@podosoft/podokit 1.6.1 → 1.6.2
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-checks.d.ts +16 -5
- package/dist/deploy-checks.js +14 -6
- package/dist/deploy-render.js +28 -3
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +83 -17
- package/dist/index.js +9 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -1
- package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +3 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +14 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +385 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +132 -43
- package/dist/templates/modules/audit-log/module.manifest.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
- package/dist/templates/modules/auth/module.manifest.json +1 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +163 -62
- package/dist/update.d.ts +25 -5
- package/dist/update.js +86 -10
- package/package.json +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from "kysely";
|
|
3
|
+
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
4
|
+
import { PROVIDERS } from "../config/providers";
|
|
5
|
+
import { postgresAuthQueries } from "./db";
|
|
6
|
+
import { EmptyListPlugin } from "./empty-lists";
|
|
7
|
+
|
|
8
|
+
function databaseProvider(): DatabaseProviderName {
|
|
9
|
+
return PROVIDERS.database;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A type alias, not an interface: withTables() needs an index signature.
|
|
13
|
+
type Tables = {
|
|
14
|
+
user: { id: string; email: string };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const db = new Kysely<Tables>({
|
|
18
|
+
dialect: {
|
|
19
|
+
createAdapter: () => new PostgresAdapter(),
|
|
20
|
+
createDriver: () => new DummyDriver(),
|
|
21
|
+
createIntrospector: (kysely) => new PostgresIntrospector(kysely),
|
|
22
|
+
createQueryCompiler: () => new PostgresQueryCompiler(),
|
|
23
|
+
},
|
|
24
|
+
plugins: [new EmptyListPlugin()],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("empty lists in authentication queries", () => {
|
|
28
|
+
it("sends an empty in list as a condition that never holds", () => {
|
|
29
|
+
const query = db.selectFrom("user").selectAll().where("id", "in", []).limit(0).compile();
|
|
30
|
+
expect(query.sql).toBe('select * from "user" where 1 = 0 limit $1');
|
|
31
|
+
expect(query.parameters).toEqual([0]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("sends an empty not in list as a condition that always holds", () => {
|
|
35
|
+
const query = db
|
|
36
|
+
.selectFrom("user")
|
|
37
|
+
.select("id")
|
|
38
|
+
.where("email", "=", "a@example.com")
|
|
39
|
+
.where("id", "not in", [])
|
|
40
|
+
.compile();
|
|
41
|
+
expect(query.sql).toBe('select "id" from "user" where "email" = $1 and 1 = 1');
|
|
42
|
+
expect(query.parameters).toEqual(["a@example.com"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("leaves lists with values and other operators as they are", () => {
|
|
46
|
+
const query = db
|
|
47
|
+
.deleteFrom("user")
|
|
48
|
+
.where((eb) => eb.or([eb("id", "in", ["a", "b"]), eb("email", "=", "c@example.com")]))
|
|
49
|
+
.compile();
|
|
50
|
+
expect(query.sql).toBe('delete from "user" where ("id" in ($1, $2) or "email" = $3)');
|
|
51
|
+
expect(query.parameters).toEqual(["a", "b", "c@example.com"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("applies to the queries Better Auth sends to PostgreSQL", () => {
|
|
55
|
+
if (databaseProvider() !== "postgres") {
|
|
56
|
+
expect(postgresAuthQueries).toBeNull();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const query = postgresAuthQueries
|
|
60
|
+
?.withTables<Tables>()
|
|
61
|
+
.selectFrom("user")
|
|
62
|
+
.selectAll()
|
|
63
|
+
.where("id", "in", [])
|
|
64
|
+
.compile();
|
|
65
|
+
expect(query?.sql).toBe('select * from "user" where 1 = 0');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BinaryOperationNode,
|
|
3
|
+
OperationNodeTransformer,
|
|
4
|
+
OperatorNode,
|
|
5
|
+
PrimitiveValueListNode,
|
|
6
|
+
ValueListNode,
|
|
7
|
+
ValueNode,
|
|
8
|
+
type KyselyPlugin,
|
|
9
|
+
type PluginTransformQueryArgs,
|
|
10
|
+
type PluginTransformResultArgs,
|
|
11
|
+
type QueryId,
|
|
12
|
+
type QueryResult,
|
|
13
|
+
type RootOperationNode,
|
|
14
|
+
type UnknownRow,
|
|
15
|
+
} from "kysely";
|
|
16
|
+
|
|
17
|
+
function constantComparison(holds: boolean): BinaryOperationNode {
|
|
18
|
+
return BinaryOperationNode.create(
|
|
19
|
+
ValueNode.createImmediate(1),
|
|
20
|
+
OperatorNode.create("="),
|
|
21
|
+
ValueNode.createImmediate(holds ? 1 : 0),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class EmptyListTransformer extends OperationNodeTransformer {
|
|
26
|
+
protected override transformBinaryOperation(node: BinaryOperationNode, queryId?: QueryId): BinaryOperationNode {
|
|
27
|
+
const list = node.rightOperand;
|
|
28
|
+
const empty = (PrimitiveValueListNode.is(list) || ValueListNode.is(list)) && list.values.length === 0;
|
|
29
|
+
if (empty && OperatorNode.is(node.operator)) {
|
|
30
|
+
if (node.operator.operator === "in") return constantComparison(false);
|
|
31
|
+
if (node.operator.operator === "not in") return constantComparison(true);
|
|
32
|
+
}
|
|
33
|
+
return super.transformBinaryOperation(node, queryId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Better Auth's Kysely adapter sends an `in` filter on an empty array as
|
|
39
|
+
* `IN ()`, which PostgreSQL rejects as a syntax error. The organization
|
|
40
|
+
* plugin's list-members endpoint looks the members' users up that way, so a
|
|
41
|
+
* filter or page that matched no member answered 500. This plugin sends an
|
|
42
|
+
* empty `in` list as `1 = 0` and an empty `not in` list as `1 = 1`.
|
|
43
|
+
*/
|
|
44
|
+
export class EmptyListPlugin implements KyselyPlugin {
|
|
45
|
+
private readonly transformer = new EmptyListTransformer();
|
|
46
|
+
|
|
47
|
+
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
|
48
|
+
return this.transformer.transformNode(args.node, args.queryId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
transformResult(args: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
|
|
52
|
+
return Promise.resolve(args.result);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -9,21 +9,85 @@ import {
|
|
|
9
9
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
10
10
|
const origin = { origin: base };
|
|
11
11
|
|
|
12
|
-
// rate-limit installs a global
|
|
13
|
-
// per RATE_LIMIT_TTL seconds, per client)
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// rate-limit installs a global guard that counts each request in one budget: the
|
|
13
|
+
// general one (RATE_LIMIT_MAX requests per RATE_LIMIT_TTL seconds, per client),
|
|
14
|
+
// the stricter authentication one for other /api/auth requests, or the runtime
|
|
15
|
+
// one for session reads. This module ships a global guard, so it never joins the
|
|
16
|
+
// shared Outer smoke app (it would throttle every other spec); this spec ships
|
|
17
|
+
// with the module and runs against a dedicated app.
|
|
16
18
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
19
|
+
// Every counted response names its budget's limit and what is left of it in the
|
|
20
|
+
// RateLimit-Limit and RateLimit-Remaining headers. The spec reads the limits from
|
|
21
|
+
// them, so it follows whatever limits the stack runs with. When the runner's
|
|
22
|
+
// environment sets RATE_LIMIT_MAX, RATE_LIMIT_AUTH_MAX, or RATE_LIMIT_RUNTIME_MAX,
|
|
23
|
+
// the spec also expects the stack to use that value. It sends one more request
|
|
24
|
+
// than the general limit, so a run that should not spend that many on a raised
|
|
25
|
+
// limit sets E2E_SKIP_RATE_LIMIT=true to skip the file.
|
|
26
|
+
test.skip(process.env.E2E_SKIP_RATE_LIMIT === "true", "E2E_SKIP_RATE_LIMIT is true");
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
28
|
+
/** Requires a session, so a session's requests count under its user from the first one. */
|
|
29
|
+
const PROBE = "/api/account/me";
|
|
30
|
+
const HEALTH = ["/api/health", "/api/health/ready"];
|
|
31
|
+
|
|
32
|
+
interface Budget {
|
|
33
|
+
limit: number;
|
|
34
|
+
remaining: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The budget a response was counted in, from its RateLimit headers. */
|
|
38
|
+
function budget(response: APIResponse): Budget {
|
|
39
|
+
const headers = response.headers();
|
|
40
|
+
const limit = Number(headers["ratelimit-limit"]);
|
|
41
|
+
const remaining = Number(headers["ratelimit-remaining"]);
|
|
42
|
+
const counted = Number.isInteger(limit) && Number.isInteger(remaining);
|
|
43
|
+
expect(counted, `RateLimit headers of ${response.url()}`).toBe(true);
|
|
44
|
+
return { limit, remaining };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Expects `limit` to be the value the runner's environment sets for `name`, if it sets one. */
|
|
48
|
+
function expectConfigured(
|
|
49
|
+
name: "RATE_LIMIT_MAX" | "RATE_LIMIT_AUTH_MAX" | "RATE_LIMIT_RUNTIME_MAX",
|
|
50
|
+
limit: number,
|
|
51
|
+
): void {
|
|
52
|
+
const configured = process.env[name];
|
|
53
|
+
if (configured) expect(limit, `${name} in the runner's environment`).toBe(Number(configured));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Runs a check that compares counts across several requests until it passes. A
|
|
58
|
+
* counter lasts its window from its first request, so with a short
|
|
59
|
+
* RATE_LIMIT_TTL a new window can start between two requests of the check and
|
|
60
|
+
* reset what it compares; the next attempt starts over in that window.
|
|
61
|
+
*/
|
|
62
|
+
async function inOneWindow(check: () => Promise<void>): Promise<void> {
|
|
63
|
+
await expect(check).toPass({ timeout: 30_000 });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Requests the probe until the limiter answers 429, or null when the general
|
|
68
|
+
* window keeps ending first. More remaining than on the request before means a
|
|
69
|
+
* new window started, which starts the count over.
|
|
70
|
+
*/
|
|
71
|
+
async function untilLimited(ctx: APIRequestContext, limit: number): Promise<APIResponse | null> {
|
|
72
|
+
let windows = 1;
|
|
73
|
+
let counted = 0;
|
|
74
|
+
let previous = Number.POSITIVE_INFINITY;
|
|
75
|
+
for (;;) {
|
|
76
|
+
const response = await ctx.get(PROBE);
|
|
77
|
+
if (response.status() === 429) return response;
|
|
78
|
+
expect(response.status(), `${PROBE} request ${counted + 1} of window ${windows}`).toBe(200);
|
|
79
|
+
const { remaining } = budget(response);
|
|
80
|
+
if (remaining >= previous) {
|
|
81
|
+
windows += 1;
|
|
82
|
+
counted = 0;
|
|
83
|
+
if (windows > 3) return null;
|
|
84
|
+
}
|
|
85
|
+
previous = remaining;
|
|
86
|
+
counted += 1;
|
|
87
|
+
// A window holds at most `limit` served requests.
|
|
88
|
+
expect(counted, `requests served in one general window`).toBeLessThanOrEqual(limit);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
27
91
|
|
|
28
92
|
type Playwright = PlaywrightWorkerArgs["playwright"];
|
|
29
93
|
const password = "Podokit3e-Str0ng!pw";
|
|
@@ -38,6 +102,8 @@ function uniqueEmail(): string {
|
|
|
38
102
|
// anonymous: the later ones carry its session cookie, so the limiter counts them
|
|
39
103
|
// against that session instead.
|
|
40
104
|
let carrier: string | undefined;
|
|
105
|
+
/** The authentication budget's limit, as the latest sign-up or sign-in reported it. */
|
|
106
|
+
let authLimit = 0;
|
|
41
107
|
|
|
42
108
|
/** Run one authentication write and return a context holding the session it set. */
|
|
43
109
|
async function authenticated(
|
|
@@ -51,6 +117,7 @@ async function authenticated(
|
|
|
51
117
|
});
|
|
52
118
|
const response = await writer.post(path, { data });
|
|
53
119
|
expect(response.ok(), `${path}: ${response.status()}`).toBeTruthy();
|
|
120
|
+
authLimit = budget(response).limit;
|
|
54
121
|
const storageState = await writer.storageState();
|
|
55
122
|
await writer.dispose();
|
|
56
123
|
const token = storageState.cookies.find((cookie) => cookie.name.endsWith("session_token"));
|
|
@@ -67,34 +134,37 @@ test("rate limit: health probes stay available while ordinary routes return 429
|
|
|
67
134
|
playwright,
|
|
68
135
|
}) => {
|
|
69
136
|
const ctx = await session(playwright);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
137
|
+
const probe = await ctx.get(PROBE);
|
|
138
|
+
test.skip(probe.status() >= 500, "the limiter's storage is not reachable");
|
|
139
|
+
expect(probe.status()).toBe(200);
|
|
140
|
+
const { limit } = budget(probe);
|
|
141
|
+
expectConfigured("RATE_LIMIT_MAX", limit);
|
|
142
|
+
test.skip(limit > 10_000, `the general budget (${limit}) is too large to exhaust here`);
|
|
143
|
+
|
|
144
|
+
// Health probes are not counted: they carry no budget and leave the general one as it was.
|
|
145
|
+
await inOneWindow(async () => {
|
|
146
|
+
const before = budget(await ctx.get(PROBE)).remaining;
|
|
147
|
+
for (const path of HEALTH) {
|
|
148
|
+
for (let i = 0; i < 5; i++) {
|
|
149
|
+
const response = await ctx.get(path);
|
|
150
|
+
expect(response.status(), `${path} request ${i + 1}`).toBe(200);
|
|
151
|
+
expect(response.headers()["ratelimit-limit"], path).toBeUndefined();
|
|
152
|
+
}
|
|
74
153
|
}
|
|
75
|
-
|
|
154
|
+
expect(budget(await ctx.get(PROBE)).remaining).toBe(before - 1);
|
|
155
|
+
});
|
|
76
156
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
let seen200 = probe.status() === 200;
|
|
83
|
-
let limitedResponse: APIResponse | undefined;
|
|
84
|
-
for (let i = 0; i < limit + 5 && !limitedResponse; i++) {
|
|
85
|
-
const response = await ctx.get("/api/cache/rate-limit-probe");
|
|
86
|
-
if (response.status() === 200) seen200 = true;
|
|
87
|
-
if (response.status() === 429) limitedResponse = response;
|
|
88
|
-
}
|
|
89
|
-
// A fresh window serves the first requests (200) and rejects once the limit is
|
|
90
|
-
// crossed (429).
|
|
91
|
-
expect(seen200).toBe(true);
|
|
92
|
-
expect(limitedResponse?.status()).toBe(429);
|
|
93
|
-
expect(limitedResponse?.headers()["retry-after"]).toBeTruthy();
|
|
94
|
-
expect(await limitedResponse?.json()).toMatchObject({
|
|
157
|
+
// A window serves `limit` requests and rejects the next one.
|
|
158
|
+
const limited = await untilLimited(ctx, limit);
|
|
159
|
+
test.skip(limited === null, `the general window ends before this client sends ${limit} requests`);
|
|
160
|
+
expect(limited?.headers()["retry-after"]).toBeTruthy();
|
|
161
|
+
expect(await limited?.json()).toMatchObject({
|
|
95
162
|
success: false,
|
|
96
163
|
error: { code: "RATE_LIMIT_EXCEEDED", statusCode: 429 },
|
|
97
164
|
});
|
|
165
|
+
for (const path of HEALTH) {
|
|
166
|
+
expect((await ctx.get(path)).status(), `${path} while the session is limited`).toBe(200);
|
|
167
|
+
}
|
|
98
168
|
await ctx.dispose();
|
|
99
169
|
});
|
|
100
170
|
|
|
@@ -103,10 +173,10 @@ test("rate limit: a user's sessions share one counter and forged sessions do not
|
|
|
103
173
|
}) => {
|
|
104
174
|
const email = uniqueEmail();
|
|
105
175
|
const first = await session(playwright, email);
|
|
106
|
-
const probe = await first.get(
|
|
107
|
-
test.skip(probe.status() >= 500, "
|
|
108
|
-
const remaining = (response: APIResponse): number =>
|
|
109
|
-
const
|
|
176
|
+
const probe = await first.get(PROBE);
|
|
177
|
+
test.skip(probe.status() >= 500, "the limiter's storage is not reachable");
|
|
178
|
+
const remaining = (response: APIResponse): number => budget(response).remaining;
|
|
179
|
+
const { limit } = budget(probe);
|
|
110
180
|
|
|
111
181
|
// A second sign-in of the same user, and the signed bearer form of the first
|
|
112
182
|
// session. The probe requires a session, so each request below resolves it; a
|
|
@@ -119,22 +189,24 @@ test("rate limit: a user's sessions share one counter and forged sessions do not
|
|
|
119
189
|
baseURL: base,
|
|
120
190
|
extraHTTPHeaders: { ...origin, authorization: `Bearer ${cookie?.value ?? ""}` },
|
|
121
191
|
});
|
|
122
|
-
await second.get(
|
|
192
|
+
await second.get(PROBE);
|
|
123
193
|
|
|
124
194
|
// Both sessions now draw from one budget: each request lowers what the next
|
|
125
195
|
// one, from either session, has left.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
196
|
+
await inOneWindow(async () => {
|
|
197
|
+
const fromFirst = remaining(await first.get(PROBE));
|
|
198
|
+
expect(remaining(await second.get(PROBE))).toBe(fromFirst - 1);
|
|
199
|
+
expect(remaining(await bearer.get(PROBE))).toBe(fromFirst - 2);
|
|
200
|
+
expect(remaining(await first.get(PROBE))).toBe(fromFirst - 3);
|
|
201
|
+
});
|
|
130
202
|
|
|
131
203
|
// Another user has a budget of their own.
|
|
132
204
|
const other = await session(playwright);
|
|
133
|
-
expect(remaining(await other.get(
|
|
205
|
+
expect(remaining(await other.get(PROBE))).toBe(limit - 1);
|
|
134
206
|
|
|
135
207
|
// A session cookie without a valid signature is counted with anonymous traffic
|
|
136
|
-
// from the same client address instead of receiving a fresh counter. The
|
|
137
|
-
//
|
|
208
|
+
// from the same client address instead of receiving a fresh counter. The probe
|
|
209
|
+
// requires a session, so compare on a public route with the general limit.
|
|
138
210
|
const token = decodeURIComponent(cookie?.value ?? "").split(".")[0] ?? "";
|
|
139
211
|
const forged = await playwright.request.newContext({
|
|
140
212
|
baseURL: base,
|
|
@@ -154,9 +226,21 @@ test("rate limit: a user's sessions share one counter and forged sessions do not
|
|
|
154
226
|
test("rate limit: session reads do not consume the strict authentication budget", async ({
|
|
155
227
|
playwright,
|
|
156
228
|
}) => {
|
|
157
|
-
test.skip(authLimit > 1000, "the authentication budget is too large to exhaust here");
|
|
158
229
|
const ctx = await session(playwright);
|
|
159
|
-
|
|
230
|
+
const read = await ctx.get("/api/auth/get-session");
|
|
231
|
+
test.skip(read.status() >= 500, "the limiter's storage is not reachable");
|
|
232
|
+
const runtimeLimit = budget(read).limit;
|
|
233
|
+
expectConfigured("RATE_LIMIT_RUNTIME_MAX", runtimeLimit);
|
|
234
|
+
expectConfigured("RATE_LIMIT_AUTH_MAX", authLimit);
|
|
235
|
+
// Reading past the authentication budget shows the reads are not counted in
|
|
236
|
+
// it, but only while those reads fit in the runtime budget.
|
|
237
|
+
const reads = authLimit + 5;
|
|
238
|
+
test.skip(
|
|
239
|
+
reads + 1 > runtimeLimit,
|
|
240
|
+
`the authentication budget (${authLimit}) is not far enough below the runtime budget (${runtimeLimit})`,
|
|
241
|
+
);
|
|
242
|
+
test.skip(authLimit > 1000, `the authentication budget (${authLimit}) is too large to exhaust here`);
|
|
243
|
+
for (let i = 0; i < reads; i++) {
|
|
160
244
|
const response = await ctx.get("/api/auth/get-session");
|
|
161
245
|
expect(response.status(), `session request ${i + 1}`).toBe(200);
|
|
162
246
|
}
|
|
@@ -167,17 +251,34 @@ test("rate limit: each route is counted in its most specific registered budget",
|
|
|
167
251
|
playwright,
|
|
168
252
|
}) => {
|
|
169
253
|
const ctx = await session(playwright);
|
|
170
|
-
const probe = await ctx.get(
|
|
171
|
-
test.skip(probe.status() >= 500, "
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
254
|
+
const probe = await ctx.get(PROBE);
|
|
255
|
+
test.skip(probe.status() >= 500, "the limiter's storage is not reachable");
|
|
256
|
+
const generalLimit = budget(probe).limit;
|
|
257
|
+
expectConfigured("RATE_LIMIT_MAX", generalLimit);
|
|
258
|
+
expectConfigured("RATE_LIMIT_AUTH_MAX", authLimit);
|
|
259
|
+
|
|
260
|
+
await inOneWindow(async () => {
|
|
261
|
+
// Ordinary routes fall back to the general budget registered at "/".
|
|
262
|
+
const ordinary = budget(await ctx.get("/api/account/capabilities"));
|
|
263
|
+
// Longer GET prefixes inside "/api/auth" win over its authentication budget:
|
|
264
|
+
// administrator reads count in the general budget and session reads in the
|
|
265
|
+
// runtime one.
|
|
266
|
+
const adminRead = budget(await ctx.get("/api/auth/admin/list-users"));
|
|
267
|
+
const sessionRead = budget(await ctx.get("/api/auth/get-session"));
|
|
268
|
+
// Other "/api/auth" requests count in the strict authentication budget.
|
|
269
|
+
const authWrite = budget(await ctx.post("/api/auth/update-user", { data: { name: "Rate Limit" } }));
|
|
270
|
+
const sessionReadAgain = budget(await ctx.get("/api/auth/get-session"));
|
|
271
|
+
const ordinaryAgain = budget(await ctx.get("/api/account/capabilities"));
|
|
272
|
+
|
|
273
|
+
expect(ordinary.limit).toBe(generalLimit);
|
|
274
|
+
expect(adminRead).toEqual({ limit: generalLimit, remaining: ordinary.remaining - 1 });
|
|
275
|
+
// Neither the session reads nor the authentication write moved the general counter,
|
|
276
|
+
expect(ordinaryAgain).toEqual({ limit: generalLimit, remaining: ordinary.remaining - 2 });
|
|
277
|
+
// the authentication write did not move the runtime one,
|
|
278
|
+
expect(sessionReadAgain).toEqual({ limit: sessionRead.limit, remaining: sessionRead.remaining - 1 });
|
|
279
|
+
// and it was counted with sign-ups and sign-ins.
|
|
280
|
+
expect(authWrite.limit).toBe(authLimit);
|
|
281
|
+
expectConfigured("RATE_LIMIT_RUNTIME_MAX", sessionRead.limit);
|
|
282
|
+
});
|
|
182
283
|
await ctx.dispose();
|
|
183
284
|
});
|
package/dist/update.d.ts
CHANGED
|
@@ -96,8 +96,14 @@ export interface TargetProject {
|
|
|
96
96
|
export declare function targetProject(projectRoot: string, templatesDir: string, targetAnswers?: TemplateVars): TargetProject;
|
|
97
97
|
export declare function planUpdate(projectRoot: string, templatesDir: string, options?: PlanOptions): UpdatePlan;
|
|
98
98
|
export interface ApplyOptions {
|
|
99
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Templates for the version the project is currently on, for 3-way merges.
|
|
101
|
+
* Without it, the update uses the installed templates when the project is on
|
|
102
|
+
* the installed version and otherwise fetches that version's.
|
|
103
|
+
*/
|
|
100
104
|
oldTemplatesDir?: string;
|
|
105
|
+
/** Fetches the templates a published PodoKit version ships. Defaults to npm. */
|
|
106
|
+
fetchTemplates?: (version: string) => PreviousTemplates;
|
|
101
107
|
/** Preinstalled previous external modules. Primarily useful for offline updates. */
|
|
102
108
|
oldExternalModulesRoot?: string;
|
|
103
109
|
/** Replay variables and manifest value used by a runtime conversion. */
|
|
@@ -124,9 +130,22 @@ export interface ApplyResult {
|
|
|
124
130
|
conflicts: string[];
|
|
125
131
|
/** Owned files whose template changed; they are left as they are. */
|
|
126
132
|
review: string[];
|
|
133
|
+
/** Edited managed files the update left as they are, and why. */
|
|
134
|
+
skipped: SkippedFile[];
|
|
127
135
|
/** Direct API paths added to `.podokit/dev.json` and deployment profiles. */
|
|
128
136
|
apiPaths: ApiPathResult;
|
|
129
137
|
}
|
|
138
|
+
export interface SkippedFile {
|
|
139
|
+
path: string;
|
|
140
|
+
reason: string;
|
|
141
|
+
}
|
|
142
|
+
/** A template set to merge against, and how to dispose of it. */
|
|
143
|
+
export interface PreviousTemplates {
|
|
144
|
+
dir: string;
|
|
145
|
+
cleanup(): void;
|
|
146
|
+
}
|
|
147
|
+
/** Install a published PodoKit CLI into a temporary directory and return its templates. */
|
|
148
|
+
export declare function publishedTemplates(version: string): PreviousTemplates;
|
|
130
149
|
export interface UpstreamChange {
|
|
131
150
|
path: string;
|
|
132
151
|
/** Unified diff from the previous template to the new one; empty when the
|
|
@@ -141,10 +160,11 @@ export interface UpstreamChange {
|
|
|
141
160
|
export declare function upstreamChanges(projectRoot: string, templatesDir: string, plan: UpdatePlan, oldTemplatesDir: string, oldExternalModulesRoot?: string): UpstreamChange[];
|
|
142
161
|
/**
|
|
143
162
|
* Apply an update to the working copy. Clean updates and additions are written,
|
|
144
|
-
* upstream removals deleted, and user-edited files 3-way merged against the
|
|
145
|
-
* version
|
|
146
|
-
*
|
|
147
|
-
*
|
|
163
|
+
* upstream removals deleted, and user-edited files 3-way merged against the
|
|
164
|
+
* templates of the version the project is on, with git-style conflict markers
|
|
165
|
+
* where both sides changed the same lines. Refuses before writing anything when
|
|
166
|
+
* those templates are unavailable. Owned files are never touched. Refreshes the
|
|
167
|
+
* lockfile and stamps the new version.
|
|
148
168
|
*/
|
|
149
169
|
export declare function applyUpdate(projectRoot: string, templatesDir: string, options?: ApplyOptions): ApplyResult;
|
|
150
170
|
/** One line per external module installed at an older version than recorded. */
|
package/dist/update.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.compareVersions = compareVersions;
|
|
|
4
4
|
exports.declaredOwnedGlobs = declaredOwnedGlobs;
|
|
5
5
|
exports.targetProject = targetProject;
|
|
6
6
|
exports.planUpdate = planUpdate;
|
|
7
|
+
exports.publishedTemplates = publishedTemplates;
|
|
7
8
|
exports.upstreamChanges = upstreamChanges;
|
|
8
9
|
exports.applyUpdate = applyUpdate;
|
|
9
10
|
exports.describeDowngrades = describeDowngrades;
|
|
@@ -334,6 +335,61 @@ function planUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
334
335
|
ownedWithoutTemplate,
|
|
335
336
|
};
|
|
336
337
|
}
|
|
338
|
+
const CLI_PACKAGE = "@podosoft/podokit";
|
|
339
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
340
|
+
/** Install a published PodoKit CLI into a temporary directory and return its templates. */
|
|
341
|
+
function publishedTemplates(version) {
|
|
342
|
+
if (!EXACT_VERSION.test(version)) {
|
|
343
|
+
throw new Error(`"${version}" is not a published ${CLI_PACKAGE} version.`);
|
|
344
|
+
}
|
|
345
|
+
const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "podokit-update-templates-"));
|
|
346
|
+
const cleanup = () => (0, node_fs_1.rmSync)(root, { recursive: true, force: true });
|
|
347
|
+
try {
|
|
348
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, "package.json"), `${JSON.stringify({ name: "podokit-update-templates", private: true, devDependencies: { [CLI_PACKAGE]: version } }, null, 2)}\n`);
|
|
349
|
+
(0, node_child_process_1.execFileSync)("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--package-lock=false"], { cwd: root, stdio: "pipe" });
|
|
350
|
+
const packageDir = (0, node_path_1.join)(root, "node_modules", ...CLI_PACKAGE.split("/"));
|
|
351
|
+
const installed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(packageDir, "package.json"), "utf8")).version;
|
|
352
|
+
const dir = (0, node_path_1.join)(packageDir, "dist", "templates");
|
|
353
|
+
if (installed !== version || !(0, node_fs_1.existsSync)(dir)) {
|
|
354
|
+
throw new Error(`the installed package is not ${version} or ships no templates`);
|
|
355
|
+
}
|
|
356
|
+
return { dir, cleanup };
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
cleanup();
|
|
360
|
+
throw new Error(`Cannot install ${CLI_PACKAGE}@${version} from the npm registry: ${npmFailure(error)}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/** npm's error code, such as ECONNREFUSED or ETARGET, rather than the command line. */
|
|
364
|
+
function npmFailure(error) {
|
|
365
|
+
const stderr = error?.stderr;
|
|
366
|
+
const code = String(stderr ?? "").match(/^npm (?:error|ERR!) code (\S+)/m)?.[1];
|
|
367
|
+
if (code)
|
|
368
|
+
return `npm error ${code}`;
|
|
369
|
+
return error instanceof Error ? (error.message.split("\n")[0] ?? error.message) : String(error);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The templates the project's edited managed files were generated from, which
|
|
373
|
+
* a 3-way merge needs as its base: `--from` when given, the installed ones when
|
|
374
|
+
* the project is on the installed version, and the recorded version's otherwise.
|
|
375
|
+
*/
|
|
376
|
+
function mergeBaseTemplates(recordedVersion, templatesDir, options, paths) {
|
|
377
|
+
if (options.oldTemplatesDir)
|
|
378
|
+
return { dir: options.oldTemplatesDir, cleanup: () => { } };
|
|
379
|
+
if (recordedVersion === (0, lockfile_1.podokitVersion)())
|
|
380
|
+
return { dir: templatesDir, cleanup: () => { } };
|
|
381
|
+
try {
|
|
382
|
+
return (options.fetchTemplates ?? publishedTemplates)(recordedVersion);
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}\n` +
|
|
386
|
+
`The 3-way merge of these edited files needs the templates of ${recordedVersion}, ` +
|
|
387
|
+
`the version this project was last generated or updated with:\n${paths
|
|
388
|
+
.map((path) => ` - ${path}`)
|
|
389
|
+
.join("\n")}\n` +
|
|
390
|
+
"Nothing was written. Check npm registry access, or pass that version's templates with --from <dir>.");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
337
393
|
function assertRecordedExternalModules(moduleRoot, templatesDir, modules) {
|
|
338
394
|
const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
|
|
339
395
|
for (const module of modules) {
|
|
@@ -486,10 +542,11 @@ function updatedFilesLock(projectRoot, newTree, previous, ownedGlobs, managedOve
|
|
|
486
542
|
}
|
|
487
543
|
/**
|
|
488
544
|
* Apply an update to the working copy. Clean updates and additions are written,
|
|
489
|
-
* upstream removals deleted, and user-edited files 3-way merged against the
|
|
490
|
-
* version
|
|
491
|
-
*
|
|
492
|
-
*
|
|
545
|
+
* upstream removals deleted, and user-edited files 3-way merged against the
|
|
546
|
+
* templates of the version the project is on, with git-style conflict markers
|
|
547
|
+
* where both sides changed the same lines. Refuses before writing anything when
|
|
548
|
+
* those templates are unavailable. Owned files are never touched. Refreshes the
|
|
549
|
+
* lockfile and stamps the new version.
|
|
493
550
|
*/
|
|
494
551
|
function applyUpdate(projectRoot, templatesDir, options = {}) {
|
|
495
552
|
const manifest = (0, lockfile_1.readManifest)(projectRoot);
|
|
@@ -528,10 +585,18 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
528
585
|
const migratedManagedOverrides = [
|
|
529
586
|
...new Set(managedOverrides.map((glob) => (0, module_migrations_1.migrateConfiguredPath)(glob, pendingMigrations))),
|
|
530
587
|
];
|
|
531
|
-
const
|
|
588
|
+
const mergePaths = plan.changes
|
|
589
|
+
.filter((change) => change.action === "conflict" && treeText(newTree, change.path) !== null)
|
|
590
|
+
.map((change) => change.path);
|
|
532
591
|
let oldTree = null;
|
|
533
|
-
if (
|
|
534
|
-
|
|
592
|
+
if (mergePaths.length) {
|
|
593
|
+
const base = mergeBaseTemplates(manifest.podokitVersion, templatesDir, options, mergePaths);
|
|
594
|
+
try {
|
|
595
|
+
oldTree = previousTree(projectRoot, manifest, base.dir, options.oldExternalModulesRoot);
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
base.cleanup();
|
|
599
|
+
}
|
|
535
600
|
}
|
|
536
601
|
if (options.abortOnConflict) {
|
|
537
602
|
const unresolved = [];
|
|
@@ -562,6 +627,7 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
562
627
|
merged: [],
|
|
563
628
|
conflicts: [],
|
|
564
629
|
review: plan.changes.filter((change) => change.action === "review").map((change) => change.path),
|
|
630
|
+
skipped: [],
|
|
565
631
|
apiPaths: { changed: [], skipped: [] },
|
|
566
632
|
};
|
|
567
633
|
for (const change of plan.changes) {
|
|
@@ -574,12 +640,22 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
574
640
|
(0, node_fs_1.rmSync)((0, node_path_1.join)(projectRoot, change.path), { force: true });
|
|
575
641
|
result.removed.push(change.path);
|
|
576
642
|
}
|
|
577
|
-
else if (change.action === "conflict"
|
|
643
|
+
else if (change.action === "conflict") {
|
|
644
|
+
if (newText === null) {
|
|
645
|
+
result.skipped.push({
|
|
646
|
+
path: change.path,
|
|
647
|
+
reason: "removed in this version but edited locally, so it was kept; delete it when you no longer need it",
|
|
648
|
+
});
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
578
651
|
const disk = (0, node_fs_1.readFileSync)((0, node_path_1.join)(projectRoot, change.path), "utf8");
|
|
579
652
|
const base = oldTree ? treeText(oldTree, change.path) : null;
|
|
580
653
|
if (base === null) {
|
|
581
|
-
//
|
|
582
|
-
result.
|
|
654
|
+
// Nothing to merge against: never clobber the application's version.
|
|
655
|
+
result.skipped.push({
|
|
656
|
+
path: change.path,
|
|
657
|
+
reason: `the previous version's templates do not generate it, so there is no merge base; compare it with podo diff ${change.path}`,
|
|
658
|
+
});
|
|
583
659
|
continue;
|
|
584
660
|
}
|
|
585
661
|
const merge = (0, podokit_template_engine_1.threeWayMerge)(base, disk, newText, { current: "current", next: "podokit" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
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": {
|