@contractkit/plugin-typescript 0.28.0 → 0.28.1

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 (40) hide show
  1. package/.turbo/turbo-build$colon$ci.log +4 -4
  2. package/.turbo/turbo-test$colon$ci.log +19 -18
  3. package/CHANGELOG.md +8 -0
  4. package/dist/codegen-contract.d.ts.map +1 -1
  5. package/dist/codegen-sdk.d.ts.map +1 -1
  6. package/dist/index.js +234 -210
  7. package/dist/index.js.map +1 -1
  8. package/dist/path-utils.d.ts.map +1 -1
  9. package/dist/ts-render.d.ts +6 -0
  10. package/dist/ts-render.d.ts.map +1 -1
  11. package/package.json +2 -2
  12. package/src/codegen-contract.ts +7 -4
  13. package/src/codegen-operation.ts +4 -4
  14. package/src/codegen-plain-types.ts +17 -14
  15. package/src/codegen-sdk.ts +5 -4
  16. package/src/path-utils.ts +37 -20
  17. package/src/ts-render.ts +21 -6
  18. package/tests/codegen-contract.test.ts +4 -0
  19. package/tests/codegen-sdk.test.ts +8 -0
  20. package/tests/escaping-security.test.ts +143 -0
  21. package/coverage/base.css +0 -224
  22. package/coverage/block-navigation.js +0 -87
  23. package/coverage/clover.xml +0 -2213
  24. package/coverage/coverage-final.json +0 -9
  25. package/coverage/favicon.png +0 -0
  26. package/coverage/index.html +0 -131
  27. package/coverage/prettify.css +0 -1
  28. package/coverage/prettify.js +0 -2
  29. package/coverage/sort-arrow-sprite.png +0 -0
  30. package/coverage/sorter.js +0 -210
  31. package/coverage/src/codegen-contract.ts.html +0 -3661
  32. package/coverage/src/codegen-operation.ts.html +0 -2584
  33. package/coverage/src/codegen-plain-types.ts.html +0 -997
  34. package/coverage/src/codegen-sdk.ts.html +0 -3901
  35. package/coverage/src/index.html +0 -206
  36. package/coverage/src/index.ts.html +0 -2761
  37. package/coverage/src/path-utils.ts.html +0 -745
  38. package/coverage/src/ts-render.ts.html +0 -592
  39. package/coverage/tests/helpers.ts.html +0 -826
  40. package/coverage/tests/index.html +0 -116
