okengine 0.5.1 → 0.6.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.
- package/README.md +148 -13
- package/package.json +4 -3
- package/site/content/docs/elements/ai.mdx +82 -1
- package/site/content/docs/elements/channel.mdx +77 -8
- package/site/content/docs/elements/flow.mdx +20 -17
- package/site/content/docs/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- package/site/content/docs/reference/configuration.mdx +12 -4
- package/site/content/docs/reference/environment-variables.mdx +42 -13
- package/site/content/docs/reference/errors.mdx +14 -0
- package/site/content/docs/reference/fx.mdx +68 -16
- package/site/content/docs/reference/i18n.mdx +313 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/plugins.mdx +1 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/method-context.ts +12 -2
- package/src/cli/openbao-restart.integration.test.ts +106 -97
- package/src/compiler/aot.test.ts +16 -13
- package/src/compiler/effects-infer.ts +46 -0
- package/src/console/server/ai.test.ts +34 -5
- package/src/docker/compose.ts +9 -0
- package/src/docker/docker.test.ts +39 -0
- package/src/docker/dockerfile.integration.test.ts +126 -119
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -1
- package/src/docker/recipes/ollama.ts +43 -0
- package/src/docker/stack-id.ts +2 -0
- package/src/docker/stack.integration.test.ts +118 -102
- package/src/drivers/ai-mock.ts +60 -0
- package/src/drivers/ai-ollama-tools.integration.test.ts +109 -0
- package/src/drivers/ai-ollama.integration.test.ts +181 -0
- package/src/drivers/ai-ollama.ts +327 -0
- package/src/drivers/ai-openai-compatible.ts +211 -21
- package/src/drivers/ai-providers.test.ts +179 -2
- package/src/drivers/ai-stream.test.ts +195 -0
- package/src/drivers/ai-types.ts +42 -1
- package/src/drivers/channel-fcm.ts +49 -53
- package/src/drivers/channel-msegat.ts +61 -0
- package/src/drivers/channel-sently-map.ts +57 -0
- package/src/drivers/channel-sently.test.ts +99 -0
- package/src/drivers/channel-smtp.ts +8 -2
- package/src/drivers/channel-sndr.ts +28 -0
- package/src/drivers/channel-taqnyat.ts +57 -0
- package/src/drivers/channel-types.ts +79 -2
- package/src/drivers/channel-unifonic.ts +26 -43
- package/src/drivers/channel-wa-cloud.ts +33 -47
- package/src/drivers/channel-webpush.ts +39 -239
- package/src/drivers/index.ts +25 -1
- package/src/drivers/ollama.ts +14 -0
- package/src/elements/ai/rate.test.ts +53 -0
- package/src/elements/ai/rate.ts +66 -0
- package/src/elements/ai/redacted-prompt.test.ts +90 -0
- package/src/elements/ai/runtime.ts +330 -100
- package/src/elements/ai/tools.test.ts +99 -0
- package/src/elements/ai.test.ts +26 -2
- package/src/elements/ai.ts +10 -1
- package/src/elements/channel/costs.test.ts +2 -2
- package/src/elements/channel/costs.ts +14 -2
- package/src/elements/channel/mime.ts +11 -0
- package/src/elements/channel/runtime.ts +94 -0
- package/src/elements/channel/sndr-webhooks.test.ts +26 -0
- package/src/elements/channel.ts +10 -1
- package/src/elements/index.ts +9 -0
- package/src/i18n/catalogs/ar.ts +67 -0
- package/src/i18n/catalogs/en.ts +68 -0
- package/src/i18n/failure-message.test.ts +56 -0
- package/src/i18n/failure-message.ts +93 -0
- package/src/i18n/format.ts +67 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/locale-context.ts +48 -0
- package/src/i18n/messages.test.ts +173 -0
- package/src/i18n/messages.ts +169 -0
- package/src/i18n/types.ts +90 -0
- package/src/index.ts +26 -0
- package/src/kernel/app.ts +92 -2
- package/src/kernel/boot-bind/ai.test.ts +60 -0
- package/src/kernel/boot-bind/ai.ts +125 -2
- package/src/kernel/boot-bind/channel.test.ts +68 -3
- package/src/kernel/boot-bind/channel.ts +93 -2
- package/src/kernel/boot.test.ts +4 -3
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/errors.ts +56 -5
- package/src/kernel/fx.test.ts +27 -0
- package/src/kernel/fx.ts +74 -18
- package/src/kernel/pipeline.test.ts +4 -0
- package/src/kernel/pipeline.ts +1 -1
- package/src/kernel/plugin.ts +16 -0
- package/src/kernel/registry.ts +15 -0
- package/src/plugins/auth/shared.ts +5 -1
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +336 -0
- package/src/plugins/auth-methods.security.test.ts +12 -10
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/index.ts +16 -2
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- package/src/release/exports.test.ts +26 -0
- package/src/release/exports.ts +64 -5
- package/src/release/index.ts +5 -0
- package/src/release/measure.exports.test.ts +13 -1
- package/src/release/measure.ts +84 -14
- package/src/release/official-plugins.ts +46 -0
- package/src/release/readme.test.ts +30 -2
|
@@ -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
|
+
});
|
package/src/plugins/username.ts
CHANGED
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
* Username + password Gate auth method plugin.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
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(
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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);
|