okengine 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/site/content/docs/deployment/docker-swarm.mdx +228 -0
- package/site/content/docs/deployment/docker.mdx +212 -0
- package/site/content/docs/deployment/index.mdx +83 -0
- package/site/content/docs/deployment/kubernetes.mdx +176 -0
- package/site/content/docs/deployment/meta.json +5 -0
- package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
- package/site/content/docs/elements/channel.mdx +3 -1
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +34 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/index.mdx +5 -0
- package/site/content/docs/meta.json +10 -1
- package/site/content/docs/plugins/index.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +2 -2
- package/site/content/docs/plugins/meta.json +1 -2
- package/site/content/docs/plugins/otp.mdx +202 -0
- package/site/content/docs/plugins/two-factor.mdx +2 -2
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/environment-variables.mdx +9 -8
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/src/auth/auth.test.ts +36 -0
- package/src/auth/bindings.ts +3 -12
- package/src/auth/identity.ts +33 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/otp-capability.ts +119 -0
- package/src/auth/otp-seal.test.ts +61 -0
- package/src/auth/otp-seal.ts +84 -0
- package/src/auth/schema.ts +3 -0
- package/src/auth/sessions.ts +26 -27
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +61 -1
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +4 -0
- package/src/cli/load-config.ts +3 -0
- package/src/cli/registry.ts +1 -1
- package/src/console/server/operator-db.ts +34 -9
- package/src/docker/compose.ts +162 -6
- package/src/docker/derive.ts +60 -3
- package/src/docker/docker.test.ts +374 -1
- package/src/docker/helpers.ts +2 -0
- package/src/docker/index.ts +11 -0
- package/src/docker/recipes/caddy.ts +51 -0
- package/src/docker/recipes/dragonfly.ts +31 -0
- package/src/docker/recipes/index.ts +25 -2
- package/src/docker/recipes/pgdog.ts +84 -0
- package/src/docker/recipes/redis.ts +6 -3
- package/src/docker/recipes/traefik.ts +83 -0
- package/src/docker/recipes/valkey.ts +30 -0
- package/src/docker/stack-id.ts +5 -0
- package/src/docker/types.ts +18 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +1 -0
- package/src/elements/channel/otp-delivery.test.ts +76 -0
- package/src/elements/channel/otp-delivery.ts +291 -0
- package/src/elements/channel/runtime.ts +152 -114
- package/src/elements/channel.ts +12 -2
- package/src/index.ts +3 -0
- package/src/kernel/app.ts +60 -4
- package/src/kernel/boot-bind/channel.ts +51 -0
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +18 -0
- package/src/kernel/boot-bind/signal.ts +20 -0
- package/src/kernel/boot-bind/store.test.ts +82 -0
- package/src/kernel/boot-bind/store.ts +22 -0
- package/src/kernel/boot.test.ts +5 -5
- package/src/kernel/fx.test.ts +3 -0
- package/src/kernel/fx.ts +49 -0
- package/src/kernel/graceful-shutdown.test.ts +76 -0
- package/src/kernel/graceful-shutdown.ts +106 -0
- package/src/kernel/horizontal-child.ts +257 -0
- package/src/kernel/horizontal.integration.test.ts +229 -0
- package/src/kernel/index.ts +8 -0
- package/src/kernel/ready.test.ts +76 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
- package/src/plugins/auth-methods.security.test.ts +19 -29
- package/src/plugins/auth-methods.test.ts +7 -6
- package/src/plugins/index.ts +12 -8
- package/src/plugins/magic-link.ts +1 -23
- package/src/plugins/otp.test.ts +236 -0
- package/src/plugins/otp.ts +570 -0
- package/src/plugins/taqnyat.live.test.ts +4 -6
- package/src/release/official-plugins.ts +1 -2
- package/site/content/docs/plugins/email-otp.mdx +0 -117
- package/site/content/docs/plugins/phone-number.mdx +0 -172
- package/src/plugins/email-otp.ts +0 -214
- package/src/plugins/phone-number.ts +0 -206
package/src/kernel/app.ts
CHANGED
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
type JournalSession,
|
|
79
79
|
type JournalStore,
|
|
80
80
|
} from "./journal.ts";
|
|
81
|
+
import { releaseInstanceLeases } from "./graceful-shutdown.ts";
|
|
81
82
|
import type { JournalRuntime } from "./boot-bind/journal.ts";
|
|
82
83
|
import { listBindings, resetBindings, type Binding } from "./on.ts";
|
|
83
84
|
import {
|
|
@@ -199,6 +200,9 @@ export interface OkeOptions {
|
|
|
199
200
|
readonly journalLeaseMs?: number;
|
|
200
201
|
}
|
|
201
202
|
|
|
203
|
+
/** Readiness probe state — see `GET /_/ready`. */
|
|
204
|
+
export type ReadyState = "booting" | "orphan_scan" | "ready";
|
|
205
|
+
|
|
202
206
|
/** Payload for a CDC invocation. */
|
|
203
207
|
export interface CdcPayload {
|
|
204
208
|
readonly before: Record<string, unknown> | null;
|
|
@@ -257,6 +261,11 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
|
|
|
257
261
|
readonly plugins: PluginRegistry;
|
|
258
262
|
/** Whether {@link OkeApp.boot} has completed. */
|
|
259
263
|
readonly booted: boolean;
|
|
264
|
+
/**
|
|
265
|
+
* Readiness for probes — `booting` until boot returns, `orphan_scan` while
|
|
266
|
+
* the durable orphan resume runs, then `ready`. See `GET /_/ready`.
|
|
267
|
+
*/
|
|
268
|
+
readonly readyState: ReadyState;
|
|
260
269
|
/** Result of {@link OkeApp.boot} (element runtimes, capabilities). */
|
|
261
270
|
readonly bootResult: BootResult | undefined;
|
|
262
271
|
/**
|
|
@@ -565,6 +574,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
565
574
|
// --- boot (vault → store → signal → clock → channel → AI → runs → caps) ---
|
|
566
575
|
let bootResult: BootResult | undefined;
|
|
567
576
|
let bootPromise: Promise<BootResult> | undefined;
|
|
577
|
+
let readyState: ReadyState = "booting";
|
|
568
578
|
let bootEnv: BootOptions["env"] = options.env ?? "local";
|
|
569
579
|
let authBinding: AppAuthBinding | undefined =
|
|
570
580
|
gateConfig.auth !== undefined
|
|
@@ -834,13 +844,29 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
834
844
|
};
|
|
835
845
|
const result = await bootApplication(merged);
|
|
836
846
|
bootResult = result;
|
|
847
|
+
// otp() Tier 1 / Tier 2 capability — fail loud at boot, never silent downgrade.
|
|
848
|
+
if (result.channel) {
|
|
849
|
+
const { assertOtpPluginCapability } = await import("../auth/otp-capability.ts");
|
|
850
|
+
for (const entry of pluginRegistry.installed) {
|
|
851
|
+
if (entry.plugin.name === "otp") {
|
|
852
|
+
assertOtpPluginCapability(entry.plugin.configSnapshot, result.channel.drivers);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
837
856
|
// Boot-time orphan discovery: resume/schedule any `running` / `sleeping`
|
|
838
857
|
// run left without a live lease by a crashed (or previous) instance.
|
|
839
|
-
//
|
|
858
|
+
// Does not block boot return — readiness stays `orphan_scan` until done.
|
|
840
859
|
if (result.journal && hasJournalLease(result.journal.store)) {
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
860
|
+
readyState = "orphan_scan";
|
|
861
|
+
void resumeOrphanedDurableRuns()
|
|
862
|
+
.catch((err) => {
|
|
863
|
+
console.error("oke: durable orphan scan failed", err);
|
|
864
|
+
})
|
|
865
|
+
.finally(() => {
|
|
866
|
+
if (bootResult === result) readyState = "ready";
|
|
867
|
+
});
|
|
868
|
+
} else {
|
|
869
|
+
readyState = "ready";
|
|
844
870
|
}
|
|
845
871
|
// Prefer the booted clock for access-token expiry checks.
|
|
846
872
|
if (authBinding) {
|
|
@@ -1196,6 +1222,9 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1196
1222
|
get booted() {
|
|
1197
1223
|
return bootResult !== undefined;
|
|
1198
1224
|
},
|
|
1225
|
+
get readyState() {
|
|
1226
|
+
return readyState;
|
|
1227
|
+
},
|
|
1199
1228
|
get bootResult() {
|
|
1200
1229
|
return bootResult;
|
|
1201
1230
|
},
|
|
@@ -1227,9 +1256,18 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1227
1256
|
async stop() {
|
|
1228
1257
|
if (!bootResult) return;
|
|
1229
1258
|
bootResult.stopScheduler();
|
|
1259
|
+
// Proactive lease release so survivors need not wait for TTL reclaim.
|
|
1260
|
+
await releaseInstanceLeases({
|
|
1261
|
+
bootResult: {
|
|
1262
|
+
clock: bootResult.clock,
|
|
1263
|
+
journal: bootResult.journal,
|
|
1264
|
+
},
|
|
1265
|
+
stop: async () => {},
|
|
1266
|
+
});
|
|
1230
1267
|
await bootResult.close();
|
|
1231
1268
|
bootResult = undefined;
|
|
1232
1269
|
bootPromise = undefined;
|
|
1270
|
+
readyState = "booting";
|
|
1233
1271
|
},
|
|
1234
1272
|
get authBinding() {
|
|
1235
1273
|
return authBinding;
|
|
@@ -1347,6 +1385,24 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1347
1385
|
return response;
|
|
1348
1386
|
};
|
|
1349
1387
|
|
|
1388
|
+
// Kernel readiness — distinct from app-authored GET /health (liveness).
|
|
1389
|
+
if (method === "GET" && url.pathname === "/_/ready") {
|
|
1390
|
+
if (readyState === "ready") {
|
|
1391
|
+
return respond(
|
|
1392
|
+
new Response(JSON.stringify({ ready: true }), {
|
|
1393
|
+
status: 200,
|
|
1394
|
+
headers: { "content-type": "application/json" },
|
|
1395
|
+
}),
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
return respond(
|
|
1399
|
+
new Response(JSON.stringify({ ready: false, reason: readyState }), {
|
|
1400
|
+
status: 503,
|
|
1401
|
+
headers: { "content-type": "application/json" },
|
|
1402
|
+
}),
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1350
1406
|
if (method === "GET" && url.pathname === "/_oke/client.json") {
|
|
1351
1407
|
return respond(
|
|
1352
1408
|
new Response(JSON.stringify(routes), {
|
|
@@ -10,7 +10,9 @@ import { openSmtpChannel } from "../../drivers/channel-smtp.ts";
|
|
|
10
10
|
import { openSndrChannel } from "../../drivers/channel-sndr.ts";
|
|
11
11
|
import { openTaqnyatChannel } from "../../drivers/channel-taqnyat.ts";
|
|
12
12
|
import { openTaqnyatMailChannel } from "../../drivers/channel-taqnyat-mail.ts";
|
|
13
|
+
import { openTaqnyatWhatsAppChannel } from "../../drivers/channel-taqnyat-whatsapp.ts";
|
|
13
14
|
import { openUnifonicChannel } from "../../drivers/channel-unifonic.ts";
|
|
15
|
+
import { openWaCloudChannel } from "../../drivers/channel-wa-cloud.ts";
|
|
14
16
|
import type { ChannelDriver, ChannelOpenOptions } from "../../drivers/channel-types.ts";
|
|
15
17
|
import { createChannelRuntime, type ChannelRuntime } from "../../elements/channel.ts";
|
|
16
18
|
import type { BootOptions } from "../boot.ts";
|
|
@@ -43,6 +45,8 @@ function defaultDrivers(options: BootOptions, env: ConfigEnv, docker: boolean):
|
|
|
43
45
|
const drivers: ChannelDriver[] = [emailDriverFor(options, env, docker)];
|
|
44
46
|
const sms = smsDriverFor(options, env);
|
|
45
47
|
if (sms) drivers.push(sms);
|
|
48
|
+
const whatsapp = whatsappDriverFor(options, env);
|
|
49
|
+
if (whatsapp) drivers.push(whatsapp);
|
|
46
50
|
return drivers;
|
|
47
51
|
}
|
|
48
52
|
|
|
@@ -92,6 +96,53 @@ export function resolveSmsDriverId(options: BootOptions, env: ConfigEnv): string
|
|
|
92
96
|
return resolveDriverId(options.config?.drivers?.channel?.sms, env);
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the configured WhatsApp driver for one environment.
|
|
101
|
+
*
|
|
102
|
+
* @param options - Boot options
|
|
103
|
+
* @param env - Active environment
|
|
104
|
+
*/
|
|
105
|
+
export function resolveWhatsappDriverId(options: BootOptions, env: ConfigEnv): string | undefined {
|
|
106
|
+
return resolveDriverId(options.config?.drivers?.channel?.whatsapp, env);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function whatsappDriverFor(options: BootOptions, env: ConfigEnv): ChannelDriver | undefined {
|
|
110
|
+
const id = resolveWhatsappDriverId(options, env);
|
|
111
|
+
if (!id || id === "console") return undefined;
|
|
112
|
+
if (id === "taqnyat-whatsapp") return openTaqnyatWhatsAppChannel(taqnyatWhatsAppOptionsFromEnv());
|
|
113
|
+
if (id === "wa-cloud") return openWaCloudChannel(waCloudOptionsFromEnv());
|
|
114
|
+
throw new Error(`oke boot: unknown whatsapp channel driver "${id}"`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve Taqnyat WhatsApp options from env.
|
|
119
|
+
*/
|
|
120
|
+
export function taqnyatWhatsAppOptionsFromEnv(): ChannelOpenOptions {
|
|
121
|
+
const bearerToken =
|
|
122
|
+
process.env.TAQNYAT_WHATSAPP_TOKEN?.trim() ??
|
|
123
|
+
process.env.TAQNYAT_BEARER_TOKEN?.trim() ??
|
|
124
|
+
process.env.TAQNYAT_TOKEN?.trim();
|
|
125
|
+
if (!bearerToken) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
"oke boot: taqnyat-whatsapp channel needs TAQNYAT_WHATSAPP_TOKEN (or TAQNYAT_BEARER_TOKEN)",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return { bearerToken };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve WhatsApp Cloud API options from env.
|
|
135
|
+
*/
|
|
136
|
+
export function waCloudOptionsFromEnv(): ChannelOpenOptions {
|
|
137
|
+
const token = process.env.WHATSAPP_TOKEN?.trim() ?? process.env.WA_CLOUD_TOKEN?.trim();
|
|
138
|
+
const from =
|
|
139
|
+
process.env.WHATSAPP_PHONE_NUMBER_ID?.trim() ?? process.env.WA_CLOUD_PHONE_NUMBER_ID?.trim();
|
|
140
|
+
if (!token || !from) {
|
|
141
|
+
throw new Error("oke boot: wa-cloud channel needs WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID");
|
|
142
|
+
}
|
|
143
|
+
return { token, from };
|
|
144
|
+
}
|
|
145
|
+
|
|
95
146
|
/**
|
|
96
147
|
* Resolve SMTP connection options from `SMTP_URL` plus optional auth overrides.
|
|
97
148
|
*
|
|
@@ -33,15 +33,14 @@ export function resolveGateKvDriverId(
|
|
|
33
33
|
return docker ? "redis" : "memory";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function kvUrlFor(docker: boolean): string
|
|
36
|
+
function kvUrlFor(docker: boolean): string {
|
|
37
37
|
const url = process.env.REDIS_URL ?? process.env.OKE_STORE_KV_URL ?? undefined;
|
|
38
38
|
if (!url) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"oke boot: gate redis kv needs REDIS_URL (did `oke dev -d` write docker/.env.docker?)"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return undefined;
|
|
39
|
+
throw new Error(
|
|
40
|
+
docker
|
|
41
|
+
? "oke boot: gate redis kv needs REDIS_URL (did `oke dev -d` write docker/.env.docker?)"
|
|
42
|
+
: "oke boot: gate redis kv needs REDIS_URL — declare drivers.store.kv memory for local, or set REDIS_URL",
|
|
43
|
+
);
|
|
45
44
|
}
|
|
46
45
|
return url;
|
|
47
46
|
}
|
|
@@ -65,19 +64,15 @@ export async function bindGate(
|
|
|
65
64
|
|
|
66
65
|
let kvNs;
|
|
67
66
|
if (kvId === "redis") {
|
|
67
|
+
// Fail loud when redis is configured without URL/inject — never soft-fallback
|
|
68
|
+
// to memory (that silently doubles rate budgets under horizontal scale).
|
|
68
69
|
const url = injected ? undefined : kvUrlFor(docker);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
name: GATE_KV_NAMESPACE,
|
|
76
|
-
...(url !== undefined ? { url } : {}),
|
|
77
|
-
...(injected !== undefined ? { client: injected } : {}),
|
|
78
|
-
nowMs: now,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
70
|
+
kvNs = await redisDriver.open({
|
|
71
|
+
name: GATE_KV_NAMESPACE,
|
|
72
|
+
...(url !== undefined ? { url } : {}),
|
|
73
|
+
...(injected !== undefined ? { client: injected } : {}),
|
|
74
|
+
nowMs: now,
|
|
75
|
+
});
|
|
81
76
|
} else {
|
|
82
77
|
kvNs = await memoryDrivers.kv.open({ name: GATE_KV_NAMESPACE, nowMs: now });
|
|
83
78
|
}
|
|
@@ -263,6 +263,24 @@ describe("boot binders honour drivers.* config", () => {
|
|
|
263
263
|
}
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
+
test("gate: drivers.store.kv redis without REDIS_URL fails loud (never soft memory)", async () => {
|
|
267
|
+
delete process.env.REDIS_URL;
|
|
268
|
+
delete process.env.OKE_STORE_KV_URL;
|
|
269
|
+
await expect(
|
|
270
|
+
bootApplication({
|
|
271
|
+
env: "local",
|
|
272
|
+
gates: [gate.rate({ max: 10, per: "1m" })],
|
|
273
|
+
config: {
|
|
274
|
+
drivers: {
|
|
275
|
+
store: {
|
|
276
|
+
kv: { local: "redis", docker: "redis", prod: "redis" },
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
).rejects.toThrow(/gate redis kv needs REDIS_URL/);
|
|
282
|
+
});
|
|
283
|
+
|
|
266
284
|
test("gate: drivers.store.kv memory keeps memory oke:gates", async () => {
|
|
267
285
|
const result = await bootApplication({
|
|
268
286
|
env: "local",
|
|
@@ -29,6 +29,25 @@ function redisUrlFor(docker: boolean): string | undefined {
|
|
|
29
29
|
return url;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
let signalRedisWarned = false;
|
|
33
|
+
|
|
34
|
+
/** One-shot: redis Signal is emit-relay + process-local outbox consume today. */
|
|
35
|
+
function warnSignalRedisProcessLocal(): void {
|
|
36
|
+
if (signalRedisWarned) return;
|
|
37
|
+
signalRedisWarned = true;
|
|
38
|
+
console.warn(
|
|
39
|
+
"oke boot: drivers.signal redis — emit relays to Redis, but consume/live/drain use a " +
|
|
40
|
+
"process-local outbox (not multi-instance competing consumers). Prefer a shared durable " +
|
|
41
|
+
"outbox path for multi-process tests, or a single consumer instance, until Redis Streams " +
|
|
42
|
+
"consume ships.",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Test helper — reset the one-shot redis Signal warn. */
|
|
47
|
+
export function resetSignalRedisWarnForTests(): void {
|
|
48
|
+
signalRedisWarned = false;
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
/**
|
|
33
52
|
* Construct a Signal runtime, register decls / binding names, start the bus.
|
|
34
53
|
*
|
|
@@ -59,6 +78,7 @@ export async function bindSignal(
|
|
|
59
78
|
break;
|
|
60
79
|
case "redis": {
|
|
61
80
|
const redis = injectedRedis ?? createBunSignalRedisClient(redisUrlFor(docker));
|
|
81
|
+
warnSignalRedisProcessLocal();
|
|
62
82
|
signal = createSignalRuntime({
|
|
63
83
|
driver: redisSignalDriver,
|
|
64
84
|
now,
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { files } from "../../elements/store.ts";
|
|
6
7
|
import {
|
|
8
|
+
bindStore,
|
|
7
9
|
indexDriverFor,
|
|
10
|
+
resetFilesFsWarnForTests,
|
|
8
11
|
resolveFilesDriverId,
|
|
9
12
|
resolveIndexDriverId,
|
|
10
13
|
resolveKvDriverId,
|
|
@@ -77,6 +80,85 @@ describe("bindStore driver resolution", () => {
|
|
|
77
80
|
});
|
|
78
81
|
});
|
|
79
82
|
|
|
83
|
+
describe("bindStore files fs multi-instance warn", () => {
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
resetFilesFsWarnForTests();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("warns once when files driver is fs", () => {
|
|
89
|
+
const warnings: string[] = [];
|
|
90
|
+
const prev = console.warn;
|
|
91
|
+
console.warn = (...args: unknown[]) => {
|
|
92
|
+
warnings.push(args.map(String).join(" "));
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
const uploads = files("uploads");
|
|
96
|
+
bindStore(
|
|
97
|
+
{
|
|
98
|
+
stores: [uploads],
|
|
99
|
+
config: {
|
|
100
|
+
drivers: {
|
|
101
|
+
store: {
|
|
102
|
+
files: { local: "fs", docker: "s3", prod: "s3" },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
"local",
|
|
108
|
+
() => Date.now(),
|
|
109
|
+
false,
|
|
110
|
+
);
|
|
111
|
+
bindStore(
|
|
112
|
+
{
|
|
113
|
+
stores: [uploads],
|
|
114
|
+
config: {
|
|
115
|
+
drivers: {
|
|
116
|
+
store: {
|
|
117
|
+
files: { local: "fs", docker: "s3", prod: "s3" },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
"local",
|
|
123
|
+
() => Date.now(),
|
|
124
|
+
false,
|
|
125
|
+
);
|
|
126
|
+
expect(warnings.some((w) => w.includes('drivers.store.files "fs"'))).toBe(true);
|
|
127
|
+
expect(warnings.filter((w) => w.includes('drivers.store.files "fs"'))).toHaveLength(1);
|
|
128
|
+
} finally {
|
|
129
|
+
console.warn = prev;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("does not warn for memory or s3 files", () => {
|
|
134
|
+
const warnings: string[] = [];
|
|
135
|
+
const prev = console.warn;
|
|
136
|
+
console.warn = (...args: unknown[]) => {
|
|
137
|
+
warnings.push(args.map(String).join(" "));
|
|
138
|
+
};
|
|
139
|
+
try {
|
|
140
|
+
bindStore(
|
|
141
|
+
{
|
|
142
|
+
stores: [files("uploads")],
|
|
143
|
+
config: {
|
|
144
|
+
drivers: {
|
|
145
|
+
store: {
|
|
146
|
+
files: { local: "memory", docker: "s3", prod: "s3" },
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
"local",
|
|
152
|
+
() => Date.now(),
|
|
153
|
+
false,
|
|
154
|
+
);
|
|
155
|
+
expect(warnings.some((w) => w.includes('drivers.store.files "fs"'))).toBe(false);
|
|
156
|
+
} finally {
|
|
157
|
+
console.warn = prev;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
80
162
|
describe("bindStore index driver resolution", () => {
|
|
81
163
|
const prevIndex = process.env.OKE_INDEX_DRIVER;
|
|
82
164
|
|
|
@@ -29,6 +29,27 @@ import type { BootOptions } from "../boot.ts"; // type-only — no cycle at runt
|
|
|
29
29
|
* @param now - Clock
|
|
30
30
|
* @param docker - Prefer compose URLs when opening postgres/redis
|
|
31
31
|
*/
|
|
32
|
+
let filesFsWarned = false;
|
|
33
|
+
|
|
34
|
+
/** Test helper — reset the one-shot `fs` multi-instance warn. */
|
|
35
|
+
export function resetFilesFsWarnForTests(): void {
|
|
36
|
+
filesFsWarned = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Warn once when `drivers.store.files` is `fs` — per-process (or per-pod)
|
|
41
|
+
* filesystem visibility under horizontal scale.
|
|
42
|
+
*/
|
|
43
|
+
function warnFilesFsMultiInstance(): void {
|
|
44
|
+
if (filesFsWarned) return;
|
|
45
|
+
filesFsWarned = true;
|
|
46
|
+
console.warn(
|
|
47
|
+
'oke boot: drivers.store.files "fs" is single-host — each instance sees its own ' +
|
|
48
|
+
"filesystem (temp root when unbound). For horizontal scale use drivers.store.files s3 " +
|
|
49
|
+
"(or an explicitly shared volume root, still single-host semantics).",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
export function bindStore(
|
|
33
54
|
options: BootOptions,
|
|
34
55
|
env: ConfigEnv,
|
|
@@ -39,6 +60,7 @@ export function bindStore(
|
|
|
39
60
|
const kvId = resolveKvDriverId(options, env, docker);
|
|
40
61
|
const filesId = resolveFilesDriverId(options, env, docker);
|
|
41
62
|
const indexId = resolveIndexDriverId(options, env, docker);
|
|
63
|
+
if (filesId === "fs") warnFilesFsMultiInstance();
|
|
42
64
|
const sqlUrl = sqlUrlFor(sqlId, docker);
|
|
43
65
|
const kvUrl = kvUrlFor(kvId, docker);
|
|
44
66
|
const filesRoot = filesRootFor(filesId);
|
package/src/kernel/boot.test.ts
CHANGED
|
@@ -79,7 +79,7 @@ describe("boot — lazy element needs", () => {
|
|
|
79
79
|
expect(needs.signal).toBe(false);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
test("oke() Store-only graph stays under the prior
|
|
82
|
+
test("oke() Store-only graph stays under the prior 53 kB baseline", async () => {
|
|
83
83
|
const dir = await mkdtemp(join(tmpdir(), "oke-store-only-"));
|
|
84
84
|
const entry = join(dir, "entry.ts");
|
|
85
85
|
const appPath = join(import.meta.dir, "app.ts");
|
|
@@ -117,10 +117,10 @@ describe("boot — lazy element needs", () => {
|
|
|
117
117
|
total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
|
|
118
118
|
}
|
|
119
119
|
// Rebased after fx.sendOtp/verifyOtp on the shared fx surface (~50.1 kB
|
|
120
|
-
// gzip),
|
|
121
|
-
//
|
|
122
|
-
// Clock/channel/journal drivers stay lazy-bound; far below eager bind.
|
|
123
|
-
expect(total).toBeLessThan(
|
|
120
|
+
// gzip), durable-journal lease surface (~51.2 kB), then fx.deliverOtp +
|
|
121
|
+
// unified otp() plugin wiring on the shared surface (~50.9 kB → 53 kB
|
|
122
|
+
// cap). Clock/channel/journal drivers stay lazy-bound; far below eager bind.
|
|
123
|
+
expect(total).toBeLessThan(53_000);
|
|
124
124
|
} finally {
|
|
125
125
|
await rm(dir, { recursive: true, force: true });
|
|
126
126
|
}
|
package/src/kernel/fx.test.ts
CHANGED
|
@@ -147,6 +147,9 @@ describe("fx — wholesale swap", () => {
|
|
|
147
147
|
async verifyOtp() {
|
|
148
148
|
return { ok: true };
|
|
149
149
|
},
|
|
150
|
+
async deliverOtp(opts) {
|
|
151
|
+
return { ok: true as const, channel: opts.only ?? opts.channels[0] ?? "email" };
|
|
152
|
+
},
|
|
150
153
|
async ask() {
|
|
151
154
|
return {};
|
|
152
155
|
},
|
package/src/kernel/fx.ts
CHANGED
|
@@ -265,6 +265,20 @@ export interface FxVerifyOtpOptions {
|
|
|
265
265
|
readonly note?: string;
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/** Options for {@link Fx.deliverOtp} (Tier-2 multi-channel delivery). */
|
|
269
|
+
export interface FxDeliverOtpOptions {
|
|
270
|
+
/** Preferred channel order. */
|
|
271
|
+
readonly channels: readonly ("sms" | "whatsapp" | "email")[];
|
|
272
|
+
/** Template name per medium. */
|
|
273
|
+
readonly templates: Readonly<Partial<Record<"sms" | "whatsapp" | "email", string>>>;
|
|
274
|
+
readonly email?: string;
|
|
275
|
+
readonly phone?: string;
|
|
276
|
+
readonly data: Readonly<Record<string, unknown>>;
|
|
277
|
+
readonly locale?: string;
|
|
278
|
+
/** Explicit single-channel resend — no cross-medium failover. */
|
|
279
|
+
readonly only?: "sms" | "whatsapp" | "email";
|
|
280
|
+
}
|
|
281
|
+
|
|
268
282
|
/** Options for {@link Fx.ask}. */
|
|
269
283
|
export interface FxAskOptions {
|
|
270
284
|
readonly via?: readonly NamedRef[];
|
|
@@ -385,6 +399,18 @@ export interface Fx {
|
|
|
385
399
|
* @param opts - Recipient + requestId + code (+ lang / note / from)
|
|
386
400
|
*/
|
|
387
401
|
verifyOtp(opts: FxVerifyOtpOptions): Promise<{ ok: true }>;
|
|
402
|
+
/**
|
|
403
|
+
* Deliver an app-owned OTP across declared channels (records `send` on `auth-otp`).
|
|
404
|
+
*
|
|
405
|
+
* Tier-2 only — uses Channel `deliverOtp` (sently FallbackTransport). Pass
|
|
406
|
+
* `only` for explicit user resend (single channel, no cross-medium failover).
|
|
407
|
+
*
|
|
408
|
+
* @param opts - Channels, templates, addresses, OTP data
|
|
409
|
+
*/
|
|
410
|
+
deliverOtp(opts: FxDeliverOtpOptions): Promise<{
|
|
411
|
+
ok: true;
|
|
412
|
+
channel: "sms" | "whatsapp" | "email";
|
|
413
|
+
}>;
|
|
388
414
|
/**
|
|
389
415
|
* Ask an AI prompt (records `ask`). Stub returns `{}`.
|
|
390
416
|
*
|
|
@@ -1213,6 +1239,29 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1213
1239
|
return { ok: true as const };
|
|
1214
1240
|
});
|
|
1215
1241
|
},
|
|
1242
|
+
deliverOtp(opts) {
|
|
1243
|
+
return gated("send", "auth-otp", async () => {
|
|
1244
|
+
if (isDryRun()) {
|
|
1245
|
+
recordWouldHaveFired("send", "auth-otp");
|
|
1246
|
+
return { ok: true as const, channel: opts.only ?? opts.channels[0] ?? "email" };
|
|
1247
|
+
}
|
|
1248
|
+
if (!options.channelRuntime) {
|
|
1249
|
+
throw new Error(
|
|
1250
|
+
"fx.deliverOtp needs a bound Channel — declare channel templates and drivers for the configured media",
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
const result = await options.channelRuntime.deliverOtp({
|
|
1254
|
+
channels: opts.channels,
|
|
1255
|
+
templates: opts.templates,
|
|
1256
|
+
...(opts.email ? { email: opts.email } : {}),
|
|
1257
|
+
...(opts.phone ? { phone: opts.phone } : {}),
|
|
1258
|
+
data: opts.data,
|
|
1259
|
+
...(opts.locale ? { locale: opts.locale } : {}),
|
|
1260
|
+
...(opts.only ? { only: opts.only } : {}),
|
|
1261
|
+
});
|
|
1262
|
+
return { ok: true as const, channel: result.channel };
|
|
1263
|
+
});
|
|
1264
|
+
},
|
|
1216
1265
|
ask(prompt, input, opts) {
|
|
1217
1266
|
const name = resolveName(prompt);
|
|
1218
1267
|
return gated("ask", name, async () => {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graceful shutdown — Clock / Journal lease release without waiting for TTL.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import { createClockRuntime, createMemoryCronStore } from "../elements/clock.ts";
|
|
8
|
+
import { tryAcquireLease } from "../elements/clock/leader.ts";
|
|
9
|
+
import { createMemoryJournalStore, hasJournalLease, JOURNAL_DEFAULT_LEASE_MS } from "./journal.ts";
|
|
10
|
+
import { releaseInstanceLeases } from "./graceful-shutdown.ts";
|
|
11
|
+
|
|
12
|
+
describe("releaseInstanceLeases", () => {
|
|
13
|
+
test("releases Clock cron leases held by this instance", async () => {
|
|
14
|
+
const store = createMemoryCronStore();
|
|
15
|
+
await store.put({
|
|
16
|
+
name: "hourly",
|
|
17
|
+
status: "active",
|
|
18
|
+
timezone: "UTC",
|
|
19
|
+
overridable: false,
|
|
20
|
+
effectiveEvery: "1h",
|
|
21
|
+
});
|
|
22
|
+
const clock = createClockRuntime({ store, instanceId: "inst-a", leaseMs: 30_000 });
|
|
23
|
+
const t = 1_000_000;
|
|
24
|
+
expect(
|
|
25
|
+
await tryAcquireLease({
|
|
26
|
+
name: "hourly",
|
|
27
|
+
instanceId: "inst-a",
|
|
28
|
+
now: t,
|
|
29
|
+
leaseMs: 30_000,
|
|
30
|
+
store,
|
|
31
|
+
}),
|
|
32
|
+
).toBe(true);
|
|
33
|
+
expect((await store.get("hourly"))!.leaderInstanceId).toBe("inst-a");
|
|
34
|
+
|
|
35
|
+
await releaseInstanceLeases({
|
|
36
|
+
bootResult: {
|
|
37
|
+
clock: {
|
|
38
|
+
instanceId: clock.instanceId,
|
|
39
|
+
store,
|
|
40
|
+
now: () => t + 1,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
stop: async () => {},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const row = await store.get("hourly");
|
|
47
|
+
expect(row!.leaderInstanceId).toBeUndefined();
|
|
48
|
+
expect(row!.leaderLeaseUntil).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("releases Journal run leases held by this instance", async () => {
|
|
52
|
+
const store = createMemoryJournalStore();
|
|
53
|
+
expect(hasJournalLease(store)).toBe(true);
|
|
54
|
+
const runId = "run-1";
|
|
55
|
+
await store.put({
|
|
56
|
+
id: runId,
|
|
57
|
+
flow: "charge",
|
|
58
|
+
status: "running",
|
|
59
|
+
input: {},
|
|
60
|
+
entries: [],
|
|
61
|
+
createdAt: 1,
|
|
62
|
+
updatedAt: 1,
|
|
63
|
+
});
|
|
64
|
+
expect(await store.acquireLease!(runId, "inst-a", 1_000, JOURNAL_DEFAULT_LEASE_MS)).toBe(true);
|
|
65
|
+
expect((await store.get(runId))!.lockedBy).toBe("inst-a");
|
|
66
|
+
|
|
67
|
+
await releaseInstanceLeases({
|
|
68
|
+
bootResult: {
|
|
69
|
+
journal: { instanceId: "inst-a", store },
|
|
70
|
+
},
|
|
71
|
+
stop: async () => {},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect((await store.get(runId))!.lockedBy).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|