@@ -1 +1 @@
1
- {"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAEzC,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBnH;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
1
+ {"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAmBzC,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBnH;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
@@ -1,6 +1,12 @@
1
1
  import type { ContractTypeNode } from '@contractkit/core';
2
2
  export declare const JSON_VALUE_TYPE_DECL = "export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };";
3
3
  export declare function quoteKey(name: string): string;
4
+ /** Escape text for safe inclusion inside a JSDoc block comment: neutralize the
5
+ * block-comment terminator sequence and split embedded newlines into separate
6
+ * ` * ` continuation lines. Returns the content lines (WITHOUT a leading prefix). */
7
+ export declare function escapeJsDocLines(text: string): string[];
8
+ /** Escape a string for inclusion inside a single-quoted TypeScript string literal. */
9
+ export declare function escapeSingleQuoted(s: string): string;
4
10
  /** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
5
11
  export declare function headerNameToProperty(name: string): string;
6
12
  export declare function renderTsType(type: ContractTypeNode): string;
@@ -1 +1 @@
1
- {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAa,MAAM,mBAAmB,CAAC;AAErE,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAoC3D;AA4CD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2B/F;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2BjG"}
1
+ {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAoC3D;AA+CD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2B/F;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2BjG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "author": {
6
6
  "name": "Marooned Software",
@@ -26,7 +26,7 @@
26
26
  ".": "./dist/index.js"
27
27
  },
28
28
  "dependencies": {
29
- "@contractkit/core": "0.22.0"
29
+ "@contractkit/core": "0.23.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@repo/config-eslint": "0.3.1",
@@ -21,6 +21,7 @@ import {
21
21
  computeModelsWithOutput as ckComputeModelsWithOutput,
22
22
  collectExternalOutputRefs as ckCollectExternalOutputRefs,
23
23
  } from '@contractkit/core';
24
+ import { escapeJsDocLines } from './ts-render.js';
24
25
 
25
26
  /**
26
27
  * Maps a ContractKit object mode to its Zod constructor name.
@@ -108,7 +109,7 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
108
109
  lines.push(` * @deprecated`);
109
110
  }
110
111
  if (model.description) {
111
- lines.push(` * ${model.description}`);
112
+ for (const l of escapeJsDocLines(model.description)) lines.push(` * ${l}`);
112
113
  }
113
114
 
114
115
  const relPath = outPath ? relative(dirname(outPath), model.loc.file) : model.loc.file;
@@ -664,8 +665,10 @@ function renderScalar(s: ScalarTypeNode): string {
664
665
  return '_ZodBinary';
665
666
  case 'json':
666
667
  return '_ZodJson';
667
- default:
668
- return 'z.unknown()';
668
+ default: {
669
+ const _exhaustive: never = s.name;
670
+ throw new Error(`plugin-typescript: unmapped scalar '${String(_exhaustive)}' — add a case`);
671
+ }
669
672
  }
670
673
  }
671
674
 
@@ -685,7 +688,7 @@ function renderRecord(r: RecordTypeNode): string {
685
688
  }
686
689
 
687
690
  function renderEnum(e: EnumTypeNode): string {
688
- const vals = e.values.map(v => `"${v}"`).join(', ');
691
+ const vals = e.values.map(v => `"${escapeString(v)}"`).join(', ');
689
692
  return `z.enum([${vals}])`;
690
693
  }
691
694
 
@@ -9,7 +9,7 @@ import {
9
9
  typeNeedsScalar,
10
10
  modeToWrapper,
11
11
  } from './codegen-contract.js';
12
- import { renderOutputTsType, quoteKey, headerNameToProperty } from './ts-render.js';
12
+ import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted } from './ts-render.js';
13
13
  import { basename, dirname, relative } from 'path';
14
14
 
15
15
  // ─── Content-type helpers ──────────────────────────────────────────────────
@@ -217,7 +217,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
217
217
  // JSDoc from description
218
218
  const desc = op.description ?? route.description;
219
219
  if (desc) {
220
- lines.push(` * ${desc}`);
220
+ for (const l of escapeJsDocLines(desc)) lines.push(` * ${l}`);
221
221
  }
222
222
  // Source location comment
223
223
  const relFile = outPath ? relative(dirname(outPath), file) : file;
@@ -261,8 +261,8 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
261
261
  }
262
262
  if (op.signature) {
263
263
  const sigArgs = op.signaturePolicy
264
- ? `'${op.signature}', { policy: '${op.signaturePolicy}' }`
265
- : `'${op.signature}'`;
264
+ ? `'${escapeSingleQuoted(op.signature)}', { policy: '${escapeSingleQuoted(op.signaturePolicy)}' }`
265
+ : `'${escapeSingleQuoted(op.signature)}'`;
266
266
  middlewares.push(`requireSignature(${sigArgs})`);
267
267
  }
268
268
  const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
@@ -10,7 +10,7 @@ import {
10
10
  resolveImportPath,
11
11
  rootNeedsScalar,
12
12
  } from './codegen-contract.js';
13
- import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, JSON_VALUE_TYPE_DECL } from './ts-render.js';
13
+ import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
14
14
 
15
15
  // ─── Public entry point ────────────────────────────────────────────────────
16
16
 
@@ -123,7 +123,7 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
123
123
  lines.push(` * @deprecated`);
124
124
  }
125
125
  if (model.description) {
126
- lines.push(` * ${model.description}`);
126
+ for (const l of escapeJsDocLines(model.description)) lines.push(` * ${l}`);
127
127
  }
128
128
 
129
129
  const relPath = outPath ? relative(dirname(outPath), model.loc.file) : model.loc.file;
@@ -205,6 +205,18 @@ function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithI
205
205
 
206
206
  // ─── Field rendering ──────────────────────────────────────────────────────
207
207
 
208
+ /** Prefix a field declaration with a JSDoc comment built from `@deprecated` / description parts,
209
+ * neutralizing any block-comment terminator and expanding embedded newlines into continuation lines. */
210
+ function withFieldJsDoc(jsdocParts: string[], line: string): string {
211
+ if (jsdocParts.length === 0) return line;
212
+ const contentLines = escapeJsDocLines(jsdocParts.join(' '));
213
+ if (contentLines.length === 1) {
214
+ return `/** ${contentLines[0]} */\n ${line}`;
215
+ }
216
+ const body = contentLines.map(l => ` * ${l}`).join('\n');
217
+ return `/**\n${body}\n */\n ${line}`;
218
+ }
219
+
208
220
  function renderField(field: FieldNode): string {
209
221
  const opt = field.optional || field.default !== undefined ? '?' : '';
210
222
  let typeStr = renderTsType(field.type);
@@ -213,10 +225,7 @@ function renderField(field: FieldNode): string {
213
225
  const jsdocParts: string[] = [];
214
226
  if (field.deprecated) jsdocParts.push('@deprecated');
215
227
  if (field.description) jsdocParts.push(field.description);
216
- if (jsdocParts.length > 0) {
217
- return `/** ${jsdocParts.join(' ')} */\n ${line}`;
218
- }
219
- return line;
228
+ return withFieldJsDoc(jsdocParts, line);
220
229
  }
221
230
 
222
231
  function renderInputField(field: FieldNode, modelsWithInput: Set<string>): string {
@@ -227,10 +236,7 @@ function renderInputField(field: FieldNode, modelsWithInput: Set<string>): strin
227
236
  const jsdocParts: string[] = [];
228
237
  if (field.deprecated) jsdocParts.push('@deprecated');
229
238
  if (field.description) jsdocParts.push(field.description);
230
- if (jsdocParts.length > 0) {
231
- return `/** ${jsdocParts.join(' ')} */\n ${line}`;
232
- }
233
- return line;
239
+ return withFieldJsDoc(jsdocParts, line);
234
240
  }
235
241
 
236
242
  // ─── Output (post-transform wire shape) ──────────────────────────────────
@@ -297,8 +303,5 @@ function renderOutputField(field: FieldNode, outputCase: 'camel' | 'snake' | 'pa
297
303
  const jsdocParts: string[] = [];
298
304
  if (field.deprecated) jsdocParts.push('@deprecated');
299
305
  if (field.description) jsdocParts.push(field.description);
300
- if (jsdocParts.length > 0) {
301
- return `/** ${jsdocParts.join(' ')} */\n ${line}`;
302
- }
303
- return line;
306
+ return withFieldJsDoc(jsdocParts, line);
304
307
  }
@@ -1,6 +1,6 @@
1
1
  import type { OpRootNode, OpRouteNode, OpOperationNode, OpRequestBodyNode, ContractTypeNode, ParamSource } from '@contractkit/core';
2
2
  import { resolveModifiers, isJsonMime, classifyContentType } from '@contractkit/core';
3
- import { renderInputTsType, renderOutputTsType, quoteKey, headerNameToProperty, JSON_VALUE_TYPE_DECL } from './ts-render.js';
3
+ import { renderInputTsType, renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
4
4
  import { pascalToDotCase, typeNeedsScalar } from './codegen-contract.js';
5
5
  import { bodyTypesStructurallyEqual } from './codegen-operation.js';
6
6
  import { basename, dirname, relative } from 'path';
@@ -289,11 +289,12 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
289
289
  const tags: string[] = [];
290
290
  if (op.name) tags.push(`@name ${op.name}`);
291
291
  if (desc) tags.push(`@description ${desc}`);
292
- if (tags.length === 1) {
293
- lines.push(` /** ${tags[0]} */`);
292
+ const contentLines = tags.flatMap(t => escapeJsDocLines(t));
293
+ if (contentLines.length === 1) {
294
+ lines.push(` /** ${contentLines[0]} */`);
294
295
  } else {
295
296
  lines.push(` /**`);
296
- for (const tag of tags) lines.push(` * ${tag}`);
297
+ for (const l of contentLines) lines.push(` * ${l}`);
297
298
  lines.push(` */`);
298
299
  }
299
300
  }
package/src/path-utils.ts CHANGED
@@ -1,9 +1,26 @@
1
- import { resolve, join, relative, dirname } from 'node:path';
1
+ import { resolve, join, relative, dirname, isAbsolute } from 'node:path';
2
2
  import type { ContractRootNode, OpRootNode } from '@contractkit/core';
3
3
  import { collectTypeRefs, collectPublicTypeNames } from '@contractkit/core';
4
4
 
5
5
  export const TEMPLATE_VAR_RE = /\{\w+\}/;
6
6
 
7
+ /**
8
+ * Guard against path traversal in output-path templates. Output-path template variables
9
+ * (`{area}`, `{dir}`, `{filename}`, `{name}`) can be sourced from a `.ck` file's
10
+ * `options { keys }` block, so a malicious value like `../../../tmp/x` could escape the
11
+ * plugin's output directory. After the final absolute path is computed, verify it stays
12
+ * within `baseOutDir`; otherwise throw.
13
+ */
14
+ function assertWithinBase(baseOutDir: string, outPath: string): string {
15
+ const rel = relative(resolve(baseOutDir), resolve(outPath));
16
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
17
+ throw new Error(
18
+ `Refusing to emit outside output directory: resolved path "${outPath}" escapes "${baseOutDir}" (check options { keys } values used in output path templates)`,
19
+ );
20
+ }
21
+ return outPath;
22
+ }
23
+
7
24
  export function resolveTemplate(template: string, vars: Record<string, string>): string {
8
25
  return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
9
26
  }
@@ -47,14 +64,14 @@ export function computeOpOutPath(
47
64
 
48
65
  if (output && TEMPLATE_VAR_RE.test(output)) {
49
66
  const resolved = resolveTemplate(output, { filename, dir: relDir, ext: 'ck', ...meta });
50
- if (includesFilename(resolved)) return join(baseOutDir, resolved);
51
- return join(baseOutDir, resolved, defaultName);
67
+ if (includesFilename(resolved)) return assertWithinBase(baseOutDir, join(baseOutDir, resolved));
68
+ return assertWithinBase(baseOutDir, join(baseOutDir, resolved, defaultName));
52
69
  }
53
70
  if (output) {
54
- if (includesFilename(output)) return join(baseOutDir, output);
55
- return join(baseOutDir, output, relDir, defaultName);
71
+ if (includesFilename(output)) return assertWithinBase(baseOutDir, join(baseOutDir, output));
72
+ return assertWithinBase(baseOutDir, join(baseOutDir, output, relDir, defaultName));
56
73
  }
57
- return join(baseOutDir, relDir, defaultName);
74
+ return assertWithinBase(baseOutDir, join(baseOutDir, relDir, defaultName));
58
75
  }
59
76
 
60
77
  export function computeContractOutPath(
@@ -86,14 +103,14 @@ export function computeSdkOutPath(
86
103
 
87
104
  if (clientOutput && TEMPLATE_VAR_RE.test(clientOutput)) {
88
105
  const resolved = resolveTemplate(clientOutput, { filename, dir: relDir, ext: 'ck', ...meta });
89
- if (includesFilename(resolved)) return join(baseOutDir, resolved);
90
- return join(baseOutDir, resolved, defaultOutName);
106
+ if (includesFilename(resolved)) return assertWithinBase(baseOutDir, join(baseOutDir, resolved));
107
+ return assertWithinBase(baseOutDir, join(baseOutDir, resolved, defaultOutName));
91
108
  }
92
109
  if (clientOutput) {
93
- if (includesFilename(clientOutput)) return join(baseOutDir, clientOutput);
94
- return join(baseOutDir, clientOutput, relDir, defaultOutName);
110
+ if (includesFilename(clientOutput)) return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput));
111
+ return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput, relDir, defaultOutName));
95
112
  }
96
- return join(baseOutDir, relDir, defaultOutName);
113
+ return assertWithinBase(baseOutDir, join(baseOutDir, relDir, defaultOutName));
97
114
  }
