okengine 0.15.2 → 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 +20 -12
- package/site/content/docs/elements/gate.mdx +108 -3
- package/site/content/docs/elements/signal.mdx +46 -17
- package/site/content/docs/elements/store.mdx +36 -32
- package/site/content/docs/elements/vault.mdx +26 -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 +75 -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 +39 -22
- package/site/content/docs/reference/security.md +1 -1
- package/src/auth/api-key-sql.test.ts +47 -0
- package/src/auth/api-key-sql.ts +261 -0
- package/src/auth/api-keys.ts +276 -14
- package/src/auth/auth.test.ts +56 -0
- package/src/auth/config.ts +24 -5
- package/src/auth/index.ts +62 -1
- package/src/auth/plugin.ts +6 -1
- package/src/auth/schema.ts +8 -0
- 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/attach-host-console.test.ts +37 -0
- package/src/cli/attach-host-console.ts +6 -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 +37 -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/access.test.ts +116 -8
- package/src/console/server/access.ts +70 -23
- package/src/console/server/app.ts +45 -11
- package/src/console/server/console.test.ts +6 -8
- package/src/console/server/dev-identities.test.ts +127 -0
- package/src/console/server/dev-identities.ts +229 -0
- package/src/console/server/flows-invoke.test.ts +21 -4
- package/src/console/server/flows.ts +113 -58
- package/src/console/server/gates.test.ts +24 -0
- package/src/console/server/gates.ts +10 -30
- package/src/console/server/index.ts +1 -0
- package/src/console/server/live.test.ts +26 -1
- package/src/console/server/live.ts +4 -2
- package/src/console/server/operator-db.ts +2 -0
- package/src/console/server/state.ts +29 -55
- 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-CXVWWMmD.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
- package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
- package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
- package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
- package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-XDpAJO8f.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
- package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
- package/src/console/ui-next/dist/assets/index-BBj2QJCu.js +66 -0
- package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
- package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
- package/src/console/ui-next/dist/assets/observability-page-BFaay44m.js +4 -0
- package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
- package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
- package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-CS5-aETQ.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BtyhmWb4.js +55 -0
- package/src/console/ui-next/dist/assets/units-page-Ca2Z-E52.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
- package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
- package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-BmOeAFwg.js +2 -0
- package/src/console/ui-next/dist/index.html +11 -9
- package/src/console/ui-next/index.html +3 -3
- package/src/console/ui-next/src/client.ts +167 -0
- package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
- package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
- package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
- package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
- package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
- package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
- package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
- package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
- package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
- package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
- package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
- package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
- package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
- package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
- package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
- package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
- package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
- package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
- package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
- package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
- package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -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/src/lib/document-meta.test.ts +1 -0
- package/src/console/ui-next/src/lib/document-meta.ts +2 -1
- package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
- package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
- package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
- package/src/console/ui-next/src/lib/shortcut.ts +3 -0
- package/src/console/ui-next/src/router.tsx +10 -1
- package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
- 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/console/ui-next/ui-next-seed-store.ts +43 -15
- package/src/console/xss.gate.test.ts +1 -0
- 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/declare.ts +2 -0
- 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/cache.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/runtime.ts +14 -0
- package/src/elements/store/schema-decl.ts +162 -44
- package/src/elements/store/schema-tenant.ts +41 -0
- package/src/elements/store/sql-rls-isolation.test.ts +39 -0
- package/src/elements/store/sql-select.types.test.ts +71 -0
- package/src/elements/store/sql-session.ts +54 -32
- package/src/elements/store.ts +5 -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 +2 -0
- package/src/kernel/adopt-routes.ts +56 -8
- package/src/kernel/api-key-host-persist.test.ts +143 -0
- package/src/kernel/app-tenant.ts +122 -0
- package/src/kernel/app.ts +196 -57
- package/src/kernel/auth-resolve.ts +13 -5
- package/src/kernel/boot-bind/clock.ts +9 -3
- package/src/kernel/boot.test.ts +4 -4
- 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.test.ts +88 -0
- package/src/kernel/fx-auth-keys.ts +203 -0
- 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 +108 -1
- package/src/kernel/fx.ts +206 -28
- 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-api-key.test.ts +192 -7
- package/src/kernel/pipeline-tenant.ts +49 -0
- package/src/kernel/pipeline.test.ts +1 -1
- package/src/kernel/pipeline.ts +41 -4
- 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 +58 -6
- package/src/release/build-lib.ts +14 -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/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
- package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
- package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
- package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
- package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
- package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
- package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
- package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
- package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
- package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
- package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
package/src/kernel/pipeline.ts
CHANGED
|
@@ -10,11 +10,14 @@
|
|
|
10
10
|
* Gate denial is a typed error value, never a thrown exception.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { ResolvedTenantAuth } from "../auth/tenant-config.ts";
|
|
14
|
+
import type { TenantStore } from "../auth/tenants.ts";
|
|
13
15
|
import type { GateEvaluation, GateRuntime } from "../elements/gate.ts";
|
|
14
16
|
import type { GatePolicyContext } from "../elements/gate/declare.ts";
|
|
15
17
|
import { fail, type FlowFailure } from "./errors.ts";
|
|
16
18
|
import type { Fx, FxAuth, FxOperator } from "./fx.ts";
|
|
17
19
|
import type { HookFn, InvocationContext } from "./hooks.ts";
|
|
20
|
+
import { lazyRequire } from "./lazy-require.ts";
|
|
18
21
|
import type { RunTelemetry } from "./run-telemetry.ts";
|
|
19
22
|
import type { HttpTrigger, Trigger } from "./triggers.ts";
|
|
20
23
|
|
|
@@ -23,11 +26,17 @@ export interface PrincipalBag {
|
|
|
23
26
|
readonly auth: {
|
|
24
27
|
userId: string | null;
|
|
25
28
|
scopes: Set<string>;
|
|
29
|
+
/** JWT / session scopes — never mutated by tenant-role union. */
|
|
30
|
+
sessionScopes?: Set<string>;
|
|
26
31
|
verified?: boolean;
|
|
32
|
+
apiKeyId?: string | null;
|
|
27
33
|
};
|
|
28
34
|
readonly operator: {
|
|
29
35
|
id: string | null;
|
|
30
36
|
};
|
|
37
|
+
readonly tenant: {
|
|
38
|
+
id: string | null;
|
|
39
|
+
};
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
/** Resolved identity from auth middleware / test harness. */
|
|
@@ -39,6 +48,8 @@ export interface ResolvedPrincipal {
|
|
|
39
48
|
readonly verified?: boolean;
|
|
40
49
|
/** Authenticating API key id when Bearer was a key secret. */
|
|
41
50
|
readonly apiKeyId?: string;
|
|
51
|
+
/** Signed `tid` / API-key tenant claim (tier 1). */
|
|
52
|
+
readonly tenantId?: string | null;
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
/** Dependencies for {@link createElementPipelineHooks}. */
|
|
@@ -53,7 +64,7 @@ export interface PipelineDeps {
|
|
|
53
64
|
* @returns Principal on success
|
|
54
65
|
* @throws On forge / expiry / revoke — pipeline maps to `Unauthorized`
|
|
55
66
|
*/
|
|
56
|
-
readonly verifyBearer?: (token: string) => Promise<ResolvedPrincipal>;
|
|
67
|
+
readonly verifyBearer?: (token: string, request?: Request) => Promise<ResolvedPrincipal>;
|
|
57
68
|
/**
|
|
58
69
|
* Optional token extraction (e.g. cookie → Bearer when cookies enabled).
|
|
59
70
|
* When omitted, only `Authorization: Bearer` is read.
|
|
@@ -76,6 +87,20 @@ export interface PipelineDeps {
|
|
|
76
87
|
readonly principals: PrincipalBag;
|
|
77
88
|
/** Telemetry collector for the current run (gates dimension). */
|
|
78
89
|
readonly telemetry: RunTelemetry;
|
|
90
|
+
/**
|
|
91
|
+
* When set, resolve `fx.tenant` after the principal and optionally union
|
|
92
|
+
* tenant-role scopes into the live auth bag.
|
|
93
|
+
*/
|
|
94
|
+
readonly tenant?: PipelineTenantDeps;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Tenant identity + conditional scope union (user-plane tenant-scoped flows). */
|
|
98
|
+
export interface PipelineTenantDeps {
|
|
99
|
+
readonly config: ResolvedTenantAuth;
|
|
100
|
+
readonly store: TenantStore;
|
|
101
|
+
/** Default true when tenancy is on; `flow({ tenantScoped: false })` opts out. */
|
|
102
|
+
readonly flowTenantScoped: boolean;
|
|
103
|
+
readonly flowPlane?: "user" | "operator";
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
/**
|
|
@@ -152,10 +177,14 @@ export function applyPrincipal(bag: PrincipalBag, resolved: ResolvedPrincipal |
|
|
|
152
177
|
if (resolved.userId !== undefined) bag.auth.userId = resolved.userId;
|
|
153
178
|
if (resolved.scopes !== undefined) {
|
|
154
179
|
bag.auth.scopes.clear();
|
|
155
|
-
for (const s of resolved.scopes)
|
|
180
|
+
for (const s of resolved.scopes) {
|
|
181
|
+
bag.auth.scopes.add(s);
|
|
182
|
+
}
|
|
156
183
|
}
|
|
157
184
|
if (resolved.verified !== undefined) bag.auth.verified = resolved.verified;
|
|
185
|
+
bag.auth.apiKeyId = resolved.apiKeyId ?? null;
|
|
158
186
|
}
|
|
187
|
+
if (resolved.tenantId !== undefined) bag.tenant.id = resolved.tenantId;
|
|
159
188
|
}
|
|
160
189
|
|
|
161
190
|
/**
|
|
@@ -192,12 +221,12 @@ export function createElementPipelineHooks(deps: PipelineDeps): {
|
|
|
192
221
|
return fail("Unauthorized", {});
|
|
193
222
|
}
|
|
194
223
|
try {
|
|
195
|
-
const principal = await deps.verifyBearer(token);
|
|
224
|
+
const principal = await deps.verifyBearer(token, ctx.request);
|
|
196
225
|
applyPrincipal(deps.principals, principal);
|
|
197
226
|
if (principal.apiKeyId) {
|
|
198
227
|
deps.telemetry.dimensions.api_key = principal.apiKeyId;
|
|
199
228
|
}
|
|
200
|
-
return;
|
|
229
|
+
return applyTenant(deps, ctx);
|
|
201
230
|
} catch {
|
|
202
231
|
// Forge / expiry / revoke → typed Unauthorized (never throw).
|
|
203
232
|
return fail("Unauthorized", {});
|
|
@@ -209,6 +238,7 @@ export function createElementPipelineHooks(deps: PipelineDeps): {
|
|
|
209
238
|
const fromState = ctx.state.principal as ResolvedPrincipal | undefined;
|
|
210
239
|
if (fromState) applyPrincipal(deps.principals, fromState);
|
|
211
240
|
}
|
|
241
|
+
return applyTenant(deps, ctx);
|
|
212
242
|
};
|
|
213
243
|
|
|
214
244
|
const beforeHandle: HookFn = async (ctx, fxOrErr) => {
|
|
@@ -233,6 +263,13 @@ export function createElementPipelineHooks(deps: PipelineDeps): {
|
|
|
233
263
|
return { onAuth, beforeHandle };
|
|
234
264
|
}
|
|
235
265
|
|
|
266
|
+
function applyTenant(deps: PipelineDeps, ctx: InvocationContext): FlowFailure | undefined {
|
|
267
|
+
if (!deps.tenant) return undefined;
|
|
268
|
+
return lazyRequire<{
|
|
269
|
+
run: (d: PipelineDeps, c: InvocationContext) => FlowFailure | undefined;
|
|
270
|
+
}>(import.meta.dir, ["pipeline", "tenant"].join("-")).run(deps, ctx);
|
|
271
|
+
}
|
|
272
|
+
|
|
236
273
|
/**
|
|
237
274
|
* Build a policy context from the live `fx` principals.
|
|
238
275
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `http.resource(path, ops).gate(...)
|
|
2
|
+
* `http.resource(path, ops).gate(...)` — same chain as `http.get`.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
@@ -28,21 +28,21 @@ function httpOf(path: string, method: string): HttpTrigger | undefined {
|
|
|
28
28
|
return hit?.trigger.kind === "http" ? hit.trigger : undefined;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
describe("http.resource — gate
|
|
32
|
-
test("bare mount has no gates and is not live", () => {
|
|
31
|
+
describe("http.resource — gate", () => {
|
|
32
|
+
test("bare mount has no gates and is not a live exposure", () => {
|
|
33
33
|
resetBindings();
|
|
34
34
|
resetFlowSeq();
|
|
35
35
|
on(http.resource("/notes", bag()));
|
|
36
36
|
const list = httpOf("/notes", "GET");
|
|
37
37
|
expect(list?.gates).toEqual([]);
|
|
38
|
-
expect(list?.
|
|
39
|
-
expect(httpOf("/notes", "POST")?.
|
|
38
|
+
expect(list?.liveSignal).toBeUndefined();
|
|
39
|
+
expect(httpOf("/notes", "POST")?.liveSignal).toBeUndefined();
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
test(".gate(member)
|
|
42
|
+
test(".gate(member) stamps every verb", () => {
|
|
43
43
|
resetBindings();
|
|
44
44
|
resetFlowSeq();
|
|
45
|
-
on(http.resource("/notes", bag()).gate(member)
|
|
45
|
+
on(http.resource("/notes", bag()).gate(member));
|
|
46
46
|
|
|
47
47
|
const list = httpOf("/notes", "GET");
|
|
48
48
|
const create = httpOf("/notes", "POST");
|
|
@@ -55,35 +55,18 @@ describe("http.resource — gate / live", () => {
|
|
|
55
55
|
expect(get?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(["member"]);
|
|
56
56
|
expect(update?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(["member"]);
|
|
57
57
|
expect(remove?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(["member"]);
|
|
58
|
-
|
|
59
|
-
expect(list?.isLive).toBe(true);
|
|
60
|
-
expect(get?.isLive).toBe(true);
|
|
61
|
-
expect(create?.isLive).toBe(false);
|
|
62
|
-
expect(update?.isLive).toBe(false);
|
|
63
|
-
expect(remove?.isLive).toBe(false);
|
|
58
|
+
expect(list?.liveSignal).toBeUndefined();
|
|
64
59
|
});
|
|
65
60
|
|
|
66
|
-
test(".public()
|
|
61
|
+
test(".public() stamps the sentinel on every verb", () => {
|
|
67
62
|
resetBindings();
|
|
68
63
|
resetFlowSeq();
|
|
69
|
-
on(http.resource("/notes", bag()).public()
|
|
64
|
+
on(http.resource("/notes", bag()).public());
|
|
70
65
|
expect(httpOf("/notes", "GET")?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(
|
|
71
66
|
["public"],
|
|
72
67
|
);
|
|
73
68
|
expect(
|
|
74
69
|
httpOf("/notes", "POST")?.gates.map((g) => (typeof g === "string" ? g : g.name)),
|
|
75
70
|
).toEqual(["public"]);
|
|
76
|
-
expect(httpOf("/notes", "GET")?.isLive).toBe(true);
|
|
77
|
-
expect(httpOf("/notes", "POST")?.isLive).toBe(false);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test(".live().gate(member) order does not matter", () => {
|
|
81
|
-
resetBindings();
|
|
82
|
-
resetFlowSeq();
|
|
83
|
-
on(http.resource("/notes", bag()).live().gate(member));
|
|
84
|
-
expect(httpOf("/notes", "GET")?.isLive).toBe(true);
|
|
85
|
-
expect(httpOf("/notes", "GET")?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(
|
|
86
|
-
["member"],
|
|
87
|
-
);
|
|
88
71
|
});
|
|
89
72
|
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-tier tenant identity resolution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { createTenant, createTenantStore } from "../auth/tenants.ts";
|
|
7
|
+
import { resolveTenantAuth } from "../auth/tenant-config.ts";
|
|
8
|
+
import { isFlowFailure } from "./hooks.ts";
|
|
9
|
+
import { resolveRequestTenant } from "./tenant-resolve.ts";
|
|
10
|
+
|
|
11
|
+
describe("resolveRequestTenant", () => {
|
|
12
|
+
test("claim trusts tid without a membership query", async () => {
|
|
13
|
+
const store = createTenantStore();
|
|
14
|
+
const result = resolveRequestTenant({
|
|
15
|
+
config: resolveTenantAuth(true),
|
|
16
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
17
|
+
claimTenantId: "not-a-member",
|
|
18
|
+
store,
|
|
19
|
+
});
|
|
20
|
+
expect(result.id).toBe("not-a-member");
|
|
21
|
+
expect(result.failure).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("header never trusts a non-member", async () => {
|
|
25
|
+
const store = createTenantStore();
|
|
26
|
+
await createTenant(store, { name: "Acme", createdBy: "owner", id: "acme" });
|
|
27
|
+
const result = resolveRequestTenant({
|
|
28
|
+
config: resolveTenantAuth({ source: "header" }),
|
|
29
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
30
|
+
claimTenantId: null,
|
|
31
|
+
request: new Request("https://app.example/x", { headers: { "x-oke-tenant": "acme" } }),
|
|
32
|
+
store,
|
|
33
|
+
});
|
|
34
|
+
expect(result.id).toBeNull();
|
|
35
|
+
expect(result.failure && isFlowFailure(result.failure)).toBe(true);
|
|
36
|
+
if (result.failure && isFlowFailure(result.failure)) {
|
|
37
|
+
expect(result.failure.error.code).toBe("Forbidden");
|
|
38
|
+
expect(result.failure.error.data).toEqual({
|
|
39
|
+
gate: "auth:tenants",
|
|
40
|
+
reason: "not_member",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("header accepts a real membership", async () => {
|
|
46
|
+
const store = createTenantStore();
|
|
47
|
+
await createTenant(store, { name: "Acme", createdBy: "u1", id: "acme" });
|
|
48
|
+
const result = resolveRequestTenant({
|
|
49
|
+
config: resolveTenantAuth({ source: "header" }),
|
|
50
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
51
|
+
claimTenantId: null,
|
|
52
|
+
request: new Request("https://app.example/x", { headers: { "x-oke-tenant": "acme" } }),
|
|
53
|
+
store,
|
|
54
|
+
});
|
|
55
|
+
expect(result.id).toBe("acme");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("required + authenticated + no tenant is Forbidden tenant_required", () => {
|
|
59
|
+
const store = createTenantStore();
|
|
60
|
+
const result = resolveRequestTenant({
|
|
61
|
+
config: resolveTenantAuth({ required: true }),
|
|
62
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
63
|
+
claimTenantId: null,
|
|
64
|
+
store,
|
|
65
|
+
});
|
|
66
|
+
expect(result.failure && isFlowFailure(result.failure)).toBe(true);
|
|
67
|
+
if (result.failure && isFlowFailure(result.failure)) {
|
|
68
|
+
expect(result.failure.error.data).toEqual({
|
|
69
|
+
gate: "auth:tenants",
|
|
70
|
+
reason: "tenant_required",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("resolve callback still checks membership unless authoritative", async () => {
|
|
76
|
+
const store = createTenantStore();
|
|
77
|
+
const denied = resolveRequestTenant({
|
|
78
|
+
config: resolveTenantAuth({
|
|
79
|
+
source: "resolve",
|
|
80
|
+
resolve: () => "ghost",
|
|
81
|
+
}),
|
|
82
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
83
|
+
claimTenantId: null,
|
|
84
|
+
store,
|
|
85
|
+
});
|
|
86
|
+
expect(denied.failure && isFlowFailure(denied.failure)).toBe(true);
|
|
87
|
+
|
|
88
|
+
await createTenant(store, { name: "Acme", createdBy: "u1", id: "acme" });
|
|
89
|
+
const trusted = resolveRequestTenant({
|
|
90
|
+
config: resolveTenantAuth({
|
|
91
|
+
source: "resolve",
|
|
92
|
+
authoritative: true,
|
|
93
|
+
resolve: () => "ghost",
|
|
94
|
+
}),
|
|
95
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
96
|
+
claimTenantId: null,
|
|
97
|
+
store,
|
|
98
|
+
});
|
|
99
|
+
expect(trusted.id).toBe("ghost");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request tenant identity resolution (three tiers).
|
|
3
|
+
*
|
|
4
|
+
* Tenant is an identity dimension like `fx.auth` — not a second authz system.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isMember, type TenantStore } from "../auth/tenants.ts";
|
|
8
|
+
import {
|
|
9
|
+
type ResolvedTenantAuth,
|
|
10
|
+
type TenantAuthOptions,
|
|
11
|
+
type TenantSource,
|
|
12
|
+
} from "../auth/tenant-config.ts";
|
|
13
|
+
import { fail, type FlowFailure } from "./errors.ts";
|
|
14
|
+
import type { FxAuthIdentity } from "./fx-auth-keys.ts";
|
|
15
|
+
|
|
16
|
+
export type { ResolvedTenantAuth, TenantAuthOptions, TenantSource };
|
|
17
|
+
|
|
18
|
+
/** Result of {@link resolveRequestTenant}. */
|
|
19
|
+
export interface RequestTenantResult {
|
|
20
|
+
readonly id: string | null;
|
|
21
|
+
readonly failure?: FlowFailure;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Options for {@link resolveRequestTenant}. */
|
|
25
|
+
export interface ResolveRequestTenantOptions {
|
|
26
|
+
readonly config: ResolvedTenantAuth;
|
|
27
|
+
readonly auth: FxAuthIdentity;
|
|
28
|
+
readonly claimTenantId: string | null;
|
|
29
|
+
readonly request?: Request;
|
|
30
|
+
readonly store: TenantStore;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve tenant id for this request.
|
|
35
|
+
*
|
|
36
|
+
* Tier 1 (`claim`): signed `tid` / API-key `tenantId` — no membership query.
|
|
37
|
+
* Tier 2 (`header` / `subdomain`): client-supplied — membership required.
|
|
38
|
+
* Tier 3 (`resolve`): callback; membership required unless `authoritative`.
|
|
39
|
+
*
|
|
40
|
+
* @param options - Config + live identity + request
|
|
41
|
+
*/
|
|
42
|
+
export function resolveRequestTenant(options: ResolveRequestTenantOptions): RequestTenantResult {
|
|
43
|
+
const { config, auth, claimTenantId, request, store } = options;
|
|
44
|
+
const userId = auth.userId;
|
|
45
|
+
const supplied = sourceValue(config, claimTenantId, request, auth);
|
|
46
|
+
|
|
47
|
+
if (supplied === null || supplied === "") {
|
|
48
|
+
if (config.required && userId) {
|
|
49
|
+
return {
|
|
50
|
+
id: null,
|
|
51
|
+
failure: fail("Forbidden", { gate: "auth:tenants", reason: "tenant_required" }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { id: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (config.source === "claim") {
|
|
58
|
+
return { id: supplied };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (config.source === "resolve" && config.authoritative) {
|
|
62
|
+
return { id: supplied };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!userId) {
|
|
66
|
+
return {
|
|
67
|
+
id: null,
|
|
68
|
+
failure: fail("Unauthorized", {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (!isMember(store, supplied, userId)) {
|
|
72
|
+
return {
|
|
73
|
+
id: null,
|
|
74
|
+
failure: fail("Forbidden", { gate: "auth:tenants", reason: "not_member" }),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { id: supplied };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sourceValue(
|
|
81
|
+
config: ResolvedTenantAuth,
|
|
82
|
+
claimTenantId: string | null,
|
|
83
|
+
request: Request | undefined,
|
|
84
|
+
auth: FxAuthIdentity,
|
|
85
|
+
): string | null {
|
|
86
|
+
if (config.source === "claim") {
|
|
87
|
+
return claimTenantId;
|
|
88
|
+
}
|
|
89
|
+
// Internal / cron / fx.call have no HTTP request — keep the stamped claim.
|
|
90
|
+
if (!request && (config.source === "header" || config.source === "subdomain")) {
|
|
91
|
+
return claimTenantId;
|
|
92
|
+
}
|
|
93
|
+
if (config.source === "header") {
|
|
94
|
+
const raw = request?.headers.get(config.header)?.trim();
|
|
95
|
+
return raw && raw.length > 0 ? raw : null;
|
|
96
|
+
}
|
|
97
|
+
if (config.source === "subdomain") {
|
|
98
|
+
return subdomainLabel(request);
|
|
99
|
+
}
|
|
100
|
+
if (config.source === "resolve" && config.resolve) {
|
|
101
|
+
const out = config.resolve({
|
|
102
|
+
auth: { userId: auth.userId, tenantId: claimTenantId },
|
|
103
|
+
request,
|
|
104
|
+
claimTenantId,
|
|
105
|
+
});
|
|
106
|
+
return out ?? null;
|
|
107
|
+
}
|
|
108
|
+
return claimTenantId;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* First Host label as a tenant slug/id (`acme.example.com` → `acme`).
|
|
113
|
+
*
|
|
114
|
+
* @param request - Incoming request
|
|
115
|
+
*/
|
|
116
|
+
export function subdomainLabel(request: Request | undefined): string | null {
|
|
117
|
+
const host = request?.headers.get("host")?.split(":")[0]?.trim().toLowerCase();
|
|
118
|
+
if (!host) return null;
|
|
119
|
+
const parts = host.split(".");
|
|
120
|
+
if (parts.length < 3) return null;
|
|
121
|
+
const label = parts[0];
|
|
122
|
+
if (!label || label === "www") return null;
|
|
123
|
+
return label;
|
|
124
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-role scope union is conditional — never JWT-melt, never on
|
|
3
|
+
* `tenantScoped: false` flows.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { createTenant, createTenantStore, upsertTenantRole } from "../auth/tenants.ts";
|
|
8
|
+
import { gate } from "../elements/gate.ts";
|
|
9
|
+
import { oke } from "./app.ts";
|
|
10
|
+
import { flow, resetFlowSeq } from "./flow.ts";
|
|
11
|
+
import { isFlowFailure } from "./hooks.ts";
|
|
12
|
+
import { on, resetBindings } from "./on.ts";
|
|
13
|
+
import { http } from "./triggers.ts";
|
|
14
|
+
|
|
15
|
+
const SECRET = "tenant-roles-test-secret";
|
|
16
|
+
|
|
17
|
+
describe("tenant-role scope union", () => {
|
|
18
|
+
test("tenant-granted scope does not authorize a tenantScoped: false route", async () => {
|
|
19
|
+
resetBindings();
|
|
20
|
+
resetFlowSeq();
|
|
21
|
+
const member = gate.policy("member", ({ auth }) => !!auth.verified);
|
|
22
|
+
const booking = gate.scope("booking:create");
|
|
23
|
+
const tenantStore = createTenantStore();
|
|
24
|
+
await createTenant(tenantStore, { name: "Acme", createdBy: "u1", id: "t1" });
|
|
25
|
+
upsertTenantRole(tenantStore, {
|
|
26
|
+
tenantId: "t1",
|
|
27
|
+
roleName: "member",
|
|
28
|
+
scopes: ["booking:create"],
|
|
29
|
+
catalog: ["booking:create", "member"],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
on(
|
|
33
|
+
http.get("/global").gate(booking),
|
|
34
|
+
flow("global.ping", {
|
|
35
|
+
tenantScoped: false,
|
|
36
|
+
do: () => "global",
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
on(
|
|
40
|
+
http.get("/tenant").gate(booking),
|
|
41
|
+
flow("tenant.ping", {
|
|
42
|
+
do: () => "tenant",
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const app = oke({
|
|
47
|
+
name: "tenant-roles",
|
|
48
|
+
env: "test",
|
|
49
|
+
startScheduler: false,
|
|
50
|
+
gate: {
|
|
51
|
+
policies: [member, booking],
|
|
52
|
+
auth: { secret: SECRET, tenant: true, tenantStore, http: false },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
await app.boot({ env: "test", startScheduler: false });
|
|
56
|
+
|
|
57
|
+
const principal = {
|
|
58
|
+
plane: "user" as const,
|
|
59
|
+
userId: "u1",
|
|
60
|
+
scopes: [] as string[],
|
|
61
|
+
verified: true,
|
|
62
|
+
tenantId: "t1",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const leak = await app.execute(
|
|
66
|
+
app.flow("global.ping")!,
|
|
67
|
+
undefined,
|
|
68
|
+
http.get("/global").gate(booking),
|
|
69
|
+
{ principal },
|
|
70
|
+
);
|
|
71
|
+
expect(leak.failure && isFlowFailure(leak.failure)).toBe(true);
|
|
72
|
+
if (leak.failure && isFlowFailure(leak.failure)) {
|
|
73
|
+
expect(leak.failure.error.code).toBe("Forbidden");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const allowed = await app.execute(
|
|
77
|
+
app.flow("tenant.ping")!,
|
|
78
|
+
undefined,
|
|
79
|
+
http.get("/tenant").gate(booking),
|
|
80
|
+
{ principal },
|
|
81
|
+
);
|
|
82
|
+
expect(allowed.failure).toBeUndefined();
|
|
83
|
+
expect(allowed.output).toBe("tenant");
|
|
84
|
+
|
|
85
|
+
await app.bootResult?.close();
|
|
86
|
+
});
|
|
87
|
+
});
|
package/src/kernel/triggers.ts
CHANGED
|
@@ -36,8 +36,10 @@ export interface HttpTrigger<M extends HttpMethod = HttpMethod, P extends string
|
|
|
36
36
|
readonly method: M;
|
|
37
37
|
readonly path: P;
|
|
38
38
|
readonly gates: readonly GateRef[];
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Signal this GET exposes as a live SSE feed (`undefined` when not a live route).
|
|
41
|
+
*/
|
|
42
|
+
readonly liveSignal?: SignalSource;
|
|
41
43
|
/**
|
|
42
44
|
* Attach gates (registration order). `gate.all` handles and arrays flatten.
|
|
43
45
|
*/
|
|
@@ -47,9 +49,29 @@ export interface HttpTrigger<M extends HttpMethod = HttpMethod, P extends string
|
|
|
47
49
|
*/
|
|
48
50
|
public(): HttpTrigger<M, P>;
|
|
49
51
|
/**
|
|
50
|
-
*
|
|
52
|
+
* Expose a `delivery: "live"` signal as SSE on this GET.
|
|
53
|
+
*
|
|
54
|
+
* @param signal - Live signal handle
|
|
51
55
|
*/
|
|
52
|
-
live():
|
|
56
|
+
live(signal: SignalSource): LiveHttpTrigger<M, P>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* HTTP GET trigger that exposes a live signal (`on(trigger)` synthesizes the Flow).
|
|
61
|
+
*
|
|
62
|
+
* `.gate` / `.public` keep {@link LiveHttpTrigger.liveSignal} so
|
|
63
|
+
* `on(http.live(signal).gate(member))` typechecks.
|
|
64
|
+
*
|
|
65
|
+
* @typeParam M - HTTP method literal
|
|
66
|
+
* @typeParam P - Path template literal
|
|
67
|
+
*/
|
|
68
|
+
export interface LiveHttpTrigger<
|
|
69
|
+
M extends HttpMethod = "GET",
|
|
70
|
+
P extends string = string,
|
|
71
|
+
> extends HttpTrigger<M, P> {
|
|
72
|
+
readonly liveSignal: SignalSource;
|
|
73
|
+
readonly gate: GateAttach<LiveHttpTrigger<M, P>>;
|
|
74
|
+
public(): LiveHttpTrigger<M, P>;
|
|
53
75
|
}
|
|
54
76
|
|
|
55
77
|
/** Clock / interval trigger (`every("1h")`). */
|
|
@@ -105,31 +127,43 @@ export function createGateAttach<T>(
|
|
|
105
127
|
}
|
|
106
128
|
|
|
107
129
|
/**
|
|
108
|
-
* Build an HTTP trigger with `.gate` / `.public` / `.live`.
|
|
130
|
+
* Build an HTTP trigger with `.gate` / `.public` / `.live(signal)`.
|
|
109
131
|
*
|
|
110
132
|
* @param method - HTTP verb
|
|
111
133
|
* @param path - Route path
|
|
112
134
|
* @param gates - Attached gate refs
|
|
113
|
-
* @param
|
|
135
|
+
* @param liveSignal - Live signal when `.live(signal)` was applied
|
|
114
136
|
*/
|
|
137
|
+
export function createHttpTrigger<M extends HttpMethod, P extends string>(
|
|
138
|
+
method: M,
|
|
139
|
+
path: P,
|
|
140
|
+
gates: readonly GateRef[],
|
|
141
|
+
liveSignal: SignalSource,
|
|
142
|
+
): LiveHttpTrigger<M, P>;
|
|
143
|
+
export function createHttpTrigger<M extends HttpMethod, P extends string>(
|
|
144
|
+
method: M,
|
|
145
|
+
path: P,
|
|
146
|
+
gates?: readonly GateRef[],
|
|
147
|
+
liveSignal?: SignalSource,
|
|
148
|
+
): HttpTrigger<M, P>;
|
|
115
149
|
export function createHttpTrigger<M extends HttpMethod, P extends string>(
|
|
116
150
|
method: M,
|
|
117
151
|
path: P,
|
|
118
152
|
gates: readonly GateRef[] = [],
|
|
119
|
-
|
|
153
|
+
liveSignal?: SignalSource,
|
|
120
154
|
): HttpTrigger<M, P> {
|
|
121
155
|
const trigger: HttpTrigger<M, P> = {
|
|
122
156
|
kind: "http",
|
|
123
157
|
method,
|
|
124
158
|
path,
|
|
125
159
|
gates,
|
|
126
|
-
|
|
127
|
-
gate: createGateAttach((next) => createHttpTrigger(method, path, next,
|
|
160
|
+
...(liveSignal !== undefined ? { liveSignal } : {}),
|
|
161
|
+
gate: createGateAttach((next) => createHttpTrigger(method, path, next, liveSignal), gates),
|
|
128
162
|
public() {
|
|
129
|
-
return createHttpTrigger(method, path, [...gates, GATE_PUBLIC_NAME],
|
|
163
|
+
return createHttpTrigger(method, path, [...gates, GATE_PUBLIC_NAME], liveSignal);
|
|
130
164
|
},
|
|
131
|
-
live() {
|
|
132
|
-
return createHttpTrigger(method, path, gates,
|
|
165
|
+
live(signal: SignalSource) {
|
|
166
|
+
return createHttpTrigger(method, path, gates, signal);
|
|
133
167
|
},
|
|
134
168
|
};
|
|
135
169
|
return trigger;
|
|
@@ -150,14 +184,12 @@ export interface ResourceFlowBag {
|
|
|
150
184
|
/**
|
|
151
185
|
* A mounted resource — the single argument to the `on(http.resource(…))`
|
|
152
186
|
* overload. Branded so `on` can tell it apart from a plain trigger.
|
|
153
|
-
* Same chain as {@link HttpTrigger}: `.gate(...)` / `.public()
|
|
187
|
+
* Same chain as {@link HttpTrigger}: `.gate(...)` / `.public()`.
|
|
154
188
|
*/
|
|
155
189
|
export interface ResourceMount {
|
|
156
190
|
readonly [resourceMountBrand]: true;
|
|
157
191
|
readonly mounts: ReadonlyArray<{ readonly trigger: HttpTrigger; readonly flow: unknown }>;
|
|
158
192
|
readonly gates: readonly GateRef[];
|
|
159
|
-
/** Whether `.live()` was applied (GET list + get). */
|
|
160
|
-
readonly isLive: boolean;
|
|
161
193
|
/**
|
|
162
194
|
* Attach gates to every verb (registration order). `gate.all` and arrays flatten.
|
|
163
195
|
*/
|
|
@@ -166,10 +198,6 @@ export interface ResourceMount {
|
|
|
166
198
|
* Attach the unauthenticated public sentinel to every verb.
|
|
167
199
|
*/
|
|
168
200
|
public(): ResourceMount;
|
|
169
|
-
/**
|
|
170
|
-
* Mark list and get as live (GET mounts). Mutations stay request/response.
|
|
171
|
-
*/
|
|
172
|
-
live(): ResourceMount;
|
|
173
201
|
}
|
|
174
202
|
/** Brand for {@link ResourceMount}. */
|
|
175
203
|
export const resourceMountBrand: unique symbol = Symbol.for("oke.resource.mount");
|
|
@@ -254,10 +282,16 @@ export interface HttpTriggerNamespace {
|
|
|
254
282
|
query<P extends string>(path: P): HttpTrigger<"QUERY", P>;
|
|
255
283
|
/**
|
|
256
284
|
* Mount a CRUD resource (list/create on `path`, get/update/remove on
|
|
257
|
-
* `path/:id`) for the `on(http.resource(…))` overload. Chain `.gate(...)
|
|
258
|
-
*
|
|
285
|
+
* `path/:id`) for the `on(http.resource(…))` overload. Chain `.gate(...)`
|
|
286
|
+
* and `.public()` like {@link HttpTrigger}.
|
|
259
287
|
*/
|
|
260
288
|
resource<P extends string>(path: P, ops: ResourceFlowBag): ResourceMount;
|
|
289
|
+
/**
|
|
290
|
+
* Default live firehose: `GET /_oke/live/{signal}`. Chain `.gate(...)`.
|
|
291
|
+
*
|
|
292
|
+
* @param signal - `delivery: "live"` handle
|
|
293
|
+
*/
|
|
294
|
+
live(signal: SignalSource): LiveHttpTrigger<"GET">;
|
|
261
295
|
}
|
|
262
296
|
|
|
263
297
|
/** Bind an HTTP verb constructor (`http.get`, `http.query`, …). */
|
|
@@ -287,6 +321,10 @@ export const http: HttpTriggerNamespace = {
|
|
|
287
321
|
head: httpVerb("HEAD"),
|
|
288
322
|
query: httpVerb("QUERY"),
|
|
289
323
|
resource: (path, ops) => loadHttpResource().httpResource(path, ops),
|
|
324
|
+
live(signal) {
|
|
325
|
+
const path = `/_oke/live/${encodeURIComponent(signal.name)}`;
|
|
326
|
+
return createHttpTrigger("GET", path, [], signal);
|
|
327
|
+
},
|
|
290
328
|
};
|
|
291
329
|
|
|
292
330
|
/**
|