feat(notification): implement the notification delivery platform
Maps the 31-module plan onto the registry's 19 leaves as packages; the two edges the registry forbids (provider->httpclient, inbox->messaging) are replaced by application-owned ports. See docs/notification/module-mapping.md. Acceptance is not delivery: ProviderSubmissionResult refuses to carry a delivery outcome, and AMBIGUOUS is a first-class terminal state that blocks automatic retry and fallback until reconciliation resolves it. Providers: SES (SigV4 + SNS callback), Twilio (X-Twilio-Signature + reconciliation), FCM (FID-primary batch), APNs, Web Push (RFC 8030/8291/8292), SMTP and webhook. Contact points are AES-256-GCM encrypted with a separate HMAC lookup fingerprint; nothing raw reaches a log, metric tag or exception. Dispatch commits the attempt row, calls the provider with no transaction open, then records the outcome; the durable queue uses FOR UPDATE SKIP LOCKED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b5aee50e3
commit
701ba67456
@@ -0,0 +1,56 @@
|
||||
# Security and privacy
|
||||
|
||||
## Protected values
|
||||
|
||||
Email addresses, phone numbers, FCM installation ids and legacy tokens, APNs device tokens, Web Push
|
||||
endpoints and keys, VAPID private keys, provider credentials, callback signing secrets, template
|
||||
variables, rendered bodies, attachment references and unsubscribe tokens.
|
||||
|
||||
## At rest
|
||||
|
||||
Contact points are encrypted with AES-256-GCM. Equality lookup uses a separate HMAC-SHA-256
|
||||
fingerprint.
|
||||
|
||||
Two keys, not one, because the requirements are opposite: the ciphertext must be non-deterministic so
|
||||
two records of the same address are not visibly identical, while equality lookup must be
|
||||
deterministic. The fingerprint is keyed rather than a plain digest because phone numbers and email
|
||||
addresses come from a small, enumerable space — an unkeyed hash of a phone number is recoverable in
|
||||
seconds.
|
||||
|
||||
The contact point kind is bound into the GCM associated data, so a ciphertext cannot be moved between
|
||||
contact kinds without failing the authentication tag.
|
||||
|
||||
An unknown key id is refused rather than silently falling back to the current key: a silent fallback
|
||||
would turn every historical row into a tag failure at read time.
|
||||
|
||||
## Never logged, never a metric tag
|
||||
|
||||
Addresses, tokens, Web Push endpoints and keys, message bodies, template variables, provider
|
||||
credentials, unsubscribe tokens, attachment URLs, raw callback payloads and raw provider request ids.
|
||||
|
||||
Two mechanisms enforce this rather than convention:
|
||||
|
||||
- `CardinalityGuard` validates every metric tag against a closed allowlist.
|
||||
- `SafeDiagnosticContext` rejects any structured-diagnostic field outside its allowlist.
|
||||
|
||||
An allowlist rather than a denylist, because the failure mode of a denylist is that the one field
|
||||
nobody thought of is the one that leaks.
|
||||
|
||||
Every contact point value type overrides `toString()` to print `[redacted]`. That covers the case a
|
||||
central redactor cannot: a value interpolated into a log line by accident.
|
||||
|
||||
## Web Push endpoints
|
||||
|
||||
RFC 8030 defines the push URI as a capability URL — knowing it is sufficient to push to the
|
||||
subscriber. It is handled as a secret, not as a URL.
|
||||
|
||||
## Callbacks
|
||||
|
||||
TLS, provider signature verification over the exact received bytes and external URL, replay defence
|
||||
where a timestamp or nonce is available, body-size and content-type limits, profile binding, rate
|
||||
limiting, idempotent ingestion and a security audit trail for rejections.
|
||||
|
||||
## Tenant isolation
|
||||
|
||||
Every store port carries the tenant boundary in its signature. Administrative operations require an
|
||||
explicit tenant or a global authority.
|
||||
Reference in New Issue
Block a user