okengine 0.16.0 → 0.17.1
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/ai/llms-txt.mdx +9 -3
- 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-9Wwx1g4Q.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-DydRDi70.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-Cg9nxd6m.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-D-OzmFOI.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-B_zeux2Y.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-CsMg7XDW.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
|
@@ -6,11 +6,23 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
|
-
import { OKE_ERRORS, OkeError, type OkeErrorDefinition } from "./errors.ts";
|
|
9
|
+
import { OKE_ERRORS, OkeError, lookupOkeError, type OkeErrorDefinition } from "./errors.ts";
|
|
10
|
+
import { LIVE_RESUME_GAP } from "./errors-live-resume.ts";
|
|
11
|
+
import { TENANT_NOT_MEMBER, TENANT_REQUIRED, TENANT_UNKNOWN_SCOPE } from "./errors-tenant.ts";
|
|
12
|
+
|
|
13
|
+
const LAZY_DEFS: readonly OkeErrorDefinition[] = [
|
|
14
|
+
LIVE_RESUME_GAP,
|
|
15
|
+
TENANT_REQUIRED,
|
|
16
|
+
TENANT_NOT_MEMBER,
|
|
17
|
+
TENANT_UNKNOWN_SCOPE,
|
|
18
|
+
];
|
|
10
19
|
|
|
11
20
|
describe("OKE error-code registry", () => {
|
|
12
21
|
test("every code is unique", () => {
|
|
13
|
-
const codes =
|
|
22
|
+
const codes = [
|
|
23
|
+
...Object.values(OKE_ERRORS).map((d) => d.code),
|
|
24
|
+
...LAZY_DEFS.map((d) => d.code),
|
|
25
|
+
];
|
|
14
26
|
expect(new Set(codes).size).toBe(codes.length);
|
|
15
27
|
});
|
|
16
28
|
|
|
@@ -21,10 +33,16 @@ describe("OKE error-code registry", () => {
|
|
|
21
33
|
expect(Number.isInteger(def.code), `${key}.code`).toBe(true);
|
|
22
34
|
expect(def.code, `${key}.code`).toBeGreaterThan(0);
|
|
23
35
|
}
|
|
36
|
+
expect(LIVE_RESUME_GAP.cause.trim().length).toBeGreaterThan(0);
|
|
37
|
+
expect(LIVE_RESUME_GAP.fix.trim().length).toBeGreaterThan(0);
|
|
38
|
+
for (const def of LAZY_DEFS) {
|
|
39
|
+
expect(def.cause.trim().length, `${def.code}.cause`).toBeGreaterThan(0);
|
|
40
|
+
expect(def.fix.trim().length, `${def.code}.fix`).toBeGreaterThan(0);
|
|
41
|
+
}
|
|
24
42
|
});
|
|
25
43
|
|
|
26
44
|
test("OkeError docsUrl matches docs origin /e/{code}", () => {
|
|
27
|
-
for (const def of Object.values(OKE_ERRORS) as OkeErrorDefinition[]) {
|
|
45
|
+
for (const def of [...Object.values(OKE_ERRORS), ...LAZY_DEFS] as OkeErrorDefinition[]) {
|
|
28
46
|
const err = new OkeError(def);
|
|
29
47
|
expect(err.docsUrl).toBe(`https://oke.omqkhafi.dev/e/${def.code}`);
|
|
30
48
|
expect(err.message).toContain(err.docsUrl);
|
|
@@ -32,4 +50,14 @@ describe("OKE error-code registry", () => {
|
|
|
32
50
|
expect(err.message).toContain("→");
|
|
33
51
|
}
|
|
34
52
|
});
|
|
53
|
+
|
|
54
|
+
test("lookupOkeError finds the lazy LIVE_RESUME_GAP entry", () => {
|
|
55
|
+
expect(lookupOkeError(1014)).toEqual(LIVE_RESUME_GAP);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("lookupOkeError finds lazy tenant entries", () => {
|
|
59
|
+
expect(lookupOkeError(1015)).toEqual(TENANT_REQUIRED);
|
|
60
|
+
expect(lookupOkeError(1016)).toEqual(TENANT_NOT_MEMBER);
|
|
61
|
+
expect(lookupOkeError(1017)).toEqual(TENANT_UNKNOWN_SCOPE);
|
|
62
|
+
});
|
|
35
63
|
});
|
package/src/kernel/errors.ts
CHANGED
|
@@ -207,6 +207,15 @@ export const OKE_ERRORS = {
|
|
|
207
207
|
cause: "An HTTP flow on {method} {path} has no name.",
|
|
208
208
|
fix: 'Use `flow("unit.export", {…})` or export it from a `src/flows/<unit>/` file so the tree can stamp `unit.export`.',
|
|
209
209
|
},
|
|
210
|
+
/**
|
|
211
|
+
* Two live HTTP exposures of the same signal share gates + match shape.
|
|
212
|
+
*/
|
|
213
|
+
LIVE_EXPOSURE_DUPLICATE: {
|
|
214
|
+
code: 1013,
|
|
215
|
+
cause:
|
|
216
|
+
'Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).',
|
|
217
|
+
fix: "Use a different gate or path-param filter, or drop the extra route.",
|
|
218
|
+
},
|
|
210
219
|
/**
|
|
211
220
|
* Emit target has no subscriber (unified-theory §21).
|
|
212
221
|
* Thrown at emit when `optional` is false and nobody is subscribed.
|
|
@@ -233,15 +242,37 @@ export const OKE_ERRORS = {
|
|
|
233
242
|
},
|
|
234
243
|
} as const satisfies Record<string, OkeErrorDefinition>;
|
|
235
244
|
|
|
245
|
+
/** Tenant error keys — definitions live in the lazy `errors-tenant` chunk. */
|
|
246
|
+
export type TenantOkeErrorKey = "TENANT_REQUIRED" | "TENANT_NOT_MEMBER" | "TENANT_UNKNOWN_SCOPE";
|
|
247
|
+
|
|
248
|
+
type TenantErrorChunk = {
|
|
249
|
+
readonly TENANT_REQUIRED: OkeErrorDefinition;
|
|
250
|
+
readonly TENANT_NOT_MEMBER: OkeErrorDefinition;
|
|
251
|
+
readonly TENANT_UNKNOWN_SCOPE: OkeErrorDefinition;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Load OKE1015–1017. Computed stem so Bun.build cannot inline the chunk.
|
|
256
|
+
*/
|
|
257
|
+
function loadTenantErrors(): TenantErrorChunk {
|
|
258
|
+
return lazyRequire(import.meta.dir, ["errors", "tenant"].join("-"));
|
|
259
|
+
}
|
|
260
|
+
|
|
236
261
|
/**
|
|
237
262
|
* Build and throw a registered framework error.
|
|
238
263
|
*
|
|
239
|
-
* @param key - Key into {@link OKE_ERRORS}
|
|
264
|
+
* @param key - Key into {@link OKE_ERRORS} or a lazy tenant code
|
|
240
265
|
* @param params - Interpolation params
|
|
241
266
|
* @returns Never
|
|
242
267
|
*/
|
|
243
|
-
export function throwOke(
|
|
244
|
-
|
|
268
|
+
export function throwOke(
|
|
269
|
+
key: keyof typeof OKE_ERRORS | TenantOkeErrorKey,
|
|
270
|
+
params: OkeErrorParams = {},
|
|
271
|
+
): never {
|
|
272
|
+
if (typeof key === "string" && key.startsWith("TENANT_")) {
|
|
273
|
+
throw new OkeError(loadTenantErrors()[key as TenantOkeErrorKey], params);
|
|
274
|
+
}
|
|
275
|
+
throw new OkeError(OKE_ERRORS[key as keyof typeof OKE_ERRORS], params);
|
|
245
276
|
}
|
|
246
277
|
|
|
247
278
|
/**
|
|
@@ -250,6 +281,15 @@ export function throwOke(key: keyof typeof OKE_ERRORS, params: OkeErrorParams =
|
|
|
250
281
|
* @param code - Permanent numeric code
|
|
251
282
|
*/
|
|
252
283
|
export function lookupOkeError(code: OkeErrorCode): OkeErrorDefinition | undefined {
|
|
284
|
+
if (code === 1014) {
|
|
285
|
+
return lazyRequire<typeof import("./errors-live-resume.ts")>(
|
|
286
|
+
import.meta.dir,
|
|
287
|
+
["errors", "live", "resume"].join("-"),
|
|
288
|
+
).LIVE_RESUME_GAP;
|
|
289
|
+
}
|
|
290
|
+
if (code === 1015) return loadTenantErrors().TENANT_REQUIRED;
|
|
291
|
+
if (code === 1016) return loadTenantErrors().TENANT_NOT_MEMBER;
|
|
292
|
+
if (code === 1017) return loadTenantErrors().TENANT_UNKNOWN_SCOPE;
|
|
253
293
|
for (const def of Object.values(OKE_ERRORS)) {
|
|
254
294
|
if (def.code === code) return def;
|
|
255
295
|
}
|
package/src/kernel/flow.ts
CHANGED
|
@@ -48,13 +48,15 @@ export interface FlowOptions<I = unknown, O = unknown, E extends FlowErrorMap =
|
|
|
48
48
|
readonly effects?: Effects;
|
|
49
49
|
/** Journal every effect call. */
|
|
50
50
|
readonly durable?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Retry the whole `do` body on thrown errors (same journal session when
|
|
53
|
+
* durable). Prefer {@link Fx.retry} inside {@link Fx.step} for fine control.
|
|
54
|
+
*/
|
|
51
55
|
/**
|
|
52
56
|
* Retry the whole `do` body on thrown errors (same journal session when
|
|
53
57
|
* durable). Prefer {@link Fx.retry} inside {@link Fx.step} for fine control.
|
|
54
58
|
*/
|
|
55
59
|
readonly retry?: FxRetryOptions;
|
|
56
|
-
/** Live-query / push result. */
|
|
57
|
-
readonly live?: boolean;
|
|
58
60
|
/**
|
|
59
61
|
* Cache policy. Read-only flows cache automatically from inferred
|
|
60
62
|
* effects (`true` / omitted). `false` opts out; a duration string
|
|
@@ -89,6 +91,11 @@ export interface FlowOptions<I = unknown, O = unknown, E extends FlowErrorMap =
|
|
|
89
91
|
},
|
|
90
92
|
fx: import("./fx.ts").Fx,
|
|
91
93
|
) => unknown | Promise<unknown>;
|
|
94
|
+
/**
|
|
95
|
+
* When `false`, skip tenant-role scope union even if `fx.tenant.id` is set.
|
|
96
|
+
* Default `true` when `gate.auth.tenant` is on.
|
|
97
|
+
*/
|
|
98
|
+
readonly tenantScoped?: boolean;
|
|
92
99
|
/** The behavior. */
|
|
93
100
|
readonly do: FlowHandler<I, O>;
|
|
94
101
|
}
|
|
@@ -171,8 +178,15 @@ export interface FlowDef<
|
|
|
171
178
|
readonly durable: boolean;
|
|
172
179
|
/** Whole-body retry policy (runtime). */
|
|
173
180
|
readonly retry: FxRetryOptions | undefined;
|
|
174
|
-
/**
|
|
175
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Live signal name when this HTTP flow streams {@link SignalDecl} SSE.
|
|
183
|
+
*/
|
|
184
|
+
readonly live: string | undefined;
|
|
185
|
+
/**
|
|
186
|
+
* True when `.live(signal)` is bound to a custom `do` (not synthesized).
|
|
187
|
+
* Boot uniqueness uses `custom:{flowName}` instead of path-param match.
|
|
188
|
+
*/
|
|
189
|
+
readonly liveCustomMatch: boolean;
|
|
176
190
|
/** Cache option. */
|
|
177
191
|
readonly cache: boolean | string | undefined;
|
|
178
192
|
/** SLO. */
|
|
@@ -181,6 +195,11 @@ export interface FlowDef<
|
|
|
181
195
|
readonly plane: FlowPlane | undefined;
|
|
182
196
|
/** Intentional contract-break acknowledgement for Manifest Diff. */
|
|
183
197
|
readonly breaking: boolean;
|
|
198
|
+
/**
|
|
199
|
+
* When `false`, this flow is tenant-unaware (no tenant-role scope union).
|
|
200
|
+
* Default `true` when `gate.auth.tenant` is on.
|
|
201
|
+
*/
|
|
202
|
+
readonly tenantScoped: boolean | undefined;
|
|
184
203
|
/**
|
|
185
204
|
* Optional compensation phase after terminal durable failure.
|
|
186
205
|
* See {@link FlowOptions.compensate}.
|
|
@@ -284,11 +303,13 @@ export function flow(
|
|
|
284
303
|
effects: options.effects,
|
|
285
304
|
durable: options.durable ?? false,
|
|
286
305
|
retry: options.retry,
|
|
287
|
-
live:
|
|
306
|
+
live: undefined,
|
|
307
|
+
liveCustomMatch: false,
|
|
288
308
|
cache: options.cache,
|
|
289
309
|
slo: options.slo,
|
|
290
310
|
plane: options.plane,
|
|
291
311
|
breaking: options.breaking ?? false,
|
|
312
|
+
tenantScoped: options.tenantScoped,
|
|
292
313
|
compensate: options.compensate as FlowDef["compensate"],
|
|
293
314
|
do: options.do as FlowHandler,
|
|
294
315
|
triggers,
|
|
@@ -26,8 +26,13 @@ import { fail, type FlowFailure } from "./errors.ts";
|
|
|
26
26
|
export interface FxAuthIdentity {
|
|
27
27
|
/** User-plane subject id, or null when unauthenticated. */
|
|
28
28
|
readonly userId: string | null;
|
|
29
|
-
/** Granted scopes for the live principal. */
|
|
29
|
+
/** Granted scopes for the live principal (may include tenant-role union). */
|
|
30
30
|
readonly scopes: ReadonlySet<string>;
|
|
31
|
+
/**
|
|
32
|
+
* Session / JWT scopes before tenant-role union.
|
|
33
|
+
* `switchTenant` mints from this set so tenant grants never melt into the token.
|
|
34
|
+
*/
|
|
35
|
+
readonly sessionScopes?: ReadonlySet<string>;
|
|
31
36
|
/** Whether the identity has completed verification (email / MFA). */
|
|
32
37
|
readonly verified?: boolean;
|
|
33
38
|
/** Authenticating API key id when Bearer was a key secret; otherwise null. */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-only tenant methods — switchTenant mints a new family; listTenants refuses keys.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { createSessionStore, issueSession, verifyAccess } from "../auth/sessions.ts";
|
|
7
|
+
import { createTenant, createTenantStore } from "../auth/tenants.ts";
|
|
8
|
+
import { isFlowFailure } from "./hooks.ts";
|
|
9
|
+
import { createFx } from "./fx.ts";
|
|
10
|
+
|
|
11
|
+
const SECRET = "tenant-methods-test-secret";
|
|
12
|
+
|
|
13
|
+
describe("fx.auth tenant methods", () => {
|
|
14
|
+
test("listTenants is session-only", async () => {
|
|
15
|
+
const store = createTenantStore();
|
|
16
|
+
await createTenant(store, { name: "Acme", createdBy: "u1", id: "t1" });
|
|
17
|
+
const asKey = createFx({
|
|
18
|
+
flow: "tenants.list",
|
|
19
|
+
effects: { reads: ["auth:tenants"] },
|
|
20
|
+
tenantStore: store,
|
|
21
|
+
tenantEnabled: true,
|
|
22
|
+
auth: { userId: "u1", scopes: new Set(), apiKeyId: "key_1" },
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
await asKey.auth.listTenants();
|
|
26
|
+
throw new Error("expected Forbidden");
|
|
27
|
+
} catch (err) {
|
|
28
|
+
expect(isFlowFailure(err)).toBe(true);
|
|
29
|
+
if (isFlowFailure(err)) {
|
|
30
|
+
expect(err.error.code).toBe("Forbidden");
|
|
31
|
+
expect(err.error.data).toEqual({ gate: "auth:tenants", reason: "session_only" });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const asSession = createFx({
|
|
36
|
+
flow: "tenants.list.session",
|
|
37
|
+
effects: { reads: ["auth:tenants"] },
|
|
38
|
+
tenantStore: store,
|
|
39
|
+
tenantEnabled: true,
|
|
40
|
+
auth: { userId: "u1", scopes: new Set(), apiKeyId: null },
|
|
41
|
+
});
|
|
42
|
+
const listed = await asSession.auth.listTenants();
|
|
43
|
+
expect(listed.map((t) => t.id)).toEqual(["t1"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("switchTenant issues a new family and copies tid without melting tenant scopes", async () => {
|
|
47
|
+
const tenantStore = createTenantStore();
|
|
48
|
+
await createTenant(tenantStore, { name: "Acme", createdBy: "u1", id: "t1" });
|
|
49
|
+
await createTenant(tenantStore, { name: "Globex", createdBy: "u1", id: "t2" });
|
|
50
|
+
const sessions = createSessionStore();
|
|
51
|
+
const crypto = { secret: SECRET };
|
|
52
|
+
const first = await issueSession(sessions, crypto, {
|
|
53
|
+
id: "u1",
|
|
54
|
+
plane: "user",
|
|
55
|
+
scopes: ["member"],
|
|
56
|
+
tenantId: "t1",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const liveScopes = new Set(["member", "booking:create"]);
|
|
60
|
+
const fx = createFx({
|
|
61
|
+
flow: "tenants.switch",
|
|
62
|
+
effects: { writes: ["auth:tenants"] },
|
|
63
|
+
tenantStore,
|
|
64
|
+
tenantEnabled: true,
|
|
65
|
+
sessions,
|
|
66
|
+
sessionCrypto: crypto,
|
|
67
|
+
auth: {
|
|
68
|
+
userId: "u1",
|
|
69
|
+
scopes: liveScopes,
|
|
70
|
+
sessionScopes: new Set(["member"]),
|
|
71
|
+
apiKeyId: null,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const switched = await fx.auth.switchTenant("t2");
|
|
75
|
+
expect(switched.session.familyId).not.toBe(first.session.familyId);
|
|
76
|
+
expect(switched.session.id).not.toBe(first.session.id);
|
|
77
|
+
|
|
78
|
+
const claims = await verifyAccess(sessions, SECRET, switched.accessToken);
|
|
79
|
+
expect(claims.tid).toBe("t2");
|
|
80
|
+
expect(claims.scopes).toEqual(["member"]);
|
|
81
|
+
expect(claims.sid).toBe(switched.session.id);
|
|
82
|
+
|
|
83
|
+
const firstStill = await verifyAccess(sessions, SECRET, first.accessToken);
|
|
84
|
+
expect(firstStill.tid).toBe("t1");
|
|
85
|
+
expect(firstStill.sid).toBe(first.session.id);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fx.auth` tenant methods — session-only, lazy-attached like API keys.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AUTH_TENANTS_RESOURCE,
|
|
7
|
+
addMember,
|
|
8
|
+
createTenant,
|
|
9
|
+
deleteTenant,
|
|
10
|
+
isMember,
|
|
11
|
+
listMembers,
|
|
12
|
+
listTenantsForUser,
|
|
13
|
+
removeMember,
|
|
14
|
+
TenantError,
|
|
15
|
+
upsertTenantRole,
|
|
16
|
+
type TenantMemberRow,
|
|
17
|
+
type TenantPublicRow,
|
|
18
|
+
type TenantRoleRow,
|
|
19
|
+
type TenantRow,
|
|
20
|
+
type TenantStore,
|
|
21
|
+
} from "../auth/tenants.ts";
|
|
22
|
+
import {
|
|
23
|
+
issueSession,
|
|
24
|
+
type IssuedSession,
|
|
25
|
+
type SessionCrypto,
|
|
26
|
+
type SessionStore,
|
|
27
|
+
} from "../auth/sessions.ts";
|
|
28
|
+
import type { Manifest } from "../manifest/types.ts";
|
|
29
|
+
import { deriveModuleActions } from "../elements/gate/permissions.ts";
|
|
30
|
+
import type { EffectKind } from "./effects.ts";
|
|
31
|
+
import { fail, type FlowFailure } from "./errors.ts";
|
|
32
|
+
import type { FxAuthIdentity } from "./fx-auth-keys.ts";
|
|
33
|
+
|
|
34
|
+
/** Options for {@link FxAuth.createTenant}. */
|
|
35
|
+
export interface FxCreateTenantInput {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly slug?: string | null;
|
|
38
|
+
readonly id?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Options for {@link FxAuth.addMember}. */
|
|
42
|
+
export interface FxAddMemberInput {
|
|
43
|
+
readonly tenantId: string;
|
|
44
|
+
readonly userId: string;
|
|
45
|
+
readonly role?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Options for {@link FxAuth.upsertTenantRole}. */
|
|
49
|
+
export interface FxUpsertTenantRoleInput {
|
|
50
|
+
readonly tenantId: string;
|
|
51
|
+
readonly roleName: string;
|
|
52
|
+
readonly scopes: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Session-only tenant primitives attached onto {@link FxAuthIdentity}.
|
|
57
|
+
*/
|
|
58
|
+
export interface FxAuthTenantMethods {
|
|
59
|
+
listTenants(): Promise<readonly TenantPublicRow[]>;
|
|
60
|
+
switchTenant(id: string): Promise<IssuedSession>;
|
|
61
|
+
createTenant(input: FxCreateTenantInput): Promise<TenantRow>;
|
|
62
|
+
deleteTenant(id: string): Promise<TenantRow>;
|
|
63
|
+
addMember(input: FxAddMemberInput): Promise<TenantMemberRow>;
|
|
64
|
+
removeMember(tenantId: string, userId: string): Promise<TenantMemberRow>;
|
|
65
|
+
listMembers(tenantId: string): Promise<readonly TenantMemberRow[]>;
|
|
66
|
+
upsertTenantRole(input: FxUpsertTenantRoleInput): Promise<TenantRoleRow>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Dependencies for {@link attachTenantMethods}. */
|
|
70
|
+
export interface AttachAuthTenantMethodsOptions {
|
|
71
|
+
readonly auth: FxAuthIdentity;
|
|
72
|
+
readonly store: TenantStore | undefined;
|
|
73
|
+
readonly sessions: SessionStore | undefined;
|
|
74
|
+
readonly crypto: SessionCrypto | undefined;
|
|
75
|
+
readonly manifest: Manifest | undefined;
|
|
76
|
+
readonly now: () => number;
|
|
77
|
+
readonly gated: <T>(kind: EffectKind, resource: string, body: () => T | Promise<T>) => Promise<T>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sessionOnly(auth: FxAuthIdentity): FlowFailure | null {
|
|
81
|
+
if (auth.apiKeyId) {
|
|
82
|
+
return fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "session_only" });
|
|
83
|
+
}
|
|
84
|
+
if (!auth.userId) {
|
|
85
|
+
return fail("Unauthorized", {});
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireStore(store: TenantStore | undefined): TenantStore {
|
|
91
|
+
if (!store) {
|
|
92
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "no_store" });
|
|
93
|
+
}
|
|
94
|
+
return store;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mapTenantError(err: unknown): never {
|
|
98
|
+
if (err instanceof TenantError) {
|
|
99
|
+
throw fail("Forbidden", {
|
|
100
|
+
gate: AUTH_TENANTS_RESOURCE,
|
|
101
|
+
reason: err.reason,
|
|
102
|
+
...(err.scope !== undefined ? { scope: err.scope } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Attach tenant primitives onto a live identity bag.
|
|
110
|
+
*
|
|
111
|
+
* @param options - Auth bag + tenant store + session crypto
|
|
112
|
+
*/
|
|
113
|
+
export function attach(
|
|
114
|
+
options: AttachAuthTenantMethodsOptions,
|
|
115
|
+
): FxAuthIdentity & FxAuthTenantMethods {
|
|
116
|
+
const { auth, now, gated } = options;
|
|
117
|
+
|
|
118
|
+
const methods: FxAuthTenantMethods = {
|
|
119
|
+
listTenants() {
|
|
120
|
+
return gated("read", AUTH_TENANTS_RESOURCE, async () => {
|
|
121
|
+
const denied = sessionOnly(auth);
|
|
122
|
+
if (denied) throw denied;
|
|
123
|
+
const store = requireStore(options.store);
|
|
124
|
+
return listTenantsForUser(store, auth.userId!);
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
switchTenant(id) {
|
|
128
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
129
|
+
const denied = sessionOnly(auth);
|
|
130
|
+
if (denied) throw denied;
|
|
131
|
+
const store = requireStore(options.store);
|
|
132
|
+
if (!isMember(store, id, auth.userId!)) {
|
|
133
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
134
|
+
}
|
|
135
|
+
const sessions = options.sessions;
|
|
136
|
+
const crypto = options.crypto;
|
|
137
|
+
if (!sessions || !crypto) {
|
|
138
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "no_sessions" });
|
|
139
|
+
}
|
|
140
|
+
// New family — tab A rotation cannot reuse-detect tab B.
|
|
141
|
+
// Mint from session scopes, never the live tenant-unioned set.
|
|
142
|
+
const minted = auth.sessionScopes ?? auth.scopes;
|
|
143
|
+
return issueSession(sessions, crypto, {
|
|
144
|
+
id: auth.userId!,
|
|
145
|
+
plane: "user",
|
|
146
|
+
scopes: [...minted],
|
|
147
|
+
tenantId: id,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
createTenant(input) {
|
|
152
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
153
|
+
const denied = sessionOnly(auth);
|
|
154
|
+
if (denied) throw denied;
|
|
155
|
+
const store = requireStore(options.store);
|
|
156
|
+
try {
|
|
157
|
+
return await createTenant(store, {
|
|
158
|
+
name: input.name,
|
|
159
|
+
createdBy: auth.userId!,
|
|
160
|
+
...(input.slug !== undefined ? { slug: input.slug } : {}),
|
|
161
|
+
...(input.id !== undefined ? { id: input.id } : {}),
|
|
162
|
+
now,
|
|
163
|
+
});
|
|
164
|
+
} catch (err) {
|
|
165
|
+
mapTenantError(err);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
deleteTenant(id) {
|
|
170
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
171
|
+
const denied = sessionOnly(auth);
|
|
172
|
+
if (denied) throw denied;
|
|
173
|
+
const store = requireStore(options.store);
|
|
174
|
+
if (!isMember(store, id, auth.userId!)) {
|
|
175
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
176
|
+
}
|
|
177
|
+
const row = await deleteTenant(store, id);
|
|
178
|
+
if (!row) {
|
|
179
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "unknown_tenant" });
|
|
180
|
+
}
|
|
181
|
+
return row;
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
addMember(input) {
|
|
185
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
186
|
+
const denied = sessionOnly(auth);
|
|
187
|
+
if (denied) throw denied;
|
|
188
|
+
const store = requireStore(options.store);
|
|
189
|
+
if (!isMember(store, input.tenantId, auth.userId!)) {
|
|
190
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
return addMember(store, {
|
|
194
|
+
tenantId: input.tenantId,
|
|
195
|
+
userId: input.userId,
|
|
196
|
+
...(input.role !== undefined ? { role: input.role } : {}),
|
|
197
|
+
now,
|
|
198
|
+
});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
mapTenantError(err);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
removeMember(tenantId, userId) {
|
|
205
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
206
|
+
const denied = sessionOnly(auth);
|
|
207
|
+
if (denied) throw denied;
|
|
208
|
+
const store = requireStore(options.store);
|
|
209
|
+
if (!isMember(store, tenantId, auth.userId!)) {
|
|
210
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
211
|
+
}
|
|
212
|
+
const row = removeMember(store, tenantId, userId);
|
|
213
|
+
if (!row) {
|
|
214
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
215
|
+
}
|
|
216
|
+
return row;
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
listMembers(tenantId) {
|
|
220
|
+
return gated("read", AUTH_TENANTS_RESOURCE, async () => {
|
|
221
|
+
const denied = sessionOnly(auth);
|
|
222
|
+
if (denied) throw denied;
|
|
223
|
+
const store = requireStore(options.store);
|
|
224
|
+
if (!isMember(store, tenantId, auth.userId!)) {
|
|
225
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
226
|
+
}
|
|
227
|
+
return listMembers(store, tenantId);
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
upsertTenantRole(input) {
|
|
231
|
+
return gated("write", AUTH_TENANTS_RESOURCE, async () => {
|
|
232
|
+
const denied = sessionOnly(auth);
|
|
233
|
+
if (denied) throw denied;
|
|
234
|
+
const store = requireStore(options.store);
|
|
235
|
+
if (!isMember(store, input.tenantId, auth.userId!)) {
|
|
236
|
+
throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
|
|
237
|
+
}
|
|
238
|
+
const catalog = options.manifest ? deriveModuleActions(options.manifest) : [];
|
|
239
|
+
try {
|
|
240
|
+
return upsertTenantRole(store, {
|
|
241
|
+
tenantId: input.tenantId,
|
|
242
|
+
roleName: input.roleName,
|
|
243
|
+
scopes: input.scopes,
|
|
244
|
+
catalog,
|
|
245
|
+
now,
|
|
246
|
+
});
|
|
247
|
+
} catch (err) {
|
|
248
|
+
mapTenantError(err);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return Object.assign(auth, methods);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Compact attach from `createFx` so Store-only graphs do not spell the
|
|
259
|
+
* tenant store / session crypto field names.
|
|
260
|
+
*
|
|
261
|
+
* @param auth - Identity after API-key methods
|
|
262
|
+
* @param options - createFx options bag
|
|
263
|
+
* @param now - Clock
|
|
264
|
+
* @param gated - Capability gate
|
|
265
|
+
*/
|
|
266
|
+
export function bind(
|
|
267
|
+
auth: FxAuthIdentity,
|
|
268
|
+
options: {
|
|
269
|
+
readonly tenantStore?: TenantStore;
|
|
270
|
+
readonly sessions?: SessionStore;
|
|
271
|
+
readonly sessionCrypto?: SessionCrypto;
|
|
272
|
+
readonly manifest?: Manifest;
|
|
273
|
+
},
|
|
274
|
+
now: () => number,
|
|
275
|
+
gated: AttachAuthTenantMethodsOptions["gated"],
|
|
276
|
+
): FxAuthIdentity & FxAuthTenantMethods {
|
|
277
|
+
return attach({
|
|
278
|
+
auth,
|
|
279
|
+
store: options.tenantStore,
|
|
280
|
+
sessions: options.sessions,
|
|
281
|
+
crypto: options.sessionCrypto,
|
|
282
|
+
manifest: options.manifest ?? undefined,
|
|
283
|
+
now,
|
|
284
|
+
gated,
|
|
285
|
+
});
|
|
286
|
+
}
|