@podosoft/podokit 1.1.6 → 1.3.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/add.d.ts +3 -1
- package/dist/add.js +116 -14
- package/dist/deploy-render.js +5 -0
- package/dist/index.js +8 -0
- package/dist/lib.d.ts +2 -2
- package/dist/lib.js +3 -2
- package/dist/lockfile.d.ts +3 -1
- package/dist/lockfile.js +17 -6
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
- 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 +10 -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/data-source.spec.ts +15 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
- 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/database/migrate.ts +1 -1
- 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/package.json +1 -1
- 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-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
- 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/fullstack/tests/playwright.extensions.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -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-users/admin-users.routes.spec.ts +76 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
- 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 +102 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -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/guards.test.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -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/lib/server/public-paths.ts +9 -0
- 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 +79 -37
- 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/admin-users.api.spec.ts +86 -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-config.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
- 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/helpers/disposable-users.ts +5 -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/email-admin.ui.spec.ts +0 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -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 +29 -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 +32 -1
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- 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 +126 -3
- 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 +57 -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.rows.spec.ts +53 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
- 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/auth-config/session-expiry.spec.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -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/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
- 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 +250 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -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 +227 -70
- package/dist/templates/modules/sse/module.manifest.json +20 -5
- package/dist/update.d.ts +17 -0
- package/dist/update.js +33 -8
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { EventSubscriber } from "./event-envelope";
|
|
2
|
+
|
|
3
|
+
export type StreamFrame =
|
|
4
|
+
| { kind: "event"; id: string; data: string }
|
|
5
|
+
| { kind: "resync" }
|
|
6
|
+
| { kind: "heartbeat" };
|
|
7
|
+
|
|
8
|
+
type EventFrame = Extract<StreamFrame, { kind: "event" }>;
|
|
9
|
+
|
|
10
|
+
const RESYNC: StreamFrame = { kind: "resync" };
|
|
11
|
+
const HEARTBEAT: StreamFrame = { kind: "heartbeat" };
|
|
12
|
+
|
|
13
|
+
export interface Scheduler {
|
|
14
|
+
/** Runs `task` every `intervalMs` until the returned function is called. */
|
|
15
|
+
every(intervalMs: number, task: () => void): () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const timerScheduler: Scheduler = {
|
|
19
|
+
every: (intervalMs, task) => {
|
|
20
|
+
const timer = setInterval(task, intervalMs);
|
|
21
|
+
timer.unref();
|
|
22
|
+
return () => clearInterval(timer);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface EventStreamOptions {
|
|
27
|
+
queueLimit: number;
|
|
28
|
+
heartbeatMs: number;
|
|
29
|
+
maxConnectionMs: number;
|
|
30
|
+
scheduler: Scheduler;
|
|
31
|
+
now: () => number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One subscriber connection. Frames are pulled by the response body, so a slow
|
|
36
|
+
* client fills the bounded queue; on overflow the backlog is dropped and the
|
|
37
|
+
* client receives a single `resync` frame telling it to refetch state.
|
|
38
|
+
*/
|
|
39
|
+
export class EventStream {
|
|
40
|
+
private readonly queue: EventFrame[] = [];
|
|
41
|
+
private readonly openedAt: number;
|
|
42
|
+
private readonly onAbort = (): void => this.close();
|
|
43
|
+
private resyncPending = false;
|
|
44
|
+
private heartbeatPending = false;
|
|
45
|
+
private sequence = 0;
|
|
46
|
+
private closed = false;
|
|
47
|
+
private waiter?: () => void;
|
|
48
|
+
private stopHeartbeat?: () => void;
|
|
49
|
+
private signal?: AbortSignal;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
readonly subscriber: EventSubscriber,
|
|
53
|
+
readonly topics: ReadonlySet<string> | undefined,
|
|
54
|
+
private readonly options: EventStreamOptions,
|
|
55
|
+
private readonly onClose: (stream: EventStream) => void,
|
|
56
|
+
) {
|
|
57
|
+
this.openedAt = options.now();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get isClosed(): boolean {
|
|
61
|
+
return this.closed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Binds the stream to its request. An already aborted signal closes it at once. */
|
|
65
|
+
start(signal: AbortSignal): void {
|
|
66
|
+
if (this.closed) return;
|
|
67
|
+
if (signal.aborted) {
|
|
68
|
+
this.close();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.signal = signal;
|
|
72
|
+
signal.addEventListener("abort", this.onAbort, { once: true });
|
|
73
|
+
this.stopHeartbeat = this.options.scheduler.every(this.options.heartbeatMs, () => this.tick());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
offer(data: string, id: string | undefined): void {
|
|
77
|
+
if (this.closed) return;
|
|
78
|
+
if (this.queue.length >= this.options.queueLimit) {
|
|
79
|
+
this.queue.length = 0;
|
|
80
|
+
this.resyncPending = true;
|
|
81
|
+
} else {
|
|
82
|
+
this.queue.push({ kind: "event", id: id ?? String(++this.sequence), data });
|
|
83
|
+
}
|
|
84
|
+
this.wake();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Resolves the next frame, or undefined once the stream is closed. Single consumer. */
|
|
88
|
+
async next(): Promise<StreamFrame | undefined> {
|
|
89
|
+
while (!this.closed) {
|
|
90
|
+
const frame = this.take();
|
|
91
|
+
if (frame) return frame;
|
|
92
|
+
await new Promise<void>((resolve) => {
|
|
93
|
+
this.waiter = resolve;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
close(): void {
|
|
100
|
+
if (this.closed) return;
|
|
101
|
+
this.closed = true;
|
|
102
|
+
this.queue.length = 0;
|
|
103
|
+
this.stopHeartbeat?.();
|
|
104
|
+
this.stopHeartbeat = undefined;
|
|
105
|
+
this.signal?.removeEventListener("abort", this.onAbort);
|
|
106
|
+
this.signal = undefined;
|
|
107
|
+
this.onClose(this);
|
|
108
|
+
this.wake();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private take(): StreamFrame | undefined {
|
|
112
|
+
if (this.resyncPending) {
|
|
113
|
+
this.resyncPending = false;
|
|
114
|
+
return RESYNC;
|
|
115
|
+
}
|
|
116
|
+
const event = this.queue.shift();
|
|
117
|
+
if (event) {
|
|
118
|
+
this.heartbeatPending = false;
|
|
119
|
+
return event;
|
|
120
|
+
}
|
|
121
|
+
if (this.heartbeatPending) {
|
|
122
|
+
this.heartbeatPending = false;
|
|
123
|
+
return HEARTBEAT;
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private tick(): void {
|
|
129
|
+
// The identity was resolved at connect. Ending long-lived streams makes the
|
|
130
|
+
// client reconnect and re-authenticate, so revoked sessions and roles lapse.
|
|
131
|
+
if (this.options.now() - this.openedAt >= this.options.maxConnectionMs) {
|
|
132
|
+
this.close();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.heartbeatPending = true;
|
|
136
|
+
this.wake();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private wake(): void {
|
|
140
|
+
const waiter = this.waiter;
|
|
141
|
+
this.waiter = undefined;
|
|
142
|
+
waiter?.();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Data events use the default `message` type with `{ topic, data }` as JSON, so
|
|
148
|
+
* `EventSource.onmessage` keeps working. Control frames use a named event or a
|
|
149
|
+
* comment and never reach `onmessage`.
|
|
150
|
+
*/
|
|
151
|
+
export function encodeFrame(frame: StreamFrame): string {
|
|
152
|
+
switch (frame.kind) {
|
|
153
|
+
case "event":
|
|
154
|
+
return `id: ${frame.id}\ndata: ${frame.data}\n\n`;
|
|
155
|
+
case "resync":
|
|
156
|
+
return "event: resync\ndata: {}\n\n";
|
|
157
|
+
case "heartbeat":
|
|
158
|
+
return ": heartbeat\n\n";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function eventStreamResponse(stream: EventStream, retryMs: number): Response {
|
|
163
|
+
const encoder = new TextEncoder();
|
|
164
|
+
const body = new ReadableStream<Uint8Array>({
|
|
165
|
+
start(controller): void {
|
|
166
|
+
controller.enqueue(encoder.encode(`retry: ${retryMs}\n\n`));
|
|
167
|
+
},
|
|
168
|
+
async pull(controller): Promise<void> {
|
|
169
|
+
const frame = await stream.next();
|
|
170
|
+
if (frame) {
|
|
171
|
+
controller.enqueue(encoder.encode(encodeFrame(frame)));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
controller.close();
|
|
176
|
+
} catch {
|
|
177
|
+
// The body was cancelled while this pull was waiting.
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
cancel(): void {
|
|
181
|
+
stream.close();
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
return new Response(body, {
|
|
185
|
+
headers: {
|
|
186
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
187
|
+
"cache-control": "no-cache, no-transform",
|
|
188
|
+
"x-accel-buffering": "no",
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
+
import { EVENT_NAME_PATTERN, isEventName, type EventSubscriber } from "./event-envelope";
|
|
3
|
+
|
|
4
|
+
export const MAX_STREAM_TOPICS = 32;
|
|
5
|
+
|
|
6
|
+
export type TopicAuthorizer = (
|
|
7
|
+
subscriber: EventSubscriber,
|
|
8
|
+
topic: string,
|
|
9
|
+
) => boolean | Promise<boolean>;
|
|
10
|
+
|
|
11
|
+
interface PrefixAuthorizer {
|
|
12
|
+
prefix: string;
|
|
13
|
+
authorize: TopicAuthorizer;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Restricts who may subscribe to a topic. A topic without an authorizer is open to
|
|
18
|
+
* every signed-in user. A pattern ending in `*` guards every topic with that prefix,
|
|
19
|
+
* and every authorizer that matches a topic must allow the subscriber.
|
|
20
|
+
*/
|
|
21
|
+
export class TopicAuthorizers {
|
|
22
|
+
private readonly exact = new Map<string, TopicAuthorizer[]>();
|
|
23
|
+
private readonly prefixes: PrefixAuthorizer[] = [];
|
|
24
|
+
|
|
25
|
+
register(pattern: string, authorize: TopicAuthorizer): void {
|
|
26
|
+
if (pattern.endsWith("*")) {
|
|
27
|
+
const prefix = pattern.slice(0, -1);
|
|
28
|
+
if (!isEventName(prefix)) throw new TypeError(`Topic prefix must match ${EVENT_NAME_PATTERN}`);
|
|
29
|
+
this.prefixes.push({ prefix, authorize });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!isEventName(pattern)) throw new TypeError(`Topic must match ${EVENT_NAME_PATTERN}`);
|
|
33
|
+
this.exact.set(pattern, [...(this.exact.get(pattern) ?? []), authorize]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
guarded(topic: string): boolean {
|
|
37
|
+
return this.exact.has(topic) || this.prefixes.some(({ prefix }) => topic.startsWith(prefix));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async allows(subscriber: EventSubscriber, topic: string): Promise<boolean> {
|
|
41
|
+
const authorizers = [
|
|
42
|
+
...(this.exact.get(topic) ?? []),
|
|
43
|
+
...this.prefixes.filter(({ prefix }) => topic.startsWith(prefix)).map(({ authorize }) => authorize),
|
|
44
|
+
];
|
|
45
|
+
for (const authorize of authorizers) {
|
|
46
|
+
if (!(await authorize(subscriber, topic))) return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parses `?topics=a,b`. Undefined means every topic that has no authorizer. */
|
|
53
|
+
export function parseTopics(value: string | undefined): string[] | undefined {
|
|
54
|
+
if (value === undefined) return undefined;
|
|
55
|
+
const topics = [...new Set(value.split(",").map((topic) => topic.trim()).filter(Boolean))];
|
|
56
|
+
if (topics.length === 0 || topics.length > MAX_STREAM_TOPICS || !topics.every(isEventName)) {
|
|
57
|
+
throw new AppException(
|
|
58
|
+
"EVENTS_TOPICS_INVALID",
|
|
59
|
+
`topics must list 1 to ${MAX_STREAM_TOPICS} comma-separated names matching ${EVENT_NAME_PATTERN}`,
|
|
60
|
+
400,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return topics;
|
|
64
|
+
}
|