@everystack/api 0.2.6 → 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.6",
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": {
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Forensic audit logging at the dispatch choke point.
3
+ *
4
+ * Distinct from observability. Observability answers "is the system healthy?" —
5
+ * counts, latency, errors. Audit answers "did principal X access subject Y's PII?",
6
+ * the question the platform must be able to answer after an incident. The handler is
7
+ * the only place the caller's identity, the action, and the params all meet, so the
8
+ * record is captured here and handed to an app-provided append-only sink.
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
+ *
18
+ * The sink should be append-only with integrity (e.g. S3 Object-Lock / WORM) — an
19
+ * audit log the app's own DB role can rewrite is not evidence. A sink failure is
20
+ * logged loudly (a dropped record is never silent) but does not fail the request:
21
+ * audit outage must not become read outage.
22
+ */
23
+
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. */
31
+ export interface AuditRecord {
32
+ /** ISO-8601 timestamp of the access. */
33
+ ts: string;
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. */
39
+ caller: string | 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. */
55
+ subject: string | null;
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;
63
+ }
64
+
65
+ /** Audit configuration. When a `sink` is set, every non-GET request is recorded. */
66
+ export interface AuditConfig {
67
+ /** Append-only sink for audit records. The app provides a WORM-backed writer
68
+ * (e.g. S3 Object-Lock). Awaited per request, so a well-behaved sink gives
69
+ * durability before the response; a throwing sink is logged, never fatal. */
70
+ sink: (record: AuditRecord) => void | Promise<void>;
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.
74
+ * Example: { get_user: { subject: 'p_user_id' } } */
75
+ rpc?: Record<string, { subject?: string }>;
76
+ /** Stamped on every record (multi-app / multi-stage trails). */
77
+ app?: string;
78
+ stage?: string;
79
+ }
80
+
81
+ /**
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.
86
+ */
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(
94
+ user: Record<string, unknown> | null,
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
+ }
131
+
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
+ }
150
+
151
+ const record: AuditRecord = {
152
+ ts: new Date().toISOString(),
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 } : {}),
167
+ };
168
+
169
+ try {
170
+ await audit.sink(record);
171
+ } catch (err) {
172
+ // A dropped audit record is a forensic gap — make it loud, never silent.
173
+ // But do not fail the request on an audit-sink outage (availability).
174
+ console.error(
175
+ `[everystack/api] audit sink failed; record dropped: `
176
+ + `action=${record.action} caller=${record.caller} subject=${record.subject}`,
177
+ err,
178
+ );
179
+ }
180
+ }
@@ -8,6 +8,10 @@
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 { emitAudit, resolvePrincipal, type AuditConfig, type RequestContext } from './audit';
12
+ export type { AuditConfig, AuditRecord, RequestContext, AuditOutcome, PrincipalKind } from './audit';
13
+ export { getPublicSurface, assertPublicSurface } from './public-surface';
14
+ export type { PublicSurface } from './public-surface';
11
15
 
12
16
  /* eslint-disable @typescript-eslint/no-explicit-any */
13
17
  type DrizzleDb = any;
