okengine 0.17.2 → 0.18.3

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 (197) hide show
  1. package/README.md +9 -5
  2. package/manifest.v1.schema.json +61 -2
  3. package/package.json +18 -4
  4. package/site/content/docs/elements/clock.mdx +1 -1
  5. package/site/content/docs/elements/flow.mdx +25 -1
  6. package/site/content/docs/elements/store.mdx +287 -341
  7. package/site/content/docs/elements/vault.mdx +5 -5
  8. package/site/content/docs/get-started/installation.mdx +1 -2
  9. package/site/content/docs/get-started/introduction.mdx +54 -110
  10. package/site/content/docs/get-started/meta.json +9 -1
  11. package/site/content/docs/get-started/testing.mdx +328 -0
  12. package/site/content/docs/get-started/why.mdx +94 -70
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/plugins/apple.mdx +151 -0
  15. package/site/content/docs/plugins/discord.mdx +139 -0
  16. package/site/content/docs/plugins/facebook.mdx +134 -0
  17. package/site/content/docs/plugins/figma.mdx +138 -0
  18. package/site/content/docs/plugins/github.mdx +138 -0
  19. package/site/content/docs/plugins/google.mdx +153 -0
  20. package/site/content/docs/plugins/index.mdx +47 -1
  21. package/site/content/docs/plugins/meta.json +10 -0
  22. package/site/content/docs/plugins/microsoft.mdx +151 -0
  23. package/site/content/docs/plugins/oauth.mdx +188 -0
  24. package/site/content/docs/plugins/x.mdx +125 -0
  25. package/site/content/docs/reference/cli.md +3 -2
  26. package/site/content/docs/reference/client.mdx +58 -1
  27. package/site/content/docs/reference/configuration.mdx +2 -4
  28. package/site/content/docs/reference/fx.mdx +3 -1
  29. package/site/content/docs/reference/index.mdx +0 -5
  30. package/site/content/docs/reference/meta.json +2 -2
  31. package/site/content/docs/reference/okid.mdx +137 -0
  32. package/src/auth/bindings.ts +1 -1
  33. package/src/auth/config.ts +9 -0
  34. package/src/auth/identity-sql.ts +314 -0
  35. package/src/auth/identity.ts +140 -2
  36. package/src/auth/index.ts +17 -1
  37. package/src/auth/method-context.ts +3 -0
  38. package/src/auth/oauth-as/cimd.ts +132 -0
  39. package/src/auth/oauth-as/crypto.test.ts +101 -0
  40. package/src/auth/oauth-as/crypto.ts +393 -0
  41. package/src/auth/oauth-as/errors.ts +68 -0
  42. package/src/auth/oauth-as/http.test.ts +419 -0
  43. package/src/auth/oauth-as/http.ts +842 -0
  44. package/src/auth/oauth-as/stores.ts +61 -0
  45. package/src/auth/oauth-as/tables.ts +142 -0
  46. package/src/auth/tables.ts +0 -11
  47. package/src/bench/README.md +83 -0
  48. package/src/bench/REPORT.md +176 -0
  49. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  50. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  51. package/src/bench/g03-signal-once.bench.ts +157 -0
  52. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  53. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  54. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  55. package/src/bench/g05-sustained-full.bench.ts +265 -0
  56. package/src/bench/g06-mixed-load.bench.ts +260 -0
  57. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  58. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  59. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  60. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  61. package/src/bench/g08-store-sql.bench.ts +178 -0
  62. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  63. package/src/bench/g10-observability-contention.bench.ts +246 -0
  64. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  65. package/src/bench/g13-elements.bench.ts +427 -0
  66. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  67. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  68. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  69. package/src/bench/lib/event-loop-lag.ts +26 -0
  70. package/src/bench/lib/infra.ts +60 -0
  71. package/src/bench/lib/report.ts +52 -0
  72. package/src/bench/lib/rss-sampler.ts +61 -0
  73. package/src/bench/lib/signal-pg.ts +88 -0
  74. package/src/bench/load-app.ts +337 -0
  75. package/src/bench/load-child.ts +108 -0
  76. package/src/bench/smoke.bench.ts +43 -0
  77. package/src/cli/competitor-mention-removal.test.ts +28 -0
  78. package/src/cli/doctor-fd.ts +117 -0
  79. package/src/cli/doctor.test.ts +192 -0
  80. package/src/cli/doctor.ts +129 -1
  81. package/src/client/create.ts +95 -1
  82. package/src/client/index.ts +9 -2
  83. package/src/client/transport.ts +11 -4
  84. package/src/client/use-live-query.ts +154 -0
  85. package/src/client-react/index.ts +15 -1
  86. package/src/client-react/live-resource.ts +246 -0
  87. package/src/client-react/use-live-query.test.ts +475 -0
  88. package/src/client-react/use-live-query.ts +530 -0
  89. package/src/compiler/extract.test.ts +518 -0
  90. package/src/compiler/extract.ts +386 -19
  91. package/src/console/server/invoke-user-flow.ts +2 -1
  92. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
  95. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
  96. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  97. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
  98. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
  99. package/src/console/ui-next/dist/index.html +1 -1
  100. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  101. package/src/drivers/cdc-outbox.ts +389 -0
  102. package/src/drivers/memory.ts +20 -0
  103. package/src/drivers/oauth-apple.ts +156 -0
  104. package/src/drivers/oauth-discord.ts +79 -0
  105. package/src/drivers/oauth-facebook.ts +80 -0
  106. package/src/drivers/oauth-figma.ts +116 -0
  107. package/src/drivers/oauth-github.ts +92 -0
  108. package/src/drivers/oauth-google.ts +142 -0
  109. package/src/drivers/oauth-microsoft.ts +174 -0
  110. package/src/drivers/oauth-oidc.ts +293 -0
  111. package/src/drivers/oauth-shared.ts +326 -0
  112. package/src/drivers/oauth-types.ts +159 -0
  113. package/src/drivers/oauth-x.ts +77 -0
  114. package/src/drivers/oauth2-common.ts +95 -0
  115. package/src/drivers/oauth2-token.ts +61 -0
  116. package/src/drivers/pg-rls-row-passes.ts +251 -0
  117. package/src/drivers/pg-rls.ts +2 -0
  118. package/src/drivers/postgres.ts +45 -2
  119. package/src/drivers/signal-postgres.ts +2 -1
  120. package/src/elements/channel/runtime.ts +29 -2
  121. package/src/elements/channel.test.ts +52 -0
  122. package/src/elements/gate/boot.ts +29 -2
  123. package/src/elements/store/emit-drizzle.ts +147 -14
  124. package/src/elements/store/field-ddl.test.ts +118 -0
  125. package/src/elements/store/field-types.test.ts +455 -0
  126. package/src/elements/store/list-query.golden.json +777 -0
  127. package/src/elements/store/list-query.parity.test.ts +396 -0
  128. package/src/elements/store/list-query.ts +792 -0
  129. package/src/elements/store/live-default.test.ts +136 -0
  130. package/src/elements/store/live-http.test.ts +160 -0
  131. package/src/elements/store/live-isolation.test.ts +291 -0
  132. package/src/elements/store/live-query-runtime.test.ts +323 -0
  133. package/src/elements/store/live-query-runtime.ts +403 -0
  134. package/src/elements/store/live-query-server.test.ts +377 -0
  135. package/src/elements/store/live-query-server.ts +102 -0
  136. package/src/elements/store/live-query.ts +97 -0
  137. package/src/elements/store/resource.ts +189 -680
  138. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  139. package/src/elements/store/schema-decl.ts +539 -41
  140. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  141. package/src/elements/store/sql-session.ts +297 -35
  142. package/src/elements/store/table.ts +102 -21
  143. package/src/elements/store.test.ts +3 -1
  144. package/src/elements/store.ts +12 -1
  145. package/src/elements/vault/chaos-child.ts +74 -1
  146. package/src/elements/vault/chaos.test.ts +4 -2
  147. package/src/elements/vault/storage.ts +4 -2
  148. package/src/index.ts +4 -1
  149. package/src/kernel/app-auth.ts +1 -0
  150. package/src/kernel/app.ts +116 -2
  151. package/src/kernel/auth-sharing.test.ts +196 -0
  152. package/src/kernel/boot.test.ts +3 -3
  153. package/src/kernel/errors.ts +8 -0
  154. package/src/kernel/fx.test.ts +1 -0
  155. package/src/kernel/fx.ts +14 -2
  156. package/src/kernel/horizontal-child.ts +2 -1
  157. package/src/kernel/http-resource.ts +33 -7
  158. package/src/kernel/identity-host-persist.test.ts +119 -0
  159. package/src/kernel/instance-id.ts +4 -2
  160. package/src/kernel/journal.ts +2 -1
  161. package/src/kernel/mcp-tool.test.ts +95 -0
  162. package/src/kernel/on.ts +9 -0
  163. package/src/kernel/realtime-bind.ts +326 -0
  164. package/src/kernel/resource-live.ts +117 -0
  165. package/src/kernel/triggers.ts +86 -4
  166. package/src/manifest/diff.ts +37 -0
  167. package/src/manifest/types.ts +64 -2
  168. package/src/okid.bench.test.ts +64 -0
  169. package/src/okid.test.ts +338 -0
  170. package/src/okid.ts +245 -0
  171. package/src/plugins/anonymous.ts +19 -1
  172. package/src/plugins/auth/shared.ts +15 -0
  173. package/src/plugins/index.ts +2 -0
  174. package/src/plugins/magic-link.ts +10 -8
  175. package/src/plugins/mcp-oauth.ts +208 -0
  176. package/src/plugins/oauth/flow-store.ts +117 -0
  177. package/src/plugins/oauth/link.ts +69 -0
  178. package/src/plugins/oauth/shared.ts +108 -0
  179. package/src/plugins/oauth/token-vault.ts +100 -0
  180. package/src/plugins/oauth.security.test.ts +535 -0
  181. package/src/plugins/oauth.ts +532 -0
  182. package/src/plugins/otp.ts +48 -6
  183. package/src/plugins/passkey.ts +20 -1
  184. package/src/plugins/two-factor.ts +11 -0
  185. package/src/plugins/username.ts +40 -7
  186. package/src/release/build-lib.ts +7 -1
  187. package/src/release/measure.ts +1 -0
  188. package/src/release/official-plugins.ts +4 -1
  189. package/src/runs/collect.ts +2 -1
  190. package/src/runs/drivers/files.ts +2 -1
  191. package/src/test/create-test-app.ts +114 -5
  192. package/src/test/export-bundle.test.ts +33 -0
  193. package/src/test/live-signals.test.ts +83 -0
  194. package/src/test/tenant-isolation.test.ts +175 -0
  195. package/src/testing.ts +26 -0
  196. package/src/upgrade/codemods.ts +1 -1
  197. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { constantTimeEqual } from "../auth/constant-time.ts";
