@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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Capabilities } from "@podosoft/podokit-contracts";
|
|
2
2
|
import { Elysia, t } from "elysia";
|
|
3
|
+
import { EVENTS } from "@podosoft/podokit-runtime";
|
|
3
4
|
import {
|
|
4
5
|
ACCESS_POLICY,
|
|
5
6
|
DATABASE,
|
|
7
|
+
LOGGER,
|
|
6
8
|
OPENAPI,
|
|
7
9
|
REQUEST_GUARDS,
|
|
8
10
|
REQUEST_IDENTITY,
|
|
@@ -17,10 +19,11 @@ import {
|
|
|
17
19
|
import { FEATURE_FLAGS, SettingsService, type FeatureFlag } from "../settings/settings.service";
|
|
18
20
|
import { authConfigStore } from "./auth-config-store";
|
|
19
21
|
import { getAuth, authRuntime, primeAuth } from "./auth-provider";
|
|
20
|
-
import {
|
|
22
|
+
import { cachedAuthOpenApiDocuments } from "./auth.openapi";
|
|
21
23
|
import { ROLE_NAMES } from "./permissions";
|
|
22
|
-
import { AuthService } from "./auth.service";
|
|
24
|
+
import { AuthService, createRequestIdentity } from "./auth.service";
|
|
23
25
|
import { configureAuthQueryDatabase } from "./db";
|
|
26
|
+
import { configEvents, publishConfigChanged } from "./config-events";
|
|
24
27
|
import { setMailConfigStore } from "../mail/mailer";
|
|
25
28
|
|
|
26
29
|
export const AUTH = Symbol("auth") as ServiceKey<AuthService>;
|
|
@@ -69,12 +72,14 @@ const accountPlugin: AppPlugin = ({ services }) => {
|
|
|
69
72
|
}, {
|
|
70
73
|
detail: { tags: ["account"], summary: "Get the current account" },
|
|
71
74
|
})
|
|
72
|
-
.get("/account/require-2fa", () => ({ require2fa: settings.getBool("require2fa") }), {
|
|
75
|
+
.get("/account/require-2fa", async () => ({ require2fa: await settings.getBool("require2fa") }), {
|
|
73
76
|
detail: { tags: ["account"], summary: "Get the two-factor enrolment policy" },
|
|
74
77
|
})
|
|
75
78
|
.get("/account/capabilities", async (): Promise<Capabilities> => {
|
|
76
|
-
const flags =
|
|
77
|
-
|
|
79
|
+
const [flags, snapshot] = await Promise.all([
|
|
80
|
+
settings.flags(),
|
|
81
|
+
authConfigStore.capabilitiesSnapshot(),
|
|
82
|
+
]);
|
|
78
83
|
return {
|
|
79
84
|
twoFactor: flags.twoFactor,
|
|
80
85
|
magicLink: flags.magicLink,
|
|
@@ -86,6 +91,7 @@ const accountPlugin: AppPlugin = ({ services }) => {
|
|
|
86
91
|
passkey: flags.passkey,
|
|
87
92
|
organization: flags.organization,
|
|
88
93
|
oidcProvider: flags.oidcProvider,
|
|
94
|
+
require2fa: flags.require2fa,
|
|
89
95
|
providers: snapshot.providers,
|
|
90
96
|
deleteAccount: snapshot.allowDelete,
|
|
91
97
|
emailVerification: snapshot.requireEmailVerification,
|
|
@@ -151,26 +157,35 @@ export const authModule: PodokitModule = {
|
|
|
151
157
|
configureAuthQueryDatabase(database.sql);
|
|
152
158
|
setMailConfigStore(authConfigStore);
|
|
153
159
|
const accessPolicy = services.resolve(ACCESS_POLICY);
|
|
154
|
-
const settings = new SettingsService(database.sql);
|
|
160
|
+
const settings = new SettingsService(database.sql, authConfigStore, publishConfigChanged);
|
|
155
161
|
const authService = new AuthService(settings, accessPolicy);
|
|
156
162
|
services.register(SETTINGS, settings);
|
|
157
|
-
services.register(AUTH_CONFIG, new AuthConfigService(database.sql));
|
|
163
|
+
services.register(AUTH_CONFIG, new AuthConfigService(database.sql, database.provider));
|
|
158
164
|
services.register(AUTH, authService, () => authService.close());
|
|
159
165
|
services.onStart(async () => {
|
|
160
|
-
await settings.refresh();
|
|
161
166
|
await primeAuth();
|
|
167
|
+
// Optional: with an events provider, other replicas' settings writes apply
|
|
168
|
+
// at once instead of at the next version probe.
|
|
169
|
+
const events = services.tryResolve(EVENTS);
|
|
170
|
+
if (!events) return;
|
|
171
|
+
try {
|
|
172
|
+
await configEvents.listen(events, () => authConfigStore.invalidate());
|
|
173
|
+
} catch (error) {
|
|
174
|
+
services.resolve(LOGGER).warn(
|
|
175
|
+
{ reason: error instanceof Error ? error.message : String(error) },
|
|
176
|
+
"Settings change events are unavailable; replicas converge on the settings TTL",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
162
179
|
});
|
|
163
|
-
services.override(REQUEST_IDENTITY,
|
|
164
|
-
userId: async (request) => (await authService.session(request))?.user.id,
|
|
165
|
-
});
|
|
180
|
+
services.override(REQUEST_IDENTITY, createRequestIdentity(authService));
|
|
166
181
|
accessPolicy.register("*", "/api/auth", "public");
|
|
167
182
|
accessPolicy.register("*", "/api/auth/*", "public");
|
|
168
183
|
accessPolicy.register("GET", "/account/capabilities", "public");
|
|
184
|
+
const authOpenApiDocument = cachedAuthOpenApiDocuments();
|
|
169
185
|
services.resolve(OPENAPI).register("better-auth", async () => {
|
|
170
186
|
await primeAuth();
|
|
171
|
-
const document = await generateAuthOpenApiDocument(getAuth().api);
|
|
172
187
|
return {
|
|
173
|
-
document,
|
|
188
|
+
document: await authOpenApiDocument(getAuth()),
|
|
174
189
|
pathPrefix: "/api/auth",
|
|
175
190
|
};
|
|
176
191
|
});
|
|
@@ -1,5 +1,23 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, it, mock, type Mock } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
cachedAuthOpenApiDocuments,
|
|
4
|
+
generateAuthOpenApiDocument,
|
|
5
|
+
isAuthOpenApiApi,
|
|
6
|
+
} from "./auth.openapi";
|
|
7
|
+
|
|
8
|
+
type GenerateSchema = () => Promise<Record<string, unknown>>;
|
|
9
|
+
|
|
10
|
+
function authInstance(version: string): { api: { generateOpenAPISchema: Mock<GenerateSchema> } } {
|
|
11
|
+
return {
|
|
12
|
+
api: {
|
|
13
|
+
generateOpenAPISchema: mock<GenerateSchema>(async () => ({
|
|
14
|
+
openapi: "3.1.0",
|
|
15
|
+
info: { version },
|
|
16
|
+
paths: {},
|
|
17
|
+
})),
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
3
21
|
|
|
4
22
|
describe("Better Auth OpenAPI integration", () => {
|
|
5
23
|
it("accepts the generated OpenAPI API without weakening the auth type", async () => {
|
|
@@ -17,4 +35,40 @@ describe("Better Auth OpenAPI integration", () => {
|
|
|
17
35
|
"invalid OpenAPI document",
|
|
18
36
|
);
|
|
19
37
|
});
|
|
38
|
+
|
|
39
|
+
it("reuses the generated document for the same auth instance", async () => {
|
|
40
|
+
const documents = cachedAuthOpenApiDocuments();
|
|
41
|
+
const auth = authInstance("1");
|
|
42
|
+
|
|
43
|
+
const first = await documents(auth);
|
|
44
|
+
const second = await documents(auth);
|
|
45
|
+
|
|
46
|
+
expect(second).toBe(first);
|
|
47
|
+
expect(auth.api.generateOpenAPISchema).toHaveBeenCalledTimes(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("generates a fresh document after the auth instance is rebuilt", async () => {
|
|
51
|
+
const documents = cachedAuthOpenApiDocuments();
|
|
52
|
+
const original = authInstance("1");
|
|
53
|
+
const rebuilt = authInstance("2");
|
|
54
|
+
|
|
55
|
+
const before = await documents(original);
|
|
56
|
+
const after = await documents(rebuilt);
|
|
57
|
+
|
|
58
|
+
expect(after).not.toBe(before);
|
|
59
|
+
expect(after).toMatchObject({ info: { version: "2" } });
|
|
60
|
+
expect(rebuilt.api.generateOpenAPISchema).toHaveBeenCalledTimes(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("retries after a rejected generation instead of caching the failure", async () => {
|
|
64
|
+
const documents = cachedAuthOpenApiDocuments();
|
|
65
|
+
const auth = authInstance("1");
|
|
66
|
+
auth.api.generateOpenAPISchema.mockImplementationOnce(async () => {
|
|
67
|
+
throw new Error("plugin endpoints unavailable");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await expect(documents(auth)).rejects.toThrow("plugin endpoints unavailable");
|
|
71
|
+
await expect(documents(auth)).resolves.toMatchObject({ openapi: "3.1.0" });
|
|
72
|
+
expect(auth.api.generateOpenAPISchema).toHaveBeenCalledTimes(2);
|
|
73
|
+
});
|
|
20
74
|
});
|
|
@@ -20,3 +20,25 @@ export async function generateAuthOpenApiDocument(api: unknown): Promise<Record<
|
|
|
20
20
|
}
|
|
21
21
|
return document;
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
export interface AuthWithApi {
|
|
25
|
+
api: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AuthOpenApiDocuments = (auth: AuthWithApi) => Promise<Record<string, unknown>>;
|
|
29
|
+
|
|
30
|
+
/** Generating the schema rebuilds every plugin's endpoints, so reuse it until the
|
|
31
|
+
* auth instance is rebuilt. A rejected generation is dropped so the next call retries. */
|
|
32
|
+
export function cachedAuthOpenApiDocuments(): AuthOpenApiDocuments {
|
|
33
|
+
const documents = new WeakMap<AuthWithApi, Promise<Record<string, unknown>>>();
|
|
34
|
+
return (auth) => {
|
|
35
|
+
const cached = documents.get(auth);
|
|
36
|
+
if (cached) return cached;
|
|
37
|
+
const document = generateAuthOpenApiDocument(auth.api);
|
|
38
|
+
documents.set(auth, document);
|
|
39
|
+
document.catch(() => {
|
|
40
|
+
if (documents.get(auth) === document) documents.delete(auth);
|
|
41
|
+
});
|
|
42
|
+
return document;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { AccessPolicy } from "../core/services";
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { AccessPolicy, type RequestIdentity } from "../core/services";
|
|
3
3
|
import type { SettingsService } from "../settings/settings.service";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AuthService,
|
|
6
|
+
createRequestIdentity,
|
|
7
|
+
rolesOf,
|
|
8
|
+
type AuthSession,
|
|
9
|
+
type SessionResolver,
|
|
10
|
+
} from "./auth.service";
|
|
5
11
|
|
|
6
12
|
function settings(require2fa: boolean): SettingsService {
|
|
7
13
|
return { getBool: () => require2fa } as unknown as SettingsService;
|
|
8
14
|
}
|
|
9
15
|
|
|
10
|
-
function session(
|
|
11
|
-
|
|
16
|
+
function session(
|
|
17
|
+
overrides: Partial<AuthSession["user"]> = {},
|
|
18
|
+
sessionFields: Record<string, unknown> = {},
|
|
19
|
+
): AuthSession {
|
|
20
|
+
return { user: { id: "user-1", ...overrides }, session: sessionFields };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function identityFor(resolver: SessionResolver): RequestIdentity {
|
|
24
|
+
return createRequestIdentity(new AuthService(settings(false), new AccessPolicy(), resolver));
|
|
12
25
|
}
|
|
13
26
|
|
|
14
27
|
describe("AuthService", () => {
|
|
@@ -50,3 +63,66 @@ describe("AuthService", () => {
|
|
|
50
63
|
.rejects.toMatchObject({ code: "ADMIN_REQUIRED", statusCode: 403 });
|
|
51
64
|
});
|
|
52
65
|
});
|
|
66
|
+
|
|
67
|
+
describe("createRequestIdentity", () => {
|
|
68
|
+
const request = (): Request => new Request("http://localhost/events");
|
|
69
|
+
|
|
70
|
+
test("treats a request without a session as anonymous", async () => {
|
|
71
|
+
const identity = identityFor(async () => null);
|
|
72
|
+
const anonymous = request();
|
|
73
|
+
expect(await identity.userId(anonymous)).toBeUndefined();
|
|
74
|
+
expect(await identity.isAdmin(anonymous)).toBe(false);
|
|
75
|
+
expect(await identity.roles(anonymous)).toEqual([]);
|
|
76
|
+
expect(await identity.activeOrganizationId(anonymous)).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("reads every role from the comma-separated admin plugin format", async () => {
|
|
80
|
+
const identity = identityFor(async () => session({ role: "user,admin" }));
|
|
81
|
+
const signedIn = request();
|
|
82
|
+
expect(await identity.userId(signedIn)).toBe("user-1");
|
|
83
|
+
expect(await identity.roles(signedIn)).toEqual(["user", "admin"]);
|
|
84
|
+
expect(await identity.isAdmin(signedIn)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("reads roles stored as an array", async () => {
|
|
88
|
+
const identity = identityFor(async () => session({ role: ["moderator"] }));
|
|
89
|
+
const signedIn = request();
|
|
90
|
+
expect(await identity.roles(signedIn)).toEqual(["moderator"]);
|
|
91
|
+
expect(await identity.isAdmin(signedIn)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("returns no roles when the user has none", async () => {
|
|
95
|
+
const identity = identityFor(async () => session({ role: null }));
|
|
96
|
+
expect(await identity.roles(request())).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("reads the active organization from the session", async () => {
|
|
100
|
+
const active = identityFor(async () => session({}, { activeOrganizationId: "org-1" }));
|
|
101
|
+
expect(await active.activeOrganizationId(request())).toBe("org-1");
|
|
102
|
+
|
|
103
|
+
const cleared = identityFor(async () => session({}, { activeOrganizationId: null }));
|
|
104
|
+
expect(await cleared.activeOrganizationId(request())).toBeUndefined();
|
|
105
|
+
|
|
106
|
+
const missing = identityFor(async () => session());
|
|
107
|
+
expect(await missing.activeOrganizationId(request())).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("resolves the session once per request across all questions", async () => {
|
|
111
|
+
const resolver = mock(async (_headers: Headers): Promise<AuthSession | null> =>
|
|
112
|
+
session({ role: "admin" }, { activeOrganizationId: "org-1" }));
|
|
113
|
+
const identity = identityFor(resolver);
|
|
114
|
+
const first = request();
|
|
115
|
+
|
|
116
|
+
await Promise.all([
|
|
117
|
+
identity.userId(first),
|
|
118
|
+
identity.isAdmin(first),
|
|
119
|
+
identity.roles(first),
|
|
120
|
+
identity.activeOrganizationId(first),
|
|
121
|
+
]);
|
|
122
|
+
await identity.roles(first);
|
|
123
|
+
expect(resolver).toHaveBeenCalledTimes(1);
|
|
124
|
+
|
|
125
|
+
await identity.userId(request());
|
|
126
|
+
expect(resolver).toHaveBeenCalledTimes(2);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
-
import type { AccessPolicy } from "../core/services";
|
|
2
|
+
import type { AccessPolicy, RequestIdentity } from "../core/services";
|
|
3
3
|
import type { SettingsService } from "../settings/settings.service";
|
|
4
4
|
import { getAuth, refreshIfStale } from "./auth-provider";
|
|
5
5
|
import { closeAuthDatabase } from "./db";
|
|
@@ -28,6 +28,11 @@ export function rolesOf(user: AuthUser): string[] {
|
|
|
28
28
|
return (user.role ?? "").split(",").map((role) => role.trim()).filter(Boolean);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function activeOrganizationOf(session: AuthSession): string | undefined {
|
|
32
|
+
const value = session.session.activeOrganizationId;
|
|
33
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
export class AuthService {
|
|
32
37
|
private readonly sessions = new WeakMap<Request, Promise<AuthSession | null>>();
|
|
33
38
|
|
|
@@ -62,7 +67,7 @@ export class AuthService {
|
|
|
62
67
|
async guard(request: Request): Promise<void> {
|
|
63
68
|
if (this.accessPolicy.resolve(request) !== "session") return;
|
|
64
69
|
const session = await this.requireSession(request);
|
|
65
|
-
if (!this.settings.getBool("require2fa")
|
|
70
|
+
if (session.user.twoFactorEnabled || !(await this.settings.getBool("require2fa"))) return;
|
|
66
71
|
const path = new URL(request.url).pathname;
|
|
67
72
|
if (path === "/account/require-2fa" || path === "/account/settings") return;
|
|
68
73
|
throw new AppException(
|
|
@@ -76,3 +81,22 @@ export class AuthService {
|
|
|
76
81
|
await closeAuthDatabase();
|
|
77
82
|
}
|
|
78
83
|
}
|
|
84
|
+
|
|
85
|
+
/** Answers identity questions for other modules from the one session lookup memoized per request. */
|
|
86
|
+
export function createRequestIdentity(auth: AuthService): RequestIdentity {
|
|
87
|
+
return {
|
|
88
|
+
userId: async (request) => (await auth.session(request))?.user.id,
|
|
89
|
+
isAdmin: async (request) => {
|
|
90
|
+
const session = await auth.session(request);
|
|
91
|
+
return session !== null && rolesOf(session.user).includes("admin");
|
|
92
|
+
},
|
|
93
|
+
roles: async (request) => {
|
|
94
|
+
const session = await auth.session(request);
|
|
95
|
+
return session ? rolesOf(session.user) : [];
|
|
96
|
+
},
|
|
97
|
+
activeOrganizationId: async (request) => {
|
|
98
|
+
const session = await auth.session(request);
|
|
99
|
+
return session ? activeOrganizationOf(session) : undefined;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -3,9 +3,10 @@ import { APIError } from "better-auth/api";
|
|
|
3
3
|
import { twoFactor, haveIBeenPwned, magicLink, emailOTP, username, multiSession, phoneNumber, organization, jwt, bearer, openAPI } from "better-auth/plugins";
|
|
4
4
|
import { actionEmail, sendMail } from "../mail/mailer";
|
|
5
5
|
import { sendSms } from "../sms/sms";
|
|
6
|
-
import { assertUserCreationAllowed, createFeatureGate,
|
|
6
|
+
import { assertUserCreationAllowed, createFeatureGate, readSignupOpen } from "./feature-gate";
|
|
7
|
+
import { featureFlags, isSignupOpen } from "./auth-config-store";
|
|
7
8
|
import { orgAc, orgRoles } from "./org-permissions";
|
|
8
|
-
import { authDatabase
|
|
9
|
+
import { authDatabase } from "./db";
|
|
9
10
|
import { type AuthConfig, envAuthConfig, sessionIdleOptions, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
|
|
10
11
|
import { apiKey } from "@better-auth/api-key";
|
|
11
12
|
import { passkey } from "@better-auth/passkey";
|
|
@@ -47,7 +48,6 @@ function buildSocial(config: AuthConfig): NonNullable<BetterAuthOptions["socialP
|
|
|
47
48
|
// server-enforced toggles) apply without a restart. Everything below the injection
|
|
48
49
|
// markers is contributed by other PodoKit modules (admin-dashboard, audit-log).
|
|
49
50
|
export function buildAuth(config: AuthConfig) {
|
|
50
|
-
const isSignupOpen = createSignupOpenCheck(readAppSettings);
|
|
51
51
|
const plugins: BetterAuthPlugin[] = [
|
|
52
52
|
twoFactor(),
|
|
53
53
|
magicLink({
|
|
@@ -112,9 +112,14 @@ export function buildAuth(config: AuthConfig) {
|
|
|
112
112
|
// Accept the session token via `Authorization: Bearer <token>` (returned in the
|
|
113
113
|
// `set-auth-token` response header on sign-in) instead of a cookie — for API and
|
|
114
114
|
// mobile clients. Infrastructure plugin (always on), like jwt(); no UI toggle.
|
|
115
|
-
|
|
115
|
+
// requireSignature ignores unsigned values, so API keys and OAuth access tokens
|
|
116
|
+
// sent as bearer credentials never cost a session lookup that cannot match.
|
|
117
|
+
bearer({ requireSignature: true }),
|
|
116
118
|
// JWT/JWKS signing keys — required by the OAuth provider to sign id_tokens.
|
|
117
|
-
|
|
119
|
+
// Without disableSettingJwtHeader, every session lookup (including server-side
|
|
120
|
+
// getSession calls) signs an unused `set-auth-jwt` header. Clients that need a
|
|
121
|
+
// JWT call GET /api/auth/token.
|
|
122
|
+
jwt({ disableSettingJwtHeader: true }),
|
|
118
123
|
// Act as an OAuth2/OIDC identity provider: other apps can "Sign in with this app".
|
|
119
124
|
// Register clients at /api/auth/oauth2/create-client; discovery at
|
|
120
125
|
// /api/auth/.well-known/openid-configuration. See docs/modules.md.
|
|
@@ -137,7 +142,7 @@ export function buildAuth(config: AuthConfig) {
|
|
|
137
142
|
before: async (user, context) => {
|
|
138
143
|
const adminEmail = adminEmails.has(user.email.toLowerCase());
|
|
139
144
|
const createdByAdmin = context?.path === "/admin/create-user";
|
|
140
|
-
assertUserCreationAllowed(await isSignupOpen
|
|
145
|
+
assertUserCreationAllowed(await readSignupOpen(isSignupOpen), context?.path);
|
|
141
146
|
return {
|
|
142
147
|
data: {
|
|
143
148
|
...user,
|
|
@@ -174,10 +179,11 @@ export function buildAuth(config: AuthConfig) {
|
|
|
174
179
|
};
|
|
175
180
|
|
|
176
181
|
// Request-time hooks. The feature gate turns the admin Settings toggles into a
|
|
177
|
-
// real server boundary (disabled features 404).
|
|
178
|
-
//
|
|
182
|
+
// real server boundary (disabled features 404). It reads the process-wide
|
|
183
|
+
// settings snapshot, so a rebuild does not start a new cache. Other PodoKit
|
|
184
|
+
// modules add their own hooks below the marker.
|
|
179
185
|
const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
|
|
180
|
-
before: createFeatureGate(
|
|
186
|
+
before: createFeatureGate({ flags: featureFlags, signupOpen: isSignupOpen }),
|
|
181
187
|
};
|
|
182
188
|
// podokit:begin:auth-hooks
|
|
183
189
|
// podokit:end:auth-hooks
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MemoryEventBus, type EventBus } from "@podosoft/podokit-runtime";
|
|
3
|
+
import { CONFIG_CHANGED_TOPIC, createConfigEvents } from "./config-events";
|
|
4
|
+
|
|
5
|
+
describe("settings change events", () => {
|
|
6
|
+
test("invalidates other processes but not the publisher", async () => {
|
|
7
|
+
const bus = new MemoryEventBus();
|
|
8
|
+
const replicaA = createConfigEvents();
|
|
9
|
+
const replicaB = createConfigEvents();
|
|
10
|
+
let changesA = 0;
|
|
11
|
+
let changesB = 0;
|
|
12
|
+
await replicaA.listen(bus, () => { changesA += 1; });
|
|
13
|
+
await replicaB.listen(bus, () => { changesB += 1; });
|
|
14
|
+
|
|
15
|
+
await replicaA.publish();
|
|
16
|
+
await Bun.sleep(0);
|
|
17
|
+
expect(changesA).toBe(0);
|
|
18
|
+
expect(changesB).toBe(1);
|
|
19
|
+
await bus.close();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("does nothing without an events provider and tolerates a failing one", async () => {
|
|
23
|
+
await expect(createConfigEvents().publish()).resolves.toBeUndefined();
|
|
24
|
+
|
|
25
|
+
const failing: EventBus = {
|
|
26
|
+
ready: async () => undefined,
|
|
27
|
+
publish: async () => {
|
|
28
|
+
throw new Error("redis unavailable");
|
|
29
|
+
},
|
|
30
|
+
subscribe: async () => () => undefined,
|
|
31
|
+
close: () => undefined,
|
|
32
|
+
};
|
|
33
|
+
const events = createConfigEvents();
|
|
34
|
+
await events.listen(failing, () => undefined);
|
|
35
|
+
await expect(events.publish()).resolves.toBeUndefined();
|
|
36
|
+
expect(CONFIG_CHANGED_TOPIC).toBe("config.changed");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { EventBus } from "@podosoft/podokit-runtime";
|
|
2
|
+
|
|
3
|
+
export const CONFIG_CHANGED_TOPIC = "config.changed";
|
|
4
|
+
|
|
5
|
+
export interface ConfigEvents {
|
|
6
|
+
/** Run `onChange` when another process reports a settings write. */
|
|
7
|
+
listen(events: EventBus, onChange: () => void): Promise<void>;
|
|
8
|
+
/** Report a local settings write. Without a listening bus this does nothing. */
|
|
9
|
+
publish(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function originOf(event: unknown): unknown {
|
|
13
|
+
return event !== null && typeof event === "object" ? (event as { origin?: unknown }).origin : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Cross-replica invalidation over the optional events provider. Delivery is
|
|
17
|
+
* best effort: a lost message only delays convergence to the settings TTL. */
|
|
18
|
+
export function createConfigEvents(): ConfigEvents {
|
|
19
|
+
const origin = crypto.randomUUID();
|
|
20
|
+
let bus: EventBus | undefined;
|
|
21
|
+
return {
|
|
22
|
+
async listen(events, onChange): Promise<void> {
|
|
23
|
+
await events.subscribe(CONFIG_CHANGED_TOPIC, (event) => {
|
|
24
|
+
if (originOf(event) !== origin) onChange();
|
|
25
|
+
});
|
|
26
|
+
bus = events;
|
|
27
|
+
},
|
|
28
|
+
async publish(): Promise<void> {
|
|
29
|
+
if (!bus) return;
|
|
30
|
+
try {
|
|
31
|
+
await bus.publish(CONFIG_CHANGED_TOPIC, { origin });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.warn("[auth-config] could not publish a settings change; other replicas follow within the TTL:", err instanceof Error ? err.message : err);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const configEvents = createConfigEvents();
|
|
40
|
+
|
|
41
|
+
export function publishConfigChanged(): Promise<void> {
|
|
42
|
+
return configEvents.publish();
|
|
43
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
3
|
+
import { validateEnv } from "../config/env.validation";
|
|
4
|
+
import { PROVIDERS } from "../config/providers";
|
|
5
|
+
import { postgresPool } from "./db";
|
|
6
|
+
|
|
7
|
+
function databaseProvider(): DatabaseProviderName {
|
|
8
|
+
return PROVIDERS.database;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("auth database pool", () => {
|
|
12
|
+
it("applies the pool limits from the environment", () => {
|
|
13
|
+
if (databaseProvider() !== "postgres") {
|
|
14
|
+
expect(postgresPool).toBeNull();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const env = validateEnv(process.env);
|
|
18
|
+
expect(postgresPool?.options).toMatchObject({
|
|
19
|
+
max: env.DB_AUTH_POOL_MAX,
|
|
20
|
+
connectionTimeoutMillis: env.DB_CONNECT_TIMEOUT_MS,
|
|
21
|
+
idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Database as SqliteDatabase } from "bun:sqlite";
|
|
2
2
|
import type { SQL } from "bun";
|
|
3
3
|
import { Pool } from "pg";
|
|
4
|
-
import type { AuthConfigRow } from "@podosoft/podokit-auth";
|
|
4
|
+
import type { AuthConfigRow, ConfigVersions } from "@podosoft/podokit-auth";
|
|
5
5
|
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
6
6
|
import { validateEnv } from "../config/env.validation";
|
|
7
7
|
import { PROVIDERS } from "../config/providers";
|
|
@@ -15,7 +15,12 @@ function databaseProvider(): DatabaseProviderName {
|
|
|
15
15
|
const provider = databaseProvider();
|
|
16
16
|
|
|
17
17
|
export const postgresPool = provider === "postgres"
|
|
18
|
-
? new Pool({
|
|
18
|
+
? new Pool({
|
|
19
|
+
connectionString: url,
|
|
20
|
+
max: env.DB_AUTH_POOL_MAX,
|
|
21
|
+
connectionTimeoutMillis: env.DB_CONNECT_TIMEOUT_MS,
|
|
22
|
+
idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS,
|
|
23
|
+
})
|
|
19
24
|
: null;
|
|
20
25
|
|
|
21
26
|
export const sqliteDatabase = provider === "sqlite"
|
|
@@ -51,6 +56,30 @@ export async function readAuthConfig(): Promise<AuthConfigRow[]> {
|
|
|
51
56
|
return rows.map((row) => ({ ...row, enabled: row.enabled === true || row.enabled === 1 }));
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
type VersionRow = {
|
|
60
|
+
authConfigCount: number | string | bigint;
|
|
61
|
+
authConfigUpdatedAt: string | null;
|
|
62
|
+
appSettingCount: number | string | bigint;
|
|
63
|
+
appSettingUpdatedAt: string | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** One cheap statement that changes whenever either settings table changes. The
|
|
67
|
+
* count catches deletes; the text cast keeps PostgreSQL's microsecond precision. */
|
|
68
|
+
export async function readConfigVersions(): Promise<ConfigVersions> {
|
|
69
|
+
const sql = queries();
|
|
70
|
+
const [row] = await sql<VersionRow[]>`
|
|
71
|
+
SELECT
|
|
72
|
+
(SELECT count(*) FROM "auth_config") AS "authConfigCount",
|
|
73
|
+
(SELECT CAST(max("updatedAt") AS TEXT) FROM "auth_config") AS "authConfigUpdatedAt",
|
|
74
|
+
(SELECT count(*) FROM "app_setting") AS "appSettingCount",
|
|
75
|
+
(SELECT CAST(max("updatedAt") AS TEXT) FROM "app_setting") AS "appSettingUpdatedAt"
|
|
76
|
+
`;
|
|
77
|
+
return {
|
|
78
|
+
authConfig: `${String(row?.authConfigCount ?? 0)}:${row?.authConfigUpdatedAt ?? ""}`,
|
|
79
|
+
appSettings: `${String(row?.appSettingCount ?? 0)}:${row?.appSettingUpdatedAt ?? ""}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
54
83
|
export async function closeAuthDatabase(): Promise<void> {
|
|
55
84
|
if (postgresPool) await postgresPool.end();
|
|
56
85
|
sqliteDatabase?.close();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { FLAG_DEFAULTS, flagsFrom } from "../settings/flag-defaults";
|
|
3
|
+
import {
|
|
4
|
+
enforceFeatureGate,
|
|
5
|
+
PUBLIC_SIGNUP_DISABLED,
|
|
6
|
+
signupOpenFrom,
|
|
7
|
+
type FeatureFlags,
|
|
8
|
+
type FeatureGateSettings,
|
|
9
|
+
} from "./feature-gate";
|
|
10
|
+
|
|
11
|
+
function settings(flags: Partial<FeatureFlags>, signupOpen = true): FeatureGateSettings {
|
|
12
|
+
return {
|
|
13
|
+
flags: async () => ({ ...FLAG_DEFAULTS, ...flags }),
|
|
14
|
+
signupOpen: async () => signupOpen,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const failing: FeatureGateSettings = {
|
|
19
|
+
flags: async () => {
|
|
20
|
+
throw new Error('relation "app_setting" does not exist');
|
|
21
|
+
},
|
|
22
|
+
signupOpen: async () => {
|
|
23
|
+
throw new Error('relation "app_setting" does not exist');
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("feature gate", () => {
|
|
28
|
+
test("rejects a disabled feature's endpoints with 404", async () => {
|
|
29
|
+
await expect(enforceFeatureGate("/sign-in/magic-link", settings({ magicLink: false })))
|
|
30
|
+
.rejects.toMatchObject({ statusCode: 404 });
|
|
31
|
+
await expect(enforceFeatureGate("/sign-in/magic-link", settings({ magicLink: true })))
|
|
32
|
+
.resolves.toBeUndefined();
|
|
33
|
+
await expect(enforceFeatureGate("/get-session", settings({ magicLink: false })))
|
|
34
|
+
.resolves.toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("closes public sign-up without blocking other paths", async () => {
|
|
38
|
+
await expect(enforceFeatureGate("/sign-up/email", settings({}, false)))
|
|
39
|
+
.rejects.toMatchObject({ body: { code: PUBLIC_SIGNUP_DISABLED } });
|
|
40
|
+
await expect(enforceFeatureGate("/sign-in/social", settings({}, false)))
|
|
41
|
+
.resolves.toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("fails open to defaults when the settings cannot be read", async () => {
|
|
45
|
+
await expect(enforceFeatureGate("/sign-up/email", failing)).resolves.toBeUndefined();
|
|
46
|
+
await expect(enforceFeatureGate("/sign-in/magic-link", failing)).resolves.toBeUndefined();
|
|
47
|
+
await expect(enforceFeatureGate("/phone-number/send-otp", failing))
|
|
48
|
+
.rejects.toMatchObject({ statusCode: 404 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("projects stored settings over the shipped defaults", () => {
|
|
52
|
+
const stored = new Map([["magicLink", "false"], ["phoneNumber", "true"], ["site.allowSignup", "false"]]);
|
|
53
|
+
expect(flagsFrom(stored)).toMatchObject({ magicLink: false, phoneNumber: true, twoFactor: true });
|
|
54
|
+
expect(flagsFrom(new Map())).toEqual(FLAG_DEFAULTS);
|
|
55
|
+
expect(signupOpenFrom(stored)).toBe(false);
|
|
56
|
+
expect(signupOpenFrom(new Map())).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|