@podosoft/podokit 1.1.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deploy-render.js +5 -0
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
- package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
- package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
- package/dist/templates/fullstack/compose.dev.yaml +21 -0
- package/dist/templates/fullstack/dot-env.docker +3 -0
- package/dist/templates/fullstack/dot-env.example +14 -0
- package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
- package/dist/templates/modules/audit-log/module.manifest.json +7 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
- package/dist/templates/modules/auth/module.manifest.json +2 -2
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
- package/dist/templates/modules/file-upload/module.manifest.json +9 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
- package/dist/templates/modules/job-progress/module.manifest.json +3 -3
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
- package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
- package/dist/templates/modules/sse/module.manifest.json +17 -4
- package/package.json +2 -2
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import type { ApiKeyVerifier } from "../api-key/api-key-verifier";
|
|
3
|
-
import {
|
|
4
|
-
REQUEST_IDENTITY,
|
|
5
|
-
type ServiceKey,
|
|
6
|
-
type ServiceRegistry,
|
|
7
|
-
} from "../core/services";
|
|
3
|
+
import type { ServiceKey, ServiceRegistry } from "../core/services";
|
|
8
4
|
import {
|
|
9
5
|
clientAddressFromProxy,
|
|
10
6
|
type RateLimitConfig,
|
|
@@ -21,7 +17,20 @@ export const RATE_LIMIT_IDENTITY_EXTENSION = Symbol(
|
|
|
21
17
|
"rate-limit-identity-extension",
|
|
22
18
|
) as ServiceKey<RateLimitIdentityExtension>;
|
|
23
19
|
|
|
24
|
-
|
|
20
|
+
// Better Auth's session cookie. It adds the __Secure- prefix when BETTER_AUTH_URL
|
|
21
|
+
// uses HTTPS; either name only counts when its signature verifies.
|
|
22
|
+
const SESSION_COOKIE_NAMES = [
|
|
23
|
+
"__Secure-better-auth.session_token",
|
|
24
|
+
"better-auth.session_token",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
// better-call signs cookies as `${token}.${base64(HMAC-SHA256(secret, token))}` and
|
|
28
|
+
// accepts only the padded 44-character standard base64 signature.
|
|
29
|
+
const SIGNATURE = /^[A-Za-z0-9+/]{43}=$/;
|
|
30
|
+
|
|
31
|
+
type TrackerKind = "session" | "api-key" | "ip";
|
|
32
|
+
|
|
33
|
+
function stableDigest(kind: TrackerKind, value: string): string {
|
|
25
34
|
return createHash("sha256")
|
|
26
35
|
.update("podokit-rate-limit-v1", "utf8")
|
|
27
36
|
.update("\0")
|
|
@@ -31,14 +40,56 @@ function stableDigest(kind: "user" | "api-key" | "ip", value: string): string {
|
|
|
31
40
|
.digest("hex");
|
|
32
41
|
}
|
|
33
42
|
|
|
34
|
-
function tracker(kind:
|
|
43
|
+
function tracker(kind: TrackerKind, value: string): string {
|
|
35
44
|
return `${kind}:${stableDigest(kind, value)}`;
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
function decodeComponent(value: string): string {
|
|
48
|
+
try {
|
|
49
|
+
return decodeURIComponent(value);
|
|
50
|
+
} catch {
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cookieValue(header: string | null, name: string): string | undefined {
|
|
56
|
+
if (!header) return undefined;
|
|
57
|
+
for (const pair of header.split(";")) {
|
|
58
|
+
const separator = pair.indexOf("=");
|
|
59
|
+
if (separator === -1 || pair.slice(0, separator).trim() !== name) continue;
|
|
60
|
+
const value = pair.slice(separator + 1).trim();
|
|
61
|
+
return decodeComponent(value.startsWith('"') ? value.slice(1, -1) : value);
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function bearerValue(header: string | null): string | undefined {
|
|
67
|
+
if (!header || header.slice(0, 7).toLowerCase() !== "bearer ") return undefined;
|
|
68
|
+
const value = header.slice(7).trim();
|
|
69
|
+
return value.includes("%") ? decodeComponent(value) : value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function verifiedSessionToken(
|
|
73
|
+
signedValue: string,
|
|
74
|
+
secret: string,
|
|
75
|
+
): string | undefined {
|
|
76
|
+
const separator = signedValue.lastIndexOf(".");
|
|
77
|
+
if (separator < 1) return undefined;
|
|
78
|
+
const token = signedValue.slice(0, separator);
|
|
79
|
+
const signature = signedValue.slice(separator + 1);
|
|
80
|
+
if (!SIGNATURE.test(signature)) return undefined;
|
|
81
|
+
const expected = createHmac("sha256", secret).update(token, "utf8").digest();
|
|
82
|
+
const provided = Buffer.from(signature, "base64");
|
|
83
|
+
return provided.length === expected.length && timingSafeEqual(provided, expected)
|
|
84
|
+
? token
|
|
85
|
+
: undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
38
88
|
export class RateLimitIdentity {
|
|
39
89
|
constructor(
|
|
40
90
|
private readonly configuredApiKeys: ApiKeyVerifier,
|
|
41
91
|
private readonly services: ServiceRegistry,
|
|
92
|
+
private readonly sessionSecret: string | undefined,
|
|
42
93
|
) {}
|
|
43
94
|
|
|
44
95
|
async resolve(
|
|
@@ -46,9 +97,6 @@ export class RateLimitIdentity {
|
|
|
46
97
|
remoteAddress: string | undefined,
|
|
47
98
|
config: Pick<RateLimitConfig, "proxyHeader" | "trustedProxyHops">,
|
|
48
99
|
): Promise<string> {
|
|
49
|
-
const userId = await this.services.resolve(REQUEST_IDENTITY).userId(request);
|
|
50
|
-
if (userId) return tracker("user", userId);
|
|
51
|
-
|
|
52
100
|
const rawApiKey = request.headers.get("x-api-key");
|
|
53
101
|
if (rawApiKey) {
|
|
54
102
|
if (this.configuredApiKeys.isValid(rawApiKey)) return tracker("api-key", rawApiKey);
|
|
@@ -57,7 +105,28 @@ export class RateLimitIdentity {
|
|
|
57
105
|
if (additionalIdentity) return tracker("api-key", additionalIdentity);
|
|
58
106
|
}
|
|
59
107
|
|
|
108
|
+
const sessionToken = this.sessionToken(request.headers);
|
|
109
|
+
if (sessionToken) return tracker("session", sessionToken);
|
|
110
|
+
|
|
60
111
|
const clientAddress = clientAddressFromProxy(request.headers, remoteAddress, config);
|
|
61
112
|
return tracker("ip", clientAddress);
|
|
62
113
|
}
|
|
114
|
+
|
|
115
|
+
// Only the signature is checked: resolving the session would cost a database
|
|
116
|
+
// read on every request. A revoked or expired token still maps to its single
|
|
117
|
+
// counter, and a value without a valid signature falls back to the client address.
|
|
118
|
+
private sessionToken(headers: Headers): string | undefined {
|
|
119
|
+
if (!this.sessionSecret) return undefined;
|
|
120
|
+
const cookies = headers.get("cookie");
|
|
121
|
+
const candidates = [
|
|
122
|
+
bearerValue(headers.get("authorization")),
|
|
123
|
+
...SESSION_COOKIE_NAMES.map((name) => cookieValue(cookies, name)),
|
|
124
|
+
];
|
|
125
|
+
for (const candidate of candidates) {
|
|
126
|
+
if (!candidate) continue;
|
|
127
|
+
const token = verifiedSessionToken(candidate, this.sessionSecret);
|
|
128
|
+
if (token) return token;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
63
132
|
}
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts
CHANGED
|
@@ -49,25 +49,31 @@ describe("RateLimiter", () => {
|
|
|
49
49
|
}));
|
|
50
50
|
const limiter = new RateLimiter(
|
|
51
51
|
config,
|
|
52
|
-
{ resolve: async () => "
|
|
52
|
+
{ resolve: async () => "session:test" } as unknown as RateLimitIdentity,
|
|
53
53
|
{ increment } as RateLimitStorage,
|
|
54
54
|
);
|
|
55
55
|
await limiter.enforce(context("/ordinary").value);
|
|
56
56
|
await limiter.enforce(context("/api/auth/sign-in", "POST").value);
|
|
57
57
|
await limiter.enforce(context("/api/auth/get-session").value);
|
|
58
58
|
await limiter.enforce(context("/site/settings").value);
|
|
59
|
+
await limiter.enforce(context("/api/auth/admin/list-users").value);
|
|
60
|
+
await limiter.enforce(context("/api/auth/admin/ban-user", "POST").value);
|
|
59
61
|
|
|
60
62
|
expect(increment.mock.calls.map((call) => call.slice(1))).toEqual([
|
|
61
63
|
[60],
|
|
62
64
|
[60],
|
|
63
65
|
[60],
|
|
64
66
|
[60],
|
|
67
|
+
[60],
|
|
68
|
+
[60],
|
|
65
69
|
]);
|
|
66
70
|
expect(increment.mock.calls.map((call) => call[0])).toEqual([
|
|
67
|
-
"podokit:test:rate-limit:general:
|
|
68
|
-
"podokit:test:rate-limit:auth:
|
|
69
|
-
"podokit:test:rate-limit:runtime:
|
|
70
|
-
"podokit:test:rate-limit:runtime:
|
|
71
|
+
"podokit:test:rate-limit:general:session:test",
|
|
72
|
+
"podokit:test:rate-limit:auth:session:test",
|
|
73
|
+
"podokit:test:rate-limit:runtime:session:test",
|
|
74
|
+
"podokit:test:rate-limit:runtime:session:test",
|
|
75
|
+
"podokit:test:rate-limit:general:session:test",
|
|
76
|
+
"podokit:test:rate-limit:auth:session:test",
|
|
71
77
|
]);
|
|
72
78
|
});
|
|
73
79
|
|
|
@@ -131,7 +137,7 @@ describe("RateLimiter", () => {
|
|
|
131
137
|
}));
|
|
132
138
|
const limiter = new RateLimiter(
|
|
133
139
|
config,
|
|
134
|
-
{ resolve: async () => "
|
|
140
|
+
{ resolve: async () => "session:test" } as unknown as RateLimitIdentity,
|
|
135
141
|
{ increment },
|
|
136
142
|
);
|
|
137
143
|
const headers: Record<string, string> = {};
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts
CHANGED
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
type RequestGuardContext,
|
|
8
8
|
type ServiceKey,
|
|
9
9
|
} from "../core/services";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
rateLimitConfig,
|
|
12
|
+
sessionSigningSecret,
|
|
13
|
+
type RateLimitConfig,
|
|
14
|
+
} from "./rate-limit.config";
|
|
11
15
|
import { RateLimitIdentity } from "./rate-limit.identity";
|
|
12
16
|
|
|
13
17
|
export interface RateLimitIncrement {
|
|
@@ -101,6 +105,11 @@ export class RateLimiter {
|
|
|
101
105
|
) {
|
|
102
106
|
return { name: "runtime", ttlSeconds: this.config.ttlSeconds, limit: this.config.runtimeLimit };
|
|
103
107
|
}
|
|
108
|
+
// Admin reads need an admin session and are not credential attempts; the
|
|
109
|
+
// server-paged admin tables issue one per page, sort, or search.
|
|
110
|
+
if (method === "GET" && path.startsWith("/api/auth/admin/")) {
|
|
111
|
+
return { name: "general", ttlSeconds: this.config.ttlSeconds, limit: this.config.limit };
|
|
112
|
+
}
|
|
104
113
|
if (path === "/api/auth" || path.startsWith("/api/auth/")) {
|
|
105
114
|
return { name: "auth", ttlSeconds: this.config.authTtlSeconds, limit: this.config.authLimit };
|
|
106
115
|
}
|
|
@@ -132,7 +141,11 @@ export const rateLimitModule: PodokitModule = {
|
|
|
132
141
|
configure: (_env, services): void => {
|
|
133
142
|
const limiter = new RateLimiter(
|
|
134
143
|
rateLimitConfig(),
|
|
135
|
-
new RateLimitIdentity(
|
|
144
|
+
new RateLimitIdentity(
|
|
145
|
+
services.resolve(API_KEY_VERIFIER),
|
|
146
|
+
services,
|
|
147
|
+
sessionSigningSecret(),
|
|
148
|
+
),
|
|
136
149
|
new CacheRateLimitStorage(services.resolve(CACHE)),
|
|
137
150
|
);
|
|
138
151
|
services.register(RATE_LIMITER, limiter);
|
|
@@ -65,6 +65,52 @@ test("rate limit: health probes stay available while ordinary routes return 429
|
|
|
65
65
|
await ctx.dispose();
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test("rate limit: each signed session has its own counter and forged sessions do not", async ({
|
|
69
|
+
playwright,
|
|
70
|
+
}) => {
|
|
71
|
+
const email = `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
72
|
+
const password = "Podokit3e-Str0ng!pw";
|
|
73
|
+
const first = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
74
|
+
expect((await first.post("/api/auth/sign-up/email", { data: { email, password, name: "Rate Limit" } })).ok()).toBeTruthy();
|
|
75
|
+
const probe = await first.get("/api/cache/rate-limit-probe");
|
|
76
|
+
test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
|
|
77
|
+
const remaining = (response: APIResponse): number => Number(response.headers()["ratelimit-remaining"]);
|
|
78
|
+
const ceiling = Number(probe.headers()["ratelimit-limit"]);
|
|
79
|
+
expect(remaining(probe)).toBe(ceiling - 1);
|
|
80
|
+
|
|
81
|
+
// The signed bearer form of the same session shares its counter.
|
|
82
|
+
const cookie = (await first.storageState()).cookies.find((c) => c.name.endsWith("session_token"));
|
|
83
|
+
expect(cookie, "sign-up set a session cookie").toBeTruthy();
|
|
84
|
+
const bearer = await playwright.request.newContext({
|
|
85
|
+
baseURL: base,
|
|
86
|
+
extraHTTPHeaders: { ...origin, authorization: `Bearer ${cookie?.value ?? ""}` },
|
|
87
|
+
});
|
|
88
|
+
expect(remaining(await bearer.get("/api/cache/rate-limit-probe"))).toBe(ceiling - 2);
|
|
89
|
+
|
|
90
|
+
// Counters follow the session, not the user: signing in again starts a new one.
|
|
91
|
+
const second = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
92
|
+
expect((await second.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
|
|
93
|
+
expect(remaining(await second.get("/api/cache/rate-limit-probe"))).toBe(ceiling - 1);
|
|
94
|
+
|
|
95
|
+
// A session cookie without a valid signature is counted with anonymous traffic
|
|
96
|
+
// from the same client address instead of receiving a fresh counter. The cache
|
|
97
|
+
// probe requires a session, so compare on a public route with the general limit.
|
|
98
|
+
const token = decodeURIComponent(cookie?.value ?? "").split(".")[0] ?? "";
|
|
99
|
+
const forged = await playwright.request.newContext({
|
|
100
|
+
baseURL: base,
|
|
101
|
+
extraHTTPHeaders: { ...origin, cookie: `${cookie?.name ?? ""}=${token}.${"A".repeat(43)}%3D` },
|
|
102
|
+
});
|
|
103
|
+
const anonymous = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
104
|
+
await expect(async () => {
|
|
105
|
+
const before = await anonymous.get("/api/account/capabilities");
|
|
106
|
+
const after = await forged.get("/api/account/capabilities");
|
|
107
|
+
expect(before.status()).toBe(200);
|
|
108
|
+
expect(remaining(after)).toBeLessThan(remaining(before));
|
|
109
|
+
}).toPass({ timeout: 10_000 });
|
|
110
|
+
|
|
111
|
+
for (const context of [first, bearer, second, forged, anonymous]) await context.dispose();
|
|
112
|
+
});
|
|
113
|
+
|
|
68
114
|
test("rate limit: session reads do not consume the strict authentication budget", async ({
|
|
69
115
|
playwright,
|
|
70
116
|
}) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rate-limit",
|
|
3
3
|
"manifestVersion": 3,
|
|
4
|
-
"description": "Provider-neutral Elysia request limiting by
|
|
4
|
+
"description": "Provider-neutral Elysia request limiting by validated API key, signed-in session, or trusted-proxy client address.",
|
|
5
5
|
"requires": ["api-key-auth"],
|
|
6
6
|
"requiresCapabilities": ["cache"],
|
|
7
7
|
"targetApp": "api",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"RATE_LIMIT_KEY_PREFIX=podokit:rate-limit",
|
|
15
15
|
"RATE_LIMIT_TTL=60",
|
|
16
16
|
"RATE_LIMIT_MAX=300",
|
|
17
|
-
"# stricter authentication
|
|
17
|
+
"# stricter ceiling for authentication writes (admin reads use RATE_LIMIT_MAX)",
|
|
18
18
|
"RATE_LIMIT_AUTH_TTL=60",
|
|
19
19
|
"RATE_LIMIT_AUTH_MAX=20",
|
|
20
20
|
"# higher ceiling for SSR runtime settings and session reads",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"Tune the general, authentication, and runtime limits in .env.",
|
|
43
43
|
"Give each deployment that shares Redis a distinct RATE_LIMIT_KEY_PREFIX.",
|
|
44
44
|
"Set RATE_LIMIT_TRUSTED_PROXY_HOPS to the exact number of trusted reverse proxies; keep 0 for direct API access.",
|
|
45
|
-
"
|
|
45
|
+
"Validated X-API-Key clients and signed-in sessions receive independent opaque counters; other traffic uses the trusted client address.",
|
|
46
|
+
"Sessions are recognized by verifying the Better Auth cookie or bearer signature with BETTER_AUTH_SECRET, without a database read, so limits apply per session rather than per user.",
|
|
46
47
|
"Exceeded limits return RATE_LIMIT_EXCEEDED with HTTP 429 and Retry-After. Cache failures return RATE_LIMIT_UNAVAILABLE with HTTP 503.",
|
|
47
48
|
"Register a RateLimitIdentityExtension under RATE_LIMIT_IDENTITY_EXTENSION in app.extensions.ts for another validated machine-key source.",
|
|
48
49
|
"GET /health and GET /health/ready bypass request quotas so orchestrator probes remain reliable.",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
FIXED_WINDOW_SCRIPT,
|
|
4
|
+
FIXED_WINDOW_SCRIPT_SHA,
|
|
5
|
+
RedisCacheStore,
|
|
6
|
+
} from "./redis-cache.store";
|
|
7
|
+
import { RedisService } from "./redis.service";
|
|
8
|
+
|
|
9
|
+
const redisUrl = process.env.REDIS_TEST_URL;
|
|
10
|
+
const integrationTest = redisUrl ? it : it.skip;
|
|
11
|
+
|
|
12
|
+
type Send = (command: string, args: string[]) => Promise<unknown>;
|
|
13
|
+
type SetValue = (key: string, value: string, ...options: Array<string | number>) => Promise<"OK">;
|
|
14
|
+
|
|
15
|
+
function storeWith(client: { send?: Send; set?: SetValue }): RedisCacheStore {
|
|
16
|
+
return new RedisCacheStore({ client } as unknown as RedisService);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function redisError(message: string): Error {
|
|
20
|
+
return Object.assign(new Error(message), { name: "RedisError", code: "ERR_REDIS_SERVER_ERROR" });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("RedisCacheStore fixed window", () => {
|
|
24
|
+
it("runs the cached script by SHA", async () => {
|
|
25
|
+
const send = mock<Send>(async () => [1, 60_000]);
|
|
26
|
+
const store = storeWith({ send });
|
|
27
|
+
|
|
28
|
+
await expect(
|
|
29
|
+
store.incrementFixedWindow("counter", { windowMs: 60_000, limit: 5 }),
|
|
30
|
+
).resolves.toMatchObject({ allowed: true, count: 1, remaining: 4 });
|
|
31
|
+
expect(send.mock.calls).toEqual([
|
|
32
|
+
["EVALSHA", [FIXED_WINDOW_SCRIPT_SHA, "1", "counter", "60000"]],
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("falls back to EVAL when Redis has not cached the script", async () => {
|
|
37
|
+
const send = mock<Send>(async (command) => {
|
|
38
|
+
if (command === "EVALSHA") throw redisError("NOSCRIPT No matching script. Please use EVAL.");
|
|
39
|
+
return [2, 500];
|
|
40
|
+
});
|
|
41
|
+
const store = storeWith({ send });
|
|
42
|
+
|
|
43
|
+
await expect(
|
|
44
|
+
store.incrementFixedWindow("counter", { windowMs: 1_000, limit: 1 }),
|
|
45
|
+
).resolves.toMatchObject({ allowed: false, count: 2, remaining: 0 });
|
|
46
|
+
expect(send.mock.calls).toEqual([
|
|
47
|
+
["EVALSHA", [FIXED_WINDOW_SCRIPT_SHA, "1", "counter", "1000"]],
|
|
48
|
+
["EVAL", [FIXED_WINDOW_SCRIPT, "1", "counter", "1000"]],
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("propagates other Redis errors unchanged", async () => {
|
|
53
|
+
const failure = redisError("LOADING Redis is loading the dataset in memory");
|
|
54
|
+
const send = mock<Send>(async () => { throw failure; });
|
|
55
|
+
const store = storeWith({ send });
|
|
56
|
+
|
|
57
|
+
await expect(
|
|
58
|
+
store.incrementFixedWindow("counter", { windowMs: 1_000, limit: 1 }),
|
|
59
|
+
).rejects.toBe(failure);
|
|
60
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("RedisCacheStore set", () => {
|
|
65
|
+
it("writes the value and its TTL in one command", async () => {
|
|
66
|
+
const set = mock<SetValue>(async () => "OK");
|
|
67
|
+
const store = storeWith({ set });
|
|
68
|
+
|
|
69
|
+
await store.set("plain", "value");
|
|
70
|
+
await store.set("expiring", "value", { ttlMs: 1_500 });
|
|
71
|
+
|
|
72
|
+
expect(set.mock.calls).toEqual([
|
|
73
|
+
["plain", "value"],
|
|
74
|
+
["expiring", "value", "PX", 1_500],
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects an invalid TTL before writing", async () => {
|
|
79
|
+
const set = mock<SetValue>(async () => "OK");
|
|
80
|
+
const store = storeWith({ set });
|
|
81
|
+
|
|
82
|
+
await expect(store.set("key", "value", { ttlMs: 0 })).rejects.toThrow("ttlMs");
|
|
83
|
+
expect(set).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("RedisCacheStore integration", () => {
|
|
88
|
+
integrationTest("matches the Redis script SHA and counts atomically", async () => {
|
|
89
|
+
const previousUrl = process.env.REDIS_URL;
|
|
90
|
+
process.env.REDIS_URL = redisUrl;
|
|
91
|
+
const service = new RedisService();
|
|
92
|
+
const store = new RedisCacheStore(service);
|
|
93
|
+
const prefix = `podokit:test:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await service.connect();
|
|
97
|
+
await expect(
|
|
98
|
+
service.client.send("SCRIPT", ["LOAD", FIXED_WINDOW_SCRIPT]),
|
|
99
|
+
).resolves.toBe(FIXED_WINDOW_SCRIPT_SHA);
|
|
100
|
+
|
|
101
|
+
const counter = `${prefix}:counter`;
|
|
102
|
+
const options = { windowMs: 60_000, limit: 1 };
|
|
103
|
+
await expect(store.incrementFixedWindow(counter, options)).resolves.toMatchObject({
|
|
104
|
+
allowed: true,
|
|
105
|
+
count: 1,
|
|
106
|
+
});
|
|
107
|
+
await expect(store.incrementFixedWindow(counter, options)).resolves.toMatchObject({
|
|
108
|
+
allowed: false,
|
|
109
|
+
count: 2,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const expiring = `${prefix}:expiring`;
|
|
113
|
+
await store.set(expiring, "value", { ttlMs: 60_000 });
|
|
114
|
+
await expect(store.get(expiring)).resolves.toBe("value");
|
|
115
|
+
expect(await service.client.pttl(expiring)).toBeGreaterThan(0);
|
|
116
|
+
|
|
117
|
+
await service.del(counter);
|
|
118
|
+
await service.del(expiring);
|
|
119
|
+
} finally {
|
|
120
|
+
service.close();
|
|
121
|
+
if (previousUrl === undefined) delete process.env.REDIS_URL;
|
|
122
|
+
else process.env.REDIS_URL = previousUrl;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { RedisClient } from "bun";
|
|
2
3
|
import type {
|
|
3
4
|
CacheSetOptions,
|
|
@@ -7,13 +8,19 @@ import type {
|
|
|
7
8
|
} from "@podosoft/podokit-runtime";
|
|
8
9
|
import type { RedisService } from "./redis.service";
|
|
9
10
|
|
|
10
|
-
const FIXED_WINDOW_SCRIPT = `
|
|
11
|
+
export const FIXED_WINDOW_SCRIPT = `
|
|
11
12
|
local count = redis.call('INCR', KEYS[1])
|
|
12
13
|
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
|
13
14
|
local ttl = redis.call('PTTL', KEYS[1])
|
|
14
15
|
return { count, ttl }
|
|
15
16
|
`;
|
|
16
17
|
|
|
18
|
+
export const FIXED_WINDOW_SCRIPT_SHA = createHash("sha1").update(FIXED_WINDOW_SCRIPT).digest("hex");
|
|
19
|
+
|
|
20
|
+
function isMissingScript(error: unknown): boolean {
|
|
21
|
+
return error instanceof Error && error.message.startsWith("NOSCRIPT");
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
function resultNumber(value: unknown): number | undefined {
|
|
18
25
|
if (typeof value === "number") return value;
|
|
19
26
|
if (typeof value === "string") {
|
|
@@ -35,13 +42,14 @@ export class RedisCacheStore implements CacheStore {
|
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
async set(key: string, value: string, options: CacheSetOptions = {}): Promise<void> {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
if (options.ttlMs === undefined) {
|
|
46
|
+
await this.client.set(key, value);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!Number.isSafeInteger(options.ttlMs) || options.ttlMs < 1) {
|
|
50
|
+
throw new Error("ttlMs must be a positive integer");
|
|
44
51
|
}
|
|
52
|
+
await this.client.set(key, value, "PX", options.ttlMs);
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
async delete(key: string): Promise<boolean> {
|
|
@@ -58,12 +66,7 @@ export class RedisCacheStore implements CacheStore {
|
|
|
58
66
|
if (!Number.isSafeInteger(options.limit) || options.limit < 1) {
|
|
59
67
|
throw new Error("limit must be a positive integer");
|
|
60
68
|
}
|
|
61
|
-
const result = await this.
|
|
62
|
-
FIXED_WINDOW_SCRIPT,
|
|
63
|
-
"1",
|
|
64
|
-
key,
|
|
65
|
-
String(options.windowMs),
|
|
66
|
-
]);
|
|
69
|
+
const result = await this.runFixedWindow(key, options.windowMs);
|
|
67
70
|
if (!Array.isArray(result)) throw new Error("Redis returned an invalid cache counter");
|
|
68
71
|
const count = resultNumber(result[0]);
|
|
69
72
|
const ttlMs = resultNumber(result[1]);
|
|
@@ -78,5 +81,16 @@ export class RedisCacheStore implements CacheStore {
|
|
|
78
81
|
};
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
private async runFixedWindow(key: string, windowMs: number): Promise<unknown> {
|
|
85
|
+
const args = ["1", key, String(windowMs)];
|
|
86
|
+
try {
|
|
87
|
+
return await this.client.send("EVALSHA", [FIXED_WINDOW_SCRIPT_SHA, ...args]);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// Redis forgets scripts on restart, failover, or SCRIPT FLUSH; EVAL reloads it.
|
|
90
|
+
if (!isMissingScript(error)) throw error;
|
|
91
|
+
return this.client.send("EVAL", [FIXED_WINDOW_SCRIPT, ...args]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
close(): void {}
|
|
82
96
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Every listed field must match. Within a list, any entry matches. */
|
|
2
|
+
export interface EventAudience {
|
|
3
|
+
userIds?: string[];
|
|
4
|
+
roles?: string[];
|
|
5
|
+
organizationId?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface EventEnvelope<T = unknown> {
|
|
9
|
+
topic: string;
|
|
10
|
+
/** Omit to deliver to every stream subscribed to the topic. */
|
|
11
|
+
audience?: EventAudience;
|
|
12
|
+
data: T;
|
|
13
|
+
/** Sent as the SSE `id:`. Defaults to a per-connection sequence number. */
|
|
14
|
+
id?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The identity a stream was opened with, resolved once at connect. */
|
|
18
|
+
export interface EventSubscriber {
|
|
19
|
+
userId: string;
|
|
20
|
+
roles: readonly string[];
|
|
21
|
+
organizationId?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Topics and event ids share one charset so they can never break SSE framing. */
|
|
25
|
+
export const EVENT_NAME_PATTERN = "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$";
|
|
26
|
+
const EVENT_NAME = new RegExp(EVENT_NAME_PATTERN);
|
|
27
|
+
|
|
28
|
+
export class InvalidEventError extends TypeError {
|
|
29
|
+
constructor(message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "InvalidEventError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isEventName(value: unknown): value is string {
|
|
36
|
+
return typeof value === "string" && EVENT_NAME.test(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
40
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
41
|
+
? value as Record<string, unknown>
|
|
42
|
+
: undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stringList(value: unknown): string[] | undefined {
|
|
46
|
+
if (!Array.isArray(value)) return undefined;
|
|
47
|
+
const items: string[] = [];
|
|
48
|
+
for (const item of value) {
|
|
49
|
+
if (typeof item !== "string" || item.length === 0) return undefined;
|
|
50
|
+
items.push(item);
|
|
51
|
+
}
|
|
52
|
+
return items;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readAudience(value: unknown): EventAudience | string {
|
|
56
|
+
const source = record(value);
|
|
57
|
+
if (!source) return "audience must be an object";
|
|
58
|
+
const audience: EventAudience = {};
|
|
59
|
+
if (source.userIds !== undefined) {
|
|
60
|
+
const userIds = stringList(source.userIds);
|
|
61
|
+
if (!userIds) return "audience.userIds must be a list of non-empty strings";
|
|
62
|
+
audience.userIds = userIds;
|
|
63
|
+
}
|
|
64
|
+
if (source.roles !== undefined) {
|
|
65
|
+
const roles = stringList(source.roles);
|
|
66
|
+
if (!roles) return "audience.roles must be a list of non-empty strings";
|
|
67
|
+
audience.roles = roles;
|
|
68
|
+
}
|
|
69
|
+
if (source.organizationId !== undefined) {
|
|
70
|
+
if (typeof source.organizationId !== "string" || source.organizationId.length === 0) {
|
|
71
|
+
return "audience.organizationId must be a non-empty string";
|
|
72
|
+
}
|
|
73
|
+
audience.organizationId = source.organizationId;
|
|
74
|
+
}
|
|
75
|
+
// An empty audience is almost always a recipient list that failed to resolve;
|
|
76
|
+
// treating it as a broadcast would leak the event.
|
|
77
|
+
if (
|
|
78
|
+
audience.userIds === undefined &&
|
|
79
|
+
audience.roles === undefined &&
|
|
80
|
+
audience.organizationId === undefined
|
|
81
|
+
) {
|
|
82
|
+
return "audience must set userIds, roles, or organizationId";
|
|
83
|
+
}
|
|
84
|
+
return audience;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Normalizes an envelope, or returns why it is invalid. */
|
|
88
|
+
export function readEnvelope(value: unknown): EventEnvelope | string {
|
|
89
|
+
const source = record(value);
|
|
90
|
+
if (!source) return "event must be an object";
|
|
91
|
+
if (!isEventName(source.topic)) return `topic must match ${EVENT_NAME_PATTERN}`;
|
|
92
|
+
if (source.id !== undefined && !isEventName(source.id)) {
|
|
93
|
+
return `id must match ${EVENT_NAME_PATTERN}`;
|
|
94
|
+
}
|
|
95
|
+
const envelope: EventEnvelope = { topic: source.topic, data: source.data };
|
|
96
|
+
if (source.id !== undefined) envelope.id = source.id;
|
|
97
|
+
if (source.audience !== undefined) {
|
|
98
|
+
const audience = readAudience(source.audience);
|
|
99
|
+
if (typeof audience === "string") return audience;
|
|
100
|
+
envelope.audience = audience;
|
|
101
|
+
}
|
|
102
|
+
return envelope;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function validEnvelope(value: EventEnvelope): EventEnvelope {
|
|
106
|
+
const envelope = readEnvelope(value);
|
|
107
|
+
if (typeof envelope === "string") throw new InvalidEventError(`Invalid event: ${envelope}`);
|
|
108
|
+
return envelope;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type AudienceMatcher = (subscriber: EventSubscriber) => boolean;
|
|
112
|
+
|
|
113
|
+
export function audienceMatcher(audience: EventAudience | undefined): AudienceMatcher {
|
|
114
|
+
if (!audience) return () => true;
|
|
115
|
+
const userIds = audience.userIds ? new Set(audience.userIds) : undefined;
|
|
116
|
+
const roles = audience.roles ? new Set(audience.roles) : undefined;
|
|
117
|
+
const organizationId = audience.organizationId;
|
|
118
|
+
if (!userIds && !roles && organizationId === undefined) return () => false;
|
|
119
|
+
return (subscriber) =>
|
|
120
|
+
(!userIds || userIds.has(subscriber.userId)) &&
|
|
121
|
+
(!roles || subscriber.roles.some((role) => roles.has(role))) &&
|
|
122
|
+
(organizationId === undefined || subscriber.organizationId === organizationId);
|
|
123
|
+
}
|