okengine 0.4.3 → 0.5.1

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 (138) hide show
  1. package/package.json +3 -1
  2. package/site/content/docs/ai/index.mdx +24 -0
  3. package/site/content/docs/ai/llms-txt.mdx +3 -0
  4. package/site/content/docs/ai/meta.json +1 -1
  5. package/site/content/docs/console/gates.mdx +46 -8
  6. package/site/content/docs/console/index.mdx +54 -0
  7. package/site/content/docs/console/meta.json +1 -0
  8. package/site/content/docs/elements/gate.mdx +187 -48
  9. package/site/content/docs/elements/index.mdx +45 -0
  10. package/site/content/docs/elements/meta.json +1 -1
  11. package/site/content/docs/get-started/basic-usage.mdx +4 -3
  12. package/site/content/docs/get-started/index.mdx +33 -0
  13. package/site/content/docs/get-started/meta.json +1 -1
  14. package/site/content/docs/get-started/why.mdx +10 -10
  15. package/site/content/docs/index.mdx +9 -31
  16. package/site/content/docs/plugins/anonymous.mdx +95 -0
  17. package/site/content/docs/plugins/compression.mdx +2 -2
  18. package/site/content/docs/plugins/cors.mdx +2 -2
  19. package/site/content/docs/plugins/csrf.mdx +2 -2
  20. package/site/content/docs/plugins/email-otp.mdx +111 -0
  21. package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +11 -11
  22. package/site/content/docs/plugins/index.mdx +69 -0
  23. package/site/content/docs/plugins/magic-link.mdx +112 -0
  24. package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
  25. package/site/content/docs/plugins/meta.json +10 -1
  26. package/site/content/docs/plugins/passkey.mdx +140 -0
  27. package/site/content/docs/plugins/phone-number.mdx +111 -0
  28. package/site/content/docs/plugins/two-factor.mdx +117 -0
  29. package/site/content/docs/plugins/username.mdx +117 -0
  30. package/site/content/docs/reference/client.mdx +331 -0
  31. package/site/content/docs/reference/fx.mdx +20 -5
  32. package/site/content/docs/reference/index.mdx +45 -0
  33. package/site/content/docs/reference/meta.json +11 -1
  34. package/site/content/docs/reference/plugins.mdx +25 -14
  35. package/src/auth/auth.test.ts +20 -2
  36. package/src/auth/bindings.ts +439 -0
  37. package/src/auth/breach-check.ts +112 -0
  38. package/src/auth/config.ts +288 -0
  39. package/src/auth/constant-time.ts +22 -0
  40. package/src/auth/cookies.ts +123 -0
  41. package/src/auth/gate-auth.test.ts +379 -0
  42. package/src/auth/identity.ts +190 -0
  43. package/src/auth/index.ts +119 -1
  44. package/src/auth/method-context.ts +33 -0
  45. package/src/auth/operator.ts +27 -1
  46. package/src/auth/password-policy.test.ts +126 -0
  47. package/src/auth/password-policy.ts +77 -0
  48. package/src/auth/plugin.ts +62 -4
  49. package/src/auth/rate.ts +45 -0
  50. package/src/auth/schema.ts +260 -0
  51. package/src/auth/secondary-storage.ts +37 -0
  52. package/src/auth/sessions.ts +58 -1
  53. package/src/auth/tables.ts +4 -0
  54. package/src/auth/verification.ts +78 -0
  55. package/src/cli/competitor-mention-removal.test.ts +3 -3
  56. package/src/cli/dev.test.ts +3 -3
  57. package/src/cli/schema.ts +95 -23
  58. package/src/client/auth.ts +120 -0
  59. package/src/client-react/index.ts +93 -0
  60. package/src/compiler/aot.test.ts +2 -1
  61. package/src/compiler/extract.ts +19 -0
  62. package/src/compiler/response.ts +16 -2
  63. package/src/console/server/app.ts +10 -6
  64. package/src/console/server/auth-rate.test.ts +3 -3
  65. package/src/console/server/bind.ts +12 -1
  66. package/src/console/server/channels.test.ts +1 -1
  67. package/src/console/server/console-gates.ts +14 -0
  68. package/src/console/server/console.test.ts +6 -6
  69. package/src/console/server/flows-invoke.test.ts +2 -2
  70. package/src/console/server/flows.ts +2 -0
  71. package/src/console/server/gates.ts +8 -1
  72. package/src/console/server/operator-db.test.ts +4 -4
  73. package/src/console/server/operator-db.ts +22 -4
  74. package/src/console/server/security.gate.test.ts +3 -3
  75. package/src/console/ui/gates/fixture.ts +4 -0
  76. package/src/console/ui/gates/types.ts +2 -0
  77. package/src/console/ui/shell/client.ts +1 -0
  78. package/src/elements/gate/boot.ts +136 -0
  79. package/src/elements/gate/config.ts +69 -0
  80. package/src/elements/gate/declare.ts +51 -1
  81. package/src/elements/gate/runtime.ts +3 -1
  82. package/src/elements/gate.test.ts +77 -0
  83. package/src/elements/gate.ts +20 -1
  84. package/src/elements/index.ts +8 -0
  85. package/src/index.ts +11 -0
  86. package/src/kernel/app.ts +253 -32
  87. package/src/kernel/boot.test.ts +40 -3
  88. package/src/kernel/boot.ts +8 -0
  89. package/src/kernel/call.test.ts +46 -2
  90. package/src/kernel/edge.test.ts +3 -3
  91. package/src/kernel/flow.test.ts +2 -2
  92. package/src/kernel/fx.test.ts +1 -0
  93. package/src/kernel/fx.ts +60 -0
  94. package/src/kernel/hooks.test.ts +4 -4
  95. package/src/kernel/index.ts +12 -0
  96. package/src/kernel/pipeline.test.ts +12 -8
  97. package/src/kernel/pipeline.ts +23 -4
  98. package/src/kernel/plugin/decorate.test.ts +3 -3
  99. package/src/kernel/plugin/scoping.test.ts +3 -3
  100. package/src/kernel/plugin-elements.test.ts +51 -0
  101. package/src/kernel/plugin-needs.test.ts +83 -0
  102. package/src/kernel/plugin-needs.ts +129 -0
  103. package/src/kernel/plugin.ts +101 -0
  104. package/src/kernel/registry-isolation.test.ts +5 -5
  105. package/src/kernel/registry.ts +102 -3
  106. package/src/manifest/types.ts +2 -0
  107. package/src/plugins/anonymous.ts +58 -0
  108. package/src/plugins/auth/shared.ts +121 -0
  109. package/src/plugins/auth-methods.security.test.ts +762 -0
  110. package/src/plugins/auth-methods.test.ts +176 -0
  111. package/src/plugins/compression.test.ts +5 -5
  112. package/src/plugins/compression.ts +1 -1
  113. package/src/plugins/config-source.test.ts +12 -12
  114. package/src/plugins/config-source.ts +2 -2
  115. package/src/plugins/cors.test.ts +16 -10
  116. package/src/plugins/cors.ts +1 -1
  117. package/src/plugins/csrf.test.ts +1 -1
  118. package/src/plugins/email-otp.ts +161 -0
  119. package/src/plugins/{security-headers.test.ts → headers.test.ts} +26 -26
  120. package/src/plugins/headers.ts +240 -41
  121. package/src/plugins/index.ts +42 -3
  122. package/src/plugins/ip-allowlist.test.ts +19 -9
  123. package/src/plugins/magic-link.ts +163 -0
  124. package/src/plugins/maintenance-mode.test.ts +9 -5
  125. package/src/plugins/passkey-webauthn.ts +217 -0
  126. package/src/plugins/passkey.ts +282 -0
  127. package/src/plugins/phone-number.ts +149 -0
  128. package/src/plugins/response-headers.ts +54 -0
  129. package/src/plugins/two-factor.ts +253 -0
  130. package/src/plugins/username.ts +148 -0
  131. package/src/runs/runs.test.ts +6 -2
  132. package/src/runtime/primitives.ts +37 -4
  133. package/src/runtime/serve.test.ts +3 -2
  134. package/src/runtime/types.ts +25 -2
  135. package/src/test/create-test-app.test.ts +1 -1
  136. package/src/test/create-test-app.ts +4 -1
  137. package/src/test/provisions.integration.test.ts +1 -1
  138. package/src/plugins/security-headers.ts +0 -255
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Passkey (WebAuthn) Gate auth method plugin.
3
+ *
4
+ * Registration and authentication verify clientDataJSON origin + challenge,
5
+ * authenticatorData rpId hash, and ECDSA P-256 signature against the stored
6
+ * SPKI public key. Presence-only authenticate is rejected.
7
+ */
8
+
9
+ import { constantTimeEqual } from "../auth/constant-time.ts";
10
+ import {
11
+ createVerificationStore,
12
+ hashChallenge,
13
+ putVerification,
14
+ type VerificationStore,
15
+ } from "../auth/verification.ts";
16
+ import { issueSessionWithScopes } from "../auth/sessions.ts";
17
+ import { plugin, type PluginDef } from "../kernel/plugin.ts";
18
+ import {
19
+ AuthFailed,
20
+ AuthRateLimited,
21
+ SessionTokensOut,
22
+ bindPublicAuth,
23
+ bindSessionAuth,
24
+ createMethodRuntime,
25
+ fail,
26
+ flow,
27
+ z,
28
+ type AuthMethodOptions,
29
+ } from "./auth/shared.ts";
30
+ import { verifyWebAuthnCeremony } from "./passkey-webauthn.ts";
31
+
32
+ /** Stored passkey credential. */
33
+ export interface PasskeyCredential {
34
+ readonly credentialId: string;
35
+ readonly userId: string;
36
+ /** Base64url SPKI public key (ECDSA P-256). */
37
+ readonly publicKey: string;
38
+ counter: number;
39
+ readonly createdAt: number;
40
+ }
41
+
42
+ /** In-memory passkey store. */
43
+ export interface PasskeyStore {
44
+ readonly byCredentialId: Map<string, PasskeyCredential>;
45
+ readonly byUserId: Map<string, PasskeyCredential[]>;
46
+ }
47
+
48
+ /**
49
+ * Create an empty passkey store.
50
+ */
51
+ export function createPasskeyStore(): PasskeyStore {
52
+ return { byCredentialId: new Map(), byUserId: new Map() };
53
+ }
54
+
55
+ /** Options for {@link passkey}. */
56
+ export interface PasskeyOptions extends AuthMethodOptions {
57
+ readonly passkeys?: PasskeyStore;
58
+ readonly challenges?: VerificationStore;
59
+ /** Relying party id (default `localhost`). */
60
+ readonly rpId?: string;
61
+ /**
62
+ * Allowed `clientDataJSON.origin` values.
63
+ * Default: `http://localhost` and `https://localhost`.
64
+ */
65
+ readonly origins?: readonly string[];
66
+ }
67
+
68
+ const CeremonyIn = z.object({
69
+ credentialId: z.string().min(1),
70
+ publicKey: z.string().min(1).optional(),
71
+ userId: z.string().min(1).optional(),
72
+ challenge: z.string().min(1),
73
+ clientDataJSON: z.string().min(1),
74
+ authenticatorData: z.string().min(1),
75
+ signature: z.string().min(1),
76
+ });
77
+
78
+ /**
79
+ * Passkey register / authenticate with cryptographic WebAuthn verify (`oke_passkeys`).
80
+ *
81
+ * @param opts - Stores / RP id / allowed origins
82
+ */
83
+ export function passkey(opts: PasskeyOptions = {}): PluginDef {
84
+ const runtime = createMethodRuntime(opts);
85
+ const passkeys = opts.passkeys ?? createPasskeyStore();
86
+ const challenges = opts.challenges ?? createVerificationStore();
87
+ const rpId = opts.rpId ?? "localhost";
88
+ const origins = opts.origins ?? ["http://localhost", "https://localhost"];
89
+
90
+ const registerOptions = flow({
91
+ name: "auth.passkeyRegisterOptions",
92
+ unit: "auth",
93
+ plane: "user",
94
+ out: z.object({
95
+ challenge: z.string(),
96
+ rpId: z.string(),
97
+ userId: z.string(),
98
+ }),
99
+ errors: { AuthFailed },
100
+ do: async (_input, fx) => {
101
+ const userId = fx.auth.userId;
102
+ if (!userId) return fail("AuthFailed", { reason: "unauthenticated" });
103
+ const challenge = crypto.randomUUID().replace(/-/g, "");
104
+ const now = runtime.now();
105
+ putVerification(challenges, {
106
+ id: crypto.randomUUID(),
107
+ identifier: `passkey-reg:${userId}`,
108
+ value: await hashChallenge(challenge),
109
+ expiresAt: now + 5 * 60 * 1000,
110
+ createdAt: now,
111
+ consumedAt: null,
112
+ attempts: 0,
113
+ });
114
+ return { challenge, rpId, userId };
115
+ },
116
+ });
117
+
118
+ const register = flow({
119
+ name: "auth.passkeyRegister",
120
+ unit: "auth",
121
+ plane: "user",
122
+ in: CeremonyIn.extend({
123
+ publicKey: z.string().min(1),
124
+ userId: z.string().min(1),
125
+ }),
126
+ out: z.object({ ok: z.literal(true) }),
127
+ errors: { AuthFailed },
128
+ do: async (input, fx) => {
129
+ const sessionUser = fx.auth.userId;
130
+ if (!sessionUser || sessionUser !== input.userId) {
131
+ return fail("AuthFailed", { reason: "unauthenticated" });
132
+ }
133
+ const consumed = await consumeChallenge(
134
+ challenges,
135
+ `passkey-reg:${input.userId}`,
136
+ input.challenge,
137
+ runtime.now(),
138
+ );
139
+ if (!consumed) return fail("AuthFailed", { reason: "invalid_credentials" });
140
+
141
+ const verified = await verifyWebAuthnCeremony({
142
+ expectedType: "webauthn.create",
143
+ expectedChallenge: input.challenge,
144
+ expectedOrigins: origins,
145
+ rpId,
146
+ publicKeySpkiB64url: input.publicKey,
147
+ clientDataJSON: input.clientDataJSON,
148
+ authenticatorData: input.authenticatorData,
149
+ signature: input.signature,
150
+ });
151
+ if (!verified.ok) {
152
+ return fail("AuthFailed", {
153
+ reason: verified.reason === "invalid_origin" ? "invalid_origin" : "invalid_credentials",
154
+ });
155
+ }
156
+ if (passkeys.byCredentialId.has(input.credentialId)) {
157
+ return fail("AuthFailed", { reason: "invalid_credentials" });
158
+ }
159
+ const cred: PasskeyCredential = {
160
+ credentialId: input.credentialId,
161
+ userId: input.userId,
162
+ publicKey: input.publicKey,
163
+ counter: verified.signCount,
164
+ createdAt: runtime.now(),
165
+ };
166
+ passkeys.byCredentialId.set(cred.credentialId, cred);
167
+ const list = passkeys.byUserId.get(cred.userId) ?? [];
168
+ list.push(cred);
169
+ passkeys.byUserId.set(cred.userId, list);
170
+ return { ok: true as const };
171
+ },
172
+ });
173
+
174
+ const authenticateOptions = flow({
175
+ name: "auth.passkeyAuthenticateOptions",
176
+ unit: "auth",
177
+ plane: "user",
178
+ in: z.object({ email: z.string().optional() }),
179
+ out: z.object({
180
+ challenge: z.string(),
181
+ rpId: z.string(),
182
+ allowCredentials: z.array(z.string()),
183
+ }),
184
+ errors: { AuthFailed, AuthRateLimited },
185
+ do: async (input) => {
186
+ const challenge = crypto.randomUUID().replace(/-/g, "");
187
+ const now = runtime.now();
188
+ const key = input.email?.trim().toLowerCase() || "anonymous";
189
+ putVerification(challenges, {
190
+ id: crypto.randomUUID(),
191
+ identifier: `passkey-auth:${key}`,
192
+ value: await hashChallenge(challenge),
193
+ expiresAt: now + 5 * 60 * 1000,
194
+ createdAt: now,
195
+ consumedAt: null,
196
+ attempts: 0,
197
+ });
198
+ return { challenge, rpId, allowCredentials: [] as string[] };
199
+ },
200
+ });
201
+
202
+ const authenticate = flow({
203
+ name: "auth.passkeyAuthenticate",
204
+ unit: "auth",
205
+ plane: "user",
206
+ in: CeremonyIn.extend({
207
+ challenge: z.string().min(1),
208
+ /** Challenge bucket key from authenticate options (default `anonymous`). */
209
+ email: z.string().optional(),
210
+ }),
211
+ out: SessionTokensOut,
212
+ errors: { AuthFailed, AuthRateLimited },
213
+ do: async (input) => {
214
+ const cred = passkeys.byCredentialId.get(input.credentialId);
215
+ if (!cred) return fail("AuthFailed", { reason: "invalid_credentials" });
216
+
217
+ const bucket = input.email?.trim().toLowerCase() || "anonymous";
218
+ const consumed = await consumeChallenge(
219
+ challenges,
220
+ `passkey-auth:${bucket}`,
221
+ input.challenge,
222
+ runtime.now(),
223
+ );
224
+ if (!consumed) return fail("AuthFailed", { reason: "invalid_credentials" });
225
+
226
+ const verified = await verifyWebAuthnCeremony({
227
+ expectedType: "webauthn.get",
228
+ expectedChallenge: input.challenge,
229
+ expectedOrigins: origins,
230
+ rpId,
231
+ publicKeySpkiB64url: cred.publicKey,
232
+ clientDataJSON: input.clientDataJSON,
233
+ authenticatorData: input.authenticatorData,
234
+ signature: input.signature,
235
+ previousSignCount: cred.counter,
236
+ });
237
+ if (!verified.ok) {
238
+ return fail("AuthFailed", {
239
+ reason: verified.reason === "invalid_origin" ? "invalid_origin" : "invalid_credentials",
240
+ });
241
+ }
242
+ cred.counter = verified.signCount;
243
+
244
+ const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
245
+ id: cred.userId,
246
+ plane: "user",
247
+ scopes: [],
248
+ });
249
+ return {
250
+ accessToken: issued.accessToken,
251
+ refreshToken: issued.refreshToken,
252
+ accessExpiresAt: issued.accessExpiresAt,
253
+ userId: cred.userId,
254
+ };
255
+ },
256
+ });
257
+
258
+ return plugin("passkey", { version: "0.0.1", config: { method: "passkey" } })
259
+ .needs("auth")
260
+ .table("oke_passkeys", undefined, { plane: "user", description: "WebAuthn credentials" })
261
+ .binding(bindSessionAuth("/passkey/register/options", registerOptions))
262
+ .binding(bindSessionAuth("/passkey/register", register))
263
+ .binding(bindPublicAuth("/passkey/authenticate/options", authenticateOptions, "otp"))
264
+ .binding(bindPublicAuth("/passkey/authenticate", authenticate, "otp"));
265
+ }
266
+
267
+ async function consumeChallenge(
268
+ store: VerificationStore,
269
+ identifier: string,
270
+ challenge: string,
271
+ now: number,
272
+ ): Promise<boolean> {
273
+ const hash = await hashChallenge(challenge);
274
+ for (const row of store.rows.values()) {
275
+ if (row.identifier !== identifier) continue;
276
+ if (row.consumedAt !== null || row.expiresAt <= now) continue;
277
+ if (!constantTimeEqual(row.value, hash)) continue;
278
+ row.consumedAt = now;
279
+ return true;
280
+ }
281
+ return false;
282
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Phone-number OTP Gate auth method plugin (E.164).
3
+ */
4
+
5
+ import { issueSessionWithScopes } from "../auth/sessions.ts";
6
+ import {
7
+ createVerificationStore,
8
+ findActiveVerification,
9
+ generateOtp,
10
+ hashChallenge,
11
+ putVerification,
12
+ type VerificationStore,
13
+ } from "../auth/verification.ts";
14
+ import { plugin, type PluginDef } from "../kernel/plugin.ts";
15
+ import {
16
+ AuthFailed,
17
+ AuthRateLimited,
18
+ SessionTokensOut,
19
+ bindPublicAuth,
20
+ createMethodRuntime,
21
+ fail,
22
+ flow,
23
+ z,
24
+ type AuthMethodOptions,
25
+ } from "./auth/shared.ts";
26
+
27
+ const E164 = /^\+[1-9]\d{7,14}$/;
28
+ const DEFAULT_TTL_MS = 10 * 60 * 1000;
29
+ const MAX_ATTEMPTS = 5;
30
+
31
+ /** Phone principal store (phone → userId). */
32
+ export interface PhoneStore {
33
+ readonly byPhone: Map<string, string>;
34
+ }
35
+
36
+ /**
37
+ * Create an empty phone → user map.
38
+ */
39
+ export function createPhoneStore(): PhoneStore {
40
+ return { byPhone: new Map() };
41
+ }
42
+
43
+ /** Options for {@link phoneNumber}. */
44
+ export interface PhoneNumberOptions extends AuthMethodOptions {
45
+ readonly ttlMs?: number;
46
+ readonly phones?: PhoneStore;
47
+ readonly verifications?: VerificationStore;
48
+ /** Return raw OTP in the request response (test / local). */
49
+ readonly exposeDevOtp?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Phone OTP request + verify (E.164, 6-digit, hashed, 5 attempts).
54
+ *
55
+ * @param opts - TTL / stores / dev OTP
56
+ */
57
+ export function phoneNumber(opts: PhoneNumberOptions = {}): PluginDef {
58
+ const runtime = createMethodRuntime(opts);
59
+ const phones = opts.phones ?? createPhoneStore();
60
+ const verifications = opts.verifications ?? createVerificationStore();
61
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
62
+
63
+ const request = flow({
64
+ name: "auth.requestPhoneOtp",
65
+ unit: "auth",
66
+ plane: "user",
67
+ in: z.object({ phone: z.string().min(8) }),
68
+ out: z.object({
69
+ ok: z.literal(true),
70
+ devOtp: z.string().optional(),
71
+ }),
72
+ errors: { AuthFailed, AuthRateLimited },
73
+ do: async (input) => {
74
+ const phone = input.phone.trim();
75
+ if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
76
+ const otp = generateOtp(6);
77
+ const now = runtime.now();
78
+ for (const row of verifications.rows.values()) {
79
+ if (row.identifier === `phone-otp:${phone}` && row.consumedAt === null) {
80
+ row.consumedAt = now;
81
+ }
82
+ }
83
+ putVerification(verifications, {
84
+ id: crypto.randomUUID(),
85
+ identifier: `phone-otp:${phone}`,
86
+ value: await hashChallenge(otp),
87
+ expiresAt: now + ttlMs,
88
+ createdAt: now,
89
+ consumedAt: null,
90
+ attempts: 0,
91
+ });
92
+ return {
93
+ ok: true as const,
94
+ ...(opts.exposeDevOtp ? { devOtp: otp } : {}),
95
+ };
96
+ },
97
+ });
98
+
99
+ const verify = flow({
100
+ name: "auth.verifyPhoneOtp",
101
+ unit: "auth",
102
+ plane: "user",
103
+ in: z.object({
104
+ phone: z.string().min(8),
105
+ otp: z.string().min(4).max(8),
106
+ }),
107
+ out: SessionTokensOut,
108
+ errors: { AuthFailed, AuthRateLimited },
109
+ do: async (input) => {
110
+ const phone = input.phone.trim();
111
+ if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
112
+ const now = runtime.now();
113
+ const row = findActiveVerification(verifications, `phone-otp:${phone}`, now);
114
+ if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
115
+ if (row.attempts >= MAX_ATTEMPTS) {
116
+ row.consumedAt = now;
117
+ return fail("AuthFailed", { reason: "invalid_credentials" });
118
+ }
119
+ const hash = await hashChallenge(input.otp.trim());
120
+ if (hash !== row.value) {
121
+ row.attempts += 1;
122
+ if (row.attempts >= MAX_ATTEMPTS) row.consumedAt = now;
123
+ return fail("AuthFailed", { reason: "invalid_credentials" });
124
+ }
125
+ row.consumedAt = now;
126
+ let userId = phones.byPhone.get(phone);
127
+ if (!userId) {
128
+ userId = crypto.randomUUID();
129
+ phones.byPhone.set(phone, userId);
130
+ }
131
+ const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
132
+ id: userId,
133
+ plane: "user",
134
+ scopes: [],
135
+ });
136
+ return {
137
+ accessToken: issued.accessToken,
138
+ refreshToken: issued.refreshToken,
139
+ accessExpiresAt: issued.accessExpiresAt,
140
+ userId,
141
+ };
142
+ },
143
+ });
144
+
145
+ return plugin("phoneNumber", { version: "0.0.1", config: { method: "phone-number" } })
146
+ .needs("auth")
147
+ .binding(bindPublicAuth("/phone/request", request, "otp"))
148
+ .binding(bindPublicAuth("/phone/verify", verify, "otp"));
149
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Shared response-header mutation for HTTP middleware plugins.
3
+ * Web `Headers` are immutable-in-place on a built `Response` — rebuild instead.
4
+ */
5
+
6
+ /**
7
+ * Rebuild a response with headers mutated by `fn`.
8
+ *
9
+ * @param response - Original response
10
+ * @param fn - Header mutation
11
+ */
12
+ export function withHeaders(response: Response, fn: (headers: Headers) => void): Response {
13
+ const headers = new Headers(response.headers);
14
+ fn(headers);
15
+ return new Response(response.body, {
16
+ status: response.status,
17
+ statusText: response.statusText,
18
+ headers,
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Set a header only when absent (or when `override` is on).
24
+ *
25
+ * @param headers - Mutable headers
26
+ * @param name - Header name
27
+ * @param value - Header value
28
+ * @param override - Replace an app-set value
29
+ */
30
+ export function setUnlessPresent(
31
+ headers: Headers,
32
+ name: string,
33
+ value: string,
34
+ override: boolean,
35
+ ): void {
36
+ if (!override && headers.has(name)) return;
37
+ headers.set(name, value);
38
+ }
39
+
40
+ /**
41
+ * Append a token to `Vary` without duplicating it.
42
+ *
43
+ * @param headers - Mutable headers
44
+ * @param token - Vary token (e.g. `"origin"`)
45
+ */
46
+ export function appendVary(headers: Headers, token: string): void {
47
+ const vary = headers.get("vary");
48
+ if (vary === null) {
49
+ headers.set("vary", token);
50
+ return;
51
+ }
52
+ const pattern = new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
53
+ if (!pattern.test(vary)) headers.set("vary", `${vary}, ${token}`);
54
+ }