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.
- package/README.md +16 -0
- package/docs/README.md +1 -1
- package/docs/for_ai.md +39 -9
- package/package.json +12 -5
- package/src/cli/codegen/notification.ts +3 -3
- package/src/cli/codegen/sql/core.ts +21 -5
- package/src/cli/codegen/sql/delete.ts +5 -7
- package/src/cli/codegen/sql/get.ts +9 -11
- package/src/cli/codegen/sql/index.ts +7 -4
- package/src/cli/codegen/sql/lookup.ts +4 -5
- package/src/cli/codegen/sql/save.ts +58 -39
- package/src/cli/codegen/sql/search.ts +44 -10
- package/src/cli/codegen/sql/utils.ts +16 -1
- package/src/cli/codegen/subscribable_sql.ts +116 -124
- package/src/cli/compile.ts +263 -0
- package/src/cli/compiler/path-validator.ts +115 -83
- package/src/cli/compiler/paths.ts +299 -379
- package/src/cli/compiler/permissions.ts +26 -26
- package/src/cli/index.ts +17 -219
- package/src/client/ws.ts +20 -10
- package/src/postgres/auth-jwt-sql.ts +32 -0
- package/src/postgres/auth-jwt.ts +209 -0
- package/src/postgres/auth.ts +50 -0
- package/src/postgres/bootstrap.ts +55 -0
- package/src/postgres/carry.ts +40 -0
- package/src/postgres/codegen.ts +172 -0
- package/src/postgres/index.ts +58 -0
- package/src/postgres/listener.ts +706 -0
- package/src/postgres/migrate.ts +143 -0
- package/src/postgres/pglite.ts +142 -0
- package/src/postgres/registry.ts +253 -0
- package/src/postgres/schema.ts +171 -0
- package/src/postgres/sql.ts +42 -0
- package/src/runtime/auth.ts +33 -2
- package/src/runtime/index.ts +47 -6
- package/src/runtime/manifest_loader.ts +10 -0
- package/src/runtime/projection.ts +19 -0
- package/src/sql/001a-delta-meta.sql +204 -0
- package/src/sql/001b-delta-scope.sql +619 -0
- package/src/sql/001c-delta-read.sql +506 -0
- package/src/sql/001d-delta-write.sql +1088 -0
- package/src/sql/001e-delta-ops.sql +142 -0
- package/src/sql/001f-delta-as.sql +41 -0
- package/src/sql/001g-delta-ledger.sql +564 -0
- package/src/sql/auth-jwt.sql +62 -0
|
@@ -1,43 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compiles
|
|
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
|
-
* -
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
134
|
+
socket.close();
|
|
132
135
|
reject(new Error('WebSocket connection timed out'));
|
|
133
136
|
}, timeout);
|
|
134
137
|
|
|
135
|
-
|
|
138
|
+
const socket = new WebSocket(wsUrl);
|
|
139
|
+
this.ws = socket;
|
|
136
140
|
|
|
137
|
-
|
|
141
|
+
socket.onopen = () => {
|
|
138
142
|
clearTimeout(connectionTimeout);
|
|
139
143
|
this.reconnectAttempts = 0;
|
|
140
144
|
resolve();
|
|
141
145
|
};
|
|
142
146
|
|
|
143
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
+
}
|