@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
|
@@ -18,29 +18,35 @@ const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
|
|
|
18
18
|
{ flag: "oidcProvider", prefixes: ["/oauth2/", "/.well-known/openid-configuration", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource"] },
|
|
19
19
|
];
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
export type FeatureFlags = Record<FeatureFlag, boolean>;
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
export
|
|
23
|
+
/** Live settings the gate reads per request, normally the shared settings snapshot. */
|
|
24
|
+
export interface FeatureGateSettings {
|
|
25
|
+
flags: () => Promise<FeatureFlags>;
|
|
26
|
+
signupOpen: () => Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Public registration is the default until an administrator explicitly closes it. */
|
|
30
|
+
export function signupOpenFrom(settings: ReadonlyMap<string, string>): boolean {
|
|
31
|
+
return settings.get("site.allowSignup") !== "false";
|
|
32
|
+
}
|
|
25
33
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
/** A settings read failure keeps public sign-up open. */
|
|
35
|
+
export async function readSignupOpen(signupOpen: () => Promise<boolean>): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
return await signupOpen();
|
|
38
|
+
} catch {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
32
42
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
fetchedAt = Date.now();
|
|
42
|
-
return signupOpen;
|
|
43
|
-
};
|
|
43
|
+
/** A settings read failure applies the shipped defaults. */
|
|
44
|
+
export async function readFeatureFlags(flags: () => Promise<FeatureFlags>): Promise<FeatureFlags> {
|
|
45
|
+
try {
|
|
46
|
+
return await flags();
|
|
47
|
+
} catch {
|
|
48
|
+
return { ...FLAG_DEFAULTS };
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/** Administrator-created users are intentional and remain available when the
|
|
@@ -60,45 +66,28 @@ export function assertUserCreationAllowed(signupOpen: boolean, requestPath?: str
|
|
|
60
66
|
|
|
61
67
|
/** Server-side enforcement for the admin Settings toggles: requests to a disabled
|
|
62
68
|
* feature's endpoints are rejected with 404, so "off" is a real boundary rather
|
|
63
|
-
* than hidden UI.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
let fetchedAt = 0;
|
|
72
|
-
|
|
73
|
-
async function flags(): Promise<Record<FeatureFlag, boolean>> {
|
|
74
|
-
if (Date.now() - fetchedAt < CACHE_TTL_MS) return cache;
|
|
75
|
-
try {
|
|
76
|
-
const rows = await readSettings();
|
|
77
|
-
const next = { ...FLAG_DEFAULTS };
|
|
78
|
-
for (const row of rows) {
|
|
79
|
-
if (row.key in FLAG_DEFAULTS) next[row.key as FeatureFlag] = row.value === "true";
|
|
80
|
-
}
|
|
81
|
-
cache = next;
|
|
82
|
-
} catch {
|
|
83
|
-
cache = { ...FLAG_DEFAULTS };
|
|
84
|
-
}
|
|
85
|
-
fetchedAt = Date.now();
|
|
86
|
-
return cache;
|
|
69
|
+
* than hidden UI. Throws the API error for a rejected path. */
|
|
70
|
+
export async function enforceFeatureGate(path: string, settings: FeatureGateSettings): Promise<void> {
|
|
71
|
+
// Reject explicit sign-up endpoints early. OAuth starts remain available so
|
|
72
|
+
// existing social users can sign in; their new-user callback is enforced by
|
|
73
|
+
// the database hook that uses this same policy.
|
|
74
|
+
if (path === "/sign-up/email" || path.startsWith("/sign-up/")) {
|
|
75
|
+
assertUserCreationAllowed(await readSignupOpen(settings.signupOpen), path);
|
|
76
|
+
return;
|
|
87
77
|
}
|
|
78
|
+
const feature = FEATURE_PATHS.find((f) =>
|
|
79
|
+
f.prefixes.some((p) => path === p || path.startsWith(p)),
|
|
80
|
+
);
|
|
81
|
+
if (!feature) return;
|
|
82
|
+
const enabled = (await readFeatureFlags(settings.flags))[feature.flag];
|
|
83
|
+
if (!enabled) throw new APIError("NOT_FOUND", { message: "This feature is disabled." });
|
|
84
|
+
}
|
|
88
85
|
|
|
86
|
+
/** Better Auth middleware over {@link enforceFeatureGate}. The settings come from
|
|
87
|
+
* the shared snapshot (a toggle takes effect within its TTL); before the
|
|
88
|
+
* migration has run, the shipped defaults apply. */
|
|
89
|
+
export function createFeatureGate(settings: FeatureGateSettings): ReturnType<typeof createAuthMiddleware> {
|
|
89
90
|
return createAuthMiddleware(async (ctx) => {
|
|
90
|
-
|
|
91
|
-
// existing social users can sign in; their new-user callback is enforced by
|
|
92
|
-
// the database hook that uses this same policy.
|
|
93
|
-
if (ctx.path === "/sign-up/email" || ctx.path.startsWith("/sign-up/")) {
|
|
94
|
-
assertUserCreationAllowed(await isSignupOpen(), ctx.path);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
const feature = FEATURE_PATHS.find((f) =>
|
|
98
|
-
f.prefixes.some((p) => ctx.path === p || ctx.path.startsWith(p)),
|
|
99
|
-
);
|
|
100
|
-
if (!feature) return;
|
|
101
|
-
const enabled = (await flags())[feature.flag];
|
|
102
|
-
if (!enabled) throw new APIError("NOT_FOUND", { message: "This feature is disabled." });
|
|
91
|
+
await enforceFeatureGate(ctx.path, settings);
|
|
103
92
|
});
|
|
104
93
|
}
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
SUPPORTED_PROVIDER_IDS,
|
|
11
11
|
SUPPORTED_SOCIAL_PROVIDERS,
|
|
12
12
|
} from "@podosoft/podokit-auth";
|
|
13
|
+
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
13
14
|
import { refreshAuthNow } from "../auth/auth-provider";
|
|
15
|
+
import { authConfigParam } from "./config-param";
|
|
14
16
|
|
|
15
17
|
export type SocialProviderView = {
|
|
16
18
|
id: string;
|
|
@@ -102,7 +104,10 @@ function booleanField(source: Record<string, unknown>, key: string, fallback: bo
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
export class AuthConfigService {
|
|
105
|
-
constructor(
|
|
107
|
+
constructor(
|
|
108
|
+
private readonly sql: SQL,
|
|
109
|
+
private readonly provider: DatabaseProviderName = "postgres",
|
|
110
|
+
) {}
|
|
106
111
|
|
|
107
112
|
private async rows(): Promise<AuthConfigRow[]> {
|
|
108
113
|
return this.sql<AuthConfigRow[]>`
|
|
@@ -119,7 +124,7 @@ export class AuthConfigService {
|
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
private async upsert(row: AuthConfigRow): Promise<void> {
|
|
122
|
-
const config = object(row.config);
|
|
127
|
+
const config = authConfigParam(this.provider, object(row.config));
|
|
123
128
|
await this.sql`
|
|
124
129
|
INSERT INTO "auth_config" ("key", "enabled", "config", "secret", "updatedAt")
|
|
125
130
|
VALUES (${row.key}, ${row.enabled}, ${config}, ${row.secret}, CURRENT_TIMESTAMP)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { authConfigParam } from "./config-param";
|
|
4
|
+
|
|
5
|
+
describe("auth config parameters", () => {
|
|
6
|
+
it("binds JSON text that SQLite stores and reads back", async () => {
|
|
7
|
+
const sql = new SQL("sqlite://:memory:");
|
|
8
|
+
await sql`CREATE TABLE "auth_config" ("key" text PRIMARY KEY, "config" text NOT NULL DEFAULT '{}')`;
|
|
9
|
+
const config = { allowDelete: true, hibp: true };
|
|
10
|
+
|
|
11
|
+
await sql`INSERT INTO "auth_config" ("key", "config") VALUES (${"server"}, ${authConfigParam("sqlite", config)})`;
|
|
12
|
+
|
|
13
|
+
const [row] = await sql<Array<{ config: unknown }>>`SELECT "config" FROM "auth_config"`;
|
|
14
|
+
expect(typeof row?.config).toBe("string");
|
|
15
|
+
expect(JSON.parse(String(row?.config))).toEqual(config);
|
|
16
|
+
await sql.close();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("keeps an object for PostgreSQL jsonb", () => {
|
|
20
|
+
const config = { clientId: "id" };
|
|
21
|
+
expect(authConfigParam("postgres", config)).toBe(config);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
2
|
+
|
|
3
|
+
// PostgreSQL stores a jsonb object only when bound as an object (P-022), while
|
|
4
|
+
// SQLite keeps the column as text and rejects object parameters.
|
|
5
|
+
export function authConfigParam(
|
|
6
|
+
provider: DatabaseProviderName,
|
|
7
|
+
config: Record<string, unknown>,
|
|
8
|
+
): Record<string, unknown> | string {
|
|
9
|
+
return provider === "sqlite" ? JSON.stringify(config) : config;
|
|
10
|
+
}
|
|
@@ -27,3 +27,13 @@ export const FLAG_DEFAULTS: Record<FeatureFlag, boolean> = {
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
export const FEATURE_FLAGS = Object.keys(FLAG_DEFAULTS) as FeatureFlag[];
|
|
30
|
+
|
|
31
|
+
/** Stored values override the shipped defaults; a missing row keeps the default. */
|
|
32
|
+
export function flagsFrom(settings: ReadonlyMap<string, string>): Record<FeatureFlag, boolean> {
|
|
33
|
+
const flags = { ...FLAG_DEFAULTS };
|
|
34
|
+
for (const flag of FEATURE_FLAGS) {
|
|
35
|
+
const stored = settings.get(flag);
|
|
36
|
+
if (stored !== undefined) flags[flag] = stored === "true";
|
|
37
|
+
}
|
|
38
|
+
return flags;
|
|
39
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, setSystemTime, test } from "bun:test";
|
|
2
|
+
import type { SQL } from "bun";
|
|
3
|
+
import { createConfigStore, type AppSettingRow, type ConfigVersions } from "@podosoft/podokit-auth";
|
|
4
|
+
import { SettingsService } from "./settings.service";
|
|
5
|
+
|
|
6
|
+
type Row = AppSettingRow & { updatedAt: number };
|
|
7
|
+
|
|
8
|
+
/** In-memory app_setting table with the same upsert semantics as the service's SQL. */
|
|
9
|
+
function table() {
|
|
10
|
+
const rows = new Map<string, Row>();
|
|
11
|
+
let clock = 1;
|
|
12
|
+
const sql = (async (_strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
13
|
+
const [key, value] = values as [string, string];
|
|
14
|
+
rows.set(key, { key, value, updatedAt: clock++ });
|
|
15
|
+
return [];
|
|
16
|
+
}) as unknown as SQL;
|
|
17
|
+
const store = () => createConfigStore({
|
|
18
|
+
list: async () => [],
|
|
19
|
+
listSettings: async () => [...rows.values()].map(({ key, value }) => ({ key, value })),
|
|
20
|
+
versions: async (): Promise<ConfigVersions> => ({
|
|
21
|
+
authConfig: "0:",
|
|
22
|
+
appSettings: `${rows.size}:${Math.max(0, ...[...rows.values()].map((row) => row.updatedAt))}`,
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
return { rows, sql, store };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("SettingsService", () => {
|
|
29
|
+
let now = Date.UTC(2026, 0, 1);
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
now = Date.UTC(2026, 0, 1);
|
|
32
|
+
setSystemTime(new Date(now));
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
setSystemTime();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("applies a local write at once and reports the change", async () => {
|
|
39
|
+
const { sql, store } = table();
|
|
40
|
+
let changes = 0;
|
|
41
|
+
const settings = new SettingsService(sql, store(), async () => {
|
|
42
|
+
changes += 1;
|
|
43
|
+
});
|
|
44
|
+
expect(await settings.getBool("require2fa")).toBe(false);
|
|
45
|
+
const flags = await settings.setMany({ require2fa: true });
|
|
46
|
+
expect(flags.require2fa).toBe(true);
|
|
47
|
+
expect(await settings.getBool("require2fa")).toBe(true);
|
|
48
|
+
expect(changes).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("follows another replica's write within the snapshot TTL", async () => {
|
|
52
|
+
const { sql, store } = table();
|
|
53
|
+
const replicaA = new SettingsService(sql, store());
|
|
54
|
+
const replicaB = new SettingsService(sql, store());
|
|
55
|
+
expect(await replicaB.getBool("require2fa")).toBe(false);
|
|
56
|
+
|
|
57
|
+
await replicaA.setMany({ require2fa: true });
|
|
58
|
+
expect(await replicaB.getBool("require2fa")).toBe(false);
|
|
59
|
+
setSystemTime(new Date(now + 3_100));
|
|
60
|
+
expect(await replicaB.getBool("require2fa")).toBe(true);
|
|
61
|
+
expect((await replicaB.flags()).require2fa).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("falls back to the shipped defaults when app_setting is unavailable", async () => {
|
|
65
|
+
const settings = new SettingsService(table().sql, createConfigStore({
|
|
66
|
+
list: async () => [],
|
|
67
|
+
listSettings: async () => [],
|
|
68
|
+
versions: async () => {
|
|
69
|
+
throw new Error('relation "app_setting" does not exist');
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
expect(await settings.flags()).toMatchObject({ twoFactor: true, phoneNumber: false, require2fa: false });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("returns a copy of the flags", async () => {
|
|
76
|
+
const { sql, store } = table();
|
|
77
|
+
const settings = new SettingsService(sql, store());
|
|
78
|
+
(await settings.flags()).require2fa = true;
|
|
79
|
+
expect(await settings.getBool("require2fa")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -1,53 +1,56 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
|
-
import {
|
|
2
|
+
import type { ConfigStore } from "@podosoft/podokit-auth";
|
|
3
|
+
import { FEATURE_FLAGS, flagsFrom, type FeatureFlag } from "./flag-defaults";
|
|
3
4
|
|
|
4
5
|
export { FEATURE_FLAGS, type FeatureFlag };
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
key: string;
|
|
8
|
-
value: string;
|
|
9
|
-
}
|
|
7
|
+
export type SettingsSnapshot = Pick<ConfigStore, "selectSettings" | "invalidate">;
|
|
10
8
|
|
|
11
|
-
/** DB-backed feature flags with migration defaults as the source of truth.
|
|
9
|
+
/** DB-backed feature flags with migration defaults as the source of truth. Reads
|
|
10
|
+
* come from the shared settings snapshot, so every replica follows a change
|
|
11
|
+
* within the snapshot TTL; a write here applies on this replica at once. */
|
|
12
12
|
export class SettingsService {
|
|
13
|
-
private
|
|
14
|
-
|
|
15
|
-
constructor(
|
|
13
|
+
private readonly current: () => Promise<Record<FeatureFlag, boolean>>;
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly sql: SQL,
|
|
17
|
+
private readonly snapshot: SettingsSnapshot,
|
|
18
|
+
private readonly onChange: () => Promise<void> = async () => undefined,
|
|
19
|
+
) {
|
|
20
|
+
this.current = snapshot.selectSettings(flagsFrom);
|
|
21
|
+
}
|
|
16
22
|
|
|
23
|
+
/** Reload the snapshot from the database. */
|
|
17
24
|
async refresh(): Promise<void> {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
SELECT "key", "value" FROM "app_setting"
|
|
21
|
-
`;
|
|
22
|
-
this.cache = new Map(rows.map((row) => [row.key, row.value]));
|
|
23
|
-
} catch {
|
|
24
|
-
this.cache = new Map();
|
|
25
|
-
}
|
|
25
|
+
this.snapshot.invalidate();
|
|
26
|
+
await this.current();
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
getBool(flag: FeatureFlag): boolean {
|
|
29
|
-
|
|
30
|
-
return stored === undefined ? FLAG_DEFAULTS[flag] : stored === "true";
|
|
29
|
+
async getBool(flag: FeatureFlag): Promise<boolean> {
|
|
30
|
+
return (await this.current())[flag];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
flags(): Record<FeatureFlag, boolean
|
|
34
|
-
return
|
|
35
|
-
FEATURE_FLAGS.map((flag) => [flag, this.getBool(flag)]),
|
|
36
|
-
) as Record<FeatureFlag, boolean>;
|
|
33
|
+
async flags(): Promise<Record<FeatureFlag, boolean>> {
|
|
34
|
+
return { ...(await this.current()) };
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
async setMany(update: Partial<Record<FeatureFlag, boolean>>): Promise<Record<FeatureFlag, boolean>> {
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
try {
|
|
39
|
+
for (const flag of FEATURE_FLAGS) {
|
|
40
|
+
const value = update[flag];
|
|
41
|
+
if (typeof value !== "boolean") continue;
|
|
42
|
+
await this.sql`
|
|
43
|
+
INSERT INTO "app_setting" ("key", "value", "updatedAt")
|
|
44
|
+
VALUES (${flag}, ${String(value)}, CURRENT_TIMESTAMP)
|
|
45
|
+
ON CONFLICT ("key") DO UPDATE
|
|
46
|
+
SET "value" = EXCLUDED."value", "updatedAt" = CURRENT_TIMESTAMP
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
// Also after a failure: earlier rows of the update may have been written.
|
|
51
|
+
await this.refresh();
|
|
49
52
|
}
|
|
50
|
-
await this.
|
|
53
|
+
await this.onChange();
|
|
51
54
|
return this.flags();
|
|
52
55
|
}
|
|
53
56
|
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
".claude/skills/podokit-configure-auth/**"
|
|
8
8
|
],
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@podosoft/podokit-auth": "^0.
|
|
11
|
-
"@podosoft/podokit-contracts": "^0.
|
|
10
|
+
"@podosoft/podokit-auth": "^0.5.0",
|
|
11
|
+
"@podosoft/podokit-contracts": "^0.5.0",
|
|
12
12
|
"better-auth": "^1.7.7",
|
|
13
13
|
"pg": "^8.12.0",
|
|
14
14
|
"@better-auth/api-key": "^1.7.7",
|
|
@@ -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
|
+
});
|