okengine 0.16.0 → 0.17.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/manifest.v1.schema.json +21 -2
- package/package.json +1 -1
- package/site/content/docs/elements/clock.mdx +7 -0
- package/site/content/docs/elements/flow.mdx +14 -12
- package/site/content/docs/elements/gate.mdx +58 -8
- package/site/content/docs/elements/signal.mdx +46 -17
- package/site/content/docs/elements/store.mdx +23 -17
- package/site/content/docs/elements/vault.mdx +23 -0
- package/site/content/docs/get-started/project-structure.mdx +4 -4
- package/site/content/docs/reference/cli.md +1 -1
- package/site/content/docs/reference/client.mdx +72 -17
- package/site/content/docs/reference/configuration.mdx +7 -4
- package/site/content/docs/reference/errors.mdx +24 -17
- package/site/content/docs/reference/fx.mdx +15 -9
- package/src/auth/api-key-sql.ts +11 -4
- package/src/auth/api-keys.ts +3 -0
- package/src/auth/config.ts +19 -0
- package/src/auth/index.ts +37 -0
- package/src/auth/plugin.ts +6 -1
- package/src/auth/sessions.ts +18 -0
- package/src/auth/tables.ts +32 -0
- package/src/auth/tenant-config.ts +74 -0
- package/src/auth/tenant-tables.ts +11 -0
- package/src/auth/tenants.test.ts +63 -0
- package/src/auth/tenants.ts +360 -0
- package/src/cli/build.ts +2 -2
- package/src/client/budget.test.ts +1 -1
- package/src/client/create.ts +75 -5
- package/src/client/index.ts +13 -0
- package/src/client/live.test.ts +422 -0
- package/src/client/live.ts +389 -0
- package/src/client/notes-contract.test.ts +41 -0
- package/src/client/types.ts +85 -6
- package/src/client-react/index.ts +85 -1
- package/src/client-react/use-live.test.ts +129 -0
- package/src/compiler/effects-infer.ts +22 -2
- package/src/compiler/extract.test.ts +143 -11
- package/src/compiler/extract.ts +210 -30
- package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
- package/src/compiler/fixtures/skyport.expected.json +2 -3
- package/src/compiler/response.ts +41 -11
- package/src/console/server/app.ts +44 -11
- package/src/console/server/console.test.ts +6 -8
- package/src/console/server/gates.ts +2 -9
- package/src/console/server/store.test.ts +17 -0
- package/src/console/server/store.ts +43 -1
- package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-CXVWWMmD.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-XDpAJO8f.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-BBj2QJCu.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-BFaay44m.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
- package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-Ca2Z-E52.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-BmOeAFwg.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
- package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
- package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
- package/src/drivers/index.ts +2 -0
- package/src/drivers/journal-postgres.ts +12 -3
- package/src/drivers/pg-rls.ts +26 -2
- package/src/drivers/pg-vault-rls.ts +68 -0
- package/src/drivers/signal-engine.ts +69 -15
- package/src/drivers/signal-live-iter.ts +65 -0
- package/src/drivers/signal-nats.ts +2 -1
- package/src/drivers/signal-postgres.ts +95 -12
- package/src/drivers/signal-redis.ts +2 -1
- package/src/drivers/signal-retention.ts +64 -0
- package/src/drivers/signal-types.ts +35 -5
- package/src/elements/clock/declare.ts +64 -2
- package/src/elements/clock/reconcile.ts +98 -25
- package/src/elements/clock/runtime.ts +13 -2
- package/src/elements/clock.test.ts +28 -0
- package/src/elements/clock.ts +9 -1
- package/src/elements/gate/permissions.ts +12 -0
- package/src/elements/gate.ts +6 -1
- package/src/elements/signal/declare.ts +44 -10
- package/src/elements/signal/delivery-modes.test.ts +90 -4
- package/src/elements/signal/order-lifecycle.test.ts +20 -4
- package/src/elements/signal/runtime.ts +42 -0
- package/src/elements/signal.test.ts +21 -8
- package/src/elements/signal.ts +1 -1
- package/src/elements/store/declare.ts +7 -0
- package/src/elements/store/rls-identity.test.ts +29 -0
- package/src/elements/store/rls-identity.ts +3 -0
- package/src/elements/store/schema-decl.ts +63 -3
- package/src/elements/store/schema-tenant.ts +41 -0
- package/src/elements/store/sql-rls-isolation.test.ts +39 -0
- package/src/elements/store.ts +2 -0
- package/src/elements/vault/builtin-adapter.ts +15 -2
- package/src/elements/vault/declare.ts +8 -0
- package/src/elements/vault/runtime.ts +7 -0
- package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
- package/src/elements/vault/storage.ts +9 -0
- package/src/elements/vault/test-helpers.ts +2 -1
- package/src/i18n/catalogs/ar.ts +19 -0
- package/src/i18n/catalogs/en.ts +19 -0
- package/src/index.ts +1 -0
- package/src/kernel/adopt-routes.ts +56 -8
- package/src/kernel/app-tenant.ts +122 -0
- package/src/kernel/app.ts +183 -56
- package/src/kernel/auth-resolve.ts +3 -0
- package/src/kernel/boot-bind/clock.ts +9 -3
- package/src/kernel/boot.ts +2 -0
- package/src/kernel/budget.test.ts +1 -1
- package/src/kernel/clock-durable.ts +8 -0
- package/src/kernel/clock-per-tenant-name.ts +5 -0
- package/src/kernel/clock-reconcile.ts +8 -0
- package/src/kernel/errors-live-resume.ts +15 -0
- package/src/kernel/errors-tenant.ts +29 -0
- package/src/kernel/errors.registry.test.ts +31 -3
- package/src/kernel/errors.ts +43 -3
- package/src/kernel/flow.ts +26 -5
- package/src/kernel/fx-auth-keys.ts +6 -1
- package/src/kernel/fx-auth-tenants.test.ts +87 -0
- package/src/kernel/fx-auth-tenants.ts +286 -0
- package/src/kernel/fx-live-stream.ts +149 -0
- package/src/kernel/fx-live.test.ts +157 -0
- package/src/kernel/fx-runtime.ts +15 -0
- package/src/kernel/fx-tenant-store.ts +213 -0
- package/src/kernel/fx.test.ts +91 -0
- package/src/kernel/fx.ts +173 -13
- package/src/kernel/hooks.ts +2 -2
- package/src/kernel/http-resource.ts +9 -18
- package/src/kernel/index.ts +2 -0
- package/src/kernel/journal.ts +12 -0
- package/src/kernel/live-http.test.ts +78 -0
- package/src/kernel/live-http.ts +114 -0
- package/src/kernel/live-resume.test.ts +125 -0
- package/src/kernel/on.ts +51 -0
- package/src/kernel/pipeline-tenant.ts +49 -0
- package/src/kernel/pipeline.test.ts +1 -1
- package/src/kernel/pipeline.ts +37 -2
- package/src/kernel/resource-mount.test.ts +10 -27
- package/src/kernel/tenant-resolve.test.ts +101 -0
- package/src/kernel/tenant-resolve.ts +124 -0
- package/src/kernel/tenant-roles.test.ts +87 -0
- package/src/kernel/triggers.ts +59 -21
- package/src/manifest/diff.test.ts +11 -2
- package/src/manifest/diff.ts +53 -11
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +0 -1
- package/src/manifest/types.ts +52 -6
- package/src/release/build-lib.ts +13 -1
- package/src/release/limits.ts +2 -2
- package/src/release/measure.ts +55 -1
- package/src/client/live-gap.test.ts +0 -35
|
@@ -100,6 +100,45 @@ describe("sql-session RLS isolation (pglite)", () => {
|
|
|
100
100
|
{ id: "b", owner: "bob" },
|
|
101
101
|
]);
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
test("tenant_id = oke.tenant() isolates concurrent tenants", async () => {
|
|
105
|
+
await conn.exec(`CREATE TABLE bookings (
|
|
106
|
+
id text PRIMARY KEY,
|
|
107
|
+
tenant_id text NOT NULL,
|
|
108
|
+
body text NOT NULL
|
|
109
|
+
)`);
|
|
110
|
+
await conn.exec(`INSERT INTO bookings VALUES ('1', 'acme', 'a'), ('2', 'globex', 'b')`);
|
|
111
|
+
await conn.exec(`ALTER TABLE bookings ENABLE ROW LEVEL SECURITY`);
|
|
112
|
+
await conn.exec(`GRANT SELECT, INSERT, UPDATE, DELETE ON bookings TO oke_app`);
|
|
113
|
+
await conn.exec(`CREATE POLICY tenant_all ON bookings
|
|
114
|
+
AS PERMISSIVE FOR ALL TO public
|
|
115
|
+
USING (tenant_id = oke.tenant())
|
|
116
|
+
WITH CHECK (tenant_id = oke.tenant())`);
|
|
117
|
+
|
|
118
|
+
const asAcme = createSqlStoreHandle("sql:app", {
|
|
119
|
+
connection: conn,
|
|
120
|
+
classifications: new Map(),
|
|
121
|
+
routedRole: "primary",
|
|
122
|
+
domainDdl: "off",
|
|
123
|
+
rls: { gate: "member", userId: "u1", scopes: ["member"], tenantId: "acme" },
|
|
124
|
+
});
|
|
125
|
+
const asGlobex = createSqlStoreHandle("sql:app", {
|
|
126
|
+
connection: conn,
|
|
127
|
+
classifications: new Map(),
|
|
128
|
+
routedRole: "primary",
|
|
129
|
+
domainDdl: "off",
|
|
130
|
+
rls: { gate: "member", userId: "u1", scopes: ["member"], tenantId: "globex" },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const [acme, globex, acmeAgain] = await Promise.all([
|
|
134
|
+
asAcme.raw(`SELECT id, tenant_id FROM bookings ORDER BY id`),
|
|
135
|
+
asGlobex.raw(`SELECT id, tenant_id FROM bookings ORDER BY id`),
|
|
136
|
+
asAcme.raw(`SELECT id, tenant_id FROM bookings ORDER BY id`),
|
|
137
|
+
]);
|
|
138
|
+
expect(acme).toEqual([{ id: "1", tenant_id: "acme" }]);
|
|
139
|
+
expect(globex).toEqual([{ id: "2", tenant_id: "globex" }]);
|
|
140
|
+
expect(acmeAgain).toEqual([{ id: "1", tenant_id: "acme" }]);
|
|
141
|
+
});
|
|
103
142
|
});
|
|
104
143
|
|
|
105
144
|
const LIVE_PG =
|
package/src/elements/store.ts
CHANGED
|
@@ -204,6 +204,18 @@ export interface BuiltinVaultAdapter extends VaultAdapter {
|
|
|
204
204
|
/** Numeric column as any supported driver may hand it back. */
|
|
205
205
|
type SqlNumber = number | string | bigint;
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* First path segment when the contract is stored as `{tenantId}/{name}`.
|
|
209
|
+
*
|
|
210
|
+
* @param path - Canonical vault path
|
|
211
|
+
*/
|
|
212
|
+
function tenantIdFromVaultPath(path: string): string | null {
|
|
213
|
+
const i = path.indexOf("/");
|
|
214
|
+
if (i <= 0) return null;
|
|
215
|
+
const id = path.slice(0, i);
|
|
216
|
+
return id.length > 0 ? id : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
207
219
|
/** `oke_vault_secrets` row as returned by the driver. */
|
|
208
220
|
interface SecretRow {
|
|
209
221
|
id: string;
|
|
@@ -539,8 +551,8 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
539
551
|
updated_at: Date | string;
|
|
540
552
|
}>(
|
|
541
553
|
`INSERT INTO oke_vault_secrets
|
|
542
|
-
(path, encrypted_value, iv, auth_tag, version, metadata, algorithm, kek_version, expires_at)
|
|
543
|
-
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9)
|
|
554
|
+
(path, encrypted_value, iv, auth_tag, version, metadata, algorithm, kek_version, expires_at, tenant_id)
|
|
555
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10)
|
|
544
556
|
RETURNING id, created_at, updated_at`,
|
|
545
557
|
[
|
|
546
558
|
path,
|
|
@@ -552,6 +564,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
|
|
|
552
564
|
ALGORITHM,
|
|
553
565
|
kekVersion,
|
|
554
566
|
expiresAt ?? null,
|
|
567
|
+
tenantIdFromVaultPath(path),
|
|
555
568
|
],
|
|
556
569
|
);
|
|
557
570
|
const row = inserted[0];
|
|
@@ -24,6 +24,11 @@ export interface VaultSecretOptions {
|
|
|
24
24
|
* in the clear. Defaults to `true` for secrets and `false` for config.
|
|
25
25
|
*/
|
|
26
26
|
readonly sensitive?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve per `fx.tenant.id` at request time (not boot).
|
|
29
|
+
* Default true when `gate.auth.tenant` is on.
|
|
30
|
+
*/
|
|
31
|
+
readonly perTenant?: boolean;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/** Declared vault contract handle. */
|
|
@@ -38,6 +43,8 @@ export interface VaultSecretDecl {
|
|
|
38
43
|
readonly dev?: string;
|
|
39
44
|
/** Whether cleartext must never leave the runtime (default by kind). */
|
|
40
45
|
readonly sensitive: boolean;
|
|
46
|
+
/** Request-time tenant path when true. */
|
|
47
|
+
readonly perTenant?: boolean;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
/**
|
|
@@ -82,6 +89,7 @@ function declareSecret(name: string, options: VaultSecretOptions = {}): VaultSec
|
|
|
82
89
|
...(options.rotate !== undefined ? { rotate: options.rotate } : {}),
|
|
83
90
|
...(options.schema !== undefined ? { schema: options.schema } : {}),
|
|
84
91
|
...(options.dev !== undefined ? { dev: options.dev } : {}),
|
|
92
|
+
...(options.perTenant !== undefined ? { perTenant: options.perTenant } : {}),
|
|
85
93
|
};
|
|
86
94
|
secretRegistry.push(decl);
|
|
87
95
|
return decl;
|
|
@@ -95,6 +95,10 @@ export interface CreateVaultRuntimeOptions {
|
|
|
95
95
|
readonly allowGaps?: boolean;
|
|
96
96
|
/** Clock for last-read timestamps (tests). */
|
|
97
97
|
readonly now?: () => number;
|
|
98
|
+
/**
|
|
99
|
+
* When true, contracts with `perTenant !== false` are not boot-gap fatal.
|
|
100
|
+
*/
|
|
101
|
+
readonly tenantEnabled?: boolean;
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
/** Vault runtime surface. */
|
|
@@ -244,6 +248,9 @@ export function createVaultRuntime(options: CreateVaultRuntimeOptions = {}): Vau
|
|
|
244
248
|
|
|
245
249
|
const gaps: VaultGap[] = [];
|
|
246
250
|
for (const c of contracts.values()) {
|
|
251
|
+
const perTenant =
|
|
252
|
+
c.perTenant === true || (options.tenantEnabled === true && c.perTenant !== false);
|
|
253
|
+
if (perTenant) continue;
|
|
247
254
|
const value = merged.get(c.name);
|
|
248
255
|
if (value === undefined || value.length === 0) {
|
|
249
256
|
gaps.push({
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `oke_vault_secrets` tenant RLS — same concurrent-identity proof as
|
|
3
|
+
* domain tables: two tenants, one shared PGlite connection, row-level
|
|
4
|
+
* isolation via `oke.tenant()`.
|
|
5
|
+
*
|
|
6
|
+
* Policy is installed by production DDL ({@link ensureVaultTables} /
|
|
7
|
+
* {@link installOkeRlsHelpers}), not by this file.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { connectPglite } from "../../drivers/pglite.ts";
|
|
12
|
+
import { installOkeRlsHelpers } from "../../drivers/pg-rls.ts";
|
|
13
|
+
import type { SqlConnection } from "../../drivers/types.ts";
|
|
14
|
+
import { createSqlStoreHandle, type SqlStoreHandle } from "../store/sql-session.ts";
|
|
15
|
+
import { sqlConnectionAsExec } from "./builtin-adapter.ts";
|
|
16
|
+
import { ensureVaultTables } from "./storage.ts";
|
|
17
|
+
|
|
18
|
+
const CIPHER = String.raw`'\x00'::bytea`;
|
|
19
|
+
|
|
20
|
+
const SEED = `
|
|
21
|
+
INSERT INTO oke_vault_secrets (path, encrypted_value, iv, auth_tag, version, tenant_id)
|
|
22
|
+
VALUES
|
|
23
|
+
('acme/stripe', ${CIPHER}, ${CIPHER}, ${CIPHER}, 1, 'acme'),
|
|
24
|
+
('globex/stripe', ${CIPHER}, ${CIPHER}, ${CIPHER}, 1, 'globex'),
|
|
25
|
+
('GLOBAL_KEY', ${CIPHER}, ${CIPHER}, ${CIPHER}, 1, NULL)
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
/** File-scoped warmed PGlite. */
|
|
29
|
+
let conn: SqlConnection;
|
|
30
|
+
/** Stamped as tenant acme. */
|
|
31
|
+
let asAcme: SqlStoreHandle;
|
|
32
|
+
/** Stamped as tenant globex. */
|
|
33
|
+
let asGlobex: SqlStoreHandle;
|
|
34
|
+
/** No stamp — table-owner / superuser bypass (ENABLE without FORCE). */
|
|
35
|
+
let asOwner: SqlStoreHandle;
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
conn = await connectPglite({ url: "memory://vault-sql-rls-isolation", role: "primary" });
|
|
39
|
+
await installOkeRlsHelpers((sql) => conn.exec(sql));
|
|
40
|
+
await ensureVaultTables(sqlConnectionAsExec(conn));
|
|
41
|
+
await conn.exec(SEED);
|
|
42
|
+
asAcme = createSqlStoreHandle("sql:app", {
|
|
43
|
+
connection: conn,
|
|
44
|
+
classifications: new Map(),
|
|
45
|
+
routedRole: "primary",
|
|
46
|
+
domainDdl: "off",
|
|
47
|
+
rls: { gate: "member", userId: "u1", scopes: ["member"], tenantId: "acme" },
|
|
48
|
+
});
|
|
49
|
+
asGlobex = createSqlStoreHandle("sql:app", {
|
|
50
|
+
connection: conn,
|
|
51
|
+
classifications: new Map(),
|
|
52
|
+
routedRole: "primary",
|
|
53
|
+
domainDdl: "off",
|
|
54
|
+
rls: { gate: "member", userId: "u1", scopes: ["member"], tenantId: "globex" },
|
|
55
|
+
});
|
|
56
|
+
asOwner = createSqlStoreHandle("sql:app", {
|
|
57
|
+
connection: conn,
|
|
58
|
+
classifications: new Map(),
|
|
59
|
+
routedRole: "primary",
|
|
60
|
+
domainDdl: "off",
|
|
61
|
+
});
|
|
62
|
+
}, 15_000);
|
|
63
|
+
|
|
64
|
+
afterAll(async () => {
|
|
65
|
+
await conn.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("oke_vault_secrets tenant RLS (pglite)", () => {
|
|
69
|
+
test("production DDL installed oke_vault_secrets_tenant", async () => {
|
|
70
|
+
const rows = await conn.query(
|
|
71
|
+
`SELECT policyname FROM pg_policies
|
|
72
|
+
WHERE tablename = 'oke_vault_secrets' AND policyname = 'oke_vault_secrets_tenant'`,
|
|
73
|
+
);
|
|
74
|
+
expect(rows).toEqual([{ policyname: "oke_vault_secrets_tenant" }]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("sequential identities see own tenant plus global NULL rows", async () => {
|
|
78
|
+
const acme = await asAcme.raw(`SELECT path, tenant_id FROM oke_vault_secrets ORDER BY path`);
|
|
79
|
+
const globex = await asGlobex.raw(
|
|
80
|
+
`SELECT path, tenant_id FROM oke_vault_secrets ORDER BY path`,
|
|
81
|
+
);
|
|
82
|
+
expect(acme).toEqual([
|
|
83
|
+
{ path: "GLOBAL_KEY", tenant_id: null },
|
|
84
|
+
{ path: "acme/stripe", tenant_id: "acme" },
|
|
85
|
+
]);
|
|
86
|
+
expect(globex).toEqual([
|
|
87
|
+
{ path: "GLOBAL_KEY", tenant_id: null },
|
|
88
|
+
{ path: "globex/stripe", tenant_id: "globex" },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("concurrent identities do not leak across the shared connection", async () => {
|
|
93
|
+
const [acme, globex, acmeAgain] = await Promise.all([
|
|
94
|
+
asAcme.raw(`SELECT path, tenant_id FROM oke_vault_secrets ORDER BY path`),
|
|
95
|
+
asGlobex.raw(`SELECT path, tenant_id FROM oke_vault_secrets ORDER BY path`),
|
|
96
|
+
asAcme.raw(`SELECT path, tenant_id FROM oke_vault_secrets ORDER BY path`),
|
|
97
|
+
]);
|
|
98
|
+
expect(acme).toEqual([
|
|
99
|
+
{ path: "GLOBAL_KEY", tenant_id: null },
|
|
100
|
+
{ path: "acme/stripe", tenant_id: "acme" },
|
|
101
|
+
]);
|
|
102
|
+
expect(globex).toEqual([
|
|
103
|
+
{ path: "GLOBAL_KEY", tenant_id: null },
|
|
104
|
+
{ path: "globex/stripe", tenant_id: "globex" },
|
|
105
|
+
]);
|
|
106
|
+
expect(acmeAgain).toEqual([
|
|
107
|
+
{ path: "GLOBAL_KEY", tenant_id: null },
|
|
108
|
+
{ path: "acme/stripe", tenant_id: "acme" },
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("unstamped owner still sees every ciphertext row", async () => {
|
|
113
|
+
const rows = await asOwner.raw(`SELECT path, tenant_id FROM oke_vault_secrets ORDER BY path`);
|
|
114
|
+
expect(rows).toEqual([
|
|
115
|
+
{ path: "GLOBAL_KEY", tenant_id: null },
|
|
116
|
+
{ path: "acme/stripe", tenant_id: "acme" },
|
|
117
|
+
{ path: "globex/stripe", tenant_id: "globex" },
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("oke_vault_secrets tenant RLS — tables before helpers", () => {
|
|
123
|
+
test("helper install attaches the policy after the table exists", async () => {
|
|
124
|
+
const late = await connectPglite({
|
|
125
|
+
url: "memory://vault-sql-rls-late-helpers",
|
|
126
|
+
role: "primary",
|
|
127
|
+
});
|
|
128
|
+
try {
|
|
129
|
+
await ensureVaultTables(sqlConnectionAsExec(late));
|
|
130
|
+
const before = await late.query(
|
|
131
|
+
`SELECT COUNT(*)::text AS n FROM pg_policies
|
|
132
|
+
WHERE tablename = 'oke_vault_secrets' AND policyname = 'oke_vault_secrets_tenant'`,
|
|
133
|
+
);
|
|
134
|
+
expect(before[0]?.n).toBe("0");
|
|
135
|
+
await installOkeRlsHelpers((sql) => late.exec(sql));
|
|
136
|
+
const after = await late.query(
|
|
137
|
+
`SELECT COUNT(*)::text AS n FROM pg_policies
|
|
138
|
+
WHERE tablename = 'oke_vault_secrets' AND policyname = 'oke_vault_secrets_tenant'`,
|
|
139
|
+
);
|
|
140
|
+
expect(after[0]?.n).toBe("1");
|
|
141
|
+
} finally {
|
|
142
|
+
await late.close();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -14,6 +14,7 @@ import type { AuditAction, AuditEntry, AuditWriter } from "./audit.ts";
|
|
|
14
14
|
import { VaultError } from "./errors.ts";
|
|
15
15
|
import type { VaultErrorCode } from "./errors.ts";
|
|
16
16
|
import type { VaultActorType } from "./types.ts";
|
|
17
|
+
import { ensureVaultSecretsTenantRls } from "../../drivers/pg-vault-rls.ts";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Minimal SQL surface the Vault needs.
|
|
@@ -75,6 +76,7 @@ CREATE TABLE IF NOT EXISTS oke_vault_secrets (
|
|
|
75
76
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
76
77
|
expires_at timestamptz,
|
|
77
78
|
deleted_at timestamptz,
|
|
79
|
+
tenant_id text,
|
|
78
80
|
CONSTRAINT oke_vault_secrets_path_version_key UNIQUE (path, version)
|
|
79
81
|
)`;
|
|
80
82
|
|
|
@@ -168,6 +170,8 @@ const POST_DDL_STATEMENTS: readonly string[] = [
|
|
|
168
170
|
`ALTER TABLE oke_vault_status ADD COLUMN IF NOT EXISTS rewrap_key_hash text`,
|
|
169
171
|
`ALTER TABLE oke_vault_status ADD COLUMN IF NOT EXISTS rotate_locked_by text`,
|
|
170
172
|
`ALTER TABLE oke_vault_status ADD COLUMN IF NOT EXISTS rotate_lease_expires_at bigint`,
|
|
173
|
+
`ALTER TABLE oke_vault_secrets ADD COLUMN IF NOT EXISTS tenant_id text`,
|
|
174
|
+
`CREATE INDEX IF NOT EXISTS oke_vault_secrets_tenant_idx ON oke_vault_secrets (tenant_id)`,
|
|
171
175
|
`CREATE INDEX IF NOT EXISTS oke_vault_audit_seq_idx ON oke_vault_audit (seq)`,
|
|
172
176
|
`CREATE INDEX IF NOT EXISTS oke_vault_audit_created_idx ON oke_vault_audit (created_at, id)`,
|
|
173
177
|
`CREATE INDEX IF NOT EXISTS oke_vault_audit_path_idx ON oke_vault_audit (path)`,
|
|
@@ -200,6 +204,11 @@ export async function ensureVaultTables(db: SqlExec): Promise<void> {
|
|
|
200
204
|
throw new VaultError("BACKEND_ERROR", "vault: failed to ensure vault tables");
|
|
201
205
|
}
|
|
202
206
|
}
|
|
207
|
+
try {
|
|
208
|
+
await ensureVaultSecretsTenantRls((sql: string) => db.execute(sql));
|
|
209
|
+
} catch {
|
|
210
|
+
throw new VaultError("BACKEND_ERROR", "vault: failed to ensure vault tables");
|
|
211
|
+
}
|
|
203
212
|
}
|
|
204
213
|
|
|
205
214
|
/**
|
|
@@ -251,7 +251,8 @@ function isDdl(text: string): boolean {
|
|
|
251
251
|
return (
|
|
252
252
|
text.startsWith("create table") ||
|
|
253
253
|
text.startsWith("alter table") ||
|
|
254
|
-
text.startsWith("create index")
|
|
254
|
+
text.startsWith("create index") ||
|
|
255
|
+
text.startsWith("do $oke_vault_rls$")
|
|
255
256
|
);
|
|
256
257
|
}
|
|
257
258
|
|
package/src/i18n/catalogs/ar.ts
CHANGED
|
@@ -36,6 +36,22 @@ export const builtinAr = {
|
|
|
36
36
|
cause: 'التدفق "{flow}" يُصدِر الإشارة "{resource}" بلا مشترك.',
|
|
37
37
|
fix: "أضف on({resource}, …) أو عيّن الإشارة '{'optional: true'}'.",
|
|
38
38
|
},
|
|
39
|
+
"1014": {
|
|
40
|
+
cause: 'مؤشر البث "{afterId}" ليس على شريط "{signal}".',
|
|
41
|
+
fix: "أعد الاتصال بدون Last-Event-ID لإعادة تشغيل الشريط المتبقي.",
|
|
42
|
+
},
|
|
43
|
+
"1015": {
|
|
44
|
+
cause: "هذه العملية تحتاج مستأجراً، ولم يُحدَّد مستأجر لهذا الطلب.",
|
|
45
|
+
fix: "استدعِ fx.auth.switchTenant(id) أو أرسل مطالبة tid موقّعة أو رأس المستأجر.",
|
|
46
|
+
},
|
|
47
|
+
"1016": {
|
|
48
|
+
cause: 'المتصل ليس عضواً في المستأجر "{tenant}".',
|
|
49
|
+
fix: "اختر مستأجراً من fx.auth.listTenants() أو أضف المستخدم كعضو.",
|
|
50
|
+
},
|
|
51
|
+
"1017": {
|
|
52
|
+
cause: 'نطاق دور المستأجر "{scope}" ليس نطاقاً تطبيقياً معلناً.',
|
|
53
|
+
fix: "استخدم اسماً من كتالوج البيان لهذا التطبيق (وليس console:*).",
|
|
54
|
+
},
|
|
39
55
|
"1101": {
|
|
40
56
|
cause: "جدول النطاق غير موجود — لم تُطبَّق الترحيلات.",
|
|
41
57
|
fix: "شغّل `oke db migrate` على هذه البيئة.",
|
|
@@ -62,6 +78,9 @@ export const builtinAr = {
|
|
|
62
78
|
"Forbidden.ip_denied": "عنوان IP الخاص بك محظور.",
|
|
63
79
|
"Forbidden.ip_not_allowed": "عنوان IP الخاص بك غير مسموح.",
|
|
64
80
|
"Forbidden.policy_denied": "رفضت السياسة هذا الطلب.",
|
|
81
|
+
"Forbidden.tenant_required": "هذا الطلب يحتاج مستأجراً.",
|
|
82
|
+
"Forbidden.not_member": "لست عضواً في ذلك المستأجر.",
|
|
83
|
+
"Forbidden.unknown_scope": "نطاق دور المستأجر هذا ليس نطاقاً تطبيقياً معلناً.",
|
|
65
84
|
"AuthRateLimited.rate_limited": "محاولات مصادقة كثيرة جداً. حاول لاحقاً.",
|
|
66
85
|
},
|
|
67
86
|
} as const;
|
package/src/i18n/catalogs/en.ts
CHANGED
|
@@ -36,6 +36,22 @@ export const builtinEn = {
|
|
|
36
36
|
cause: 'Flow "{flow}" emits signal "{resource}" with no subscriber.',
|
|
37
37
|
fix: "Add on({resource}, …) or mark the signal '{'optional: true'}'.",
|
|
38
38
|
},
|
|
39
|
+
"1014": {
|
|
40
|
+
cause: 'Live cursor "{afterId}" is not on the tape for "{signal}".',
|
|
41
|
+
fix: "Reconnect without Last-Event-ID to replay the remaining tape.",
|
|
42
|
+
},
|
|
43
|
+
"1015": {
|
|
44
|
+
cause: "This operation needs a tenant, but none is resolved for this request.",
|
|
45
|
+
fix: "Call fx.auth.switchTenant(id), send a signed tid claim, or pass the tenant header.",
|
|
46
|
+
},
|
|
47
|
+
"1016": {
|
|
48
|
+
cause: 'The caller is not a member of tenant "{tenant}".',
|
|
49
|
+
fix: "Pick a tenant from fx.auth.listTenants() or add the user as a member.",
|
|
50
|
+
},
|
|
51
|
+
"1017": {
|
|
52
|
+
cause: 'Tenant role scope "{scope}" is not a declared application scope.',
|
|
53
|
+
fix: "Use a name from this app's Manifest catalog (never console:*).",
|
|
54
|
+
},
|
|
39
55
|
"1101": {
|
|
40
56
|
cause: "domain table not found — migrations have not been applied.",
|
|
41
57
|
fix: "run `oke db migrate` against this environment.",
|
|
@@ -63,6 +79,9 @@ export const builtinEn = {
|
|
|
63
79
|
"Forbidden.ip_denied": "Your IP address is blocked.",
|
|
64
80
|
"Forbidden.ip_not_allowed": "Your IP address is not allowed.",
|
|
65
81
|
"Forbidden.policy_denied": "Policy denied this request.",
|
|
82
|
+
"Forbidden.tenant_required": "This request needs a tenant.",
|
|
83
|
+
"Forbidden.not_member": "You are not a member of that tenant.",
|
|
84
|
+
"Forbidden.unknown_scope": "That tenant role scope is not a declared application scope.",
|
|
66
85
|
"AuthRateLimited.rate_limited": "Too many authentication attempts. Try again later.",
|
|
67
86
|
},
|
|
68
87
|
} as const;
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
import type { InferSchemaOutput } from "../validation/standard-schema.ts";
|
|
9
9
|
import type { AnyFlowDef, FlowDef, FlowErrorMap } from "./flow.ts";
|
|
10
10
|
import { isFlow } from "./flow.ts";
|
|
11
|
-
import type { HttpTrigger, Trigger } from "./triggers.ts";
|
|
11
|
+
import type { HttpTrigger, LiveHttpTrigger, Trigger } from "./triggers.ts";
|
|
12
|
+
import { httpPathParams } from "./live-http.ts";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* One flow's route contract on `app.$routes` (compatible with client
|
|
@@ -52,6 +53,10 @@ export type AppRouteMap = {
|
|
|
52
53
|
readonly errors?: unknown;
|
|
53
54
|
readonly method?: string;
|
|
54
55
|
readonly path?: string;
|
|
56
|
+
readonly live?: string;
|
|
57
|
+
readonly matchKey?: readonly string[];
|
|
58
|
+
readonly gates?: readonly string[];
|
|
59
|
+
readonly stream?: true;
|
|
55
60
|
};
|
|
56
61
|
};
|
|
57
62
|
};
|
|
@@ -73,6 +78,28 @@ export type ErrorDataOf<E extends FlowErrorMap> = {
|
|
|
73
78
|
[K in keyof E]: InferSchemaOutput<E[K]>;
|
|
74
79
|
};
|
|
75
80
|
|
|
81
|
+
/**
|
|
82
|
+
* HTTP live exposure on `$routes` so `createClient(app)` types
|
|
83
|
+
* `api.unit.flow(input, { onEvent })` as subscribe, not JSON RPC.
|
|
84
|
+
*
|
|
85
|
+
* @typeParam I - Input
|
|
86
|
+
* @typeParam O - Output
|
|
87
|
+
* @typeParam E - Error map
|
|
88
|
+
* @typeParam M - HTTP method
|
|
89
|
+
* @typeParam P - Path
|
|
90
|
+
*/
|
|
91
|
+
export type LiveAppFlowRoute<
|
|
92
|
+
I = unknown,
|
|
93
|
+
O = unknown,
|
|
94
|
+
E extends Record<string, unknown> = Record<string, unknown>,
|
|
95
|
+
M extends string = string,
|
|
96
|
+
P extends string = string,
|
|
97
|
+
> = AppFlowRoute<I, O, E, M, P> & {
|
|
98
|
+
readonly live: string;
|
|
99
|
+
readonly stream: true;
|
|
100
|
+
readonly matchKey: readonly string[];
|
|
101
|
+
};
|
|
102
|
+
|
|
76
103
|
/**
|
|
77
104
|
* Derive a client route contract from a {@link FlowDef} (trigger → REST).
|
|
78
105
|
*
|
|
@@ -80,9 +107,11 @@ export type ErrorDataOf<E extends FlowErrorMap> = {
|
|
|
80
107
|
*/
|
|
81
108
|
export type RouteFromFlow<F> =
|
|
82
109
|
F extends FlowDef<infer I, infer O, infer E, any, infer T>
|
|
83
|
-
? [T] extends [
|
|
84
|
-
?
|
|
85
|
-
:
|
|
110
|
+
? [T] extends [LiveHttpTrigger<infer M, infer P>]
|
|
111
|
+
? LiveAppFlowRoute<I, O, ErrorDataOf<E>, M, P>
|
|
112
|
+
: [T] extends [HttpTrigger<infer M, infer P>]
|
|
113
|
+
? AppFlowRoute<I, O, ErrorDataOf<E>, M, P>
|
|
114
|
+
: AppFlowRoute<I, O, ErrorDataOf<E>>
|
|
86
115
|
: never;
|
|
87
116
|
|
|
88
117
|
/**
|
|
@@ -130,6 +159,14 @@ export type RoutesFromAdoptArgs<Args extends readonly unknown[]> = Args extends
|
|
|
130
159
|
export interface RuntimeFlowRoute {
|
|
131
160
|
readonly method?: string;
|
|
132
161
|
readonly path?: string;
|
|
162
|
+
/** SSE live signal name when the trigger used `.live(signal)`. */
|
|
163
|
+
readonly live?: string;
|
|
164
|
+
/** Sorted auto-match path-param names (empty = firehose). */
|
|
165
|
+
readonly matchKey?: readonly string[];
|
|
166
|
+
/** Flattened gate names on the HTTP trigger. */
|
|
167
|
+
readonly gates?: readonly string[];
|
|
168
|
+
/** True when this flow returns `text/event-stream`. */
|
|
169
|
+
readonly stream?: true;
|
|
133
170
|
}
|
|
134
171
|
|
|
135
172
|
/** Runtime `$routes` table (values; types live on {@link OkeApp}). */
|
|
@@ -146,10 +183,21 @@ export type RuntimeRouteMap = {
|
|
|
146
183
|
*/
|
|
147
184
|
export function runtimeRouteFromFlow(flowDef: AnyFlowDef): RuntimeFlowRoute {
|
|
148
185
|
const trigger: Trigger | undefined = flowDef.$trigger ?? flowDef.triggers[0];
|
|
149
|
-
if (trigger?.kind
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return {
|
|
186
|
+
if (trigger?.kind !== "http") return {};
|
|
187
|
+
const liveName = trigger.liveSignal?.name ?? flowDef.live;
|
|
188
|
+
const gates = trigger.gates.map((g) => (typeof g === "string" ? g : g.name));
|
|
189
|
+
return {
|
|
190
|
+
method: trigger.method,
|
|
191
|
+
path: trigger.path,
|
|
192
|
+
...(liveName !== undefined
|
|
193
|
+
? {
|
|
194
|
+
live: liveName,
|
|
195
|
+
stream: true as const,
|
|
196
|
+
matchKey: httpPathParams(trigger.path),
|
|
197
|
+
...(gates.length > 0 ? { gates } : {}),
|
|
198
|
+
}
|
|
199
|
+
: {}),
|
|
200
|
+
};
|
|
153
201
|
}
|
|
154
202
|
|
|
155
203
|
/**
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-shell tenancy wiring — lazy chunk.
|
|
3
|
+
*
|
|
4
|
+
* One `w` dispatcher so Store-only `oke()` graphs do not spell Clock /
|
|
5
|
+
* pipeline tenant field names.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ResolvedTenantAuth } from "../auth/tenant-config.ts";
|
|
9
|
+
import type { TenantStore } from "../auth/tenants.ts";
|
|
10
|
+
import type { ClockDecl } from "../elements/clock/declare.ts";
|
|
11
|
+
import { parsePerTenantCronName } from "./clock-per-tenant-name.ts";
|
|
12
|
+
import { orphanPerTenantCronRows, putPerTenantCronRows } from "./clock-reconcile.ts";
|
|
13
|
+
import type { AnyFlowDef } from "./flow.ts";
|
|
14
|
+
import type { PipelineTenantDeps } from "./pipeline.ts";
|
|
15
|
+
|
|
16
|
+
function cron(name: string): { readonly tenant: { readonly id: string } } | undefined {
|
|
17
|
+
const parsed = parsePerTenantCronName(name);
|
|
18
|
+
return parsed ? { tenant: { id: parsed.tenantId } } : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function boot(store: TenantStore): { readonly tenantIds: () => string[] } {
|
|
22
|
+
return { tenantIds: () => [...store.tenants.keys()] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clock(
|
|
26
|
+
tenantStore: TenantStore,
|
|
27
|
+
clockStore: Parameters<typeof putPerTenantCronRows>[0],
|
|
28
|
+
templates: readonly ClockDecl[],
|
|
29
|
+
): void {
|
|
30
|
+
tenantStore.hooks = {
|
|
31
|
+
onCreate: (row) => putPerTenantCronRows(clockStore, templates, row.id),
|
|
32
|
+
onDelete: (row) => orphanPerTenantCronRows(clockStore, templates, row.id),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resume(auth: { readonly tenant?: unknown; readonly tenantStore?: TenantStore }): {
|
|
37
|
+
readonly tenantEnabled: boolean;
|
|
38
|
+
readonly tenantStore: TenantStore | undefined;
|
|
39
|
+
} {
|
|
40
|
+
return {
|
|
41
|
+
tenantEnabled: auth.tenant !== undefined,
|
|
42
|
+
tenantStore: auth.tenantStore,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pipe(
|
|
47
|
+
tenant: ResolvedTenantAuth,
|
|
48
|
+
store: TenantStore,
|
|
49
|
+
flowTenantScoped: boolean,
|
|
50
|
+
flowPlane: AnyFlowDef["plane"],
|
|
51
|
+
): { readonly tenant: PipelineTenantDeps } {
|
|
52
|
+
return {
|
|
53
|
+
tenant: {
|
|
54
|
+
config: tenant,
|
|
55
|
+
store,
|
|
56
|
+
flowTenantScoped,
|
|
57
|
+
flowPlane,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fx(args: {
|
|
63
|
+
readonly enabled: boolean;
|
|
64
|
+
readonly store: TenantStore | undefined;
|
|
65
|
+
readonly scoped: boolean;
|
|
66
|
+
readonly plane: AnyFlowDef["plane"];
|
|
67
|
+
readonly tenant: { readonly id: string | null } | undefined;
|
|
68
|
+
readonly bind?: { readonly secret: string; readonly now: () => number };
|
|
69
|
+
}): {
|
|
70
|
+
readonly tenantStore: TenantStore | undefined;
|
|
71
|
+
readonly tenantEnabled: boolean;
|
|
72
|
+
readonly flowTenantScoped: boolean;
|
|
73
|
+
readonly flowPlane: AnyFlowDef["plane"];
|
|
74
|
+
readonly sessionCrypto?: { readonly secret: string; readonly now: () => number };
|
|
75
|
+
readonly tenant?: { readonly id: string | null };
|
|
76
|
+
} {
|
|
77
|
+
return {
|
|
78
|
+
tenantStore: args.store,
|
|
79
|
+
tenantEnabled: args.enabled,
|
|
80
|
+
flowTenantScoped: args.scoped,
|
|
81
|
+
flowPlane: args.plane,
|
|
82
|
+
...(args.bind ? { sessionCrypto: { secret: args.bind.secret, now: args.bind.now } } : {}),
|
|
83
|
+
...(args.tenant ? { tenant: args.tenant } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Dispatch tenancy wiring.
|
|
89
|
+
*
|
|
90
|
+
* 0 cron · 1 boot · 2 clock · 3 pipe · 4 fx · 5 resume
|
|
91
|
+
*
|
|
92
|
+
* @param kind - Operation
|
|
93
|
+
* @param args - Kind-specific arguments
|
|
94
|
+
*/
|
|
95
|
+
export function w(kind: number, ...args: unknown[]): unknown {
|
|
96
|
+
switch (kind) {
|
|
97
|
+
case 0:
|
|
98
|
+
return cron(args[0] as string);
|
|
99
|
+
case 1:
|
|
100
|
+
return boot(args[0] as TenantStore);
|
|
101
|
+
case 2:
|
|
102
|
+
clock(
|
|
103
|
+
args[0] as TenantStore,
|
|
104
|
+
args[1] as Parameters<typeof putPerTenantCronRows>[0],
|
|
105
|
+
args[2] as readonly ClockDecl[],
|
|
106
|
+
);
|
|
107
|
+
return undefined;
|
|
108
|
+
case 3:
|
|
109
|
+
return pipe(
|
|
110
|
+
args[0] as ResolvedTenantAuth,
|
|
111
|
+
args[1] as TenantStore,
|
|
112
|
+
args[2] as boolean,
|
|
113
|
+
args[3] as AnyFlowDef["plane"],
|
|
114
|
+
);
|
|
115
|
+
case 4:
|
|
116
|
+
return fx(args[0] as Parameters<typeof fx>[0]);
|
|
117
|
+
case 5:
|
|
118
|
+
return resume(args[0] as Parameters<typeof resume>[0]);
|
|
119
|
+
default:
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|