@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.
- package/dist/DomainStack.js +11 -0
- package/dist/__tests__/domain-stack.test.js +14 -0
- package/dist/__tests__/pack-domain.test.d.ts +1 -0
- package/dist/__tests__/pack-domain.test.js +325 -0
- package/dist/__tests__/security-assertion-aspect.test.js +88 -20
- package/dist/__tests__/validate-registry.test.d.ts +1 -0
- package/dist/__tests__/validate-registry.test.js +352 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/pack-domain.js +11 -0
- package/dist/validate-registry.d.ts +38 -0
- package/dist/validate-registry.js +205 -0
- package/package.json +1 -1
package/dist/DomainStack.js
CHANGED
|
@@ -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';
|
package/dist/pack-domain.js
CHANGED
|
@@ -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
|
+
}
|