@latimer-woods-tech/intake
v0.2.0
Published
The portfolio's customer intake horizontal — requests/jobs with attached files, a role-aware lifecycle state machine, hardened upload handling (magic-byte sniffing, ownership-scoped keys), and a mountable Hono router. Built once under the extraction contr
Readme
@latimer-woods-tech/intake
The portfolio's customer intake horizontal: customers open requests/jobs, attach files, exchange messages, and track a role-aware lifecycle — built once, mounted by hosts. Charter: docs/planning/intake-portal.md (RATIFIED 2026-07-13); access model: ADR-0016.
Extraction contract
The package never imports a host package and never owns a user table. A host injects four narrow ports at mount time; swap the host and only these change:
| Port | Question it answers | COH v1 implementation |
|---|---|---|
| IdentityPort | Who is making this HTTP request? | cookie/Bearer JWT → {id, email, role} (client→customer, admin→staff) |
| StoragePort | Where do file bytes live? | R2 MEDIA binding under the intake/ prefix |
| NotifierPort | How do participants hear about activity? (best-effort) | Resend templates |
| AuditPort | Where do staff mutations get recorded? | writeAudit → audit_log |
Mount (Hono host)
import { createIntakeRouter, DrizzleIntakeStore } from '@latimer-woods-tech/intake';
const portal = createIntakeRouter({
store: new DrizzleIntakeStore(db), // any Drizzle pg handle
identity: { // host auth → Viewer | null
getViewer: async (c) => {
const user = await resolveYourSession(c as Context);
if (!user) return null;
return {
id: user.id,
email: user.email,
role: user.role === 'client' ? 'customer' : 'staff',
};
},
},
storage: wrapR2(env.MEDIA, 'your-bucket-name'), // R2-shaped StoragePort
notifier: { notify: async (n) => sendYourEmail(n) },
audit: { record: async (e) => writeYourAudit(e) },
limits: {
categories: ['consultation', 'custom-order', 'other'],
maxRelayBytes: 20 * 1024 * 1024,
},
});
app.route('/api/portal', portal); // host adds its own rate limits around thisThe host owns rate limiting, CORS, and monitoring around the mount. The router 401s when getViewer returns null and 404s (never 403s) cross-customer probes — no existence oracle.
Routes
| Route | Actor | Notes |
|---|---|---|
| POST /requests | customer, or staff with onBehalfOf | 201 |
| GET /requests | both | customers: own; staff: ?status=&customerEmail=&limit=&before= |
| GET /requests/:id | both | detail + timeline + files + allowedTransitions |
| POST /requests/:id/messages | both | customer reply on needs_info auto-returns it to in_review |
| POST /requests/:id/transition | per machine | server-enforced REQUEST_TRANSITIONS |
| POST /requests/:id/assign | staff | audited |
| POST /requests/:id/files | both | relayed multipart ≤ maxRelayBytes; magic-byte sniffed inline |
| POST /requests/:id/files/presign | both | 501 unless the storage port implements presignPut |
| POST /requests/:id/files/:fileId/confirm | uploader | HEAD size check + post-confirm byte-range sniff |
| GET /requests/:id/files/:fileId | both | Worker-streamed, attachment, private, no-store |
| DELETE /requests/:id/files/:fileId | uploader or staff | soft delete; staff audited |
Upload rejections map to precise statuses: 413 too large, 415 type not allowed (image/svg+xml is hard-banned), 422 declared≠sniffed / size mismatch / not uploaded / file cap, 409 request closed.
Lifecycle
submitted → in_review → in_progress → needs_info ⇄ (customer reply) → completed | declined | cancelledREQUEST_TRANSITIONS is exported — render UI controls from the same table the server enforces (allowedTransitions(status, role)).
Migrations
migrations/0000_intake_core.sql is canonical. Hosts COPY it into their own migration rail (COH: drizzle/0003_intake_tables.sql) — never fork the table definitions. Statements are separated with --> statement-breakpoint for embedded runners; the SQL is IF NOT EXISTS-idempotent.
DSR (privacy) wiring
createIntakeServices(config) returns the service pair without the router. For GET /privacy/export include store.listFilesForCustomer(scope, userId) + the customer's requests; for erasure, delete the customer's storage prefix (customerStoragePrefix(scope, userId)) and their rows.
Known limits (v1)
- No antivirus:
infectedstatus is reserved for a future scanner; downloads are attachment-only so nothing renders inline. - Presigned uploads can't be sniffed in-flight; the post-confirm range sniff covers them after landing. Presign credentials must be bucket-scoped + write-only (ADR-0016).
- The store is non-transactional; writes are ordered request-row-first so a crash can lose a timeline entry but never corrupt request state.
