@mettlecast/domain-cdk-packer 0.2.94 → 0.2.96

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.
@@ -84,11 +84,37 @@ export interface DomainStackProps extends cdk.StackProps {
84
84
  * Defaults to the second segment of the stack name; falls back to `dev` when not derivable.
85
85
  */
86
86
  envCode?: string;
87
+ /**
88
+ * Map of OTHER domain ids → their internal-action grouped Lambda ARNs.
89
+ * Injected into this domain's subscriber/action Lambdas (via the
90
+ * `TIB_ACTION_LAMBDA_ARNS` env var) so `ctx.actions.otherDomain.someAction()`
91
+ * dispatches cross-domain through the runtime's Lambda invoke envelope
92
+ * (#5226). ARNs use the deterministic action-Lambda function name
93
+ * `${projectId}-${envCode}-domain-${domainId}-action`.
94
+ */
95
+ crossDomainActionArns?: Record<string, string>;
87
96
  /**
88
97
  * When true, disables the auto-generated per-domain CloudWatch dashboard.
89
98
  * Existing dashboards will be removed on the next CDK deploy.
90
99
  */
91
100
  disableCloudWatchDashboards?: boolean;
101
+ /**
102
+ * When true, the per-domain DynamoDB table and S3 bucket are imported
103
+ * (adopted) instead of created. This is the migration-safe path for
104
+ * environments where those resources already exist OUTSIDE the CloudFormation
105
+ * stack (e.g. created manually or by an earlier deployment generation) —
106
+ * CloudFormation would otherwise fail the change-set with "already exists".
107
+ *
108
+ * Imported resources are not owned by the stack, so:
109
+ * - their data is preserved untouched;
110
+ * - CloudFormation can never accidentally delete them on stack deletion;
111
+ * - IAM grants, env vars (DOMAIN_TABLE_NAME / DOMAIN_BUCKET_NAME) and
112
+ * CfnOutputs keep referencing the same physical names.
113
+ *
114
+ * The deploy workflow detects pre-existing resources and passes the CDK
115
+ * context `adopt-existing-resources=true` (see workflowGenerator.ts).
116
+ */
117
+ adoptExistingResources?: boolean;
92
118
  }
