@podosoft/podokit 1.1.5 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deploy-render.js +5 -0
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
- package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
- package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
- package/dist/templates/fullstack/compose.dev.yaml +21 -0
- package/dist/templates/fullstack/dot-env.docker +3 -0
- package/dist/templates/fullstack/dot-env.example +14 -0
- package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
- package/dist/templates/modules/audit-log/module.manifest.json +7 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +17 -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/schema-validation.ts +13 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
- package/dist/templates/modules/auth/module.manifest.json +2 -2
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
- package/dist/templates/modules/file-upload/module.manifest.json +9 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
- package/dist/templates/modules/job-progress/module.manifest.json +3 -3
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
- package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
- package/dist/templates/modules/sse/module.manifest.json +17 -4
- package/package.json +2 -2
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
3
|
+
import { Elysia } from "elysia";
|
|
4
|
+
import type { RequestIdentity } from "../core/services";
|
|
5
|
+
import { storageDemoRoutes, type DemoStorage } from "./storage.module";
|
|
6
|
+
|
|
7
|
+
interface MemoryStorage extends DemoStorage {
|
|
8
|
+
objects: Map<string, string>;
|
|
9
|
+
presigned: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function memoryStorage(): MemoryStorage {
|
|
13
|
+
const objects = new Map<string, string>();
|
|
14
|
+
const presigned: string[] = [];
|
|
15
|
+
return {
|
|
16
|
+
objects,
|
|
17
|
+
presigned,
|
|
18
|
+
put: async (key, body) => {
|
|
19
|
+
if (typeof body === "string") objects.set(key, body);
|
|
20
|
+
else if (body instanceof Blob) objects.set(key, await body.text());
|
|
21
|
+
else objects.set(key, Buffer.from(body).toString("utf8"));
|
|
22
|
+
},
|
|
23
|
+
open: async (key) => {
|
|
24
|
+
const content = objects.get(key);
|
|
25
|
+
if (content === undefined) return null;
|
|
26
|
+
const bytes = Buffer.from(content);
|
|
27
|
+
return {
|
|
28
|
+
key,
|
|
29
|
+
size: bytes.byteLength,
|
|
30
|
+
contentType: "text/plain",
|
|
31
|
+
etag: `"${bytes.toString("hex")}"`,
|
|
32
|
+
lastModified: new Date("2026-01-02T03:04:05Z"),
|
|
33
|
+
body: new Blob([bytes]).stream(),
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
presignedGetUrl: async (key) => {
|
|
37
|
+
presigned.push(key);
|
|
38
|
+
return `https://storage.example.com/${key}?signature=test`;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function caller(userId: string | undefined, admin = false): RequestIdentity {
|
|
44
|
+
return {
|
|
45
|
+
userId: async () => userId,
|
|
46
|
+
isAdmin: async () => admin,
|
|
47
|
+
roles: async () => (admin ? ["admin"] : []),
|
|
48
|
+
activeOrganizationId: async () => undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function call(
|
|
53
|
+
storage: DemoStorage,
|
|
54
|
+
identity: RequestIdentity,
|
|
55
|
+
method: "GET" | "PUT",
|
|
56
|
+
path: string,
|
|
57
|
+
options: { content?: string; headers?: Record<string, string> } = {},
|
|
58
|
+
): Promise<Response> {
|
|
59
|
+
const app = new Elysia()
|
|
60
|
+
.onError(({ code, error, set }) => {
|
|
61
|
+
if (error instanceof AppException) {
|
|
62
|
+
set.status = error.statusCode;
|
|
63
|
+
return { code: error.code };
|
|
64
|
+
}
|
|
65
|
+
if (code === "VALIDATION") {
|
|
66
|
+
set.status = 400;
|
|
67
|
+
return { code: "VALIDATION_ERROR" };
|
|
68
|
+
}
|
|
69
|
+
if (code === "NOT_FOUND") {
|
|
70
|
+
set.status = 404;
|
|
71
|
+
return { code: "NOT_FOUND" };
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
})
|
|
75
|
+
.use(storageDemoRoutes(storage, identity));
|
|
76
|
+
return app.handle(new Request(`http://localhost${path}`, {
|
|
77
|
+
method,
|
|
78
|
+
headers: {
|
|
79
|
+
...(options.content === undefined ? {} : { "content-type": "application/json" }),
|
|
80
|
+
...options.headers,
|
|
81
|
+
},
|
|
82
|
+
...(options.content === undefined ? {} : { body: JSON.stringify({ content: options.content }) }),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function reply(response: Promise<Response>): Promise<{ status: number; body: unknown }> {
|
|
87
|
+
const resolved = await response;
|
|
88
|
+
return { status: resolved.status, body: await resolved.json() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("object storage demo routes", () => {
|
|
92
|
+
it("require a signed-in administrator", async () => {
|
|
93
|
+
const storage = memoryStorage();
|
|
94
|
+
|
|
95
|
+
expect(await reply(call(storage, caller(undefined), "PUT", "/storage/hello", { content: "x" }))).toEqual({
|
|
96
|
+
status: 401,
|
|
97
|
+
body: { code: "AUTH_REQUIRED" },
|
|
98
|
+
});
|
|
99
|
+
for (const [method, path] of [
|
|
100
|
+
["PUT", "/storage/hello"],
|
|
101
|
+
["GET", "/storage/hello"],
|
|
102
|
+
["GET", "/storage/hello/presigned"],
|
|
103
|
+
] as const) {
|
|
104
|
+
const response = call(storage, caller("user-1"), method, path, method === "PUT" ? { content: "x" } : {});
|
|
105
|
+
expect(await reply(response)).toEqual({ status: 403, body: { code: "ADMIN_REQUIRED" } });
|
|
106
|
+
}
|
|
107
|
+
expect(storage.objects.size).toBe(0);
|
|
108
|
+
expect(storage.presigned).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("stores, streams, and presigns objects under the demo prefix for administrators", async () => {
|
|
112
|
+
const storage = memoryStorage();
|
|
113
|
+
const admin = caller("admin-1", true);
|
|
114
|
+
|
|
115
|
+
expect(await reply(call(storage, admin, "PUT", "/storage/hello", { content: "hi" }))).toEqual({
|
|
116
|
+
status: 200,
|
|
117
|
+
body: { key: "hello" },
|
|
118
|
+
});
|
|
119
|
+
expect([...storage.objects.keys()]).toEqual(["demo/hello"]);
|
|
120
|
+
|
|
121
|
+
const read = await call(storage, admin, "GET", "/storage/hello");
|
|
122
|
+
expect(read.status).toBe(200);
|
|
123
|
+
expect(read.headers.get("content-type")).toBe("text/plain");
|
|
124
|
+
expect(read.headers.get("content-length")).toBe("2");
|
|
125
|
+
expect(read.headers.get("x-content-type-options")).toBe("nosniff");
|
|
126
|
+
const etag = read.headers.get("etag");
|
|
127
|
+
expect(etag).toBe("\"6869\"");
|
|
128
|
+
expect(await read.text()).toBe("hi");
|
|
129
|
+
|
|
130
|
+
const revalidated = await call(storage, admin, "GET", "/storage/hello", {
|
|
131
|
+
headers: { "if-none-match": etag ?? "" },
|
|
132
|
+
});
|
|
133
|
+
expect(revalidated.status).toBe(304);
|
|
134
|
+
expect(await revalidated.text()).toBe("");
|
|
135
|
+
|
|
136
|
+
const presigned = await call(storage, admin, "GET", "/storage/hello/presigned");
|
|
137
|
+
expect(presigned.status).toBe(200);
|
|
138
|
+
expect(storage.presigned).toEqual(["demo/hello"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("reports a missing demo object as not found", async () => {
|
|
142
|
+
const response = call(memoryStorage(), caller("admin-1", true), "GET", "/storage/missing");
|
|
143
|
+
expect(await reply(response)).toEqual({ status: 404, body: { code: "STORAGE_OBJECT_NOT_FOUND" } });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rejects keys that would leave the demo prefix", async () => {
|
|
147
|
+
const storage = memoryStorage();
|
|
148
|
+
const admin = caller("admin-1", true);
|
|
149
|
+
|
|
150
|
+
for (const key of ["site%2Ffavicon", "profile-images%2Fa.png", "..%2Fsite%2Ffavicon", ".podokit", "%2E%2E"]) {
|
|
151
|
+
const response = await call(storage, admin, "PUT", `/storage/${key}`, { content: "x" });
|
|
152
|
+
expect([400, 404]).toContain(response.status);
|
|
153
|
+
}
|
|
154
|
+
expect(storage.objects.size).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
+
import { t } from "elysia";
|
|
3
|
+
import type { RequestIdentity } from "../core/services";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The demo routes only reach objects under this prefix, so even an
|
|
7
|
+
* administrator cannot overwrite keys owned by other features such as
|
|
8
|
+
* uploads, profile images, or the site favicon.
|
|
9
|
+
*/
|
|
10
|
+
export const DEMO_KEY_PREFIX = "demo/";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One path segment. Elysia decodes `%2F` inside `:key`, so the pattern rejects
|
|
14
|
+
* slashes and leading dots instead of trusting the router.
|
|
15
|
+
*/
|
|
16
|
+
export const demoKeyParams = t.Object({
|
|
17
|
+
key: t.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export function demoObjectKey(name: string): string {
|
|
21
|
+
return `${DEMO_KEY_PREFIX}${name}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function storageObjectNotFound(): AppException {
|
|
25
|
+
return new AppException("STORAGE_OBJECT_NOT_FOUND", "Object not found", 404);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function requireStorageAdmin(
|
|
29
|
+
identity: RequestIdentity,
|
|
30
|
+
request: Request,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
if (!(await identity.userId(request))) {
|
|
33
|
+
throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
|
|
34
|
+
}
|
|
35
|
+
if (!(await identity.isAdmin(request))) {
|
|
36
|
+
throw new AppException("ADMIN_REQUIRED", "Administrator access is required", 403);
|
|
37
|
+
}
|
|
38
|
+
}
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts
CHANGED
|
@@ -1,44 +1,62 @@
|
|
|
1
1
|
import { Elysia, t } from "elysia";
|
|
2
|
-
import { OBJECT_STORAGE } from "@podosoft/podokit-runtime";
|
|
2
|
+
import { OBJECT_STORAGE, objectResponse } from "@podosoft/podokit-runtime";
|
|
3
3
|
import { PROVIDERS } from "../config/providers";
|
|
4
4
|
import {
|
|
5
5
|
type AppPlugin,
|
|
6
|
+
type ElysiaPlugin,
|
|
6
7
|
type PodokitModule,
|
|
7
8
|
READINESS,
|
|
9
|
+
REQUEST_IDENTITY,
|
|
10
|
+
type RequestIdentity,
|
|
8
11
|
type ServiceKey,
|
|
9
12
|
} from "../core/services";
|
|
10
13
|
import { StorageService } from "./storage.service";
|
|
11
14
|
import { S3ObjectStore } from "./s3-object.store";
|
|
15
|
+
import {
|
|
16
|
+
demoKeyParams,
|
|
17
|
+
demoObjectKey,
|
|
18
|
+
storageObjectNotFound,
|
|
19
|
+
requireStorageAdmin,
|
|
20
|
+
} from "./storage-demo";
|
|
12
21
|
|
|
13
22
|
export const STORAGE = Symbol("storage") as ServiceKey<StorageService>;
|
|
14
23
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
const storage = services.resolve(STORAGE);
|
|
24
|
+
export type DemoStorage = Pick<StorageService, "put" | "open" | "presignedGetUrl">;
|
|
25
|
+
|
|
26
|
+
export function storageDemoRoutes(storage: DemoStorage, identity: RequestIdentity): ElysiaPlugin {
|
|
20
27
|
return new Elysia({ name: "podokit.storage" })
|
|
21
|
-
.put("/storage/:key", async ({ params, body }) => {
|
|
22
|
-
await
|
|
28
|
+
.put("/storage/:key", async ({ request, params, body }) => {
|
|
29
|
+
await requireStorageAdmin(identity, request);
|
|
30
|
+
await storage.put(demoObjectKey(params.key), body.content, "text/plain");
|
|
23
31
|
return { key: params.key };
|
|
24
32
|
}, {
|
|
25
|
-
params:
|
|
33
|
+
params: demoKeyParams,
|
|
26
34
|
body: t.Object({ content: t.String() }),
|
|
27
|
-
detail: { tags: ["storage"], summary: "Store
|
|
35
|
+
detail: { tags: ["storage"], summary: "Store a demo object (administrators only)" },
|
|
28
36
|
})
|
|
29
|
-
.get("/storage/:key/presigned", async ({ params }) =>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
.get("/storage/:key/presigned", async ({ request, params }) => {
|
|
38
|
+
await requireStorageAdmin(identity, request);
|
|
39
|
+
return { url: await storage.presignedGetUrl(demoObjectKey(params.key)) };
|
|
40
|
+
}, {
|
|
41
|
+
params: demoKeyParams,
|
|
42
|
+
detail: { tags: ["storage"], summary: "Presign a demo object download (administrators only)" },
|
|
34
43
|
})
|
|
35
|
-
.get("/storage/:key", async ({ params }) =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
.get("/storage/:key", async ({ request, params }) => {
|
|
45
|
+
await requireStorageAdmin(identity, request);
|
|
46
|
+
const object = await storage.open(demoObjectKey(params.key));
|
|
47
|
+
if (!object) throw storageObjectNotFound();
|
|
48
|
+
return objectResponse(request, object, { headers: { "x-content-type-options": "nosniff" } });
|
|
49
|
+
}, {
|
|
50
|
+
params: demoKeyParams,
|
|
51
|
+
detail: { tags: ["storage"], summary: "Read a demo object (administrators only)" },
|
|
41
52
|
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const storagePlugin: AppPlugin = ({ services }) => {
|
|
56
|
+
if (PROVIDERS["object-storage"] !== "s3") {
|
|
57
|
+
return new Elysia({ name: "podokit.storage-s3.inactive" });
|
|
58
|
+
}
|
|
59
|
+
return storageDemoRoutes(services.resolve(STORAGE), services.resolve(REQUEST_IDENTITY));
|
|
42
60
|
};
|
|
43
61
|
|
|
44
62
|
export const storageModule: PodokitModule = {
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts
CHANGED
|
@@ -1,7 +1,70 @@
|
|
|
1
|
-
import { S3Client } from "bun";
|
|
1
|
+
import { S3Client, type S3Stats } from "bun";
|
|
2
|
+
import {
|
|
3
|
+
objectListLimit,
|
|
4
|
+
type ListObjectsOptions,
|
|
5
|
+
type ObjectData,
|
|
6
|
+
type ObjectInfo,
|
|
7
|
+
type ObjectPage,
|
|
8
|
+
} from "@podosoft/podokit-runtime";
|
|
2
9
|
import { ReadinessService } from "../health/readiness.service";
|
|
3
10
|
import { storageSettings } from "./storage.config";
|
|
4
11
|
|
|
12
|
+
function isMissingObject(error: unknown): boolean {
|
|
13
|
+
return error instanceof Error && "code" in error && error.code === "NoSuchKey";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function entityTag(etag: string): string {
|
|
17
|
+
return etag.startsWith("\"") || etag.startsWith("W/\"") ? etag : `"${etag}"`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function objectInfo(key: string, stats: S3Stats): ObjectInfo {
|
|
21
|
+
return {
|
|
22
|
+
key,
|
|
23
|
+
size: stats.size,
|
|
24
|
+
...(stats.type ? { contentType: stats.type } : {}),
|
|
25
|
+
etag: entityTag(stats.etag),
|
|
26
|
+
lastModified: stats.lastModified,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Downloads on the first read, so an unread body costs no request. Bun's
|
|
32
|
+
* `S3File.stream()` reads ahead without backpressure and would hold the rest of
|
|
33
|
+
* the object for a slow client; a presigned GET through fetch is paced by the
|
|
34
|
+
* socket instead, and If-Match keeps it on the version that was described.
|
|
35
|
+
*/
|
|
36
|
+
function download(client: S3Client, key: string, etag: string): ReadableStream<Uint8Array> {
|
|
37
|
+
const abort = new AbortController();
|
|
38
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
39
|
+
return new ReadableStream<Uint8Array>({
|
|
40
|
+
async pull(controller): Promise<void> {
|
|
41
|
+
if (!reader) {
|
|
42
|
+
const response = await fetch(client.presign(key, { expiresIn: 60 }), {
|
|
43
|
+
headers: { "if-match": etag },
|
|
44
|
+
decompress: false,
|
|
45
|
+
signal: abort.signal,
|
|
46
|
+
});
|
|
47
|
+
if (!response.ok || !response.body) {
|
|
48
|
+
await response.body?.cancel();
|
|
49
|
+
throw new Error(
|
|
50
|
+
response.status === 404 || response.status === 412
|
|
51
|
+
? `Object ${key} changed while it was being read`
|
|
52
|
+
: `Object ${key} download failed with status ${response.status}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
reader = response.body.getReader();
|
|
56
|
+
}
|
|
57
|
+
const next = await reader.read();
|
|
58
|
+
if (next.done) controller.close();
|
|
59
|
+
else controller.enqueue(next.value);
|
|
60
|
+
},
|
|
61
|
+
async cancel(reason): Promise<void> {
|
|
62
|
+
abort.abort(reason);
|
|
63
|
+
await reader?.cancel(reason).catch(() => undefined);
|
|
64
|
+
},
|
|
65
|
+
}, { highWaterMark: 0 });
|
|
66
|
+
}
|
|
67
|
+
|
|
5
68
|
export class StorageService {
|
|
6
69
|
private readonly client: S3Client;
|
|
7
70
|
private unregisterReadiness?: () => void;
|
|
@@ -25,8 +88,44 @@ export class StorageService {
|
|
|
25
88
|
await this.client.file(key).write(body, contentType ? { type: contentType } : undefined);
|
|
26
89
|
}
|
|
27
90
|
|
|
91
|
+
/** Reads a whole object into memory. Use `open()` for anything that can be large. */
|
|
28
92
|
async get(key: string): Promise<Buffer> {
|
|
29
|
-
|
|
93
|
+
const bytes = await this.client.file(key).bytes();
|
|
94
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Object metadata, or null when the key does not exist. */
|
|
98
|
+
async stat(key: string): Promise<ObjectInfo | null> {
|
|
99
|
+
try {
|
|
100
|
+
return objectInfo(key, await this.client.file(key).stat());
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (isMissingObject(error)) return null;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Metadata plus a streamed body, or null when the key does not exist. The
|
|
109
|
+
* metadata request comes first because a missing key would otherwise only
|
|
110
|
+
* fail the stream, after a response has already started.
|
|
111
|
+
*/
|
|
112
|
+
async open(key: string): Promise<ObjectData | null> {
|
|
113
|
+
const info = await this.stat(key);
|
|
114
|
+
if (!info) return null;
|
|
115
|
+
return { ...info, body: download(this.client, key, info.etag) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One page of the keys under `prefix`; the bucket is never listed in full. */
|
|
119
|
+
async list(prefix: string, options: ListObjectsOptions = {}): Promise<ObjectPage> {
|
|
120
|
+
const page = await this.client.list({
|
|
121
|
+
prefix,
|
|
122
|
+
maxKeys: objectListLimit(options),
|
|
123
|
+
...(options.cursor ? { continuationToken: options.cursor } : {}),
|
|
124
|
+
});
|
|
125
|
+
const keys = (page.contents ?? []).map((object) => object.key);
|
|
126
|
+
return page.isTruncated && page.nextContinuationToken
|
|
127
|
+
? { keys, nextCursor: page.nextContinuationToken }
|
|
128
|
+
: { keys };
|
|
30
129
|
}
|
|
31
130
|
|
|
32
131
|
async exists(key: string): Promise<boolean> {
|
|
@@ -1,19 +1,50 @@
|
|
|
1
|
-
import { expect, test } from "@playwright/test";
|
|
1
|
+
import { expect, test, type APIRequestContext, 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
|
+
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
6
|
+
// spec also compiles in apps that do not install that module.
|
|
7
|
+
const admin = {
|
|
8
|
+
email: "admin@example.com",
|
|
9
|
+
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
10
|
+
};
|
|
5
11
|
|
|
6
|
-
|
|
7
|
-
|
|
12
|
+
type Playwright = PlaywrightWorkerArgs["playwright"];
|
|
13
|
+
|
|
14
|
+
function uniqueKey(): string {
|
|
15
|
+
return `obj-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function newContext(playwright: Playwright): Promise<APIRequestContext> {
|
|
19
|
+
return playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function sessionRoles(ctx: APIRequestContext): Promise<string[] | undefined> {
|
|
23
|
+
const response = await ctx.get("/api/auth/get-session");
|
|
24
|
+
if (!response.ok()) return undefined;
|
|
25
|
+
const session = await response.json() as { user?: { role?: string | null } } | null;
|
|
26
|
+
if (!session?.user) return undefined;
|
|
27
|
+
return (session.user.role ?? "").split(",").map((role) => role.trim()).filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolves false when the auth module is missing or sign-up does not start a session. */
|
|
31
|
+
async function signUp(ctx: APIRequestContext): Promise<boolean> {
|
|
8
32
|
const email = `storage-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
9
|
-
await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "S" } })
|
|
10
|
-
return ctx;
|
|
33
|
+
await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "S" } });
|
|
34
|
+
return (await sessionRoles(ctx)) !== undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolves false when no administrator session is available. */
|
|
38
|
+
async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
|
|
39
|
+
const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
|
|
40
|
+
return response.ok() && ((await sessionRoles(ctx)) ?? []).includes("admin");
|
|
11
41
|
}
|
|
12
42
|
|
|
13
43
|
test("object storage: put, get, and presign @smoke", async ({ playwright }) => {
|
|
14
|
-
const ctx = await
|
|
15
|
-
const key =
|
|
44
|
+
const ctx = await newContext(playwright);
|
|
45
|
+
const key = uniqueKey();
|
|
16
46
|
try {
|
|
47
|
+
test.skip(!(await signInAdmin(ctx)), "requires the auth module and the seeded administrator");
|
|
17
48
|
const put = await ctx.put(`/api/storage/${key}`, { data: { content: "hello world" } });
|
|
18
49
|
test.skip(!process.env.S3_ENDPOINT && put.status() >= 500, "optional S3-compatible service not configured");
|
|
19
50
|
expect(put.ok()).toBeTruthy();
|
|
@@ -21,12 +52,20 @@ test("object storage: put, get, and presign @smoke", async ({ playwright }) => {
|
|
|
21
52
|
|
|
22
53
|
const read = await ctx.get(`/api/storage/${key}`);
|
|
23
54
|
expect(read.ok()).toBeTruthy();
|
|
24
|
-
expect(
|
|
55
|
+
expect(read.headers()["content-type"]).toContain("text/plain");
|
|
56
|
+
expect(read.headers()["content-length"]).toBe("11");
|
|
57
|
+
expect(read.headers()["last-modified"]).toBeTruthy();
|
|
58
|
+
const etag = read.headers()["etag"];
|
|
59
|
+
expect(etag).toMatch(/^"[^"]+"$/);
|
|
60
|
+
expect(await read.text()).toBe("hello world");
|
|
61
|
+
|
|
62
|
+
const revalidated = await ctx.get(`/api/storage/${key}`, { headers: { "if-none-match": etag ?? "" } });
|
|
63
|
+
expect(revalidated.status()).toBe(304);
|
|
25
64
|
|
|
26
65
|
const presigned = await ctx.get(`/api/storage/${key}/presigned`);
|
|
27
66
|
expect(presigned.ok()).toBeTruthy();
|
|
28
67
|
const pre = await presigned.json() as { url: string };
|
|
29
|
-
expect(new URL(pre.url).pathname).toContain(key);
|
|
68
|
+
expect(new URL(pre.url, base).pathname).toContain(`demo/${key}`);
|
|
30
69
|
// The URL is signed for the API's S3 endpoint. A containerized dev stack signs an
|
|
31
70
|
// in-network host, so download it only when the runner was given that endpoint too.
|
|
32
71
|
if (process.env.S3_ENDPOINT) {
|
|
@@ -38,3 +77,39 @@ test("object storage: put, get, and presign @smoke", async ({ playwright }) => {
|
|
|
38
77
|
await ctx.dispose();
|
|
39
78
|
}
|
|
40
79
|
});
|
|
80
|
+
|
|
81
|
+
test("object storage demo routes are limited to administrators", async ({ playwright }) => {
|
|
82
|
+
const anonymous = await newContext(playwright);
|
|
83
|
+
const user = await newContext(playwright);
|
|
84
|
+
const key = uniqueKey();
|
|
85
|
+
try {
|
|
86
|
+
test.skip(!(await signUp(user)), "requires the auth module");
|
|
87
|
+
const unauthenticated = await anonymous.put(`/api/storage/${key}`, { data: { content: "x" } });
|
|
88
|
+
expect(unauthenticated.status()).toBe(401);
|
|
89
|
+
|
|
90
|
+
for (const response of [
|
|
91
|
+
await user.put(`/api/storage/${key}`, { data: { content: "x" } }),
|
|
92
|
+
await user.get(`/api/storage/${key}`),
|
|
93
|
+
await user.get(`/api/storage/${key}/presigned`),
|
|
94
|
+
]) {
|
|
95
|
+
expect(response.status()).toBe(403);
|
|
96
|
+
expect(await response.json()).toMatchObject({ error: { code: "ADMIN_REQUIRED" } });
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
await anonymous.dispose();
|
|
100
|
+
await user.dispose();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("object storage demo keys cannot reach other features' objects", async ({ playwright }) => {
|
|
105
|
+
const ctx = await newContext(playwright);
|
|
106
|
+
try {
|
|
107
|
+
test.skip(!(await signInAdmin(ctx)), "requires the auth module and the seeded administrator");
|
|
108
|
+
for (const key of ["site%2Ffavicon", "profile-images%2Fx.png", "..%2Fsite%2Ffavicon", ".podokit"]) {
|
|
109
|
+
const response = await ctx.put(`/api/storage/${key}`, { data: { content: "x" } });
|
|
110
|
+
expect([400, 404], `PUT /api/storage/${key}`).toContain(response.status());
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
await ctx.dispose();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
"requiredProjectFiles": [
|
|
8
8
|
"apps/api/src/health/readiness.service.ts"
|
|
9
9
|
],
|
|
10
|
+
"requiredProjectContents": {
|
|
11
|
+
"apps/api/src/core/services.ts": ["isAdmin(request: Request): Promise<boolean>;"]
|
|
12
|
+
},
|
|
10
13
|
"targetApp": "api",
|
|
11
14
|
"env": [
|
|
12
15
|
"# object storage — STORAGE_PROVIDER: minio | aws",
|
|
@@ -24,9 +27,11 @@
|
|
|
24
27
|
],
|
|
25
28
|
"instructions": [
|
|
26
29
|
"Start Silo for local dev: docker compose -f infra/docker/docker-compose.yml -f infra/docker/minio.compose.yml up -d",
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
+
"The /storage demo routes need the auth module and an administrator session, and only reach keys under demo/.",
|
|
31
|
+
"Sign in as an administrator: curl -c cookies.txt -XPOST localhost:5002/api/auth/sign-in/email -H 'content-type: application/json' -d '{\"email\":\"admin@example.com\",\"password\":\"<password>\"}'",
|
|
32
|
+
"Store an object: curl -b cookies.txt -XPUT localhost:5002/storage/hello -H 'content-type: application/json' -d '{\"content\":\"hi\"}'",
|
|
33
|
+
"Read it back: curl -b cookies.txt localhost:5002/storage/hello",
|
|
34
|
+
"Presigned URL: curl -b cookies.txt localhost:5002/storage/hello/presigned",
|
|
30
35
|
"For AWS S3: set STORAGE_PROVIDER=aws, remove S3_ENDPOINT, set S3_FORCE_PATH_STYLE=false and real credentials/bucket."
|
|
31
36
|
]
|
|
32
37
|
}
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
clientAddressFromProxy,
|
|
4
4
|
rateLimitConfig,
|
|
5
|
+
sessionSigningSecret,
|
|
5
6
|
} from "./rate-limit.config";
|
|
6
7
|
|
|
7
8
|
describe("rate limit configuration", () => {
|
|
@@ -88,4 +89,12 @@ describe("rate limit configuration", () => {
|
|
|
88
89
|
}),
|
|
89
90
|
).toBe("10.0.0.9");
|
|
90
91
|
});
|
|
92
|
+
|
|
93
|
+
test("reads the session signing secret the way the auth module does", () => {
|
|
94
|
+
expect(
|
|
95
|
+
sessionSigningSecret({ BETTER_AUTH_SECRET: " session-secret-with-at-least-32-chars " }),
|
|
96
|
+
).toBe("session-secret-with-at-least-32-chars");
|
|
97
|
+
expect(sessionSigningSecret({ BETTER_AUTH_SECRET: " " })).toBeUndefined();
|
|
98
|
+
expect(sessionSigningSecret({})).toBeUndefined();
|
|
99
|
+
});
|
|
91
100
|
});
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts
CHANGED
|
@@ -84,6 +84,13 @@ export function rateLimitConfig(env: NodeJS.ProcessEnv = process.env): RateLimit
|
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// The secret Better Auth signs session cookies with (the auth module trims it the
|
|
88
|
+
// same way). Without it, sessions are not recognized and requests are counted by
|
|
89
|
+
// client address.
|
|
90
|
+
export function sessionSigningSecret(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
91
|
+
return env.BETTER_AUTH_SECRET?.trim() || undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
87
94
|
function normalizeIp(value: string | undefined): string | undefined {
|
|
88
95
|
if (!value) return undefined;
|
|
89
96
|
const trimmed = value.trim();
|