@contractkit/plugin-typescript 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.23.1",
3
+ "version": "0.25.0",
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,11 +26,11 @@
26
26
  ".": "./dist/index.js"
27
27
  },
28
28
  "dependencies": {
29
- "@contractkit/core": "0.17.0"
29
+ "@contractkit/core": "0.18.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@repo/config-typescript": "0.1.0",
33
- "@repo/config-eslint": "0.3.1"
32
+ "@repo/config-eslint": "0.3.1",
33
+ "@repo/config-typescript": "0.1.0"
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly --declaration",
@@ -300,8 +300,17 @@ function generateSimpleModel(model: ModelNode, outPath?: string): string[] {
300
300
  for (const field of model.fields) {
301
301
  const inputKey = applyCase(field.name, inputCase);
302
302
  const outputKey = applyCase(field.name, outputCase);
303
- const val = field.optional ? `data.${inputKey} ?? undefined` : `data.${inputKey}`;
304
- lines.push(` ${quoteKey(outputKey)}: ${val},`);
303
+ if (field.optional) {
304
+ // Conditional spread keeps the field optional (`k?: T`) in the inferred
305
+ // z.output / z.input type, instead of widening to required-nullable (`k: T | undefined`).
306
+ // Consumer code built with `...(x ? { k: x } : {})` is only assignable to the optional form.
307
+ // When inputCase is set, the input schema uses `.nullish()` so the guard must reject both
308
+ // null and undefined; otherwise `.optional()` only allows undefined.
309
+ const guard = hasInputTransform ? `data.${inputKey} != null` : `data.${inputKey} !== undefined`;
310
+ lines.push(` ...(${guard} ? { ${quoteKey(outputKey)}: data.${inputKey} } : {}),`);
311
+ } else {
312
+ lines.push(` ${quoteKey(outputKey)}: data.${inputKey},`);
313
+ }
305
314
  }
306
315
  lines.push(`}));`);
307
316
  // When only outputCase is set, the developer-facing type is the schema's
@@ -739,9 +748,13 @@ function renderInlineObject(o: InlineObjectTypeNode, parseCaseTransform?: 'snake
739
748
  const transformEntries = o.fields
740
749
  .map(f => {
741
750
  const snakeKey = camelToSnake(f.name);
742
- // Optional fields use .nullish() on input; coerce null undefined in output
743
- const val = f.optional ? `data.${snakeKey} ?? undefined` : `data.${snakeKey}`;
744
- return ` ${quoteKey(f.name)}: ${val},`;
751
+ // Optional fields use .nullish() on input. Conditional spread (instead of `?? undefined`)
752
+ // keeps the key optional in the inferred output type (`k?: T`) rather than widening to
753
+ // required-nullable (`k: T | undefined`).
754
+ if (f.optional) {
755
+ return ` ...(data.${snakeKey} != null ? { ${quoteKey(f.name)}: data.${snakeKey} } : {}),`;
756
+ }
757
+ return ` ${quoteKey(f.name)}: data.${snakeKey},`;
745
758
  })
746
759
  .join('\n');
747
760
  return `${wrapper}({\n${joined}\n}).transform(data => ({\n${transformEntries}\n}))`;
@@ -752,8 +765,10 @@ function renderInlineObject(o: InlineObjectTypeNode, parseCaseTransform?: 'snake
752
765
  const transformEntries = o.fields
753
766
  .map(f => {
754
767
  const pascalKey = camelToPascal(f.name);
755
- const val = f.optional ? `data.${pascalKey} ?? undefined` : `data.${pascalKey}`;
756
- return ` ${quoteKey(f.name)}: ${val},`;
768
+ if (f.optional) {
769
+ return ` ...(data.${pascalKey} != null ? { ${quoteKey(f.name)}: data.${pascalKey} } : {}),`;
770
+ }
771
+ return ` ${quoteKey(f.name)}: data.${pascalKey},`;
757
772
  })
758
773
  .join('\n');
759
774
  return `${wrapper}({\n${joined}\n}).transform(data => ({\n${transformEntries}\n}))`;
@@ -137,9 +137,9 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
137
137
  // before deciding whether to emit the zod import.
138
138
  const body: string[] = [];
139
139
  const needsSignature = fileNeedsSignature(root);
140
- const needsSecurity = fileNeedsSecurity(root);
140
+ const needsPolicy = fileNeedsPolicy(root);
141
141
  const koaImports = ['ServerKitRouter', 'bodyParserMiddleware'];
142
- if (needsSecurity) koaImports.push('requireSecurity');
142
+ if (needsPolicy) koaImports.push('requirePolicy');
143
143
  if (needsSignature) koaImports.push('requireSignature');
144
144
  body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
145
145
 
@@ -241,8 +241,14 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
241
241
  // Middleware list
242
242
  const middlewares: string[] = [];
243
243
  if (effectiveSecurity !== SECURITY_NONE) {
244
- const args = effectiveSecurity && effectiveSecurity.requireMfa !== undefined ? `{ requireMfa: ${effectiveSecurity.requireMfa} }` : '';
245
- middlewares.push(`requireSecurity(${args})`);
244
+ const policy = effectiveSecurity?.policy;
245
+ const args =
246
+ policy === undefined
247
+ ? ''
248
+ : policy === false
249
+ ? '{ policy: false }'
250
+ : `{ policy: '${policy}' }`;
251
+ middlewares.push(`requirePolicy(${args})`);
246
252
  }
247
253
  if (hasBody) {
248
254
  const parserTokens = Array.from(new Set(bodies.map(b => bodyParserToken(b.contentType))));
@@ -761,7 +767,7 @@ function routeNeedsValidation(root: OpRootNode): boolean {
761
767
  );
762
768
  }
763
769
 
764
- function fileNeedsSecurity(root: OpRootNode): boolean {
770
+ function fileNeedsPolicy(root: OpRootNode): boolean {
765
771
  return root.routes.some(route => route.operations.some(op => resolveSecurity(route, op, root) !== SECURITY_NONE));
766
772
  }
767
773
 
@@ -1060,12 +1060,15 @@ describe('generateContract', () => {
1060
1060
  /export const Child = z\.strictObject\(\{[\s\S]*grant_type:[\s\S]*client_id:[\s\S]*client_secret:[\s\S]*\}\)\.transform/,
1061
1061
  );
1062
1062
  expect(output).toContain('grantType: data.grant_type');
1063
- expect(output).toContain('clientId: data.client_id ?? undefined');
1063
+ expect(output).toContain('clientId: data.client_id,');
1064
1064
  expect(output).toContain('clientSecret: data.client_secret');
1065
1065
  expect(output).toContain('export type Child = z.output<typeof Child>');
1066
1066
  });
1067
1067
 
1068
- it('input=snake: optional fields coerce null undefined in the transform', () => {
1068
+ it('input=snake: optional fields use conditional spread guarded by `!= null`', () => {
1069
+ // .nullish() on input accepts null and undefined; conditional spread keeps the field
1070
+ // optional in the inferred output type (`k?: T`) instead of widening to required-nullable
1071
+ // (`k: T | undefined`). The `!= null` guard omits the key for both null and undefined.
1069
1072
  const root = contractRoot([
1070
1073
  model(
1071
1074
  'User',
@@ -1076,7 +1079,40 @@ describe('generateContract', () => {
1076
1079
  const output = generateContract(root);
1077
1080
  expect(output).toContain('client_id: z.uuid().nullish()');
1078
1081
  expect(output).toContain('firstName: data.first_name,');
1079
- expect(output).toContain('clientId: data.client_id ?? undefined,');
1082
+ expect(output).toContain('...(data.client_id != null ? { clientId: data.client_id } : {}),');
1083
+ expect(output).not.toContain('?? undefined');
1084
+ });
1085
+
1086
+ it('output=snake: optional fields use conditional spread guarded by `!== undefined`', () => {
1087
+ // With only outputCase set, input is `.optional()` (undefined-only). Conditional spread keeps
1088
+ // the field optional in the inferred z.input type so consumers building values with
1089
+ // `...(x ? { k: x } : {})` are assignable.
1090
+ const root = contractRoot([
1091
+ model('Token', [field('accessToken', scalarType('string')), field('refreshToken', scalarType('string'), { optional: true })], {
1092
+ outputCase: 'snake',
1093
+ }),
1094
+ ]);
1095
+ const output = generateContract(root);
1096
+ expect(output).toContain('refreshToken: z.string().optional()');
1097
+ expect(output).toContain('access_token: data.accessToken,');
1098
+ expect(output).toContain('...(data.refreshToken !== undefined ? { refresh_token: data.refreshToken } : {}),');
1099
+ expect(output).not.toContain('?? undefined');
1100
+ });
1101
+
1102
+ it('input=pascal: inline-object optional fields use conditional spread guarded by `!= null`', () => {
1103
+ const dataType = inlineObjectType([
1104
+ field('id', scalarType('uuid')),
1105
+ field('amount', scalarType('number'), { optional: true }),
1106
+ ]);
1107
+ const root = contractRoot([
1108
+ model('Webhook', [field('event', scalarType('string')), field('data', dataType)], { inputCase: 'pascal' }),
1109
+ ]);
1110
+ const output = generateContract(root);
1111
+ // Inline object emits its own transform inside the Webhook input shape.
1112
+ expect(output).toContain('Amount: z.coerce.number().nullish()');
1113
+ expect(output).toContain('id: data.Id,');
1114
+ expect(output).toContain('...(data.Amount != null ? { amount: data.Amount } : {}),');
1115
+ expect(output).not.toContain('?? undefined');
1080
1116
  });
1081
1117
  });
1082
1118
  });
@@ -807,9 +807,9 @@ describe('generateOp — route modifiers JSDoc', () => {
807
807
  expect(out).toContain('anonymous access, no security required');
808
808
  });
809
809
 
810
- it('emits no annotation for security with requireMfa', () => {
810
+ it('emits no annotation for security with policy', () => {
811
811
  const op = opOperation('get', {
812
- security: { requireMfa: true, loc: { file: 'test.op', line: 1 } },
812
+ security: { policy: 'paymentsWrite', loc: { file: 'test.op', line: 1 } },
813
813
  });
814
814
  const root = opRoot([opRoute('/users', [op])]);
815
815
  const out = generateOp(root);
@@ -845,7 +845,7 @@ describe('generateOp — route modifiers JSDoc', () => {
845
845
  });
846
846
  const root = opRoot([opRoute('/webhooks', [op])]);
847
847
  const out = generateOp(root);
848
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requireSecurity, requireSignature }`);
848
+ expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requirePolicy, requireSignature }`);
849
849
  expect(out).toContain(`requireSignature('MODERN_TREASURY_WEBHOOK')`);
850
850
  });
851
851
 
@@ -867,63 +867,63 @@ describe('generateOp — route modifiers JSDoc', () => {
867
867
 
868
868
  it('does not import requireSignature when no signature is set', () => {
869
869
  const op = opOperation('get', {
870
- security: { requireMfa: true, loc: { file: 'test.op', line: 1 } },
870
+ security: { policy: 'paymentsWrite', loc: { file: 'test.op', line: 1 } },
871
871
  });
872
872
  const root = opRoot([opRoute('/users', [op])]);
873
873
  const out = generateOp(root);
874
874
  expect(out).not.toContain('requireSignature');
875
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requireSecurity }`);
875
+ expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requirePolicy }`);
876
876
  });
877
877
  });
878
878
 
879
- // ─── Security (requireMfa) middleware ───────────────────────────
879
+ // ─── Policy middleware ──────────────────────────────────────────
880
880
 
881
- describe('security middleware', () => {
882
- it('injects requireSecurity() with no args for unannotated routes', () => {
881
+ describe('policy middleware', () => {
882
+ it('injects requirePolicy() with no args for unannotated routes', () => {
883
883
  const op = opOperation('get');
884
884
  const root = opRoot([opRoute('/users', [op])]);
885
885
  const out = generateOp(root);
886
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requireSecurity }`);
887
- expect(out).toContain(`requireSecurity()`);
886
+ expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requirePolicy }`);
887
+ expect(out).toContain(`requirePolicy()`);
888
888
  });
889
889
 
890
- it('injects requireSecurity with requireMfa: true when set', () => {
890
+ it('injects requirePolicy with a named policy when set', () => {
891
891
  const op = opOperation('get', {
892
- security: { requireMfa: true, loc: { file: 'test.op', line: 1 } },
892
+ security: { policy: 'paymentsWrite', loc: { file: 'test.op', line: 1 } },
893
893
  });
894
894
  const root = opRoot([opRoute('/users', [op])]);
895
895
  const out = generateOp(root);
896
- expect(out).toContain(`requireSecurity({ requireMfa: true })`);
896
+ expect(out).toContain(`requirePolicy({ policy: 'paymentsWrite' })`);
897
897
  });
898
898
 
899
- it('injects requireSecurity with requireMfa: false when set', () => {
899
+ it('injects requirePolicy with policy: false when explicitly bypassed', () => {
900
900
  const op = opOperation('get', {
901
- security: { requireMfa: false, loc: { file: 'test.op', line: 1 } },
901
+ security: { policy: false, loc: { file: 'test.op', line: 1 } },
902
902
  });
903
903
  const routeLine = generateOp(opRoot([opRoute('/users', [op])]))
904
904
  .split('\n')
905
905
  .find(l => l.includes('.get('));
906
- expect(routeLine).toContain(`requireSecurity({ requireMfa: false })`);
906
+ expect(routeLine).toContain(`requirePolicy({ policy: false })`);
907
907
  });
908
908
 
909
- it('does not inject requireSecurity for public (security: none) routes', () => {
909
+ it('does not inject requirePolicy for public (security: none) routes', () => {
910
910
  const op = opOperation('get', { security: SECURITY_NONE });
911
911
  const root = opRoot([opRoute('/health', [op])]);
912
912
  const out = generateOp(root);
913
- expect(out).not.toContain('requireSecurity');
913
+ expect(out).not.toContain('requirePolicy');
914
914
  });
915
915
 
916
- it('does not import requireSecurity when all routes are public', () => {
916
+ it('does not import requirePolicy when all routes are public', () => {
917
917
  const op = opOperation('get', { security: SECURITY_NONE });
918
918
  const root = opRoot([opRoute('/health', [op])]);
919
919
  const out = generateOp(root);
920
920
  expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware }`);
921
- expect(out).not.toContain('requireSecurity');
921
+ expect(out).not.toContain('requirePolicy');
922
922
  });
923
923
 
924
- it('places requireSecurity before bodyParserMiddleware in the route line', () => {
924
+ it('places requirePolicy before bodyParserMiddleware in the route line', () => {
925
925
  const op = opOperation('post', {
926
- security: { requireMfa: true, loc: { file: 'test.op', line: 1 } },
926
+ security: { policy: 'paymentsWrite', loc: { file: 'test.op', line: 1 } },
927
927
  request: opRequest('Payload'),
928
928
  });
929
929
  const root = opRoot([opRoute('/users', [op])]);
@@ -931,16 +931,16 @@ describe('generateOp — route modifiers JSDoc', () => {
931
931
  .split('\n')
932
932
  .find(l => l.includes('.post('));
933
933
  expect(routeLine).toBeDefined();
934
- const secIdx = routeLine!.indexOf(`requireSecurity`);
934
+ const polIdx = routeLine!.indexOf(`requirePolicy`);
935
935
  const bodyIdx = routeLine!.indexOf(`bodyParserMiddleware`);
936
- expect(secIdx).toBeGreaterThan(-1);
937
- expect(bodyIdx).toBeGreaterThan(secIdx);
936
+ expect(polIdx).toBeGreaterThan(-1);
937
+ expect(bodyIdx).toBeGreaterThan(polIdx);
938
938
  });
939
939
 
940
- it('places requireSecurity before requireSignature when both are set', () => {
940
+ it('places requirePolicy before requireSignature when both are set', () => {
941
941
  const op = opOperation('post', {
942
942
  signature: 'MY_KEY',
943
- security: { requireMfa: true, loc: { file: 'test.op', line: 1 } },
943
+ security: { policy: 'paymentsWrite', loc: { file: 'test.op', line: 1 } },
944
944
  request: opRequest('Payload'),
945
945
  });
946
946
  const root = opRoot([opRoute('/webhooks', [op])]);
@@ -948,30 +948,30 @@ describe('generateOp — route modifiers JSDoc', () => {
948
948
  .split('\n')
949
949
  .find(l => l.includes('.post('));
950
950
  expect(routeLine).toBeDefined();
951
- const secIdx = routeLine!.indexOf(`requireSecurity`);
951
+ const polIdx = routeLine!.indexOf(`requirePolicy`);
952
952
  const sigIdx = routeLine!.indexOf(`requireSignature`);
953
- expect(secIdx).toBeGreaterThan(-1);
954
- expect(sigIdx).toBeGreaterThan(secIdx);
953
+ expect(polIdx).toBeGreaterThan(-1);
954
+ expect(sigIdx).toBeGreaterThan(polIdx);
955
955
  });
956
956
 
957
- it('imports both requireSecurity and requireSignature when both are set', () => {
957
+ it('imports both requirePolicy and requireSignature when both are set', () => {
958
958
  const op = opOperation('post', {
959
959
  signature: 'MY_KEY',
960
- security: { requireMfa: true, loc: { file: 'test.op', line: 1 } },
960
+ security: { policy: 'paymentsWrite', loc: { file: 'test.op', line: 1 } },
961
961
  request: opRequest('Payload'),
962
962
  });
963
963
  const root = opRoot([opRoute('/webhooks', [op])]);
964
964
  const out = generateOp(root);
965
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requireSecurity, requireSignature }`);
965
+ expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requirePolicy, requireSignature }`);
966
966
  });
967
967
 
968
- it('works with route-level requireMfa security', () => {
968
+ it('works with route-level policy security', () => {
969
969
  const op = opOperation('get');
970
970
  const route = opRoute('/users', [op]);
971
- route.security = { requireMfa: true, loc: { file: 'test.op', line: 1 } };
971
+ route.security = { policy: 'paymentsWrite', loc: { file: 'test.op', line: 1 } };
972
972
  const root = opRoot([route]);
973
973
  const out = generateOp(root);
974
- expect(out).toContain(`requireSecurity({ requireMfa: true })`);
974
+ expect(out).toContain(`requirePolicy({ policy: 'paymentsWrite' })`);
975
975
  });
976
976
  });
977
977
  });