okengine 0.14.1 → 0.15.2
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 +3 -4
- package/site/content/docs/elements/ai.mdx +2 -1
- package/site/content/docs/elements/channel.mdx +1 -0
- package/site/content/docs/elements/clock.mdx +3 -0
- package/site/content/docs/elements/flow.mdx +3 -0
- package/site/content/docs/elements/gate.mdx +25 -22
- package/site/content/docs/elements/signal.mdx +4 -4
- package/site/content/docs/elements/store.mdx +86 -27
- package/site/content/docs/elements/vault.mdx +1 -0
- package/site/content/docs/get-started/basic-usage.mdx +22 -13
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/introduction.mdx +11 -3
- package/site/content/docs/get-started/meta.json +1 -1
- package/site/content/docs/get-started/project-structure.mdx +932 -0
- package/site/content/docs/reference/cli.md +13 -10
- package/site/content/docs/reference/client.mdx +25 -21
- package/site/content/docs/reference/security.md +1 -1
- package/src/auth/bindings.ts +14 -5
- package/src/auth/cross-plane.ts +10 -3
- package/src/cli/ai-setup/ai-setup.test.ts +62 -0
- package/src/cli/ai-setup/apply.ts +32 -1
- package/src/cli/attach-host-console.test.ts +3 -2
- package/src/cli/attach-host-console.ts +3 -0
- package/src/cli/build.test.ts +4 -2
- package/src/cli/db-seed.ts +48 -15
- package/src/cli/db.test.ts +104 -0
- package/src/cli/db.ts +122 -10
- package/src/cli/dev-controls.test.ts +20 -0
- package/src/cli/dev-controls.ts +13 -0
- package/src/cli/dev.test.ts +15 -4
- package/src/cli/dev.ts +102 -29
- package/src/cli/ensure-drizzle-config.ts +2 -1
- package/src/cli/tui/DevLive.tsx +12 -2
- package/src/cli/tui/keys.test.ts +2 -1
- package/src/cli/tui/keys.ts +3 -2
- package/src/compiler/aot.test.ts +1 -1
- package/src/compiler/extract.test.ts +409 -33
- package/src/compiler/extract.ts +318 -67
- package/src/compiler/flow-path.test.ts +96 -0
- package/src/compiler/flow-path.ts +173 -0
- package/src/compiler/generate-adopt.test.ts +112 -3
- package/src/compiler/generate-adopt.ts +280 -26
- package/src/compiler/schema-from-ast.ts +609 -0
- package/src/console/server/bind.ts +2 -2
- package/src/console/server/console.test.ts +3 -0
- package/src/console/server/flows.ts +120 -40
- package/src/console/server/invoke-user-flow.test.ts +3 -1
- package/src/console/server/invoke-user-flow.ts +17 -2
- package/src/console/server/iso-at.test.ts +9 -0
- package/src/console/server/iso-at.ts +11 -0
- package/src/console/server/security-headers.ts +6 -3
- package/src/console/server/spa-proxy.test.ts +1 -0
- package/src/console/server/sql-catalog.ts +147 -7
- package/src/console/server/sql-rls.test.ts +16 -0
- package/src/console/server/state.ts +8 -0
- package/src/console/server/store.test.ts +7 -4
- package/src/console/server/store.ts +142 -58
- package/src/console/ui-next/dist/assets/agent-disclosure-C0X1fbWF.js +1 -0
- package/src/console/ui-next/dist/assets/{cache-glyph-BanhLsEY.js → cache-glyph-F1FI122b.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-bqkxMrJH.js → call-pii-button-DEDCl_j3.js} +1 -1
- package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +1 -0
- package/src/console/ui-next/dist/assets/{confirm-sheet-DDCRmG62.js → confirm-sheet-3ptDzXbz.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-oiRxPVsZ.js → duration-tone-sC3lGABz.js} +1 -1
- package/src/console/ui-next/dist/assets/{explorer-empty-HjTnVQoR.js → explorer-empty-BvYrygyO.js} +1 -1
- package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CvDPvveV.js → highlighted-json-CS_O8L-r.js} +1 -1
- package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +1 -0
- package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +66 -0
- package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +2 -0
- package/src/console/ui-next/dist/assets/{link-DF7SZ9Ek.js → link-COeyggt-.js} +1 -1
- package/src/console/ui-next/dist/assets/{observability-page-BEZDzyYh.js → observability-page-HK9-BO8z.js} +2 -2
- package/src/console/ui-next/dist/assets/react-D8E3mtu1.js +1 -0
- package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +18 -0
- package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +1 -0
- package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +54 -0
- package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-Cl79j_ku.js → use-vault-list-BLbZhzyF.js} +1 -1
- package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +2 -0
- package/src/console/ui-next/dist/index.html +6 -6
- package/src/console/ui-next/src/client.ts +35 -7
- package/src/console/ui-next/src/components/motion/table/index.tsx +16 -0
- package/src/console/ui-next/src/components/motion/table/types.ts +4 -0
- package/src/console/ui-next/src/components/ui/kbd.tsx +2 -2
- package/src/console/ui-next/src/features/flows/traces/http-method.ts +19 -0
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +19 -1
- package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +10 -3
- package/src/console/ui-next/src/features/store/detail/browse-section.tsx +45 -1
- package/src/console/ui-next/src/features/store/detail/resource-panel.tsx +78 -16
- package/src/console/ui-next/src/features/store/detail/store-row-detail-sheet.tsx +155 -14
- package/src/console/ui-next/src/features/store/explorer/store-tree.tsx +33 -133
- package/src/console/ui-next/src/features/store/files/file-preview.tsx +4 -1
- package/src/console/ui-next/src/features/store/grid/rls-policy-sheet.tsx +326 -165
- package/src/console/ui-next/src/features/store/grid/sql-insert-sheet.tsx +6 -0
- package/src/console/ui-next/src/features/store/grid/store-data-grid.tsx +50 -3
- package/src/console/ui-next/src/features/store/lib/files-meta.test.ts +3 -0
- package/src/console/ui-next/src/features/store/lib/files-meta.ts +12 -0
- package/src/console/ui-next/src/features/store/lib/query-defaults.test.ts +25 -0
- package/src/console/ui-next/src/features/store/lib/query-defaults.ts +85 -3
- package/src/console/ui-next/src/features/store/lib/query-gate.ts +30 -4
- package/src/console/ui-next/src/features/store/lib/rls-policy.test.ts +139 -0
- package/src/console/ui-next/src/features/store/lib/rls-policy.ts +223 -21
- package/src/console/ui-next/src/features/store/lib/schema-graph.ts +4 -0
- package/src/console/ui-next/src/features/store/lib/sql-catalog.test.ts +24 -0
- package/src/console/ui-next/src/features/store/lib/sql-catalog.ts +19 -0
- package/src/console/ui-next/src/features/store/lib/store-tree.test.ts +6 -0
- package/src/console/ui-next/src/features/store/lib/store-tree.ts +13 -0
- package/src/console/ui-next/src/features/store/query/query-console.tsx +48 -14
- package/src/console/ui-next/src/features/store/query/query-gate-menu.tsx +19 -20
- package/src/console/ui-next/src/features/store/query/query-gate-parts.tsx +2 -1
- package/src/console/ui-next/src/features/store/query/query-results.tsx +110 -83
- package/src/console/ui-next/src/features/store/schema/schema-table-node.tsx +17 -0
- package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +112 -72
- package/src/console/ui-next/src/features/units/lib/contract-input.test.ts +206 -1
- package/src/console/ui-next/src/features/units/lib/contract-input.ts +55 -0
- package/src/console/ui-next/src/features/vault/lib/types.ts +1 -1
- package/src/docker/compose-health.test.ts +27 -0
- package/src/docker/compose-health.ts +19 -3
- package/src/docker/docker.test.ts +3 -0
- package/src/docker/recipes/pgdog.ts +2 -0
- package/src/drivers/clock-postgres.ts +13 -9
- package/src/drivers/instances-postgres.ts +4 -6
- package/src/drivers/journal-postgres.ts +13 -9
- package/src/drivers/pg-rls.test.ts +87 -0
- package/src/drivers/pg-rls.ts +233 -7
- package/src/drivers/pglite.ts +26 -3
- package/src/drivers/postgres.test.ts +23 -0
- package/src/drivers/postgres.ts +133 -9
- package/src/drivers/types.ts +10 -0
- package/src/elements/clock/declare.ts +26 -1
- package/src/elements/gate/boot.ts +5 -5
- package/src/elements/gate/declare.ts +36 -4
- package/src/elements/gate/flatten.ts +1 -1
- package/src/elements/gate/runtime.ts +1 -1
- package/src/elements/store/declare.ts +9 -6
- package/src/elements/store/emit-drizzle.ts +44 -8
- package/src/elements/store/resource.test.ts +1 -1
- package/src/elements/store/rls-identity.test.ts +95 -0
- package/src/elements/store/rls-identity.ts +116 -0
- package/src/elements/store/runtime.ts +4 -0
- package/src/elements/store/schema-decl.test.ts +37 -0
- package/src/elements/store/schema-decl.ts +161 -0
- package/src/elements/store/seed.ts +1 -1
- package/src/elements/store/sql-condition.test.ts +12 -0
- package/src/elements/store/sql-condition.ts +26 -2
- package/src/elements/store/sql-rls-isolation.test.ts +165 -0
- package/src/elements/store/sql-rls-stamp.test.ts +117 -0
- package/src/elements/store/sql-session.ts +81 -3
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/store.ts +14 -0
- package/src/http.ts +20 -3
- package/src/index.ts +10 -1
- package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
- package/src/kernel/adopt-routes.ts +30 -2
- package/src/kernel/app.ts +131 -14
- package/src/kernel/auto-cache.test.ts +8 -8
- package/src/kernel/auto-registry.test.ts +49 -9
- package/src/kernel/boot.test.ts +4 -4
- package/src/kernel/boot.ts +2 -2
- package/src/kernel/effects-stamping.test.ts +3 -3
- package/src/kernel/element-registries.ts +12 -5
- package/src/kernel/errors.ts +24 -0
- package/src/kernel/flow-units.ts +64 -0
- package/src/kernel/flow.ts +29 -17
- package/src/kernel/fx.ts +23 -0
- package/src/kernel/horizontal-child.ts +4 -4
- package/src/kernel/http-path-pending.ts +20 -0
- package/src/kernel/http-resource.ts +4 -0
- package/src/kernel/http-stream.test.ts +4 -4
- package/src/kernel/pipeline.test.ts +2 -2
- package/src/kernel/plugin-elements.test.ts +1 -1
- package/src/kernel/plugin-needs.test.ts +1 -1
- package/src/kernel/ready.test.ts +2 -2
- package/src/kernel/resource-mount.test.ts +14 -0
- package/src/kernel/run-duration.test.ts +2 -2
- package/src/kernel/stamp-http.test.ts +79 -0
- package/src/kernel/stamp-http.ts +75 -0
- package/src/kernel/triggers.ts +52 -27
- package/src/manifest/types.ts +13 -0
- package/src/release/measure.ts +2 -2
- package/src/runtime/json-code-block.test.ts +2 -2
- package/src/runtime/serve.test.ts +4 -4
- package/src/term.test.ts +52 -4
- package/src/term.ts +125 -17
- package/src/test/reset-element-registries.ts +8 -3
- package/src/cli/ask-seed.test.ts +0 -96
- package/src/cli/ask-seed.ts +0 -82
- package/src/console/ui-next/dist/assets/collapsible-DYb0xU8C.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +0 -1
- package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +0 -1
- package/src/console/ui-next/dist/assets/index-Ce6WKWKM.js +0 -66
- package/src/console/ui-next/dist/assets/index-D4Ldtj79.css +0 -2
- package/src/console/ui-next/dist/assets/query-gate-parts-1m8m1iNp.js +0 -2
- package/src/console/ui-next/dist/assets/react-B1ML8gxg.js +0 -1
- package/src/console/ui-next/dist/assets/replica-lag-C8_BRt2x.js +0 -18
- package/src/console/ui-next/dist/assets/sheet-form-D-ata7jy.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-DO4IsVqv.js +0 -1
- package/src/console/ui-next/dist/assets/skeleton-CL_X0GCj.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-v3LXdYpr.js +0 -45
- package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +0 -2
- package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-DuKzqwzW.js +0 -2
|
@@ -69,6 +69,42 @@ export interface SchemaColumnDecl extends ColumnDef {
|
|
|
69
69
|
getSQL(): never;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** Policy command on a {@link SchemaPolicyDecl}. */
|
|
73
|
+
export type SchemaPolicyFor = "all" | "select" | "insert" | "update" | "delete";
|
|
74
|
+
|
|
75
|
+
/** Policy behavior on a {@link SchemaPolicyDecl}. */
|
|
76
|
+
export type SchemaPolicyAs = "permissive" | "restrictive";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Drizzle-shaped policy extra (`pgPolicy` emit).
|
|
80
|
+
*/
|
|
81
|
+
export interface SchemaPolicyDecl {
|
|
82
|
+
readonly kind: "schema-policy";
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly as?: SchemaPolicyAs;
|
|
85
|
+
readonly to?: string | readonly string[];
|
|
86
|
+
readonly for?: SchemaPolicyFor;
|
|
87
|
+
readonly using?: string;
|
|
88
|
+
readonly withCheck?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Enable RLS with no policies (`pgTable.withRLS`). */
|
|
92
|
+
export interface SchemaRlsEnableDecl {
|
|
93
|
+
readonly kind: "schema-rls";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Third-arg extra for {@link schemaTable}. */
|
|
97
|
+
export type SchemaTableExtra = SchemaPolicyDecl | SchemaRlsEnableDecl;
|
|
98
|
+
|
|
99
|
+
/** Options for {@link schemaPolicy} / Gate helpers. */
|
|
100
|
+
export interface SchemaPolicyOptions {
|
|
101
|
+
readonly as?: SchemaPolicyAs;
|
|
102
|
+
readonly to?: string | readonly string[];
|
|
103
|
+
readonly for?: SchemaPolicyFor;
|
|
104
|
+
readonly using?: string;
|
|
105
|
+
readonly withCheck?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
/** Table from {@link store.schema.table} — extends {@link TableHandle}. */
|
|
73
109
|
export interface SchemaTableDecl extends TableHandle {
|
|
74
110
|
readonly kind: "schema-table";
|
|
@@ -78,6 +114,10 @@ export interface SchemaTableDecl extends TableHandle {
|
|
|
78
114
|
* (spreading columns would otherwise shadow {@link TableHandle.name}).
|
|
79
115
|
*/
|
|
80
116
|
readonly tableName?: string;
|
|
117
|
+
/** `true` when {@link schemaRls} was declared or any policy is present. */
|
|
118
|
+
readonly rls?: boolean;
|
|
119
|
+
/** Declared policies (emit + Manifest). */
|
|
120
|
+
readonly policies?: readonly SchemaPolicyDecl[];
|
|
81
121
|
}
|
|
82
122
|
|
|
83
123
|
/** Fluent field builder before key finalization. */
|
|
@@ -310,20 +350,28 @@ export type SchemaTableWithColumns<
|
|
|
310
350
|
*
|
|
311
351
|
* @param name - SQL table name
|
|
312
352
|
* @param columns - Column map using {@link field} builders
|
|
353
|
+
* @param extras - RLS enable + {@link schemaPolicy} extras (Drizzle third arg)
|
|
313
354
|
*/
|
|
314
355
|
export function schemaTable<C extends Record<string, SchemaColumnInput>>(
|
|
315
356
|
name: string,
|
|
316
357
|
columns: C,
|
|
358
|
+
extras: readonly SchemaTableExtra[] = [],
|
|
317
359
|
): SchemaTableWithColumns<{ [K in keyof C]: SchemaColumnDecl }> {
|
|
318
360
|
const finalized = finalizeColumnMap(columns);
|
|
319
361
|
const stamped: Record<string, SchemaColumnDecl> = {};
|
|
320
362
|
for (const [key, col] of Object.entries(finalized)) {
|
|
321
363
|
stamped[key] = { ...col, tableName: name };
|
|
322
364
|
}
|
|
365
|
+
const policies = extras.filter(
|
|
366
|
+
(extra): extra is SchemaPolicyDecl => extra.kind === "schema-policy",
|
|
367
|
+
);
|
|
368
|
+
const rls = extras.some((extra) => extra.kind === "schema-rls") || policies.length > 0;
|
|
323
369
|
const table = {
|
|
324
370
|
name,
|
|
325
371
|
columns: stamped,
|
|
326
372
|
...stamped,
|
|
373
|
+
...(rls ? { rls: true } : {}),
|
|
374
|
+
...(policies.length > 0 ? { policies } : {}),
|
|
327
375
|
};
|
|
328
376
|
// Survive columns named `name` or `kind` (they would otherwise shadow
|
|
329
377
|
// the table discriminant / SQL name).
|
|
@@ -332,6 +380,117 @@ export function schemaTable<C extends Record<string, SchemaColumnInput>>(
|
|
|
332
380
|
return table as SchemaTableWithColumns<{ [K in keyof C]: SchemaColumnDecl }>;
|
|
333
381
|
}
|
|
334
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Enable RLS with no policies (`pgTable.withRLS`).
|
|
385
|
+
*/
|
|
386
|
+
export function schemaRls(): SchemaRlsEnableDecl {
|
|
387
|
+
return { kind: "schema-rls" };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Raw Drizzle-shaped policy extra.
|
|
392
|
+
*
|
|
393
|
+
* @param name - Policy name
|
|
394
|
+
* @param options - `as` / `to` / `for` / `using` / `withCheck`
|
|
395
|
+
*/
|
|
396
|
+
export function schemaPolicy(name: string, options: SchemaPolicyOptions = {}): SchemaPolicyDecl {
|
|
397
|
+
return {
|
|
398
|
+
kind: "schema-policy",
|
|
399
|
+
name,
|
|
400
|
+
...(options.as !== undefined ? { as: options.as } : {}),
|
|
401
|
+
...(options.to !== undefined ? { to: options.to } : {}),
|
|
402
|
+
...(options.for !== undefined ? { for: options.for } : {}),
|
|
403
|
+
...(options.using !== undefined ? { using: options.using } : {}),
|
|
404
|
+
...(options.withCheck !== undefined ? { withCheck: options.withCheck } : {}),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function sqlStringLiteral(value: string): string {
|
|
409
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function helperPolicyName(prefix: string, key: string, command: SchemaPolicyFor): string {
|
|
413
|
+
const slug = key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_|_$/g, "");
|
|
414
|
+
return `${prefix}_${slug}_${command}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function policyPredicates(
|
|
418
|
+
command: SchemaPolicyFor,
|
|
419
|
+
expr: string,
|
|
420
|
+
): { readonly using?: string; readonly withCheck?: string } {
|
|
421
|
+
if (command === "insert") return { withCheck: expr };
|
|
422
|
+
if (command === "update" || command === "all") return { using: expr, withCheck: expr };
|
|
423
|
+
return { using: expr };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Gate-name policy — `oke.gate() = 'member'`.
|
|
428
|
+
*
|
|
429
|
+
* @param gate - Policy / public Gate name
|
|
430
|
+
* @param options - Command (default `select`)
|
|
431
|
+
*/
|
|
432
|
+
export function schemaPolicyGate(
|
|
433
|
+
gate: string,
|
|
434
|
+
options: Pick<SchemaPolicyOptions, "for" | "as" | "to"> = {},
|
|
435
|
+
): SchemaPolicyDecl {
|
|
436
|
+
const command = options.for ?? "select";
|
|
437
|
+
return schemaPolicy(helperPolicyName("gate", gate, command), {
|
|
438
|
+
...options,
|
|
439
|
+
for: command,
|
|
440
|
+
...policyPredicates(command, `oke.gate() = ${sqlStringLiteral(gate)}`),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Owner-column policy — `owner = oke.user()`.
|
|
446
|
+
*
|
|
447
|
+
* @param column - SQL / JS column name
|
|
448
|
+
* @param options - Command (default `all`)
|
|
449
|
+
*/
|
|
450
|
+
export function schemaPolicyOwner(
|
|
451
|
+
column: string,
|
|
452
|
+
options: Pick<SchemaPolicyOptions, "for" | "as" | "to"> = {},
|
|
453
|
+
): SchemaPolicyDecl {
|
|
454
|
+
const command = options.for ?? "all";
|
|
455
|
+
return schemaPolicy(helperPolicyName("owner", column, command), {
|
|
456
|
+
...options,
|
|
457
|
+
for: command,
|
|
458
|
+
...policyPredicates(command, `${column} = oke.user()`),
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Scope policy — `oke.has_scope('booking:create')`.
|
|
464
|
+
*
|
|
465
|
+
* @param scope - Scope string
|
|
466
|
+
* @param options - Command (default `insert`)
|
|
467
|
+
*/
|
|
468
|
+
export function schemaPolicyScope(
|
|
469
|
+
scope: string,
|
|
470
|
+
options: Pick<SchemaPolicyOptions, "for" | "as" | "to"> = {},
|
|
471
|
+
): SchemaPolicyDecl {
|
|
472
|
+
const command = options.for ?? "insert";
|
|
473
|
+
return schemaPolicy(helperPolicyName("scope", scope, command), {
|
|
474
|
+
...options,
|
|
475
|
+
for: command,
|
|
476
|
+
...policyPredicates(command, `oke.has_scope(${sqlStringLiteral(scope)})`),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Callable `store.schema.policy` plus Gate helpers. */
|
|
481
|
+
export type SchemaPolicyApi = typeof schemaPolicy & {
|
|
482
|
+
readonly gate: typeof schemaPolicyGate;
|
|
483
|
+
readonly owner: typeof schemaPolicyOwner;
|
|
484
|
+
readonly scope: typeof schemaPolicyScope;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
/** `store.schema.policy` — raw + Gate helpers. */
|
|
488
|
+
export const schemaPolicyApi: SchemaPolicyApi = Object.assign(schemaPolicy, {
|
|
489
|
+
gate: schemaPolicyGate,
|
|
490
|
+
owner: schemaPolicyOwner,
|
|
491
|
+
scope: schemaPolicyScope,
|
|
492
|
+
});
|
|
493
|
+
|
|
335
494
|
// ─── Relations (mirrors drizzle-orm `defineRelations`) ───────────────────────
|
|
336
495
|
|
|
337
496
|
/** Column path recorded by relation helpers (`r.links.code`). */
|
|
@@ -527,6 +686,8 @@ export function schemaRelations<T extends Readonly<Record<string, SchemaTableDec
|
|
|
527
686
|
export const schema = {
|
|
528
687
|
table: schemaTable,
|
|
529
688
|
relations: schemaRelations,
|
|
689
|
+
rls: schemaRls,
|
|
690
|
+
policy: schemaPolicyApi,
|
|
530
691
|
} as const;
|
|
531
692
|
|
|
532
693
|
/**
|
|
@@ -86,6 +86,18 @@ describe("compileWhere", () => {
|
|
|
86
86
|
expect(c.params).toEqual(["n1", "t"]);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
test("legacy array-valued StringChunk fragments still compile", () => {
|
|
90
|
+
const c = compileWhere({
|
|
91
|
+
queryChunks: [
|
|
92
|
+
notes.id,
|
|
93
|
+
{ constructor: { name: "StringChunk" }, value: [" = "] },
|
|
94
|
+
{ value: "n1" },
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
expect(c.clause).toBe(`"id" = ?`);
|
|
98
|
+
expect(c.params).toEqual(["n1"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
89
101
|
test("inArray expands one placeholder per value", () => {
|
|
90
102
|
const c = compileWhere(inArray(notes.id, ["a", "b", "c"]));
|
|
91
103
|
expect(c.clause).toBe(`"id" in (?, ?, ?)`);
|
|
@@ -333,10 +333,33 @@ function chunksOf(node: unknown): readonly unknown[] {
|
|
|
333
333
|
return (node as { queryChunks: unknown[] }).queryChunks;
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
-
/**
|
|
336
|
+
/**
|
|
337
|
+
* True when `chunk` is a Drizzle `StringChunk` (or the synthetic array-valued
|
|
338
|
+
* shape `resource.ts` still emits). `Param` also has a `value` field — never
|
|
339
|
+
* treat those as SQL text.
|
|
340
|
+
*/
|
|
341
|
+
function isStringChunk(chunk: object): boolean {
|
|
342
|
+
const ctor = (chunk as { constructor?: { name?: unknown } }).constructor?.name;
|
|
343
|
+
if (ctor === "StringChunk") return true;
|
|
344
|
+
const value = (chunk as { value?: unknown }).value;
|
|
345
|
+
return (
|
|
346
|
+
Array.isArray(value) &&
|
|
347
|
+
value.every((part) => typeof part === "string") &&
|
|
348
|
+
!("encoder" in chunk) &&
|
|
349
|
+
!("name" in chunk)
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Joined text of a Drizzle `StringChunk`.
|
|
355
|
+
*
|
|
356
|
+
* drizzle-orm `1.0.0-rc.5-169397b` stores `value` as a `string`. Earlier
|
|
357
|
+
* snapshots (and the synthetic chunks in `resource.ts`) store `string[]`.
|
|
358
|
+
*/
|
|
337
359
|
function chunkText(chunk: unknown): string | undefined {
|
|
338
|
-
if (!chunk || typeof chunk !== "object") return undefined;
|
|
360
|
+
if (!chunk || typeof chunk !== "object" || !isStringChunk(chunk)) return undefined;
|
|
339
361
|
const value = (chunk as { value?: unknown }).value;
|
|
362
|
+
if (typeof value === "string") return value;
|
|
340
363
|
return Array.isArray(value) ? value.join("") : undefined;
|
|
341
364
|
}
|
|
342
365
|
|
|
@@ -394,6 +417,7 @@ function asParamValue(chunk: unknown): { readonly found: boolean; readonly value
|
|
|
394
417
|
if (chunk === undefined) return { found: false };
|
|
395
418
|
if (chunk === null || typeof chunk !== "object") return { found: true, value: chunk };
|
|
396
419
|
if (Array.isArray(chunk)) return { found: false };
|
|
420
|
+
if (isStringChunk(chunk)) return { found: false };
|
|
397
421
|
if (!("value" in (chunk as object))) return { found: false };
|
|
398
422
|
if (Array.isArray((chunk as { value: unknown }).value)) return { found: false };
|
|
399
423
|
if ((chunk as { name?: unknown }).name) return { found: false };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real PGlite RLS isolation through {@link createSqlStoreHandle}.
|
|
3
|
+
*
|
|
4
|
+
* Covers the gap unit tests missed: a live policy, two concurrent identity
|
|
5
|
+
* bags on one shared connection, and row-level isolation. PGlite `query`
|
|
6
|
+
* rejects multi-command batches — the stamp is split statements pinned
|
|
7
|
+
* through `SqlConnection.transaction`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { connectPglite } from "../../drivers/pglite.ts";
|
|
12
|
+
import { connectPostgres } from "../../drivers/postgres.ts";
|
|
13
|
+
import { installOkeRlsHelpers } from "../../drivers/pg-rls.ts";
|
|
14
|
+
import type { SqlConnection } from "../../drivers/types.ts";
|
|
15
|
+
import { createSqlStoreHandle, type SqlStoreHandle } from "./sql-session.ts";
|
|
16
|
+
|
|
17
|
+
const NOTES_DDL = `CREATE TABLE notes (
|
|
18
|
+
id text PRIMARY KEY,
|
|
19
|
+
owner text NOT NULL,
|
|
20
|
+
body text NOT NULL
|
|
21
|
+
)`;
|
|
22
|
+
|
|
23
|
+
const OWNER_POLICY = `CREATE POLICY owner_select ON notes
|
|
24
|
+
AS PERMISSIVE FOR SELECT TO public
|
|
25
|
+
USING (owner = oke.user())`;
|
|
26
|
+
|
|
27
|
+
/** File-scoped warmed PGlite. */
|
|
28
|
+
let conn: SqlConnection;
|
|
29
|
+
/** Stamped as alice. */
|
|
30
|
+
let asAlice: SqlStoreHandle;
|
|
31
|
+
/** Stamped as bob. */
|
|
32
|
+
let asBob: SqlStoreHandle;
|
|
33
|
+
/** No stamp — table-owner / superuser bypass. */
|
|
34
|
+
let asOwner: SqlStoreHandle;
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
conn = await connectPglite({ url: "memory://sql-rls-isolation", role: "primary" });
|
|
38
|
+
await conn.exec(NOTES_DDL);
|
|
39
|
+
await conn.exec(`INSERT INTO notes VALUES ('a', 'alice', 'hi'), ('b', 'bob', 'yo')`);
|
|
40
|
+
await conn.exec(`ALTER TABLE notes ENABLE ROW LEVEL SECURITY`);
|
|
41
|
+
await installOkeRlsHelpers((sql) => conn.exec(sql));
|
|
42
|
+
await conn.exec(OWNER_POLICY);
|
|
43
|
+
asAlice = createSqlStoreHandle("sql:app", {
|
|
44
|
+
connection: conn,
|
|
45
|
+
classifications: new Map(),
|
|
46
|
+
routedRole: "primary",
|
|
47
|
+
domainDdl: "off",
|
|
48
|
+
rls: { gate: "member", userId: "alice", scopes: ["member"] },
|
|
49
|
+
});
|
|
50
|
+
asBob = createSqlStoreHandle("sql:app", {
|
|
51
|
+
connection: conn,
|
|
52
|
+
classifications: new Map(),
|
|
53
|
+
routedRole: "primary",
|
|
54
|
+
domainDdl: "off",
|
|
55
|
+
rls: { gate: "member", userId: "bob", scopes: ["member"] },
|
|
56
|
+
});
|
|
57
|
+
asOwner = createSqlStoreHandle("sql:app", {
|
|
58
|
+
connection: conn,
|
|
59
|
+
classifications: new Map(),
|
|
60
|
+
routedRole: "primary",
|
|
61
|
+
domainDdl: "off",
|
|
62
|
+
});
|
|
63
|
+
}, 15_000);
|
|
64
|
+
|
|
65
|
+
afterAll(async () => {
|
|
66
|
+
await conn.close();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("sql-session RLS isolation (pglite)", () => {
|
|
70
|
+
test("pglite query rejects a multi-command batch (the old prelude)", async () => {
|
|
71
|
+
await expect(
|
|
72
|
+
conn.query("BEGIN; SET LOCAL row_security = on; SELECT set_config('oke.user', $1, true)", [
|
|
73
|
+
"alice",
|
|
74
|
+
]),
|
|
75
|
+
).rejects.toThrow(/multiple commands/i);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("sequential identities see only their rows", async () => {
|
|
79
|
+
const alice = await asAlice.raw(`SELECT id, owner FROM notes ORDER BY id`);
|
|
80
|
+
const bob = await asBob.raw(`SELECT id, owner FROM notes ORDER BY id`);
|
|
81
|
+
expect(alice).toEqual([{ id: "a", owner: "alice" }]);
|
|
82
|
+
expect(bob).toEqual([{ id: "b", owner: "bob" }]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("concurrent identities do not leak across the shared connection", async () => {
|
|
86
|
+
const [alice, bob, aliceAgain] = await Promise.all([
|
|
87
|
+
asAlice.raw(`SELECT id, owner FROM notes ORDER BY id`),
|
|
88
|
+
asBob.raw(`SELECT id, owner FROM notes ORDER BY id`),
|
|
89
|
+
asAlice.raw(`SELECT id, owner FROM notes ORDER BY id`),
|
|
90
|
+
]);
|
|
91
|
+
expect(alice).toEqual([{ id: "a", owner: "alice" }]);
|
|
92
|
+
expect(bob).toEqual([{ id: "b", owner: "bob" }]);
|
|
93
|
+
expect(aliceAgain).toEqual([{ id: "a", owner: "alice" }]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("unstamped owner/superuser still sees every row", async () => {
|
|
97
|
+
const rows = await asOwner.raw(`SELECT id, owner FROM notes ORDER BY id`);
|
|
98
|
+
expect(rows).toEqual([
|
|
99
|
+
{ id: "a", owner: "alice" },
|
|
100
|
+
{ id: "b", owner: "bob" },
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const LIVE_PG =
|
|
106
|
+
process.env.OKE_TEST_DOCKER === "1" &&
|
|
107
|
+
(process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL)?.startsWith("postgres");
|
|
108
|
+
if (!LIVE_PG) {
|
|
109
|
+
console.log("skip: live Postgres RLS isolation needs OKE_TEST_DOCKER=1 and DATABASE_URL");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe("sql-session RLS isolation (live postgres)", () => {
|
|
113
|
+
const live = LIVE_PG ? test : test.skip;
|
|
114
|
+
const table = "oke_rls_isolation_notes";
|
|
115
|
+
|
|
116
|
+
live(
|
|
117
|
+
"split stamp isolates concurrent identities",
|
|
118
|
+
async () => {
|
|
119
|
+
const pg = await connectPostgres({
|
|
120
|
+
url: process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL,
|
|
121
|
+
role: "primary",
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
await pg.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
125
|
+
await pg.exec(
|
|
126
|
+
`CREATE TABLE ${table} (id text PRIMARY KEY, owner text NOT NULL, body text NOT NULL)`,
|
|
127
|
+
);
|
|
128
|
+
await pg.exec(`INSERT INTO ${table} VALUES ('a', 'alice', 'hi'), ('b', 'bob', 'yo')`);
|
|
129
|
+
await pg.exec(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`);
|
|
130
|
+
await installOkeRlsHelpers((sql) => pg.exec(sql));
|
|
131
|
+
await pg.exec(
|
|
132
|
+
`CREATE POLICY owner_select ON ${table} AS PERMISSIVE FOR SELECT TO public USING (owner = oke.user())`,
|
|
133
|
+
);
|
|
134
|
+
const alice = createSqlStoreHandle("sql:app", {
|
|
135
|
+
connection: pg,
|
|
136
|
+
classifications: new Map(),
|
|
137
|
+
routedRole: "primary",
|
|
138
|
+
domainDdl: "off",
|
|
139
|
+
rls: { gate: "member", userId: "alice", scopes: ["member"] },
|
|
140
|
+
});
|
|
141
|
+
const bob = createSqlStoreHandle("sql:app", {
|
|
142
|
+
connection: pg,
|
|
143
|
+
classifications: new Map(),
|
|
144
|
+
routedRole: "primary",
|
|
145
|
+
domainDdl: "off",
|
|
146
|
+
rls: { gate: "member", userId: "bob", scopes: ["member"] },
|
|
147
|
+
});
|
|
148
|
+
const [a, b] = await Promise.all([
|
|
149
|
+
alice.raw(`SELECT id, owner FROM ${table} ORDER BY id`),
|
|
150
|
+
bob.raw(`SELECT id, owner FROM ${table} ORDER BY id`),
|
|
151
|
+
]);
|
|
152
|
+
expect(a).toEqual([{ id: "a", owner: "alice" }]);
|
|
153
|
+
expect(b).toEqual([{ id: "b", owner: "bob" }]);
|
|
154
|
+
} finally {
|
|
155
|
+
try {
|
|
156
|
+
await pg.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
157
|
+
} catch {
|
|
158
|
+
// Best-effort cleanup.
|
|
159
|
+
}
|
|
160
|
+
await pg.close();
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
20_000,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RLS stamps on pooled postgres must `reserve()` one slot, then BEGIN/SET
|
|
3
|
+
* on that slot. `begin()` + parent `unsafe()` checkouts a second slot and
|
|
4
|
+
* deadlocks a tiny pool (`checkout timeout` from PgDog).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { connectPostgres, type PostgresClientLike } from "../../drivers/postgres.ts";
|
|
9
|
+
import { createSqlStoreHandle } from "./sql-session.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Two-slot pool. `reserve()` holds a slot until `release()`. Unreserved
|
|
13
|
+
* `BEGIN` occupies a slot until COMMIT on that same checkout.
|
|
14
|
+
*/
|
|
15
|
+
function createTinyPoolClient(size = 2): PostgresClientLike & {
|
|
16
|
+
readonly reserveCalls: { n: number };
|
|
17
|
+
readonly rootBegins: { n: number };
|
|
18
|
+
} {
|
|
19
|
+
const slots = Array.from({ length: size }, () => ({ held: false }));
|
|
20
|
+
const reserveCalls = { n: 0 };
|
|
21
|
+
const rootBegins = { n: 0 };
|
|
22
|
+
|
|
23
|
+
function checkout(): { held: boolean } {
|
|
24
|
+
const idle = slots.find((slot) => !slot.held);
|
|
25
|
+
if (!idle) {
|
|
26
|
+
throw new Error("checkout timeout");
|
|
27
|
+
}
|
|
28
|
+
return idle;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rowsFor(sql: string): Record<string, unknown>[] {
|
|
32
|
+
return /select/i.test(sql) && !/set_config/i.test(sql) ? [{ id: "a", owner: "alice" }] : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const client: PostgresClientLike & {
|
|
36
|
+
readonly reserveCalls: { n: number };
|
|
37
|
+
readonly rootBegins: { n: number };
|
|
38
|
+
} = {
|
|
39
|
+
reserveCalls,
|
|
40
|
+
rootBegins,
|
|
41
|
+
async reserve() {
|
|
42
|
+
reserveCalls.n += 1;
|
|
43
|
+
const slot = checkout();
|
|
44
|
+
slot.held = true;
|
|
45
|
+
return {
|
|
46
|
+
async unsafe(sql) {
|
|
47
|
+
return rowsFor(sql);
|
|
48
|
+
},
|
|
49
|
+
release() {
|
|
50
|
+
slot.held = false;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
async unsafe(sql) {
|
|
55
|
+
const slot = checkout();
|
|
56
|
+
const head = sql.trim().split(/\s+/)[0]?.toUpperCase();
|
|
57
|
+
if (head === "BEGIN") {
|
|
58
|
+
rootBegins.n += 1;
|
|
59
|
+
slot.held = true;
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
if (head === "COMMIT" || head === "ROLLBACK") {
|
|
63
|
+
slot.held = false;
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
return rowsFor(sql);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
return client;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("sql-session RLS stamp on pooled postgres", () => {
|
|
73
|
+
test("pins the stamp through reserve — no leaked root BEGIN", async () => {
|
|
74
|
+
const pool = createTinyPoolClient(2);
|
|
75
|
+
const connection = await connectPostgres({ client: pool, role: "primary" });
|
|
76
|
+
const handle = createSqlStoreHandle("sql:app", {
|
|
77
|
+
connection,
|
|
78
|
+
classifications: new Map(),
|
|
79
|
+
routedRole: "primary",
|
|
80
|
+
domainDdl: "off",
|
|
81
|
+
rls: { gate: "member", userId: "alice", scopes: ["member"] },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const [a, b] = await Promise.all([
|
|
85
|
+
handle.raw(`SELECT id, owner FROM notes`),
|
|
86
|
+
handle.raw(`SELECT id, owner FROM notes`),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
expect(a).toEqual([{ id: "a", owner: "alice" }]);
|
|
90
|
+
expect(b).toEqual([{ id: "a", owner: "alice" }]);
|
|
91
|
+
expect(pool.reserveCalls.n).toBe(2);
|
|
92
|
+
expect(pool.rootBegins.n).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("table browse SELECT pins through reserve like any other stamped query", async () => {
|
|
96
|
+
const pool = createTinyPoolClient(2);
|
|
97
|
+
const connection = await connectPostgres({ client: pool, role: "primary" });
|
|
98
|
+
const handle = createSqlStoreHandle("sql:app", {
|
|
99
|
+
connection,
|
|
100
|
+
classifications: new Map(),
|
|
101
|
+
routedRole: "primary",
|
|
102
|
+
domainDdl: "off",
|
|
103
|
+
rls: { gate: "member", userId: "alice", scopes: ["member"] },
|
|
104
|
+
});
|
|
105
|
+
const rows = await handle.raw(`SELECT * FROM "notes" LIMIT 50`);
|
|
106
|
+
expect(rows).toEqual([{ id: "a", owner: "alice" }]);
|
|
107
|
+
expect(pool.reserveCalls.n).toBe(1);
|
|
108
|
+
expect(pool.rootBegins.n).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("standalone BEGIN on a 2-slot pool reproduces checkout timeout", async () => {
|
|
112
|
+
const pool = createTinyPoolClient(2);
|
|
113
|
+
await expect(pool.unsafe("BEGIN")).resolves.toEqual([]);
|
|
114
|
+
await expect(pool.unsafe("BEGIN")).resolves.toEqual([]);
|
|
115
|
+
await expect(pool.unsafe("SET LOCAL ROLE oke_app")).rejects.toThrow("checkout timeout");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import type { DomainDdlMode } from "../../config/index.ts";
|
|
9
9
|
import type { ClassificationMap, SqlConnection, SqlRow } from "../../drivers/types.ts";
|
|
10
|
+
import {
|
|
11
|
+
buildRlsIdentityPreludeSql,
|
|
12
|
+
installOkeRlsHelpers,
|
|
13
|
+
RLS_CONTEXT_DRIVERS,
|
|
14
|
+
type RlsIdentity,
|
|
15
|
+
} from "../../drivers/pg-rls.ts";
|
|
10
16
|
import { throwOke } from "../../kernel/errors.ts";
|
|
11
17
|
import { maskRows, tableFromSql } from "./classify.ts";
|
|
12
18
|
import { isMissingDomainRelationError } from "./missing-relation.ts";
|
|
@@ -28,6 +34,45 @@ import {
|
|
|
28
34
|
|
|
29
35
|
export type { WhereMap } from "./sql-condition.ts";
|
|
30
36
|
|
|
37
|
+
/** Serialize stamp frames on a shared connection so concurrent identities cannot interleave. */
|
|
38
|
+
const rlsStampTails = new WeakMap<SqlConnection, Promise<unknown>>();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True when SQL must not enter an RLS identity frame (txn control or DDL).
|
|
42
|
+
*
|
|
43
|
+
* @param sql - Statement
|
|
44
|
+
*/
|
|
45
|
+
export function isRlsStampExemptSql(sql: string): boolean {
|
|
46
|
+
return /^\s*(begin|commit|rollback|create|alter|drop|truncate|grant|revoke|comment)\b/i.test(sql);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run `fn` after any in-flight stamp on `connection` finishes.
|
|
51
|
+
*
|
|
52
|
+
* @param connection - Shared SQL connection
|
|
53
|
+
* @param fn - Stamp frame
|
|
54
|
+
*/
|
|
55
|
+
async function withRlsStampLock<T>(connection: SqlConnection, fn: () => Promise<T>): Promise<T> {
|
|
56
|
+
const prev = rlsStampTails.get(connection) ?? Promise.resolve();
|
|
57
|
+
let release!: () => void;
|
|
58
|
+
const gate = new Promise<void>((resolve) => {
|
|
59
|
+
release = resolve;
|
|
60
|
+
});
|
|
61
|
+
rlsStampTails.set(
|
|
62
|
+
connection,
|
|
63
|
+
prev.then(
|
|
64
|
+
() => gate,
|
|
65
|
+
() => gate,
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
await prev.catch(() => undefined);
|
|
69
|
+
try {
|
|
70
|
+
return await fn();
|
|
71
|
+
} finally {
|
|
72
|
+
release();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
31
76
|
/** Options for a SQL session. */
|
|
32
77
|
export interface SqlSessionOptions {
|
|
33
78
|
/** Open connection (already role-routed). */
|
|
@@ -44,6 +89,8 @@ export interface SqlSessionOptions {
|
|
|
44
89
|
* and local+autoPush). Default `ensure` for backward-compatible tests.
|
|
45
90
|
*/
|
|
46
91
|
readonly domainDdl?: DomainDdlMode;
|
|
92
|
+
/** Gate identity — stamps `oke.*` on postgres / pglite. */
|
|
93
|
+
readonly rls?: RlsIdentity;
|
|
47
94
|
}
|
|
48
95
|
|
|
49
96
|
/**
|
|
@@ -339,8 +386,9 @@ export function createSqlStoreHandle(
|
|
|
339
386
|
ref: `sql:${string}`,
|
|
340
387
|
options: SqlSessionOptions,
|
|
341
388
|
): SqlStoreHandle {
|
|
342
|
-
const { connection, classifications, revealPii } = options;
|
|
389
|
+
const { connection, classifications, revealPii, rls } = options;
|
|
343
390
|
const domainDdl: DomainDdlMode = options.domainDdl ?? "ensure";
|
|
391
|
+
let helpersReady = false;
|
|
344
392
|
|
|
345
393
|
function mask(rows: SqlRow[], table?: string): SqlRow[] {
|
|
346
394
|
return maskRows(rows, { classifications, table, revealPii });
|
|
@@ -362,11 +410,41 @@ export function createSqlStoreHandle(
|
|
|
362
410
|
}
|
|
363
411
|
|
|
364
412
|
function query(sql: string, params: readonly unknown[] = []): Promise<SqlRow[]> {
|
|
365
|
-
return withSchemaGuard(() =>
|
|
413
|
+
return withSchemaGuard(() => withRlsStamp((conn) => conn.query(sql, params), sql));
|
|
366
414
|
}
|
|
367
415
|
|
|
368
416
|
function exec(sql: string, params: readonly unknown[] = []): Promise<{ changes: number }> {
|
|
369
|
-
return withSchemaGuard(() =>
|
|
417
|
+
return withSchemaGuard(() => withRlsStamp((conn) => conn.exec(sql, params), sql));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function withRlsStamp<T>(fn: (conn: SqlConnection) => Promise<T>, sql: string): Promise<T> {
|
|
421
|
+
if (!rls || !RLS_CONTEXT_DRIVERS.has(connection.driverId)) return fn(connection);
|
|
422
|
+
if (isRlsStampExemptSql(sql)) return fn(connection);
|
|
423
|
+
const run = (): Promise<T> => applyRlsStamp(fn);
|
|
424
|
+
// PGlite is one backend session — concurrent identities must not interleave.
|
|
425
|
+
// Pooled postgres pins each stamp via `transaction()` instead.
|
|
426
|
+
if (connection.driverId === "pglite") {
|
|
427
|
+
return withRlsStampLock(connection, run);
|
|
428
|
+
}
|
|
429
|
+
return run();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function applyRlsStamp<T>(fn: (conn: SqlConnection) => Promise<T>): Promise<T> {
|
|
433
|
+
if (!rls) return fn(connection);
|
|
434
|
+
if (!helpersReady) {
|
|
435
|
+
await installOkeRlsHelpers((sql) => connection.exec(sql));
|
|
436
|
+
helpersReady = true;
|
|
437
|
+
}
|
|
438
|
+
const frame = async (tx: SqlConnection): Promise<T> => {
|
|
439
|
+
for (const stmt of buildRlsIdentityPreludeSql(rls)) {
|
|
440
|
+
await tx.exec(stmt.sql, stmt.params ?? []);
|
|
441
|
+
}
|
|
442
|
+
return fn(tx);
|
|
443
|
+
};
|
|
444
|
+
if (!connection.transaction) {
|
|
445
|
+
throw new Error("RLS stamp needs SqlConnection.transaction to pin SET LOCAL");
|
|
446
|
+
}
|
|
447
|
+
return connection.transaction(frame);
|
|
370
448
|
}
|
|
371
449
|
|
|
372
450
|
async function ensureFromMeta(table: TableHandle | unknown): Promise<void> {
|
|
@@ -45,7 +45,7 @@ describe("fx.store(db).upsert via app.fetch (real boot)", () => {
|
|
|
45
45
|
const db = store.sql("app", { schema: { notes } });
|
|
46
46
|
|
|
47
47
|
const seed = on(
|
|
48
|
-
http.post("/seed").
|
|
48
|
+
http.post("/seed").public(),
|
|
49
49
|
flow("notes.seed", {
|
|
50
50
|
in: UpsertIn,
|
|
51
51
|
out: UpsertOut,
|