8
+ import { IdentityError, ensureUserExists } from "../auth/identity.ts";
8
9
  import { issueSessionWithScopes } from "../auth/sessions.ts";
9
10
  import { plugin, type PluginDef } from "../kernel/plugin.ts";
10
11
  import {
@@ -16,6 +17,7 @@ import {
16
17
  createMethodRuntime,
17
18
  fail,
18
19
  flow,
20
+ resolveSharedIdentities,
19
21
  z,
20
22
  type AuthMethodOptions,
21
23
  } from "./auth/shared.ts";
@@ -56,6 +58,7 @@ export interface TwoFactorOptions extends AuthMethodOptions {
56
58
  */
57
59
  export function twoFactor(opts: TwoFactorOptions = {}): PluginDef {
58
60
  const runtime = createMethodRuntime(opts);
61
+ const identities = resolveSharedIdentities(opts);
59
62
  const factors = opts.factors ?? createTwoFactorStore();
60
63
  const issuer = opts.issuer ?? "oke";
61
64
 
@@ -70,6 +73,14 @@ export function twoFactor(opts: TwoFactorOptions = {}): PluginDef {
70
73
  do: async (_input, fx) => {
71
74
  const userId = fx.auth.userId;
72
75
  if (!userId) return fail("AuthFailed", { reason: "unauthenticated" });
76
+ try {
77
+ await ensureUserExists(identities, userId, runtime.now());
78
+ } catch (err) {
79
+ if (err instanceof IdentityError) {
80
+ return fail("AuthFailed", { reason: "unauthenticated" });
81
+ }
82
+ throw err;
83
+ }
73
84
  const secret = generateBase32Secret(20);
74
85
  const recoveryCodes = Array.from({ length: 8 }, () => randomRecoveryCode());
75
86
  const recoveryHashes = new Set<string>();
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { assertNotBreached, BreachCheckError, type BreachCheckFn } from "../auth/breach-check.ts";
6
6
  import { getActiveGateAuthContext } from "../auth/method-context.ts";
7
+ import { IdentityError, linkOrProvision } from "../auth/identity.ts";
7
8
  import {
8
9
  assertPasswordPolicy,
9
10
  PasswordPolicyError,
@@ -21,6 +22,7 @@ import {
21
22
  createMethodRuntime,
22
23
  fail,
23
24
  flow,
25
+ resolveSharedIdentities,
24
26
  z,
25
27
  type AuthMethodOptions,
26
28
  } from "./auth/shared.ts";
@@ -311,6 +313,7 @@ export function username(opts: UsernamePluginOptions = {}): PluginDef {
311
313
  const runtime = createMethodRuntime(opts);
312
314
  const active = getActiveGateAuthContext();
313
315
  const usernames = opts.usernames ?? createUsernameStore();
316
+ const identities = resolveSharedIdentities(opts);
314
317
  const usernamePolicy = opts.usernamePolicy ?? {};
315
318
  const passwordPolicy = opts.passwordPolicy ?? active?.passwordPolicy ?? {};
316
319
  const passwordHash = opts.password ?? active?.password ?? { algorithm: "argon2id" };
@@ -359,18 +362,34 @@ export function username(opts: UsernamePluginOptions = {}): PluginDef {
359
362
  if (usernames.byUsername.has(key)) {
360
363
  return fail("AuthFailed", { reason: "invalid_credentials" });
361
364
  }
362
- const userId = crypto.randomUUID();
363
365
  const passwordHashValue = await crypto.hashPassword(input.password, passwordHash);
366
+ let user: { id: string };
367
+ try {
368
+ user = (
369
+ await linkOrProvision(identities, {
370
+ provider: "username",
371
+ providerAccountId: key,
372
+ name: key,
373
+ passwordHash: passwordHashValue,
374
+ now: runtime.now,
375
+ })
376
+ ).user;
377
+ } catch (err) {
378
+ if (err instanceof IdentityError) {
379
+ return fail("AuthFailed", { reason: "invalid_credentials" });
380
+ }
381
+ throw err;
382
+ }
364
383
  const row: UsernameRow = {
365
- userId,
384
+ userId: user.id,
366
385
  username: key,
367
386
  passwordHash: passwordHashValue,
368
387
  createdAt: runtime.now(),
369
388
  };
370
389
  usernames.byUsername.set(key, row);
371
- usernames.byUserId.set(userId, row);
390
+ usernames.byUserId.set(user.id, row);
372
391
  const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
373
- id: userId,
392
+ id: user.id,
374
393
  plane: "user",
375
394
  scopes: [],
376
395
  });
@@ -378,7 +397,7 @@ export function username(opts: UsernamePluginOptions = {}): PluginDef {
378
397
  accessToken: issued.accessToken,
379
398
  refreshToken: issued.refreshToken,
380
399
  accessExpiresAt: issued.accessExpiresAt,
381
- userId,
400
+ userId: user.id,
382
401
  };
383
402
  },
384
403
  });
@@ -394,8 +413,22 @@ export function username(opts: UsernamePluginOptions = {}): PluginDef {
394
413
  const hash = row?.passwordHash ?? (await dummyHash(crypto));
395
414
  const ok = await crypto.verifyPassword(input.password, hash);
396
415
  if (!ok || !row) return fail("AuthFailed", { reason: "invalid_credentials" });
416
+ const userId = row.userId;
417
+ try {
418
+ await linkOrProvision(identities, {
419
+ provider: "username",
420
+ providerAccountId: key,
421
+ currentUserId: userId,
422
+ now: runtime.now,
423
+ });
424
+ } catch (err) {
425
+ if (err instanceof IdentityError) {
426
+ return fail("AuthFailed", { reason: "invalid_credentials" });
427
+ }
428
+ throw err;
429
+ }
397
430
  const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
398
- id: row.userId,
431
+ id: userId,
399
432
  plane: "user",
400
433
  scopes: [],
401
434
  });
@@ -403,7 +436,7 @@ export function username(opts: UsernamePluginOptions = {}): PluginDef {
403
436
  accessToken: issued.accessToken,
404
437
  refreshToken: issued.refreshToken,
405
438
  accessExpiresAt: issued.accessExpiresAt,
406
- userId: row.userId,
439
+ userId,
407
440
  };
408
441
  },
409
442
  });
