41 Commits
Author SHA1 Message Date
Stephan D c6ab4680fd Record autofill validation and delivery link 2026-10-17 07:24:55 +02:00
Stephan D 86b6aafe7d Enable credential autofill for signup and login (#56) 2026-10-17 07:24:27 +02:00
Stephan D bd675357bb Record payout destination delivery and validation links 2026-10-16 12:02:01 +02:00
Stephan D 24af794112 Add saved payout destination UX and service contracts (#54) 2026-10-16 12:01:33 +02:00
Stephan D a5689876ff Connect local SXCHNGE to the Sendico stand 2026-10-16 10:19:59 +02:00
Stephan D 34d8ef18d1 Record quotation and payment integration delivery links 2026-10-16 09:58:12 +02:00
Stephan D 7a6510d1a8 Connect customer quotations and payment execution to Sendico (#50) 2026-10-16 09:57:28 +02:00
Stephan D 5d67c25c8e Document reconciled Sendico review dependencies 2026-10-15 09:43:26 +02:00
Stephan D b303285b8b Record identity verification delivery links 2026-10-15 02:23:24 +02:00
Stephan D 1354551ae8 Submit customer identity proofs and show reviewed limits 2026-10-15 02:22:28 +02:00
Stephan D 7bf8b8087a Record Facts intake delivery and validation (#46) 2026-10-15 00:55:33 +02:00
Stephan D a584c8189a Connect sign-up to Sendico Facts intake (#46) 2026-10-15 00:55:19 +02:00
tech 30f0455122 Merge pull request 'Run local infrastructure and product images with Vault-managed secrets' (#43) from dev-env-42 into main
Reviewed-on: #43
2026-09-13 08:35:01 +00:00
tech 9f9525df56 Merge pull request 'Install product API versions independently with shared services' (#45) from product-api-44 into dev-env-42
Reviewed-on: #45
2026-09-13 08:34:33 +00:00
Stephan D 259f84e0a1 Record API version delivery and validation 2026-09-13 10:27:08 +02:00
Stephan D d04c8b12ab Make product API versions independently installable 2026-09-13 10:26:06 +02:00
Stephan D ce6583f454 Show service links and next steps after local startup 2026-09-13 08:40:24 +02:00
Stephan D caad7a3d5b Remove internal notification caller tokens 2026-09-12 23:59:30 +02:00
Stephan D 1fe173397f Keep email runtime configuration out of Vault 2026-09-12 23:45:58 +02:00
Stephan D 9d57a65010 Group Mongo bootstrap secrets under infrastructure ownership 2026-09-12 23:36:47 +02:00
Stephan D 9dbf0bd80c Organize local infrastructure and Vault secrets by service ownership 2026-09-12 23:30:18 +02:00
Stephan D 1b8800ee73 Replace Python development tooling with Make and containerized shell 2026-09-12 23:14:34 +02:00
Stephan D 23f7b78c3b Keep Mongo operations online across Vault credential rotation 2026-09-12 22:19:36 +02:00
Stephan D 80d6917aa7 Record local environment delivery PR (#42) 2026-09-12 21:05:39 +02:00
Stephan D 184a3be413 Build local product stack with Vault-managed secrets (#42) 2026-09-12 21:04:42 +02:00
tech 8e2ef7aa88 Merge pull request 'Separate second-factor verification and delivery from authentication' (#41) from twofactor-40 into main
Reviewed-on: #41
2026-09-12 18:38:01 +00:00
Stephan D fc33630a0a Record second-factor service delivery PR (#40) 2026-09-12 20:37:09 +02:00
Stephan D ecb63b61c6 Separate second-factor verification and delivery from auth (#40) 2026-09-12 20:36:35 +02:00
tech 12a38cd654 Merge pull request 'Connect signup and login verification to email notifications' (#39) from auth-38 into main
Reviewed-on: #39
2026-09-12 17:46:15 +00:00
Stephan D b6039493c9 docs: prohibit committing autogenerated code 2026-09-12 19:45:07 +02:00
Stephan D 1a079e00cc Integrate latest notifications delivery updates and record PR #39 2026-09-12 15:09:18 +02:00
Stephan D ad5a061164 Integrate email notifications into signup and login (#38) 2026-09-12 15:07:00 +02:00
Stephan D 4b14649b3a Complete notification expiry and configurable durable delivery (#36) 2026-09-12 14:52:44 +02:00
Stephan D 326d58f705 Record notifications delivery PR and merge order (#36) 2026-09-12 14:42:34 +02:00
Stephan D 5c2d36281b Implement notifications service with shared messaging and email drivers (#36) 2026-09-12 14:41:55 +02:00
Stephan D 36277b6c45 docs: require shared database and NATS infrastructure 2026-09-12 14:34:19 +02:00
Stephan D f0720ec66b Organize backend into product executable and shared Go modules 2026-09-12 14:14:08 +02:00
Stephan D 981e84a4b4 Remove platform tenancy from SXCHNGE customer BFF (#34) 2026-09-12 14:00:14 +02:00
Stephan D 7062672a87 Bootstrap copied BFF and local accounts with public Sendico contracts (#34) 2026-09-12 13:37:48 +02:00
Stephan D 67dc5b5b1e Record BFF plan delivery in PR #35 2026-09-12 13:11:57 +02:00
Stephan D 45666d33fc Plan independent BFF and adapt selected Sendico foundations (#34) 2026-09-12 13:11:10 +02:00
522 changed files with 42741 additions and 3677 deletions

No files matched your search

@@ -0,0 +1,59 @@
---
name: review-backend-change
description: Review, fix, and validate the complete SXCHNGE backend contribution for a task. Use before reporting completion whenever Go, protobuf, or other backend code under backend/ was added or modified, or when the user requests a backend implementation review.
---
# Review Backend Change
## Establish the review scope
1. Read the repository root `AGENTS.md` and `backend/AGENTS.md` completely.
2. Inspect repository status and the full backend diff attributable to the
current task, including uncommitted and committed changes against the task's
base.
3. Separate unrelated pre-existing work from the task contribution. Preserve
it and do not include it in review fixes.
4. Identify changed behavior, public or internal contracts, and expected test
coverage before judging individual lines.
## Review and fix the contribution
1. Apply every quality requirement in `backend/AGENTS.md` to the entire task diff,
not only the most recently edited file.
2. Search the repository for canonical protobuf messages, enums, Go types,
interfaces, and shared domain definitions before accepting new ones.
3. Check project-error usage, cause preservation, cohesion, naming, API and
module boundaries, duplication, dead code, and testability.
4. Review structured logging at meaningful operational and domain boundaries.
Check level selection, context, sensitive-data handling, and duplicate error
logging.
5. Review success, edge, and failure paths. Add or update focused tests for the
changed behavior.
6. Fix every in-scope issue found, then re-read the resulting complete backend
diff. Repeat until no task-caused finding remains.
Do not weaken checks, suppress failures, or modify unrelated user work to make
validation pass.
## Validate
After review fixes are complete, run these commands from the repository root:
```text
make test
make lint-backend
```
Then select and run every applicable security command from the change-scoped
security completion requirements in the root `AGENTS.md`.
All commands must succeed before reporting the backend task as complete. If a
command fails, determine whether the task caused it and fix in-scope failures.
Report an external or environmental blocker with the exact command and failure;
never represent blocked validation as successful.
## Report the result
Summarize the reviewed backend scope, material fixes made during review, tests
added or updated, and the outcome of each required command. Call out contract,
migration, rollout, security, or observability implications that remain relevant.
+16
View File
@@ -0,0 +1,16 @@
.git
.local
**/.dart_tool
**/build
**/.env*
**/node_modules
**/*.pb.go
frontend/app/lib/generated
**/.DS_Store
dist
website
**/config.yml
**/tls
**/*.pem
**/*.key
**/__pycache__
+15
View File
@@ -25,3 +25,18 @@ pnpm-debug.log*
# Local editor configuration
/.vscode/
# Local BFF configuration and build output
/backend/config.yml
/backend/bff
# Protobuf client bindings are generated, never committed.
/backend/notifications/interface/notifications/**/*.pb.go
/backend/notifications/config.yml
/backend/notifications/notifications
# Local container pins, Vault bootstrap material and service identities
/.local/
# Python bytecode
__pycache__/
+47 -2
View File
@@ -8,6 +8,36 @@
- For cross-module work, apply every relevant scoped instruction file and
completion workflow.
## Autogenerated code
- Never commit autogenerated code to the repository. This applies to backend,
frontend, shared packages, and every other module, including generated
protobuf bindings, API clients, serializers, and framework-generated code.
- Commit the source definitions, generator configuration, and reproducible
generation commands instead. Generate code locally or during build and CI
workflows before any dependent validation or compilation steps.
- Keep generated outputs ignored by Git. Do not force-add ignored generated
files or copy generated code into tracked source files.
- Change the source definitions or generator inputs rather than editing
generated outputs. Document the generation workflow so a clean checkout can
build without committed generated code.
## Local development tooling
Use Make and shell for local development orchestration. Do not introduce Python
scripts or a Python runtime dependency. Keep bootstrap tools inside Docker so
the host needs only Make, a POSIX shell and Docker with Compose/BuildKit.
## Container versions
Use only the latest stable upstream releases for infrastructure, product base
images, build stages and disposable test containers. Exclude prereleases and
vendor mainline channels when a separate stable channel exists. Resolve stable
versions when building, pull fresh manifests, and pin the resolved image digests
for that local build. Fail when stable release resolution fails; never silently
fall back to an older cached version. Keep the reproducible resolver tracked and
its generated local pins ignored by Git.
## Data and state architecture
Preserve explicit **DTO → mapper → service → data model → provider → UI**
@@ -74,8 +104,8 @@ repository changes do not require a branch.
## Scoped implementation requirements
- For backend code under `api/`, read and follow
[`api/AGENTS.md`](api/AGENTS.md). Before reporting completion, use the
- For backend code under `backend/`, read and follow
[`backend/AGENTS.md`](backend/AGENTS.md). Before reporting completion, use the
[`$review-backend-change`](.agents/skills/review-backend-change/SKILL.md)
skill.
- For Dart or Flutter code under `frontend/`, read and follow
@@ -139,3 +169,18 @@ an uncommitted working tree. Preserve unrelated changes and avoid mixing them
into task commits. Record scope, validation, limitations, and PR links in the
task and durable repository documentation. Merge only when authorized by the
user. If work is delivered as stacked PRs, document dependencies and merge order.
## Local infrastructure ownership
Keep infrastructure Dockerfiles under `infra/images/` and local Compose, service
configuration and shell tooling under `infra/local/`. Keep product source under
`backend/` and `frontend/`. Organize Vault KV paths by owning service under
`services/<service>/<purpose>`, infrastructure under `infrastructure/<component>/<purpose>`,
and bootstrap administration under `infrastructure/<component>/bootstrap`.
Do not add BFF-to-notifications caller authentication on the trusted private network.
Preserve NATS authentication.
Use Caddy for local browser HTTPS and certificate management. Treat the private
local Docker network as trusted; do not add internal gRPC TLS or Vault-managed
local TLS certificates without an explicit requirement.
Store only secret values in Vault. Driver selection, sender identities, endpoints
and other non-secret runtime settings belong in service configuration files.
+91
View File
@@ -1,6 +1,11 @@
.PHONY: test
test: test-backend test-frontend
.PHONY: test-frontend
test-frontend:
cd frontend/app && flutter gen-l10n
cd frontend/packages/sxchnge_wallet && flutter test
cd frontend/packages/sxchnge_kyc && flutter test
cd frontend/packages/sxchnge_analytics && flutter test
@@ -8,3 +13,89 @@ test-frontend:
cd frontend/packages/sxchnge_theme && flutter test
cd frontend/packages/sxchnge_theme/example && flutter test
cd frontend/app && flutter test
.PHONY: test-backend lint-backend run-backend
test-backend: generate-notifications
docker pull mongo:latest
docker pull nats:latest
cd backend/shared && go test -race -count=1 ./...
cd backend/notifications/interface && go test -race -count=1 ./...
cd backend/notifications && go test -race -count=1 ./...
cd backend/product && go test -race -count=1 ./...
lint-backend: generate-notifications
cd backend/product && go test ./architecture
cd backend && test -z "$$(gofmt -l .)"
cd backend/shared && go vet ./...
cd backend/notifications/interface && go vet ./...
cd backend/notifications && go vet ./...
cd backend/product && go vet ./...
run-backend:
cd backend/product && go run .
.PHONY: run-notifications generate-notifications
run-notifications: generate-notifications
cd backend/notifications && go run .
generate-notifications:
@command -v protoc >/dev/null
@tmp=$$(mktemp -d); trap 'rm -rf "$$tmp"' EXIT; \
GOBIN="$$tmp" go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 && \
GOBIN="$$tmp" go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 && \
PATH="$$tmp:$$PATH" protoc -I backend/notifications/interface/proto --go_out=backend/notifications/interface --go_opt=module=git.sendico.io/tech/sxchnge/notifications/interface --go-grpc_out=backend/notifications/interface --go-grpc_opt=module=git.sendico.io/tech/sxchnge/notifications/interface backend/notifications/interface/proto/v1/interface.proto
.PHONY: package-notifications-interface
package-notifications-interface: generate-notifications
mkdir -p dist
tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md
tar -czf dist/notifications-go-v1.tar.gz -C backend/notifications/interface go.mod go.sum notifications README.md
# Local product stack. Releases are resolved afresh, then pinned by digest.
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
ifneq ($(wildcard .local/dev/sendico/settings.env),)
COMPOSE += -f infra/local/sendico/compose.yml
endif
.PHONY: build up down logs ps test-dev
build:
sh infra/local/tools.sh build
sh infra/local/tools.sh images
$(COMPOSE) pull vault mongo
$(COMPOSE) build --pull
up:
sh infra/local/tools.sh up
down:
$(COMPOSE) down
logs:
$(COMPOSE) logs --tail=100 -f
ps:
$(COMPOSE) ps
test-dev:
sh infra/local/tools.sh test
sh infra/local/tools.sh verify
.PHONY: test-dev-infra
test-dev-infra:
sh infra/local/tools.sh test
sh infra/local/tools.sh verify-infra
.PHONY: test-dev-rotation
test-dev-rotation:
sh infra/local/tools.sh rotation
.PHONY: email test-dev-tooling
email:
sh infra/local/tools.sh email
test-dev-tooling:
sh infra/local/tools.sh test
.PHONY: sendico
sendico:
sh infra/local/tools.sh sendico
+50 -12
View File
@@ -1,7 +1,7 @@
# SXCHNGE
Product repository for the sxchnge public website, Flutter consumer app, reusable
packages, and planned backend for frontend (BFF).
packages, and a backend for frontend (BFF) bootstrapped from Sendico.
## Current state
@@ -9,10 +9,11 @@ The consumer app includes responsive login/signup, English and Russian
localization, email verification and recovery screens, and URL-based navigation
with `go_router`. Its interface uses the shared theme's colors and typography.
**Authentication, email verification and analytics use dummy services.** No real
account or session is created, no email is delivered, and no analytics data is
transmitted. The backend is documentation only. Session/KYC access guards and
Sendico integration are not implemented.
The default preview uses dummy services. Configuring the existing backend URL
and client ID selects authenticated BFF adapters for auth, identity, limits and
wallet quotations/payment execution. Optional Sendico configuration and dedicated
customer financial resources are required for real operations; analytics still
defaults to an in-memory sink. See [payment setup and rollout](docs/payments.md).
## Repository structure
@@ -23,7 +24,7 @@ Sendico integration are not implemented.
| [`sxchnge_theme`](frontend/packages/sxchnge_theme/README.md) | Reusable light/dark themes and bundled Onest font |
| [`sxchnge_auth`](frontend/packages/sxchnge_auth/README.md) | Shared auth contracts, providers, two-factor page and dummy services |
| [`sxchnge_analytics`](frontend/packages/sxchnge_analytics/README.md) | Typed journey events, recorder and bounded in-memory dummy analytics |
| [`backend/`](backend/README.md) | Planned BFF and Sendico integration boundaries |
| [`backend/`](backend/README.md) | Copied BFF/auth/account foundation and isolated public Sendico contracts |
| [`.agents/skills/`](.agents/skills) | Repository agent skills |
| [`docs/`](docs/implementation.md) | Implementation records and UX measurement contracts |
@@ -31,6 +32,24 @@ Each application owns its dependencies and build configuration. Shared Dart and
Flutter packages can be reused by a future mobile app; mobile runners are not
currently included. Local `.vscode/` configuration is ignored.
## Local Docker development
```sh
make build && make up
```
This builds the product images and starts an isolated Vault, authenticated MongoDB
replica set and NATS JetStream. First startup intentionally stops after bootstrap
until you set `api_key` in Vault's `kv/services/notifications/email/resend` secret
and the verified sender in `infra/local/notifications/runtime.yml`.
Then rerun `make up` to start notifications, BFF and the frontend at
[https://localhost:5126](https://localhost:5126).
All image inputs resolve to current stable releases and are pinned by digest for
the build. Services fetch secrets from Vault; Mongo application credentials come
from Vault's database plugin. See the [local development guide](docs/local-development.md)
for Vault provisioning, local HTTPS trust, lifecycle commands and verification.
## Run the consumer app
Requires Flutter **3.47.0 or later** with Dart **3.13.0 or later** (below Dart 4), matching
@@ -53,7 +72,8 @@ Output is `frontend/app/build/web/`. To inspect that build locally, from the
repository root:
```sh
python3 -m http.server 5123 --bind 127.0.0.1 --directory frontend/app/build/web
docker run --rm --pull always -p 127.0.0.1:5123:80 \
-v "$PWD/frontend/app/build/web:/srv:ro" caddy:alpine caddy file-server --root /srv --listen :80
```
Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU
@@ -143,18 +163,21 @@ usability, accessibility and journey measurement under the repository's UX rules
## Product boundaries
The intended integration is: **consumer app → sxchnge BFF → supported Sendico API**.
The integration is: **consumer app → sxchnge BFF → Sendico public HTTP API**.
SXCHNGE is one organization in Sendico; its customers are owned locally.
Local accounts have no organization tenancy, membership, roles or policy engine.
- sxchnge will own consumer registration/sessions, onboarding and exchange UX,
and mappings to stable, organization-scoped Sendico customer references.
and mappings from local customers to organization-scoped platform resources.
- Sendico remains authoritative for payment execution, KYC records and
verification, policy decisions and limit enforcement.
- The sxchnge organization, backend integration principal and individual customer
references are distinct identities. Consumers must not share the integration
account's customer identity.
- The BFF should use a supported API contract rather than copying Sendico's BFF
or depending on its local internal modules. Integration credentials belong in
the backend.
- The BFF uses only the public HTTP API. Existing BFF/auth/account components
are copied into SXCHNGE-owned code; private transports and sibling module
dependencies are not used.
Integration credentials belong in the backend.
Before backend integration, agree on the customer, KYC, eligibility/limits and
customer-scoped payment contracts for one complete transaction flow. These are
@@ -224,3 +247,18 @@ instructions also support authenticator challenges; actual TOTP enrollment,
validation and recovery remain backend integration work. See the
[two-factor UX contract](docs/ux/two-factor.md) and
[shared auth package](frontend/packages/sxchnge_auth/README.md).
## Backend integration plan
The [BFF architecture and Sendico service audit](docs/architecture/bff.md) defines
consumer identity, financial-provider boundaries, reusable components, contract
gaps and delivery phases. Selected Sendico code has been adapted with recorded
[provenance](backend/PROVENANCE.md); the projects share no runtime modules.
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
The runtime installs local authentication, verification delivery, identity and
wallet transports. Public Sendico adapters support independent customer facts,
reviewed identity/limits and quotation/execution through the BFF. Deployment and
financial-resource provisioning are separate; backend account-language saving
and additional funding/recipient onboarding remain follow-up work. See the
[backend run guide](backend/README.md) and [payment integration](docs/payments.md).
+132
View File
@@ -0,0 +1,132 @@
# Backend Agent Guidelines
These instructions extend the repository-wide rules in the root `AGENTS.md`
and apply whenever a task adds or modifies backend code, including Go and
protobuf files under `backend/`.
Before completing the task, review the entire backend diff produced by the
current task, fix every in-scope issue found, and then perform the required
validation. Use the
[`$review-backend-change`](../.agents/skills/review-backend-change/SKILL.md)
skill to perform this completion workflow.
## Reuse existing definitions
- Search the repository before introducing a protobuf message, enum, Go struct,
interface, or shared domain type.
- Reuse or extend the canonical existing definition when its semantics match.
Do not create parallel representations or conversion layers for the same
concept without a clear architectural reason.
- Avoid naming stutter in packages, types, fields, parameters, and access paths.
Each nested name should add meaning instead of repeating its parent context.
For example, prefer a concise path such as `payment.Details.ID` over
`payment.Payment.PaymentID`.
## Reuse shared database and messaging infrastructure
- All database integrations must use the existing shared database wrappers in
`backend/shared/database`. Do not bypass them with direct database clients or
create feature-local replacements.
- All NATS connections must use shared messaging drivers owned by the
`backend/shared` library module. Keep reusable NATS drivers in
`backend/shared/messaging`; executable modules must consume that package.
Do not open direct NATS connections or implement separate connection
management in a feature or service.
- If the shared messaging package is missing, consolidate the existing driver
into `backend/shared/messaging` and reuse it. Do not create another
feature-local driver or import a sibling executable module's infrastructure.
- Search for the existing wrappers, drivers, and their usage before implementing
an integration. Reuse their connection lifecycle, configuration, retries, and
other infrastructure behavior instead of copying or rewriting it.
- If an integration needs a capability the shared infrastructure does not yet
expose, extend the canonical wrapper or driver. Keep reusable infrastructure
there and domain-specific behavior in the owning module.
- During review, check that database and NATS integrations use these shared
paths and remove any duplicated infrastructure introduced by the task.
## Use the project error model
- Do not introduce `fmt.Errorf` or `errors.New`. Use the constructors and
wrapping helpers in `backend/shared/errors`.
- Preserve the underlying cause when adding context by using an appropriate
`merrors` wrapping helper.
- When no suitable project error exists, add the smallest generally useful
definition or helper to `backend/shared/errors` instead of creating an ad hoc error
elsewhere.
## Keep modules cohesive and names concise
- Review touched files for excessive size, mixed responsibilities, duplicated
logic, and poor separation of concerns.
- Refactor oversized contributions into focused, cohesive files or packages.
Do not split code mechanically; organize it along clear domain or
architectural boundaries.
- Avoid repeating directory or package context in child directory and file
names. For example, prefer `payment/widget.dart` over
`payment/payment_widget.dart`.
## Review implementation quality
- Review the current task's backend contribution for correctness, readability,
maintainability, testability, and consistency with the surrounding
architecture.
- Keep public APIs and module boundaries small and intentional. Remove
unnecessary indirection, duplication, dead code, and speculative abstractions.
- Add or update tests for changed behavior, edge cases, and failure paths.
- Fix all findings caused by the current task before reporting completion.
## Review logging
- Ensure the contribution has sufficient structured logging at meaningful
operational and domain boundaries. Include useful identifiers and context,
but never log credentials, secrets, or sensitive payment data.
- Follow the repository's logging style and use levels consistently:
- `Debug`: fine-grained runtime execution tracking useful during debugging.
- `Info`: important payment-domain events, module-boundary operations,
effective module configuration, and noteworthy but expected deviations from
the usual path.
- `Warn`: runtime errors and unexpected conditions from which the service can
continue or which are returned to a caller.
- `Error`: unrecoverable failures that cause, or imminently lead to, service
or component termination.
- Avoid logging the same error repeatedly at multiple layers. Log it where the
layer has enough context to make the event actionable.
## Required validation
- From the repository root, run `make test` and `make lint-backend`, plus every
applicable command from the root `AGENTS.md` change-scoped security
requirements, after all review fixes are complete.
- All commands must succeed before the task is reported as complete. Fix
failures introduced by the current task; do not silently skip, suppress, or
weaken checks.
- Report the commands run and their outcomes in the final response. If an
external or environmental blocker prevents a command from running, report
the exact command, failure, and blocker explicitly; do not claim successful
validation.
## SXCHNGE provider boundary
SXCHNGE owns auth, sessions and customer management. SXCHNGE is one organization
inside Sendico. This organization reference belongs only in the public provider
adapter configuration. Local customers have no organizations, tenant IDs,
membership, roles or policy engine. Do not copy platform scaffolding to satisfy
unused dependencies; remove those dependencies from the selected components.
Copied BFF components are locally owned and may diverge.
Use only Sendico's public HTTP API for platform operations. Never import sibling
Sendico modules or access its database, internal gRPC APIs, discovery or message
bus. Keep consumed public wire contracts inside the owning adapter, separate
from local DTOs and models. Do not copy unused API snapshots.
Resolve the configured provider organization and customer resource ownership on
the server. Never treat all customers as the backend integration principal.
Use the copied structured logger, injected at operational boundaries.
## Go module structure
`backend/` groups independent Go modules; it is not itself a Go module.
`product/` is the customer-facing executable and owns its `main.go` and `go.mod`.
`shared/` is a library module for reusable infrastructure and has no entry point.
Backoffice belongs in its own executable module (`backoffice/`) when implemented,
with its own entry point and configuration, not inside the product executable.
Keep feature models, storage, services and transport together inside their owning
module. Shared infrastructure must not import a product or backoffice module.
File diff suppressed because it is too large. Load diff
+81
View File
@@ -0,0 +1,81 @@
# Copied Sendico source
Source repository: `S_I/sendico` (`git.sendico.io`), revision
`8bb20dcd622dd8eb92d289b6b34411e7eccab083`.
[PROVENANCE.json](PROVENANCE.json) records each retained file, its original path
and Git blob, and whether a Go file changed beyond import-path replacement and
formatting. It currently covers 233 Go source/test entries across `product` and `shared`
(one original configuration file contributes to two feature packages). Sources came from the pinned commit, not the dirty Sendico checkout.
These are SXCHNGE-owned copies that may diverge independently.
## Retained implementation
- Chi HTTP runtime, process/version bootstrap, structured logging and health.
- Customer password validation/hashing, account repository and account service.
- Customer login, pending verification, session validation, refresh rotation,
revocation, browser cookies/CSRF and login rate limiting.
- Verification issuance, expiry, attempts, consumption and idempotency.
- Local Mongo connection, transactions and the repository/query implementation
required by account, session and verification storage, with retained tests.
## Adaptations and exclusions
The backend contains Go modules `git.sendico.io/tech/sxchnge/product` and
`git.sendico.io/tech/sxchnge/shared`. Product owns its executable entry point;
shared is a dependency library with no executable or domain-module imports. The composition root selects
local customer services. Discovery, message-bus registration, private gRPC and
platform factories are absent. Process identity and the refresh cookie use SXCHNGE.
Startup validates required settings and timeouts, closes failed connections and
preserves process failures. Errors use `merrors`; logs exclude raw DB settings.
The earlier extraction retained too much. Local organizations, membership,
roles, Casbin, policies, invitations, policy seeding and their repositories and
models are removed. There is no replacement tenant or permissive enforcer.
Unneeded financial models/errors, ledger and wallet provisioning metadata,
platform service names, S3 configuration and WebSocket scaffolding are removed.
Tests specific to removed features are removed with those implementations.
The copied account service now creates a local customer from a raw password,
hashes it and commits the customer and activation token atomically. It uses the
same configured collections as login. Deletion revokes that customer's sessions
and deletes its record atomically; it has no sole-member rule. The copied Mongo
transaction helper joins an active outer transaction so verification does not
commit separately from customer creation. Generic repositories no longer expose
organization- or permission-bound operations. Customer responses use explicit DTO fields and a mapper; they have no anonymous
organization-account flag or embedded database record. Refresh and protected
handlers reject inactive customers; refresh checks account-bound application
clients.
## Local additions and limits
Architecture tests reject private provider imports, policy-engine dependencies
and local tenancy declarations. Fresh Mongo replica-set tests exercise the actual
customer lifecycle, isolation, duplicate login, rollback, deletion/session
revocation and runtime startup without a seed. Existing retained auth/session and
verification tests remain in place.
This is a bootstrap. Signup/profile HTTP wiring, verification delivery/completion
and the public Sendico HTTP adapter remain explicit work in the
[architecture plan](../docs/architecture/bff.md). The sole Sendico organization
reference belongs to that adapter's configuration, never to local customers.
## Module organization
`backend/` groups modules; it has no root `go.mod`. The inherited `cmd`, `edge`,
`internal`, `pkg` and empty `scripts` top-level directories are gone. `product`
contains `main.go`, its manifest and app/customer/auth/session/verification code.
`shared` owns its manifest and generic infrastructure. Models moved beside their
owning features. Customer repository construction moved out of shared database
code. Auth and verification configurations moved out of application interfaces.
All 112 unconsumed public API snapshot files and their snapshot-only check were
removed. Future public adapters will own just the wire types they consume.
Backoffice is a separate future executable module, not an alias for product.
## Second-factor separation (#40)
The new `product/twofactor` domain service reuses the already copied
`product/verification/store` issuance and consumption implementation at the pinned
Sendico revision. No additional Sendico source files were copied. Coupled
verification HTTP/session/private-messaging handlers were inspected but excluded.
See [reuse decisions and domain boundary](../docs/architecture/two-factor.md).
+34 -9
View File
@@ -1,14 +1,39 @@
# SXCHNGE backend
# Go backend modules
Reserved for a small backend for frontend (BFF) using a supported Sendico API. No service, runtime, or dependencies have been initialized yet.
`backend/` groups independent Go modules. It is not an executable or a Go module.
Planned responsibilities:
```text
backend/
├── product/ Customer-facing BFF executable
│ ├── main.go
│ ├── go.mod
│ ├── app/ Startup, composition, HTTP server and health
│ ├── customers/ Customer service, models and Mongo repository
│ ├── auth/ Login/protected HTTP routes, DTOs and configuration
│ ├── sessions/ Session models, refresh storage, cookies and tokens
│ └── verification/ Verification models, configuration and storage
├── notifications/ Internal gRPC notification service and email workers
│ └── interface/ Independent protobuf/client contract module
└── shared/ Reusable library module
├── go.mod
├── database/ Connection, transactions and repository primitives
├── errors/
├── logging/
└── … Generic HTTP, configuration and rate-limit helpers
```
- Consumer authentication and sessions, with a stable mapping to organization-scoped Sendico customer references.
- Consumer-authorized access to onboarding, quotes, orders, and transaction history.
- Product-specific orchestration and presentation of Sendico KYC, eligibility, and remaining-limit responses.
- Server-side integration credentials and request correlation with Sendico.
The backoffice BFF belongs in a separate `backend/backoffice/` executable module,
with its own `main.go`, `go.mod`, configuration and operator authentication. It is
not implemented by this customer-BFF bootstrap. Shared infrastructure has no
`main.go` and must not depend on an executable module. The product module uses a
local `replace` for `../shared`; each module has its own dependency manifest.
Sendico owns payment execution, authoritative KYC evidence and verification records, policy evaluation, and limit reservations/enforcement. Do not copy its existing BFF or import local Sendico implementation modules.
Run the product with `make run-backend`; see its [run guide](product/README.md).
`make test` runs all backend modules and the frontend. `make lint-backend` checks
all Go modules and their architecture boundaries.
Before implementation, agree on customer registration and identity mapping, customer KYC operations, eligibility/limits reads, customer-scoped payment access, and retry/idempotency behavior. Validate consumer isolation even when multiple customers use the same sxchnge organization and integration principal.
Copied source remains locally owned. See [provenance](PROVENANCE.md), the
[architecture/API plan](../docs/architecture/bff.md) and the
[delivery record](../docs/implementation.md).
Notifications uses shared database and messaging infrastructure. See its [run guide](notifications/README.md) and [protobuf interface](notifications/interface/proto/v1/interface.proto). Generated client bindings are ignored; Make generates them before backend checks.
+26
View File
@@ -0,0 +1,26 @@
# Source provenance
SendGrid driver source adapted from the local Sendico checkout at commit
`8bb20dcd622dd8eb92d289b6b34411e7eccab083`:
`api/notification/internal/server/notificationimp/mail/internal/sendgrid.go`.
The adaptation retains SendGrid SDK message construction, sender configuration
and personalization. It implements our provider-neutral `email.Sender` contract,
accepts already-rendered subject/text/HTML, uses bounded context-aware HTTP,
classifies delivery outcomes and never logs provider response bodies. Sendico's
builders, dynamic-template dependency, notification events and unused producer
were removed. Resend is a new driver using the same contract.
Shared messaging was selected from that same Sendico revision's
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
`internal/natsb/broker.go`, `internal/natsb/config/config.go`, and
`internal/natscap/durable.go`. The selected code lives in
`backend/shared/messaging`, with imports adapted to SXCHNGE. The runtime composes
those adapters and owns connection closure. Unused discovery, best-effort envelope
subscriptions, platform stream registries and parking provisioning were omitted.
The durable adapter was separated into transport, subscription and admin files;
publishing now honors its context and fetch polling respects its bounded wait.
No Sendico module or live Sendico infrastructure is required. Use SXCHNGE-owned
MongoDB and NATS deployments.
+99
View File
@@ -0,0 +1,99 @@
# Notifications backend
Independent internal gRPC service with durable asynchronous email delivery.
The public contract belongs to [interface/proto/v1/interface.proto](interface/proto/v1/interface.proto).
Generated clients are build artifacts, not committed source.
## Architecture
The gRPC transport maps protobuf DTOs to domain requests. The service validates
and renders immutable template revisions, then saves a notification and its
pending-work state atomically in one document through the shared DB wrapper.
The outbox dispatcher publishes opaque IDs using shared messaging's durable
transport, adapted from Sendico. A durable NATS worker claims the notification
with an atomic conditional patch and saves the outcome before acknowledging.
`DriverConfig` selects the email driver: `sendgrid` or `resend`.
Email adapters only map rendered email models to provider wire payloads. SendGrid
uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
subject, text and HTML, bounded timeouts and safe error classification.
## Run
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here.
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL` and the selected email provider's API key environment
variable. Configure a verified sender address and template
catalog. Paths are relative to the process working directory.
From the root, run `make run-notifications`. The gRPC listener uses plaintext on the private local network. Configure both
`tls_cert` and `tls_key` only when TLS is explicitly required.
Use a private authenticated NATS endpoint; `tls://` URLs and credentials in the URL
are supported by the shared driver. Mongo and messaging both use shared
`DriverConfig` settings; email settings reject unknown keys.
The queue uses `SXCHNGE_NOTIFICATIONS`, subject `sxchnge.notifications.email`,
and durable consumer `email`. Configure `queue.stream.replicas` as 1, 3 or 5 to
match the NATS cluster, and `queue.stream.max_age_hours` for retention (48 hours
minimum). File storage and the owned stream/subject names are enforced.
`queue.provisioning.environment` accepts `production` or `non-production`;
`mode: ops` validates existing resources without changing them, `mode: app`
provisions missing resources, and `mode: hybrid` provisions only outside production.
Production retention and consumer-setting drift fail startup instead of being
silently accepted. Provisioning operations honor their startup context.
The example defaults to one replica and app provisioning for local development.
Multiple service workers can safely compete for jobs through atomic claims.
## Templates and parameters
See `templates.example.json`, including the `auth.activation` and `auth.login`
English/Russian templates used by [product authentication](../../docs/auth-notifications.md). Parameters use Go template map syntax such as
`{{.first_name}}`. Each revision declares the exact allowed parameter names and
locale-specific content. Missing/extra parameters, unknown revisions/locales and
invalid content are rejected before acceptance. Empty locale uses the default.
Values are data, not executable templates; HTML is escaped by `html/template`.
The catalog is loaded and compiled at startup. Never edit a published revision;
add a new version. Rendered payloads are persisted so retries do not depend on a
later catalog change. Recipient, body and subject are cleared on terminal state;
remaining status and request fingerprints expire through a seven-day TTL.
## Delivery semantics
- Send persists before returning; it does not wait for NATS or the email provider.
- Outbox scans every five seconds and republishes safely after a bus failure.
Pending notifications expire and have their rendered content cleared even when
the bus is unavailable or a retry is scheduled later.
- Duplicate RPCs and duplicate broker deliveries do not create extra jobs/sends.
- Provider HTTP 2xx becomes ACCEPTED. No receipt/webhook or read tracking is claimed.
- Explicit rate limiting (429) retries with bounded exponential backoff and
Retry-After, at most five provider attempts, bounded by message expiry.
- Transport errors, 408, 5xx and expired worker leases become UNKNOWN. These may
have sent the email; operators must reconcile before requesting another send.
- Other provider failures are terminal. `provider_authentication_failed` signals
credentials/permissions, `provider_rejected` a nonretryable rejection,
`retry_exhausted` the attempt limit, and `expired` the message deadline.
- Resend receives the notification ID as its provider idempotency key. Its window
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
delivery is not promised.
- Changing the configured driver fails queued jobs pinned to the old driver with
`driver_changed`. Drain queued jobs before changing sender credentials/config.
- Callers authorize recipients and preferences. No direct customer-database access,
bulk campaigns, provider failover, SMS, push or Telegram delivery is included.
Structured logs report notification IDs, driver, state and attempt count; message
content, template values, addresses and provider response bodies are excluded.
Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings.
## Validate and distribute
`make test` and `make lint-backend` generate interfaces and validate all modules.
Notifications tests exercise real disposable MongoDB and NATS containers, concurrent
idempotency and claims, lease recovery, retries, transport authentication, rendering
and both provider payloads through local HTTP servers. An authenticated TLS gRPC
Send/Get test covers the full shared-database → NATS → rendered-email driver path.
No live emails are sent.
`make package-notifications-interface` prepares source and Go-client archives.
See [provenance](PROVENANCE.md) and [contract usage](interface/README.md).
Provider references: [SendGrid Mail Send](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send),
[Resend Send Email](https://resend.com/docs/api-reference/emails/send-email).
+51
View File
@@ -0,0 +1,51 @@
package app
import (
"bytes"
"io"
"os"
"git.sendico.io/tech/sxchnge/notifications/bus"
"git.sendico.io/tech/sxchnge/notifications/email"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging"
"gopkg.in/yaml.v3"
)
type Config struct {
Queue bus.Config `yaml:"queue"`
Messaging messaging.Config `yaml:"messaging"`
Listen string `yaml:"listen"`
TLSCert string `yaml:"tls_cert"`
TLSKey string `yaml:"tls_key"`
Database db.Config `yaml:"database"`
Templates string `yaml:"templates"`
Email email.Config `yaml:"email"`
}
func Load(path string) (Config, error) {
c := Config{Queue: bus.DefaultConfig()}
data, err := os.ReadFile(path)
if err != nil {
return c, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "cannot read configuration")
}
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err = decoder.Decode(&c); err != nil {
return c, merrors.InvalidArgument("invalid configuration")
}
if err = decoder.Decode(new(any)); err != io.EOF {
return c, merrors.InvalidArgument("unexpected configuration content")
}
if c.Listen == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" {
return c, merrors.InvalidArgument("missing notification configuration")
}
if (c.TLSCert == "") != (c.TLSKey == "") {
return c, merrors.InvalidArgument("both TLS certificate and key must be configured together")
}
if err = c.Queue.Validate(); err != nil {
return c, err
}
return c, nil
}
+55
View File
@@ -0,0 +1,55 @@
package app
import (
"os"
"path/filepath"
"testing"
)
func TestExampleConfigAndSecrets(t *testing.T) {
c, err := Load("../config.example.yml")
if err != nil {
t.Fatal(err)
}
if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" {
t.Fatal("wrong driver configuration")
}
data, err := os.ReadFile("../config.example.yml")
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "config.yml")
if err = os.WriteFile(path, append(data, []byte("\n---\nunexpected: document\n")...), 0600); err != nil {
t.Fatal(err)
}
if _, err = Load(path); err == nil {
t.Fatal("extra YAML document accepted")
}
}
func TestPlaintextConfigAndIncompleteTLS(t *testing.T) {
c, err := Load("../config.example.yml")
if err != nil {
t.Fatal(err)
}
options, err := serverOptions(c)
if err != nil || len(options) != 0 {
t.Fatal("plain gRPC must not require TLS material")
}
data, err := os.ReadFile("../config.example.yml")
if err != nil {
t.Fatal(err)
}
for _, partial := range []string{"\ntls_cert: missing.crt\n", "\ntls_key: missing.key\n"} {
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, append(data, []byte(partial)...), 0600); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("incomplete TLS configuration accepted")
}
}
if _, err := serverOptions(Config{TLSCert: "missing.crt", TLSKey: "missing.key"}); err == nil {
t.Fatal("bad explicit certificate accepted")
}
}
+112
View File
@@ -0,0 +1,112 @@
package app
import (
"context"
"crypto/tls"
"net"
"sync"
"time"
"git.sendico.io/tech/sxchnge/notifications/bus"
"git.sendico.io/tech/sxchnge/notifications/email"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/service"
"git.sendico.io/tech/sxchnge/notifications/store"
"git.sendico.io/tech/sxchnge/notifications/templates"
"git.sendico.io/tech/sxchnge/notifications/transport"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
msgruntime "git.sendico.io/tech/sxchnge/shared/messaging/runtime"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
catalog, err := templates.Load(c.Templates)
if err != nil {
return err
}
sender, err := email.New(c.Email)
if err != nil {
return err
}
options, err := serverOptions(c)
if err != nil {
return err
}
client, err := db.ConnectMongo(logger, &c.Database)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "connect notifications database")
}
defer func() {
cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = client.Disconnect(cleanup)
}()
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
repo, err := store.New(logger, client.Database())
if err != nil {
return err
}
rt, err := msgruntime.New(logger, &c.Messaging)
if err != nil {
return err
}
defer rt.Close()
svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger)
broker, err := bus.New(startup, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue)
if err != nil {
return err
}
defer broker.Close()
server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...)
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
listener, err := net.Listen("tcp", c.Listen)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "listen for notifications")
}
defer listener.Close()
runCtx, stop := context.WithCancel(ctx)
defer stop()
var wg sync.WaitGroup
for _, run := range []func(context.Context){broker.RunPublisher, broker.RunWorker} {
wg.Add(1)
go func() { defer wg.Done(); run(runCtx) }()
}
serveErr := make(chan error, 1)
go func() { serveErr <- server.Serve(listener) }()
logger.Info("Notifications service started", zap.String("listen", c.Listen), zap.String("email_driver", string(c.Email.Driver)))
select {
case <-ctx.Done():
case err = <-serveErr:
}
stop()
stopped := make(chan struct{})
go func() { server.GracefulStop(); close(stopped) }()
select {
case <-stopped:
case <-time.After(5 * time.Second):
server.Stop()
}
wg.Wait()
if err != nil {
return merrors.OpaqueWrap(merrors.ErrInternal, err, "notification server stopped unexpectedly")
}
logger.Info("Notifications service stopped")
return nil
}
// An omitted certificate pair uses plaintext gRPC on the private service network.
func serverOptions(c Config) ([]grpc.ServerOption, error) {
if c.TLSCert == "" && c.TLSKey == "" {
return nil, nil
}
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey)
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate")
}
return []grpc.ServerOption{grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}))}, nil
}
+38
View File
@@ -0,0 +1,38 @@
package bus
import (
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
)
// Config composes canonical shared messaging settings for this service's queue.
type Config struct {
Stream reliable.DurableStreamSettings `yaml:"stream"`
Provisioning reliable.DurableProvisioningSettings `yaml:"provisioning"`
}
func DefaultConfig() Config {
return Config{
Stream: reliable.DurableStreamSettings{Name: "SXCHNGE_NOTIFICATIONS", Subjects: []string{subject}, Storage: "file", MaxAgeHours: 48, Replicas: 1},
Provisioning: reliable.DurableProvisioningSettings{Mode: reliable.DurableProvisioningApp, Environment: reliable.DurableEnvironmentNonProduction},
}
}
func (c Config) Validate() error {
if c.Stream.Name != "SXCHNGE_NOTIFICATIONS" || len(c.Stream.Subjects) != 1 || c.Stream.Subjects[0] != subject || c.Stream.Storage != "file" || c.Stream.MaxAgeHours < 48 || c.Stream.MaxAgeHours > 24*30 {
return merrors.InvalidArgument("invalid notifications stream configuration")
}
if c.Stream.Replicas != 1 && c.Stream.Replicas != 3 && c.Stream.Replicas != 5 {
return merrors.InvalidArgument("stream replicas must be 1, 3 or 5")
}
switch c.Provisioning.Mode {
case reliable.DurableProvisioningApp, reliable.DurableProvisioningOps, reliable.DurableProvisioningHybrid:
default:
return merrors.InvalidArgument("invalid stream provisioning mode")
}
switch c.Provisioning.Environment {
case reliable.DurableEnvironmentProduction, reliable.DurableEnvironmentNonProduction:
default:
return merrors.InvalidArgument("invalid stream provisioning environment")
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
package bus
import (
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
"testing"
)
func TestQueueConfiguration(t *testing.T) {
config := DefaultConfig()
config.Stream.Replicas = 3
config.Provisioning.Mode = reliable.DurableProvisioningOps
config.Provisioning.Environment = reliable.DurableEnvironmentProduction
if err := config.Validate(); err != nil {
t.Fatal(err)
}
for _, change := range []func(*Config){func(c *Config) { c.Stream.Replicas = 0 }, func(c *Config) { c.Stream.Storage = "memory" }, func(c *Config) { c.Stream.MaxAgeHours = 1 }, func(c *Config) { c.Provisioning.Mode = "typo" }, func(c *Config) { c.Provisioning.Environment = "typo" }, func(c *Config) { c.Stream.Subjects = []string{"other"} }} {
c := DefaultConfig()
change(&c)
if err := c.Validate(); err == nil {
t.Fatal("invalid queue configuration accepted")
}
}
}
+111
View File
@@ -0,0 +1,111 @@
// Package bus connects the notification outbox to the shared messaging runtime.
package bus
import (
"context"
"strconv"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
)
const subject = "sxchnge.notifications.email"
type Outbox interface {
Due(context.Context, time.Time) ([]model.Notification, error)
Recover(context.Context, time.Time) error
}
type Handler interface {
Dispatch(context.Context, string) (bool, error)
}
type Bus struct {
transport reliable.Transport
subscription reliable.Subscription
outbox Outbox
handler Handler
logger mlogger.Logger
}
func New(ctx context.Context, transport reliable.Transport, admin reliable.Admin, outbox Outbox, handler Handler, logger mlogger.Logger, config Config) (*Bus, error) {
if err := config.Validate(); err != nil {
return nil, err
}
settings := reliable.DurableSettings{Enabled: true, Stream: config.Stream, Provisioning: config.Provisioning}
consumer := reliable.DurableConsumerSettings{Durable: "email", Subject: subject, AckWaitSeconds: 60, MaxDeliver: -1, DeliverPolicy: reliable.DurableDeliverAll}
if err := admin.EnsureConsumerResources(ctx, settings, consumer); err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "configure notification messaging")
}
subscription, err := transport.OpenConsumer(ctx, settings, consumer)
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
}
return &Bus{transport, subscription, outbox, handler, mlogger.Named(logger, "notifications", "bus")}, nil
}
func (b *Bus) Close() error { return b.subscription.Close() }
func (b *Bus) PublishDue(ctx context.Context) error {
if err := b.outbox.Recover(ctx, time.Now().UTC()); err != nil {
return err
}
jobs, err := b.outbox.Due(ctx, time.Now().UTC())
if err != nil {
return err
}
for _, job := range jobs {
err = b.transport.Publish(ctx, reliable.Record{Subject: subject, Payload: []byte(job.ID.Hex()), MessageID: job.ID.Hex() + "-" + strconv.FormatUint(uint64(job.Attempts), 10)})
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "publish notification job")
}
}
return nil
}
func (b *Bus) RunPublisher(ctx context.Context) {
for ctx.Err() == nil {
op, cancel := context.WithTimeout(ctx, 10*time.Second)
err := b.PublishDue(op)
cancel()
if err != nil && ctx.Err() == nil {
b.logger.Warn("Notification outbox publication failed")
}
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
}
}
func (b *Bus) RunWorker(ctx context.Context) {
for ctx.Err() == nil {
deliveries, err := b.subscription.Fetch(ctx, 1, time.Second)
if err != nil {
if ctx.Err() == nil {
b.logger.Warn("Notification job fetch failed")
}
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
continue
}
for _, delivery := range deliveries {
payload := delivery.Record().Payload
if len(payload) == 24 {
if _, err = b.handler.Dispatch(ctx, string(payload)); err != nil {
b.logger.Warn("Notification job processing failed")
continue
}
} else {
b.logger.Warn("Malformed notification job discarded")
}
ackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
err = delivery.Ack(ackCtx)
cancel()
if err != nil {
b.logger.Warn("Notification acknowledgement failed")
}
}
}
}
@@ -0,0 +1,55 @@
package bus
import (
"context"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/shared/database/record"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
"go.mongodb.org/mongo-driver/v2/bson"
)
type outbox struct{ jobs []model.Notification }
func (o *outbox) Due(context.Context, time.Time) ([]model.Notification, error) { return o.jobs, nil }
func (o *outbox) Recover(context.Context, time.Time) error { return nil }
type publisher struct {
reliable.Transport
fail bool
records []reliable.Record
}
func (p *publisher) Publish(_ context.Context, r reliable.Record) error {
if p.fail {
return merrors.ServiceUnavailable("offline")
}
p.records = append(p.records, r)
return nil
}
func TestOutboxSurvivesPublishFailure(t *testing.T) {
id := bson.NewObjectID()
out := &outbox{jobs: []model.Notification{{Base: storable.Base{ID: id}, Email: model.Email{Address: "private@example.com", Text: "private"}}}}
transport := &publisher{fail: true}
b := &Bus{outbox: out, transport: transport}
if err := b.PublishDue(context.Background()); err == nil {
t.Fatal("publication failure hidden")
}
transport.fail = false
if err := b.PublishDue(context.Background()); err != nil {
t.Fatal(err)
}
if len(transport.records) != 1 || string(transport.records[0].Payload) != id.Hex() || transport.records[0].MessageID != id.Hex()+"-0" {
t.Fatal(transport.records)
}
out.jobs[0].Attempts = 1
if err := b.PublishDue(context.Background()); err != nil {
t.Fatal(err)
}
if transport.records[1].MessageID == transport.records[0].MessageID {
t.Fatal("retry shares transport deduplication identity")
}
}
+30
View File
@@ -0,0 +1,30 @@
listen: "127.0.0.1:8083"
database:
driver: mongodb
settings:
uri_env: SXCHNGE_MONGO_URI
database: sxchnge_notifications
messaging:
driver: NATS
settings:
url_env: SXCHNGE_NATS_URL
broker_name: sxchnge-notifications
max_reconnects: -1
reconnect_wait: 2
templates: "templates.example.json"
email:
driver: resend # sendgrid or resend
settings:
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
sender_address: "notifications@example.com"
sender_name: "SXCHNGE"
# Use replicas: 3 and environment: production for a three-node NATS cluster.
# mode: ops validates resources without modifying them; app provisions them.
queue:
stream:
replicas: 1
max_age_hours: 48
provisioning:
mode: app
environment: non-production
+79
View File
@@ -0,0 +1,79 @@
package email
import (
"context"
"net/http"
"net/mail"
"os"
"strings"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/go-viper/mapstructure/v2"
)
type Driver string
const (
SendGrid Driver = "sendgrid"
Resend Driver = "resend"
)
type Config = sharedmodel.DriverConfig[Driver]
type Settings struct {
APIKeyEnv string `mapstructure:"api_key_env"`
SenderAddress string `mapstructure:"sender_address"`
SenderName string `mapstructure:"sender_name"`
}
// Outcome describes provider acceptance, never recipient delivery or reading.
type Outcome struct {
State model.State
Code string
RetryAfter time.Duration
}
type Sender interface {
Send(context.Context, string, model.Email) Outcome
}
type client struct {
http *http.Client
key string
settings Settings
endpoint string
}
func New(config Config) (Sender, error) {
var s Settings
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
if err != nil {
return nil, merrors.InternalWrap(err, "create driver settings decoder")
}
if err = decoder.Decode(config.Settings); err != nil {
return nil, merrors.InvalidArgument("invalid email driver settings")
}
if !ValidAddress(s.SenderAddress) || strings.ContainsAny(s.SenderName, "\r\n") {
return nil, merrors.InvalidArgument("invalid sender")
}
key := os.Getenv(s.APIKeyEnv)
if s.APIKeyEnv == "" || strings.TrimSpace(key) == "" || strings.ContainsAny(key, "\r\n") {
return nil, merrors.FailedPrecondition("email API key environment variable is missing")
}
c := client{http: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, key: key, settings: s}
switch config.Driver {
case SendGrid:
c.endpoint = "https://api.sendgrid.com/v3/mail/send"
return &sendgrid{c}, nil
case Resend:
c.endpoint = "https://api.resend.com/emails"
return &resend{c}, nil
default:
return nil, merrors.InvalidArgument("unsupported email driver")
}
}
func ValidAddress(s string) bool {
a, err := mail.ParseAddress(s)
return err == nil && a.Address == s && !strings.ContainsAny(s, "\r\n") && len(s) <= 254
}
+105
View File
@@ -0,0 +1,105 @@
package email
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
)
func TestDriversRenderWirePayload(t *testing.T) {
for _, driver := range []Driver{SendGrid, Resend} {
t.Run(string(driver), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-secret" {
t.Error("missing authorization")
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
if body["subject"] != "Completed" {
t.Errorf("subject: %v", body)
}
if driver == SendGrid {
if body["template_id"] != nil {
t.Error("provider-managed templates must not be used")
}
p := body["personalizations"].([]any)[0].(map[string]any)
if p["to"].([]any)[0].(map[string]any)["email"] != "user@example.com" {
t.Error("wrong recipient")
}
content := body["content"].([]any)
if len(content) != 2 || content[1].(map[string]any)["value"] != "<p>A &amp; B</p>" {
t.Error("wrong body")
}
} else {
if r.Header.Get("Idempotency-Key") != "delivery-id" {
t.Error("missing idempotency key")
}
if body["html"] != "<p>A &amp; B</p>" || body["text"] != "A & B" {
t.Error("wrong body")
}
}
w.WriteHeader(http.StatusAccepted)
}))
defer server.Close()
c := client{http: server.Client(), key: "test-secret", endpoint: server.URL, settings: Settings{SenderAddress: "sender@example.com", SenderName: "SXCHNGE"}}
var sender Sender = &sendgrid{c}
if driver == Resend {
sender = &resend{c}
}
outcome := sender.Send(context.Background(), "delivery-id", model.Email{Address: "user@example.com", Subject: "Completed", HTML: "<p>A &amp; B</p>", Text: "A & B"})
if outcome.State != model.Accepted {
t.Fatal(outcome)
}
})
}
}
func TestProviderFailures(t *testing.T) {
for _, tc := range []struct {
code int
state model.State
}{{429, model.RetryWait}, {400, model.Failed}, {401, model.Failed}, {403, model.Failed}, {500, model.Unknown}, {408, model.Unknown}, {302, model.Failed}} {
t.Run(http.StatusText(tc.code), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "120")
w.WriteHeader(tc.code)
_, _ = w.Write([]byte("secret provider response"))
}))
defer server.Close()
c := client{http: server.Client(), endpoint: server.URL}
out := c.post(context.Background(), "", struct{}{})
if out.State != tc.state || strings.Contains(out.Code, "secret") {
t.Fatal(out)
}
if tc.code == 429 && out.RetryAfter != 2*time.Minute {
t.Fatal(out)
}
})
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
c := client{http: http.DefaultClient, endpoint: "http://127.0.0.1:1"}
if out := c.post(ctx, "", struct{}{}); out.State != model.Unknown {
t.Fatal(out)
}
}
func TestDriverConfig(t *testing.T) {
t.Setenv("EMAIL_TEST_KEY", "secret")
for _, driver := range []Driver{SendGrid, Resend} {
if _, err := New(Config{Driver: driver, Settings: map[string]any{"api_key_env": "EMAIL_TEST_KEY", "sender_address": "sender@example.com"}}); err != nil {
t.Fatal(err)
}
}
for _, settings := range []map[string]any{{"api_key_env": "MISSING_KEY", "sender_address": "sender@example.com"}, {"api_key_env": "EMAIL_TEST_KEY", "sender_address": "bad"}, {"api_key_env": "EMAIL_TEST_KEY", "sender_address": "sender@example.com", "typo": true}} {
if _, err := New(Config{Driver: Resend, Settings: settings}); err == nil {
t.Fatal("accepted invalid driver config")
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package email
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
)
func (c *client) post(ctx context.Context, key string, payload any) Outcome {
body, err := json.Marshal(payload)
if err != nil {
return Outcome{State: model.Failed, Code: "invalid_payload"}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
if err != nil {
return Outcome{State: model.Failed, Code: "invalid_provider_config"}
}
req.Header.Set("Authorization", "Bearer "+c.key)
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
resp, err := c.http.Do(req)
if err != nil {
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
}
defer resp.Body.Close()
// Discard a bounded response without logging provider-controlled content.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return Outcome{State: model.Accepted}
case resp.StatusCode == 429:
delay := time.Minute
if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 {
delay = time.Duration(n) * time.Second
} else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 {
delay = time.Until(date)
}
return Outcome{State: model.RetryWait, Code: "provider_rate_limited", RetryAfter: delay}
case resp.StatusCode >= 500 || resp.StatusCode == 408:
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
case resp.StatusCode == 401 || resp.StatusCode == 403:
return Outcome{State: model.Failed, Code: "provider_authentication_failed"}
default:
return Outcome{State: model.Failed, Code: "provider_rejected"}
}
}
+22
View File
@@ -0,0 +1,22 @@
package email
import (
"context"
"net/mail"
"git.sendico.io/tech/sxchnge/notifications/model"
)
type resend struct{ client }
type resendRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
}
func (s *resend) Send(ctx context.Context, key string, email model.Email) Outcome {
from := (&mail.Address{Name: s.settings.SenderName, Address: s.settings.SenderAddress}).String()
return s.post(ctx, key, resendRequest{From: from, To: []string{email.Address}, Subject: email.Subject, HTML: email.HTML, Text: email.Text})
}
+28
View File
@@ -0,0 +1,28 @@
package email
// Adapted from Sendico's SendGrid notifier (see PROVENANCE.md). The SDK mail
// construction is retained; templates, retries and logging belong to our service.
import (
"context"
"git.sendico.io/tech/sxchnge/notifications/model"
"github.com/sendgrid/sendgrid-go/helpers/mail"
)
type sendgrid struct{ client }
func (s *sendgrid) Send(ctx context.Context, _ string, email model.Email) Outcome {
m := mail.NewV3Mail()
m.SetFrom(mail.NewEmail(s.settings.SenderName, s.settings.SenderAddress))
m.Subject = email.Subject
p := mail.NewPersonalization()
p.AddTos(mail.NewEmail("", email.Address))
m.AddPersonalizations(p)
if email.Text != "" {
m.AddContent(mail.NewContent("text/plain", email.Text))
}
if email.HTML != "" {
m.AddContent(mail.NewContent("text/html", email.HTML))
}
return s.post(ctx, "", m)
}
+84
View File
@@ -0,0 +1,84 @@
module git.sendico.io/tech/sxchnge/notifications
go 1.27.0
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/nats-io/nats.go v1.53.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.56.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
)
replace git.sendico.io/tech/sxchnge/shared => ../shared
require (
git.sendico.io/tech/sxchnge/notifications/interface v0.0.0
git.sendico.io/tech/sxchnge/shared v0.0.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/sendgrid/sendgrid-go v3.16.1+incompatible
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0
go.mongodb.org/mongo-driver/v2 v2.9.0
go.uber.org/zap v1.28.0
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.12
gopkg.in/yaml.v3 v3.0.1
)
replace git.sendico.io/tech/sxchnge/notifications/interface => ./interface
+203
View File
@@ -0,0 +1,203 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/nats-io/nats.go v1.53.1 h1:Otsq3uLc/kLdjmkNHkXH0jBqwUquwdKFoe3fq6/3/Xo=
github.com/nats-io/nats.go v1.53.1/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sendgrid/sendgrid-go v3.16.1+incompatible h1:zWhTmB0Y8XCDzeWIm2/BIt1GjJohAA0p6hVEaDtHWWs=
github.com/sendgrid/sendgrid-go v3.16.1+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0 h1:VSPDFiumAtt0CkZEVbmAkEmYVRvsJpKJy9oF3exRKYg=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0/go.mod h1:kHfzrY1cYP/zr9H4TdqAxbP836A1C2fyUojlHidhFGI=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0=
go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+41
View File
@@ -0,0 +1,41 @@
# Notifications service contract
This independent module owns the public internal-service contract. The canonical
source is `proto/v1/interface.proto`; it defines `Send`, `Get`, requests, statuses
and email/SMS/push/Telegram targets. The executable currently implements email.
From the repository root, `make generate-notifications` produces Go/gRPC client
bindings in `notifications/v1`. Generated `.pb.go` files are ignored and must not
be committed. Both backend validation targets generate bindings before compiling.
The generators are pinned in the Makefile; install `protoc` separately.
`make package-notifications-interface` creates a distributable source archive and
a standalone Go client-module archive under `dist/`. Client consumers can generate
bindings for their own language from the source archive. The Go archive contains
the generated bindings and its own go.mod/go.sum, with no executable or shared
infrastructure dependency. Archive generation does not upload to a registry.
Internal Go consumers use the module
`git.sendico.io/tech/sxchnge/notifications/interface` and import
`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this
checkout, use a local replace to this directory and generate before building.
Calls use the trusted private network without caller authentication. The service
uses the existing product storage namespace. Callers authorize user/destination
ownership and preferences before sending; this contract is not a public user API.
Parameters are string values keyed by template parameter name. The entire request
is validated before persistence. An identical idempotency key/request returns the
original resource, even after completion or expiry. A changed request conflicts.
Keys are scoped to the product namespace and retained at least seven days;
Mongo TTL deletion is asynchronous. Expiry defaults to 24 hours and cannot exceed
24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED.
ACCEPTED means provider acceptance, not delivery or reading. UNKNOWN means the
provider may have sent the message and automatic replay is unsafe. DELIVERED is
reserved for a future receipt implementation. Get never exposes recipient addresses,
rendered content, parameters, provider credentials or raw provider responses.
Keep published template revisions and field numbers stable. Reserve removed
protobuf field/enum names and numbers. Compatible additions belong in v1;
incompatible changes require a new version.
+15
View File
@@ -0,0 +1,15 @@
module git.sendico.io/tech/sxchnge/notifications/interface
go 1.27.0
require (
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.12
)
require (
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
)
+38
View File
@@ -0,0 +1,38 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
@@ -0,0 +1,73 @@
syntax = "proto3";
package sxchnge.notifications.v1;
option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1";
import "google/protobuf/timestamp.proto";
// Internal service on the trusted private network; no caller authentication. Send durably queues work.
service NotificationsService {
rpc Send(SendRequest) returns (SendResponse);
rpc Get(GetRequest) returns (GetResponse);
}
message SendRequest {
string idempotency_key = 1; // Required; scoped to the product namespace, retained seven days.
string user_id = 2; // Caller resolves and authorizes recipient ownership.
string template_id = 3;
string template_version = 4;
map<string, string> parameters = 5;
string locale = 6;
repeated Target targets = 7; // V1 accepts exactly one email target.
google.protobuf.Timestamp expires_at = 8; // Default/max 24h from acceptance.
}
message Target {
oneof destination {
EmailTarget email = 1;
SmsTarget sms = 2;
PushTarget push = 3;
TelegramTarget telegram = 4;
}
}
message EmailTarget { string address = 1; }
message SmsTarget { string phone_number = 1; }
message PushTarget { string endpoint_id = 1; }
message TelegramTarget { string chat_id = 1; }
message SendResponse { Notification notification = 1; }
message GetRequest { string id = 1; }
message GetResponse { Notification notification = 1; }
message Notification {
string id = 1;
string user_id = 2;
string template_id = 3;
string template_version = 4;
repeated Delivery deliveries = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
google.protobuf.Timestamp expires_at = 8;
}
message Delivery {
string id = 1;
Channel channel = 2;
DeliveryState state = 3;
uint32 attempt_count = 4;
Failure failure = 5;
google.protobuf.Timestamp next_attempt_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
message Failure { string code = 1; string message = 2; }
enum Channel {
CHANNEL_UNSPECIFIED = 0;
CHANNEL_EMAIL = 1;
CHANNEL_SMS = 2;
CHANNEL_PUSH = 3;
CHANNEL_TELEGRAM = 4;
}
enum DeliveryState {
DELIVERY_STATE_UNSPECIFIED = 0;
DELIVERY_STATE_QUEUED = 1;
DELIVERY_STATE_SENDING = 2;
DELIVERY_STATE_RETRY_WAIT = 3;
DELIVERY_STATE_ACCEPTED = 4;
DELIVERY_STATE_DELIVERED = 5; // Reserved for future verified receipts.
DELIVERY_STATE_FAILED = 6;
DELIVERY_STATE_EXPIRED = 7;
DELIVERY_STATE_UNKNOWN = 8; // May have sent; no automatic replay.
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"context"
"flag"
"os"
"os/signal"
"syscall"
"git.sendico.io/tech/sxchnge/notifications/app"
"go.uber.org/zap"
)
func main() {
path := flag.String("config.file", "config.yml", "notification configuration")
flag.Parse()
logger, err := zap.NewProduction()
if err != nil {
os.Exit(1)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
config, err := app.Load(*path)
if err == nil {
err = app.Run(ctx, config, logger)
}
if err != nil {
logger.Error("Notifications service terminated", zap.Error(err))
_ = logger.Sync()
os.Exit(1)
}
_ = logger.Sync()
}
+58
View File
@@ -0,0 +1,58 @@
package model
import (
"git.sendico.io/tech/sxchnge/shared/database/record"
"time"
)
type State string
const (
Queued State = "queued"
Sending State = "sending"
RetryWait State = "retry_wait"
Accepted State = "accepted"
Failed State = "failed"
Expired State = "expired"
Unknown State = "unknown"
)
type Request struct {
Key string
UserID string
TemplateID string
TemplateVersion string
Parameters map[string]string
Locale string
Address string
ExpiresAt time.Time
}
// Email is a rendered immutable snapshot, independent of provider wire formats.
type Email struct {
Address string
Subject string
HTML string
Text string
}
type Notification struct {
storable.Base `bson:",inline"`
Caller string `bson:"caller"`
Key string `bson:"key"`
Fingerprint string `bson:"fingerprint"`
UserID string `bson:"user_id"`
TemplateID string `bson:"template_id"`
TemplateVersion string `bson:"template_version"`
Email Email `bson:"email"`
Driver string `bson:"driver"`
State State `bson:"state"`
Attempts uint32 `bson:"attempts"`
FailureCode string `bson:"failure_code"`
ExpiresAt time.Time `bson:"expires_at"`
NextAttemptAt time.Time `bson:"next_attempt_at"`
LeaseUntil time.Time `bson:"lease_until"`
PurgeAt time.Time `bson:"purge_at"`
}
func (*Notification) Collection() string { return "notifications" }
@@ -0,0 +1,53 @@
package service_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"math/big"
"net"
"testing"
"time"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/service"
"git.sendico.io/tech/sxchnge/notifications/transport"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// Exercise optional TLS transport against the real service and storage.
func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), DNSNames: []string{"localhost"}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certificate, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(certificate)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})))
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
go func() { _ = server.Serve(listener) }()
t.Cleanup(server.Stop)
conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots, ServerName: "localhost"})))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
return pb.NewNotificationsServiceClient(conn)
}
@@ -0,0 +1,277 @@
package service_test
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/bus"
"git.sendico.io/tech/sxchnge/notifications/email"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/notifications/service"
"git.sendico.io/tech/sxchnge/notifications/store"
"git.sendico.io/tech/sxchnge/notifications/templates"
db "git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/database/repository"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging"
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
msgruntime "git.sendico.io/tech/sxchnge/shared/messaging/runtime"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.uber.org/zap"
)
type sender struct {
calls atomic.Int32
mu sync.Mutex
messages map[string]model.Email
state model.State
}
func (s *sender) Send(_ context.Context, id string, message model.Email) email.Outcome {
s.mu.Lock()
if s.messages == nil {
s.messages = map[string]model.Email{}
}
s.messages[id] = message
s.mu.Unlock()
s.calls.Add(1)
return email.Outcome{State: s.state, Code: "test", RetryAfter: time.Millisecond}
}
func TestDurableDelivery(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:latest")
if err != nil {
t.Fatal(err)
}
defer func() { _ = container.Terminate(context.Background()) }()
uri, err := container.ConnectionString(ctx)
if err != nil {
t.Fatal(err)
}
conn, err := db.ConnectMongo(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: map[string]any{"uri": uri, "database": "notifications_test"}})
if err != nil {
t.Fatal(err)
}
defer conn.Disconnect(context.Background())
repo, err := store.New(zap.NewNop(), conn.Database())
if err != nil {
t.Fatal(err)
}
catalog, err := templates.New([]templates.Definition{{ID: "test", Version: "1", DefaultLocale: "en", Parameters: []string{"name"}, Locales: map[string]templates.Content{"en": {Subject: "Hello", HTML: "<p>{{.name}}</p>"}}}})
if err != nil {
t.Fatal(err)
}
driver := &sender{state: model.Accepted}
svc := service.New(repo, catalog, driver, "resend", zap.NewNop())
request := model.Request{Key: "duplicate", UserID: "u", TemplateID: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "A & B"}, Address: "user@example.com"}
var wg sync.WaitGroup
ids := make(chan string, 10)
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
n, e := svc.Send(ctx, "product", request)
if e != nil {
t.Error(e)
return
}
ids <- n.ID.Hex()
}()
}
wg.Wait()
close(ids)
id := ""
for value := range ids {
if id != "" && value != id {
t.Error("idempotency race")
}
id = value
}
if id == "" {
t.Fatal("no notification")
}
request.Parameters = map[string]string{"name": "Changed"}
if _, err = svc.Send(ctx, "product", request); !errors.Is(err, merrors.ErrDataConflict) {
t.Fatal("missing conflict", err)
}
if _, err = svc.Get(ctx, "other", id); !errors.Is(err, merrors.ErrNoData) {
t.Fatal("caller isolation failed", err)
}
stored, err := svc.Get(ctx, "product", id)
if err != nil || stored.Email.HTML != "<p>A &amp; B</p>" {
t.Fatal(stored, err)
}
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
if _, e := svc.Dispatch(ctx, id); e != nil {
t.Error(e)
}
}()
}
wg.Wait()
if driver.calls.Load() != 1 {
t.Fatalf("sent %d times", driver.calls.Load())
}
stored, err = svc.Get(ctx, "product", id)
if err != nil || stored.State != model.Accepted || stored.Email.Address != "" {
t.Fatal("completion/scrubbing failed", stored, err)
}
// A stale sending lease is never automatically resent.
request.Key = "stale"
stale, err := svc.Send(ctx, "product", request)
if err != nil {
t.Fatal(err)
}
old := time.Now().Add(-2 * time.Minute)
if err = repo.DBImp.Patch(ctx, stale.ID, repository.Patch().Set(repository.Field("next_attempt_at"), old)); err != nil {
t.Fatal(err)
}
claimed, err := repo.Claim(ctx, stale.ID.Hex(), old)
if err != nil {
t.Fatal(err)
}
if err = repo.Recover(ctx, time.Now()); err != nil {
t.Fatal(err)
}
claimed.State = model.Accepted
if err = repo.Finish(ctx, claimed); !errors.Is(err, merrors.ErrDataConflict) {
t.Fatal("stale worker overwrote recovery", err)
}
stale, err = svc.Get(ctx, "product", stale.ID.Hex())
if err != nil || stale.State != model.Unknown {
t.Fatal(stale, err)
}
// Retry state persists, and a duplicate dispatch cannot bypass the due time.
request.Key = "retry"
retry, err := svc.Send(ctx, "product", request)
if err != nil {
t.Fatal(err)
}
retryDriver := &sender{state: model.RetryWait}
retrySvc := service.New(repo, catalog, retryDriver, "resend", zap.NewNop())
if _, err = retrySvc.Dispatch(ctx, retry.ID.Hex()); err != nil {
t.Fatal(err)
}
if _, err = retrySvc.Dispatch(ctx, retry.ID.Hex()); err != nil {
t.Fatal(err)
}
retry, err = svc.Get(ctx, "product", retry.ID.Hex())
if err != nil || retry.State != model.RetryWait || retryDriver.calls.Load() != 1 {
t.Fatal(retry, err)
}
// Expire both queued and retrying notifications without a connected broker.
for _, state := range []model.State{model.Queued, model.RetryWait} {
request.Key = "offline-expiry-" + string(state)
expired, e := svc.Send(ctx, "product", request)
if e != nil {
t.Fatal(e)
}
patch := repository.Patch().Set(repository.Field("state"), state).Set(repository.Field("expires_at"), time.Now().Add(-time.Minute)).Set(repository.Field("next_attempt_at"), time.Now().Add(time.Hour))
if e = repo.DBImp.Patch(ctx, expired.ID, patch); e != nil {
t.Fatal(e)
}
if e = repo.Recover(ctx, time.Now()); e != nil {
t.Fatal(e)
}
expired, e = svc.Get(ctx, "product", expired.ID.Hex())
if e != nil || expired.State != model.Expired || expired.Email.Address != "" {
t.Fatal("offline expiry failed", expired, e)
}
}
// Verify the real JetStream publisher/consumer path with an isolated broker.
ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:latest", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true})
if err != nil {
t.Fatal(err)
}
defer ncContainer.Terminate(context.Background())
endpoint, err := ncContainer.Endpoint(ctx, "")
if err != nil {
t.Fatal(err)
}
t.Setenv("TEST_NOTIFICATIONS_NATS", "nats://"+endpoint)
rt, err := msgruntime.New(zap.NewNop(), &messaging.Config{Driver: messaging.BBNats, Settings: map[string]any{"url_env": "TEST_NOTIFICATIONS_NATS", "broker_name": "test", "max_reconnects": -1, "reconnect_wait": 1}})
if err != nil {
t.Fatal(err)
}
defer rt.Close()
broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, zap.NewNop(), bus.DefaultConfig())
if err != nil {
t.Fatal(err)
}
defer broker.Close()
// Production ops mode validates existing resources without silently rewriting drift.
queue := bus.DefaultConfig()
settings := reliable.DurableSettings{Stream: queue.Stream, Provisioning: reliable.DurableProvisioningSettings{Mode: reliable.DurableProvisioningOps, Environment: reliable.DurableEnvironmentProduction}}
if err = rt.DurableAdmin.EnsureProducerResources(ctx, settings); err != nil {
t.Fatal(err)
}
settings.Stream.MaxAgeHours = 72
if err = rt.DurableAdmin.EnsureProducerResources(ctx, settings); !errors.Is(err, merrors.ErrDataConflict) {
t.Fatal("retention drift accepted", err)
}
settings.Stream.MaxAgeHours = 48
if err = rt.DurableAdmin.EnsureProducerResources(ctx, settings); err != nil {
t.Fatal("ops mode mutated the stream", err)
}
canceled, stopAdmin := context.WithCancel(ctx)
stopAdmin()
if err = rt.DurableAdmin.EnsureProducerResources(canceled, settings); err == nil {
t.Fatal("canceled provisioning succeeded")
}
client := grpcClient(t, svc)
rpcCtx := ctx
input := &pb.SendRequest{IdempotencyKey: "jetstream", UserId: "u", TemplateId: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "End to end & escaped"}, Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
sent, err := client.Send(rpcCtx, input)
if err != nil {
t.Fatal(err)
}
replay, err := client.Send(rpcCtx, input)
if err != nil || replay.Notification.Id != sent.Notification.Id {
t.Fatal("RPC idempotency failed", err)
}
if _, e := client.Get(ctx, &pb.GetRequest{Id: sent.Notification.Id}); e != nil {
t.Fatal("RPC retrieval without credentials failed", e)
}
if err = broker.PublishDue(ctx); err != nil {
t.Fatal(err)
}
workerCtx, stop := context.WithCancel(ctx)
done := make(chan struct{})
go func() { broker.RunWorker(workerCtx); close(done) }()
defer func() { stop(); <-done }()
deadline := time.Now().Add(10 * time.Second)
for {
result, e := client.Get(rpcCtx, &pb.GetRequest{Id: sent.Notification.Id})
if e != nil {
t.Fatal(e)
}
if strings.Contains(result.String(), "user@example.com") || strings.Contains(result.String(), "escaped") {
t.Fatal("response exposes email content")
}
if result.Notification.Deliveries[0].State == pb.DeliveryState_DELIVERY_STATE_ACCEPTED {
break
}
if time.Now().After(deadline) {
t.Fatal("JetStream job did not complete", result.Notification.Deliveries[0].State)
}
time.Sleep(20 * time.Millisecond)
}
driver.mu.Lock()
delivered := driver.messages[sent.Notification.Id]
driver.mu.Unlock()
if delivered.HTML != "<p>End to end &amp; escaped</p>" || delivered.Subject != "Hello" || delivered.Address != "user@example.com" {
t.Fatal("parameters did not reach driver", delivered)
}
}
+109
View File
@@ -0,0 +1,109 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/notifications/templates"
"git.sendico.io/tech/sxchnge/shared/database/record"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type Repository interface {
Find(context.Context, string, string) (*model.Notification, error)
Get(context.Context, string, string) (*model.Notification, error)
Create(context.Context, *model.Notification) (*model.Notification, error)
Claim(context.Context, string, time.Time) (*model.Notification, error)
Finish(context.Context, *model.Notification) error
Recover(context.Context, time.Time) error
}
type Service struct {
repo Repository
catalog *templates.Catalog
sender email.Sender
driver string
logger mlogger.Logger
}
func New(repo Repository, catalog *templates.Catalog, sender email.Sender, driver string, logger mlogger.Logger) *Service {
return &Service{repo, catalog, sender, driver, mlogger.Named(logger, "notifications")}
}
func (s *Service) Send(ctx context.Context, caller string, r model.Request) (*model.Notification, error) {
if caller == "" {
return nil, merrors.Unauthorized("missing caller")
}
if r.Key == "" || len(r.Key) > 128 || r.UserID == "" || len(r.UserID) > 128 || !email.ValidAddress(r.Address) || len(r.TemplateID) > 128 || len(r.TemplateVersion) > 64 || len(r.Locale) > 64 || strings.ContainsRune(r.TemplateID+r.TemplateVersion, 0) {
return nil, merrors.InvalidArgument("invalid notification request")
}
if len(r.Parameters) > 64 {
return nil, merrors.InvalidArgument("too many parameters")
}
for k, v := range r.Parameters {
if k == "" || len(k) > 64 || len(v) > 4096 {
return nil, merrors.InvalidArgument("invalid template parameter")
}
}
payload, err := json.Marshal(r)
if err != nil {
return nil, merrors.InvalidArgument("invalid request")
}
if len(payload) > 64<<10 {
return nil, merrors.InvalidArgument("request too large")
}
sum := sha256.Sum256(payload)
fingerprint := hex.EncodeToString(sum[:])
existing, err := s.repo.Find(ctx, caller, r.Key)
if err == nil {
return same(existing, fingerprint)
}
if !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
now := time.Now().UTC()
expiry := r.ExpiresAt
if expiry.IsZero() {
expiry = now.Add(24 * time.Hour)
}
if !expiry.After(now) || expiry.After(now.Add(24*time.Hour)) {
return nil, merrors.InvalidArgument("expiry must be within the next 24 hours")
}
rendered, err := s.catalog.Render(r)
if err != nil {
return nil, err
}
n := &model.Notification{Base: storable.Base{ID: bson.NewObjectID(), CreatedAt: now, UpdatedAt: now}, Caller: caller, Key: r.Key, Fingerprint: fingerprint, UserID: r.UserID, TemplateID: r.TemplateID, TemplateVersion: r.TemplateVersion, Email: rendered, Driver: s.driver, State: model.Queued, ExpiresAt: expiry, NextAttemptAt: now, PurgeAt: now.Add(7 * 24 * time.Hour)}
stored, err := s.repo.Create(ctx, n)
if err != nil {
return nil, err
}
result, err := same(stored, fingerprint)
if err == nil {
s.logger.Info("Notification accepted", zap.String("notification_id", stored.ID.Hex()), zap.String("caller", caller))
}
return result, err
}
func same(n *model.Notification, fingerprint string) (*model.Notification, error) {
if n.Fingerprint != fingerprint {
return nil, merrors.DataConflict("idempotency key already used with a different request")
}
return n, nil
}
func (s *Service) Get(ctx context.Context, caller, id string) (*model.Notification, error) {
if caller == "" {
return nil, merrors.Unauthorized("missing caller")
}
if _, err := bson.ObjectIDFromHex(id); err != nil {
return nil, merrors.InvalidArgument("invalid notification ID")
}
return s.repo.Get(ctx, caller, id)
}
+75
View File
@@ -0,0 +1,75 @@
package service
import (
"context"
"errors"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap"
)
// Dispatch consumes an opaque job ID from JetStream. Duplicate messages are safe.
func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
now := time.Now().UTC()
if err := s.repo.Recover(ctx, now); err != nil {
return false, err
}
n, err := s.repo.Claim(ctx, id, now)
if errors.Is(err, merrors.ErrNoData) {
return false, nil
}
if err != nil {
return false, err
}
switch {
case !n.ExpiresAt.After(now):
n.State = model.Expired
n.FailureCode = "expired"
case n.Driver != s.driver:
n.State = model.Failed
n.FailureCode = "driver_changed"
default:
deadline := now.Add(20 * time.Second)
if n.ExpiresAt.Before(deadline) {
deadline = n.ExpiresAt
}
sendCtx, cancel := context.WithDeadline(ctx, deadline)
outcome := s.sender.Send(sendCtx, n.ID.Hex(), n.Email)
cancel()
n.State = outcome.State
n.FailureCode = outcome.Code
if n.State == model.RetryWait {
delay := time.Duration(1<<min(n.Attempts, 8)) * time.Second
if outcome.RetryAfter > delay {
delay = outcome.RetryAfter
}
n.NextAttemptAt = time.Now().UTC().Add(delay)
if n.Attempts >= 5 {
n.State = model.Failed
n.FailureCode = "retry_exhausted"
} else if !n.NextAttemptAt.Before(n.ExpiresAt) {
n.State = model.Expired
n.FailureCode = "expired"
}
} else if n.State != model.Accepted && n.State != model.Failed && n.State != model.Unknown {
n.State = model.Unknown
n.FailureCode = "invalid_driver_outcome"
}
}
n.UpdatedAt = time.Now().UTC()
// Persist the result even when shutdown canceled the provider request.
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err = s.repo.Finish(finishCtx, n); err != nil {
return true, err
}
fields := []zap.Field{zap.String("notification_id", n.ID.Hex()), zap.String("driver", n.Driver), zap.String("state", string(n.State)), zap.Uint32("attempts", n.Attempts), zap.String("failure_code", n.FailureCode)}
if n.State == model.Accepted {
s.logger.Info("Email accepted by provider", fields...)
} else {
s.logger.Warn("Email delivery attempt finished", fields...)
}
return true, nil
}
@@ -0,0 +1,64 @@
package service
import (
"context"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model"
"go.uber.org/zap"
)
type workerRepo struct {
Repository
job model.Notification
saved *model.Notification
}
func (r *workerRepo) Recover(context.Context, time.Time) error { return nil }
func (r *workerRepo) Claim(context.Context, string, time.Time) (*model.Notification, error) {
n := r.job
return &n, nil
}
func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error { r.saved = n; return nil }
type workerSender struct {
calls int
outcome email.Outcome
}
func (s *workerSender) Send(context.Context, string, model.Email) email.Outcome {
s.calls++
return s.outcome
}
func TestWorkerTerminalAndRetryOutcomes(t *testing.T) {
for _, tc := range []struct {
name string
expiry time.Time
driver string
attempts uint32
outcome model.State
want model.State
calls int
}{
{"expired", time.Now().Add(-time.Minute), "resend", 1, model.Accepted, model.Expired, 0},
{"driver changed", time.Now().Add(time.Hour), "sendgrid", 1, model.Accepted, model.Failed, 0},
{"retry exhausted", time.Now().Add(time.Hour), "resend", 5, model.RetryWait, model.Failed, 1},
{"retry expiry", time.Now().Add(time.Second), "resend", 1, model.RetryWait, model.Expired, 1},
{"ambiguous", time.Now().Add(time.Hour), "resend", 1, model.Unknown, model.Unknown, 1},
{"invalid driver outcome", time.Now().Add(time.Hour), "resend", 1, model.Queued, model.Unknown, 1},
} {
t.Run(tc.name, func(t *testing.T) {
repo := &workerRepo{job: model.Notification{Driver: tc.driver, ExpiresAt: tc.expiry, Attempts: tc.attempts}}
sender := &workerSender{outcome: email.Outcome{State: tc.outcome}}
svc := New(repo, nil, sender, "resend", zap.NewNop())
if _, err := svc.Dispatch(context.Background(), "id"); err != nil {
t.Fatal(err)
}
if repo.saved == nil || repo.saved.State != tc.want || sender.calls != tc.calls {
t.Fatal(repo.saved, sender.calls)
}
})
}
}
+133
View File
@@ -0,0 +1,133 @@
package store
import (
"context"
"errors"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/template"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type Store struct {
template.DBImp[*model.Notification]
}
func New(logger mlogger.Logger, db source.Source) (*Store, error) {
s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)}
ttl := int32(0)
for _, index := range []*ri.Definition{
{Name: "unique_caller_key", Keys: []ri.Key{{Field: "caller", Sort: ri.Asc}, {Field: "key", Sort: ri.Asc}}, Unique: true},
{Name: "pending_dispatch", Keys: []ri.Key{{Field: "state", Sort: ri.Asc}, {Field: "next_attempt_at", Sort: ri.Asc}}},
{Name: "pending_expiry", Keys: []ri.Key{{Field: "state", Sort: ri.Asc}, {Field: "expires_at", Sort: ri.Asc}}},
{Name: "expired_lease", Keys: []ri.Key{{Field: "state", Sort: ri.Asc}, {Field: "lease_until", Sort: ri.Asc}}},
{Name: "purge_notifications", Keys: []ri.Key{{Field: "purge_at", Sort: ri.Asc}}, TTL: &ttl},
} {
if err := s.Repository.CreateIndex(index); err != nil {
return nil, wrap(err)
}
}
return s, nil
}
func wrap(err error) error {
if err == nil {
return nil
}
if errors.Is(err, merrors.ErrNoData) || errors.Is(err, merrors.ErrDataConflict) {
return err
}
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "notification storage unavailable")
}
func (s *Store) Find(ctx context.Context, caller, key string) (*model.Notification, error) {
var n model.Notification
err := s.FindOne(ctx, repository.Query().And(repository.Filter("caller", caller), repository.Filter("key", key)), &n)
return &n, wrap(err)
}
func (s *Store) Get(ctx context.Context, caller, id string) (*model.Notification, error) {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, merrors.InvalidArgument("invalid notification ID")
}
var n model.Notification
err = s.FindOne(ctx, repository.Query().And(repository.Filter("caller", caller), repository.IDFilter(oid)), &n)
return &n, wrap(err)
}
func (s *Store) Create(ctx context.Context, n *model.Notification) (*model.Notification, error) {
err := s.DBImp.Create(ctx, n)
if errors.Is(err, merrors.ErrDataConflict) {
return s.Find(ctx, n.Caller, n.Key)
}
return n, wrap(err)
}
func pending(now time.Time) builder.Query {
return repository.Query().And(repository.Query().In(repository.Field("state"), model.Queued, model.RetryWait), repository.Query().Comparison(repository.Field("next_attempt_at"), builder.Lte, now))
}
// Expire pending messages independently of bus availability, then fence lost workers.
// A lost worker may already have sent its message. Do not automatically replay it.
func (s *Store) Recover(ctx context.Context, now time.Time) error {
expired := repository.Query().And(repository.Query().In(repository.Field("state"), model.Queued, model.RetryWait), repository.Query().Comparison(repository.Field("expires_at"), builder.Lte, now))
expirePatch := repository.Patch().Set(repository.Field("state"), model.Expired).Set(repository.Field("failure_code"), "expired").Set(repository.Field("updatedAt"), now).Set(repository.Field("email"), model.Email{})
count, err := s.PatchMany(ctx, expired, expirePatch)
if err != nil {
return wrap(err)
}
if count > 0 {
s.Logger.Info("Pending notifications expired", zap.Int("count", count))
}
query := repository.Query().And(repository.Filter("state", model.Sending), repository.Query().Comparison(repository.Field("lease_until"), builder.Lte, now))
patch := repository.Patch().Set(repository.Field("state"), model.Unknown).Set(repository.Field("failure_code"), "worker_outcome_unknown").Set(repository.Field("updatedAt"), now).Set(repository.Field("email"), model.Email{})
count, err = s.PatchMany(ctx, query, patch)
if err == nil && count > 0 {
s.Logger.Warn("Notification workers lost their leases", zap.Int("count", count))
}
return wrap(err)
}
func (s *Store) Claim(ctx context.Context, id string, now time.Time) (*model.Notification, error) {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, merrors.NoData("invalid job ID")
}
var n model.Notification
query := repository.Query().And(repository.IDFilter(oid), pending(now))
patch := repository.Patch().Set(repository.Field("state"), model.Sending).Set(repository.Field("updatedAt"), now).Set(repository.Field("lease_until"), now.Add(time.Minute)).Inc(repository.Field("attempts"), 1)
matched, err := s.Repository.PatchOneByFilter(ctx, query, patch, &n)
if err != nil {
return nil, wrap(err)
}
if !matched {
return nil, merrors.NoData("job is not pending")
}
return &n, nil
}
func (s *Store) Finish(ctx context.Context, n *model.Notification) error {
query := repository.Query().And(repository.IDFilter(n.ID), repository.Filter("state", model.Sending), repository.Filter("attempts", n.Attempts), repository.Filter("lease_until", n.LeaseUntil))
patch := repository.Patch().Set(repository.Field("state"), n.State).Set(repository.Field("failure_code"), n.FailureCode).Set(repository.Field("updatedAt"), n.UpdatedAt).Set(repository.Field("next_attempt_at"), n.NextAttemptAt)
if n.State != model.RetryWait {
patch.Set(repository.Field("email"), model.Email{})
}
count, err := s.PatchMany(ctx, query, patch)
if err != nil {
return wrap(err)
}
if count != 1 {
return merrors.DataConflict("delivery lease lost")
}
return nil
}
// Acceptance and outbox work are one document, so no dual-write transaction is needed.
func (s *Store) Due(ctx context.Context, now time.Time) ([]model.Notification, error) {
limit := int64(100)
var jobs []model.Notification
err := s.Repository.FindAllByFilter(ctx, pending(now).Sort(repository.Field("next_attempt_at"), true).Limit(&limit), &jobs)
return jobs, wrap(err)
}
@@ -0,0 +1,79 @@
[
{
"id": "exchange.completed",
"version": "1",
"default_locale": "en",
"parameters": [
"first_name",
"amount",
"currency"
],
"locales": {
"en": {
"subject": "Your exchange is complete",
"html": "<p>Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete.</p>",
"text": "Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete."
}
}
},
{
"id": "auth.activation",
"version": "1",
"default_locale": "en",
"parameters": [
"url"
],
"locales": {
"en": {
"subject": "Verify your SXCHNGE email",
"text": "Confirm your email: {{.url}}\nThis link expires in 24 hours. If you did not sign up, ignore this email.",
"html": "<p>Confirm your email to finish signing up.</p><p><a href=\"{{.url}}\">Verify email</a></p><p>This link expires in 24 hours. If you did not sign up, ignore this email.</p>"
},
"ru": {
"subject": "Подтвердите почту SXCHNGE",
"text": "Подтвердите почту: {{.url}}\nСсылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.",
"html": "<p><a href=\"{{.url}}\">Подтвердить почту</a></p><p>Ссылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.</p>"
}
}
},
{
"id": "auth.login",
"version": "1",
"default_locale": "en",
"parameters": [
"code"
],
"locales": {
"en": {
"subject": "Your SXCHNGE login code",
"text": "Your login code is {{.code}}. It expires in 10 minutes. Never share this code.",
"html": "<p>Your login code is <strong>{{.code}}</strong>.</p><p>It expires in 10 minutes. Never share this code.</p>"
},
"ru": {
"subject": "Код входа SXCHNGE",
"text": "Ваш код входа: {{.code}}. Он действует 10 минут. Никому не сообщайте код.",
"html": "<p>Ваш код входа: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Никому не сообщайте код.</p>"
}
}
},
{
"id": "twofactor.code",
"version": "1",
"default_locale": "en",
"parameters": [
"code"
],
"locales": {
"en": {
"subject": "Your SXCHNGE verification code",
"text": "Your verification code is {{.code}}. It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.",
"html": "<p>Your verification code is <strong>{{.code}}</strong>.</p><p>It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.</p>"
},
"ru": {
"subject": "Код подтверждения SXCHNGE",
"text": "Ваш код подтверждения: {{.code}}. Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.",
"html": "<p>Ваш код подтверждения: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.</p>"
}
}
}
]
+146
View File
@@ -0,0 +1,146 @@
package templates
import (
"bytes"
"encoding/json"
html "html/template"
"io"
"os"
"strings"
text "text/template"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
type Content struct {
Subject string `json:"subject"`
HTML string `json:"html"`
Text string `json:"text"`
}
type Definition struct {
ID string `json:"id"`
Version string `json:"version"`
DefaultLocale string `json:"default_locale"`
Parameters []string `json:"parameters"`
Locales map[string]Content `json:"locales"`
}
type compiled struct {
definition Definition
locales map[string]renderers
}
type renderers struct {
subject, text *text.Template
html *html.Template
}
type Catalog struct{ entries map[string]compiled }
func Load(path string) (*Catalog, error) {
f, err := os.Open(path)
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "cannot open template catalog")
}
defer f.Close()
var definitions []Definition
dec := json.NewDecoder(io.LimitReader(f, 4<<20))
dec.DisallowUnknownFields()
if err = dec.Decode(&definitions); err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid template catalog")
}
if err = dec.Decode(new(any)); err != io.EOF {
return nil, merrors.InvalidArgument("unexpected template catalog content")
}
return New(definitions)
}
func New(definitions []Definition) (*Catalog, error) {
c := &Catalog{entries: map[string]compiled{}}
if len(definitions) == 0 {
return nil, merrors.InvalidArgument("empty template catalog")
}
for _, d := range definitions {
key := d.ID + "\x00" + d.Version
if d.ID == "" || d.Version == "" || strings.ContainsRune(d.ID+d.Version, 0) {
return nil, merrors.InvalidArgument("invalid template identity")
}
if _, ok := c.entries[key]; ok {
return nil, merrors.InvalidArgument("duplicate template revision")
}
if _, ok := d.Locales[d.DefaultLocale]; !ok || d.DefaultLocale == "" {
return nil, merrors.InvalidArgument("missing default locale")
}
params := map[string]string{}
for _, p := range d.Parameters {
if p == "" {
return nil, merrors.InvalidArgument("empty parameter")
}
if _, ok := params[p]; ok {
return nil, merrors.InvalidArgument("duplicate parameter")
}
params[p] = "sample"
}
entry := compiled{definition: d, locales: map[string]renderers{}}
for locale, content := range d.Locales {
s, err := text.New("subject").Option("missingkey=error").Parse(content.Subject)
if err != nil {
return nil, merrors.InvalidArgument("invalid subject template")
}
t, err := text.New("text").Option("missingkey=error").Parse(content.Text)
if err != nil {
return nil, merrors.InvalidArgument("invalid text template")
}
h, err := html.New("html").Option("missingkey=error").Parse(content.HTML)
if err != nil {
return nil, merrors.InvalidArgument("invalid HTML template")
}
entry.locales[locale] = renderers{s, t, h}
}
c.entries[key] = entry
for locale := range d.Locales {
if _, err := c.Render(model.Request{TemplateID: d.ID, TemplateVersion: d.Version, Locale: locale, Parameters: params}); err != nil {
return nil, err
}
}
}
return c, nil
}
type boundedBuffer struct{ bytes.Buffer }
func (b *boundedBuffer) Write(p []byte) (int, error) {
if b.Len()+len(p) > 256<<10 {
return 0, merrors.InvalidArgument("rendered content too large")
}
return b.Buffer.Write(p)
}
func (c *Catalog) Render(r model.Request) (model.Email, error) {
entry, ok := c.entries[r.TemplateID+"\x00"+r.TemplateVersion]
if !ok {
return model.Email{}, merrors.NoData("template revision not found")
}
if len(r.Parameters) != len(entry.definition.Parameters) {
return model.Email{}, merrors.InvalidArgument("template parameters do not match")
}
for _, p := range entry.definition.Parameters {
if _, ok := r.Parameters[p]; !ok {
return model.Email{}, merrors.InvalidArgument("missing template parameter")
}
}
locale := r.Locale
if locale == "" {
locale = entry.definition.DefaultLocale
}
render, ok := entry.locales[locale]
if !ok {
return model.Email{}, merrors.InvalidArgument("unsupported template locale")
}
var subject, body, plain boundedBuffer
for _, run := range []func() error{func() error { return render.subject.Execute(&subject, r.Parameters) }, func() error { return render.html.Execute(&body, r.Parameters) }, func() error { return render.text.Execute(&plain, r.Parameters) }} {
if err := run(); err != nil {
return model.Email{}, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "template rendering failed")
}
}
if subject.Len() == 0 || subject.Len() > 998 || strings.ContainsAny(subject.String(), "\r\n") || (body.Len() == 0 && plain.Len() == 0) {
return model.Email{}, merrors.InvalidArgument("invalid rendered email")
}
return model.Email{Address: r.Address, Subject: subject.String(), HTML: body.String(), Text: plain.String()}, nil
}
@@ -0,0 +1,42 @@
package templates
import (
"strings"
"testing"
"git.sendico.io/tech/sxchnge/notifications/model"
)
func TestRenderAndValidate(t *testing.T) {
c, err := New([]Definition{{ID: "test", Version: "1", DefaultLocale: "en", Parameters: []string{"name"}, Locales: map[string]Content{"en": {Subject: "Hello {{.name}}", HTML: "<p>{{.name}}</p>", Text: "{{.name}}"}}}})
if err != nil {
t.Fatal(err)
}
r := model.Request{TemplateID: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "<script>{{.other}}</script>"}}
result, err := c.Render(r)
if err != nil {
t.Fatal(err)
}
if strings.Contains(result.HTML, "<script>") || !strings.Contains(result.HTML, "{{.other}}") || result.Text != r.Parameters["name"] {
t.Fatalf("unsafe substitution: %+v", result)
}
for _, params := range []map[string]string{{}, {"name": "a", "extra": "b"}, {"wrong": "a"}, {"name": "bad\r\nsubject"}} {
r.Parameters = params
if _, err := c.Render(r); err == nil {
t.Fatal("invalid parameters accepted")
}
}
r.Parameters = map[string]string{"name": "valid"}
r.Locale = "unknown"
if _, err := c.Render(r); err == nil {
t.Fatal("unknown locale accepted")
}
}
func TestRejectBrokenCatalog(t *testing.T) {
for _, content := range []Content{{Subject: "Hello", HTML: "{{.undeclared}}"}, {Subject: "Hello", HTML: "{{"}, {HTML: "body"}} {
_, err := New([]Definition{{ID: "test", Version: "1", DefaultLocale: "en", Locales: map[string]Content{"en": content}}})
if err == nil {
t.Fatal("invalid template accepted")
}
}
}
+46
View File
@@ -0,0 +1,46 @@
package transport
import (
"time"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"google.golang.org/protobuf/types/known/timestamppb"
)
func request(r *pb.SendRequest) (model.Request, error) {
if r == nil || len(r.Targets) != 1 {
return model.Request{}, merrors.InvalidArgument("exactly one email target is required")
}
target := r.Targets[0]
if target == nil || target.Destination == nil {
return model.Request{}, merrors.InvalidArgument("missing destination")
}
email, ok := target.Destination.(*pb.Target_Email)
if !ok {
return model.Request{}, merrors.NotImplemented("channel is not implemented")
}
if email.Email == nil {
return model.Request{}, merrors.InvalidArgument("missing email target")
}
var expiry time.Time
if r.ExpiresAt != nil {
if err := r.ExpiresAt.CheckValid(); err != nil {
return model.Request{}, merrors.InvalidArgument("invalid expiry")
}
expiry = r.ExpiresAt.AsTime()
}
return model.Request{Key: r.IdempotencyKey, UserID: r.UserId, TemplateID: r.TemplateId, TemplateVersion: r.TemplateVersion, Parameters: r.Parameters, Locale: r.Locale, Address: email.Email.Address, ExpiresAt: expiry}, nil
}
func response(n *model.Notification) *pb.Notification {
states := map[model.State]pb.DeliveryState{model.Queued: pb.DeliveryState_DELIVERY_STATE_QUEUED, model.Sending: pb.DeliveryState_DELIVERY_STATE_SENDING, model.RetryWait: pb.DeliveryState_DELIVERY_STATE_RETRY_WAIT, model.Accepted: pb.DeliveryState_DELIVERY_STATE_ACCEPTED, model.Failed: pb.DeliveryState_DELIVERY_STATE_FAILED, model.Expired: pb.DeliveryState_DELIVERY_STATE_EXPIRED, model.Unknown: pb.DeliveryState_DELIVERY_STATE_UNKNOWN}
d := &pb.Delivery{Id: n.ID.Hex() + "-email", Channel: pb.Channel_CHANNEL_EMAIL, State: states[n.State], AttemptCount: n.Attempts, UpdatedAt: timestamppb.New(n.UpdatedAt)}
if n.FailureCode != "" {
d.Failure = &pb.Failure{Code: n.FailureCode, Message: "See the documented delivery failure code."}
}
if n.State == model.Queued || n.State == model.RetryWait {
d.NextAttemptAt = timestamppb.New(n.NextAttemptAt)
}
return &pb.Notification{Id: n.ID.Hex(), UserId: n.UserID, TemplateId: n.TemplateID, TemplateVersion: n.TemplateVersion, Deliveries: []*pb.Delivery{d}, CreatedAt: timestamppb.New(n.CreatedAt), UpdatedAt: timestamppb.New(n.UpdatedAt), ExpiresAt: timestamppb.New(n.ExpiresAt)}
}
+66
View File
@@ -0,0 +1,66 @@
package transport
import (
"context"
"errors"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type API interface {
Send(context.Context, string, model.Request) (*model.Notification, error)
Get(context.Context, string, string) (*model.Notification, error)
}
type Server struct {
pb.UnimplementedNotificationsServiceServer
service API
}
// The private endpoint uses the existing product storage namespace without caller authentication.
func New(service API) *Server { return &Server{service: service} }
func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) {
input, err := request(r)
if err != nil {
return nil, rpcError(err)
}
n, err := s.service.Send(ctx, "product", input)
if err != nil {
return nil, rpcError(err)
}
return &pb.SendResponse{Notification: response(n)}, nil
}
func (s *Server) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, error) {
if r == nil {
return nil, status.Error(codes.InvalidArgument, "missing request")
}
n, err := s.service.Get(ctx, "product", r.Id)
if err != nil {
return nil, rpcError(err)
}
return &pb.GetResponse{Notification: response(n)}, nil
}
func rpcError(err error) error {
if errors.Is(err, context.Canceled) {
return status.Error(codes.Canceled, "notification operation canceled")
}
if errors.Is(err, context.DeadlineExceeded) {
return status.Error(codes.DeadlineExceeded, "notification operation deadline exceeded")
}
for _, pair := range []struct {
err error
code codes.Code
message string
}{
{merrors.ErrInvalidArg, codes.InvalidArgument, "invalid notification request"}, {merrors.ErrNoData, codes.NotFound, "notification or template not found"}, {merrors.ErrDataConflict, codes.AlreadyExists, "idempotency key already used"}, {merrors.ErrUnauthorized, codes.Unauthenticated, "missing service credentials"}, {merrors.ErrAccessDenied, codes.PermissionDenied, "access denied"}, {merrors.ErrNotImplemented, codes.Unimplemented, "channel is not implemented"}, {merrors.ErrServiceUnavailable, codes.Unavailable, "notification storage unavailable"},
} {
if errors.Is(err, pair.err) {
return status.Error(pair.code, pair.message)
}
}
return status.Error(codes.Internal, "notification operation failed")
}
@@ -0,0 +1,70 @@
package transport
import (
"context"
"net"
"strings"
"testing"
"time"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/shared/database/record"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
)
type fakeAPI struct{}
func (fakeAPI) Send(_ context.Context, caller string, r model.Request) (*model.Notification, error) {
if caller != "product" {
return nil, merrors.Unauthorized("missing caller")
}
return &model.Notification{Base: storable.Base{ID: bson.NewObjectID(), CreatedAt: time.Now(), UpdatedAt: time.Now()}, UserID: r.UserID, State: model.Queued, Email: model.Email{Address: r.Address, Text: "sensitive"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
}
func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
return nil, merrors.NoData("private details")
}
func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) {
listener := bufconn.Listen(1 << 20)
server := grpc.NewServer()
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
go func() { _ = server.Serve(listener) }()
defer server.Stop()
conn, err := grpc.NewClient("passthrough:///buf", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
client := pb.NewNotificationsServiceClient(conn)
req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
ctx := context.Background()
result, err := client.Send(ctx, req)
if err != nil {
t.Fatal(err)
}
if result.Notification.UserId != "u" || strings.Contains(result.String(), "sensitive") || strings.Contains(result.String(), "user@example.com") {
t.Fatal(result)
}
req.Targets[0].Destination = &pb.Target_Telegram{Telegram: &pb.TelegramTarget{ChatId: "123"}}
if _, err = client.Send(ctx, req); status.Code(err) != codes.Unimplemented {
t.Fatal(err)
}
if _, err = client.Get(ctx, &pb.GetRequest{Id: "id"}); status.Code(err) != codes.NotFound {
t.Fatal(err)
}
}
func TestCancellationStatus(t *testing.T) {
if status.Code(rpcError(merrors.OpaqueWrap(merrors.ErrServiceUnavailable, context.Canceled, "storage"))) != codes.Canceled {
t.Fatal("cancellation lost at transport boundary")
}
if status.Code(rpcError(context.DeadlineExceeded)) != codes.DeadlineExceeded {
t.Fatal("deadline lost at transport boundary")
}
}
+52
View File
@@ -0,0 +1,52 @@
# SXCHNGE product BFF
This Go module is the customer-facing executable. `main.go` composes the copied
runtime and local customer service. Customers, auth, sessions and verification
own their models and supporting code; generic infrastructure comes from the
sibling `shared` library module.
## Run
Requires Go 1.27 and an SXCHNGE-owned MongoDB replica set for transactions.
Copy `config.example.yml` to an untracked `config.yml` in this directory. Set:
- `SXCHNGE_MONGO_URI`: URI for SXCHNGE's database.
- `SXCHNGE_JWT_SECRET`: random signing key, at least 32 bytes for HS256.
- `SXCHNGE_DOMAIN`: local hostname, such as `localhost:8082`.
- `SXCHNGE_API_PROTOCOL`: `http` locally, `https` in deployment.
- `SXCHNGE_API_ENDPOINT`: `/api/v1`.
From the repository root, run `make run-backend`, or run `go run .` here.
Flags: `-config.file`, `-version`, `-debug`. Configure exact browser origins in
YAML; browser refresh cookies require HTTPS. An empty local database needs no
organization, role, policy or seed; the runtime creates its indexes.
The runtime mounts health, login, API login, refresh, rotation and logout routes.
Customer/application-client records must exist for login. The customer service
supports atomic account/activation creation and deletion with session revocation.
Signup and email verification delivery/completion are available when
`api.notifications` is configured. Flutter selects the backend using
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
[auth notification setup and contracts](../../docs/auth-notifications.md).
API versions are installed explicitly by the product composition. See
[version installation and compatibility](../../docs/api-versions.md) for selection,
mounts, shared lifecycle and adding a future version. Customer profile endpoint
wiring remains follow-up work.
Optional `api.sendico` connects sign-up to the public individual registry and
immutable Facts intake. The adapter owns organization context and consumed wire
contracts; local customers retain no platform tenancy. See
[configuration, recovery and measurement](../../docs/signup-facts.md).
## Validate
`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker
for disposable Mongo replica sets. Root `make test` and `make lint-backend` also
validate `shared`. See [source provenance](../PROVENANCE.md).
## Customer quotations and payments
With `api.sendico`, runtime composes the customer-scoped payment service and
public HTTP provider adapter. Flutter backend mode selects this path automatically;
missing provider configuration never returns demo money. See
[contracts, provisioning, authorization and rollout](../../docs/payments.md).
+41
View File
@@ -0,0 +1,41 @@
package api
import (
"git.sendico.io/tech/sxchnge/product/app/urls"
"git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/flow"
"git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/payments"
"git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
)
// Dependencies are initialized once and shared by installed transports.
type Dependencies struct {
Stores *storage.Stores
Customers accountservice.AccountService
Auth *flow.Service
Factors *twofactor.Service
Identity *identity.Service
Payments *payments.Service
Token middleware.TokenConfig
Signature middleware.Signature
LoginLimits middleware.LoginRateLimits
}
// Scope belongs to one installation. Router paths are relative to Mount.
type Scope struct {
Router chi.Router
Mount string
URLs domainprovider.DomainProvider
Logger mlogger.Logger
}
type Installer func(Scope, *Dependencies) error
type Mount struct {
Version string `yaml:"version"`
Path string `yaml:"path"`
}
+22
View File
@@ -0,0 +1,22 @@
package api
import (
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
)
type Config struct {
Sendico *sendico.Config `yaml:"sendico"`
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
Versions []Mount `yaml:"versions"`
Notifications *notifications.Config `yaml:"notifications"`
Mw *mwa.Config `yaml:"middleware"`
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
Verification *verificationconfig.Config `yaml:"verification"`
}
func (c *Config) SharedConfig() *Config {
return c
}
+35
View File
@@ -0,0 +1,35 @@
package api
import (
"context"
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/logging"
)
type ConfigProvider interface{ SharedConfig() *Config }
type ConfigFactory func() ConfigProvider
// Bootstrap opens process-owned resources. Its cleanup is called once even if
// installation fails. On error, bootstrap must return cleanup or clean its own
// resources before returning, never both.
type Bootstrap func(context.Context, mlogger.Logger, *Config, db.Factory) (*Dependencies, func(context.Context) error, error)
type RuntimeConfig struct {
Name string
ConfigFactory ConfigFactory
Bootstrap Bootstrap
Versions map[string]Installer
}
func (cfg RuntimeConfig) RuntimeName() string {
if cfg.Name == "" {
return "product"
}
return cfg.Name
}
func (cfg RuntimeConfig) NewConfig() ConfigProvider {
if cfg.ConfigFactory != nil {
return cfg.ConfigFactory()
}
return &Config{}
}
+30
View File
@@ -0,0 +1,30 @@
// Package v1 composes the product's first public HTTP contract.
package v1
import (
"git.sendico.io/tech/sxchnge/product/app/api"
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
paymenthttp "git.sendico.io/tech/sxchnge/product/payments/http/v1"
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
"git.sendico.io/tech/sxchnge/shared/errors"
)
func Install(scope api.Scope, deps *api.Dependencies) error {
if deps == nil || deps.Stores == nil {
return merrors.InvalidArgument("v1 auth dependencies are required")
}
flows := public.Flows{Onboarding: deps.Auth}
if deps.Factors != nil {
flows.Factors = deps.Factors
}
// Signature is resolved at bootstrap, not by each version from the environment.
routes := auth.Install(scope.Logger, scope.Router, "", deps.Stores.Accounts, deps.Stores.RefreshTokens, deps.Stores.LoginAttempts, &deps.Token, deps.Signature, deps.LoginLimits, flows)
if deps.Factors != nil {
factors.Install(routes, deps.Factors, scope.Logger)
}
identity.Install(routes, deps.Identity, scope.Logger)
paymenthttp.Install(routes, deps.Payments, scope.Logger)
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package routers
import (
"git.sendico.io/tech/sxchnge/product/app/health/internal"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
)
type Health interface {
SetStatus(status health.ServiceStatus)
SetStatusWithReason(status health.ServiceStatus, reason string)
Finish()
}
func NewHealthRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Health, error) {
return healthimp.NewRouter(logger, router, endpoint), nil
}
@@ -0,0 +1,76 @@
package healthimp
import (
"net/http"
"strings"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
type Router struct {
logger mlogger.Logger
status *Status
}
func (hr *Router) SetStatus(status health.ServiceStatus) {
hr.SetStatusWithReason(status, defaultReason(status))
}
func (hr *Router) SetStatusWithReason(status health.ServiceStatus, reason string) {
previous, changed := hr.status.transition(status)
if !changed {
return
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = defaultReason(status)
}
hr.logger.Info("Health status changed",
zap.String("previous_status", string(previous)),
zap.String("status", string(status)),
zap.String("reason", reason),
)
}
func (hr *Router) Finish() {
hr.status.Finish()
hr.logger.Debug("Stopped")
}
func (hr *Router) handle(w http.ResponseWriter, r *http.Request) {
hr.status.healthHandler()(w, r)
}
func NewRouter(logger mlogger.Logger, router chi.Router, endpoint string) *Router {
hr := Router{
logger: logger.Named("health_check"),
}
hr.status = StatusHandler(hr.logger)
logger.Debug("Installing healthcheck middleware...")
router.Group(func(r chi.Router) {
ep := endpoint + "/health"
r.Get(ep, hr.handle)
logger.Info("Health handler installed", zap.String("endpoint", ep))
})
return &hr
}
func defaultReason(status health.ServiceStatus) string {
switch status {
case health.SSCreated:
return "service created"
case health.SSStarting:
return "service is starting"
case health.SSRunning:
return "service is ready"
case health.SSTerminating:
return "service is shutting down"
default:
return "service health status changed"
}
}
@@ -0,0 +1,53 @@
package healthimp
import (
"testing"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestSetStatusLogsOnlyTransitionsWithReason(t *testing.T) {
core, observed := observer.New(zap.InfoLevel)
router := NewRouter(zap.New(core), chi.NewRouter(), "")
router.SetStatusWithReason(health.SSStarting, "observability endpoint initialized")
router.SetStatusWithReason(health.SSStarting, "duplicate readiness report")
router.SetStatusWithReason(health.SSRunning, "all configured ingestion drivers are ready")
router.SetStatus(health.SSRunning)
entries := observed.FilterMessage("Health status changed").All()
if len(entries) != 2 {
t.Fatalf("health transition log count = %d, want 2", len(entries))
}
assertTransition(t, entries[0].ContextMap(), "created", "starting", "observability endpoint initialized")
assertTransition(t, entries[1].ContextMap(), "starting", "ok", "all configured ingestion drivers are ready")
}
func TestSetStatusUsesNonEmptyDefaultReason(t *testing.T) {
core, observed := observer.New(zap.InfoLevel)
router := NewRouter(zap.New(core), chi.NewRouter(), "")
router.SetStatusWithReason(health.SSTerminating, " ")
entries := observed.FilterMessage("Health status changed").All()
if len(entries) != 1 {
t.Fatalf("health transition log count = %d, want 1", len(entries))
}
assertTransition(t, entries[0].ContextMap(), "created", "deactivating", "service is shutting down")
}
func assertTransition(t *testing.T, fields map[string]any, previous, status, reason string) {
t.Helper()
for key, want := range map[string]string{
"previous_status": previous,
"status": status,
"reason": reason,
} {
if got, ok := fields[key].(string); !ok || got != want {
t.Fatalf("%s = %v, want %q", key, fields[key], want)
}
}
}
@@ -0,0 +1,50 @@
package healthimp
import (
"net/http"
"sync"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging"
)
type Status struct {
logger mlogger.Logger
mu sync.RWMutex
status health.ServiceStatus
}
func (hs *Status) healthHandler() http.HandlerFunc {
hs.mu.RLock()
status := hs.status
hs.mu.RUnlock()
return response.Ok(hs.logger, struct {
Status health.ServiceStatus `json:"status"`
}{
status,
})
}
func (hr *Status) Finish() {
hr.logger.Info("Finished")
}
func (hs *Status) transition(status health.ServiceStatus) (health.ServiceStatus, bool) {
hs.mu.Lock()
defer hs.mu.Unlock()
previous := hs.status
if previous == status {
return previous, false
}
hs.status = status
return previous, true
}
func StatusHandler(logger mlogger.Logger) *Status {
hs := Status{
status: health.SSCreated,
logger: logger.Named("status"),
}
return &hs
}
@@ -0,0 +1,10 @@
package health
type ServiceStatus string
const (
SSCreated ServiceStatus = "created"
SSStarting ServiceStatus = "starting"
SSRunning ServiceStatus = "ok"
SSTerminating ServiceStatus = "deactivating"
)
+103
View File
@@ -0,0 +1,103 @@
package httptransport
import (
"net/http"
"path"
mr "git.sendico.io/tech/sxchnge/product/app/metrics"
"git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
cm "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/metrics"
"go.uber.org/zap"
"moul.io/chizap"
)
type Middleware struct {
logger mlogger.Logger
router *chi.Mux
apiEndpoint string
metrics mr.Metrics
}
func (mw *Middleware) Finish() {
if mw.metrics != nil {
mw.metrics.Finish()
}
}
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
mw.logger.Debug("Installing middleware stack...")
// Collect metrics for application traffic only. Scrapes and health probes are
// operational traffic and would otherwise distort request and in-flight SLIs.
mw.router.Use(metrics.Collector(metrics.CollectorOpts{
Host: false, // avoid high-cardinality "host" label
Proto: true, // include HTTP protocol label
Skip: func(r *http.Request) bool {
return isObservabilityRequest(mw.apiEndpoint, r.URL.Path)
},
}))
mw.router.Use(cm.RequestID)
if debug {
mw.router.Use(chizap.New(mw.logger.Named("http_trace"), &chizap.Opts{
WithReferer: true,
WithUserAgent: true,
}))
}
mw.router.Use(cors.Handler(cors.Options{
AllowedOrigins: config.CORS.AllowedOrigins,
AllowedMethods: config.CORS.AllowedMethods,
AllowedHeaders: config.CORS.AllowedHeaders,
ExposedHeaders: config.CORS.ExposedHeaders,
AllowCredentials: config.CORS.AllowCredentials,
MaxAge: config.CORS.MaxAge,
OptionsPassthrough: false,
Debug: debug,
}))
mw.router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := browser.ValidateCSRF(r, config.CORS.AllowedOrigins); err != nil {
mw.logger.Info("Rejected browser request with invalid CSRF context",
zap.String("method", r.Method), zap.String("path", r.URL.Path))
response.Forbidden(mw.logger, mservice.Accounts, "csrf_rejected", "browser request failed CSRF validation").ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
})
mw.router.Use(cm.Recoverer)
mw.router.Handle("/metrics", metrics.Handler())
mw.logger.Info("Middleware stack installation complete")
}
func isObservabilityRequest(apiEndpoint, requestPath string) bool {
healthPath := path.Join("/", apiEndpoint, "health")
metricsPath := path.Join("/", apiEndpoint, "metrics")
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
}
func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middleware.Config, endpoint string, debug bool) (*Middleware, error) {
if config == nil {
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
}
if err := config.Token.Validate(); err != nil {
return nil, err
}
if err := config.CORS.Validate(); err != nil {
return nil, err
}
p := &Middleware{logger: logger.Named("middleware"), router: apiRouter, apiEndpoint: endpoint}
p.installMiddleware(config, debug)
var err error
p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint)
if err != nil {
return nil, err
}
return p, nil
}
+155
View File
@@ -0,0 +1,155 @@
package httptransport
import (
"context"
"net/http"
"net/http/httptest"
"testing"
amr "git.sendico.io/tech/sxchnge/product/app/health"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"github.com/go-chi/chi/v5"
chimetrics "github.com/go-chi/metrics"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"moul.io/chizap"
)
func TestHTTPCollectorDoesNotGatherHealthOrMetricsRequests(t *testing.T) {
router := chi.NewRouter()
router.Use(chimetrics.Collector(chimetrics.CollectorOpts{
Proto: true,
Skip: func(r *http.Request) bool {
return isObservabilityRequest("/api/v1", r.URL.Path)
},
}))
router.Get("/api/v1/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
router.Handle("/metrics", chimetrics.Handler())
router.Handle("/api/v1/metrics", chimetrics.Handler())
router.Get("/api/v1/accounts", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
beforeHealth := gatheredHTTPRequestCount(t, "GET /api/v1/health")
beforeMetrics := gatheredHTTPRequestCount(t, "GET /metrics")
beforeAPIMetrics := gatheredHTTPRequestCount(t, "GET /api/v1/metrics")
beforeApplication := gatheredHTTPRequestCount(t, "GET /api/v1/accounts")
for _, requestPath := range []string{"/api/v1/health", "/metrics", "/api/v1/metrics", "/api/v1/accounts"} {
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, requestPath, nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("GET %s returned %d", requestPath, response.Code)
}
}
if got := gatheredHTTPRequestCount(t, "GET /api/v1/health") - beforeHealth; got != 0 {
t.Fatalf("health probe changed http_requests_total by %v", got)
}
if got := gatheredHTTPRequestCount(t, "GET /metrics") - beforeMetrics; got != 0 {
t.Fatalf("metrics scrape changed http_requests_total by %v", got)
}
if got := gatheredHTTPRequestCount(t, "GET /api/v1/metrics") - beforeAPIMetrics; got != 0 {
t.Fatalf("API metrics scrape changed http_requests_total by %v", got)
}
if got := gatheredHTTPRequestCount(t, "GET /api/v1/accounts") - beforeApplication; got != 1 {
t.Fatalf("application request changed http_requests_total by %v, want 1", got)
}
}
func gatheredHTTPRequestCount(t *testing.T, endpoint string) float64 {
t.Helper()
families, err := prometheus.DefaultGatherer.Gather()
if err != nil {
t.Fatalf("gather metrics: %v", err)
}
for _, family := range families {
if family.GetName() != "http_requests_total" {
continue
}
var total float64
for _, metric := range family.GetMetric() {
for _, label := range metric.GetLabel() {
if label.GetName() == "endpoint" && label.GetValue() == endpoint {
total += metric.GetCounter().GetValue()
}
}
}
return total
}
return 0
}
func TestObservabilityRequestsAreExcludedFromHTTPMetrics(t *testing.T) {
tests := []struct {
name string
apiEndpoint string
requestPath string
want bool
}{
{name: "health under API endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/health", want: true},
{name: "metrics at root", apiEndpoint: "/api/v1", requestPath: "/metrics", want: true},
{name: "metrics under API endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/metrics", want: true},
{name: "trailing endpoint slash", apiEndpoint: "/api/v1/", requestPath: "/api/v1/health", want: true},
{name: "application endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/accounts", want: false},
{name: "health suffix is not a probe", apiEndpoint: "/api/v1", requestPath: "/api/v1/health/history", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isObservabilityRequest(tt.apiEndpoint, tt.requestPath); got != tt.want {
t.Fatalf("isObservabilityRequest(%q, %q) = %t, want %t", tt.apiEndpoint, tt.requestPath, got, tt.want)
}
})
}
}
func TestHealthMountedOutsideTraceMiddleware(t *testing.T) {
core, observed := observer.New(zapcore.InfoLevel)
logger := zap.New(core)
rootRouter := chi.NewRouter()
apiRouter := chi.NewRouter()
apiRouter.Use(chizap.New(logger.Named("middleware").Named("http_trace"), &chizap.Opts{}))
apiRouter.Get("/api/v1/accounts", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
healthRouter, err := amr.NewHealthRouter(logger.Named("middleware"), rootRouter, "/api/v1")
if err != nil {
t.Fatalf("failed to create health router: %v", err)
}
healthRouter.SetStatus(health.SSRunning)
rootRouter.Mount("/", apiRouter)
healthReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/health", nil)
healthRes := httptest.NewRecorder()
rootRouter.ServeHTTP(healthRes, healthReq)
if healthRes.Code != http.StatusOK {
t.Fatalf("expected health status %d, got %d", http.StatusOK, healthRes.Code)
}
if got := observed.FilterMessage("Served").Len(); got != 0 {
t.Fatalf("expected no trace logs for /health, got %d", got)
}
apiReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/accounts", nil)
apiRes := httptest.NewRecorder()
rootRouter.ServeHTTP(apiRes, apiReq)
if apiRes.Code != http.StatusOK {
t.Fatalf("expected api status %d, got %d", http.StatusOK, apiRes.Code)
}
entries := observed.FilterMessage("Served").All()
if len(entries) != 1 {
t.Fatalf("expected one trace log for non-health request, got %d", len(entries))
}
if got := entries[0].ContextMap()["path"]; got != "/api/v1/accounts" {
t.Fatalf("expected logged path %q, got %#v", "/api/v1/accounts", got)
}
}
+40
View File
@@ -0,0 +1,40 @@
package metrics
import (
"net/http"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
"github.com/go-chi/metrics"
"go.uber.org/zap"
)
type metricsRouter struct {
logger mlogger.Logger
handler http.Handler
}
func (mr *metricsRouter) Finish() {
mr.logger.Debug("Stopped")
}
func (mr *metricsRouter) handle(w http.ResponseWriter, r *http.Request) {
mr.logger.Debug("Serving metrics request...")
mr.handler.ServeHTTP(w, r)
}
func newMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) *metricsRouter {
mr := metricsRouter{
logger: logger.Named("metrics"),
handler: metrics.Handler(),
}
logger.Debug("Installing Prometheus middleware...")
router.Group(func(r chi.Router) {
ep := endpoint + "/metrics"
r.Get(ep, mr.handle)
logger.Info("Prometheus handler installed", zap.String("endpoint", ep))
})
return &mr
}
+14
View File
@@ -0,0 +1,14 @@
package metrics
import (
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
)
type Metrics interface {
Finish()
}
func NewMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Metrics, error) {
return newMetricsRouter(logger, router, endpoint), nil
}
+5
View File
@@ -0,0 +1,5 @@
package server
import "git.sendico.io/tech/sxchnge/shared/logging"
type ServerFactoryT = func(logger mlogger.Logger, file string, debug bool) (Application, error)
@@ -0,0 +1,40 @@
package serverimp
import (
"git.sendico.io/tech/sxchnge/product/app/process"
"git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap"
)
type Instance struct {
srv server.Application
logger mlogger.Logger
file string
debug bool
factory server.ServerFactoryT
}
func (i *Instance) Start() error {
var err error
if i.srv, err = i.factory(i.logger, i.file, i.debug); err != nil {
i.logger.Warn("Failed to create server instance", zap.Error(err))
return err
}
return i.srv.Start()
}
func (i *Instance) Shutdown() {
if i.srv != nil {
i.srv.Shutdown()
}
}
func NewInstance(factory server.ServerFactoryT, logger mlogger.Logger, file string, debug bool) *Instance {
return &Instance{
srv: nil,
logger: logger,
file: file,
debug: debug,
factory: factory,
}
}
@@ -0,0 +1,66 @@
package serverimp
import (
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"git.sendico.io/tech/sxchnge/product/app/process"
"git.sendico.io/tech/sxchnge/shared/logging"
lf "git.sendico.io/tech/sxchnge/shared/logging/factory"
"git.sendico.io/tech/sxchnge/shared/version"
"github.com/google/uuid"
"go.uber.org/zap"
)
var (
configFileFlag = flag.String("config.file", "config.yml", "Path to the configuration file.")
versionFlag = flag.Bool("version", false, "Show version information.")
debugFlag = flag.Bool("debug", false, "Show debug information.")
)
func prepareLogger() mlogger.Logger {
flag.Parse()
return lf.NewLogger(*debugFlag)
}
func RunServer(rootLoggerName string, av version.Printer, factory server.ServerFactoryT) error {
logger := mlogger.Named(prepareLogger(), rootLoggerName)
logger = logger.With(zap.String("instance_id", uuid.NewString()))
defer func() {
_ = logger.Sync()
}()
// Show version information
if *versionFlag {
if _, err := fmt.Fprintln(os.Stdout, av.Print()); err != nil {
logger.Warn("Failed to print version", zap.Error(err))
}
return nil
}
// Create server instance
instance := NewInstance(factory, logger, *configFileFlag, *debugFlag)
// Interrupt handler
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
sig := <-c
logger.Info("Received sigint/segterm signal, shutting down", zap.String("signal", sig.String()))
instance.Shutdown()
}()
// Start server
logger.Info(fmt.Sprintf("Starting %s", av.Program()), zap.String("version", av.Info()))
logger.Info("Build context", zap.String("context", av.Context()))
if err := instance.Start(); err != nil {
logger.Error("Failed to start service", zap.Error(err))
return err
}
logger.Info("Server stopped")
return nil
}
+11
View File
@@ -0,0 +1,11 @@
package server
import (
sd "git.sendico.io/tech/sxchnge/product/app/process"
serverimp "git.sendico.io/tech/sxchnge/product/app/process/internal"
"git.sendico.io/tech/sxchnge/shared/version"
)
func RunServer(rootLoggerName string, av version.Printer, factory sd.ServerFactoryT) error {
return serverimp.RunServer(rootLoggerName, av, factory)
}
+7
View File
@@ -0,0 +1,7 @@
package server
// Typical service interface
type Application interface {
Shutdown()
Start() error
}
+12
View File
@@ -0,0 +1,12 @@
// Package profile selects the product's supported API implementations.
package profile
import (
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/api/v1"
"git.sendico.io/tech/sxchnge/product/app/runtime"
)
func Runtime() api.RuntimeConfig {
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: runtime.OpenDependencies, Versions: map[string]api.Installer{"v1": v1.Install}}
}
+21
View File
@@ -0,0 +1,21 @@
package runtime
import (
"context"
"sync"
amr "git.sendico.io/tech/sxchnge/product/app/health"
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
"git.sendico.io/tech/sxchnge/shared/logging"
)
type instance struct {
logger mlogger.Logger
health amr.Health
mw *httptransport.Middleware
cleanup func(context.Context) error
once sync.Once
finishErr error
}
func (a *instance) Name() string { return "api" }
+85
View File
@@ -0,0 +1,85 @@
package runtime
import (
"context"
"os"
"git.sendico.io/tech/sxchnge/product/app/api"
amr "git.sendico.io/tech/sxchnge/product/app/health"
"git.sendico.io/tech/sxchnge/product/app/health/status"
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
"git.sendico.io/tech/sxchnge/product/app/urls"
"git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
func CreateAPI(
ctx context.Context,
logger mlogger.Logger,
config *api.Config,
db db.Factory,
rootRouter, apiRouter *chi.Mux,
debug bool,
runtime api.RuntimeConfig,
) (_ mservice.MicroService, result error) {
if config == nil || config.Mw == nil {
return nil, merrors.InvalidArgument("API middleware configuration is required")
}
endpoint := os.Getenv(config.Mw.EndPointEnv)
mounts, err := resolveMounts(config.Versions, endpoint, runtime.Versions)
if err != nil {
return nil, err
}
if runtime.Bootstrap == nil {
return nil, merrors.InvalidArgument("runtime bootstrap is required")
}
// Resolve every URL before opening resources or publishing routes.
scopes := make([]api.Scope, len(mounts))
for i, mount := range mounts {
urls, err := domainprovider.CreateDomainProvider(logger, os.Getenv(config.Mw.DomainEnv), os.Getenv(config.Mw.APIProtocolEnv), mount.Path)
if err != nil {
return nil, err
}
scopes[i] = api.Scope{Router: chi.NewRouter(), Mount: mount.Path, URLs: urls, Logger: logger.With(zap.String("api_version", mount.Version), zap.String("mount", mount.Path))}
scopes[i].Router.Use(browser.Scope(mount.Path))
}
a := &instance{logger: logger.Named("api").With(zap.String("runtime", runtime.RuntimeName()))}
defer func() {
if result != nil {
if err := a.Finish(context.Background()); err != nil {
a.logger.Warn("Failed startup resource cleanup", zap.Error(err))
}
}
}()
a.mw, err = httptransport.CreateMiddleware(logger, apiRouter, config.Mw, endpoint, debug)
if err != nil {
return nil, err
}
deps, cleanup, err := runtime.Bootstrap(ctx, a.logger, config, db)
a.cleanup = cleanup
if err != nil {
return nil, err
}
for i, mount := range mounts {
if err := runtime.Versions[mount.Version](scopes[i], deps); err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrInternal, err, "API version installation failed: "+mount.Version)
}
}
// Attach only after every installer succeeds.
for i, mount := range mounts {
apiRouter.Mount(mount.Path, scopes[i].Router)
scopes[i].Logger.Info("API version installed")
}
a.health, err = amr.NewHealthRouter(a.logger, rootRouter, endpoint)
if err != nil {
return nil, err
}
a.logger.Info("API installation complete", zap.Int("versions", len(mounts)))
a.health.SetStatus(health.SSRunning)
return a, nil
}
@@ -0,0 +1,86 @@
package runtime_test
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/profile"
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/shared/database"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
endpoint, err := url.Parse(uri)
require.NoError(t, err)
query := endpoint.Query()
query.Set("directConnection", "true")
endpoint.RawQuery = query.Encode()
uri = endpoint.String()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": uri, "database": "sxchnge_runtime"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
data, err := os.ReadFile("../../config.example.yml")
require.NoError(t, err)
var example struct {
API api.Config `yaml:"api"`
}
require.NoError(t, yaml.Unmarshal(data, &example))
t.Setenv("SXCHNGE_DOMAIN", "localhost")
t.Setenv("SXCHNGE_API_PROTOCOL", "http")
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
root, routes := chi.NewRouter(), chi.NewRouter()
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, factory, root, routes, false, profile.Runtime())
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
root.Mount("/", routes)
result := httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
require.Equal(t, http.StatusOK, result.Code)
// Runtime auth routes are installed even with zero customers and no seed.
result = httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
require.Equal(t, http.StatusBadRequest, result.Code)
// Enabling the adapter validates credentials, composes once, and does not
// register any provider resource until a customer starts sign-up.
const source = "507f1f77bcf86cd799439011"
claims, err := json.Marshal(map[string]any{"id": source, "exp": time.Now().Add(time.Hour).Unix()})
require.NoError(t, err)
file := filepath.Join(t.TempDir(), "credential")
require.NoError(t, os.WriteFile(file, []byte("header."+base64.RawURLEncoding.EncodeToString(claims)+".signature"), 0600))
example.API.Sendico = &sendico.Config{APIURL: "https://api.example/api/v1", OrganizationID: source, SourceID: source, TokenFile: file}
dependencies, finish, err := sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.NoError(t, err)
require.NotNil(t, dependencies.Auth.Facts)
require.NoError(t, finish(ctx))
example.API.Sendico.TokenFile = ""
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.Error(t, err)
example.API.Notifications = nil
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.Error(t, err)
}
+23
View File
@@ -0,0 +1,23 @@
package runtime
import (
"context"
"git.sendico.io/tech/sxchnge/product/app/health/status"
)
func (a *instance) Finish(ctx context.Context) error {
a.once.Do(func() {
if a.health != nil {
a.health.SetStatus(health.SSTerminating)
a.health.Finish()
}
if a.cleanup != nil {
a.finishErr = a.cleanup(ctx)
}
if a.mw != nil {
a.mw.Finish()
}
})
return a.finishErr
}
+58
View File
@@ -0,0 +1,58 @@
package runtime
import (
"path"
"strings"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/shared/errors"
)
func validMount(value string) bool {
if value == "" || value == "/" || !strings.HasPrefix(value, "/") || path.Clean(value) != value {
return false
}
for _, c := range value {
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("/-._~", c) {
continue
}
return false
}
return true
}
func overlaps(a, b string) bool {
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
}
func resolveMounts(configured []api.Mount, endpoint string, supported map[string]api.Installer) ([]api.Mount, error) {
if !validMount(endpoint) {
return nil, merrors.InvalidArgument("a canonical absolute API endpoint is required", "api_endpoint")
}
if configured == nil {
configured = []api.Mount{{Version: "v1", Path: endpoint}}
}
seen := map[string]bool{}
for i, mount := range configured {
if supported[mount.Version] == nil {
return nil, merrors.InvalidArgument("unknown or unavailable API version: "+mount.Version, "api.versions")
}
if seen[mount.Version] {
return nil, merrors.InvalidArgument("duplicate API version: "+mount.Version, "api.versions")
}
seen[mount.Version] = true
if !validMount(mount.Path) {
return nil, merrors.InvalidArgument("invalid API mount: "+mount.Path, "api.versions")
}
for _, reserved := range []string{"/metrics", endpoint + "/metrics", endpoint + "/health"} {
if mount.Path == reserved || strings.HasPrefix(mount.Path, reserved+"/") {
return nil, merrors.InvalidArgument("API mount conflicts with operational endpoint", "api.versions")
}
}
for _, previous := range configured[:i] {
if overlaps(mount.Path, previous.Path) {
return nil, merrors.InvalidArgument("overlapping API mounts", "api.versions")
}
}
}
return configured, nil
}
+190
View File
@@ -0,0 +1,190 @@
package runtime
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
func testConfig(t *testing.T) *api.Config {
t.Helper()
data, err := os.ReadFile("../../config.example.yml")
require.NoError(t, err)
var raw struct {
API api.Config `yaml:"api"`
}
require.NoError(t, yaml.Unmarshal(data, &raw))
t.Setenv(raw.API.Mw.EndPointEnv, "/api/v1")
t.Setenv(raw.API.Mw.DomainEnv, "example.test")
t.Setenv(raw.API.Mw.APIProtocolEnv, "https")
return &raw.API
}
func TestInstalledVersionsShareDependenciesAndKeepScopes(t *testing.T) {
config := testConfig(t)
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/gateway/v2"}}
initialized, closed := 0, 0
deps := &api.Dependencies{}
installer := func(version string) api.Installer {
return func(scope api.Scope, got *api.Dependencies) error {
require.Same(t, deps, got)
link, err := scope.URLs.GetAPILink("accounts")
require.NoError(t, err)
require.Equal(t, "https://example.test"+scope.Mount+"/accounts", link)
scope.Router.Get("/contract", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(version)) })
scope.Router.Post("/accounts/session", func(w http.ResponseWriter, r *http.Request) {
browser.SetRefreshCookie(w, r, "credential", time.Now().Add(time.Hour))
})
scope.Router.Delete("/accounts/session", browser.ClearRefreshCookie)
return nil
}
}
runtime := api.RuntimeConfig{Versions: map[string]api.Installer{"v1": installer("old"), "v2": installer("new"), "v3": installer("disabled")}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
initialized++
return deps, func(context.Context) error { closed++; return nil }, nil
}}
root, routes := chi.NewRouter(), chi.NewRouter()
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, runtime)
require.NoError(t, err)
root.Mount("/", routes)
for target, expected := range map[string]string{"/api/v1/contract": "old", "/gateway/v2/contract": "new"} {
result := httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
require.Equal(t, http.StatusOK, result.Code)
require.Equal(t, expected, result.Body.String())
}
t.Setenv("API_ENDPOINT", "/wrong/global")
for _, mount := range config.Versions {
for _, method := range []string{http.MethodPost, http.MethodDelete} {
result := httptest.NewRecorder()
request := httptest.NewRequest(method, mount.Path+"/accounts/session", nil)
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
request.Header.Set(browser.CSRFHeader, browser.CSRFValue)
request.Header.Set("Origin", config.Mw.CORS.AllowedOrigins[0])
root.ServeHTTP(result, request)
require.Equal(t, http.StatusOK, result.Code)
cookies := result.Result().Cookies()
require.Len(t, cookies, 1)
require.Equal(t, mount.Path+"/accounts", cookies[0].Path)
if method == http.MethodDelete {
require.Equal(t, -1, cookies[0].MaxAge)
}
}
}
result := httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v3/contract", nil))
require.Equal(t, 404, result.Code)
require.Equal(t, 1, initialized)
var wg sync.WaitGroup
results := make(chan error, 3)
for range 3 {
wg.Go(func() { results <- srv.Finish(context.Background()) })
}
wg.Wait()
close(results)
for err := range results {
require.NoError(t, err)
}
require.Equal(t, 1, closed)
}
func TestInvalidMountsFailBeforeBootstrap(t *testing.T) {
for name, mounts := range map[string][]api.Mount{
"unknown": {{Version: "v9", Path: "/api/v9"}}, "duplicate": {{Version: "v1", Path: "/api/v1"}, {Version: "v1", Path: "/other"}},
"same path": {{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v1"}},
"nested": {{Version: "v1", Path: "/api"}, {Version: "v2", Path: "/api/v2"}},
"cookie delimiter": {{Version: "v1", Path: "/api;v1"}},
"control": {{Version: "v1", Path: "/api/\x01"}},
"relative": {{Version: "v1", Path: "api/v1"}}, "wildcard": {{Version: "v1", Path: "/api/*"}},
"unclean": {{Version: "v1", Path: "/api/../v1"}}, "metrics": {{Version: "v1", Path: "/metrics"}},
"health": {{Version: "v1", Path: "/api/v1/health"}}, "root": {{Version: "v1", Path: "/"}},
} {
t.Run(name, func(t *testing.T) {
config := testConfig(t)
config.Versions = mounts
install := func(api.Scope, *api.Dependencies) error { t.Fatal("installer must not run"); return nil }
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Versions: map[string]api.Installer{"v1": install, "v2": install}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
t.Fatal("bootstrap must not run")
return nil, nil, nil
}})
require.ErrorIs(t, err, merrors.ErrInvalidArg)
})
}
}
func TestInstallFailureCleansResourcesWithoutPublishingRoutes(t *testing.T) {
config := testConfig(t)
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v2"}}
closed := 0
cause := merrors.InvalidArgument("test installation failure")
routes := chi.NewRouter()
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), routes, false, api.RuntimeConfig{
Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
return &api.Dependencies{}, func(context.Context) error { closed++; return nil }, nil
},
Versions: map[string]api.Installer{
"v1": func(scope api.Scope, _ *api.Dependencies) error {
scope.Router.Get("/ready", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
return nil
},
"v2": func(api.Scope, *api.Dependencies) error { return cause },
}})
require.True(t, errors.Is(err, cause))
require.Equal(t, 1, closed)
result := httptest.NewRecorder()
routes.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil))
require.Equal(t, 404, result.Code)
}
func TestExplicitEmptyVersionsInstallNoAPI(t *testing.T) {
config := testConfig(t)
config.Versions = []api.Mount{}
root, routes := chi.NewRouter(), chi.NewRouter()
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
return &api.Dependencies{}, nil, nil
}})
require.NoError(t, err)
defer srv.Finish(context.Background())
root.Mount("/", routes)
for target, status := range map[string]int{"/api/v1/accounts/login": 404, "/api/v1/health": 200, "/metrics": 200, "/api/v1/metrics": 200} {
result := httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
require.Equal(t, status, result.Code, target)
}
}
func TestBootstrapFailureClosesReturnedResources(t *testing.T) {
config := testConfig(t)
config.Versions = []api.Mount{}
closed := 0
cause := merrors.Internal("bootstrap failure")
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
return nil, func(context.Context) error { closed++; return nil }, cause
}})
require.ErrorIs(t, err, cause)
require.Equal(t, 1, closed)
}
func TestFinishPreservesCleanupErrorAndRunsOnce(t *testing.T) {
cause := merrors.Internal("cleanup failure")
closed := 0
srv := &instance{logger: zap.NewNop(), cleanup: func(context.Context) error { closed++; return cause }}
require.ErrorIs(t, srv.Finish(context.Background()), cause)
require.ErrorIs(t, srv.Finish(context.Background()), cause)
require.Equal(t, 1, closed)
}
+97
View File
@@ -0,0 +1,97 @@
package runtime
import (
"context"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/flow"
"git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/payments"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
"git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap"
)
func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Config, db db.Factory) (_ *api.Dependencies, _ func(context.Context) error, result error) {
limits, err := config.Mw.ResolveLoginRateLimits()
if err != nil {
return nil, nil, err
}
stores, err := storage.OpenStores(logger, db, config.AuthStorage)
if err != nil {
return nil, nil, err
}
customers, err := accountservice.FromStores(logger, &config.Mw.Password, stores, db.TransactionFactory())
if err != nil {
return nil, nil, err
}
deps := &api.Dependencies{Stores: stores, Customers: customers, Token: config.Mw.Token, Signature: middleware.SignatureConf(config.Mw), LoginLimits: limits}
var client *notifications.Client
cleanup := func(context.Context) error {
if client != nil {
return client.Close()
}
return nil
}
defer func() {
if result != nil {
if err := cleanup(context.Background()); err != nil {
logger.Warn("Partial bootstrap cleanup failed", zap.Error(err))
}
}
}()
if c := config.Notifications; c != nil {
deps.Auth = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: deps.Signature.PrivateKey, ActivationURL: c.ActivationURL}
if err := deps.Auth.Validate(); err != nil {
return nil, nil, err
}
client, err = notifications.Open(*c)
if err != nil {
return nil, nil, err
}
deps.Auth.Sender = client
if config.Sendico != nil {
provider, err := sendico.Open(*config.Sendico)
if err != nil {
return nil, nil, err
}
bindings, err := identity.OpenStore(db)
if err != nil {
return nil, nil, err
}
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
deps.Auth.Facts = deps.Identity
paymentStore, err := payments.OpenStore(db)
if err != nil {
return nil, nil, err
}
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
logger.Info("Sendico sign-up facts integration enabled")
}
records, err := factorstore.Open(logger, db)
if err != nil {
return nil, nil, err
}
deps.Factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), deps.Signature.PrivateKey, logger, delivery.Email{Sender: client})
if err != nil {
return nil, nil, err
}
}
if deps.Payments != nil {
deps.Payments.Factors = deps.Factors
}
if config.Sendico != nil && deps.Auth == nil {
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
}
logger.Info("Product dependencies initialized")
return deps, cleanup, nil
}
@@ -0,0 +1,66 @@
package server
import (
"errors"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/shared/errors"
"os"
"strings"
"testing"
)
func TestIncompleteConfigReturnsProjectError(t *testing.T) {
for _, data := range []string{"api: {}", "api: {middleware: {}}\ndatabase: {driver: mongodb}", "api: {middleware: {}}\ndatabase: {driver: mongodb}\nhttp_server: {}"} {
instance := &Imp{runtime: api.RuntimeConfig{}}
if err := instance.decodeConfig([]byte(data)); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected configuration error, got %v", err)
}
}
}
func TestExampleConfigMatchesCopiedRuntime(t *testing.T) {
data, err := os.ReadFile("../../../config.example.yml")
if err != nil {
t.Fatal(err)
}
instance := &Imp{runtime: api.RuntimeConfig{}}
if err := instance.decodeConfig(data); err != nil {
t.Fatal(err)
}
if err := instance.config.API.SharedConfig().Mw.Token.Validate(); err != nil {
t.Fatal(err)
}
if err := instance.config.API.SharedConfig().Mw.CORS.Validate(); err != nil {
t.Fatal(err)
}
}
func TestVersionSelectionPreservesOmittedAndEmptyLists(t *testing.T) {
data, err := os.ReadFile("../../../config.example.yml")
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, setting string
wantNil bool
wantLength int
}{
{name: "omitted", wantNil: true},
{name: "disabled", setting: " versions: []\n"},
{name: "explicit", setting: " versions: [{version: v1, path: /gateway/v1}]\n", wantLength: 1},
} {
t.Run(tc.name, func(t *testing.T) {
instance := &Imp{runtime: api.RuntimeConfig{}}
configured := strings.Replace(string(data), "api:\n", "api:\n"+tc.setting, 1)
if err := instance.decodeConfig([]byte(configured)); err != nil {
t.Fatal(err)
}
versions := instance.config.API.SharedConfig().Versions
if (versions == nil) != tc.wantNil || len(versions) != tc.wantLength {
t.Fatalf("unexpected versions: %#v", versions)
}
if tc.wantLength == 1 && (versions[0].Version != "v1" || versions[0].Path != "/gateway/v1") {
t.Fatalf("lost version configuration: %#v", versions)
}
})
}
}
@@ -0,0 +1,177 @@
package server
import (
"context"
"errors"
"net/http"
"os"
"time"
ac "git.sendico.io/tech/sxchnge/product/app/api"
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
"git.sendico.io/tech/sxchnge/shared/database"
mduration "git.sendico.io/tech/sxchnge/shared/duration"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type httpServerConf struct {
ListenAddress string `yaml:"listen_address"`
ReadHeaderTimeout int `yaml:"read_header_timeout"`
ShutdownTimeout int `yaml:"shutdown_timeout"`
}
// Config represents the server configuration
type Config struct {
API ac.ConfigProvider
DB *db.Config `yaml:"database"`
HTTPServer *httpServerConf `yaml:"http_server"`
}
// Instance represents an instance of the server
type Imp struct {
lifecycleCtx context.Context
lifecycleCancel context.CancelFunc
logger mlogger.Logger
api mservice.MicroService
config *Config
db db.Factory
httpServer *http.Server
runtime ac.RuntimeConfig
debug bool
file string
}
// Shutdown stops the server
func (i *Imp) Shutdown() {
if i.lifecycleCancel != nil {
i.lifecycleCancel()
}
if i.httpServer == nil {
return
}
// Shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), mduration.Param2Duration(i.config.HTTPServer.ShutdownTimeout, time.Second))
i.logger.Info("Shutting HTTP server down...")
if err := i.httpServer.Shutdown(ctx); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
i.logger.Warn("Failed to shutdown HTTP server gracefully", zap.Error(err))
cancel()
os.Exit(1)
}
}
cancel()
}
func (i *Imp) Run() error {
err := i.httpServer.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
i.logger.Info("HTTP Server stopped")
if err := i.api.Finish(context.Background()); err != nil {
i.logger.Warn("Error when finishing service", zap.Error(err))
}
i.db.CloseConnection()
if err != nil {
return merrors.OpaqueWrap(merrors.ErrInternal, err, "HTTP server stopped unexpectedly")
}
return nil
}
// Start starts the server
func (i *Imp) Start() error {
if i.lifecycleCancel != nil {
defer i.lifecycleCancel()
}
i.logger.Info("Starting...", zap.String("config_file", i.file), zap.Bool("debug_mode", i.debug))
// Load configuration file
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not load configuration", zap.Error(err), zap.String("config_file", i.file))
return err
}
if err = i.decodeConfig(data); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return err
}
if i.db, err = db.NewConnection(i.logger, i.config.DB); err != nil {
i.logger.Error("Could not open database connection", zap.Error(err))
return err
}
rootRouter := chi.NewRouter()
apiRouter := chi.NewRouter()
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
i.db.CloseConnection()
return err
}
rootRouter.Mount("/", apiRouter)
// Startup the HTTP Server in a way that we can gracefully shut it down again
i.httpServer = &http.Server{
Addr: i.config.HTTPServer.ListenAddress,
Handler: rootRouter,
ReadHeaderTimeout: mduration.Param2Duration(i.config.HTTPServer.ReadHeaderTimeout, time.Second),
}
return i.Run()
}
func (i *Imp) decodeConfig(data []byte) error {
var raw struct {
API yaml.Node `yaml:"api"`
DB *db.Config `yaml:"database"`
HTTPServer *httpServerConf `yaml:"http_server"`
}
if err := yaml.Unmarshal(data, &raw); err != nil {
return err
}
apiConfig := i.runtime.NewConfig()
if apiConfig == nil {
return merrors.Internal("runtime config factory returned nil api config")
}
if raw.API.Kind == 0 {
return merrors.InvalidArgument("api configuration is required", "api")
}
if err := raw.API.Decode(apiConfig); err != nil {
return err
}
if raw.DB == nil || raw.HTTPServer == nil || apiConfig.SharedConfig() == nil || apiConfig.SharedConfig().Mw == nil {
return merrors.InvalidArgument("database, http_server and api.middleware configuration are required")
}
if raw.HTTPServer.ListenAddress == "" || raw.HTTPServer.ReadHeaderTimeout <= 0 || raw.HTTPServer.ShutdownTimeout <= 0 {
return merrors.InvalidArgument("HTTP address and positive timeouts are required")
}
i.config = &Config{
API: apiConfig,
DB: raw.DB,
HTTPServer: raw.HTTPServer,
}
return nil
}
func Create(logger mlogger.Logger, file string, debug bool, runtime ac.RuntimeConfig) (*Imp, error) {
lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background())
srv := &Imp{
lifecycleCtx: lifecycleCtx,
lifecycleCancel: lifecycleCancel,
logger: logger,
debug: debug,
file: file,
runtime: runtime,
}
return srv, nil
}
@@ -0,0 +1,22 @@
package server
import (
"context"
"errors"
"testing"
"git.sendico.io/tech/sxchnge/product/app/api"
"go.uber.org/zap"
)
func TestShutdownCancelsLifecycleContextBeforeStartupCompletes(t *testing.T) {
instance, err := Create(zap.NewNop(), "config.yml", false, api.RuntimeConfig{})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
instance.Shutdown()
if !errors.Is(instance.lifecycleCtx.Err(), context.Canceled) {
t.Fatalf("lifecycle context error = %v, want context.Canceled", instance.lifecycleCtx.Err())
}
}
+12
View File
@@ -0,0 +1,12 @@
package server
import (
edgeapi "git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/process"
internalserver "git.sendico.io/tech/sxchnge/product/app/server/internal"
"git.sendico.io/tech/sxchnge/shared/logging"
)
func Create(logger mlogger.Logger, file string, debug bool, runtime edgeapi.RuntimeConfig) (server.Application, error) {
return internalserver.Create(logger, file, debug, runtime)
}
@@ -0,0 +1,16 @@
package domainprovider
import (
dimp "git.sendico.io/tech/sxchnge/product/app/urls/internal"
"git.sendico.io/tech/sxchnge/shared/logging"
)
type DomainProvider interface {
GetFullLink(linkElem ...string) (string, error)
GetAPILink(linkElem ...string) (string, error)
}
// CreateDomainProvider accepts a resolved host, protocol and installation mount.
func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) {
return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint)
}
@@ -0,0 +1,43 @@
package domainproviderimp
import (
"net/url"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap"
)
type DomainProviderImp struct {
domain string
apiEndpoint string
}
func (dp *DomainProviderImp) GetFullLink(linkElem ...string) (string, error) {
return url.JoinPath(dp.domain, linkElem...)
}
func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) {
return url.JoinPath(dp.apiEndpoint, linkElem...)
}
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPath string) (*DomainProviderImp, error) {
if protocol != "http" && protocol != "https" {
return nil, merrors.InvalidArgument("API protocol must be http or https")
}
parsed, err := url.Parse(protocol + "://" + domain)
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid API origin")
}
if parsed.Hostname() == "" || parsed.Host != domain || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, merrors.InvalidArgument("API domain must be a host with optional port")
}
p := new(DomainProviderImp)
p.domain = protocol + "://" + domain
if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil {
logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath))
return nil, err
}
logger.Named("domain_provider").Info("Domain name provider is up", zap.String("domain", p.domain))
return p, nil
}
@@ -0,0 +1,27 @@
package domainproviderimp
import (
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestExplicitOriginAndMount(t *testing.T) {
provider, err := CreateDomainProviderImp(zap.NewNop(), "localhost:8082", "https", "/gateway/v2")
require.NoError(t, err)
link, err := provider.GetAPILink("accounts", "me")
require.NoError(t, err)
require.Equal(t, "https://localhost:8082/gateway/v2/accounts/me", link)
link, err = provider.GetFullLink("signup")
require.NoError(t, err)
require.Equal(t, "https://localhost:8082/signup", link)
}
func TestInvalidOrigin(t *testing.T) {
for _, domain := range []string{"", "host/path", "user@host", "host?query", "host?", "host#", "host#fragment", "bad host"} {
_, err := CreateDomainProviderImp(zap.NewNop(), domain, "https", "/v1")
require.Error(t, err, domain)
}
_, err := CreateDomainProviderImp(zap.NewNop(), "host", "ftp", "/v1")
require.Error(t, err)
}
+27
View File
@@ -0,0 +1,27 @@
package appversion
import (
"git.sendico.io/tech/sxchnge/shared/version"
vf "git.sendico.io/tech/sxchnge/shared/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
vi := version.Info{
Program: "SXCHNGE BFF",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
}
@@ -0,0 +1,45 @@
package architecture
import (
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strconv"
"strings"
"testing"
)
// SXCHNGE-owned gRPC and NATS are allowed. Provider access is HTTP only. Local Mongo repositories belong to SXCHNGE.
func TestNoSendicoRuntimeOrPrivateTransportImports(t *testing.T) {
err := filepath.WalkDir("../..", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || filepath.Ext(path) != ".go" {
return nil
}
f, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, im := range f.Imports {
name, err := strconv.Unquote(im.Path.Value)
if err != nil {
return err
}
if strings.HasPrefix(filepath.ToSlash(path), "../../shared/") && strings.HasPrefix(name, "git.sendico.io/tech/sxchnge/") && !strings.HasPrefix(name, "git.sendico.io/tech/sxchnge/shared/") {
t.Errorf("shared library imports an executable module: %s in %s", name, path)
}
for _, blocked := range []string{"github.com/tech/sendico/", "/pkg/discovery", "/pkg/proto/", "github.com/casbin/", "/pkg/auth", "/storage/organization", "/mongo/policiesdb", "/mongo/rolesdb", "/mongo/invitationdb"} {
if strings.Contains(name, blocked) {
t.Errorf("%s imports private provider transport or shared runtime: %s", path, name)
}
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,47 @@
package architecture
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strings"
"testing"
)
func TestLocalCustomerModelHasNoPlatformTenancy(t *testing.T) {
// Local customer models and storage must stay free of provider tenancy.
forbidden := map[string]bool{"organization": true, "organizationref": true, "orgref": true, "orgdb": true, "role": true, "roles": true, "roledescription": true, "rolemanager": true, "policy": true, "policies": true, "policydescription": true, "policydb": true, "permissionbound": true, "signup_provisioning": true}
err := filepath.WalkDir("../..", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") {
return nil
}
parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
if err != nil {
return err
}
ast.Inspect(parsed, func(node ast.Node) bool {
var names []*ast.Ident
switch n := node.(type) {
case *ast.TypeSpec:
names = []*ast.Ident{n.Name}
case *ast.Field:
names = n.Names
}
for _, name := range names {
if forbidden[strings.ToLower(name.Name)] {
t.Errorf("%s reintroduces local platform tenancy: %s", path, name.Name)
}
}
return true
})
return nil
})
if err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,69 @@
package architecture
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strconv"
"testing"
)
// Keep the backend error-model guideline executable, including aliased imports.
func TestErrorConstructorsUseProjectModel(t *testing.T) {
root := filepath.Join("..", "..")
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || filepath.Ext(path) != ".go" {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return err
}
imports := map[string]string{}
for _, imp := range file.Imports {
pkg, err := strconv.Unquote(imp.Path.Value)
if err != nil {
return err
}
if pkg != "errors" && pkg != "fmt" {
continue
}
alias := pkg
if imp.Name != nil {
alias = imp.Name.Name
}
if alias == "." {
t.Errorf("%s: dot import obscures error constructors", path)
}
imports[alias] = pkg
}
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
if (imports[ident.Name] == "errors" && sel.Sel.Name == "New") || (imports[ident.Name] == "fmt" && sel.Sel.Name == "Errorf") {
t.Errorf("%s: use backend/shared/errors", fset.Position(call.Pos()))
}
return true
})
return nil
})
if err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,48 @@
package architecture
import (
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strconv"
"strings"
"testing"
)
// Keep the reusable domain independent of transport and session ownership.
func TestSecondFactorDomainBoundary(t *testing.T) {
err := filepath.WalkDir("../twofactor", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == "http" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, im := range file.Imports {
name, err := strconv.Unquote(im.Path.Value)
if err != nil {
return err
}
for _, blocked := range []string{"/product/auth/", "/product/sessions/", "/jwtauth", "/jwt/"} {
if strings.Contains(name, blocked) {
t.Errorf("2FA domain imports operation/transport dependency %s in %s", name, path)
}
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,41 @@
package architecture
import (
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestGenericRuntimeDoesNotInstallConcreteContracts(t *testing.T) {
for _, dir := range []string{"../app/runtime", "../app/http"} {
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, im := range file.Imports {
name, err := strconv.Unquote(im.Path.Value)
if err != nil {
return err
}
if strings.Contains(name, "/http/v") || strings.Contains(name, "/app/api/v") || strings.HasSuffix(name, "/app/profile") {
t.Errorf("generic runtime imports a concrete API contract: %s in %s", name, path)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package client
import (
"context"
"errors"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"strings"
"git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/shared/errors"
)
var ErrUnknownClientID = merrors.Define("unknown client id")
func Resolve(ctx context.Context, rtdb refreshtokens.DB, clientID string) (*sessionmodel.Client, error) {
clientID = strings.TrimSpace(clientID)
if clientID == "" {
return nil, ErrUnknownClientID
}
client, err := rtdb.GetClient(ctx, clientID)
if errors.Is(err, merrors.ErrNoData) || client == nil {
return nil, ErrUnknownClientID
}
if err != nil {
return nil, err
}
return client, nil
}
@@ -0,0 +1,53 @@
package client
import (
"context"
"errors"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"testing"
"git.sendico.io/tech/sxchnge/product/sessions/store"
)
type resolveClientDB struct {
refreshtokens.DB
calls int
client *sessionmodel.Client
err error
}
func (db *resolveClientDB) GetClient(ctx context.Context, clientID string) (*sessionmodel.Client, error) {
db.calls++
return db.client, db.err
}
func TestResolveRejectsBlankClientIDWithoutStorageLookup(t *testing.T) {
db := &resolveClientDB{}
client, err := Resolve(context.Background(), db, " ")
if !errors.Is(err, ErrUnknownClientID) {
t.Fatalf("expected unknown client id, got %v", err)
}
if client != nil {
t.Fatalf("expected nil client, got %#v", client)
}
if db.calls != 0 {
t.Fatalf("expected no storage lookup for blank client id, got %d", db.calls)
}
}
func TestResolveTrimsClientID(t *testing.T) {
want := &sessionmodel.Client{ClientID: "client-1"}
db := &resolveClientDB{client: want}
client, err := Resolve(context.Background(), db, " client-1 ")
if err != nil {
t.Fatalf("expected client resolution to succeed, got %v", err)
}
if client != want {
t.Fatalf("expected resolved client %#v, got %#v", want, client)
}
if db.calls != 1 {
t.Fatalf("expected one storage lookup, got %d", db.calls)
}
}
@@ -0,0 +1,36 @@
package middleware
type AuthStorageConfig struct {
AccountsCollection string `yaml:"accounts_collection"`
RefreshTokensCollection string `yaml:"refresh_tokens_collection"`
ClientsCollection string `yaml:"clients_collection"`
VerificationTokensCollection string `yaml:"verification_tokens_collection"`
LoginAttemptsCollection string `yaml:"login_attempts_collection"`
VerificationRateLimitsCollection string `yaml:"verification_rate_limits_collection"`
}
func (c *AuthStorageConfig) WithDefaults() AuthStorageConfig {
if c == nil {
c = &AuthStorageConfig{}
}
res := *c
if res.AccountsCollection == "" {
res.AccountsCollection = "accounts"
}
if res.RefreshTokensCollection == "" {
res.RefreshTokensCollection = "refresh_tokens"
}
if res.ClientsCollection == "" {
res.ClientsCollection = "clients"
}
if res.VerificationTokensCollection == "" {
res.VerificationTokensCollection = "verification_tokens"
}
if res.LoginAttemptsCollection == "" {
res.LoginAttemptsCollection = "login_attempts"
}
if res.VerificationRateLimitsCollection == "" {
res.VerificationRateLimitsCollection = "verification_rate_limits"
}
return res
}
+49
View File
@@ -0,0 +1,49 @@
package middleware
import "testing"
func TestCredentialedCORSRequiresExplicitOrigins(t *testing.T) {
tests := []struct {
name string
config CORSSettings
wantErr bool
}{
{
name: "explicit origin",
config: CORSSettings{
AllowCredentials: true,
AllowedOrigins: []string{"https://app.sendico.io"},
},
},
{
name: "wildcard",
config: CORSSettings{
AllowCredentials: true,
AllowedOrigins: []string{"*"},
},
wantErr: true,
},
{
name: "missing origin",
config: CORSSettings{
AllowCredentials: true,
},
wantErr: true,
},
{
name: "non-credentialed compatibility",
config: CORSSettings{
AllowedOrigins: []string{"*"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr)
}
})
}
}
@@ -0,0 +1,47 @@
package middleware
import (
"time"
sharedlimit "git.sendico.io/tech/sxchnge/shared/ratelimit"
)
const (
defaultLoginAccountLimit = 5
defaultLoginAccountWindow = 15 * time.Minute
defaultLoginIPLimit = 50
defaultLoginIPWindow = 15 * time.Minute
)
type LoginRateLimitWindowConfig = sharedlimit.WindowConfig
type LoginRateLimitsConfig struct {
PerAccount LoginRateLimitWindowConfig `yaml:"per_account"`
PerIP LoginRateLimitWindowConfig `yaml:"per_ip"`
}
type LoginRateLimitWindow = sharedlimit.Window
type LoginRateLimits struct {
PerAccount LoginRateLimitWindow
PerIP LoginRateLimitWindow
}
func (c *Config) ResolveLoginRateLimits() (LoginRateLimits, error) {
if c == nil || c.LoginRateLimits == nil {
return LoginRateLimits{
PerAccount: LoginRateLimitWindow{Limit: defaultLoginAccountLimit, Window: defaultLoginAccountWindow},
PerIP: LoginRateLimitWindow{Limit: defaultLoginIPLimit, Window: defaultLoginIPWindow},
}, nil
}
account, err := sharedlimit.ResolveWindow(c.LoginRateLimits.PerAccount, "middleware.login_rate_limits.per_account")
if err != nil {
return LoginRateLimits{}, err
}
ip, err := sharedlimit.ResolveWindow(c.LoginRateLimits.PerIP, "middleware.login_rate_limits.per_ip")
if err != nil {
return LoginRateLimits{}, err
}
return LoginRateLimits{PerAccount: account, PerIP: ip}, nil
}
@@ -0,0 +1,60 @@
package middleware
import (
"errors"
"testing"
"time"
"git.sendico.io/tech/sxchnge/shared/errors"
)
func TestResolveLoginRateLimitsUsesSecureDefaults(t *testing.T) {
got, err := (&Config{}).ResolveLoginRateLimits()
if err != nil {
t.Fatalf("resolve defaults: %v", err)
}
if got.PerAccount.Limit != 5 || got.PerAccount.Window != 15*time.Minute {
t.Fatalf("unexpected account defaults: %+v", got.PerAccount)
}
if got.PerIP.Limit != 50 || got.PerIP.Window != 15*time.Minute {
t.Fatalf("unexpected IP defaults: %+v", got.PerIP)
}
}
func TestResolveLoginRateLimitsRejectsUnsafeConfiguration(t *testing.T) {
tests := []struct {
name string
cfg *LoginRateLimitsConfig
}{
{
name: "zero account threshold",
cfg: &LoginRateLimitsConfig{
PerAccount: LoginRateLimitWindowConfig{Window: "15m"},
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "15m"},
},
},
{
name: "invalid IP window",
cfg: &LoginRateLimitsConfig{
PerAccount: LoginRateLimitWindowConfig{Limit: 5, Window: "15m"},
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "forever"},
},
},
{
name: "excessive account window",
cfg: &LoginRateLimitsConfig{
PerAccount: LoginRateLimitWindowConfig{Limit: 5, Window: "25h"},
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "15m"},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := (&Config{LoginRateLimits: test.cfg}).ResolveLoginRateLimits()
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument, got %v", err)
}
})
}
}
+110
View File
@@ -0,0 +1,110 @@
package middleware
import (
"os"
"strings"
"git.sendico.io/tech/sxchnge/shared/errors"
)
type CORSSettings struct {
MaxAge int `yaml:"max_age"`
AllowedOrigins []string `yaml:"allowed_origins"`
AllowedMethods []string `yaml:"allowed_methods"`
AllowedHeaders []string `yaml:"allowed_headers"`
ExposedHeaders []string `yaml:"exposed_headers"`
AllowCredentials bool `yaml:"allow_credentials"`
}
func (c CORSSettings) Validate() error {
if !c.AllowCredentials {
return nil
}
if len(c.AllowedOrigins) == 0 {
return merrors.InvalidArgument("credentialed CORS requires explicit allowed origins", "middleware.CORS.allowed_origins")
}
for _, origin := range c.AllowedOrigins {
if strings.TrimSpace(origin) == "*" {
return merrors.InvalidArgument("credentialed CORS does not allow wildcard origins", "middleware.CORS.allowed_origins")
}
}
return nil
}
type Signature struct {
PublicKey any
PrivateKey []byte
Algorithm string
}
type SignatureConfig struct {
PublicKeyEnv string `yaml:"public_key_env,omitempty"`
PrivateKeyEnv string `yaml:"secret_key_env"`
Algorithm string `yaml:"algorithm"`
}
type TokenExpiration struct {
Account int `yaml:"account"`
Refresh int `yaml:"refresh"`
}
type TokenConfig struct {
Expiration TokenExpiration `yaml:"expiration_hours"`
Length int `yaml:"length"`
}
const MaxAccessTokenTTLHours = 1
func (c TokenConfig) Validate() error {
if c.Expiration.Account <= 0 || c.Expiration.Account > MaxAccessTokenTTLHours {
return merrors.InvalidArgument("access token expiration must be between 1 and 1 hour", "middleware.token.expiration_hours.account")
}
if c.Expiration.Refresh <= c.Expiration.Account {
return merrors.InvalidArgument("refresh token expiration must exceed access token expiration", "middleware.token.expiration_hours.refresh")
}
if c.Length < 32 {
return merrors.InvalidArgument("refresh token length must be at least 32 bytes", "middleware.token.length")
}
return nil
}
type PasswordChecks struct {
Digit bool `yaml:"digit"`
Upper bool `yaml:"upper"`
Lower bool `yaml:"lower"`
Special bool `yaml:"special"`
MinLength int `yaml:"min_length"`
}
type PasswordConfig struct {
TokenLength int `yaml:"token_length"`
Check PasswordChecks `yaml:"check"`
}
type Config struct {
DomainEnv string `yaml:"domain_env"`
EndPointEnv string `yaml:"api_endpoint_env"`
APIProtocolEnv string `yaml:"api_protocol_env"`
Signature SignatureConfig `yaml:"signature"`
CORS CORSSettings `yaml:"CORS"`
Token TokenConfig `yaml:"token"`
Password PasswordConfig `yaml:"password"`
LoginRateLimits *LoginRateLimitsConfig `yaml:"login_rate_limits"`
}
type MapClaims = map[string]any
func getKey(osEnv string) any {
if len(osEnv) == 0 {
return nil
}
return []byte(os.Getenv(osEnv))
}
func SignatureConf(conf *Config) Signature {
return Signature{
PrivateKey: []byte(os.Getenv(conf.Signature.PrivateKeyEnv)),
PublicKey: getKey(conf.Signature.PublicKeyEnv),
Algorithm: conf.Signature.Algorithm,
}
}
+52
View File
@@ -0,0 +1,52 @@
package middleware
import "testing"
func TestTokenConfigValidate(t *testing.T) {
tests := []struct {
name string
config TokenConfig
wantErr bool
}{
{
name: "secure explicit TTLs",
config: TokenConfig{
Expiration: TokenExpiration{Account: 1, Refresh: 720},
Length: 32,
},
},
{
name: "rejects legacy 24 hour access token",
config: TokenConfig{
Expiration: TokenExpiration{Account: 24, Refresh: 720},
Length: 32,
},
wantErr: true,
},
{
name: "rejects missing access TTL",
config: TokenConfig{
Expiration: TokenExpiration{Refresh: 720},
Length: 32,
},
wantErr: true,
},
{
name: "rejects weak refresh token",
config: TokenConfig{
Expiration: TokenExpiration{Account: 1, Refresh: 720},
Length: 16,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr)
}
})
}
}
+206
View File
@@ -0,0 +1,206 @@
// Package flow owns signup and account activation, independently of second-factor checks.
package flow
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"net/url"
"strings"
"time"
accountservice "git.sendico.io/tech/sxchnge/product/customers"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
account "git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/notifications"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
ratelimits "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
"go.mongodb.org/mongo-driver/v2/bson"
)
const Cooldown = time.Minute
type SignupFacts interface {
Ensure(context.Context, *customer.Account) error
Ready(context.Context, bson.ObjectID) error
}
type Service struct {
Facts SignupFacts
Accounts account.DB
Customers accountservice.AccountService
Tokens verification.DB
Transactions transaction.Factory
Attempts ratelimits.Store
Sender notifications.Sender
Secret []byte
ActivationURL string
}
func (s *Service) Validate() error {
u, err := url.Parse(s.ActivationURL)
if err != nil || u.Host == "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) || u.User != nil || u.Fragment != "" {
return merrors.InvalidArgument("activation URL must be an absolute HTTPS URL (HTTP allowed on localhost)")
}
if len(s.Secret) < 32 {
return merrors.InvalidArgument("authentication signing secret must be at least 32 bytes")
}
return nil
}
func (s *Service) key(parts ...string) string {
h := hmac.New(sha256.New, s.Secret)
for _, p := range parts {
h.Write([]byte(p))
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
func (s *Service) Limit(ctx context.Context, ip, email string) error {
now := time.Now().UTC()
start := now.Truncate(time.Hour)
for _, scope := range []struct {
kind, value string
limit int
}{{"auth_email", email, 10}, {"auth_ip", ip, 30}} {
count, err := s.Attempts.Increment(ctx, scope.kind, ratelimits.HashScope(scope.kind, scope.value), start, start.Add(time.Hour))
if err != nil {
return merrors.ServiceUnavailableWrap(err, "authentication rate limit unavailable")
}
if count > scope.limit {
return verification.ErrorIssuanceRateLimited(scope.kind, start.Add(time.Hour).Sub(now))
}
}
return nil
}
func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key string) (*customer.Account, error) {
email, err := customer.NormalizeEmail(a.Login)
if err != nil {
return nil, err
}
a.Login = email
a.Name = strings.TrimSpace(a.Name)
if len(a.Name) > 200 || len(a.Password) > 72 {
return nil, merrors.InvalidArgument("name or password too long")
}
if err = s.Limit(ctx, ip, email); err != nil {
return nil, err
}
raw, err := s.Customers.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key))
if errors.Is(err, merrors.ErrDataConflict) {
existing, lookup := s.Accounts.GetByEmail(ctx, email)
if lookup != nil {
return nil, lookup
}
// A retry after a queue failure may resume only with the original password.
if existing.Status != customer.AccountPendingVerification || existing.IsArchived() || !existing.MatchPassword(a.Password) {
return nil, merrors.DataConflict("email already registered")
}
a = existing
} else if err != nil {
return nil, err
}
if s.Facts != nil {
if err := s.Facts.Ensure(ctx, a); err != nil {
return nil, err
}
}
if raw != "" {
u, _ := url.Parse(s.ActivationURL)
q := u.Query()
q.Set("token", raw)
u.RawQuery = q.Encode()
locale := "en"
if strings.HasPrefix(a.Locale, "ru") {
locale = "ru"
}
err = s.Sender.Send(ctx, notifications.Message{Key: s.key("signup", raw), UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", Parameters: map[string]string{"url": u.String()}, ExpiresAt: time.Now().UTC().Add(23 * time.Hour)})
} else {
_, err = s.sendActivation(ctx, a, key)
}
return a, err
}
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
email, err := customer.NormalizeEmail(email)
if err != nil {
return err
}
if err = s.Limit(ctx, ip, email); err != nil {
return err
}
a, err := s.Accounts.GetByEmail(ctx, email)
if errors.Is(err, merrors.ErrNoData) {
return nil
}
if err != nil {
return err
}
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil
}
if s.Facts != nil {
if err := s.Facts.Ensure(ctx, a); err != nil {
return err
}
}
_, err = s.sendActivation(ctx, a, key)
return err
}
func (s *Service) activationKey(email, key string) string {
return s.key(email, string(verificationmodel.PurposeAccountActivation), "", key)
}
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
if strings.TrimSpace(key) == "" || len(key) > 128 {
return time.Time{}, merrors.InvalidArgument("idempotency key required")
}
secretKey := s.activationKey(a.Login, key)
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
result, err := s.Tokens.CreateWithResult(ctx, req)
if err != nil {
return time.Time{}, err
}
if !result.ExpiresAt.After(time.Now()) {
return time.Time{}, verification.ErorrTokenExpired()
}
locale := "en"
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
locale = "ru"
}
u, _ := url.Parse(s.ActivationURL)
q := u.Query()
q.Set("token", result.VerificationToken)
u.RawQuery = q.Encode()
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
return result.ExpiresAt, err
}
func (s *Service) Activate(ctx context.Context, raw string) error {
if len(raw) != 43 {
return verification.ErorrTokenNotFound()
}
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
if err != nil {
return nil, err
}
a := &customer.Account{}
if err = s.Accounts.Get(tx, token.AccountRef, a); err != nil {
return nil, err
}
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil, merrors.Unauthorized("account cannot be activated")
}
if s.Facts != nil {
if err := s.Facts.Ready(tx, a.ID); err != nil {
return nil, err
}
}
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
})
return err
}
+73
View File
@@ -0,0 +1,73 @@
package flow
import (
"context"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/notifications"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"strings"
"testing"
"time"
)
type tokenStub struct {
verification.DB
request *verification.Request
result verification.CreateResult
err error
}
func (s *tokenStub) CreateWithResult(_ context.Context, r *verification.Request) (verification.CreateResult, error) {
s.request = r
return s.result, s.err
}
type senderStub struct {
messages []notifications.Message
err error
}
func (s *senderStub) Send(_ context.Context, m notifications.Message) error {
s.messages = append(s.messages, m)
return s.err
}
func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
expiry := time.Now().UTC().Add(time.Minute)
tokens := &tokenStub{result: verification.CreateResult{VerificationToken: "123456", ExpiresAt: expiry}}
sender := &senderStub{err: merrors.ServiceUnavailable("queue")}
service := &Service{Tokens: tokens, Sender: sender, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
a := &customer.Account{}
a.ID = bson.NewObjectID()
a.Login = "user@example.com"
a.Locale = "ru-RU"
_, err := service.sendActivation(context.Background(), a, "client-key")
require.Error(t, err)
key := *tokens.request.IdempotencyKey
require.NotEqual(t, "client-key", key)
require.Equal(t, verification.TokenKindLink, tokens.request.Kind)
require.Equal(t, 24*time.Hour, tokens.request.Ttl)
sender.err = nil
_, err = service.sendActivation(context.Background(), a, "client-key")
require.NoError(t, err)
require.Equal(t, sender.messages[0], sender.messages[1])
require.Equal(t, "ru", sender.messages[0].Locale)
service.Secret = []byte(strings.Repeat("b", 32))
require.NotEqual(t, key, service.activationKey(a.Login, "client-key"))
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
_, err = service.sendActivation(context.Background(), a, "expired")
require.ErrorIs(t, err, verification.ErrTokenExpired)
require.Len(t, sender.messages, 2)
}
func TestValidateActivationDestination(t *testing.T) {
for _, destination := range []string{"https://app.example/verify-email", "http://localhost:5126/verify-email"} {
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
require.NoError(t, s.Validate())
}
for _, destination := range []string{"http://app.example/verify-email", "//app.example", "https://user:pass@app.example/verify-email", "https://app.example/#/verify"} {
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
require.Error(t, s.Validate())
}
}
@@ -0,0 +1,75 @@
package authorized
import (
"context"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"net/http"
"net/http/httptest"
"testing"
"git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type customerDB struct {
account.DB
customer customermodel.Account
requested bson.ObjectID
}
func (d *customerDB) Get(_ context.Context, id bson.ObjectID, result *customermodel.Account) error {
d.requested = id
if id != d.customer.ID {
return merrors.NoData("customer not found")
}
*result = d.customer
return nil
}
func (d *sessionDB) GetClient(context.Context, string) (*sessionmodel.Client, error) {
return &sessionmodel.Client{ClientID: "web"}, nil
}
func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
for _, status := range []customermodel.AccountStatus{customermodel.AccountActive, customermodel.AccountBlocked, customermodel.AccountPendingVerification} {
t.Run(string(status), func(t *testing.T) {
accounts := &customerDB{}
accounts.customer.ID = bson.NewObjectID()
accounts.customer.Status = status
routes := chi.NewRouter()
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")}
router := Install(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
called := false
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc {
called = true
require.Equal(t, accounts.customer.ID, c.ID)
return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
})
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, nil)
_, token, err := ja.Encode(emodel.Account2ClaimsForSession(&accounts.customer, 1, sessionmodel.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}))
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/me?accountRef="+bson.NewObjectID().Hex(), nil)
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
routes.ServeHTTP(recorder, req)
require.Equal(t, accounts.customer.ID, accounts.requested)
if status == customermodel.AccountActive {
require.Equal(t, http.StatusNoContent, recorder.Code)
require.True(t, called)
} else {
require.Equal(t, http.StatusUnauthorized, recorder.Code)
require.False(t, called)
}
})
}
}
@@ -0,0 +1,161 @@
package authorized
import (
"errors"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
"net/http"
"strings"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
"git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging/fields"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/jwtauth/v5"
"go.uber.org/zap"
)
type tokenHandlerFunc = func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc
func (ar *AuthorizedRouter) validateClientPolicy(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
clientID := strings.TrimSpace(t.ClientID)
if clientID == "" {
// Legacy tokens without client_id remain valid until expiration.
return nil
}
client, err := ar.rtdb.GetClient(r.Context(), clientID)
if errors.Is(err, merrors.ErrNoData) || client == nil {
ar.logger.Debug("Client not found for access token", zap.String("client_id", clientID))
return response.Unauthorized(ar.logger, ar.service, "client not found")
}
if err != nil {
ar.logger.Warn("Failed to resolve client for access token", zap.Error(err), zap.String("client_id", clientID))
return response.Internal(ar.logger, ar.service, err)
}
if client.IsRevoked {
return response.Unauthorized(ar.logger, ar.service, "client has been revoked")
}
if client.AccountRef != nil && *client.AccountRef != t.AccountRef {
return response.Unauthorized(ar.logger, ar.service, "client account mismatch")
}
clientIP := ipguard.ClientIP(r)
allowed, err := ipguard.Allowed(clientIP, client.AllowedCIDRs)
if err != nil {
ar.logger.Warn("Client IP policy contains invalid CIDR", zap.Error(err), zap.String("client_id", clientID))
return response.Forbidden(ar.logger, ar.service, "client_ip_policy_invalid", "client ip policy is invalid")
}
if !allowed {
rawIP := ""
if clientIP != nil {
rawIP = clientIP.String()
}
ar.logger.Info("Client IP policy denied authorized request", zap.String("client_id", clientID), zap.String("remote_ip", rawIP))
return response.Forbidden(ar.logger, ar.service, "ip_not_allowed", "request ip is not allowed for this client")
}
return nil
}
func (ar *AuthorizedRouter) validateSession(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
if t.Pending {
return nil
}
if strings.TrimSpace(t.ClientID) == "" || strings.TrimSpace(t.DeviceID) == "" || strings.TrimSpace(t.SessionVersion) == "" {
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.MissingIdentity)
ar.logger.Debug("Access token has no session identifier", mzap.AccRef(t.AccountRef))
return response.Unauthorized(ar.logger, ar.service, "session identifier is required")
}
if _, err := ar.rtdb.GetSession(r.Context(), t.AccountRef, &t.SessionIdentifier); err != nil {
if errors.Is(err, merrors.ErrNoData) || errors.Is(err, merrors.ErrAccessDenied) {
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.AlreadyInactive)
ar.logger.Debug("Access token session is not active", mzap.AccRef(t.AccountRef),
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
return response.Unauthorized(ar.logger, ar.service, "session is not active")
}
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.StorageError)
ar.logger.Warn("Failed to validate access token session", zap.Error(err), mzap.AccRef(t.AccountRef),
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
return response.Internal(ar.logger, ar.service, err)
}
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.Active)
return nil
}
func (ar *AuthorizedRouter) tokenHandlerWithSessionCheck(service mservice.Type, endpoint string, method api.HTTPMethod, checkSession bool, handler tokenHandlerFunc) {
hndlr := func(r *http.Request) http.HandlerFunc {
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
ar.logger.Debug("Authorization failed", zap.Error(err), zap.String("request", r.URL.Path))
return response.Unauthorized(ar.logger, ar.service, "credentials required")
}
t, err := emodel.Claims2Token(claims)
if err != nil {
ar.logger.Debug("Failed to decode account token", zap.Error(err))
return response.BadRequest(ar.logger, ar.service, "credentials_unreadable", "faild to parse credentials")
}
if h := ar.validateClientPolicy(r, t); h != nil {
return h
}
if checkSession {
if h := ar.validateSession(r, t); h != nil {
return h
}
}
return handler(r, t)
}
ar.imp.InstallHandler(service, endpoint, method, hndlr)
}
func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler tokenHandlerFunc) {
ar.tokenHandlerWithSessionCheck(service, endpoint, method, true, handler)
}
func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
if t.Pending {
return response.Unauthorized(ar.logger, ar.service, "additional verification required")
}
var a customermodel.Account
if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil {
if errors.Is(err, merrors.ErrNoData) {
ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.AccRef(t.AccountRef))
return response.NotFound(ar.logger, ar.service, err.Error())
}
return response.Internal(ar.logger, ar.service, err)
}
if !a.IsActive() || a.IsArchived() {
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
}
accessToken, err := ar.imp.CreateAccessTokenForSession(&a, t.SessionIdentifier)
if err != nil {
ar.logger.Warn("Failed to generate access token", zap.Error(err))
return response.Internal(ar.logger, ar.service, err)
}
return handler(r, &a, &accessToken)
}
ar.tokenHandler(service, endpoint, method, hndlr)
}
func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
var a customermodel.Account
if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil {
if errors.Is(err, merrors.ErrNoData) {
ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.AccRef(t.AccountRef))
return response.NotFound(ar.logger, ar.service, err.Error())
}
return response.Internal(ar.logger, ar.service, err)
}
if a.Status == customermodel.AccountBlocked || a.IsArchived() {
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
}
return handler(r, &a, t)
}
ar.tokenHandler(service, endpoint, method, hndlr)
}
@@ -0,0 +1,60 @@
package authorized
import (
"net/http"
"git.sendico.io/tech/sxchnge/product/auth/config"
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
"git.sendico.io/tech/sxchnge/product/sessions/store"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/logging/fields"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"go.uber.org/zap"
)
type AuthorizedRouter struct {
logger mlogger.Logger
db account.DB
rtdb refreshtokens.DB
imp *re.HttpEndpointRouter
service mservice.Type
}
func Install(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
router.Use(jwtauth.Verifier(ja))
router.Use(jwtauth.Authenticator(ja))
l := logger.Named("authorized")
ar := AuthorizedRouter{
logger: l,
db: db,
rtdb: rtdb,
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
service: mservice.Accounts,
}
ar.tokenHandlerWithSessionCheck(mservice.Accounts, "/logout/all", api.Post, false, ar.logoutAll)
return &ar
}
func (ar *AuthorizedRouter) logoutAll(r *http.Request, token *emodel.AccountToken) http.HandlerFunc {
if token.ClientID == "" || token.DeviceID == "" || token.SessionVersion == "" {
sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.MissingIdentity)
return response.Unauthorized(ar.logger, ar.service, "session identifier is required")
}
if err := ar.rtdb.RevokeAll(r.Context(), token.AccountRef, ""); err != nil {
sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.StorageError)
ar.logger.Warn("Failed to revoke all account sessions", zap.Error(err), mzap.AccRef(token.AccountRef))
return response.Internal(ar.logger, ar.service, err)
}
sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.Succeeded)
ar.logger.Info("All account sessions revoked", mzap.AccRef(token.AccountRef))
return response.Success(ar.logger)
}
@@ -0,0 +1,113 @@
package authorized
import (
"context"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"net/http"
"net/http/httptest"
"testing"
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type sessionDB struct {
refreshtokens.DB
token *sessionmodel.RefreshToken
err error
revokeAllID string
}
func (db *sessionDB) GetSession(context.Context, bson.ObjectID, *sessionmodel.SessionIdentifier) (*sessionmodel.RefreshToken, error) {
return db.token, db.err
}
func (db *sessionDB) RevokeAll(_ context.Context, _ bson.ObjectID, deviceID string) error {
db.revokeAllID = deviceID
return db.err
}
func TestValidateSessionFailsClosed(t *testing.T) {
accountRef := bson.NewObjectID()
token := &emodel.AccountToken{
AccountRef: accountRef,
SessionIdentifier: sessionmodel.SessionIdentifier{
ClientID: "web",
DeviceID: "device-a",
SessionVersion: "version-a",
},
}
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/accounts", nil)
tests := []struct {
name string
db *sessionDB
mutate func(*emodel.AccountToken)
wantStatus int
}{
{name: "active", db: &sessionDB{token: &sessionmodel.RefreshToken{}}, wantStatus: 0},
{name: "revoked", db: &sessionDB{err: merrors.ErrNoData}, wantStatus: http.StatusUnauthorized},
{name: "storage unavailable", db: &sessionDB{err: merrors.Internal("database unavailable")}, wantStatus: http.StatusInternalServerError},
{
name: "missing version",
db: &sessionDB{},
mutate: func(token *emodel.AccountToken) {
token.SessionVersion = ""
},
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
candidate := *token
if tt.mutate != nil {
tt.mutate(&candidate)
}
router := &AuthorizedRouter{logger: zap.NewNop(), rtdb: tt.db, service: mservice.Accounts}
handler := router.validateSession(request, &candidate)
if tt.wantStatus == 0 {
if handler != nil {
t.Fatal("active session was rejected")
}
return
}
if handler == nil {
t.Fatal("invalid session was accepted")
}
response := httptest.NewRecorder()
handler(response, request)
if response.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d", response.Code, tt.wantStatus)
}
})
}
}
func TestLogoutAllRevokesEveryDevice(t *testing.T) {
db := &sessionDB{}
router := &AuthorizedRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
token := &emodel.AccountToken{
AccountRef: bson.NewObjectID(),
SessionIdentifier: sessionmodel.SessionIdentifier{
ClientID: "web",
DeviceID: "device-a",
SessionVersion: "version-a",
},
}
request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/accounts/logout/all", nil)
response := httptest.NewRecorder()
router.logoutAll(request, token)(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
if db.revokeAllID != "" {
t.Fatalf("device exclusion = %q, want all devices", db.revokeAllID)
}
}
@@ -0,0 +1,60 @@
package router
import (
"git.sendico.io/tech/sxchnge/product/auth/config"
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/v1/authorized"
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/store"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
)
type Dispatcher struct {
logger mlogger.Logger
public APIRouter
protected ProtectedAPIRouter
}
func (d *Dispatcher) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
d.public.InstallHandler(service, endpoint, method, handler)
}
func (d *Dispatcher) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
d.protected.AccountHandler(service, endpoint, method, handler)
}
func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
d.protected.PendingAccountHandler(service, endpoint, method, handler)
}
func Install(
logger mlogger.Logger,
router chi.Router,
endpoint string,
db account.DB,
rtdb refreshtokens.DB,
attempts ratelimits.Store,
token *middleware.TokenConfig,
signature middleware.Signature,
loginRateLimits middleware.LoginRateLimits,
flows ...rpublic.Flows,
) *Dispatcher {
d := &Dispatcher{
logger: logger.Named("api_dispatcher"),
}
d.logger.Debug("Installing endpoints middleware...")
router.Group(func(r chi.Router) {
d.public = rpublic.Install(d.logger, endpoint, db, rtdb, attempts, r, token, &signature, loginRateLimits, flows...)
})
router.Group(func(r chi.Router) {
d.protected = rauthorized.Install(d.logger, endpoint, r, db, rtdb, token, &signature)
})
return d
}
Loaded 100 of 522 files, more files were not shown because too many files have changed in this diff. Show more