okengine 0.7.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 +25 -12
- package/site/content/docs/elements/clock.mdx +17 -15
- package/site/content/docs/elements/flow.mdx +6 -2
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +165 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/installation.mdx +18 -16
- 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 +44 -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 -1
- package/site/content/docs/reference/cli.md +5 -2
- package/site/content/docs/reference/configuration.mdx +21 -3
- package/site/content/docs/reference/environment-variables.mdx +21 -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/db-seed.ts +359 -0
- package/src/cli/db.test.ts +341 -3
- package/src/cli/db.ts +75 -8
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +26 -0
- package/src/cli/load-config.ts +10 -2
- package/src/cli/registry.ts +38 -2
- package/src/compiler/effects-infer.ts +1 -0
- package/src/config/index.ts +4 -0
- 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-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +72 -0
- package/src/drivers/clock-postgres.test.ts +258 -0
- package/src/drivers/clock-postgres.ts +410 -0
- package/src/drivers/index.ts +18 -0
- package/src/drivers/journal-postgres.test.ts +175 -0
- package/src/drivers/journal-postgres.ts +492 -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 +203 -114
- package/src/elements/channel.test.ts +71 -0
- package/src/elements/channel.ts +12 -2
- package/src/elements/clock/chaos-child.ts +280 -41
- package/src/elements/clock/durable.ts +7 -0
- package/src/elements/clock/reconcile.ts +2 -2
- package/src/elements/clock/runtime.ts +5 -3
- package/src/elements/clock.ts +1 -1
- package/src/elements/store/seed.test.ts +27 -0
- package/src/elements/store/seed.ts +68 -0
- package/src/elements/store/sql-session.test.ts +39 -0
- package/src/elements/store/sql-session.ts +55 -0
- package/src/elements/store/upsert-app.test.ts +103 -0
- package/src/elements/store.ts +5 -0
- package/src/index.ts +18 -0
- package/src/kernel/app.ts +221 -14
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +64 -0
- package/src/kernel/boot-bind/clock.ts +17 -6
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +123 -4
- package/src/kernel/boot-bind/journal.ts +89 -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 +6 -4
- package/src/kernel/boot.ts +53 -13
- package/src/kernel/concurrency.ts +1 -1
- package/src/kernel/fx.test.ts +9 -0
- package/src/kernel/fx.ts +175 -5
- 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 +14 -0
- package/src/kernel/journal-boot.test.ts +397 -0
- package/src/kernel/journal-suspend.ts +35 -0
- package/src/kernel/journal.test.ts +142 -0
- package/src/kernel/journal.ts +202 -27
- 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 +20 -27
- 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 +172 -0
- 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 -111
- package/src/plugins/email-otp.ts +0 -214
- package/src/plugins/phone-number.ts +0 -149
package/src/kernel/journal.ts
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
import { mkdir } from "node:fs/promises";
|
|
11
11
|
import { dirname } from "node:path";
|
|
12
|
+
import { JournalSuspend } from "./journal-suspend.ts";
|
|
13
|
+
|
|
14
|
+
export { JournalSuspend, isJournalSuspend } from "./journal-suspend.ts";
|
|
12
15
|
|
|
13
16
|
/** Status of a durable run. */
|
|
14
17
|
export type JournalRunStatus = "running" | "sleeping" | "completed" | "failed";
|
|
@@ -53,32 +56,58 @@ export interface JournalRun {
|
|
|
53
56
|
wakeAt?: number;
|
|
54
57
|
error?: string;
|
|
55
58
|
output?: unknown;
|
|
59
|
+
/** Lease holder instance id (run-level coordination — Signal/Clock physics). */
|
|
60
|
+
lockedBy?: string;
|
|
61
|
+
/** Lease expiry epoch-ms; a crashed holder's run is reclaimable after this. */
|
|
62
|
+
leaseExpiresAt?: number;
|
|
56
63
|
readonly createdAt: number;
|
|
57
64
|
updatedAt: number;
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
68
|
+
* Run-level lease coordination — same SKIP LOCKED + lazy-reclaim physics as
|
|
69
|
+
* Signal's message claims and Clock's tick claims. No sweeper, no fencing
|
|
70
|
+
* token: at-least-once after lease expiry, journal replay keeps completed
|
|
71
|
+
* steps from re-running.
|
|
63
72
|
*/
|
|
64
|
-
export
|
|
65
|
-
readonly wakeAt: number;
|
|
66
|
-
readonly label: string;
|
|
67
|
-
|
|
73
|
+
export interface JournalLeaseStore {
|
|
68
74
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
75
|
+
* Acquire / renew / reclaim a run lease. Claimable when unlocked, held by
|
|
76
|
+
* the same instance, or expired.
|
|
77
|
+
*
|
|
78
|
+
* @param runId - Run id
|
|
79
|
+
* @param instanceId - Claimant instance
|
|
80
|
+
* @param now - Epoch-ms
|
|
81
|
+
* @param leaseMs - Lease duration
|
|
71
82
|
*/
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
acquireLease(runId: string, instanceId: string, now: number, leaseMs: number): Promise<boolean>;
|
|
84
|
+
/**
|
|
85
|
+
* Release a lease held by `instanceId` (no-op for other holders).
|
|
86
|
+
*
|
|
87
|
+
* @param runId - Run id
|
|
88
|
+
* @param instanceId - Holder instance
|
|
89
|
+
*/
|
|
90
|
+
releaseLease(runId: string, instanceId: string): Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* Atomically claim the next due sleep (`status=sleeping`, `wakeAt<=now`, no
|
|
93
|
+
* live lease) and return it — `undefined` when none is claimable.
|
|
94
|
+
*
|
|
95
|
+
* @param instanceId - Claimant instance
|
|
96
|
+
* @param now - Epoch-ms
|
|
97
|
+
* @param leaseMs - Lease duration
|
|
98
|
+
*/
|
|
99
|
+
claimDueSleep(instanceId: string, now: number, leaseMs: number): Promise<JournalRun | undefined>;
|
|
100
|
+
/**
|
|
101
|
+
* Boot-time orphan discovery: `running` / `sleeping` runs with no live lease
|
|
102
|
+
* (crashed holder or never claimed). Rows are never deleted.
|
|
103
|
+
*
|
|
104
|
+
* @param now - Epoch-ms
|
|
105
|
+
*/
|
|
106
|
+
listOrphans(now: number): Promise<readonly JournalRun[]>;
|
|
78
107
|
}
|
|
79
108
|
|
|
80
109
|
/** Persistence backend for journal runs. */
|
|
81
|
-
export interface JournalStore {
|
|
110
|
+
export interface JournalStore extends Partial<JournalLeaseStore> {
|
|
82
111
|
/**
|
|
83
112
|
* Load a run by id.
|
|
84
113
|
*
|
|
@@ -95,12 +124,107 @@ export interface JournalStore {
|
|
|
95
124
|
list(): Promise<readonly JournalRun[]>;
|
|
96
125
|
}
|
|
97
126
|
|
|
127
|
+
/** Default run lease — matches Signal's claim lease. */
|
|
128
|
+
export const JOURNAL_DEFAULT_LEASE_MS = 30_000;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Narrow a store to its lease-coordination surface (present on the built-in
|
|
132
|
+
* memory / file / postgres stores; absent on custom minimal stores).
|
|
133
|
+
*
|
|
134
|
+
* @param store - Journal store
|
|
135
|
+
*/
|
|
136
|
+
export function hasJournalLease(store: JournalStore): store is JournalStore & JournalLeaseStore {
|
|
137
|
+
return (
|
|
138
|
+
typeof store.acquireLease === "function" &&
|
|
139
|
+
typeof store.releaseLease === "function" &&
|
|
140
|
+
typeof store.claimDueSleep === "function" &&
|
|
141
|
+
typeof store.listOrphans === "function"
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Thrown when a run resume loses the lease race to another live instance. */
|
|
146
|
+
export class JournalLeaseBusy extends Error {
|
|
147
|
+
readonly runId: string;
|
|
148
|
+
constructor(runId: string) {
|
|
149
|
+
super(`journal: run "${runId}" is leased by another instance`);
|
|
150
|
+
this.name = "JournalLeaseBusy";
|
|
151
|
+
this.runId = runId;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Type guard for {@link JournalLeaseBusy}. */
|
|
156
|
+
export function isJournalLeaseBusy(err: unknown): err is JournalLeaseBusy {
|
|
157
|
+
return err instanceof JournalLeaseBusy;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Live lease = a holder with an unexpired expiry. */
|
|
161
|
+
function hasLiveLease(run: JournalRun, now: number): boolean {
|
|
162
|
+
return run.lockedBy !== undefined && run.leaseExpiresAt !== undefined && run.leaseExpiresAt > now;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Claimable when unlocked, same-holder, or without a live lease. */
|
|
166
|
+
function claimable(run: JournalRun, instanceId: string, now: number): boolean {
|
|
167
|
+
if (run.lockedBy === undefined) return true;
|
|
168
|
+
if (run.lockedBy === instanceId) return true;
|
|
169
|
+
return !hasLiveLease(run, now);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Lease methods shared by the memory + file stores (single-writer maps). */
|
|
173
|
+
function leaseMethods(
|
|
174
|
+
load: () => Promise<Map<string, JournalRun>>,
|
|
175
|
+
flush?: (map: Map<string, JournalRun>) => Promise<void>,
|
|
176
|
+
): JournalLeaseStore {
|
|
177
|
+
return {
|
|
178
|
+
async acquireLease(runId, instanceId, now, leaseMs) {
|
|
179
|
+
const map = await load();
|
|
180
|
+
const run = map.get(runId);
|
|
181
|
+
if (!run || !claimable(run, instanceId, now)) return false;
|
|
182
|
+
run.lockedBy = instanceId;
|
|
183
|
+
run.leaseExpiresAt = now + leaseMs;
|
|
184
|
+
await flush?.(map);
|
|
185
|
+
return true;
|
|
186
|
+
},
|
|
187
|
+
async releaseLease(runId, instanceId) {
|
|
188
|
+
const map = await load();
|
|
189
|
+
const run = map.get(runId);
|
|
190
|
+
if (!run || run.lockedBy !== instanceId) return;
|
|
191
|
+
delete run.lockedBy;
|
|
192
|
+
delete run.leaseExpiresAt;
|
|
193
|
+
await flush?.(map);
|
|
194
|
+
},
|
|
195
|
+
async claimDueSleep(instanceId, now, leaseMs) {
|
|
196
|
+
const map = await load();
|
|
197
|
+
const due = [...map.values()]
|
|
198
|
+
.filter(
|
|
199
|
+
(r) =>
|
|
200
|
+
r.status === "sleeping" &&
|
|
201
|
+
r.wakeAt !== undefined &&
|
|
202
|
+
r.wakeAt <= now &&
|
|
203
|
+
claimable(r, instanceId, now),
|
|
204
|
+
)
|
|
205
|
+
.sort((a, b) => (a.wakeAt ?? 0) - (b.wakeAt ?? 0))[0];
|
|
206
|
+
if (!due) return undefined;
|
|
207
|
+
due.lockedBy = instanceId;
|
|
208
|
+
due.leaseExpiresAt = now + leaseMs;
|
|
209
|
+
await flush?.(map);
|
|
210
|
+
return cloneRun(due);
|
|
211
|
+
},
|
|
212
|
+
async listOrphans(now) {
|
|
213
|
+
const map = await load();
|
|
214
|
+
return [...map.values()]
|
|
215
|
+
.filter((r) => (r.status === "running" || r.status === "sleeping") && !hasLiveLease(r, now))
|
|
216
|
+
.map(cloneRun);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
98
221
|
/** In-memory journal store. */
|
|
99
222
|
export function createMemoryJournalStore(seed?: readonly JournalRun[]): JournalStore {
|
|
100
223
|
const runs = new Map<string, JournalRun>();
|
|
101
224
|
for (const r of seed ?? []) {
|
|
102
225
|
runs.set(r.id, cloneRun(r));
|
|
103
226
|
}
|
|
227
|
+
const load = async (): Promise<Map<string, JournalRun>> => runs;
|
|
104
228
|
return {
|
|
105
229
|
async get(runId) {
|
|
106
230
|
const r = runs.get(runId);
|
|
@@ -112,6 +236,7 @@ export function createMemoryJournalStore(seed?: readonly JournalRun[]): JournalS
|
|
|
112
236
|
async list() {
|
|
113
237
|
return [...runs.values()].map(cloneRun);
|
|
114
238
|
},
|
|
239
|
+
...leaseMethods(load),
|
|
115
240
|
};
|
|
116
241
|
}
|
|
117
242
|
|
|
@@ -156,9 +281,19 @@ export function createFileJournalStore(path: string): JournalStore {
|
|
|
156
281
|
const map = await load();
|
|
157
282
|
return [...map.values()].map(cloneRun);
|
|
158
283
|
},
|
|
284
|
+
// Single-host file: leases coordinate same-machine processes only.
|
|
285
|
+
...leaseMethods(load, flush),
|
|
159
286
|
};
|
|
160
287
|
}
|
|
161
288
|
|
|
289
|
+
/** Run-level lease holder for {@link CreateJournalOptions.lease}. */
|
|
290
|
+
export interface JournalLeaseOptions {
|
|
291
|
+
/** This instance's id (lease holder). */
|
|
292
|
+
readonly instanceId: string;
|
|
293
|
+
/** Lease duration ms (default {@link JOURNAL_DEFAULT_LEASE_MS}). */
|
|
294
|
+
readonly leaseMs?: number;
|
|
295
|
+
}
|
|
296
|
+
|
|
162
297
|
/** Options for {@link createJournal}. */
|
|
163
298
|
export interface CreateJournalOptions {
|
|
164
299
|
/** Persistence backend. */
|
|
@@ -167,6 +302,13 @@ export interface CreateJournalOptions {
|
|
|
167
302
|
readonly now?: () => number;
|
|
168
303
|
/** Id factory (defaults to UUID). */
|
|
169
304
|
readonly id?: () => string;
|
|
305
|
+
/**
|
|
306
|
+
* Run-level lease (when the store supports it). `start` inserts with the
|
|
307
|
+
* lease held; `resume` claims the run or throws {@link JournalLeaseBusy};
|
|
308
|
+
* every persist renews; parking a sleep and terminal commits release so a
|
|
309
|
+
* sleeping/finished run never holds a 30s lock.
|
|
310
|
+
*/
|
|
311
|
+
readonly lease?: JournalLeaseOptions;
|
|
170
312
|
}
|
|
171
313
|
|
|
172
314
|
/**
|
|
@@ -240,16 +382,31 @@ export interface Journal {
|
|
|
240
382
|
export function createJournal(options: CreateJournalOptions): Journal {
|
|
241
383
|
const now = options.now ?? (() => Date.now());
|
|
242
384
|
const newId = options.id ?? (() => crypto.randomUUID());
|
|
385
|
+
const lease = options.lease;
|
|
386
|
+
const coordinated = lease !== undefined && hasJournalLease(options.store);
|
|
243
387
|
|
|
244
|
-
function openSession(run: JournalRun): JournalSession {
|
|
388
|
+
function openSession(run: JournalRun, leased: boolean): JournalSession {
|
|
245
389
|
/** Next entry index to consume on replay. */
|
|
246
390
|
let cursor = 0;
|
|
391
|
+
let leaseHeld = leased;
|
|
247
392
|
|
|
248
393
|
async function persist(): Promise<void> {
|
|
249
394
|
run.updatedAt = now();
|
|
395
|
+
// Natural heartbeat — a live holder renews on every journal write.
|
|
396
|
+
if (leaseHeld && lease) {
|
|
397
|
+
run.lockedBy = lease.instanceId;
|
|
398
|
+
run.leaseExpiresAt = now() + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
|
|
399
|
+
}
|
|
250
400
|
await options.store.put(cloneRun(run));
|
|
251
401
|
}
|
|
252
402
|
|
|
403
|
+
/** Parking / terminal states must not hold a short lease across days. */
|
|
404
|
+
function releaseLeaseLocally(): void {
|
|
405
|
+
leaseHeld = false;
|
|
406
|
+
delete run.lockedBy;
|
|
407
|
+
delete run.leaseExpiresAt;
|
|
408
|
+
}
|
|
409
|
+
|
|
253
410
|
return {
|
|
254
411
|
runId: run.id,
|
|
255
412
|
run,
|
|
@@ -282,6 +439,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
282
439
|
if (now() < e.wakeAt) {
|
|
283
440
|
run.status = "sleeping";
|
|
284
441
|
run.wakeAt = e.wakeAt;
|
|
442
|
+
releaseLeaseLocally();
|
|
285
443
|
await persist();
|
|
286
444
|
throw new JournalSuspend(label, e.wakeAt);
|
|
287
445
|
}
|
|
@@ -301,6 +459,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
301
459
|
if (now() < wakeAt) {
|
|
302
460
|
run.status = "sleeping";
|
|
303
461
|
run.wakeAt = wakeAt;
|
|
462
|
+
releaseLeaseLocally();
|
|
304
463
|
await persist();
|
|
305
464
|
throw new JournalSuspend(label, wakeAt);
|
|
306
465
|
}
|
|
@@ -341,6 +500,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
341
500
|
if (patch?.error !== undefined) run.error = patch.error;
|
|
342
501
|
if (status === "completed" || status === "failed") {
|
|
343
502
|
delete run.wakeAt;
|
|
503
|
+
releaseLeaseLocally();
|
|
344
504
|
}
|
|
345
505
|
await persist();
|
|
346
506
|
},
|
|
@@ -360,31 +520,46 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
360
520
|
createdAt: t,
|
|
361
521
|
updatedAt: t,
|
|
362
522
|
};
|
|
523
|
+
if (coordinated && lease) {
|
|
524
|
+
// Fresh id — insert already holding the lease (no claim race).
|
|
525
|
+
run.lockedBy = lease.instanceId;
|
|
526
|
+
run.leaseExpiresAt = t + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
|
|
527
|
+
}
|
|
363
528
|
await options.store.put(cloneRun(run));
|
|
364
|
-
return openSession(run);
|
|
529
|
+
return openSession(run, coordinated);
|
|
365
530
|
},
|
|
366
531
|
async resume(runId) {
|
|
532
|
+
if (coordinated && lease) {
|
|
533
|
+
const t = now();
|
|
534
|
+
const claimed = await options.store.acquireLease!(
|
|
535
|
+
runId,
|
|
536
|
+
lease.instanceId,
|
|
537
|
+
t,
|
|
538
|
+
lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS,
|
|
539
|
+
);
|
|
540
|
+
if (!claimed) {
|
|
541
|
+
throw new JournalLeaseBusy(runId);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
367
544
|
const run = await options.store.get(runId);
|
|
368
545
|
if (!run) {
|
|
546
|
+
if (coordinated && lease) {
|
|
547
|
+
await options.store.releaseLease!(runId, lease.instanceId);
|
|
548
|
+
}
|
|
369
549
|
throw new Error(`journal: run "${runId}" not found`);
|
|
370
550
|
}
|
|
371
551
|
// Leave status intact — the durable runner parks or continues.
|
|
372
552
|
run.updatedAt = now();
|
|
553
|
+
if (coordinated && lease) {
|
|
554
|
+
run.lockedBy = lease.instanceId;
|
|
555
|
+
run.leaseExpiresAt = now() + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
|
|
556
|
+
}
|
|
373
557
|
await options.store.put(cloneRun(run));
|
|
374
|
-
return openSession(run);
|
|
558
|
+
return openSession(run, coordinated);
|
|
375
559
|
},
|
|
376
560
|
};
|
|
377
561
|
}
|
|
378
562
|
|
|
379
|
-
/**
|
|
380
|
-
* True when `err` is a {@link JournalSuspend}.
|
|
381
|
-
*
|
|
382
|
-
* @param err - Unknown
|
|
383
|
-
*/
|
|
384
|
-
export function isJournalSuspend(err: unknown): err is JournalSuspend {
|
|
385
|
-
return err instanceof JournalSuspend;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
563
|
function cloneRun(run: JournalRun): JournalRun {
|
|
389
564
|
return structuredClone(run);
|
|
390
565
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kernel readiness endpoint — boot vs orphan_scan vs ready.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import { oke } from "./app.ts";
|
|
8
|
+
import { flow, type AnyFlowDef } from "./flow.ts";
|
|
9
|
+
import { createMemoryJournalStore, hasJournalLease } from "./journal.ts";
|
|
10
|
+
import type { Binding } from "./on.ts";
|
|
11
|
+
import { http } from "./triggers.ts";
|
|
12
|
+
import { gate } from "../elements/gate.ts";
|
|
13
|
+
|
|
14
|
+
describe("GET /_/ready", () => {
|
|
15
|
+
test("returns 503 booting before boot, 200 ready after boot without journal", async () => {
|
|
16
|
+
const app = oke({
|
|
17
|
+
name: "ready-plain",
|
|
18
|
+
autoBoot: false,
|
|
19
|
+
startScheduler: false,
|
|
20
|
+
});
|
|
21
|
+
const before = await app.fetch(new Request("http://127.0.0.1/_/ready"));
|
|
22
|
+
expect(before.status).toBe(503);
|
|
23
|
+
expect(await before.json()).toEqual({ ready: false, reason: "booting" });
|
|
24
|
+
|
|
25
|
+
await app.boot();
|
|
26
|
+
expect(app.readyState).toBe("ready");
|
|
27
|
+
const after = await app.fetch(new Request("http://127.0.0.1/_/ready"));
|
|
28
|
+
expect(after.status).toBe(200);
|
|
29
|
+
expect(await after.json()).toEqual({ ready: true });
|
|
30
|
+
await app.stop();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("orphan_scan then ready when durable journal is bound", async () => {
|
|
34
|
+
const journalStore = createMemoryJournalStore();
|
|
35
|
+
expect(hasJournalLease(journalStore)).toBe(true);
|
|
36
|
+
const charge = flow({
|
|
37
|
+
name: "charge",
|
|
38
|
+
durable: true,
|
|
39
|
+
do: () => ({ ok: true }),
|
|
40
|
+
});
|
|
41
|
+
const bindings: Binding[] = [
|
|
42
|
+
{
|
|
43
|
+
trigger: http.post("/charge").gate(gate.public),
|
|
44
|
+
flow: charge as AnyFlowDef,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
const app = oke({
|
|
48
|
+
name: "ready-journal",
|
|
49
|
+
autoBoot: false,
|
|
50
|
+
startScheduler: false,
|
|
51
|
+
env: "test",
|
|
52
|
+
gate: { unguardedHttp: "allow" },
|
|
53
|
+
bindings,
|
|
54
|
+
elements: {
|
|
55
|
+
journal: {
|
|
56
|
+
store: journalStore,
|
|
57
|
+
instanceId: "ready-j",
|
|
58
|
+
leaseMs: 30_000,
|
|
59
|
+
driverId: "memory",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
await app.boot();
|
|
64
|
+
// Memory journal orphan scan is fast — may already be ready; accept either
|
|
65
|
+
// intermediate orphan_scan or ready, but never stay booting after boot().
|
|
66
|
+
expect(["orphan_scan", "ready"]).toContain(app.readyState);
|
|
67
|
+
const deadline = Date.now() + 2_000;
|
|
68
|
+
while (app.readyState !== "ready" && Date.now() < deadline) {
|
|
69
|
+
await Bun.sleep(5);
|
|
70
|
+
}
|
|
71
|
+
expect(app.readyState).toBe("ready");
|
|
72
|
+
const res = await app.fetch(new Request("http://127.0.0.1/_/ready"));
|
|
73
|
+
expect(res.status).toBe(200);
|
|
74
|
+
await app.stop();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -14,7 +14,7 @@ import { deriveInfrastructure, writeDerivedFiles } from "../docker/index.ts";
|
|
|
14
14
|
import { oke } from "../kernel/app.ts";
|
|
15
15
|
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
16
16
|
import { resetBindings } from "../kernel/on.ts";
|
|
17
|
-
import {
|
|
17
|
+
import { otp } from "./otp.ts";
|
|
18
18
|
import { magicLink } from "./magic-link.ts";
|
|
19
19
|
|
|
20
20
|
const SECRET = "test-secret-at-least-16";
|
|
@@ -290,21 +290,21 @@ describe("auth delivery — Mailpit integration", () => {
|
|
|
290
290
|
config: {
|
|
291
291
|
drivers: { channel: { email: { test: "smtp" } } },
|
|
292
292
|
},
|
|
293
|
-
}).plug(
|
|
293
|
+
}).plug(otp({ tier: 2, channels: ["email"], exposeDevOtp: true }));
|
|
294
294
|
await app.boot({ env: "test" });
|
|
295
295
|
|
|
296
|
-
const res = await app.fetch(jsonPost("/auth/
|
|
296
|
+
const res = await app.fetch(jsonPost("/auth/otp/request", { email }));
|
|
297
297
|
expect(res.status).toBe(200);
|
|
298
298
|
const body = (await res.json()) as { data: { ok: true; devOtp?: string } };
|
|
299
299
|
expect(body.data.ok).toBe(true);
|
|
300
300
|
expect(body.data.devOtp).toMatch(/^\d{6}$/);
|
|
301
|
-
const
|
|
301
|
+
const code = body.data.devOtp!;
|
|
302
302
|
|
|
303
303
|
const msg = await waitForMessage(uiUrl, email);
|
|
304
304
|
expect(msg.Subject).toBe("Your sign-in code");
|
|
305
305
|
const text = msg.Text ?? "";
|
|
306
306
|
const html = msg.HTML ?? "";
|
|
307
|
-
expect(text.includes(
|
|
307
|
+
expect(text.includes(code) || html.includes(code)).toBe(true);
|
|
308
308
|
|
|
309
309
|
await app.stop();
|
|
310
310
|
} finally {
|
|
@@ -12,11 +12,10 @@ import { oke } from "../kernel/app.ts";
|
|
|
12
12
|
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
13
13
|
import { resetBindings } from "../kernel/on.ts";
|
|
14
14
|
import { anonymous } from "./anonymous.ts";
|
|
15
|
-
import { emailOtp } from "./email-otp.ts";
|
|
16
15
|
import { magicLink } from "./magic-link.ts";
|
|
16
|
+
import { otp } from "./otp.ts";
|
|
17
17
|
import { passkey } from "./passkey.ts";
|
|
18
18
|
import { b64urlEncode, buildAuthenticatorData, signWebAuthnAssertion } from "./passkey-webauthn.ts";
|
|
19
|
-
import { phoneNumber } from "./phone-number.ts";
|
|
20
19
|
import { twoFactor, verifyTotp, createTwoFactorStore } from "./two-factor.ts";
|
|
21
20
|
import { username } from "./username.ts";
|
|
22
21
|
|
|
@@ -51,8 +50,7 @@ function fullAuthApp() {
|
|
|
51
50
|
.plug(username())
|
|
52
51
|
.plug(anonymous())
|
|
53
52
|
.plug(magicLink({ exposeDevToken: true }))
|
|
54
|
-
.plug(
|
|
55
|
-
.plug(phoneNumber({ exposeDevOtp: true }))
|
|
53
|
+
.plug(otp({ tier: 2, channels: ["email", "sms"], exposeDevOtp: true }))
|
|
56
54
|
.plug(twoFactor())
|
|
57
55
|
.plug(passkey({ origins: ["http://localhost"] }));
|
|
58
56
|
}
|
|
@@ -141,8 +139,7 @@ describe("auth methods — rate limiting / enumeration", () => {
|
|
|
141
139
|
|
|
142
140
|
const paths: Array<{ path: string; body: unknown }> = [
|
|
143
141
|
{ path: "/auth/magic-link/request", body: { email: "r@example.com" } },
|
|
144
|
-
{ path: "/auth/
|
|
145
|
-
{ path: "/auth/phone/request", body: { phone: "+15551234567" } },
|
|
142
|
+
{ path: "/auth/otp/request", body: { email: "r@example.com" } },
|
|
146
143
|
{ path: "/auth/passkey/authenticate/options", body: {} },
|
|
147
144
|
];
|
|
148
145
|
|
|
@@ -225,14 +222,14 @@ describe("auth methods — single-use OTP / token / backup codes", () => {
|
|
|
225
222
|
const app = fullAuthApp();
|
|
226
223
|
await app.boot({ env: "test" });
|
|
227
224
|
|
|
228
|
-
const req = await app.fetch(jsonPost("/auth/
|
|
225
|
+
const req = await app.fetch(jsonPost("/auth/otp/request", { email: "otp@example.com" }));
|
|
229
226
|
const { data } = (await req.json()) as { data: { devOtp: string } };
|
|
230
227
|
const first = await app.fetch(
|
|
231
|
-
jsonPost("/auth/
|
|
228
|
+
jsonPost("/auth/otp/verify", { email: "otp@example.com", otp: data.devOtp }),
|
|
232
229
|
);
|
|
233
230
|
expect(first.status).toBe(200);
|
|
234
231
|
const reuse = await app.fetch(
|
|
235
|
-
jsonPost("/auth/
|
|
232
|
+
jsonPost("/auth/otp/verify", { email: "otp@example.com", otp: data.devOtp }),
|
|
236
233
|
);
|
|
237
234
|
expect((await readError(reuse)).reason).toBe("invalid_credentials");
|
|
238
235
|
|
|
@@ -243,14 +240,14 @@ describe("auth methods — single-use OTP / token / backup codes", () => {
|
|
|
243
240
|
const app = fullAuthApp();
|
|
244
241
|
await app.boot({ env: "test" });
|
|
245
242
|
|
|
246
|
-
const req = await app.fetch(jsonPost("/auth/
|
|
243
|
+
const req = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15559876543" }));
|
|
247
244
|
const { data } = (await req.json()) as { data: { devOtp: string } };
|
|
248
245
|
const first = await app.fetch(
|
|
249
|
-
jsonPost("/auth/
|
|
246
|
+
jsonPost("/auth/otp/verify", { phone: "+15559876543", otp: data.devOtp }),
|
|
250
247
|
);
|
|
251
248
|
expect(first.status).toBe(200);
|
|
252
249
|
const reuse = await app.fetch(
|
|
253
|
-
jsonPost("/auth/
|
|
250
|
+
jsonPost("/auth/otp/verify", { phone: "+15559876543", otp: data.devOtp }),
|
|
254
251
|
);
|
|
255
252
|
expect((await readError(reuse)).reason).toBe("invalid_credentials");
|
|
256
253
|
|
|
@@ -421,7 +418,7 @@ describe("auth methods — anonymous non-escalation", () => {
|
|
|
421
418
|
});
|
|
422
419
|
|
|
423
420
|
describe("auth methods — channel delivery", () => {
|
|
424
|
-
test("magic
|
|
421
|
+
test("magic uses fx.send; otp Tier 2 uses fx.deliverOtp; exposeDev* stays off by default", async () => {
|
|
425
422
|
resetBindings();
|
|
426
423
|
resetFlowSeq();
|
|
427
424
|
const app = oke({
|
|
@@ -431,8 +428,7 @@ describe("auth methods — channel delivery", () => {
|
|
|
431
428
|
gate: { auth: { secret: SECRET, emailAndPassword: { enabled: true } } },
|
|
432
429
|
})
|
|
433
430
|
.plug(magicLink())
|
|
434
|
-
.plug(
|
|
435
|
-
.plug(phoneNumber());
|
|
431
|
+
.plug(otp({ tier: 2, channels: ["email", "sms"] }));
|
|
436
432
|
await app.boot({ env: "test" });
|
|
437
433
|
|
|
438
434
|
const ml = await app.fetch(jsonPost("/auth/magic-link/request", { email: "x@example.com" }));
|
|
@@ -440,27 +436,24 @@ describe("auth methods — channel delivery", () => {
|
|
|
440
436
|
expect(mlBody.data.ok).toBe(true);
|
|
441
437
|
expect(mlBody.data.devToken).toBeUndefined();
|
|
442
438
|
|
|
443
|
-
const
|
|
444
|
-
const otpBody = (await
|
|
439
|
+
const otpRes = await app.fetch(jsonPost("/auth/otp/request", { email: "x@example.com" }));
|
|
440
|
+
const otpBody = (await otpRes.json()) as { data: Record<string, unknown> };
|
|
445
441
|
expect(otpBody.data.ok).toBe(true);
|
|
446
442
|
expect(otpBody.data.devOtp).toBeUndefined();
|
|
447
443
|
|
|
448
|
-
const phone = await app.fetch(jsonPost("/auth/
|
|
444
|
+
const phone = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15551112222" }));
|
|
449
445
|
const phoneBody = (await phone.json()) as { data: Record<string, unknown> };
|
|
450
446
|
expect(phoneBody.data.ok).toBe(true);
|
|
451
447
|
expect(phoneBody.data.devOtp).toBeUndefined();
|
|
452
448
|
|
|
453
449
|
const magicSrc = await Bun.file(new URL("./magic-link.ts", import.meta.url)).text();
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
expect(magicSrc).toMatch(/fx\.send/);
|
|
450
|
+
const otpSrc = await Bun.file(new URL("./otp.ts", import.meta.url)).text();
|
|
451
|
+
expect(magicSrc).toMatch(/fx\.send\(/);
|
|
457
452
|
expect(magicSrc).toMatch(/channel\./);
|
|
458
|
-
expect(
|
|
459
|
-
expect(
|
|
460
|
-
|
|
461
|
-
expect(
|
|
462
|
-
expect(phoneSrc).not.toMatch(/channel\./);
|
|
463
|
-
expect(phoneSrc).not.toMatch(/from ["'].*channel/);
|
|
453
|
+
expect(otpSrc).toMatch(/fx\s*\.\s*deliverOtp/);
|
|
454
|
+
expect(otpSrc).toMatch(/fx\s*\.\s*sendOtp/);
|
|
455
|
+
expect(otpSrc).toMatch(/fx\s*\.\s*verifyOtp/);
|
|
456
|
+
expect(otpSrc).not.toMatch(/taqnyat-sms|sently\/transports/);
|
|
464
457
|
|
|
465
458
|
await app.stop();
|
|
466
459
|
});
|
|
@@ -7,8 +7,8 @@ import { oke } from "../kernel/app.ts";
|
|
|
7
7
|
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
8
8
|
import { resetBindings } from "../kernel/on.ts";
|
|
9
9
|
import { anonymous } from "./anonymous.ts";
|
|
10
|
-
import { emailOtp } from "./email-otp.ts";
|
|
11
10
|
import { magicLink } from "./magic-link.ts";
|
|
11
|
+
import { otp } from "./otp.ts";
|
|
12
12
|
import { username } from "./username.ts";
|
|
13
13
|
|
|
14
14
|
afterEach(() => {
|
|
@@ -31,7 +31,7 @@ function authApp() {
|
|
|
31
31
|
.plug(username())
|
|
32
32
|
.plug(anonymous())
|
|
33
33
|
.plug(magicLink({ exposeDevToken: true }))
|
|
34
|
-
.plug(
|
|
34
|
+
.plug(otp({ tier: 2, channels: ["email"], exposeDevOtp: true }));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
describe("auth method plugins", () => {
|
|
@@ -42,8 +42,9 @@ describe("auth method plugins", () => {
|
|
|
42
42
|
expect(app.router.match("POST", "/auth/sign-in/anonymous")).toBeTruthy();
|
|
43
43
|
expect(app.router.match("POST", "/auth/magic-link/request")).toBeTruthy();
|
|
44
44
|
expect(app.router.match("POST", "/auth/magic-link/verify")).toBeTruthy();
|
|
45
|
-
expect(app.router.match("POST", "/auth/
|
|
46
|
-
expect(app.router.match("POST", "/auth/
|
|
45
|
+
expect(app.router.match("POST", "/auth/otp/request")).toBeTruthy();
|
|
46
|
+
expect(app.router.match("POST", "/auth/otp/verify")).toBeTruthy();
|
|
47
|
+
expect(app.router.match("POST", "/auth/otp/resend")).toBeTruthy();
|
|
47
48
|
await app.boot({ env: "test" });
|
|
48
49
|
await app.stop();
|
|
49
50
|
});
|
|
@@ -147,7 +148,7 @@ describe("auth method plugins", () => {
|
|
|
147
148
|
await app.boot({ env: "test" });
|
|
148
149
|
|
|
149
150
|
const req = await app.fetch(
|
|
150
|
-
new Request("http://localhost/auth/
|
|
151
|
+
new Request("http://localhost/auth/otp/request", {
|
|
151
152
|
method: "POST",
|
|
152
153
|
headers: { "content-type": "application/json" },
|
|
153
154
|
body: JSON.stringify({ email: "otp@example.com" }),
|
|
@@ -158,7 +159,7 @@ describe("auth method plugins", () => {
|
|
|
158
159
|
expect(requested.data.devOtp).toMatch(/^\d{6}$/);
|
|
159
160
|
|
|
160
161
|
const verify = await app.fetch(
|
|
161
|
-
new Request("http://localhost/auth/
|
|
162
|
+
new Request("http://localhost/auth/otp/verify", {
|
|
162
163
|
method: "POST",
|
|
163
164
|
headers: { "content-type": "application/json" },
|
|
164
165
|
body: JSON.stringify({
|
package/src/plugins/index.ts
CHANGED
|
@@ -15,8 +15,6 @@ export {
|
|
|
15
15
|
type ConfigSourceOptions,
|
|
16
16
|
} from "./config-source.ts";
|
|
17
17
|
export { cors, type CorsOptions } from "./cors.ts";
|
|
18
|
-
export { csrf, type CsrfOptions } from "./csrf.ts";
|
|
19
|
-
export { emailOtp, emailOtpCatalog, emailOtpTemplate, type EmailOtpOptions } from "./email-otp.ts";
|
|
20
18
|
export { ipAllowlist, type IpAllowlistOptions } from "./ip-allowlist.ts";
|
|
21
19
|
export {
|
|
22
20
|
magicLink,
|
|
@@ -25,6 +23,18 @@ export {
|
|
|
25
23
|
type MagicLinkOptions,
|
|
26
24
|
} from "./magic-link.ts";
|
|
27
25
|
export { maintenanceMode, type MaintenanceModeOptions } from "./maintenance-mode.ts";
|
|
26
|
+
export {
|
|
27
|
+
createPhoneStore,
|
|
28
|
+
otp,
|
|
29
|
+
otpCatalog,
|
|
30
|
+
otpEmailTemplate,
|
|
31
|
+
otpSmsTemplate,
|
|
32
|
+
otpWhatsappTemplate,
|
|
33
|
+
type OtpOptions,
|
|
34
|
+
type OtpTier1Options,
|
|
35
|
+
type OtpTier2Options,
|
|
36
|
+
type PhoneStore,
|
|
37
|
+
} from "./otp.ts";
|
|
28
38
|
export {
|
|
29
39
|
createPasskeyStore,
|
|
30
40
|
passkey,
|
|
@@ -40,12 +50,6 @@ export {
|
|
|
40
50
|
verifyWebAuthnCeremony,
|
|
41
51
|
type WebAuthnVerifyOptions,
|
|
42
52
|
} from "./passkey-webauthn.ts";
|
|
43
|
-
export {
|
|
44
|
-
createPhoneStore,
|
|
45
|
-
phoneNumber,
|
|
46
|
-
type PhoneNumberOptions,
|
|
47
|
-
type PhoneStore,
|
|
48
|
-
} from "./phone-number.ts";
|
|
49
53
|
export {
|
|
50
54
|
defaultCspDirectives,
|
|
51
55
|
headers,
|