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
package/src/kernel/fx.ts
CHANGED
|
@@ -39,6 +39,10 @@ import type { AiRuntime } from "../elements/ai.ts";
|
|
|
39
39
|
import { parseDurationMs } from "../elements/clock/duration.ts";
|
|
40
40
|
import type { ApiKeyStore } from "../auth/api-keys.ts";
|
|
41
41
|
import type { FxAuthIdentity, FxAuthKeyMethods } from "./fx-auth-keys.ts";
|
|
42
|
+
import type { FxAuthTenantMethods } from "./fx-auth-tenants.ts";
|
|
43
|
+
import type { TenantStore } from "../auth/tenants.ts";
|
|
44
|
+
import type { SessionCrypto, SessionStore } from "../auth/sessions.ts";
|
|
45
|
+
import type { Manifest } from "../manifest/types.ts";
|
|
42
46
|
import { createCapabilityToken, type CapabilityToken } from "./capability.ts";
|
|
43
47
|
import { createEffectLedger, recordEffect, reversibilityOf, type EffectLedger } from "./effects.ts";
|
|
44
48
|
import { resolveDurationMs } from "./elapsed.ts";
|
|
@@ -76,6 +80,15 @@ async function loadRunsWindow(): Promise<typeof import("../runs/window.ts")> {
|
|
|
76
80
|
return import("../runs/window.ts");
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Sync-load live SSE + Last-Event-ID resume only when `fx.live` runs.
|
|
85
|
+
* A static import would pin `checkLiveResume` / 410 encoding on every
|
|
86
|
+
* createFx — including Store-only `oke()` graphs.
|
|
87
|
+
*/
|
|
88
|
+
function loadFxLiveStream(): typeof import("./fx-live-stream.ts") {
|
|
89
|
+
return lazyRequire(import.meta.dir, ["fx", "live", "stream"].join("-"));
|
|
90
|
+
}
|
|
91
|
+
|
|
79
92
|
/** Resource ref Flows declare to read the Runs store via {@link Fx.runs}. */
|
|
80
93
|
export const RUNS_RESOURCE = "runs";
|
|
81
94
|
|
|
@@ -87,7 +100,7 @@ export const RUNS_RESOURCE = "runs";
|
|
|
87
100
|
export const AUTH_API_KEYS_RESOURCE = "auth:api-keys";
|
|
88
101
|
|
|
89
102
|
/**
|
|
90
|
-
* Capability ref for {@link Fx.deadLetters} — `signal:<name>`, never a store facet.
|
|
103
|
+
* Capability ref for {@link Fx.deadLetters} / {@link Fx.live} — `signal:<name>`, never a store facet.
|
|
91
104
|
*
|
|
92
105
|
* @param name - Signal name
|
|
93
106
|
*/
|
|
@@ -116,7 +129,7 @@ export function resolveStoreRef(ref: NamedRef | { readonly ref: ResourceRef }):
|
|
|
116
129
|
export type { FxAuthIdentity } from "./fx-auth-keys.ts";
|
|
117
130
|
|
|
118
131
|
/** Auth principal on the user plane. */
|
|
119
|
-
export interface FxAuth extends FxAuthIdentity, FxAuthKeyMethods {}
|
|
132
|
+
export interface FxAuth extends FxAuthIdentity, FxAuthKeyMethods, FxAuthTenantMethods {}
|
|
120
133
|
|
|
121
134
|
/** Operator principal on the Console plane. */
|
|
122
135
|
export interface FxOperator {
|
|
@@ -366,16 +379,46 @@ export interface JsonResult<T = unknown> {
|
|
|
366
379
|
readonly kind?: undefined;
|
|
367
380
|
}
|
|
368
381
|
|
|
369
|
-
/** SSE carrier from {@link FxJson.stream}. */
|
|
382
|
+
/** SSE carrier from {@link FxJson.stream} / {@link Fx.live}. */
|
|
370
383
|
export interface JsonStreamResult {
|
|
371
384
|
readonly [jsonResultBrand]: true;
|
|
372
385
|
readonly kind: "stream";
|
|
373
386
|
readonly status: 200;
|
|
374
|
-
readonly chunks: AsyncIterable<
|
|
387
|
+
readonly chunks: AsyncIterable<unknown>;
|
|
388
|
+
/** Awaited before the 200 SSE body; throws OKE1014 on a missing resume cursor. */
|
|
389
|
+
ready?: () => Promise<void>;
|
|
375
390
|
/** Set by the kernel to commit journal / Runs after the stream settles. */
|
|
376
391
|
finalize?: () => Promise<void>;
|
|
377
392
|
}
|
|
378
393
|
|
|
394
|
+
const sseFrameBrand: unique symbol = Symbol.for("oke.sse.frame");
|
|
395
|
+
|
|
396
|
+
/** One SSE frame — optional `id:` plus JSON `data:`. */
|
|
397
|
+
export interface SseFrame {
|
|
398
|
+
readonly [sseFrameBrand]: true;
|
|
399
|
+
readonly data: unknown;
|
|
400
|
+
readonly id?: string;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Brand a stream chunk so {@link encodeSseStream} can emit `id:`.
|
|
405
|
+
*
|
|
406
|
+
* @param data - JSON payload
|
|
407
|
+
* @param id - Optional SSE id
|
|
408
|
+
*/
|
|
409
|
+
export function sseFrame(data: unknown, id?: string): SseFrame {
|
|
410
|
+
return id !== undefined ? { [sseFrameBrand]: true, data, id } : { [sseFrameBrand]: true, data };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* True when `value` is a branded SSE frame.
|
|
415
|
+
*
|
|
416
|
+
* @param value - Unknown
|
|
417
|
+
*/
|
|
418
|
+
export function isSseFrame(value: unknown): value is SseFrame {
|
|
419
|
+
return typeof value === "object" && value !== null && (value as SseFrame)[sseFrameBrand] === true;
|
|
420
|
+
}
|
|
421
|
+
|
|
379
422
|
/** True when `value` is an {@link FxJson} JSON-envelope carrier. */
|
|
380
423
|
export function isJsonResult(value: unknown): value is JsonResult {
|
|
381
424
|
return (
|
|
@@ -429,9 +472,9 @@ export interface FxJson {
|
|
|
429
472
|
withQuery<T>(rows: readonly T[], input: unknown, spec?: QueryPageSpec<T>): JsonResult<T[]>;
|
|
430
473
|
/**
|
|
431
474
|
* 200 — `text/event-stream` of JSON `data:` frames, then `data: [DONE]`.
|
|
432
|
-
* Pass {@link Fx.stream} or any async iterable of
|
|
475
|
+
* Pass {@link Fx.stream} or any async iterable of chunks.
|
|
433
476
|
*/
|
|
434
|
-
stream(chunks: AsyncIterable<
|
|
477
|
+
stream(chunks: AsyncIterable<unknown>): JsonStreamResult;
|
|
435
478
|
}
|
|
436
479
|
|
|
437
480
|
/**
|
|
@@ -645,6 +688,29 @@ export interface Fx {
|
|
|
645
688
|
*/
|
|
646
689
|
deadLetters<T>(signal: SignalDecl<T>): Promise<readonly DeadLetter<T>[]>;
|
|
647
690
|
deadLetters(signal: NamedRef): Promise<readonly DeadLetter[]>;
|
|
691
|
+
/**
|
|
692
|
+
* Stream a `delivery: "live"` signal as SSE (records `read` on `signal:<name>`).
|
|
693
|
+
*
|
|
694
|
+
* HTTP `Last-Event-ID` is applied when `opts.afterId` is omitted. A missing
|
|
695
|
+
* cursor throws OKE1014; `JsonStreamResult.ready` turns that into HTTP 410.
|
|
696
|
+
*
|
|
697
|
+
* @param signal - Signal name or handle
|
|
698
|
+
* @param opts - Payload filter and optional resume cursor
|
|
699
|
+
*/
|
|
700
|
+
live<T>(
|
|
701
|
+
signal: SignalDecl<T>,
|
|
702
|
+
opts?: {
|
|
703
|
+
readonly match?: (payload: T) => boolean;
|
|
704
|
+
readonly afterId?: string;
|
|
705
|
+
},
|
|
706
|
+
): JsonStreamResult;
|
|
707
|
+
live(
|
|
708
|
+
signal: NamedRef,
|
|
709
|
+
opts?: {
|
|
710
|
+
readonly match?: (payload: unknown) => boolean;
|
|
711
|
+
readonly afterId?: string;
|
|
712
|
+
},
|
|
713
|
+
): JsonStreamResult;
|
|
648
714
|
/**
|
|
649
715
|
* Call another flow (records `call`). Stub returns `undefined`.
|
|
650
716
|
*
|
|
@@ -848,6 +914,23 @@ export interface CreateFxOptions {
|
|
|
848
914
|
readonly auth?: FxAuthIdentity;
|
|
849
915
|
/** Shared API key store for {@link Fx.auth} key methods. */
|
|
850
916
|
readonly apiKeyStore?: ApiKeyStore;
|
|
917
|
+
/** Tenant registry (when `gate.auth.tenant` is on). */
|
|
918
|
+
readonly tenantStore?: TenantStore;
|
|
919
|
+
/** Session store for {@link Fx.auth.switchTenant}. */
|
|
920
|
+
readonly sessions?: SessionStore;
|
|
921
|
+
/** Session crypto for {@link Fx.auth.switchTenant}. */
|
|
922
|
+
readonly sessionCrypto?: SessionCrypto;
|
|
923
|
+
/** Manifest for tenant-role catalog validation. */
|
|
924
|
+
readonly manifest?: Manifest | null;
|
|
925
|
+
/** When true, tenant-scoped KV / vault defaults apply. */
|
|
926
|
+
readonly tenantEnabled?: boolean;
|
|
927
|
+
/**
|
|
928
|
+
* When false, skip tenant-role scope union (tenant-unaware flow).
|
|
929
|
+
* Default true when {@link tenantEnabled}.
|
|
930
|
+
*/
|
|
931
|
+
readonly flowTenantScoped?: boolean;
|
|
932
|
+
/** Current flow plane (tenant-role union is user-plane only). */
|
|
933
|
+
readonly flowPlane?: "user" | "operator";
|
|
851
934
|
/** Operator principal. */
|
|
852
935
|
readonly operator?: FxOperator;
|
|
853
936
|
/**
|
|
@@ -920,6 +1003,8 @@ export interface CreateFxOptions {
|
|
|
920
1003
|
* as `parentRunId` so consuming Flows can join the trace chain.
|
|
921
1004
|
*/
|
|
922
1005
|
readonly runId?: string;
|
|
1006
|
+
/** HTTP `Last-Event-ID` for {@link Fx.live} resume (tests may pass `opts.afterId`). */
|
|
1007
|
+
readonly lastEventId?: string;
|
|
923
1008
|
/** Reveal PII through the store runtime (requires `pii:reveal` upstream). */
|
|
924
1009
|
readonly revealPii?: boolean;
|
|
925
1010
|
/** Trigger gate names for RLS (`oke.gate` = first policy/public). */
|
|
@@ -1011,17 +1096,19 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1011
1096
|
const cacheStore = new Map<string, unknown>();
|
|
1012
1097
|
|
|
1013
1098
|
// Computed stem — a static import would pin HMAC / api-keys on every createFx.
|
|
1014
|
-
const auth:
|
|
1099
|
+
const authBag = options.auth ?? { userId: null, scopes: new Set() };
|
|
1100
|
+
const keys = lazyRequire<typeof import("./fx-auth-keys.ts")>(
|
|
1015
1101
|
import.meta.dir,
|
|
1016
1102
|
["fx", "auth", "keys"].join("-"),
|
|
1017
1103
|
).attach({
|
|
1018
|
-
auth:
|
|
1104
|
+
auth: authBag,
|
|
1019
1105
|
store: options.apiKeyStore,
|
|
1020
1106
|
now,
|
|
1021
1107
|
gated,
|
|
1022
1108
|
});
|
|
1109
|
+
const auth: FxAuth = keys as FxAuth;
|
|
1023
1110
|
const operator: FxOperator = options.operator ?? { id: null };
|
|
1024
|
-
const tenant:
|
|
1111
|
+
const tenant: { id: string | null } = options.tenant ?? { id: null };
|
|
1025
1112
|
const defaultLocale = options.i18n?.defaultLocale ?? "en";
|
|
1026
1113
|
const locale = options.i18n?.locale ?? defaultLocale;
|
|
1027
1114
|
const catalogs = options.i18n?.catalogs ?? {};
|
|
@@ -1057,6 +1144,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1057
1144
|
gateNames: options.rlsGateNames ?? [],
|
|
1058
1145
|
bypass: false,
|
|
1059
1146
|
operator: false,
|
|
1147
|
+
...(options.tenantEnabled === true ? { tenantId: tenant.id } : {}),
|
|
1060
1148
|
});
|
|
1061
1149
|
return identity ? { rls: identity } : {};
|
|
1062
1150
|
}
|
|
@@ -1356,6 +1444,33 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1356
1444
|
} as SqlStoreHandle;
|
|
1357
1445
|
}
|
|
1358
1446
|
|
|
1447
|
+
function loadFxTenantStore(): {
|
|
1448
|
+
kv: (
|
|
1449
|
+
mode: "in" | "out",
|
|
1450
|
+
tenantId: string | null,
|
|
1451
|
+
decl: KvStoreDecl,
|
|
1452
|
+
enabled: boolean,
|
|
1453
|
+
prop: string | symbol,
|
|
1454
|
+
payload: unknown,
|
|
1455
|
+
) => unknown;
|
|
1456
|
+
path: (
|
|
1457
|
+
tenantId: string | null,
|
|
1458
|
+
enabled: boolean,
|
|
1459
|
+
contracts: { get(name: string): { perTenant?: boolean } | undefined } | undefined,
|
|
1460
|
+
name: string,
|
|
1461
|
+
) => string;
|
|
1462
|
+
missingVault: (storagePath: string) => Error;
|
|
1463
|
+
} {
|
|
1464
|
+
return lazyRequire(import.meta.dir, ["fx", "tenant", "store"].join("-"));
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
const tenantOn = options.tenantEnabled === true;
|
|
1468
|
+
|
|
1469
|
+
function tenantVaultPath(name: string): string {
|
|
1470
|
+
if (!tenantOn) return name;
|
|
1471
|
+
return loadFxTenantStore().path(tenant.id, true, options.vaultRuntime?.contracts, name);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1359
1474
|
function storeHandle(ref: SqlStoreDecl): SqlStoreHandle;
|
|
1360
1475
|
function storeHandle(ref: KvStoreDecl): KvStoreFxHandle;
|
|
1361
1476
|
function storeHandle(ref: FilesStoreDecl): FilesStoreFxHandle;
|
|
@@ -1607,7 +1722,19 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1607
1722
|
const vaultSurface: FxVault = {
|
|
1608
1723
|
get(secret) {
|
|
1609
1724
|
const name = resolveName(secret);
|
|
1610
|
-
return gatedSecret(name, () => {
|
|
1725
|
+
return gatedSecret(name, async () => {
|
|
1726
|
+
const path = tenantVaultPath(name);
|
|
1727
|
+
if (path !== name) {
|
|
1728
|
+
if (options.vaultAdapter) {
|
|
1729
|
+
const rec = await options.vaultAdapter.get(path);
|
|
1730
|
+
if (!rec) {
|
|
1731
|
+
throw loadFxTenantStore().missingVault(path);
|
|
1732
|
+
}
|
|
1733
|
+
return new Redacted(rec.value);
|
|
1734
|
+
}
|
|
1735
|
+
const value = secrets[path] ?? secrets[name] ?? `[secret:${path}]`;
|
|
1736
|
+
return new Redacted(value);
|
|
1737
|
+
}
|
|
1611
1738
|
const value = options.vaultRuntime
|
|
1612
1739
|
? options.vaultRuntime.read(name)
|
|
1613
1740
|
: (secrets[name] ?? `[secret:${name}]`);
|
|
@@ -1618,7 +1745,8 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1618
1745
|
const name = resolveName(path);
|
|
1619
1746
|
return gatedSecret(name, async () => {
|
|
1620
1747
|
refuseDryRunVaultWrite("set", name);
|
|
1621
|
-
const
|
|
1748
|
+
const storage = tenantVaultPath(name);
|
|
1749
|
+
const written = await vaultAdapterFor("set").set(storage, value, {
|
|
1622
1750
|
...(setOptions?.ttlMs !== undefined ? { ttlMs: setOptions.ttlMs } : {}),
|
|
1623
1751
|
...(setOptions?.metadata !== undefined ? { metadata: setOptions.metadata } : {}),
|
|
1624
1752
|
actor: vaultActor,
|
|
@@ -1630,7 +1758,8 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1630
1758
|
const name = resolveName(path);
|
|
1631
1759
|
return gatedSecret(name, async () => {
|
|
1632
1760
|
refuseDryRunVaultWrite("rotate", name);
|
|
1633
|
-
const
|
|
1761
|
+
const storage = tenantVaultPath(name);
|
|
1762
|
+
const written = await vaultAdapterFor("rotate").rotate(storage, value, {
|
|
1634
1763
|
actor: vaultActor,
|
|
1635
1764
|
});
|
|
1636
1765
|
return { path: written.path, version: written.version };
|
|
@@ -1640,7 +1769,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1640
1769
|
const name = resolveName(path);
|
|
1641
1770
|
return gatedSecret(name, async () => {
|
|
1642
1771
|
refuseDryRunVaultWrite("delete", name);
|
|
1643
|
-
return vaultAdapterFor("delete").delete(name, { actor: vaultActor });
|
|
1772
|
+
return vaultAdapterFor("delete").delete(tenantVaultPath(name), { actor: vaultActor });
|
|
1644
1773
|
});
|
|
1645
1774
|
},
|
|
1646
1775
|
async list(prefix) {
|
|
@@ -1723,6 +1852,18 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1723
1852
|
return options.signalRuntime.deadLetters(name);
|
|
1724
1853
|
});
|
|
1725
1854
|
},
|
|
1855
|
+
live(
|
|
1856
|
+
signal: NamedRef,
|
|
1857
|
+
opts?: { readonly match?: (payload: unknown) => boolean; readonly afterId?: string },
|
|
1858
|
+
) {
|
|
1859
|
+
return loadFxLiveStream().createLiveStream({
|
|
1860
|
+
name: resolveName(signal),
|
|
1861
|
+
afterId: opts?.afterId ?? options.lastEventId,
|
|
1862
|
+
match: opts?.match,
|
|
1863
|
+
gated,
|
|
1864
|
+
signalRuntime: options.signalRuntime,
|
|
1865
|
+
}) as JsonStreamResult;
|
|
1866
|
+
},
|
|
1726
1867
|
call(flow, input) {
|
|
1727
1868
|
const name = resolveName(flow);
|
|
1728
1869
|
return gated("call", name, async () => {
|
|
@@ -2003,5 +2144,24 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
2003
2144
|
},
|
|
2004
2145
|
};
|
|
2005
2146
|
|
|
2147
|
+
if (options.tenantStore !== undefined || options.tenantEnabled === true) {
|
|
2148
|
+
lazyRequire<{
|
|
2149
|
+
install: (
|
|
2150
|
+
bag: { auth: FxAuthIdentity; store: (ref: never) => object },
|
|
2151
|
+
ctx: {
|
|
2152
|
+
readonly tenantId: string | null;
|
|
2153
|
+
readonly options: typeof options;
|
|
2154
|
+
readonly now: () => number;
|
|
2155
|
+
readonly gated: typeof gated;
|
|
2156
|
+
},
|
|
2157
|
+
) => void;
|
|
2158
|
+
}>(import.meta.dir, ["fx", "tenant", "store"].join("-")).install(fx, {
|
|
2159
|
+
tenantId: tenant.id,
|
|
2160
|
+
options,
|
|
2161
|
+
now,
|
|
2162
|
+
gated,
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2006
2166
|
return { fx, ledger, capability };
|
|
2007
2167
|
}
|
package/src/kernel/hooks.ts
CHANGED
|
@@ -156,7 +156,7 @@ export type PipelineEncoder = (result: {
|
|
|
156
156
|
readonly failure?: FlowFailure | undefined;
|
|
157
157
|
readonly output?: unknown;
|
|
158
158
|
readonly error?: unknown;
|
|
159
|
-
}) => Response
|
|
159
|
+
}) => Response | Promise<Response>;
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
162
|
* Run the seven-stage pipeline around `handler`.
|
|
@@ -234,7 +234,7 @@ export async function runPipeline(
|
|
|
234
234
|
// (short-circuit wins; encoder input mirrors the app layer exactly).
|
|
235
235
|
if (encode !== undefined && ctx.response === undefined) {
|
|
236
236
|
const failure = isFlowFailure(ctx.error) ? ctx.error : undefined;
|
|
237
|
-
ctx.response = encode({ failure, output: ctx.result, error: ctx.error });
|
|
237
|
+
ctx.response = await encode({ failure, output: ctx.result, error: ctx.error });
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
await runStage("onResponse", hooks, ctx, fx);
|
|
@@ -22,37 +22,28 @@ import {
|
|
|
22
22
|
* @param path - Base path (`/notes`)
|
|
23
23
|
* @param ops - The five FlowDefs (usually `resource.all()`)
|
|
24
24
|
* @param gates - Shared gate chain
|
|
25
|
-
* @param isLive - Live on GET list + get
|
|
26
25
|
*/
|
|
27
26
|
export function httpResource<P extends string>(
|
|
28
27
|
path: P,
|
|
29
28
|
ops: ResourceFlowBag,
|
|
30
29
|
gates: readonly GateRef[] = [],
|
|
31
|
-
isLive = false,
|
|
32
30
|
): ResourceMount {
|
|
33
31
|
const id = `${path}/:id` as `${P}/:id`;
|
|
34
|
-
const verb = <M extends HttpMethod>(
|
|
35
|
-
method
|
|
36
|
-
p: P | `${P}/:id`,
|
|
37
|
-
live: boolean,
|
|
38
|
-
): HttpTrigger<M> => createHttpTrigger(method, p, gates, live);
|
|
32
|
+
const verb = <M extends HttpMethod>(method: M, p: P | `${P}/:id`): HttpTrigger<M> =>
|
|
33
|
+
createHttpTrigger(method, p, gates);
|
|
39
34
|
const mount: ResourceMount = {
|
|
40
35
|
[resourceMountBrand]: true,
|
|
41
36
|
gates,
|
|
42
|
-
isLive,
|
|
43
37
|
mounts: [
|
|
44
|
-
{ trigger: verb("GET", path
|
|
45
|
-
{ trigger: verb("POST", path
|
|
46
|
-
{ trigger: verb("GET", id
|
|
47
|
-
{ trigger: verb("PATCH", id
|
|
48
|
-
{ trigger: verb("DELETE", id
|
|
38
|
+
{ trigger: verb("GET", path), flow: ops.list },
|
|
39
|
+
{ trigger: verb("POST", path), flow: ops.create },
|
|
40
|
+
{ trigger: verb("GET", id), flow: ops.get },
|
|
41
|
+
{ trigger: verb("PATCH", id), flow: ops.update },
|
|
42
|
+
{ trigger: verb("DELETE", id), flow: ops.remove },
|
|
49
43
|
],
|
|
50
|
-
gate: createGateAttach((next) => httpResource(path, ops, next
|
|
44
|
+
gate: createGateAttach((next) => httpResource(path, ops, next), gates),
|
|
51
45
|
public() {
|
|
52
|
-
return httpResource(path, ops, [...gates, GATE_PUBLIC_NAME]
|
|
53
|
-
},
|
|
54
|
-
live() {
|
|
55
|
-
return httpResource(path, ops, gates, true);
|
|
46
|
+
return httpResource(path, ops, [...gates, GATE_PUBLIC_NAME]);
|
|
56
47
|
},
|
|
57
48
|
};
|
|
58
49
|
return mount;
|
package/src/kernel/index.ts
CHANGED
|
@@ -78,6 +78,7 @@ export type {
|
|
|
78
78
|
AppFlowRoute,
|
|
79
79
|
AppRouteMap,
|
|
80
80
|
FlowNamespace,
|
|
81
|
+
LiveAppFlowRoute,
|
|
81
82
|
RouteFromFlow,
|
|
82
83
|
RoutesFromAdoptArgs,
|
|
83
84
|
} from "./adopt-routes.ts";
|
|
@@ -359,6 +360,7 @@ export {
|
|
|
359
360
|
type HttpMethod,
|
|
360
361
|
type HttpTrigger,
|
|
361
362
|
type InternalTrigger,
|
|
363
|
+
type LiveHttpTrigger,
|
|
362
364
|
type ResourceFlowBag,
|
|
363
365
|
type ResourceMount,
|
|
364
366
|
type SignalAsTrigger,
|
package/src/kernel/journal.ts
CHANGED
|
@@ -104,6 +104,8 @@ export interface JournalRun {
|
|
|
104
104
|
leaseExpiresAt?: number;
|
|
105
105
|
readonly createdAt: number;
|
|
106
106
|
updatedAt: number;
|
|
107
|
+
/** Isolation context for resume (`fx.tenant`). */
|
|
108
|
+
tenant?: string | null;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
/**
|
|
@@ -366,6 +368,12 @@ export interface JournalSession {
|
|
|
366
368
|
readonly runId: string;
|
|
367
369
|
/** Underlying run snapshot (mutated as entries append). */
|
|
368
370
|
readonly run: JournalRun;
|
|
371
|
+
/**
|
|
372
|
+
* Persist isolation context so resume restamps {@link Fx.tenant}.
|
|
373
|
+
*
|
|
374
|
+
* @param id - Tenant id (null clears)
|
|
375
|
+
*/
|
|
376
|
+
stampTenant(id: string | null): Promise<void>;
|
|
369
377
|
/**
|
|
370
378
|
* Replay or execute a named step. Never re-runs `fn` when already journaled.
|
|
371
379
|
*
|
|
@@ -607,6 +615,10 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
607
615
|
undoStack() {
|
|
608
616
|
return undos;
|
|
609
617
|
},
|
|
618
|
+
async stampTenant(id) {
|
|
619
|
+
run.tenant = id;
|
|
620
|
+
await persist();
|
|
621
|
+
},
|
|
610
622
|
beginRegistrationPass() {
|
|
611
623
|
registrationPass = true;
|
|
612
624
|
},
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live HTTP exposure uniqueness (OKE1013) and GET-only synthesis.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test, beforeEach } from "bun:test";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { gate } from "../elements/gate.ts";
|
|
8
|
+
import { signal, resetSignals } from "../elements/signal/declare.ts";
|
|
9
|
+
import { oke } from "./app.ts";
|
|
10
|
+
import { resetFlowSeq } from "./flow.ts";
|
|
11
|
+
import { on, resetBindings } from "./on.ts";
|
|
12
|
+
import { stampFlowName } from "./stamp-http.ts";
|
|
13
|
+
import { http } from "./triggers.ts";
|
|
14
|
+
|
|
15
|
+
const member = gate.policy("member", ({ auth }) => !!auth.verified);
|
|
16
|
+
const admin = gate.policy("admin", ({ auth }) => !!auth.verified);
|
|
17
|
+
const partner = gate.policy("partner", ({ auth }) => !!auth.verified);
|
|
18
|
+
|
|
19
|
+
const orderStatus = () =>
|
|
20
|
+
signal("order-status", {
|
|
21
|
+
delivery: "live",
|
|
22
|
+
optional: true,
|
|
23
|
+
schema: z.object({
|
|
24
|
+
orderId: z.string(),
|
|
25
|
+
status: z.enum(["placed", "fulfilling", "shipped"]),
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
resetBindings();
|
|
31
|
+
resetFlowSeq();
|
|
32
|
+
resetSignals();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("live HTTP uniqueness", () => {
|
|
36
|
+
test("member :orderId + admin firehose both boot", () => {
|
|
37
|
+
const sig = orderStatus();
|
|
38
|
+
const events = on(http.get("/orders/:orderId/events").gate(member).live(sig));
|
|
39
|
+
const feed = on(http.get("/admin/order-status").gate(admin).live(sig));
|
|
40
|
+
stampFlowName(events, "orders.events");
|
|
41
|
+
stampFlowName(feed, "admin.adminFeed");
|
|
42
|
+
expect(() => oke({ name: "t", autoBoot: false })).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("same match, different gates boot (via disambiguates on the client)", () => {
|
|
46
|
+
const sig = orderStatus();
|
|
47
|
+
const a = on(http.get("/orders/:orderId/events").gate(member).live(sig));
|
|
48
|
+
const b = on(http.get("/partners/:orderId/events").gate(partner).live(sig));
|
|
49
|
+
stampFlowName(a, "orders.events");
|
|
50
|
+
stampFlowName(b, "partners.events");
|
|
51
|
+
expect(() => oke({ name: "t", autoBoot: false })).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("two member firehoses on different paths fail OKE1013", () => {
|
|
55
|
+
const sig = orderStatus();
|
|
56
|
+
const a = on(http.get("/feed-a").gate(member).live(sig));
|
|
57
|
+
const b = on(http.get("/feed-b").gate(member).live(sig));
|
|
58
|
+
stampFlowName(a, "orders.feedA");
|
|
59
|
+
stampFlowName(b, "orders.feedB");
|
|
60
|
+
expect(() => oke({ name: "t", autoBoot: false })).toThrow(/OKE1013/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("duplicate GET path still OKE1011", () => {
|
|
64
|
+
const sig = orderStatus();
|
|
65
|
+
const a = on(http.get("/orders/:orderId/events").gate(member).live(sig));
|
|
66
|
+
const b = on(http.get("/orders/:orderId/events").gate(admin).live(sig));
|
|
67
|
+
stampFlowName(a, "orders.events");
|
|
68
|
+
stampFlowName(b, "admin.events");
|
|
69
|
+
expect(() => oke({ name: "t", autoBoot: false })).toThrow(/OKE1011/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("POST .live(signal) is rejected", () => {
|
|
73
|
+
const sig = orderStatus();
|
|
74
|
+
expect(() => on(http.post("/orders").gate(member).live(sig))).toThrow(
|
|
75
|
+
/live exposure must be GET/,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live HTTP helpers — path params, auto-match, exposure uniqueness, synthesis.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SignalDecl } from "../elements/signal/declare.ts";
|
|
6
|
+
import type { SignalResourceRef } from "../manifest/types.ts";
|
|
7
|
+
import type { Fx } from "./fx.ts";
|
|
8
|
+
import { flow, type AnyFlowDef } from "./flow.ts";
|
|
9
|
+
import type { GateRef, SignalSource } from "./triggers.ts";
|
|
10
|
+
|
|
11
|
+
/** Default GET path for {@link http.live}. */
|
|
12
|
+
export const LIVE_HTTP_PREFIX = "/_oke/live/";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default HTTP path for a live-signal firehose.
|
|
16
|
+
*
|
|
17
|
+
* @param signalName - Signal name
|
|
18
|
+
*/
|
|
19
|
+
export function liveHttpPath(signalName: string): string {
|
|
20
|
+
return `${LIVE_HTTP_PREFIX}${encodeURIComponent(signalName)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `:param` names on an HTTP path template, first occurrence order.
|
|
25
|
+
*
|
|
26
|
+
* @param path - Route path
|
|
27
|
+
*/
|
|
28
|
+
export function httpPathParams(path: string): string[] {
|
|
29
|
+
const names: string[] = [];
|
|
30
|
+
const re = /:([A-Za-z_][\w]*)/g;
|
|
31
|
+
let match: RegExpExecArray | null = re.exec(path);
|
|
32
|
+
while (match) {
|
|
33
|
+
const name = match[1];
|
|
34
|
+
if (name !== undefined && !names.includes(name)) names.push(name);
|
|
35
|
+
match = re.exec(path);
|
|
36
|
+
}
|
|
37
|
+
return names;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sorted auto-match field list joined for uniqueness (`""` = firehose).
|
|
42
|
+
*
|
|
43
|
+
* @param path - HTTP path template
|
|
44
|
+
*/
|
|
45
|
+
export function liveMatchKeyFromPath(path: string): string {
|
|
46
|
+
return httpPathParams(path).toSorted().join(",");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sorted flattened gate names (`public` included).
|
|
51
|
+
*
|
|
52
|
+
* @param gates - Trigger gates
|
|
53
|
+
*/
|
|
54
|
+
export function liveGatesKey(gates: readonly GateRef[]): string {
|
|
55
|
+
return gates
|
|
56
|
+
.map((g) => (typeof g === "string" ? g : g.name))
|
|
57
|
+
.toSorted()
|
|
58
|
+
.join(",");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Boot uniqueness key: `(signalName, gatesKey, matchKey)`.
|
|
63
|
+
*
|
|
64
|
+
* @param signalName - Signal name
|
|
65
|
+
* @param gatesKey - {@link liveGatesKey}
|
|
66
|
+
* @param matchKey - {@link liveMatchKeyFromPath} or `custom:{flow}`
|
|
67
|
+
*/
|
|
68
|
+
export function liveExposureKey(signalName: string, gatesKey: string, matchKey: string): string {
|
|
69
|
+
return `${signalName}\0${gatesKey}\0${matchKey}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Auto-match: payload fields equal same-named input fields.
|
|
74
|
+
*
|
|
75
|
+
* @param payload - Signal payload
|
|
76
|
+
* @param input - HTTP input (path params)
|
|
77
|
+
* @param fields - Field names (path params)
|
|
78
|
+
*/
|
|
79
|
+
export function payloadAutoMatch(
|
|
80
|
+
payload: unknown,
|
|
81
|
+
input: unknown,
|
|
82
|
+
fields: readonly string[],
|
|
83
|
+
): boolean {
|
|
84
|
+
if (fields.length === 0) return true;
|
|
85
|
+
if (payload === null || typeof payload !== "object") return true;
|
|
86
|
+
const row = payload as Record<string, unknown>;
|
|
87
|
+
const params =
|
|
88
|
+
input !== null && typeof input === "object" ? (input as Record<string, unknown>) : {};
|
|
89
|
+
for (const key of fields) {
|
|
90
|
+
if (!(key in row)) continue;
|
|
91
|
+
if (row[key] !== params[key]) return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Synthesize a stream Flow for `on(http.get(path).live(signal))`.
|
|
98
|
+
*
|
|
99
|
+
* @param signal - Live signal handle
|
|
100
|
+
* @param path - HTTP path (auto-match keys)
|
|
101
|
+
*/
|
|
102
|
+
export function synthesizeLiveFlow(signal: SignalSource, path: string): AnyFlowDef {
|
|
103
|
+
const fields = httpPathParams(path);
|
|
104
|
+
const name = signal.name;
|
|
105
|
+
const schema = "schema" in signal ? (signal as SignalDecl).schema : undefined;
|
|
106
|
+
return flow({
|
|
107
|
+
effects: { reads: [`signal:${name}` as SignalResourceRef] },
|
|
108
|
+
...(schema !== undefined ? { out: schema } : {}),
|
|
109
|
+
do: (input, fx: Fx) =>
|
|
110
|
+
fx.live(signal, {
|
|
111
|
+
match: (payload) => payloadAutoMatch(payload, input, fields),
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
}
|