dzql 0.6.37 → 0.8.0

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.
Files changed (45) hide show
  1. package/README.md +16 -0
  2. package/docs/README.md +1 -1
  3. package/docs/for_ai.md +39 -9
  4. package/package.json +12 -5
  5. package/src/cli/codegen/notification.ts +3 -3
  6. package/src/cli/codegen/sql/core.ts +21 -5
  7. package/src/cli/codegen/sql/delete.ts +5 -7
  8. package/src/cli/codegen/sql/get.ts +9 -11
  9. package/src/cli/codegen/sql/index.ts +7 -4
  10. package/src/cli/codegen/sql/lookup.ts +4 -5
  11. package/src/cli/codegen/sql/save.ts +58 -39
  12. package/src/cli/codegen/sql/search.ts +44 -10
  13. package/src/cli/codegen/sql/utils.ts +16 -1
  14. package/src/cli/codegen/subscribable_sql.ts +116 -124
  15. package/src/cli/compile.ts +263 -0
  16. package/src/cli/compiler/path-validator.ts +115 -83
  17. package/src/cli/compiler/paths.ts +299 -379
  18. package/src/cli/compiler/permissions.ts +26 -26
  19. package/src/cli/index.ts +17 -219
  20. package/src/client/ws.ts +20 -10
  21. package/src/postgres/auth-jwt-sql.ts +32 -0
  22. package/src/postgres/auth-jwt.ts +209 -0
  23. package/src/postgres/auth.ts +50 -0
  24. package/src/postgres/bootstrap.ts +55 -0
  25. package/src/postgres/carry.ts +40 -0
  26. package/src/postgres/codegen.ts +172 -0
  27. package/src/postgres/index.ts +58 -0
  28. package/src/postgres/listener.ts +706 -0
  29. package/src/postgres/migrate.ts +143 -0
  30. package/src/postgres/pglite.ts +142 -0
  31. package/src/postgres/registry.ts +253 -0
  32. package/src/postgres/schema.ts +171 -0
  33. package/src/postgres/sql.ts +42 -0
  34. package/src/runtime/auth.ts +33 -2
  35. package/src/runtime/index.ts +47 -6
  36. package/src/runtime/manifest_loader.ts +10 -0
  37. package/src/runtime/projection.ts +19 -0
  38. package/src/sql/001a-delta-meta.sql +204 -0
  39. package/src/sql/001b-delta-scope.sql +619 -0
  40. package/src/sql/001c-delta-read.sql +506 -0
  41. package/src/sql/001d-delta-write.sql +1088 -0
  42. package/src/sql/001e-delta-ops.sql +142 -0
  43. package/src/sql/001f-delta-as.sql +41 -0
  44. package/src/sql/001g-delta-ledger.sql +564 -0
  45. package/src/sql/auth-jwt.sql +62 -0
@@ -1,43 +1,43 @@
1
1
  /**
2
- * Compiles a permission rule into SQL.
2
+ * Compiles permission rules into SQL.
3
3
  *
4
4
  * Supported formats:
5
- * - Literal: TRUE, FALSE
5
+ * - Literal: TRUE (public), FALSE (nobody), AUTHENTICATED (any signed-in user)
6
6
  * - Simple field check: @author_id (implies author_id == user_id)
7
7
  * - Explicit comparison: @field == @user_id
8
8
  * - Single-hop traversal: @org_id->acts_for[org_id=$]{active}.user_id
9
9
  * - Multi-hop traversal: @venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
10
- * - Deep traversal: @site_id->sites.venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
10
+ * - Filtered middle hop: @occasion_id->occasion_promoters[occasion_id=$].promoter_id->user_orgs[org_id=$].user_id
11
+ * - Several filters: @org_id->user_orgs[org_id=$,role=owner].user_id
11
12
  * - Table-first: contractor_rights[package_id=@package_id]{active}.contractor_org_id->...
12
13
  *
13
- * Uses the shared path parser for parity with notification compilation.
14
- *
15
- * @param entityName - The entity name
16
- * @param rule - The permission rule string
17
- * @param context - Optional context (unused)
18
- * @param dataVar - Either a JSONB variable name (e.g., 'p_data') or a table name (e.g., 'packages')
19
- * When it's a table name, we use table.column syntax instead of jsonb->>'column'
14
+ * Uses the shared path parser for parity with notification and subscribe compilation.
15
+ * A rule that can't be compiled throws PathCompileError; it never compiles to FALSE.
20
16
  */
