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:
saqib mir
2026-08-21 17:45:02 +05:30
co-authored by Claude Sonnet 5
parent 0e92faf615
commit a7216b7531
5 changed files with 494 additions and 0 deletions
@@ -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.
+89
View File
@@ -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.
+136
View File
@@ -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.*
+73
View File
@@ -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.
+132
View File
@@ -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).