@podosoft/podokit 1.4.1 → 1.5.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-compose-profile.js +2 -2
- package/dist/deploy-compose-render.js +3 -1
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +16 -8
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +23 -4
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +27 -2
- package/dist/templates/fullstack/README.md +1 -1
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
- package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
- package/dist/templates/fullstack/apps/web/Dockerfile +5 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +436 -6
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
- package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +6 -0
- package/dist/templates/fullstack/dot-env.example +23 -0
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
- package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
- package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +54 -47
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +19 -8
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/todo/AGENTS.md +41 -5
- package/dist/templates/todo/README.md +1 -1
- package/package.json +2 -2
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts
CHANGED
|
@@ -8,14 +8,18 @@ import type { RateLimitConfig } from "./rate-limit.config";
|
|
|
8
8
|
/** A budget with its window resolved, or "exempt". */
|
|
9
9
|
export type ResolvedRateLimitProfile = Required<RateLimitProfile> | "exempt";
|
|
10
10
|
|
|
11
|
+
/** A literal path segment, or null for a `:name` parameter. */
|
|
12
|
+
type Segment = string | null;
|
|
13
|
+
|
|
11
14
|
interface RouteEntry {
|
|
12
15
|
method?: string;
|
|
13
|
-
|
|
16
|
+
segments: Segment[];
|
|
14
17
|
profile: ResolvedRateLimitProfile;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
const PROFILE_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
18
21
|
const METHOD = /^[A-Z]+$/;
|
|
22
|
+
const PARAMETER = /^:[A-Za-z_][A-Za-z0-9_]*$/;
|
|
19
23
|
|
|
20
24
|
function positiveInteger(value: number | undefined, label: string): void {
|
|
21
25
|
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) {
|
|
@@ -23,15 +27,47 @@ function positiveInteger(value: number | undefined, label: string): void {
|
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
function
|
|
30
|
+
function parsePrefix(prefix: string): Segment[] {
|
|
27
31
|
if (!prefix.startsWith("/") || /[?#\s]/.test(prefix) || prefix.includes("//")) {
|
|
28
32
|
throw new Error(`Rate limit path prefix must be an absolute path: ${prefix}`);
|
|
29
33
|
}
|
|
30
|
-
|
|
34
|
+
if (prefix.includes("*")) {
|
|
35
|
+
throw new Error(`Rate limit path prefix takes ":name" parameters, not wildcards: ${prefix}`);
|
|
36
|
+
}
|
|
37
|
+
const base = prefix.length > 1 && prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
38
|
+
return base === "/"
|
|
39
|
+
? []
|
|
40
|
+
: base.slice(1).split("/").map((segment) => {
|
|
41
|
+
if (segment === "." || segment === "..") {
|
|
42
|
+
throw new Error(`Rate limit path prefix must not contain "." or ".." segments: ${prefix}`);
|
|
43
|
+
}
|
|
44
|
+
if (!segment.startsWith(":")) return segment;
|
|
45
|
+
if (!PARAMETER.test(segment)) {
|
|
46
|
+
throw new Error(`Rate limit path parameter must be ":" and a name such as ":hostId": ${prefix}`);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whole-segment prefix match; a parameter matches one non-empty segment. */
|
|
53
|
+
function covers(segments: Segment[], path: string[]): boolean {
|
|
54
|
+
return (
|
|
55
|
+
path.length >= segments.length &&
|
|
56
|
+
segments.every((segment, index) => (segment === null ? path[index] !== "" : segment === path[index]))
|
|
57
|
+
);
|
|
31
58
|
}
|
|
32
59
|
|
|
33
|
-
|
|
34
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Below zero when `a` is more specific: more segments, then, from the left, a
|
|
62
|
+
* literal segment before a parameter.
|
|
63
|
+
*/
|
|
64
|
+
function compareSpecificity(a: Segment[], b: Segment[]): number {
|
|
65
|
+
if (a.length !== b.length) return b.length - a.length;
|
|
66
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
67
|
+
const literalA = a[index] !== null;
|
|
68
|
+
if (literalA !== (b[index] !== null)) return literalA ? -1 : 1;
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
35
71
|
}
|
|
36
72
|
|
|
37
73
|
/**
|
|
@@ -50,24 +86,21 @@ export class RateLimitRoutes implements RateLimitRouteRegistry {
|
|
|
50
86
|
if (method !== undefined && !METHOD.test(method)) {
|
|
51
87
|
throw new Error(`Rate limit route method is invalid: ${route.method}`);
|
|
52
88
|
}
|
|
53
|
-
const
|
|
89
|
+
const segments = parsePrefix(route.pathPrefix);
|
|
54
90
|
const profile = route.profile === "exempt" ? "exempt" : this.budget(route.profile);
|
|
55
|
-
this.entries.push({ ...(method ? { method } : {}),
|
|
91
|
+
this.entries.push({ ...(method ? { method } : {}), segments, profile });
|
|
56
92
|
}
|
|
57
93
|
|
|
58
94
|
/** The most specific registered profile for a request, if any route matches. */
|
|
59
95
|
resolve(method: string, path: string): ResolvedRateLimitProfile | undefined {
|
|
96
|
+
const pathSegments = path.split("/").slice(1);
|
|
60
97
|
let best: RouteEntry | undefined;
|
|
61
98
|
for (const entry of this.entries) {
|
|
62
99
|
if (entry.method !== undefined && entry.method !== method) continue;
|
|
63
|
-
if (!covers(entry.
|
|
64
|
-
// The
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
entry.prefix.length > best.prefix.length ||
|
|
68
|
-
(entry.prefix.length === best.prefix.length &&
|
|
69
|
-
(entry.method !== undefined || best.method === undefined))
|
|
70
|
-
) {
|
|
100
|
+
if (!covers(entry.segments, pathSegments)) continue;
|
|
101
|
+
// The more specific prefix wins, then a route with a method, then the later one.
|
|
102
|
+
const order = best ? compareSpecificity(entry.segments, best.segments) : -1;
|
|
103
|
+
if (order < 0 || (order === 0 && (entry.method !== undefined || best?.method === undefined))) {
|
|
71
104
|
best = entry;
|
|
72
105
|
}
|
|
73
106
|
}
|
|
@@ -70,7 +70,7 @@ test("rate limit: health probes stay available while ordinary routes return 429
|
|
|
70
70
|
await ctx.dispose();
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
test("rate limit:
|
|
73
|
+
test("rate limit: a user's sessions share one counter and forged sessions do not get one", async ({
|
|
74
74
|
playwright,
|
|
75
75
|
}) => {
|
|
76
76
|
const email = `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
@@ -81,21 +81,31 @@ test("rate limit: each signed session has its own counter and forged sessions do
|
|
|
81
81
|
test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
|
|
82
82
|
const remaining = (response: APIResponse): number => Number(response.headers()["ratelimit-remaining"]);
|
|
83
83
|
const ceiling = Number(probe.headers()["ratelimit-limit"]);
|
|
84
|
-
expect(remaining(probe)).toBe(ceiling - 1);
|
|
85
84
|
|
|
86
|
-
//
|
|
85
|
+
// A second sign-in of the same user, and the signed bearer form of the first
|
|
86
|
+
// session. The probe requires a session, so each request below resolves it; a
|
|
87
|
+
// session counts under its user from the request after the API first resolves
|
|
88
|
+
// it (or from that request when authentication runs before the limiter).
|
|
89
|
+
const second = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
90
|
+
expect((await second.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
|
|
87
91
|
const cookie = (await first.storageState()).cookies.find((c) => c.name.endsWith("session_token"));
|
|
88
92
|
expect(cookie, "sign-up set a session cookie").toBeTruthy();
|
|
89
93
|
const bearer = await playwright.request.newContext({
|
|
90
94
|
baseURL: base,
|
|
91
95
|
extraHTTPHeaders: { ...origin, authorization: `Bearer ${cookie?.value ?? ""}` },
|
|
92
96
|
});
|
|
93
|
-
|
|
97
|
+
await second.get("/api/cache/rate-limit-probe");
|
|
94
98
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
expect(remaining(await second.get("/api/cache/rate-limit-probe"))).toBe(
|
|
99
|
+
// Both sessions now draw from one budget: each request lowers what the next
|
|
100
|
+
// one, from either session, has left.
|
|
101
|
+
const fromFirst = remaining(await first.get("/api/cache/rate-limit-probe"));
|
|
102
|
+
expect(remaining(await second.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 1);
|
|
103
|
+
expect(remaining(await bearer.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 2);
|
|
104
|
+
expect(remaining(await first.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 3);
|
|
105
|
+
|
|
106
|
+
// Another user has a budget of their own.
|
|
107
|
+
const other = await session(playwright);
|
|
108
|
+
expect(remaining(await other.get("/api/cache/rate-limit-probe"))).toBe(ceiling - 1);
|
|
99
109
|
|
|
100
110
|
// A session cookie without a valid signature is counted with anonymous traffic
|
|
101
111
|
// from the same client address instead of receiving a fresh counter. The cache
|
|
@@ -113,12 +123,13 @@ test("rate limit: each signed session has its own counter and forged sessions do
|
|
|
113
123
|
expect(remaining(after)).toBeLessThan(remaining(before));
|
|
114
124
|
}).toPass({ timeout: 10_000 });
|
|
115
125
|
|
|
116
|
-
for (const context of [first, bearer,
|
|
126
|
+
for (const context of [first, second, bearer, other, forged, anonymous]) await context.dispose();
|
|
117
127
|
});
|
|
118
128
|
|
|
119
129
|
test("rate limit: session reads do not consume the strict authentication budget", async ({
|
|
120
130
|
playwright,
|
|
121
131
|
}) => {
|
|
132
|
+
test.skip(authLimit > 1000, "the authentication budget is too large to exhaust here");
|
|
122
133
|
const ctx = await session(playwright);
|
|
123
134
|
for (let i = 0; i < authLimit + 5; i++) {
|
|
124
135
|
const response = await ctx.get("/api/auth/get-session");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rate-limit",
|
|
3
3
|
"manifestVersion": 3,
|
|
4
|
-
"description": "Provider-neutral Elysia request limiting by validated API key, signed-in
|
|
4
|
+
"description": "Provider-neutral Elysia request limiting by validated API key, signed-in user, or trusted-proxy client address.",
|
|
5
5
|
"requires": ["api-key-auth"],
|
|
6
6
|
"requiresCapabilities": ["cache"],
|
|
7
7
|
"targetApp": "api",
|
|
@@ -19,9 +19,6 @@
|
|
|
19
19
|
"RATE_LIMIT_AUTH_MAX=20",
|
|
20
20
|
"# higher ceiling for SSR runtime settings and session reads",
|
|
21
21
|
"RATE_LIMIT_RUNTIME_MAX=1000",
|
|
22
|
-
"# trust no proxy hop by default; set the exact production proxy depth",
|
|
23
|
-
"RATE_LIMIT_TRUSTED_PROXY_HOPS=0",
|
|
24
|
-
"RATE_LIMIT_PROXY_HEADER=x-forwarded-for",
|
|
25
22
|
"RATE_LIMIT_STORAGE_TIMEOUT_MS=1000",
|
|
26
23
|
"RATE_LIMIT_UNAVAILABLE_RETRY_AFTER=1"
|
|
27
24
|
],
|
|
@@ -40,20 +37,20 @@
|
|
|
40
37
|
"file": "AGENTS.md",
|
|
41
38
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
42
39
|
"optional": true,
|
|
43
|
-
"text": "### rate-limit\n- Every request counts against a per-caller budget (general, `/api/auth`, or runtime reads); exceeded limits return 429 `RATE_LIMIT_EXCEEDED` with `Retry-After
|
|
40
|
+
"text": "### rate-limit\n- Every request counts against a per-caller budget (general, `/api/auth`, or runtime reads), keyed by API key, then signed-in user, then client address; exceeded limits return 429 `RATE_LIMIT_EXCEEDED` with `Retry-After`. A user's sessions share one budget once the API has resolved them for a request.\n- Give routes that legitimately send many requests, such as upload chunks, their own budget instead of raising `RATE_LIMIT_MAX`: `services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method: \"PUT\", pathPrefix: \"/uploads\", profile: { name: \"uploads\", limit: 3000 } })` (`RATE_LIMIT_ROUTES` from `@podosoft/podokit-runtime`). Register from a module's `plugin` factory or `configureServices()` in `app.extensions.ts`; the most specific prefix wins (more segments, then a literal segment over a `:name` parameter), and `profile: \"exempt\"` skips limiting.\n- Prefixes are API paths (`/uploads`, not the web proxy's `/api/uploads`); Better Auth routes keep `/api/auth`. A `:name` segment matches one path segment, as in `/hosts/:hostId/file-transfers`.\n"
|
|
44
41
|
}
|
|
45
42
|
],
|
|
46
43
|
"instructions": [
|
|
47
44
|
"Configure the selected cache provider before enabling request limits.",
|
|
48
45
|
"Tune the general, authentication, and runtime limits in .env.",
|
|
49
46
|
"Give each deployment that shares Redis a distinct RATE_LIMIT_KEY_PREFIX.",
|
|
50
|
-
"
|
|
51
|
-
"Validated X-API-Key clients and signed-in
|
|
52
|
-
"Sessions are recognized by verifying the Better Auth cookie or bearer signature with BETTER_AUTH_SECRET, without a database read,
|
|
47
|
+
"Requests without a validated API key or session count by the client address the API resolves from TRUSTED_PROXY_HOPS and TRUSTED_PROXY_HEADER, which audit entries and Better Auth sessions use too.",
|
|
48
|
+
"Validated X-API-Key clients and signed-in users receive independent opaque counters; other traffic uses the resolved client address.",
|
|
49
|
+
"Sessions are recognized by verifying the Better Auth cookie or bearer signature with BETTER_AUTH_SECRET, without a database read, and count under their user once the API has resolved them for a request, so limits apply per user.",
|
|
53
50
|
"Exceeded limits return RATE_LIMIT_EXCEEDED with HTTP 429 and Retry-After. Cache failures return RATE_LIMIT_UNAVAILABLE with HTTP 503.",
|
|
54
51
|
"Register a RateLimitIdentityExtension under RATE_LIMIT_IDENTITY_EXTENSION in app.extensions.ts for another validated machine-key source.",
|
|
55
52
|
"GET /health and GET /health/ready bypass request quotas so orchestrator probes remain reliable.",
|
|
56
|
-
"Give a route its own budget or exempt it with services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method, pathPrefix, profile }) from a module's plugin factory or configureServices() in app.extensions.ts; the most specific prefix wins.",
|
|
53
|
+
"Give a route its own budget or exempt it with services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method, pathPrefix, profile }) from a module's plugin factory or configureServices() in app.extensions.ts; a :name segment matches one path segment, and the most specific prefix wins.",
|
|
57
54
|
"Redis counters are shared across replicas; memory counters require one API replica."
|
|
58
55
|
]
|
|
59
56
|
}
|
|
@@ -10,8 +10,9 @@ A Bun 1.4 full-stack TypeScript monorepo ({{packageManager}} workspaces):
|
|
|
10
10
|
|
|
11
11
|
- `apps/api` — **Elysia** backend (`{{projectName}}-api`), Bun.SQL + `{{databaseProvider}}`, merged OpenAPI at `/api-docs`.
|
|
12
12
|
- `apps/web` — **SvelteKit** frontend (`{{projectName}}-web`), Tailwind v4 + shadcn-svelte + typesafe-i18n.
|
|
13
|
-
- `infra/` — Docker Compose
|
|
14
|
-
- `.podokit/` — generation metadata plus application-owned development and
|
|
13
|
+
- `infra/` — provider-aware Docker Compose and example k3s manifests.
|
|
14
|
+
- `.podokit/` — PodoKit generation metadata plus application-owned development and
|
|
15
|
+
deployment profiles (see "PodoKit tooling" below).
|
|
15
16
|
- **Todo example**: `apps/api/src/todos/` (Elysia plugin, Bun.SQL repository, and migration) and the todo UI in `apps/web/src/routes/` show the end-to-end patterns to follow.
|
|
16
17
|
|
|
17
18
|
## Setup & commands
|
|
@@ -49,7 +50,32 @@ Merge added module settings from `.env.example` into an existing `.env`.
|
|
|
49
50
|
managed lists. Cap a route's request body in the app-owned
|
|
50
51
|
`apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
|
|
51
52
|
`PAYLOAD_TOO_LARGE`). `BODY_SIZE_LIMIT` is the ceiling for every web route, so
|
|
52
|
-
uploads larger than it must use a direct API path (`apiPaths`) instead.
|
|
53
|
+
uploads larger than it must use a direct API path (`apiPaths`) instead. Give a
|
|
54
|
+
route a longer, shorter, or no response deadline in the app-owned
|
|
55
|
+
`apps/web/src/lib/server/proxy-timeouts.ts` (`APP_PROXY_TIMEOUTS`, `null` for
|
|
56
|
+
none); never edit the managed deadline. Both lists take path prefixes with
|
|
57
|
+
`:name` parameters, such as `/api/hosts/:hostId/file-transfers`, and the most
|
|
58
|
+
specific prefix wins.
|
|
59
|
+
- Server-side API calls (`serverApiClient`) give up after `BACKEND_TIMEOUT_MS`
|
|
60
|
+
(2000), and the proxy answers 504 `BACKEND_TIMEOUT` when the API sends no
|
|
61
|
+
response headers within `BACKEND_PROXY_TIMEOUT_MS` (30000). Once the API's
|
|
62
|
+
`GET /health` stops answering too, calls throw `BackendUnavailableError` and the
|
|
63
|
+
proxy answers 503 `BACKEND_UNAVAILABLE` at once until it answers again. Handle
|
|
64
|
+
any non-`ApiError` failure in a load function as a temporary outage. The API
|
|
65
|
+
(`API_IDLE_TIMEOUT`) and the built web server (`CONNECTION_IDLE_TIMEOUT`) close
|
|
66
|
+
a connection that sends nothing for that many seconds (35 each): raise both
|
|
67
|
+
with any longer `BACKEND_PROXY_TIMEOUT_MS` or `APP_PROXY_TIMEOUTS` entry, more
|
|
68
|
+
than 4 above it, or set `0` when a route has no deadline. The proxy refuses to
|
|
69
|
+
load when `CONNECTION_IDLE_TIMEOUT` cannot cover a deadline.
|
|
70
|
+
- Read a caller's address in the API with `clientAddressOf(request.headers)` from
|
|
71
|
+
`apps/api/src/core/client-address.ts`, never from `X-Forwarded-For` directly. The
|
|
72
|
+
API resolves it once per request from `TRUSTED_PROXY_HOPS` (1: the web proxy) and
|
|
73
|
+
`TRUSTED_PROXY_HEADER`; Better Auth sessions, audit entries, and rate limits use
|
|
74
|
+
the same address.
|
|
75
|
+
- `GET /healthz` (`apps/web/src/routes/healthz/+server.ts`) is the web server's own
|
|
76
|
+
health route for the Kubernetes probes, the Compose healthcheck, and the image
|
|
77
|
+
`HEALTHCHECK`. Keep it free of API calls, so an API outage does not take the web
|
|
78
|
+
out of service.
|
|
53
79
|
- When the root layout or a server hook fails, such as with 503 while the API is
|
|
54
80
|
down, SvelteKit serves the app-owned `apps/web/src/error.html`. It is plain HTML
|
|
55
81
|
with inline styles: no components, app CSS, scripts, or assets, and only the
|
|
@@ -96,15 +122,25 @@ changes need an e2e/unit test.
|
|
|
96
122
|
This project is managed by the `podo` CLI. Files have an ownership tier recorded
|
|
97
123
|
in `.podokit/files.lock` — respect it:
|
|
98
124
|
|
|
99
|
-
- **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations.
|
|
125
|
+
- **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations. Set Bun server options (`maxRequestBodySize`, `idleTimeout`) with its `apiServerOptions` export rather than editing `main.ts`; `API_MAX_REQUEST_BODY_SIZE` and `API_IDLE_TIMEOUT` apply when the export leaves them out.
|
|
100
126
|
- **assembled** (e.g. `apps/api/src/app.ts`, `auth.ts`) — contain `// podokit:begin:… / …:end` fenced regions that the toolkit recomputes. Edit outside the fences only; **never hand-edit inside the fences**.
|
|
101
127
|
- **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
|
|
102
128
|
|
|
103
129
|
Useful commands:
|
|
104
130
|
|
|
105
|
-
- `podo
|
|
131
|
+
- `podo dev watch|up|ps|logs|exec|down` — run container development through the
|
|
132
|
+
shared loopback gateway. `.podokit/dev.json` owns the local/public hosts and the
|
|
133
|
+
exact API WebSocket allowlist; never use a prefix or wildcard for that allowlist.
|
|
134
|
+
`podo dev up` rebuilds the images from the current source so a recreated
|
|
135
|
+
container never runs code older than the files Compose Watch synced.
|
|
136
|
+
`podo dev down` also stops this project's running `podo dev watch`; do not
|
|
137
|
+
kill Compose processes by hand.
|
|
138
|
+
The containers read the untracked `.env.docker`; `podo dev` creates it from the
|
|
139
|
+
committed `.env.docker.example` with generated secrets when it is missing. Never
|
|
140
|
+
commit `.env.docker` or put secrets in the example.
|
|
106
141
|
- `podo add <module>` — add a feature (auth, admin-dashboard, redis, bullmq, …). It wires itself into the app; run `podo add` with no argument to list modules.
|
|
107
142
|
- `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
|
|
143
|
+
- `podo provider list` / `podo provider set <capability> <provider> [--apply]` — inspect or switch database, cache, object storage, events, and jobs. Set is dry-run by default and never migrates or deletes provider data. Local providers require one API process.
|
|
108
144
|
- `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
|
|
109
145
|
- `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
|
|
110
146
|
- `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist, and API routes that take bodies above the web server's `BODY_SIZE_LIMIT` (file uploads) in `exposure.apiPaths` (and `.podokit/dev.json` `apiPaths` for `podo dev`; `podo add` and `podo deploy init` fill in the paths modules declare), which reach the API without the web proxy, so the API must check their size, session, and `Origin` itself; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
|
|
@@ -131,7 +131,7 @@ Run each service in a separate terminal from the project root:
|
|
|
131
131
|
```
|
|
132
132
|
|
|
133
133
|
- API: http://localhost:5002 — health at `/health`, docs at `/api-docs`
|
|
134
|
-
- Web: http://localhost:5001
|
|
134
|
+
- Web: http://localhost:5001 — its own health at `/healthz`, which never calls the API
|
|
135
135
|
|
|
136
136
|
For multi-project routing, container profiles, and OAuth over a stable HTTPS
|
|
137
137
|
tunnel, see the PodoKit [development guide](https://github.com/podosoft-dev/podokit/blob/main/docs/development.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "An opinionated Bun and Elysia full-stack TypeScript starter toolkit and CLI with SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"test": "vitest run"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@podosoft/podokit-runtime": "^0.4.
|
|
48
|
+
"@podosoft/podokit-runtime": "^0.4.1",
|
|
49
49
|
"@podosoft/podokit-template-engine": "^0.4.2"
|
|
50
50
|
}
|
|
51
51
|
}
|