@podosoft/podokit 1.3.0 → 1.4.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 +10 -1
- package/dist/add.js +20 -7
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +9 -0
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +17 -2
- package/dist/templates/fullstack/README.md +19 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -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 +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +9 -1
- package/dist/templates/todo/README.md +18 -5
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
|
@@ -16,11 +16,18 @@ POSTGRES_DB=podokit
|
|
|
16
16
|
# queries, plus DB_AUTH_POOL_MAX for the auth module when it is installed. Keep
|
|
17
17
|
# replicas x (DB_POOL_MAX + DB_AUTH_POOL_MAX), plus a few for migrations, below
|
|
18
18
|
# the server's max_connections (100 by default). The timeouts are milliseconds.
|
|
19
|
+
# DB_QUERY_TIMEOUT_MS bounds how long a query waits for a server that stopped
|
|
20
|
+
# answering; migrations and maintenance commands are not bounded.
|
|
19
21
|
# DB_POOL_MAX=20
|
|
20
22
|
# DB_CONNECT_TIMEOUT_MS=10000
|
|
21
23
|
# DB_IDLE_TIMEOUT_MS=30000
|
|
24
|
+
# DB_QUERY_TIMEOUT_MS=10000
|
|
22
25
|
# DB_AUTH_POOL_MAX=10
|
|
23
26
|
|
|
27
|
+
# Largest API request body in bytes (Bun's default is 128 MiB). Requests through
|
|
28
|
+
# the web app's /api proxy are also bounded by BODY_SIZE_LIMIT below.
|
|
29
|
+
# API_MAX_REQUEST_BODY_SIZE=134217728
|
|
30
|
+
|
|
24
31
|
# Readiness (GET /health/ready): the longest one dependency check may run, and how
|
|
25
32
|
# long a result is reused across probes (0 checks on every request).
|
|
26
33
|
# READINESS_CHECK_TIMEOUT_MS=800
|
|
@@ -44,3 +51,6 @@ REDIS_DB=0
|
|
|
44
51
|
# REDIS_USERNAME=
|
|
45
52
|
# REDIS_PASSWORD=
|
|
46
53
|
REDIS_TLS=false
|
|
54
|
+
# Longest wait in milliseconds for Redis to answer one command. A command that
|
|
55
|
+
# gets no reply in time fails, and its connection is closed and reopened.
|
|
56
|
+
# REDIS_COMMAND_TIMEOUT_MS=1000
|
|
@@ -20,4 +20,6 @@ branch tags, and empty tags, and so should you.
|
|
|
20
20
|
For a hand-managed API WebSocket endpoint, add a `pathType: Exact` Ingress path to
|
|
21
21
|
the API service before the existing `/` web path. Do not route a prefix or the root
|
|
22
22
|
to API. Deployment profiles express the same rule with
|
|
23
|
-
`exposure.webSocketPaths` and validate it before rendering.
|
|
23
|
+
`exposure.webSocketPaths` and validate it before rendering. A large-upload route
|
|
24
|
+
under `/api/` needs `/api` removed on the way to the API; `exposure.apiPaths`
|
|
25
|
+
renders a separate Ingress with a Traefik `stripPrefix` Middleware for that.
|
package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// The command runs against a migrated schema and reports a missing table from its
|
|
4
4
|
// own query. Better Auth's asynchronous startup check would only race the
|
|
5
5
|
// connection the command closes when it finishes.
|
|
6
|
-
import "./auth/
|
|
6
|
+
import "./auth/maintenance-command";
|
|
7
7
|
import { verifyPassword } from "better-auth/crypto";
|
|
8
8
|
import { auth } from "./auth/auth";
|
|
9
9
|
import { closeAuthDatabase, postgresPool, sqliteDatabase } from "./auth/db";
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { error } from "@sveltejs/kit";
|
|
1
|
+
import { error, type RequestEvent } from "@sveltejs/kit";
|
|
2
2
|
import type { Handle } from "@sveltejs/kit/hooks";
|
|
3
|
+
import { building } from "$app/env";
|
|
4
|
+
import { routes } from "$app/manifest";
|
|
3
5
|
import { serverApiClient } from "#lib/server/api.js";
|
|
4
6
|
import { resolveLocale } from "#lib/i18n/messages.js";
|
|
5
7
|
import { applySearchIndexingHeaders } from "#lib/server/search-indexing.js";
|
|
6
8
|
import { isPublicPath } from "#lib/server/guards.js";
|
|
9
|
+
import { needsRuntimeContext } from "#lib/server/runtime-context.js";
|
|
7
10
|
import type { SiteSettings } from "#lib/site.svelte.js";
|
|
8
11
|
import { ApiError } from "@podosoft/podokit-api-client";
|
|
9
12
|
|
|
@@ -12,15 +15,18 @@ function isTemporaryServiceFailure(cause: unknown): boolean {
|
|
|
12
15
|
return cause.statusCode === 408 || cause.statusCode === 429 || cause.statusCode >= 500;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
// An anonymous visitor with no site settings, for responses that render no
|
|
19
|
+
// layout. The locale still follows the visitor's cookie.
|
|
20
|
+
function setEmptyRuntimeContext(event: RequestEvent): void {
|
|
21
|
+
event.locals.site = null;
|
|
22
|
+
event.locals.siteUnavailable = false;
|
|
23
|
+
event.locals.user = null;
|
|
24
|
+
event.locals.session = null;
|
|
25
|
+
event.locals.authUnavailable = false;
|
|
26
|
+
event.locals.locale = resolveLocale(event.cookies.get("locale"));
|
|
27
|
+
}
|
|
23
28
|
|
|
29
|
+
async function loadRuntimeContext(event: RequestEvent): Promise<void> {
|
|
24
30
|
// Public site settings (branding, locale/timezone defaults, maintenance, …)
|
|
25
31
|
// and the session are independent, so both requests run at once. The
|
|
26
32
|
// settings are loaded once here and reused by the layout load. The async
|
|
@@ -73,6 +79,18 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|
|
73
79
|
) {
|
|
74
80
|
error(503, "Service temporarily unavailable");
|
|
75
81
|
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const handle: Handle = async ({ event, resolve }) => {
|
|
85
|
+
// Listed asset endpoints, missing subresources, the API proxy, and
|
|
86
|
+
// prerendering skip the two API lookups. Pages, their data, form actions,
|
|
87
|
+
// remote functions, and the 404 page get the complete runtime context.
|
|
88
|
+
const route = routes.find(({ id }) => id === event.route.id);
|
|
89
|
+
if (needsRuntimeContext(event, { building, route })) {
|
|
90
|
+
await loadRuntimeContext(event);
|
|
91
|
+
} else {
|
|
92
|
+
setEmptyRuntimeContext(event);
|
|
93
|
+
}
|
|
76
94
|
const response = await resolve(event, {
|
|
77
95
|
transformPageChunk: ({ html }) =>
|
|
78
96
|
html.replace(
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-owned prefixes for `+server.ts` endpoints that serve files, feeds,
|
|
3
|
+
* or other responses needing neither the session nor the site settings (for
|
|
4
|
+
* example "/docs" when an endpoint serves documentation files). A listed path
|
|
5
|
+
* also covers its subpaths. When an endpoint under a listed prefix answers a
|
|
6
|
+
* request, hooks.server.ts skips its two API lookups and the endpoint sees an
|
|
7
|
+
* anonymous `locals.user`. A `+page` under a listed prefix still gets the
|
|
8
|
+
* session and site settings, so it keeps its sign-in and maintenance checks.
|
|
9
|
+
* PodoKit creates this file once and never overwrites it.
|
|
10
|
+
*/
|
|
11
|
+
export const APP_ASSET_PATHS: readonly string[] = [];
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Stands in for the application's own list in the app-owned asset-paths.ts.
|
|
4
|
+
vi.mock("./asset-paths", () => ({ APP_ASSET_PATHS: ["/docs", "/feed.xml"] }));
|
|
5
|
+
|
|
6
|
+
const { needsRuntimeContext, prefersHtml } = await import("./runtime-context");
|
|
7
|
+
|
|
8
|
+
const NAVIGATION = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
|
|
9
|
+
const IMAGE = "image/avif,image/webp,image/apng,*/*;q=0.8";
|
|
10
|
+
|
|
11
|
+
interface RequestOptions {
|
|
12
|
+
routeId?: string | null;
|
|
13
|
+
method?: string;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
isDataRequest?: boolean;
|
|
16
|
+
isRemoteRequest?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function request(path: string, options: RequestOptions = {}) {
|
|
20
|
+
const url = new URL(path, "http://app.localhost");
|
|
21
|
+
return {
|
|
22
|
+
url,
|
|
23
|
+
route: { id: options.routeId === undefined ? url.pathname : options.routeId },
|
|
24
|
+
request: new Request(url, { method: options.method ?? "GET", headers: options.headers }),
|
|
25
|
+
isDataRequest: options.isDataRequest ?? false,
|
|
26
|
+
isRemoteRequest: options.isRemoteRequest ?? false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("needsRuntimeContext", () => {
|
|
31
|
+
it("loads the session and site settings for pages and their data", () => {
|
|
32
|
+
expect(needsRuntimeContext(request("/account", { headers: { accept: NAVIGATION } }))).toBe(true);
|
|
33
|
+
expect(needsRuntimeContext(request("/account", { headers: { accept: "*/*" } }))).toBe(true);
|
|
34
|
+
expect(needsRuntimeContext(request("/account", { isDataRequest: true }))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("loads them for an unmatched route because the 404 page renders the root layout", () => {
|
|
38
|
+
// Without the session a signed-in visitor was sent to /login, which sent
|
|
39
|
+
// them straight back: a redirect loop on every missing page.
|
|
40
|
+
expect(
|
|
41
|
+
needsRuntimeContext(
|
|
42
|
+
request("/missing", { routeId: null, headers: { accept: NAVIGATION, "sec-fetch-dest": "document" } }),
|
|
43
|
+
),
|
|
44
|
+
).toBe(true);
|
|
45
|
+
expect(needsRuntimeContext(request("/missing", { routeId: null, headers: { accept: "*/*" } }))).toBe(true);
|
|
46
|
+
expect(needsRuntimeContext(request("/missing", { routeId: null, isDataRequest: true }))).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("skips them for a missing subresource that SvelteKit answers without a layout", () => {
|
|
50
|
+
for (const destination of ["image", "script", "style", "font"]) {
|
|
51
|
+
expect(
|
|
52
|
+
needsRuntimeContext(request("/missing.png", { routeId: null, headers: { "sec-fetch-dest": destination } })),
|
|
53
|
+
).toBe(false);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("skips them for the backend proxy", () => {
|
|
58
|
+
expect(needsRuntimeContext(request("/api", { routeId: "/api/[...path]" }))).toBe(false);
|
|
59
|
+
expect(needsRuntimeContext(request("/api/site/settings", { routeId: "/api/[...path]" }))).toBe(false);
|
|
60
|
+
expect(needsRuntimeContext(request("/api/auth/get-session", { routeId: "/api/auth/[...all]" }))).toBe(false);
|
|
61
|
+
expect(needsRuntimeContext(request("/apiary", { headers: { accept: NAVIGATION } }))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("skips them while prerendering", () => {
|
|
65
|
+
expect(needsRuntimeContext(request("/pricing", { headers: { accept: NAVIGATION } }), { building: true })).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("loads them for remote functions, which may read the session without a route", () => {
|
|
69
|
+
expect(needsRuntimeContext(request("/_app/remote/abc/list", { routeId: null, isRemoteRequest: true }))).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("skips them when an endpoint under an application asset path answers", () => {
|
|
73
|
+
const files = { page: false, endpoint: true };
|
|
74
|
+
expect(
|
|
75
|
+
needsRuntimeContext(
|
|
76
|
+
request("/docs/guide/diagram.png", { routeId: "/docs/[...path]", headers: { accept: IMAGE, "sec-fetch-dest": "image" } }),
|
|
77
|
+
{ route: files },
|
|
78
|
+
),
|
|
79
|
+
).toBe(false);
|
|
80
|
+
// Opening a file in a tab is a navigation, but no page renders.
|
|
81
|
+
expect(
|
|
82
|
+
needsRuntimeContext(
|
|
83
|
+
request("/docs/manual.pdf", { routeId: "/docs/[...path]", headers: { accept: NAVIGATION, "sec-fetch-dest": "document" } }),
|
|
84
|
+
{ route: files },
|
|
85
|
+
),
|
|
86
|
+
).toBe(false);
|
|
87
|
+
expect(needsRuntimeContext(request("/feed.xml", { headers: { accept: "application/rss+xml" } }), { route: files })).toBe(false);
|
|
88
|
+
expect(needsRuntimeContext(request("/documents", { headers: { accept: "*/*" } }), { route: files })).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps them when a page under an application asset path answers", () => {
|
|
92
|
+
const page = { page: true, endpoint: false };
|
|
93
|
+
expect(needsRuntimeContext(request("/docs", { headers: { accept: "*/*" } }), { route: page })).toBe(true);
|
|
94
|
+
expect(needsRuntimeContext(request("/docs", { headers: { accept: NAVIGATION } }), { route: page })).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("follows SvelteKit's page or endpoint choice when an asset route has both", () => {
|
|
98
|
+
const both = { page: true, endpoint: true };
|
|
99
|
+
const docs = (options: RequestOptions) =>
|
|
100
|
+
needsRuntimeContext(request("/docs/intro", { routeId: "/docs/[slug]", ...options }), { route: both });
|
|
101
|
+
expect(docs({ headers: { accept: NAVIGATION } })).toBe(true);
|
|
102
|
+
expect(docs({ isDataRequest: true })).toBe(true);
|
|
103
|
+
expect(docs({ method: "POST", headers: { accept: "application/json", "x-sveltekit-action": "true" } })).toBe(true);
|
|
104
|
+
expect(docs({ headers: { accept: IMAGE } })).toBe(false);
|
|
105
|
+
expect(docs({ headers: { accept: "*/*" } })).toBe(false);
|
|
106
|
+
expect(docs({ method: "PUT", headers: { accept: NAVIGATION } })).toBe(false);
|
|
107
|
+
// Without route information the same negotiation applies.
|
|
108
|
+
expect(needsRuntimeContext(request("/docs/intro", { headers: { accept: IMAGE } }))).toBe(false);
|
|
109
|
+
expect(needsRuntimeContext(request("/docs/intro", { headers: { accept: NAVIGATION } }))).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("prefersHtml", () => {
|
|
114
|
+
it("follows SvelteKit's page and endpoint negotiation", () => {
|
|
115
|
+
expect(prefersHtml(NAVIGATION)).toBe(true);
|
|
116
|
+
expect(prefersHtml("text/*")).toBe(true);
|
|
117
|
+
expect(prefersHtml("*/*")).toBe(false);
|
|
118
|
+
expect(prefersHtml(null)).toBe(false);
|
|
119
|
+
expect(prefersHtml(IMAGE)).toBe(false);
|
|
120
|
+
expect(prefersHtml("application/json, text/html;q=0.5")).toBe(true);
|
|
121
|
+
expect(prefersHtml("application/json, */*;q=0.9, text/html;q=0.5")).toBe(false);
|
|
122
|
+
expect(prefersHtml("*/*;q=0.9, text/html")).toBe(true);
|
|
123
|
+
expect(prefersHtml("text/html;q=0, */*")).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { APP_ASSET_PATHS } from "./asset-paths";
|
|
2
|
+
|
|
3
|
+
/** The request fields that decide whether a response can use the session and site settings. */
|
|
4
|
+
export interface RuntimeContextRequest {
|
|
5
|
+
url: URL;
|
|
6
|
+
route: { id: string | null };
|
|
7
|
+
request: Request;
|
|
8
|
+
isDataRequest: boolean;
|
|
9
|
+
isRemoteRequest: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Fetch destinations that SvelteKit answers with a plain-text 404, without the
|
|
13
|
+
// root layout, when no route matches.
|
|
14
|
+
const SUBRESOURCE_DESTINATIONS = new Set([
|
|
15
|
+
"audio",
|
|
16
|
+
"audioworklet",
|
|
17
|
+
"font",
|
|
18
|
+
"image",
|
|
19
|
+
"json",
|
|
20
|
+
"manifest",
|
|
21
|
+
"paintworklet",
|
|
22
|
+
"report",
|
|
23
|
+
"script",
|
|
24
|
+
"serviceworker",
|
|
25
|
+
"sharedworker",
|
|
26
|
+
"style",
|
|
27
|
+
"track",
|
|
28
|
+
"video",
|
|
29
|
+
"webidentity",
|
|
30
|
+
"worker",
|
|
31
|
+
"xslt",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function underPrefix(pathname: string, prefix: string): boolean {
|
|
35
|
+
return pathname === prefix || (prefix !== "/" && pathname.startsWith(`${prefix}/`));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when an Accept header ranks HTML ahead of the wildcard. This mirrors how
|
|
40
|
+
* SvelteKit chooses between a page and an endpoint on the same route.
|
|
41
|
+
*/
|
|
42
|
+
export function prefersHtml(accept: string | null): boolean {
|
|
43
|
+
const ranges = (accept ?? "*/*")
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((part, index) => {
|
|
46
|
+
const [range = "", ...params] = part.trim().toLowerCase().split(";");
|
|
47
|
+
const quality = params.map((param) => param.trim()).find((param) => param.startsWith("q="));
|
|
48
|
+
return { range: range.trim(), q: quality ? Number(quality.slice(2)) : 1, index };
|
|
49
|
+
})
|
|
50
|
+
.filter(({ range, q }) => range.includes("/") && q > 0)
|
|
51
|
+
.sort((a, b) => b.q - a.q || specificity(b.range) - specificity(a.range) || a.index - b.index);
|
|
52
|
+
const html = ranges.findIndex(({ range }) => range === "text/html" || range === "text/*");
|
|
53
|
+
const wildcard = ranges.findIndex(({ range }) => range === "*/*");
|
|
54
|
+
return html !== -1 && (wildcard === -1 || html < wildcard);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function specificity(range: string): number {
|
|
58
|
+
if (range === "*/*") return 0;
|
|
59
|
+
return range.endsWith("/*") ? 1 : 2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Which files the matched route has, from `routes` in `$app/manifest`. */
|
|
63
|
+
export interface RouteFiles {
|
|
64
|
+
page: boolean;
|
|
65
|
+
endpoint: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True when SvelteKit answers the request with the route's `+page` rather than its `+server`. */
|
|
69
|
+
function answeredByPage(event: RuntimeContextRequest, route: RouteFiles | undefined): boolean {
|
|
70
|
+
if (route && !route.page) return false;
|
|
71
|
+
if (route && !route.endpoint) return true;
|
|
72
|
+
if (event.isDataRequest) return true;
|
|
73
|
+
const { headers, method } = event.request;
|
|
74
|
+
if (method !== "GET" && method !== "HEAD" && method !== "POST") return false;
|
|
75
|
+
if (method === "POST" && headers.get("x-sveltekit-action") === "true") return true;
|
|
76
|
+
return prefersHtml(headers.get("accept"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide whether hooks.server.ts must load the session and the site settings
|
|
81
|
+
* before SvelteKit answers the request. It skips them only when the response
|
|
82
|
+
* cannot render a layout, or when the application listed the path in
|
|
83
|
+
* APP_ASSET_PATHS and a `+server` endpoint, not a `+page`, answers it.
|
|
84
|
+
*/
|
|
85
|
+
export function needsRuntimeContext(
|
|
86
|
+
event: RuntimeContextRequest,
|
|
87
|
+
options: { building?: boolean; route?: RouteFiles } = {},
|
|
88
|
+
): boolean {
|
|
89
|
+
// Prerendering runs at build time without the API, and the stored HTML must
|
|
90
|
+
// not depend on one visitor's session or on runtime site settings.
|
|
91
|
+
if (options.building) return false;
|
|
92
|
+
const { pathname } = event.url;
|
|
93
|
+
// The backend proxy forwards the cookies; the API resolves the session itself.
|
|
94
|
+
if (underPrefix(pathname, "/api")) return false;
|
|
95
|
+
// Remote functions run application code that may read the session.
|
|
96
|
+
if (event.isRemoteRequest) return true;
|
|
97
|
+
if (event.route.id === null) {
|
|
98
|
+
// SvelteKit renders its 404 page with the root layout, which redirects or
|
|
99
|
+
// greets the visitor, unless the browser fetched a subresource.
|
|
100
|
+
const destination = event.request.headers.get("sec-fetch-dest") ?? "";
|
|
101
|
+
return !SUBRESOURCE_DESTINATIONS.has(destination);
|
|
102
|
+
}
|
|
103
|
+
if (APP_ASSET_PATHS.some((prefix) => underPrefix(pathname, prefix))) {
|
|
104
|
+
return answeredByPage(event, options.route);
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { ADMIN } from "../helpers/accounts";
|
|
3
|
+
import { expectObjectLength } from "../helpers/object-response";
|
|
3
4
|
|
|
4
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
6
|
const origin = { origin: base };
|
|
@@ -48,7 +49,7 @@ test("profile image upload, replacement, public read, and removal", async ({ pla
|
|
|
48
49
|
expect(firstImage.ok()).toBeTruthy();
|
|
49
50
|
expect(firstImage.headers()["content-type"]).toContain("image/png");
|
|
50
51
|
expect(firstImage.headers()["cache-control"]).toBe("public, max-age=31536000, immutable");
|
|
51
|
-
|
|
52
|
+
expectObjectLength(firstImage.headers(), PNG.byteLength);
|
|
52
53
|
expect((await firstImage.body()).equals(PNG)).toBe(true);
|
|
53
54
|
const etag = firstImage.headers()["etag"];
|
|
54
55
|
expect(etag).toMatch(/^"[^"]+"$/);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { ADMIN } from "../helpers/accounts";
|
|
3
|
+
import { expectObjectLength } from "../helpers/object-response";
|
|
3
4
|
|
|
4
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
6
|
const origin = { origin: base };
|
|
@@ -101,7 +102,7 @@ test("site favicon: the versioned URL is cached for good and other URLs revalida
|
|
|
101
102
|
expect(current.status()).toBe(200);
|
|
102
103
|
expect(current.headers()["cache-control"]).toBe(IMMUTABLE);
|
|
103
104
|
expect(current.headers()["content-type"]).toContain("image/png");
|
|
104
|
-
|
|
105
|
+
expectObjectLength(current.headers(), ICON.byteLength);
|
|
105
106
|
expect((await current.body()).equals(ICON)).toBe(true);
|
|
106
107
|
const etag = current.headers()["etag"] ?? "";
|
|
107
108
|
expect(etag).toMatch(/^"[^"]+"$/);
|
|
@@ -35,16 +35,16 @@ async function readCreatedUserId(response: CreatedUserResponse): Promise<string>
|
|
|
35
35
|
export const test = base.extend<{ disposableUsers: DisposableUsers }>({
|
|
36
36
|
disposableUsers: async ({ context, playwright }, use, testInfo) => {
|
|
37
37
|
// API projects do not inherit the UI project's admin storageState, while a
|
|
38
|
-
// second admin sign-in can invalidate the shared UI session.
|
|
39
|
-
// isolated request context
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
// second admin sign-in can invalidate the shared UI session. API tests sign in
|
|
39
|
+
// an isolated request context. UI tests copy the browser's admin cookies into
|
|
40
|
+
// their own context once, so a test that later signs its page in as another
|
|
41
|
+
// user does not turn cleanup into that user's (forbidden) requests.
|
|
42
|
+
const request = await playwright.request.newContext({
|
|
43
|
+
baseURL,
|
|
44
|
+
extraHTTPHeaders: origin,
|
|
45
|
+
...(testInfo.project.name === "api" ? {} : { storageState: await context.storageState() }),
|
|
46
|
+
});
|
|
42
47
|
if (testInfo.project.name === "api") {
|
|
43
|
-
request = await playwright.request.newContext({
|
|
44
|
-
baseURL,
|
|
45
|
-
extraHTTPHeaders: origin,
|
|
46
|
-
});
|
|
47
|
-
ownsRequest = true;
|
|
48
48
|
const signIn = await request.post("/api/auth/sign-in/email", {
|
|
49
49
|
data: { email: ADMIN.email, password: ADMIN.password },
|
|
50
50
|
});
|
|
@@ -94,7 +94,7 @@ export const test = base.extend<{ disposableUsers: DisposableUsers }>({
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
} finally {
|
|
97
|
-
|
|
97
|
+
await request.dispose();
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
},
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// IN_MEMORY_READ_BYTES of @podosoft/podokit-runtime. Both object storage
|
|
4
|
+
// providers and the web proxy keep the Content-Length of objects up to this
|
|
5
|
+
// size; Bun may send larger ones chunked.
|
|
6
|
+
const IN_MEMORY_READ_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export function expectObjectLength(headers: Record<string, string>, size: number): void {
|
|
9
|
+
const length = headers["content-length"];
|
|
10
|
+
if (length === undefined && size > IN_MEMORY_READ_BYTES) return;
|
|
11
|
+
expect(length).toBe(String(size));
|
|
12
|
+
}
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"ownedGlobs": [
|
|
15
15
|
"apps/web/src/lib/i18n/locales/**",
|
|
16
16
|
"apps/web/src/lib/i18n/catalogs/app/**",
|
|
17
|
-
"apps/web/src/lib/server/public-paths.ts"
|
|
17
|
+
"apps/web/src/lib/server/public-paths.ts",
|
|
18
|
+
"apps/web/src/lib/server/asset-paths.ts"
|
|
18
19
|
],
|
|
19
20
|
"managedOverrides": [
|
|
20
21
|
"apps/web/src/lib/components/ui/input/input.svelte",
|
|
@@ -118,7 +119,7 @@
|
|
|
118
119
|
"file": "AGENTS.md",
|
|
119
120
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
120
121
|
"optional": true,
|
|
121
|
-
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
|
|
122
|
+
"text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- `hooks.server.ts` loads the site settings and the session for pages, page data, form actions, remote functions, and the 404 page. When a `+server.ts` endpoint serves files or feeds that need neither, list its prefix in `APP_ASSET_PATHS` in the app-owned `apps/web/src/lib/server/asset-paths.ts` to skip both API calls; such endpoints see an anonymous `locals.user`, and page requests under the prefix still get the full context.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
|
|
122
123
|
},
|
|
123
124
|
{
|
|
124
125
|
"file": "apps/api/src/app.ts",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { validateEnv } from "../config/env.validation";
|
|
4
|
+
import { refreshForRequest } from "./auth-provider";
|
|
5
|
+
import { configureAuthQueryDatabase } from "./db";
|
|
6
|
+
|
|
7
|
+
describe("refreshForRequest", () => {
|
|
8
|
+
it("stops waiting for a configuration refresh the database does not answer", async () => {
|
|
9
|
+
let queries = 0;
|
|
10
|
+
// Every query waits forever, like one on a paused server.
|
|
11
|
+
const silent = (): Promise<never> => {
|
|
12
|
+
queries += 1;
|
|
13
|
+
return new Promise(() => undefined);
|
|
14
|
+
};
|
|
15
|
+
configureAuthQueryDatabase(silent as unknown as SQL);
|
|
16
|
+
|
|
17
|
+
const waitMs = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
|
|
18
|
+
const started = Date.now();
|
|
19
|
+
await refreshForRequest();
|
|
20
|
+
const waited = Date.now() - started;
|
|
21
|
+
|
|
22
|
+
expect(queries).toBeGreaterThan(0);
|
|
23
|
+
expect(waited).toBeGreaterThanOrEqual(waitMs - 10);
|
|
24
|
+
expect(waited).toBeLessThan(waitMs + 500);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { AuthConfig } from "@podosoft/podokit-auth";
|
|
2
|
+
import { validateEnv } from "../config/env.validation";
|
|
2
3
|
import { auth as bootstrapAuth, buildAuth } from "./auth";
|
|
3
4
|
import { authConfigStore } from "./auth-config-store";
|
|
4
5
|
import { publishConfigChanged } from "./config-events";
|
|
6
|
+
import { answersWithin, checkAuthResponse } from "./database-outage";
|
|
7
|
+
import { authDatabaseReachable } from "./db";
|
|
8
|
+
|
|
9
|
+
const REFRESH_WAIT_MS = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
|
|
5
10
|
|
|
6
11
|
// Runtime auth instance that rebuilds when the DB config changes, so admin edits
|
|
7
12
|
// (OAuth credentials, SMTP, server-enforced toggles) apply WITHOUT a restart.
|
|
@@ -52,10 +57,20 @@ export async function refreshAuthNow(): Promise<void> {
|
|
|
52
57
|
await publishConfigChanged();
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Refreshes before a request is served. While PostgreSQL does not answer, the
|
|
62
|
+
* refresh lasts as long as a query may wait, and the request would wait for
|
|
63
|
+
* Better Auth's own queries after it. Past the readiness check timeout the
|
|
64
|
+
* current instance serves the request, and the refresh finishes in the background.
|
|
65
|
+
*/
|
|
66
|
+
export async function refreshForRequest(): Promise<void> {
|
|
67
|
+
await answersWithin(refreshIfStale, REFRESH_WAIT_MS);
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
// Stable handler reference the adapter captures once; redispatches per request.
|
|
56
71
|
const dynamicHandler: typeof bootstrapAuth.handler = async (request) => {
|
|
57
|
-
await
|
|
58
|
-
return current.handler(request);
|
|
72
|
+
await refreshForRequest();
|
|
73
|
+
return checkAuthResponse(await current.handler(request), authDatabaseReachable);
|
|
59
74
|
};
|
|
60
75
|
|
|
61
76
|
export const authRuntime: typeof bootstrapAuth = new Proxy(bootstrapAuth, {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
2
|
import type { AccessPolicy, RequestIdentity } from "../core/services";
|
|
3
3
|
import type { SettingsService } from "../settings/settings.service";
|
|
4
|
-
import { getAuth,
|
|
5
|
-
import {
|
|
4
|
+
import { getAuth, refreshForRequest } from "./auth-provider";
|
|
5
|
+
import { rethrowAuthError } from "./database-outage";
|
|
6
|
+
import { authDatabaseReachable, closeAuthDatabase } from "./db";
|
|
6
7
|
|
|
7
8
|
export interface AuthUser extends Record<string, unknown> {
|
|
8
9
|
id: string;
|
|
@@ -18,9 +19,12 @@ export interface AuthSession {
|
|
|
18
19
|
export type SessionResolver = (headers: Headers) => Promise<AuthSession | null>;
|
|
19
20
|
|
|
20
21
|
function defaultSessionResolver(headers: Headers): Promise<AuthSession | null> {
|
|
21
|
-
return
|
|
22
|
+
return refreshForRequest()
|
|
22
23
|
.then(() => getAuth().api.getSession({ headers }))
|
|
23
|
-
.then(
|
|
24
|
+
.then(
|
|
25
|
+
(session) => session as AuthSession | null,
|
|
26
|
+
(error: unknown) => rethrowAuthError(error, authDatabaseReachable),
|
|
27
|
+
);
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export function rolesOf(user: AuthUser): string[] {
|
|
@@ -14,7 +14,7 @@ import { oauthProvider } from "@better-auth/oauth-provider";
|
|
|
14
14
|
import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-contracts";
|
|
15
15
|
import { runUserDeletedHandlers } from "./user-delete-handlers";
|
|
16
16
|
import { authBaseUrl, authSecret } from "./auth-environment";
|
|
17
|
-
import {
|
|
17
|
+
import { isMaintenanceCommand } from "./maintenance";
|
|
18
18
|
// podokit:begin:auth-imports
|
|
19
19
|
// podokit:end:auth-imports
|
|
20
20
|
|
|
@@ -233,7 +233,7 @@ export function buildAuth(config: AuthConfig) {
|
|
|
233
233
|
// resolved client IP as x-forwarded-for; trust it so sessions record an IP.
|
|
234
234
|
ipAddressHeaders: ["x-forwarded-for"],
|
|
235
235
|
},
|
|
236
|
-
...(
|
|
236
|
+
...(isMaintenanceCommand() ? { database: { validateSchema: false } } : {}),
|
|
237
237
|
},
|
|
238
238
|
user: {
|
|
239
239
|
additionalFields: {
|