@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
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
3
|
+
import { MemoryEventBus } from "@podosoft/podokit-runtime";
|
|
4
|
+
import { Elysia } from "elysia";
|
|
5
|
+
import type { RequestIdentity } from "../core/services";
|
|
6
|
+
import { ReadinessService } from "../health/readiness.service";
|
|
7
|
+
import {
|
|
8
|
+
eventsRoutes,
|
|
9
|
+
maxStreamsPerUser,
|
|
10
|
+
requestEventIdentity,
|
|
11
|
+
type EventIdentity,
|
|
12
|
+
} from "./events.module";
|
|
13
|
+
import { EventsService } from "./events.service";
|
|
14
|
+
|
|
15
|
+
/** Maps the `x-user`, `x-admin`, `x-roles`, and `x-org` test headers to the request identity. */
|
|
16
|
+
const headerIdentity: RequestIdentity = {
|
|
17
|
+
userId: async (request) => request.headers.get("x-user") ?? undefined,
|
|
18
|
+
isAdmin: async (request) => request.headers.get("x-admin") === "true",
|
|
19
|
+
roles: async (request) => (request.headers.get("x-roles") ?? "").split(",").filter(Boolean),
|
|
20
|
+
activeOrganizationId: async (request) => request.headers.get("x-org") ?? undefined,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const opened: Array<{ events: EventsService; controllers: AbortController[] }> = [];
|
|
24
|
+
|
|
25
|
+
async function setup(eventIdentity?: EventIdentity): Promise<{
|
|
26
|
+
events: EventsService;
|
|
27
|
+
send: (path: string, init?: RequestInit) => Promise<Response>;
|
|
28
|
+
}> {
|
|
29
|
+
const events = new EventsService(new MemoryEventBus(), new ReadinessService());
|
|
30
|
+
await events.connect();
|
|
31
|
+
const controllers: AbortController[] = [];
|
|
32
|
+
opened.push({ events, controllers });
|
|
33
|
+
const app = new Elysia()
|
|
34
|
+
.onError(({ code, error, set }) => {
|
|
35
|
+
if (code === "VALIDATION") {
|
|
36
|
+
set.status = 400;
|
|
37
|
+
return { code: "VALIDATION_ERROR" };
|
|
38
|
+
}
|
|
39
|
+
if (!(error instanceof AppException)) return undefined;
|
|
40
|
+
set.status = error.statusCode;
|
|
41
|
+
return { code: error.code };
|
|
42
|
+
})
|
|
43
|
+
.use(eventsRoutes(events, headerIdentity, eventIdentity));
|
|
44
|
+
return {
|
|
45
|
+
events,
|
|
46
|
+
send: (path, init = {}) => {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
controllers.push(controller);
|
|
49
|
+
return app.handle(new Request(`http://localhost${path}`, { ...init, signal: controller.signal }));
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function publish(
|
|
55
|
+
send: (path: string, init?: RequestInit) => Promise<Response>,
|
|
56
|
+
headers: Record<string, string>,
|
|
57
|
+
body: Record<string, unknown>,
|
|
58
|
+
): Promise<Response> {
|
|
59
|
+
return send("/events", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "content-type": "application/json", ...headers },
|
|
62
|
+
body: JSON.stringify(body),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class SseReader {
|
|
67
|
+
private readonly reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
68
|
+
private readonly decoder = new TextDecoder();
|
|
69
|
+
private buffered = "";
|
|
70
|
+
|
|
71
|
+
constructor(response: Response) {
|
|
72
|
+
const reader = response.body?.getReader();
|
|
73
|
+
if (!reader) throw new Error("missing body");
|
|
74
|
+
this.reader = reader;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Reads until `text` arrives and returns everything received up to it. */
|
|
78
|
+
async until(text: string): Promise<string> {
|
|
79
|
+
while (!this.buffered.includes(text)) {
|
|
80
|
+
const { value, done } = await this.reader.read();
|
|
81
|
+
if (done) throw new Error(`stream ended before ${text}`);
|
|
82
|
+
this.buffered += this.decoder.decode(value, { stream: true });
|
|
83
|
+
}
|
|
84
|
+
const end = this.buffered.indexOf(text) + text.length;
|
|
85
|
+
const seen = this.buffered.slice(0, end);
|
|
86
|
+
this.buffered = this.buffered.slice(end);
|
|
87
|
+
return seen;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
for (const { events, controllers } of opened.splice(0)) {
|
|
93
|
+
for (const controller of controllers) controller.abort();
|
|
94
|
+
await events.close();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("events routes", () => {
|
|
99
|
+
it("requires a signed-in user to stream", async () => {
|
|
100
|
+
const { send } = await setup();
|
|
101
|
+
const response = await send("/events/stream");
|
|
102
|
+
expect(response.status).toBe(401);
|
|
103
|
+
expect(await response.json()).toEqual({ code: "AUTH_REQUIRED" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects malformed topic lists", async () => {
|
|
107
|
+
const { send } = await setup();
|
|
108
|
+
for (const topics of ["", "a b", Array.from({ length: 33 }, (_, i) => `t${i}`).join(",")]) {
|
|
109
|
+
const response = await send(`/events/stream?topics=${encodeURIComponent(topics)}`, {
|
|
110
|
+
headers: { "x-user": "alice" },
|
|
111
|
+
});
|
|
112
|
+
expect(response.status).toBe(400);
|
|
113
|
+
expect(await response.json()).toEqual({ code: "EVENTS_TOPICS_INVALID" });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("refuses a guarded topic the user may not read", async () => {
|
|
118
|
+
const { events, send } = await setup();
|
|
119
|
+
events.topics.register("ops", (subscriber) => subscriber.roles.includes("admin"));
|
|
120
|
+
const denied = await send("/events/stream?topics=ops", { headers: { "x-user": "alice" } });
|
|
121
|
+
expect(denied.status).toBe(403);
|
|
122
|
+
expect(await denied.json()).toEqual({ code: "EVENTS_TOPIC_FORBIDDEN" });
|
|
123
|
+
const allowed = await send("/events/stream?topics=ops", {
|
|
124
|
+
headers: { "x-user": "root", "x-admin": "true", "x-roles": "admin" },
|
|
125
|
+
});
|
|
126
|
+
expect(allowed.status).toBe(200);
|
|
127
|
+
expect(allowed.headers.get("content-type")).toContain("text/event-stream");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("lets only administrators publish", async () => {
|
|
131
|
+
const { send } = await setup();
|
|
132
|
+
const anonymous = await publish(send, {}, { message: "hi" });
|
|
133
|
+
expect(anonymous.status).toBe(401);
|
|
134
|
+
const member = await publish(send, { "x-user": "alice" }, { message: "hi" });
|
|
135
|
+
expect(member.status).toBe(403);
|
|
136
|
+
expect(await member.json()).toEqual({ code: "ADMIN_REQUIRED" });
|
|
137
|
+
const admin = await publish(send, { "x-user": "root", "x-admin": "true" }, { message: "hi" });
|
|
138
|
+
expect(admin.status).toBe(201);
|
|
139
|
+
expect(await admin.json()).toEqual({ ok: true });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("rejects an empty or unknown audience field", async () => {
|
|
143
|
+
const { send } = await setup();
|
|
144
|
+
const admin = { "x-user": "root", "x-admin": "true" };
|
|
145
|
+
expect((await publish(send, admin, { message: "x", audience: {} })).status).toBe(400);
|
|
146
|
+
expect((await publish(send, admin, { message: "x", audience: { team: "a" } })).status).toBe(400);
|
|
147
|
+
expect((await publish(send, admin, { message: "x", topic: "bad topic" })).status).toBe(400);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("delivers an admin publish only to the addressed user", async () => {
|
|
151
|
+
const { send } = await setup();
|
|
152
|
+
const alice = new SseReader(await send("/events/stream?topics=notes", { headers: { "x-user": "alice" } }));
|
|
153
|
+
const bob = new SseReader(await send("/events/stream?topics=notes", { headers: { "x-user": "bob" } }));
|
|
154
|
+
expect(await alice.until("\n\n")).toBe("retry: 3000\n\n");
|
|
155
|
+
expect(await bob.until("\n\n")).toBe("retry: 3000\n\n");
|
|
156
|
+
const admin = { "x-user": "root", "x-admin": "true" };
|
|
157
|
+
|
|
158
|
+
await publish(send, admin, { topic: "notes", audience: { userIds: ["alice"] }, message: "secret" });
|
|
159
|
+
await publish(send, admin, { topic: "notes", message: "everyone" });
|
|
160
|
+
|
|
161
|
+
expect(await alice.until("everyone\"}}\n\n")).toBe(
|
|
162
|
+
'id: 1\ndata: {"topic":"notes","data":{"message":"secret"}}\n\n' +
|
|
163
|
+
'id: 2\ndata: {"topic":"notes","data":{"message":"everyone"}}\n\n',
|
|
164
|
+
);
|
|
165
|
+
expect(await bob.until("everyone")).not.toContain("secret");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("matches roles and organizations from a registered event identity", async () => {
|
|
169
|
+
const identity: EventIdentity = {
|
|
170
|
+
subscriber: async (request) => {
|
|
171
|
+
const userId = request.headers.get("x-user");
|
|
172
|
+
if (!userId) return undefined;
|
|
173
|
+
return { userId, roles: ["member"], organizationId: request.headers.get("x-org") ?? undefined };
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
const { send } = await setup(identity);
|
|
177
|
+
const inside = new SseReader(await send("/events/stream?topics=org", {
|
|
178
|
+
headers: { "x-user": "a", "x-org": "o1" },
|
|
179
|
+
}));
|
|
180
|
+
const outside = new SseReader(await send("/events/stream?topics=org", {
|
|
181
|
+
headers: { "x-user": "b", "x-org": "o2" },
|
|
182
|
+
}));
|
|
183
|
+
const admin = { "x-user": "root", "x-admin": "true" };
|
|
184
|
+
|
|
185
|
+
await publish(send, admin, {
|
|
186
|
+
topic: "org",
|
|
187
|
+
audience: { organizationId: "o1", roles: ["member"] },
|
|
188
|
+
message: "o1 only",
|
|
189
|
+
});
|
|
190
|
+
await publish(send, admin, { topic: "org", message: "done" });
|
|
191
|
+
|
|
192
|
+
expect(await inside.until("done")).toContain("o1 only");
|
|
193
|
+
expect(await outside.until("done")).not.toContain("o1 only");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("default event identity", () => {
|
|
198
|
+
it("carries the request roles and active organization", async () => {
|
|
199
|
+
const identity = requestEventIdentity(headerIdentity);
|
|
200
|
+
const request = new Request("http://localhost/events/stream", {
|
|
201
|
+
headers: { "x-user": "u1", "x-roles": "admin,editor", "x-org": "org-1" },
|
|
202
|
+
});
|
|
203
|
+
expect(await identity.subscriber(request)).toEqual({ userId: "u1", roles: ["admin", "editor"], organizationId: "org-1" });
|
|
204
|
+
expect(await identity.subscriber(new Request("http://localhost/events/stream", { headers: { "x-user": "u2" } })))
|
|
205
|
+
.toEqual({ userId: "u2", roles: [] });
|
|
206
|
+
expect(await identity.subscriber(new Request("http://localhost/events/stream"))).toBeUndefined();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("stream limit configuration", () => {
|
|
211
|
+
it("reads SSE_MAX_STREAMS_PER_USER and defaults to eight", () => {
|
|
212
|
+
expect(maxStreamsPerUser({})).toBe(8);
|
|
213
|
+
expect(maxStreamsPerUser({ SSE_MAX_STREAMS_PER_USER: "3" })).toBe(3);
|
|
214
|
+
for (const value of ["0", "-1", "1.5", "many"]) {
|
|
215
|
+
expect(() => maxStreamsPerUser({ SSE_MAX_STREAMS_PER_USER: value })).toThrow(
|
|
216
|
+
"SSE_MAX_STREAMS_PER_USER must be a positive integer",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("answers 429 EVENTS_STREAM_LIMIT over HTTP", async () => {
|
|
222
|
+
const events = new EventsService(new MemoryEventBus(), new ReadinessService(), {
|
|
223
|
+
maxStreamsPerUser: 1,
|
|
224
|
+
});
|
|
225
|
+
await events.connect();
|
|
226
|
+
const controllers: AbortController[] = [];
|
|
227
|
+
opened.push({ events, controllers });
|
|
228
|
+
const app = new Elysia()
|
|
229
|
+
.onError(({ error, set }) => {
|
|
230
|
+
if (!(error instanceof AppException)) return undefined;
|
|
231
|
+
set.status = error.statusCode;
|
|
232
|
+
return { code: error.code };
|
|
233
|
+
})
|
|
234
|
+
.use(eventsRoutes(events, headerIdentity));
|
|
235
|
+
const send = (): Promise<Response> => {
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
controllers.push(controller);
|
|
238
|
+
return app.handle(
|
|
239
|
+
new Request("http://localhost/events/stream", {
|
|
240
|
+
headers: { "x-user": "u1" },
|
|
241
|
+
signal: controller.signal,
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
};
|
|
245
|
+
expect((await send()).status).toBe(200);
|
|
246
|
+
const refused = await send();
|
|
247
|
+
expect(refused.status).toBe(429);
|
|
248
|
+
expect(await refused.json()).toEqual({ code: "EVENTS_STREAM_LIMIT" });
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -1,27 +1,116 @@
|
|
|
1
1
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
2
|
import { EVENTS } from "@podosoft/podokit-runtime";
|
|
3
|
-
import { Elysia,
|
|
3
|
+
import { Elysia, t } from "elysia";
|
|
4
4
|
import {
|
|
5
|
+
READINESS,
|
|
6
|
+
REQUEST_IDENTITY,
|
|
5
7
|
type AppPlugin,
|
|
8
|
+
type ElysiaPlugin,
|
|
6
9
|
type PodokitModule,
|
|
7
|
-
|
|
10
|
+
type RequestIdentity,
|
|
11
|
+
type ServiceKey,
|
|
8
12
|
} from "../core/services";
|
|
13
|
+
import {
|
|
14
|
+
EVENT_NAME_PATTERN,
|
|
15
|
+
InvalidEventError,
|
|
16
|
+
type EventEnvelope,
|
|
17
|
+
type EventSubscriber,
|
|
18
|
+
} from "./event-envelope";
|
|
19
|
+
import { eventStreamResponse } from "./event-stream";
|
|
20
|
+
import { parseTopics } from "./event-topics";
|
|
9
21
|
import { EventsService } from "./events.service";
|
|
10
22
|
|
|
11
|
-
export const SSE_EVENTS = Symbol("sse-events") as
|
|
23
|
+
export const SSE_EVENTS = Symbol("sse-events") as ServiceKey<EventsService>;
|
|
24
|
+
|
|
25
|
+
/** `SSE_MAX_STREAMS_PER_USER`: open streams one user may hold on each API replica. */
|
|
26
|
+
export function maxStreamsPerUser(env: Record<string, string | undefined> = process.env): number {
|
|
27
|
+
const value = Number(env.SSE_MAX_STREAMS_PER_USER ?? 8);
|
|
28
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
29
|
+
throw new Error("SSE_MAX_STREAMS_PER_USER must be a positive integer");
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolves who opens a stream. Register one in app.extensions.ts to replace the default. */
|
|
35
|
+
export interface EventIdentity {
|
|
36
|
+
subscriber(request: Request): Promise<EventSubscriber | undefined>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const EVENT_IDENTITY = Symbol("event-identity") as ServiceKey<EventIdentity>;
|
|
40
|
+
|
|
41
|
+
/** The default identity: the request user with their roles and active organization. */
|
|
42
|
+
export function requestEventIdentity(identity: RequestIdentity): EventIdentity {
|
|
43
|
+
return {
|
|
44
|
+
subscriber: async (request) => {
|
|
45
|
+
const userId = await identity.userId(request);
|
|
46
|
+
if (!userId) return undefined;
|
|
47
|
+
const [roles, organizationId] = await Promise.all([
|
|
48
|
+
identity.roles(request),
|
|
49
|
+
identity.activeOrganizationId(request),
|
|
50
|
+
]);
|
|
51
|
+
return organizationId === undefined ? { userId, roles } : { userId, roles, organizationId };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
12
55
|
|
|
13
|
-
const
|
|
14
|
-
|
|
56
|
+
const nameSchema = t.String({ pattern: EVENT_NAME_PATTERN });
|
|
57
|
+
const idListSchema = t.Array(t.String({ minLength: 1, maxLength: 256 }), { maxItems: 1_000 });
|
|
58
|
+
const audienceSchema = t.Object(
|
|
59
|
+
{
|
|
60
|
+
userIds: t.Optional(idListSchema),
|
|
61
|
+
roles: t.Optional(idListSchema),
|
|
62
|
+
organizationId: t.Optional(t.String({ minLength: 1, maxLength: 256 })),
|
|
63
|
+
},
|
|
64
|
+
{ additionalProperties: false, minProperties: 1 },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
async function requireAdmin(identity: RequestIdentity, request: Request): Promise<void> {
|
|
68
|
+
if (!(await identity.userId(request))) {
|
|
69
|
+
throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
|
|
70
|
+
}
|
|
71
|
+
if (!(await identity.isAdmin(request))) {
|
|
72
|
+
throw new AppException("ADMIN_REQUIRED", "Administrator access is required", 403);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function eventsRoutes(
|
|
77
|
+
events: EventsService,
|
|
78
|
+
identity: RequestIdentity,
|
|
79
|
+
eventIdentity: EventIdentity = requestEventIdentity(identity),
|
|
80
|
+
): ElysiaPlugin {
|
|
15
81
|
return new Elysia({ name: "podokit.events" })
|
|
16
|
-
.get("/events/stream", async
|
|
17
|
-
|
|
82
|
+
.get("/events/stream", async ({ request, query }) => {
|
|
83
|
+
const subscriber = await eventIdentity.subscriber(request);
|
|
84
|
+
if (!subscriber) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
|
|
85
|
+
const stream = await events.open(subscriber, parseTopics(query.topics), request.signal);
|
|
86
|
+
return eventStreamResponse(stream, events.retryMs);
|
|
18
87
|
}, {
|
|
19
|
-
|
|
88
|
+
query: t.Object({ topics: t.Optional(t.String({ maxLength: 4_096 })) }),
|
|
89
|
+
detail: {
|
|
90
|
+
tags: ["events"],
|
|
91
|
+
summary: "Stream server-sent events",
|
|
92
|
+
description:
|
|
93
|
+
"Signed-in users only. `topics` is a comma-separated list; without it the stream " +
|
|
94
|
+
"carries every topic that has no authorizer. Each data event is a default " +
|
|
95
|
+
"`message` whose data is `{ topic, data }`. A `resync` event means events were " +
|
|
96
|
+
"dropped and the client should reload its state. A user may hold " +
|
|
97
|
+
"`SSE_MAX_STREAMS_PER_USER` streams (default 8) on each API replica; another " +
|
|
98
|
+
"answers 429 `EVENTS_STREAM_LIMIT`.",
|
|
99
|
+
},
|
|
20
100
|
})
|
|
21
|
-
.post("/events", async ({ body, set }) => {
|
|
101
|
+
.post("/events", async ({ request, body, set }) => {
|
|
102
|
+
await requireAdmin(identity, request);
|
|
103
|
+
const event: EventEnvelope = {
|
|
104
|
+
topic: body.topic ?? "message",
|
|
105
|
+
data: { message: body.message },
|
|
106
|
+
...(body.audience ? { audience: body.audience } : {}),
|
|
107
|
+
};
|
|
22
108
|
try {
|
|
23
|
-
await events.publishAsync(
|
|
24
|
-
} catch {
|
|
109
|
+
await events.publishAsync(event);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof InvalidEventError) {
|
|
112
|
+
throw new AppException("EVENTS_INVALID", error.message, 400);
|
|
113
|
+
}
|
|
25
114
|
throw new AppException(
|
|
26
115
|
"EVENTS_TRANSPORT_UNAVAILABLE",
|
|
27
116
|
"Event transport is unavailable",
|
|
@@ -31,15 +120,32 @@ const eventsPlugin: AppPlugin = ({ services }) => {
|
|
|
31
120
|
set.status = 201;
|
|
32
121
|
return { ok: true as const };
|
|
33
122
|
}, {
|
|
34
|
-
body: t.Object({
|
|
35
|
-
|
|
123
|
+
body: t.Object({
|
|
124
|
+
message: t.String({ maxLength: 4_096 }),
|
|
125
|
+
topic: t.Optional(nameSchema),
|
|
126
|
+
audience: t.Optional(audienceSchema),
|
|
127
|
+
}),
|
|
128
|
+
detail: {
|
|
129
|
+
tags: ["events"],
|
|
130
|
+
summary: "Publish an event",
|
|
131
|
+
description: "Administrators only. Publishes `{ message }` to `topic` (default `message`).",
|
|
132
|
+
},
|
|
36
133
|
});
|
|
37
|
-
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const eventsPlugin: AppPlugin = ({ services }) =>
|
|
137
|
+
eventsRoutes(
|
|
138
|
+
services.resolve(SSE_EVENTS),
|
|
139
|
+
services.resolve(REQUEST_IDENTITY),
|
|
140
|
+
services.tryResolve(EVENT_IDENTITY),
|
|
141
|
+
);
|
|
38
142
|
|
|
39
143
|
export const eventsModule: PodokitModule = {
|
|
40
144
|
name: "sse",
|
|
41
145
|
configure: (_env, services): void => {
|
|
42
|
-
const events = new EventsService(services.resolve(EVENTS), services.resolve(READINESS)
|
|
146
|
+
const events = new EventsService(services.resolve(EVENTS), services.resolve(READINESS), {
|
|
147
|
+
maxStreamsPerUser: maxStreamsPerUser(),
|
|
148
|
+
});
|
|
43
149
|
services.register(SSE_EVENTS, events, () => events.close());
|
|
44
150
|
services.onStart(() => events.connect());
|
|
45
151
|
},
|