tempest-react-sdk 0.77.0 → 0.78.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.
Files changed (79) hide show
  1. package/bin/lib/alias/collect.mjs +20 -3
  2. package/bin/lib/alias/index.mjs +12 -1
  3. package/bin/lib/alias/index.test.mjs +24 -0
  4. package/bin/lib/css/analyze.test.mjs +21 -1
  5. package/bin/lib/css/collect.mjs +14 -0
  6. package/bin/lib/css/extract.mjs +63 -5
  7. package/bin/lib/css/extract.test.mjs +110 -1
  8. package/bin/lib/css/fix.e2e.test.mjs +50 -0
  9. package/bin/lib/css/index.mjs +6 -1
  10. package/bin/lib/fs/contain.mjs +50 -0
  11. package/bin/lib/icons/generate.mjs +28 -3
  12. package/bin/lib/icons/generate.test.mjs +57 -1
  13. package/bin/lib/openapi/escape.mjs +20 -6
  14. package/bin/lib/openapi/generate.encode.test.mjs +120 -0
  15. package/bin/lib/openapi/generate.hostile.test.mjs +38 -9
  16. package/bin/lib/openapi/generate.mjs +41 -6
  17. package/bin/lib/openapi/generate.test.mjs +7 -2
  18. package/bin/lib/openapi/schema-to-zod.mjs +5 -3
  19. package/bin/tempest-gen.test.mjs +188 -0
  20. package/bin/tempest.mjs +120 -44
  21. package/dist/auth/create-tempest-auth.cjs +1 -1
  22. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  23. package/dist/auth/create-tempest-auth.js +186 -113
  24. package/dist/auth/create-tempest-auth.js.map +1 -1
  25. package/dist/br/pix.cjs +1 -1
  26. package/dist/br/pix.cjs.map +1 -1
  27. package/dist/br/pix.js +30 -27
  28. package/dist/br/pix.js.map +1 -1
  29. package/dist/br.d.ts +11 -4
  30. package/dist/components/CodeBlock/CodeBlock.cjs +1 -1
  31. package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -1
  32. package/dist/components/CodeBlock/CodeBlock.js +1 -1
  33. package/dist/components/CodeBlock/CodeBlock.js.map +1 -1
  34. package/dist/components/CodeBlock/tokenize.cjs +1 -1
  35. package/dist/components/CodeBlock/tokenize.cjs.map +1 -1
  36. package/dist/components/CodeBlock/tokenize.js +54 -25
  37. package/dist/components/CodeBlock/tokenize.js.map +1 -1
  38. package/dist/components/Markdown/markdown-parse.cjs +6 -6
  39. package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
  40. package/dist/components/Markdown/markdown-parse.js +91 -74
  41. package/dist/components/Markdown/markdown-parse.js.map +1 -1
  42. package/dist/http/resumable-upload.cjs +1 -1
  43. package/dist/http/resumable-upload.cjs.map +1 -1
  44. package/dist/http/resumable-upload.js +141 -105
  45. package/dist/http/resumable-upload.js.map +1 -1
  46. package/dist/http/retry.cjs +1 -1
  47. package/dist/http/retry.cjs.map +1 -1
  48. package/dist/http/retry.js +5 -6
  49. package/dist/http/retry.js.map +1 -1
  50. package/dist/sw/background-sync.cjs +1 -1
  51. package/dist/sw/background-sync.cjs.map +1 -1
  52. package/dist/sw/background-sync.js +32 -31
  53. package/dist/sw/background-sync.js.map +1 -1
  54. package/dist/sw/credential-headers.cjs.map +1 -1
  55. package/dist/sw/credential-headers.js.map +1 -1
  56. package/dist/sw.d.ts +46 -8
  57. package/dist/telemetry/app-error-reporter.cjs +1 -1
  58. package/dist/telemetry/app-error-reporter.cjs.map +1 -1
  59. package/dist/telemetry/app-error-reporter.js +1 -1
  60. package/dist/telemetry/app-error-reporter.js.map +1 -1
  61. package/dist/tempest-react-sdk.cjs +1 -1
  62. package/dist/tempest-react-sdk.d.ts +214 -12
  63. package/dist/tempest-react-sdk.js +119 -119
  64. package/dist/utils/csv.cjs +3 -3
  65. package/dist/utils/csv.cjs.map +1 -1
  66. package/dist/utils/csv.js +11 -10
  67. package/dist/utils/csv.js.map +1 -1
  68. package/dist/{telemetry/app-error-owner.cjs → utils/owner-fingerprint.cjs} +2 -2
  69. package/dist/utils/owner-fingerprint.cjs.map +1 -0
  70. package/dist/{telemetry/app-error-owner.js → utils/owner-fingerprint.js} +3 -3
  71. package/dist/utils/owner-fingerprint.js.map +1 -0
  72. package/dist/vite/tempest-csp.cjs +2 -2
  73. package/dist/vite/tempest-csp.cjs.map +1 -1
  74. package/dist/vite/tempest-csp.js +69 -58
  75. package/dist/vite/tempest-csp.js.map +1 -1
  76. package/dist/vite.d.ts +5 -3
  77. package/package.json +1 -1
  78. package/dist/telemetry/app-error-owner.cjs.map +0 -1
  79. package/dist/telemetry/app-error-owner.js.map +0 -1
