alepha 0.20.4 → 0.20.6
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/dist/api/audits/index.d.ts +391 -359
- package/dist/api/audits/index.d.ts.map +1 -1
- package/dist/api/audits/index.js +23 -1
- package/dist/api/audits/index.js.map +1 -1
- package/dist/api/files/index.d.ts +18 -0
- package/dist/api/files/index.d.ts.map +1 -1
- package/dist/api/files/index.js +51 -0
- package/dist/api/files/index.js.map +1 -1
- package/dist/api/jobs/index.browser.js +33 -14
- package/dist/api/jobs/index.browser.js.map +1 -1
- package/dist/api/jobs/index.d.ts +452 -155
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +474 -159
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/keys/index.d.ts +32 -4
- package/dist/api/keys/index.d.ts.map +1 -1
- package/dist/api/keys/index.js +53 -0
- package/dist/api/keys/index.js.map +1 -1
- package/dist/api/notifications/index.d.ts +29 -1
- package/dist/api/notifications/index.d.ts.map +1 -1
- package/dist/api/notifications/index.js +55 -13
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/organizations/index.js.map +1 -1
- package/dist/api/parameters/index.d.ts +15 -0
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +37 -0
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/api/payments/index.js.map +1 -1
- package/dist/api/users/index.d.ts +150 -9
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +237 -28
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +3 -3
- package/dist/api/verifications/index.js.map +1 -1
- package/dist/batch/index.js.map +1 -1
- package/dist/bin/index.js +0 -0
- package/dist/bucket/index.d.ts +18 -0
- package/dist/bucket/index.d.ts.map +1 -1
- package/dist/bucket/index.js +47 -0
- package/dist/bucket/index.js.map +1 -1
- package/dist/bucket/index.workerd.js +24 -0
- package/dist/bucket/index.workerd.js.map +1 -1
- package/dist/cache/core/index.d.ts +20 -3
- package/dist/cache/core/index.d.ts.map +1 -1
- package/dist/cache/core/index.js.map +1 -1
- package/dist/cache/core/index.workerd.js.map +1 -1
- package/dist/cache/database/index.d.ts +155 -0
- package/dist/cache/database/index.d.ts.map +1 -0
- package/dist/cache/database/index.js +266 -0
- package/dist/cache/database/index.js.map +1 -0
- package/dist/cache/redis/index.js.map +1 -1
- package/dist/captcha/index.js.map +1 -1
- package/dist/cli/config/index.js.map +1 -1
- package/dist/cli/core/index.d.ts +35 -5
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +85 -6
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/devtools/index.js.map +1 -1
- package/dist/cli/platform/index.js +1 -1
- package/dist/cli/platform/index.js.map +1 -1
- package/dist/cli/vendor/index.js.map +1 -1
- package/dist/command/index.js.map +1 -1
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js.map +1 -1
- package/dist/crypto/index.browser.js.map +1 -1
- package/dist/crypto/index.js.map +1 -1
- package/dist/datetime/index.js.map +1 -1
- package/dist/email/brevo/index.js.map +1 -1
- package/dist/email/core/index.js.map +1 -1
- package/dist/email/core/index.workerd.js.map +1 -1
- package/dist/email/smtp/index.js.map +1 -1
- package/dist/fake/index.js.map +1 -1
- package/dist/lock/core/index.js.map +1 -1
- package/dist/lock/redis/index.js.map +1 -1
- package/dist/logger/index.js.map +1 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/orm/core/index.browser.js.map +1 -1
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.js.map +1 -1
- package/dist/orm/postgres/index.bun.js.map +1 -1
- package/dist/orm/postgres/index.js.map +1 -1
- package/dist/queue/core/index.js.map +1 -1
- package/dist/queue/core/index.workerd.js.map +1 -1
- package/dist/queue/redis/index.js.map +1 -1
- package/dist/react/auth/index.browser.js.map +1 -1
- package/dist/react/auth/index.js.map +1 -1
- package/dist/react/core/index.js.map +1 -1
- package/dist/react/form/index.js +2 -0
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/head/index.browser.js.map +1 -1
- package/dist/react/head/index.js.map +1 -1
- package/dist/react/i18n/index.js.map +1 -1
- package/dist/react/intro/index.js.map +1 -1
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.js.map +1 -1
- package/dist/react/testing/index.js.map +1 -1
- package/dist/react/ui/index.js.map +1 -1
- package/dist/react/websocket/index.js.map +1 -1
- package/dist/redis/index.bun.js.map +1 -1
- package/dist/redis/index.js.map +1 -1
- package/dist/retry/index.js.map +1 -1
- package/dist/router/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +22 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +12 -0
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/index.workerd.js +12 -0
- package/dist/scheduler/index.workerd.js.map +1 -1
- package/dist/security/index.browser.js.map +1 -1
- package/dist/security/index.js.map +1 -1
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/cookies/index.browser.js.map +1 -1
- package/dist/server/cookies/index.js.map +1 -1
- package/dist/server/core/index.browser.js.map +1 -1
- package/dist/server/core/index.js.map +1 -1
- package/dist/server/cors/index.js.map +1 -1
- package/dist/server/etag/index.js.map +1 -1
- package/dist/server/health/index.js.map +1 -1
- package/dist/server/links/index.browser.js.map +1 -1
- package/dist/server/links/index.js.map +1 -1
- package/dist/server/metrics/index.js.map +1 -1
- package/dist/server/proxy/index.js.map +1 -1
- package/dist/server/rate-limit/index.js.map +1 -1
- package/dist/server/static/index.js.map +1 -1
- package/dist/server/swagger/index.js.map +1 -1
- package/dist/sms/index.js.map +1 -1
- package/dist/system/index.browser.js.map +1 -1
- package/dist/system/index.js.map +1 -1
- package/dist/system/index.workerd.js.map +1 -1
- package/dist/topic/core/index.js.map +1 -1
- package/dist/topic/redis/index.js.map +1 -1
- package/dist/websocket/index.browser.js +4 -0
- package/dist/websocket/index.browser.js.map +1 -1
- package/dist/websocket/index.js +10 -0
- package/dist/websocket/index.js.map +1 -1
- package/package.json +282 -272
- package/src/api/audits/controllers/AdminAuditController.ts +29 -0
- package/src/api/files/controllers/FileController.ts +24 -0
- package/src/api/files/services/FileService.ts +41 -0
- package/src/api/jobs/__tests__/$job.spec.ts +427 -2
- package/src/api/jobs/entities/jobExecutionEntity.ts +3 -3
- package/src/api/jobs/index.ts +47 -10
- package/src/api/jobs/primitives/$job.ts +22 -9
- package/src/api/jobs/providers/DirectJobDispatcher.ts +71 -0
- package/src/api/jobs/providers/JobDispatcher.ts +49 -0
- package/src/api/jobs/providers/JobProvider.ts +365 -142
- package/src/api/jobs/providers/JobQueueProvider.ts +43 -18
- package/src/api/jobs/schemas/jobConfigAtom.ts +4 -3
- package/src/api/jobs/schemas/jobExecutionResourceSchema.ts +11 -0
- package/src/api/jobs/schemas/jobRegistrationSchema.ts +4 -2
- package/src/api/jobs/services/JobService.ts +21 -11
- package/src/api/keys/controllers/AdminApiKeyController.ts +23 -0
- package/src/api/keys/services/ApiKeyService.ts +42 -0
- package/src/api/notifications/__tests__/AlephaApiNotifications.spec.ts +63 -0
- package/src/api/notifications/controllers/AdminNotificationController.ts +48 -1
- package/src/api/notifications/index.ts +13 -3
- package/src/api/notifications/jobs/NotificationJobs.ts +0 -6
- package/src/api/parameters/controllers/AdminParameterController.ts +26 -0
- package/src/api/parameters/services/ParameterProvider.ts +18 -0
- package/src/api/users/__tests__/Registration-emailMode.spec.ts +203 -0
- package/src/api/users/__tests__/UsernameSlugger.spec.ts +138 -0
- package/src/api/users/atoms/realmAuthSettingsAtom.ts +41 -3
- package/src/api/users/controllers/AdminSessionController.ts +29 -0
- package/src/api/users/controllers/AdminUserController.ts +32 -0
- package/src/api/users/index.ts +3 -0
- package/src/api/users/services/CredentialService.ts +5 -0
- package/src/api/users/services/RegistrationService.ts +49 -1
- package/src/api/users/services/SessionCrudService.ts +16 -0
- package/src/api/users/services/SessionService.ts +17 -59
- package/src/api/users/services/UsernameSlugger.ts +195 -0
- package/src/bucket/primitives/$bucket.ts +21 -0
- package/src/bucket/providers/CloudflareR2Provider.ts +15 -0
- package/src/bucket/providers/FileStorageProvider.ts +9 -0
- package/src/bucket/providers/LocalFileStorageProvider.ts +14 -0
- package/src/bucket/providers/MemoryFileStorageProvider.ts +9 -0
- package/src/bucket/providers/NodeS3BucketProvider.ts +35 -0
- package/src/cache/core/primitives/$cache.ts +20 -3
- package/src/cache/database/__tests__/DatabaseCacheProvider.behavior.spec.ts +203 -0
- package/src/cache/database/__tests__/DatabaseCacheProvider.spec.ts +110 -0
- package/src/cache/database/entities/cacheEntries.ts +55 -0
- package/src/cache/database/index.ts +36 -0
- package/src/cache/database/providers/DatabaseCacheProvider.ts +348 -0
- package/src/cli/core/services/ProjectScaffolder.ts +0 -2
- package/src/cli/core/tasks/BuildCloudflareTask.ts +17 -3
- package/src/cli/core/tasks/BuildSitemapTask.ts +7 -0
- package/src/cli/core/tasks/BuildVercelTask.ts +82 -3
- package/src/cli/platform/__tests__/detectResources.spec.ts +96 -0
- package/src/cli/platform/commands/platform.ts +7 -1
- package/src/scheduler/index.ts +14 -0
- package/src/scheduler/providers/CronProvider.ts +13 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Alepha } from "alepha";
|
|
2
|
+
import { AlephaApiVerification } from "alepha/api/verifications";
|
|
3
|
+
import { AlephaEmail, MemoryEmailProvider } from "alepha/email";
|
|
4
|
+
import { AlephaOrmPostgres } from "alepha/orm/postgres";
|
|
5
|
+
import { AlephaSecurity } from "alepha/security";
|
|
6
|
+
import { BadRequestError } from "alepha/server";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
AlephaApiUsers,
|
|
10
|
+
RealmProvider,
|
|
11
|
+
RegistrationService,
|
|
12
|
+
UserNotifications,
|
|
13
|
+
UserService,
|
|
14
|
+
} from "../index.ts";
|
|
15
|
+
|
|
16
|
+
const setup = async (
|
|
17
|
+
realmName: string,
|
|
18
|
+
realmSettings?: Record<string, unknown>,
|
|
19
|
+
) => {
|
|
20
|
+
const alepha = Alepha.create();
|
|
21
|
+
alepha.with(AlephaOrmPostgres);
|
|
22
|
+
alepha.with(AlephaSecurity);
|
|
23
|
+
alepha.with(AlephaEmail);
|
|
24
|
+
alepha.with(AlephaApiVerification);
|
|
25
|
+
alepha.with(AlephaApiUsers);
|
|
26
|
+
alepha.with(UserNotifications);
|
|
27
|
+
await alepha.start();
|
|
28
|
+
|
|
29
|
+
const realmProvider = alepha.inject(RealmProvider);
|
|
30
|
+
if (realmSettings) {
|
|
31
|
+
realmProvider.register(realmName, {
|
|
32
|
+
settings: realmSettings as never,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Wipe between cases.
|
|
37
|
+
await realmProvider.userRepository(realmName).deleteMany({});
|
|
38
|
+
alepha.inject(MemoryEmailProvider).records = [];
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
alepha,
|
|
42
|
+
registrationService: alepha.inject(RegistrationService),
|
|
43
|
+
userService: alepha.inject(UserService),
|
|
44
|
+
realmProvider,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
describe("RegistrationService — username: 'email' mode", () => {
|
|
51
|
+
it("derives the username from the email and ignores any client-sent value", async () => {
|
|
52
|
+
const { registrationService, userService } = await setup("email-mode-1", {
|
|
53
|
+
username: "email",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const intent = await registrationService.createRegistrationIntent(
|
|
57
|
+
{
|
|
58
|
+
email: "ni.foures+testkv@gmail.com",
|
|
59
|
+
password: "SecurePassword123!",
|
|
60
|
+
// The client sneaks a custom username into the request — server
|
|
61
|
+
// must drop it on the floor and use the slugger.
|
|
62
|
+
username: "i-am-the-admin" as never,
|
|
63
|
+
},
|
|
64
|
+
"email-mode-1",
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const user = await registrationService.completeRegistration({
|
|
68
|
+
intentId: intent.intentId,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Slug rule: gmail "+suffix" preserved, dots → "-".
|
|
72
|
+
expect(user.username).toBe("ni-foures-testkv");
|
|
73
|
+
|
|
74
|
+
const reloaded = await userService
|
|
75
|
+
.users("email-mode-1")
|
|
76
|
+
.findOne({ where: { email: { eq: "ni.foures+testkv@gmail.com" } } });
|
|
77
|
+
expect(reloaded?.username).toBe("ni-foures-testkv");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("appends a 4-char random suffix on collision instead of failing", async () => {
|
|
81
|
+
const { registrationService, userService } = await setup("email-mode-2", {
|
|
82
|
+
username: "email",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// First user takes the slug.
|
|
86
|
+
await userService.users("email-mode-2").create({
|
|
87
|
+
realm: "email-mode-2",
|
|
88
|
+
username: "alice",
|
|
89
|
+
email: "preexisting@example.com",
|
|
90
|
+
roles: ["user"],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const intent = await registrationService.createRegistrationIntent(
|
|
94
|
+
{
|
|
95
|
+
email: "alice@example.com",
|
|
96
|
+
password: "SecurePassword123!",
|
|
97
|
+
},
|
|
98
|
+
"email-mode-2",
|
|
99
|
+
);
|
|
100
|
+
const created = await registrationService.completeRegistration({
|
|
101
|
+
intentId: intent.intentId,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(created.username).toMatch(/^alice-[a-z0-9]{4}$/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects email-mode when no email is provided", async () => {
|
|
108
|
+
const { registrationService } = await setup("email-mode-3", {
|
|
109
|
+
username: "email",
|
|
110
|
+
email: "optional",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
registrationService.createRegistrationIntent(
|
|
115
|
+
{
|
|
116
|
+
password: "SecurePassword123!",
|
|
117
|
+
},
|
|
118
|
+
"email-mode-3",
|
|
119
|
+
),
|
|
120
|
+
).rejects.toThrowError(BadRequestError);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("blocklist applies even though the client never sees the field", async () => {
|
|
124
|
+
const { registrationService } = await setup("email-mode-blocklist", {
|
|
125
|
+
username: "email",
|
|
126
|
+
usernameBlocklist: ["admin"],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const intent = await registrationService.createRegistrationIntent(
|
|
130
|
+
{
|
|
131
|
+
email: "admin@example.com",
|
|
132
|
+
password: "SecurePassword123!",
|
|
133
|
+
},
|
|
134
|
+
"email-mode-blocklist",
|
|
135
|
+
);
|
|
136
|
+
const user = await registrationService.completeRegistration({
|
|
137
|
+
intentId: intent.intentId,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Slug "admin" is blocked → falls through to the suffix path.
|
|
141
|
+
expect(user.username).not.toBe("admin");
|
|
142
|
+
expect(user.username).toMatch(/^admin-[a-z0-9]{4}$/);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
describe("RegistrationService — blocklist applies in 'required' mode too", () => {
|
|
149
|
+
it("rejects a manual username that hits the blocklist", async () => {
|
|
150
|
+
const { registrationService } = await setup("manual-mode", {
|
|
151
|
+
username: "required",
|
|
152
|
+
usernameBlocklist: ["admin"],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await expect(
|
|
156
|
+
registrationService.createRegistrationIntent(
|
|
157
|
+
{
|
|
158
|
+
username: "admin",
|
|
159
|
+
email: "someone@example.com",
|
|
160
|
+
password: "SecurePassword123!",
|
|
161
|
+
},
|
|
162
|
+
"manual-mode",
|
|
163
|
+
),
|
|
164
|
+
).rejects.toThrowError(BadRequestError);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("blocklist match is case-insensitive in manual mode", async () => {
|
|
168
|
+
const { registrationService } = await setup("manual-case", {
|
|
169
|
+
username: "required",
|
|
170
|
+
usernameBlocklist: ["Admin"],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await expect(
|
|
174
|
+
registrationService.createRegistrationIntent(
|
|
175
|
+
{
|
|
176
|
+
username: "ADMIN",
|
|
177
|
+
email: "someone@example.com",
|
|
178
|
+
password: "SecurePassword123!",
|
|
179
|
+
},
|
|
180
|
+
"manual-case",
|
|
181
|
+
),
|
|
182
|
+
).rejects.toThrowError(BadRequestError);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("default (empty) blocklist does not reject special names", async () => {
|
|
186
|
+
const { registrationService } = await setup("manual-empty", {
|
|
187
|
+
username: "required",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const intent = await registrationService.createRegistrationIntent(
|
|
191
|
+
{
|
|
192
|
+
username: "admin",
|
|
193
|
+
email: "admin-empty@example.com",
|
|
194
|
+
password: "SecurePassword123!",
|
|
195
|
+
},
|
|
196
|
+
"manual-empty",
|
|
197
|
+
);
|
|
198
|
+
const created = await registrationService.completeRegistration({
|
|
199
|
+
intentId: intent.intentId,
|
|
200
|
+
});
|
|
201
|
+
expect(created.username).toBe("admin");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Alepha } from "alepha";
|
|
2
|
+
import { AlephaOrmPostgres } from "alepha/orm/postgres";
|
|
3
|
+
import { AlephaSecurity } from "alepha/security";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { realmAuthSettingsAtom } from "../atoms/realmAuthSettingsAtom.ts";
|
|
6
|
+
import { AlephaApiUsers, RealmProvider, UsernameSlugger } from "../index.ts";
|
|
7
|
+
|
|
8
|
+
const setup = async (realmSettings?: Record<string, unknown>) => {
|
|
9
|
+
const alepha = Alepha.create();
|
|
10
|
+
alepha.with(AlephaOrmPostgres);
|
|
11
|
+
alepha.with(AlephaSecurity);
|
|
12
|
+
alepha.with(AlephaApiUsers);
|
|
13
|
+
|
|
14
|
+
await alepha.start();
|
|
15
|
+
|
|
16
|
+
if (realmSettings) {
|
|
17
|
+
alepha.inject(RealmProvider).register("default", {
|
|
18
|
+
settings: realmSettings as never,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Wipe users table between cases so collision tests don't interfere.
|
|
23
|
+
const users = alepha.inject(RealmProvider).userRepository();
|
|
24
|
+
await users.deleteMany({});
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
alepha,
|
|
28
|
+
slugger: alepha.inject(UsernameSlugger),
|
|
29
|
+
users,
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
describe("UsernameSlugger.slug — pure rule", () => {
|
|
36
|
+
it("strips the domain and the gmail '+suffix' is kept verbatim", async () => {
|
|
37
|
+
const { slugger } = await setup();
|
|
38
|
+
expect(slugger.slug("ni.foures+testkv@gmail.com")).toBe("ni-foures-testkv");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("collapses runs of non-alphanumeric chars and trims edges", async () => {
|
|
42
|
+
const { slugger } = await setup();
|
|
43
|
+
expect(slugger.slug("john...doe@example.com")).toBe("john-doe");
|
|
44
|
+
expect(slugger.slug("---weird---name---@x.io")).toBe("weird-name");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("lowercases the result", async () => {
|
|
48
|
+
const { slugger } = await setup();
|
|
49
|
+
expect(slugger.slug("Alice.SMITH@Example.com")).toBe("alice-smith");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("drops every non-[a-z0-9] char (no NFD normalize)", async () => {
|
|
53
|
+
// `é` is dropped, so `josé` becomes `jos` — and then padded to MIN_LENGTH.
|
|
54
|
+
const { slugger } = await setup();
|
|
55
|
+
const result = slugger.slug("josé@example.com");
|
|
56
|
+
expect(result.startsWith("jos")).toBe(true);
|
|
57
|
+
expect(result.length).toBeGreaterThanOrEqual(UsernameSlugger.MIN_LENGTH);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("pads short slugs with random alphanumerics", async () => {
|
|
61
|
+
const { slugger } = await setup();
|
|
62
|
+
const result = slugger.slug("a@example.com");
|
|
63
|
+
expect(result.startsWith("a")).toBe(true);
|
|
64
|
+
expect(result.length).toBeGreaterThanOrEqual(UsernameSlugger.MIN_LENGTH);
|
|
65
|
+
expect(result).toMatch(/^[a-z0-9-]+$/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to the EMPTY_LOCAL_FALLBACK when the local part has no [a-z0-9]", async () => {
|
|
69
|
+
const { slugger } = await setup();
|
|
70
|
+
expect(slugger.slug("é@example.com")).toBe(
|
|
71
|
+
UsernameSlugger.EMPTY_LOCAL_FALLBACK,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("clamps to MAX_LENGTH", async () => {
|
|
76
|
+
const { slugger } = await setup();
|
|
77
|
+
const long = `${"a".repeat(60)}@example.com`;
|
|
78
|
+
expect(slugger.slug(long).length).toBe(UsernameSlugger.MAX_LENGTH);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("treats null/empty input by returning the fallback", async () => {
|
|
82
|
+
const { slugger } = await setup();
|
|
83
|
+
expect(slugger.slug(null)).toBe(UsernameSlugger.EMPTY_LOCAL_FALLBACK);
|
|
84
|
+
expect(slugger.slug("")).toBe(UsernameSlugger.EMPTY_LOCAL_FALLBACK);
|
|
85
|
+
expect(slugger.slug(undefined)).toBe(UsernameSlugger.EMPTY_LOCAL_FALLBACK);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
describe("UsernameSlugger.pickAvailable — DB-backed availability + retry", () => {
|
|
92
|
+
it("returns the base when nothing collides", async () => {
|
|
93
|
+
const { slugger } = await setup();
|
|
94
|
+
const picked = await slugger.pickAvailable("default", "alice");
|
|
95
|
+
expect(picked).toBe("alice");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("appends a 4-char random suffix when the base is taken", async () => {
|
|
99
|
+
const { slugger, users } = await setup();
|
|
100
|
+
await users.create({ realm: "default", username: "alice", email: "a@x" });
|
|
101
|
+
|
|
102
|
+
const picked = await slugger.pickAvailable("default", "alice");
|
|
103
|
+
expect(picked).toMatch(/^alice-[a-z0-9]{4}$/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("treats blocklisted candidates as collisions and falls through to the suffix path", async () => {
|
|
107
|
+
const { slugger } = await setup({ usernameBlocklist: ["admin", "root"] });
|
|
108
|
+
|
|
109
|
+
const picked = await slugger.pickAvailable("default", "admin");
|
|
110
|
+
expect(picked).toMatch(/^admin-[a-z0-9]{4}$/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("trims the base before adding the suffix when MAX_LENGTH would be exceeded", async () => {
|
|
114
|
+
const { slugger, users } = await setup();
|
|
115
|
+
const long = "a".repeat(UsernameSlugger.MAX_LENGTH);
|
|
116
|
+
await users.create({ realm: "default", username: long, email: "a@x" });
|
|
117
|
+
|
|
118
|
+
const picked = await slugger.pickAvailable("default", long);
|
|
119
|
+
expect(picked.length).toBeLessThanOrEqual(UsernameSlugger.MAX_LENGTH);
|
|
120
|
+
expect(picked).toMatch(/-[a-z0-9]{4}$/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("blocklist match is case-insensitive", async () => {
|
|
124
|
+
const { slugger } = await setup({ usernameBlocklist: ["Admin"] });
|
|
125
|
+
expect(await slugger.isBlocked("default", "admin")).toBe(true);
|
|
126
|
+
expect(await slugger.isBlocked("default", "ADMIN")).toBe(true);
|
|
127
|
+
expect(await slugger.isBlocked("default", "user")).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("default blocklist is empty — no name is implicitly reserved", async () => {
|
|
131
|
+
const { alepha, slugger } = await setup();
|
|
132
|
+
const settings = alepha.get(realmAuthSettingsAtom);
|
|
133
|
+
expect(settings.usernameBlocklist).toEqual([]);
|
|
134
|
+
|
|
135
|
+
const picked = await slugger.pickAvailable("default", "admin");
|
|
136
|
+
expect(picked).toBe("admin");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -14,6 +14,31 @@ const fieldRequirement = (description: string) =>
|
|
|
14
14
|
description,
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Username-specific field requirement, extending {@link FieldRequirement}
|
|
19
|
+
* with an additional auto-derivation mode.
|
|
20
|
+
*
|
|
21
|
+
* - `"none"` / `"optional"` / `"required"`: same as {@link FieldRequirement}.
|
|
22
|
+
* - `"email"`: Field is hidden in the registration UI and the value is
|
|
23
|
+
* auto-derived from the user's email at signup. Same handling on
|
|
24
|
+
* credentials and OAuth flows. See `UsernameSlugger` for the rule and
|
|
25
|
+
* collision behavior.
|
|
26
|
+
*/
|
|
27
|
+
export type UsernameFieldRequirement = FieldRequirement | "email";
|
|
28
|
+
|
|
29
|
+
const usernameFieldRequirement = (description: string) =>
|
|
30
|
+
t.union(
|
|
31
|
+
[
|
|
32
|
+
t.const("none"),
|
|
33
|
+
t.const("optional"),
|
|
34
|
+
t.const("required"),
|
|
35
|
+
t.const("email"),
|
|
36
|
+
],
|
|
37
|
+
{
|
|
38
|
+
description,
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
17
42
|
export const realmAuthSettingsAtom = $atom({
|
|
18
43
|
name: "alepha.api.users.realmAuthSettings",
|
|
19
44
|
schema: t.object({
|
|
@@ -42,11 +67,20 @@ export const realmAuthSettingsAtom = $atom({
|
|
|
42
67
|
email: fieldRequirement(
|
|
43
68
|
"Email address field requirement for user accounts",
|
|
44
69
|
),
|
|
45
|
-
username:
|
|
70
|
+
username: usernameFieldRequirement(
|
|
71
|
+
"Username field requirement for user accounts",
|
|
72
|
+
),
|
|
46
73
|
usernameRegExp: t.string({
|
|
47
74
|
description:
|
|
48
75
|
"Regular expression that usernames must match (if username is enabled)",
|
|
49
76
|
}),
|
|
77
|
+
usernameBlocklist: t.array(t.text(), {
|
|
78
|
+
description:
|
|
79
|
+
"Usernames that the slugger / manual registration must reject. " +
|
|
80
|
+
"Default empty so apps can register `admin`/`root`/`me`/etc. if " +
|
|
81
|
+
"they want; populate it explicitly for handles you want to keep " +
|
|
82
|
+
"off-limits.",
|
|
83
|
+
}),
|
|
50
84
|
phoneNumber: fieldRequirement(
|
|
51
85
|
"Phone number field requirement for user accounts",
|
|
52
86
|
),
|
|
@@ -138,8 +172,12 @@ export const realmAuthSettingsAtom = $atom({
|
|
|
138
172
|
// for a fresh hello world setup, we accept registration and email login
|
|
139
173
|
registrationAllowed: true,
|
|
140
174
|
email: "required" as FieldRequirement,
|
|
141
|
-
username: "none" as
|
|
142
|
-
|
|
175
|
+
username: "none" as UsernameFieldRequirement,
|
|
176
|
+
// Allow hyphens by default so the UsernameSlugger output (`ni-foures-testkv`)
|
|
177
|
+
// matches without app overrides. Existing `[a-zA-Z0-9_]` usernames remain
|
|
178
|
+
// valid because the new pattern is a strict superset.
|
|
179
|
+
usernameRegExp: "^[a-zA-Z0-9_-]{3,30}$",
|
|
180
|
+
usernameBlocklist: [] as string[],
|
|
143
181
|
phoneNumber: "none" as FieldRequirement,
|
|
144
182
|
verifyEmailRequired: false,
|
|
145
183
|
verifyPhoneRequired: false,
|
|
@@ -74,4 +74,33 @@ export class AdminSessionController {
|
|
|
74
74
|
return { ok: true, id: params.id };
|
|
75
75
|
},
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Delete many sessions in one repository call.
|
|
80
|
+
*/
|
|
81
|
+
public readonly deleteSessions = $action({
|
|
82
|
+
method: "POST",
|
|
83
|
+
path: `${this.url}/delete`,
|
|
84
|
+
group: this.group,
|
|
85
|
+
use: [$secure({ permissions: ["admin:session:delete"] })],
|
|
86
|
+
description: "Delete many sessions",
|
|
87
|
+
schema: {
|
|
88
|
+
query: t.object({
|
|
89
|
+
userRealmName: t.optional(t.string()),
|
|
90
|
+
}),
|
|
91
|
+
body: t.object({
|
|
92
|
+
ids: t.array(t.uuid(), { minItems: 1, maxItems: 1000 }),
|
|
93
|
+
}),
|
|
94
|
+
response: t.object({
|
|
95
|
+
deleted: t.array(t.string()),
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
handler: async ({ body, query }) => {
|
|
99
|
+
const deleted = await this.sessionService.deleteSessions(
|
|
100
|
+
body.ids,
|
|
101
|
+
query.userRealmName,
|
|
102
|
+
);
|
|
103
|
+
return { deleted };
|
|
104
|
+
},
|
|
105
|
+
});
|
|
77
106
|
}
|
|
@@ -119,4 +119,36 @@ export class AdminUserController {
|
|
|
119
119
|
return { ok: true, id: params.id };
|
|
120
120
|
},
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Delete many users in one request. Each id is processed sequentially so
|
|
125
|
+
* cascades and side-effects run as if called one-by-one. Errors on a single
|
|
126
|
+
* id surface with that id in the response.
|
|
127
|
+
*/
|
|
128
|
+
public readonly deleteUsers = $action({
|
|
129
|
+
method: "POST",
|
|
130
|
+
path: `${this.url}/delete`,
|
|
131
|
+
group: this.group,
|
|
132
|
+
use: [$secure({ permissions: ["admin:user:delete"] })],
|
|
133
|
+
description: "Delete many users",
|
|
134
|
+
schema: {
|
|
135
|
+
query: t.object({
|
|
136
|
+
userRealmName: t.optional(t.string()),
|
|
137
|
+
}),
|
|
138
|
+
body: t.object({
|
|
139
|
+
ids: t.array(t.uuid(), { minItems: 1, maxItems: 1000 }),
|
|
140
|
+
}),
|
|
141
|
+
response: t.object({
|
|
142
|
+
deleted: t.array(t.uuid()),
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
handler: async ({ body, query }) => {
|
|
146
|
+
const deleted: string[] = [];
|
|
147
|
+
for (const id of body.ids) {
|
|
148
|
+
await this.userService.deleteUser(id, query.userRealmName);
|
|
149
|
+
deleted.push(id);
|
|
150
|
+
}
|
|
151
|
+
return { deleted };
|
|
152
|
+
},
|
|
153
|
+
});
|
|
122
154
|
}
|
package/src/api/users/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { IdentityService } from "./services/IdentityService.ts";
|
|
|
14
14
|
import { RegistrationService } from "./services/RegistrationService.ts";
|
|
15
15
|
import { SessionCrudService } from "./services/SessionCrudService.ts";
|
|
16
16
|
import { SessionService } from "./services/SessionService.ts";
|
|
17
|
+
import { UsernameSlugger } from "./services/UsernameSlugger.ts";
|
|
17
18
|
import { UserService } from "./services/UserService.ts";
|
|
18
19
|
|
|
19
20
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
@@ -54,6 +55,7 @@ export * from "./services/IdentityService.ts";
|
|
|
54
55
|
export * from "./services/RegistrationService.ts";
|
|
55
56
|
export * from "./services/SessionCrudService.ts";
|
|
56
57
|
export * from "./services/SessionService.ts";
|
|
58
|
+
export * from "./services/UsernameSlugger.ts";
|
|
57
59
|
export * from "./services/UserService.ts";
|
|
58
60
|
|
|
59
61
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
@@ -82,6 +84,7 @@ export const AlephaApiUsers = $module({
|
|
|
82
84
|
CredentialService,
|
|
83
85
|
RegistrationService,
|
|
84
86
|
UserService,
|
|
87
|
+
UsernameSlugger,
|
|
85
88
|
IdentityService,
|
|
86
89
|
UserController,
|
|
87
90
|
AdminUserController,
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { $inject, Alepha } from "alepha";
|
|
3
3
|
import type { VerificationController } from "alepha/api/verifications";
|
|
4
4
|
import { $cache } from "alepha/cache";
|
|
5
|
+
import { DatabaseCacheProvider } from "alepha/cache/database";
|
|
5
6
|
import { DateTimeProvider } from "alepha/datetime";
|
|
6
7
|
import { $logger } from "alepha/logger";
|
|
7
8
|
import { CryptoProvider } from "alepha/security";
|
|
@@ -52,6 +53,10 @@ export class CredentialService {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
protected readonly intentCache = $cache<PasswordResetIntent>({
|
|
56
|
+
// Use the SQL-backed cache so phase-2 reads what phase-1 wrote with
|
|
57
|
+
// strong consistency, and so bare deployments don't need a distributed
|
|
58
|
+
// KV resource just to support password reset.
|
|
59
|
+
provider: DatabaseCacheProvider,
|
|
55
60
|
name: "api:users:password-reset-intents",
|
|
56
61
|
ttl: [INTENT_TTL_MINUTES, "minutes"],
|
|
57
62
|
});
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { $inject, Alepha, AlephaError } from "alepha";
|
|
3
3
|
import type { VerificationController } from "alepha/api/verifications";
|
|
4
4
|
import { $cache } from "alepha/cache";
|
|
5
|
+
import { DatabaseCacheProvider } from "alepha/cache/database";
|
|
5
6
|
import { CaptchaProvider } from "alepha/captcha";
|
|
6
7
|
import { DateTimeProvider } from "alepha/datetime";
|
|
7
8
|
import { $logger } from "alepha/logger";
|
|
@@ -16,6 +17,7 @@ import type { CompleteRegistrationRequest } from "../schemas/completeRegistratio
|
|
|
16
17
|
import type { RegisterRequest } from "../schemas/registerRequestSchema.ts";
|
|
17
18
|
import type { RegistrationIntentResponse } from "../schemas/registrationIntentResponseSchema.ts";
|
|
18
19
|
import { CredentialService } from "./CredentialService.ts";
|
|
20
|
+
import { UsernameSlugger } from "./UsernameSlugger.ts";
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Intent stored in cache during the registration flow.
|
|
@@ -50,13 +52,24 @@ export class RegistrationService {
|
|
|
50
52
|
protected readonly realmProvider = $inject(RealmProvider);
|
|
51
53
|
protected readonly credentialService = $inject(CredentialService);
|
|
52
54
|
protected readonly captchaProvider = $inject(CaptchaProvider);
|
|
55
|
+
protected readonly usernameSlugger = $inject(UsernameSlugger);
|
|
53
56
|
|
|
54
57
|
protected readonly intentCache = $cache<RegistrationIntent>({
|
|
58
|
+
// Pinned to the SQL-backed cache so:
|
|
59
|
+
// - phase 2 reliably reads the partial-registration payload phase 1 wrote;
|
|
60
|
+
// - the password hash held in the intent never lives in a distributed
|
|
61
|
+
// K/V outside the user's own DB unless they explicitly opt in.
|
|
62
|
+
provider: DatabaseCacheProvider,
|
|
55
63
|
name: "api:users:registrations",
|
|
56
64
|
ttl: [INTENT_TTL_MINUTES, "minutes"],
|
|
57
65
|
});
|
|
58
66
|
|
|
59
67
|
protected readonly rateLimitCache = $cache<number>({
|
|
68
|
+
// Use the SQL-backed cache so `incr()` is atomic (`INSERT ... ON CONFLICT
|
|
69
|
+
// DO UPDATE SET count = count + 1`). Cloudflare KV silently coalesces
|
|
70
|
+
// concurrent writes to the same key, which makes the limiter useless
|
|
71
|
+
// against bursts.
|
|
72
|
+
provider: DatabaseCacheProvider,
|
|
60
73
|
name: "api:users:registration-rate-limit",
|
|
61
74
|
ttl: [15, "minutes"],
|
|
62
75
|
});
|
|
@@ -128,9 +141,16 @@ export class RegistrationService {
|
|
|
128
141
|
throw new BadRequestError("Username is required");
|
|
129
142
|
}
|
|
130
143
|
|
|
144
|
+
// In "email" mode the server is authoritative — any username sent in
|
|
145
|
+
// the request is dropped on the floor and replaced by the slugger
|
|
146
|
+
// output below.
|
|
147
|
+
if (realmSettings?.username === "email") {
|
|
148
|
+
body.username = undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
131
151
|
if (body.username) {
|
|
132
152
|
// Security note: usernameRegExp is admin-controlled (from realmAuthSettingsAtom),
|
|
133
|
-
// not user input. Default is ^[a-zA-Z0-9_]{3,30}$ which is ReDoS-safe.
|
|
153
|
+
// not user input. Default is ^[a-zA-Z0-9_-]{3,30}$ which is ReDoS-safe.
|
|
134
154
|
// No need for regex timeout or safe-regex validation here.
|
|
135
155
|
const usernameRegExp = realmSettings?.usernameRegExp;
|
|
136
156
|
if (usernameRegExp) {
|
|
@@ -145,6 +165,17 @@ export class RegistrationService {
|
|
|
145
165
|
);
|
|
146
166
|
}
|
|
147
167
|
}
|
|
168
|
+
|
|
169
|
+
// Manual usernames must also clear the realm blocklist — same set
|
|
170
|
+
// that the slugger uses, so apps can't be sidestepped by clients
|
|
171
|
+
// POSTing the reserved name directly.
|
|
172
|
+
if (await this.usernameSlugger.isBlocked(userRealmName, body.username)) {
|
|
173
|
+
this.log.debug("Registration rejected: username is blocked", {
|
|
174
|
+
userRealmName,
|
|
175
|
+
username: body.username,
|
|
176
|
+
});
|
|
177
|
+
throw new BadRequestError("This username is not available");
|
|
178
|
+
}
|
|
148
179
|
}
|
|
149
180
|
|
|
150
181
|
if (realmSettings?.email === "required" && !body.email) {
|
|
@@ -161,6 +192,23 @@ export class RegistrationService {
|
|
|
161
192
|
throw new BadRequestError("Phone number is required");
|
|
162
193
|
}
|
|
163
194
|
|
|
195
|
+
// In "email" mode, derive the username from the email *now* so that
|
|
196
|
+
// checkUserAvailability picks it up too, the DB unique index sees a
|
|
197
|
+
// concrete value, and the persisted intent already carries the final
|
|
198
|
+
// username.
|
|
199
|
+
if (realmSettings?.username === "email") {
|
|
200
|
+
if (!body.email) {
|
|
201
|
+
throw new BadRequestError(
|
|
202
|
+
"Email is required to derive a username from email",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const base = this.usernameSlugger.slug(body.email);
|
|
206
|
+
body.username = await this.usernameSlugger.pickAvailable(
|
|
207
|
+
userRealmName,
|
|
208
|
+
base,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
164
212
|
// Check for existing users (username, email, phone)
|
|
165
213
|
await this.checkUserAvailability(body, userRealmName);
|
|
166
214
|
|
|
@@ -71,4 +71,20 @@ export class SessionCrudService {
|
|
|
71
71
|
await this.sessions(userRealmName).deleteById(id);
|
|
72
72
|
this.log.info("Session deleted", { id });
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Delete many sessions by ID in one repository call.
|
|
77
|
+
*/
|
|
78
|
+
public async deleteSessions(
|
|
79
|
+
ids: string[],
|
|
80
|
+
userRealmName?: string,
|
|
81
|
+
): Promise<string[]> {
|
|
82
|
+
if (ids.length === 0) return [];
|
|
83
|
+
this.log.trace("Deleting sessions", { count: ids.length, userRealmName });
|
|
84
|
+
const deleted = await this.sessions(userRealmName).deleteMany({
|
|
85
|
+
id: { inArray: ids },
|
|
86
|
+
});
|
|
87
|
+
this.log.info("Sessions deleted", { count: deleted.length });
|
|
88
|
+
return deleted.map(String);
|
|
89
|
+
}
|
|
74
90
|
}
|