@everystack/api 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -7,66 +7,163 @@
7
7
  * the only place the caller's identity, the action, and the params all meet, so the
8
8
  * record is captured here and handed to an app-provided append-only sink.
9
9
  *
10
+ * Captures the **query, not the response**: who, what (method + table or RPC), which
11
+ * (the filter and the column *names* a write touched — never values), and the outcome.
12
+ * No row contents, no before/after — that keeps the trail low-sensitivity and needs no
13
+ * DB triggers. Every **non-GET** request (writes + RPCs) is recorded when a sink is
14
+ * configured — not an opt-in list, so there's no forgot-to-declare gap. GET reads are
15
+ * delegated to RLS (a sound RLS read returns only self-data). See
16
+ * docs/plans/forensic-audit-spec.md.
17
+ *
10
18
  * The sink should be append-only with integrity (e.g. S3 Object-Lock / WORM) — an
11
19
  * audit log the app's own DB role can rewrite is not evidence. A sink failure is
12
20
  * logged loudly (a dropped record is never silent) but does not fail the request:
13
21
  * audit outage must not become read outage.
14
22
  */
15
23
 
16
- /** One recorded access of a sensitive surface. */
24
+ /** Whether the access was allowed, refused (authz/validation), or faulted (system error). */
25
+ export type AuditOutcome = 'allow' | 'deny' | 'error';
26
+
27
+ /** What kind of principal made the call. */
28
+ export type PrincipalKind = 'user' | 'client' | 'system' | 'anonymous';
29
+
30
+ /** One recorded access of the non-GET surface. */
17
31
  export interface AuditRecord {
18
32
  /** ISO-8601 timestamp of the access. */
19
33
  ts: string;
20
- /** The action — the RPC function name. */
21
- action: string;
22
- /** The authenticated caller (user `sub`), or null if unauthenticated. */
34
+ /** Request correlation id — the trace-id spine. Sourced from the CloudFront
35
+ * request id (`x-amz-cf-id`), which matches `x-edge-request-id` in the access log. */
36
+ requestId?: string;
37
+ // WHO
38
+ /** The principal id: the user `sub`, the client id, or null when anonymous. */
23
39
  caller: string | null;
24
- /** The subject accessed — the id from the configured subject param, or null. */
40
+ /** Whether the principal is a user, a client (machine), system, or anonymous. */
41
+ principalKind: PrincipalKind;
42
+ /** The principal's role, when known (from the JWT claims). */
43
+ role?: string;
44
+ // WHAT
45
+ /** The action — `rpc:<fn>` or `rest:<table>`. */
46
+ action: string;
47
+ /** For REST: the table and the operation performed. */
48
+ resource?: { table: string; operation: 'insert' | 'update' | 'delete' | 'select' };
49
+ /** For REST writes/deletes: the WHERE predicate (query string) — ids, not values. */
50
+ filter?: string;
51
+ /** For writes: the column NAMES the body touched. Never values — values can be PII. */
52
+ columns?: string[];
53
+ // WHOM
54
+ /** The subject accessed — the id from the configured RPC subject param, or null. */
25
55
  subject: string | null;
26
- /** Whether the call succeeded ('allow') or errored/was denied ('deny'). */
27
- result: 'allow' | 'deny';
28
- /** Optional request correlation id. */
29
- requestId?: string;
56
+ // OUTCOME
57
+ /** allow (succeeded), deny (refused — authz/validation), or error (system fault). */
58
+ result: AuditOutcome;
59
+ // provenance
60
+ /** Stamped from AuditConfig for multi-app/stage trails. */
61
+ app?: string;
62
+ stage?: string;
30
63
  }
31
64
 
32
- /** Audit configuration. Records are emitted only for the RPCs listed in `rpc`. */
65
+ /** Audit configuration. When a `sink` is set, every non-GET request is recorded. */
33
66
  export interface AuditConfig {
34
67
  /** Append-only sink for audit records. The app provides a WORM-backed writer
35
68
  * (e.g. S3 Object-Lock). Awaited per request, so a well-behaved sink gives
36
69
  * durability before the response; a throwing sink is logged, never fatal. */
37
70
  sink: (record: AuditRecord) => void | Promise<void>;
38
- /** Which RPC functions are sensitive, and how to derive the subject id from the
39
- * request body. The surfaces that bypass RLS (SECDEF PII reads: get_user,
40
- * export_user_data, …) belong here by default.
71
+ /** Optional subject-extraction hints per RPC — which body param identifies the
72
+ * subject. NOT a gate: with a sink set, every non-GET request is audited; this
73
+ * only enriches the `subject` field for the SECDEF PII-read functions.
41
74
  * Example: { get_user: { subject: 'p_user_id' } } */
42
75
  rpc?: Record<string, { subject?: string }>;
76
+ /** Stamped on every record (multi-app / multi-stage trails). */
77
+ app?: string;
78
+ stage?: string;
43
79
  }
