@everystack/api 0.2.5 → 0.2.7

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.5",
3
+ "version": "0.2.7",
4
4
  "description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -340,8 +340,11 @@ function buildUrl(
340
340
  const base = typeof globalThis.location !== 'undefined' ? globalThis.location.origin : undefined;
341
341
  const url = new URL(path, base);
342
342
 
343
+ // append, not set: PostgREST ANDs repeated column params, so multiple
344
+ // conditions on one column (e.g. a range: created_at=gte.X & created_at=lte.Y)
345
+ // must both survive.
343
346
  for (const [key, value] of filters) {
344
- url.searchParams.set(key, value);
347
+ url.searchParams.append(key, value);
345
348
  }
346
349
  if (select) url.searchParams.set('select', select);
347
350
  if (order) url.searchParams.set('order', order);
@@ -0,0 +1,83 @@
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
+ * The sink should be append-only with integrity (e.g. S3 Object-Lock / WORM) — an
11
+ * audit log the app's own DB role can rewrite is not evidence. A sink failure is
12
+ * logged loudly (a dropped record is never silent) but does not fail the request:
13
+ * audit outage must not become read outage.
14
+ */
15
+
16
+ /** One recorded access of a sensitive surface. */
17
+ export interface AuditRecord {
18
+ /** ISO-8601 timestamp of the access. */
19
+ ts: string;
20
+ /** The action — the RPC function name. */
21
+ action: string;
22
+ /** The authenticated caller (user `sub`), or null if unauthenticated. */
23
+ caller: string | null;
24
+ /** The subject accessed — the id from the configured subject param, or null. */
25
+ 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;
30
+ }
31
+
32
+ /** Audit configuration. Records are emitted only for the RPCs listed in `rpc`. */
33
+ export interface AuditConfig {
34
+ /** Append-only sink for audit records. The app provides a WORM-backed writer
35
+ * (e.g. S3 Object-Lock). Awaited per request, so a well-behaved sink gives
36
+ * durability before the response; a throwing sink is logged, never fatal. */
37
+ 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.
41
+ * Example: { get_user: { subject: 'p_user_id' } } */
42
+ rpc?: Record<string, { subject?: string }>;
43
+ }
44
+
45
+ /**
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.
49
+ */
50
+ export async function emitRpcAudit(
51
+ audit: AuditConfig | undefined,
52
+ action: string,
53
+ 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;
60
+
61
+ const rawSubject = entry.subject ? body?.[entry.subject] : undefined;
62
+
63
+ const record: AuditRecord = {
64
+ 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 } : {}),
70
+ };
71
+
72
+ try {
73
+ await audit.sink(record);
74
+ } catch (err) {
75
+ // A dropped audit record is a forensic gap — make it loud, never silent.
76
+ // But do not fail the request on an audit-sink outage (availability).
77
+ console.error(
78
+ `[everystack/api] audit sink failed; record dropped: `
79
+ + `action=${record.action} caller=${record.caller} subject=${record.subject}`,
80
+ err,
81
+ );
82
+ }
83
+ }
@@ -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 { emitRpcAudit, type AuditConfig } from './audit';
12
+ export type { AuditConfig, AuditRecord } 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' } */
@@ -177,6 +189,10 @@ export interface HandlerOptions {
177
189
  /** When true, error messages hide internal details (table names, column names, operators).
178
190
  * Enable in production to prevent schema enumeration. Default: false. */
179
191
  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. */
195
+ audit?: AuditConfig;
180
196
  }
181
197
 
182
198
  function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
@@ -437,13 +453,16 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
437
453
  const pathSegments = pathname.replace(/^\/+/, '').split('/').filter(Boolean);
438
454
  const method = request.method.toUpperCase();
439
455
 
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.
456
+ // Phase 1: Client *identification* (if configured) — which app is calling.
457
+ // NOT a trust boundary. Two paths:
458
+ // 1. Secret-based (native/server): X-Client-Id + X-Client-Secret — a real secret.
459
+ // 2. Referer/Origin (web): the browser can't hold a secret, so this path has none.
460
+ // Origin/Referer are spoofable by any non-browser caller, so a referer-only
461
+ // client is UNTRUSTED — passing here proves nothing about the caller.
462
+ // Real authorization is Phase 2 (JWT + role) + RLS; client auth must never be the
463
+ // sole gate for sensitive data. Skipped for publicRoutes methods (e.g. GET).
464
+ // publicRpc skips JWT verification only, NOT client identification — so a publicRpc
465
+ // backed by this alone is internet-reachable. Treat the publicRpc list as public.
447
466
  let user: Record<string, unknown> | null = null;
448
467
  let client: Record<string, unknown> | null = null;
449
468
  const isPublicRoute = options?.auth?.publicRoutes?.includes(method) ?? false;
@@ -514,6 +533,8 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
514
533
 
515
534
  const fn = isObjectForm ? entry.fn : entry;
516
535
  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;
517
538
  try {
518
539
  // Plain RPCs dispatch directly — wrapping them in the settings
519
540
  // transaction deadlocks single-connection pools (the fn's queries
@@ -527,8 +548,12 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
527
548
  (tx) => fn(body, user, client, tx),
528
549
  )
529
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);
530
553
  return json(result, 200);
531
554
  } 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);
532
557
  if (error instanceof HandlerError) {
533
558
  return json({ message: error.message }, error.status);
534
559
  }
@@ -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
+ }