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.
Files changed (125) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. package/src/plugins/phone-number.ts +0 -149
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Tier-2 OTP cross-medium FallbackTransport delivery.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { openConsoleChannel } from "../../drivers/channel-console.ts";
7
+ import type { ChannelSendResult } from "../../drivers/channel-types.ts";
8
+ import { deliverOtpAcrossChannels, shouldFallbackOtpMedium } from "./otp-delivery.ts";
9
+
10
+ describe("otp-delivery", () => {
11
+ test("shouldFallbackOtpMedium rejects invalid-address errors", () => {
12
+ expect(shouldFallbackOtpMedium(new Error("invalid recipient address"))).toBe(false);
13
+ expect(shouldFallbackOtpMedium(Object.assign(new Error("x"), { statusCode: 400 }))).toBe(false);
14
+ expect(shouldFallbackOtpMedium(new Error("provider timeout"))).toBe(true);
15
+ });
16
+
17
+ test("FailoverTransport advances to next medium on provider error", async () => {
18
+ let calls = 0;
19
+ const send = async (template: string): Promise<ChannelSendResult> => {
20
+ calls += 1;
21
+ if (template === "auth-otp-sms") {
22
+ return {
23
+ ok: false,
24
+ messageId: "x",
25
+ driverId: "sms",
26
+ attempts: [{ driverId: "sms", ok: false, error: "provider timeout", at: Date.now() }],
27
+ };
28
+ }
29
+ return {
30
+ ok: true,
31
+ messageId: "e1",
32
+ driverId: "email",
33
+ attempts: [{ driverId: "email", ok: true, at: Date.now() }],
34
+ };
35
+ };
36
+
37
+ const result = await deliverOtpAcrossChannels([openConsoleChannel()], send, {
38
+ channels: ["sms", "email"],
39
+ templates: { sms: "auth-otp-sms", email: "auth-otp-email" },
40
+ phone: "+15551234567",
41
+ email: "a@example.com",
42
+ data: { otp: "123456" },
43
+ });
44
+
45
+ expect(result.ok).toBe(true);
46
+ expect(result.channel).toBe("email");
47
+ expect(calls).toBe(2);
48
+ expect(result.attempts.some((a) => !a.ok)).toBe(true);
49
+ expect(result.attempts.some((a) => a.ok)).toBe(true);
50
+ });
51
+
52
+ test("only: delivers a single channel (explicit resend)", async () => {
53
+ const templates: string[] = [];
54
+ const send = async (template: string): Promise<ChannelSendResult> => {
55
+ templates.push(template);
56
+ return {
57
+ ok: true,
58
+ messageId: "m",
59
+ driverId: "console",
60
+ attempts: [{ driverId: "console", ok: true, at: Date.now() }],
61
+ };
62
+ };
63
+
64
+ const result = await deliverOtpAcrossChannels([openConsoleChannel()], send, {
65
+ channels: ["sms", "email"],
66
+ templates: { sms: "auth-otp-sms", email: "auth-otp-email" },
67
+ phone: "+15551234567",
68
+ email: "a@example.com",
69
+ data: { otp: "123456" },
70
+ only: "email",
71
+ });
72
+
73
+ expect(result.channel).toBe("email");
74
+ expect(templates).toEqual(["auth-otp-email"]);
75
+ });
76
+ });
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Tier-2 OTP multi-medium delivery via sently {@link FallbackTransport}.
3
+ *
4
+ * Within each medium, {@link ChannelRuntime.send} already runs the existing
5
+ * email/SMS FallbackTransport chains. This module reuses FallbackTransport
6
+ * again for **cross-medium** provider-error failover — adapters normalize
7
+ * per-medium sends to one options shape. Explicit user resend never calls
8
+ * this chain; it delivers a single channel only.
9
+ */
10
+
11
+ import { FallbackError, FallbackTransport } from "sently/transports/fallback";
12
+ import type { TaqnyatWhatsAppFailover } from "sently/transports/taqnyat-whatsapp";
13
+ import type { OtpChannel } from "../../auth/verification.ts";
14
+ import { hasWhatsAppSendWithFailover } from "../../drivers/channel-taqnyat-whatsapp.ts";
15
+ import type {
16
+ ChannelAttempt,
17
+ ChannelDriver,
18
+ ChannelSendResult,
19
+ } from "../../drivers/channel-types.ts";
20
+
21
+ /** Minimal send surface used by OTP multi-medium delivery. */
22
+ export type OtpChannelSend = (
23
+ template: string,
24
+ options: {
25
+ readonly to: string;
26
+ readonly data?: Readonly<Record<string, unknown>>;
27
+ readonly locale?: string;
28
+ },
29
+ ) => Promise<ChannelSendResult>;
30
+
31
+ /** Template names keyed by OTP channel. */
32
+ export type OtpTemplateMap = Readonly<Partial<Record<OtpChannel, string>>>;
33
+
34
+ /** Options for cross-medium OTP delivery. */
35
+ export interface DeliverOtpOptions {
36
+ /** Preferred channel order (build-time declaration). */
37
+ readonly channels: readonly OtpChannel[];
38
+ /** Template name per medium. */
39
+ readonly templates: OtpTemplateMap;
40
+ readonly email?: string;
41
+ readonly phone?: string;
42
+ readonly data: Readonly<Record<string, unknown>>;
43
+ readonly locale?: string;
44
+ /**
45
+ * When set, deliver **only** this channel (explicit resend) — no
46
+ * cross-medium FallbackTransport walk.
47
+ */
48
+ readonly only?: OtpChannel;
49
+ }
50
+
51
+ /** Result of a Tier-2 OTP delivery attempt. */
52
+ export interface DeliverOtpResult {
53
+ readonly ok: true;
54
+ readonly channel: OtpChannel;
55
+ readonly attempts: readonly ChannelAttempt[];
56
+ readonly messageId: string;
57
+ readonly driverId: string;
58
+ }
59
+
60
+ type MediumSendOpts = {
61
+ readonly channel: OtpChannel;
62
+ readonly to: string;
63
+ readonly template: string;
64
+ readonly data: Readonly<Record<string, unknown>>;
65
+ readonly locale?: string;
66
+ };
67
+
68
+ type MediumSendResult = {
69
+ readonly messageId: string;
70
+ readonly status: string;
71
+ readonly provider: string;
72
+ readonly channel: OtpChannel;
73
+ };
74
+
75
+ /**
76
+ * Whether an error should advance to the next medium (provider outage),
77
+ * not permanent client mistakes (invalid address).
78
+ *
79
+ * @param error - Thrown error
80
+ */
81
+ export function shouldFallbackOtpMedium(error: unknown): boolean {
82
+ const msg = (error instanceof Error ? error.message : String(error)).toLowerCase();
83
+ if (
84
+ /invalid.*(address|email|recipient|phone)|unknown user|no such user|mailbox unavailable|550 5\.1\.1/.test(
85
+ msg,
86
+ )
87
+ ) {
88
+ return false;
89
+ }
90
+ const status = (error as { statusCode?: number })?.statusCode;
91
+ if (status === 400 || status === 401 || status === 403) return false;
92
+ return true;
93
+ }
94
+
95
+ /**
96
+ * Resolve the recipient address for a channel.
97
+ *
98
+ * @param channel - Medium
99
+ * @param email - Email when present
100
+ * @param phone - Phone when present
101
+ */
102
+ export function addressForChannel(
103
+ channel: OtpChannel,
104
+ email: string | undefined,
105
+ phone: string | undefined,
106
+ ): string | undefined {
107
+ if (channel === "email") return email;
108
+ return phone;
109
+ }
110
+
111
+ /**
112
+ * Deliver an OTP across declared channels using sently FallbackTransport.
113
+ *
114
+ * @param drivers - Bound drivers (for Taqnyat WhatsApp sendWithFailover)
115
+ * @param send - ChannelRuntime.send bound function
116
+ * @param opts - Delivery options
117
+ * @param now - Clock
118
+ */
119
+ export async function deliverOtpAcrossChannels(
120
+ drivers: readonly ChannelDriver[],
121
+ send: OtpChannelSend,
122
+ opts: DeliverOtpOptions,
123
+ now: () => number = () => Date.now(),
124
+ ): Promise<DeliverOtpResult> {
125
+ const ordered = opts.only ? [opts.only] : [...opts.channels];
126
+ const adapters: Array<{
127
+ readonly provider: string;
128
+ send(o: MediumSendOpts): Promise<MediumSendResult>;
129
+ }> = [];
130
+
131
+ for (const channel of ordered) {
132
+ const template = opts.templates[channel];
133
+ const to = addressForChannel(channel, opts.email, opts.phone);
134
+ if (!template || !to) continue;
135
+
136
+ if (channel === "whatsapp" && !opts.only) {
137
+ const wa = drivers.find((d) => hasWhatsAppSendWithFailover(d.whatsappTransport));
138
+ if (wa && hasWhatsAppSendWithFailover(wa.whatsappTransport)) {
139
+ const transport = wa.whatsappTransport;
140
+ const failover = buildTaqnyatWhatsAppFailover(opts, ordered);
141
+ if (failover.sms || failover.mail) {
142
+ adapters.push({
143
+ provider: "taqnyat-whatsapp",
144
+ async send() {
145
+ const result = await transport.sendWithFailover(
146
+ { to, text: String(opts.data.otp ?? "") },
147
+ failover,
148
+ );
149
+ return {
150
+ messageId: result.messageId,
151
+ status: result.status,
152
+ provider: "taqnyat-whatsapp",
153
+ channel: "whatsapp" as const,
154
+ };
155
+ },
156
+ });
157
+ // Provider-side failover already covers later sms/email — skip client adapters for those.
158
+ break;
159
+ }
160
+ }
161
+ }
162
+
163
+ adapters.push({
164
+ provider: channel,
165
+ async send() {
166
+ const result = await send(template, {
167
+ to,
168
+ data: opts.data,
169
+ ...(opts.locale ? { locale: opts.locale } : {}),
170
+ });
171
+ if (!result.ok) {
172
+ const errMsg =
173
+ result.attempts
174
+ .map((a) => a.error)
175
+ .filter(Boolean)
176
+ .join("; ") || `${channel} delivery failed`;
177
+ const err = new Error(errMsg) as Error & { statusCode?: number };
178
+ if (/invalid.*(address|email|recipient|phone)/i.test(errMsg)) {
179
+ err.statusCode = 400;
180
+ }
181
+ throw err;
182
+ }
183
+ return {
184
+ messageId: result.messageId,
185
+ status: "sent",
186
+ provider: result.driverId,
187
+ channel,
188
+ };
189
+ },
190
+ });
191
+ }
192
+
193
+ if (adapters.length === 0) {
194
+ throw new Error(
195
+ "channel: otp delivery has no viable channel — need a matching address for a declared channel",
196
+ );
197
+ }
198
+
199
+ const attempts: ChannelAttempt[] = [];
200
+ const fallback = new FallbackTransport(adapters, {
201
+ shouldFallback: shouldFallbackOtpMedium,
202
+ onFallback(failedIndex, error) {
203
+ const provider = adapters[failedIndex]?.provider ?? `medium-${failedIndex}`;
204
+ attempts.push({
205
+ driverId: provider,
206
+ ok: false,
207
+ error: error instanceof Error ? error.message : String(error),
208
+ at: now(),
209
+ });
210
+ },
211
+ });
212
+
213
+ try {
214
+ const first = ordered.find((ch) => {
215
+ const template = opts.templates[ch];
216
+ const to = addressForChannel(ch, opts.email, opts.phone);
217
+ return !!template && !!to;
218
+ });
219
+ if (!first) {
220
+ throw new Error("channel: otp delivery has no viable channel");
221
+ }
222
+ const template = opts.templates[first]!;
223
+ const to = addressForChannel(first, opts.email, opts.phone)!;
224
+ const result = await fallback.send({
225
+ channel: first,
226
+ to,
227
+ template,
228
+ data: opts.data,
229
+ ...(opts.locale ? { locale: opts.locale } : {}),
230
+ });
231
+ attempts.push({
232
+ driverId: result.provider,
233
+ ok: true,
234
+ at: now(),
235
+ messageId: result.messageId,
236
+ });
237
+ return {
238
+ ok: true,
239
+ channel: result.channel,
240
+ attempts,
241
+ messageId: result.messageId,
242
+ driverId: result.provider,
243
+ };
244
+ } catch (err) {
245
+ if (err instanceof FallbackError) {
246
+ for (const a of err.attempts) {
247
+ if (!attempts.some((x) => x.driverId === a.provider && !x.ok)) {
248
+ attempts.push({
249
+ driverId: a.provider,
250
+ ok: false,
251
+ error: a.error instanceof Error ? a.error.message : String(a.error),
252
+ at: now(),
253
+ });
254
+ }
255
+ }
256
+ throw new Error(
257
+ `channel: otp delivery failed on all channels (${err.attempts.map((a) => a.provider).join(" → ")})`,
258
+ );
259
+ }
260
+ throw err;
261
+ }
262
+ }
263
+
264
+ function buildTaqnyatWhatsAppFailover(
265
+ opts: DeliverOtpOptions,
266
+ ordered: readonly OtpChannel[],
267
+ ): TaqnyatWhatsAppFailover {
268
+ const waIndex = ordered.indexOf("whatsapp");
269
+ const rest = waIndex >= 0 ? ordered.slice(waIndex + 1) : [];
270
+ const otp = String(opts.data.otp ?? "");
271
+ const sender = process.env.TAQNYAT_SENDER?.trim() ?? "";
272
+ const campaign = process.env.TAQNYAT_CAMPAIGN?.trim() ?? "oke-otp";
273
+ const out: TaqnyatWhatsAppFailover = {};
274
+ if (rest.includes("sms") && opts.phone && sender) {
275
+ out.sms = {
276
+ sender,
277
+ campaign,
278
+ body: `Your sign-in code is: ${otp}`,
279
+ };
280
+ }
281
+ if (rest.includes("email") && opts.email) {
282
+ out.mail = {
283
+ from: process.env.TAQNYAT_MAIL_FROM?.trim() ?? "OKE <no-reply@oke.local>",
284
+ to: opts.email,
285
+ campaign,
286
+ subject: "Your sign-in code",
287
+ msg: `Your one-time sign-in code is: ${otp}`,
288
+ };
289
+ }
290
+ return out;
291
+ }