44
80
 
45
81
  /**
46
- * Emit an audit record for a sensitive RPC, if one is configured for `action`.
47
- * No-op when the function isn't audited. Never throws — a sink failure is logged
48
- * loudly (never silent) but must not break the request.
82
+ * The request-scoped context threaded to RPC handlers so app code (e.g. job enqueue)
83
+ * can carry the trace-id spine and the verified actor one hop downstream. The actor is
84
+ * verified at the handler; a worker inherits it from the job envelope (it can't
85
+ * re-verify a queued job). See the spec's "self-sufficient record" rule.
49
86
  */
50
- export async function emitRpcAudit(
51
- audit: AuditConfig | undefined,
52
- action: string,
87
+ export interface RequestContext {
88
+ requestId: string;
89
+ actor: { kind: PrincipalKind; id: string | null; role?: string };
90
+ }
91
+
92
+ /** Resolve the principal (and role) from the verified user / identified client. */
93
+ export function resolvePrincipal(
53
94
  user: Record<string, unknown> | null,
54
- body: Record<string, unknown> | undefined,
55
- result: 'allow' | 'deny',
56
- requestId?: string,
57
- ): Promise<void> {
58
- const entry = audit?.rpc?.[action];
59
- if (!audit || !entry) return;
95
+ client: Record<string, unknown> | null,
96
+ ): { id: string | null; kind: PrincipalKind; role?: string } {
97
+ if (user && typeof user.sub === 'string') {
98
+ return {
99
+ id: user.sub,
100
+ kind: 'user',
101
+ ...(typeof user.role === 'string' ? { role: user.role } : {}),
102
+ };
103
+ }
104
+ if (client) {
105
+ // App-defined client object; by convention carries `id` (or `clientId`).
106
+ const raw = client.id ?? client.clientId;
107
+ return { id: raw == null ? null : String(raw), kind: 'client' };
108
+ }
109
+ return { id: null, kind: 'anonymous' };
110
+ }
111
+
112
+ /** Inputs the dispatch layer hands to emitAudit. */
113
+ export interface AuditInput {
114
+ /** `rpc` (RPC dispatch) or `rest` (table write). */
115
+ kind: 'rpc' | 'rest';
116
+ /** The RPC function name, or the REST table name. */
117
+ name: string;
118
+ user: Record<string, unknown> | null;
119
+ client: Record<string, unknown> | null;
120
+ result: AuditOutcome;
121
+ requestId?: string;
122
+ /** RPC body — used to extract the subject via the AuditConfig hint. */
123
+ body?: Record<string, unknown>;
124
+ /** REST: the operation. */
125
+ operation?: 'insert' | 'update' | 'delete' | 'select';
126
+ /** REST: the WHERE predicate (query string). */
127
+ filter?: string;
128
+ /** REST writes: the column names the body touched. */
129
+ columns?: string[];
130
+ }
60
131
 
61
- const rawSubject = entry.subject ? body?.[entry.subject] : undefined;
132
+ /**
133
+ * Emit one audit record for a non-GET access. No-op when no sink is configured.
134
+ * Never throws — a sink failure is logged loudly (never silent) but must not break
135
+ * the request.
136
+ */
137
+ export async function emitAudit(audit: AuditConfig | undefined, input: AuditInput): Promise<void> {
138
+ if (!audit?.sink) return;
139
+
140
+ const principal = resolvePrincipal(input.user, input.client);
141
+
142
+ // RPC subject from the configured hint; REST has no per-row subject here (the
143
+ // `filter` is the target descriptor).
144
+ let subject: string | null = null;
145
+ if (input.kind === 'rpc') {
146
+ const param = audit.rpc?.[input.name]?.subject;
147
+ const raw = param ? input.body?.[param] : undefined;
148
+ subject = raw == null ? null : String(raw);
149
+ }
62
150
 
63
151
  const record: AuditRecord = {
64
152
  ts: new Date().toISOString(),
65
- action,
66
- caller: typeof user?.sub === 'string' ? user.sub : null,
67
- subject: rawSubject == null ? null : String(rawSubject),
68
- result,
69
- ...(requestId ? { requestId } : {}),
153
+ action: `${input.kind}:${input.name}`,
154
+ caller: principal.id,
155
+ principalKind: principal.kind,
156
+ subject,
157
+ result: input.result,
158
+ ...(principal.role ? { role: principal.role } : {}),
159
+ ...(input.requestId ? { requestId: input.requestId } : {}),
160
+ ...(input.kind === 'rest' && input.operation
161
+ ? { resource: { table: input.name, operation: input.operation } }
162
+ : {}),
163
+ ...(input.filter ? { filter: input.filter } : {}),
164
+ ...(input.columns && input.columns.length ? { columns: input.columns } : {}),
165
+ ...(audit.app ? { app: audit.app } : {}),
166
+ ...(audit.stage ? { stage: audit.stage } : {}),
70
167
  };
71
168
 
72
169
  try {
@@ -8,8 +8,8 @@
8
8
  */
9
9
 
10
10
  import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, and, or, not, asc, desc, sql } from 'drizzle-orm';
11
- import { emitRpcAudit, type AuditConfig } from './audit';
12
- export type { AuditConfig, AuditRecord } from './audit';
11
+ import { emitAudit, resolvePrincipal, type AuditConfig, type RequestContext } from './audit';
12
+ export type { AuditConfig, AuditRecord, RequestContext, AuditOutcome, PrincipalKind } from './audit';
13
13
  export { getPublicSurface, assertPublicSurface } from './public-surface';
14
14
  export type { PublicSurface } from './public-surface';
15
15
 
@@ -123,9 +123,11 @@ export interface CacheConfig {
123
123
  /** RPC handler — plain function (backward compatible).
124
124
  * Plain handlers dispatch directly: no transaction, no pgSettings — they own
125
125
  * their DB access through whatever they close over, same privilege model as
126
- * SSR loaders. The optional 4th argument is only provided to handlers
127
- * registered with `settings: true` (see RpcHandlerWithRole). */
128
- export type RpcHandler = (body: unknown, user?: Record<string, unknown> | null, client?: Record<string, unknown> | null, db?: DrizzleDb) => Promise<unknown>;
126
+ * SSR loaders. The optional 4th argument (`db`) is only provided to handlers
127
+ * registered with `settings: true` (see RpcHandlerWithRole); plain handlers
128
+ * receive `ctx` as the 4th argument instead. The final `ctx` carries the
129
+ * trace-id spine (requestId + verified actor) for downstream propagation. */
130
+ export type RpcHandler = (body: unknown, user?: Record<string, unknown> | null, client?: Record<string, unknown> | null, db?: DrizzleDb | RequestContext, ctx?: RequestContext) => Promise<unknown>;
129
131
 
130
132
  /** RPC handler with per-function role requirement and/or RLS context opt-in */
131
133
  export interface RpcHandlerWithRole {
@@ -189,9 +191,10 @@ export interface HandlerOptions {
189
191
  /** When true, error messages hide internal details (table names, column names, operators).
190
192
  * Enable in production to prevent schema enumeration. Default: false. */
191
193
  hideSchemaErrors?: boolean;
192
- /** Forensic audit logging for sensitive RPCs (distinct from observability).
193
- * Records (caller, action, subject, result, ts) to an app-provided append-only
194
- * sink for the listed functions — the surfaces that bypass RLS. See AuditConfig. */
194
+ /** Forensic audit logging (distinct from observability). When a sink is set,
195
+ * records every non-GET request (writes + RPCs) — the query, not the response —
196
+ * to an app-provided append-only sink, stamped with the trace-id (`x-amz-cf-id`).
197
+ * GET is delegated to RLS. See AuditConfig. */
195
198
  audit?: AuditConfig;
196
199
  }
197
200
 
@@ -453,6 +456,13 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
453
456
  const pathSegments = pathname.replace(/^\/+/, '').split('/').filter(Boolean);
454
457
  const method = request.method.toUpperCase();
455
458
 
459
+ // The trace-id spine. Source from the CloudFront request id (`x-amz-cf-id`,
460
+ // added to every origin request) so the handler audit joins straight to the
461
+ // access log's `x-edge-request-id`. Falls back to trace/request ids off-CDN.
462
+ const requestId = request.headers.get('x-amz-cf-id')
463
+ ?? request.headers.get('x-amzn-trace-id')
464
+ ?? request.headers.get('x-request-id') ?? undefined;
465
+
456
466
  // Phase 1: Client *identification* (if configured) — which app is calling.
457
467
  // NOT a trust boundary. Two paths:
458
468
  // 1. Secret-based (native/server): X-Client-Id + X-Client-Secret — a real secret.
@@ -533,8 +543,11 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
533
543
 
534
544
  const fn = isObjectForm ? entry.fn : entry;
535
545
  const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
536
- const auditReqId = request.headers.get('x-amzn-trace-id')
537
- ?? request.headers.get('x-request-id') ?? undefined;
546
+ // Request-scoped context for the trace-id spine — RPC handlers receive it
547
+ // as a trailing arg (plain: 4th; settings: 5th, after tx) so app code can
548
+ // carry requestId + the verified actor one hop into jobs. Existing fns that
549
+ // ignore the extra arg are unaffected.
550
+ const ctx: RequestContext = { requestId: requestId ?? '', actor: resolvePrincipal(user, client) };
538
551
  try {
539
552
  // Plain RPCs dispatch directly — wrapping them in the settings
540
553
  // transaction deadlocks single-connection pools (the fn's queries
@@ -545,25 +558,33 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
545
558
  ? await executeWithSettings(
546
559
  db,
547
560
  options?.pgSettings?.(user, client),
548
- (tx) => fn(body, user, client, tx),
561
+ (tx) => fn(body, user, client, tx, ctx),
549
562
  )
550
- : await fn(body, user, client);
551
- // Forensic audit: record access of a sensitive surface (no-op unless configured).
552
- await emitRpcAudit(options?.audit, fnName, user, body as Record<string, unknown>, 'allow', auditReqId);
563
+ : await fn(body, user, client, ctx);
564
+ // Forensic audit: every non-GET is recorded when a sink is configured.
565
+ await emitAudit(options?.audit, {
566
+ kind: 'rpc', name: fnName, user, client,
567
+ result: 'allow', requestId, body: body as Record<string, unknown>,
568
+ });
553
569
  return json(result, 200);
554
570
  } catch (error) {
555
- // A denied/errored access is forensically relevant too — record it.
556
- await emitRpcAudit(options?.audit, fnName, user, body as Record<string, unknown>, 'deny', auditReqId);
557
- if (error instanceof HandlerError) {
558
- return json({ message: error.message }, error.status);
559
- }
560
- const message = error instanceof Error ? error.message : 'RPC execution failed';
561
571
  const pgCode = (error as any)?.code;
562
572
  let status = 500;
563
573
  if (pgCode === '28P01') status = 401;
564
574
  else if (pgCode === '42501') status = 403;
565
575
  else if (pgCode === '23505') status = 409;
566
576
  else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
577
+ // Split the outcome: an authz/validation refusal (4xx) is `deny`; a
578
+ // system fault (5xx) is `error`. Conflating them blinds an investigation.
579
+ const refused = error instanceof HandlerError ? error.status < 500 : status < 500;
580
+ await emitAudit(options?.audit, {
581
+ kind: 'rpc', name: fnName, user, client,
582
+ result: refused ? 'deny' : 'error', requestId, body: body as Record<string, unknown>,
583
+ });
584
+ if (error instanceof HandlerError) {
585
+ return json({ message: error.message }, error.status);
586
+ }
587
+ const message = error instanceof Error ? error.message : 'RPC execution failed';
567
588
  if (options?.hideSchemaErrors && status === 500) {
568
589
  return json({ message: 'Internal Server Error' }, 500);
569
590
  }
@@ -608,8 +629,24 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
608
629
  const settings = options?.pgSettings?.(user, client);
609
630
  const table = schema[tableName];
610
631
 
632
+ // Forensic audit of the non-GET table surface (writes). Capture the query, not
633
+ // the response: the operation, the WHERE predicate, and the body's column NAMES
634
+ // (never values — values can be PII). GET is delegated to RLS, never audited.
635
+ const isWrite = method === 'POST' || method === 'PATCH' || method === 'DELETE';
636
+ const auditOp: 'insert' | 'update' | 'delete' =
637
+ method === 'POST' ? 'insert' : method === 'PATCH' ? 'update' : 'delete';
638
+ const auditFilter = url.searchParams.toString() || undefined;
639
+ let auditColumns: string[] | undefined;
640
+ if (isWrite && options?.audit?.sink && method !== 'DELETE') {
641
+ // Cheap clone (writes are low-volume) so the handler still reads the body.
642
+ const cloned = await request.clone().json().catch(() => null);
643
+ if (cloned && typeof cloned === 'object' && !Array.isArray(cloned)) {
644
+ auditColumns = Object.keys(cloned as Record<string, unknown>);
645
+ }
646
+ }
647
+
611
648
  try {
612
- return await executeWithSettings(db, settings, async (dbOrTx) => {
649
+ const response = await executeWithSettings(db, settings, async (dbOrTx) => {
613
650
  if (method === 'GET') {
614
651
  const cacheControl = getCacheControl(options);
615
652
  return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
@@ -629,7 +666,22 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
629
666
 
630
667
  return json({ message: `Method ${method} not allowed` }, 405);
631
668
  });
669
+ if (isWrite) {
670
+ await emitAudit(options?.audit, {
671
+ kind: 'rest', name: tableName, user, client,
672
+ result: response.status < 400 ? 'allow' : response.status < 500 ? 'deny' : 'error',
673
+ requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
674
+ });
675
+ }
676
+ return response;
632
677
  } catch (error) {
678
+ if (isWrite) {
679
+ await emitAudit(options?.audit, {
680
+ kind: 'rest', name: tableName, user, client,
681
+ result: isPermissionDenied(error) ? 'deny' : 'error',
682
+ requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
683
+ });
684
+ }
633
685
  if (isPermissionDenied(error)) {
634
686
  return sanitizeError('Permission denied', 403, options);
635
687
  }