@@ -20,6 +20,7 @@ const ENTRIES: readonly { readonly src: string; readonly out: string }[] = [
20
20
  { src: "src/kernel-entry.ts", out: "dist/kernel-entry.js" },
21
21
  { src: "src/full.ts", out: "dist/full.js" },
22
22
  { src: "src/i18n-entry.ts", out: "dist/i18n-entry.js" },
23
+ { src: "src/testing.ts", out: "dist/testing.js" },
23
24
  { src: "src/compiler-entry.ts", out: "dist/compiler-entry.js" },
24
25
  { src: "src/journal-entry.ts", out: "dist/journal-entry.js" },
25
26
  // Lazy sync chunks — loaded via requirePackageModule / computed require
@@ -44,6 +45,7 @@ const ENTRIES: readonly { readonly src: string; readonly out: string }[] = [
44
45
  { src: "src/elements/store/cache.ts", out: "dist/store-cache.js" },
45
46
  { src: "src/i18n/messages.ts", out: "dist/messages.js" },
46
47
  { src: "src/i18n/failure-message.ts", out: "dist/failure-message.js" },
48
+ { src: "src/okid.ts", out: "dist/okid.js" },
47
49
  ];
48
50
 
49
51
  /**
@@ -63,7 +65,11 @@ export async function buildLib(): Promise<void> {
63
65
  naming: relative(DIST, absOut).replace(/\\/g, "/"),
64
66
  target: "bun",
65
67
  format: "esm",
66
- minify: false,
68
+ // Non-Bun consumers ship this output; minify keeps `dist/` compact.
69
+ // Budgets measure a *separate* minified build (measure.ts), but the
70
+ // published artifact should not ship full-size sources when the compiler
71
+ // is available to minify at the same cost.
72
+ minify: true,
67
73
  sourcemap: "external",
68
74
  external: [...EXPORT_BUILD_EXTERNALS],
69
75
  splitting: false,
@@ -66,6 +66,7 @@ const GROUP_HEADINGS: Readonly<Record<BudgetGroup, string>> = {
66
66
  /** Docs category → markdown subheading under Plugins. */
67
67
  const PLUGIN_CATEGORY_HEADINGS: Readonly<Record<string, string>> = {
68
68
  auth: "Auth",
69
+ oauth: "OAuth",
69
70
  security: "Security",
70
71
  ops: "Ops",
71
72
  perf: "Perf",
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /** Docs / landing category for an official plugin. */
8
- export type PluginBudgetCategory = "auth" | "security" | "ops" | "perf";
8
+ export type PluginBudgetCategory = "auth" | "oauth" | "security" | "ops" | "perf";
9
9
 
10
10
  /** One official plugin entry measured as `export:./plugins/<name>`. */
11
11
  export type OfficialPluginBudget = {
@@ -20,6 +20,7 @@ export type OfficialPluginBudget = {
20
20
  /** Category display order (docs index). */
21
21
  export const PLUGIN_BUDGET_CATEGORIES: readonly PluginBudgetCategory[] = [
22
22
  "auth",
23
+ "oauth",
23
24
  "security",
24
25
  "ops",
25
26
  "perf",
@@ -36,6 +37,8 @@ export const OFFICIAL_PLUGIN_BUDGETS: readonly OfficialPluginBudget[] = [
36
37
  { name: "otp", file: "otp.ts", category: "auth" },
37
38
  { name: "twoFactor", file: "two-factor.ts", category: "auth" },
38
39
  { name: "passkey", file: "passkey.ts", category: "auth" },
40
+ { name: "oauth", file: "oauth.ts", category: "oauth" },
41
+ { name: "mcpOauth", file: "mcp-oauth.ts", category: "oauth" },
39
42
  { name: "headers", file: "headers.ts", category: "security" },
40
43
  { name: "cors", file: "cors.ts", category: "security" },
41
44
  { name: "csrf", file: "csrf.ts", category: "security" },
@@ -12,6 +12,7 @@ import type { Fx } from "../kernel/fx.ts";
12
12
  import type { Trigger } from "../kernel/triggers.ts";
13
13
  import type { FlowPlane } from "../manifest/types.ts";
14
14
  import { cacheDimensionOf, type RunTelemetry } from "../kernel/run-telemetry.ts";
15
+ import { okid } from "../okid.ts";
15
16
  import type { RunError, WideEvent } from "./types.ts";
16
17
 
17
18
  /** Inputs for {@link collectWideEvent}. */
@@ -97,7 +98,7 @@ export function collectWideEvent(input: CollectWideEventInput): WideEvent {
97
98
  };
98
99
 
99
100
  return {
100
- id: input.id ?? crypto.randomUUID(),
101
+ id: input.id ?? okid(),
101
102
  ...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
102
103
  flow: input.flow.name,
103
104
  ...(input.flow.unit !== undefined ? { unit: input.flow.unit } : {}),
@@ -12,6 +12,7 @@ import { join, resolve } from "node:path";
12
12
 
13
13
  import { fsDriver } from "../../drivers/fs.ts";
14
14
  import type { FilesBucket } from "../../drivers/types.ts";
15
+ import { okid } from "../../okid.ts";
15
16
  import {
16
17
  duckPath,
17
18
  duckQuery,
@@ -102,7 +103,7 @@ export const filesRunsDriver: RunsDriver = {
102
103
  locality === "remote" && remote
103
104
  ? { bucket: remote, keyPrefix: remotePrefix }
104
105
  : { bucket: local, keyPrefix: "" };
105
- const fileId = crypto.randomUUID();
106
+ const fileId = okid();
106
107
  const objectKey = `${target.keyPrefix}${partitionObjectKey(day!, fileId)}`;
107
108
  const tmpDir = await mkdtemp(join(tmpdir(), "oke-pq-"));
108
109
  const tmp = join(tmpDir, `${fileId}.parquet`);
@@ -44,6 +44,8 @@ export interface TestUser {
44
44
  readonly id: string;
45
45
  readonly scopes: ReadonlySet<string>;
46
46
  readonly verified: boolean;
47
+ /** Tenant id stamped onto `fx.tenant.id` for scoped calls. */
48
+ readonly tenantId?: string;
47
49
  }
48
50
 
49
51
  /** Options for {@link TestAuth.loginAs}. */
@@ -52,11 +54,15 @@ export interface LoginAsOptions {
52
54
  readonly scopes?: readonly string[];
53
55
  readonly verified?: boolean;
54
56
  readonly plane?: "user" | "operator";
57
+ /** Tenant the principal belongs to — stamped onto `fx.tenant.id`. */
58
+ readonly tenantId?: string;
55
59
  }
56
60
 
57
61
  /** Second argument to test API calls. */
58
62
  export interface TestCallOptions {
59
63
  readonly as?: TestUser | ResolvedPrincipal;
64
+ /** Override the tenant stamped for this call (overrides `as.tenantId`). */
65
+ readonly tenant?: string;
60
66
  }
61
67
 
62
68
  /** Auth surface on the harness. */
@@ -91,10 +97,44 @@ export interface TestCron {
91
97
  run(name: string): Promise<boolean>;
92
98
  }
93
99
 
100
+ /** Live-subscription handlers for {@link TestSignals.subscribeLive}. */
101
+ export interface TestLiveHandlers<T = unknown> {
102
+ /** Called with each live event payload. */
103
+ readonly onEvent: (event: T) => void;
104
+ /** Called when the live stream errors. */
105
+ readonly onError?: (error: unknown) => void;
106
+ }
107
+
108
+ /** Unsubscribe handle returned by {@link TestSignals.subscribeLive}. */
109
+ export type TestLiveUnsubscribe = () => void;
110
+
94
111
  /** Signal surface. */
95
112
  export interface TestSignals {
96
113
  /** Drain the bus until idle (deterministic queued work). */
97
114
  drain(): Promise<void>;
115
+ /**
116
+ * Subscribe to a `delivery: "live"` signal's event stream.
117
+ *
118
+ * @param signal - Signal declaration or name
119
+ * @param handlers - Event / error handlers
120
+ * @returns Unsubscribe function
121
+ */
122
+ subscribeLive<T = unknown>(
123
+ signal: SignalDecl | string,
124
+ handlers: TestLiveHandlers<T>,
125
+ ): TestLiveUnsubscribe;
126
+ /**
127
+ * Resolve once a live event matching `predicate` arrives.
128
+ *
129
+ * @param signal - Signal declaration or name
130
+ * @param predicate - Event filter
131
+ * @param timeoutMs - Reject after this many ms (default 1000)
132
+ */
133
+ waitForLive<T = unknown>(
134
+ signal: SignalDecl | string,
135
+ predicate: (event: T) => boolean,
136
+ timeoutMs?: number,
137
+ ): Promise<T>;
98
138
  }
99
139
 
100
140
  /** Channel surface. */
@@ -269,6 +309,7 @@ export async function createTestApp<App extends OkeApp>(
269
309
  id: opts.id ?? `user_${crypto.randomUUID().slice(0, 8)}`,
270
310
  scopes: new Set(opts.scopes ?? []),
271
311
  verified: opts.verified ?? true,
312
+ ...(opts.tenantId !== undefined ? { tenantId: opts.tenantId } : {}),
272
313
  };
273
314
  },
274
315
  };
@@ -306,6 +347,63 @@ export async function createTestApp<App extends OkeApp>(
306
347
  const bus = app.bootResult?.signal?.bus;
307
348
  if (bus) await bus.drain();
308
349
  },
350
+ subscribeLive(signal, handlers) {
351
+ const name = typeof signal === "string" ? signal : signal.name;
352
+ let active = true;
353
+ const rt = app.bootResult?.signal;
354
+ void (async () => {
355
+ const iter = rt?.live(name)[Symbol.asyncIterator]();
356
+ if (!iter) return;
357
+ for (;;) {
358
+ if (!active) {
359
+ await iter.return?.();
360
+ return;
361
+ }
362
+ const { done, value } = await iter.next();
363
+ if (done || !active) {
364
+ await iter.return?.();
365
+ return;
366
+ }
367
+ if (!active) break;
368
+ try {
369
+ handlers.onEvent(value.payload as never);
370
+ } catch (err) {
371
+ handlers.onError?.(err);
372
+ }
373
+ }
374
+ })();
375
+ return () => {
376
+ active = false;
377
+ };
378
+ },
379
+ waitForLive(signal, predicate, timeoutMs) {
380
+ const name = typeof signal === "string" ? signal : signal.name;
381
+ return new Promise((resolve, reject) => {
382
+ let unsubscribe: TestLiveUnsubscribe | undefined;
383
+ const timer = setTimeout(() => {
384
+ unsubscribe?.();
385
+ reject(
386
+ new Error(
387
+ `waitForLive: no matching event for "${name}" within ${timeoutMs ?? 1000}ms`,
388
+ ),
389
+ );
390
+ }, timeoutMs ?? 1000);
391
+ unsubscribe = this.subscribeLive(signal as never, {
392
+ onEvent: (event: unknown) => {
393
+ if (predicate(event as never)) {
394
+ clearTimeout(timer);
395
+ unsubscribe?.();
396
+ resolve(event as never);
397
+ }
398
+ },
399
+ onError: (err) => {
400
+ clearTimeout(timer);
401
+ unsubscribe?.();
402
+ reject(err);
403
+ },
404
+ });
405
+ });
406
+ },
309
407
  },
310
408
  channels: {
311
409
  sent() {
@@ -340,7 +438,10 @@ export async function createTestApp<App extends OkeApp>(
340
438
  return events;
341
439
  },
342
440
  async close() {
343
- await app.bootResult?.close();
441
+ // Prefer `app.stop()` so the process-wide realtime bridge unbinds with
442
+ // the SQL connections — bare `bootResult.close()` left CDC classifying
443
+ // against a dead PGLite instance for later tests in the same process.
444
+ await app.stop();
344
445
  },
345
446
  };
346
447
 
@@ -381,7 +482,7 @@ function createTestApi(app: OkeApp, now: () => number): TestApi {
381
482
  };
382
483
  }
383
484
 
384
- const principal = toPrincipal(opts?.as);
485
+ const principal = toPrincipal(opts?.as, opts?.tenant);
385
486
  const httpTrigger = flowDef.triggers.find((t) => t.kind === "http");
386
487
  const trigger: Trigger =
387
488
  httpTrigger ?? flowDef.triggers[0] ?? ({ kind: "internal" } satisfies InternalTrigger);
@@ -389,6 +490,7 @@ function createTestApi(app: OkeApp, now: () => number): TestApi {
389
490
  const result = await app.execute(flowDef, input, trigger, {
390
491
  principal,
391
492
  auth: principal,
493
+ ...(principal?.tenantId !== undefined ? { tenant: { id: principal.tenantId } } : {}),
392
494
  });
393
495
 
394
496
  // Keep effects cache warm for t.effects.of after the caller awaits runs().
@@ -533,8 +635,13 @@ function matchRestHeuristic(
533
635
  return undefined;
534
636
  }
535
637
 
536
- function toPrincipal(as: TestUser | ResolvedPrincipal | undefined): ResolvedPrincipal | undefined {
537
- if (!as) return undefined;
638
+ function toPrincipal(
639
+ as: TestUser | ResolvedPrincipal | undefined,
640
+ tenantOverride?: string,
641
+ ): ResolvedPrincipal | undefined {
642
+ if (!as) {
643
+ return tenantOverride ? { tenantId: tenantOverride } : undefined;
644
+ }
538
645
  if ("scopes" in as && as.scopes instanceof Set && "id" in as) {
539
646
  const u = as as TestUser;
540
647
  return {
@@ -542,9 +649,11 @@ function toPrincipal(as: TestUser | ResolvedPrincipal | undefined): ResolvedPrin
542
649
  userId: u.id,
543
650
  scopes: u.scopes,
544
651
  verified: u.verified,
652
+ tenantId: tenantOverride ?? u.tenantId,
545
653
  };
546
654
  }
547
- return as as ResolvedPrincipal;
655
+ const p = as as ResolvedPrincipal;
656
+ return tenantOverride ? { ...p, tenantId: tenantOverride } : p;
548
657
  }
549
658
 
550
659
  /** @internal re-export fail for harness tests that assert typed denials */
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `okengine/testing` subpath export & core/client bundle isolation.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { readFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { measureClientGzipBytes, measureKernelEdgeGzipBytes } from "../release/measure.ts";
9
+ import { CLIENT_BUDGET_BYTES, KERNEL_EDGE_BUDGET_BYTES } from "../release/limits.ts";
10
+
11
+ describe("testing entrypoint export & bundle isolation", () => {
12
+ test("package.json exports ./testing and ./test point to the harness entry", () => {
13
+ const pkg = JSON.parse(
14
+ readFileSync(resolve(import.meta.dir, "../../package.json"), "utf8"),
15
+ ) as {
16
+ exports: Record<string, unknown>;
17
+ };
18
+ const testing = pkg.exports["./testing"] as Record<string, string>;
19
+ expect(testing).toBeDefined();
20
+ expect(testing.bun).toBe("./src/testing.ts");
21
+ expect(testing.types).toBe("./src/testing.ts");
22
+ expect(testing.import).toBe("./dist/testing.js");
23
+ expect(testing.default).toBe("./dist/testing.js");
24
+ expect(pkg.exports["./test"]).toBeDefined();
25
+ });
26
+
27
+ test("importing okengine or okengine/http does not pull the test harness into core/client bundles", async () => {
28
+ const edgeSize = await measureKernelEdgeGzipBytes();
29
+ const clientSize = await measureClientGzipBytes();
30
+ expect(edgeSize).toBeLessThan(KERNEL_EDGE_BUDGET_BYTES);
31
+ expect(clientSize).toBeLessThan(CLIENT_BUDGET_BYTES);
32
+ });
33
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `createTestApp` deterministic live-signal subscriptions
3
+ * (`signals.subscribeLive` / `signals.waitForLive`).
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { z } from "zod";
8
+ import { oke } from "../kernel/app.ts";
9
+ import { flow, resetFlowSeq } from "../kernel/flow.ts";
10
+ import { on, resetBindings } from "../kernel/on.ts";
11
+ import { http } from "../kernel/triggers.ts";
12
+ import { signal } from "../elements/signal.ts";
13
+ import { createTestApp } from "./create-test-app.ts";
14
+
15
+ describe("createTestApp live-signal subscriptions", () => {
16
+ test("subscribeLive observes deterministic live emits; waitForLive resolves matching payloads", async () => {
17
+ resetBindings();
18
+ resetFlowSeq();
19
+
20
+ const orderStatus = signal("order-status", {
21
+ delivery: "live",
22
+ retention: { maxCount: 10 },
23
+ });
24
+
25
+ on(
26
+ http.post("/orders/status").public(),
27
+ flow("orders.updateStatus", {
28
+ in: z.object({ orderId: z.string(), status: z.string() }),
29
+ effects: { emits: ["order-status"] },
30
+ do: async (input, fx) => {
31
+ await fx.emit(orderStatus, input);
32
+ return { ok: true };
33
+ },
34
+ }),
35
+ );
36
+
37
+ const app = oke({
38
+ name: "live-signals-app",
39
+ signals: [orderStatus],
40
+ env: "test",
41
+ });
42
+
43
+ const t = await createTestApp(app, {
44
+ signals: [orderStatus],
45
+ });
46
+
47
+ const seen: Array<{ orderId: string; status: string }> = [];
48
+ const unsubscribe = t.signals.subscribeLive(orderStatus, {
49
+ onEvent: (event) => {
50
+ seen.push(event as { orderId: string; status: string });
51
+ },
52
+ });
53
+
54
+ // Emit 1 — deterministic after drain.
55
+ const res1 = await t.api.orders!.updateStatus!({ orderId: "o1", status: "placed" });
56
+ expect(res1.error).toBeNull();
57
+ await t.signals.drain();
58
+ expect(seen.map((e) => e.status)).toEqual(["placed"]);
59
+
60
+ // Emit 2 — observer updates.
61
+ await t.api.orders!.updateStatus!({ orderId: "o1", status: "shipped" });
62
+ await t.signals.drain();
63
+ expect(seen.map((e) => e.status)).toEqual(["placed", "shipped"]);
64
+
65
+ // waitForLive resolves the first payload matching the predicate.
66
+ const deliveredPromise = t.signals.waitForLive(
67
+ orderStatus,
68
+ (event) => (event as { status: string }).status === "delivered",
69
+ );
70
+ await t.api.orders!.updateStatus!({ orderId: "o1", status: "delivered" });
71
+ await t.signals.drain();
72
+ const delivered = await deliveredPromise;
73
+ expect((delivered as { status: string }).status).toBe("delivered");
74
+
75
+ // Unsubscribe then emit — the subscription must not observe it.
76
+ unsubscribe();
77
+ await t.api.orders!.updateStatus!({ orderId: "o2", status: "placed" });
78
+ await t.signals.drain();
79
+ expect(seen).toHaveLength(3);
80
+
81
+ await t.close();
82
+ });
83
+ });