okengine 0.5.1 → 0.6.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 (76) hide show
  1. package/package.json +2 -1
  2. package/site/content/docs/elements/ai.mdx +82 -1
  3. package/site/content/docs/elements/channel.mdx +6 -1
  4. package/site/content/docs/elements/flow.mdx +20 -17
  5. package/site/content/docs/plugins/email-otp.mdx +25 -19
  6. package/site/content/docs/plugins/magic-link.mdx +27 -21
  7. package/site/content/docs/reference/configuration.mdx +7 -0
  8. package/site/content/docs/reference/environment-variables.mdx +10 -5
  9. package/site/content/docs/reference/errors.mdx +14 -0
  10. package/site/content/docs/reference/fx.mdx +68 -16
  11. package/site/content/docs/reference/i18n.mdx +313 -0
  12. package/site/content/docs/reference/index.mdx +6 -1
  13. package/site/content/docs/reference/meta.json +1 -0
  14. package/site/content/docs/reference/plugins.mdx +1 -0
  15. package/src/auth/auth.test.ts +3 -0
  16. package/src/auth/bindings.ts +1 -1
  17. package/src/auth/method-context.ts +12 -2
  18. package/src/compiler/aot.test.ts +16 -13
  19. package/src/compiler/effects-infer.ts +46 -0
  20. package/src/console/server/ai.test.ts +34 -5
  21. package/src/docker/compose.ts +9 -0
  22. package/src/docker/docker.test.ts +39 -0
  23. package/src/docker/index.ts +11 -1
  24. package/src/docker/recipes/index.ts +3 -1
  25. package/src/docker/recipes/ollama.ts +43 -0
  26. package/src/docker/stack-id.ts +2 -0
  27. package/src/drivers/ai-mock.ts +60 -0
  28. package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
  29. package/src/drivers/ai-ollama.integration.test.ts +197 -0
  30. package/src/drivers/ai-ollama.ts +327 -0
  31. package/src/drivers/ai-openai-compatible.ts +211 -21
  32. package/src/drivers/ai-providers.test.ts +179 -2
  33. package/src/drivers/ai-stream.test.ts +195 -0
  34. package/src/drivers/ai-types.ts +42 -1
  35. package/src/drivers/channel-smtp.ts +8 -2
  36. package/src/drivers/index.ts +21 -1
  37. package/src/drivers/ollama.ts +14 -0
  38. package/src/elements/ai/rate.test.ts +53 -0
  39. package/src/elements/ai/rate.ts +66 -0
  40. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  41. package/src/elements/ai/runtime.ts +330 -100
  42. package/src/elements/ai/tools.test.ts +99 -0
  43. package/src/elements/ai.test.ts +26 -2
  44. package/src/elements/ai.ts +10 -1
  45. package/src/i18n/catalogs/ar.ts +67 -0
  46. package/src/i18n/catalogs/en.ts +68 -0
  47. package/src/i18n/failure-message.test.ts +56 -0
  48. package/src/i18n/failure-message.ts +93 -0
  49. package/src/i18n/format.ts +67 -0
  50. package/src/i18n/index.ts +57 -0
  51. package/src/i18n/locale-context.ts +48 -0
  52. package/src/i18n/messages.test.ts +173 -0
  53. package/src/i18n/messages.ts +169 -0
  54. package/src/i18n/types.ts +90 -0
  55. package/src/index.ts +26 -0
  56. package/src/kernel/app.ts +92 -2
  57. package/src/kernel/boot-bind/ai.test.ts +60 -0
  58. package/src/kernel/boot-bind/ai.ts +125 -2
  59. package/src/kernel/boot.test.ts +4 -3
  60. package/src/kernel/boot.ts +1 -1
  61. package/src/kernel/errors.ts +56 -5
  62. package/src/kernel/fx.test.ts +27 -0
  63. package/src/kernel/fx.ts +74 -18
  64. package/src/kernel/pipeline.test.ts +4 -0
  65. package/src/kernel/pipeline.ts +1 -1
  66. package/src/kernel/plugin.ts +16 -0
  67. package/src/kernel/registry.ts +15 -0
  68. package/src/plugins/auth/shared.ts +5 -1
  69. package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
  70. package/src/plugins/auth-methods.security.test.ts +12 -10
  71. package/src/plugins/email-otp.ts +54 -1
  72. package/src/plugins/index.ts +16 -2
  73. package/src/plugins/magic-link.ts +63 -3
  74. package/src/plugins/username-policy.test.ts +302 -0
  75. package/src/plugins/username.ts +290 -9
  76. package/src/release/measure.ts +8 -1
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Username policy unit + sign-up integration.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { oke } from "../kernel/app.ts";
7
+ import { resetFlowSeq } from "../kernel/flow.ts";
8
+ import { resetBindings } from "../kernel/on.ts";
9
+ import {
10
+ assertUsernamePolicy,
11
+ DEFAULT_RESERVED_USERNAMES,
12
+ DEFAULT_USERNAME_MAX_LENGTH,
13
+ DEFAULT_USERNAME_MIN_LENGTH,
14
+ resolveUsernamePolicy,
15
+ username,
16
+ UsernamePolicyError,
17
+ } from "./username.ts";
18
+
19
+ afterEach(() => {
20
+ resetBindings();
21
+ resetFlowSeq();
22
+ });
23
+
24
+ describe("username policy", () => {
25
+ test("defaults: 3–64 from a-z0-9._-", () => {
26
+ const policy = resolveUsernamePolicy();
27
+ expect(policy.minLength).toBe(DEFAULT_USERNAME_MIN_LENGTH);
28
+ expect(policy.maxLength).toBe(DEFAULT_USERNAME_MAX_LENGTH);
29
+ expect(() => assertUsernamePolicy("ali")).not.toThrow();
30
+ expect(() => assertUsernamePolicy("a.b_c-1")).not.toThrow();
31
+ expect(() => assertUsernamePolicy("ab")).toThrow(UsernamePolicyError);
32
+ expect(() => assertUsernamePolicy("ali!")).toThrow(UsernamePolicyError);
33
+ });
34
+
35
+ test("extraAllowedChars extends default; allowedChars replaces then extends", () => {
36
+ expect(() => assertUsernamePolicy("al!ice")).toThrow(/allowedChars/);
37
+ expect(() => assertUsernamePolicy("al!ice", { extraAllowedChars: "!" })).not.toThrow();
38
+ expect(() => assertUsernamePolicy("al+ice", { extraAllowedChars: "!" })).toThrow(
39
+ /allowedChars/,
40
+ );
41
+
42
+ const replaced = resolveUsernamePolicy({
43
+ allowedChars: "abc",
44
+ extraAllowedChars: "!",
45
+ });
46
+ expect(replaced.allowedChars).toBe("abc!");
47
+ expect(() =>
48
+ assertUsernamePolicy("a!b", {
49
+ allowedChars: "abc",
50
+ extraAllowedChars: "!",
51
+ forbidEdgeSymbols: false,
52
+ }),
53
+ ).not.toThrow();
54
+ expect(() =>
55
+ assertUsernamePolicy("ali", { allowedChars: "abc", extraAllowedChars: "!" }),
56
+ ).toThrow(/allowedChars/);
57
+ });
58
+
59
+ test("requireLetter / requireNumber / requireSymbol", () => {
60
+ expect(() => assertUsernamePolicy("12345", { minLength: 3, requireLetter: true })).toThrow(
61
+ /requireLetter/,
62
+ );
63
+ expect(() => assertUsernamePolicy("alice", { minLength: 3, requireNumber: true })).toThrow(
64
+ /requireNumber/,
65
+ );
66
+ expect(() => assertUsernamePolicy("alice1", { minLength: 3, requireSymbol: true })).toThrow(
67
+ /requireSymbol/,
68
+ );
69
+ expect(() =>
70
+ assertUsernamePolicy("alice.1", {
71
+ minLength: 3,
72
+ requireLetter: true,
73
+ requireNumber: true,
74
+ requireSymbol: true,
75
+ }),
76
+ ).not.toThrow();
77
+ });
78
+
79
+ test("default reserved blocks admin; [] opts out; custom replaces; extraReserved appends", () => {
80
+ const policy = resolveUsernamePolicy();
81
+ expect(policy.reserved.size).toBe(DEFAULT_RESERVED_USERNAMES.length);
82
+ expect(() => assertUsernamePolicy("admin")).toThrow(/reserved/);
83
+ expect(() => assertUsernamePolicy("anonymous")).toThrow(/reserved/);
84
+ expect(() => assertUsernamePolicy("admin", { reserved: [] })).not.toThrow();
85
+ expect(() => assertUsernamePolicy("admin", { reserved: ["brand"] })).not.toThrow();
86
+ expect(() => assertUsernamePolicy("brand", { reserved: ["Brand"] })).toThrow(/reserved/);
87
+ expect(() => assertUsernamePolicy("acme", { extraReserved: ["Acme"] })).toThrow(/reserved/);
88
+ expect(() => assertUsernamePolicy("admin", { extraReserved: ["acme"] })).toThrow(/reserved/);
89
+ const long = "a".repeat(DEFAULT_USERNAME_MAX_LENGTH + 1);
90
+ expect(() => assertUsernamePolicy(long)).toThrow(/maxLength/);
91
+ });
92
+
93
+ test("shape rules: edge / consecutive symbols + mustStartWithLetter", () => {
94
+ expect(() => assertUsernamePolicy(".alice")).toThrow(/forbidEdgeSymbols/);
95
+ expect(() => assertUsernamePolicy("alice.")).toThrow(/forbidEdgeSymbols/);
96
+ expect(() => assertUsernamePolicy("ali..ce")).toThrow(/forbidConsecutiveSymbols/);
97
+ expect(() => assertUsernamePolicy("a.b_c-1")).not.toThrow();
98
+ expect(() => assertUsernamePolicy(".alice", { forbidEdgeSymbols: false })).not.toThrow();
99
+ expect(() => assertUsernamePolicy("12345", { mustStartWithLetter: true })).toThrow(
100
+ /mustStartWithLetter/,
101
+ );
102
+ expect(() => assertUsernamePolicy("a2345", { mustStartWithLetter: true })).not.toThrow();
103
+ });
104
+
105
+ test("sign-up rejects default reserved names", async () => {
106
+ const app = oke({
107
+ name: "username-default-reserved",
108
+ env: "test",
109
+ registry: "ignore",
110
+ gate: { auth: { secret: "test-secret-at-least-16" } },
111
+ }).plug(username());
112
+ await app.boot({ env: "test" });
113
+
114
+ const res = await app.fetch(
115
+ new Request("http://localhost/auth/sign-up/username", {
116
+ method: "POST",
117
+ headers: { "content-type": "application/json" },
118
+ body: JSON.stringify({ username: "Admin", password: "CorrectHorse1" }),
119
+ }),
120
+ );
121
+ const body = (await res.json()) as {
122
+ error: { data?: { reason?: string; reasons?: string[] } };
123
+ };
124
+ expect(body.error.data?.reason).toBe("username_policy");
125
+ expect(body.error.data?.reasons).toContain("reserved");
126
+
127
+ await app.stop();
128
+ });
129
+
130
+ test("sign-up returns username_policy / password_policy reasons", async () => {
131
+ const app = oke({
132
+ name: "username-policy",
133
+ env: "test",
134
+ registry: "ignore",
135
+ gate: { auth: { secret: "test-secret-at-least-16" } },
136
+ }).plug(
137
+ username({
138
+ usernamePolicy: {
139
+ minLength: 5,
140
+ requireLetter: true,
141
+ requireNumber: true,
142
+ reserved: ["root"],
143
+ },
144
+ passwordPolicy: {
145
+ minLength: 12,
146
+ requireLetter: true,
147
+ requireNumber: true,
148
+ requireSymbol: true,
149
+ },
150
+ }),
151
+ );
152
+ await app.boot({ env: "test" });
153
+
154
+ const shortName = await app.fetch(
155
+ new Request("http://localhost/auth/sign-up/username", {
156
+ method: "POST",
157
+ headers: { "content-type": "application/json" },
158
+ body: JSON.stringify({ username: "ab", password: "CorrectHorse1!" }),
159
+ }),
160
+ );
161
+ const shortBody = (await shortName.json()) as {
162
+ error: { data?: { reason?: string; reasons?: string[] } };
163
+ };
164
+ expect(shortBody.error.data?.reason).toBe("username_policy");
165
+ expect(shortBody.error.data?.reasons?.some((r) => r.includes("minLength"))).toBe(true);
166
+
167
+ const reserved = await app.fetch(
168
+ new Request("http://localhost/auth/sign-up/username", {
169
+ method: "POST",
170
+ headers: { "content-type": "application/json" },
171
+ body: JSON.stringify({ username: "root", password: "CorrectHorse1!" }),
172
+ }),
173
+ );
174
+ const reservedBody = (await reserved.json()) as {
175
+ error: { data?: { reason?: string; reasons?: string[] } };
176
+ };
177
+ expect(reservedBody.error.data?.reason).toBe("username_policy");
178
+ expect(reservedBody.error.data?.reasons).toContain("reserved");
179
+
180
+ const weakPass = await app.fetch(
181
+ new Request("http://localhost/auth/sign-up/username", {
182
+ method: "POST",
183
+ headers: { "content-type": "application/json" },
184
+ body: JSON.stringify({ username: "alice1", password: "CorrectHorse1" }),
185
+ }),
186
+ );
187
+ const weakBody = (await weakPass.json()) as {
188
+ error: { data?: { reason?: string; reasons?: string[] } };
189
+ };
190
+ expect(weakBody.error.data?.reason).toBe("password_policy");
191
+ expect(weakBody.error.data?.reasons).toContain("requireSymbol");
192
+
193
+ const ok = await app.fetch(
194
+ new Request("http://localhost/auth/sign-up/username", {
195
+ method: "POST",
196
+ headers: { "content-type": "application/json" },
197
+ body: JSON.stringify({ username: "alice1", password: "CorrectHorse1!" }),
198
+ }),
199
+ );
200
+ expect(ok.status).toBe(200);
201
+
202
+ await app.stop();
203
+ });
204
+
205
+ test("default password policy rejects short passwords on sign-up", async () => {
206
+ const app = oke({
207
+ name: "username-default-pw",
208
+ env: "test",
209
+ registry: "ignore",
210
+ gate: { auth: { secret: "test-secret-at-least-16" } },
211
+ }).plug(username());
212
+ await app.boot({ env: "test" });
213
+
214
+ const res = await app.fetch(
215
+ new Request("http://localhost/auth/sign-up/username", {
216
+ method: "POST",
217
+ headers: { "content-type": "application/json" },
218
+ body: JSON.stringify({ username: "alice", password: "short" }),
219
+ }),
220
+ );
221
+ const body = (await res.json()) as {
222
+ error: { data?: { reason?: string; reasons?: string[] } };
223
+ };
224
+ expect(body.error.data?.reason).toBe("password_policy");
225
+ expect(body.error.data?.reasons?.some((r) => r.includes("minLength"))).toBe(true);
226
+
227
+ await app.stop();
228
+ });
229
+
230
+ test("inherits gate.auth.passwordPolicy when plugin omits passwordPolicy", async () => {
231
+ const app = oke({
232
+ name: "username-inherit-pw",
233
+ env: "test",
234
+ registry: "ignore",
235
+ gate: {
236
+ auth: {
237
+ secret: "test-secret-at-least-16",
238
+ passwordPolicy: {
239
+ minLength: 12,
240
+ requireLetter: true,
241
+ requireNumber: true,
242
+ requireSymbol: true,
243
+ },
244
+ },
245
+ },
246
+ }).plug(username());
247
+ await app.boot({ env: "test" });
248
+
249
+ const weak = await app.fetch(
250
+ new Request("http://localhost/auth/sign-up/username", {
251
+ method: "POST",
252
+ headers: { "content-type": "application/json" },
253
+ body: JSON.stringify({ username: "alice", password: "CorrectHorse1" }),
254
+ }),
255
+ );
256
+ const weakBody = (await weak.json()) as {
257
+ error: { data?: { reason?: string; reasons?: string[] } };
258
+ };
259
+ expect(weakBody.error.data?.reason).toBe("password_policy");
260
+ expect(weakBody.error.data?.reasons).toContain("requireSymbol");
261
+
262
+ const ok = await app.fetch(
263
+ new Request("http://localhost/auth/sign-up/username", {
264
+ method: "POST",
265
+ headers: { "content-type": "application/json" },
266
+ body: JSON.stringify({ username: "alice", password: "CorrectHorse1!" }),
267
+ }),
268
+ );
269
+ expect(ok.status).toBe(200);
270
+
271
+ await app.stop();
272
+ });
273
+
274
+ test("inherits gate.auth.breachCheck on sign-up", async () => {
275
+ const app = oke({
276
+ name: "username-inherit-breach",
277
+ env: "test",
278
+ registry: "ignore",
279
+ gate: {
280
+ auth: {
281
+ secret: "test-secret-at-least-16",
282
+ breachCheck: async () => true,
283
+ },
284
+ },
285
+ }).plug(username());
286
+ await app.boot({ env: "test" });
287
+
288
+ const res = await app.fetch(
289
+ new Request("http://localhost/auth/sign-up/username", {
290
+ method: "POST",
291
+ headers: { "content-type": "application/json" },
292
+ body: JSON.stringify({ username: "alice", password: "CorrectHorse1" }),
293
+ }),
294
+ );
295
+ const body = (await res.json()) as {
296
+ error: { data?: { reason?: string } };
297
+ };
298
+ expect(body.error.data?.reason).toBe("password_breached");
299
+
300
+ await app.stop();
301
+ });
302
+ });
@@ -2,9 +2,17 @@
2
2
  * Username + password Gate auth method plugin.