21
17
 
22
- import { parsePath, compilePathToExists } from "./paths.js";
23
-
24
- export function compilePermission(entityName: string, rule: string, context: any, dataVar: string = 'p_data'): string {
25
- // Literal TRUE/FALSE - public access or deny all
26
- if (rule === 'TRUE' || rule === 'true') {
27
- return 'TRUE';
28
- }
29
- if (rule === 'FALSE' || rule === 'false') {
30
- return 'FALSE';
31
- }
18
+ import { parsePath, compilePathToExists, PathCompileError, type RowSource } from "./paths.js";
32
19
 
33
- // Parse the path
20
+ /**
21
+ * @param entityName - The entity name (for error messages)
22
+ * @param rule - The permission rule string
23
+ * @param context - Optional context (unused)
24
+ * @param dataVar - A JSONB variable name (e.g., 'p_data', 'v_old_data'), a table name or
25
+ * alias in scope (e.g., 'packages'), or an explicit RowSource
26
+ */
27
+ export function compilePermission(entityName: string, rule: string, context: any, dataVar: string | RowSource = 'p_data'): string {
34
28
  const parsed = parsePath(rule);
35
-
36
29
  if (!parsed.isValid) {
37
- console.warn(`[Compiler] Warning: ${parsed.error}`);
38
- return 'FALSE';
30
+ throw new PathCompileError(`${entityName}: ${parsed.error}`, rule);
39
31
  }
40
-
41
- // Compile to EXISTS check
42
32
  return compilePathToExists(parsed, dataVar, 'p_user_id');
43
33
  }
34
+
35
+ /**
36
+ * Compile a list of rules: any one of them grants access. An empty or missing
37
+ * list denies everyone.
38
+ */
39
+ export function compileRuleList(entityName: string, rules: string[] | undefined, dataVar: string | RowSource = 'p_data'): string {
40
+ if (!rules || rules.length === 0) return 'FALSE';
41
+ if (rules.length === 1) return compilePermission(entityName, rules[0], null, dataVar);
42
+ return rules.map(rule => `(${compilePermission(entityName, rule, null, dataVar)})`).join(' OR ');
43
+ }
package/src/cli/index.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { loadDomain } from "./compiler/loader.js";
3
- import { analyzeDomain } from "./compiler/analyzer.js";
4
- import { generateIR } from "./compiler/ir.js";
5
- import { generateCoreSQL, generateAuthSQL, generateEntitySQL, generateSchemaSQL } from "./codegen/sql.js";
6
- import { generateSubscribableSQL, generateComputeAffectedKeysFunction } from "./codegen/subscribable_sql.js";
3
+ import { compileDomain, DomainValidationError } from "./compile.js";
7
4
  import { generateManifest } from "./codegen/manifest.js";
8
5
  import { generateSubscribableStore } from "./codegen/subscribable_store.js";
9
6
  import { generateClientSDK } from "./codegen/client.js";
@@ -60,62 +57,19 @@ async function main() {
60
57
  const domain = await loadDomain(fullInputPath);
61
58
  console.log("[Compiler] Domain loaded.");
62
59
 
63
- const errors = analyzeDomain(domain);
64
- if (errors.length > 0) {
65
- console.error("[Compiler] Validation Failed:");
66
- errors.forEach(err => console.error(` - ${err}`));
67
- process.exit(1);
68
- }
69
-
70
- // Phase 2: Generate IR
71
- const ir = generateIR(domain);
72
- console.log(`[Compiler] IR Generated. Subscribables: ${Object.keys(ir.subscribables).join(', ')}`);
73
-
74
- // Phase 3: Generate SQL
75
- const coreSQL = generateCoreSQL();
76
-
77
- // Topologically sort entities by FK dependencies
78
- // Entities must be created before entities that reference them
79
- // Also detects circular FKs that need deferred constraint creation
80
- const { sorted: sortedEntityNames, circularFKs } = topologicalSortEntities(ir.entities);
81
-
82
- // Build set of circular FK columns for stripping during schema generation
83
- const circularFKSet = new Set(circularFKs.map(fk => `${fk.table}.${fk.column}`));
84
-
85
- const entitySQL: string[] = [];
86
- for (const name of sortedEntityNames) {
87
- const entityIR = ir.entities[name];
88
- // Pass circular FK info so schema generation can strip those constraints
89
- entitySQL.push(generateSchemaSQL(name, entityIR, circularFKSet));
90
- // Skip CRUD generation for unmanaged entities (e.g., junction tables)
91
- if (entityIR.managed !== false) {
92
- entitySQL.push(generateEntitySQL(name, entityIR));
93
- } else {
94
- console.log(`[Compiler] Skipping CRUD for unmanaged entity: ${name}`);
60
+ let compiled;
61
+ try {
62
+ compiled = compileDomain(domain);
63
+ } catch (e) {
64
+ if (e instanceof DomainValidationError) {
65
+ console.error("[Compiler] Validation Failed:");
66
+ e.errors.forEach(err => console.error(` - ${err}`));
67
+ process.exit(1);
95
68
  }
69
+ throw e;
96
70
  }
97
-
98
- // Add deferred FK constraints at the end (for circular dependencies)
99
- if (circularFKs.length > 0) {
100
- entitySQL.push(generateDeferredFKConstraints(circularFKs));
101
- }
102
-
103
- // Generate subscribable SQL functions
104
- const subscribableSQL: string[] = [];
105
- const subscribableNames = Object.keys(ir.subscribables);
106
- for (const [name, subIR] of Object.entries(ir.subscribables)) {
107
- console.log(`[Compiler] Generating SQL for subscribable: ${name}`);
108
- subscribableSQL.push(generateSubscribableSQL(name, subIR as any, ir.entities));
109
- }
110
- // Generate central compute_affected_keys function that aggregates all subscribables
111
- subscribableSQL.push(generateComputeAffectedKeysFunction(subscribableNames));
112
-
113
- // Collect custom functions SQL
114
- const customFunctionSQL: string[] = [];
115
- for (const fn of ir.customFunctions) {
116
- console.log(`[Compiler] Adding custom function: ${fn.name}`);
117
- customFunctionSQL.push(fn.sql);
118
- }
71
+ const { ir, coreSQL, schemaSQL } = compiled;
72
+ console.log(`[Compiler] IR Generated. Subscribables: ${Object.keys(ir.subscribables).join(', ')}`);
119
73
 
120
74
  // Phase 4: Generate Manifest
121
75
  const manifest = generateManifest(ir);
@@ -128,26 +82,15 @@ async function main() {
128
82
 
129
83
  writeFileSync(resolve(dbDir, `000_core.sql`), coreSQL);
130
84
  const timestamp = new Date().toISOString().replace(/[:.-]/g, '');
85
+ writeFileSync(resolve(dbDir, `${timestamp}_schema.sql`), schemaSQL);
131
86
 
132
- // Combine entity SQL with auth functions and custom functions
133
- // Auth functions must come after schema (they depend on users table)
134
- const authSQL = generateAuthSQL();
135
- let schemaContent = entitySQL.join('\n');
136
- schemaContent += '\n\n-- Auth Functions\n' + authSQL;
137
- if (customFunctionSQL.length > 0) {
138
- schemaContent += '\n\n-- Custom Functions\n' + customFunctionSQL.join('\n\n');
139
- }
140
- writeFileSync(resolve(dbDir, `${timestamp}_schema.sql`), schemaContent);
141
-
142
- if (customFunctionSQL.length > 0) {
143
- console.log(`[Generated] ${customFunctionSQL.length} Custom Functions`);
87
+ if (compiled.customFunctionCount > 0) {
88
+ console.log(`[Generated] ${compiled.customFunctionCount} Custom Functions`);
144
89
  }
145
90
 
146
91
  // Write subscribable SQL
147
- if (subscribableSQL.length > 0) {
148
- writeFileSync(resolve(dbDir, `${timestamp}_subscribables.sql`), subscribableSQL.join('\n\n'));
149
- console.log(`[Generated] ${subscribableSQL.length} Subscribable SQL functions`);
150
- }
92
+ writeFileSync(resolve(dbDir, `${timestamp}_subscribables.sql`), compiled.subscribableSQL);
93
+ console.log(`[Generated] Subscribable SQL for ${Object.keys(ir.subscribables).length} subscribables`);
151
94
 
152
95
  console.log(`[Generated] DB Migrations in ${dbDir}`);
153
96
 
@@ -227,148 +170,3 @@ async function main() {
227
170
  }
228
171
 
229
172
  main();
230
-
231
- /** FK info for dependency analysis */
232
- interface FKInfo {
233
- table: string;
234
- column: string;
235
- referencedTable: string;
236
- fullConstraint: string; // e.g., "int NOT NULL REFERENCES users(id)"
237
- }
238
-
239
- /** Result of topological sort with cycle detection */
240
- interface SortResult {
241
- sorted: string[];
242
- circularFKs: FKInfo[];
243
- }
244
-
245
- /**
246
- * Topologically sort entities based on FK dependencies.
247
- * Entities that are referenced by others come first.
248
- * Uses Kahn's algorithm for topological sorting.
249
- * Returns both sorted order and any circular FK constraints that need deferred creation.
250
- */
251
- function topologicalSortEntities(entities: Record<string, any>): SortResult {
252
- const entityNames = Object.keys(entities);
253
-
254
- // Build dependency graph: entity -> entities it depends on (references)
255
- const dependencies: Record<string, Set<string>> = {};
256
- const dependents: Record<string, Set<string>> = {};
257
-
258
- // Track all FK info for cycle detection
259
- const allFKs: FKInfo[] = [];
260
-
261
- for (const name of entityNames) {
262
- dependencies[name] = new Set();
263
- dependents[name] = new Set();
264
- }
265
-
266
- // Parse REFERENCES from column types
267
- for (const name of entityNames) {
268
- const entity = entities[name];
269
- for (const col of entity.columns || []) {
270
- const match = col.type?.match(/REFERENCES\s+(\w+)/i);
271
- if (match) {
272
- const referencedEntity = match[1];
273
- // Only track dependencies to entities we're managing
274
- if (entityNames.includes(referencedEntity)) {
275
- dependencies[name].add(referencedEntity);
276
- dependents[referencedEntity].add(name);
277
- allFKs.push({
278
- table: name,
279
- column: col.name,
280
- referencedTable: referencedEntity,
281
- fullConstraint: col.type
282
- });
283
- }
284
- }
285
- }
286
- }
287
-
288
- // Kahn's algorithm
289
- const result: string[] = [];
290
- const noIncoming: string[] = [];
291
- const remainingDeps: Record<string, Set<string>> = {};
292
-
293
- // Copy dependencies for mutation
294
- for (const name of entityNames) {
295
- remainingDeps[name] = new Set(dependencies[name]);
296
- }
297
-
298
- // Find entities with no dependencies (no incoming edges)
299
- for (const name of entityNames) {
300
- if (remainingDeps[name].size === 0) {
301
- noIncoming.push(name);
302
- }
303
- }
304
-
305
- while (noIncoming.length > 0) {
306
- const node = noIncoming.shift()!;
307
- result.push(node);
308
-
309
- // Remove this node from the graph
310
- for (const dependent of dependents[node]) {
311
- remainingDeps[dependent].delete(node);
312
- if (remainingDeps[dependent].size === 0) {
313
- noIncoming.push(dependent);
314
- }
315
- }
316
- }
317
-
318
- // Detect circular FKs
319
- const circularFKs: FKInfo[] = [];
320
- if (result.length !== entityNames.length) {
321
- const cycleEntities = new Set(entityNames.filter(n => !result.includes(n)));
322
- console.warn(`[Compiler] Warning: Circular FK dependencies detected among: ${[...cycleEntities].join(', ')}`);
323
-
324
- // Find FKs that are part of the cycle - these need deferred creation
325
- for (const fk of allFKs) {
326
- if (cycleEntities.has(fk.table) && cycleEntities.has(fk.referencedTable)) {
327
- circularFKs.push(fk);
328
- console.log(`[Compiler] Deferring circular FK: ${fk.table}.${fk.column} -> ${fk.referencedTable}`);
329
- }
330
- }
331
-
332
- // Add remaining entities (cycle members) to result
333
- result.push(...cycleEntities);
334
- }
335
-
336
- return { sorted: result, circularFKs };
337
- }
338
-
339
- /**
340
- * Generate ALTER TABLE statements for deferred circular FK constraints.
341
- */
342
- function generateDeferredFKConstraints(circularFKs: FKInfo[]): string {
343
- if (circularFKs.length === 0) return '';
344
-
345
- const statements: string[] = [
346
- '\n-- === DEFERRED FK CONSTRAINTS (for circular dependencies) ===\n'
347
- ];
348
-
349
- for (const fk of circularFKs) {
350
- // Extract the REFERENCES part from the full constraint
351
- const refMatch = fk.fullConstraint.match(/REFERENCES\s+(\w+)(\([^)]+\))?(\s+ON\s+.+)?/i);
352
- if (refMatch) {
353
- const targetTable = refMatch[1];
354
- const targetCol = refMatch[2] || '(id)';
355
- const cascadeClause = refMatch[3] || '';
356
- const constraintName = `fk_${fk.table}_${fk.column}`;
357
-
358
- statements.push(`ALTER TABLE ${fk.table} ADD CONSTRAINT ${constraintName} FOREIGN KEY (${fk.column}) REFERENCES ${targetTable}${targetCol}${cascadeClause};`);
359
- }
360
- }
361
-
362
- return statements.join('\n');
363
- }
364
-
365
- /**
366
- * Remove REFERENCES clause from a column type for deferred FK creation.
367
- * Preserves NOT NULL and other modifiers.
368
- */
369
- function stripReferences(columnType: string): string {
370
- // Remove REFERENCES clause and any ON DELETE/UPDATE clauses
371
- return columnType
372
- .replace(/\s*REFERENCES\s+\w+(\([^)]+\))?(\s+ON\s+(DELETE|UPDATE)\s+(CASCADE|SET NULL|SET DEFAULT|RESTRICT|NO ACTION))*\s*/gi, ' ')
373
- .trim();
374
- }
package/src/client/ws.ts CHANGED
@@ -51,6 +51,8 @@ export class WebSocketManager {
51
51
  protected maxReconnectAttempts = 5;
52
52
  protected tokenName = 'dzql_token';
53
53
  protected isShuttingDown = false;
54
+ /** The URL last given to connect(): a reconnect goes back to it. */
55
+ protected url: string | null = null;
54
56
 
55
57
  public user: any = null;
56
58
  public ready: boolean = false;
@@ -101,15 +103,16 @@ export class WebSocketManager {
101
103
  this.ready = false;
102
104
  this.ws?.close();
103
105
  this.ws = null;
104
- this.isShuttingDown = false;
105
106
  }
106
107
 
107
108
  connect(url: string | null = null, timeout = 5000): Promise<void> {
108
109
  return new Promise((resolve, reject) => {
109
110
  this.ready = false;
110
111
  this.user = null;
112
+ this.isShuttingDown = false;
111
113
 
112
- let wsUrl = url;
114
+ let wsUrl = url ?? this.url;
115
+ if (url) this.url = url;
113
116
  if (!wsUrl) {
114
117
  if (typeof globalThis !== "undefined" && "window" in globalThis) {
115
118
  const win = globalThis as unknown as { location: { protocol: string; host: string } };
@@ -128,19 +131,20 @@ export class WebSocketManager {
128
131
  }
129
132
 
130
133
  const connectionTimeout = setTimeout(() => {
131
- this.ws?.close();
134
+ socket.close();
132
135
  reject(new Error('WebSocket connection timed out'));
133
136
  }, timeout);
134
137
 
135
- this.ws = new WebSocket(wsUrl);
138
+ const socket = new WebSocket(wsUrl);
139
+ this.ws = socket;
136
140
 
137
- this.ws.onopen = () => {
141
+ socket.onopen = () => {
138
142
  clearTimeout(connectionTimeout);
139
143
  this.reconnectAttempts = 0;
140
144
  resolve();
141
145
  };
142
146
 
143
- this.ws.onmessage = (event) => {
147
+ socket.onmessage = (event) => {
144
148
  try {
145
149
  this.handleMessage(JSON.parse(event.data));
146
150
  } catch (error) {
@@ -148,13 +152,14 @@ export class WebSocketManager {
148
152
  }
149
153
  };
150
154
 
151
- this.ws.onclose = () => {
152
- if (!this.isShuttingDown) {
155
+ // only the current socket reconnects: one replaced by a later connect() or a disconnect() does not
156
+ socket.onclose = () => {
157
+ if (this.ws === socket && !this.isShuttingDown) {
153
158
  this.attemptReconnect();
154
159
  }
155
160
  };
156
161
 
157
- this.ws.onerror = (error) => {
162
+ socket.onerror = (error) => {
158
163
  clearTimeout(connectionTimeout);
159
164
  reject(error);
160
165
  };
@@ -164,7 +169,12 @@ export class WebSocketManager {
164
169
  attemptReconnect() {
165
170
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
166
171
  this.reconnectAttempts++;
167
- setTimeout(() => this.connect(), 1000 * this.reconnectAttempts);
172
+ setTimeout(() => {
173
+ // a disconnect() since, or a connect() already open or opening, needs no reconnect
174
+ if (this.isShuttingDown || (this.ws && this.ws.readyState <= WebSocket.OPEN)) return;
175
+ // a failed attempt closes, and its onclose tries again: nothing to reject to
176
+ this.connect().catch(() => {});
177
+ }, 1000 * this.reconnectAttempts);
168
178
  }
169
179
  }
170
180
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Auth-JWT SQL helpers — pure filesystem access, no jose dependency.
3
+ *
4
+ * These are split out of `auth-jwt.ts` so the `delta init` CLI (which only
5
+ * needs the file path) can run in projects that haven't installed jose.
6
+ * Consumers of the `@blueshed/delta/auth-jwt` subpath still get these via
7
+ * re-export from `auth-jwt.ts`.
8
+ */
9
+ import type { Pool } from "pg";
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ const AUTH_JWT_SQL_PATH = join(import.meta.dir, "..", "sql", "auth-jwt.sql");
14
+
15
+ /** Absolute path to the reference auth-jwt SQL file (users + register + login). */
16
+ export function authJwtSqlFile(): string {
17
+ return AUTH_JWT_SQL_PATH;
18
+ }
19
+
20
+ /** The reference auth-jwt SQL as a string. */
21
+ export function authJwtSql(): string {
22
+ return readFileSync(AUTH_JWT_SQL_PATH, "utf8");
23
+ }
24
+
25
+ /**
26
+ * Apply the reference users schema + login/register stored functions to a
27
+ * pool. Idempotent. Call once at boot alongside `applyFramework(pool)`, or
28
+ * use `delta init --with-auth` to copy the file for docker-entrypoint use.
29
+ */
30
+ export async function applyAuthJwtSchema(pool: Pool): Promise<void> {
31
+ await pool.query(authJwtSql());
32
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Auth-JWT — reference `DeltaAuth` implementation using JWTs (jose) and
3
+ * Postgres stored functions for credential verification.
4
+ *
5
+ * Ship this as an opt-in subpath: `@blueshed/delta/auth-jwt`. Users who want
6
+ * a different auth scheme (sessions, OAuth, magic links, none) implement
7
+ * their own `DeltaAuth` instead.
8
+ *
9
+ * Contract with Postgres:
10
+ * - `login(email, password) RETURNS jsonb` — return a user row or NULL.
11
+ * - `register(name, email, password) RETURNS jsonb` — return a user row,
12
+ * raise unique_violation (23505) on duplicate email.
13
+ *
14
+ * Both are overridable via the `loginSql` / `registerSql` options.
15
+ *
16
+ * import { jwtAuth } from "@blueshed/delta/auth-jwt";
17
+ *
18
+ * const auth = jwtAuth({
19
+ * pool,
20
+ * secret: process.env.JWT_SECRET!,
21
+ * });
22
+ *
23
+ * wireAuth(ws, auth);
24
+ * await createDocListener(ws, pool, { auth });
25
+ *
26
+ * SQL-file helpers (`authJwtSqlFile`, `authJwtSql`, `applyAuthJwtSchema`) live
27
+ * in `./auth-jwt-sql.ts` and are re-exported from here for convenience. They
28
+ * have no jose dependency, so the `delta init` CLI can load them without
29
+ * forcing consumers to install jose just to copy SQL files.
30
+ */
31
+ import { SignJWT, jwtVerify, decodeJwt } from "jose";
32
+ import type { Pool } from "pg";
33
+ import { createLogger } from "@blueshed/delta/logger";
34
+ import type { DeltaAuth } from "@blueshed/delta/auth";
35
+ import { dropClientSubscriptions } from "@blueshed/delta/backend";
36
+
37
+ /** Algorithm this module signs with — pinned on verify to prevent alg-confusion. */
38
+ const JWT_ALG = "HS256";
39
+
40
+ export {
41
+ authJwtSqlFile,
42
+ authJwtSql,
43
+ applyAuthJwtSchema,
44
+ } from "./auth-jwt-sql";
45
+
46
+ const log = createLogger("[auth-jwt]");
47
+
48
+ export interface User {
49
+ id: number | string;
50
+ name?: string;
51
+ email?: string;
52
+ [key: string]: unknown;
53
+ }
54
+
55
+ export interface JwtAuthOpts {
56
+ pool: Pool;
57
+ secret: string | Uint8Array;
58
+ expirationTime?: string;
59
+ loginSql?: string;
60
+ registerSql?: string;
61
+ /**
62
+ * Optional re-validation hook for `authenticate`. A JWT is stateless: by
63
+ * default a signed, unexpired token grants access even if the user was
64
+ * since deleted or disabled (the documented trade-off of stateless auth).
65
+ * Provide `verifyUser` to re-check the identity against the database on
66
+ * each `authenticate`; return the (possibly refreshed) user to allow, or
67
+ * `null` to reject. Not called on `login` / `register` (those just hit the DB).
68
+ */
69
+ verifyUser?: (user: User, pool: Pool) => Promise<User | null>;
70
+ }
71
+
72
+ /** Set the socket's identity, tearing down a prior *different* identity's
73
+ * live subscriptions so an identity switch on one socket can't keep
74
+ * receiving the previous user's scoped docs. `expires` is when the token it
75
+ * came from runs out (ms since the epoch); `gate` refuses it after that. */
76
+ function switchIdentity(client: any, user: User, expires: number | undefined): void {
77
+ const prev = client.data?.identity as User | undefined;
78
+ if (prev && String(prev.id) !== String(user.id)) {
79
+ dropClientSubscriptions(client);
80
+ }
81
+ if (!client.data) client.data = {};
82
+ client.data.identity = user;
83
+ client.data.identityExpires = expires;
84
+ }
85
+
86
+ /** When a token's `exp` says it runs out, in ms since the epoch. */
87
+ const expiresOf = (exp: unknown): number | undefined => (typeof exp === "number" ? exp * 1000 : undefined);
88
+
89
+ export function jwtAuth(opts: JwtAuthOpts): DeltaAuth<User> {
90
+ const secret =
91
+ typeof opts.secret === "string"
92
+ ? new TextEncoder().encode(opts.secret)
93
+ : opts.secret;
94
+ const expirationTime = opts.expirationTime ?? "7d";
95
+ const loginSql = opts.loginSql ?? "SELECT login($1, $2) AS result";
96
+ const registerSql = opts.registerSql ?? "SELECT register($1, $2, $3) AS result";
97
+
98
+ async function signToken(user: User): Promise<string> {
99
+ return new SignJWT({
100
+ sub: String(user.id),
101
+ name: user.name,
102
+ email: user.email,
103
+ })
104
+ .setProtectedHeader({ alg: JWT_ALG })
105
+ .setIssuedAt()
106
+ .setExpirationTime(expirationTime)
107
+ .sign(secret);
108
+ }
109
+
110
+ async function verifyToken(token: string): Promise<{ user: User; expires: number | undefined } | null> {
111
+ try {
112
+ // Pin the algorithm allowlist — without it, jose accepts any alg the
113
+ // token header claims, opening an alg-confusion vector.
114
+ const { payload } = await jwtVerify(token, secret, { algorithms: [JWT_ALG] });
115
+ const id = payload.sub;
116
+ if (id === undefined) return null;
117
+ const user: User = {
118
+ id: /^\d+$/.test(String(id)) ? Number(id) : String(id),
119
+ name: payload.name as string | undefined,
120
+ email: payload.email as string | undefined,
121
+ };
122
+ return { user, expires: expiresOf(payload.exp) };
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ return {
129
+ actions: {
130
+ async login(params, client) {
131
+ const { email, password } = params ?? {};
132
+ if (!email || !password) return { error: "email and password required" };
133
+ const { rows } = await opts.pool.query(loginSql, [email, password]);
134
+ const user = rows[0]?.result as User | null;
135
+ if (!user) return { error: "Invalid credentials" };
136
+ const token = await signToken(user);
137
+ switchIdentity(client, user, expiresOf(decodeJwt(token).exp));
138
+ log.info(`login user=${user.id}`);
139
+ return { result: { ...user, token } };
140
+ },
141
+
142
+ async register(params, client) {
143
+ const { name, email, password } = params ?? {};
144
+ if (!name || !email || !password) {
145
+ return { error: "name, email, and password required" };
146
+ }
147
+ try {
148
+ const { rows } = await opts.pool.query(registerSql, [name, email, password]);
149
+ const user = rows[0]?.result as User;
150
+ const token = await signToken(user);
151
+ switchIdentity(client, user, expiresOf(decodeJwt(token).exp));
152
+ log.info(`register user=${user.id}`);
153
+ return { result: { ...user, token } };
154
+ } catch (err: any) {
155
+ if (err.code === "23505") return { error: "Email already registered" };
156
+ throw err;
157
+ }
158
+ },
159
+
160
+ async authenticate(params, client) {
161
+ const { token } = params ?? {};
162
+ if (!token) return { error: "token required" };
163
+ const verified = await verifyToken(token);
164
+ if (!verified) return { error: "Invalid token" };
165
+ let user = verified.user;
166
+ if (opts.verifyUser) {
167
+ const checked = await opts.verifyUser(user, opts.pool);
168
+ if (!checked) return { error: "Invalid token" };
169
+ user = checked;
170
+ }
171
+ switchIdentity(client, user, verified.expires);
172
+ log.info(`authenticate user=${user.id}`);
173
+ return { result: user };
174
+ },
175
+
176
+ async logout(_params, client) {
177
+ const prev = client.data?.identity as User | undefined;
178
+ if (client.data) { delete client.data.identity; delete client.data.identityExpires; }
179
+ // Tear down the prior identity's live doc subscriptions so its scoped
180
+ // ops stop streaming to this socket immediately.
181
+ dropClientSubscriptions(client);
182
+ if (prev) log.info(`logout user=${prev.id}`);
183
+ return { result: { ack: true } };
184
+ },
185
+ },
186
+
187
+ gate(client) {
188
+ // A session lasts as long as its token: once it runs out the socket is
189
+ // signed out (and its documents let go), as a logout would do, and says
190
+ // so until it signs in again -- the listener asks the gate before it
191
+ // tells a socket of a change, too, so that is not always a request.
192
+ const expires = client.data?.identityExpires as number | undefined;
193
+ if (expires !== undefined && Date.now() >= expires) {
194
+ if (client.data.identity !== undefined) {
195
+ delete client.data.identity;
196
+ dropClientSubscriptions(client);
197
+ }
198
+ return { error: "Session expired: authenticate again" };
199
+ }
200
+ const identity = client.data?.identity as User | undefined;
201
+ if (!identity) return { error: "Authentication required" };
202
+ return identity;
203
+ },
204
+
205
+ asSqlArg(identity) {
206
+ return identity.id;
207
+ },
208
+ };
209
+ }