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
@@ -70,7 +70,9 @@ describe("store facets", () => {
70
70
  });
71
71
 
72
72
  test("id and now helpers", () => {
73
- expect(id()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
73
+ const value = id();
74
+ expect(value).toMatch(/^[A-Za-z0-9_-]+$/);
75
+ expect(value.length).toBe(21);
74
76
  expect(now()).toBeGreaterThan(0);
75
77
  });
76
78
  });
@@ -93,7 +93,15 @@ export type { CacheTier, CacheEntry, InvalidationEvent, StoreCache } from "./sto
93
93
  export { isReadOnlyStoreFlow, sqlRoleForEffects, resolveSqlTarget } from "./store/replica.ts";
94
94
  export type { SqlBindingConfig } from "./store/replica.ts";
95
95
 
96
- export { defineTable, resolveTableName, classificationsFromTable, id, now } from "./store/table.ts";
96
+ export {
97
+ defineTable,
98
+ resolveTableName,
99
+ classificationsFromTable,
100
+ id,
101
+ now,
102
+ nowIso,
103
+ nowDate,
104
+ } from "./store/table.ts";
97
105
  export type { ColumnDef, TableHandle } from "./store/table.ts";
98
106
 
99
107
  export { resource } from "./store/resource.ts";
@@ -109,6 +117,9 @@ export type {
109
117
  ResolvedListConfig,
110
118
  } from "./store/resource.ts";
111
119
 
