@mettlecast/domain-cdk-packer 0.2.61 → 0.2.63

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.
@@ -82,6 +82,11 @@ export interface DomainStackProps extends cdk.StackProps {
82
82
  * Defaults to the second segment of the stack name; falls back to `dev` when not derivable.
83
83
  */
84
84
  envCode?: string;
85
+ /**
86
+ * When true, disables the auto-generated per-domain CloudWatch dashboard.
87
+ * Existing dashboards will be removed on the next CDK deploy.
88
+ */
89
+ disableCloudWatchDashboards?: boolean;
85
90
  }
86
91
  export declare class DomainStack extends cdk.Stack {
87
92
  /** Shared HTTP API for routing API and webhook requests. */
@@ -64,7 +64,7 @@ export class DomainStack extends cdk.Stack {
64
64
  */
65
65
  constructor(scope, id, props) {
66
66
  super(scope, id, props);
67
- const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, userPoolArn, userPoolId, userPoolClientId, internalSubnetSelection, internetSubnetSelection, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials } = props;
67
+ const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, userPoolArn, userPoolId, userPoolClientId, internalSubnetSelection, internetSubnetSelection, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials, disableCloudWatchDashboards } = props;
68
68
  const vpc = props.vpc ?? (props.vpcId
69
69
  ? ec2.Vpc.fromVpcAttributes(this, 'SharedVpc', {
70
70
  vpcId: props.vpcId,
@@ -124,12 +124,15 @@ export class DomainStack extends cdk.Stack {
124
124
  // Determine whether any route in this domain requires a JWT authorizer.
125
125
  // If so and `jwtAuth` is undefined, emit a synth-time error so missing Cognito
126
126
  // configuration fails fast instead of producing an unauthenticated route.
127
- const apiNeedsJwt = registry.apis.some(api => api.authType === 'jwt');
127
+ //
128
+ // Issue #4689: only API-exposed actions (`exposure.auth === 'required'`)
129
+ // contribute to this check. Legacy `registry.apis` rows are always
130
+ // empty in fresh registries, so checking them would never fire.
128
131
  const actionNeedsJwt = registry.actions.some(a => a.exposure?.type === 'api' && a.exposure.auth === 'required');
129
- const anyRouteRequiresJwt = apiNeedsJwt || actionNeedsJwt;
132
+ const anyRouteRequiresJwt = actionNeedsJwt;
130
133
  if (anyRouteRequiresJwt && !jwtAuth) {
131
134
  cdk.Annotations.of(this).addError(`[DomainStack] Domain "${domainId}" declares one or more JWT-protected routes ` +
132
- `(api.authType='jwt' or action.exposure.auth='required') but no Cognito user pool is ` +
135
+ `(action.exposure.auth='required') but no Cognito user pool is ` +
133
136
  `configured. Provide userPoolArn/userPoolId AND userPoolClientId to DomainStackProps.`);
134
137
  }
135
138
  // Issue #4662 Task D — deployment-time security assertions. The aspect
@@ -190,7 +193,6 @@ export class DomainStack extends cdk.Stack {
190
193
  const hasApiExposedAction = primitiveType === 'action'
191
194
  && group.entries.some(e => e.adapter === 'createExposedActionApiHandler');
192
195
  const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated')
193
- || primitiveType === 'api'
194
196
  || hasApiExposedAction;
195
197
  const groupLambdas = createGroupedLambdas(this, {
196
198
  domainId,
@@ -266,7 +268,6 @@ export class DomainStack extends cdk.Stack {
266
268
  });
267
269
  environment['DOMAIN_TENANT_ROLE_ARN'] = tenantScopedRole.roleArn;
268
270
  // Pre-declare lambda group variables
269
- let apiLambdas = [];
270
271
  let webhookLambdas = [];
271
272
  let subscriberLambdas = [];
272
273
  let scheduleLambdas = [];
@@ -275,33 +276,18 @@ export class DomainStack extends cdk.Stack {
275
276
  // Track all DLQs for alarm construct
276
277
  const allDlqs = [];
277
278
  // Deploy API endpoints as grouped Lambdas
278
- if (registry.apis.length > 0) {
279
- const apiHandlers = createPrimitiveHandlers(registry.apis, 'api');
280
- const apiLambdaById = apiHandlers.byId;
281
- apiLambdas = apiHandlers.lambdas;
282
- this.lambdaArns[`${domainId}-api`] = apiLambdas[0].functionArn;
283
- // Wire each API Lambda to the HttpApi
284
- const iamPolicies = iamBuilder.forApi();
285
- apiLambdas.forEach((fn) => {
286
- iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
287
- });
288
- // Add dbSecretArn grant if provided
289
- if (dbSecretArn) {
290
- apiLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
291
- actions: ['secretsmanager:GetSecretValue'],
292
- resources: [dbSecretArn],
293
- })));
294
- }
295
- // Add routes for each API entry
296
- for (const api of registry.apis) {
297
- const fn = apiLambdaById.get(api.id);
298
- if (api.authType === 'api-key') {
299
- cdk.Annotations.of(this).addWarning(`[DomainStack] API "${api.id}" uses authType "api-key" which is not supported on HTTP API v2 — route is UNAUTHENTICATED. Switch to "jwt" or implement a Lambda authorizer.`);
300
- }
301
- const routeAuth = api.authType === 'jwt' ? jwtAuth : undefined;
302
- addRouteToApi(this, fn, `/${domainId}${api.path}`, [toHttpMethod(api.method)], this.httpApi.httpApiId, routeAuth);
303
- }
304
- }
279
+ //
280
+ // Issue #4689: the legacy `defineApi` primitive was removed. The
281
+ // canonical HTTP endpoint surface is `actions[]` with
282
+ // `exposure.type === 'api'`. Each API-exposed action is wrapped with
283
+ // `createExposedActionApiHandler` (see grouped-lambda-factory) and
284
+ // mounted on the HTTP API in the action-routing loop below. The
285
+ // legacy `registry.apis` block has been removed because the builder
286
+ // no longer populates that slot in fresh registries.
287
+ //
288
+ // We intentionally do NOT iterate `registry.apis` here; doing so
289
+ // would re-introduce legacy routing that is no longer reachable
290
+ // from the action-first source-of-truth.
305
291
  // Deploy webhooks as grouped Lambdas
306
292
  if (registry.webhooks.length > 0) {
307
293
  // Create shared dedupe table for webhooks
@@ -520,7 +506,7 @@ export class DomainStack extends cdk.Stack {
520
506
  }
521
507
  // Grant all domain Lambdas read/write access to the per-domain table and bucket
522
508
  const allDomainLambdas = [
523
- ...apiLambdas, ...webhookLambdas, ...subscriberLambdas,
509
+ ...webhookLambdas, ...subscriberLambdas,
524
510
  ...scheduleLambdas, ...jobLambdas, ...actionLambdas,
525
511
  ];
526
512
  for (const fn of allDomainLambdas) {
@@ -590,19 +576,30 @@ export class DomainStack extends cdk.Stack {
590
576
  addRouteToApi(this, healthConstruct.healthFn, `/${domainId}/_health`, [apigwv2.HttpMethod.GET], this.httpApi.httpApiId);
591
577
  addRouteToApi(this, healthConstruct.readyFn, `/${domainId}/_ready`, [apigwv2.HttpMethod.GET], this.httpApi.httpApiId);
592
578
  // Create CloudWatch dashboard for all primitives
579
+ //
580
+ // Issue #4689: HTTP endpoints are now action entries with
581
+ // `exposure.type === 'api'`. We tag those actions with the
582
+ // `primitiveClass: 'api'` so the dashboard can still group them
583
+ // separately from internal-only actions.
593
584
  const allEndpoints = [
594
- ...registry.apis.map(a => ({ id: a.id, primitiveClass: 'api' })),
595
- ...registry.actions.map(a => ({ id: a.id, primitiveClass: 'action' })),
585
+ ...registry.actions
586
+ .filter(a => a.exposure?.type === 'api')
587
+ .map(a => ({ id: a.id, primitiveClass: 'api' })),
588
+ ...registry.actions
589
+ .filter(a => a.exposure?.type !== 'api')
590
+ .map(a => ({ id: a.id, primitiveClass: 'action' })),
596
591
  ...registry.subscribers.map(s => ({ id: s.id, primitiveClass: 'subscriber' })),
597
592
  ...registry.jobs.map(j => ({ id: j.id, primitiveClass: 'job' })),
598
593
  ...registry.webhooks.map(w => ({ id: w.id, primitiveClass: 'webhook' })),
599
594
  ...registry.schedules.map(sc => ({ id: sc.id, primitiveClass: 'schedule' })),
600
595
  ];
601
- new DashboardConstruct(this, 'Dashboard', {
602
- domainId,
603
- envCode: this.stackName.split('-')[1] ?? 'dev',
604
- endpoints: allEndpoints,
605
- });
596
+ if (!disableCloudWatchDashboards) {
597
+ new DashboardConstruct(this, 'Dashboard', {
598
+ domainId,
599
+ envCode: this.stackName.split('-')[1] ?? 'dev',
600
+ endpoints: allEndpoints,
601
+ });
602
+ }
606
603
  new cdk.CfnOutput(this, 'DomainTableName', {
607
604
  value: domainTable.tableName,
608
605
  description: 'Per-domain DynamoDB table name',
@@ -11,7 +11,7 @@ const baseRegistry = {
11
11
  schemaVersion: '1',
12
12
  domainRoot: DOMAIN_ROOT,
13
13
  domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
14
- apis: [], webhooks: [], subscribers: [], schedules: [], jobs: [],
14
+ webhooks: [], subscribers: [], schedules: [], jobs: [],
15
15
  actions: [], integrations: [], events: [],
16
16
  };
17
17
  beforeAll(() => {
@@ -22,32 +22,44 @@ const minimalRegistry = {
22
22
  schemaVersion: '1',
23
23
  domainRoot: DOMAIN_ROOT,
24
24
  domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
25
- apis: [
25
+ // Issue #4689: the canonical HTTP endpoint surface is actions[] with
26
+ // exposure.type === 'api'. The minimal registry now declares its
27
+ // single test endpoint as an action; apis is always empty.
28
+ webhooks: [],
29
+ subscribers: [],
30
+ schedules: [],
31
+ jobs: [],
32
+ actions: [
26
33
  {
27
34
  id: 'get-users',
28
- kind: 'api',
35
+ kind: 'action',
29
36
  handlerFile: 'src/handlers/get-users.ts',
30
- path: '/users',
31
- method: 'GET',
32
- authType: 'jwt',
37
+ backendAccess: 'domain',
38
+ exposure: {
39
+ type: 'api',
40
+ path: '/v1/tenants/{tenantId}/users',
41
+ method: 'GET',
42
+ auth: 'required',
43
+ tenancy: 'required',
44
+ },
45
+ idempotent: false,
33
46
  },
34
47
  ],
35
- webhooks: [],
36
- subscribers: [],
37
- schedules: [],
38
- jobs: [],
39
- actions: [],
40
48
  integrations: [],
41
49
  events: [],
42
50
  };
43
51
  const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
44
52
  fs.mkdirSync(handlerDir, { recursive: true });
45
- fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), 'export const getUsers = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
46
- 'export const createUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
47
- 'export const replaceUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
48
- 'export const patchUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
49
- 'export const deleteUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
50
- 'export const listThings = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n');
53
+ // Match the grouped-lambda-factory's export-name conversion: kebab-case
54
+ // ids map to camelCase export names (e.g. `get-users` `getUsers`).
55
+ const toExportName = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
56
+ const handlerStub = (id, method, path, auth = 'required', securityException) => `export const ${toExportName(id)} = { id: ${JSON.stringify(id)}, backendAccess: "domain", exposure: { type: "api", path: ${JSON.stringify(path)}, method: ${JSON.stringify(method)}, auth: ${JSON.stringify(auth)}, tenancy: "required"${securityException ? `, securityException: { reason: ${JSON.stringify(securityException)} }` : ''} }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n`;
57
+ fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), handlerStub('get-users', 'GET', '/v1/tenants/{tenantId}/users'));
58
+ fs.writeFileSync(path.join(handlerDir, 'create-user.ts'), handlerStub('create-user', 'POST', '/v1/tenants/{tenantId}/users'));
59
+ fs.writeFileSync(path.join(handlerDir, 'replace-user.ts'), handlerStub('replace-user', 'PUT', '/v1/tenants/{tenantId}/users/{id}'));
60
+ fs.writeFileSync(path.join(handlerDir, 'patch-user.ts'), handlerStub('patch-user', 'PATCH', '/v1/tenants/{tenantId}/users/{id}'));
61
+ fs.writeFileSync(path.join(handlerDir, 'delete-user.ts'), handlerStub('delete-user', 'DELETE', '/v1/tenants/{tenantId}/users/{id}'));
62
+ fs.writeFileSync(path.join(handlerDir, 'list-things.ts'), handlerStub('list-things', 'GET', '/v1/tenants/{tenantId}/things', 'none', 'public read-only listing'));
51
63
  afterAll(() => {
52
64
  fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
53
65
  });
@@ -84,14 +96,22 @@ describe('DomainStack', () => {
84
96
  AuthorizerType: 'JWT',
85
97
  });
86
98
  });
87
- it('no authorizer attached when authType is none', () => {
99
+ it('no authorizer attached when no JWT-protected actions are declared', () => {
88
100
  const app = new cdk.App();
89
101
  const registryNoAuth = {
90
102
  ...minimalRegistry,
91
- apis: [
103
+ // Replace the JWT action with an auth=none + securityException
104
+ // action so the authorizer is not required.
105
+ actions: [
92
106
  {
93
- ...minimalRegistry.apis[0],
94
- authType: 'none',
107
+ id: 'list-things', kind: 'action', handlerFile: 'src/handlers/list-things.ts',
108
+ backendAccess: 'domain',
109
+ exposure: {
110
+ type: 'api', path: '/v1/tenants/{tenantId}/things', method: 'GET',
111
+ auth: 'none', tenancy: 'required',
112
+ securityException: { reason: 'public read-only listing' },
113
+ },
114
+ idempotent: false,
95
115
  },
96
116
  ],
97
117
  };
@@ -202,13 +222,43 @@ describe('DomainStack', () => {
202
222
  */
203
223
  const multiMethodRegistry = {
204
224
  ...minimalRegistry,
205
- apis: [
206
- { ...minimalRegistry.apis[0], id: 'get-users', path: '/users', method: 'GET', authType: 'jwt' },
207
- { ...minimalRegistry.apis[0], id: 'create-user', path: '/users', method: 'POST', authType: 'jwt' },
208
- { ...minimalRegistry.apis[0], id: 'replace-user', path: '/users/{id}', method: 'PUT', authType: 'jwt' },
209
- { ...minimalRegistry.apis[0], id: 'patch-user', path: '/users/{id}', method: 'PATCH', authType: 'jwt' },
210
- { ...minimalRegistry.apis[0], id: 'delete-user', path: '/users/{id}', method: 'DELETE', authType: 'jwt' },
211
- { ...minimalRegistry.apis[0], id: 'list-things', path: '/things', method: 'GET', authType: 'none' },
225
+ actions: [
226
+ {
227
+ id: 'get-users', kind: 'action', handlerFile: 'src/handlers/get-users.ts',
228
+ backendAccess: 'domain',
229
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
230
+ idempotent: false,
231
+ },
232
+ {
233
+ id: 'create-user', kind: 'action', handlerFile: 'src/handlers/create-user.ts',
234
+ backendAccess: 'domain',
235
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'POST', auth: 'required', tenancy: 'required' },
236
+ idempotent: false,
237
+ },
238
+ {
239
+ id: 'replace-user', kind: 'action', handlerFile: 'src/handlers/replace-user.ts',
240
+ backendAccess: 'domain',
241
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'PUT', auth: 'required', tenancy: 'required' },
242
+ idempotent: false,
243
+ },
244
+ {
245
+ id: 'patch-user', kind: 'action', handlerFile: 'src/handlers/patch-user.ts',
246
+ backendAccess: 'domain',
247
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'PATCH', auth: 'required', tenancy: 'required' },
248
+ idempotent: false,
249
+ },
250
+ {
251
+ id: 'delete-user', kind: 'action', handlerFile: 'src/handlers/delete-user.ts',
252
+ backendAccess: 'domain',
253
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users/{id}', method: 'DELETE', auth: 'required', tenancy: 'required' },
254
+ idempotent: false,
255
+ },
256
+ {
257
+ id: 'list-things', kind: 'action', handlerFile: 'src/handlers/list-things.ts',
258
+ backendAccess: 'domain',
259
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/things', method: 'GET', auth: 'none', tenancy: 'required', securityException: { reason: 'public read-only listing' } },
260
+ idempotent: false,
261
+ },
212
262
  ],
213
263
  };
214
264
  it('uses the actual HTTP method for each route key (GET/POST/PUT/PATCH/DELETE)', () => {
@@ -224,11 +274,14 @@ describe('DomainStack', () => {
224
274
  const routeKeys = Object.values(routes).map(r => r.Properties.RouteKey);
225
275
  // Every method must round-trip — the previous implementation coerced all
226
276
  // non-GET methods to POST, so PUT/PATCH/DELETE were broken.
227
- expect(routeKeys).toContain('GET /test-domain/users');
228
- expect(routeKeys).toContain('POST /test-domain/users');
229
- expect(routeKeys).toContain('PUT /test-domain/users/{id}');
230
- expect(routeKeys).toContain('PATCH /test-domain/users/{id}');
231
- expect(routeKeys).toContain('DELETE /test-domain/users/{id}');
277
+ // Issue #4689: routes now use the action's exposure.path (which
278
+ // already includes `/v1/tenants/{tenantId}/`) and the canonical
279
+ // tenant placeholder.
280
+ expect(routeKeys).toContain('GET /test-domain/v1/tenants/{tenantId}/users');
281
+ expect(routeKeys).toContain('POST /test-domain/v1/tenants/{tenantId}/users');
282
+ expect(routeKeys).toContain('PUT /test-domain/v1/tenants/{tenantId}/users/{id}');
283
+ expect(routeKeys).toContain('PATCH /test-domain/v1/tenants/{tenantId}/users/{id}');
284
+ expect(routeKeys).toContain('DELETE /test-domain/v1/tenants/{tenantId}/users/{id}');
232
285
  });
233
286
  it('sets AuthorizationType=JWT and AuthorizerId on jwt-auth routes', () => {
234
287
  const app = new cdk.App();
@@ -242,7 +295,8 @@ describe('DomainStack', () => {
242
295
  const routes = template.findResources('AWS::ApiGatewayV2::Route');
243
296
  const jwtRoutes = Object.values(routes).filter(r => {
244
297
  const key = r.Properties.RouteKey;
245
- return key.endsWith('/users') || key.endsWith('/users/{id}');
298
+ // Issue #4689: paths now include the tenant placeholder.
299
+ return key.includes('/users') && !key.includes('Health') && !key.includes('Ready');
246
300
  });
247
301
  // Every jwt-auth route must carry AuthorizationType and AuthorizerId.
248
302
  expect(jwtRoutes.length).toBeGreaterThan(0);
@@ -252,7 +306,7 @@ describe('DomainStack', () => {
252
306
  expect(authorizerId).toBeDefined();
253
307
  }
254
308
  });
255
- it('does not attach authorizer to non-jwt routes (authType=none)', () => {
309
+ it('does not attach authorizer to non-jwt routes (auth=none)', () => {
256
310
  const app = new cdk.App();
257
311
  const stack = new DomainStack(app, 'TestDomainStackUnauthRoute', {
258
312
  registry: multiMethodRegistry,
@@ -262,7 +316,7 @@ describe('DomainStack', () => {
262
316
  });
263
317
  const template = Template.fromStack(stack);
264
318
  const routes = template.findResources('AWS::ApiGatewayV2::Route');
265
- const unauthRoute = Object.values(routes).find(r => r.Properties.RouteKey === 'GET /test-domain/things');
319
+ const unauthRoute = Object.values(routes).find(r => r.Properties.RouteKey === 'GET /test-domain/v1/tenants/{tenantId}/things');
266
320
  expect(unauthRoute).toBeDefined();
267
321
  expect(unauthRoute.Properties.AuthorizationType).toBeUndefined();
268
322
  expect(unauthRoute.Properties.AuthorizerId).toBeUndefined();
@@ -282,11 +336,24 @@ describe('DomainStack', () => {
282
336
  .map(m => (typeof m.data === 'string' ? m.data : JSON.stringify(m.data)));
283
337
  expect(errorMessages.some(a => a.includes('JWT-protected routes') && a.includes('Cognito'))).toBe(true);
284
338
  });
285
- it('does NOT emit the missing-pool error when authType=none', () => {
339
+ it('does NOT emit the missing-pool error when no JWT-protected actions are present', () => {
286
340
  const app = new cdk.App();
287
341
  const noJwtRegistry = {
288
342
  ...minimalRegistry,
289
- apis: [{ ...minimalRegistry.apis[0], authType: 'none' }],
343
+ // Replace the JWT-protected action with an auth=none + securityException
344
+ // action so the JWT-needs-pool check does not fire.
345
+ actions: [
346
+ {
347
+ id: 'list-things', kind: 'action', handlerFile: 'src/handlers/list-things.ts',
348
+ backendAccess: 'domain',
349
+ exposure: {
350
+ type: 'api', path: '/v1/tenants/{tenantId}/things', method: 'GET',
351
+ auth: 'none', tenancy: 'required',
352
+ securityException: { reason: 'public read-only listing' },
353
+ },
354
+ idempotent: false,
355
+ },
356
+ ],
290
357
  };
291
358
  const stack = new DomainStack(app, 'TestDomainStackNoJwtNeeded', {
292
359
  registry: noJwtRegistry,
@@ -35,7 +35,6 @@ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
35
35
  });
36
36
  describe('adapter selection by primitive type (defaults)', () => {
37
37
  const cases = [
38
- { primitive: 'api', expectedAdapter: 'createApiLambdaHandler' },
39
38
  { primitive: 'subscriber', expectedAdapter: 'createSubscriberLambdaHandler' },
40
39
  { primitive: 'job', expectedAdapter: 'createJobLambdaHandler' },
41
40
  { primitive: 'webhook', expectedAdapter: 'createWebhookLambdaHandler' },
@@ -91,11 +90,11 @@ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
91
90
  expect(content).not.toContain(EXPOSED_ACTION_ADAPTER);
92
91
  });
93
92
  it('per-entry adapter override takes precedence over the primitive-type default', () => {
94
- // Even if primitiveType were 'api', an explicit adapter override wins.
93
+ // Even when an explicit adapter is supplied, the override wins.
95
94
  const overridden = entry('charge-card', EXPOSED_ACTION_ADAPTER);
96
- const content = buildDedicatedEntryContent('./handler.js', overridden, 'api', DOMAIN_ID);
95
+ const content = buildDedicatedEntryContent('./handler.js', overridden, 'webhook', DOMAIN_ID);
97
96
  expect(content).toContain(`import { ${EXPOSED_ACTION_ADAPTER} } from '@mettlecast/domain-runtime';`);
98
- expect(content).not.toContain('createApiLambdaHandler');
97
+ expect(content).not.toContain('createWebhookLambdaHandler');
99
98
  });
100
99
  });
101
100
  describe('schedule envelope', () => {
@@ -120,7 +119,7 @@ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
120
119
  describe('handler import path wiring', () => {
121
120
  it('uses the caller-supplied relative import path verbatim', () => {
122
121
  const e = entry('list-things');
123
- const content = buildDedicatedEntryContent('../../../../src/handlers/list-things.ts', e, 'api', DOMAIN_ID);
122
+ const content = buildDedicatedEntryContent('../../../../src/handlers/list-things.ts', e, 'webhook', DOMAIN_ID);
124
123
  expect(content).toContain(`import { listThings } from '../../../../src/handlers/list-things.ts';`);
125
124
  });
126
125
  it('converts kebab-case handler IDs to camelCase export names', () => {
@@ -132,7 +131,7 @@ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
132
131
  });
133
132
  it('emits a single export const handler per entry', () => {
134
133
  const e = entry('list-things');
135
- const content = buildDedicatedEntryContent('./handler.js', e, 'api', DOMAIN_ID);
134
+ const content = buildDedicatedEntryContent('./handler.js', e, 'webhook', DOMAIN_ID);
136
135
  const matches = content.match(/^export const handler\b/gm) ?? [];
137
136
  expect(matches.length).toBe(1);
138
137
  });
@@ -5,12 +5,12 @@ import * as lambdaNode from 'aws-cdk-lib/aws-lambda-nodejs';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { LambdaFactory } from '../lambda-factory.js';
8
- const DOMAIN_ROOT = '/tmp/test-domain-cdk-packer';
8
+ const DOMAIN_ROOT = path.join(process.cwd(), '.tmp', 'test-domain-cdk-packer');
9
9
  const minimalRegistry = {
10
10
  schemaVersion: '1',
11
11
  domainRoot: DOMAIN_ROOT,
12
12
  domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
13
- apis: [], webhooks: [], subscribers: [], schedules: [], jobs: [], actions: [], integrations: [], events: [],
13
+ webhooks: [], subscribers: [], schedules: [], jobs: [], actions: [], integrations: [], events: [],
14
14
  };
15
15
  beforeAll(() => {
16
16
  const handlerDir = path.join(DOMAIN_ROOT, 'src');
@@ -26,7 +26,7 @@ describe('LambdaFactory', () => {
26
26
  const stack = new cdk.Stack(app, 'TestStack');
27
27
  const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
28
28
  expect(() => {
29
- factory.createFunction({ id: 'test-api', kind: 'api', handlerFile: 'src/api.ts' });
29
+ factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
30
30
  }).not.toThrow();
31
31
  });
32
32
  describe('bundling configuration', () => {
@@ -34,7 +34,7 @@ describe('LambdaFactory', () => {
34
34
  const app = new cdk.App();
35
35
  const stack = new cdk.Stack(app, 'TestStackArch');
36
36
  const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
37
- factory.createFunction({ id: 'test-api', kind: 'api', handlerFile: 'src/api.ts' });
37
+ factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
38
38
  const template = Template.fromStack(stack);
39
39
  template.hasResourceProperties('AWS::Lambda::Function', {
40
40
  Architectures: ['arm64'],
@@ -44,7 +44,7 @@ describe('LambdaFactory', () => {
44
44
  const app = new cdk.App();
45
45
  const stack = new cdk.Stack(app, 'TestStackRuntime');
46
46
  const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
47
- factory.createFunction({ id: 'test-api', kind: 'api', handlerFile: 'src/api.ts' });
47
+ factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
48
48
  const template = Template.fromStack(stack);
49
49
  template.hasResourceProperties('AWS::Lambda::Function', {
50
50
  Runtime: 'nodejs22.x',
@@ -54,7 +54,7 @@ describe('LambdaFactory', () => {
54
54
  const app = new cdk.App();
55
55
  const stack = new cdk.Stack(app, 'TestStackSourceMaps');
56
56
  const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
57
- factory.createFunction({ id: 'test-api', kind: 'api', handlerFile: 'src/api.ts' });
57
+ factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
58
58
  const template = Template.fromStack(stack);
59
59
  template.hasResourceProperties('AWS::Lambda::Function', {
60
60
  Environment: {
@@ -68,7 +68,7 @@ describe('LambdaFactory', () => {
68
68
  const app = new cdk.App();
69
69
  const stack = new cdk.Stack(app, 'TestStackDefaults');
70
70
  const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
71
- const fn = factory.createFunction({ id: 'test-api', kind: 'api', handlerFile: 'src/api.ts' });
71
+ const fn = factory.createFunction({ id: 'test-action', kind: 'action', handlerFile: 'src/api.ts' });
72
72
  expect(fn).toBeInstanceOf(lambdaNode.NodejsFunction);
73
73
  expect(fn.runtime?.name).toContain('nodejs22');
74
74
  });
@@ -5,7 +5,6 @@ describe('DomainRegistry type', () => {
5
5
  schemaVersion: '1',
6
6
  domainRoot: '/workspace/my-domain',
7
7
  domain: { id: 'my-domain', kind: 'domain', name: 'My Domain', tenancy: 'required' },
8
- apis: [],
9
8
  webhooks: [],
10
9
  subscribers: [],
11
10
  schedules: [],
@@ -19,12 +18,12 @@ describe('DomainRegistry type', () => {
19
18
  });
20
19
  it('discriminates RegistryEntry union by kind', () => {
21
20
  const entries = [
22
- { id: 'test-api', kind: 'api', handlerFile: 'src/api.ts', path: '/test', method: 'GET', authType: 'jwt' },
21
+ { id: 'test-action', kind: 'action', handlerFile: 'src/action.ts', backendAccess: 'domain', exposure: { type: 'api', path: '/test', method: 'GET', auth: 'required', tenancy: 'none' }, idempotent: true },
23
22
  { id: 'test-job', kind: 'job', handlerFile: 'src/job.ts', maxRetries: 3, visibilityTimeoutSeconds: 30 },
24
23
  ];
25
24
  for (const entry of entries) {
26
- if (entry.kind === 'api') {
27
- expect(entry.path).toBeDefined();
25
+ if (entry.kind === 'action') {
26
+ expect(entry.exposure).toBeDefined();
28
27
  }
29
28
  if (entry.kind === 'job') {
30
29
  expect(entry.maxRetries).toBeDefined();
@@ -8,7 +8,6 @@ const baseRegistry = {
8
8
  schemaVersion: '1',
9
9
  domainRoot: DOMAIN_ROOT,
10
10
  domain: { id: 'sec-domain', kind: 'domain', name: 'Sec Domain', tenancy: 'none' },
11
- apis: [],
12
11
  webhooks: [],
13
12
  subscribers: [],
14
13
  schedules: [],
@@ -60,9 +59,15 @@ beforeAll(() => {
60
59
  // entry id (`list-users` -> `listUsers`). We provide every named export
61
60
  // the security-aspect test uses so each test can pick the same handler
62
61
  // 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.
63
66
  fs.writeFileSync(path.join(handlerDir, 'noop.ts'), [
64
- 'export const listUsers = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };',
65
- 'export const listAll = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };',
67
+ // Match the grouped-lambda-factory's export-name conversion so the
68
+ // bundler can resolve the import for every test fixture.
69
+ '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
+ '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 }) };',
66
71
  '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 }) };',
67
72
  ].join('\n') + '\n');
68
73
  });
@@ -75,7 +80,7 @@ afterAll(() => {
75
80
  * invariants the CLI validator already enforces. These tests pin each
76
81
  * invariant independently so a regression in any single check is caught.
77
82
  */
78
- describe('SecurityAssertionAspect (#4662 Task D)', () => {
83
+ describe('SecurityAssertionAspect (#4662 Task D, #4689)', () => {
79
84
  it('does not emit errors for an empty registry', () => {
80
85
  const { errorMessages } = buildStackAndGetErrors(baseRegistry);
81
86
  // The base stack emits zero routes / zero Function URLs, so the aspect
@@ -83,21 +88,34 @@ describe('SecurityAssertionAspect (#4662 Task D)', () => {
83
88
  const aspectCodes = ['SECURITY_MISSING_JWT_AUTHORIZER', 'SECURITY_MISSING_TENANT_PATH', 'SECURITY_ACTION_FUNCTION_URL', 'SECURITY_MISSING_SECURITY_EXCEPTION'];
84
89
  expect(errorMessages.filter(m => aspectCodes.some(c => m.includes(c)))).toHaveLength(0);
85
90
  });
86
- it('emits SECURITY_MISSING_TENANT_PATH when an API path lacks the tenant placeholder', () => {
91
+ 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[]`.
87
95
  const registry = {
88
96
  ...baseRegistry,
89
- apis: [
90
- { id: 'list-users', kind: 'api', handlerFile: 'src/handlers/noop.ts', path: '/users', method: 'GET', authType: 'jwt' },
97
+ actions: [
98
+ {
99
+ id: 'list-users', kind: 'action', handlerFile: 'src/handlers/noop.ts',
100
+ backendAccess: 'domain',
101
+ exposure: { type: 'api', path: '/users', method: 'GET', auth: 'required', tenancy: 'required' },
102
+ idempotent: false,
103
+ },
91
104
  ],
92
105
  };
93
106
  const { errorMessages } = buildStackAndGetErrors(registry);
94
107
  expect(errorMessages.some(m => m.includes('SECURITY_MISSING_TENANT_PATH') && m.includes('list-users'))).toBe(true);
95
108
  });
96
- it('does NOT emit SECURITY_MISSING_TENANT_PATH when the path includes the placeholder', () => {
109
+ it('does NOT emit SECURITY_MISSING_TENANT_PATH when the action API path includes the placeholder', () => {
97
110
  const registry = {
98
111
  ...baseRegistry,
99
- apis: [
100
- { id: 'list-users', kind: 'api', handlerFile: 'src/handlers/noop.ts', path: '/v1/tenants/{tenantId}/users', method: 'GET', authType: 'jwt' },
112
+ actions: [
113
+ {
114
+ id: 'list-users', kind: 'action', handlerFile: 'src/handlers/noop.ts',
115
+ backendAccess: 'domain',
116
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
117
+ idempotent: false,
118
+ },
101
119
  ],
102
120
  };
103
121
  // We expect the JWT authorizer error (because no Cognito is provided),
@@ -105,11 +123,16 @@ describe('SecurityAssertionAspect (#4662 Task D)', () => {
105
123
  const { errorMessages } = buildStackAndGetErrors(registry);
106
124
  expect(errorMessages.some(m => m.includes('SECURITY_MISSING_TENANT_PATH'))).toBe(false);
107
125
  });
108
- it('emits SECURITY_MISSING_JWT_AUTHORIZER when JWT API is declared without Cognito config', () => {
126
+ it('emits SECURITY_MISSING_JWT_AUTHORIZER when an action API is declared without Cognito config', () => {
109
127
  const registry = {
110
128
  ...baseRegistry,
111
- apis: [
112
- { id: 'list-users', kind: 'api', handlerFile: 'src/handlers/noop.ts', path: '/v1/tenants/{tenantId}/users', method: 'GET', authType: 'jwt' },
129
+ actions: [
130
+ {
131
+ id: 'list-users', kind: 'action', handlerFile: 'src/handlers/noop.ts',
132
+ backendAccess: 'domain',
133
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
134
+ idempotent: false,
135
+ },
113
136
  ],
114
137
  };
115
138
  const { errorMessages } = buildStackAndGetErrors(registry);
@@ -121,7 +144,6 @@ describe('SecurityAssertionAspect (#4662 Task D)', () => {
121
144
  // ignores them), the aspect must NOT fire for them.
122
145
  const registry = {
123
146
  ...baseRegistry,
124
- apis: [],
125
147
  };
126
148
  const { errorMessages } = buildStackAndGetErrors(registry);
127
149
  expect(errorMessages.some(m => m.includes('SECURITY_MISSING_SECURITY_EXCEPTION'))).toBe(false);
@@ -113,8 +113,7 @@ export declare class SecurityAssertionAspect implements cdk.IAspect {
113
113
  * invariants in one pass:
114
114
  *
115
115
  * 1. Routes declared as JWT-protected actually carry an `AuthorizerId`.
116
- * Covers both legacy `defineApi` entries (`authType: 'jwt'`) and
117
- * new-style action API exposures (`exposure.auth === 'required'`).
116
+ * Covers action API exposures (`exposure.auth === 'required'`).
118
117
  * 2. Routes declared as tenant-required include the canonical
119
118
  * `/v1/tenants/{tenantId}/` placeholder in their path.
120
119
  *
@@ -136,46 +135,17 @@ export declare class SecurityAssertionAspect implements cdk.IAspect {
136
135
  */
137
136
  private assertNoFunctionUrls;
138
137
  /**
139
- * Walk every `CfnRoute` whose registry counterpart declares
140
- * `auth: 'none'` or `authType: 'none'` and verify that the registry
141
- * entry carries a `securityException.reason`. Public routes must
142
- * document the exception so security reviewers can audit the
143
- * relaxation.
138
+ * Walk every `CfnRoute` whose action registry counterpart declares
139
+ * `auth: 'none'` and verify that the action exposure carries a
140
+ * `securityException.reason`. Public routes must document the exception
141
+ * so security reviewers can audit the relaxation.
144
142
  */
145
143
  private assertAnonymousRoutesHaveException;
146
- /**
147
- * Find the registry API entry that corresponds to a synthesised route.
148
- * DomainStack prefixes every route with `/<domainId>`, so we strip that
149
- * prefix before comparing paths.
150
- *
151
- * @param path - The route path including the domain prefix.
152
- * @param method - Upper-case HTTP method.
153
- * @returns The matching {@link ApiRegistryEntry} or `undefined`.
154
- */
155
- private findApiRegistryEntryForRoute;
156
144
  /**
157
145
  * Find the registry action whose `exposure.path` matches a synthesised
158
- * route. Mirrors {@link findApiRegistryEntryForRoute} but for action API
159
- * exposures.
146
+ * route for action API exposures.
160
147
  */
161
148
  private findActionRegistryEntryForRoute;
162
- /**
163
- * Predicate — does the registry entry imply that the route must
164
- * include `/v1/tenants/{tenantId}/`?
165
- *
166
- * The legacy `defineApi` surface does not carry an explicit tenancy
167
- * marker; we treat any non-anonymous API as tenant-required because
168
- * the only legitimate JWT-protected routes are tenant-scoped. Routes
169
- * with `authType: 'none'` are exempted — they are explicitly public.
170
- */
171
- private registryEntryRequiresTenant;
172
- /**
173
- * Predicate — does the API registry entry carry a securityException?
174
- * Legacy `defineApi` rows do not have this field; we accept the
175
- * registry absence and rely on the CLI validator to catch it before
176
- * synth.
177
- */
178
- private hasSecurityException;
179
149
  /**
180
150
  * Predicate — does the action registry entry carry a securityException
181
151
  * on its exposure block?
@@ -89,8 +89,7 @@ export class SecurityAssertionAspect {
89
89
  * invariants in one pass:
90
90
  *
91
91
  * 1. Routes declared as JWT-protected actually carry an `AuthorizerId`.
92
- * Covers both legacy `defineApi` entries (`authType: 'jwt'`) and
93
- * new-style action API exposures (`exposure.auth === 'required'`).
92
+ * Covers action API exposures (`exposure.auth === 'required'`).
94
93
  * 2. Routes declared as tenant-required include the canonical
95
94
  * `/v1/tenants/{tenantId}/` placeholder in their path.
96
95
  *
@@ -113,27 +112,6 @@ export class SecurityAssertionAspect {
113
112
  const path = routeKey.slice(spaceIndex + 1);
114
113
  const method = routeKey.slice(0, spaceIndex).toUpperCase();
115
114
  const authorizerId = node.authorizerId;
116
- const api = this.findApiRegistryEntryForRoute(path, method);
117
- if (api) {
118
- // Invariant 1a: JWT-required API has an authorizer
119
- if (api.authType === 'jwt' && !authorizerId) {
120
- const message = `[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_JWT_AUTHORIZER}: ` +
121
- `API '${api.id}' declares authType='jwt' but the synthesised route ${routeKey} ` +
122
- `has no AuthorizerId. Provide a Cognito user pool via DomainStackProps.userPoolArn + userPoolClientId.`;
123
- if (this.props.failOnMissingAuthorizer) {
124
- cdk.Annotations.of(node).addError(message);
125
- }
126
- else {
127
- cdk.Annotations.of(node).addWarning(message);
128
- }
129
- }
130
- // Invariant 2: tenant-required API path includes placeholder
131
- if (this.registryEntryRequiresTenant(api) && !this.hasTenantPlaceholder(path)) {
132
- cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_TENANT_PATH}: ` +
133
- `API '${api.id}' (${method} ${path}) is tenant-required but its path does not include ` +
134
- `the canonical '/v1/tenants/{tenantId}/' placeholder. Synth will fail.`);
135
- }
136
- }
137
115
  const action = this.findActionRegistryEntryForRoute(path, method);
138
116
  if (action && action.exposure.type === 'api') {
139
117
  const exposure = action.exposure;
@@ -185,11 +163,10 @@ export class SecurityAssertionAspect {
185
163
  }
186
164
  }
187
165
  /**
188
- * Walk every `CfnRoute` whose registry counterpart declares
189
- * `auth: 'none'` or `authType: 'none'` and verify that the registry
190
- * entry carries a `securityException.reason`. Public routes must
191
- * document the exception so security reviewers can audit the
192
- * relaxation.
166
+ * Walk every `CfnRoute` whose action registry counterpart declares
167
+ * `auth: 'none'` and verify that the action exposure carries a
168
+ * `securityException.reason`. Public routes must document the exception
169
+ * so security reviewers can audit the relaxation.
193
170
  */
194
171
  assertAnonymousRoutesHaveException(node) {
195
172
  if (!(node instanceof Apigwv2CfnRoute))
@@ -200,12 +177,6 @@ export class SecurityAssertionAspect {
200
177
  return;
201
178
  const path = routeKey.slice(spaceIndex + 1);
202
179
  const method = routeKey.slice(0, spaceIndex).toUpperCase();
203
- const api = this.findApiRegistryEntryForRoute(path, method);
204
- if (api && api.authType === 'none' && !this.hasSecurityException(api)) {
205
- cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_SECURITY_EXCEPTION}: ` +
206
- `API '${api.id}' (${method} ${path}) is anonymous (authType='none') but does not declare ` +
207
- `a securityException.reason. Public routes must document the exception.`);
208
- }
209
180
  const action = this.findActionRegistryEntryForRoute(path, method);
210
181
  if (action
211
182
  && action.exposure.type === 'api'
@@ -216,26 +187,9 @@ export class SecurityAssertionAspect {
216
187
  `exposure.securityException.reason. Public routes must document the exception.`);
217
188
  }
218
189
  }
219
- /**
220
- * Find the registry API entry that corresponds to a synthesised route.
221
- * DomainStack prefixes every route with `/<domainId>`, so we strip that
222
- * prefix before comparing paths.
223
- *
224
- * @param path - The route path including the domain prefix.
225
- * @param method - Upper-case HTTP method.
226
- * @returns The matching {@link ApiRegistryEntry} or `undefined`.
227
- */
228
- findApiRegistryEntryForRoute(path, method) {
229
- const domainId = this.props.registry.domain.id;
230
- if (!path.startsWith(`/${domainId}`))
231
- return undefined;
232
- const trimmed = path.slice(`/${domainId}`.length) || '/';
233
- return this.props.registry.apis.find(api => api.path === trimmed && api.method.toUpperCase() === method);
234
- }
235
190
  /**
236
191
  * Find the registry action whose `exposure.path` matches a synthesised
237
- * route. Mirrors {@link findApiRegistryEntryForRoute} but for action API
238
- * exposures.
192
+ * route for action API exposures.
239
193
  */
240
194
  findActionRegistryEntryForRoute(path, method) {
241
195
  const domainId = this.props.registry.domain.id;
@@ -249,31 +203,6 @@ export class SecurityAssertionAspect {
249
203
  return exposure.path === trimmed && exposure.method.toUpperCase() === method;
250
204
  });
251
205
  }
252
- /**
253
- * Predicate — does the registry entry imply that the route must
254
- * include `/v1/tenants/{tenantId}/`?
255
- *
256
- * The legacy `defineApi` surface does not carry an explicit tenancy
257
- * marker; we treat any non-anonymous API as tenant-required because
258
- * the only legitimate JWT-protected routes are tenant-scoped. Routes
259
- * with `authType: 'none'` are exempted — they are explicitly public.
260
- */
261
- registryEntryRequiresTenant(api) {
262
- if (api.authType === 'none')
263
- return false;
264
- return true;
265
- }
266
- /**
267
- * Predicate — does the API registry entry carry a securityException?
268
- * Legacy `defineApi` rows do not have this field; we accept the
269
- * registry absence and rely on the CLI validator to catch it before
270
- * synth.
271
- */
272
- hasSecurityException(api) {
273
- const entry = api;
274
- const reason = entry.securityException?.reason;
275
- return typeof reason === 'string' && reason.trim().length > 0;
276
- }
277
206
  /**
278
207
  * Predicate — does the action registry entry carry a securityException
279
208
  * on its exposure block?
@@ -7,7 +7,7 @@ import type { DomainRegistry } from '../registry.js';
7
7
  * Configuration properties for ApiConstruct.
8
8
  */
9
9
  export interface ApiConstructProps {
10
- /** Domain registry containing all API entries. */
10
+ /** Domain registry. Issue #4689: HTTP endpoints are now action entries with `exposure.type === 'api'`. */
11
11
  registry: DomainRegistry;
12
12
  /** Shared HttpApi to route requests to Lambdas. */
13
13
  httpApi: apigwv2.HttpApi;
@@ -17,7 +17,15 @@ export interface ApiConstructProps {
17
17
  iamBuilder: IamPolicyBuilder;
18
18
  }
19
19
  /**
20
- * CDK Construct that synthesises one Lambda per registered API and wires each to the shared HttpApi.
20
+ * CDK Construct that synthesises one Lambda per API-exposed action and wires each to the shared HttpApi.
21
+ *
22
+ * Issue #4689: the legacy `defineApi` primitive was removed. The
23
+ * canonical HTTP endpoint surface is `defineAction({ exposure: { type:
24
+ * 'api', ... } })`, so this construct now iterates
25
+ * `registry.actions` and filters for `exposure.type === 'api'`. The
26
+ * legacy `registry.apis` slot is always empty in fresh registries; we
27
+ * intentionally do NOT iterate it because the builder no longer
28
+ * populates it.
21
29
  */
22
30
  export declare class ApiConstruct extends Construct {
23
31
  /**
@@ -2,7 +2,15 @@ import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
2
2
  import * as apigwv2integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
3
3
  import { Construct } from 'constructs';
4
4
  /**
5
- * CDK Construct that synthesises one Lambda per registered API and wires each to the shared HttpApi.
5
+ * CDK Construct that synthesises one Lambda per API-exposed action and wires each to the shared HttpApi.
6
+ *
7
+ * Issue #4689: the legacy `defineApi` primitive was removed. The
8
+ * canonical HTTP endpoint surface is `defineAction({ exposure: { type:
9
+ * 'api', ... } })`, so this construct now iterates
10
+ * `registry.actions` and filters for `exposure.type === 'api'`. The
11
+ * legacy `registry.apis` slot is always empty in fresh registries; we
12
+ * intentionally do NOT iterate it because the builder no longer
13
+ * populates it.
6
14
  */
7
15
  export class ApiConstruct extends Construct {
8
16
  /**
@@ -13,15 +21,27 @@ export class ApiConstruct extends Construct {
13
21
  */
14
22
  constructor(scope, id, props) {
15
23
  super(scope, id);
16
- for (const entry of props.registry.apis) {
24
+ for (const action of props.registry.actions) {
25
+ if (action.exposure?.type !== 'api')
26
+ continue;
27
+ const exposure = action.exposure;
28
+ const entry = {
29
+ ...action,
30
+ // The action entry is structurally compatible with the lambda
31
+ // factory's expected shape; spread the exposure fields onto the
32
+ // top level so existing IAM / deployment logic does not need to
33
+ // change.
34
+ path: exposure.path,
35
+ method: exposure.method,
36
+ };
17
37
  const fn = props.lambdaFactory.createFunction(entry, entry.deployment);
18
38
  props.iamBuilder.forApi().forEach((statement) => {
19
39
  fn.addToRolePolicy(statement);
20
40
  });
21
41
  props.httpApi.addRoutes({
22
- path: entry.path,
23
- methods: [toHttpMethod(entry.method)],
24
- integration: new apigwv2integrations.HttpLambdaIntegration(`${id}${toPascalCase(entry.id)}Integration`, fn),
42
+ path: exposure.path,
43
+ methods: [toHttpMethod(exposure.method)],
44
+ integration: new apigwv2integrations.HttpLambdaIntegration(`${id}${toPascalCase(action.id)}Integration`, fn),
25
45
  });
26
46
  }
27
47
  }
@@ -2,7 +2,7 @@ import * as lambda from 'aws-cdk-lib/aws-lambda';
2
2
  import * as ec2 from 'aws-cdk-lib/aws-ec2';
3
3
  import { Construct } from 'constructs';
4
4
  /** Primitive types that can be grouped into a single Lambda. */
5
- export type PrimitiveType = 'api' | 'subscriber' | 'schedule' | 'job' | 'webhook' | 'action';
5
+ export type PrimitiveType = 'subscriber' | 'schedule' | 'job' | 'webhook' | 'action';
6
6
  /** A single handler entry within a grouped Lambda. */
7
7
  export interface HandlerEntry {
8
8
  /** Unique handler ID within the domain + primitive type. */
@@ -15,7 +15,6 @@ export interface HandlerEntry {
15
15
  * into a Lambda handler.
16
16
  *
17
17
  * When omitted, the adapter is inferred from `primitiveType`:
18
- * - `api` → `createApiLambdaHandler`
19
18
  * - `subscriber` → `createSubscriberLambdaHandler`
20
19
  * - `job` → `createJobLambdaHandler`
21
20
  * - `webhook` → `createWebhookLambdaHandler`
@@ -20,7 +20,6 @@ function toPascalCase(str) {
20
20
  * used by API-exposed actions (see `EXPOSED_ACTION_ADAPTER`).
21
21
  */
22
22
  export const ADAPTER_BY_PRIMITIVE = {
23
- api: 'createApiLambdaHandler',
24
23
  subscriber: 'createSubscriberLambdaHandler',
25
24
  job: 'createJobLambdaHandler',
26
25
  webhook: 'createWebhookLambdaHandler',
@@ -83,7 +82,7 @@ export function buildDedicatedEntryContent(handlerImportPath, entry, primitiveTy
83
82
  // primitive-type default. Used so API-exposed actions wrap with
84
83
  // `createExposedActionApiHandler` while internal actions in the same domain
85
84
  // continue to use `createActionLambdaHandler`.
86
- const adapter = entry.adapter ?? ADAPTER_BY_PRIMITIVE[primitiveType] ?? 'createApiLambdaHandler';
85
+ const adapter = entry.adapter ?? ADAPTER_BY_PRIMITIVE[primitiveType];
87
86
  // Wave 7 Task 7.1 (#4619): the action adapters take a (registry, options)
88
87
  // pair / (action, options) pair, NOT a bare handler export. Generating
89
88
  // `${adapter}(${exportName})` would produce invalid JS for actions — the
@@ -221,8 +221,6 @@ export class IamPolicyBuilder {
221
221
  return this.forSubscriber(params);
222
222
  case 'job':
223
223
  return this.forJob(params);
224
- case 'api':
225
- return this.forApi();
226
224
  case 'schedule':
227
225
  return this.forSchedule();
228
226
  case 'action':
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { DomainRegistry, RegistryEntry, RegistryEntryKind, BaseRegistryEntry, SerialDeploymentConfig, ApiRegistryEntry, ApiVersionSnapshot, WebhookRegistryEntry, SubscriberRegistryEntry, ScheduleRegistryEntry, JobRegistryEntry, ActionRegistryEntry, ActionBackendAccess, ActionExposure, ActionApiExposure, IntegrationRegistryEntry, EventRegistryEntry, DomainRegistryEntry, SchemaSnapshot, } from './registry.js';
1
+ export type { DomainRegistry, RegistryEntry, RegistryEntryKind, BaseRegistryEntry, SerialDeploymentConfig, WebhookRegistryEntry, SubscriberRegistryEntry, ScheduleRegistryEntry, JobRegistryEntry, ActionRegistryEntry, ActionBackendAccess, ActionExposure, ActionApiExposure, IntegrationRegistryEntry, EventRegistryEntry, DomainRegistryEntry, SchemaSnapshot, } from './registry.js';
2
2
  export { LambdaFactory } from './lambda-factory.js';
3
3
  export type { LambdaFactoryProps } from './lambda-factory.js';
4
4
  export { createGroupedLambdas, buildDedicatedEntryContent, ADAPTER_BY_PRIMITIVE, EXPOSED_ACTION_ADAPTER } from './grouped-lambda-factory.js';
@@ -47,6 +47,8 @@ export interface PackDomainOptions {
47
47
  projectId?: string;
48
48
  /** Environment code used to prefix per-domain physical resource names (e.g. `dev`, `prod`). */
49
49
  envCode?: string;
50
+ /** Disable auto-generated per-domain CloudWatch dashboard. */
51
+ disableCloudWatchDashboards?: boolean;
50
52
  }
51
53
  /**
52
54
  * Convenience entry-point: constructs a DomainStack from a compiled registry.
@@ -13,9 +13,14 @@ import { DomainStack } from './DomainStack.js';
13
13
  export function packDomain(registry, app, stackId, eventBusArn, options) {
14
14
  // Support both old (env as 5th arg) and new (options object) calling conventions
15
15
  // Check if options looks like a CDK Environment (has 'account' and/or 'region' props, not the full options interface)
16
- const opts = options && typeof options === 'object'
17
- && ('account' in options || 'region' in options)
18
- && !('userPoolArn' in options || 'userPoolId' in options || 'userPoolClientId' in options || 'databaseUrl' in options || 'dbSecretArn' in options)
16
+ const opts = options &&
17
+ typeof options === 'object' &&
18
+ ('account' in options || 'region' in options) &&
19
+ !('userPoolArn' in options ||
20
+ 'userPoolId' in options ||
21
+ 'userPoolClientId' in options ||
22
+ 'databaseUrl' in options ||
23
+ 'dbSecretArn' in options)
19
24
  ? { env: options }
20
25
  : options || {};
21
26
  return new DomainStack(app, stackId, {
@@ -41,5 +46,6 @@ export function packDomain(registry, app, stackId, eventBusArn, options) {
41
46
  eventBusName: opts.eventBusName,
42
47
  projectId: opts.projectId,
43
48
  envCode: opts.envCode,
49
+ disableCloudWatchDashboards: opts.disableCloudWatchDashboards,
44
50
  });
45
51
  }
@@ -5,7 +5,7 @@ export type SchemaSnapshot = Record<string, unknown>;
5
5
  /**
6
6
  * Kind discriminant for registry entries.
7
7
  */
8
- export type RegistryEntryKind = 'api' | 'webhook' | 'subscriber' | 'schedule' | 'job' | 'action' | 'integration' | 'event' | 'domain';
8
+ export type RegistryEntryKind = 'webhook' | 'subscriber' | 'schedule' | 'job' | 'action' | 'integration' | 'event' | 'domain';
9
9
  /** Controls whether a VPC-attached handler may use NAT-backed public internet egress. */
10
10
  export type RegistryOutboundAccess = 'internal' | 'internet';
11
11
  /**
@@ -38,61 +38,6 @@ export interface BaseRegistryEntry {
38
38
  /** Optional human-readable description of this primitive. */
39
39
  description?: string;
40
40
  }
41
- /**
42
- * Schema snapshot for a single API version.
43
- */
44
- export interface ApiVersionSnapshot {
45
- /** Semver-style version string, e.g. '1'. */
46
- version: string;
47
- /** JSON Schema for the request body of this version. */
48
- requestSchema?: SchemaSnapshot;
49
- /** JSON Schema for the successful response body of this version. */
50
- responseSchema?: SchemaSnapshot;
51
- }
52
- /**
53
- * API registry entry for HTTP endpoint handlers.
54
- */
55
- export interface ApiRegistryEntry extends BaseRegistryEntry {
56
- /** Discriminant. */
57
- kind: 'api';
58
- /** Handler file path (required for API). */
59
- handlerFile: string;
60
- /** HTTP route path, e.g. '/users/:id'. */
61
- path: string;
62
- /** HTTP method. */
63
- method: string;
64
- /** Authentication type. */
65
- authType: 'jwt' | 'api-key' | 'none';
66
- /**
67
- * Required for `authType: 'none'` (anonymous) APIs. Carries a free-text
68
- * reason that documents the security exception so reviewers can audit
69
- * the relaxation. The CDK synth-time aspect and the CLI validator both
70
- * refuse to synthesize anonymous APIs without this field.
71
- *
72
- * Added in #4662 Task D — the legacy `defineApi` surface did not
73
- * require a reason because early projects had only a handful of
74
- * public routes; that did not scale and the runtime needs the
75
- * documentation alongside the route.
76
- */
77
- securityException?: {
78
- reason: string;
79
- };
80
- /** Optional deployment overrides. */
81
- deployment?: SerialDeploymentConfig;
82
- /** Whether this handler may use NAT-backed public internet egress. */
83
- outboundAccess?: RegistryOutboundAccess;
84
- /** JSON Schema snapshot for the HTTP request body. */
85
- requestSchema?: SchemaSnapshot;
86
- /** JSON Schema snapshot for the HTTP response body. */
87
- responseSchema?: SchemaSnapshot;
88
- /** Per-version request/response schema snapshots. */
89
- versions?: ApiVersionSnapshot[];
90
- /** Example request/response payloads for documentation (no runtime impact). */
91
- examples?: {
92
- request?: Record<string, unknown>;
93
- response?: Record<string, unknown>;
94
- };
95
- }
96
41
  /**
97
42
  * Webhook registry entry for webhook handlers.
98
43
  */
@@ -321,7 +266,7 @@ export interface DomainRegistryEntry extends BaseRegistryEntry {
321
266
  /**
322
267
  * Union type of all registry entry kinds.
323
268
  */
324
- export type RegistryEntry = ApiRegistryEntry | WebhookRegistryEntry | SubscriberRegistryEntry | ScheduleRegistryEntry | JobRegistryEntry | ActionRegistryEntry | IntegrationRegistryEntry | EventRegistryEntry | DomainRegistryEntry;
269
+ export type RegistryEntry = WebhookRegistryEntry | SubscriberRegistryEntry | ScheduleRegistryEntry | JobRegistryEntry | ActionRegistryEntry | IntegrationRegistryEntry | EventRegistryEntry | DomainRegistryEntry;
325
270
  /**
326
271
  * Top-level domain registry — the compiled snapshot for CDK packer.
327
272
  */
@@ -332,8 +277,6 @@ export interface DomainRegistry {
332
277
  domainRoot: string;
333
278
  /** The domain itself. */
334
279
  domain: DomainRegistryEntry;
335
- /** All API endpoints. */
336
- apis: ApiRegistryEntry[];
337
280
  /** All webhook receivers. */
338
281
  webhooks: WebhookRegistryEntry[];
339
282
  /** All event subscribers. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cdk-packer",
3
- "version": "0.2.61",
3
+ "version": "0.2.63",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",