Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a0b211d5f | ||
|
|
cd0981cea1 | ||
|
|
efeac88a49 | ||
|
|
3b551f1565 | ||
|
|
645181c65c | ||
|
|
de449d53bf | ||
|
|
fe60b7ac1a | ||
|
|
b644a10663 | ||
|
|
9370eacd9c | ||
|
|
5c255296d3 | ||
|
|
c106706615 | ||
|
|
915b8e8123 | ||
|
|
ec4115d083 | ||
|
|
f3ff7fc016 | ||
|
|
658d715c8a | ||
|
|
288a2f0c02 | ||
|
|
4605a197ad | ||
|
|
62df9eac5f | ||
|
|
4b22f6ce12 | ||
|
|
b5c4772168 | ||
|
|
464bd2d565 | ||
|
|
f1ff40d072 | ||
|
|
417e6d96b6 | ||
|
|
c8ef9c3d14 | ||
|
|
3af60f38b1 | ||
|
|
e84d2d1ad3 | ||
|
|
fa7b75d1b8 | ||
|
|
a587cbbc24 | ||
|
|
5f3b443e7b | ||
|
|
30f06c2f8f | ||
|
|
4c4b639321 | ||
|
|
13cc9c302e | ||
|
|
021bd04324 | ||
|
|
4844fb5bc9 | ||
|
|
3aeea9aa77 | ||
|
|
69fba8df70 | ||
|
|
b36eb72c23 | ||
|
|
3e4e496037 | ||
|
|
c1d28e705e | ||
|
|
abca15f047 | ||
|
|
c6ab4680fd | ||
|
|
86b6aafe7d | ||
|
|
bd675357bb | ||
|
|
24af794112 | ||
|
|
a5689876ff | ||
|
|
34d8ef18d1 | ||
|
|
7a6510d1a8 | ||
|
|
5d67c25c8e | ||
|
|
b303285b8b | ||
|
|
1354551ae8 | ||
|
|
7bf8b8087a | ||
|
|
a584c8189a | ||
|
|
30f0455122 | ||
|
|
9f9525df56 | ||
|
|
259f84e0a1 | ||
|
|
d04c8b12ab | ||
|
|
ce6583f454 | ||
|
|
caad7a3d5b | ||
|
|
1fe173397f | ||
|
|
9d57a65010 | ||
|
|
9dbf0bd80c | ||
|
|
1b8800ee73 | ||
|
|
23f7b78c3b | ||
|
|
80d6917aa7 | ||
|
|
184a3be413 | ||
|
|
8e2ef7aa88 | ||
|
|
fc33630a0a | ||
|
|
ecb63b61c6 | ||
|
|
12a38cd654 | ||
|
|
b6039493c9 | ||
|
|
1a079e00cc | ||
|
|
ad5a061164 | ||
|
|
4b14649b3a | ||
|
|
326d58f705 | ||
|
|
5c2d36281b | ||
|
|
36277b6c45 | ||
|
|
f0720ec66b | ||
|
|
981e84a4b4 | ||
|
|
7062672a87 | ||
|
|
67dc5b5b1e | ||
|
|
45666d33fc |
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.
|
||||
@@ -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
@@ -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__/
|
||||
@@ -8,6 +8,39 @@
|
||||
- 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.
|
||||
- Use externally published schemas as generation inputs. Do not embed or commit
|
||||
schema snapshots in the repository. Commit external schema references,
|
||||
generator configuration, and reproducible generation commands instead.
|
||||
- Fetch the externally published schemas and generate code at build time, before
|
||||
any dependent validation or compilation steps, including in CI.
|
||||
- Keep generated outputs ignored by Git. Do not force-add ignored generated
|
||||
files or copy generated code into tracked source files.
|
||||
- Change schema sources in their owning project or update external schema
|
||||
references and generator inputs rather than editing generated outputs.
|
||||
Document the generation workflow so a clean checkout can build without
|
||||
committed generated code or embedded schema snapshots.
|
||||
|
||||
## 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 +107,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 +172,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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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.
|
||||
|
||||
SMTP is an owned adapter using Go's standard `net/smtp`, verified `crypto/tls`
|
||||
connections and standard MIME encoders. Its implicit-TLS connection configuration
|
||||
matches Sendico's existing mail client, without importing its modules or copying
|
||||
its keepalive/reconnect behavior. Local mailbox credentials are provisioned into
|
||||
SXCHNGE Vault explicitly; the application does not read Sendico files or secrets.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,150 @@
|
||||
# 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`, `resend` or `smtp`.
|
||||
|
||||
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.
|
||||
|
||||
The owned SMTP adapter uses authenticated implicit TLS (normally port 465),
|
||||
verified server certificates and context-bounded connections. It sends MIME
|
||||
multipart/alternative with quoted-printable UTF-8 text/HTML and encoded subjects.
|
||||
It uses Go's standard SMTP client and does not import Sendico modules or share a
|
||||
mutable connection between workers. Configure it with:
|
||||
|
||||
```yaml
|
||||
email:
|
||||
driver: smtp
|
||||
settings:
|
||||
host: mail.example.com
|
||||
port: 465
|
||||
username: notifications@example.com
|
||||
password_env: SMTP_PASSWORD
|
||||
sender_address: notifications@example.com
|
||||
sender_name: SXCHNGE
|
||||
network_timeout: 10
|
||||
```
|
||||
|
||||
`network_timeout` must be 1–20 seconds; the worker's expiry/shutdown context also
|
||||
bounds every operation. Only the password belongs in Vault. There is no plaintext
|
||||
fallback or certificate-verification bypass.
|
||||
|
||||
## 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.
|
||||
- SMTP acceptance requires the final DATA 250 reply. A later QUIT failure cannot
|
||||
undo acceptance. Explicit SMTP 4xx replies become retryable
|
||||
`provider_temporarily_unavailable`; 5xx replies are terminal (authentication
|
||||
failures retain `provider_authentication_failed`). Transport/TLS errors and a
|
||||
lost final DATA reply become UNKNOWN. No immediate reconnect/resend occurs.
|
||||
A stable Message-ID is included for correlation, not an exactly-once promise.
|
||||
- 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. Provider
|
||||
HTTP outcomes include `provider_status`; failures also include `provider_error`
|
||||
and `provider_message` when available. The adapter reads at most 16 KiB plus an
|
||||
overflow byte and extracts only the Resend `name`/`message` or first SendGrid
|
||||
`errors[].message`. Diagnostic messages are bounded to 512 Unicode characters
|
||||
plus an ellipsis. Credentials, idempotency keys, outgoing request string values,
|
||||
email addresses, URLs, long token-like strings and numeric verification/account
|
||||
values embedded in the request are redacted; controls and formatting characters
|
||||
are normalized. Malformed, incomplete and oversized error
|
||||
responses produce a diagnostic fallback instead of raw content. Successful
|
||||
response bodies are not logged. Diagnostic fields are transient: they are neither
|
||||
persisted in notifications nor exposed in gRPC responses. The existing bounded
|
||||
failure codes and delivery/retry decisions remain unchanged. Transport failures
|
||||
include a sanitized cause. Provider outcome logging happens before the result
|
||||
write so a storage failure cannot discard the only provider diagnostic.
|
||||
|
||||
Message content, template values, addresses and raw provider bodies must remain
|
||||
excluded from logs. Provider text is sanitized external diagnostic data, never
|
||||
an instruction. Use `provider_status`, `provider_error` and `provider_message`
|
||||
together: the generic `provider_authentication_failed` category combines HTTP
|
||||
401 and 403, including sender/domain authorization failures. A sending-only
|
||||
Resend key is valid for sending, but cannot read the provider's log API.
|
||||
SMTP outcomes use `provider_smtp_status` for protocol reply codes and the same
|
||||
sanitized provider error/message fields, including password/auth-payload redaction.
|
||||
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).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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)
|
||||
}()
|
||||
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(ctx, 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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}
|
||||
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
if err := admin.EnsureConsumerResources(startup, settings, consumer); err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "configure notification messaging")
|
||||
}
|
||||
// NATS retains this context and unsubscribes when it ends. The worker must
|
||||
// live until service shutdown, independently of the provisioning deadline.
|
||||
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,100 @@
|
||||
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"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type consumerTransport struct {
|
||||
reliable.Transport
|
||||
reliable.Subscription
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (t *consumerTransport) OpenConsumer(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) (reliable.Subscription, error) {
|
||||
t.ctx = ctx
|
||||
return t.Subscription, nil
|
||||
}
|
||||
|
||||
type consumerAdmin struct {
|
||||
reliable.Admin
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (a *consumerAdmin) EnsureConsumerResources(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) error {
|
||||
a.ctx = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSubscriptionOutlivesProvisioning(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
transport := &consumerTransport{}
|
||||
admin := &consumerAdmin{}
|
||||
if _, err := New(ctx, transport, admin, &outbox{}, nil, zap.NewNop(), DefaultConfig()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if admin.ctx.Err() != context.Canceled {
|
||||
t.Fatal("provisioning context was not released")
|
||||
}
|
||||
if _, ok := admin.ctx.Deadline(); !ok {
|
||||
t.Fatal("provisioning has no deadline")
|
||||
}
|
||||
if err := transport.ctx.Err(); err != nil {
|
||||
t.Fatalf("subscription ended with provisioning: %v", err)
|
||||
}
|
||||
cancel()
|
||||
if transport.ctx.Err() != context.Canceled {
|
||||
t.Fatal("subscription does not follow service shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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; see README for authenticated SMTP settings
|
||||
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
|
||||
@@ -0,0 +1,88 @@
|
||||
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"
|
||||
SMTP Driver = "smtp"
|
||||
)
|
||||
|
||||
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
|
||||
// Provider diagnostics are sanitized operational data, not public failures.
|
||||
HTTPStatus int
|
||||
SMTPStatus int
|
||||
ProviderError string
|
||||
ProviderMessage string
|
||||
}
|
||||
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) {
|
||||
if config.Driver == SMTP {
|
||||
return newSMTP(config)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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 & 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 & 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 & 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxErrorBody = 16 << 10
|
||||
const maxErrorMessage = 512
|
||||
|
||||
var (
|
||||
errorName = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,64}$`)
|
||||
addresses = regexp.MustCompile(`[^\s<>"'(),;:]+@[^\s<>"'(),;:]+`)
|
||||
urls = regexp.MustCompile(`(?i)https?://[^\s<>"']+`)
|
||||
tokens = regexp.MustCompile(`[a-zA-Z0-9_+/-]{32,}={0,2}`)
|
||||
numbers = regexp.MustCompile(`[0-9]{4,}`)
|
||||
)
|
||||
|
||||
// Only the documented human-readable error fields cross the adapter boundary.
|
||||
// Full bodies, headers and request data must never reach logs or public failures.
|
||||
func providerFailure(body, request []byte, secrets ...string) (string, string) {
|
||||
var dto struct {
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &dto); err != nil {
|
||||
return "", "Provider error details unavailable: invalid JSON response"
|
||||
}
|
||||
if dto.Message == "" && len(dto.Errors) > 0 {
|
||||
dto.Message = dto.Errors[0].Message
|
||||
}
|
||||
// Providers can echo any scalar string from their request DTOs. Strip those
|
||||
// values before truncation, in addition to credentials and embedded tokens.
|
||||
var payload any
|
||||
if json.Unmarshal(request, &payload) == nil {
|
||||
for _, value := range requestStrings(payload) {
|
||||
secrets = append(secrets, value)
|
||||
// Rendered login/verification codes and numeric account references
|
||||
// can be echoed as fragments of an otherwise longer email body.
|
||||
secrets = append(secrets, numbers.FindAllString(value, -1)...)
|
||||
}
|
||||
}
|
||||
name := safeErrorText(dto.Name, secrets)
|
||||
if !errorName.MatchString(name) {
|
||||
name = ""
|
||||
}
|
||||
message := safeErrorText(dto.Message, secrets)
|
||||
if message == "" {
|
||||
message = "Provider did not include an error message"
|
||||
}
|
||||
return name, message
|
||||
}
|
||||
|
||||
func requestStrings(value any) []string {
|
||||
var values []string
|
||||
switch value := value.(type) {
|
||||
case string:
|
||||
values = append(values, value)
|
||||
case []any:
|
||||
for _, item := range value {
|
||||
values = append(values, requestStrings(item)...)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, item := range value {
|
||||
values = append(values, requestStrings(item)...)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func safeErrorText(text string, secrets []string) string {
|
||||
sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) })
|
||||
var replacements []string
|
||||
for _, secret := range secrets {
|
||||
if secret != "" {
|
||||
replacements = append(replacements, secret, "[redacted]")
|
||||
}
|
||||
}
|
||||
text = strings.NewReplacer(replacements...).Replace(text)
|
||||
text = addresses.ReplaceAllString(text, "[redacted-address]")
|
||||
text = urls.ReplaceAllString(text, "[redacted-url]")
|
||||
text = tokens.ReplaceAllString(text, "[redacted-token]")
|
||||
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r)
|
||||
}), " ")
|
||||
runes := []rune(text)
|
||||
if len(runes) > maxErrorMessage {
|
||||
text = string(runes[:maxErrorMessage]) + "…"
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
func TestProviderErrorDiagnostics(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body, code, message string
|
||||
}{
|
||||
{"resend", `{"name":"validation_error","message":"The sxchnge.com domain is not verified"}`, "validation_error", "The sxchnge.com domain is not verified"},
|
||||
{"sendgrid", `{"errors":[{"message":"The sender identity is not verified","field":"from","help":"private URL"}]}`, "", "The sender identity is not verified"},
|
||||
{"no message", `{"unrecognized":"private response"}`, "", "Provider did not include an error message"},
|
||||
{"malformed", `{"message":"private response"`, "", "Provider error details unavailable: invalid JSON response"},
|
||||
{"wrong type", `{"message":{"secret":"private response"}}`, "", "Provider error details unavailable: invalid JSON response"},
|
||||
{"trailing", `{"message":"private response"} {}`, "", "Provider error details unavailable: invalid JSON response"},
|
||||
{"oversized", `{"message":"` + strings.Repeat("x", maxErrorBody) + `"}`, "", "Provider error details unavailable: incomplete or oversized response"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, tc.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
c := client{http: server.Client(), endpoint: server.URL}
|
||||
out := c.post(context.Background(), "", struct{}{})
|
||||
if out.State != model.Failed || out.Code != "provider_authentication_failed" || out.HTTPStatus != 403 || out.ProviderError != tc.code || out.ProviderMessage != tc.message {
|
||||
t.Fatalf("unexpected provider outcome: %+v", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDiagnosticsRedactRequestDataBeforeTruncation(t *testing.T) {
|
||||
key := "re_credential-secret"
|
||||
request := []byte(`{"from":"Sender <sender@example.com>","to":["recipient@example.com"],"personalizations":[{"dynamic_template_data":{"secret":"short-private-value"}}],"subject":"Private subject","text":"Private text","html":"<p>Private HTML</p>","content":[{"value":"Your verification code is 123456; account 9876543210"}]}`)
|
||||
token := strings.Repeat("a", 43)
|
||||
message := "Credential " + key + "; recipient@example.com; Private subject; Private text; <p>Private HTML</p>; short-private-value; https://example.com/verify?token=" + token + "; " + token + "; 123456; 9876543210; job-secret\n\r\x00\u202eforged-log"
|
||||
body, err := json.Marshal(map[string]string{"name": "validation_error", "message": message})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, result := providerFailure(body, request, key, "job-secret")
|
||||
if name != "validation_error" || !strings.Contains(result, "forged-log") || !strings.Contains(result, "[redacted]") {
|
||||
t.Fatal("lost useful diagnostic", name, result)
|
||||
}
|
||||
for _, forbidden := range []string{key, "recipient@example.com", "Private subject", "Private text", "Private HTML", "short-private-value", "https://", token, "123456", "9876543210", "job-secret", "\n", "\r", "\x00", "\u202e"} {
|
||||
if strings.Contains(result, forbidden) {
|
||||
t.Fatalf("diagnostic contains sensitive value or control %q", forbidden)
|
||||
}
|
||||
}
|
||||
body, err = json.Marshal(map[string]string{"name": "validation_error", "message": strings.Repeat("ą", maxErrorMessage+20) + key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, result = providerFailure(body, request, key)
|
||||
if !utf8.ValidString(result) || utf8.RuneCountInString(result) != maxErrorMessage+1 || !strings.HasSuffix(result, "…") || strings.Contains(result, "re_") {
|
||||
t.Fatal("invalid or unbounded diagnostic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessfulResponsesDoNotExposeProviderContent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"name":"private","message":"private provider response"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
c := client{http: server.Client(), endpoint: server.URL}
|
||||
if out := c.post(context.Background(), "", struct{}{}); out.State != model.Accepted || out.HTTPStatus != 200 || out.ProviderMessage != "" || out.ProviderError != "" {
|
||||
t.Fatal("success leaked provider content", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportFailureHasSanitizedDiagnostic(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
c := client{http: http.DefaultClient, key: "credential-secret", endpoint: "https://example.com/emails?credential=credential-secret"}
|
||||
out := c.post(ctx, "job-secret", struct{}{})
|
||||
if out.State != model.Unknown || !strings.Contains(out.ProviderMessage, "context canceled") || strings.Contains(out.ProviderMessage, "credential-secret") || strings.Contains(out.ProviderMessage, "https://") {
|
||||
t.Fatal("transport cause missing or unsafe", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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", ProviderMessage: safeErrorText(err.Error(), []string{c.key, key})}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Never pass raw provider bodies through the outcome or log them directly.
|
||||
details, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
|
||||
outcome := Outcome{HTTPStatus: resp.StatusCode}
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
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)
|
||||
}
|
||||
outcome.State, outcome.Code, outcome.RetryAfter = model.RetryWait, "provider_rate_limited", delay
|
||||
case resp.StatusCode >= 500 || resp.StatusCode == 408:
|
||||
outcome.State, outcome.Code = model.Unknown, "provider_outcome_unknown"
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
outcome.State, outcome.Code = model.Failed, "provider_authentication_failed"
|
||||
default:
|
||||
outcome.State, outcome.Code = model.Failed, "provider_rejected"
|
||||
}
|
||||
if outcome.State != model.Accepted {
|
||||
if readErr != nil || len(details) > maxErrorBody {
|
||||
outcome.ProviderMessage = "Provider error details unavailable: incomplete or oversized response"
|
||||
} else {
|
||||
outcome.ProviderError, outcome.ProviderMessage = providerFailure(details, body, c.key, key)
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
func smtpMessage(s smtpSettings, key string, email model.Email) ([]byte, error) {
|
||||
if !ValidAddress(email.Address) || strings.ContainsAny(email.Subject, "\r\n") || (email.Text == "" && email.HTML == "") {
|
||||
return nil, merrors.InvalidArgument("invalid email content")
|
||||
}
|
||||
var body bytes.Buffer
|
||||
parts := multipart.NewWriter(&body)
|
||||
for _, part := range []struct{ kind, value string }{{"text/plain", email.Text}, {"text/html", email.HTML}} {
|
||||
if part.value == "" {
|
||||
continue
|
||||
}
|
||||
writer, err := parts.CreatePart(textproto.MIMEHeader{"Content-Type": {part.kind + "; charset=utf-8"}, "Content-Transfer-Encoding": {"quoted-printable"}})
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "create email MIME part")
|
||||
}
|
||||
encoded := quotedprintable.NewWriter(writer)
|
||||
if _, err = encoded.Write([]byte(part.value)); err != nil {
|
||||
return nil, merrors.InternalWrap(err, "encode email MIME part")
|
||||
}
|
||||
if err = encoded.Close(); err != nil {
|
||||
return nil, merrors.InternalWrap(err, "finish email MIME part")
|
||||
}
|
||||
}
|
||||
if err := parts.Close(); err != nil {
|
||||
return nil, merrors.InternalWrap(err, "finish email MIME body")
|
||||
}
|
||||
from := (&mail.Address{Name: s.SenderName, Address: s.SenderAddress}).String()
|
||||
to := (&mail.Address{Address: email.Address}).String()
|
||||
domain := s.SenderAddress[strings.LastIndex(s.SenderAddress, "@")+1:]
|
||||
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%x@%s>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=%q\r\n\r\n", from, to, mime.QEncoding.Encode("utf-8", email.Subject), time.Now().UTC().Format(time.RFC1123Z), sha256.Sum256([]byte(key)), domain, parts.Boundary())
|
||||
return append([]byte(headers), body.Bytes()...), nil
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
type smtpSettings struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
PasswordEnv string `mapstructure:"password_env"`
|
||||
SenderAddress string `mapstructure:"sender_address"`
|
||||
SenderName string `mapstructure:"sender_name"`
|
||||
NetworkTimeout int `mapstructure:"network_timeout"`
|
||||
}
|
||||
|
||||
type smtpSender struct {
|
||||
settings smtpSettings
|
||||
password string
|
||||
tls *tls.Config
|
||||
}
|
||||
|
||||
func newSMTP(config Config) (Sender, error) {
|
||||
var s smtpSettings
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "create SMTP settings decoder")
|
||||
}
|
||||
if decoder.Decode(config.Settings) != nil || s.Host == "" || strings.ContainsAny(s.Host, " /@\r\n\t") || s.Port < 1 || s.Port > 65535 || !ValidAddress(s.Username) || !ValidAddress(s.SenderAddress) || strings.ContainsAny(s.SenderName, "\r\n") || s.NetworkTimeout < 1 || s.NetworkTimeout > 20 {
|
||||
return nil, merrors.InvalidArgument("invalid SMTP settings")
|
||||
}
|
||||
password := os.Getenv(s.PasswordEnv)
|
||||
if s.PasswordEnv == "" || password == "" || strings.ContainsAny(password, "\r\n\x00") {
|
||||
return nil, merrors.FailedPrecondition("SMTP password environment variable is missing or invalid")
|
||||
}
|
||||
return &smtpSender{settings: s, password: password, tls: &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}}, nil
|
||||
}
|
||||
|
||||
// SMTP uses a fresh authenticated TLS connection per attempt. No automatic
|
||||
// resend follows an uncertain result, since SMTP has no idempotency guarantee.
|
||||
func (s *smtpSender) Send(ctx context.Context, key string, email model.Email) Outcome {
|
||||
body, err := smtpMessage(s.settings, key, email)
|
||||
if err != nil {
|
||||
return Outcome{State: model.Failed, Code: "invalid_payload"}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Duration(s.settings.NetworkTimeout)*time.Second)
|
||||
defer cancel()
|
||||
dialer := tls.Dialer{NetDialer: &net.Dialer{}, Config: s.tls}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(s.settings.Host, strconv.Itoa(s.settings.Port)))
|
||||
if err != nil {
|
||||
return s.failure(err, key, email)
|
||||
}
|
||||
defer conn.Close()
|
||||
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||||
defer stop()
|
||||
deadline, _ := ctx.Deadline()
|
||||
if err = conn.SetDeadline(deadline); err != nil {
|
||||
return s.failure(err, key, email)
|
||||
}
|
||||
client, err := smtp.NewClient(conn, s.settings.Host)
|
||||
if err == nil {
|
||||
err = client.Auth(smtp.PlainAuth("", s.settings.Username, s.password, s.settings.Host))
|
||||
}
|
||||
if err == nil {
|
||||
err = client.Mail(s.settings.SenderAddress)
|
||||
}
|
||||
if err == nil {
|
||||
err = client.Rcpt(email.Address)
|
||||
}
|
||||
if err != nil {
|
||||
return s.failure(err, key, email)
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return s.failure(err, key, email)
|
||||
}
|
||||
if _, err = io.Copy(writer, bytes.NewReader(body)); err == nil {
|
||||
err = writer.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return s.failure(err, key, email)
|
||||
}
|
||||
// DATA's final 250 is acceptance. A later QUIT error cannot undo it.
|
||||
_ = client.Quit()
|
||||
return Outcome{State: model.Accepted, SMTPStatus: 250}
|
||||
}
|
||||
|
||||
func (s *smtpSender) failure(err error, key string, email model.Email) Outcome {
|
||||
secrets := []string{s.password, s.settings.Username, key, email.Address, email.Subject, email.HTML, email.Text}
|
||||
secrets = append(secrets, base64.StdEncoding.EncodeToString([]byte("\x00"+s.settings.Username+"\x00"+s.password)))
|
||||
secrets = append(secrets, numbers.FindAllString(email.Text+" "+email.HTML, -1)...)
|
||||
out := Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderError: "smtp", ProviderMessage: safeErrorText(err.Error(), secrets)}
|
||||
var reply *textproto.Error
|
||||
if errors.As(err, &reply) {
|
||||
out.SMTPStatus = reply.Code
|
||||
if reply.Code >= 400 && reply.Code < 500 {
|
||||
out.State, out.Code, out.RetryAfter = model.RetryWait, "provider_temporarily_unavailable", time.Minute
|
||||
} else if reply.Code >= 500 && reply.Code < 600 {
|
||||
out.State, out.Code = model.Failed, "provider_rejected"
|
||||
if reply.Code == 530 || reply.Code == 534 || reply.Code == 535 || reply.Code == 538 {
|
||||
out.Code = "provider_authentication_failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
type smtpFixture struct {
|
||||
settings smtpSettings
|
||||
root *x509.CertPool
|
||||
body chan []byte
|
||||
auth atomic.Bool
|
||||
}
|
||||
|
||||
func smtpServer(t *testing.T, reject string, code int) *smtpFixture {
|
||||
t.Helper()
|
||||
certServer := httptest.NewTLSServer(nil)
|
||||
cert := certServer.TLS.Certificates[0]
|
||||
root := x509.NewCertPool()
|
||||
root.AddCert(certServer.Certificate())
|
||||
certServer.Close()
|
||||
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
host, port, _ := net.SplitHostPort(listener.Addr().String())
|
||||
n, _ := strconv.Atoi(port)
|
||||
f := &smtpFixture{settings: smtpSettings{Host: host, Port: n, Username: "sender@example.com", SenderAddress: "sender@example.com", SenderName: "SXCHNGE", NetworkTimeout: 2}, root: root, body: make(chan []byte, 1)}
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
if reject == "greeting" {
|
||||
_ = conn.(*tls.Conn).Handshake()
|
||||
_, _ = io.Copy(io.Discard, conn)
|
||||
return
|
||||
}
|
||||
c := textproto.NewConn(conn)
|
||||
if c.PrintfLine("220 localhost ESMTP") != nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
line, err := c.ReadLine()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
command := strings.SplitN(line, " ", 2)[0]
|
||||
if command == reject {
|
||||
_ = c.PrintfLine("%d rejected recipient user@example.com credential test-password", code)
|
||||
continue
|
||||
}
|
||||
switch command {
|
||||
case "EHLO":
|
||||
_ = c.PrintfLine("250-localhost\r\n250 AUTH PLAIN")
|
||||
case "AUTH":
|
||||
want := "AUTH PLAIN " + base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00test-password"))
|
||||
f.auth.Store(line == want)
|
||||
_ = c.PrintfLine("235 authenticated")
|
||||
case "MAIL", "RCPT":
|
||||
_ = c.PrintfLine("250 OK")
|
||||
case "DATA":
|
||||
_ = c.PrintfLine("354 send message")
|
||||
body, err := c.ReadDotBytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f.body <- body
|
||||
if reject == "final" {
|
||||
_ = c.PrintfLine("%d final rejection", code)
|
||||
} else if reject == "disconnect" {
|
||||
return
|
||||
} else {
|
||||
_ = c.PrintfLine("250 queued")
|
||||
}
|
||||
case "QUIT":
|
||||
// A lost QUIT reply must not turn accepted DATA into UNKNOWN.
|
||||
return
|
||||
default:
|
||||
_ = c.PrintfLine("500 unexpected command")
|
||||
}
|
||||
}
|
||||
}()
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *smtpFixture) sender() *smtpSender {
|
||||
return &smtpSender{settings: f.settings, password: "test-password", tls: &tls.Config{ServerName: f.settings.Host, RootCAs: f.root, MinVersion: tls.VersionTLS12}}
|
||||
}
|
||||
|
||||
func TestSMTPAcceptanceAndMIME(t *testing.T) {
|
||||
f := smtpServer(t, "", 0)
|
||||
sender := f.sender()
|
||||
email := model.Email{Address: "user@example.com", Subject: "Подтвердите почту", Text: "Confirm code 123456\n.line", HTML: "<p>Confirm code 123456</p>"}
|
||||
out := sender.Send(context.Background(), "notification-id", email)
|
||||
if out.State != model.Accepted || out.SMTPStatus != 250 || !f.auth.Load() {
|
||||
t.Fatal("SMTP acceptance/authentication failed", out)
|
||||
}
|
||||
msg, err := mail.ReadMessage(strings.NewReader(string(<-f.body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subject, err := new(mime.WordDecoder).DecodeHeader(msg.Header.Get("Subject"))
|
||||
if err != nil || subject != email.Subject {
|
||||
t.Fatal("wrong MIME subject", subject, err)
|
||||
}
|
||||
if msg.Header.Get("From") != "\"SXCHNGE\" <sender@example.com>" || !strings.HasSuffix(msg.Header.Get("Message-ID"), "@example.com>") {
|
||||
t.Fatal("invalid sender/message identifier")
|
||||
}
|
||||
kind, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil || kind != "multipart/alternative" {
|
||||
t.Fatal("wrong MIME type", kind, err)
|
||||
}
|
||||
parts := multipart.NewReader(msg.Body, params["boundary"])
|
||||
for _, want := range []string{email.Text, email.HTML} {
|
||||
part, err := parts.NextPart()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(part)
|
||||
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != want {
|
||||
t.Fatal("incorrect MIME content", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPFailureSafety(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
stage string
|
||||
code int
|
||||
state model.State
|
||||
failure string
|
||||
}{
|
||||
{"AUTH", 535, model.Failed, "provider_authentication_failed"},
|
||||
{"RCPT", 550, model.Failed, "provider_rejected"},
|
||||
{"final", 451, model.RetryWait, "provider_temporarily_unavailable"},
|
||||
{"disconnect", 0, model.Unknown, "provider_outcome_unknown"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s-%d", tc.stage, tc.code), func(t *testing.T) {
|
||||
f := smtpServer(t, tc.stage, tc.code)
|
||||
out := f.sender().Send(context.Background(), "id", model.Email{Address: "user@example.com", Subject: "Subject", Text: "Body"})
|
||||
if out.State != tc.state || out.Code != tc.failure || out.SMTPStatus != tc.code {
|
||||
t.Fatal("incorrect SMTP classification", out)
|
||||
}
|
||||
if strings.Contains(out.ProviderMessage, "test-password") || strings.Contains(out.ProviderMessage, "user@example.com") {
|
||||
t.Fatal("SMTP rejection leaked private data")
|
||||
}
|
||||
if tc.code == 451 && out.RetryAfter <= 0 {
|
||||
t.Fatal("temporary rejection did not schedule retry")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPCertificateAndCancellation(t *testing.T) {
|
||||
t.Run("untrusted certificate", func(t *testing.T) {
|
||||
f := smtpServer(t, "", 0)
|
||||
sender := f.sender()
|
||||
sender.tls.RootCAs = x509.NewCertPool()
|
||||
if out := sender.Send(context.Background(), "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || f.auth.Load() {
|
||||
t.Fatal("untrusted TLS accepted", out)
|
||||
}
|
||||
})
|
||||
t.Run("cancel blocked greeting", func(t *testing.T) {
|
||||
f := smtpServer(t, "greeting", 0)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
if out := f.sender().Send(ctx, "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || time.Since(started) > time.Second {
|
||||
t.Fatal("SMTP ignored cancellation", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSMTPConfigAndHeaderValidation(t *testing.T) {
|
||||
t.Setenv("TEST_SMTP_PASSWORD", "test-password")
|
||||
valid := map[string]any{"host": "mail.example.com", "port": 465, "username": "sender@example.com", "password_env": "TEST_SMTP_PASSWORD", "sender_address": "sender@example.com", "network_timeout": 10}
|
||||
if _, err := New(Config{Driver: SMTP, Settings: valid}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for field, value := range map[string]any{"port": 0, "host": "https://bad", "username": "bad", "network_timeout": 0, "password_env": "MISSING_SMTP_PASSWORD", "typo": true} {
|
||||
settings := map[string]any{}
|
||||
for key, item := range valid {
|
||||
settings[key] = item
|
||||
}
|
||||
settings[field] = value
|
||||
if _, err := New(Config{Driver: SMTP, Settings: settings}); err == nil {
|
||||
t.Fatalf("invalid setting %s accepted", field)
|
||||
}
|
||||
}
|
||||
if _, err := smtpMessage(smtpSettings{}, "id", model.Email{Address: "user@example.com", Subject: "Subject\r\nBcc: stolen@example.com", Text: "Body"}); err == nil {
|
||||
t.Fatal("header injection accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPAuthenticationDiagnosticRedactsEncodedCredential(t *testing.T) {
|
||||
f := &smtpSender{settings: smtpSettings{Username: "sender@example.com"}, password: "a"}
|
||||
auth := base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00a"))
|
||||
out := f.failure(&textproto.Error{Code: 535, Msg: "Rejected AUTH " + auth}, "id", model.Email{})
|
||||
if out.SMTPStatus != 535 || strings.Contains(out.ProviderMessage, auth) || !strings.Contains(out.ProviderMessage, "AUTH [redacted]") {
|
||||
t.Fatalf("encoded SMTP credential was not fully redacted: %q", out.ProviderMessage)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 & 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 & escaped</p>" || delivered.Subject != "Hello" || delivered.Address != "user@example.com" {
|
||||
t.Fatal("parameters did not reach driver", delivered)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||
"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
|
||||
}
|
||||
var outcome email.Outcome
|
||||
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()
|
||||
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 outcome.HTTPStatus != 0 {
|
||||
fields = append(fields, zap.Int("provider_status", outcome.HTTPStatus))
|
||||
}
|
||||
if outcome.SMTPStatus != 0 {
|
||||
fields = append(fields, zap.Int("provider_smtp_status", outcome.SMTPStatus))
|
||||
}
|
||||
if outcome.ProviderError != "" {
|
||||
fields = append(fields, zap.String("provider_error", outcome.ProviderError))
|
||||
}
|
||||
if outcome.ProviderMessage != "" {
|
||||
fields = append(fields, zap.String("provider_message", outcome.ProviderMessage))
|
||||
}
|
||||
// Keep the provider outcome observable even if its local result write fails.
|
||||
if n.State == model.Accepted {
|
||||
s.logger.Info("Email accepted by provider", fields...)
|
||||
} else {
|
||||
s.logger.Warn("Email delivery attempt finished", fields...)
|
||||
}
|
||||
if err = s.repo.Finish(finishCtx, n); err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
type workerRepo struct {
|
||||
Repository
|
||||
job model.Notification
|
||||
saved *model.Notification
|
||||
finishErr error
|
||||
}
|
||||
|
||||
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 r.finishErr
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerLogsProviderFailureOnce(t *testing.T) {
|
||||
for _, failPersistence := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(failPersistence), func(t *testing.T) {
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
repo := &workerRepo{job: model.Notification{Driver: "resend", ExpiresAt: time.Now().Add(time.Hour), Attempts: 1, Email: model.Email{Address: "private@example.com", Subject: "Private subject", Text: "Private text"}}}
|
||||
if failPersistence {
|
||||
repo.finishErr = merrors.ServiceUnavailable("storage offline")
|
||||
}
|
||||
sender := &workerSender{outcome: email.Outcome{State: model.Failed, Code: "provider_authentication_failed", HTTPStatus: 403, ProviderError: "validation_error", ProviderMessage: "The sxchnge.com domain is not verified"}}
|
||||
svc := New(repo, nil, sender, "resend", zap.New(core))
|
||||
if _, err := svc.Dispatch(context.Background(), "id"); !errors.Is(err, repo.finishErr) {
|
||||
t.Fatal("unexpected persistence error", err)
|
||||
}
|
||||
entries := logs.All()
|
||||
if len(entries) != 1 || entries[0].Message != "Email delivery attempt finished" {
|
||||
t.Fatal("expected one provider outcome log", entries)
|
||||
}
|
||||
fields := entries[0].ContextMap()
|
||||
if fields["provider_status"] != int64(403) || fields["provider_error"] != "validation_error" || fields["provider_message"] != "The sxchnge.com domain is not verified" {
|
||||
t.Fatal("provider diagnostic missing", fields)
|
||||
}
|
||||
if repo.saved.FailureCode != "provider_authentication_failed" {
|
||||
t.Fatal("public failure code changed", repo.saved.FailureCode)
|
||||
}
|
||||
for _, field := range []string{"address", "email", "subject", "text", "html", "parameters"} {
|
||||
if _, ok := fields[field]; ok {
|
||||
t.Fatalf("private notification field %s logged", field)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package server
|
||||
|
||||
// Typical service interface
|
||||
type Application interface {
|
||||
Shutdown()
|
||||
Start() error
|
||||
}
|
||||
@@ -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}}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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")}
|
||||
deps.Auth.Wallet = deps.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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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 CurrencyAccounts interface {
|
||||
Ensure(context.Context, *customer.Account) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Facts SignupFacts
|
||||
Wallet CurrencyAccounts
|
||||
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 s.Wallet != nil {
|
||||
if err := s.Wallet.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
|
||||
}
|
||||
}
|
||||
if s.Wallet != nil {
|
||||
if err := s.Wallet.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) (string, error) {
|
||||
if len(raw) != 43 {
|
||||
return "", verification.ErorrTokenNotFound()
|
||||
}
|
||||
var email string
|
||||
_, 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
|
||||
}
|
||||
}
|
||||
if err := s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email = a.Login
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Loaded 100 of 553 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user