@@ -180,13 +180,23 @@ function templateText(text) {
180
180
  *
181
181
  * Static segments are escaped for the template literal; each `{name}` placeholder
182
182
  * is matched by the spec's original name against the declared path parameters
183
- * and becomes `${identifier}`, the variable that parameter was mapped to
184
- * (`{user-id}` → `${userId}`). A path without placeholders is emitted as a JSON
185
- * string literal.
183
+ * and becomes `${encodeIdSegment(identifier)}`, the variable that parameter was
184
+ * mapped to passed through the SDK's segment encoder (`{user-id}` →
185
+ * `${encodeIdSegment(userId)}`). A path without placeholders is emitted as a
186
+ * JSON string literal.
187
+ *
188
+ * The encoder is what keeps a runtime value inside its own segment: interpolated
189
+ * raw, `"../../account"` turned `DELETE /api/comments/{id}` into
190
+ * `DELETE /account` carrying the Bearer, and `"7?role=admin"` injected a query
191
+ * string (#499 — the class #438 closed for the data provider). Encoded, `/`, `?`,
192
+ * `#` and `%` stay in the segment, and `null`, `undefined`, `""`, `"."` and
193
+ * `".."` throw before the request is built.
186
194
  *
187
195
  * @param {string} path - The OpenAPI path key ("/users/{user-id}").
188
- * @param {Map<string, string>} declared - Original name → identifier of each of
189
- * the operation's `in: "path"` params.
196
+ * @param {Map<string, string>} declared - Original name → argument expression of
197
+ * each of the operation's `in: "path"` params: the identifier, or the
198
+ * identifier with a type assertion when its type is wider than
199
+ * `encodeIdSegment` accepts.
190
200
  * @returns {string} A TypeScript expression evaluating to the URL.
191
201
  * @throws {Error} When a placeholder is not a declared path parameter — there is
192
202
  * no variable it could safely map to.
@@ -202,7 +212,11 @@ export function urlTemplate(path, declared) {
202
212
  `OpenAPI path ${JSON.stringify(path)}: placeholder ${JSON.stringify(name)} is not a declared path parameter.`,
203
213
  );
204
214
  }
205
- out += templateText(path.slice(last, match.index)) + "${" + declared.get(name) + "}";
215
+ out +=
216
+ templateText(path.slice(last, match.index)) +
217
+ "${encodeIdSegment(" +
218
+ declared.get(name) +
219
+ ")}";
206
220
  last = match.index + match[0].length;
207
221
  }
208
222
  out += templateText(path.slice(last));