98
115
 
99
116
  /**
@@ -118,14 +135,14 @@ export function computeSdkAreaClientOutPath(area: string, rootDir: string, clien
118
135
  if (clientOutput && TEMPLATE_VAR_RE.test(clientOutput)) {
119
136
  const resolved = resolveTemplate(clientOutput, { filename, dir: '', ext: 'ck', area, subarea: '' });
120
137
  const cleaned = fixHiddenSegment(resolved.replace(/\/+/g, '/').replace(/^\//, ''));
121
- if (includesFilename(cleaned)) return join(baseOutDir, cleaned);
122
- return join(baseOutDir, cleaned, `${filename}.client.ts`);
138
+ if (includesFilename(cleaned)) return assertWithinBase(baseOutDir, join(baseOutDir, cleaned));
139
+ return assertWithinBase(baseOutDir, join(baseOutDir, cleaned, `${filename}.client.ts`));
123
140
  }
124
141
  if (clientOutput) {
125
- if (includesFilename(clientOutput)) return join(baseOutDir, clientOutput);
126
- return join(baseOutDir, clientOutput, `${filename}.client.ts`);
142
+ if (includesFilename(clientOutput)) return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput));
143
+ return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput, `${filename}.client.ts`));
127
144
  }
128
- return join(baseOutDir, `${filename}.client.ts`);
145
+ return assertWithinBase(baseOutDir, join(baseOutDir, `${filename}.client.ts`));
129
146
  }
130
147
 
131
148
  export function computeSdkTypeOutPath(
@@ -144,11 +161,11 @@ export function computeSdkTypeOutPath(
144
161
 
145
162
  if (TEMPLATE_VAR_RE.test(typeOutput)) {
146
163
  const resolved = resolveTemplate(typeOutput, { filename, dir: relDir, ext: 'ck', ...meta });
147
- if (includesFilename(resolved)) return join(baseOutDir, resolved);
148
- return join(baseOutDir, resolved, defaultOutName);
164
+ if (includesFilename(resolved)) return assertWithinBase(baseOutDir, join(baseOutDir, resolved));
165
+ return assertWithinBase(baseOutDir, join(baseOutDir, resolved, defaultOutName));
149
166
  }
150
- if (includesFilename(typeOutput)) return join(baseOutDir, typeOutput);
151
- return join(baseOutDir, typeOutput, relDir, defaultOutName);
167
+ if (includesFilename(typeOutput)) return assertWithinBase(baseOutDir, join(baseOutDir, typeOutput));
168
+ return assertWithinBase(baseOutDir, join(baseOutDir, typeOutput, relDir, defaultOutName));
152
169
  }
153
170
 
154
171
  export function generateBarrelFiles(contractPaths: string[]): { outPath: string; content: string }[] {
package/src/ts-render.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ContractTypeNode, FieldNode } from '@contractkit/core';
1
+ import type { ContractTypeNode, FieldNode, ScalarTypeNode } from '@contractkit/core';
2
2
 
3
3
  export const JSON_VALUE_TYPE_DECL = 'export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };';
4
4
 
@@ -6,6 +6,18 @@ export function quoteKey(name: string): string {
6
6
  return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `'${name}'`;
7
7
  }
8
8
 
9
+ /** Escape text for safe inclusion inside a JSDoc block comment: neutralize the
10
+ * block-comment terminator sequence and split embedded newlines into separate
11
+ * ` * ` continuation lines. Returns the content lines (WITHOUT a leading prefix). */
12
+ export function escapeJsDocLines(text: string): string[] {
13
+ return text.replace(/\*\//g, '*\\/').split('\n');
14
+ }
15
+
16
+ /** Escape a string for inclusion inside a single-quoted TypeScript string literal. */
17
+ export function escapeSingleQuoted(s: string): string {
18
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
19
+ }
20
+
9
21
  /** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
10
22
  export function headerNameToProperty(name: string): string {
11
23
  const parts = name.split(/[-_]/).filter(Boolean);
@@ -37,9 +49,9 @@ export function renderTsType(type: ContractTypeNode): string {
37
49
  case 'record':
38
50
  return `Record<${renderTsType(type.key)}, ${renderTsType(type.value)}>`;
39
51
  case 'enum':
40
- return type.values.map(v => `'${v}'`).join(' | ');
52
+ return type.values.map(v => `'${escapeSingleQuoted(v)}'`).join(' | ');
41
53
  case 'literal':
42
- return typeof type.value === 'string' ? `'${type.value}'` : String(type.value);
54
+ return typeof type.value === 'string' ? `'${escapeSingleQuoted(type.value)}'` : String(type.value);
43
55
  case 'union':
44
56
  return type.members.map(renderTsType).join(' | ');
45
57
  case 'discriminatedUnion':
@@ -57,7 +69,7 @@ export function renderTsType(type: ContractTypeNode): string {
57
69
  }
58
70
  }
59
71
 
60
- function renderTsScalar(name: string): string {
72
+ function renderTsScalar(name: ScalarTypeNode['name']): string {
61
73
  switch (name) {
62
74
  case 'string':
63
75
  case 'email':
@@ -72,6 +84,7 @@ function renderTsScalar(name: string): string {
72
84
  case 'boolean':
73
85
  return 'boolean';
74
86
  case 'date':
87
+ case 'time':
75
88
  case 'datetime':
76
89
  case 'duration':
77
90
  case 'interval':
@@ -86,8 +99,10 @@ function renderTsScalar(name: string): string {
86
99
  return 'Blob';
87
100
  case 'json':
88
101
  return 'JsonValue';
89
- default:
90
- return 'unknown';
102
+ default: {
103
+ const _exhaustive: never = name;
104
+ throw new Error(`plugin-typescript: unmapped scalar '${String(_exhaustive)}' — add a case`);
105
+ }
91
106
  }
92
107
  }
93
108
 
@@ -26,6 +26,10 @@ describe('renderType', () => {
26
26
  expect(renderType(scalarType('string'))).toBe('z.string()');
27
27
  });
28
28
 
29
+ it('throws on an unmapped scalar name', () => {
30
+ expect(() => renderType({ kind: 'scalar', name: 'decimal' } as any)).toThrow(/unmapped scalar 'decimal'/);
31
+ });
32
+
29
33
  it('renders z.string() with min/max', () => {
30
34
  expect(renderType(scalarType('string', { min: 1, max: 100 }))).toBe('z.string().min(1).max(100)');
31
35
  });
@@ -1110,6 +1110,14 @@ describe('renderTsType', () => {
1110
1110
  it('maps json to JsonValue', () => {
1111
1111
  expect(renderTsType(scalarType('json'))).toBe('JsonValue');
1112
1112
  });
1113
+
1114
+ it('maps time to string', () => {
1115
+ expect(renderTsType(scalarType('time'))).toBe('string');
1116
+ });
1117
+
1118
+ it('throws on an unmapped scalar name', () => {
1119
+ expect(() => renderTsType({ kind: 'scalar', name: 'decimal' } as any)).toThrow(/unmapped scalar 'decimal'/);
1120
+ });
1113
1121
  });
1114
1122
 
1115
1123
  it('renders tuple type', () => {
@@ -0,0 +1,143 @@
1
+ import { generateContract, renderType } from '../src/codegen-contract.js';
2
+ import { generatePlainTypes } from '../src/codegen-plain-types.js';
3
+ import { generateOp } from '../src/codegen-operation.js';
4
+ import { renderTsType, escapeJsDocLines, escapeSingleQuoted } from '../src/ts-render.js';
5
+ import { computeOpOutPath, computeSdkOutPath, computeSdkTypeOutPath, computeSdkAreaClientOutPath } from '../src/path-utils.js';
6
+ import { field, model, contractRoot, enumType, scalarType, opRoot, opRoute, opOperation, opResponse } from './helpers.js';
7
+
8
+ // ─── Fix 1: JSDoc comment injection via descriptions ────────────────────────
9
+
10
+ describe('JSDoc comment injection (descriptions)', () => {
11
+ it('neutralizes `*/` in a model description (Zod codegen)', () => {
12
+ const root = contractRoot([model('Widget', [field('id', scalarType('uuid'))], { description: 'closes */ early' })]);
13
+ const out = generateContract(root);
14
+ // The raw terminator must not appear inside the generated comment.
15
+ expect(out).not.toContain('closes */ early');
16
+ expect(out).toContain('closes *\\/ early');
17
+ });
18
+
19
+ it('splits a two-line model description into ` * ` continuation lines (Zod codegen)', () => {
20
+ const root = contractRoot([model('Widget', [field('id', scalarType('uuid'))], { description: 'line one\nline two' })]);
21
+ const out = generateContract(root);
22
+ expect(out).toContain(' * line one');
23
+ expect(out).toContain(' * line two');
24
+ });
25
+
26
+ it('neutralizes `*/` in a model description (plain-types codegen)', () => {
27
+ const root = contractRoot([model('Widget', [field('id', scalarType('uuid'))], { description: 'closes */ early' })]);
28
+ const out = generatePlainTypes(root);
29
+ expect(out).not.toContain('closes */ early');
30
+ expect(out).toContain('closes *\\/ early');
31
+ });
32
+
33
+ it('neutralizes `*/` in a field description (plain-types codegen)', () => {
34
+ const root = contractRoot([model('Widget', [field('id', scalarType('uuid'), { description: 'bad */ desc' })])]);
35
+ const out = generatePlainTypes(root);
36
+ expect(out).not.toContain('bad */ desc');
37
+ expect(out).toContain('bad *\\/ desc');
38
+ });
39
+
40
+ it('splits a two-line field description into a multi-line JSDoc block (plain-types codegen)', () => {
41
+ const root = contractRoot([model('Widget', [field('id', scalarType('uuid'), { description: 'first\nsecond' })])]);
42
+ const out = generatePlainTypes(root);
43
+ expect(out).toContain('first');
44
+ expect(out).toContain('* second');
45
+ // No premature close on the same physical line as content.
46
+ expect(out).not.toMatch(/first\nsecond/);
47
+ });
48
+
49
+ it('neutralizes `*/` in an operation description (server codegen)', () => {
50
+ const root = opRoot([opRoute('/x', [opOperation('get', { description: 'op */ desc', responses: [opResponse(200)] })])]);
51
+ const out = generateOp(root);
52
+ expect(out).not.toContain('op */ desc');
53
+ expect(out).toContain('op *\\/ desc');
54
+ });
55
+
56
+ it('escapeJsDocLines neutralizes terminators and splits newlines', () => {
57
+ expect(escapeJsDocLines('a */ b')).toEqual(['a *\\/ b']);
58
+ expect(escapeJsDocLines('a\nb')).toEqual(['a', 'b']);
59
+ });
60
+ });
61
+
62
+ // ─── Fix 2: enum / literal value escaping ───────────────────────────────────
63
+
64
+ describe('enum value escaping', () => {
65
+ it('escapes double and single quotes in a Zod enum', () => {
66
+ const out = renderType(enumType('a"b', "c'd"));
67
+ expect(out).toBe('z.enum(["a\\"b", "c\'d"])');
68
+ });
69
+
70
+ it('escapes single quotes in a TS union of enum values', () => {
71
+ const out = renderTsType(enumType('a"b', "c'd"));
72
+ expect(out).toBe("'a\"b' | 'c\\'d'");
73
+ });
74
+
75
+ it('escapes single quotes in a TS string literal', () => {
76
+ expect(renderTsType({ kind: 'literal', value: "it's" })).toBe("'it\\'s'");
77
+ });
78
+
79
+ it('escapeSingleQuoted escapes backslashes, quotes and newlines', () => {
80
+ expect(escapeSingleQuoted("a'b\\c\nd")).toBe("a\\'b\\\\c\\nd");
81
+ });
82
+ });
83
+
84
+ // ─── Fix 3: signature interpolation escaping ────────────────────────────────
85
+
86
+ describe('signature escaping (server codegen)', () => {
87
+ it('escapes a single quote in the signature value', () => {
88
+ const root = opRoot([opRoute('/x', [opOperation('get', { signature: "sig'v", responses: [opResponse(200)] })])]);
89
+ const out = generateOp(root);
90
+ expect(out).toContain("requireSignature('sig\\'v')");
91
+ });
92
+
93
+ it('escapes single quotes in signature and policy', () => {
94
+ const root = opRoot([
95
+ opRoute('/x', [opOperation('get', { signature: "sig'v", signaturePolicy: "pol'y", responses: [opResponse(200)] })]),
96
+ ]);
97
+ const out = generateOp(root);
98
+ expect(out).toContain("requireSignature('sig\\'v', { policy: 'pol\\'y' })");
99
+ });
100
+ });
101
+
102
+ // ─── Fix 4: path traversal via .ck-derived template vars ────────────────────
103
+
104
+ describe('path traversal containment', () => {
105
+ const base = '/project/out';
106
+
107
+ it('throws when a `.ck`-derived {area} escapes the base output dir', () => {
108
+ expect(() =>
109
+ computeOpOutPath('/project/contracts/a.ck', base, '{area}/{filename}.ts', '.ts', '/project/contracts', {
110
+ area: '../../../tmp/x',
111
+ }),
112
+ ).toThrow(/Refusing to emit outside output directory/);
113
+ });
114
+
115
+ it('throws when a `.ck`-derived {filename} escapes the base output dir', () => {
116
+ expect(() =>
117
+ computeSdkTypeOutPath('/project/contracts/a.ck', base, '{filename}.ts', '/project/contracts', {
118
+ filename: '../../etc/passwd',
119
+ }),
120
+ ).toThrow(/Refusing to emit outside output directory/);
121
+ });
122
+
123
+ it('throws for computeSdkOutPath traversal', () => {
124
+ expect(() =>
125
+ computeSdkOutPath('/project/contracts/a.ck', base, '{area}/{filename}.ts', '/project/contracts', {
126
+ area: '../../../evil',
127
+ }),
128
+ ).toThrow(/Refusing to emit outside output directory/);
129
+ });
130
+
131
+ it('throws for computeSdkAreaClientOutPath traversal', () => {
132
+ expect(() => computeSdkAreaClientOutPath('../../../evil', base, '{area}/{filename}.client.ts')).toThrow(
133
+ /Refusing to emit outside output directory/,
134
+ );
135
+ });
136
+
137
+ it('allows a normal in-base path', () => {
138
+ const p = computeOpOutPath('/project/contracts/a.ck', base, '{area}/{filename}.ts', '.ts', '/project/contracts', {
139
+ area: 'billing',
140
+ });
141
+ expect(p).toBe('/project/out/billing/a.ts');
142
+ });
143
+ });