okengine 0.19.8 → 0.20.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.
Files changed (108) hide show
  1. package/AGENTS.md +1 -1
  2. package/package.json +1 -1
  3. package/site/content/docs/client/auth.mdx +1 -2
  4. package/site/content/docs/client/calling.mdx +8 -8
  5. package/site/content/docs/client/index.mdx +4 -4
  6. package/site/content/docs/client/react.mdx +5 -0
  7. package/site/content/docs/elements/channel/email.mdx +25 -36
  8. package/site/content/docs/elements/channel/index.mdx +88 -46
  9. package/site/content/docs/elements/channel/push.mdx +7 -9
  10. package/site/content/docs/elements/channel/sms.mdx +6 -4
  11. package/site/content/docs/elements/channel/whatsapp.mdx +11 -9
  12. package/site/content/docs/elements/clock/index.mdx +14 -25
  13. package/site/content/docs/elements/flow/http.mdx +24 -8
  14. package/site/content/docs/elements/flow/index.mdx +15 -12
  15. package/site/content/docs/elements/flow/routing.mdx +166 -122
  16. package/site/content/docs/elements/gate/rls.mdx +2 -2
  17. package/site/content/docs/elements/store/index.mdx +11 -3
  18. package/site/content/docs/elements/store/search.mdx +5 -5
  19. package/site/content/docs/elements/store/sql.mdx +91 -33
  20. package/site/content/docs/elements/vault/index.mdx +3 -5
  21. package/site/content/docs/plugins/magic-link.mdx +4 -3
  22. package/site/content/docs/plugins/otp.mdx +4 -3
  23. package/site/content/docs/plugins/two-factor.mdx +4 -0
  24. package/site/content/docs/providers/index.mdx +1 -1
  25. package/site/content/docs/recipes/index.mdx +1 -1
  26. package/site/content/docs/reference/cli.mdx +13 -4
  27. package/site/content/docs/reference/configuration.mdx +7 -7
  28. package/site/content/docs/reference/errors.mdx +30 -30
  29. package/site/content/docs/reference/fx.mdx +16 -8
  30. package/site/content/docs/reference/i18n.mdx +4 -4
  31. package/site/content/docs/reference/plugins.mdx +4 -4
  32. package/site/content/docs/understand/try-it.mdx +2 -0
  33. package/src/cli/ai-setup/ai-setup.test.ts +40 -0
  34. package/src/cli/ai-setup/apply.ts +28 -46
  35. package/src/cli/ask-vault-gaps.test.ts +60 -4
  36. package/src/cli/ask-vault-gaps.ts +59 -3
  37. package/src/cli/build.test.ts +3 -3
  38. package/src/cli/build.ts +5 -5
  39. package/src/cli/db-auto-push.test.ts +11 -0
  40. package/src/cli/db-auto-push.ts +6 -2
  41. package/src/cli/db.test.ts +1 -1
  42. package/src/cli/db.ts +6 -6
  43. package/src/cli/dev-app-runner.ts +12 -4
  44. package/src/cli/dev-db-push.test.ts +6 -2
  45. package/src/cli/dev-schema-sync.ts +1 -1
  46. package/src/cli/dev.test.ts +10 -7
  47. package/src/cli/dev.ts +18 -12
  48. package/src/cli/ensure-drizzle-config.ts +4 -3
  49. package/src/client-react/browser.test.ts +23 -0
  50. package/src/client-react/use-live-query.ts +1 -1
  51. package/src/compiler/flow-path.test.ts +1 -0
  52. package/src/compiler/flow-path.ts +1 -1
  53. package/src/compiler/generate-adopt.test.ts +55 -1
  54. package/src/compiler/generate-adopt.ts +111 -21
  55. package/src/config/index.ts +6 -4
  56. package/src/console/ui-next/dist/assets/{access-page-C5yG4aS2.js → access-page-DFLu0wTA.js} +1 -1
  57. package/src/console/ui-next/dist/assets/{agent-disclosure-D2ToVK86.js → agent-disclosure-DGscxaF5.js} +1 -1
  58. package/src/console/ui-next/dist/assets/{cache-glyph-7HUR0kCz.js → cache-glyph-BGmRZk7d.js} +1 -1
  59. package/src/console/ui-next/dist/assets/{call-pii-button-D0ky3aXt.js → call-pii-button--feUYxvG.js} +1 -1
  60. package/src/console/ui-next/dist/assets/{collapsible-BLUHH2dB.js → collapsible-JWvpaiGY.js} +1 -1
  61. package/src/console/ui-next/dist/assets/{duration-tone-BgHEFMtm.js → duration-tone-D9yCJG4n.js} +1 -1
  62. package/src/console/ui-next/dist/assets/{flows-page-DDybqshQ.js → flows-page-Bs6MD9GB.js} +1 -1
  63. package/src/console/ui-next/dist/assets/{highlighted-json-BFiJKYV4.js → highlighted-json-xH8MrEnv.js} +1 -1
  64. package/src/console/ui-next/dist/assets/{http-method-BVAcB0bI.js → http-method-C4vB6ZIw.js} +1 -1
  65. package/src/console/ui-next/dist/assets/{index-BID6LSYI.js → index-yTCY4AcS.js} +3 -3
  66. package/src/console/ui-next/dist/assets/{observability-page-CANo_mMK.js → observability-page-BxJ3R6dU.js} +1 -1
  67. package/src/console/ui-next/dist/assets/{replica-lag-CqQGCshp.js → replica-lag-QRKB_IE8.js} +1 -1
  68. package/src/console/ui-next/dist/assets/{request-meta-DyUDkEt4.js → request-meta-DqZ-fMu5.js} +1 -1
  69. package/src/console/ui-next/dist/assets/{store-page-CA0rJ_Ow.js → store-page-Dixb6L7a.js} +1 -1
  70. package/src/console/ui-next/dist/assets/{trace-detail-sheet-DjH_fMzI.js → trace-detail-sheet-CazhjtiU.js} +1 -1
  71. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWSomIeQ.js → tree-expand-toggle-DlnqYKfr.js} +1 -1
  72. package/src/console/ui-next/dist/assets/{units-page-Cz9TIViw.js → units-page-BXTLjU2-.js} +1 -1
  73. package/src/console/ui-next/dist/assets/{vault-page-Bb1o1O9p.js → vault-page-39KR__bc.js} +1 -1
  74. package/src/console/ui-next/dist/index.html +1 -1
  75. package/src/drivers/clock-postgres.test.ts +10 -2
  76. package/src/drivers/clock-postgres.ts +18 -2
  77. package/src/drivers/vault-driver-removal.test.ts +2 -2
  78. package/src/elements/channel/declare.ts +66 -3
  79. package/src/elements/channel/runtime.ts +9 -11
  80. package/src/elements/channel.test.ts +42 -0
  81. package/src/elements/channel.ts +4 -2
  82. package/src/elements/clock/reconcile.ts +45 -24
  83. package/src/elements/clock.test.ts +33 -0
  84. package/src/elements/store/emit-drizzle.ts +285 -65
  85. package/src/elements/store/load-plugin-tables.ts +1 -1
  86. package/src/elements/store/prepare-row.test.ts +57 -4
  87. package/src/elements/store/schema-decl.test.ts +178 -0
  88. package/src/elements/store/sql-session.ts +44 -2
  89. package/src/elements/store/table.ts +8 -6
  90. package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
  91. package/src/kernel/app.ts +26 -32
  92. package/src/kernel/auto-registry.test.ts +26 -1
  93. package/src/kernel/boot.ts +2 -2
  94. package/src/kernel/boundary-contract.ts +6 -1
  95. package/src/kernel/errors.ts +3 -3
  96. package/src/kernel/flow-units.ts +3 -3
  97. package/src/kernel/fx.ts +12 -2
  98. package/src/kernel/mutation-id.ts +8 -0
  99. package/src/kernel/plugin.ts +4 -3
  100. package/src/kernel/project-out.test.ts +176 -0
  101. package/src/kernel/project-out.ts +91 -0
  102. package/src/kernel/realtime-bind.ts +2 -3
  103. package/src/kernel/router/linear.ts +12 -6
  104. package/src/kernel/router.test.ts +13 -0
  105. package/src/plugins/magic-link.ts +25 -24
  106. package/src/plugins/otp.ts +35 -24
  107. package/src/plugins/two-factor.ts +15 -0
  108. package/src/runs/duckdb.test.ts +2 -2
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Flow `out` projection — store rows through ISO `out` without DTO mappers.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { z } from "zod";
7
+ import { oke } from "./app.ts";
8
+ import { flow, resetFlowSeq } from "./flow.ts";
9
+ import { isJsonResult } from "./fx.ts";
10
+ import { on, resetBindings } from "./on.ts";
11
+ import { projectFlowOut } from "./project-out.ts";
12
+ import { http } from "./triggers.ts";
13
+
14
+ afterEach(() => {
15
+ resetBindings();
16
+ resetFlowSeq();
17
+ });
18
+
19
+ const IsoInstant = z.iso.datetime();
20
+
21
+ const LinkOut = z.object({
22
+ id: z.string(),
23
+ clicks: z.number(),
24
+ expiresAt: IsoInstant.nullable(),
25
+ createdAt: IsoInstant,
26
+ });
27
+
28
+ describe("projectFlowOut", () => {
29
+ test("JSON-wires Date timestamps onto iso.datetime out", async () => {
30
+ const createdAt = new Date("2026-09-14T12:00:00.000Z");
31
+ const projected = await projectFlowOut(LinkOut, {
32
+ id: "lnk_1",
33
+ clicks: 0,
34
+ expiresAt: null,
35
+ createdAt,
36
+ extra: "drop-me",
37
+ });
38
+ expect(projected).toEqual({
39
+ id: "lnk_1",
40
+ clicks: 0,
41
+ expiresAt: null,
42
+ createdAt: "2026-09-14T12:00:00.000Z",
43
+ });
44
+ });
45
+
46
+ test("JSON-wires fx.clock.now()-style epoch-ms *At onto iso.datetime out", async () => {
47
+ const createdAt = Date.parse("2026-09-14T12:00:00.000Z");
48
+ const projected = await projectFlowOut(LinkOut, {
49
+ id: "lnk_1",
50
+ clicks: 0,
51
+ expiresAt: null,
52
+ createdAt,
53
+ });
54
+ expect(projected).toEqual({
55
+ id: "lnk_1",
56
+ clicks: 0,
57
+ expiresAt: null,
58
+ createdAt: "2026-09-14T12:00:00.000Z",
59
+ });
60
+ });
61
+
62
+ test("fx.json.create carrier projects .value and keeps 201", async () => {
63
+ const createdAt = new Date("2026-09-14T12:00:00.000Z");
64
+ const fx = (await import("./fx.ts")).createFx({ flow: "links.create" });
65
+ const carrier = await projectFlowOut(
66
+ LinkOut,
67
+ fx.json.create({
68
+ id: "lnk_1",
69
+ clicks: 0,
70
+ expiresAt: null,
71
+ createdAt,
72
+ }),
73
+ );
74
+ expect(isJsonResult(carrier)).toBe(true);
75
+ if (!isJsonResult(carrier)) return;
76
+ expect(carrier.status).toBe(201);
77
+ expect(carrier.value).toEqual({
78
+ id: "lnk_1",
79
+ clicks: 0,
80
+ expiresAt: null,
81
+ createdAt: "2026-09-14T12:00:00.000Z",
82
+ });
83
+ });
84
+
85
+ test("z.date() out keeps Date (direct parse, no wire)", async () => {
86
+ const createdAt = new Date("2026-09-14T12:00:00.000Z");
87
+ const Out = z.object({ createdAt: z.date() });
88
+ const projected = await projectFlowOut(Out, { createdAt });
89
+ expect(projected).toEqual({ createdAt });
90
+ expect((projected as { createdAt: Date }).createdAt).toBe(createdAt);
91
+ });
92
+
93
+ test("array out projects each row", async () => {
94
+ const createdAt = new Date("2026-09-14T12:00:00.000Z");
95
+ const projected = await projectFlowOut(z.array(LinkOut), [
96
+ { id: "lnk_1", clicks: 1, expiresAt: null, createdAt },
97
+ ]);
98
+ expect(projected).toEqual([
99
+ { id: "lnk_1", clicks: 1, expiresAt: null, createdAt: "2026-09-14T12:00:00.000Z" },
100
+ ]);
101
+ });
102
+
103
+ test("bigint integers JSON-wire onto z.number()", async () => {
104
+ const Out = z.object({ clicks: z.number() });
105
+ const projected = await projectFlowOut(Out, { clicks: 3n });
106
+ expect(projected).toEqual({ clicks: 3 });
107
+ });
108
+
109
+ test("missing out is a no-op", async () => {
110
+ const row = { id: "lnk_1", createdAt: new Date() };
111
+ expect(await projectFlowOut(undefined, row)).toBe(row);
112
+ });
113
+ });
114
+
115
+ describe("execute — out projects fx.json.create(row)", () => {
116
+ test("in-process create returns ISO timestamps from a Date row", async () => {
117
+ resetBindings();
118
+ resetFlowSeq();
119
+ const createdAt = new Date("2026-09-14T12:00:00.000Z");
120
+ on(
121
+ http.post("/links", { out: LinkOut }).public(),
122
+ flow("links.create", {
123
+ do: (_input, fx) =>
124
+ fx.json.create({
125
+ id: "lnk_1",
126
+ clicks: 0,
127
+ expiresAt: null,
128
+ createdAt,
129
+ secret: "not-on-the-wire",
130
+ }),
131
+ }),
132
+ );
133
+ const app = oke({ name: "project-out", env: "test" });
134
+ await app.boot({ env: "test" });
135
+ const flowDef = app.flow("links.create")!;
136
+ const result = await app.execute(flowDef, {}, flowDef.triggers[0]!);
137
+ expect(result.failure).toBeUndefined();
138
+ expect(isJsonResult(result.output)).toBe(true);
139
+ if (!isJsonResult(result.output)) return;
140
+ expect(result.output.status).toBe(201);
141
+ expect(result.output.value).toEqual({
142
+ id: "lnk_1",
143
+ clicks: 0,
144
+ expiresAt: null,
145
+ createdAt: "2026-09-14T12:00:00.000Z",
146
+ });
147
+ await app.bootResult?.close();
148
+ });
149
+
150
+ test("withQuery page projects Date rows onto array out", async () => {
151
+ resetBindings();
152
+ resetFlowSeq();
153
+ const createdAt = new Date("2026-09-14T12:00:00.000Z");
154
+ on(
155
+ http.get("/links", { out: z.array(LinkOut) }).public(),
156
+ flow("links.list", {
157
+ do: (input, fx) =>
158
+ fx.json.withQuery(
159
+ [{ id: "lnk_1", clicks: 0, expiresAt: null, createdAt, extra: true }],
160
+ input,
161
+ ),
162
+ }),
163
+ );
164
+ const app = oke({ name: "project-out-list", env: "test" });
165
+ await app.boot({ env: "test" });
166
+ const flowDef = app.flow("links.list")!;
167
+ const result = await app.execute(flowDef, { limit: 10 }, flowDef.triggers[0]!);
168
+ expect(result.failure).toBeUndefined();
169
+ expect(isJsonResult(result.output)).toBe(true);
170
+ if (!isJsonResult(result.output)) return;
171
+ expect(result.output.value).toEqual([
172
+ { id: "lnk_1", clicks: 0, expiresAt: null, createdAt: "2026-09-14T12:00:00.000Z" },
173
+ ]);
174
+ await app.bootResult?.close();
175
+ });
176
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Project a Flow success value onto the exposure `out` schema.
3
+ *
4
+ * Store rows carry `Date` timestamps (or epoch-ms from `fx.clock.now()`);
5
+ * HTTP `out` is usually ISO-8601. Direct Standard Schema parse keeps
6
+ * `z.date()` replies as dates. When that fails, JSON-wire (`Date` → ISO,
7
+ * `*At` / `*_at` / `at` epoch-ms → ISO) and parse again so
8
+ * `fx.json.create(row)` matches `out` without a hand-written DTO mapper.
9
+ * Extra keys strip when parse succeeds. Parse failure is not a client
10
+ * error — `out` is a projector, not a hard gate.
11
+ */
12
+
13
+ import type { SchemaInput } from "../validation/standard-schema.ts";
14
+ import { validate } from "../validation/standard-schema.ts";
15
+ import { isJsonResult, isJsonStreamResult, jsonResultBrand, type JsonResult } from "./fx.ts";
16
+
17
+ /**
18
+ * Project `output` through `schema` when the exposure declared `out`.
19
+ *
20
+ * JsonResult carriers project `.value` (status / meta stay). Streams, 204,
21
+ * and missing schema pass through.
22
+ *
23
+ * @param schema - Flow `out` (Standard Schema or unknown)
24
+ * @param output - Handler return
25
+ */
26
+ export async function projectFlowOut(
27
+ schema: SchemaInput | undefined,
28
+ output: unknown,
29
+ ): Promise<unknown> {
30
+ if (schema === undefined || schema === null) return output;
31
+ if (output === undefined) return output;
32
+ if (isJsonStreamResult(output)) return output;
33
+ if (isJsonResult(output)) {
34
+ if (output.status === 204 || output.value === undefined) return output;
35
+ const value = await projectValue(schema, output.value);
36
+ if (value === output.value) return output;
37
+ return {
38
+ [jsonResultBrand]: true,
39
+ status: output.status,
40
+ value,
41
+ ...(output.meta !== undefined ? { meta: output.meta } : {}),
42
+ } as JsonResult;
43
+ }
44
+ return projectValue(schema, output);
45
+ }
46
+
47
+ /**
48
+ * Parse `value` as `schema`. On miss, JSON-wire then parse (Date / epoch-ms → ISO).
49
+ *
50
+ * @param schema - Success reply schema
51
+ * @param value - Raw handler / store value
52
+ */
53
+ async function projectValue(schema: SchemaInput, value: unknown): Promise<unknown> {
54
+ const direct = await validate(schema, value);
55
+ if (direct.ok) return direct.value;
56
+ const wired = jsonWire(value);
57
+ if (wired === value) return value;
58
+ const second = await validate(schema, wired);
59
+ return second.ok ? second.value : value;
60
+ }
61
+
62
+ /**
63
+ * Whether a JSON key looks like a temporal field (`createdAt`, `expires_at`, `at`).
64
+ *
65
+ * @param key - Property name from {@link JSON.stringify} replacer
66
+ */
67
+ function isTemporalKey(key: string): boolean {
68
+ return key === "at" || key.endsWith("At") || key.endsWith("_at");
69
+ }
70
+
71
+ /**
72
+ * JSON round-trip so `Date` and temporal epoch-ms become ISO-8601
73
+ * (same bytes `Response.json` emits for dates).
74
+ *
75
+ * @param value - Arbitrary success payload
76
+ */
77
+ function jsonWire(value: unknown): unknown {
78
+ try {
79
+ return JSON.parse(
80
+ JSON.stringify(value, (key, item) => {
81
+ if (typeof item === "bigint") return Number(item);
82
+ if (typeof item === "number" && Number.isFinite(item) && isTemporalKey(key)) {
83
+ return new Date(item).toISOString();
84
+ }
85
+ return item;
86
+ }),
87
+ );
88
+ } catch {
89
+ return value;
90
+ }
91
+ }
@@ -22,6 +22,8 @@ import { setSqlCdcSink, type SqlCdcSink } from "../elements/store/sql-session.ts
22
22
  import type { LiveQueryEvent, LiveSubscription } from "../elements/store/live-query-runtime.ts";
23
23
  import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
24
24
 
25
+ export { MUTATION_ID_HEADER } from "./mutation-id.ts";
26
+
25
27
  /**
26
28
  * Column-kind hint for one SQL column — drives {@link restoreImage} type
27
29
  * restoration for outbox-round-tripped CDC images.
@@ -41,9 +43,6 @@ export function liveSignalName(table: string): string {
41
43
  return `${LIVE_SIGNAL_PREFIX}${table}`;
42
44
  }
43
45
 
44
- /** Mutation header echoed into `mutationId` on write-path events. */
45
- export const MUTATION_ID_HEADER = "x-oke-mutation-id";
46
-
47
46
  /** Per-stream event buffer cap (bounded memory per SSE session). */
48
47
  const LIVE_STREAM_BUFFER_MAX = 512;
49
48
 
@@ -31,6 +31,9 @@ export class LinearRouter<T> implements Router<T> {
31
31
  }
32
32
 
33
33
  /**
34
+ * Match a method + pathname. Static routes win over `/:param` / `*` even
35
+ * when the parametric route was registered first.
36
+ *
34
37
  * @param method - HTTP method
35
38
  * @param path - Pathname
36
39
  */
@@ -40,15 +43,18 @@ export class LinearRouter<T> implements Router<T> {
40
43
  pathParts.pop();
41
44
  }
42
45
 
46
+ // Static first — `GET /health` must win over an earlier `GET /:code`.
43
47
  for (const route of this.#routes) {
44
48
  if (route.method !== method) continue;
45
-
46
- if (!route.path.includes(":") && !route.path.includes("*")) {
47
- if (route.path === path || `${route.path}/` === path) {
48
- return { value: route.value, params: {} };
49
- }
50
- continue;
49
+ if (route.path.includes(":") || route.path.includes("*")) continue;
50
+ if (route.path === path || `${route.path}/` === path) {
51
+ return { value: route.value, params: {} };
51
52
  }
53
+ }
54
+
55
+ for (const route of this.#routes) {
56
+ if (route.method !== method) continue;
57
+ if (!route.path.includes(":") && !route.path.includes("*")) continue;
52
58
 
53
59
  const params: Record<string, string> = {};
54
60
  let pi = 0;
@@ -71,6 +71,19 @@ describe("router — RegExp + Trie + Linear + Smart", () => {
71
71
  expect(r.match("GET", "/x/1")).toEqual({ value: "x", params: { id: "1" } });
72
72
  });
73
73
 
74
+ test("LinearRouter prefers static over an earlier /:param", () => {
75
+ const linear = new LinearRouter<string>();
76
+ linear.add("GET", "/:code", "redirect");
77
+ linear.add("GET", "/health", "health");
78
+ linear.add("GET", "/links", "list");
79
+ expect(linear.match("GET", "/health")?.value).toBe("health");
80
+ expect(linear.match("GET", "/links")?.value).toBe("list");
81
+ expect(linear.match("GET", "/abc")).toEqual({
82
+ value: "redirect",
83
+ params: { code: "abc" },
84
+ });
85
+ });
86
+
74
87
  test("allowedMethods lists verbs for a path and is empty for unknown paths", () => {
75
88
  const regexp = new RegExpRouter<string>();
76
89
  regexp.add("POST", "/notes", "create");
@@ -40,31 +40,36 @@ const DEFAULT_TTL_MS = 10 * 60 * 1000;
40
40
  const DEFAULT_FROM = "OKE <no-reply@oke.local>";
41
41
  const DEFAULT_BASE_URL = "http://127.0.0.1:6530";
42
42
 
43
+ const MAGIC_LINK_SCHEMA = z.object({
44
+ email: z.string(),
45
+ token: z.string(),
46
+ link: z.string(),
47
+ });
48
+
49
+ const MAGIC_LINK_BODIES = {
50
+ en: {
51
+ subject: "Your sign-in link",
52
+ text: "Sign in with this link:\n{{link}}\n\nOr enter this token:\n{{token}}\n",
53
+ html: '<p>Sign in with this link:</p><p><a href="{{link}}">{{link}}</a></p><p>Or enter this token:</p><p><code>{{token}}</code></p>',
54
+ },
55
+ ar: {
56
+ subject: "رابط تسجيل الدخول",
57
+ text: "سجّل الدخول عبر هذا الرابط:\n{{link}}\n\nأو أدخل هذا الرمز:\n{{token}}\n",
58
+ html: '<p dir="rtl">سجّل الدخول عبر هذا الرابط:</p><p dir="rtl"><a href="{{link}}">{{link}}</a></p><p dir="rtl">أو أدخل هذا الرمز:</p><p dir="rtl"><code>{{token}}</code></p>',
59
+ },
60
+ } as const;
61
+
43
62
  /** Channel template for magic-link delivery. */
44
63
  export const magicLinkTemplate = channel.email({ from: DEFAULT_FROM }).template("auth-magic-link", {
45
64
  description: "Magic-link sign-in email",
46
- schema: z.object({
47
- email: z.string(),
48
- token: z.string(),
49
- link: z.string(),
50
- }),
65
+ schema: MAGIC_LINK_SCHEMA,
51
66
  locales: ["en", "ar"],
67
+ catalog: MAGIC_LINK_BODIES,
52
68
  });
53
69
 
54
- /** Default EN/AR bodies for {@link magicLinkTemplate}. */
70
+ /** Default EN/AR bodies for {@link magicLinkTemplate} (overlay / tests). */
55
71
  export const magicLinkCatalog = {
56
- "auth-magic-link": {
57
- en: {
58
- subject: "Your sign-in link",
59
- text: "Sign in with this link:\n{{link}}\n\nOr enter this token:\n{{token}}\n",
60
- html: '<p>Sign in with this link:</p><p><a href="{{link}}">{{link}}</a></p><p>Or enter this token:</p><p><code>{{token}}</code></p>',
61
- },
62
- ar: {
63
- subject: "رابط تسجيل الدخول",
64
- text: "سجّل الدخول عبر هذا الرابط:\n{{link}}\n\nأو أدخل هذا الرمز:\n{{token}}\n",
65
- html: '<p dir="rtl">سجّل الدخول عبر هذا الرابط:</p><p dir="rtl"><a href="{{link}}">{{link}}</a></p><p dir="rtl">أو أدخل هذا الرمز:</p><p dir="rtl"><code>{{token}}</code></p>',
66
- },
67
- },
72
+ "auth-magic-link": MAGIC_LINK_BODIES,
68
73
  } as const;
69
74
 
70
75
  /** Options for {@link magicLink}. */
@@ -101,12 +106,9 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
101
106
  opts.from !== undefined
102
107
  ? channel.email({ from: opts.from }).template("auth-magic-link", {
103
108
  description: "Magic-link sign-in email",
104
- schema: z.object({
105
- email: z.string(),
106
- token: z.string(),
107
- link: z.string(),
108
- }),
109
+ schema: MAGIC_LINK_SCHEMA,
109
110
  locales: ["en", "ar"],
111
+ catalog: MAGIC_LINK_BODIES,
110
112
  })
111
113
  : magicLinkTemplate;
112
114
 
@@ -213,7 +215,6 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
213
215
  .needs("auth")
214
216
  .needs("channel")
215
217
  .channelTemplate(tmpl)
216
- .channelCatalog(magicLinkCatalog)
217
218
  .binding(bindPublicAuth("/magic-link/request", request, "otp", requestContract))
218
219
  .binding(bindPublicAuth("/magic-link/verify", verify, "otp", verifyContract));
219
220
  }
@@ -94,11 +94,37 @@ export interface OtpAppModeOptions extends OtpBaseOptions {
94
94
  /** Options for {@link otp}. `mode` is mandatory — no auto-detect. */
95
95
  export type OtpOptions = OtpProviderModeOptions | OtpAppModeOptions;
96
96
 
97
+ const OTP_EMAIL_BODIES = {
98
+ en: {
99
+ subject: "Your sign-in code",
100
+ text: "Your one-time sign-in code is: {{otp}}\n",
101
+ html: "<p>Your one-time sign-in code is:</p><p><strong>{{otp}}</strong></p>",
102
+ },
103
+ ar: {
104
+ subject: "رمز تسجيل الدخول",
105
+ text: "رمز تسجيل الدخول لمرة واحدة هو: {{otp}}\n",
106
+ html: '<p dir="rtl">رمز تسجيل الدخول لمرة واحدة هو:</p><p dir="rtl"><strong>{{otp}}</strong></p>',
107
+ },
108
+ } as const;
109
+
110
+ const OTP_SMS_BODIES = {
111
+ en: { text: "Your sign-in code is: {{otp}}" },
112
+ ar: { text: "رمز تسجيل الدخول: {{otp}}" },
113
+ } as const;
114
+
115
+ const OTP_WHATSAPP_BODIES = {
116
+ en: { text: "Your sign-in code is: {{otp}}" },
117
+ ar: { text: "رمز تسجيل الدخول: {{otp}}" },
118
+ } as const;
119
+
120
+ const OTP_EMAIL_SCHEMA = z.object({ email: z.string(), otp: z.string() });
121
+
97
122
  /** Email OTP template (app mode). */
98
123
  export const otpEmailTemplate = channel.email({ from: DEFAULT_FROM }).template("auth-otp-email", {
99
124
  description: "OTP sign-in code (email)",
100
- schema: z.object({ email: z.string(), otp: z.string() }),
125
+ schema: OTP_EMAIL_SCHEMA,
101
126
  locales: ["en", "ar"],
127
+ catalog: OTP_EMAIL_BODIES,
102
128
  });
103
129
 
104
130
  /** SMS OTP template (app mode — plain message, not provider Verify). */
@@ -106,6 +132,7 @@ export const otpSmsTemplate = channel.sms().template("auth-otp-sms", {
106
132
  description: "OTP sign-in code (SMS)",
107
133
  schema: z.object({ phone: z.string(), otp: z.string() }),
108
134
  locales: ["en", "ar"],
135
+ catalog: OTP_SMS_BODIES,
109
136
  });
110
137
 
111
138
  /** WhatsApp OTP template (app mode). */
@@ -113,30 +140,14 @@ export const otpWhatsappTemplate = channel.whatsapp().template("auth-otp-whatsap
113
140
  description: "OTP sign-in code (WhatsApp)",
114
141
  schema: z.object({ phone: z.string(), otp: z.string() }),
115
142
  locales: ["en", "ar"],
143
+ catalog: OTP_WHATSAPP_BODIES,
116
144
  });
117
145
 
118
- /** Default EN/AR bodies for OTP templates. */
146
+ /** Default EN/AR bodies for OTP templates (overlay / tests). */
119
147
  export const otpCatalog = {
120
- "auth-otp-email": {
121
- en: {
122
- subject: "Your sign-in code",
123
- text: "Your one-time sign-in code is: {{otp}}\n",
124
- html: "<p>Your one-time sign-in code is:</p><p><strong>{{otp}}</strong></p>",
125
- },
126
- ar: {
127
- subject: "رمز تسجيل الدخول",
128
- text: "رمز تسجيل الدخول لمرة واحدة هو: {{otp}}\n",
129
- html: '<p dir="rtl">رمز تسجيل الدخول لمرة واحدة هو:</p><p dir="rtl"><strong>{{otp}}</strong></p>',
130
- },
131
- },
132
- "auth-otp-sms": {
133
- en: { text: "Your sign-in code is: {{otp}}" },
134
- ar: { text: "رمز تسجيل الدخول: {{otp}}" },
135
- },
136
- "auth-otp-whatsapp": {
137
- en: { text: "Your sign-in code is: {{otp}}" },
138
- ar: { text: "رمز تسجيل الدخول: {{otp}}" },
139
- },
148
+ "auth-otp-email": OTP_EMAIL_BODIES,
149
+ "auth-otp-sms": OTP_SMS_BODIES,
150
+ "auth-otp-whatsapp": OTP_WHATSAPP_BODIES,
140
151
  } as const;
141
152
 
142
153
  function assertOtpOptions(opts: OtpOptions): void {
@@ -217,8 +228,9 @@ export function otp(opts: OtpOptions): PluginDef {
217
228
  from !== undefined
218
229
  ? channel.email({ from }).template("auth-otp-email", {
219
230
  description: "OTP sign-in code (email)",
220
- schema: z.object({ email: z.string(), otp: z.string() }),
231
+ schema: OTP_EMAIL_SCHEMA,
221
232
  locales: ["en", "ar"],
233
+ catalog: OTP_EMAIL_BODIES,
222
234
  })
223
235
  : otpEmailTemplate;
224
236
 
@@ -613,7 +625,6 @@ export function otp(opts: OtpOptions): PluginDef {
613
625
  .channelTemplate(emailTmpl)
614
626
  .channelTemplate(otpSmsTemplate)
615
627
  .channelTemplate(otpWhatsappTemplate)
616
- .channelCatalog(otpCatalog)
617
628
  .binding(bindPublicAuth("/otp/request", request, "otp", requestContract))
618
629
  .binding(bindPublicAuth("/otp/verify", verify, "otp", verifyContract))
619
630
  .binding(bindPublicAuth("/otp/resend", resend, "otp", resendContract));
@@ -126,10 +126,24 @@ export function twoFactor(opts: TwoFactorOptions = {}): PluginDef {
126
126
  opts.verifications ?? active?.twoFactorVerifications ?? createVerificationStore();
127
127
  const exposeDevOtp = opts.exposeDevOtp === true;
128
128
 
129
+ const TWO_FACTOR_EMAIL_BODIES = {
130
+ en: {
131
+ subject: "Your two-factor code",
132
+ text: "Your two-factor sign-in code is: {{otp}}\n",
133
+ html: "<p>Your two-factor sign-in code is:</p><p><strong>{{otp}}</strong></p>",
134
+ },
135
+ ar: {
136
+ subject: "رمز التحقق بخطوتين",
137
+ text: "رمز التحقق بخطوتين هو: {{otp}}\n",
138
+ html: '<p dir="rtl">رمز التحقق بخطوتين هو:</p><p dir="rtl"><strong>{{otp}}</strong></p>',
139
+ },
140
+ } as const;
141
+
129
142
  const emailTmpl = channel.email({ from: "OKE <no-reply@oke.local>" }).template("auth-2fa-email", {
130
143
  description: "Two-factor email OTP",
131
144
  schema: z.object({ email: z.string(), otp: z.string() }),
132
145
  locales: ["en", "ar"],
146
+ catalog: TWO_FACTOR_EMAIL_BODIES,
133
147
  });
134
148
 
135
149
  async function provisionEmailOtp(userId: string, now: number): Promise<string | undefined> {
@@ -566,6 +580,7 @@ export function twoFactor(opts: TwoFactorOptions = {}): PluginDef {
566
580
 
567
581
  return plugin("twoFactor", { version: "0.0.1", config: { method: "two-factor" } })
568
582
  .needs("auth")
583
+ .channelTemplate(emailTmpl)
569
584
  .table("oke_two_factor", undefined, { plane: "user", description: "TOTP secrets + recovery" })
570
585
  .binding(bindSessionAuth("/two-factor/enable", enable, enableContract))
571
586
  .binding(bindPublicAuth("/two-factor/verify", verify, "otp", verifyContract))
@@ -19,9 +19,9 @@ describe("openDuckDB", () => {
19
19
  }
20
20
  });
21
21
 
22
- test("both create-oke templates declare @duckdb/node-api", () => {
22
+ test("create-oke templates declare @duckdb/node-api", () => {
23
23
  const root = join(import.meta.dir, "../../packages/create-oke/templates");
24
- for (const tier of ["standard", "advanced"] as const) {
24
+ for (const tier of ["blank", "shorter"] as const) {
25
25
  const pkg = JSON.parse(readFileSync(join(root, tier, "package.json"), "utf8")) as {
26
26
  dependencies?: { "@duckdb/node-api"?: string };
27
27
  trustedDependencies?: readonly string[];