docs: plan and design artifacts for SaaS integration feature
/speckit-plan output for 002-saas-integration: technical context and constitution gate check (all PASS), Phase 0 research (9 decisions: signed-token format, replay resistance via Redis jti tracking, clock skew tolerance, rotation via previous-credential transition window, per-integration/per-user rate limiting, strict unknown-field rejection, non-leaking error responses, and reconciling the placeholder Prisma schema with docs/06's real Product shape), Phase 1 data model (Product revision, ProductIntegration, CustomerReference, AuditLog reuse for auth events), the inbound request validation contract (9 fixed steps + admin lifecycle endpoints), and an 8-scenario quickstart. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0e92faf615
commit
a7216b7531
@@ -0,0 +1,64 @@
|
||||
# Contract: Inbound SaaS Request Authentication
|
||||
|
||||
## Request
|
||||
|
||||
Every inbound request from an integrated SaaS product carries:
|
||||
|
||||
- **Header**: `Authorization: Bearer <signed-token>` — the signed short-lived token from
|
||||
research.md ("Signed short-lived token format").
|
||||
- **Body**: JSON matching the Inbound Request Contract shape in `data-model.md`, validated with a
|
||||
`.strict()` Zod schema.
|
||||
|
||||
## Validation order (fixed — each step's failure short-circuits the rest)
|
||||
|
||||
1. **Token parses and verifies** against a known `ProductIntegration.credentialRef` or
|
||||
non-expired `previousCredentialRef` → else `401 INVALID_INTEGRATION_CREDENTIAL`.
|
||||
2. **Token not expired** (beyond the configured clock-skew tolerance) → else
|
||||
`401 INVALID_INTEGRATION_CREDENTIAL`.
|
||||
3. **Token `jti` not previously seen** (replay check against Redis) → else
|
||||
`401 INVALID_INTEGRATION_CREDENTIAL`.
|
||||
4. **`ProductIntegration.revokedAt IS NULL`** → else `401 INVALID_INTEGRATION_CREDENTIAL`.
|
||||
5. **`ProductIntegration.status == 'active'`** → else `403 PRODUCT_INTEGRATION_SUSPENDED`.
|
||||
6. **`Product.status == 'active'`** → else `403 PRODUCT_INTEGRATION_SUSPENDED`.
|
||||
7. **Request body matches the strict schema** (no unknown fields) → else `400 VALIDATION_ERROR`.
|
||||
8. **`tenantId`/`userId` fall within `ProductIntegration.allowedScope`** → else
|
||||
`403 REQUEST_OUT_OF_SCOPE`.
|
||||
9. **Rate limit (integration-level, then user-level) not exceeded** → else `429 RATE_LIMITED`.
|
||||
|
||||
Only after all nine checks pass does `request.reqContext` get populated
|
||||
(`productId` → internal `Product.id`, `customerId` → `CustomerReference.id`,
|
||||
`tenantId` → `externalTenantId`, `actorType` → `CUSTOMER`, `actorId` → `externalUserId`) and the
|
||||
request reaches its route handler. Every attempt — pass or fail at any step — writes one
|
||||
`AuditLog` row (data-model.md).
|
||||
|
||||
## Guarantees (callable contract)
|
||||
|
||||
1. **No side effect before full validation.** No `CustomerReference` row, no `AuditLog` success
|
||||
row, no downstream processing happens until step 9 passes.
|
||||
2. **Identical response for "unregistered" and "invalid credential."** Per research.md's FR-010
|
||||
decision — callers cannot distinguish "you don't exist" from "you exist but this credential is
|
||||
wrong."
|
||||
3. **Distinguishable suspension and scope errors.** `403 PRODUCT_INTEGRATION_SUSPENDED` and
|
||||
`403 REQUEST_OUT_OF_SCOPE` are each their own error code, safe to distinguish per research.md.
|
||||
4. **No raw credential value ever appears in a log, audit row, or error response.**
|
||||
5. **A revoked credential is rejected starting with the very next request** — no propagation
|
||||
delay (SC-002).
|
||||
6. **During a rotation's transition window, both the old and new credential validate
|
||||
successfully** (SC-003).
|
||||
7. **An unknown field anywhere in the request body rejects the entire request**, not just that
|
||||
field (FR-008).
|
||||
|
||||
## Admin: Integration Lifecycle Endpoints
|
||||
|
||||
Extends the existing `catalog/products` module (`src/modules/catalog/products/`):
|
||||
|
||||
| Operation | Effect |
|
||||
|---|---|
|
||||
| Register integration | Creates `ProductIntegration` for a `Product` with a freshly generated `credentialRef`/secret, `authMechanism: 'signed_token'`, and an initial `allowedScope` |
|
||||
| Rotate credential | Moves current `credentialRef` → `previousCredentialRef`, sets `previousCredentialExpiresAt`, issues a new `credentialRef`/secret; returns the new secret exactly once (never retrievable again, matches "never persist raw credential" — only a reference/hash is stored) |
|
||||
| Revoke credential | Sets `revokedAt`; both current and previous credentials become invalid immediately |
|
||||
| Update status | Sets `ProductIntegration.status` (`active`/`suspended`) — independent of `Product.status` |
|
||||
| Get audit trail | Lists `AuditLog` rows where `entityType = 'ProductIntegration'` and `entityId` matches, newest first |
|
||||
|
||||
All five are admin-authenticated via the existing human/admin JWT `auth.plugin.ts` — a separate
|
||||
concern from the product-integration signed-token auth this contract otherwise describes.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Phase 1 Data Model: SaaS Product Integration & Inbound Request Trust
|
||||
|
||||
All models below use `cuid()` ids, matching `docs/06-database-schema.md`. This supersedes the
|
||||
current placeholder `Product` model in `prisma/schema.prisma` (see research.md "Reconciling the
|
||||
placeholder Prisma schema").
|
||||
|
||||
## Product (revised)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | String @id @default(cuid()) | |
|
||||
| externalProductId | String @unique | Reference into the SaaS — not authoritative here (Constitution Principle I) |
|
||||
| name | String | |
|
||||
| supportEnabled | Boolean @default(true) | Per spec.md/docs/01 §5: support is enabled by default per product |
|
||||
| status | String | `active` \| `suspended` \| `deprecated` — admin-editable, drives FR-011/FR-012 |
|
||||
| createdAt / updatedAt | DateTime | |
|
||||
|
||||
**Relations added by this feature**: `integration ProductIntegration?` (1:1). Relations to
|
||||
`KnowledgeEntry[]`, `Runbook[]`, `Ticket[]` from doc 06 are deferred until those models exist in
|
||||
their owning features (Phase 3/5) — Prisma can't reference a model that doesn't exist yet.
|
||||
|
||||
## ProductIntegration
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | String @id @default(cuid()) | |
|
||||
| productId | String @unique | 1:1 with Product |
|
||||
| credentialRef | String | Pointer into secret manager / the current signing secret's identifier — never the raw secret value (FR-010) |
|
||||
| previousCredentialRef | String? | Set during a rotation's transition window (research.md) |
|
||||
| previousCredentialExpiresAt | DateTime? | When the previous credential stops being accepted |
|
||||
| authMechanism | String | `signed_token` for this feature; free-text so a future integration can use `oauth2_client_credentials` or `mtls` without a schema change |
|
||||
| allowedScope | Json | Structured scope: at minimum `{ tenantIds?: string[], allowAnyTenant?: boolean }` — validated against inbound `tenantId`/`userId` (FR-003) |
|
||||
| rateLimitPerMinute | Int @default(60) | Integration-level limit (FR-009), admin-editable |
|
||||
| rateLimitPerUserPerMinute | Int @default(20) | Per-user-within-integration limit (FR-009) |
|
||||
| status | String @default("active") | `active` \| `suspended` — independent of Product.status so an integration can be disabled without touching the product record |
|
||||
| rotatedAt | DateTime? | Last rotation timestamp |
|
||||
| revokedAt | DateTime? | Set on revocation; a revoked integration's `credentialRef` and `previousCredentialRef` are both immediately invalid regardless of `previousCredentialExpiresAt` |
|
||||
| createdAt | DateTime @default(now()) | |
|
||||
|
||||
**Validation rule**: A token is accepted only if it verifies against `credentialRef`, OR against
|
||||
`previousCredentialRef` AND `now() < previousCredentialExpiresAt` — AND `revokedAt IS NULL` — AND
|
||||
the owning `Product.status == 'active'` AND this record's own `status == 'active'`.
|
||||
|
||||
## CustomerReference
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | String @id @default(cuid()) | |
|
||||
| externalUserId | String | Reference only — never authoritative (Constitution Principle I) |
|
||||
| externalTenantId | String | |
|
||||
| createdAt | DateTime @default(now()) | |
|
||||
|
||||
**Unique constraint**: `@@unique([externalUserId, externalTenantId])` — first-seen wins; repeat
|
||||
requests for the same user+tenant reuse the same reference row rather than creating duplicates.
|
||||
|
||||
## Authentication Audit Event → reuses `AuditLog`
|
||||
|
||||
No new model. Every authentication attempt writes one `AuditLog` row:
|
||||
|
||||
| AuditLog field | Value for an auth event |
|
||||
|---|---|
|
||||
| actor | The `ProductIntegration.id` if resolvable (even on failure, once the credential at least identifies *a* product), else `"unknown"` |
|
||||
| actorType | `system` |
|
||||
| action | `integration.auth.success` \| `integration.auth.failure` |
|
||||
| entityType | `ProductIntegration` |
|
||||
| entityId | The `ProductIntegration.id` |
|
||||
| reason | On failure: which check failed (`invalid_credential` \| `suspended` \| `out_of_scope` \| `expired` \| `replayed`) — never the raw token/credential |
|
||||
| metadata | `{ externalUserId?, externalTenantId? }` — no raw credential value, ever (FR-007/FR-010) |
|
||||
| createdAt | now() |
|
||||
|
||||
## Inbound Request Contract (validated shape, not persisted as its own table)
|
||||
|
||||
Extends `docs/02-integration-and-security.md` §3 with the reserved idempotency field from
|
||||
spec.md FR-012:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| productId | string | The *external* product id as the caller knows it — resolved to internal `Product.id` during validation |
|
||||
| tenantId | string | → `CustomerReference.externalTenantId` |
|
||||
| userId | string | → `CustomerReference.externalUserId` |
|
||||
| source | string | e.g. `"docuqube-web"` |
|
||||
| problem | string | Free-text — not validated/interpreted by this feature |
|
||||
| feature | string? | Optional |
|
||||
| referenceIds | string[]? | Optional |
|
||||
| context | Record<string, unknown>? | Optional |
|
||||
| idempotencyKey | string? | Reserved per FR-012 — accepted and echoed if present, not deduplicated against anything yet |
|
||||
|
||||
Validated with a Zod `.strict()` schema (research.md) — any field not in this list rejects the
|
||||
whole request.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Implementation Plan: SaaS Product Integration & Inbound Request Trust
|
||||
|
||||
**Branch**: `002-saas-integration` | **Date**: 2026-08-21 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/002-saas-integration/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add the trust boundary between an integrating SaaS product and SupportHub: a `ProductIntegration`
|
||||
record per product (credential reference, allowed scope, rotation/revocation state), a signed
|
||||
short-lived-token service-to-service auth mechanism validated on every inbound request via a new
|
||||
Fastify plugin, per-integration/per-user rate limiting, and admin CRUD for onboarding/rotating/
|
||||
revoking an integration. Populates the existing `RequestContext` (`productId`/`customerId`/
|
||||
`tenantId`) so every later module can trust that context without re-validating it.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+, matching the rest of the repo.
|
||||
|
||||
**Primary Dependencies**: Fastify (new plugin), `@fastify/rate-limit` (already a dependency,
|
||||
currently registered with a single global limit — extended with a per-route `keyGenerator`),
|
||||
Zod (inbound contract schema), Prisma (new models), Node's built-in `crypto` (HMAC signing/
|
||||
verification for the signed-token mechanism — no new signing library needed).
|
||||
|
||||
**Storage**: PostgreSQL via Prisma — adds `ProductIntegration` and `CustomerReference` models,
|
||||
and aligns the existing placeholder `Product` model with `docs/06-database-schema.md`'s real
|
||||
shape (see research.md "Reconciling the placeholder Prisma schema").
|
||||
|
||||
**Testing**: Vitest — unit tests for token verification/scope-checking logic, integration tests
|
||||
for the full inbound-request preHandler against a real Postgres (per existing
|
||||
`docker-compose.test.yml`, wired up by the 001-ci-pipeline feature).
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith; this feature adds one new Fastify plugin and
|
||||
one module's worth of admin endpoints — no new service, no new deployable unit.
|
||||
|
||||
**Project Type**: Backend service — single project, no frontend changes in this feature (an admin
|
||||
UI for onboarding/rotating integrations is Phase 10 per the roadmap; this feature only needs the
|
||||
API surface admin tooling will eventually call).
|
||||
|
||||
**Performance Goals**: Credential/token validation must not add meaningfully to request latency —
|
||||
target under 10ms added overhead per request for the signed-token verification path (in-process
|
||||
HMAC check, no external call).
|
||||
|
||||
**Constraints**: MUST NOT log or persist raw credential/token values (FR-007, FR-010 from
|
||||
spec.md); MUST reject unknown fields on the inbound contract (FR-008); rate limiting MUST be
|
||||
adjustable without a deploy (Constitution Principle II).
|
||||
|
||||
**Scale/Scope**: One inbound endpoint contract (the `ProductToSupportHubRequest` shape from
|
||||
doc 02 §3, extended with the reserved `idempotencyKey` field from spec.md FR-012), plus admin
|
||||
endpoints for integration lifecycle (register, rotate, revoke, list, get). Does not include
|
||||
ticket creation itself — this feature validates and trusts the request; acting on it (creating a
|
||||
ticket) is the ticketing feature.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle / Section | Check | Result |
|
||||
|---|---|---|
|
||||
| I. SaaS Is the Sole Identity & Access Authority | `CustomerReference` stores only `externalUserId`/`externalTenantId` as references, never a copy of SaaS user/tenant data; SupportHub never authenticates the end customer itself, only the product's service-to-service credential. | PASS |
|
||||
| II. Configuration Over Hardcoding | Rate limits, integration status, and credential scope are all admin-editable data (`ProductIntegration.allowedScope`, plus a new rate-limit config), not hardcoded. | PASS |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | New Fastify plugin is infrastructure (like `auth.plugin.ts`), not a module; it only reads validated data via the repository layer of the integration-management module — no controller touches Prisma directly. | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI involved in this feature. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable — no resolution/verification concept in this feature. | PASS — N/A |
|
||||
| VI. Durable Audit & History | Every auth attempt (success/failure) is written to the existing `AuditLog` model (FR-007) — reused, not duplicated. | PASS |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Credential rotation must not race with an in-flight validation using the old credential — handled by checking both old/new credential validity within the transition window rather than an atomic cutover (see research.md). No `setTimeout`-based expiry. | PASS |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — this feature predates both entities. | PASS — N/A |
|
||||
| Technology & Platform Constraints | Uses Fastify/Zod/Prisma/Node crypto only — no new runtime dependency added. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Post-Design Constitution Re-check
|
||||
|
||||
All gates above remain PASS after Phase 1 design (data-model.md, contracts/, quickstart.md).
|
||||
One design detail worth calling out explicitly: `ProductIntegration.authMechanism` is stored as
|
||||
free text, not an enum restricted to `signed_token` — this is deliberate so a future integration
|
||||
requiring OAuth2 or mTLS (both still valid per docs/02 §4) doesn't require a schema migration,
|
||||
keeping this decision genuinely configuration-driven (Principle II) rather than a hardcoded
|
||||
assumption that every integration uses the same mechanism forever.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/002-saas-integration/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output (inbound request contract + admin endpoints)
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-api/
|
||||
├── prisma/
|
||||
│ └── schema.prisma # MODIFIED — align Product with docs/06, add
|
||||
│ ProductIntegration, CustomerReference
|
||||
├── src/
|
||||
│ ├── plugins/
|
||||
│ │ ├── product-integration-auth.plugin.ts # NEW — validates inbound signed tokens,
|
||||
│ │ │ scope, product status; populates reqContext
|
||||
│ │ └── rate-limit.plugin.ts # MODIFIED — per-route keyGenerator support
|
||||
│ ├── common/
|
||||
│ │ └── types/
|
||||
│ │ └── request-context.types.ts # UNCHANGED — productId/customerId/tenantId
|
||||
│ │ already present, this feature just populates them
|
||||
│ └── modules/
|
||||
│ └── catalog/
|
||||
│ └── products/ # EXTENDED (existing scaffold) — adds
|
||||
│ ├── controller/ integration lifecycle endpoints alongside
|
||||
│ ├── service/ existing product endpoints, since
|
||||
│ ├── repository/ ProductIntegration is 1:1 with Product
|
||||
│ ├── schema/ per docs/06
|
||||
│ ├── mapper/
|
||||
│ └── types/
|
||||
└── tests/
|
||||
├── unit/ # token verification, scope-check logic
|
||||
└── integration/ # full preHandler against real Postgres
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project, extending the existing `catalog/products` module rather
|
||||
than introducing a new top-level module — `docs/07-backend-architecture.md`'s module list has no
|
||||
separate "product-integrations" module, and `docs/06-database-schema.md` nests
|
||||
`ProductIntegration` directly under Product's own domain grouping (1:1 relation). The inbound
|
||||
auth *validation* itself is cross-cutting request-handling infrastructure, so it lives in
|
||||
`src/plugins/`, matching the existing `auth.plugin.ts` pattern for human/admin JWT auth — these
|
||||
are two distinct auth concerns (product-to-SupportHub vs. person-to-SupportHub) and stay in
|
||||
separate plugins rather than merged into one.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,73 @@
|
||||
# Quickstart: Validating SaaS Product Integration & Inbound Request Trust
|
||||
|
||||
Prerequisites: local dev environment running (`docker:up:dev` or equivalent), Prisma migrated
|
||||
with this feature's schema changes applied, one `Product` + `ProductIntegration` seeded (or
|
||||
created via the admin endpoints below).
|
||||
|
||||
## Scenario 1 — a valid, in-scope request is accepted (User Story 1)
|
||||
|
||||
1. Register a product integration (admin endpoint) and note the returned signing secret.
|
||||
2. Sign a token for that integration with `tenantId`/`userId` values inside its `allowedScope`.
|
||||
3. Send the inbound request with `Authorization: Bearer <token>` and a body matching the contract.
|
||||
4. **Expected**: `200`-level response; a `CustomerReference` row exists for the `tenantId`/
|
||||
`userId`; an `AuditLog` row records `integration.auth.success`.
|
||||
|
||||
## Scenario 2 — invalid/unregistered credential is rejected (User Story 1)
|
||||
|
||||
1. Send the same request with a token signed by an arbitrary/wrong secret.
|
||||
2. **Expected**: `401 INVALID_INTEGRATION_CREDENTIAL`; no `CustomerReference` created; an
|
||||
`AuditLog` row records `integration.auth.failure` with `reason: invalid_credential`.
|
||||
3. Repeat with a `productId` that has never been registered at all.
|
||||
4. **Expected**: the exact same `401 INVALID_INTEGRATION_CREDENTIAL` response — confirm the two
|
||||
failure modes are indistinguishable from the response alone (FR-010).
|
||||
|
||||
## Scenario 3 — suspended product is distinguishably rejected (User Story 1, Edge Cases)
|
||||
|
||||
1. Set the seeded `ProductIntegration.status` (or `Product.status`) to suspended.
|
||||
2. Send a request with an otherwise-valid token.
|
||||
3. **Expected**: `403 PRODUCT_INTEGRATION_SUSPENDED` — distinguishable from Scenario 2's `401`.
|
||||
|
||||
## Scenario 4 — unknown field rejects the whole request (User Story 1)
|
||||
|
||||
1. Send an otherwise-valid request body with one extra, undefined field.
|
||||
2. **Expected**: `400 VALIDATION_ERROR` — the request is rejected outright, not partially
|
||||
processed with the extra field ignored.
|
||||
|
||||
## Scenario 5 — credential rotation is zero-downtime (User Story 2)
|
||||
|
||||
1. Rotate the seeded integration's credential (admin endpoint) — note both old and new secrets.
|
||||
2. Immediately send one request signed with the OLD secret and one with the NEW secret.
|
||||
3. **Expected**: both succeed (SC-003).
|
||||
4. Wait past the transition window (or adjust it down for the test), then retry with the OLD
|
||||
secret.
|
||||
5. **Expected**: `401 INVALID_INTEGRATION_CREDENTIAL` — old credential now rejected.
|
||||
|
||||
## Scenario 6 — revocation takes effect immediately (User Story 2)
|
||||
|
||||
1. Revoke the seeded integration's credential (admin endpoint).
|
||||
2. Immediately send a request signed with that credential.
|
||||
3. **Expected**: `401 INVALID_INTEGRATION_CREDENTIAL` on the very next request (SC-002); an
|
||||
`AuditLog` row records the revocation itself as an admin action.
|
||||
|
||||
## Scenario 7 — audit trail is retrievable (User Story 2)
|
||||
|
||||
1. After Scenarios 1-6 above, call the admin "get audit trail" endpoint for the seeded
|
||||
integration.
|
||||
2. **Expected**: a chronological list including the success from Scenario 1 and the failures from
|
||||
Scenarios 2-4, each without any raw credential value present anywhere in the response.
|
||||
|
||||
## Scenario 8 — rate limiting throttles one integration/user without affecting others (User Story 3)
|
||||
|
||||
1. Seed two separate product integrations, A and B.
|
||||
2. Send requests from integration A past its configured `rateLimitPerMinute`.
|
||||
3. **Expected**: later requests from A in the burst receive `429 RATE_LIMITED`; concurrent
|
||||
requests from integration B continue succeeding normally.
|
||||
4. Within integration A, send requests as two different `userId`s, one past
|
||||
`rateLimitPerUserPerMinute` and one under it.
|
||||
5. **Expected**: the over-limit user is throttled; the other user's requests continue succeeding.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All eight scenarios pass, and together they demonstrate every functional requirement and success
|
||||
criterion in `spec.md` without needing to read the plugin/module implementation to know what
|
||||
"correct" means.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Phase 0 Research: SaaS Product Integration & Inbound Request Trust
|
||||
|
||||
## Decision: Signed short-lived token format
|
||||
|
||||
- **Decision**: HMAC-SHA256-signed token, structured as a compact JWT
|
||||
(`header.payload.signature`), signed with the `ProductIntegration`'s own per-product secret
|
||||
(never a shared/global secret). Payload carries `productId` (SupportHub's internal id, not the
|
||||
raw external one), `tenantId`, `userId`, `iat`, `exp` (short — target 60s, generous enough for
|
||||
clock skew tolerance below), and a `jti` (nonce) for replay detection.
|
||||
- **Rationale**: User picked "signed short-lived tokens" over OAuth2/mTLS for this plan (lowest
|
||||
operational overhead, no token-issuance service to build, straightforward per-product secret
|
||||
rotation). JWT is chosen over a bespoke signed-string format purely because Node's ecosystem
|
||||
already has well-reviewed JWT libraries and it's a format integrating teams will recognize —
|
||||
but the *validation* logic is fully custom (see below), it doesn't defer trust decisions to a
|
||||
JWT library's defaults.
|
||||
- **Alternatives considered**: OAuth2 client-credentials (rejected per user decision — adds a
|
||||
token-issuance/introspection surface this plan doesn't need); mTLS (rejected — heavier
|
||||
operationally, reserved as a future per-integration option since `ProductIntegration
|
||||
.authMechanism` is already a free-text field in docs/06, not an enum, so nothing here blocks
|
||||
adding mTLS support to a specific high-trust integration later without a schema change).
|
||||
|
||||
## Decision: Replay resistance
|
||||
|
||||
- **Decision**: Reject a token whose `jti` has been seen before within its own validity window.
|
||||
Track seen `jti`s in Redis (already an infrastructure dependency — `infrastructure/cache`) with
|
||||
a TTL matching the token's `exp`, so the tracking set never grows unbounded.
|
||||
- **Rationale**: A 60-second token expiry alone bounds the replay window but doesn't close it — a
|
||||
captured token is still valid for up to 60s. `jti`-tracking closes it to "exactly once."
|
||||
- **Alternatives considered**: Expiry-only (no `jti` tracking) — rejected, doesn't satisfy
|
||||
spec.md's Edge Cases requirement that a replayed token "MUST be rejected," only that it
|
||||
eventually stops being accepted.
|
||||
|
||||
## Decision: Clock skew tolerance
|
||||
|
||||
- **Decision**: Accept a token up to 5 seconds past its `exp` and up to 5 seconds before its `iat`
|
||||
(both configurable, not hardcoded — Constitution Principle II).
|
||||
- **Rationale**: Small enough that it doesn't meaningfully widen the replay window beyond what
|
||||
`jti` tracking already closes, generous enough to absorb realistic NTP drift between two
|
||||
independently-operated systems.
|
||||
- **Alternatives considered**: Zero tolerance — rejected as operationally fragile; large tolerance
|
||||
(e.g. 60s) — rejected as unnecessarily widening the token's effective lifetime.
|
||||
|
||||
## Decision: Credential rotation mechanism
|
||||
|
||||
- **Decision**: `ProductIntegration` gains a nullable `previousCredentialRef` and
|
||||
`previousCredentialExpiresAt` alongside the existing `credentialRef`. On rotation: the current
|
||||
`credentialRef` moves to `previousCredentialRef` with `previousCredentialExpiresAt` set to
|
||||
"now + transition window," and a new `credentialRef` is issued. Token validation tries the
|
||||
current secret first, then the previous one (if `previousCredentialExpiresAt` hasn't passed).
|
||||
- **Rationale**: This is what makes rotation zero-downtime (SC-003) without a distributed
|
||||
"atomic cutover" — both secrets are simultaneously valid for a bounded window, which directly
|
||||
satisfies Constitution Principle VII's concurrency requirement without inventing new
|
||||
coordination infrastructure.
|
||||
- **Alternatives considered**: Versioned credential list (unbounded history) — rejected as more
|
||||
than the spec requires (only *one* prior credential needs to remain valid, per spec.md User
|
||||
Story 2's "old and new credential both valid during a transition window").
|
||||
|
||||
## Decision: Rate limiting design (per-integration and per-user)
|
||||
|
||||
- **Decision**: Apply `@fastify/rate-limit` at the route level (not the single global registration
|
||||
currently in `rate-limit.plugin.ts`) for the inbound SaaS-facing route, with a custom
|
||||
`keyGenerator` returning `` `integration:${productIntegrationId}` `` for the integration-level
|
||||
limit and a second, stricter per-route rate-limit instance keyed by
|
||||
`` `integration:${productIntegrationId}:user:${externalUserId}` `` for the per-user limit. Both
|
||||
read their max/window from `ProductIntegration`-linked configuration (new fields, see
|
||||
data-model.md), not a hardcoded value.
|
||||
- **Rationale**: The existing global `rate-limit.plugin.ts` registration (`max: 1000, timeWindow:
|
||||
'1 minute'`, ungated) stays as a blanket floor for the whole API; it doesn't get removed, just
|
||||
supplemented — this feature's per-integration/per-user limits are strictly tighter and specific
|
||||
to the inbound SaaS route. `keyGenerator` needs the validated `productIntegrationId`/
|
||||
`externalUserId`, so the rate-limit check runs in the route's handler chain *after* the
|
||||
`product-integration-auth` plugin's preHandler populates `request.reqContext`.
|
||||
- **Alternatives considered**: A single global per-IP limit — rejected, doesn't satisfy FR-009's
|
||||
"per integration and independently per end user" requirement; a product's shared outbound IP
|
||||
would incorrectly throttle every user behind it together.
|
||||
|
||||
## Decision: Unknown-field rejection (FR-008)
|
||||
|
||||
- **Decision**: The inbound Zod schema uses `.strict()` (rejects any key not explicitly defined),
|
||||
not the Zod default of silently stripping unknown keys.
|
||||
- **Rationale**: FR-008 requires the *entire request* rejected on an unknown field, not a
|
||||
best-effort parse — `.strict()` is exactly this behavior in Zod; the default `.parse()`
|
||||
behavior (strip unknown keys silently) would violate FR-008.
|
||||
- **Alternatives considered**: None — this is a direct, unambiguous mapping from requirement to
|
||||
Zod API.
|
||||
|
||||
## Decision: Error response shape without leaking registration status (FR-010)
|
||||
|
||||
- **Decision**: "Unregistered product" and "invalid credential for a registered product" return
|
||||
the *same* generic `401 INVALID_INTEGRATION_CREDENTIAL` response body and status code. "Product
|
||||
suspended" returns a distinguishable `403 PRODUCT_INTEGRATION_SUSPENDED` (this one is safe to
|
||||
distinguish — a suspended product's own registered caller already knows it's registered).
|
||||
"Out-of-scope request" (valid credential, but tenant/user outside `allowedScope`) returns
|
||||
`403 REQUEST_OUT_OF_SCOPE`.
|
||||
- **Rationale**: This satisfies both FR-010 requirements at once: distinguishable where doing so
|
||||
is safe (suspended vs. scope), identical where distinguishing would leak whether an arbitrary
|
||||
product ID is registered at all (invalid credential vs. unregistered product).
|
||||
- **Alternatives considered**: Fully distinguishing all four cases — rejected, directly
|
||||
contradicts FR-010's "without leaking whether an unregistered product ID exists."
|
||||
|
||||
## Decision: Reconciling the placeholder Prisma schema
|
||||
|
||||
- **Decision**: The current `Product` model in `prisma/schema.prisma` (`code`, `name`,
|
||||
`description`, `status: ProductStatus` enum) is starter-template scaffolding, not the real
|
||||
domain model — it doesn't match `docs/06-database-schema.md`'s `Product` shape at all
|
||||
(`externalProductId`, `supportEnabled`, `status: String`). This feature replaces it with the
|
||||
doc 06 shape. New models (`ProductIntegration`, `CustomerReference`) use `cuid()` ids matching
|
||||
doc 06 exactly. The existing `Category`, `User`, and `AuditLog` placeholder models are left
|
||||
alone (out of scope for this feature — `Category`'s real shape belongs to whichever future
|
||||
feature builds the catalog domain properly; `User`/`AuditLog` aren't touched by this feature's
|
||||
requirements beyond *reusing* `AuditLog` for FR-007).
|
||||
- **Rationale**: Phase 2 is explicitly where `docs/10-implementation-roadmap.md` places
|
||||
"Product/ProductIntegration models" — this is the correct feature to fix `Product`, not a
|
||||
scope-creep addition. Leaving `Category`/`User` alone keeps the change bounded to what this
|
||||
feature actually needs.
|
||||
- **Alternatives considered**: Adding `ProductIntegration` pointing at the old placeholder
|
||||
`Product` shape and deferring the `Product` fix — rejected, would mean building
|
||||
`ProductIntegration.product` against a model with no `externalProductId` to validate inbound
|
||||
requests against, defeating the feature's own purpose.
|
||||
|
||||
## Decision: Where `ProductIntegration` lifecycle endpoints live
|
||||
|
||||
- **Decision**: Extend the existing `src/modules/catalog/products/` module (already scaffolded
|
||||
with controller/service/repository/routes/schema/mapper/types) with integration lifecycle
|
||||
operations, rather than creating a new top-level module.
|
||||
- **Rationale**: `docs/07-backend-architecture.md`'s module list has no separate
|
||||
"product-integrations" module; `docs/06-database-schema.md` groups `ProductIntegration` under
|
||||
the same "Domain: Integration / Catalog" heading as `Product`, and it's a 1:1 relation.
|
||||
- **Alternatives considered**: A new `src/modules/platform/integrations/` addition — rejected;
|
||||
that module already exists for outbound webhook delivery (docs/11 gap A2, a different, future
|
||||
concern), and conflating inbound-trust management with outbound-webhook delivery in one module
|
||||
would blur a module boundary the constitution requires to stay clear (Principle III).
|
||||
Reference in New Issue
Block a user