Completes User Story 4's eleven named metrics: first-response-time
(messages.service.ts's post(), guarded against double-counting a
ticket's second agent message), SLA compliance (sla.service.ts's
complete()/runBreachDetectionSweep(), with a guard so a run already
breached by the sweep is never also counted "met" when it later
resolves), most-common-errors (error-codes.service.ts, counted only
once a code is confirmed real), and tool-failure-rate/knowledge-
effectiveness (tools.service.ts's single executeTool call site).
Verified end-to-end against real Postgres/Redis by scraping the real
/metrics endpoint before and after driving each metric's actual
underlying event through the real service layer — including a genuine
tool-execution failure (a nonexistent ticket ID) rather than a
simulated one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
User Story 3: initializes a real OpenTelemetry TracerProvider (previously
inert — getTracer() returned a no-op tracer with nothing ever exported).
Adds ticket.create, ai.escalation, and orchestration.assignment spans
covering both FR-006 cross-module paths, verified via a real, in-memory
test exporter that confirms actual trace/parent-span nesting, not mocked.
Also registers an AsyncLocalStorageContextManager
(@opentelemetry/context-async-hooks) — without one, OTel's context API is
a no-op that doesn't propagate across the await boundaries this feature's
own event-bus subscribers rely on for span nesting; caught by the first
version of the tracing integration test actually failing on real
parent/child assertions, not assumed.
Graceful degradation (FR-007) verified against a real, deliberately
unreachable OTLP endpoint: the SDK's own background export path (what
production actually exercises) never produces an unhandled rejection.
Starts on the 11 named business-health metrics: AI session
resolved/escalated outcomes (session.repository.ts, the single choke
point every branch in session.service.ts funnels through), human-vs-AI
resolution + resolution-time (a new TICKET_UPDATED/RESOLVED subscriber),
escalation rate (a new subscriber on ESCALATION_TRIGGERED, published
unconditionally since 008 but never previously consumed), and recurring
problems (tickets.service.ts's existing problem-creation call site).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
User Stories 1-2: every completed request (including 404s and early
replies from other hooks) now emits exactly one structured access-log
line, and every log line produced during that request's handling shares
its requestId/correlationId via a new AsyncLocalStorage-backed Pino mixin
— with zero changes to any existing log call site. The previously-dead
supporthub_http_request_duration_seconds histogram now actually receives
observations, so error rate and latency per route are computable from
/metrics alone.
Also bumps @opentelemetry/sdk-trace-base 1.x -> 2.x to align with the two
new tracing dependencies added in this same branch (exporter-trace-otlp-http,
resources) onto one consistent major version — npm had otherwise installed
two incompatible OTel core/resources majors side by side, which also
happened to resolve a moderate DoS advisory in @opentelemetry/core <2.8.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes the two gaps 010-identity-auth explicitly deferred (password reset,
login rate-limiting), plus a shared password-strength validator both the
reset-consume endpoint and admin account creation now depend on.
- Password reset: single-use, paired-Redis-key tokens (never in Postgres),
identical response regardless of account existence, stubbed delivery via
a structured log line (no email infrastructure exists yet).
- Password strength: one validatePasswordStrength() call site, wired into
both POST /admin/users and the reset-consume flow.
- Login rate-limiting: checkRateLimit keyed by submitted email, checked
before any credential verification.
Also fixes tests/helpers/auth.ts's shared loginAs() helper, which reused
two fixed accounts across the whole integration suite via upsert — now
rate-limited per email, that collided across ~30 files sharing one budget.
Each call now gets a unique email; no call sites needed to change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up to 012-admin-list-views, discovered while building
supporthub-web's own escalation admin screen (001-agent-admin-ui User
Story 5): the list endpoint returned bare policies with no way to read
back which rules (trigger type, target node) already existed under
each one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up to 012-admin-list-views, discovered while building
supporthub-web's own SLA/calendar admin screen (001-agent-admin-ui User
Story 4): holidays could only be added or removed, never read back -
GET /admin/business-calendars/:id returned the bare calendar with no
way to display what holidays were already on file. The repository
already had findByIdWithHolidays; it just wasn't wired to this route.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up to 012-admin-list-views, discovered while building supporthub-
web's own knowledge-governance screen (001-agent-admin-ui User Story 7):
GET /knowledge/retrieve only ever returns published entries (its own
AI-consumption purpose), so a governance screen that needs to see and
publish a draft entry had no endpoint to list it. Adds a small
admin-list-views-style read query scoped to the knowledge module itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds GET /admin/sla-runs (filterable by status), GET /admin/escalation-
events (capped, most-recent-first), and GET /admin/products (with
integration status joined in, never the full ProductIntegration row).
None of these existed as a single query before - only per-ticket or
per-integration-id lookups did.
Discovered while planning supporthub-web's 001-agent-admin-ui User
Stories 6-7 (SLA/escalation monitoring, product catalog), the same way
011-agent-ticket-queue was discovered for User Story 1.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends PATCH /admin/agents/:agentId with an optional userId to finally
wire Agent.userId (added in 010-identity-auth as schema-only, never
consumed by any workflow), with proactive role/duplicate-link checks
mirroring UsersService.create's own pre-check style.
Adds GET /agents/me/tickets and GET /admin/agents/:agentId/tickets,
sharing one TicketsService.listAssignedTo method, returning a dashboard-
ready summary (product, customer, priority, severity, status, SLA state)
of every ticket currently assigned to an agent — no such query existed
anywhere in the ticketing or orchestration modules before this. Backed by
a new Assignment @@index([agentId, isCurrent]).
Discovered while starting supporthub-web's 001-agent-admin-ui: its agent-
dashboard user story had no backend data source without this.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the no-op fastify.authenticate stub and the never-implemented
identity/auth login with real bcrypt password verification, JWT session
issuance/verification (reusing the existing JWT_SECRET), and a Redis-backed
revocation denylist for logout. Adds requireRole('ADMIN') to admin-only
configuration writes across 002-009 that previously relied on a decorator
that never actually checked anything. Adds self-identity (GET /auth/me,
re-validated against live account state) and admin-provisioned accounts
(POST /admin/users).
Making the auth check genuinely reject invalid/missing tokens exposed that
~18 pre-existing integration test files called already-gated routes with no
Authorization header (safe against the old no-op stub, broken against a real
one) — fixed via a shared tests/helpers/auth.ts (loginAs/authHeader) and a
file-by-file pass, plus two related SLA-run cleanup races exposed once admin
setup calls in those files' own beforeAll blocks started actually succeeding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Populates the five real problem-management stubs (investigation,
root-causes, solutions, verification, resolutions -- problems is
confirmed dead/unwired scaffold and stays untouched) with doc04's
sequential workflow engine:
- investigation: version-row-per-attempt (never overwritten), with a
customer-safe read path that always strips internalNotes.
- root-causes/solutions/verification: a strict existence chain
(investigation -> root cause -> solution -> approval -> implementation
-> verification), each step resolve-or-409 on its own precondition,
matching doc06's schema field-for-field with no invented columns.
- resolutions: gated on a successfully verified solution (no stored
solutionId FK, per doc06 -- resolved via a join at write time), moving
the ticket to RESOLUTION_PENDING_CUSTOMER; explicit customer
confirmation and a durable auto-close sweep (the previously-unregistered
CLEANUP queue stub, mirroring 008's breach-detection job) both resolve
it from there.
- reopen (ticketing/tickets): two real, separately-audited transitions
(RESOLVED|CLOSED -> REOPENED -> IN_PROGRESS), touching no prior
problem-resolution record and no SLARun -- closes the loop 008's own
spec.md left open.
Verification-failure escalation reuses 003/007's existing
HUMAN_ESCALATION transition directly rather than adding an eleventh
trigger type to 008's already-shipped escalation rules.
Customer-facing confirm-resolution/reopen needed a body-shape variant of
002's inbound trust boundary that didn't previously exist:
fastify.authenticateProductIntegration hard-required a full
ticket-creation-shaped body. Extracted the shared token/scope/replay
verification into verifyIntegrationIdentity and added a narrower
authenticateProductIntegrationIdentity decorator + identityOnlyRequestSchema
on top of it -- purely additive, ticket creation's own behavior is
unchanged.
Also fixes a real test-data-hygiene bug surfaced by running this
feature's suite alongside 008's: a wildcard-scoped HierarchyNode and an
intentionally-global SLAPolicy in 008's own test fixtures were silently
affecting other test files' tickets sharing the same live Postgres.
Verified against throwaway Docker Postgres/Redis: typecheck, lint,
architecture-check all clean; full regression (tests/unit +
tests/integration together, 172 tests) passes except the 2 pre-existing
MinIO-dependent attachment failures, unrelated to this feature.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Populates platform/business-calendars, orchestration/sla, and
orchestration/escalation (all thin stubs until now) with the real engine:
- business-calendars: a luxon-based day-by-day calendar walk
(addBusinessMinutes/isWithinWorkingHours) excluding non-working hours,
weekends, and holidays — replacing the naive createdAt+hours stub FR-004
explicitly forbids.
- sla: most-specific SLAPolicy resolution (product/category/problemType/
priority, wildcard-or-exact-match, specificity-count + updatedAt
tiebreak), SLARun creation on the first real publish of the
long-unused TICKET_ASSIGNED domain event, durable pause/resume via an
absolute-timestamp shift (no in-memory state, verified across a real
buildApp() restart), and a repeatable BullMQ breach-detection sweep
(src/jobs/sla, itself a previously-unregistered stub) that is directly
callable for tests, not only reachable through a running worker.
- escalation: EscalationPolicy/Rule CRUD (all 10 doc05 trigger types
storable, only resolution_breach/first_response_breach evaluated),
breach-triggered and manual escalation both funnel through one EscalationEvent
+ scoped re-assignment path. AssignmentEngine (007) gains
assignToSpecificNode — a new, explicitly node-scoped entry point,
since escalation must never let 007's general resolution re-derive a
different node than the one a rule or a caller targeted.
Two small pre-existing scaffold gaps were closed along the way:
CategoriesRepository had no findById, and TICKET_ASSIGNED/SLA_BREACHED/
ESCALATION_TRIGGERED were defined since earlier phases but never
published by any code.
Verified against throwaway Docker Postgres/Redis (typecheck, lint,
architecture-check all clean; 148/150 relevant tests pass — the 2
failures are pre-existing, MinIO-dependent, and unrelated to this
feature).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase 7 of the roadmap. On a ticket's automatic transition to
HUMAN_ESCALATION, routing resolves 006's hierarchy nodes and
capability-eligibility lookup directly (never a second matching
algorithm), and a pluggable strategy (ROUND_ROBIN, concurrency-safe via
atomic Redis INCR; LEAST_LOADED; SKILL_BASED) selects exactly one
eligible agent, persisted as a version-row-per-period Assignment plus an
append-only AssignmentHistory event log. MANUAL/DIRECT are never
auto-selected — only an explicit admin-supplied agentId reaches them.
On success the ticket moves HUMAN_ESCALATION -> IN_PROGRESS through
003's existing state machine. A ticket's "required skill" comes from
its most recent AI diagnosis's problemType (005) when one exists,
unioned with any matching hierarchy node's skills (006); when neither
exists, there's no skill constraint (every active agent eligible),
never zero.
Found and fixed two real, latent bugs in the shared event-bus
infrastructure while building this feature's own tests: (1)
EventBus.publish was built on EventEmitter.emit(), which never awaits
async listeners, so a caller had no guarantee any subscriber (005's
AI-session-ending hook, now also this feature's orchestration hook) had
actually finished — rewritten to track subscribers directly and await
them via Promise.all, same per-handler error isolation as before. (2)
registerDomainEventHandlers() was only called from server.ts's
production startup path, never from buildApp() — meaning every
integration test in this codebase had zero domain-event subscribers
registered at all. Now called (idempotently) from buildApp() itself,
since domain-event wiring is synchronous application behavior, not a
background-worker concern like the queue.
Adds 8 unit tests (each strategy's pure selection/tie-break logic), a
dedicated round-robin concurrency test verifying no two concurrent
selections collide under real parallel load, and 2 integration test
files covering all five user stories. Full regression (every
pre-existing 002-006 integration test plus every new 007 test) run
together against real Postgres/Redis/MinIO: 124 passed, 9 skipped
(005's AI-key-gated tests, unrelated), 0 failed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase 6 of the roadmap. Populates the identity/teams and
orchestration/hierarchy module directories (previously empty or
near-empty scaffold stubs) and replaces identity/agents' pre-existing
placeholder, which queried a generic User/UserRole model unrelated to
this system's real architecture (nothing since 002-saas-integration's
CustomerReference has used it).
Teams and agents: active/inactive CRUD, never hard deletion; team
deactivation never cascades to its agents. Skills: compound-unique
upsert on (agentId, skillTag), never duplicates. Availability: a single
current record per agent, deliberately last-write-wins rather than
optimistic-locked — operational telemetry, not a durable business
record. The dynamic hierarchy: a nestable, orderable HierarchyNode tree
with every field (scope, assignment strategy reference, entry/exit
conditions) stored as opaque admin-set data; cycle detection runs only
on reparenting edits (a new node can't form a cycle); every
create/edit/activate/deactivate is audited via AuditLog, reusing
002's existing writer pattern. A capability-eligibility read path
composes hierarchy scope with caller-supplied skills for the future
orchestration/assignment phase (Phase 7) to call, deliberately
excluding availability per doc 05's own capability-before-availability
ordering.
Found and fixed a real bug before it reached tests: the initial
availability upsert reset currentLoad to 0 on every update, not just
creation.
Adds 11 unit tests (cycle detection, capability matching) and 4
integration test files covering all four user stories. Full regression
(every pre-existing 002-005 integration test plus all new ones) run
against real Postgres/Redis/MinIO: 107 passed, 9 skipped (005's
AI-key-gated tests, unrelated), 0 failed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Real Anthropic Claude integration per explicit product decision: a
ticket's AI session diagnoses the problem via a structured-output call,
applies a DB-configurable confidence-band policy (FR-005), and on
"proceed" reasons and acts through a small permission/risk-gated tool
system (FR-011/FR-012), optionally walking a matching runbook step by
step with the application — never the model — owning the step index
(FR-015/FR-016). Resolution requires real tool evidence, never customer
claims alone (FR-018) — verifyProductResolution is a documented
fail-closed placeholder mirroring the existing malware-scanner precedent,
since no real per-product operational signal exists yet.
AISupportSession.status mirrors onto Ticket.status through 003-ticketing's
existing AI_ANALYZING/AI_TROUBLESHOOTING/AI_VERIFYING/AI_RESOLVED/
HUMAN_ESCALATION state machine, discovered during planning to have been
built anticipating this exact feature. Two circular module dependencies
(escalation<->sessions, tools<->sessions) were designed around rather than
found as bugs: escalation is a pure summary formatter with no state
dependencies of its own, and tools stays a clean leaf module with zero
dependency on ai-support/sessions. Ticket creation enqueues the first
diagnosis turn via the existing queue infrastructure (off the hot path of
the inbound SaaS integration endpoint); a human actor changing ticket
status ends the AI session via the event-bus scaffold that existed in
this codebase but had never been wired to anything.
A real Prisma limitation was found and fixed before it reached tests:
compound-unique upsert rejects null for a nullable key column, so
AIConfidencePolicy uses find-then-update/create instead, same fix class
004 already used for the same underlying limitation.
Adds 9 unit tests (confidence-band, tool-policy-gate, runbook-step-
advance) and 6 integration test files, including the two constitution-
required standing E2E scenarios. AI-independent tests were run against
real Postgres/Redis/MinIO (88 passed, 0 failed across the full suite,
including every pre-existing 002/003/004 test). The AI-dependent tests
compile and skip cleanly via describe.skipIf but were not run against a
live model — no ANTHROPIC_API_KEY was available in this session; a real
key must be supplied before this feature can actually run.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements 26 of 28 tasks from specs/004-product-knowledge/tasks.md
across all three user stories -- Phase 3 of the roadmap. First
feature to populate src/modules/ai-support/ (doc 07 places
`knowledge` there; only that submodule is built, matching this
codebase's convention of not pre-building unneeded submodules).
Schema (prisma/schema.prisma + migration):
- KnowledgeEntry, ErrorCode, KnownIssue, Runbook per docs/06,
refining its conceptual flat `version` field into an explicit
version-history mechanism: each edit inserts a new row
(isCurrentVersion flag, compound unique on (code, version) /
(key, productId, version)) instead of overwriting in place -- the
only way "prior versions remain retrievable" (FR-004/FR-009) is
actually true rather than aspirational.
User Story 1 -- knowledge entry authoring/publish/version (P1, MVP):
- draft -> published -> unpublished lifecycle; publish only takes
effect from its effectiveDate.
- Editing uses the same conditional-update-then-insert optimistic
concurrency pattern as 003-ticketing's Ticket.version (409 on a
stale expectedVersion).
- Full version history readable via GET .../versions.
User Story 2 -- error codes, known issues, runbooks (P2):
- ErrorCode + KnownIssue with direct lookup-by-error-code.
- Runbook steps stored as an ordered JSON array, preserved exactly;
same version-on-edit mechanism as knowledge entries; inactive
runbooks are indistinguishable from nonexistent ones on lookup.
User Story 3 -- filtered retrieval (P3):
- GET /knowledge/retrieve: product-scoped, excludes draft/
unpublished/not-yet-effective entries, validated entries ranked
ahead of unvalidated. Deliberately NOT semantic/vector search --
doc 11 gap B1 explicitly defers embedding-model choice to the
future AI-support feature; this is real, usable structured
filtering a semantic layer can sit in front of later.
Found and fixed one real bug before it reached tests: the retrieval
endpoint initially queried by the raw external product id instead of
resolving it to the internal Product.id first (every other endpoint
in this feature does that resolution) -- would have silently
returned zero results for every caller. Fixed with a lenient
tryResolveProductId (empty array, not 404, for an unregistered
product -- matches the "no matches, never an error" contract).
Deliberately skipped (not forgotten, see checklist notes): the two
planned mock-repository unit-test tasks (T004, T019) -- unlike
003-ticketing's state machine, this feature has no pure-logic
surface to isolate from Prisma; coverage comes entirely from
integration tests instead.
All 13 integration test files in the repo (36 tests, spanning this
feature and every prior one) verified passing together against a
real Postgres/Redis/MinIO -- no regressions. Full quality gate
(typecheck/lint/format/architecture/unit tests) passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements all 39 tasks from specs/003-ticketing/tasks.md across all
three user stories -- Phase 5 of the roadmap.
Schema (prisma/schema.prisma + migration):
- Ticket (code, status, version for optimistic concurrency,
idempotencyKey, customerId FK), Problem, TicketMessage,
TicketAttachment per docs/06, with Product/Category/
CustomerReference back-relations.
User Story 1 -- ticket/problem creation (P1, MVP):
- Explicit 12-state lifecycle adjacency table
(ticket-state-machine.ts), not "any transition allowed."
- Ticket code generation (<PRODUCT_CODE>-<YEAR>-<SEQUENCE>) scoped
by the actual code prefix, not productId -- see the collision bug
fixed below.
- Idempotency-key enforcement via atomic create-then-catch-conflict
(never a read-then-write race), completing the FR-012 placeholder
from 002-saas-integration.
- Explicit-reference-only recurring-problem linking (no fuzzy
matching -- that's a future AI-support concern).
- POST /v1/support/requests (002-saas-integration) now creates a
real ticket instead of echoing context back.
- PATCH /tickets/:id/status with expectedVersion-based optimistic
concurrency (409 on stale version, 400 on an invalid transition).
User Story 2 -- typed messages (P2):
- Message type -> visibleToCustomer mapping is a fixed constant map,
never caller-supplied; customer-scoped reads filter at the query
layer so an internal note is never fetched, not just hidden.
- POST/GET /tickets/:id/messages (customer-scoped) and
GET /agent/tickets/:id/messages (agent-scoped).
User Story 3 -- attachment pipeline (P3):
- Presigned-PUT upload (new getPresignedUploadUrl on the existing
storageService) -- file bytes never transit this API.
- A MalwareScanner interface with a fail-closed placeholder
(UnimplementedPlaceholderScanner) since no scanner exists in this
stack -- it always reports 'infected', never silently 'clean'.
- The existing attachments-queue job stub now actually calls the
scanner and updates scanStatus; registerAttachmentWorker() is
wired into bootstrapQueue() (previously defined but never called).
- Downloads are gated on scanStatus === 'clean' -- currently always
refused until a real scanner replaces the placeholder.
- MinIO added to docker-compose.{test,development}.yml for local/CI
S3-compatible storage, matching doc 04's explicit guidance.
Two real bugs found and fixed via integration testing against a
live Postgres/Redis/MinIO (not just typechecked):
- Ticket codes could collide across different products: the
sequence counter was scoped by internal productId, but the code
column's uniqueness is global, and deriveProductCode's 4-character
truncation means different products can share a prefix. Fixed by
counting against the actual code prefix instead.
- Three existing 002-saas-integration integration tests' cleanup
started failing an FK RESTRICT check once ticket creation was
wired in (deleting a Product before the Ticket/Problem that now
reference it). Fixed their afterAll ordering.
All 9 integration test files (24 tests, spanning this feature and
the pre-existing suite) verified passing against real Postgres,
Redis, and MinIO, including a genuine presigned-PUT/GET round trip.
Full quality gate (typecheck/lint/format/architecture/unit tests)
passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements tasks T026-T032 from specs/002-saas-integration/tasks.md
(User Story 3, P3 - the final piece of this feature) plus Polish.
- New bespoke Redis fixed-window counter (checkRateLimit,
src/infrastructure/cache/rate-limiter.ts) rather than
@fastify/rate-limit's default onRequest-stage hook -- that hook
runs before this feature's preHandler-based auth resolves the
integration/user identity the limit needs to key on. A second
preHandler (checkIntegrationRateLimit) runs after
authenticateProductIntegration on the inbound route, checking the
integration-level limit then the per-user limit independently,
each throwing the existing RateLimitError (429
RATE_LIMIT_EXCEEDED) on breach.
- New integration test (inbound-rate-limit.test.ts) verifies both
limits are enforced independently against a real Postgres/Redis:
a throttled user doesn't affect others, and the integration cap
throttles even when no individual user has hit their own limit.
- Docs: contracts/quickstart updated from the placeholder
"RATE_LIMITED" code to the actual reused RATE_LIMIT_EXCEEDED code;
cleaned up a duplicated paragraph in the admin endpoints section;
added a "SaaS Integration" section to README.md documenting the
inbound contract, admin routes (and their known auth-stub
limitation), and how rate limits are configured.
All 32 tasks in tasks.md are now complete -- all three user stories
(P1 trust boundary, P2 admin lifecycle, P3 rate limiting) are
implemented and covered by integration tests verified against a
live database, in addition to unit tests for the crypto/token
primitives. Full quality gate (typecheck/lint/format/architecture/
unit tests) passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements tasks T021-T025 from specs/002-saas-integration/tasks.md
(User Story 2, P2): an admin can register, rotate, revoke, and change
the status of a ProductIntegration, and retrieve its audit trail.
- ProductIntegrationsService: register (finds-or-creates the Product
by external id), rotate (dual-credential transition window per
research.md), revoke, updateStatus, getAuditTrail -- each writes
its own AuditLog entry via a new shared
integration-audit-log.repository.ts (extracted from the auth
plugin, which now reuses it instead of writing to Prisma directly).
- Routes: POST /admin/products/:externalProductId/integration,
POST/admin/integrations/:id/rotate|revoke, PATCH .../status,
GET .../audit-trail -- gated by the existing fastify.authenticate
(human/admin JWT) decorator.
- New integration test (product-integrations-admin.test.ts) covers
Quickstart Scenarios 5-7 end-to-end against a real Postgres/Redis:
register+rotate+audit-trail, and revoke-takes-effect-immediately.
Verified passing against a live database.
Known, pre-existing limitation flagged (not fixed here, out of
scope): fastify.authenticate is currently a no-op stub with no real
JWT verification, so these admin endpoints aren't actually
access-controlled yet -- that depends on the unimplemented
identity/auth module. Documented in the contract and checklist notes
so it isn't mistaken for done.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements tasks T001-T020 from specs/002-saas-integration/tasks.md
(Setup, Foundational, and User Story 1 - the P1 MVP: every inbound
request is authenticated and trusted before anything happens).
User Story 2 (admin onboarding/rotation/revocation) and User Story 3
(rate limiting) are not yet implemented (T021-T032 remain).
Schema (prisma/schema.prisma + initial migration):
- Replace the placeholder Product model (leftover starter-template
scaffolding: code/description/ProductStatus enum) with the real
docs/06-database-schema.md shape (externalProductId,
supportEnabled, status).
- Add ProductIntegration (credential ref, rotation/revocation state,
allowed scope, per-integration/per-user rate limits) and
CustomerReference models.
- Align AuditLog to docs/06's shape (actor/actorType/entityType/
entityId/reason/metadata) -- the placeholder shape had no fields
to satisfy this feature's audit requirements.
Auth:
- HMAC-signed short-lived tokens (issue/verify) with jti-based replay
defense via Redis and a bounded clock-skew tolerance.
- Credential secrets are AES-256-GCM encrypted at rest (new required
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY env var) since no secret
manager exists in this stack yet -- see research.md "Credential
storage".
- New product-integration-auth.plugin.ts Fastify plugin runs the
validation order in contracts/inbound-request-contract.md and
populates request.reqContext only on full success; every attempt
(success or failure) is audit-logged without ever persisting the
raw token/credential. Unregistered product and invalid credential
return an identical response (FR-010).
- New POST /v1/support/requests endpoint exercises the boundary
end-to-end (ticket creation itself is a future feature).
Also:
- Fix docker-compose.test.yml's container_name collisions --
discovered while testing this change concurrently is now covered
by an app-level regression test (separate commit).
- Fix test:unit to scope to tests/unit only (it was running the
entire tests/** glob including integration tests) -- this feature's
new integration test makes real Prisma/Redis calls, unlike the
prior instantiation-only checks, so the existing glob-scoping gap
became actually harmful.
- Update Jenkinsfile with the new required credential.
Verified: full quality gate (typecheck/lint/format/architecture/
unit tests) passes; all of User Story 1's quickstart scenarios
manually verified end-to-end against a live server + Postgres +
Redis; the new integration test suite verified against a live
database (not run as part of `npm test`, matches existing
test:integration convention).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fastify resolves each encapsulated child context's error handler at
the time that context is registered. app.ts called
app.setErrorHandler()/setNotFoundHandler() AFTER bootstrapRoutes()
had already registered every domain module's routes (each
app.register(someRoutes) call creates its own child context, since
none of the route modules use fastify-plugin). A handler set on the
parent afterwards does not retroactively apply to already-registered
children, so every module's routes were silently falling back to
Fastify's default {statusCode, error, message} error shape instead
of this app's {success:false, error:{code,message,details},
requestId} envelope, for any thrown error -- not specific to any one
feature. Discovered while building and manually verifying the
002-saas-integration feature's inbound endpoint.
Also fixed the generic error-handler branch to preserve a framework
error's own client-facing statusCode (e.g. 400 for malformed JSON)
instead of always reporting 500.
Added a regression test in tests/unit/app.test.ts that fails without
this fix and passes with it (verified both ways).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>