@@ -47,11 +51,19 @@ export interface AuthConfig {
47
51
  publicRpc?: string[];
48
52
  /** Called after successful authentication with the user payload */
49
53
  onAuthenticated?: (user: Record<string, unknown>) => void;
50
- /** Verify client credentials and return client metadata, or null if invalid.
51
- * When configured, every request must provide client headers OR a Referer/Origin header.
52
- * Called with (clientId, clientSecret, referer) — the callback decides which path to validate:
53
- * - Secret-based (native/server): clientId + clientSecret present
54
- * - Referer-based (web): referer present, no secret */
54
+ /** Identify the calling app and return client metadata, or null to reject.
55
+ * Called with (clientId, clientSecret, referer); the callback picks the path:
56
+ * - Secret-based (native/server): clientId + clientSecret — a real shared secret.
57
+ * - Referer-based (web): referer/origin only — browsers can't hold a secret.
58
+ *
59
+ * SECURITY: this is *identification*, not a trust boundary. The referer/origin
60
+ * path is NOT authentication — Origin/Referer are trivially spoofable by any
61
+ * non-browser caller (curl, a script), so a referer-validated client is an
62
+ * UNTRUSTED public client and passing here proves nothing about the caller.
63
+ * Never gate sensitive data on client auth alone — real authorization is the
64
+ * user JWT + RLS (see verifyToken). Anything in `publicRpc` is reachable by the
65
+ * whole internet. (Origin is sound only as a CSRF signal on cookie auth — never
66
+ * as a data-access gate; the two threat models are different.) */
55
67
  verifyClient?: (clientId: string, clientSecret: string, referer?: string) => Promise<Record<string, unknown> | null>;
56
68
  /** Custom header names for client credentials.
57
69
  * Defaults: { id: 'X-Client-Id', secret: 'X-Client-Secret' } */
@@ -111,9 +123,11 @@ export interface CacheConfig {
111
123
  /** RPC handler — plain function (backward compatible).
112
124
  * Plain handlers dispatch directly: no transaction, no pgSettings — they own
113
125
  * their DB access through whatever they close over, same privilege model as
114
- * SSR loaders. The optional 4th argument is only provided to handlers
115
- * registered with `settings: true` (see RpcHandlerWithRole). */
116
- 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>;
117
131
 
118
132
  /** RPC handler with per-function role requirement and/or RLS context opt-in */
119
133
  export interface RpcHandlerWithRole {
@@ -177,6 +191,11 @@ export interface HandlerOptions {
177
191
  /** When true, error messages hide internal details (table names, column names, operators).
178
192
  * Enable in production to prevent schema enumeration. Default: false. */
179
193
  hideSchemaErrors?: boolean;
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. */
198
+ audit?: AuditConfig;
180
199
  }
181
200
 
182
201
  function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
@@ -437,13 +456,23 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
437
456
  const pathSegments = pathname.replace(/^\/+/, '').split('/').filter(Boolean);
438
457
  const method = request.method.toUpperCase();
439
458
 
440
- // Phase 1: Client auth (if configured) — identifies which app is calling
441
- // Supports two validation paths:
442
- // 1. Header-based (native/server): X-Client-Id + X-Client-Secret
443
- // 2. Referer-based (web): Referer or Origin header (no secrets in browser source)
444
- // The verifyClient callback receives all three and decides which path to validate.
445
- // Skipped for publicRoutes methods (e.g. GET) — no credentials needed.
446
- // publicRpc skips JWT verification only, NOT client auth.
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
+
466
+ // Phase 1: Client *identification* (if configured) — which app is calling.
467
+ // NOT a trust boundary. Two paths:
468
+ // 1. Secret-based (native/server): X-Client-Id + X-Client-Secret — a real secret.
469
+ // 2. Referer/Origin (web): the browser can't hold a secret, so this path has none.
470
+ // Origin/Referer are spoofable by any non-browser caller, so a referer-only
471
+ // client is UNTRUSTED — passing here proves nothing about the caller.
472
+ // Real authorization is Phase 2 (JWT + role) + RLS; client auth must never be the
473
+ // sole gate for sensitive data. Skipped for publicRoutes methods (e.g. GET).
474
+ // publicRpc skips JWT verification only, NOT client identification — so a publicRpc
475
+ // backed by this alone is internet-reachable. Treat the publicRpc list as public.
447
476
  let user: Record<string, unknown> | null = null;
448
477
  let client: Record<string, unknown> | null = null;
449
478
  const isPublicRoute = options?.auth?.publicRoutes?.includes(method) ?? false;
@@ -514,6 +543,11 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
514
543
 
515
544
  const fn = isObjectForm ? entry.fn : entry;
516
545
  const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
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) };
517
551
  try {
518
552
  // Plain RPCs dispatch directly — wrapping them in the settings
519
553
  // transaction deadlocks single-connection pools (the fn's queries
@@ -524,21 +558,33 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
524
558
  ? await executeWithSettings(
525
559
  db,
526
560
  options?.pgSettings?.(user, client),
527
- (tx) => fn(body, user, client, tx),
561
+ (tx) => fn(body, user, client, tx, ctx),
528
562
  )
529
- : await fn(body, user, client);
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
+ });
530
569
  return json(result, 200);
