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
|
@@ -17,6 +17,7 @@ import { UserAudits } from "../audits/UserAudits.ts";
|
|
|
17
17
|
import type { UserEntity } from "../entities/users.ts";
|
|
18
18
|
import { UserNotifications } from "../notifications/UserNotifications.ts";
|
|
19
19
|
import { RealmProvider } from "../providers/RealmProvider.ts";
|
|
20
|
+
import { UsernameSlugger } from "./UsernameSlugger.ts";
|
|
20
21
|
|
|
21
22
|
export class SessionService {
|
|
22
23
|
protected readonly alepha = $inject(Alepha);
|
|
@@ -27,6 +28,7 @@ export class SessionService {
|
|
|
27
28
|
protected readonly realmProvider = $inject(RealmProvider);
|
|
28
29
|
protected readonly fileController = $client<FileController>();
|
|
29
30
|
protected readonly cacheProvider = $inject(CacheProvider);
|
|
31
|
+
protected readonly usernameSlugger = $inject(UsernameSlugger);
|
|
30
32
|
|
|
31
33
|
protected userAudits(realmName?: string) {
|
|
32
34
|
const realm = this.realmProvider.getRealm(realmName);
|
|
@@ -110,69 +112,24 @@ export class SessionService {
|
|
|
110
112
|
/**
|
|
111
113
|
* Generate a unique username from an OAuth profile.
|
|
112
114
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
115
|
+
* Routes through {@link UsernameSlugger}, which is the same code path as
|
|
116
|
+
* `username: "email"` registration. The OAuth profile's email is the
|
|
117
|
+
* primary signal; if absent (rare — most IDPs return one), we fall back
|
|
118
|
+
* to `profile.name`, then to a random handle. The slugger applies the
|
|
119
|
+
* realm's `usernameBlocklist` and retries on collision.
|
|
117
120
|
*/
|
|
118
121
|
protected async generateUniqueUsername(
|
|
119
122
|
profile: OAuth2Profile,
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
_realmSettings: any,
|
|
124
|
+
_users: any,
|
|
125
|
+
realmName?: string,
|
|
122
126
|
): Promise<string> {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// Strip characters not allowed in usernames (keep alphanumeric, underscore, dot, hyphen)
|
|
130
|
-
candidate = candidate.replace(/[^a-zA-Z0-9_.-]/g, "");
|
|
131
|
-
|
|
132
|
-
// If realm has a custom regex, further sanitize
|
|
133
|
-
if (realmSettings?.usernameRegExp) {
|
|
134
|
-
try {
|
|
135
|
-
const regex = new RegExp(realmSettings.usernameRegExp);
|
|
136
|
-
if (!regex.test(candidate)) {
|
|
137
|
-
// Strip to basic alphanumeric as safe fallback
|
|
138
|
-
candidate = candidate.replace(/[^a-zA-Z0-9_]/g, "");
|
|
139
|
-
}
|
|
140
|
-
} catch {
|
|
141
|
-
// Invalid regex, continue with sanitized candidate
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Ensure minimum length
|
|
146
|
-
if (candidate.length < 3) {
|
|
147
|
-
candidate = `user${candidate}`;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Truncate to leave room for suffix
|
|
151
|
-
candidate = candidate.slice(0, maxLength - 2);
|
|
152
|
-
|
|
153
|
-
// Check uniqueness (case-insensitive exact match)
|
|
154
|
-
const isAvailable = async (name: string) => {
|
|
155
|
-
const existing = await users.findOne({
|
|
156
|
-
where: { username: { ilike: name } },
|
|
157
|
-
});
|
|
158
|
-
return !existing;
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
if (await isAvailable(candidate)) {
|
|
162
|
-
return candidate;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Try with numeric suffix
|
|
166
|
-
for (let i = 2; i <= maxSuffixAttempts + 1; i++) {
|
|
167
|
-
const withSuffix = `${candidate}${i}`;
|
|
168
|
-
if (withSuffix.length <= maxLength && (await isAvailable(withSuffix))) {
|
|
169
|
-
return withSuffix;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Final fallback: random username
|
|
174
|
-
const random = Math.random().toString(36).slice(2, 8);
|
|
175
|
-
return `user${random}`;
|
|
127
|
+
const seed =
|
|
128
|
+
profile.email ??
|
|
129
|
+
profile.name ??
|
|
130
|
+
`user-${Math.random().toString(36).slice(2, 8)}`;
|
|
131
|
+
const base = this.usernameSlugger.slug(seed);
|
|
132
|
+
return this.usernameSlugger.pickAvailable(realmName, base);
|
|
176
133
|
}
|
|
177
134
|
|
|
178
135
|
/**
|
|
@@ -766,6 +723,7 @@ export class SessionService {
|
|
|
766
723
|
profile,
|
|
767
724
|
realmSettings,
|
|
768
725
|
users,
|
|
726
|
+
userRealmName,
|
|
769
727
|
);
|
|
770
728
|
|
|
771
729
|
const user = await users.create({
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { $inject, AlephaError } from "alepha";
|
|
2
|
+
import { $logger } from "alepha/logger";
|
|
3
|
+
import { RealmProvider } from "../providers/RealmProvider.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Derive stable, URL-safe usernames from email addresses.
|
|
7
|
+
*
|
|
8
|
+
* Used by the registration flow when `realm.settings.username === "email"`,
|
|
9
|
+
* and reusable from any custom user-creation site (e.g. an OAuth-only flow
|
|
10
|
+
* that wants the same handle convention).
|
|
11
|
+
*
|
|
12
|
+
* **Slug rule** — the local-part of the email is kept as-is (gmail
|
|
13
|
+
* `+suffix` retained for predictability). Everything outside `[a-z0-9]` is
|
|
14
|
+
* replaced with `-`, runs of `-` are collapsed, leading/trailing `-` are
|
|
15
|
+
* trimmed, lowercased. If the result is shorter than {@link MIN_LENGTH} it
|
|
16
|
+
* is padded with random alphanumerics. Result is clamped to
|
|
17
|
+
* {@link MAX_LENGTH}.
|
|
18
|
+
*
|
|
19
|
+
* **Collision retry** — `pickAvailable()` checks the realm's `users` table
|
|
20
|
+
* and the realm's `usernameBlocklist`. On hit, it appends `-<4 random>` and
|
|
21
|
+
* retries up to {@link MAX_RETRIES} times. Best-effort against concurrent
|
|
22
|
+
* registrations: the unique index on `(realm, lower(username))` is the
|
|
23
|
+
* authoritative race guard, callers that hit a unique-violation should
|
|
24
|
+
* call `pickAvailable` again with a fresh suffix.
|
|
25
|
+
*
|
|
26
|
+
* @see RegistrationService.createRegistrationIntent
|
|
27
|
+
*/
|
|
28
|
+
export class UsernameSlugger {
|
|
29
|
+
protected readonly realmProvider = $inject(RealmProvider);
|
|
30
|
+
protected readonly log = $logger();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Floor for derived usernames. Shorter slugs are padded with random
|
|
34
|
+
* alphanumerics. Matches the lower bound of the default `usernameRegExp`.
|
|
35
|
+
*/
|
|
36
|
+
static readonly MIN_LENGTH = 3;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ceiling for derived usernames. Matches the upper bound of the default
|
|
40
|
+
* `usernameRegExp` and gives enough headroom for the random suffix added
|
|
41
|
+
* on collisions.
|
|
42
|
+
*/
|
|
43
|
+
static readonly MAX_LENGTH = 30;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Length of the random suffix appended on collision (e.g. `-3dp6`).
|
|
47
|
+
* 36⁴ ≈ 1.6M variants per base — plenty for a tiny number of retries.
|
|
48
|
+
*/
|
|
49
|
+
static readonly SUFFIX_LENGTH = 4;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How many times `pickAvailable` retries before giving up.
|
|
53
|
+
*/
|
|
54
|
+
static readonly MAX_RETRIES = 5;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Alphabet used for the random suffix and for padding short slugs.
|
|
58
|
+
*/
|
|
59
|
+
static readonly ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Default replacement when the email's local-part contains no
|
|
63
|
+
* `[a-z0-9]` characters at all (rare but possible: `é@example.com`).
|
|
64
|
+
*/
|
|
65
|
+
static readonly EMPTY_LOCAL_FALLBACK = "user";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sanitize an email into a base username slug.
|
|
69
|
+
*
|
|
70
|
+
* Pure function — no DB access. Always returns a string that satisfies
|
|
71
|
+
* `^[a-z0-9-]{MIN_LENGTH,MAX_LENGTH}$`.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* slug("ni.foures+testkv@gmail.com") // "ni-foures-testkv"
|
|
75
|
+
* slug("john.doe@example.com") // "john-doe"
|
|
76
|
+
* slug("é@example.com") // "user-XXX" (padded)
|
|
77
|
+
*/
|
|
78
|
+
public slug(email: string | null | undefined): string {
|
|
79
|
+
const raw = (email ?? "").trim();
|
|
80
|
+
const at = raw.indexOf("@");
|
|
81
|
+
const local = at > 0 ? raw.slice(0, at) : raw;
|
|
82
|
+
|
|
83
|
+
const cleaned = local
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
86
|
+
.replace(/^-+|-+$/g, "");
|
|
87
|
+
|
|
88
|
+
let result = cleaned || UsernameSlugger.EMPTY_LOCAL_FALLBACK;
|
|
89
|
+
|
|
90
|
+
if (result.length < UsernameSlugger.MIN_LENGTH) {
|
|
91
|
+
const needed = UsernameSlugger.MIN_LENGTH - result.length;
|
|
92
|
+
result += this.randomSuffix(needed);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return this.clamp(result);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Find an available username for the realm, starting from `base`.
|
|
100
|
+
*
|
|
101
|
+
* Returns `base` when nothing collides. On a hit (existing row OR
|
|
102
|
+
* blocklisted name) appends `-<4 random>` and tries again, up to
|
|
103
|
+
* {@link MAX_RETRIES} times.
|
|
104
|
+
*
|
|
105
|
+
* The check is best-effort: a concurrent registration may still claim
|
|
106
|
+
* the same value before the caller's INSERT runs, in which case the DB
|
|
107
|
+
* unique index throws and the caller should retry.
|
|
108
|
+
*/
|
|
109
|
+
public async pickAvailable(
|
|
110
|
+
realmName: string | undefined,
|
|
111
|
+
base: string,
|
|
112
|
+
): Promise<string> {
|
|
113
|
+
const blocklist = await this.getBlocklist(realmName);
|
|
114
|
+
const repo = this.realmProvider.userRepository(realmName);
|
|
115
|
+
const realm = this.realmProvider.getRealm(realmName);
|
|
116
|
+
|
|
117
|
+
const isAvailable = async (candidate: string): Promise<boolean> => {
|
|
118
|
+
if (this.isBlockedAgainst(candidate, blocklist)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const existing = await repo.findOne({
|
|
122
|
+
where: {
|
|
123
|
+
realm: { eq: realm.name },
|
|
124
|
+
username: { ilike: candidate },
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
return !existing;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
if (await isAvailable(base)) {
|
|
131
|
+
return base;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Reserve room for "-" + suffix at the end of the candidate.
|
|
135
|
+
const reserve = 1 + UsernameSlugger.SUFFIX_LENGTH;
|
|
136
|
+
const trimmedBase =
|
|
137
|
+
base.length > UsernameSlugger.MAX_LENGTH - reserve
|
|
138
|
+
? base.slice(0, UsernameSlugger.MAX_LENGTH - reserve)
|
|
139
|
+
: base;
|
|
140
|
+
|
|
141
|
+
for (let i = 0; i < UsernameSlugger.MAX_RETRIES; i++) {
|
|
142
|
+
const candidate = `${trimmedBase}-${this.randomSuffix(
|
|
143
|
+
UsernameSlugger.SUFFIX_LENGTH,
|
|
144
|
+
)}`;
|
|
145
|
+
if (await isAvailable(candidate)) {
|
|
146
|
+
return candidate;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new AlephaError(
|
|
151
|
+
`Could not find an available username starting from "${base}" after ${UsernameSlugger.MAX_RETRIES} attempts.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check a name against the realm's `usernameBlocklist`. Case-insensitive.
|
|
157
|
+
*/
|
|
158
|
+
public async isBlocked(
|
|
159
|
+
realmName: string | undefined,
|
|
160
|
+
name: string,
|
|
161
|
+
): Promise<boolean> {
|
|
162
|
+
const blocklist = await this.getBlocklist(realmName);
|
|
163
|
+
return this.isBlockedAgainst(name, blocklist);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// -------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
protected async getBlocklist(
|
|
169
|
+
realmName: string | undefined,
|
|
170
|
+
): Promise<Set<string>> {
|
|
171
|
+
const realm = this.realmProvider.getRealm(realmName);
|
|
172
|
+
const settings = await realm.getSettings();
|
|
173
|
+
const list = settings?.usernameBlocklist ?? [];
|
|
174
|
+
return new Set(list.map((b) => b.toLowerCase()));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
protected isBlockedAgainst(name: string, blocklist: Set<string>): boolean {
|
|
178
|
+
return blocklist.has(name.toLowerCase());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
protected clamp(s: string): string {
|
|
182
|
+
return s.length > UsernameSlugger.MAX_LENGTH
|
|
183
|
+
? s.slice(0, UsernameSlugger.MAX_LENGTH)
|
|
184
|
+
: s;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
protected randomSuffix(length: number): string {
|
|
188
|
+
const chars = UsernameSlugger.ALPHABET;
|
|
189
|
+
let out = "";
|
|
190
|
+
for (let i = 0; i < length; i++) {
|
|
191
|
+
out += chars[Math.floor(Math.random() * chars.length)];
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -277,6 +277,27 @@ export class BucketPrimitive extends Primitive<BucketPrimitiveOptions> {
|
|
|
277
277
|
});
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Delete many files in one round-trip when the underlying provider supports
|
|
282
|
+
* batch (R2/S3 up to 1000 keys per call). Emits one `bucket:file:deleted`
|
|
283
|
+
* event per id unless `skipHook` is set.
|
|
284
|
+
*/
|
|
285
|
+
public async deleteMany(fileIds: string[], skipHook = false): Promise<void> {
|
|
286
|
+
if (fileIds.length === 0) return;
|
|
287
|
+
await this.provider.deleteMany(this.name, fileIds);
|
|
288
|
+
|
|
289
|
+
if (skipHook) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const id of fileIds) {
|
|
294
|
+
await this.alepha.events.emit("bucket:file:deleted", {
|
|
295
|
+
id,
|
|
296
|
+
bucket: this,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
280
301
|
/**
|
|
281
302
|
* Checks if a file exists in the bucket.
|
|
282
303
|
*/
|
|
@@ -210,6 +210,21 @@ export class CloudflareR2Provider implements FileStorageProvider {
|
|
|
210
210
|
await r2.delete(key);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
public async deleteMany(
|
|
214
|
+
bucketName: string,
|
|
215
|
+
fileIds: string[],
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
if (fileIds.length === 0) return;
|
|
218
|
+
const r2 = this.getR2();
|
|
219
|
+
const keys = fileIds.map((id) => this.key(bucketName, id));
|
|
220
|
+
this.log.trace(`Deleting ${keys.length} keys from '${bucketName}'`);
|
|
221
|
+
// R2 binding accepts a string[] and silently ignores missing keys.
|
|
222
|
+
// Chunk at 1000 to stay within the documented batch limit.
|
|
223
|
+
for (let i = 0; i < keys.length; i += 1000) {
|
|
224
|
+
await r2.delete(keys.slice(i, i + 1000));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
213
228
|
/**
|
|
214
229
|
* Build the full R2 key: {prefix}/{bucketName}/{fileId}
|
|
215
230
|
*/
|
|
@@ -40,4 +40,13 @@ export abstract class FileStorageProvider {
|
|
|
40
40
|
* @param fileId - Identifier of the file to delete
|
|
41
41
|
*/
|
|
42
42
|
abstract delete(bucketName: string, fileId: string): Promise<void>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Delete multiple files in one round-trip where the provider supports
|
|
46
|
+
* batch (R2/S3, up to 1000 per call). Memory/Local fall back to a loop.
|
|
47
|
+
*
|
|
48
|
+
* @param bucketName - Container name
|
|
49
|
+
* @param fileIds - Identifiers of the files to delete
|
|
50
|
+
*/
|
|
51
|
+
abstract deleteMany(bucketName: string, fileIds: string[]): Promise<void>;
|
|
43
52
|
}
|
|
@@ -157,6 +157,20 @@ export class LocalFileStorageProvider implements FileStorageProvider {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
public async deleteMany(
|
|
161
|
+
bucketName: string,
|
|
162
|
+
fileIds: string[],
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
await Promise.all(
|
|
165
|
+
fileIds.map((id) =>
|
|
166
|
+
unlink(this.path(bucketName, id)).catch((error) => {
|
|
167
|
+
if (this.isErrorNoEntry(error)) return;
|
|
168
|
+
throw new AlephaError("Error deleting file", { cause: error });
|
|
169
|
+
}),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
160
174
|
protected stat(bucket: string, fileId: string): Promise<fs.Stats> {
|
|
161
175
|
return stat(this.path(bucket, fileId));
|
|
162
176
|
}
|
|
@@ -70,6 +70,15 @@ export class MemoryFileStorageProvider implements FileStorageProvider {
|
|
|
70
70
|
delete this.files[fileKey];
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
public async deleteMany(
|
|
74
|
+
bucketName: string,
|
|
75
|
+
fileIds: string[],
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
for (const id of fileIds) {
|
|
78
|
+
delete this.files[`${bucketName}/${id}`];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
protected createId(): string {
|
|
74
83
|
return randomUUID();
|
|
75
84
|
}
|
|
@@ -215,4 +215,39 @@ export class NodeS3BucketProvider implements FileStorageProvider {
|
|
|
215
215
|
throw error;
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
|
+
|
|
219
|
+
public async deleteMany(
|
|
220
|
+
bucketName: string,
|
|
221
|
+
fileIds: string[],
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
if (fileIds.length === 0) return;
|
|
224
|
+
this.log.trace(
|
|
225
|
+
`Deleting ${fileIds.length} files from bucket '${bucketName}'...`,
|
|
226
|
+
);
|
|
227
|
+
const client = this.getClient(bucketName);
|
|
228
|
+
// S3 DeleteObjects caps at 1000 keys per request.
|
|
229
|
+
for (let i = 0; i < fileIds.length; i += 1000) {
|
|
230
|
+
const chunk = fileIds.slice(i, i + 1000);
|
|
231
|
+
try {
|
|
232
|
+
// bun:s3 client exposes a per-key deleteObject; some SDKs also expose
|
|
233
|
+
// deleteObjects(keys: string[]). Prefer batch when available.
|
|
234
|
+
const batch = (
|
|
235
|
+
client as unknown as {
|
|
236
|
+
deleteObjects?: (keys: string[]) => Promise<unknown>;
|
|
237
|
+
}
|
|
238
|
+
).deleteObjects;
|
|
239
|
+
if (typeof batch === "function") {
|
|
240
|
+
await batch.call(client, chunk);
|
|
241
|
+
} else {
|
|
242
|
+
await Promise.all(chunk.map((id) => client.deleteObject(id)));
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
this.log.error(`Failed to delete files: ${error}`);
|
|
246
|
+
if (error instanceof Error) {
|
|
247
|
+
throw new FileNotFoundError("Error deleting files", { cause: error });
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
218
253
|
}
|
|
@@ -4,11 +4,11 @@ import {
|
|
|
4
4
|
$state,
|
|
5
5
|
AlephaError,
|
|
6
6
|
createPrimitive,
|
|
7
|
-
type InstantiableClass,
|
|
8
7
|
KIND,
|
|
9
8
|
type MiddlewareMetadata,
|
|
10
9
|
OPTIONS,
|
|
11
10
|
Primitive,
|
|
11
|
+
type Service,
|
|
12
12
|
type Static,
|
|
13
13
|
t,
|
|
14
14
|
} from "alepha";
|
|
@@ -112,9 +112,26 @@ export interface CachePrimitiveOptions<
|
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
114
|
* The store provider for the cache.
|
|
115
|
-
*
|
|
115
|
+
*
|
|
116
|
+
* Accepts:
|
|
117
|
+
* - `"memory"` — short-circuits to {@link MemoryCacheProvider} regardless
|
|
118
|
+
* of the default `CacheProvider` binding. Useful for caches that must
|
|
119
|
+
* stay process-local (e.g. ETag, HTTP client).
|
|
120
|
+
* - A {@link CacheProvider} class (concrete OR abstract) — resolved via
|
|
121
|
+
* `alepha.inject(...)` at primitive construction. Use this to opt a
|
|
122
|
+
* specific cache into a non-default backend (e.g.
|
|
123
|
+
* `provider: DatabaseCacheProvider` to keep one cache in SQL while the
|
|
124
|
+
* rest of the app uses Cloudflare KV).
|
|
125
|
+
* - `undefined` — falls back to whatever is bound to `CacheProvider` in
|
|
126
|
+
* the container. On Cloudflare workers this is
|
|
127
|
+
* {@link CloudflareKVProvider} by default; on Node it's
|
|
128
|
+
* {@link MemoryCacheProvider}.
|
|
129
|
+
*
|
|
130
|
+
* Note: passing an *abstract* class works because Alepha's DI resolves
|
|
131
|
+
* through substitutions, e.g. `alepha.with({ provide: CacheProvider, use:
|
|
132
|
+
* MyCacheProvider })`.
|
|
116
133
|
*/
|
|
117
|
-
provider?:
|
|
134
|
+
provider?: Service<CacheProvider> | "memory";
|
|
118
135
|
|
|
119
136
|
/**
|
|
120
137
|
* The time-to-live for the cache in seconds.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Alepha } from "alepha";
|
|
2
|
+
import { $cache, CacheProvider } from "alepha/cache";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
DatabaseCacheProvider,
|
|
6
|
+
databaseCacheOptions,
|
|
7
|
+
} from "../providers/DatabaseCacheProvider.ts";
|
|
8
|
+
|
|
9
|
+
const make = (
|
|
10
|
+
configure: (app: Alepha) => void = () => {},
|
|
11
|
+
): { alepha: Alepha; provider: () => DatabaseCacheProvider } => {
|
|
12
|
+
const alepha = Alepha.create({
|
|
13
|
+
env: { DATABASE_URL: "sqlite://:memory:" },
|
|
14
|
+
}).with({
|
|
15
|
+
provide: CacheProvider,
|
|
16
|
+
use: DatabaseCacheProvider,
|
|
17
|
+
});
|
|
18
|
+
configure(alepha);
|
|
19
|
+
return {
|
|
20
|
+
alepha,
|
|
21
|
+
provider: () => alepha.inject(DatabaseCacheProvider),
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
describe("DatabaseCacheProvider — atomic incr", () => {
|
|
28
|
+
it("increments through SQL upsert (no read/modify/write race)", async () => {
|
|
29
|
+
const { alepha, provider } = make();
|
|
30
|
+
await alepha.start();
|
|
31
|
+
const p = provider();
|
|
32
|
+
|
|
33
|
+
expect(await p.incr("counters", "k", 1)).toBe(1);
|
|
34
|
+
expect(await p.incr("counters", "k", 1)).toBe(2);
|
|
35
|
+
expect(await p.incr("counters", "k", 5)).toBe(7);
|
|
36
|
+
expect(await p.incr("counters", "k", -3)).toBe(4);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("survives concurrent increments without losing updates", async () => {
|
|
40
|
+
const { alepha, provider } = make();
|
|
41
|
+
await alepha.start();
|
|
42
|
+
const p = provider();
|
|
43
|
+
|
|
44
|
+
// SQLite serializes writes; this proves that even concurrent .incr()
|
|
45
|
+
// calls all end up reflected in the final count, unlike the KV
|
|
46
|
+
// read-modify-write race we want to avoid.
|
|
47
|
+
const concurrent = 50;
|
|
48
|
+
await Promise.all(
|
|
49
|
+
Array.from({ length: concurrent }, () => p.incr("hits", "ip", 1)),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const bytes = await p.get("hits", "ip");
|
|
53
|
+
const value = JSON.parse(new TextDecoder().decode(bytes!.subarray(1)));
|
|
54
|
+
expect(value).toBe(concurrent);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("reads back an incr'd value through get()", async () => {
|
|
58
|
+
const { alepha, provider } = make();
|
|
59
|
+
await alepha.start();
|
|
60
|
+
const p = provider();
|
|
61
|
+
|
|
62
|
+
await p.incr("counters", "k", 7);
|
|
63
|
+
const bytes = await p.get("counters", "k");
|
|
64
|
+
expect(bytes).toBeDefined();
|
|
65
|
+
// JSON-encoded number with the JSON marker byte (0x02).
|
|
66
|
+
expect(bytes![0]).toBe(0x02);
|
|
67
|
+
expect(new TextDecoder().decode(bytes!.subarray(1))).toBe("7");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
describe("DatabaseCacheProvider — lazy expiration on read", () => {
|
|
74
|
+
it("returns undefined for an expired entry without sweeping it first", async () => {
|
|
75
|
+
const { alepha, provider } = make();
|
|
76
|
+
await alepha.start();
|
|
77
|
+
const p = provider();
|
|
78
|
+
|
|
79
|
+
// Write directly with a TTL of 1ms so the row is already expired by the
|
|
80
|
+
// time we read it, regardless of clock granularity.
|
|
81
|
+
await p.set("c", "k", new TextEncoder().encode("hello"), 1);
|
|
82
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
83
|
+
|
|
84
|
+
expect(await p.get("c", "k")).toBeUndefined();
|
|
85
|
+
expect(await p.has("c", "k")).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("excludes expired entries from keys() and from get() but leaves them in storage until swept", async () => {
|
|
89
|
+
const { alepha, provider } = make();
|
|
90
|
+
await alepha.start();
|
|
91
|
+
const p = provider();
|
|
92
|
+
|
|
93
|
+
await p.set("c", "fresh", new TextEncoder().encode("a"));
|
|
94
|
+
await p.set("c", "stale", new TextEncoder().encode("b"), 1);
|
|
95
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
96
|
+
|
|
97
|
+
expect(await p.keys("c")).toEqual(["fresh"]);
|
|
98
|
+
expect(await p.has("c", "stale")).toBe(false);
|
|
99
|
+
|
|
100
|
+
// The expired row is still on disk until sweep runs; correctness is
|
|
101
|
+
// preserved by the read-time filter, not by aggressive cleanup.
|
|
102
|
+
const rows = await (p as any).repository.findMany({});
|
|
103
|
+
expect(rows.length).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe("DatabaseCacheProvider — opportunistic sweep", () => {
|
|
110
|
+
it("does nothing when sweepProbability is 0", async () => {
|
|
111
|
+
const { alepha, provider } = make((app) => {
|
|
112
|
+
app.store.mut(databaseCacheOptions, (cur) => ({
|
|
113
|
+
...cur,
|
|
114
|
+
sweepProbability: 0,
|
|
115
|
+
}));
|
|
116
|
+
});
|
|
117
|
+
await alepha.start();
|
|
118
|
+
const p = provider();
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < 30; i++) {
|
|
121
|
+
await p.set("c", `k${i}`, new TextEncoder().encode("x"));
|
|
122
|
+
}
|
|
123
|
+
expect(p.sweeps).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("triggers a sweep on every write when sweepProbability is 1", async () => {
|
|
127
|
+
const { alepha, provider } = make((app) => {
|
|
128
|
+
app.store.mut(databaseCacheOptions, (cur) => ({
|
|
129
|
+
...cur,
|
|
130
|
+
sweepProbability: 1,
|
|
131
|
+
}));
|
|
132
|
+
});
|
|
133
|
+
await alepha.start();
|
|
134
|
+
const p = provider();
|
|
135
|
+
|
|
136
|
+
await p.set("c", "fresh", new TextEncoder().encode("a"));
|
|
137
|
+
await p.set("c", "stale", new TextEncoder().encode("b"), 1);
|
|
138
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
139
|
+
|
|
140
|
+
// Trigger another write; the stale row should be reaped during the sweep.
|
|
141
|
+
await p.set("c", "trigger", new TextEncoder().encode("c"));
|
|
142
|
+
expect(p.sweeps).toBeGreaterThan(0);
|
|
143
|
+
|
|
144
|
+
// Nothing references the swept row anymore.
|
|
145
|
+
expect(await p.has("c", "stale")).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("sweepExpired() reaps every expired row in one shot", async () => {
|
|
149
|
+
const { alepha, provider } = make((app) => {
|
|
150
|
+
app.store.mut(databaseCacheOptions, (cur) => ({
|
|
151
|
+
...cur,
|
|
152
|
+
sweepProbability: 0,
|
|
153
|
+
}));
|
|
154
|
+
});
|
|
155
|
+
await alepha.start();
|
|
156
|
+
const p = provider();
|
|
157
|
+
|
|
158
|
+
for (let i = 0; i < 5; i++) {
|
|
159
|
+
await p.set("c", `stale${i}`, new TextEncoder().encode("x"), 1);
|
|
160
|
+
}
|
|
161
|
+
await p.set("c", "fresh", new TextEncoder().encode("y"));
|
|
162
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
163
|
+
|
|
164
|
+
const reaped = await p.sweepExpired();
|
|
165
|
+
expect(reaped).toBe(5);
|
|
166
|
+
|
|
167
|
+
// Sanity: the live entry is intact.
|
|
168
|
+
expect(await p.has("c", "fresh")).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
describe("DatabaseCacheProvider — provider option on $cache", () => {
|
|
175
|
+
it("$cache({ provider: DatabaseCacheProvider }) routes to the SQL backend even when the default CacheProvider is something else", async () => {
|
|
176
|
+
class App {
|
|
177
|
+
// Default fallback CacheProvider is MemoryCacheProvider in this test.
|
|
178
|
+
pinned = $cache<string>({
|
|
179
|
+
provider: DatabaseCacheProvider,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const alepha = Alepha.create({
|
|
184
|
+
env: { DATABASE_URL: "sqlite://:memory:" },
|
|
185
|
+
});
|
|
186
|
+
// Note: NO substitution of CacheProvider — the framework default is in
|
|
187
|
+
// play. The pinned `$cache` should still reach DatabaseCacheProvider via
|
|
188
|
+
// the explicit option.
|
|
189
|
+
const app = alepha.inject(App);
|
|
190
|
+
await alepha.start();
|
|
191
|
+
|
|
192
|
+
await app.pinned.set("k", "v");
|
|
193
|
+
expect(await app.pinned.get("k")).toBe("v");
|
|
194
|
+
|
|
195
|
+
// The row really lives in the cache_entries table, not in
|
|
196
|
+
// MemoryCacheProvider's map.
|
|
197
|
+
const rows = await (
|
|
198
|
+
alepha.inject(DatabaseCacheProvider) as any
|
|
199
|
+
).repository.findMany({});
|
|
200
|
+
expect(rows.length).toBe(1);
|
|
201
|
+
expect(rows[0].cacheKey).toBe("k");
|
|
202
|
+
});
|
|
203
|
+
});
|