@@ -0,0 +1,120 @@
1
+ import ts from "typescript";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { encodeIdSegment } from "../../../src/data";
5
+ import { createApiClient } from "../../../src/http";
6
+ import { generate } from "./generate.mjs";
7
+
8
+ const BASE = "https://api.example.test";
9
+
10
+ /**
11
+ * Transpile one generated `service.ts` and load it as CommonJS, resolving
12
+ * `tempest-react-sdk` to the SDK's real source — so the call goes through the
13
+ * genuine `encodeIdSegment` and `createApiClient`, not a stub of either.
14
+ *
15
+ * @param {string} source - The generated `service.ts` contents.
16
+ * @returns {Record<string, unknown>} The module's exports.
17
+ */
18
+ function load(source) {
19
+ const { outputText } = ts.transpileModule(source, {
20
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
21
+ });
22
+ const module = { exports: {} };
23
+ const require = (id) => {
24
+ if (id === "tempest-react-sdk") return { encodeIdSegment };
25
+ throw new Error(`unexpected import ${id}`);
26
+ };
27
+ new Function("exports", "require", "module", outputText)(module.exports, require, module);
28
+ return module.exports;
29
+ }
30
+
31
+ /**
32
+ * Generate a one-route `comments` service (`GET`/`DELETE /api/comments/{id}`)
33
+ * bound to a real `ApiClient` whose `fetcher` records every request.
34
+ *
35
+ * @param {object} [schema] - The `id` path parameter's schema.
36
+ * @param {string} [baseURL] - The client's base URL.
37
+ * @returns {{ service: Record<string, Function>, seen: string[], source: string }}
38
+ */
39
+ function commentsService(schema = { type: "string" }, baseURL = BASE) {
40
+ const op = (operationId) => ({
41
+ operationId,
42
+ tags: ["comments"],
43
+ parameters: [{ name: "id", in: "path", required: true, schema }],
44
+ responses: { 204: { description: "x" } },
45
+ });
46
+ const { files } = generate({
47
+ openapi: "3.1.0",
48
+ info: { title: "t", version: "1" },
49
+ paths: { "/api/comments/{id}": { get: op("getComment"), delete: op("deleteComment") } },
50
+ });
51
+ const source = files["comments/service.ts"];
52
+ const seen = [];
53
+ const api = createApiClient({
54
+ baseURL,
55
+ getToken: () => "DUMMY_TOKEN",
56
+ fetcher: async (url, init) => {
57
+ seen.push(`${init?.method ?? "GET"} ${String(url)}`);
58
+ return new Response(null, { status: 204 });
59
+ },
60
+ });
61
+ const { CommentsService } = load(source);
62
+ return { service: new CommentsService(api), seen, source };
63
+ }
64
+
65
+ describe("generate — path params are encoded into one segment (#499, as #438 did for the data provider)", () => {
66
+ it.each([
67
+ ["../../account", "..%2F..%2Faccount"],
68
+ ["../x", "..%2Fx"],
69
+ ["%2e%2e/%2e%2e/account", "%252e%252e%2F%252e%252e%2Faccount"],
70
+ ["7?a=b", "7%3Fa%3Db"],
71
+ ["7?role=admin", "7%3Frole%3Dadmin"],
72
+ ["7#x", "7%23x"],
73
+ ])("a hostile id %j stays one segment under /api/comments", async (id, segment) => {
74
+ const { service, seen } = commentsService();
75
+ await service.deleteComment(id);
76
+ expect(seen).toEqual([`DELETE ${BASE}/api/comments/${segment}`]);
77
+ });
78
+
79
+ it("a base URL prefix cannot be escaped with extra dot-segments", async () => {
80
+ const { service, seen } = commentsService({ type: "string" }, `${BASE}/v1`);
81
+ await service.deleteComment("../../../account");
82
+ expect(seen).toEqual([`DELETE ${BASE}/v1/api/comments/..%2F..%2F..%2Faccount`]);
83
+ });
84
+
85
+ it.each(["", ".", ".."])("the dot-segment id %j throws before any request", async (id) => {
86
+ const { service, seen } = commentsService();
87
+ await expect(service.deleteComment(id)).rejects.toThrow(TypeError);
88
+ expect(seen).toEqual([]);
89
+ });
90
+
91
+ it("null and undefined from an untyped caller throw instead of becoming a literal segment", async () => {
92
+ const { service, seen } = commentsService();
93
+ await expect(service.getComment(null)).rejects.toThrow(TypeError);
94
+ await expect(service.getComment(undefined)).rejects.toThrow(TypeError);
95
+ expect(seen).toEqual([]);
96
+ });
97
+
98
+ it("a numeric id is still sent as its decimal segment", async () => {
99
+ const { service, seen, source } = commentsService({ type: "integer" });
100
+ expect(source).toContain("async getComment(id: number): Promise<void>");
101
+ await service.getComment(7);
102
+ expect(seen).toEqual([`GET ${BASE}/api/comments/7`]);
103
+ });
104
+
105
+ it.each([
106
+ ["a boolean", { type: "boolean" }, true, "true"],
107
+ ["an array", { type: "array", items: { type: "string" } }, ["a", "b/c"], "a%2Cb%2Fc"],
108
+ ["a nullable string", { type: ["string", "null"] }, "a/b", "a%2Fb"],
109
+ ])("%s path param is encoded too", async (_kind, schema, value, segment) => {
110
+ const { service, seen } = commentsService(schema);
111
+ await service.getComment(value);
112
+ expect(seen).toEqual([`GET ${BASE}/api/comments/${segment}`]);
113
+ });
114
+
115
+ it("a nullable path param still rejects null at runtime", async () => {
116
+ const { service, seen } = commentsService({ type: ["string", "null"] });
117
+ await expect(service.getComment(null)).rejects.toThrow(TypeError);
118
+ expect(seen).toEqual([]);
119
+ });
120
+ });
@@ -6,6 +6,7 @@ import vm from "node:vm";
6
6
  import ts from "typescript";