531
570
  } catch (error) {
532
- if (error instanceof HandlerError) {
533
- return json({ message: error.message }, error.status);
534
- }
535
- const message = error instanceof Error ? error.message : 'RPC execution failed';
536
571
  const pgCode = (error as any)?.code;
537
572
  let status = 500;
538
573
  if (pgCode === '28P01') status = 401;
539
574
  else if (pgCode === '42501') status = 403;
540
575
  else if (pgCode === '23505') status = 409;
541
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';
542
588
  if (options?.hideSchemaErrors && status === 500) {
543
589
  return json({ message: 'Internal Server Error' }, 500);
544
590
  }
@@ -583,8 +629,24 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
583
629
  const settings = options?.pgSettings?.(user, client);
584
630
  const table = schema[tableName];
585
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
+
586
648
  try {
587
- return await executeWithSettings(db, settings, async (dbOrTx) => {
649
+ const response = await executeWithSettings(db, settings, async (dbOrTx) => {
588
650
  if (method === 'GET') {
589
651
  const cacheControl = getCacheControl(options);
590
652
  return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
@@ -604,7 +666,22 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
604
666
 
605
667
  return json({ message: `Method ${method} not allowed` }, 405);
606
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;
607
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
+ }
608
685
  if (isPermissionDenied(error)) {
609
686
  return sanitizeError('Permission denied', 403, options);
610
687
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Public-surface guard.
3
+ *
4
+ * `publicRoutes` (HTTP methods that skip auth) and `publicRpc` (RPC names that skip
5
+ * JWT) are the explicitly internet-reachable surface. Client auth is NOT a gate for
6
+ * them — Origin/Referer is spoofable, so anything here is reachable by the whole
7
+ * internet (see verifyClient). This guard makes that surface a signed-off registry:
8
+ * snapshot the approved set in a test and any silent addition fails it. Each entry
9
+ * is a deliberate "safe to hand the whole internet" decision, not an accident.
10
+ *
11
+ * import { assertPublicSurface } from '@everystack/api';
12
+ * it('public surface is signed off', () => {
13
+ * assertPublicSurface(handlerOptions, {
14
+ * publicRoutes: ['GET'],
15
+ * publicRpc: ['health', 'signup', 'signin'], // each reviewed as internet-safe
16
+ * });
17
+ * });
18
+ */
19
+
20
+ /** The explicitly internet-reachable surface. */
21
+ export interface PublicSurface {
22
+ /** HTTP methods served without authentication. */
23
+ publicRoutes: string[];
24
+ /** RPC function names served without a JWT. */
25
+ publicRpc: string[];
26
+ }
27
+
28
+ /** Minimal structural view of the parts of HandlerOptions this reads. */
29
+ type PublicSurfaceSource = {
30
+ auth?: { publicRoutes?: string[]; publicRpc?: string[] };
31
+ } | undefined;
32
+
33
+ /** Extract the live public surface from handler options (sorted, deduped-by-source). */
34
+ export function getPublicSurface(options: PublicSurfaceSource): PublicSurface {
35
+ return {
36
+ publicRoutes: [...(options?.auth?.publicRoutes ?? [])].sort(),
37
+ publicRpc: [...(options?.auth?.publicRpc ?? [])].sort(),
38
+ };
39
+ }
40
+
41
+ function diff(live: string[], approved: string[]): { added: string[]; removed: string[] } {
42
+ return {
43
+ added: live.filter(x => !approved.includes(x)),
44
+ removed: approved.filter(x => !live.includes(x)),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Assert the live public surface equals the approved set. Throws on ANY drift —
50
+ * an addition (new internet-reachable surface that wasn't signed off) or a removal
51
+ * (the approved list is stale). Use as a test gate so the attack surface can't grow
52
+ * silently.
53
+ */
54
+ export function assertPublicSurface(options: PublicSurfaceSource, approved: PublicSurface): void {
55
+ const live = getPublicSurface(options);
56
+ const routes = diff(live.publicRoutes, [...approved.publicRoutes].sort());
57
+ const rpc = diff(live.publicRpc, [...approved.publicRpc].sort());
58
+
59
+ const problems: string[] = [];
60
+ if (routes.added.length) problems.push(`publicRoutes added (internet-reachable, unsigned): ${routes.added.join(', ')}`);
61
+ if (rpc.added.length) problems.push(`publicRpc added (internet-reachable, unsigned): ${rpc.added.join(', ')}`);
62
+ if (routes.removed.length) problems.push(`publicRoutes in the approved set but no longer live: ${routes.removed.join(', ')}`);
63
+ if (rpc.removed.length) problems.push(`publicRpc in the approved set but no longer live: ${rpc.removed.join(', ')}`);
64
+
65
+ if (problems.length) {
66
+ throw new Error(
67
+ '[everystack/api] public surface drift — the internet-reachable surface changed '
68
+ + 'without sign-off:\n ' + problems.join('\n ')
69
+ + '\nEvery publicRoutes/publicRpc entry is reachable by the whole internet with no '
70
+ + 'JWT (client auth is not a gate). Update the approved set only after confirming '
71
+ + 'each entry is safe to expose.',
72
+ );
73
+ }
74
+ }