okengine 0.7.0 → 0.8.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 +2 -2
- package/site/content/docs/elements/channel.mdx +23 -12
- package/site/content/docs/elements/clock.mdx +17 -15
- package/site/content/docs/elements/flow.mdx +6 -2
- package/site/content/docs/elements/store.mdx +131 -0
- package/site/content/docs/get-started/installation.mdx +18 -16
- package/site/content/docs/plugins/magic-link.mdx +42 -0
- package/site/content/docs/plugins/phone-number.mdx +78 -17
- package/site/content/docs/plugins/two-factor.mdx +1 -0
- package/site/content/docs/reference/cli.md +2 -0
- package/site/content/docs/reference/configuration.mdx +5 -3
- package/site/content/docs/reference/environment-variables.mdx +20 -8
- 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/load-config.images.test.ts +22 -0
- package/src/cli/load-config.ts +7 -2
- package/src/cli/registry.ts +37 -1
- package/src/compiler/effects-infer.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/drivers/channel-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-types.ts +71 -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/runtime.ts +51 -0
- package/src/elements/channel.test.ts +71 -0
- 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 +15 -0
- package/src/kernel/app.ts +165 -14
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +13 -0
- package/src/kernel/boot-bind/clock.ts +17 -6
- package/src/kernel/boot-bind/honor-config.test.ts +105 -4
- package/src/kernel/boot-bind/journal.ts +89 -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 +6 -0
- package/src/kernel/fx.ts +126 -5
- package/src/kernel/index.ts +6 -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/plugins/auth-methods.security.test.ts +10 -7
- package/src/plugins/phone-number.ts +67 -10
- package/src/plugins/taqnyat.live.test.ts +174 -0
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
|
}
|
|
@@ -421,7 +421,7 @@ describe("auth methods — anonymous non-escalation", () => {
|
|
|
421
421
|
});
|
|
422
422
|
|
|
423
423
|
describe("auth methods — channel delivery", () => {
|
|
424
|
-
test("magic / email-otp send via fx.send; phone
|
|
424
|
+
test("magic / email-otp send via fx.send; phone uses fx.sendOtp (Taqnyat Verify); exposeDev* stays off by default", async () => {
|
|
425
425
|
resetBindings();
|
|
426
426
|
resetFlowSeq();
|
|
427
427
|
const app = oke({
|
|
@@ -445,6 +445,7 @@ describe("auth methods — channel delivery", () => {
|
|
|
445
445
|
expect(otpBody.data.ok).toBe(true);
|
|
446
446
|
expect(otpBody.data.devOtp).toBeUndefined();
|
|
447
447
|
|
|
448
|
+
// No SMS driver in test env → local hashed path; never exposeDevOtp by default.
|
|
448
449
|
const phone = await app.fetch(jsonPost("/auth/phone/request", { phone: "+15551112222" }));
|
|
449
450
|
const phoneBody = (await phone.json()) as { data: Record<string, unknown> };
|
|
450
451
|
expect(phoneBody.data.ok).toBe(true);
|
|
@@ -453,14 +454,16 @@ describe("auth methods — channel delivery", () => {
|
|
|
453
454
|
const magicSrc = await Bun.file(new URL("./magic-link.ts", import.meta.url)).text();
|
|
454
455
|
const emailSrc = await Bun.file(new URL("./email-otp.ts", import.meta.url)).text();
|
|
455
456
|
const phoneSrc = await Bun.file(new URL("./phone-number.ts", import.meta.url)).text();
|
|
456
|
-
expect(magicSrc).toMatch(/fx\.send/);
|
|
457
|
+
expect(magicSrc).toMatch(/fx\.send\(/);
|
|
457
458
|
expect(magicSrc).toMatch(/channel\./);
|
|
458
|
-
expect(emailSrc).toMatch(/fx\.send/);
|
|
459
|
+
expect(emailSrc).toMatch(/fx\.send\(/);
|
|
459
460
|
expect(emailSrc).toMatch(/channel\./);
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
expect(phoneSrc).
|
|
461
|
+
// Phone OTP is provider-managed via Taqnyat Verify through fx — never a
|
|
462
|
+
// raw transport import or a generic fx.send template. Regexes tolerate
|
|
463
|
+
// formatter line wraps between `fx` and the method name.
|
|
464
|
+
expect(phoneSrc).toMatch(/fx\s*\.\s*sendOtp/);
|
|
465
|
+
expect(phoneSrc).toMatch(/fx\s*\.\s*verifyOtp/);
|
|
466
|
+
expect(phoneSrc).not.toMatch(/taqnyat-sms|sently\/transports/);
|
|
464
467
|
|
|
465
468
|
await app.stop();
|
|
466
469
|
});
|
|
@@ -64,22 +64,57 @@ export function phoneNumber(opts: PhoneNumberOptions = {}): PluginDef {
|
|
|
64
64
|
name: "auth.requestPhoneOtp",
|
|
65
65
|
unit: "auth",
|
|
66
66
|
plane: "user",
|
|
67
|
-
in: z.object({
|
|
67
|
+
in: z.object({
|
|
68
|
+
phone: z.string().min(8),
|
|
69
|
+
/** OTP message language for the Taqnyat Verify path. */
|
|
70
|
+
lang: z.enum(["en", "ar"]).optional(),
|
|
71
|
+
}),
|
|
68
72
|
out: z.object({
|
|
69
73
|
ok: z.literal(true),
|
|
70
74
|
devOtp: z.string().optional(),
|
|
71
75
|
}),
|
|
72
76
|
errors: { AuthFailed, AuthRateLimited },
|
|
73
|
-
|
|
77
|
+
effects: { sends: ["auth-phone-otp", "sms-otp"] },
|
|
78
|
+
do: async (input, fx) => {
|
|
74
79
|
const phone = input.phone.trim();
|
|
75
80
|
if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
|
|
76
|
-
const otp = generateOtp(6);
|
|
77
81
|
const now = runtime.now();
|
|
78
82
|
for (const row of verifications.rows.values()) {
|
|
79
83
|
if (row.identifier === `phone-otp:${phone}` && row.consumedAt === null) {
|
|
80
84
|
row.consumedAt = now;
|
|
81
85
|
}
|
|
82
86
|
}
|
|
87
|
+
|
|
88
|
+
// Provider-managed OTP (Taqnyat Verify) when a Taqnyat SMS driver is
|
|
89
|
+
// bound via Channel. Throws loudly for unsupported SMS drivers; returns
|
|
90
|
+
// undefined only when no SMS driver is bound (local/dev path).
|
|
91
|
+
const requestId = crypto.randomUUID();
|
|
92
|
+
const provider = await fx
|
|
93
|
+
.sendOtp({
|
|
94
|
+
to: phone,
|
|
95
|
+
requestId,
|
|
96
|
+
...(input.lang ? { lang: input.lang } : {}),
|
|
97
|
+
})
|
|
98
|
+
.catch((err: unknown) => {
|
|
99
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
100
|
+
if (msg.includes("no SMS driver bound")) return undefined;
|
|
101
|
+
throw err;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (provider) {
|
|
105
|
+
putVerification(verifications, {
|
|
106
|
+
id: crypto.randomUUID(),
|
|
107
|
+
identifier: `phone-otp:${phone}`,
|
|
108
|
+
value: `taqnyat:${requestId}`,
|
|
109
|
+
expiresAt: now + ttlMs,
|
|
110
|
+
createdAt: now,
|
|
111
|
+
consumedAt: null,
|
|
112
|
+
attempts: 0,
|
|
113
|
+
});
|
|
114
|
+
return { ok: true as const };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const otp = generateOtp(6);
|
|
83
118
|
putVerification(verifications, {
|
|
84
119
|
id: crypto.randomUUID(),
|
|
85
120
|
identifier: `phone-otp:${phone}`,
|
|
@@ -103,10 +138,13 @@ export function phoneNumber(opts: PhoneNumberOptions = {}): PluginDef {
|
|
|
103
138
|
in: z.object({
|
|
104
139
|
phone: z.string().min(8),
|
|
105
140
|
otp: z.string().min(4).max(8),
|
|
141
|
+
/** OTP message language for the Taqnyat Verify path. */
|
|
142
|
+
lang: z.enum(["en", "ar"]).optional(),
|
|
106
143
|
}),
|
|
107
144
|
out: SessionTokensOut,
|
|
108
145
|
errors: { AuthFailed, AuthRateLimited },
|
|
109
|
-
|
|
146
|
+
effects: { sends: ["sms-otp"] },
|
|
147
|
+
do: async (input, fx) => {
|
|
110
148
|
const phone = input.phone.trim();
|
|
111
149
|
if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
|
|
112
150
|
const now = runtime.now();
|
|
@@ -116,13 +154,32 @@ export function phoneNumber(opts: PhoneNumberOptions = {}): PluginDef {
|
|
|
116
154
|
row.consumedAt = now;
|
|
117
155
|
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
118
156
|
}
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
row.
|
|
122
|
-
|
|
123
|
-
|
|
157
|
+
|
|
158
|
+
if (row.value.startsWith("taqnyat:")) {
|
|
159
|
+
const requestId = row.value.slice("taqnyat:".length);
|
|
160
|
+
try {
|
|
161
|
+
await fx.verifyOtp({
|
|
162
|
+
to: phone,
|
|
163
|
+
requestId,
|
|
164
|
+
code: input.otp.trim(),
|
|
165
|
+
...(input.lang ? { lang: input.lang } : {}),
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
row.attempts += 1;
|
|
169
|
+
if (row.attempts >= MAX_ATTEMPTS) row.consumedAt = now;
|
|
170
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
171
|
+
}
|
|
172
|
+
row.consumedAt = now;
|
|
173
|
+
} else {
|
|
174
|
+
const hash = await hashChallenge(input.otp.trim());
|
|
175
|
+
if (hash !== row.value) {
|
|
176
|
+
row.attempts += 1;
|
|
177
|
+
if (row.attempts >= MAX_ATTEMPTS) row.consumedAt = now;
|
|
178
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
179
|
+
}
|
|
180
|
+
row.consumedAt = now;
|
|
124
181
|
}
|
|
125
|
-
|
|
182
|
+
|
|
126
183
|
let userId = phones.byPhone.get(phone);
|
|
127
184
|
if (!userId) {
|
|
128
185
|
userId = crypto.randomUUID();
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real Taqnyat end-to-end: phoneNumber OTP via Verify API + magic-link via Taqnyat Mail.
|
|
3
|
+
*
|
|
4
|
+
* Double-gated — runs ONLY when BOTH:
|
|
5
|
+
* 1. The global per-medium opt-in flag is set explicitly
|
|
6
|
+
* (`OKE_SMS_LIVE=1` / `OKE_EMAIL_LIVE=1`), and
|
|
7
|
+
* 2. the matching real credentials are present in the environment.
|
|
8
|
+
*
|
|
9
|
+
* Credential presence alone is NEVER enough — this mirrors sently's own
|
|
10
|
+
* opt-in live suite and prevents burning provider quota on a routine
|
|
11
|
+
* `bun test` run with stray credentials in the shell. The flags are
|
|
12
|
+
* provider-agnostic: any future SMS or email provider live suite gates on
|
|
13
|
+
* the same medium flag plus its own credentials.
|
|
14
|
+
*
|
|
15
|
+
* Skip is always visible (`console.log("skip: …")` + `test.skip`), never a
|
|
16
|
+
* silent pass.
|
|
17
|
+
*
|
|
18
|
+
* Setup:
|
|
19
|
+
* # SMS OTP — sends exactly ONE real SMS (the plugin's own send; its
|
|
20
|
+
* # provider response is captured for the code-5 assertion)
|
|
21
|
+
* export OKE_SMS_LIVE=1
|
|
22
|
+
* export TAQNYAT_TOKEN=… (or TAQNYAT_BEARER_TOKEN)
|
|
23
|
+
* export TAQNYAT_SENDER=YourBrand
|
|
24
|
+
* export OKE_TEST_TAQNYAT_PHONE=+9665xxxxxxxx (or TAQNYAT_TO)
|
|
25
|
+
* # Mail
|
|
26
|
+
* export OKE_EMAIL_LIVE=1
|
|
27
|
+
* export TAQNYAT_MAIL_TOKEN=…
|
|
28
|
+
* export TAQNYAT_CAMPAIGN=auth
|
|
29
|
+
* export OKE_TEST_TAQNYAT_MAIL=you@example.com (or TAQNYAT_MAIL_TO)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
33
|
+
import { oke } from "../kernel/app.ts";
|
|
34
|
+
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
35
|
+
import { resetBindings } from "../kernel/on.ts";
|
|
36
|
+
import { magicLink } from "./magic-link.ts";
|
|
37
|
+
import { phoneNumber } from "./phone-number.ts";
|
|
38
|
+
|
|
39
|
+
const SECRET = "test-secret-at-least-16";
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
resetBindings();
|
|
43
|
+
resetFlowSeq();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function jsonPost(path: string, body: unknown): Request {
|
|
47
|
+
return new Request(`http://localhost${path}`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "content-type": "application/json" },
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const WANT_SMS = process.env.OKE_SMS_LIVE === "1";
|
|
55
|
+
const WANT_MAIL = process.env.OKE_EMAIL_LIVE === "1";
|
|
56
|
+
|
|
57
|
+
const SMS_TOKEN = process.env.TAQNYAT_TOKEN ?? process.env.TAQNYAT_BEARER_TOKEN;
|
|
58
|
+
const SMS_CREDS = Boolean(SMS_TOKEN) && Boolean(process.env.TAQNYAT_SENDER);
|
|
59
|
+
// OKE_TEST_TAQNYAT_PHONE wins; TAQNYAT_TO (sently's convention) is the alias.
|
|
60
|
+
const SMS_PHONE = process.env.OKE_TEST_TAQNYAT_PHONE ?? process.env.TAQNYAT_TO;
|
|
61
|
+
const LIVE_SMS = WANT_SMS && SMS_CREDS && Boolean(SMS_PHONE);
|
|
62
|
+
|
|
63
|
+
const MAIL_CREDS = Boolean(process.env.TAQNYAT_MAIL_TOKEN) && Boolean(process.env.TAQNYAT_CAMPAIGN);
|
|
64
|
+
const MAIL_TO = process.env.OKE_TEST_TAQNYAT_MAIL ?? process.env.TAQNYAT_MAIL_TO;
|
|
65
|
+
const LIVE_MAIL = WANT_MAIL && MAIL_CREDS && Boolean(MAIL_TO);
|
|
66
|
+
|
|
67
|
+
if (!LIVE_SMS) {
|
|
68
|
+
console.log(
|
|
69
|
+
WANT_SMS
|
|
70
|
+
? "skip: taqnyat SMS OTP live (missing TAQNYAT_TOKEN/TAQNYAT_SENDER or OKE_TEST_TAQNYAT_PHONE)"
|
|
71
|
+
: "skip: taqnyat SMS OTP live (OKE_SMS_LIVE≠1)",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (!LIVE_MAIL) {
|
|
75
|
+
console.log(
|
|
76
|
+
WANT_MAIL
|
|
77
|
+
? "skip: taqnyat mail live (missing TAQNYAT_MAIL_TOKEN/TAQNYAT_CAMPAIGN or OKE_TEST_TAQNYAT_MAIL)"
|
|
78
|
+
: "skip: taqnyat mail live (OKE_EMAIL_LIVE≠1)",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const liveSms = LIVE_SMS ? test : test.skip;
|
|
83
|
+
const liveMail = LIVE_MAIL ? test : test.skip;
|
|
84
|
+
|
|
85
|
+
describe("taqnyat live — provider-managed OTP (Taqnyat Verify)", () => {
|
|
86
|
+
liveSms(
|
|
87
|
+
"phone OTP request → real sendOtp → Taqnyat success code 5",
|
|
88
|
+
async () => {
|
|
89
|
+
const app = oke({
|
|
90
|
+
name: `taqnyat-live-${crypto.randomUUID()}`,
|
|
91
|
+
env: "test",
|
|
92
|
+
registry: "ignore",
|
|
93
|
+
gate: { auth: { secret: SECRET } },
|
|
94
|
+
config: {
|
|
95
|
+
drivers: { channel: { sms: { test: "taqnyat" } } },
|
|
96
|
+
},
|
|
97
|
+
}).plug(phoneNumber());
|
|
98
|
+
await app.boot({ env: "test" });
|
|
99
|
+
|
|
100
|
+
// Wrap the live transport so the plugin's single real send also proves
|
|
101
|
+
// the provider accepted it (success code 5) — exactly one SMS, never two.
|
|
102
|
+
const sms = app.bootResult?.channel?.drivers.find((d) => d.id === "taqnyat")?.smsTransport as
|
|
103
|
+
| {
|
|
104
|
+
sendOtp(o: {
|
|
105
|
+
to: string;
|
|
106
|
+
requestId: string;
|
|
107
|
+
lang?: "en" | "ar";
|
|
108
|
+
}): Promise<{ code: number }>;
|
|
109
|
+
}
|
|
110
|
+
| undefined;
|
|
111
|
+
expect(sms).toBeDefined();
|
|
112
|
+
const realSendOtp = sms!.sendOtp.bind(sms);
|
|
113
|
+
let providerCode: number | undefined;
|
|
114
|
+
sms!.sendOtp = async (opts) => {
|
|
115
|
+
const result = await realSendOtp(opts);
|
|
116
|
+
providerCode = result.code;
|
|
117
|
+
return result;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const res = await app.fetch(
|
|
121
|
+
jsonPost("/auth/phone/request", { phone: SMS_PHONE, lang: "en" }),
|
|
122
|
+
);
|
|
123
|
+
const body = (await res.json()) as { data?: { ok: true }; error?: { message?: string } };
|
|
124
|
+
if (res.status !== 200) {
|
|
125
|
+
console.log("taqnyat sendOtp response", res.status, body);
|
|
126
|
+
}
|
|
127
|
+
expect(res.status).toBe(200);
|
|
128
|
+
expect(body.data?.ok).toBe(true);
|
|
129
|
+
// Local dev leak must stay off in the provider path.
|
|
130
|
+
expect((body.data as { devOtp?: string }).devOtp).toBeUndefined();
|
|
131
|
+
// Taqnyat Verify documented success code — from the plugin's own send.
|
|
132
|
+
expect(providerCode).toBe(5);
|
|
133
|
+
|
|
134
|
+
await app.stop();
|
|
135
|
+
},
|
|
136
|
+
30_000,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("taqnyat live — magic-link via Taqnyat Mail", () => {
|
|
141
|
+
liveMail(
|
|
142
|
+
"magic-link request → real TaqnyatMailTransport send",
|
|
143
|
+
async () => {
|
|
144
|
+
const app = oke({
|
|
145
|
+
name: `taqnyat-mail-live-${crypto.randomUUID()}`,
|
|
146
|
+
env: "test",
|
|
147
|
+
registry: "ignore",
|
|
148
|
+
gate: { auth: { secret: SECRET } },
|
|
149
|
+
config: {
|
|
150
|
+
drivers: { channel: { email: { test: "taqnyat-mail" } } },
|
|
151
|
+
},
|
|
152
|
+
}).plug(magicLink({ baseUrl: "http://app.test:6530" }));
|
|
153
|
+
await app.boot({ env: "test" });
|
|
154
|
+
|
|
155
|
+
const res = await app.fetch(jsonPost("/auth/magic-link/request", { email: MAIL_TO }));
|
|
156
|
+
const body = (await res.json()) as { data?: { ok: true }; error?: { message?: string } };
|
|
157
|
+
if (res.status !== 200) {
|
|
158
|
+
console.log("taqnyat mail send response", res.status, body);
|
|
159
|
+
}
|
|
160
|
+
expect(res.status).toBe(200);
|
|
161
|
+
expect(body.data?.ok).toBe(true);
|
|
162
|
+
expect((body.data as { devToken?: string }).devToken).toBeUndefined();
|
|
163
|
+
|
|
164
|
+
// A successful send is recorded in the Channel receipt ledger.
|
|
165
|
+
const receipts = app.bootResult?.channel?.receipts.all() ?? [];
|
|
166
|
+
const sent = receipts.find((r) => r.to === MAIL_TO);
|
|
167
|
+
expect(sent?.status === "sent" || sent?.status === "fallback").toBe(true);
|
|
168
|
+
expect(sent?.driverId).toContain("taqnyat");
|
|
169
|
+
|
|
170
|
+
await app.stop();
|
|
171
|
+
},
|
|
172
|
+
30_000,
|
|
173
|
+
);
|
|
174
|
+
});
|