@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,136 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
3
|
+
import type { ObjectBody, ObjectInfo, ObjectStore } from "@podosoft/podokit-runtime";
|
|
4
|
+
import { Elysia } from "elysia";
|
|
5
|
+
import type { RequestIdentity } from "../core/services";
|
|
6
|
+
import { filesRoutes } from "./files.module";
|
|
7
|
+
|
|
8
|
+
async function bytes(body: ObjectBody): Promise<Uint8Array> {
|
|
9
|
+
if (typeof body === "string") return Buffer.from(body);
|
|
10
|
+
if (body instanceof Uint8Array) return body;
|
|
11
|
+
const chunks: Uint8Array[] = [];
|
|
12
|
+
for await (const chunk of body) chunks.push(chunk);
|
|
13
|
+
return Buffer.concat(chunks);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function streamOf(value: Uint8Array): ReadableStream<Uint8Array> {
|
|
17
|
+
return new ReadableStream({
|
|
18
|
+
start(controller): void {
|
|
19
|
+
controller.enqueue(value);
|
|
20
|
+
controller.close();
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function memoryStore(): ObjectStore {
|
|
26
|
+
const objects = new Map<string, Uint8Array>();
|
|
27
|
+
const info = (key: string): ObjectInfo | null => {
|
|
28
|
+
const value = objects.get(key);
|
|
29
|
+
return value
|
|
30
|
+
? { key, size: value.byteLength, etag: `"${value.byteLength}"`, lastModified: new Date(0) }
|
|
31
|
+
: null;
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
put: async (key, body, options) => {
|
|
35
|
+
const value = await bytes(body);
|
|
36
|
+
objects.set(key, value);
|
|
37
|
+
return { key, size: value.byteLength, ...options };
|
|
38
|
+
},
|
|
39
|
+
get: async (key) => {
|
|
40
|
+
const found = info(key);
|
|
41
|
+
const value = objects.get(key);
|
|
42
|
+
return found && value ? { ...found, body: streamOf(value) } : null;
|
|
43
|
+
},
|
|
44
|
+
stat: async (key) => info(key),
|
|
45
|
+
list: async (prefix) => ({ keys: [...objects.keys()].filter((key) => key.startsWith(prefix)).sort() }),
|
|
46
|
+
exists: async (key) => objects.has(key),
|
|
47
|
+
delete: async (key) => {
|
|
48
|
+
objects.delete(key);
|
|
49
|
+
},
|
|
50
|
+
getDownloadUrl: async (key) => `https://storage.example.com/${key}`,
|
|
51
|
+
close: () => undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function caller(userId: string | undefined, admin = false): RequestIdentity {
|
|
56
|
+
return {
|
|
57
|
+
userId: async () => userId,
|
|
58
|
+
isAdmin: async () => admin,
|
|
59
|
+
roles: async () => (admin ? ["admin"] : []),
|
|
60
|
+
activeOrganizationId: async () => undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Reply {
|
|
65
|
+
status: number;
|
|
66
|
+
body: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function send(store: ObjectStore, identity: RequestIdentity, request: Request): Promise<Reply> {
|
|
70
|
+
const app = new Elysia()
|
|
71
|
+
.onError(({ error, set }) => {
|
|
72
|
+
if (!(error instanceof AppException)) return undefined;
|
|
73
|
+
set.status = error.statusCode;
|
|
74
|
+
return { code: error.code };
|
|
75
|
+
})
|
|
76
|
+
.use(filesRoutes(store, identity));
|
|
77
|
+
const response = await app.handle(request);
|
|
78
|
+
return { status: response.status, body: await response.json() as Record<string, unknown> };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function upload(store: ObjectStore, identity: RequestIdentity): Promise<string> {
|
|
82
|
+
const form = new FormData();
|
|
83
|
+
form.append("file", new File(["hello"], "hello.txt", { type: "text/plain" }));
|
|
84
|
+
const reply = await send(store, identity, new Request("http://localhost/files", {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: form,
|
|
87
|
+
}));
|
|
88
|
+
expect(reply.status).toBe(201);
|
|
89
|
+
return String(reply.body.key);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function presign(store: ObjectStore, identity: RequestIdentity, key: string): Promise<Reply> {
|
|
93
|
+
return send(store, identity, new Request(`http://localhost/files/${encodeURIComponent(key)}/url`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("file upload routes", () => {
|
|
97
|
+
it("stores uploads under the uploader and presigns them for the owner", async () => {
|
|
98
|
+
const store = memoryStore();
|
|
99
|
+
const key = await upload(store, caller("user_1"));
|
|
100
|
+
|
|
101
|
+
expect(key).toMatch(/^uploads\/user_1\/[0-9a-f-]{36}-hello\.txt$/);
|
|
102
|
+
expect(await presign(store, caller("user_1"), key)).toEqual({
|
|
103
|
+
status: 200,
|
|
104
|
+
body: { url: `https://storage.example.com/${key}` },
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("refuses another user's upload but lets an administrator presign it", async () => {
|
|
109
|
+
const store = memoryStore();
|
|
110
|
+
const key = await upload(store, caller("user_1"));
|
|
111
|
+
|
|
112
|
+
expect(await presign(store, caller("user_2"), key)).toEqual({
|
|
113
|
+
status: 403,
|
|
114
|
+
body: { code: "FILE_FORBIDDEN" },
|
|
115
|
+
});
|
|
116
|
+
expect((await presign(store, caller("admin_1", true), key)).status).toBe(200);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does not presign keys outside uploads", async () => {
|
|
120
|
+
const store = memoryStore();
|
|
121
|
+
for (const key of ["site/favicon", "profile-images/a.png", "demo/hello"]) {
|
|
122
|
+
expect(await presign(store, caller("admin_1", true), key)).toEqual({
|
|
123
|
+
status: 404,
|
|
124
|
+
body: { code: "FILE_NOT_FOUND" },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("treats uploads from before ownership as administrator-only", async () => {
|
|
130
|
+
const store = memoryStore();
|
|
131
|
+
const legacy = "uploads/0b5f6b7e-8c1d-4d6e-9f00-112233445566-old.txt";
|
|
132
|
+
|
|
133
|
+
expect((await presign(store, caller("user_1"), legacy)).status).toBe(403);
|
|
134
|
+
expect((await presign(store, caller("admin_1", true), legacy)).status).toBe(200);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
1
2
|
import { Elysia, t } from "elysia";
|
|
2
|
-
import { OBJECT_STORAGE } from "@podosoft/podokit-runtime";
|
|
3
|
-
import
|
|
3
|
+
import { OBJECT_STORAGE, type ObjectStore } from "@podosoft/podokit-runtime";
|
|
4
|
+
import {
|
|
5
|
+
REQUEST_IDENTITY,
|
|
6
|
+
type AppPlugin,
|
|
7
|
+
type ElysiaPlugin,
|
|
8
|
+
type PodokitModule,
|
|
9
|
+
type RequestIdentity,
|
|
10
|
+
} from "../core/services";
|
|
11
|
+
import { uploadAccess, uploadKey } from "./upload-keys";
|
|
4
12
|
|
|
5
|
-
|
|
6
|
-
const storage = services.resolve(OBJECT_STORAGE);
|
|
13
|
+
export function filesRoutes(storage: ObjectStore, identity: RequestIdentity): ElysiaPlugin {
|
|
7
14
|
return new Elysia({ name: "podokit.files" })
|
|
8
|
-
.post("/files", async ({ body, set }) => {
|
|
9
|
-
const
|
|
10
|
-
const key = `uploads/${crypto.randomUUID()}-${safeName}`;
|
|
15
|
+
.post("/files", async ({ request, body, set }) => {
|
|
16
|
+
const key = uploadKey(await identity.userId(request), body.file.name);
|
|
11
17
|
await storage.put(
|
|
12
18
|
key,
|
|
13
19
|
new Uint8Array(await body.file.arrayBuffer()),
|
|
@@ -19,13 +25,28 @@ const filesPlugin: AppPlugin = ({ services }) => {
|
|
|
19
25
|
body: t.Object({ file: t.File() }),
|
|
20
26
|
detail: { tags: ["files"], summary: "Upload a file" },
|
|
21
27
|
})
|
|
22
|
-
.get("/files/:key/url", async ({ params }) =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
.get("/files/:key/url", async ({ request, params }) => {
|
|
29
|
+
const access = uploadAccess(params.key, {
|
|
30
|
+
userId: await identity.userId(request),
|
|
31
|
+
admin: await identity.isAdmin(request),
|
|
32
|
+
});
|
|
33
|
+
if (access === "not-found") throw new AppException("FILE_NOT_FOUND", "File not found", 404);
|
|
34
|
+
if (access === "forbidden") {
|
|
35
|
+
throw new AppException("FILE_FORBIDDEN", "You cannot access this file", 403);
|
|
36
|
+
}
|
|
37
|
+
return { url: await storage.getDownloadUrl(params.key) };
|
|
38
|
+
}, {
|
|
39
|
+
params: t.Object({ key: t.String({ minLength: 1, maxLength: 1024 }) }),
|
|
40
|
+
detail: {
|
|
41
|
+
tags: ["files"],
|
|
42
|
+
summary: "Presign a download of your upload",
|
|
43
|
+
description: "Pass the upload key URL-encoded as one path segment. Owners and administrators only.",
|
|
44
|
+
},
|
|
27
45
|
});
|
|
28
|
-
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const filesPlugin: AppPlugin = ({ services }) =>
|
|
49
|
+
filesRoutes(services.resolve(OBJECT_STORAGE), services.resolve(REQUEST_IDENTITY));
|
|
29
50
|
|
|
30
51
|
export const filesModule: PodokitModule = {
|
|
31
52
|
name: "file-upload",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { uploadAccess, uploadKey } from "./upload-keys";
|
|
3
|
+
|
|
4
|
+
const id = "0b5f6b7e-8c1d-4d6e-9f00-112233445566";
|
|
5
|
+
|
|
6
|
+
describe("uploadKey", () => {
|
|
7
|
+
it("places a signed-in user's upload under their id", () => {
|
|
8
|
+
expect(uploadKey("user_1", "my photo.png", id)).toBe(`uploads/user_1/${id}-my_photo.png`);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("keeps the unowned format when there is no signed-in user", () => {
|
|
12
|
+
expect(uploadKey(undefined, "a.txt", id)).toBe(`uploads/${id}-a.txt`);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("keeps file names to one bounded segment", () => {
|
|
16
|
+
expect(uploadKey("u", "../../etc/passwd", id)).toBe(`uploads/u/${id}-.._.._etc_passwd`);
|
|
17
|
+
expect(uploadKey("u", `${"x".repeat(300)}.png`, id)).toBe(`uploads/u/${id}-${"x".repeat(124)}.png`);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("refuses a user id that is not a safe key segment", () => {
|
|
21
|
+
expect(() => uploadKey("../admin", "a.txt", id)).toThrow("upload key segment");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("uploadAccess", () => {
|
|
26
|
+
const owner = { userId: "user_1", admin: false };
|
|
27
|
+
const other = { userId: "user_2", admin: false };
|
|
28
|
+
const admin = { userId: "admin_1", admin: true };
|
|
29
|
+
const anonymous = { userId: undefined, admin: false };
|
|
30
|
+
|
|
31
|
+
it("lets owners and administrators read owned uploads", () => {
|
|
32
|
+
const key = `uploads/user_1/${id}-a.txt`;
|
|
33
|
+
expect(uploadAccess(key, owner)).toBe("allowed");
|
|
34
|
+
expect(uploadAccess(key, admin)).toBe("allowed");
|
|
35
|
+
expect(uploadAccess(key, other)).toBe("forbidden");
|
|
36
|
+
expect(uploadAccess(key, anonymous)).toBe("forbidden");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps unowned uploads from signed-in users who are not administrators", () => {
|
|
40
|
+
const key = `uploads/${id}-a.txt`;
|
|
41
|
+
expect(uploadAccess(key, owner)).toBe("forbidden");
|
|
42
|
+
expect(uploadAccess(key, admin)).toBe("allowed");
|
|
43
|
+
expect(uploadAccess(key, anonymous)).toBe("allowed");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not presign keys owned by other features", () => {
|
|
47
|
+
for (const key of [
|
|
48
|
+
"site/favicon",
|
|
49
|
+
"profile-images/a.png",
|
|
50
|
+
"demo/hello",
|
|
51
|
+
"uploads/",
|
|
52
|
+
"uploads/user_1/",
|
|
53
|
+
"uploads/../site/favicon",
|
|
54
|
+
"uploads/user_1/../user_2/a.txt",
|
|
55
|
+
"uploads/user_1/nested/a.txt",
|
|
56
|
+
]) {
|
|
57
|
+
expect(uploadAccess(key, admin)).toBe("not-found");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const UPLOAD_KEY_PREFIX = "uploads/";
|
|
2
|
+
|
|
3
|
+
const OWNER_SEGMENT = /^[A-Za-z0-9_-]{1,128}$/;
|
|
4
|
+
const MAX_NAME_LENGTH = 128;
|
|
5
|
+
|
|
6
|
+
export type UploadAccess = "allowed" | "forbidden" | "not-found";
|
|
7
|
+
|
|
8
|
+
export interface UploadRequester {
|
|
9
|
+
userId: string | undefined;
|
|
10
|
+
admin: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Keys are `uploads/<userId>/<uuid>-<name>`. Without a signed-in user (an app
|
|
15
|
+
* without the auth module) the key has no owner segment, which is also the
|
|
16
|
+
* format every upload had before ownership existed.
|
|
17
|
+
*/
|
|
18
|
+
export function uploadKey(
|
|
19
|
+
userId: string | undefined,
|
|
20
|
+
fileName: string,
|
|
21
|
+
id: string = crypto.randomUUID(),
|
|
22
|
+
): string {
|
|
23
|
+
const safeName = fileName.replace(/[^\w.-]+/g, "_").slice(-MAX_NAME_LENGTH);
|
|
24
|
+
const name = `${id}-${safeName}`;
|
|
25
|
+
if (userId === undefined) return `${UPLOAD_KEY_PREFIX}${name}`;
|
|
26
|
+
if (!OWNER_SEGMENT.test(userId)) {
|
|
27
|
+
throw new Error("The user id cannot be used as an upload key segment");
|
|
28
|
+
}
|
|
29
|
+
return `${UPLOAD_KEY_PREFIX}${userId}/${name}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Owners and administrators may read owned uploads. Unowned uploads are
|
|
34
|
+
* readable by administrators, and by requests without a user only when no
|
|
35
|
+
* identity provider is installed; with auth, such requests never reach the
|
|
36
|
+
* route because the session guard rejects them first.
|
|
37
|
+
*/
|
|
38
|
+
export function uploadAccess(key: string, requester: UploadRequester): UploadAccess {
|
|
39
|
+
if (!key.startsWith(UPLOAD_KEY_PREFIX)) return "not-found";
|
|
40
|
+
const segments = key.slice(UPLOAD_KEY_PREFIX.length).split("/");
|
|
41
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
42
|
+
return "not-found";
|
|
43
|
+
}
|
|
44
|
+
if (segments.length === 1) {
|
|
45
|
+
return requester.admin || requester.userId === undefined ? "allowed" : "forbidden";
|
|
46
|
+
}
|
|
47
|
+
if (segments.length === 2) {
|
|
48
|
+
return requester.admin || segments[0] === requester.userId ? "allowed" : "forbidden";
|
|
49
|
+
}
|
|
50
|
+
return "not-found";
|
|
51
|
+
}
|
|
@@ -1,20 +1,46 @@
|
|
|
1
|
-
import { expect, test } from "@playwright/test";
|
|
1
|
+
import { expect, test, type APIRequestContext, type APIResponse, type PlaywrightWorkerArgs } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
4
|
const origin = { origin: base };
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
type Playwright = PlaywrightWorkerArgs["playwright"];
|
|
7
|
+
|
|
8
|
+
interface Session {
|
|
9
|
+
ctx: APIRequestContext;
|
|
10
|
+
/** Undefined when the app has no auth module. */
|
|
11
|
+
userId: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function session(playwright: Playwright): Promise<Session> {
|
|
7
15
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
8
16
|
const email = `files-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
9
17
|
await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "F" } }).catch(() => undefined);
|
|
10
|
-
|
|
18
|
+
const current = await ctx.get("/api/auth/get-session");
|
|
19
|
+
const body = current.ok() ? await current.json() as { user?: { id?: string } } | null : null;
|
|
20
|
+
return { ctx, userId: body?.user?.id };
|
|
11
21
|
}
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
multipart: { file: { name: "hello.txt", mimeType: "text/plain", buffer: Buffer.from("hi there") } },
|
|
23
|
+
function upload(ctx: APIRequestContext, text = "hi there"): Promise<APIResponse> {
|
|
24
|
+
return ctx.post("/api/files", {
|
|
25
|
+
multipart: { file: { name: "hello.txt", mimeType: "text/plain", buffer: Buffer.from(text) } },
|
|
17
26
|
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function uploadedKey(ctx: APIRequestContext): Promise<string> {
|
|
30
|
+
const res = await upload(ctx);
|
|
31
|
+
test.skip(res.status() >= 500, "selected object storage is not reachable");
|
|
32
|
+
expect(res.status(), await res.text()).toBe(201);
|
|
33
|
+
return (await res.json() as { key: string }).key;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Keys contain slashes, so they travel URL-encoded as one path segment.
|
|
37
|
+
function presign(ctx: APIRequestContext, key: string): Promise<APIResponse> {
|
|
38
|
+
return ctx.get(`/api/files/${encodeURIComponent(key)}/url`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("file upload: multipart upload returns a key and url @smoke", async ({ playwright }) => {
|
|
42
|
+
const { ctx, userId } = await session(playwright);
|
|
43
|
+
const res = await upload(ctx);
|
|
18
44
|
const responseText = await res.text();
|
|
19
45
|
test.skip(res.status() >= 500, "selected object storage is not reachable");
|
|
20
46
|
expect(res.ok(), `upload returned ${res.status()}: ${responseText}`).toBeTruthy();
|
|
@@ -24,13 +50,48 @@ test("file upload: multipart upload returns a key and url @smoke", async ({ play
|
|
|
24
50
|
if (typeof body !== "object" || body === null) throw new Error("upload response must be an object");
|
|
25
51
|
const response = body as Record<string, unknown>;
|
|
26
52
|
expect(typeof response.key).toBe("string");
|
|
27
|
-
expect(String(response.
|
|
53
|
+
if (userId) expect(String(response.key)).toMatch(new RegExp(`^uploads/${userId}/[0-9a-f-]{36}-hello\\.txt$`));
|
|
54
|
+
// S3 returns an absolute presigned URL; local storage returns a signed API path.
|
|
55
|
+
expect(String(response.url)).toMatch(/^(https?:\/\/|\/files\/content\/)/);
|
|
28
56
|
await ctx.dispose();
|
|
29
57
|
});
|
|
30
58
|
|
|
31
59
|
test("file upload: a request with no file is a 400", async ({ playwright }) => {
|
|
32
|
-
const ctx = await session(playwright);
|
|
60
|
+
const { ctx } = await session(playwright);
|
|
33
61
|
const res = await ctx.post("/api/files", { multipart: { notfile: "x" } });
|
|
34
62
|
expect(res.status()).toBe(400);
|
|
35
63
|
await ctx.dispose();
|
|
36
64
|
});
|
|
65
|
+
|
|
66
|
+
test("file upload: the uploader can presign their own file", async ({ playwright }) => {
|
|
67
|
+
const { ctx } = await session(playwright);
|
|
68
|
+
try {
|
|
69
|
+
const key = await uploadedKey(ctx);
|
|
70
|
+
const res = await presign(ctx, key);
|
|
71
|
+
expect(res.status(), await res.text()).toBe(200);
|
|
72
|
+
const { url } = await res.json() as { url: string };
|
|
73
|
+
expect(url).toContain(key.split("/").pop() ?? key);
|
|
74
|
+
} finally {
|
|
75
|
+
await ctx.dispose();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("file upload: users cannot presign another user's file", async ({ playwright }) => {
|
|
80
|
+
const owner = await session(playwright);
|
|
81
|
+
const other = await session(playwright);
|
|
82
|
+
try {
|
|
83
|
+
test.skip(!owner.userId || !other.userId, "requires the auth module");
|
|
84
|
+
const key = await uploadedKey(owner.ctx);
|
|
85
|
+
|
|
86
|
+
const forbidden = await presign(other.ctx, key);
|
|
87
|
+
expect(forbidden.status()).toBe(403);
|
|
88
|
+
expect(await forbidden.json()).toMatchObject({ error: { code: "FILE_FORBIDDEN" } });
|
|
89
|
+
|
|
90
|
+
const foreign = await presign(other.ctx, "site/favicon");
|
|
91
|
+
expect(foreign.status()).toBe(404);
|
|
92
|
+
expect(await foreign.json()).toMatchObject({ error: { code: "FILE_NOT_FOUND" } });
|
|
93
|
+
} finally {
|
|
94
|
+
await owner.ctx.dispose();
|
|
95
|
+
await other.ctx.dispose();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifestVersion": 3,
|
|
3
3
|
"name": "file-upload",
|
|
4
|
-
"description": "Multipart file upload endpoint that stores files via object storage and
|
|
4
|
+
"description": "Multipart file upload endpoint that stores each user's files via object storage and presigns them only for the uploader or an administrator.",
|
|
5
5
|
"requiresCapabilities": ["object-storage"],
|
|
6
6
|
"targetApp": "api",
|
|
7
|
+
"requiredProjectContents": {
|
|
8
|
+
"apps/api/src/core/services.ts": ["isAdmin(request: Request): Promise<boolean>;"]
|
|
9
|
+
},
|
|
7
10
|
"env": [],
|
|
8
11
|
"inject": [
|
|
9
12
|
{ "file": "apps/api/src/app.ts", "marker": "// podokit:end:imports", "text": "import { filesModule } from \"./files/files.module\";" },
|
|
@@ -11,7 +14,10 @@
|
|
|
11
14
|
],
|
|
12
15
|
"instructions": [
|
|
13
16
|
"Configure the selected object-storage provider before uploading files.",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
17
|
+
"Uploads are stored as uploads/<userId>/<uuid>-<name>. Add the auth module so every upload has an owner.",
|
|
18
|
+
"Upload a file: curl -b cookies.txt -F 'file=@./some.png' localhost:5002/files",
|
|
19
|
+
"The response includes { key, url } — an S3 presigned URL or a signed local download URL.",
|
|
20
|
+
"Presign it again (owner or administrator, key URL-encoded): curl -b cookies.txt localhost:5002/files/uploads%2F<userId>%2F<file>/url",
|
|
21
|
+
"Uploads stored before per-user keys (uploads/<file>) can only be presigned by administrators."
|
|
16
22
|
]
|
|
17
23
|
}
|
package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts
CHANGED
|
@@ -1,46 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
+
import { JOBS, type JobQueue } from "@podosoft/podokit-runtime";
|
|
2
3
|
import { Elysia, t } from "elysia";
|
|
3
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
REQUEST_IDENTITY,
|
|
6
|
+
type AppPlugin,
|
|
7
|
+
type ElysiaPlugin,
|
|
8
|
+
type PodokitModule,
|
|
9
|
+
type RequestIdentity,
|
|
10
|
+
type ServiceKey,
|
|
11
|
+
} from "../core/services";
|
|
4
12
|
import { SSE_EVENTS } from "../events/events.module";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
class ProgressWorker {
|
|
8
|
-
private unsubscribe?: Unsubscribe;
|
|
9
|
-
|
|
10
|
-
constructor(
|
|
11
|
-
private readonly queue: JobQueue,
|
|
12
|
-
private readonly publish: (event: unknown) => Promise<void>,
|
|
13
|
-
) {}
|
|
14
|
-
|
|
15
|
-
async connect(): Promise<void> {
|
|
16
|
-
this.unsubscribe = await this.queue.process("progress", async (payload, job) => {
|
|
17
|
-
await processProgress(payload, (progress) => this.publish({
|
|
18
|
-
type: "job-progress",
|
|
19
|
-
jobId: job.id,
|
|
20
|
-
progress,
|
|
21
|
-
}));
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async close(): Promise<void> {
|
|
26
|
-
await this.unsubscribe?.();
|
|
27
|
-
}
|
|
28
|
-
}
|
|
13
|
+
import { PROGRESS_JOB } from "./progress.processor";
|
|
14
|
+
import { ProgressWorker } from "./progress.worker";
|
|
29
15
|
|
|
30
16
|
const PROGRESS_WORKER = Symbol("progress-worker") as ServiceKey<ProgressWorker>;
|
|
31
17
|
|
|
32
|
-
|
|
33
|
-
const queue = services.resolve(JOBS);
|
|
18
|
+
export function progressRoutes(queue: JobQueue, identity: RequestIdentity): ElysiaPlugin {
|
|
34
19
|
return new Elysia({ name: "podokit.job-progress" })
|
|
35
|
-
.post("/progress", async ({ body, set }) => {
|
|
36
|
-
const
|
|
20
|
+
.post("/progress", async ({ request, body, set }) => {
|
|
21
|
+
const ownerId = await identity.userId(request);
|
|
22
|
+
if (!ownerId) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
|
|
23
|
+
const job = await queue.enqueue(PROGRESS_JOB, { steps: body.steps ?? 5, ownerId });
|
|
37
24
|
set.status = 201;
|
|
38
25
|
return { jobId: job.id };
|
|
39
26
|
}, {
|
|
40
27
|
body: t.Object({ steps: t.Optional(t.Integer({ minimum: 1, maximum: 100 })) }),
|
|
41
|
-
detail: {
|
|
28
|
+
detail: {
|
|
29
|
+
tags: ["progress"],
|
|
30
|
+
summary: "Start a progress job",
|
|
31
|
+
description: "Progress is published on the `job-progress` topic to the caller only.",
|
|
32
|
+
},
|
|
42
33
|
});
|
|
43
|
-
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const progressPlugin: AppPlugin = ({ services }) =>
|
|
37
|
+
progressRoutes(services.resolve(JOBS), services.resolve(REQUEST_IDENTITY));
|
|
44
38
|
|
|
45
39
|
export const jobProgressModule: PodokitModule = {
|
|
46
40
|
name: "job-progress",
|
package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
-
import { processProgress } from "./progress.processor";
|
|
2
|
+
import { processProgress, progressEvent, progressPayload } from "./progress.processor";
|
|
3
3
|
|
|
4
4
|
describe("progress processor", () => {
|
|
5
5
|
it("publishes provider-neutral progress updates", async () => {
|
|
@@ -10,4 +10,19 @@ describe("progress processor", () => {
|
|
|
10
10
|
expect(values).toEqual([25, 50, 75, 100]);
|
|
11
11
|
expect(delay).toHaveBeenCalledTimes(4);
|
|
12
12
|
});
|
|
13
|
+
|
|
14
|
+
it("reads the owner from the job payload", () => {
|
|
15
|
+
expect(progressPayload({ steps: 2, ownerId: "alice" })).toEqual({ steps: 2, ownerId: "alice" });
|
|
16
|
+
expect(progressPayload({ ownerId: "" })).toEqual({});
|
|
17
|
+
expect(progressPayload({ ownerId: 7 })).toEqual({});
|
|
18
|
+
expect(progressPayload(null)).toEqual({});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("addresses progress events to the owner", () => {
|
|
22
|
+
expect(progressEvent("job-1", "alice", 40)).toEqual({
|
|
23
|
+
topic: "job-progress",
|
|
24
|
+
audience: { userIds: ["alice"] },
|
|
25
|
+
data: { jobId: "job-1", progress: 40 },
|
|
26
|
+
});
|
|
27
|
+
});
|
|
13
28
|
});
|
package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts
CHANGED
|
@@ -1,11 +1,38 @@
|
|
|
1
|
+
import type { EventEnvelope } from "../events/event-envelope";
|
|
2
|
+
|
|
3
|
+
export const PROGRESS_JOB = "progress";
|
|
4
|
+
export const JOB_PROGRESS_TOPIC = "job-progress";
|
|
5
|
+
|
|
1
6
|
export interface ProgressPayload {
|
|
2
7
|
steps?: number;
|
|
8
|
+
/** The user who started the job; only that user receives its progress. */
|
|
9
|
+
ownerId?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface JobProgress {
|
|
13
|
+
jobId: string;
|
|
14
|
+
progress: number;
|
|
3
15
|
}
|
|
4
16
|
|
|
5
|
-
function
|
|
17
|
+
export function progressPayload(value: unknown): ProgressPayload {
|
|
6
18
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
7
|
-
const steps =
|
|
8
|
-
return
|
|
19
|
+
const { steps, ownerId } = value as Record<string, unknown>;
|
|
20
|
+
return {
|
|
21
|
+
...(typeof steps === "number" ? { steps } : {}),
|
|
22
|
+
...(typeof ownerId === "string" && ownerId.length > 0 ? { ownerId } : {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function progressEvent(
|
|
27
|
+
jobId: string,
|
|
28
|
+
ownerId: string,
|
|
29
|
+
progress: number,
|
|
30
|
+
): EventEnvelope<JobProgress> {
|
|
31
|
+
return {
|
|
32
|
+
topic: JOB_PROGRESS_TOPIC,
|
|
33
|
+
audience: { userIds: [ownerId] },
|
|
34
|
+
data: { jobId, progress },
|
|
35
|
+
};
|
|
9
36
|
}
|
|
10
37
|
|
|
11
38
|
export async function processProgress(
|
|
@@ -13,7 +40,7 @@ export async function processProgress(
|
|
|
13
40
|
publish: (progress: number) => Promise<void>,
|
|
14
41
|
delay: (milliseconds: number) => Promise<unknown> = Bun.sleep,
|
|
15
42
|
): Promise<{ done: true }> {
|
|
16
|
-
const steps =
|
|
43
|
+
const steps = progressPayload(value).steps ?? 5;
|
|
17
44
|
for (let index = 1; index <= steps; index += 1) {
|
|
18
45
|
await delay(400);
|
|
19
46
|
const progress = Math.round((index / steps) * 100);
|