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.
Files changed (150) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/ai/llms-txt.mdx +9 -3
  4. package/site/content/docs/elements/clock.mdx +7 -0
  5. package/site/content/docs/elements/flow.mdx +14 -12
  6. package/site/content/docs/elements/gate.mdx +58 -8
  7. package/site/content/docs/elements/signal.mdx +46 -17
  8. package/site/content/docs/elements/store.mdx +23 -17
  9. package/site/content/docs/elements/vault.mdx +23 -0
  10. package/site/content/docs/get-started/project-structure.mdx +4 -4
  11. package/site/content/docs/reference/cli.md +1 -1
  12. package/site/content/docs/reference/client.mdx +72 -17
  13. package/site/content/docs/reference/configuration.mdx +7 -4
  14. package/site/content/docs/reference/errors.mdx +24 -17
  15. package/site/content/docs/reference/fx.mdx +15 -9
  16. package/src/auth/api-key-sql.ts +11 -4
  17. package/src/auth/api-keys.ts +3 -0
  18. package/src/auth/config.ts +19 -0
  19. package/src/auth/index.ts +37 -0
  20. package/src/auth/plugin.ts +6 -1
  21. package/src/auth/sessions.ts +18 -0
  22. package/src/auth/tables.ts +32 -0
  23. package/src/auth/tenant-config.ts +74 -0
  24. package/src/auth/tenant-tables.ts +11 -0
  25. package/src/auth/tenants.test.ts +63 -0
  26. package/src/auth/tenants.ts +360 -0
  27. package/src/cli/build.ts +2 -2
  28. package/src/client/budget.test.ts +1 -1
  29. package/src/client/create.ts +75 -5
  30. package/src/client/index.ts +13 -0
  31. package/src/client/live.test.ts +422 -0
  32. package/src/client/live.ts +389 -0
  33. package/src/client/notes-contract.test.ts +41 -0
  34. package/src/client/types.ts +85 -6
  35. package/src/client-react/index.ts +85 -1
  36. package/src/client-react/use-live.test.ts +129 -0
  37. package/src/compiler/effects-infer.ts +22 -2
  38. package/src/compiler/extract.test.ts +143 -11
  39. package/src/compiler/extract.ts +210 -30
  40. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  41. package/src/compiler/fixtures/skyport.expected.json +2 -3
  42. package/src/compiler/response.ts +41 -11
  43. package/src/console/server/app.ts +44 -11
  44. package/src/console/server/console.test.ts +6 -8
  45. package/src/console/server/gates.ts +2 -9
  46. package/src/console/server/store.test.ts +17 -0
  47. package/src/console/server/store.ts +43 -1
  48. package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-9Wwx1g4Q.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-DydRDi70.js} +1 -1
  50. package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-Cg9nxd6m.js} +3 -3
  51. package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-D-OzmFOI.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
  54. package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-B_zeux2Y.js} +1 -1
  55. package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-CsMg7XDW.js} +1 -1
  56. package/src/console/ui-next/dist/index.html +1 -1
  57. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  58. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  59. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  60. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  61. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  62. package/src/drivers/index.ts +2 -0
  63. package/src/drivers/journal-postgres.ts +12 -3
  64. package/src/drivers/pg-rls.ts +26 -2
  65. package/src/drivers/pg-vault-rls.ts +68 -0
  66. package/src/drivers/signal-engine.ts +69 -15
  67. package/src/drivers/signal-live-iter.ts +65 -0
  68. package/src/drivers/signal-nats.ts +2 -1
  69. package/src/drivers/signal-postgres.ts +95 -12
  70. package/src/drivers/signal-redis.ts +2 -1
  71. package/src/drivers/signal-retention.ts +64 -0
  72. package/src/drivers/signal-types.ts +35 -5
  73. package/src/elements/clock/declare.ts +64 -2
  74. package/src/elements/clock/reconcile.ts +98 -25
  75. package/src/elements/clock/runtime.ts +13 -2
  76. package/src/elements/clock.test.ts +28 -0
  77. package/src/elements/clock.ts +9 -1
  78. package/src/elements/gate/permissions.ts +12 -0
  79. package/src/elements/gate.ts +6 -1
  80. package/src/elements/signal/declare.ts +44 -10
  81. package/src/elements/signal/delivery-modes.test.ts +90 -4
  82. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  83. package/src/elements/signal/runtime.ts +42 -0
  84. package/src/elements/signal.test.ts +21 -8
  85. package/src/elements/signal.ts +1 -1
  86. package/src/elements/store/declare.ts +7 -0
  87. package/src/elements/store/rls-identity.test.ts +29 -0
  88. package/src/elements/store/rls-identity.ts +3 -0
  89. package/src/elements/store/schema-decl.ts +63 -3
  90. package/src/elements/store/schema-tenant.ts +41 -0
  91. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  92. package/src/elements/store.ts +2 -0
  93. package/src/elements/vault/builtin-adapter.ts +15 -2
  94. package/src/elements/vault/declare.ts +8 -0
  95. package/src/elements/vault/runtime.ts +7 -0
  96. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  97. package/src/elements/vault/storage.ts +9 -0
  98. package/src/elements/vault/test-helpers.ts +2 -1
  99. package/src/i18n/catalogs/ar.ts +19 -0
  100. package/src/i18n/catalogs/en.ts +19 -0
  101. package/src/index.ts +1 -0
  102. package/src/kernel/adopt-routes.ts +56 -8
  103. package/src/kernel/app-tenant.ts +122 -0
  104. package/src/kernel/app.ts +183 -56
  105. package/src/kernel/auth-resolve.ts +3 -0
  106. package/src/kernel/boot-bind/clock.ts +9 -3
  107. package/src/kernel/boot.ts +2 -0
  108. package/src/kernel/budget.test.ts +1 -1
  109. package/src/kernel/clock-durable.ts +8 -0
  110. package/src/kernel/clock-per-tenant-name.ts +5 -0
  111. package/src/kernel/clock-reconcile.ts +8 -0
  112. package/src/kernel/errors-live-resume.ts +15 -0
  113. package/src/kernel/errors-tenant.ts +29 -0
  114. package/src/kernel/errors.registry.test.ts +31 -3
  115. package/src/kernel/errors.ts +43 -3
  116. package/src/kernel/flow.ts +26 -5
  117. package/src/kernel/fx-auth-keys.ts +6 -1
  118. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  119. package/src/kernel/fx-auth-tenants.ts +286 -0
  120. package/src/kernel/fx-live-stream.ts +149 -0
  121. package/src/kernel/fx-live.test.ts +157 -0
  122. package/src/kernel/fx-runtime.ts +15 -0
  123. package/src/kernel/fx-tenant-store.ts +213 -0
  124. package/src/kernel/fx.test.ts +91 -0
  125. package/src/kernel/fx.ts +173 -13
  126. package/src/kernel/hooks.ts +2 -2
  127. package/src/kernel/http-resource.ts +9 -18
  128. package/src/kernel/index.ts +2 -0
  129. package/src/kernel/journal.ts +12 -0
  130. package/src/kernel/live-http.test.ts +78 -0
  131. package/src/kernel/live-http.ts +114 -0
  132. package/src/kernel/live-resume.test.ts +125 -0
  133. package/src/kernel/on.ts +51 -0
  134. package/src/kernel/pipeline-tenant.ts +49 -0
  135. package/src/kernel/pipeline.test.ts +1 -1
  136. package/src/kernel/pipeline.ts +37 -2
  137. package/src/kernel/resource-mount.test.ts +10 -27
  138. package/src/kernel/tenant-resolve.test.ts +101 -0
  139. package/src/kernel/tenant-resolve.ts +124 -0
  140. package/src/kernel/tenant-roles.test.ts +87 -0
  141. package/src/kernel/triggers.ts +59 -21
  142. package/src/manifest/diff.test.ts +11 -2
  143. package/src/manifest/diff.ts +53 -11
  144. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  145. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  146. package/src/manifest/types.ts +52 -6
  147. package/src/release/build-lib.ts +13 -1
  148. package/src/release/limits.ts +2 -2
  149. package/src/release/measure.ts +55 -1
  150. package/src/client/live-gap.test.ts +0 -35
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Tenant registry table names — kept off AUTH_TABLES so Store-only graphs
3
+ * that already ship `oke_api_keys` do not also pin `oke_tenants`.
4
+ */
5
+
6
+ /** Opt-in via `gate.auth.tenant`. */
7
+ export const AUTH_TENANT_TABLES = {
8
+ tenants: "oke_tenants",
9
+ tenantMembers: "oke_tenant_members",
10
+ tenantRoles: "oke_tenant_roles",
11
+ } as const;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Tenant registry — user-plane catalog on write; membership helpers.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { isApplicationScope } from "../elements/gate/permissions.ts";
7
+ import {
8
+ createTenant,
9
+ createTenantStore,
10
+ tenantRoleScopeFailure,
11
+ upsertTenantRole,
12
+ } from "./tenants.ts";
13
+
14
+ describe("tenantRoleScopeFailure", () => {
15
+ const catalog = ["booking:create", "member", "console:store.sql:write", "console:*"];
16
+
17
+ test("unknown names and console:* fail the same way", () => {
18
+ const invented = tenantRoleScopeFailure(["this:was:never:declared"], catalog);
19
+ const consoleWrite = tenantRoleScopeFailure(["console:store.sql:write"], catalog);
20
+ const consoleStar = tenantRoleScopeFailure(["console:*"], catalog);
21
+ expect(invented?.reason).toBe("unknown_scope");
22
+ expect(consoleWrite?.reason).toBe("unknown_scope");
23
+ expect(consoleStar?.reason).toBe("unknown_scope");
24
+ expect(isApplicationScope("console:store.sql:write")).toBe(false);
25
+ expect(isApplicationScope("console:*")).toBe(false);
26
+ });
27
+
28
+ test("application catalog names are grantable", () => {
29
+ expect(tenantRoleScopeFailure(["booking:create", "member"], catalog)).toBeNull();
30
+ });
31
+ });
32
+
33
+ describe("upsertTenantRole", () => {
34
+ test("console-shaped upsert throws like an invented name", async () => {
35
+ const store = createTenantStore();
36
+ await createTenant(store, { name: "Acme", createdBy: "u1", id: "t1" });
37
+ const catalog = ["booking:create", "console:store.sql:write", "console:*"];
38
+ expect(() =>
39
+ upsertTenantRole(store, {
40
+ tenantId: "t1",
41
+ roleName: "admin",
42
+ scopes: ["console:store.sql:write"],
43
+ catalog,
44
+ }),
45
+ ).toThrow(/unknown or operator-plane scope/);
46
+ expect(() =>
47
+ upsertTenantRole(store, {
48
+ tenantId: "t1",
49
+ roleName: "admin",
50
+ scopes: ["console:*"],
51
+ catalog,
52
+ }),
53
+ ).toThrow(/unknown or operator-plane scope/);
54
+ expect(() =>
55
+ upsertTenantRole(store, {
56
+ tenantId: "t1",
57
+ roleName: "admin",
58
+ scopes: ["this:was:never:declared"],
59
+ catalog,
60
+ }),
61
+ ).toThrow(/unknown or operator-plane scope/);
62
+ });
63
+ });
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Built-in tenant registry + N:N membership + tenant-scoped roles.
3
+ *
4
+ * Opt-in via `gate.auth.tenant`. Not an organizations product — no
5
+ * invitations UI, billing, or admin portal.
6
+ */
7
+
8
+ import { isApplicationScope } from "../elements/gate/permissions.ts";
9
+ import type { TenantMemberRow, TenantRoleRow, TenantRow } from "./tables.ts";
10
+
11
+ export type { TenantMemberRow, TenantRoleRow, TenantRow };
12
+
13
+ /** Capability / Manifest resource for `fx.auth` tenant methods. */
14
+ export const AUTH_TENANTS_RESOURCE = "auth:tenants";
15
+
16
+ /** Tenant registry / membership / role error. */
17
+ export class TenantError extends Error {
18
+ readonly reason: string;
19
+ readonly scope?: string;
20
+
21
+ /**
22
+ * @param reason - Stable reason token
23
+ * @param message - Diagnostic
24
+ * @param scope - Illegal scope name when `unknown_scope`
25
+ */
26
+ constructor(reason: string, message: string, scope?: string) {
27
+ super(message);
28
+ this.name = "TenantError";
29
+ this.reason = reason;
30
+ if (scope !== undefined) this.scope = scope;
31
+ }
32
+ }
33
+
34
+ /** Default member role name until a mapping exists. */
35
+ export const DEFAULT_TENANT_ROLE = "member";
36
+
37
+ /** Lifecycle hooks for Clock per-tenant row expansion. */
38
+ export interface TenantStoreHooks {
39
+ readonly onCreate?: (tenant: TenantRow) => void | Promise<void>;
40
+ readonly onDelete?: (tenant: TenantRow) => void | Promise<void>;
41
+ }
42
+
43
+ /** In-memory tenant store. */
44
+ export interface TenantStore {
45
+ tenants: Map<string, TenantRow>;
46
+ members: Map<string, TenantMemberRow>;
47
+ roles: Map<string, TenantRoleRow>;
48
+ hooks?: TenantStoreHooks;
49
+ }
50
+
51
+ /**
52
+ * Create an empty tenant store.
53
+ *
54
+ * @param hooks - Optional create/delete hooks (Clock expansion)
55
+ */
56
+ export function createTenantStore(hooks?: TenantStoreHooks): TenantStore {
57
+ return {
58
+ tenants: new Map(),
59
+ members: new Map(),
60
+ roles: new Map(),
61
+ ...(hooks !== undefined ? { hooks } : {}),
62
+ };
63
+ }
64
+
65
+ function memberKey(tenantId: string, userId: string): string {
66
+ return `${tenantId}:${userId}`;
67
+ }
68
+
69
+ function roleKey(tenantId: string, roleName: string): string {
70
+ return `${tenantId}:${roleName}`;
71
+ }
72
+
73
+ /** Public tenant row returned by `fx.auth.listTenants`. */
74
+ export interface TenantPublicRow {
75
+ readonly id: string;
76
+ readonly name: string;
77
+ readonly slug: string | null;
78
+ readonly role: string;
79
+ }
80
+
81
+ /** Options for {@link createTenant}. */
82
+ export interface CreateTenantOptions {
83
+ readonly name: string;
84
+ readonly createdBy: string;
85
+ readonly slug?: string | null;
86
+ readonly id?: string;
87
+ readonly now?: () => number;
88
+ }
89
+
90
+ /**
91
+ * Create a tenant and add the creator as a member.
92
+ *
93
+ * @param store - Tenant store
94
+ * @param options - Name / creator / optional id
95
+ */
96
+ export async function createTenant(
97
+ store: TenantStore,
98
+ options: CreateTenantOptions,
99
+ ): Promise<TenantRow> {
100
+ const now = options.now ?? (() => Date.now());
101
+ const t = now();
102
+ const id = options.id ?? crypto.randomUUID();
103
+ if (store.tenants.has(id)) {
104
+ throw new TenantError("tenant_exists", `tenant already exists: ${id}`);
105
+ }
106
+ const slug = options.slug ?? null;
107
+ if (slug) {
108
+ for (const row of store.tenants.values()) {
109
+ if (row.slug === slug) {
110
+ throw new TenantError("slug_taken", `tenant slug already taken: ${slug}`);
111
+ }
112
+ }
113
+ }
114
+ const row: TenantRow = {
115
+ id,
116
+ name: options.name,
117
+ slug,
118
+ createdAt: t,
119
+ createdBy: options.createdBy,
120
+ };
121
+ store.tenants.set(id, row);
122
+ addMember(store, {
123
+ tenantId: id,
124
+ userId: options.createdBy,
125
+ role: DEFAULT_TENANT_ROLE,
126
+ now,
127
+ });
128
+ await store.hooks?.onCreate?.(row);
129
+ return row;
130
+ }
131
+
132
+ /**
133
+ * Delete a tenant, its memberships, and its custom roles.
134
+ *
135
+ * @param store - Tenant store
136
+ * @param id - Tenant id
137
+ */
138
+ export async function deleteTenant(store: TenantStore, id: string): Promise<TenantRow | undefined> {
139
+ const row = store.tenants.get(id);
140
+ if (!row) return undefined;
141
+ store.tenants.delete(id);
142
+ for (const [key, member] of [...store.members.entries()]) {
143
+ if (member.tenantId === id) store.members.delete(key);
144
+ }
145
+ for (const [key, role] of [...store.roles.entries()]) {
146
+ if (role.tenantId === id) store.roles.delete(key);
147
+ }
148
+ await store.hooks?.onDelete?.(row);
149
+ return row;
150
+ }
151
+
152
+ /** Options for {@link addMember}. */
153
+ export interface AddMemberOptions {
154
+ readonly tenantId: string;
155
+ readonly userId: string;
156
+ readonly role?: string;
157
+ readonly id?: string;
158
+ readonly now?: () => number;
159
+ }
160
+
161
+ /**
162
+ * Add a membership (idempotent on tenant+user).
163
+ *
164
+ * @param store - Tenant store
165
+ * @param options - Membership fields
166
+ */
167
+ export function addMember(store: TenantStore, options: AddMemberOptions): TenantMemberRow {
168
+ if (!store.tenants.has(options.tenantId)) {
169
+ throw new TenantError("unknown_tenant", `unknown tenant: ${options.tenantId}`);
170
+ }
171
+ const key = memberKey(options.tenantId, options.userId);
172
+ const existing = store.members.get(key);
173
+ if (existing) {
174
+ if (options.role !== undefined && options.role !== existing.role) {
175
+ const next: TenantMemberRow = { ...existing, role: options.role };
176
+ store.members.set(key, next);
177
+ return next;
178
+ }
179
+ return existing;
180
+ }
181
+ const now = options.now ?? (() => Date.now());
182
+ const row: TenantMemberRow = {
183
+ id: options.id ?? crypto.randomUUID(),
184
+ tenantId: options.tenantId,
185
+ userId: options.userId,
186
+ role: options.role ?? DEFAULT_TENANT_ROLE,
187
+ createdAt: now(),
188
+ };
189
+ store.members.set(key, row);
190
+ return row;
191
+ }
192
+
193
+ /**
194
+ * Remove a membership.
195
+ *
196
+ * @param store - Tenant store
197
+ * @param tenantId - Tenant id
198
+ * @param userId - User id
199
+ */
200
+ export function removeMember(
201
+ store: TenantStore,
202
+ tenantId: string,
203
+ userId: string,
204
+ ): TenantMemberRow | undefined {
205
+ const key = memberKey(tenantId, userId);
206
+ const row = store.members.get(key);
207
+ if (!row) return undefined;
208
+ store.members.delete(key);
209
+ return row;
210
+ }
211
+
212
+ /**
213
+ * Look up one membership.
214
+ *
215
+ * @param store - Tenant store
216
+ * @param tenantId - Tenant id
217
+ * @param userId - User id
218
+ */
219
+ export function getMember(
220
+ store: TenantStore,
221
+ tenantId: string,
222
+ userId: string,
223
+ ): TenantMemberRow | undefined {
224
+ return store.members.get(memberKey(tenantId, userId));
225
+ }
226
+
227
+ /**
228
+ * Whether `userId` is a member of `tenantId`.
229
+ *
230
+ * @param store - Tenant store
231
+ * @param tenantId - Tenant id
232
+ * @param userId - User id
233
+ */
234
+ export function isMember(store: TenantStore, tenantId: string, userId: string): boolean {
235
+ return store.members.has(memberKey(tenantId, userId));
236
+ }
237
+
238
+ /**
239
+ * List memberships for a user (for `listTenants`).
240
+ *
241
+ * @param store - Tenant store
242
+ * @param userId - User id
243
+ */
244
+ export function listTenantsForUser(store: TenantStore, userId: string): TenantPublicRow[] {
245
+ const out: TenantPublicRow[] = [];
246
+ for (const member of store.members.values()) {
247
+ if (member.userId !== userId) continue;
248
+ const tenant = store.tenants.get(member.tenantId);
249
+ if (!tenant) continue;
250
+ out.push({
251
+ id: tenant.id,
252
+ name: tenant.name,
253
+ slug: tenant.slug,
254
+ role: member.role,
255
+ });
256
+ }
257
+ return out.sort((a, b) => a.name.localeCompare(b.name));
258
+ }
259
+
260
+ /**
261
+ * List members of a tenant.
262
+ *
263
+ * @param store - Tenant store
264
+ * @param tenantId - Tenant id
265
+ */
266
+ export function listMembers(store: TenantStore, tenantId: string): TenantMemberRow[] {
267
+ const out: TenantMemberRow[] = [];
268
+ for (const member of store.members.values()) {
269
+ if (member.tenantId === tenantId) out.push(member);
270
+ }
271
+ return out.sort((a, b) => a.userId.localeCompare(b.userId));
272
+ }
273
+
274
+ /**
275
+ * All tenant ids currently in the store (Clock reconcile expansion).
276
+ *
277
+ * @param store - Tenant store
278
+ */
279
+ export function listTenantIds(store: TenantStore): string[] {
280
+ return [...store.tenants.keys()].sort();
281
+ }
282
+
283
+ /** Options for {@link upsertTenantRole}. */
284
+ export interface UpsertTenantRoleOptions {
285
+ readonly tenantId: string;
286
+ readonly roleName: string;
287
+ readonly scopes: readonly string[];
288
+ readonly catalog: readonly string[];
289
+ readonly now?: () => number;
290
+ }
291
+
292
+ /**
293
+ * Validate scopes against the Manifest-derived **user-plane** catalog.
294
+ *
295
+ * Unknown names and `console:*` / `console:…` fail the same way.
296
+ *
297
+ * @param scopes - Requested scope names
298
+ * @param catalog - `deriveModuleActions(manifest)`
299
+ * @returns Failure when any name is illegal; `null` when all are grantable
300
+ */
301
+ export function tenantRoleScopeFailure(
302
+ scopes: readonly string[],
303
+ catalog: readonly string[],
304
+ ): TenantError | null {
305
+ const allowed = new Set(catalog.filter(isApplicationScope));
306
+ for (const scope of scopes) {
307
+ if (!allowed.has(scope)) {
308
+ return new TenantError("unknown_scope", `unknown or operator-plane scope: ${scope}`, scope);
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+
314
+ /**
315
+ * Create or replace a tenant role mapping.
316
+ *
317
+ * @param store - Tenant store
318
+ * @param options - Role + catalog
319
+ */
320
+ export function upsertTenantRole(
321
+ store: TenantStore,
322
+ options: UpsertTenantRoleOptions,
323
+ ): TenantRoleRow {
324
+ if (!store.tenants.has(options.tenantId)) {
325
+ throw new TenantError("unknown_tenant", `unknown tenant: ${options.tenantId}`);
326
+ }
327
+ const denied = tenantRoleScopeFailure(options.scopes, options.catalog);
328
+ if (denied) throw denied;
329
+ const now = options.now ?? (() => Date.now());
330
+ const t = now();
331
+ const key = roleKey(options.tenantId, options.roleName);
332
+ const prev = store.roles.get(key);
333
+ const row: TenantRoleRow = {
334
+ tenantId: options.tenantId,
335
+ roleName: options.roleName,
336
+ scopes: [...options.scopes],
337
+ createdAt: prev?.createdAt ?? t,
338
+ updatedAt: t,
339
+ };
340
+ store.roles.set(key, row);
341
+ return row;
342
+ }
343
+
344
+ /**
345
+ * Expand a member's tenant role into application scopes.
346
+ *
347
+ * @param store - Tenant store
348
+ * @param tenantId - Tenant id
349
+ * @param userId - User id
350
+ */
351
+ export function tenantScopesForMember(
352
+ store: TenantStore,
353
+ tenantId: string,
354
+ userId: string,
355
+ ): readonly string[] {
356
+ const member = getMember(store, tenantId, userId);
357
+ if (!member) return [];
358
+ const role = store.roles.get(roleKey(tenantId, member.role));
359
+ return role?.scopes ?? [];
360
+ }
package/src/cli/build.ts CHANGED
@@ -44,7 +44,7 @@ async function defaultSyncAdoptBarrel(rootDir: string): Promise<readonly string[
44
44
  }
45
45
 
46
46
  /**
47
- * Build a deployable bundle. Edge target aims at the &lt;15 kB kernel budget.
47
+ * Build a deployable bundle. Edge target aims at the &lt;16 kB kernel budget.
48
48
  *
49
49
  * @param options - Target / entry
50
50
  */
@@ -109,7 +109,7 @@ export async function buildCli(args: readonly string[]): Promise<number> {
109
109
  else if (a === "--help" || a === "-h") {
110
110
  console.log(`oke build [--target|-t bun|node|edge] [--entry|-e src/app.ts]
111
111
 
112
- Tree-shaken bundle. --target edge aims at the <15 kB kernel budget.
112
+ Tree-shaken bundle. --target edge aims at the <16 kB kernel budget.
113
113
  `);
114
114
  return 0;
115
115
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Client runtime budget — AGENTS.md / unified-theory §24.
3
- * Limit: < 3 kB gzipped (minified bundle).
3
+ * Limit: gzipped minified bundle under {@link CLIENT_BUDGET_BYTES}.
4
4
  */
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
@@ -14,7 +14,22 @@
14
14
 
15
15
  import { createTransport, type Transport } from "./transport.ts";
16
16
  import { asThenableIterable, attachPager } from "./pager.ts";
17
- import type { Client, ClientOptions, ClientRouteMap, ResolveApp } from "./types.ts";
17
+ import {
18
+ flattenLiveRoutes,
19
+ isLiveHandlers,
20
+ pickLiveExposure,
21
+ subscribeLive,
22
+ type LiveByFlow,
23
+ type LiveRouteTable,
24
+ } from "./live.ts";
25
+ import type {
26
+ Client,
27
+ ClientLive,
28
+ ClientOptions,
29
+ ClientRouteMap,
30
+ LiveHandlers,
31
+ ResolveApp,
32
+ } from "./types.ts";
18
33
 
19
34
  /** App-shaped value that carries a runtime `$routes` table from typed adopt. */
20
35
  export interface AppWithRoutes {
@@ -80,10 +95,23 @@ export function createClient(
80
95
  function buildClient(url: string, opts: ClientOptions = {}): Client {
81
96
  const base = url.replace(/\/+$/, "");
82
97
  const routes = opts.routes ?? flattenRoutes(opts.$routes);
98
+ const live = flattenLiveRoutes(opts.$routes);
83
99
  const transport = createTransport(base, { ...opts, routes });
84
- return proxy(transport, []) as Client;
100
+ return proxy(transport, [], {
101
+ base,
102
+ opts,
103
+ liveBySignal: live.bySignal,
104
+ liveByFlow: live.byFlow,
105
+ }) as Client;
85
106
  }
86
107
 
108
+ type ProxyCtx = {
109
+ readonly base: string;
110
+ readonly opts: ClientOptions;
111
+ readonly liveBySignal: LiveRouteTable;
112
+ readonly liveByFlow: LiveByFlow;
113
+ };
114
+
87
115
  /**
88
116
  * Flatten `app.$routes` into the transport REST table (`unit.flow` → method/path).
89
117
  * Entries without both method and path are omitted (RPC fallback).
@@ -115,7 +143,7 @@ export function flattenRoutes(
115
143
  * @param transport - HTTP transport
116
144
  * @param path - Accumulated property path
117
145
  */
118
- function proxy(transport: Transport, path: readonly string[]): unknown {
146
+ function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): unknown {
119
147
  const invoke = async (input?: unknown) => {
120
148
  if (path.length < 2) {
121
149
  return attachPager(
@@ -137,7 +165,18 @@ function proxy(transport: Transport, path: readonly string[]): unknown {
137
165
  const result = await transport.call(`${unit}/${flow}`, input);
138
166
  return attachPager(result, invoke, input);
139
167
  };
140
- const call = (input?: unknown) => asThenableIterable(invoke, input);
168
+ const call = (a?: unknown, b?: unknown) => {
169
+ if (path.length >= 2) {
170
+ const key = `${path[0]}.${path.slice(1).join(".")}`;
171
+ const exposure = ctx.liveByFlow[key];
172
+ if (exposure && (isLiveHandlers(a) || isLiveHandlers(b))) {
173
+ const handlers = (isLiveHandlers(a) ? a : b) as LiveHandlers<unknown>;
174
+ const input = isLiveHandlers(a) ? undefined : a;
175
+ return subscribeLive(ctx.base, exposure, input, handlers, ctx.opts);
176
+ }
177
+ }
178
+ return asThenableIterable(invoke, a);
179
+ };
141
180
 
142
181
  return new Proxy(call, {
143
182
  get(_target, prop, receiver) {
@@ -145,7 +184,38 @@ function proxy(transport: Transport, path: readonly string[]): unknown {
145
184
  return Reflect.get(_target, prop, receiver);
146
185
  }
147
186
  if (prop === "then") return undefined;
148
- return proxy(transport, [...path, prop]);
187
+ if (path.length === 0 && prop === "live") {
188
+ return makeLive(ctx);
189
+ }
190
+ return proxy(transport, [...path, prop], ctx);
149
191
  },
150
192
  });
151
193
  }
194
+
195
+ function makeLive(ctx: ProxyCtx): ClientLive {
196
+ return ((signalOrName: unknown, inputOrHandlers: unknown, maybeHandlers?: unknown) => {
197
+ const name =
198
+ typeof signalOrName === "string"
199
+ ? signalOrName
200
+ : signalOrName !== null &&
201
+ typeof signalOrName === "object" &&
202
+ "name" in signalOrName &&
203
+ typeof (signalOrName as { name: unknown }).name === "string"
204
+ ? (signalOrName as { name: string }).name
205
+ : undefined;
206
+ if (!name) throw new Error("api.live requires a signal handle or name");
207
+ const handlers = isLiveHandlers(inputOrHandlers)
208
+ ? inputOrHandlers
209
+ : isLiveHandlers(maybeHandlers)
210
+ ? maybeHandlers
211
+ : undefined;
212
+ if (!handlers) throw new Error("api.live requires handlers with onEvent");
213
+ const input = isLiveHandlers(inputOrHandlers) ? undefined : inputOrHandlers;
214
+ const exposures = ctx.liveBySignal[name] ?? [];
215
+ if (exposures.length === 0) {
216
+ throw new Error(`No live HTTP exposure for signal "${name}"`);
217
+ }
218
+ const exposure = pickLiveExposure(exposures, input, handlers.via);
219
+ return subscribeLive(ctx.base, exposure, input, handlers, ctx.opts);
220
+ }) as ClientLive;
221
+ }
@@ -5,6 +5,14 @@
5
5
  * @module
6
6
  */
7
7
 
8
+ export {
9
+ flattenLiveRoutes,
10
+ isLiveHandlers,
11
+ LIVE_RESUBSCRIBE_INITIAL_MS,
12
+ LIVE_RESUBSCRIBE_MAX_MS,
13
+ nextResubscribeDelay,
14
+ pickLiveExposure,
15
+ } from "./live.ts";
8
16
  export { createClient, flattenRoutes } from "./create.ts";
9
17
  export type { AppWithRoutes } from "./create.ts";
10
18
  export { isErrorCode, isFail, isOk, isTransportError } from "./errors.ts";
@@ -29,7 +37,12 @@ export type {
29
37
  ClientResult,
30
38
  ClientThenableIterable,
31
39
  ClientRouteMap,
40
+ ClientLive,
32
41
  FlowContract,
42
+ LiveHandlers,
43
+ LiveInput,
44
+ LiveSignalHandle,
45
+ LiveUnsubscribe,
33
46
  Register,
34
47
  ResolveApp,
35
48
  RoutesOf,