@mettlecast/domain-cdk-packer 0.2.87 → 0.2.88

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.
@@ -19,6 +19,7 @@ import { AlarmConstruct } from './constructs/alarm-construct.js';
19
19
  import { CanaryConstruct } from './constructs/canary-construct.js';
20
20
  import { domainResourceName } from './naming.js';
21
21
  import { SecurityAssertionAspect } from './aspects/security-assertion-aspect.js';
22
+ import { validateRegistry } from './validate-registry.js';
22
23
  /** L1 helper: adds a CfnRoute + CfnIntegration to an existing HTTP API. */
23
24
  function addRouteToApi(scope, fn, path, methods, apiId, auth) {
24
25
  const id = path.replace(/[^a-zA-Z0-9]/g, '_').replace(/^_/, 'api') + '_' + methods[0].toLowerCase();
@@ -64,6 +65,16 @@ export class DomainStack extends cdk.Stack {
64
65
  */
65
66
  constructor(scope, id, props) {
66
67
  super(scope, id, props);
68
+ // Validate registry against strict contract before creating any constructs (#5090).
69
+ const validationErrors = validateRegistry(props.registry);
70
+ if (validationErrors.length > 0) {
71
+ const lines = validationErrors.map(e => {
72
+ const code = e.code ? ` [${e.code}]` : '';
73
+ return ` [${e.field}]${code} ${e.message}`;
74
+ });
75
+ throw new Error(`Registry validation failed for domain '${props.registry.domain?.id ?? '<unknown>'}':\n${lines.join('\n')}\n\n` +
76
+ `Fix registry data before deploying.`);
77
+ }
67
78
  const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, appSecretArn, userPoolArn, userPoolId, userPoolClientId, internalSubnetSelection, internetSubnetSelection, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials, disableCloudWatchDashboards } = props;
68
79
  const vpc = props.vpc ?? (props.vpcId
69
80
  ? ec2.Vpc.fromVpcAttributes(this, 'SharedVpc', {
@@ -43,6 +43,8 @@ const minimalRegistry = {
43
43
  tenancy: 'required',
44
44
  },
45
45
  idempotent: false,
46
+ inputSchema: { type: 'object', properties: {} },
47
+ outputSchema: { type: 'object', properties: {} },
46
48
  },
47
49
  ],
48
50
  integrations: [],
@@ -228,36 +230,48 @@ describe('DomainStack', () => {
228
230
  backendAccess: 'domain',
229
231
  exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
230
232
  idempotent: false,
233
+ inputSchema: { type: 'object', properties: {} },
234
+ outputSchema: { type: 'object', properties: {} },
231
235
  },
232
236
  {
233
237
  id: 'create-user', kind: 'action', handlerFile: 'src/handlers/create-user.ts',
234
238
  backendAccess: 'domain',
235
239
  exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'POST', auth: 'required', tenancy: 'required' },
236
240
  idempotent: false,
241
+ inputSchema: { type: 'object', properties: {} },
242
+ outputSchema: { type: 'object', properties: {} },
237
243
  },
238
244
  {
239
245
  id: 'replace-user', kind: 'action', handlerFile: 'src/handlers/replace-user.ts',
240
246
  backendAccess: 'domain',
241
247
  exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'PUT', auth: 'required', tenancy: 'required' },
242
248
  idempotent: false,
249
+ inputSchema: { type: 'object', properties: {} },
250
+ outputSchema: { type: 'object', properties: {} },
243
251
  },
244
252
  {
245
253
  id: 'patch-user', kind: 'action', handlerFile: 'src/handlers/patch-user.ts',
246
254
  backendAccess: 'domain',
247
255
  exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'PATCH', auth: 'required', tenancy: 'required' },
248
256
  idempotent: false,
257
+ inputSchema: { type: 'object', properties: {} },
258
+ outputSchema: { type: 'object', properties: {} },
249
259
  },
250
260
  {
251
261
  id: 'delete-user', kind: 'action', handlerFile: 'src/handlers/delete-user.ts',
252
262
  backendAccess: 'domain',
253
263
  exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'DELETE', auth: 'required', tenancy: 'required' },
254
264
  idempotent: false,
265
+ inputSchema: { type: 'object', properties: {} },
266
+ outputSchema: { type: 'object', properties: {} },
255
267
  },
256
268
  {
257
269
  id: 'list-things', kind: 'action', handlerFile: 'src/handlers/list-things.ts',
258
270
  backendAccess: 'domain',
259
271
  exposure: { type: 'api', path: '/v1/tenants/{tenantId}/things', method: 'GET', auth: 'none', tenancy: 'required', securityException: { reason: 'public read-only listing' } },
260
272
  idempotent: false,
273
+ inputSchema: { type: 'object', properties: {} },
274
+ outputSchema: { type: 'object', properties: {} },
261
275
  },
262
276
  ],