7
7
  import { describe, expect, it } from "vitest";
8
8
 
9
+ import { encodeIdSegment } from "../../../src/data";
9
10
  import { generate } from "./generate.mjs";
10
11
 
11
12
  const require = createRequire(import.meta.url);
@@ -14,7 +15,9 @@ const require = createRequire(import.meta.url);
14
15
  *
15
16
  * The context gets its own `globalThis`, so any `globalThis.PWNED_*` an injected
16
17
  * statement sets lands on `context` and the test can read it back. `zod` is the
17
- * real package; `./schemas` resolves to the module evaluated before it.
18
+ * real package, `tempest-react-sdk` exposes the real `encodeIdSegment` every path
19
+ * parameter goes through (#499); `./schemas` resolves to the module evaluated
20
+ * before it.
18
21
  *
19
22
  * @param {string} source - The generated `.ts` contents.
20
23
  * @param {object} context - The VM context shared by the group's files.
@@ -33,7 +36,8 @@ function evaluate(source, context, modules) {
33
36
  const module = { exports: {} };
34
37
  const localRequire = (id) => {
35
38
  if (id in modules) return modules[id];
36
- if (id === "zod" || id === "tempest-react-sdk") return id === "zod" ? require("zod") : {};
39
+ if (id === "zod") return require("zod");
40
+ if (id === "tempest-react-sdk") return { encodeIdSegment };
37
41
  throw new Error(`unexpected import ${id}`);
38
42
  };
39
43
  vm.runInContext(`(function (exports, require, module) {\n${outputText}\n})`, context)(
@@ -398,7 +402,7 @@ const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
398
402
  *
399
403
  * Files live in a virtual folder under the repository so `zod` resolves from its
400
404
  * `node_modules`; `tempest-react-sdk` is swapped for a stub with the three types
401
- * the generator imports from it.
405
+ * and the `encodeIdSegment` value the generator imports from it.
402
406
  *
403
407
  * @param {Record<string, string>} files - The generator output.
404
408
  * @param {string} slug - The group folder.
@@ -409,7 +413,7 @@ function typecheck(files, slug) {
409
413
  const virtual = new Map(
410
414
  ["schemas.ts", "types.ts", "service.ts"].map((f) => [
411
415
  join(root, f),
412
- files[`${slug}/${f}`].replace('from "tempest-react-sdk"', 'from "./sdk"'),
416
+ files[`${slug}/${f}`].replaceAll('from "tempest-react-sdk"', 'from "./sdk"'),
413
417
  ]),
414
418
  );
415
419
  virtual.set(
@@ -419,6 +423,7 @@ function typecheck(files, slug) {
419
423
  "export type ApiClient = { get: Call; post: Call; put: Call; patch: Call; delete: Call };",
420
424
  "export type OffsetPage<T> = { items: T[]; total: number };",
421
425
  "export type CursorPage<T> = { items: T[]; next_cursor: string | null };",
426
+ "export declare function encodeIdSegment(id: string | number): string;",
422
427
  ].join("\n"),
423
428
  );
424
429
  const options = {
@@ -451,11 +456,11 @@ const JSON_BODY = (ref) => ({
451
456
  const JSON_OK = (ref) => ({ 200: JSON_BODY(ref) });
452
457
 
453
458
  describe("generate — names that collide with what the generator itself declares", () => {
454
- it("a path parameter named body, params or S is renamed, not duplicated or shadowing", async () => {
459
+ it("a path parameter named body, params, S or encodeIdSegment is renamed, not duplicated or shadowing", async () => {
455
460
  const { files } = generate(
456
461
  spec(
457
462
  {
458
- "/a/{body}/{params}/{S}": {
463
+ "/a/{body}/{params}/{S}/{encodeIdSegment}": {
459
464
  post: {
460
465
  tags: ["t"],
461
466
  operationId: "send",
@@ -463,6 +468,7 @@ describe("generate — names that collide with what the generator itself declare
463
468
  { name: "body", in: "path", required: true },
464
469
  { name: "params", in: "path", required: true },
465
470
  { name: "S", in: "path", required: true },
471
+ { name: "encodeIdSegment", in: "path", required: true },
466
472
  { name: "q", in: "query" },
467
473
  ],
468
474
  requestBody: JSON_BODY("Req"),
@@ -479,8 +485,11 @@ describe("generate — names that collide with what the generator itself declare
479
485
  const mod = evaluate(files["t/service.ts"], context, { "./schemas": S });
480
486
  const calls = [];
481
487
  const api = { post: async (url, opts) => calls.push({ url, opts }) };
482
- await new mod.TService(api).send("b", "p", "s", { k: 1 }, { q: "x" });
483
- expect(calls).toEqual([{ url: "/a/b/p/s", opts: { body: { k: 1 }, params: { q: "x" } } }]);
488
+ await new mod.TService(api).send("b", "p", "s", "e/x", { k: 1 }, { q: "x" });
489
+ expect(files["t/service.ts"]).toContain("${encodeIdSegment(encodeIdSegmentParam)}");
490
+ expect(calls).toEqual([
491
+ { url: "/a/b/p/s/e%2Fx", opts: { body: { k: 1 }, params: { q: "x" } } },
492
+ ]);
484
493
  });
485
494
 
486
495
  it("an operationId of constructor or api becomes a distinct method", async () => {
@@ -541,7 +550,16 @@ describe("generate — names that collide with what the generator itself declare
541
550
  size: { type: "integer" },
542
551
  },
543
552
  };
544
- const names = ["ApiClient", "OffsetPage", "CursorPage", "Promise", "Record", "z", "S"];
553
+ const names = [
554
+ "ApiClient",
555
+ "OffsetPage",
556
+ "CursorPage",
557
+ "Promise",
558
+ "Record",
559
+ "z",
560
+ "S",
561
+ "encodeIdSegment",
562
+ ];
545
563
  const paths = Object.fromEntries(
546
564
  names.map((n, i) => [
547
565
  `/n${i}`,
@@ -556,10 +574,21 @@ describe("generate — names that collide with what the generator itself declare
556
574
  },
557
575
  },
558
576
  };
577
+ paths["/p/{id}"] = {
578
+ get: {
579
+ tags: ["t"],
580
+ parameters: [{ name: "id", in: "path", required: true }],
581
+ responses: OK,
582
+ },
583
+ };
559
584
  const schemas = Object.fromEntries(names.map((n) => [n, { type: "object" }]));
560
585
  schemas.Page = page;
561
586
  const { files } = generate(spec(paths, schemas));
562
587
  expect(files["t/service.ts"]).toContain("OffsetPage<_Promise>");
588
+ expect(files["t/service.ts"]).toContain(
589
+ 'import { encodeIdSegment } from "tempest-react-sdk";',
590
+ );
591
+ expect(files["t/types.ts"]).toContain("export type _encodeIdSegment =");
563
592
  expect(typecheck(files, "t")).toEqual([]);
564
593
  });
565
594
 
@@ -51,11 +51,34 @@ const CLASS_MEMBERS = ["constructor", "api"];
51
51
 
52
52
  /**
53
53
  * Names a generated method body uses besides its path parameters: the `body` and
54
- * `params` arguments and the `S` schemas import. A path parameter that maps to one
55
- * of them becomes `<name>Param` (`{body}` → `bodyParam`), so it neither duplicates
56
- * an argument nor shadows the import.
54
+ * `params` arguments, the `S` schemas import and the `encodeIdSegment` import. A
55
+ * path parameter that maps to one of them becomes `<name>Param` (`{body}` →
56
+ * `bodyParam`), so it neither duplicates an argument nor shadows the import.
57
57
  */
58
- const METHOD_LOCALS = new Set(["body", "params", "S"]);
58
+ const METHOD_LOCALS = new Set(["body", "params", "S", "encodeIdSegment"]);
59
+
60
+ /**
61
+ * Whether a path parameter's schema only ever produces a `string` or a `number` —
62
+ * the types `encodeIdSegment(id: string | number)` accepts — so the generated call
63
+ * can pass the argument as is.
64
+ *
65
+ * `$ref` chains are followed, and `anyOf`/`oneOf`/single-member `allOf` qualify
66
+ * when every branch does. `null`, `boolean`, arrays, objects and anything
67
+ * unrecognised do not.
68
+ *
69
+ * @param {object | undefined} schema - The parameter's schema.
70
+ * @param {Record<string, object>} schemas - `components.schemas`.
71
+ * @param {number} [depth] - Recursion guard for self-referencing unions.
72
+ * @returns {boolean} `true` when the TypeScript type is assignable to `string | number`.
73
+ */
74
+ function isSegmentScalar(schema, schemas, depth = 0) {
75
+ const node = resolveSchema(schema, schemas);
76
+ if (!node || typeof node !== "object" || depth > 16) return false;
77
+ const branches = node.anyOf ?? node.oneOf ?? (node.allOf?.length === 1 ? node.allOf : null);
78
+ if (branches) return branches.every((b) => isSegmentScalar(b, schemas, depth + 1));
79
+ if (node.nullable || Array.isArray(node.type)) return false;
80
+ return node.type === "string" || node.type === "integer" || node.type === "number";
81
+ }
59
82
 
60
83
  /**
61
84
  * Check every `pattern` in a schema compiles as a JavaScript regex.
@@ -311,8 +334,13 @@ export function generate(doc) {
311
334
  const sdkTypes = ["ApiClient"];
312
335
  if (body.includes("OffsetPage<")) sdkTypes.push("OffsetPage");
313
336
  if (body.includes("CursorPage<")) sdkTypes.push("CursorPage");
337
+ const sdkValues = body.includes("encodeIdSegment(")
338
+ ? `import { encodeIdSegment } from "tempest-react-sdk";\n`
339
+ : "";
314
340
  files[`${slug}/service.ts`] =
315
- `import type { ${sdkTypes.join(", ")} } from "tempest-react-sdk";\n\n` +
341
+ `import type { ${sdkTypes.join(", ")} } from "tempest-react-sdk";\n` +
342
+ sdkValues +
343
+ "\n" +
316
344
  schemaImport +
317
345
  typeImport +
318
346
  `\n/** Generated service for the "${commentText(tag)}" routes. Inject an ApiClient (createApiClient). */\n` +
@@ -353,6 +381,13 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
353
381
  return METHOD_LOCALS.has(id) ? `${id}Param` : id;
354
382
  };
355
383
  const paramIds = new Map(pathParams.map((p) => [p.name, paramId(p.name)]));
384
+ const segmentArgs = new Map(
385
+ pathParams.map((p) => {
386
+ const id = paramIds.get(p.name);
387
+ const schema = p.schema ?? { type: "string" };
388
+ return [p.name, isSegmentScalar(schema, schemas) ? id : `${id} as never`];
389
+ }),
390
+ );
356
391
  assertNoCollision(
357
392
  [
358
393
  ...[...paramIds],
@@ -389,7 +424,7 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
389
424
  args.push(`params: { ${q} }`);
390
425
  }
391
426
 
392
- const url = urlTemplate(path, paramIds);
427
+ const url = urlTemplate(path, segmentArgs);
393
428
 
394
429
  const callOpts = [];
395
430
  if (body) callOpts.push("body");
@@ -122,8 +122,13 @@ describe("generate — FastAPI-style spec", () => {
122
122
  // create with Zod input validation
123
123
  expect(svc).toContain("S.UserCreateSchema.parse(body);");
124
124
  expect(svc).toMatch(/async createUser\(body: UserCreate\): Promise<User>/);
125
- // path param interpolation
126
125
  expect(svc).toMatch(/async getUser\(id: number\): Promise<User>/);
127
- expect(svc).toContain("return this.api.get<User>(`/users/${id}`);");
126
+ });
127
+
128
+ it("wraps every path param in encodeIdSegment — the raw `${id}` this used to pin sent '../../account' to /account with the Bearer (#499, the class #438 closed)", () => {
129
+ const svc = files["users/service.ts"];
130
+ expect(svc).not.toContain("`/users/${id}`");
131
+ expect(svc).toContain("return this.api.get<User>(`/users/${encodeIdSegment(id)}`);");
132
+ expect(svc).toContain('import { encodeIdSegment } from "tempest-react-sdk";');
128
133
  });
129
134
  });
@@ -14,9 +14,10 @@ export function refName(ref) {
14
14
 
15
15
  /**
16
16
  * Names the generated files declare, import or rely on as globals next to the
17
- * schema types: the SDK types `service.ts` imports, the `z` and `S` imports, and
18
- * the `Promise` / `Record` globals the emitted signatures use. A schema with one
19
- * of these names would collide with the import or shadow the global.
17
+ * schema types: the SDK types and the `encodeIdSegment` value `service.ts`
18
+ * imports, the `z` and `S` imports, and the `Promise` / `Record` globals the
19
+ * emitted signatures use. A schema with one of these names would collide with
20
+ * the import or shadow the global.
20
21
  */
21
22
  export const GENERATED_NAMES = new Set([
22
23
  "ApiClient",
@@ -25,6 +26,7 @@ export const GENERATED_NAMES = new Set([
25
26
  "Promise",
26
27
  "Record",
27
28
  "S",
29
+ "encodeIdSegment",
28
30
  "z",
29
31
  ]);
30
32
 
@@ -0,0 +1,188 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
17
+
18
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), "tempest.mjs");
19
+
20
+ const SPEC = {
21
+ openapi: "3.0.0",
22
+ info: { title: "t", version: "1" },
23
+ paths: {
24
+ "/users": {
25
+ get: {
26
+ tags: ["users"],
27
+ operationId: "listUsers",
28
+ responses: { 200: { description: "ok" } },
29
+ },
30
+ },
31
+ },
32
+ };
33
+
34
+ /**
35
+ * Every file under `dir`, relative, sorted — `[]` when nothing was written.
36
+ *
37
+ * @param {string} dir - Absolute folder.
38
+ * @returns {string[]}
39
+ */
40
+ function filesUnder(dir) {
41
+ return readdirSync(dir, { recursive: true }).sort();
42
+ }
43
+
44
+ describe("tempest gen api / gen icons — write containment (#484)", () => {
45
+ let cwd;
46
+ let outside;
47
+
48
+ beforeEach(() => {
49
+ cwd = mkdtempSync(join(tmpdir(), "tempest-gen-cli-"));
50
+ outside = mkdtempSync(join(tmpdir(), "tempest-gen-outside-"));
51
+ mkdirSync(join(cwd, "src"));
52
+ writeFileSync(join(cwd, "spec.json"), JSON.stringify(SPEC));
53
+ });
54
+
55
+ afterEach(() => {
56
+ rmSync(cwd, { recursive: true, force: true });
57
+ rmSync(outside, { recursive: true, force: true });
58
+ });
59
+
60
+ /**
61
+ * Run the `tempest` CLI in the temporary project.
62
+ *
63
+ * @param {string[]} args - CLI arguments.
64
+ * @returns {{ status: number | null, out: string }}
65
+ */
66
+ function tempest(...args) {
67
+ const result = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8" });
68
+ return { status: result.status, out: result.stdout + result.stderr };
69
+ }
70
+
71
+ it("gen api still writes into a regular src/api", () => {
72
+ const { status } = tempest("gen", "api", "spec.json");
73
+ expect(status).toBe(0);
74
+ expect(existsSync(join(cwd, "src", "api", "users", "service.ts"))).toBe(true);
75
+ });
76
+
77
+ it("gen api refuses a src/api that is a symlink leaving the project", () => {
78
+ symlinkSync(outside, join(cwd, "src", "api"));
79
+
80
+ const { status, out } = tempest("gen", "api", "spec.json");
81
+
82
+ expect(status).toBe(1);
83
+ expect(out).toMatch(/src\/api is a symlink/);
84
+ expect(filesUnder(outside)).toEqual([]);
85
+ });
86
+
87
+ it("gen api refuses a symlinked --out even when it points inside the project", () => {
88
+ mkdirSync(join(cwd, "elsewhere"));
89
+ symlinkSync(join(cwd, "elsewhere"), join(cwd, "src", "api"));
90
+
91
+ const { status, out } = tempest("gen", "api", "spec.json");
92
+
93
+ expect(status).toBe(1);
94
+ expect(out).toMatch(/symlink/);
95
+ expect(filesUnder(join(cwd, "elsewhere"))).toEqual([]);
96
+ });
97
+
98
+ it("gen api refuses a dangling symlink, which writeFile would follow and create", () => {
99
+ symlinkSync(join(outside, "not-yet"), join(cwd, "src", "api"));
100
+
101
+ const { status, out } = tempest("gen", "api", "spec.json");
102
+
103
+ expect(status).toBe(1);
104
+ expect(out).toMatch(/symlink/);
105
+ expect(existsSync(join(outside, "not-yet"))).toBe(false);
106
+ });
107
+
108
+ it("gen api refuses a symlinked route-group folder below --out, before writing anything", () => {
109
+ mkdirSync(join(cwd, "src", "api"));
110
+ symlinkSync(outside, join(cwd, "src", "api", "users"));
111
+
112
+ const { status, out } = tempest("gen", "api", "spec.json");
113
+
114
+ expect(status).toBe(1);
115
+ expect(out).toMatch(/src\/api\/users is a symlink/);
116
+ expect(filesUnder(outside)).toEqual([]);
117
+ expect(filesUnder(join(cwd, "src", "api"))).toEqual(["users"]);
118
+ });
119
+
120
+ it("gen api refuses a generated file that is a symlink", () => {
121
+ mkdirSync(join(cwd, "src", "api"));
122
+ const victim = join(outside, "victim.ts");
123
+ writeFileSync(victim, "SENTINEL");
124
+ symlinkSync(victim, join(cwd, "src", "api", "index.ts"));
125
+
126
+ const { status, out } = tempest("gen", "api", "spec.json");
127
+
128
+ expect(status).toBe(1);
129
+ expect(out).toMatch(/src\/api\/index\.ts is a symlink/);
130
+ expect(readFileSync(victim, "utf8")).toBe("SENTINEL");
131
+ });
132
+
133
+ it("gen api refuses an --out whose existing ancestor is a symlink leaving the project", () => {
134
+ symlinkSync(outside, join(cwd, "gen"));
135
+
136
+ const { status, out } = tempest("gen", "api", "spec.json", "--out", "gen/api");
137
+
138
+ expect(status).toBe(1);
139
+ expect(out).toMatch(/outside the project/);
140
+ expect(filesUnder(outside)).toEqual([]);
141
+ });
142
+
143
+ it("gen api refuses an --out outside the project", () => {
144
+ const { status, out } = tempest("gen", "api", "spec.json", "--out", outside);
145
+ expect(status).toBe(1);
146
+ expect(out).toMatch(/outside the project/);
147
+ expect(filesUnder(outside)).toEqual([]);
148
+ });
149
+
150
+ it("gen icons refuses a src/icons.generated.ts that is a symlink", () => {
151
+ const victim = join(outside, "victim.ts");
152
+ writeFileSync(victim, "SENTINEL");
153
+ symlinkSync(victim, join(cwd, "src", "icons.generated.ts"));
154
+
155
+ const { status, out } = tempest("gen", "icons");
156
+
157
+ expect(status).toBe(1);
158
+ expect(out).toMatch(/src\/icons\.generated\.ts is a symlink/);
159
+ expect(readFileSync(victim, "utf8")).toBe("SENTINEL");
160
+ });
161
+
162
+ it("gen icons refuses a dangling symlink at the output", () => {
163
+ symlinkSync(join(outside, "not-yet.ts"), join(cwd, "src", "icons.generated.ts"));
164
+
165
+ const { status, out } = tempest("gen", "icons");
166
+
167
+ expect(status).toBe(1);
168
+ expect(out).toMatch(/symlink/);
169
+ expect(existsSync(join(outside, "not-yet.ts"))).toBe(false);
170
+ });
171
+
172
+ it("gen icons refuses an --out whose folder is a symlink leaving the project", () => {
173
+ symlinkSync(outside, join(cwd, "gen"));
174
+
175
+ const { status, out } = tempest("gen", "icons", "--out", "gen/icons.ts");
176
+
177
+ expect(status).toBe(1);
178
+ expect(out).toMatch(/gen is a symlink/);
179
+ expect(filesUnder(outside)).toEqual([]);
180
+ });
181
+
182
+ it("gen icons refuses an --out outside the project", () => {
183
+ const { status, out } = tempest("gen", "icons", "--out", join(outside, "icons.ts"));
184
+ expect(status).toBe(1);
185
+ expect(out).toMatch(/outside the project/);
186
+ expect(filesUnder(outside)).toEqual([]);
187
+ });
188
+ });