@podosoft/podokit 1.1.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deploy-render.js +5 -0
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
- package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
- package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
- package/dist/templates/fullstack/compose.dev.yaml +21 -0
- package/dist/templates/fullstack/dot-env.docker +3 -0
- package/dist/templates/fullstack/dot-env.example +14 -0
- package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
- package/dist/templates/modules/audit-log/module.manifest.json +7 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
- package/dist/templates/modules/auth/module.manifest.json +2 -2
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
- package/dist/templates/modules/file-upload/module.manifest.json +9 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
- package/dist/templates/modules/job-progress/module.manifest.json +3 -3
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
- package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
- package/dist/templates/modules/sse/module.manifest.json +17 -4
- package/package.json +2 -2
package/dist/templates/modules/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();
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { describe, expect, mock, test, type Mock } from "bun:test";
|
|
2
3
|
import { ApiKeyVerifier } from "../api-key/api-key-verifier";
|
|
3
4
|
import {
|
|
4
5
|
REQUEST_IDENTITY,
|
|
@@ -8,36 +9,130 @@ import {
|
|
|
8
9
|
RATE_LIMIT_IDENTITY_EXTENSION,
|
|
9
10
|
RateLimitIdentity,
|
|
10
11
|
RateLimitIdentityExtension,
|
|
12
|
+
verifiedSessionToken,
|
|
11
13
|
} from "./rate-limit.identity";
|
|
12
14
|
|
|
13
15
|
const proxyConfig = { proxyHeader: "x-forwarded-for", trustedProxyHops: 1 };
|
|
16
|
+
const secret = "rate-limit-test-secret-with-at-least-32-characters";
|
|
17
|
+
const sessionToken = "Sx0d8mFqY3KcT7wJpLr2NvBhZa9EgUi4";
|
|
14
18
|
|
|
15
19
|
function request(headers: Record<string, string> = {}): Request {
|
|
16
20
|
return new Request("http://localhost/ordinary", { headers });
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
// The same format Better Auth issues: `${token}.${base64(HMAC-SHA256)}`.
|
|
24
|
+
function signed(token: string, key = secret): string {
|
|
25
|
+
return `${token}.${createHmac("sha256", key).update(token).digest("base64")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function tamperedSignature(value: string): string {
|
|
29
|
+
const index = value.lastIndexOf(".") + 1;
|
|
30
|
+
const replacement = value[index] === "A" ? "B" : "A";
|
|
31
|
+
return `${value.slice(0, index)}${replacement}${value.slice(index + 1)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sessionCookie(value: string, name = "better-auth.session_token"): string {
|
|
35
|
+
return `theme=dark; ${name}=${encodeURIComponent(value)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function rejectingVerifier(): ApiKeyVerifier {
|
|
39
|
+
return { isValid: mock(() => false) } as unknown as ApiKeyVerifier;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Registers a request identity that would hit the session store, so tests can
|
|
43
|
+
// prove the limiter never asks for it.
|
|
44
|
+
function servicesWithSessionStore(): {
|
|
45
|
+
registry: ServiceRegistry;
|
|
46
|
+
userId: Mock<(request: Request) => Promise<string | undefined>>;
|
|
47
|
+
} {
|
|
20
48
|
const registry = new ServiceRegistry();
|
|
21
|
-
|
|
22
|
-
|
|
49
|
+
const userId = mock(async (_request: Request): Promise<string | undefined> => "user-secret-id");
|
|
50
|
+
registry.register(REQUEST_IDENTITY, {
|
|
51
|
+
userId,
|
|
52
|
+
isAdmin: async () => false,
|
|
53
|
+
roles: async () => [],
|
|
54
|
+
activeOrganizationId: async () => undefined,
|
|
55
|
+
});
|
|
56
|
+
return { registry, userId };
|
|
23
57
|
}
|
|
24
58
|
|
|
25
59
|
describe("RateLimitIdentity", () => {
|
|
26
|
-
test("uses
|
|
27
|
-
const
|
|
28
|
-
const identity = new RateLimitIdentity(
|
|
60
|
+
test("uses a verified session cookie without resolving the session", async () => {
|
|
61
|
+
const { registry, userId } = servicesWithSessionStore();
|
|
62
|
+
const identity = new RateLimitIdentity(rejectingVerifier(), registry, secret);
|
|
63
|
+
|
|
29
64
|
const value = await identity.resolve(
|
|
30
|
-
request({
|
|
65
|
+
request({ cookie: sessionCookie(signed(sessionToken)) }),
|
|
66
|
+
"10.0.0.9",
|
|
67
|
+
proxyConfig,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(value).toMatch(/^session:[a-f0-9]{64}$/);
|
|
71
|
+
expect(value).not.toContain(sessionToken);
|
|
72
|
+
expect(userId).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("gives one session the same counter across cookie names and bearer forms", async () => {
|
|
76
|
+
const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
|
|
77
|
+
const value = signed(sessionToken);
|
|
78
|
+
const resolved = await Promise.all([
|
|
79
|
+
identity.resolve(request({ cookie: sessionCookie(value) }), "10.0.0.9", proxyConfig),
|
|
80
|
+
identity.resolve(
|
|
81
|
+
request({ cookie: sessionCookie(value, "__Secure-better-auth.session_token") }),
|
|
82
|
+
"10.0.0.9",
|
|
83
|
+
proxyConfig,
|
|
84
|
+
),
|
|
85
|
+
identity.resolve(request({ authorization: `Bearer ${value}` }), "10.0.0.9", proxyConfig),
|
|
86
|
+
identity.resolve(
|
|
87
|
+
request({ authorization: `bearer ${encodeURIComponent(value)}` }),
|
|
88
|
+
"10.0.0.9",
|
|
89
|
+
proxyConfig,
|
|
90
|
+
),
|
|
91
|
+
]);
|
|
92
|
+
expect(new Set(resolved).size).toBe(1);
|
|
93
|
+
|
|
94
|
+
const other = await identity.resolve(
|
|
95
|
+
request({ cookie: sessionCookie(signed("AnotherSessionToken0123456789abcd")) }),
|
|
31
96
|
"10.0.0.9",
|
|
32
97
|
proxyConfig,
|
|
33
98
|
);
|
|
34
|
-
expect(
|
|
35
|
-
expect(
|
|
36
|
-
|
|
99
|
+
expect(other).toMatch(/^session:/);
|
|
100
|
+
expect(other).not.toBe(resolved[0]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("counts forged, tampered, and unsigned session values by client address", async () => {
|
|
104
|
+
const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
|
|
105
|
+
const address = { "x-forwarded-for": "203.0.113.7" };
|
|
106
|
+
const anonymous = await identity.resolve(request(address), "10.0.0.9", proxyConfig);
|
|
107
|
+
expect(anonymous).toMatch(/^ip:[a-f0-9]{64}$/);
|
|
108
|
+
|
|
109
|
+
const genuine = signed(sessionToken);
|
|
110
|
+
const forgedValues = [
|
|
111
|
+
signed(sessionToken, "an-attacker-chosen-secret-of-32-characters"),
|
|
112
|
+
`${sessionToken}x${genuine.slice(sessionToken.length)}`,
|
|
113
|
+
tamperedSignature(genuine),
|
|
114
|
+
genuine.replace("=", ""),
|
|
115
|
+
sessionToken,
|
|
116
|
+
"",
|
|
117
|
+
];
|
|
118
|
+
for (const forged of forgedValues) {
|
|
119
|
+
const variants: Array<Record<string, string>> = [
|
|
120
|
+
{ cookie: sessionCookie(forged) },
|
|
121
|
+
{ authorization: `Bearer ${forged}` },
|
|
122
|
+
];
|
|
123
|
+
for (const headers of variants) {
|
|
124
|
+
const value = await identity.resolve(
|
|
125
|
+
request({ ...address, ...headers }),
|
|
126
|
+
"10.0.0.9",
|
|
127
|
+
proxyConfig,
|
|
128
|
+
);
|
|
129
|
+
expect(value).toBe(anonymous);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
37
132
|
});
|
|
38
133
|
|
|
39
|
-
test("
|
|
40
|
-
const registry =
|
|
134
|
+
test("checks API keys before sessions without storing raw keys", async () => {
|
|
135
|
+
const registry = new ServiceRegistry();
|
|
41
136
|
class Extension extends RateLimitIdentityExtension {
|
|
42
137
|
async validatedApiKeyId(_request: Request, rawApiKey: string): Promise<string | undefined> {
|
|
43
138
|
return rawApiKey === "external-valid" ? "external-stable-id" : undefined;
|
|
@@ -47,26 +142,49 @@ describe("RateLimitIdentity", () => {
|
|
|
47
142
|
const verifier = {
|
|
48
143
|
isValid: mock((value: string) => value === "configured-valid"),
|
|
49
144
|
} as unknown as ApiKeyVerifier;
|
|
50
|
-
const identity = new RateLimitIdentity(verifier, registry);
|
|
145
|
+
const identity = new RateLimitIdentity(verifier, registry, secret);
|
|
146
|
+
const cookie = sessionCookie(signed(sessionToken));
|
|
51
147
|
|
|
52
148
|
for (const raw of ["configured-valid", "external-valid"]) {
|
|
53
|
-
const value = await identity.resolve(
|
|
149
|
+
const value = await identity.resolve(
|
|
150
|
+
request({ "x-api-key": raw, cookie }),
|
|
151
|
+
"10.0.0.9",
|
|
152
|
+
proxyConfig,
|
|
153
|
+
);
|
|
54
154
|
expect(value).toMatch(/^api-key:[a-f0-9]{64}$/);
|
|
55
155
|
expect(value).not.toContain(raw);
|
|
56
156
|
}
|
|
57
|
-
});
|
|
58
157
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
158
|
+
const invalidKey = await identity.resolve(
|
|
159
|
+
request({ "x-api-key": "invalid", cookie }),
|
|
160
|
+
"10.0.0.9",
|
|
161
|
+
proxyConfig,
|
|
63
162
|
);
|
|
163
|
+
expect(invalidKey).toMatch(/^session:/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("falls back to the trusted client address without authentication", async () => {
|
|
167
|
+
const cookie = sessionCookie(signed(sessionToken));
|
|
168
|
+
// No session secret: the auth module is not installed or not configured.
|
|
169
|
+
const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), undefined);
|
|
64
170
|
const value = await identity.resolve(
|
|
65
|
-
request({ "x-forwarded-for": "203.0.113.7" }),
|
|
171
|
+
request({ "x-forwarded-for": "203.0.113.7", cookie }),
|
|
66
172
|
"10.0.0.9",
|
|
67
173
|
proxyConfig,
|
|
68
174
|
);
|
|
69
175
|
expect(value).toMatch(/^ip:[a-f0-9]{64}$/);
|
|
70
176
|
expect(value).not.toContain("203.0.113.7");
|
|
177
|
+
expect(
|
|
178
|
+
await identity.resolve(request({ "x-forwarded-for": "203.0.113.7" }), "10.0.0.9", proxyConfig),
|
|
179
|
+
).toBe(value);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("verifiedSessionToken", () => {
|
|
184
|
+
test("returns the token only for a matching signature", () => {
|
|
185
|
+
expect(verifiedSessionToken(signed(sessionToken), secret)).toBe(sessionToken);
|
|
186
|
+
expect(verifiedSessionToken(signed(sessionToken), `${secret}-rotated`)).toBeUndefined();
|
|
187
|
+
expect(verifiedSessionToken(".", secret)).toBeUndefined();
|
|
188
|
+
expect(verifiedSessionToken(`${sessionToken}.`, secret)).toBeUndefined();
|
|
71
189
|
});
|
|
72
190
|
});
|