120
+ export { liveQuery } from "./store/live-query.ts";
121
+ export type { ListOptions } from "./store/list-query.ts";
122
+
112
123
  export {
113
124
  defineSeed,
114
125
  normalizeSeedFns,
@@ -18,12 +18,15 @@
18
18
  * audit-tamper — raw SQL UPDATE on oke_vault_audit (bypass adapter)
19
19
  * rotate-race — call rotateMaster once; write ok/error JSON
20
20
  * read-loop — get(path) until stopPath appears or duration elapses
21
+ * bench-read-loop — read-loop variant that records per-read durationMs
22
+ * (G7 rotate-under-read bench)
21
23
  * set-race — write `count` distinct paths; append to donePath
22
24
  */
23
25
 
24
26
  import { connectPglite } from "../../drivers/pglite.ts";
25
27
  import { connectPostgres } from "../../drivers/postgres.ts";
26
28
  import type { SqlConnection } from "../../drivers/types.ts";
29
+ import { appendFile } from "node:fs/promises";
27
30
  import {
28
31
  createBuiltinVaultAdapter,
29
32
  sqlConnectionAsExec,
@@ -56,7 +59,9 @@ interface RotateState {
56
59
  */
57
60
  async function openSql(sqlUrl: string): Promise<SqlConnection> {
58
61
  if (/^postgres(ql)?:\/\//.test(sqlUrl)) {
59
- return connectPostgres({ url: sqlUrl });
62
+ // Dedicated client: rotation / manual-BEGIN workloads fail on a shared
63
+ // pooled client (ERR_POSTGRES_UNSAFE_TRANSACTION).
64
+ return connectPostgres({ url: sqlUrl, pool: { max: 1 } });
60
65
  }
61
66
  return connectPglite({ url: sqlUrl });
62
67
  }
@@ -390,6 +395,74 @@ if (mode === "read-loop") {
390
395
  }
391
396
  }
392
397
 
398
+ if (mode === "bench-read-loop") {
399
+ const sqlUrl = process.argv[3];
400
+ const masterKey = process.argv[4];
401
+ const path = process.argv[5];
402
+ const readsPath = process.argv[6];
403
+ const stopPath = process.argv[7];
404
+ const durationMs = Number(process.argv[8] ?? "15000");
405
+ if (!sqlUrl || !masterKey || !path || !readsPath || !stopPath) {
406
+ console.error(
407
+ "usage: bench-read-loop <sqlUrl> <masterKey> <path> <readsPath> <stopPath> [durationMs]",
408
+ );
409
+ process.exit(2);
410
+ }
411
+
412
+ const conn = await openSql(sqlUrl);
413
+ try {
414
+ const adapter = createBuiltinVaultAdapter({ db: sqlConnectionAsExec(conn) });
415
+ await adapter.unseal(masterKey);
416
+ // Append-only JSONL without re-reading the file each iteration.
417
+ const lines: string[] = [];
418
+ let flushTimer: ReturnType<typeof setTimeout> | undefined;
419
+ const flush = async () => {
420
+ if (lines.length === 0) return;
421
+ const batch = lines.splice(0).join("");
422
+ await appendFile(readsPath, batch);
423
+ };
424
+ const scheduleFlush = () => {
425
+ if (flushTimer) return;
426
+ flushTimer = setTimeout(() => {
427
+ flushTimer = undefined;
428
+ void flush();
429
+ }, 50);
430
+ };
431
+
432
+ const deadline = Date.now() + durationMs;
433
+ while (Date.now() < deadline) {
434
+ if (await Bun.file(stopPath).exists()) break;
435
+ const s = performance.now();
436
+ try {
437
+ const secret = await adapter.get(path);
438
+ const dur = performance.now() - s;
439
+ lines.push(
440
+ `${JSON.stringify({
441
+ ok: true,
442
+ durMs: Number(dur.toFixed(3)),
443
+ value: secret?.value ?? null,
444
+ kekVersion: secret?.kekVersion ?? null,
445
+ at: Date.now(),
446
+ })}\n`,
447
+ );
448
+ } catch (error) {
449
+ const dur = performance.now() - s;
450
+ const message = error instanceof Error ? error.message : String(error);
451
+ lines.push(
452
+ `${JSON.stringify({ ok: false, durMs: Number(dur.toFixed(3)), error: message, at: Date.now() })}\n`,
453
+ );
454
+ }
455
+ scheduleFlush();
456
+ await Bun.sleep(2);
457
+ }
458
+ if (flushTimer) clearTimeout(flushTimer);
459
+ await flush();
460
+ process.exit(0);
461
+ } finally {
462
+ await conn.close();
463
+ }
464
+ }
465
+
393
466
  if (mode === "set-race") {
394
467
  const sqlUrl = process.argv[3];
395
468
  const masterKey = process.argv[4];
@@ -251,7 +251,7 @@ describe.skipIf(!LIVE_URL)("chaos — 2b. concurrent rotateMaster (multi-process
251
251
  const dir = await mkdtemp(join(tmpdir(), "oke-vault-chaos-rotate-race-"));
252
252
  const resultPath = join(dir, "results.jsonl");
253
253
 
254
- const schema = await connectPostgres({ url });
254
+ const schema = await connectPostgres({ url, pool: { max: 1 } });
255
255
  try {
256
256
  await resetVaultTables(schema);
257
257
  const adapter = createBuiltinVaultAdapter({ db: sqlConnectionAsExec(schema) });
@@ -345,7 +345,9 @@ describe.skipIf(!LIVE_URL)("chaos — 3b. read during rotation (second process,
345
345
  const stopPath = join(dir, "stop");
346
346
  const path = "chaos/cross-read";
347
347
 
348
- const conn = await connectPostgres({ url });
348
+ // Dedicated client: rotateMaster holds manual BEGIN state across
349
+ // statements — the shared pool raises ERR_POSTGRES_UNSAFE_TRANSACTION.
350
+ const conn = await connectPostgres({ url, pool: { max: 1 } });
349
351
  try {
350
352
  await resetVaultTables(conn);
351
353
  const adapter = createBuiltinVaultAdapter({
@@ -153,9 +153,11 @@ CREATE TABLE IF NOT EXISTS oke_vault_status (
153
153
 
154
154
  /**
155
155
  * Claim the rotate-master lease — same predicate as Clock/Signal
156
- * (`FOR UPDATE SKIP LOCKED` + lease-expiry reclaim).
156
+ * (lease-expiry reclaim). Plain `FOR UPDATE` (no `SKIP LOCKED`): a row busy
157
+ * with a concurrent audit append must briefly wait, not be misread as
158
+ * "lease held by another instance".
157
159
  */
158
- export const CLAIM_ROTATE_LEASE_SQL: string = `SELECT id FROM oke_vault_status WHERE id = 1 AND ((rotate_locked_by IS NULL) OR (rotate_locked_by = $1) OR (rotate_lease_expires_at IS NULL) OR (rotate_lease_expires_at <= $2)) FOR UPDATE SKIP LOCKED`;
160
+ export const CLAIM_ROTATE_LEASE_SQL: string = `SELECT id FROM oke_vault_status WHERE id = 1 AND ((rotate_locked_by IS NULL) OR (rotate_locked_by = $1) OR (rotate_lease_expires_at IS NULL) OR (rotate_lease_expires_at <= $2)) FOR UPDATE`;
159
161
 
160
162
  /** Default rotate-master lease TTL (ms) — lazy reclaim, no sweeper. */
161
163
  export const DEFAULT_ROTATE_LEASE_MS: number = 30_000;
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ export {
24
24
  } from "./kernel/app.ts";
25
25
  export { on, type Binding } from "./kernel/on.ts";
26
26
  export { flow, isFlow, type FlowDef } from "./kernel/flow.ts";
27
- export { http, every, internal, table } from "./kernel/triggers.ts";
27
+ export { http, every, internal, table, mcp } from "./kernel/triggers.ts";
28
28
  export { registerFlowUnits } from "./kernel/flow-units.ts";
29
29
  export { stampFlowName, stampHttpPath } from "./kernel/stamp-http.ts";
30
30
  export { fail, type FlowFailure, type FlowErrorValue } from "./kernel/errors.ts";
@@ -51,6 +51,8 @@ export {
51
51
  classify,
52
52
  id,
53
53
  now,
54
+ nowIso,
55
+ nowDate,
54
56
  defineTable,
55
57
  field,
56
58
  defineSeed,
@@ -59,6 +61,7 @@ export {
59
61
  resolveSeedIdentity,
60
62
  seedPromptMessage,
61
63
  createStoreRuntime,
64
+ liveQuery,
62
65
  type StoreDecl,
63
66
  type StoreRuntime,
64
67
  type SqlStoreHandle,
@@ -80,6 +80,7 @@ export function wireGateAuth(options: WireGateAuthOptions): WiredGateAuth {
80
80
  setActiveGateAuthContext({
81
81
  secret: authBinding.secret,
82
82
  sessions: authBinding.sessions,
83
+ identities: auth.identities,
83
84
  now: authBinding.now,
84
85
  passwordPolicy: auth.passwordPolicy,
85
86
  password: auth.password,
package/src/kernel/app.ts CHANGED
@@ -26,6 +26,7 @@ import type { BootOptions, BootResult, ElementRuntimes } from "./boot.ts";
26
26
  import type { CapabilityToken } from "./capability.ts";
27
27
  import type { AppAuthBinding } from "./auth-resolve.ts";
28
28
  import type { ApiKeyStore } from "../auth/api-keys.ts";
29
+ import type { IdentityStore } from "../auth/identity.ts";
29
30
  import type { AuthHttpMaterialization } from "../auth/bindings.ts";
30
31
  import type { WiredGateAuth } from "./app-auth.ts";
31
32
  import {
@@ -42,6 +43,8 @@ import { runWithLocale } from "../i18n/locale-context.ts";
42
43
  import { isFlow, type AnyFlowDef } from "./flow.ts";
43
44
  import { failureFromUnknown, runCompensationPhase } from "./compensate.ts";
44
45
  import { withAbortSignal } from "./abort-scope.ts";
46
+ import { withCdcMutationId } from "../elements/store/sql-session.ts";
47
+ import { MUTATION_ID_HEADER } from "./realtime-bind.ts";
45
48
  import { fxRetry } from "./concurrency.ts";
46
49
  import type {
47
50
  CreateFxOptions,
@@ -125,6 +128,7 @@ import {
125
128
  type SmartRouter,
126
129
  } from "./router.ts";
127
130
  import { isPendingHttpPath } from "./http-path-pending.ts";
131
+ import { drainPendingResourceLiveMounts } from "./resource-live.ts";
128
132
  import type {
129
133
  CdcTrigger,
130
134
  EveryTrigger,
@@ -263,6 +267,16 @@ export interface OkeOptions {
263
267
  readonly clocks?: BootOptions["clocks"];
264
268
  /** Store facet declarations to register. */
265
269
  readonly stores?: BootOptions["stores"];
270
+ /**
271
+ * Store-wide behavior options. `{ live: true }` flips NEW
272
+ * `store.schema.table()` declarations to live-by-default (an automatic
273
+ * CDC + RLS-per-event live stream, same as `.live(table)` /
274
+ * `liveQuery()`) unless a table explicitly opts out with
275
+ * `store.schema.live(false)`. Default `false` — today's explicit-only
276
+ * behavior, zero change to existing apps. Declaration ergonomics only;
277
+ * never the runtime cost model.
278
+ */
279
+ readonly store?: { readonly live?: boolean };
266
280
  /** Channel runtime options. */
267
281
  readonly channel?: BootOptions["channel"];
268
282
  /** AI runtime options. */
@@ -399,6 +413,8 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
399
413
  readonly authBinding: AppAuthBinding | undefined;
400
414
  /** Shared API key store when `gate.auth` is enabled. */
401
415
  readonly apiKeys: ApiKeyStore | undefined;
416
+ /** Shared identity/credential store when `gate.auth` is enabled. */
417
+ readonly identities: IdentityStore | undefined;
402
418
  /**
403
419
  * Attach a plugin app-wide. Scope is the attachment point.
404
420
  * Types accumulate — decorations are visible on downstream handlers.
@@ -546,6 +562,14 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
546
562
  * @param input - Input
547
563
  */
548
564
  call(ref: NamedRef | AnyFlowDef, input?: unknown): Promise<unknown>;
565
+ /**
566
+ * Resolve a user-plane MCP tool exposure by its declared tool name.
567
+ * Returns the bound flow when {@link on}`(`{@link mcp.tool}`(`"name"`).gate(...)`)
568
+ * registered it; `undefined` when absent or ungated (not indexed).
569
+ *
570
+ * @param name - Tool name from `mcp.tool("…")`
571
+ */
572
+ resolveMcpTool(name: string): AnyFlowDef | undefined;
549
573
  /** All adopted bindings. */
550
574
  readonly bindings: readonly Binding[];
551
575
  /**
@@ -692,6 +716,35 @@ function registerHttpRoute(
692
716
  compiled.set(binding, compileHttpBinding(binding, aot));
693
717
  }
694
718
 
719
+ /**
720
+ * Register one MCP tool binding: unique tool name, indexed for dispatch lookup.
721
+ *
722
+ * Only gated exposures are indexed — bare `mcp.tool(name)` bindings remain
723
+ * unlisted (deny-by-default) and are rejected at boot by gate posture audit.
724
+ *
725
+ * @param binding - MCP tool binding
726
+ * @param seenMcpTools - Tool names already registered
727
+ * @param mcpToolsByName - Tool name → binding index for dispatch
728
+ */
729
+ function registerMcpTool(
730
+ binding: Binding,
731
+ seenMcpTools: Set<string>,
732
+ mcpToolsByName: Map<string, Binding>,
733
+ ): void {
734
+ const trigger = binding.trigger;
735
+ if (trigger.kind !== "mcp") return;
736
+ if (seenMcpTools.has(trigger.name)) {
737
+ throwOke("MCP_TOOL_DUPLICATE", {
738
+ tool: trigger.name,
739
+ flow: binding.flow.name || "(unnamed)",
740
+ });
741
+ }
742
+ seenMcpTools.add(trigger.name);
743
+ if (trigger.gates.length > 0) {
744
+ mcpToolsByName.set(trigger.name, binding);
745
+ }
746
+ }
747
+
695
748
  /**
696
749
  * Fold `generated.ts` units into `$routes` and `flowsByName`.
697
750
  *
@@ -832,6 +885,7 @@ export function oke(options: OkeOptions): OkeApp {
832
885
  const $options: OkeOptions = {
833
886
  ...options,
834
887
  ...(effectiveAi !== undefined ? { ai: effectiveAi } : {}),
888
+ ...(effectiveChannel !== undefined ? { channel: effectiveChannel } : {}),
835
889
  ...(effectiveStores.length > 0 ? { stores: effectiveStores } : {}),
836
890
  ...(effectiveSecrets.length > 0 ? { secrets: effectiveSecrets } : {}),
837
891
  ...(effectiveSignals.length > 0 ? { signals: effectiveSignals } : {}),
@@ -873,11 +927,16 @@ export function oke(options: OkeOptions): OkeApp {
873
927
  const smart = createRouter<Binding>(options.router ?? "default");
874
928
  const seenHttpRoutes = new Set<string>();
875
929
  const seenLiveExposures = new Map<string, string>();
930
+ const seenMcpTools = new Set<string>();
931
+ const mcpToolsByName = new Map<string, Binding>();
876
932
  for (const b of adopted) {
877
933
  if (b.trigger.kind === "http") {
878
934
  assertHttpBindingReady(b);
879
935
  registerHttpRoute(b, seenHttpRoutes, seenLiveExposures, smart, compiled, aot);
880
936
  }
937
+ if (b.trigger.kind === "mcp") {
938
+ registerMcpTool(b, seenMcpTools, mcpToolsByName);
939
+ }
881
940
  }
882
941
  // Defer SmartRouter selection until first match so `.plug()` can still
883
942
  // contribute auth-method Bindings before traffic (plan Phase 2).
@@ -890,8 +949,18 @@ export function oke(options: OkeOptions): OkeApp {
890
949
  assertHttpBindingReady(b);
891
950
  registerHttpRoute(b, seenHttpRoutes, seenLiveExposures, smart, compiled, aot);
892
951
  }
952
+ if (b.trigger.kind === "mcp") {
953
+ registerMcpTool(b, seenMcpTools, mcpToolsByName);
954
+ }
893
955
  }
894
956
 
957
+ // Project-wide `store.live` default — drain deferred resource live mounts
958
+ // now that the flag is known. Off flag: pending state is discarded and the
959
+ // behavior is 100% today's explicit-only. On flag: every pending resource
960
+ // whose table did not opt out with `store.schema.live(false)` adopts its
961
+ // `GET <path>/live` SSE binding through the normal route path.
962
+ drainPendingResourceLiveMounts(options.store?.live === true, adoptBinding);
963
+
895
964
  // Phase 1a: mirror tokens into Set-Cookie when gate.auth.cookies.enabled.
896
965
  if (gateConfig.auth?.cookies.enabled) {
897
966
  const cookieCfg = gateConfig.auth.cookies;
@@ -1260,6 +1329,24 @@ export function oke(options: OkeOptions): OkeApp {
1260
1329
  const { bindHostApiKeySqlFromStore } = await import("../auth/api-key-sql.ts");
1261
1330
  await bindHostApiKeySqlFromStore(result.store, gateConfig.auth.apiKeyStore);
1262
1331
  }
1332
+ if (gateConfig.auth?.identities && result.store) {
1333
+ const { bindHostIdentitySqlFromStore } = await import("../auth/identity-sql.ts");
1334
+ await bindHostIdentitySqlFromStore(result.store, gateConfig.auth.identities);
1335
+ }
1336
+ // Realtime bridge — CDC sink + LiveQuery runtime + outbox poller when a
1337
+ // Postgres-capable primary SQL connection exists. No-op otherwise.
1338
+ if (result.store) {
1339
+ const { bindRealtimeBridge } = await import("./realtime-bind.ts");
1340
+ const primaryConn = await result.store.primarySql();
1341
+ if (primaryConn) {
1342
+ bindRealtimeBridge(primaryConn, (tableName, payload) =>
1343
+ app.dispatchCdc(tableName, {
1344
+ before: (payload.before ?? null) as Record<string, unknown> | null,
1345
+ after: (payload.after ?? null) as Record<string, unknown> | null,
1346
+ }),
1347
+ );
1348
+ }
1349
+ }
1263
1350
  // otp() provider / app mode capability — fail loud at boot, never silent downgrade.
1264
1351
  if (result.channel) {
1265
1352
  const { assertOtpPluginCapability } = await import("../auth/otp-capability.ts");
@@ -1360,8 +1447,24 @@ export function oke(options: OkeOptions): OkeApp {
1360
1447
  );
1361
1448
 
1362
1449
  return runWithLocale({ locale: resolvedLocale, defaultLocale }, () => {
1363
- const run = () =>
1364
- executeInLocale({
1450
+ const run = async (): Promise<ExecuteResult> => {
1451
+ // Ambient mutation id — the client's `X-Oke-Mutation-Id` header rides
1452
+ // onto every CDC event this write produces so optimistic clients can
1453
+ // dedupe their own writes (Realtime correctness contract).
1454
+ const mutationId = extras?.request?.headers.get(MUTATION_ID_HEADER)?.trim();
1455
+ if (mutationId !== undefined && mutationId.length > 0) {
1456
+ return withCdcMutationId(mutationId, () =>
1457
+ executeInLocale({
1458
+ flowDef,
1459
+ input,
1460
+ trigger,
1461
+ extras,
1462
+ resolvedLocale,
1463
+ defaultLocale,
1464
+ }),
1465
+ );
1466
+ }
1467
+ return executeInLocale({
1365
1468
  flowDef,
1366
1469
  input,
1367
1470
  trigger,
@@ -1369,6 +1472,7 @@ export function oke(options: OkeOptions): OkeApp {
1369
1472
  resolvedLocale,
1370
1473
  defaultLocale,
1371
1474
  });
1475
+ };
1372
1476
  const abort = extras?.request?.signal;
1373
1477
  return abort ? withAbortSignal(abort, run) : run();
1374
1478
  });
@@ -1901,6 +2005,11 @@ export function oke(options: OkeOptions): OkeApp {
1901
2005
  async stop() {
1902
2006
  if (!bootResult) return;
1903
2007
  bootResult.stopScheduler();
2008
+ // Realtime bridge down before connections close.
2009
+ {
2010
+ const { unbindRealtimeBridge } = await import("./realtime-bind.ts");
2011
+ unbindRealtimeBridge();
2012
+ }
1904
2013
  // Proactive lease release so survivors need not wait for TTL reclaim.
1905
2014
  await releaseInstanceLeases({
1906
2015
  bootResult: {
@@ -1919,6 +2028,8 @@ export function oke(options: OkeOptions): OkeApp {
1919
2028
  return authBinding;
1920
2029
  },
1921
2030
  apiKeys: gateConfig.auth?.apiKeyStore,
2031
+ /** Shared identity store when `gate.auth` is enabled. */
2032
+ identities: gateConfig.auth?.identities,
1922
2033
  async resumeDurable(now) {
1923
2034
  const t = now ?? bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
1924
2035
  const { store, instanceId, leaseMs } = activeJournal();
@@ -2227,6 +2338,9 @@ export function oke(options: OkeOptions): OkeApp {
2227
2338
  if (result.failure) return result.failure;
2228
2339
  return result.output;
2229
2340
  },
2341
+ resolveMcpTool(name) {
2342
+ return mcpToolsByName.get(name)?.flow;
2343
+ },
2230
2344
  compiledFor(binding) {
2231
2345
  return compiled.get(binding);
2232
2346
  },
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Shared identity store + locked account-linking rules across Gate auth method
3
+ * plugins:
4
+ *
5
+ * (b) two different auth methods (e.g. username sign-up then magic-link with
6
+ * the same email) do NOT silently resolve to the same user without an
7
+ * authenticated linking action — `email_in_use` is refused on the second
8
+ * credential rather than auto-linked by email match.
9
+ * (c) linking a new credential while genuinely authenticated as an existing
10
+ * user correctly attaches to that user's real row (passkey precedent,
11
+ * now generalized and enforced for all methods).
12
+ */
13
+
14
+ import { afterEach, describe, expect, test } from "bun:test";
15
+ import { linkOrProvision, type IdentityStore } from "../auth/identity.ts";
16
+ import { oke } from "./app.ts";
17
+ import { resetFlowSeq } from "./flow.ts";
18
+ import { resetBindings } from "./on.ts";
19
+ import { magicLink } from "../plugins/magic-link.ts";
20
+ import { username } from "../plugins/username.ts";
21
+
22
+ afterEach(() => {
23
+ resetBindings();
24
+ resetFlowSeq();
25
+ });
26
+
27
+ const SECRET = "test-secret-at-least-16";
28
+
29
+ function jsonPost(path: string, body: unknown, headers: Record<string, string> = {}): Request {
30
+ return new Request(`http://localhost${path}`, {
31
+ method: "POST",
32
+ headers: { "content-type": "application/json", ...headers },
33
+ body: JSON.stringify(body),
34
+ });
35
+ }
36
+
37
+ function authApp() {
38
+ return oke({
39
+ name: `link-${crypto.randomUUID()}`,
40
+ env: "test",
41
+ registry: "ignore",
42
+ gate: {
43
+ auth: {
44
+ secret: SECRET,
45
+ emailAndPassword: { enabled: true },
46
+ },
47
+ },
48
+ })
49
+ .plug(username())
50
+ .plug(magicLink({ exposeDevToken: true }));
51
+ }
52
+
53
+ describe("shared identity store — method plugins share one store", () => {
54
+ test("username, magic-link, and gate.auth resolve against the app's single store", async () => {
55
+ const app = authApp();
56
+ await app.boot({ env: "test" });
57
+ const identities = app.identities as IdentityStore | undefined;
58
+ expect(identities).toBeDefined();
59
+
60
+ const signUp = await app.fetch(
61
+ jsonPost("/auth/sign-up/username", {
62
+ username: "shared_store_user",
63
+ password: "CorrectHorse1!",
64
+ }),
65
+ );
66
+ expect(signUp.status).toBe(200);
67
+ const created = (await signUp.json()) as { data: { userId: string } };
68
+
69
+ // The identity store now holds the username credential account.
70
+ const usernameAccount = [...identities!.accounts.values()].find(
71
+ (a) => a.provider === "username",
72
+ );
73
+ expect(usernameAccount?.userId).toBe(created.data.userId);
74
+
75
+ // A magic-link sign-in for the same email must NOT silently adopt the
76
+ // username principal — it provisions its own user (or is refused).
77
+ const req = await app.fetch(
78
+ jsonPost("/auth/magic-link/request", { email: "shared_store_user@example.com" }),
79
+ );
80
+ const devToken = ((await req.json()) as { data: { devToken: string } }).data.devToken;
81
+ const verify = await app.fetch(jsonPost("/auth/magic-link/verify", { token: devToken }));
82
+ const magic = (await verify.json()) as { data: { userId: string } };
83
+ expect(magic.data.userId).not.toBe(created.data.userId);
84
+
85
+ await app.stop();
86
+ });
87
+ });
88
+
89
+ describe("locked account-linking rule (never email-match auto-link)", () => {
90
+ test("a second unauthenticated credential for an email owned by another user is refused (email_in_use)", async () => {
91
+ const app = authApp();
92
+ await app.boot({ env: "test" });
93
+ const identities = app.identities as IdentityStore;
94
+
95
+ // Provision a magic-link user with an email.
96
+ const first = await linkOrProvision(identities, {
97
+ provider: "magic-link",
98
+ providerAccountId: "linkme@example.com",
99
+ email: "linkme@example.com",
100
+ emailVerified: true,
101
+ now: () => 3_000_000,
102
+ });
103
+ expect(first.created).toBe(true);
104
+
105
+ // A new unauthenticated credential claiming the same email must be refused,
106
+ // NOT auto-linked to the magic-link user by email match.
107
+ let refused: string | undefined;
108
+ try {
109
+ await linkOrProvision(identities, {
110
+ provider: "otp",
111
+ providerAccountId: "linkme@example.com",
112
+ email: "linkme@example.com",
113
+ now: () => 3_000_100,
114
+ });
115
+ } catch (err) {
116
+ refused = err instanceof Error && "code" in err ? (err as { code: string }).code : undefined;
117
+ }
118
+ expect(refused).toBe("email_in_use");
119
+
120
+ await app.stop();
121
+ });
122
+
123
+ test("username sign-up then magic-link with the same email do NOT resolve to the same user (real HTTP)", async () => {
124
+ const app = authApp();
125
+ await app.boot({ env: "test" });
126
+
127
+ const signUp = await app.fetch(
128
+ jsonPost("/auth/sign-up/username", {
129
+ username: "dual_identity",
130
+ password: "CorrectHorse1!",
131
+ }),
132
+ );
133
+ expect(signUp.status).toBe(200);
134
+ const created = (await signUp.json()) as { data: { userId: string } };
135
+
136
+ // magic-link with the same email as an identifier: must NOT silently resolve
137
+ // to the username user.
138
+ const req = await app.fetch(
139
+ jsonPost("/auth/magic-link/request", { email: "dual@example.com" }),
140
+ );
141
+ const devToken = ((await req.json()) as { data: { devToken: string } }).data.devToken;
142
+ const verify = await app.fetch(jsonPost("/auth/magic-link/verify", { token: devToken }));
143
+ const magic = (await verify.json()) as { data: { userId: string } };
144
+ expect(magic.data.userId).not.toBe(created.data.userId);
145
+
146
+ // And the username user resolves via sign-in to the SAME id as before.
147
+ const signIn = await app.fetch(
148
+ jsonPost("/auth/sign-in/username", {
149
+ username: "dual_identity",
150
+ password: "CorrectHorse1!",
151
+ }),
152
+ );
153
+ const session = (await signIn.json()) as { data: { userId: string } };
154
+ expect(session.data.userId).toBe(created.data.userId);
155
+
156
+ await app.stop();
157
+ });
158
+
159
+ test("authenticated linking attaches a second credential to the same user row", async () => {
160
+ const app = authApp();
161
+ await app.boot({ env: "test" });
162
+ const identities = app.identities as IdentityStore;
163
+
164
+ const signUp = await app.fetch(
165
+ jsonPost("/auth/sign-up/username", {
166
+ username: "linked_user",
167
+ password: "CorrectHorse1!",
168
+ }),
169
+ );
170
+ expect(signUp.status).toBe(200);
171
+ const created = (await signUp.json()) as { data: { accessToken: string; userId: string } };
172
+
173
+ // Authenticated linking: attach an OTP credential to the same user while
174
+ // holding the user's session (matching the passkey precedent).
175
+ const linked = await linkOrProvision(identities, {
176
+ provider: "otp",
177
+ providerAccountId: "linked_phone",
178
+ currentUserId: created.data.userId,
179
+ now: () => 4_000_000,
180
+ });
181
+ expect(linked.user.id).toBe(created.data.userId);
182
+ expect(linked.account.userId).toBe(created.data.userId);
183
+ expect(linked.created).toBe(true);
184
+
185
+ // Signing in via the new credential resolves to the SAME user.
186
+ const res = await linkOrProvision(identities, {
187
+ provider: "otp",
188
+ providerAccountId: "linked_phone",
189
+ now: () => 4_000_001,
190
+ });
191
+ expect(res.user.id).toBe(created.data.userId);
192
+ expect(res.created).toBe(false);
193
+
194
+ await app.stop();
195
+ });
196
+ });
@@ -116,9 +116,9 @@ describe("boot — lazy element needs", () => {
116
116
  if (raw.byteLength === 0) continue;
117
117
  total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
118
118
  }
119
- // Rebased after host API-key persist + allowlist on `oke()`.
120
- // (~50.3 kB gzip with export externals).
121
- expect(total).toBeLessThan(51_000);
119
+ // Rebased after host API-key persist + allowlist on `oke()` and the
120
+ // realtime bridge (CDC sink, LiveQuery runtime, openLiveStream).
121
+ expect(total).toBeLessThan(58_000);
122
122
  } finally {
123
123
  await rm(dir, { recursive: true, force: true });
124
124
  }
@@ -216,6 +216,14 @@ export const OKE_ERRORS = {
216
216
  'Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).',
217
217
  fix: "Use a different gate or path-param filter, or drop the extra route.",
218
218
  },
219
+ /**
220
+ * Two MCP tool bindings share the same exposed tool name.
221
+ */
222
+ MCP_TOOL_DUPLICATE: {
223
+ code: 1018,
224
+ cause: 'MCP tool "{tool}" is bound twice (flow "{flow}").',
225
+ fix: "Give each MCP tool exposure a unique tool name.",
226
+ },
219
227
  /**
220
228
  * Emit target has no subscriber (unified-theory §21).
221
229
  * Thrown at emit when `optional` is false and nobody is subscribed.
@@ -574,6 +574,7 @@ describe("fx — wholesale swap", () => {
574
574
  get signal() {
575
575
  return new AbortController().signal;
576
576
  },
577
+ rlsIdentity: undefined,
577
578
  all(thunks) {
578
579
  return Promise.all(thunks.map((t) => t())) as never;
579
580
  },