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
@@ -8,9 +8,9 @@
8
8
 
9
9
  import {
10
10
  createIdentityStore,
11
+ ensureUserByEmail,
11
12
  normalizeEmail,
12
13
  type IdentityStore,
13
- type UserIdentityRow,
14
14
  } from "../auth/identity.ts";
15
15
  import { issueSessionWithScopes } from "../auth/sessions.ts";
16
16
  import {
@@ -199,25 +199,3 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
199
199
  .binding(bindPublicAuth("/magic-link/request", request, "otp"))
200
200
  .binding(bindPublicAuth("/magic-link/verify", verify, "otp"));
201
201
  }
202
-
203
- function ensureUserByEmail(store: IdentityStore, email: string, now: number): UserIdentityRow {
204
- const existingId = store.byEmail.get(email);
205
- if (existingId) {
206
- const existing = store.users.get(existingId);
207
- if (existing) return existing;
208
- }
209
- const id = crypto.randomUUID();
210
- const user: UserIdentityRow = {
211
- id,
212
- email,
213
- name: email.split("@")[0] || "user",
214
- emailVerified: true,
215
- status: "active",
216
- createdAt: now,
217
- updatedAt: now,
218
- extra: {},
219
- };
220
- store.users.set(id, user);
221
- store.byEmail.set(email, id);
222
- return user;
223
- }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * otp() plugin — plug-time fail-loud, Tier 2 resend / sealed lifetime, Tier 1 boot.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import {
7
+ createVerificationStore,
8
+ findActiveVerification,
9
+ type VerificationStore,
10
+ } from "../auth/verification.ts";
11
+ import { openConsoleChannel } from "../drivers/channel-console.ts";
12
+ import type { ChannelDriver, SmsOtpTransport, SmsTransport } from "../drivers/channel-types.ts";
13
+ import { oke } from "../kernel/app.ts";
14
+ import { resetFlowSeq } from "../kernel/flow.ts";
15
+ import { resetBindings } from "../kernel/on.ts";
16
+ import { otp } from "./otp.ts";
17
+
18
+ afterEach(() => {
19
+ resetBindings();
20
+ resetFlowSeq();
21
+ });
22
+
23
+ const SECRET = "test-secret-at-least-16";
24
+
25
+ function jsonPost(path: string, body: unknown): Request {
26
+ return new Request(`http://localhost${path}`, {
27
+ method: "POST",
28
+ headers: { "content-type": "application/json" },
29
+ body: JSON.stringify(body),
30
+ });
31
+ }
32
+
33
+ function mockOtpSmsDriver(): ChannelDriver {
34
+ const codes = new Map<string, string>();
35
+ const otpTransport: SmsTransport & SmsOtpTransport = {
36
+ provider: "mock-otp",
37
+ async send() {
38
+ return { messageId: "m", to: "+", status: "ok", response: "ok" };
39
+ },
40
+ async sendOtp(opts) {
41
+ codes.set(opts.requestId, "pending");
42
+ return {
43
+ requestId: opts.requestId,
44
+ to: opts.to,
45
+ code: 5,
46
+ response: "ok",
47
+ provider: "mock-otp",
48
+ };
49
+ },
50
+ async verifyOtp(opts) {
51
+ if (!codes.has(opts.requestId)) throw new Error("unknown request");
52
+ return { ok: true as const, code: 10, message: "ok", response: "ok", provider: "mock-otp" };
53
+ },
54
+ };
55
+ return {
56
+ id: "taqnyat",
57
+ smsTransport: otpTransport,
58
+ channel: {
59
+ provider: "mock-otp",
60
+ mediums: ["sms"],
61
+ async send() {
62
+ return {
63
+ ok: true,
64
+ messageId: "m",
65
+ driverId: "mock-otp",
66
+ attempts: [{ driverId: "mock-otp", ok: true, at: Date.now() }],
67
+ };
68
+ },
69
+ },
70
+ };
71
+ }
72
+
73
+ describe("otp() plug-time fail-loud", () => {
74
+ test("missing tier throws", () => {
75
+ expect(() => otp({} as never)).toThrow(/tier is required/);
76
+ });
77
+
78
+ test("tier 1 with channels throws", () => {
79
+ expect(() => otp({ tier: 1, channels: ["sms"] } as never)).toThrow(/channels are forbidden/);
80
+ });
81
+
82
+ test("tier 1 with exposeDevOtp throws", () => {
83
+ expect(() => otp({ tier: 1, exposeDevOtp: true } as never)).toThrow(
84
+ /exposeDevOtp is forbidden/,
85
+ );
86
+ });
87
+
88
+ test("tier 2 without channels throws", () => {
89
+ expect(() => otp({ tier: 2 } as never)).toThrow(/channels is required/);
90
+ });
91
+ });
92
+
93
+ describe("otp() Tier 1 boot", () => {
94
+ test("fails loud without Verify-capable SMS driver", async () => {
95
+ const app = oke({
96
+ name: `otp-t1-${crypto.randomUUID()}`,
97
+ env: "test",
98
+ registry: "ignore",
99
+ gate: { auth: { secret: SECRET } },
100
+ }).plug(otp({ tier: 1 }));
101
+ await expect(app.boot({ env: "test" })).rejects.toThrow(/tier: 1/);
102
+ });
103
+
104
+ test("boots when Verify-capable SMS driver is bound", async () => {
105
+ const app = oke({
106
+ name: `otp-t1-ok-${crypto.randomUUID()}`,
107
+ env: "test",
108
+ registry: "ignore",
109
+ gate: { auth: { secret: SECRET } },
110
+ channel: { drivers: [mockOtpSmsDriver()] },
111
+ }).plug(otp({ tier: 1 }));
112
+ await app.boot({ env: "test" });
113
+ const res = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15551234567" }));
114
+ expect(res.status).toBe(200);
115
+ const body = (await res.json()) as { data: { ok: true; devOtp?: string } };
116
+ expect(body.data.ok).toBe(true);
117
+ expect(body.data.devOtp).toBeUndefined();
118
+ expect(app.router.match("POST", "/auth/otp/resend")).toBeFalsy();
119
+ await app.stop();
120
+ });
121
+ });
122
+
123
+ describe("otp() Tier 2 resend + sealed lifetime", () => {
124
+ test("cross-channel resend keeps same code; cooldown then verify wipes seal", async () => {
125
+ let now = 1_000_000;
126
+ const verifications: VerificationStore = createVerificationStore();
127
+ const app = oke({
128
+ name: `otp-t2-${crypto.randomUUID()}`,
129
+ env: "test",
130
+ registry: "ignore",
131
+ gate: { auth: { secret: SECRET } },
132
+ channel: { drivers: [openConsoleChannel()] },
133
+ fx: { now: () => now },
134
+ }).plug(
135
+ otp({
136
+ tier: 2,
137
+ channels: ["sms", "email"],
138
+ exposeDevOtp: true,
139
+ resendCooldownMs: 60_000,
140
+ verifications,
141
+ now: () => now,
142
+ secret: SECRET,
143
+ }),
144
+ );
145
+ await app.boot({ env: "test" });
146
+
147
+ const req = await app.fetch(
148
+ jsonPost("/auth/otp/request", {
149
+ phone: "+15551234567",
150
+ email: "a@example.com",
151
+ }),
152
+ );
153
+ expect(req.status).toBe(200);
154
+ const requested = (await req.json()) as {
155
+ data: { ok: true; devOtp: string; channel: string };
156
+ };
157
+ expect(requested.data.devOtp).toMatch(/^\d{6}$/);
158
+ const code = requested.data.devOtp;
159
+ const expiresAt = findActiveVerification(verifications, "otp:+15551234567", now)?.expiresAt;
160
+ expect(expiresAt).toBeDefined();
161
+ expect(findActiveVerification(verifications, "otp:+15551234567", now)?.sealedOtp).toBeTruthy();
162
+
163
+ const cool = await app.fetch(
164
+ jsonPost("/auth/otp/resend", {
165
+ phone: "+15551234567",
166
+ email: "a@example.com",
167
+ channel: "email",
168
+ }),
169
+ );
170
+ const coolBody = (await cool.json()) as { error?: { data?: { reason?: string } } };
171
+ expect(coolBody.error?.data?.reason).toBe("resend_cooldown");
172
+
173
+ now += 60_001;
174
+ const resent = await app.fetch(
175
+ jsonPost("/auth/otp/resend", {
176
+ phone: "+15551234567",
177
+ email: "a@example.com",
178
+ channel: "email",
179
+ }),
180
+ );
181
+ expect(resent.status).toBe(200);
182
+ const resentBody = (await resent.json()) as { data: { channel: string; devOtp?: string } };
183
+ expect(resentBody.data.channel).toBe("email");
184
+ expect(resentBody.data.devOtp).toBe(code);
185
+ expect(findActiveVerification(verifications, "otp:+15551234567", now)?.expiresAt).toBe(
186
+ expiresAt,
187
+ );
188
+
189
+ const verify = await app.fetch(
190
+ jsonPost("/auth/otp/verify", { phone: "+15551234567", otp: code }),
191
+ );
192
+ expect(verify.status).toBe(200);
193
+
194
+ const row = [...verifications.rows.values()].find((r) => r.identifier === "otp:+15551234567");
195
+ expect(row?.sealedOtp).toBeNull();
196
+ expect(row?.consumedAt).toBeTruthy();
197
+
198
+ await app.stop();
199
+ });
200
+
201
+ test("expired challenge wipes sealed OTP", async () => {
202
+ let now = 1_000_000;
203
+ const verifications = createVerificationStore();
204
+ const app = oke({
205
+ name: `otp-ttl-${crypto.randomUUID()}`,
206
+ env: "test",
207
+ registry: "ignore",
208
+ gate: { auth: { secret: SECRET } },
209
+ fx: { now: () => now },
210
+ }).plug(
211
+ otp({
212
+ tier: 2,
213
+ channels: ["email"],
214
+ exposeDevOtp: true,
215
+ ttlMs: 1_000,
216
+ verifications,
217
+ now: () => now,
218
+ secret: SECRET,
219
+ }),
220
+ );
221
+ await app.boot({ env: "test" });
222
+
223
+ await app.fetch(jsonPost("/auth/otp/request", { email: "ttl@example.com" }));
224
+ const active = findActiveVerification(verifications, "otp:ttl@example.com", now);
225
+ expect(active?.sealedOtp).toBeTruthy();
226
+
227
+ now += 2_000;
228
+ expect(findActiveVerification(verifications, "otp:ttl@example.com", now)).toBeUndefined();
229
+ const row = [...verifications.rows.values()].find(
230
+ (r) => r.identifier === "otp:ttl@example.com",
231
+ );
232
+ expect(row?.sealedOtp).toBeNull();
233
+
234
+ await app.stop();
235
+ });
236
+ });