@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.
Files changed (25) hide show
  1. package/dist/deploy-checks.d.ts +16 -5
  2. package/dist/deploy-checks.js +14 -6
  3. package/dist/deploy-render.js +28 -3
  4. package/dist/deploy.d.ts +9 -0
  5. package/dist/deploy.js +83 -17
  6. package/dist/index.js +9 -2
  7. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
  8. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -1
  9. package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
  10. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
  11. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
  12. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +3 -3
  13. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +14 -2
  14. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +385 -0
  15. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +132 -43
  16. package/dist/templates/modules/audit-log/module.manifest.json +2 -2
  17. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
  18. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
  19. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
  20. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
  21. package/dist/templates/modules/auth/module.manifest.json +1 -0
  22. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +163 -62
  23. package/dist/update.d.ts +25 -5
  24. package/dist/update.js +86 -10
  25. 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
+ }
@@ -13,6 +13,7 @@
13
13
  "@podosoft/podokit-auth": "^0.6.0",
14
14
  "@podosoft/podokit-contracts": "^0.6.0",
15
15
  "better-auth": "^1.7.7",
16
+ "kysely": "^0.29.0",
16
17
  "pg": "^8.12.0",
17
18
  "@better-auth/api-key": "^1.7.7",
18
19
  "@better-auth/passkey": "^1.7.7",
@@ -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 throttler guard (default RATE_LIMIT_MAX requests
13
- // per RATE_LIMIT_TTL seconds, per client). This module ships a global guard, so
14
- // it never joins the shared Outer smoke app (it would throttle every other
15
- // spec); this spec ships with the module and runs against a dedicated app.
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
- // Honor RATE_LIMIT_MAX when the test runner has it (a dedicated app sets a small
18
- // value so the window is crossed in a handful of requests); otherwise fall back
19
- // to the module default and allow a little headroom.
20
- // A local stack that raises the limits to run the whole suite quickly sets
21
- // E2E_SKIP_RATE_LIMIT=true and runs this file separately against the defaults.
22
- test.skip(process.env.E2E_SKIP_RATE_LIMIT === "true", "assumes the configured limits; run it on its own");
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
- const limit = Number(process.env.RATE_LIMIT_MAX ?? 300);
25
- const authLimit = Number(process.env.RATE_LIMIT_AUTH_MAX ?? 20);
26
- const runtimeLimit = Number(process.env.RATE_LIMIT_RUNTIME_MAX ?? 1000);
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
- for (const path of ["/api/health", "/api/health/ready"]) {
71
- for (let i = 0; i < limit + 5; i++) {
72
- const response = await ctx.get(path);
73
- expect(response.status(), `${path} request ${i + 1}`).toBe(200);
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
- // The redis module is added automatically and exposes a public demo cache
78
- // route, so it provides a stable ordinary endpoint for the global limit.
79
- const probe = await ctx.get("/api/cache/rate-limit-probe");
80
- test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
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("/api/cache/rate-limit-probe");
107
- test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
108
- const remaining = (response: APIResponse): number => Number(response.headers()["ratelimit-remaining"]);
109
- const ceiling = Number(probe.headers()["ratelimit-limit"]);
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("/api/cache/rate-limit-probe");
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
- const fromFirst = remaining(await first.get("/api/cache/rate-limit-probe"));
127
- expect(remaining(await second.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 1);
128
- expect(remaining(await bearer.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 2);
129
- expect(remaining(await first.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 3);
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("/api/cache/rate-limit-probe"))).toBe(ceiling - 1);
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 cache
137
- // probe requires a session, so compare on a public route with the general limit.
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
- for (let i = 0; i < authLimit + 5; i++) {
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("/api/account/capabilities");
171
- test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
172
- const ceiling = (response: APIResponse): number => Number(response.headers()["ratelimit-limit"]);
173
-
174
- // Ordinary routes fall back to the general budget registered at "/".
175
- expect(ceiling(probe)).toBe(limit);
176
- // Longer GET prefixes inside "/api/auth" win over its authentication budget:
177
- // session reads use the runtime budget and administrator reads the general one.
178
- expect(ceiling(await ctx.get("/api/auth/get-session"))).toBe(runtimeLimit);
179
- expect(ceiling(await ctx.get("/api/auth/admin/list-users"))).toBe(limit);
180
- // Other "/api/auth" requests carry the strict authentication budget.
181
- expect(ceiling(await ctx.post("/api/auth/sign-out", { data: {} }))).toBe(authLimit);
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
- /** Templates for the version the project is currently on, for 3-way merges. */
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 old
145
- * version (when `oldTemplatesDir` is given) or written with git-style conflict
146
- * markers otherwise. Owned files are never touched. Refreshes the lockfile and
147
- * stamps the new version.
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 old
490
- * version (when `oldTemplatesDir` is given) or written with git-style conflict
491
- * markers otherwise. Owned files are never touched. Refreshes the lockfile and
492
- * stamps the new version.
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 needsMergeBase = plan.changes.some((change) => change.action === "conflict");
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 (options.oldTemplatesDir && needsMergeBase) {
534
- oldTree = previousTree(projectRoot, manifest, options.oldTemplatesDir, options.oldExternalModulesRoot);
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" && newText !== null) {
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
- // No old version to merge against — never clobber the user's edits.
582
- result.conflicts.push(change.path);
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.1",
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": {