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.
- package/README.md +9 -5
- package/manifest.v1.schema.json +61 -2
- package/package.json +18 -4
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/store.mdx +287 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/installation.mdx +1 -2
- package/site/content/docs/get-started/introduction.mdx +54 -110
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +94 -70
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +4 -1
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- 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
|
-
|
|
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
|
});
|
package/src/elements/store.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
|
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,
|
package/src/kernel/app-auth.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/kernel/boot.test.ts
CHANGED
|
@@ -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
|
-
// (
|
|
121
|
-
expect(total).toBeLessThan(
|
|
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
|
}
|
package/src/kernel/errors.ts
CHANGED
|
@@ -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.
|
package/src/kernel/fx.test.ts
CHANGED