3
3
  */
4
4
 
5
- import { createBunCrypto } from "../runtime/primitives.ts";
5
+ import { assertNotBreached, BreachCheckError, type BreachCheckFn } from "../auth/breach-check.ts";
6
+ import { getActiveGateAuthContext } from "../auth/method-context.ts";
7
+ import {
8
+ assertPasswordPolicy,
9
+ PasswordPolicyError,
10
+ type PasswordPolicyOptions,
11
+ } from "../auth/password-policy.ts";
6
12
  import { issueSessionWithScopes } from "../auth/sessions.ts";
7
13
  import { plugin, type PluginDef } from "../kernel/plugin.ts";
14
+ import { createBunCrypto } from "../runtime/primitives.ts";
15
+ import type { PasswordHashOptions } from "../runtime/types.ts";
8
16
  import {
9
17
  AuthFailed,
10
18
  AuthRateLimited,
@@ -38,14 +46,237 @@ export function createUsernameStore(): UsernameStore {
38
46
  return { byUsername: new Map(), byUserId: new Map() };
39
47
  }
40
48
 
49
+ /** Default allowed charset after normalize (`a-z`, digits, `.`, `_`, `-`). */
50
+ export const DEFAULT_USERNAME_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789._-" as const;
51
+
52
+ /** Default minimum username length. */
53
+ export const DEFAULT_USERNAME_MIN_LENGTH = 3;
54
+
55
+ /** Default maximum username length. */
56
+ export const DEFAULT_USERNAME_MAX_LENGTH = 64;
57
+
58
+ /**
59
+ * Default reserved usernames (impersonation + common route collisions).
60
+ * Pass `reserved: []` to opt out; pass a custom list to replace.
61
+ * Use {@link UsernamePolicyOptions.extraReserved} to append.
62
+ */
63
+ export const DEFAULT_RESERVED_USERNAMES = [
64
+ "admin",
65
+ "administrator",
66
+ "root",
67
+ "system",
68
+ "support",
69
+ "api",
70
+ "auth",
71
+ "www",
72
+ "console",
73
+ "oke",
74
+ "login",
75
+ "logout",
76
+ "signup",
77
+ "register",
78
+ "me",
79
+ "null",
80
+ "undefined",
81
+ "anonymous",
82
+ ] as const;
83
+
84
+ /** Options for {@link assertUsernamePolicy}. */
85
+ export interface UsernamePolicyOptions {
86
+ /** Minimum length after normalize (default 3). */
87
+ readonly minLength?: number;
88
+ /** Maximum length after normalize (default 64). */
89
+ readonly maxLength?: number;
90
+ /**
91
+ * Allowed characters after normalize (default `a-z0-9._-`).
92
+ * Replaces the default set. Prefer {@link extraAllowedChars} to keep
93
+ * the default and add more.
94
+ */
95
+ readonly allowedChars?: string;
96
+ /**
97
+ * Characters appended to the base set (`allowedChars` or the default).
98
+ * Duplicates are ignored.
99
+ */
100
+ readonly extraAllowedChars?: string;
101
+ /** Require at least one letter `a-z` (default false). */
102
+ readonly requireLetter?: boolean;
103
+ /** Require at least one digit (default false). */
104
+ readonly requireNumber?: boolean;
105
+ /**
106
+ * Require at least one non-alphanumeric from `allowedChars`
107
+ * (default false).
108
+ */
109
+ readonly requireSymbol?: boolean;
110
+ /** Require the first character to be `a-z` (default false). */
111
+ readonly mustStartWithLetter?: boolean;
112
+ /**
113
+ * Reject leading or trailing non-alphanumeric characters
114
+ * (default true).
115
+ */
116
+ readonly forbidEdgeSymbols?: boolean;
117
+ /**
118
+ * Reject two or more consecutive non-alphanumeric characters
119
+ * (default true).
120
+ */
121
+ readonly forbidConsecutiveSymbols?: boolean;
122
+ /**
123
+ * Reserved names (compared after normalize). Defaults to
124
+ * {@link DEFAULT_RESERVED_USERNAMES}. Pass `[]` to clear the base list;
125
+ * a non-empty list replaces the default (does not merge). Combine with
126
+ * {@link extraReserved} to append.
127
+ */
128
+ readonly reserved?: readonly string[];
129
+ /**
130
+ * Extra reserved names appended after {@link reserved} (or the default).
131
+ */
132
+ readonly extraReserved?: readonly string[];
133
+ }
134
+
135
+ /** Resolved username policy with defaults applied. */
136
+ export interface ResolvedUsernamePolicy {
137
+ readonly minLength: number;
138
+ readonly maxLength: number;
139
+ readonly allowedChars: string;
140
+ readonly requireLetter: boolean;
141
+ readonly requireNumber: boolean;
142
+ readonly requireSymbol: boolean;
143
+ readonly mustStartWithLetter: boolean;
144
+ readonly forbidEdgeSymbols: boolean;
145
+ readonly forbidConsecutiveSymbols: boolean;
146
+ readonly reserved: ReadonlySet<string>;
147
+ }
148
+
149
+ /**
150
+ * Resolve username policy options with defaults.
151
+ *
152
+ * @param options - Partial policy
153
+ */
154
+ export function resolveUsernamePolicy(options: UsernamePolicyOptions = {}): ResolvedUsernamePolicy {
155
+ const minLength = options.minLength ?? DEFAULT_USERNAME_MIN_LENGTH;
156
+ const maxLength = options.maxLength ?? DEFAULT_USERNAME_MAX_LENGTH;
157
+ if (minLength < 1) {
158
+ throw new RangeError("username policy: minLength must be >= 1");
159
+ }
160
+ if (maxLength < minLength) {
161
+ throw new RangeError("username policy: maxLength must be >= minLength");
162
+ }
163
+ const baseChars = options.allowedChars ?? DEFAULT_USERNAME_ALLOWED_CHARS;
164
+ const allowedChars = mergeAllowedChars(baseChars, options.extraAllowedChars);
165
+ if (allowedChars.length === 0) {
166
+ throw new RangeError("username policy: allowedChars must be non-empty");
167
+ }
168
+ const baseReserved =
169
+ options.reserved !== undefined ? options.reserved : DEFAULT_RESERVED_USERNAMES;
170
+ const reservedNames = [...baseReserved, ...(options.extraReserved ?? [])];
171
+ return {
172
+ minLength,
173
+ maxLength,
174
+ allowedChars,
175
+ requireLetter: options.requireLetter ?? false,
176
+ requireNumber: options.requireNumber ?? false,
177
+ requireSymbol: options.requireSymbol ?? false,
178
+ mustStartWithLetter: options.mustStartWithLetter ?? false,
179
+ forbidEdgeSymbols: options.forbidEdgeSymbols ?? true,
180
+ forbidConsecutiveSymbols: options.forbidConsecutiveSymbols ?? true,
181
+ reserved: new Set(reservedNames.map((r) => normalizeUsername(r))),
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Validate a normalized username against policy.
187
+ * Throws {@link UsernamePolicyError}.
188
+ *
189
+ * @param username - Already normalized (`trim` + lowercase)
190
+ * @param options - Policy (defaults applied)
191
+ */
192
+ export function assertUsernamePolicy(username: string, options: UsernamePolicyOptions = {}): void {
193
+ const policy = resolveUsernamePolicy(options);
194
+ const reasons: string[] = [];
195
+
196
+ if (username.length < policy.minLength) {
197
+ reasons.push(`minLength ${policy.minLength} (got ${username.length})`);
198
+ }
199
+ if (username.length > policy.maxLength) {
200
+ reasons.push(`maxLength ${policy.maxLength} (got ${username.length})`);
201
+ }
202
+
203
+ const allowed = new Set(policy.allowedChars);
204
+ for (const ch of username) {
205
+ if (!allowed.has(ch)) {
206
+ reasons.push("allowedChars");
207
+ break;
208
+ }
209
+ }
210
+
211
+ if (policy.requireLetter && !/[a-z]/.test(username)) {
212
+ reasons.push("requireLetter");
213
+ }
214
+ if (policy.requireNumber && !/\d/.test(username)) {
215
+ reasons.push("requireNumber");
216
+ }
217
+ if (policy.requireSymbol && !/[^a-z0-9]/.test(username)) {
218
+ reasons.push("requireSymbol");
219
+ }
220
+ if (policy.mustStartWithLetter && !/^[a-z]/.test(username)) {
221
+ reasons.push("mustStartWithLetter");
222
+ }
223
+ if (policy.forbidEdgeSymbols && username.length > 0) {
224
+ if (/^[^a-z0-9]/.test(username) || /[^a-z0-9]$/.test(username)) {
225
+ reasons.push("forbidEdgeSymbols");
226
+ }
227
+ }
228
+ if (policy.forbidConsecutiveSymbols && /[^a-z0-9]{2,}/.test(username)) {
229
+ reasons.push("forbidConsecutiveSymbols");
230
+ }
231
+ if (policy.reserved.has(username)) {
232
+ reasons.push("reserved");
233
+ }
234
+
235
+ if (reasons.length > 0) {
236
+ throw new UsernamePolicyError(reasons);
237
+ }
238
+ }
239
+
240
+ /** Username failed policy checks. */
241
+ export class UsernamePolicyError extends Error {
242
+ readonly reasons: readonly string[];
243
+
244
+ constructor(reasons: readonly string[]) {
245
+ super(`username policy failed: ${reasons.join(", ")}`);
246
+ this.name = "UsernamePolicyError";
247
+ this.reasons = reasons;
248
+ }
249
+ }
250
+
41
251
  /** Options for {@link username}. */
42
252
  export interface UsernamePluginOptions extends AuthMethodOptions {
43
253
  /** Shared username credential store. */
44
254
  readonly usernames?: UsernameStore;
255
+ /**
256
+ * Username length / charset / character-class / shape policy
257
+ * (defaults: 3–64 chars from `a-z0-9._-`, edge + consecutive symbols forbidden).
258
+ */
259
+ readonly usernamePolicy?: UsernamePolicyOptions;
260
+ /**
261
+ * Password length / character-class policy on sign-up.
262
+ * Defaults to `gate.auth.passwordPolicy` when plugged after `oke()`,
263
+ * else the same secure defaults (minLength 12, letter + number).
264
+ */
265
+ readonly passwordPolicy?: PasswordPolicyOptions;
266
+ /**
267
+ * Bun.password cost knobs. Defaults to `gate.auth.password`, else
268
+ * `{ algorithm: "argon2id" }`.
269
+ */
270
+ readonly password?: PasswordHashOptions;
271
+ /**
272
+ * Optional breach check (`true` = reject). Defaults to
273
+ * `gate.auth.breachCheck` when plugged after `oke()`.
274
+ */
275
+ readonly breachCheck?: BreachCheckFn;
45
276
  }
46
277
 
47
278
  const UsernameIn = z.object({
48
- username: z.string().min(3).max(64),
279
+ username: z.string().min(1).max(256),
49
280
  password: z.string().min(1),
50
281
  });
51
282
 
@@ -53,14 +284,39 @@ function normalizeUsername(raw: string): string {
53
284
  return raw.trim().toLowerCase();
54
285
  }
55
286
 
287
+ /**
288
+ * Merge base charset with extras, preserving first-seen order.
289
+ *
290
+ * @param base - Base allowed characters
291
+ * @param extra - Optional characters to append
292
+ */
293
+ function mergeAllowedChars(base: string, extra?: string): string {
294
+ if (!extra || extra.length === 0) return base;
295
+ const seen = new Set<string>();
296
+ let out = "";
297
+ for (const ch of base + extra) {
298
+ if (seen.has(ch)) continue;
299
+ seen.add(ch);
300
+ out += ch;
301
+ }
302
+ return out;
303
+ }
304
+
56
305
  /**
57
306
  * Username + password sign-up / sign-in (`oke_usernames`).
58
307
  *
59
- * @param opts - Secret / session / store overrides
308
+ * @param opts - Secret / session / store / policy overrides
60
309
  */
61
310
  export function username(opts: UsernamePluginOptions = {}): PluginDef {
62
311
  const runtime = createMethodRuntime(opts);
312
+ const active = getActiveGateAuthContext();
63
313
  const usernames = opts.usernames ?? createUsernameStore();
314
+ const usernamePolicy = opts.usernamePolicy ?? {};
315
+ const passwordPolicy = opts.passwordPolicy ?? active?.passwordPolicy ?? {};
316
+ const passwordHash = opts.password ?? active?.password ?? { algorithm: "argon2id" };
317
+ const breachCheck = opts.breachCheck ?? active?.breachCheck;
318
+ // Resolve once at plug-time so bad config fails early.
319
+ resolveUsernamePolicy(usernamePolicy);
64
320
  const crypto = createBunCrypto();
65
321
 
66
322
  const signUp = flow({
@@ -72,20 +328,45 @@ export function username(opts: UsernamePluginOptions = {}): PluginDef {
72
328
  errors: { AuthFailed, AuthRateLimited },
73
329
  do: async (input) => {
74
330
  const key = normalizeUsername(input.username);
75
- if (!/^[a-z0-9._-]{3,64}$/.test(key)) {
76
- return fail("AuthFailed", { reason: "invalid_credentials" });
331
+ try {
332
+ assertUsernamePolicy(key, usernamePolicy);
333
+ } catch (err) {
334
+ if (err instanceof UsernamePolicyError) {
335
+ return fail("AuthFailed", {
336
+ reason: "username_policy",
337
+ reasons: [...err.reasons],
338
+ });
339
+ }
340
+ throw err;
341
+ }
342
+ try {
343
+ assertPasswordPolicy(input.password, passwordPolicy);
344
+ } catch (err) {
345
+ if (err instanceof PasswordPolicyError) {
346
+ return fail("AuthFailed", {
347
+ reason: "password_policy",
348
+ reasons: [...err.reasons],
349
+ });
350
+ }
351
+ throw err;
352
+ }
353
+ try {
354
+ await assertNotBreached(input.password, breachCheck);
355
+ } catch (err) {
356
+ if (err instanceof BreachCheckError) {
357
+ return fail("AuthFailed", { reason: "password_breached" });
358
+ }
359
+ throw err;
77
360
  }
78
361
  if (usernames.byUsername.has(key)) {
79
362
  return fail("AuthFailed", { reason: "invalid_credentials" });
80
363
  }
81
364
  const userId = crypto.randomUUID();
82
- const passwordHash = await crypto.hashPassword(input.password, {
83
- algorithm: "argon2id",
84
- });
365
+ const passwordHashValue = await crypto.hashPassword(input.password, passwordHash);
85
366
  const row: UsernameRow = {
86
367
  userId,
87
368
  username: key,
88
- passwordHash,
369
+ passwordHash: passwordHashValue,
89
370
  createdAt: runtime.now(),
90
371
  };
91
372
  usernames.byUsername.set(key, row);
@@ -24,7 +24,14 @@ const BUDGETS_JSON_PATH = resolve(ROOT, "budgets.json");
24
24
  * Deps measured separately from OKE code in export samples.
25
25
  * Edge/client absolute gates keep the historical smaller external list.
26
26
  */
27
- const BUILD_EXTERNALS = ["@duckdb/node-api", "@duckdb/*"] as const;
27
+ const BUILD_EXTERNALS = [
28
+ "@duckdb/node-api",
29
+ "@duckdb/*",
30
+ // ICU MessageFormat — adopted FormatJS runtime; kept out of the edge
31
+ // micro-bundle the same way other heavy deps are measured separately.
32
+ "intl-messageformat",
33
+ "@formatjs/*",
34
+ ] as const;
28
35
 
29
36
  /** Heavy optional / peer packages — not counted in export:* gzip samples. */
30
37
  const EXPORT_BUILD_EXTERNALS = [