@podosoft/podokit 1.1.6 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/add.d.ts +3 -1
- package/dist/add.js +116 -14
- package/dist/deploy-render.js +5 -0
- package/dist/index.js +8 -0
- package/dist/lib.d.ts +2 -2
- package/dist/lib.js +3 -2
- package/dist/lockfile.d.ts +3 -1
- package/dist/lockfile.js +17 -6
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
- package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
- package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
- package/dist/templates/fullstack/compose.dev.yaml +21 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
- package/dist/templates/fullstack/dot-env.docker +3 -0
- package/dist/templates/fullstack/dot-env.example +14 -0
- package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
- package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +102 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +79 -37
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +32 -1
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +126 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +57 -0
- package/dist/templates/modules/audit-log/module.manifest.json +7 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
- package/dist/templates/modules/auth/module.manifest.json +2 -2
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
- package/dist/templates/modules/file-upload/module.manifest.json +9 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
- package/dist/templates/modules/job-progress/module.manifest.json +3 -3
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
- package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +250 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -40
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +227 -70
- package/dist/templates/modules/sse/module.manifest.json +20 -5
- package/dist/update.d.ts +17 -0
- package/dist/update.js +33 -8
- package/package.json +2 -2
|
@@ -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
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import type { QueryRunner } from "typeorm";
|
|
4
|
+
import { InitAuthConfig1720500000000 } from "../migrations/1720500000000-InitAuthConfig";
|
|
5
|
+
import { readAuthConfigRow, readAuthConfigRows, writeAuthConfigRow } from "./auth-config.rows";
|
|
6
|
+
|
|
7
|
+
let open: SQL | undefined;
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await open?.close();
|
|
11
|
+
open = undefined;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
async function sqliteDatabase(): Promise<SQL> {
|
|
15
|
+
const sql = new SQL(":memory:");
|
|
16
|
+
open = sql;
|
|
17
|
+
const runner = {
|
|
18
|
+
connection: { options: { type: "better-sqlite3" } },
|
|
19
|
+
query: (query: string): Promise<unknown> => sql.unsafe(query),
|
|
20
|
+
} as unknown as QueryRunner;
|
|
21
|
+
await new InitAuthConfig1720500000000().up(runner);
|
|
22
|
+
return sql;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("auth config rows on SQLite", () => {
|
|
26
|
+
it("reads the integer enabled column back as a boolean", async () => {
|
|
27
|
+
const sql = await sqliteDatabase();
|
|
28
|
+
await writeAuthConfigRow(sql, "sqlite", { key: "smtp", enabled: true, config: { host: "mail" }, secret: null });
|
|
29
|
+
await writeAuthConfigRow(sql, "sqlite", { key: "social.github", enabled: false, config: {}, secret: null });
|
|
30
|
+
|
|
31
|
+
expect(await readAuthConfigRow(sql, "smtp")).toEqual({
|
|
32
|
+
key: "smtp",
|
|
33
|
+
enabled: true,
|
|
34
|
+
config: JSON.stringify({ host: "mail" }),
|
|
35
|
+
secret: null,
|
|
36
|
+
});
|
|
37
|
+
const rows = await readAuthConfigRows(sql);
|
|
38
|
+
expect(rows.map((row) => [row.key, row.enabled])).toEqual([
|
|
39
|
+
["smtp", true],
|
|
40
|
+
["social.github", false],
|
|
41
|
+
]);
|
|
42
|
+
expect(await readAuthConfigRow(sql, "server")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps a boolean through a read and rewrite", async () => {
|
|
46
|
+
const sql = await sqliteDatabase();
|
|
47
|
+
await writeAuthConfigRow(sql, "sqlite", { key: "smtp", enabled: true, config: {}, secret: null });
|
|
48
|
+
const current = await readAuthConfigRow(sql, "smtp");
|
|
49
|
+
if (!current) throw new Error("expected the smtp row");
|
|
50
|
+
await writeAuthConfigRow(sql, "sqlite", { ...current, secret: "sealed" });
|
|
51
|
+
expect((await readAuthConfigRow(sql, "smtp"))?.enabled).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
3
|
+
import { authConfigParam } from "./config-param";
|
|
4
|
+
|
|
5
|
+
export interface AuthConfigRow {
|
|
6
|
+
key: string;
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
config: unknown;
|
|
9
|
+
secret: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function configObject(value: unknown): Record<string, unknown> {
|
|
13
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
14
|
+
return value as Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
if (typeof value === "string") {
|
|
17
|
+
try {
|
|
18
|
+
return configObject(JSON.parse(value) as unknown);
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// SQLite stores booleans as 0 or 1.
|
|
27
|
+
interface StoredAuthConfigRow extends Omit<AuthConfigRow, "enabled"> {
|
|
28
|
+
enabled: boolean | number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function authConfigRow(row: StoredAuthConfigRow): AuthConfigRow {
|
|
32
|
+
return { ...row, enabled: row.enabled === true || row.enabled === 1 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function readAuthConfigRows(sql: SQL): Promise<AuthConfigRow[]> {
|
|
36
|
+
const rows = await sql<StoredAuthConfigRow[]>`
|
|
37
|
+
SELECT "key", "enabled", "config", "secret" FROM "auth_config"
|
|
38
|
+
`;
|
|
39
|
+
return rows.map(authConfigRow);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readAuthConfigRow(sql: SQL, key: string): Promise<AuthConfigRow | null> {
|
|
43
|
+
const rows = await sql<StoredAuthConfigRow[]>`
|
|
44
|
+
SELECT "key", "enabled", "config", "secret"
|
|
45
|
+
FROM "auth_config" WHERE "key" = ${key}
|
|
46
|
+
`;
|
|
47
|
+
return rows[0] ? authConfigRow(rows[0]) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function writeAuthConfigRow(
|
|
51
|
+
sql: SQL,
|
|
52
|
+
provider: DatabaseProviderName,
|
|
53
|
+
row: AuthConfigRow,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const config = authConfigParam(provider, configObject(row.config));
|
|
56
|
+
await sql`
|
|
57
|
+
INSERT INTO "auth_config" ("key", "enabled", "config", "secret", "updatedAt")
|
|
58
|
+
VALUES (${row.key}, ${row.enabled}, ${config}, ${row.secret}, CURRENT_TIMESTAMP)
|
|
59
|
+
ON CONFLICT ("key") DO UPDATE SET
|
|
60
|
+
"enabled" = EXCLUDED."enabled",
|
|
61
|
+
"config" = EXCLUDED."config",
|
|
62
|
+
"secret" = EXCLUDED."secret",
|
|
63
|
+
"updatedAt" = CURRENT_TIMESTAMP
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
@@ -10,7 +10,15 @@ 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 {
|
|
16
|
+
readAuthConfigRow,
|
|
17
|
+
readAuthConfigRows,
|
|
18
|
+
writeAuthConfigRow,
|
|
19
|
+
type AuthConfigRow,
|
|
20
|
+
} from "./auth-config.rows";
|
|
21
|
+
import { resetSessionExpirations } from "./session-expiry";
|
|
14
22
|
|
|
15
23
|
export type SocialProviderView = {
|
|
16
24
|
id: string;
|
|
@@ -71,13 +79,6 @@ export type AuthConfigUpdate = {
|
|
|
71
79
|
};
|
|
72
80
|
};
|
|
73
81
|
|
|
74
|
-
interface AuthConfigRow {
|
|
75
|
-
key: string;
|
|
76
|
-
enabled: boolean;
|
|
77
|
-
config: unknown;
|
|
78
|
-
secret: string | null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
82
|
function object(value: unknown): Record<string, unknown> {
|
|
82
83
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
83
84
|
return value as Record<string, unknown>;
|
|
@@ -102,33 +103,21 @@ function booleanField(source: Record<string, unknown>, key: string, fallback: bo
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
export class AuthConfigService {
|
|
105
|
-
constructor(
|
|
106
|
+
constructor(
|
|
107
|
+
private readonly sql: SQL,
|
|
108
|
+
private readonly provider: DatabaseProviderName = "postgres",
|
|
109
|
+
) {}
|
|
106
110
|
|
|
107
111
|
private async rows(): Promise<AuthConfigRow[]> {
|
|
108
|
-
return this.sql
|
|
109
|
-
SELECT "key", "enabled", "config", "secret" FROM "auth_config"
|
|
110
|
-
`;
|
|
112
|
+
return readAuthConfigRows(this.sql);
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
private async row(key: string): Promise<AuthConfigRow | null> {
|
|
114
|
-
|
|
115
|
-
SELECT "key", "enabled", "config", "secret"
|
|
116
|
-
FROM "auth_config" WHERE "key" = ${key}
|
|
117
|
-
`;
|
|
118
|
-
return rows[0] ?? null;
|
|
116
|
+
return readAuthConfigRow(this.sql, key);
|
|
119
117
|
}
|
|
120
118
|
|
|
121
119
|
private async upsert(row: AuthConfigRow): Promise<void> {
|
|
122
|
-
|
|
123
|
-
await this.sql`
|
|
124
|
-
INSERT INTO "auth_config" ("key", "enabled", "config", "secret", "updatedAt")
|
|
125
|
-
VALUES (${row.key}, ${row.enabled}, ${config}, ${row.secret}, CURRENT_TIMESTAMP)
|
|
126
|
-
ON CONFLICT ("key") DO UPDATE SET
|
|
127
|
-
"enabled" = EXCLUDED."enabled",
|
|
128
|
-
"config" = EXCLUDED."config",
|
|
129
|
-
"secret" = EXCLUDED."secret",
|
|
130
|
-
"updatedAt" = CURRENT_TIMESTAMP
|
|
131
|
-
`;
|
|
120
|
+
await writeAuthConfigRow(this.sql, this.provider, row);
|
|
132
121
|
}
|
|
133
122
|
|
|
134
123
|
async describe(): Promise<AuthConfigView> {
|
|
@@ -211,7 +200,12 @@ export class AuthConfigService {
|
|
|
211
200
|
if (update.server) await this.upsertServer(update.server);
|
|
212
201
|
await refreshAuthNow();
|
|
213
202
|
if (update.server?.sessionIdleTimeoutMinutes !== undefined) {
|
|
214
|
-
|
|
203
|
+
const minutes = update.server.sessionIdleTimeoutMinutes;
|
|
204
|
+
await resetSessionExpirations(
|
|
205
|
+
this.sql,
|
|
206
|
+
this.provider,
|
|
207
|
+
minutes === null ? DEFAULT_SESSION_LIFETIME_SECONDS : minutes * 60,
|
|
208
|
+
);
|
|
215
209
|
}
|
|
216
210
|
return this.describe();
|
|
217
211
|
}
|
|
@@ -280,14 +274,4 @@ export class AuthConfigService {
|
|
|
280
274
|
}
|
|
281
275
|
await this.upsert({ ...current, config });
|
|
282
276
|
}
|
|
283
|
-
|
|
284
|
-
private async resetSessionExpirations(minutes: number | null): Promise<void> {
|
|
285
|
-
const lifetimeSeconds = minutes === null ? DEFAULT_SESSION_LIFETIME_SECONDS : minutes * 60;
|
|
286
|
-
await this.sql`
|
|
287
|
-
UPDATE "session"
|
|
288
|
-
SET "expiresAt" = CURRENT_TIMESTAMP + (${lifetimeSeconds} * INTERVAL '1 second'),
|
|
289
|
-
"updatedAt" = CURRENT_TIMESTAMP
|
|
290
|
-
WHERE "expiresAt" > CURRENT_TIMESTAMP
|
|
291
|
-
`;
|
|
292
|
-
}
|
|
293
277
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { resetSessionExpirations } from "./session-expiry";
|
|
4
|
+
|
|
5
|
+
let open: SQL | undefined;
|
|
6
|
+
|
|
7
|
+
afterEach(async () => {
|
|
8
|
+
await open?.close();
|
|
9
|
+
open = undefined;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("session expiry reset on SQLite", () => {
|
|
13
|
+
it("moves active sessions to the new lifetime and leaves expired ones", async () => {
|
|
14
|
+
const sql = new SQL(":memory:");
|
|
15
|
+
open = sql;
|
|
16
|
+
// Better Auth stores SQLite dates as ISO 8601 text.
|
|
17
|
+
await sql`CREATE TABLE "session" ("id" text PRIMARY KEY, "expiresAt" date NOT NULL, "updatedAt" date NOT NULL)`;
|
|
18
|
+
const now = new Date("2026-10-15T10:00:00.000Z");
|
|
19
|
+
const active = new Date(now.getTime() + 3_600_000).toISOString();
|
|
20
|
+
const expired = new Date(now.getTime() - 1_000).toISOString();
|
|
21
|
+
await sql`INSERT INTO "session" VALUES (${"active"}, ${active}, ${active}), (${"expired"}, ${expired}, ${expired})`;
|
|
22
|
+
|
|
23
|
+
await resetSessionExpirations(sql, "sqlite", 1_800, now);
|
|
24
|
+
|
|
25
|
+
const rows = await sql<Array<{ id: string; expiresAt: string; updatedAt: string }>>`
|
|
26
|
+
SELECT "id", "expiresAt", "updatedAt" FROM "session" ORDER BY "id"
|
|
27
|
+
`;
|
|
28
|
+
expect(rows).toEqual([
|
|
29
|
+
{ id: "active", expiresAt: "2026-10-15T10:30:00.000Z", updatedAt: now.toISOString() },
|
|
30
|
+
{ id: "expired", expiresAt: expired, updatedAt: expired },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
3
|
+
|
|
4
|
+
/** Give every active session the configured lifetime from now. */
|
|
5
|
+
export async function resetSessionExpirations(
|
|
6
|
+
sql: SQL,
|
|
7
|
+
provider: DatabaseProviderName,
|
|
8
|
+
lifetimeSeconds: number,
|
|
9
|
+
now: Date = new Date(),
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
if (provider === "sqlite") {
|
|
12
|
+
// Better Auth stores SQLite dates as ISO 8601 text, which sorts in time
|
|
13
|
+
// order. SQLite has no interval arithmetic.
|
|
14
|
+
const current = now.toISOString();
|
|
15
|
+
const expiresAt = new Date(now.getTime() + lifetimeSeconds * 1000).toISOString();
|
|
16
|
+
await sql`
|
|
17
|
+
UPDATE "session"
|
|
18
|
+
SET "expiresAt" = ${expiresAt}, "updatedAt" = ${current}
|
|
19
|
+
WHERE "expiresAt" > ${current}
|
|
20
|
+
`;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
await sql`
|
|
24
|
+
UPDATE "session"
|
|
25
|
+
SET "expiresAt" = CURRENT_TIMESTAMP + (${lifetimeSeconds} * INTERVAL '1 second'),
|
|
26
|
+
"updatedAt" = CURRENT_TIMESTAMP
|
|
27
|
+
WHERE "expiresAt" > CURRENT_TIMESTAMP
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Bootstrap the initial administrator
|
|
2
2
|
|
|
3
3
|
Use the generated database-backed command after the API build and migrations.
|
|
4
|
+
With `NODE_ENV=production` it loads the bundled `dist/bootstrap-admin-runtime.js`;
|
|
5
|
+
otherwise it runs the source entry. It supports PostgreSQL and SQLite.
|
|
4
6
|
Do not create the first administrator through the public sign-up page.
|
|
5
7
|
|
|
6
8
|
## Set the persistent allowlist
|
|
@@ -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",
|