263
277
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,325 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateRegistry } from '../validate-registry.js';
3
+ describe('validateRegistry (#5090 strict contract)', () => {
4
+ const validRegistry = {
5
+ schemaVersion: '1',
6
+ domainRoot: '/tmp/test',
7
+ domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'required' },
8
+ webhooks: [],
9
+ subscribers: [],
10
+ schedules: [],
11
+ jobs: [],
12
+ actions: [],
13
+ integrations: [],
14
+ events: [],
15
+ };
16
+ it('accepts an empty registry (no actions)', () => {
17
+ const errors = validateRegistry(validRegistry);
18
+ expect(errors).toHaveLength(0);
19
+ });
20
+ it('accepts a valid internal action', () => {
21
+ const registry = {
22
+ ...validRegistry,
23
+ actions: [
24
+ {
25
+ id: 'do-thing',
26
+ kind: 'action',
27
+ handlerFile: 'src/actions/do-thing.ts',
28
+ backendAccess: 'private',
29
+ exposure: { type: 'internal' },
30
+ idempotent: false,
31
+ },
32
+ ],
33
+ };
34
+ expect(validateRegistry(registry)).toHaveLength(0);
35
+ });
36
+ it('accepts a valid API-exposed action', () => {
37
+ const registry = {
38
+ ...validRegistry,
39
+ actions: [
40
+ {
41
+ id: 'list-users',
42
+ kind: 'action',
43
+ handlerFile: 'src/actions/list-users.ts',
44
+ backendAccess: 'domain',
45
+ exposure: {
46
+ type: 'api',
47
+ path: '/v1/tenants/{tenantId}/users',
48
+ method: 'GET',
49
+ auth: 'required',
50
+ tenancy: 'required',
51
+ },
52
+ idempotent: false,
53
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' } } },
54
+ outputSchema: { type: 'object', properties: { items: { type: 'array' } } },
55
+ },
56
+ ],
57
+ };
58
+ expect(validateRegistry(registry)).toHaveLength(0);
59
+ });
60
+ it('rejects missing exposure', () => {
61
+ const registry = {
62
+ ...validRegistry,
63
+ actions: [
64
+ {
65
+ id: 'no-exposure',
66
+ kind: 'action',
67
+ handlerFile: 'src/actions/no-exposure.ts',
68
+ backendAccess: 'private',
69
+ exposure: undefined,
70
+ idempotent: false,
71
+ },
72
+ ],
73
+ };
74
+ const errors = validateRegistry(registry);
75
+ expect(errors.length).toBeGreaterThan(0);
76
+ expect(errors.some(e => e.field === 'exposure')).toBe(true);
77
+ });
78
+ it('rejects null exposure', () => {
79
+ const registry = {
80
+ ...validRegistry,
81
+ actions: [
82
+ {
83
+ id: 'null-exposure',
84
+ kind: 'action',
85
+ handlerFile: 'src/actions/null-exposure.ts',
86
+ backendAccess: 'private',
87
+ exposure: null,
88
+ idempotent: false,
89
+ },
90
+ ],
91
+ };
92
+ const errors = validateRegistry(registry);
93
+ expect(errors.length).toBeGreaterThan(0);
94
+ expect(errors.some(e => e.field === 'exposure')).toBe(true);
95
+ });
96
+ it('rejects malformed exposure type', () => {
97
+ const registry = {
98
+ ...validRegistry,
99
+ actions: [
100
+ {
101
+ id: 'bad-type',
102
+ kind: 'action',
103
+ handlerFile: 'src/actions/bad-type.ts',
104
+ backendAccess: 'private',
105
+ exposure: { type: 'public' },
106
+ idempotent: false,
107
+ },
108
+ ],
109
+ };
110
+ const errors = validateRegistry(registry);
111
+ expect(errors.length).toBeGreaterThan(0);
112
+ expect(errors.some(e => e.field === 'exposure.type')).toBe(true);
113
+ });
114
+ it('rejects internal exposure with extra keys', () => {
115
+ const registry = {
116
+ ...validRegistry,
117
+ actions: [
118
+ {
119
+ id: 'internal-extra',
120
+ kind: 'action',
121
+ handlerFile: 'src/actions/internal-extra.ts',
122
+ backendAccess: 'private',
123
+ exposure: { type: 'internal', path: '/v1/secret' },
124
+ idempotent: false,
125
+ },
126
+ ],
127
+ };
128
+ const errors = validateRegistry(registry);
129
+ expect(errors.length).toBeGreaterThan(0);
130
+ // Should complain about unexpected keys on internal
131
+ expect(errors.some(e => e.message.includes('unexpected keys'))).toBe(true);
132
+ });
133
+ it('rejects API exposure missing path', () => {
134
+ const registry = {
135
+ ...validRegistry,
136
+ actions: [
137
+ {
138
+ id: 'no-path',
139
+ kind: 'action',
140
+ handlerFile: 'src/actions/no-path.ts',
141
+ backendAccess: 'domain',
142
+ exposure: { type: 'api', method: 'GET', auth: 'required', tenancy: 'required' },
143
+ idempotent: false,
144
+ },
145
+ ],
146
+ };
147
+ const errors = validateRegistry(registry);
148
+ expect(errors.length).toBeGreaterThan(0);
149
+ expect(errors.some(e => e.field === 'exposure.path')).toBe(true);
150
+ });
151
+ it('rejects API exposure missing method', () => {
152
+ const registry = {
153
+ ...validRegistry,
154
+ actions: [
155
+ {
156
+ id: 'no-method',
157
+ kind: 'action',
158
+ handlerFile: 'src/actions/no-method.ts',
159
+ backendAccess: 'domain',
160
+ exposure: { type: 'api', path: '/v1/test', auth: 'required', tenancy: 'required' },
161
+ idempotent: false,
162
+ },
163
+ ],
164
+ };
165
+ const errors = validateRegistry(registry);
166
+ expect(errors.length).toBeGreaterThan(0);
167
+ expect(errors.some(e => e.field === 'exposure.method')).toBe(true);
168
+ });
169
+ it('rejects API exposure with invalid auth', () => {
170
+ const registry = {
171
+ ...validRegistry,
172
+ actions: [
173
+ {
174
+ id: 'bad-auth',
175
+ kind: 'action',
176
+ handlerFile: 'src/actions/bad-auth.ts',
177
+ backendAccess: 'domain',
178
+ exposure: { type: 'api', path: '/v1/test', method: 'GET', auth: 'invalid', tenancy: 'required' },
179
+ idempotent: false,
180
+ },
181
+ ],
182
+ };
183
+ const errors = validateRegistry(registry);
184
+ expect(errors.length).toBeGreaterThan(0);
185
+ expect(errors.some(e => e.field === 'exposure.auth')).toBe(true);
186
+ });
187
+ it('rejects missing backendAccess', () => {
188
+ const registry = {
189
+ ...validRegistry,
190
+ actions: [
191
+ {
192
+ id: 'no-backend',
193
+ kind: 'action',
194
+ handlerFile: 'src/actions/no-backend.ts',
195
+ backendAccess: undefined,
196
+ exposure: { type: 'internal' },
197
+ idempotent: false,
198
+ },
199
+ ],
200
+ };
201
+ const errors = validateRegistry(registry);
202
+ expect(errors.length).toBeGreaterThan(0);
203
+ expect(errors.some(e => e.field === 'backendAccess')).toBe(true);
204
+ });
205
+ it('rejects invalid backendAccess', () => {
206
+ const registry = {
207
+ ...validRegistry,
208
+ actions: [
209
+ {
210
+ id: 'bad-backend',
211
+ kind: 'action',
212
+ handlerFile: 'src/actions/bad-backend.ts',
213
+ backendAccess: 'public',
214
+ exposure: { type: 'internal' },
215
+ idempotent: false,
216
+ },
217
+ ],
218
+ };
219
+ const errors = validateRegistry(registry);
220
+ expect(errors.length).toBeGreaterThan(0);
221
+ expect(errors.some(e => e.field === 'backendAccess')).toBe(true);
222
+ });
223
+ it('rejects non-boolean idempotent', () => {
224
+ const registry = {
225
+ ...validRegistry,
226
+ actions: [
227
+ {
228
+ id: 'bad-idempotent',
229
+ kind: 'action',
230
+ handlerFile: 'src/actions/bad-idempotent.ts',
231
+ backendAccess: 'private',
232
+ exposure: { type: 'internal' },
233
+ idempotent: 'yes',
234
+ },
235
+ ],
236
+ };
237
+ const errors = validateRegistry(registry);
238
+ expect(errors.length).toBeGreaterThan(0);
239
+ expect(errors.some(e => e.field === 'idempotent')).toBe(true);
240
+ });
241
+ it('rejects null action entries', () => {
242
+ const registry = {
243
+ ...validRegistry,
244
+ actions: [
245
+ null,
246
+ ],
247
+ };
248
+ const errors = validateRegistry(registry);
249
+ expect(errors.length).toBeGreaterThan(0);
250
+ expect(errors.some(e => e.field === 'actions')).toBe(true);
251
+ });
252
+ it('rejects missing handlerFile', () => {
253
+ const registry = {
254
+ ...validRegistry,
255
+ actions: [
256
+ {
257
+ id: 'no-handler',
258
+ kind: 'action',
259
+ handlerFile: '',
260
+ backendAccess: 'private',
261
+ exposure: { type: 'internal' },
262
+ idempotent: false,
263
+ },
264
+ ],
265
+ };
266
+ const errors = validateRegistry(registry);
267
+ expect(errors.length).toBeGreaterThan(0);
268
+ expect(errors.some(e => e.field === 'handlerFile')).toBe(true);
269
+ });
270
+ it('rejects wrong schemaVersion', () => {
271
+ const registry = {
272
+ ...validRegistry,
273
+ schemaVersion: '2',
274
+ };
275
+ const errors = validateRegistry(registry);
276
+ expect(errors.length).toBeGreaterThan(0);
277
+ expect(errors.some(e => e.field === 'schemaVersion')).toBe(true);
278
+ });
279
+ it('reports multiple errors for a completely malformed action', () => {
280
+ const registry = {
281
+ ...validRegistry,
282
+ actions: [
283
+ {
284
+ id: '',
285
+ kind: 'not-action',
286
+ handlerFile: '',
287
+ backendAccess: 'bad',
288
+ exposure: undefined,
289
+ idempotent: 'nope',
290
+ },
291
+ ],
292
+ };
293
+ // Should have multiple errors about different fields
294
+ const errors = validateRegistry(registry);
295
+ expect(errors.length).toBeGreaterThanOrEqual(3);
296
+ });
297
+ it('does NOT reject actions with valid authDeclared/tenancyDeclared flags', () => {
298
+ // The authDeclared/tenancyDeclared flags are part of the registry data
299
+ // and must not be treated as unexpected keys on internal exposure
300
+ const registry = {
301
+ ...validRegistry,
302
+ actions: [
303
+ {
304
+ id: 'with-declared',
305
+ kind: 'action',
306
+ handlerFile: 'src/actions/with-declared.ts',
307
+ backendAccess: 'domain',
308
+ exposure: {
309
+ type: 'api',
310
+ path: '/v1/tenants/{tenantId}/users',
311
+ method: 'GET',
312
+ auth: 'required',
313
+ tenancy: 'required',
314
+ authDeclared: true,
315
+ tenancyDeclared: true,
316
+ },
317
+ idempotent: false,
318
+ inputSchema: { type: 'object', properties: { limit: { type: 'number' } } },
319
+ outputSchema: { type: 'object', properties: { items: { type: 'array' } } },
320
+ },
321
+ ],
322
+ };
323
+ expect(validateRegistry(registry)).toHaveLength(0);
324
+ });
325
+ });
@@ -29,6 +29,8 @@ function buildStackAndGetErrors(registry) {
29
29
  const stack = new DomainStack(app, 'SecAspectStack', {
30
30
  registry,
31
31
  eventBusArn: 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus',
32
+ projectId: 'mtc',
33
+ envCode: 'dev',
32
34
  });
33
35
  // Force synthesis so the aspect's visit() fires.
34
36
  try {
@@ -59,13 +61,7 @@ beforeAll(() => {
59
61
  // entry id (`list-users` -> `listUsers`). We provide every named export
60
62
  // the security-aspect test uses so each test can pick the same handler
61
63
  // file regardless of which entry id it instantiates.
62
- //
63
- // Issue #4689: defineApi was removed; HTTP endpoints are now declared
64
- // as actions with `exposure.type === 'api'`. Each named export below
65
- // matches one of the action ids used in the test fixtures.
66
64
  fs.writeFileSync(path.join(handlerDir, 'noop.ts'), [
67
- // Match the grouped-lambda-factory's export-name conversion so the
68
- // bundler can resolve the import for every test fixture.
69
65
  'export const listUsers = { id: "list-users", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/users", method: "GET", auth: "required", tenancy: "required" }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };',
70
66
  'export const listAll = { id: "list-all", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/all", method: "GET", auth: "required", tenancy: "required" }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };',
71
67
  'export const noopAction = { id: "noop-action", backendAccess: "private", exposure: { type: "internal" }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };',
@@ -79,8 +75,13 @@ afterAll(() => {
79
75
  * last line of defence: it walks the synthesised tree and verifies the
80
76
  * invariants the CLI validator already enforces. These tests pin each
81
77
  * invariant independently so a regression in any single check is caught.
78
+ *
79
+ * Issue #5090 — the strict registry contract now validates all action
80
+ * entries before any constructs are created. Null/missing exposure
81
+ * entries are rejected at validation time, so the aspect no longer
82
+ * needs to handle them.
82
83
  */
83
- describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
84
+ describe('SecurityAssertionAspect (#4662 Task D, #4689, #5090)', () => {
84
85
  it('does not emit errors for an empty registry', () => {
85
86
  const { errorMessages } = buildStackAndGetErrors(baseRegistry);
86
87
  // The base stack emits zero routes / zero Function URLs, so the aspect
@@ -89,9 +90,6 @@ describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
89
90
  expect(errorMessages.filter(m => aspectCodes.some(c => m.includes(c)))).toHaveLength(0);
90
91
  });
91
92
  it('emits SECURITY_MISSING_TENANT_PATH when an action API path lacks the tenant placeholder', () => {
92
- // Issue #4689: the canonical HTTP endpoint surface is the action
93
- // array with `exposure.type === 'api'`. The aspect now reads from
94
- // `actions[]` rather than `apis[]`.
95
93
  const registry = {
96
94
  ...baseRegistry,
97
95
  actions: [
@@ -100,6 +98,8 @@ describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
100
98
  backendAccess: 'domain',
101
99
  exposure: { type: 'api', path: '/users', method: 'GET', auth: 'required', tenancy: 'required' },
102
100
  idempotent: false,
101
+ inputSchema: { type: 'object', properties: {} },
102
+ outputSchema: { type: 'object', properties: {} },
103
103
  },
104
104
  ],
105
105
  };
@@ -115,6 +115,8 @@ describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
115
115
  backendAccess: 'domain',
116
116
  exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
117
117
  idempotent: false,
118
+ inputSchema: { type: 'object', properties: {} },
119
+ outputSchema: { type: 'object', properties: {} },
118
120
  },
119
121
  ],
120
122
  };
@@ -132,6 +134,8 @@ describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
132
134
  backendAccess: 'domain',
133
135
  exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
134
136
  idempotent: false,
137
+ inputSchema: { type: 'object', properties: {} },
138
+ outputSchema: { type: 'object', properties: {} },
135
139
  },
136
140
  ],
137
141
  };
@@ -139,9 +143,6 @@ describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
139
143
  expect(errorMessages.some(m => m.includes('SECURITY_MISSING_JWT_AUTHORIZER') || m.includes('JWT-protected routes'))).toBe(true);
140
144
  });
141
145
  it('does NOT emit SECURITY_MISSING_SECURITY_EXCEPTION when authType is none but the route is the health probe', () => {
142
- // The DomainStack installs the health/ready probes as `authType: 'none'` routes.
143
- // Because they are special-cased in the aspect (and CLI validator
144
- // ignores them), the aspect must NOT fire for them.
145
146
  const registry = {
146
147
  ...baseRegistry,
147
148
  };
@@ -149,13 +150,9 @@ describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
149
150
  expect(errorMessages.some(m => m.includes('SECURITY_MISSING_SECURITY_EXCEPTION'))).toBe(false);
150
151
  });
151
152
  it('does NOT emit Function URL error for the existing health/ready probes', () => {
152
- // HealthConstruct attaches `Lambda::Url` to its functions. The aspect
153
- // exempts them via fn-name regex; we must not regress that exemption.
154
153
  const registry = { ...baseRegistry };
155
154
  const { errorMessages } = buildStackAndGetErrors(registry);
156
155
  // HealthConstruct uses NodejsFunction which DOES NOT create Lambda::Url
157
- // (URLs are only created when fn.addFunctionUrl is called). So there
158
- // should be no Function URL errors at all on the base stack.
159
156
  expect(errorMessages.some(m => m.includes('SECURITY_ACTION_FUNCTION_URL'))).toBe(false);
160
157
  });
161
158
  it('emits SECURITY_MISSING_TENANT_PATH for an action exposure with tenancy=required but bad path', () => {
@@ -169,6 +166,8 @@ describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
169
166
  backendAccess: 'domain',
170
167
  exposure: { type: 'api', path: '/v1/admin/users', method: 'GET', auth: 'required', tenancy: 'required' },
171
168
  idempotent: false,
169
+ inputSchema: { type: 'object', properties: {} },
170
+ outputSchema: { type: 'object', properties: {} },
172
171
  },
173
172
  ],
174
173
  };
@@ -186,6 +185,8 @@ describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
186
185
  backendAccess: 'domain',
187
186
  exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
188
187
  idempotent: false,
188
+ inputSchema: { type: 'object', properties: {} },
189
+ outputSchema: { type: 'object', properties: {} },
189
190
  },
190
191
  ],
