okengine 0.4.3 → 0.5.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 (127) 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/index.mdx +9 -31
  15. package/site/content/docs/plugins/anonymous.mdx +95 -0
  16. package/site/content/docs/plugins/compression.mdx +2 -2
  17. package/site/content/docs/plugins/cors.mdx +2 -2
  18. package/site/content/docs/plugins/csrf.mdx +2 -2
  19. package/site/content/docs/plugins/email-otp.mdx +111 -0
  20. package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +1 -1
  21. package/site/content/docs/plugins/index.mdx +69 -0
  22. package/site/content/docs/plugins/magic-link.mdx +112 -0
  23. package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
  24. package/site/content/docs/plugins/meta.json +10 -1
  25. package/site/content/docs/plugins/passkey.mdx +128 -0
  26. package/site/content/docs/plugins/phone-number.mdx +111 -0
  27. package/site/content/docs/plugins/two-factor.mdx +116 -0
  28. package/site/content/docs/plugins/username.mdx +117 -0
  29. package/site/content/docs/reference/client.mdx +331 -0
  30. package/site/content/docs/reference/fx.mdx +20 -5
  31. package/site/content/docs/reference/index.mdx +45 -0
  32. package/site/content/docs/reference/meta.json +11 -1
  33. package/site/content/docs/reference/plugins.mdx +25 -14
  34. package/src/auth/auth.test.ts +20 -2
  35. package/src/auth/bindings.ts +439 -0
  36. package/src/auth/breach-check.ts +112 -0
  37. package/src/auth/config.ts +288 -0
  38. package/src/auth/cookies.ts +123 -0
  39. package/src/auth/gate-auth.test.ts +379 -0
  40. package/src/auth/identity.ts +190 -0
  41. package/src/auth/index.ts +117 -1
  42. package/src/auth/method-context.ts +33 -0
  43. package/src/auth/operator.ts +27 -1
  44. package/src/auth/password-policy.test.ts +126 -0
  45. package/src/auth/password-policy.ts +77 -0
  46. package/src/auth/plugin.ts +62 -4
  47. package/src/auth/rate.ts +45 -0
  48. package/src/auth/schema.ts +260 -0
  49. package/src/auth/secondary-storage.ts +37 -0
  50. package/src/auth/sessions.ts +58 -1
  51. package/src/auth/tables.ts +4 -0
  52. package/src/auth/verification.ts +78 -0
  53. package/src/cli/dev.test.ts +3 -3
  54. package/src/cli/schema.ts +95 -23
  55. package/src/client/auth.ts +120 -0
  56. package/src/client-react/index.ts +93 -0
  57. package/src/compiler/aot.test.ts +2 -1
  58. package/src/compiler/extract.ts +19 -0
  59. package/src/compiler/response.ts +16 -2
  60. package/src/console/server/app.ts +10 -6
  61. package/src/console/server/auth-rate.test.ts +3 -3
  62. package/src/console/server/bind.ts +12 -1
  63. package/src/console/server/channels.test.ts +1 -1
  64. package/src/console/server/console-gates.ts +14 -0
  65. package/src/console/server/console.test.ts +6 -6
  66. package/src/console/server/flows-invoke.test.ts +2 -2
  67. package/src/console/server/flows.ts +2 -0
  68. package/src/console/server/gates.ts +8 -1
  69. package/src/console/server/operator-db.test.ts +4 -4
  70. package/src/console/server/operator-db.ts +22 -4
  71. package/src/console/server/security.gate.test.ts +3 -3
  72. package/src/console/ui/gates/fixture.ts +4 -0
  73. package/src/console/ui/gates/types.ts +2 -0
  74. package/src/console/ui/shell/client.ts +1 -0
  75. package/src/elements/gate/boot.ts +136 -0
  76. package/src/elements/gate/config.ts +69 -0
  77. package/src/elements/gate/declare.ts +51 -1
  78. package/src/elements/gate/runtime.ts +3 -1
  79. package/src/elements/gate.test.ts +77 -0
  80. package/src/elements/gate.ts +20 -1
  81. package/src/elements/index.ts +8 -0
  82. package/src/index.ts +11 -0
  83. package/src/kernel/app.ts +253 -32
  84. package/src/kernel/boot.test.ts +40 -3
  85. package/src/kernel/boot.ts +8 -0
  86. package/src/kernel/call.test.ts +46 -2
  87. package/src/kernel/edge.test.ts +3 -3
  88. package/src/kernel/flow.test.ts +2 -2
  89. package/src/kernel/fx.test.ts +1 -0
  90. package/src/kernel/fx.ts +60 -0
  91. package/src/kernel/hooks.test.ts +4 -4
  92. package/src/kernel/index.ts +12 -0
  93. package/src/kernel/pipeline.test.ts +12 -8
  94. package/src/kernel/pipeline.ts +23 -4
  95. package/src/kernel/plugin/decorate.test.ts +3 -3
  96. package/src/kernel/plugin/scoping.test.ts +3 -3
  97. package/src/kernel/plugin-elements.test.ts +51 -0
  98. package/src/kernel/plugin-needs.test.ts +83 -0
  99. package/src/kernel/plugin-needs.ts +129 -0
  100. package/src/kernel/plugin.ts +101 -0
  101. package/src/kernel/registry-isolation.test.ts +5 -5
  102. package/src/kernel/registry.ts +102 -3
  103. package/src/manifest/types.ts +2 -0
  104. package/src/plugins/anonymous.ts +58 -0
  105. package/src/plugins/auth/shared.ts +121 -0
  106. package/src/plugins/auth-methods.test.ts +176 -0
  107. package/src/plugins/compression.test.ts +5 -5
  108. package/src/plugins/config-source.test.ts +1 -1
  109. package/src/plugins/cors.test.ts +16 -10
  110. package/src/plugins/csrf.test.ts +1 -1
  111. package/src/plugins/email-otp.ts +161 -0
  112. package/src/plugins/index.ts +31 -0
  113. package/src/plugins/ip-allowlist.test.ts +19 -9
  114. package/src/plugins/magic-link.ts +163 -0
  115. package/src/plugins/maintenance-mode.test.ts +9 -5
  116. package/src/plugins/passkey.ts +216 -0
  117. package/src/plugins/phone-number.ts +149 -0
  118. package/src/plugins/security-headers.test.ts +14 -14
  119. package/src/plugins/two-factor.ts +249 -0
  120. package/src/plugins/username.ts +148 -0
  121. package/src/runs/runs.test.ts +6 -2
  122. package/src/runtime/primitives.ts +37 -4
  123. package/src/runtime/serve.test.ts +3 -2
  124. package/src/runtime/types.ts +25 -2
  125. package/src/test/create-test-app.test.ts +1 -1
  126. package/src/test/create-test-app.ts +4 -1
  127. package/src/test/provisions.integration.test.ts +1 -1
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Magic-link Gate auth method plugin.
3
+ *
4
+ * Delivery via Channel is optional in v1 — when {@link MagicLinkOptions.exposeDevToken}
5
+ * is set, the request response includes `devToken` for tests / local DX.
6
+ */
7
+
8
+ import {
9
+ createIdentityStore,
10
+ normalizeEmail,
11
+ type IdentityStore,
12
+ type UserIdentityRow,
13
+ } from "../auth/identity.ts";
14
+ import { issueSessionWithScopes } from "../auth/sessions.ts";
15
+ import {
16
+ createVerificationStore,
17
+ hashChallenge,
18
+ putVerification,
19
+ type VerificationStore,
20
+ } from "../auth/verification.ts";
21
+ import { plugin, type PluginDef } from "../kernel/plugin.ts";
22
+ import {
23
+ AuthFailed,
24
+ AuthRateLimited,
25
+ SessionTokensOut,
26
+ bindPublicAuth,
27
+ createMethodRuntime,
28
+ fail,
29
+ flow,
30
+ z,
31
+ type AuthMethodOptions,
32
+ } from "./auth/shared.ts";
33
+
34
+ const DEFAULT_TTL_MS = 10 * 60 * 1000;
35
+
36
+ /** Options for {@link magicLink}. */
37
+ export interface MagicLinkOptions extends AuthMethodOptions {
38
+ /** Challenge TTL (default 10m). */
39
+ readonly ttlMs?: number;
40
+ /** Identity store for email → user. */
41
+ readonly identities?: IdentityStore;
42
+ /** Verification / challenge store. */
43
+ readonly verifications?: VerificationStore;
44
+ /** Return raw token in the request response (test / local). */
45
+ readonly exposeDevToken?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Magic-link request + verify (`oke_verifications` challenge rows).
50
+ *
51
+ * @param opts - TTL / stores / dev token
52
+ */
53
+ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
54
+ const runtime = createMethodRuntime(opts);
55
+ const identities = opts.identities ?? createIdentityStore();
56
+ const verifications = opts.verifications ?? createVerificationStore();
57
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
58
+
59
+ const request = flow({
60
+ name: "auth.requestMagicLink",
61
+ unit: "auth",
62
+ plane: "user",
63
+ in: z.object({ email: z.string().min(3) }),
64
+ out: z.object({
65
+ ok: z.literal(true),
66
+ devToken: z.string().optional(),
67
+ }),
68
+ errors: { AuthFailed, AuthRateLimited },
69
+ do: async (input) => {
70
+ const email = normalizeEmail(input.email);
71
+ if (!email.includes("@")) return fail("AuthFailed", { reason: "invalid_email" });
72
+ const token = `ml_${crypto.randomUUID().replace(/-/g, "")}`;
73
+ const now = runtime.now();
74
+ putVerification(verifications, {
75
+ id: crypto.randomUUID(),
76
+ identifier: `magic:${email}`,
77
+ value: await hashChallenge(token),
78
+ expiresAt: now + ttlMs,
79
+ createdAt: now,
80
+ consumedAt: null,
81
+ attempts: 0,
82
+ });
83
+ return {
84
+ ok: true as const,
85
+ ...(opts.exposeDevToken ? { devToken: token } : {}),
86
+ };
87
+ },
88
+ });
89
+
90
+ const verify = flow({
91
+ name: "auth.verifyMagicLink",
92
+ unit: "auth",
93
+ plane: "user",
94
+ in: z.object({ token: z.string().min(1) }),
95
+ out: SessionTokensOut,
96
+ errors: { AuthFailed, AuthRateLimited },
97
+ do: async (input) => {
98
+ const hash = await hashChallenge(input.token);
99
+ const now = runtime.now();
100
+ let row:
101
+ | {
102
+ id: string;
103
+ identifier: string;
104
+ value: string;
105
+ expiresAt: number;
106
+ consumedAt: number | null;
107
+ attempts: number;
108
+ }
109
+ | undefined;
110
+ for (const candidate of verifications.rows.values()) {
111
+ if (!candidate.identifier.startsWith("magic:")) continue;
112
+ if (candidate.consumedAt !== null) continue;
113
+ if (candidate.expiresAt <= now) continue;
114
+ if (candidate.value === hash) {
115
+ row = candidate;
116
+ break;
117
+ }
118
+ }
119
+ if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
120
+ row.consumedAt = now;
121
+ const email = row.identifier.slice("magic:".length);
122
+ const user = ensureUserByEmail(identities, email, now);
123
+ const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
124
+ id: user.id,
125
+ plane: "user",
126
+ scopes: [],
127
+ });
128
+ return {
129
+ accessToken: issued.accessToken,
130
+ refreshToken: issued.refreshToken,
131
+ accessExpiresAt: issued.accessExpiresAt,
132
+ userId: user.id,
133
+ };
134
+ },
135
+ });
136
+
137
+ return plugin("magicLink", { version: "0.0.1", config: { method: "magic-link" } })
138
+ .needs("auth")
139
+ .binding(bindPublicAuth("/magic-link/request", request, "otp"))
140
+ .binding(bindPublicAuth("/magic-link/verify", verify, "otp"));
141
+ }
142
+
143
+ function ensureUserByEmail(store: IdentityStore, email: string, now: number): UserIdentityRow {
144
+ const existingId = store.byEmail.get(email);
145
+ if (existingId) {
146
+ const existing = store.users.get(existingId);
147
+ if (existing) return existing;
148
+ }
149
+ const id = crypto.randomUUID();
150
+ const user: UserIdentityRow = {
151
+ id,
152
+ email,
153
+ name: email.split("@")[0] || "user",
154
+ emailVerified: true,
155
+ status: "active",
156
+ createdAt: now,
157
+ updatedAt: now,
158
+ extra: {},
159
+ };
160
+ store.users.set(id, user);
161
+ store.byEmail.set(email, id);
162
+ return user;
163
+ }
@@ -18,7 +18,7 @@ beforeEach(() => {
18
18
  describe("maintenanceMode plugin", () => {
19
19
  test("returns 503 with ServiceUnavailable envelope and Retry-After", async () => {
20
20
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
21
- const app = oke({ name: "maint" }).plug(maintenanceMode({ retryAfter: 120 }));
21
+ const app = oke({ autoBoot: false, name: "maint" }).plug(maintenanceMode({ retryAfter: 120 }));
22
22
 
23
23
  const res = await app.fetch(new Request("http://localhost/x"));
24
24
 
@@ -36,7 +36,9 @@ describe("maintenanceMode plugin", () => {
36
36
 
37
37
  test("enabled: false passes traffic through", async () => {
38
38
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
39
- const app = oke({ name: "maint-off" }).plug(maintenanceMode({ enabled: false }));
39
+ const app = oke({ autoBoot: false, name: "maint-off" }).plug(
40
+ maintenanceMode({ enabled: false }),
41
+ );
40
42
 
41
43
  const res = await app.fetch(new Request("http://localhost/x"));
42
44
 
@@ -46,7 +48,9 @@ describe("maintenanceMode plugin", () => {
46
48
  test("allowPaths prefixes keep serving", async () => {
47
49
  on(http.get("/health"), flow({ name: "health.get", do: () => ({ up: true }) }));
48
50
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
49
- const app = oke({ name: "maint-allow" }).plug(maintenanceMode({ allowPaths: ["/health"] }));
51
+ const app = oke({ autoBoot: false, name: "maint-allow" }).plug(
52
+ maintenanceMode({ allowPaths: ["/health"] }),
53
+ );
50
54
 
51
55
  const health = await app.fetch(new Request("http://localhost/health"));
52
56
  expect(health.status).toBe(200);
@@ -57,7 +61,7 @@ describe("maintenanceMode plugin", () => {
57
61
 
58
62
  test("bypass header lets operators through (any non-empty value)", async () => {
59
63
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
60
- const app = oke({ name: "maint-bypass" }).plug(
64
+ const app = oke({ autoBoot: false, name: "maint-bypass" }).plug(
61
65
  maintenanceMode({ bypassHeader: "x-ops-token" }),
62
66
  );
63
67
 
@@ -72,7 +76,7 @@ describe("maintenanceMode plugin", () => {
72
76
 
73
77
  test("the 503 still flows through onResponse (other plugins stamp it)", async () => {
74
78
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
75
- const app = oke({ name: "maint-stamp" }).plug(maintenanceMode());
79
+ const app = oke({ autoBoot: false, name: "maint-stamp" }).plug(maintenanceMode());
76
80
  app.hook("onResponse", (ctx) => {
77
81
  if (!ctx.response) return;
78
82
  const headers = new Headers(ctx.response.headers);
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Passkey (WebAuthn-shaped) Gate auth method plugin — simplified v1 ceremony.
3
+ *
4
+ * Registration / authentication accept attestation-like payloads for tests
5
+ * without a full WebAuthn library. Production should replace the ceremony
6
+ * with a standards-compliant verifier.
7
+ */
8
+
9
+ import {
10
+ createVerificationStore,
11
+ hashChallenge,
12
+ putVerification,
13
+ type VerificationStore,
14
+ } from "../auth/verification.ts";
15
+ import { issueSessionWithScopes } from "../auth/sessions.ts";
16
+ import { plugin, type PluginDef } from "../kernel/plugin.ts";
17
+ import {
18
+ AuthFailed,
19
+ AuthRateLimited,
20
+ SessionTokensOut,
21
+ bindPublicAuth,
22
+ bindSessionAuth,
23
+ createMethodRuntime,
24
+ fail,
25
+ flow,
26
+ z,
27
+ type AuthMethodOptions,
28
+ } from "./auth/shared.ts";
29
+
30
+ /** Stored passkey credential (simplified). */
31
+ export interface PasskeyCredential {
32
+ readonly credentialId: string;
33
+ readonly userId: string;
34
+ readonly publicKey: string;
35
+ counter: number;
36
+ readonly createdAt: number;
37
+ }
38
+
39
+ /** In-memory passkey store. */
40
+ export interface PasskeyStore {
41
+ readonly byCredentialId: Map<string, PasskeyCredential>;
42
+ readonly byUserId: Map<string, PasskeyCredential[]>;
43
+ }
44
+
45
+ /**
46
+ * Create an empty passkey store.
47
+ */
48
+ export function createPasskeyStore(): PasskeyStore {
49
+ return { byCredentialId: new Map(), byUserId: new Map() };
50
+ }
51
+
52
+ /** Options for {@link passkey}. */
53
+ export interface PasskeyOptions extends AuthMethodOptions {
54
+ readonly passkeys?: PasskeyStore;
55
+ readonly challenges?: VerificationStore;
56
+ /** Relying party id (default `localhost`). */
57
+ readonly rpId?: string;
58
+ }
59
+
60
+ /**
61
+ * Passkey register / authenticate options + simplified ceremony (`oke_passkeys`).
62
+ *
63
+ * @param opts - Stores / RP id
64
+ */
65
+ export function passkey(opts: PasskeyOptions = {}): PluginDef {
66
+ const runtime = createMethodRuntime(opts);
67
+ const passkeys = opts.passkeys ?? createPasskeyStore();
68
+ const challenges = opts.challenges ?? createVerificationStore();
69
+ const rpId = opts.rpId ?? "localhost";
70
+
71
+ const registerOptions = flow({
72
+ name: "auth.passkeyRegisterOptions",
73
+ unit: "auth",
74
+ plane: "user",
75
+ out: z.object({
76
+ challenge: z.string(),
77
+ rpId: z.string(),
78
+ userId: z.string(),
79
+ }),
80
+ errors: { AuthFailed },
81
+ do: async (_input, fx) => {
82
+ const userId = fx.auth.userId;
83
+ if (!userId) return fail("AuthFailed", { reason: "unauthenticated" });
84
+ const challenge = crypto.randomUUID().replace(/-/g, "");
85
+ const now = runtime.now();
86
+ putVerification(challenges, {
87
+ id: crypto.randomUUID(),
88
+ identifier: `passkey-reg:${userId}`,
89
+ value: await hashChallenge(challenge),
90
+ expiresAt: now + 5 * 60 * 1000,
91
+ createdAt: now,
92
+ consumedAt: null,
93
+ attempts: 0,
94
+ });
95
+ return { challenge, rpId, userId };
96
+ },
97
+ });
98
+
99
+ const register = flow({
100
+ name: "auth.passkeyRegister",
101
+ unit: "auth",
102
+ plane: "user",
103
+ in: z.object({
104
+ credentialId: z.string().min(1),
105
+ publicKey: z.string().min(1),
106
+ userId: z.string().min(1),
107
+ challenge: z.string().optional(),
108
+ }),
109
+ out: z.object({ ok: z.literal(true) }),
110
+ errors: { AuthFailed },
111
+ do: async (input, fx) => {
112
+ const sessionUser = fx.auth.userId;
113
+ if (!sessionUser || sessionUser !== input.userId) {
114
+ return fail("AuthFailed", { reason: "unauthenticated" });
115
+ }
116
+ if (input.challenge) {
117
+ const hash = await hashChallenge(input.challenge);
118
+ const now = runtime.now();
119
+ let found = false;
120
+ for (const row of challenges.rows.values()) {
121
+ if (row.identifier !== `passkey-reg:${input.userId}`) continue;
122
+ if (row.consumedAt !== null || row.expiresAt <= now) continue;
123
+ if (row.value === hash) {
124
+ row.consumedAt = now;
125
+ found = true;
126
+ break;
127
+ }
128
+ }
129
+ if (!found) return fail("AuthFailed", { reason: "invalid_credentials" });
130
+ }
131
+ if (passkeys.byCredentialId.has(input.credentialId)) {
132
+ return fail("AuthFailed", { reason: "invalid_credentials" });
133
+ }
134
+ const cred: PasskeyCredential = {
135
+ credentialId: input.credentialId,
136
+ userId: input.userId,
137
+ publicKey: input.publicKey,
138
+ counter: 0,
139
+ createdAt: runtime.now(),
140
+ };
141
+ passkeys.byCredentialId.set(cred.credentialId, cred);
142
+ const list = passkeys.byUserId.get(cred.userId) ?? [];
143
+ list.push(cred);
144
+ passkeys.byUserId.set(cred.userId, list);
145
+ return { ok: true as const };
146
+ },
147
+ });
148
+
149
+ const authenticateOptions = flow({
150
+ name: "auth.passkeyAuthenticateOptions",
151
+ unit: "auth",
152
+ plane: "user",
153
+ in: z.object({ email: z.string().optional() }),
154
+ out: z.object({
155
+ challenge: z.string(),
156
+ rpId: z.string(),
157
+ allowCredentials: z.array(z.string()),
158
+ }),
159
+ errors: { AuthFailed, AuthRateLimited },
160
+ do: async (input) => {
161
+ const challenge = crypto.randomUUID().replace(/-/g, "");
162
+ const now = runtime.now();
163
+ const key = input.email?.trim().toLowerCase() || "anonymous";
164
+ putVerification(challenges, {
165
+ id: crypto.randomUUID(),
166
+ identifier: `passkey-auth:${key}`,
167
+ value: await hashChallenge(challenge),
168
+ expiresAt: now + 5 * 60 * 1000,
169
+ createdAt: now,
170
+ consumedAt: null,
171
+ attempts: 0,
172
+ });
173
+ // Simplified: empty allow list when no email mapping (caller supplies credentialId).
174
+ return { challenge, rpId, allowCredentials: [] as string[] };
175
+ },
176
+ });
177
+
178
+ const authenticate = flow({
179
+ name: "auth.passkeyAuthenticate",
180
+ unit: "auth",
181
+ plane: "user",
182
+ in: z.object({
183
+ credentialId: z.string().min(1),
184
+ userId: z.string().min(1),
185
+ }),
186
+ out: SessionTokensOut,
187
+ errors: { AuthFailed, AuthRateLimited },
188
+ do: async (input) => {
189
+ // Simplified v1: presence of stored credential + matching userId issues a session.
190
+ const cred = passkeys.byCredentialId.get(input.credentialId);
191
+ if (!cred || cred.userId !== input.userId) {
192
+ return fail("AuthFailed", { reason: "invalid_credentials" });
193
+ }
194
+ cred.counter += 1;
195
+ const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
196
+ id: cred.userId,
197
+ plane: "user",
198
+ scopes: [],
199
+ });
200
+ return {
201
+ accessToken: issued.accessToken,
202
+ refreshToken: issued.refreshToken,
203
+ accessExpiresAt: issued.accessExpiresAt,
204
+ userId: cred.userId,
205
+ };
206
+ },
207
+ });
208
+
209
+ return plugin("passkey", { version: "0.0.1", config: { method: "passkey" } })
210
+ .needs("auth")
211
+ .table("oke_passkeys", undefined, { plane: "user", description: "WebAuthn credentials" })
212
+ .binding(bindSessionAuth("/passkey/register/options", registerOptions))
213
+ .binding(bindSessionAuth("/passkey/register", register))
214
+ .binding(bindPublicAuth("/passkey/authenticate/options", authenticateOptions, "otp"))
215
+ .binding(bindPublicAuth("/passkey/authenticate", authenticate, "otp"));
216
+ }
@@ -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
+ }
@@ -19,7 +19,7 @@ beforeEach(() => {
19
19
  describe("securityHeaders plugin", () => {
20
20
  test("sets the default trio on a successful response", async () => {
21
21
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
22
- const app = oke({ name: "sec" }).plug(securityHeaders());
22
+ const app = oke({ autoBoot: false, name: "sec" }).plug(securityHeaders());
23
23
 
24
24
  const res = await app.fetch(new Request("http://localhost/x"));
25
25
 
@@ -32,7 +32,7 @@ describe("securityHeaders plugin", () => {
32
32
 
33
33
  test("helmet-parity defaults: agent cluster, dns prefetch, download, cross-domain, xss filter", async () => {
34
34
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
35
- const app = oke({ name: "sec-parity" }).plug(securityHeaders());
35
+ const app = oke({ autoBoot: false, name: "sec-parity" }).plug(securityHeaders());
36
36
 
37
37
  const res = await app.fetch(new Request("http://localhost/x"));
38
38
 
@@ -45,7 +45,7 @@ describe("securityHeaders plugin", () => {
45
45
 
46
46
  test("each parity default can be switched off", async () => {
47
47
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
48
- const app = oke({ name: "sec-off" }).plug(
48
+ const app = oke({ autoBoot: false, name: "sec-off" }).plug(
49
49
  securityHeaders({
50
50
  originAgentCluster: false,
51
51
  dnsPrefetchControl: false,
@@ -65,7 +65,7 @@ describe("securityHeaders plugin", () => {
65
65
 
66
66
  test("dnsPrefetchControl allow, custom cross-domain policy, COEP stamps when configured", async () => {
67
67
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
68
- const app = oke({ name: "sec-tuned" }).plug(
68
+ const app = oke({ autoBoot: false, name: "sec-tuned" }).plug(
69
69
  securityHeaders({
70
70
  dnsPrefetchControl: { allow: true },
71
71
  permittedCrossDomainPolicies: "by-content-type",
@@ -92,7 +92,7 @@ describe("securityHeaders plugin", () => {
92
92
  }),
93
93
  }),
94
94
  );
95
- return oke({ name }).plug(securityHeaders(options));
95
+ return oke({ autoBoot: false, name }).plug(securityHeaders(options));
96
96
  };
97
97
 
98
98
  const removed = await poweredByApp({}, "sec-pb-off").fetch(new Request("http://localhost/x"));
@@ -112,7 +112,7 @@ describe("securityHeaders plugin", () => {
112
112
 
113
113
  test("structured CSP merges over helmet's defaults, camelCase keys, report-only mode", async () => {
114
114
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
115
- const app = oke({ name: "sec-csp-builder" }).plug(
115
+ const app = oke({ autoBoot: false, name: "sec-csp-builder" }).plug(
116
116
  securityHeaders({
117
117
  contentSecurityPolicy: {
118
118
  directives: { scriptSrc: ["'self'", "https://cdn.example.com"] },
@@ -132,7 +132,7 @@ describe("securityHeaders plugin", () => {
132
132
 
133
133
  test("structured CSP with useDefaults: false emits exactly the given directives", async () => {
134
134
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
135
- const app = oke({ name: "sec-csp-bare" }).plug(
135
+ const app = oke({ autoBoot: false, name: "sec-csp-bare" }).plug(
136
136
  securityHeaders({
137
137
  contentSecurityPolicy: { useDefaults: false, directives: { "default-src": ["'none'"] } },
138
138
  }),
@@ -155,7 +155,7 @@ describe("securityHeaders plugin", () => {
155
155
  fail("Forbidden", {}),
156
156
  ),
157
157
  );
158
- const app = oke({ name: "sec-fail" }).plug(securityHeaders());
158
+ const app = oke({ autoBoot: false, name: "sec-fail" }).plug(securityHeaders());
159
159
 
160
160
  const res = await app.fetch(new Request("http://localhost/deny"));
161
161
 
@@ -165,7 +165,7 @@ describe("securityHeaders plugin", () => {
165
165
 
166
166
  test("adds CSP only when configured", async () => {
167
167
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
168
- const app = oke({ name: "sec-csp" }).plug(
168
+ const app = oke({ autoBoot: false, name: "sec-csp" }).plug(
169
169
  securityHeaders({ contentSecurityPolicy: "default-src 'self'" }),
170
170
  );
171
171
 
@@ -176,7 +176,7 @@ describe("securityHeaders plugin", () => {
176
176
 
177
177
  test("HSTS is off by default", async () => {
178
178
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
179
- const app = oke({ name: "sec-hsts-off" }).plug(securityHeaders());
179
+ const app = oke({ autoBoot: false, name: "sec-hsts-off" }).plug(securityHeaders());
180
180
 
181
181
  const res = await app.fetch(new Request("http://localhost/x"));
182
182
 
@@ -185,7 +185,7 @@ describe("securityHeaders plugin", () => {
185
185
 
186
186
  test("hsts: true stamps a one-year max-age", async () => {
187
187
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
188
- const app = oke({ name: "sec-hsts-on" }).plug(securityHeaders({ hsts: true }));
188
+ const app = oke({ autoBoot: false, name: "sec-hsts-on" }).plug(securityHeaders({ hsts: true }));
189
189
 
190
190
  const res = await app.fetch(new Request("http://localhost/x"));
191
191
 
@@ -194,7 +194,7 @@ describe("securityHeaders plugin", () => {
194
194
 
195
195
  test("hsts object tunes max-age, subdomains, preload", async () => {
196
196
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
197
- const app = oke({ name: "sec-hsts-tuned" }).plug(
197
+ const app = oke({ autoBoot: false, name: "sec-hsts-tuned" }).plug(
198
198
  securityHeaders({ hsts: { maxAge: 63072000, includeSubDomains: true, preload: true } }),
199
199
  );
200
200
 
@@ -207,7 +207,7 @@ describe("securityHeaders plugin", () => {
207
207
 
208
208
  test("permissions-policy and cross-origin policies stamp when configured", async () => {
209
209
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
210
- const app = oke({ name: "sec-extra" }).plug(
210
+ const app = oke({ autoBoot: false, name: "sec-extra" }).plug(
211
211
  securityHeaders({
212
212
  permissionsPolicy: "camera=(), microphone=()",
213
213
  crossOriginOpenerPolicy: "same-origin",
@@ -224,7 +224,7 @@ describe("securityHeaders plugin", () => {
224
224
 
225
225
  test("an explicit app-set value wins by default", async () => {
226
226
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
227
- const app = oke({ name: "sec-keep" });
227
+ const app = oke({ autoBoot: false, name: "sec-keep" });
228
228
  app.hook("onResponse", (ctx) => {
229
229
  if (!ctx.response) return;
230
230
  const headers = new Headers(ctx.response.headers);