93
119
  export declare class DomainStack extends cdk.Stack {
94
120
  /** Shared HTTP API for routing API and webhook requests. */
@@ -75,7 +75,7 @@ export class DomainStack extends cdk.Stack {
75
75
  throw new Error(`Registry validation failed for domain '${props.registry.domain?.id ?? '<unknown>'}':\n${lines.join('\n')}\n\n` +
76
76
  `Fix registry data before deploying.`);
77
77
  }
78
- const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, appSecretArn, userPoolArn, userPoolId, userPoolClientId, internalSubnetSelection, internetSubnetSelection, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials, disableCloudWatchDashboards } = props;
78
+ const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, appSecretArn, userPoolArn, userPoolId, userPoolClientId, internalSubnetSelection, internetSubnetSelection, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials, disableCloudWatchDashboards, adoptExistingResources } = props;
79
79
  const vpc = props.vpc ?? (props.vpcId
80
80
  ? ec2.Vpc.fromVpcAttributes(this, 'SharedVpc', {
81
81
  vpcId: props.vpcId,
@@ -191,6 +191,17 @@ export class DomainStack extends cdk.Stack {
191
191
  if (process.env['SES_FROM_EMAIL']) {
192
192
  environment.SES_FROM_EMAIL = process.env['SES_FROM_EMAIL'];
193
193
  }
194
+ // Cross-domain action dispatch: the runtime's ActionsProxy invokes other
195
+ // domains' internal-action grouped Lambdas by ARN. The map is resolved by
196
+ // the generated app (deterministic function names) and injected so
197
+ // subscribers/actions can call `ctx.actions.otherDomain.someAction()`.
198
+ const crossDomainActionArns = props.crossDomainActionArns ?? {};
199
+ if (Object.keys(crossDomainActionArns).length > 0) {
200
+ environment.TIB_ACTION_LAMBDA_ARNS = JSON.stringify(crossDomainActionArns);
201
+ }
202
+ // Deterministic physical name for THIS domain's internal-action grouped
203
+ // Lambda so other stacks can construct its ARN for cross-domain calls.
204
+ const actionLambdaName = `${resourceBaseName}-action`;
194
205
  const resolveSubnetSelection = (access) => {
195
206
  if (access === 'internet')
196
207
  return internetSubnetSelection ?? internalSubnetSelection;
@@ -257,6 +268,10 @@ export class DomainStack extends cdk.Stack {
257
268
  environment,
258
269
  eventBusArn,
259
270
  dedicated: internalDedicated,
271
+ // Deterministic physical name so OTHER domain stacks can resolve
272
+ // this Lambda's ARN for cross-domain `ctx.actions` dispatch.
273
+ // Only applied in grouped (non-dedicated) mode (#5226).
274
+ functionName: actionLambdaName,
260
275
  reservedConcurrency,
261
276
  logRetentionDays: logRetentionDays ?? 30,
262
277
  lambdaGroupId: group.outboundAccess,
@@ -313,34 +328,45 @@ export class DomainStack extends cdk.Stack {
313
328
  }
314
329
  return { lambdas, byId };
315
330
  };
316
- // Per-domain DynamoDB table — PK=tenantId (tenant is primary partition), SK=entity key
317
- const domainTable = new dynamodb.Table(this, 'DomainTable', {
318
- tableName: resourceBaseName,
319
- partitionKey: { name: 'tenantId', type: dynamodb.AttributeType.STRING },
320
- sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
321
- billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
322
- encryption: enableCmk ? dynamodb.TableEncryption.CUSTOMER_MANAGED : dynamodb.TableEncryption.AWS_MANAGED,
323
- encryptionKey: cmkKey,
324
- timeToLiveAttribute: 'expiresAt',
325
- removalPolicy: cdk.RemovalPolicy.RETAIN,
326
- pointInTimeRecovery: true,
327
- });
328
- // Per-domain S3 bucket all objects prefixed {tenantId}/ enforced in runtime
331
+ // Per-domain DynamoDB table — PK=tenantId (tenant is primary partition), SK=entity key.
332
+ //
333
+ // Issue #5234: when `adoptExistingResources` is set (detected by the deploy
334
+ // workflow), the table already exists OUTSIDE this CloudFormation stack.
335
+ // Import it instead of creating it so the change-set does not fail with
336
+ // "resource already exists"; the imported table is not owned by the stack,
337
+ // so its data is preserved and CloudFormation can never delete it.
338
+ const domainTable = adoptExistingResources
339
+ ? dynamodb.Table.fromTableName(this, 'DomainTable', resourceBaseName)
340
+ : new dynamodb.Table(this, 'DomainTable', {
341
+ tableName: resourceBaseName,
342
+ partitionKey: { name: 'tenantId', type: dynamodb.AttributeType.STRING },
343
+ sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
344
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
345
+ encryption: enableCmk ? dynamodb.TableEncryption.CUSTOMER_MANAGED : dynamodb.TableEncryption.AWS_MANAGED,
346
+ encryptionKey: cmkKey,
347
+ timeToLiveAttribute: 'expiresAt',
348
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
349
+ pointInTimeRecovery: true,
350
+ });
351
+ // Per-domain S3 bucket — all objects prefixed {tenantId}/ enforced in runtime.
329
352
  const bucketName = resourceBaseName.length <= 63
330
353
  ? resourceBaseName
331
354
  : `${resourceBaseName.slice(0, 55)}-${cdk.Fn.select(0, cdk.Fn.split('-', cdk.Names.uniqueId(this))).toLowerCase()}`;
332
- const domainBucket = new s3.Bucket(this, 'DomainBucket', {
333
- bucketName,
334
- blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
335
- encryption: enableCmk ? s3.BucketEncryption.KMS : s3.BucketEncryption.S3_MANAGED,
336
- encryptionKey: cmkKey,
337
- versioned: true,
338
- enforceSSL: true,
339
- removalPolicy: cdk.RemovalPolicy.RETAIN,
340
- lifecycleRules: [{
341
- noncurrentVersionExpiration: cdk.Duration.days(90),
342
- }],
343
- });
355
+ // Issue #5234: same adoption path for the bucket as the table above.
356
+ const domainBucket = adoptExistingResources
357
+ ? s3.Bucket.fromBucketName(this, 'DomainBucket', bucketName)
358
+ : new s3.Bucket(this, 'DomainBucket', {
359
+ bucketName,
360
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
361
+ encryption: enableCmk ? s3.BucketEncryption.KMS : s3.BucketEncryption.S3_MANAGED,
362
+ encryptionKey: cmkKey,
363
+ versioned: true,
364
+ enforceSSL: true,
365
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
366
+ lifecycleRules: [{
367
+ noncurrentVersionExpiration: cdk.Duration.days(90),
368
+ }],
369
+ });
344
370
  environment['DOMAIN_TABLE_NAME'] = domainTable.tableName;
345
371
  environment['DOMAIN_BUCKET_NAME'] = domainBucket.bucketName;
346
372
  // Per-domain tenant-scoped IAM role for defence-in-depth storage segregation
@@ -439,6 +465,16 @@ export class DomainStack extends cdk.Stack {
439
465
  // Grant IAM permissions for this queue
440
466
  const queuePolicies = iamBuilder.forSubscriber({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
441
467
  queuePolicies.forEach(statement => fn.addToRolePolicy(statement));
468
+ // Cross-domain action dispatch: subscribers (e.g. auth's
469
+ // on-member-invited) call other domains' internal-action grouped
470
+ // Lambdas via `ctx.actions` (#5226).
471
+ const crossDomainActionArnList = Object.values(crossDomainActionArns);
472
+ if (crossDomainActionArnList.length > 0) {
473
+ fn.addToRolePolicy(new iam.PolicyStatement({
474
+ actions: ['lambda:InvokeFunction'],
475
+ resources: crossDomainActionArnList,
476
+ }));
477
+ }
442
478
  // Create EventBridge rule targeting SQS queue
443
479
  new events.Rule(this, `${pascalId}Rule`, {
444
480
  eventBus,
@@ -564,9 +600,12 @@ export class DomainStack extends cdk.Stack {
564
600
  const actionLambdaById = actionHandlers.byId;
565
601
  actionLambdas = actionHandlers.lambdas;
566
602
  this.lambdaArns[`${domainId}-action`] = actionLambdas[0].functionArn;
567
- const iamPolicies = iamBuilder.forAction([
568
- `arn:aws:lambda:${this.region}:${this.account}:function:TIB-*-domain-*-action-*`,
569
- ]);
603
+ // Cross-domain invoke grants: each domain's action/subscriber Lambdas
604
+ // may call other domains' internal-action grouped Lambdas via
605
+ // `ctx.actions`. Use the resolved ARN list (deterministic function
606
+ // names) instead of a stale wildcard pattern (#5226).
607
+ const crossDomainActionArnList = Object.values(crossDomainActionArns);
608
+ const iamPolicies = iamBuilder.forAction(crossDomainActionArnList);
570
609
  actionLambdas.forEach((fn) => {
571
610
  iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
572
611
  });
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ /**
8
+ * Focused registration test for the API-owned bootstrap-admin invitation
9
+ * endpoint (#5226).
10
+ *
11
+ * The deployment pipeline relies on the DOCUMENTED generation process:
12
+ * `mc-domain-module build domains/{id}` for every directory under `domains/`,
13
+ * then `mc-domain-module build-catalog`. There is no hand-edited `.mc`
14
+ * registry. This test runs the REAL CLI against the checked-in source for ALL
15
+ * current domains (auth, data-management, email, orgs) and asserts:
16
+ * - `bootstrap-invite` is registered with the contract the pipeline depends
17
+ * on: POST /v1/auth/bootstrap-invite, auth 'none', tenancy 'none',
18
+ * ADMIN_BOOTSTRAP_SECRET securityException, idempotent: true, and an
19
+ * input schema that accepts only secret + email.
20
+ * - the removed `register-sys-admin` endpoint (fixed-password flow) is gone.
21
+ * - the email domain registry is generated so the vanilla
22
+ * `on-member-invited` subscriber's `email.send-template-email` call stays
23
+ * typed in the regenerated `.mc/actions-types.d.ts` and present in the
24
+ * merged catalog (regression: a partial regeneration dropped the email
25
+ * declarations because `.mc/email-registry.json` was absent).
26
+ */
27
+ describe('auth registry — bootstrap-invite registration (#5226)', () => {
28
+ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url));
29
+ // Isolated work tree. `build` must run with cwd=repoRoot (its tsx temp loader
30
+ // resolves `zod`/`@mettlecast/*` from the repo tree), but `build-catalog` is
31
+ // pure JSON and writes the merged catalog to `<cwd>/.mc/domain-registry.json`
32
+ // — so it runs from the temp work dir and never touches the repo's `.mc`.
33
+ const workDir = mkdtempSync(join(tmpdir(), 'mc-registry-test-'));
34
+ const regDir = join(workDir, 'registries');
35
+ const cliJs = join(repoRoot, 'node_modules', '@mettlecast', 'domain-cli', 'dist', 'cli.js');
36
+ const domains = ['auth', 'data-management', 'email', 'orgs'];
37
+ let authRegistry;
38
+ let catalog;
39
+ let typesContent;
40
+ beforeAll(() => {
41
+ mkdirSync(regDir, { recursive: true });
42
+ // Mirror the CI "Build domain registries" + "Build domain catalog" steps:
43
+ // delete stale per-domain registries, build every domains/*/, then merge.
44
+ for (const domain of domains) {
45
+ execFileSync(process.execPath, [cliJs, 'build', join(repoRoot, 'domains', domain), '--out', join(regDir, `${domain}-registry.json`)], { cwd: repoRoot, stdio: 'pipe', timeout: 120_000 });
46
+ }
47
+ execFileSync(process.execPath, [cliJs, 'build-catalog', '--mc-dir', regDir], { cwd: workDir, stdio: 'pipe', timeout: 60_000 });
48
+ authRegistry = JSON.parse(readFileSync(join(regDir, 'auth-registry.json'), 'utf8'));
49
+ catalog = JSON.parse(readFileSync(join(workDir, '.mc', 'domain-registry.json'), 'utf8'));
50
+ // The last `build` regenerates actions-types.d.ts next to the registries.
51
+ typesContent = readFileSync(join(regDir, 'actions-types.d.ts'), 'utf8');
52
+ });
53
+ afterAll(() => {
54
+ rmSync(workDir, { recursive: true, force: true });
55
+ });
56
+ describe('bootstrap-invite contract', () => {
57
+ it('registers bootstrap-invite as an API-exposed action', () => {
58
+ const action = authRegistry.actions.find((a) => a.id === 'bootstrap-invite');
59
+ expect(action).toBeDefined();
60
+ expect(action.exposure.type).toBe('api');
61
+ });
62
+ it('exposes the CDK route POST /auth/v1/auth/bootstrap-invite (domain prefix + path)', () => {
63
+ const action = authRegistry.actions.find((a) => a.id === 'bootstrap-invite');
64
+ expect(action.exposure.path).toBe('/v1/auth/bootstrap-invite');
65
+ expect(action.exposure.method).toBe('POST');
66
+ // DomainStack.ts wires the route as `/${domainId}${exposure.path}`.
67
+ expect(`/auth${action.exposure.path}`).toBe('/auth/v1/auth/bootstrap-invite');
68
+ });
69
+ it('is auth:none, tenancy:none and carries an ADMIN_BOOTSTRAP_SECRET security exception', () => {
70
+ const action = authRegistry.actions.find((a) => a.id === 'bootstrap-invite');
71
+ expect(action.exposure.auth).toBe('none');
72
+ expect(action.exposure.tenancy).toBe('none');
73
+ expect(action.exposure.authDeclared).toBe(true);
74
+ expect(action.exposure.tenancyDeclared).toBe(true);
75
+ expect(action.exposure.securityException?.reason).toContain('ADMIN_BOOTSTRAP_SECRET');
76
+ });
77
+ it('is idempotent and accepts only secret + email', () => {
78
+ const action = authRegistry.actions.find((a) => a.id === 'bootstrap-invite');
79
+ expect(action.idempotent).toBe(true);
80
+ expect(action.inputSchema.required).toEqual(['secret', 'email']);
81
+ expect(action.inputSchema.properties.password).toBeUndefined();
82
+ expect(action.inputSchema.default.password).toBeUndefined();
83
+ });
84
+ it('no longer registers the fixed-password register-sys-admin endpoint', () => {
85
+ const action = authRegistry.actions.find((a) => a.id === 'register-sys-admin');
86
+ expect(action).toBeUndefined();
87
+ });
88
+ });
89
+ describe('full documented build process — all current domains', () => {
90
+ it('generates a registry file for EVERY domain under domains/ (auth, data-management, email, orgs)', () => {
91
+ for (const domain of domains) {
92
+ expect(existsSync(join(regDir, `${domain}-registry.json`))).toBe(true);
93
+ }
94
+ });
95
+ it('generates the email registry so on-member-invited keeps its email.send-template-email call', () => {
96
+ const email = JSON.parse(readFileSync(join(regDir, 'email-registry.json'), 'utf8'));
97
+ const sendTemplateEmail = email.actions.find((a) => a.id === 'send-template-email');
98
+ expect(sendTemplateEmail).toBeDefined();
99
+ expect(sendTemplateEmail.backendAccess).toBe('domain');
100
+ expect(sendTemplateEmail.exposure.type).toBe('internal');
101
+ });
102
+ it('regenerated actions-types.d.ts keeps email.send-template-email and auth.bootstrap-invite, drops auth.register-sys-admin', () => {
103
+ expect(typesContent).toContain("call(actionId: 'email.send-template-email'");
104
+ expect(typesContent).toContain("call(actionId: 'auth.bootstrap-invite'");
105
+ expect(typesContent).not.toContain("call(actionId: 'auth.register-sys-admin'");
106
+ });
107
+ it('merged catalog includes all four domains and both endpoints', () => {
108
+ expect(catalog.domains.map((d) => d.id)).toEqual(expect.arrayContaining(['auth', 'data-management', 'email', 'orgs']));
109
+ const ids = catalog.actions.map((a) => a.id);
110
+ expect(ids).toContain('bootstrap-invite');
111
+ expect(ids).toContain('send-template-email');
112
+ expect(ids).not.toContain('register-sys-admin');
113
+ });
114
+ });
115
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import * as cdk from 'aws-cdk-lib';
3
+ import { Template } from 'aws-cdk-lib/assertions';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { DomainStack } from '../DomainStack.js';
7
+ /**
8
+ * Cross-domain wiring contract (#5226):
9
+ *
10
+ * A domain's subscriber and action Lambdas must be able to invoke other
11
+ * domains' internal-action grouped Lambdas via `ctx.actions`. This test
12
+ * synthesises a real DomainStack with a subscriber + internal action and
13
+ * asserts the ACTUAL CDK output:
14
+ * - the internal-action grouped Lambda has a deterministic `FunctionName`
15
+ * (`<projectId>-<envCode>-<domainId>-action`) so other stacks can resolve
16
+ * its ARN;
17
+ * - every subscriber/action Lambda receives `TIB_ACTION_LAMBDA_ARNS`
18
+ * (the resolved cross-domain ARN map injected by the generated app);
19
+ * - the subscriber/action roles carry `lambda:InvokeFunction` on those
20
+ * exact ARNs.
21
+ */
22
+ const DOMAIN_ROOT = path.join(process.cwd(), '.test-cross-domain-cdk-packer');
23
+ const DIST_ROOT = path.join(process.cwd(), 'dist', 'domains', 'test-domain');
24
+ const EVENT_BUS_ARN = 'arn:aws:events:eu-north-1:123456789012:event-bus/test-bus';
25
+ const EMAIL_ACTION_ARN = 'arn:aws:lambda:eu-north-1:123456789012:function:test-dev-domain-email-action';
26
+ const ORGS_ACTION_ARN = 'arn:aws:lambda:eu-north-1:123456789012:function:test-dev-domain-orgs-action';
27
+ const registry = {
28
+ schemaVersion: '1',
29
+ domainRoot: DOMAIN_ROOT,
30
+ domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
31
+ webhooks: [],
32
+ subscribers: [
33
+ {
34
+ id: 'on-thing-created',
35
+ kind: 'subscriber',
36
+ handlerFile: 'src/handlers/on-thing-created.ts',
37
+ event: 'thing.created',
38
+ semverRange: '^1',
39
+ outboundAccess: 'internal',
40
+ },
41
+ ],
42
+ schedules: [],
43
+ jobs: [],
44
+ actions: [
45
+ {
46
+ id: 'internal-helper',
47
+ kind: 'action',
48
+ handlerFile: 'src/handlers/internal-helper.ts',
49
+ backendAccess: 'domain',
50
+ exposure: { type: 'internal' },
51
+ idempotent: false,
52
+ inputSchema: { type: 'object', properties: {}, required: [] },
53
+ outputSchema: { type: 'object', properties: {}, required: [] },
54
+ },
55
+ ],
56
+ integrations: [],
57
+ events: [],
58
+ };
59
+ // The grouped (asset) path requires the dist asset directories to exist at
60
+ // synth time; the dedicated NodejsFunction path needs the handler stubs.
61
+ beforeAll(() => {
62
+ fs.mkdirSync(path.join(DOMAIN_ROOT, 'src', 'handlers'), { recursive: true });
63
+ fs.writeFileSync(path.join(DOMAIN_ROOT, 'src', 'handlers', 'internal-helper.ts'), `export const internalHelper = { id: "internal-helper", backendAccess: "domain", exposure: { type: "internal" }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n`);
64
+ fs.writeFileSync(path.join(DOMAIN_ROOT, 'src', 'handlers', 'on-thing-created.ts'), `export const onThingCreated = { id: "on-thing-created", event: "thing.created", semverRange: "^1", handler: async () => {} };\n`);
65
+ fs.mkdirSync(path.join(DIST_ROOT, 'action'), { recursive: true });
66
+ fs.mkdirSync(path.join(DIST_ROOT, 'subscriber'), { recursive: true });
67
+ });
68
+ afterAll(() => {
69
+ fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
70
+ fs.rmSync(path.join(process.cwd(), 'dist', 'domains', 'test-domain'), { recursive: true, force: true });
71
+ });
72
+ describe('DomainStack cross-domain action wiring (#5226)', () => {
73
+ it('gives the internal-action grouped Lambda a deterministic FunctionName', () => {
74
+ const app = new cdk.App();
75
+ const stack = new DomainStack(app, 'TestCrossDomainStack', {
76
+ registry,
77
+ eventBusArn: EVENT_BUS_ARN,
78
+ projectId: 'Test',
79
+ envCode: 'Dev',
80
+ crossDomainActionArns: { email: EMAIL_ACTION_ARN, orgs: ORGS_ACTION_ARN },
81
+ });
82
+ const template = Template.fromStack(stack);
83
+ const lambdas = template.findResources('AWS::Lambda::Function');
84
+ const actionLambda = Object.values(lambdas).find(l => l.Properties.FunctionName === 'test-dev-test-domain-action');
85
+ expect(actionLambda).toBeDefined();
86
+ });
87
+ it('injects TIB_ACTION_LAMBDA_ARNS into subscriber and action Lambdas', () => {
88
+ const app = new cdk.App();
89
+ const stack = new DomainStack(app, 'TestCrossDomainStackEnv', {
90
+ registry,
91
+ eventBusArn: EVENT_BUS_ARN,
92
+ projectId: 'Test',
93
+ envCode: 'Dev',
94
+ crossDomainActionArns: { email: EMAIL_ACTION_ARN, orgs: ORGS_ACTION_ARN },
95
+ });
96
+ const template = Template.fromStack(stack);
97
+ const lambdas = template.findResources('AWS::Lambda::Function');
98
+ // The subscriber group (asset) and the internal-action group both carry
99
+ // the env — select by construct-id substring (keys are hashed, e.g.
100
+ // `testdomainsubscriberinternal...`), since only the action group has an
101
+ // explicit FunctionName.
102
+ const domainLambdas = Object.entries(lambdas).filter(([id]) => /subscriber|action/i.test(id) && !/Health|Ready|LogRetention/i.test(id));
103
+ expect(domainLambdas.length).toBeGreaterThanOrEqual(2);
104
+ for (const [, l] of domainLambdas) {
105
+ const arnsJson = l.Properties.Environment?.Variables?.TIB_ACTION_LAMBDA_ARNS;
106
+ expect(arnsJson).toBeDefined();
107
+ const arns = JSON.parse(arnsJson);
108
+ expect(arns.email).toBe(EMAIL_ACTION_ARN);
109
+ expect(arns.orgs).toBe(ORGS_ACTION_ARN);
110
+ }
111
+ });
112
+ it('grants lambda:InvokeFunction on the exact cross-domain action ARNs', () => {
113
+ const app = new cdk.App();
114
+ const stack = new DomainStack(app, 'TestCrossDomainStackIam', {
115
+ registry,
116
+ eventBusArn: EVENT_BUS_ARN,
117
+ projectId: 'Test',
118
+ envCode: 'Dev',
119
+ crossDomainActionArns: { email: EMAIL_ACTION_ARN, orgs: ORGS_ACTION_ARN },
120
+ });
121
+ const template = Template.fromStack(stack);
122
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
123
+ const hasInvokeGrant = policies.some(p => p.Properties.PolicyDocument.Statement.some(s => s.Action?.includes('lambda:InvokeFunction') && s.Resource?.includes(EMAIL_ACTION_ARN)));
124
+ expect(hasInvokeGrant).toBe(true);
125
+ });
126
+ });
@@ -191,6 +191,52 @@ describe('DomainStack', () => {
191
191
  template.hasOutput('DomainBucketName', {});
192
192
  });
193
193
  });
194
+ describe('adopt existing resources (#5234)', () => {
195
+ const app = new cdk.App();
196
+ const stack = new DomainStack(app, 'TestDomainStackAdopt', {
197
+ registry: minimalRegistry,
198
+ eventBusArn,
199
+ projectId: 'Test',
200
+ envCode: 'Dev',
201
+ adoptExistingResources: true,
202
+ });
203
+ const template = Template.fromStack(stack);
204
+ it('does NOT create a DynamoDB table — the pre-existing table is imported', () => {
205
+ template.resourceCountIs('AWS::DynamoDB::Table', 0);
206
+ });
207
+ it('does NOT create an S3 bucket — the pre-existing bucket is imported', () => {
208
+ template.resourceCountIs('AWS::S3::Bucket', 0);
209
+ });
210
+ it('keeps the CfnOutputs for the adopted table and bucket names', () => {
211
+ template.hasOutput('DomainTableName', {});
212
+ template.hasOutput('DomainBucketName', {});
213
+ });
214
+ it('sets DOMAIN_TABLE_NAME and DOMAIN_BUCKET_NAME env vars to the adopted names', () => {
215
+ const lambdas = template.findResources('AWS::Lambda::Function');
216
+ const domainLambdas = Object.entries(lambdas).filter(([id]) => !id.includes('Health') && !id.includes('Ready') && !id.includes('LogRetention'));
217
+ for (const [, res] of domainLambdas) {
218
+ expect(res.Properties.Environment?.Variables?.DOMAIN_TABLE_NAME).toBe('test-dev-test-domain');
219
+ expect(res.Properties.Environment?.Variables?.DOMAIN_BUCKET_NAME).toBe('test-dev-test-domain');
220
+ }
221
+ });
222
+ it('still grants Lambdas read/write on the adopted table and bucket', () => {
223
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
224
+ const statementResources = (action) => policies.flatMap(p => p.Properties.PolicyDocument.Statement
225
+ .filter(s => s.Action?.includes(action))
226
+ .flatMap(s => {
227
+ const res = s.Resource;
228
+ if (Array.isArray(res))
229
+ return res.map(r => JSON.stringify(r));
230
+ if (res === undefined)
231
+ return [];
232
+ return [JSON.stringify(res)];
233
+ }));
234
+ // DynamoDB grant targets the imported table ARN (synthesized as Fn::Join).
235
+ expect(statementResources('dynamodb:PutItem').some(r => r.includes(':table/test-dev-test-domain'))).toBe(true);
236
+ // S3 grant targets the imported bucket ARN.
237
+ expect(statementResources('s3:PutObject').some(r => r.includes(':s3:::test-dev-test-domain/*'))).toBe(true);
238
+ });
239
+ });
194
240
  describe('tenant-scoped IAM role (defence in depth)', () => {
195
241
  const app = new cdk.App();
196
242
  const stack = new DomainStack(app, 'TestDomainStackTenant', { registry: minimalRegistry, eventBusArn, projectId: 'Test', envCode: 'Dev' });
@@ -49,6 +49,13 @@ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
49
49
  expect(content).toContain('export const handler = createActionLambdaHandler(registry, {');
50
50
  expect(content).toContain(`domainId: "${DOMAIN_ID}"`);
51
51
  }
52
+ else if (primitive === 'subscriber') {
53
+ // Subscribers forward the cross-domain action ARN map so their
54
+ // ctx.actions proxy can dispatch other domains' actions (#5226).
55
+ expect(content).toContain(`export const handler = ${expectedAdapter}(listThings, {`);
56
+ expect(content).toContain(`lambdaArns: JSON.parse(process.env.TIB_ACTION_LAMBDA_ARNS ?? '{}')`);
57
+ expect(content).toContain(`callerDomainId: "${DOMAIN_ID}"`);
58
+ }
52
59
  else {
53
60
  expect(content).toContain(`export const handler = ${expectedAdapter}(listThings);`);
54
61
  }
@@ -147,21 +154,38 @@ describe('buildDedicatedEntryContent (Wave 7 Task 7.3, #4619)', () => {
147
154
  it('generates a single entry that imports all API actions and dispatches by routeKey', () => {
148
155
  const entries = [
149
156
  { id: 'whoami', handlerFile: 'actions/whoami.ts', method: 'GET', routePath: '/auth/v1/auth/whoami' },
150
- { id: 'register-sys-admin', handlerFile: 'actions/register-sys-admin.ts', method: 'POST', routePath: '/auth/v1/auth/register-sys-admin' },
157
+ { id: 'bootstrap-invite', handlerFile: 'actions/bootstrap-invite.ts', method: 'POST', routePath: '/auth/v1/auth/bootstrap-invite' },
151
158
  ];
152
159
  const content = buildGroupedApiActionEntryContent('auth', entries, '/tmp/entry', '/repo/domains/auth');
153
160
  // Imports createExposedActionApiHandler from the runtime
154
161
  expect(content).toContain("import { createExposedActionApiHandler } from '@mettlecast/domain-runtime'");
155
162
  // Imports each action
156
163
  expect(content).toContain('import { whoami } from');
157
- expect(content).toContain('import { registerSysAdmin } from');
164
+ expect(content).toContain('import { bootstrapInvite } from');
158
165
  // Route handlers keyed by routeKey
159
166
  expect(content).toContain('"GET /auth/v1/auth/whoami"');
160
- expect(content).toContain('"POST /auth/v1/auth/register-sys-admin"');
167
+ expect(content).toContain('"POST /auth/v1/auth/bootstrap-invite"');
161
168
  // Dispatcher reads routeKey from event
162
169
  expect(content).toContain('event.requestContext?.routeKey');
163
170
  // 404 fallback
164
171
  expect(content).toContain('statusCode: 404');
165
172
  });
166
173
  });
174
+ describe('subscriber ctx.actions wiring (#5226)', () => {
175
+ it('dedicated subscriber entry forwards lambdaArns + callerDomainId to createSubscriberLambdaHandler', () => {
176
+ const sub = entry('on-member-invited');
177
+ const content = buildDedicatedEntryContent('./handler.js', sub, 'subscriber', DOMAIN_ID);
178
+ expect(content).toContain(`export const handler = createSubscriberLambdaHandler(onMemberInvited, {`);
179
+ // Cross-domain dispatch map is read from env (set by DomainStack from
180
+ // PackDomainOptions.crossDomainActionArns).
181
+ expect(content).toContain(`lambdaArns: JSON.parse(process.env.TIB_ACTION_LAMBDA_ARNS ?? '{}')`);
182
+ expect(content).toContain(`callerDomainId: "${DOMAIN_ID}"`);
183
+ });
184
+ it('non-subscriber primitives keep the plain adapter call (no stray options object)', () => {
185
+ const job = entry('nightly-rollup');
186
+ const content = buildDedicatedEntryContent('./handler.js', job, 'job', DOMAIN_ID);
187
+ expect(content).toContain('export const handler = createJobLambdaHandler(nightlyRollup);');
188
+ expect(content).not.toContain('TIB_ACTION_LAMBDA_ARNS');
189
+ });
190
+ });
167
191
  });
@@ -55,6 +55,12 @@ export interface GroupedLambdaProps {
55
55
  lambdaGroupId?: string;
56
56
  /** When true, creates one dedicated Lambda per handler instead of a single grouped Lambda. */
57
57
  dedicated?: boolean;
58
+ /**
59
+ * Explicit Lambda function name for the GROUPED (non-dedicated) Lambda.
60
+ * Used for the internal-action group so other stacks can resolve its ARN
61
+ * deterministically for cross-domain `ctx.actions` dispatch (#5226).
62
+ */
63
+ functionName?: string;
58
64
  /** Reserved concurrency for all Lambdas in this group. If omitted, no limit. */
59
65
  reservedConcurrency?: number;
60
66
  /** Log retention in days. Default: 30. */
@@ -122,6 +122,23 @@ export function buildDedicatedEntryContent(handlerImportPath, entry, primitiveTy
122
122
  `});`,
123
123
  ].join('\n');
124
124
  }
125
+ if (primitiveType === 'subscriber') {
126
+ // Subscribers need a usable `ctx.actions` proxy. The runtime honours
127
+ // `lambdaArns` (cross-domain dispatch) whenever an actionRegistry is
128
+ // passed, so the generated wrapper forwards the resolved cross-domain
129
+ // action ARN map (injected via TIB_ACTION_LAMBDA_ARNS env) plus the
130
+ // owning domain id. Without this, `ctx.actions.email['send-template-email']`
131
+ // in a subscriber is an empty proxy and throws (#5226).
132
+ return [
133
+ `import { ${adapter} } from '@mettlecast/domain-runtime';`,
134
+ `import { ${exportName} } from '${handlerImportPath}';`,
135
+ '',
136
+ `export const handler = ${adapter}(${exportName}, {`,
137
+ ` lambdaArns: JSON.parse(process.env.TIB_ACTION_LAMBDA_ARNS ?? '{}'),`,
138
+ ` callerDomainId: ${JSON.stringify(domainId)},`,
139
+ `});`,
140
+ ].join('\n');
141
+ }
125
142
  return [
126
143
  `import { ${adapter} } from '@mettlecast/domain-runtime';`,
127
144
  `import { ${exportName} } from '${handlerImportPath}';`,
@@ -202,6 +219,7 @@ export function createGroupedLambdas(scope, props) {
202
219
  handler: 'index.handler',
203
220
  code: lambda.Code.fromAsset(`dist/domains/${props.domainId}/${props.primitiveType}`),
204
221
  layers: [powertoolsLayer],
222
+ ...(props.functionName ? { functionName: props.functionName } : {}),
205
223
  environment: {
206
224
  ...props.environment,
207
225
  POWERTOOLS_SERVICE_NAME: serviceName,
@@ -49,8 +49,20 @@ export interface PackDomainOptions {
49
49
  projectId?: string;
50
50
  /** Environment code used to prefix per-domain physical resource names (e.g. `dev`, `prod`). */
51
51
  envCode?: string;
52
+ /**
53
+ * Map of OTHER domain ids → their internal-action grouped Lambda ARNs.
54
+ * Injected into this domain's Lambdas so `ctx.actions.otherDomain.someAction()`
55
+ * dispatches cross-domain (#5226).
56
+ */
57
+ crossDomainActionArns?: Record<string, string>;
52
58
  /** Disable auto-generated per-domain CloudWatch dashboard. */
53
59
  disableCloudWatchDashboards?: boolean;
60
+ /**
61
+ * When true, the per-domain DynamoDB table and S3 bucket are imported
62
+ * (adopted) instead of created — the migration-safe path when those
63
+ * resources already exist outside the CloudFormation stack (#5234).
64
+ */
65
+ adoptExistingResources?: boolean;
54
66
  }
55
67
  /**
56
68
  * Convenience entry-point: constructs a DomainStack from a compiled registry.
@@ -59,6 +59,8 @@ export function packDomain(registry, app, stackId, eventBusArn, options) {
59
59
  eventBusName: opts.eventBusName,
60
60
  projectId: opts.projectId,
61
61
  envCode: opts.envCode,
62
+ crossDomainActionArns: opts.crossDomainActionArns,
62
63
  disableCloudWatchDashboards: opts.disableCloudWatchDashboards,
64
+ adoptExistingResources: opts.adoptExistingResources,
63
65
  });
64
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cdk-packer",
3
- "version": "0.2.94",
3
+ "version": "0.2.96",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",