191
192
  };
@@ -210,13 +211,80 @@ describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
210
211
  securityException: { reason: 'public liveness probe; ticket OPS-123' },
211
212
  },
212
213
  idempotent: false,
214
+ inputSchema: { type: 'object', properties: {} },
215
+ outputSchema: { type: 'object', properties: {} },
213
216
  },
214
217
  ],
215
218
  };
216
219
  const { errorMessages } = buildStackAndGetErrors(registry);
217
- // Note: the CLI validator's AUTH_NONE_REQUIRES_EXCEPTION rule mirrors
218
- // this; we only check that the *aspect* does not block a well-formed
219
- // action.
220
220
  expect(errorMessages.some(m => m.includes('SECURITY_MISSING_SECURITY_EXCEPTION'))).toBe(false);
221
221
  });
222
+ /**
223
+ * Issue #5090 — strict registry contract: null/missing exposure entries
224
+ * are rejected at validation time. The tests below verify that
225
+ * DomainStack throws when given an invalid registry, rather than
226
+ * silently skipping bad entries.
227
+ */
228
+ describe('strict validation rejects malformed entries (#5090)', () => {
229
+ it('throws on null exposure', () => {
230
+ const registry = {
231
+ ...baseRegistry,
232
+ actions: [
233
+ {
234
+ id: 'null-exposure',
235
+ kind: 'action',
236
+ handlerFile: 'src/handlers/noop.ts',
237
+ backendAccess: 'domain',
238
+ exposure: null,
239
+ idempotent: false,
240
+ },
241
+ ],
242
+ };
243
+ expect(() => {
244
+ const app = new cdk.App();
245
+ new DomainStack(app, 'NullExposureStack', { registry, eventBusArn: 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus' });
246
+ }).toThrow('Registry validation failed');
247
+ });
248
+ it('throws on undefined exposure', () => {
249
+ const registry = {
250
+ ...baseRegistry,
251
+ actions: [
252
+ {
253
+ id: 'missing-exposure',
254
+ kind: 'action',
255
+ handlerFile: 'src/handlers/noop.ts',
256
+ backendAccess: 'domain',
257
+ exposure: undefined,
258
+ idempotent: false,
259
+ },
260
+ ],
261
+ };
262
+ expect(() => {
263
+ const app = new cdk.App();
264
+ new DomainStack(app, 'MissingExposureStack', { registry, eventBusArn: 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus' });
265
+ }).toThrow('Registry validation failed');
266
+ });
267
+ it('throws on null entry in actions array', () => {
268
+ const registry = {
269
+ ...baseRegistry,
270
+ actions: [
271
+ null,
272
+ {
273
+ id: 'list-users',
274
+ kind: 'action',
275
+ handlerFile: 'src/handlers/noop.ts',
276
+ backendAccess: 'domain',
277
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
278
+ idempotent: false,
279
+ inputSchema: { type: 'object', properties: {} },
280
+ outputSchema: { type: 'object', properties: {} },
281
+ },
282
+ ],
283
+ };
284
+ expect(() => {
285
+ const app = new cdk.App();
286
+ new DomainStack(app, 'NullEntryStack', { registry, eventBusArn: 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus' });
287
+ }).toThrow('Registry validation failed');
288
+ });
289
+ });
222
290
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,352 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateRegistry } from '../validate-registry.js';
3
+ /**
4
+ * Helper: build a minimal valid registry, then override specific fields
5
+ * for each test case.
6
+ */
7
+ function minimalValidRegistry(overrides) {
8
+ return {
9
+ schemaVersion: '1',
10
+ domainRoot: '/workspace/my-domain',
11
+ domain: { id: 'my-domain', kind: 'domain', name: 'My Domain', tenancy: 'required' },
12
+ webhooks: [],
13
+ subscribers: [],
14
+ schedules: [],
15
+ jobs: [],
16
+ actions: [],
17
+ integrations: [],
18
+ events: [],
19
+ ...overrides,
20
+ };
21
+ }
22
+ /**
23
+ * Helper: build a valid API action entry that passes all checks.
24
+ */
25
+ function validApiAction(overrides) {
26
+ return {
27
+ id: 'test-action',
28
+ kind: 'action',
29
+ handlerFile: 'src/actions/test.ts',
30
+ backendAccess: 'domain',
31
+ idempotent: true,
32
+ exposure: {
33
+ type: 'api',
34
+ path: '/v1/tenants/{tenantId}/test',
35
+ method: 'GET',
36
+ auth: 'required',
37
+ tenancy: 'required',
38
+ },
39
+ inputSchema: { type: 'object', properties: { foo: { type: 'string' } } },
40
+ outputSchema: { type: 'object', properties: { bar: { type: 'number' } } },
41
+ ...overrides,
42
+ };
43
+ }
44
+ describe('validateRegistry', () => {
45
+ describe('structural checks', () => {
46
+ it('passes for a valid minimal registry with no actions', () => {
47
+ const errors = validateRegistry(minimalValidRegistry());
48
+ expect(errors).toHaveLength(0);
49
+ });
50
+ it('passes for a valid registry with a well-formed API action', () => {
51
+ const registry = minimalValidRegistry({
52
+ actions: [validApiAction()],
53
+ });
54
+ const errors = validateRegistry(registry);
55
+ expect(errors).toHaveLength(0);
56
+ });
57
+ it('passes for a valid internal action', () => {
58
+ const registry = minimalValidRegistry({
59
+ actions: [{
60
+ id: 'internal-action',
61
+ kind: 'action',
62
+ handlerFile: 'src/actions/internal.ts',
63
+ backendAccess: 'private',
64
+ idempotent: false,
65
+ exposure: { type: 'internal' },
66
+ }],
67
+ });
68
+ const errors = validateRegistry(registry);
69
+ expect(errors).toHaveLength(0);
70
+ });
71
+ it('rejects null/undefined action at index', () => {
72
+ const registry = minimalValidRegistry();
73
+ registry.actions = [null];
74
+ const errors = validateRegistry(registry);
75
+ expect(errors.length).toBeGreaterThan(0);
76
+ expect(errors[0]?.code).toBe('ACTION_NULL');
77
+ });
78
+ it('rejects missing domain entry', () => {
79
+ const registry = minimalValidRegistry({ domain: undefined });
80
+ const errors = validateRegistry(registry);
81
+ expect(errors.length).toBeGreaterThan(0);
82
+ expect(errors[0]?.code).toBe('DOMAIN_MISSING');
83
+ });
84
+ it('rejects invalid schemaVersion', () => {
85
+ const registry = minimalValidRegistry({ schemaVersion: '2' });
86
+ const errors = validateRegistry(registry);
87
+ expect(errors.some(e => e.code === 'SCHEMA_VERSION')).toBe(true);
88
+ });
89
+ it('rejects missing kind', () => {
90
+ const registry = minimalValidRegistry({
91
+ actions: [{
92
+ id: 'bad-kind',
93
+ kind: 'webhook',
94
+ handlerFile: 'src/actions/bad.ts',
95
+ backendAccess: 'private',
96
+ idempotent: false,
97
+ exposure: { type: 'internal' },
98
+ }],
99
+ });
100
+ const errors = validateRegistry(registry);
101
+ expect(errors.some(e => e.code === 'ACTION_KIND_INVALID')).toBe(true);
102
+ });
103
+ it('rejects missing handlerFile', () => {
104
+ const registry = minimalValidRegistry({
105
+ actions: [{
106
+ id: 'no-handler',
107
+ kind: 'action',
108
+ backendAccess: 'private',
109
+ idempotent: false,
110
+ exposure: { type: 'internal' },
111
+ }],
112
+ });
113
+ const errors = validateRegistry(registry);
114
+ expect(errors.some(e => e.code === 'ACTION_HANDLER_MISSING')).toBe(true);
115
+ });
116
+ it('rejects invalid backendAccess', () => {
117
+ const registry = minimalValidRegistry({
118
+ actions: [{
119
+ id: 'bad-backend',
120
+ kind: 'action',
121
+ handlerFile: 'src/actions/bad.ts',
122
+ backendAccess: 'public',
123
+ idempotent: false,
124
+ exposure: { type: 'internal' },
125
+ }],
126
+ });
127
+ const errors = validateRegistry(registry);
128
+ expect(errors.some(e => e.code === 'ACTION_BACKEND_ACCESS_INVALID')).toBe(true);
129
+ });
130
+ it('rejects non-boolean idempotent', () => {
131
+ const registry = minimalValidRegistry({
132
+ actions: [{
133
+ id: 'bad-idempotent',
134
+ kind: 'action',
135
+ handlerFile: 'src/actions/bad.ts',
136
+ backendAccess: 'private',
137
+ idempotent: 'yes',
138
+ exposure: { type: 'internal' },
139
+ }],
140
+ });
141
+ const errors = validateRegistry(registry);
142
+ expect(errors.some(e => e.code === 'ACTION_IDEMPOTENT_INVALID')).toBe(true);
143
+ });
144
+ it('rejects missing exposure', () => {
145
+ const registry = minimalValidRegistry({
146
+ actions: [{
147
+ id: 'no-exposure',
148
+ kind: 'action',
149
+ handlerFile: 'src/actions/no-exp.ts',
150
+ backendAccess: 'private',
151
+ idempotent: false,
152
+ }],
153
+ });
154
+ const errors = validateRegistry(registry);
155
+ expect(errors.some(e => e.code === 'ACTION_EXPOSURE_MISSING')).toBe(true);
156
+ });
157
+ it('rejects invalid exposure type', () => {
158
+ const registry = minimalValidRegistry({
159
+ actions: [{
160
+ id: 'bad-exp-type',
161
+ kind: 'action',
162
+ handlerFile: 'src/actions/bad-exp.ts',
163
+ backendAccess: 'private',
164
+ idempotent: false,
165
+ exposure: { type: 'hybrid' },
166
+ }],
167
+ });
168
+ const errors = validateRegistry(registry);
169
+ expect(errors.some(e => e.code === 'ACTION_EXPOSURE_TYPE_INVALID')).toBe(true);
170
+ });
171
+ it('rejects internal exposure with extra keys', () => {
172
+ const registry = minimalValidRegistry({
173
+ actions: [{
174
+ id: 'internal-extra',
175
+ kind: 'action',
176
+ handlerFile: 'src/actions/extra.ts',
177
+ backendAccess: 'private',
178
+ idempotent: false,
179
+ exposure: { type: 'internal', path: '/v1/secret' },
180
+ }],
181
+ });
182
+ const errors = validateRegistry(registry);
183
+ expect(errors.some(e => e.code === 'INTERNAL_EXPOSURE_EXTRA_KEYS')).toBe(true);
184
+ });
185
+ });
186
+ describe('API exposure validation', () => {
187
+ it('rejects API action missing path', () => {
188
+ const registry = minimalValidRegistry({
189
+ actions: [validApiAction({ exposure: { type: 'api', method: 'GET', auth: 'required', tenancy: 'required' } })],
190
+ });
191
+ const errors = validateRegistry(registry);
192
+ expect(errors.some(e => e.code === 'API_PATH_INVALID')).toBe(true);
193
+ });
194
+ it('rejects API action with path not starting with /', () => {
195
+ const registry = minimalValidRegistry({
196
+ actions: [validApiAction({ exposure: { type: 'api', path: 'v1/test', method: 'GET', auth: 'required', tenancy: 'required' } })],
197
+ });
198
+ const errors = validateRegistry(registry);
199
+ expect(errors.some(e => e.code === 'API_PATH_INVALID')).toBe(true);
200
+ });
201
+ it('rejects API action missing method', () => {
202
+ const registry = minimalValidRegistry({
203
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/test', auth: 'required', tenancy: 'required' } })],
204
+ });
205
+ const errors = validateRegistry(registry);
206
+ expect(errors.some(e => e.code === 'API_METHOD_INVALID')).toBe(true);
207
+ });
208
+ it('rejects invalid HTTP method', () => {
209
+ const registry = minimalValidRegistry({
210
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/test', method: 'INVALID', auth: 'required', tenancy: 'required' } })],
211
+ });
212
+ const errors = validateRegistry(registry);
213
+ expect(errors.some(e => e.code === 'INVALID_HTTP_METHOD')).toBe(true);
214
+ });
215
+ it('rejects API action missing auth', () => {
216
+ const registry = minimalValidRegistry({
217
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/test', method: 'GET', tenancy: 'required' } })],
218
+ });
219
+ const errors = validateRegistry(registry);
220
+ expect(errors.some(e => e.code === 'API_AUTH_INVALID')).toBe(true);
221
+ });
222
+ it('rejects API action missing tenancy', () => {
223
+ const registry = minimalValidRegistry({
224
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/test', method: 'GET', auth: 'required' } })],
225
+ });
226
+ const errors = validateRegistry(registry);
227
+ expect(errors.some(e => e.code === 'API_TENANCY_INVALID')).toBe(true);
228
+ });
229
+ });
230
+ describe('input/output schema validation for API actions', () => {
231
+ it('rejects API action lacking inputSchema', () => {
232
+ const registry = minimalValidRegistry({
233
+ actions: [{
234
+ id: 'no-input',
235
+ kind: 'action',
236
+ handlerFile: 'src/actions/no-input.ts',
237
+ backendAccess: 'domain',
238
+ idempotent: false,
239
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/test', method: 'GET', auth: 'required', tenancy: 'required' },
240
+ outputSchema: { type: 'object', properties: { id: { type: 'string' } } },
241
+ }],
242
+ });
243
+ const errors = validateRegistry(registry);
244
+ expect(errors.some(e => e.code === 'API_INPUT_SCHEMA_REQUIRED')).toBe(true);
245
+ });
246
+ it('rejects API action lacking outputSchema', () => {
247
+ const registry = minimalValidRegistry({
248
+ actions: [{
249
+ id: 'no-output',
250
+ kind: 'action',
251
+ handlerFile: 'src/actions/no-output.ts',
252
+ backendAccess: 'domain',
253
+ idempotent: false,
254
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/test', method: 'GET', auth: 'required', tenancy: 'required' },
255
+ inputSchema: { type: 'object', properties: { foo: { type: 'string' } } },
256
+ }],
257
+ });
258
+ const errors = validateRegistry(registry);
259
+ expect(errors.some(e => e.code === 'API_OUTPUT_SCHEMA_REQUIRED')).toBe(true);
260
+ });
261
+ it('accepts API action with empty inputSchema object (empty schema is valid)', () => {
262
+ const registry = minimalValidRegistry({
263
+ actions: [{
264
+ id: 'empty-input',
265
+ kind: 'action',
266
+ handlerFile: 'src/actions/empty-input.ts',
267
+ backendAccess: 'domain',
268
+ idempotent: false,
269
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/test', method: 'GET', auth: 'required', tenancy: 'required' },
270
+ inputSchema: {},
271
+ outputSchema: { type: 'object', properties: { id: { type: 'string' } } },
272
+ }],
273
+ });
274
+ const errors = validateRegistry(registry);
275
+ expect(errors.some(e => e.code === 'API_INPUT_SCHEMA_REQUIRED')).toBe(false);
276
+ });
277
+ it('accepts API action with empty outputSchema object (empty schema is valid)', () => {
278
+ const registry = minimalValidRegistry({
279
+ actions: [{
280
+ id: 'empty-output',
281
+ kind: 'action',
282
+ handlerFile: 'src/actions/empty-output.ts',
283
+ backendAccess: 'domain',
284
+ idempotent: false,
285
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/test', method: 'GET', auth: 'required', tenancy: 'required' },
286
+ inputSchema: { type: 'object', properties: { foo: { type: 'string' } } },
287
+ outputSchema: {},
288
+ }],
289
+ });
290
+ const errors = validateRegistry(registry);
291
+ expect(errors.some(e => e.code === 'API_OUTPUT_SCHEMA_REQUIRED')).toBe(false);
292
+ });
293
+ });
294
+ describe('security exception validation', () => {
295
+ it('rejects auth: none without securityException.reason', () => {
296
+ const registry = minimalValidRegistry({
297
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/health', method: 'GET', auth: 'none', tenancy: 'none' }, inputSchema: { type: 'object', properties: {} }, outputSchema: { type: 'object', properties: {} } })],
298
+ });
299
+ const errors = validateRegistry(registry);
300
+ expect(errors.some(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION')).toBe(true);
301
+ });
302
+ it('accepts auth: none with valid securityException.reason', () => {
303
+ const registry = minimalValidRegistry({
304
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/health', method: 'GET', auth: 'none', tenancy: 'none', securityException: { reason: 'public health check; ticket OPS-123' } } })],
305
+ });
306
+ const errors = validateRegistry(registry);
307
+ expect(errors.some(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION')).toBe(false);
308
+ });
309
+ it('rejects auth: none with whitespace-only securityException.reason', () => {
310
+ const registry = minimalValidRegistry({
311
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/health', method: 'GET', auth: 'none', tenancy: 'none', securityException: { reason: ' ' } } })],
312
+ });
313
+ const errors = validateRegistry(registry);
314
+ expect(errors.some(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION')).toBe(true);
315
+ });
316
+ it('rejects tenancy: none without securityException.reason', () => {
317
+ const registry = minimalValidRegistry({
318
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/public/lookup', method: 'GET', auth: 'required', tenancy: 'none' } })],
319
+ });
320
+ const errors = validateRegistry(registry);
321
+ expect(errors.some(e => e.code === 'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC')).toBe(true);
322
+ });
323
+ it('accepts tenancy: none with valid securityException.reason', () => {
324
+ const registry = minimalValidRegistry({
325
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/public/lookup', method: 'GET', auth: 'required', tenancy: 'none', securityException: { reason: 'no-tenant lookup; ticket OPS-456' } } })],
326
+ });
327
+ const errors = validateRegistry(registry);
328
+ expect(errors.some(e => e.code === 'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC')).toBe(false);
329
+ });
330
+ it('rejects system tenancy + required auth without roles', () => {
331
+ const registry = minimalValidRegistry({
332
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/admin/ping', method: 'GET', auth: 'required', tenancy: 'system' } })],
333
+ });
334
+ const errors = validateRegistry(registry);
335
+ expect(errors.some(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBe(true);
336
+ });
337
+ it('accepts system tenancy + required auth with roles', () => {
338
+ const registry = minimalValidRegistry({
339
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/admin/ping', method: 'GET', auth: 'required', tenancy: 'system', roles: ['sys-admin'] } })],
340
+ });
341
+ const errors = validateRegistry(registry);
342
+ expect(errors.some(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBe(false);
343
+ });
344
+ it('accepts system tenancy + service auth without roles (service-only route)', () => {
345
+ const registry = minimalValidRegistry({
346
+ actions: [validApiAction({ exposure: { type: 'api', path: '/v1/admin/replicate', method: 'POST', auth: 'service', tenancy: 'system' } })],
347
+ });
348
+ const errors = validateRegistry(registry);
349
+ expect(errors.some(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBe(false);
350
+ });
351
+ });
352
+ });
package/dist/index.d.ts CHANGED
@@ -29,6 +29,8 @@ export { DomainStack } from './DomainStack.js';
29
29
  export type { DomainStackProps } from './DomainStack.js';
30
30
  export { packDomain } from './pack-domain.js';
31
31
  export type { PackDomainOptions } from './pack-domain.js';
32
+ export { validateRegistry } from './validate-registry.js';
33
+ export type { RegistryValidationError } from './validate-registry.js';
32
34
  export { StepFunctionsCodegen } from './step-functions-codegen.js';
33
35
  export type { DomainActionFlowNode, StepFunctionsTaskState } from './step-functions-codegen.js';
34
36
  export type { FlowRegistry, FlowRegistryEntry, SerialFlowStep, SerialDomainActionStep, SerialDomainApiStep, SerialDomainEventStep, SerialDomainQueryStep, SerialAwsServiceStep, SerialFlowControlStep } from './flow-registry.js';
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export { AlarmConstruct } from './constructs/alarm-construct.js';
13
13
  export { CanaryConstruct } from './constructs/canary-construct.js';
14
14
  export { DomainStack } from './DomainStack.js';
15
15
  export { packDomain } from './pack-domain.js';
16
+ export { validateRegistry } from './validate-registry.js';
16
17
  export { StepFunctionsCodegen } from './step-functions-codegen.js';
17
18
  export { FlowsStack, packFlows } from './pack-flows.js';
18
19
  export { domainResourceName } from './naming.js';
@@ -1,4 +1,5 @@
1
1
  import { DomainStack } from './DomainStack.js';
2
+ import { validateRegistry } from './validate-registry.js';
2
3
  /**
3
4
  * Convenience entry-point: constructs a DomainStack from a compiled registry.
4
5
  * Intended to be called from a CDK app entrypoint (e.g. bin/app.ts in the project's infra).
@@ -11,6 +12,16 @@ import { DomainStack } from './DomainStack.js';
11
12
  * @returns The constructed DomainStack.
12
13
  */
13
14
  export function packDomain(registry, app, stackId, eventBusArn, options) {
15
+ // Validate registry before any CDK constructs are created (#5090)
16
+ const validationErrors = validateRegistry(registry);
17
+ if (validationErrors.length > 0) {
18
+ const lines = validationErrors.map(e => {
19
+ const code = e.code ? ` [${e.code}]` : '';
20
+ return ` [${e.field}]${code} ${e.message}`;
21
+ });
22
+ throw new Error(`Registry validation failed for domain '${registry.domain?.id ?? '<unknown>'}':\n${lines.join('\n')}\n\n` +
23
+ `Fix these issues in your domain source before deploying.`);
24
+ }
14
25
  // Support both old (env as 5th arg) and new (options object) calling conventions
15
26
  // Check if options looks like a CDK Environment (has 'account' and/or 'region' props, not the full options interface)
16
27
  const opts = options &&
@@ -0,0 +1,38 @@
1
+ import type { DomainRegistry } from './registry.js';
2
+ /**
3
+ * Validation error detail.
4
+ */
5
+ export interface RegistryValidationError {
6
+ /** Domain ID from the registry. */
7
+ domainId: string;
8
+ /** Entry ID (action id, etc). */
9
+ entryId: string;
10
+ /** Field path that failed validation. */
11
+ field: string;
12
+ /** Stable error code for CI/tooling correlation. */
13
+ code?: string;
14
+ /** Human-readable error message. */
15
+ message: string;
16
+ }
17
+ /**
18
+ * Validate a DomainRegistry against the strict contract (#5090) before
19
+ * any CDK constructs are created.
20
+ *
21
+ * Checks:
22
+ * - Every action has `kind: 'action'`, `id`, `handlerFile`, `backendAccess`,
23
+ * `idempotent`, and explicit `exposure`.
24
+ * - API exposure requires non-empty `path`, valid HTTP `method`, valid
25
+ * `auth`, valid `tenancy`.
26
+ * - API-exposed actions must have non-empty `inputSchema` and `outputSchema`.
27
+ * - `auth: 'none'` requires a `securityException.reason`.
28
+ * - `tenancy: 'none'` requires a `securityException.reason`.
29
+ * - `tenancy: 'system'` + `auth: 'required'` requires non-empty `roles`.
30
+ * - Internal exposure must be exactly `{type:'internal'}`.
31
+ * - Path must start with `/`.
32
+ * - No null/undefined entries in arrays.
33
+ * - Schema version contract is honoured.
34
+ *
35
+ * @param registry The compiled domain registry.
36
+ * @returns Array of validation errors. Empty array means valid.
37
+ */
38
+ export declare function validateRegistry(registry: DomainRegistry): RegistryValidationError[];
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Allowed HTTP methods for API-exposed actions.
3
+ */
4
+ const ALLOWED_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
5
+ /**
6
+ * Validate a DomainRegistry against the strict contract (#5090) before
7
+ * any CDK constructs are created.
8
+ *
9
+ * Checks:
10
+ * - Every action has `kind: 'action'`, `id`, `handlerFile`, `backendAccess`,
11
+ * `idempotent`, and explicit `exposure`.
12
+ * - API exposure requires non-empty `path`, valid HTTP `method`, valid
13
+ * `auth`, valid `tenancy`.
14
+ * - API-exposed actions must have non-empty `inputSchema` and `outputSchema`.
15
+ * - `auth: 'none'` requires a `securityException.reason`.
16
+ * - `tenancy: 'none'` requires a `securityException.reason`.
17
+ * - `tenancy: 'system'` + `auth: 'required'` requires non-empty `roles`.
18
+ * - Internal exposure must be exactly `{type:'internal'}`.
19
+ * - Path must start with `/`.
20
+ * - No null/undefined entries in arrays.
21
+ * - Schema version contract is honoured.
22
+ *
23
+ * @param registry The compiled domain registry.
24
+ * @returns Array of validation errors. Empty array means valid.
25
+ */
26
+ export function validateRegistry(registry) {
27
+ const errors = [];
28
+ const domainId = registry.domain?.id ?? '<unknown>';
29
+ if (!registry.domain) {
30
+ errors.push({ domainId, entryId: '', field: 'domain', code: 'DOMAIN_MISSING', message: 'Registry has no domain entry.' });
31
+ return errors;
32
+ }
33
+ // Schema version contract
34
+ if (registry.schemaVersion !== '1') {
35
+ errors.push({
36
+ domainId, entryId: '', field: 'schemaVersion', code: 'SCHEMA_VERSION',
37
+ message: `Registry schemaVersion must be '1'. Got: ${JSON.stringify(registry.schemaVersion)}`,
38
+ });
39
+ }
40
+ // Validate every action entry
41
+ for (let i = 0; i < (registry.actions ?? []).length; i++) {
42
+ const action = registry.actions[i];
43
+ const entryId = action?.id ?? `[index ${i}]`;
44
+ if (action == null) {
45
+ errors.push({
46
+ domainId, entryId, field: 'actions', code: 'ACTION_NULL',
47
+ message: `Action at index ${i} is null or undefined. All action entries must be valid objects.`,
48
+ });
49
+ continue;
50
+ }
51
+ // kind must be 'action'
52
+ if (action.kind !== 'action') {
53
+ errors.push({
54
+ domainId, entryId, field: 'kind', code: 'ACTION_KIND_INVALID',
55
+ message: `Action '${entryId}' has kind '${action.kind}', expected 'action'.`,
56
+ });
57
+ }
58
+ // id must be non-empty
59
+ if (!action.id || typeof action.id !== 'string') {
60
+ errors.push({
61
+ domainId, entryId, field: 'id', code: 'ACTION_ID_MISSING',
62
+ message: `Action at index ${i} has missing or invalid id.`,
63
+ });
64
+ }
65
+ // handlerFile must be non-empty
66
+ if (!action.handlerFile || typeof action.handlerFile !== 'string') {
67
+ errors.push({
68
+ domainId, entryId, field: 'handlerFile', code: 'ACTION_HANDLER_MISSING',
69
+ message: `Action '${entryId}' is missing handlerFile (required).`,
70
+ });
71
+ }
72
+ // backendAccess must be valid
73
+ const validBackendAccess = ['private', 'domain', 'platform'];
74
+ if (!action.backendAccess || !validBackendAccess.includes(action.backendAccess)) {
75
+ errors.push({
76
+ domainId, entryId, field: 'backendAccess', code: 'ACTION_BACKEND_ACCESS_INVALID',
77
+ message: `Action '${entryId}' has invalid backendAccess '${action.backendAccess}'. Must be one of: ${validBackendAccess.join(', ')}.`,
78
+ });
79
+ }
80
+ // idempotent must be boolean
81
+ if (typeof action.idempotent !== 'boolean') {
82
+ errors.push({
83
+ domainId, entryId, field: 'idempotent', code: 'ACTION_IDEMPOTENT_INVALID',
84
+ message: `Action '${entryId}' has invalid idempotent '${action.idempotent}'. Must be a boolean.`,
85
+ });
86
+ }
87
+ // exposure is required
88
+ if (!action.exposure || typeof action.exposure !== 'object') {
89
+ errors.push({
90
+ domainId, entryId, field: 'exposure', code: 'ACTION_EXPOSURE_MISSING',
91
+ message: `Action '${entryId}' is missing exposure. Every action must declare exposure explicitly.`,
92
+ });
93
+ continue;
94
+ }
95
+ const exposure = action.exposure;
96
+ // Must be 'api' or 'internal'
97
+ if (exposure.type !== 'api' && exposure.type !== 'internal') {
98
+ errors.push({
99
+ domainId, entryId, field: 'exposure.type', code: 'ACTION_EXPOSURE_TYPE_INVALID',
100
+ message: `Action '${entryId}' has exposure.type '${String(exposure.type)}'. Must be 'api' or 'internal'.`,
101
+ });
102
+ continue;
103
+ }
104
+ if (exposure.type === 'internal') {
105
+ // Internal must have no extra keys
106
+ const extraKeys = Object.keys(exposure).filter(k => k !== 'type');
107
+ if (extraKeys.length > 0) {
108
+ errors.push({
109
+ domainId, entryId, field: 'exposure', code: 'INTERNAL_EXPOSURE_EXTRA_KEYS',
110
+ message: `Action '${entryId}' has internal exposure with unexpected keys: ${extraKeys.join(', ')}. Internal exposure must be exactly { type: 'internal' }.`,
111
+ });
112
+ }
113
+ }
114
+ else if (exposure.type === 'api') {
115
+ // API requires path, method, auth, tenancy
116
+ // Path must be a non-empty string starting with '/'
117
+ if (typeof exposure.path !== 'string' || !exposure.path) {
118
+ errors.push({
119
+ domainId, entryId, field: 'exposure.path', code: 'API_PATH_INVALID',
120
+ message: `Action '${entryId}' has api exposure but path is missing or invalid.`,
121
+ });
122
+ }
123
+ else if (!exposure.path.startsWith('/')) {
124
+ errors.push({
125
+ domainId, entryId, field: 'exposure.path', code: 'API_PATH_INVALID',
126
+ message: `Action '${entryId}' has api exposure but path '${exposure.path}' does not start with '/'.`,
127
+ });
128
+ }
129
+ // Method must be a non-empty string and one of the allowed HTTP methods
130
+ if (typeof exposure.method !== 'string' || !exposure.method) {
131
+ errors.push({
132
+ domainId, entryId, field: 'exposure.method', code: 'API_METHOD_INVALID',
133
+ message: `Action '${entryId}' has api exposure but method is missing or invalid.`,
134
+ });
135
+ }
136
+ else if (!ALLOWED_HTTP_METHODS.includes(exposure.method)) {
137
+ errors.push({
138
+ domainId, entryId, field: 'exposure.method', code: 'INVALID_HTTP_METHOD',
139
+ message: `Action '${entryId}' has invalid HTTP method '${exposure.method}'. Must be one of: ${ALLOWED_HTTP_METHODS.join(', ')}.`,
140
+ });
141
+ }
142
+ // Auth must be valid
143
+ if (!['required', 'none', 'service'].includes(exposure.auth)) {
144
+ errors.push({
145
+ domainId, entryId, field: 'exposure.auth', code: 'API_AUTH_INVALID',
146
+ message: `Action '${entryId}' has api exposure but auth is missing or invalid. Must be 'required', 'none', or 'service'.`,
147
+ });
148
+ }
149
+ // Tenancy must be valid
150
+ if (!['required', 'none', 'system'].includes(exposure.tenancy)) {
151
+ errors.push({
152
+ domainId, entryId, field: 'exposure.tenancy', code: 'API_TENANCY_INVALID',
153
+ message: `Action '${entryId}' has api exposure but tenancy is missing or invalid. Must be 'required', 'none', or 'system'.`,
154
+ });
155
+ }
156
+ // API-exposed actions must have inputSchema
157
+ if (!action.inputSchema || typeof action.inputSchema !== 'object') {
158
+ errors.push({
159
+ domainId, entryId, field: 'inputSchema', code: 'API_INPUT_SCHEMA_REQUIRED',
160
+ message: `Action '${entryId}' is API-exposed but has no \`inputSchema\`. API-exposed actions must declare input/output Zod schemas.`,
161
+ });
162
+ }
163
+ // API-exposed actions must have outputSchema
164
+ if (!action.outputSchema || typeof action.outputSchema !== 'object') {
165
+ errors.push({
166
+ domainId, entryId, field: 'outputSchema', code: 'API_OUTPUT_SCHEMA_REQUIRED',
167
+ message: `Action '${entryId}' is API-exposed but has no \`outputSchema\`. API-exposed actions must declare input/output Zod schemas.`,
168
+ });
169
+ }
170
+ // AUTH_NONE_REQUIRES_EXCEPTION — auth: 'none' must carry a securityException with non-empty reason
171
+ if (exposure.auth === 'none') {
172
+ const apiExposure = exposure;
173
+ const reason = apiExposure.securityException?.reason;
174
+ if (!reason || reason.trim().length === 0) {
175
+ errors.push({
176
+ domainId, entryId, field: 'exposure.securityException', code: 'AUTH_NONE_REQUIRES_EXCEPTION',
177
+ message: `Action '${entryId}' has \`exposure.auth: 'none'\` but no \`exposure.securityException.reason\`. Public/anonymous routes must document the security exception with a non-empty reason.`,
178
+ });
179
+ }
180
+ }
181
+ // TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC — tenancy: 'none' must carry a securityException with non-empty reason
182
+ if (exposure.tenancy === 'none') {
183
+ const apiExposure = exposure;
184
+ const reason = apiExposure.securityException?.reason;
185
+ if (!reason || reason.trim().length === 0) {
186
+ errors.push({
187
+ domainId, entryId, field: 'exposure.securityException', code: 'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC',
188
+ message: `Action '${entryId}' has \`exposure.tenancy: 'none'\` but no \`exposure.securityException.reason\`. Routes without tenant context must document the security exception with a non-empty reason.`,
189
+ });
190
+ }
191
+ }
192
+ // SYSTEM_API_REQUIRES_ROLE — tenancy: 'system' + auth: 'required' requires non-empty roles
193
+ if (exposure.tenancy === 'system' && exposure.auth === 'required') {
194
+ const apiExposure = exposure;
195
+ if (!Array.isArray(apiExposure.roles) || apiExposure.roles.length === 0) {
196
+ errors.push({
197
+ domainId, entryId, field: 'exposure.roles', code: 'SYSTEM_API_REQUIRES_ROLE',
198
+ message: `Action '${entryId}' has \`exposure.tenancy: 'system'\` and \`exposure.auth: 'required'\` but no \`exposure.roles\`. System-tenancy routes using user auth must declare at least one required role.`,
199
+ });
200
+ }
201
+ }
202
+ }
203
+ }
204
+ return errors;
205
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cdk-packer",
3
- "version": "0.2.87",
3
+ "version": "0.2.88",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",