@mettlecast/domain-cdk-packer 0.2.94 → 0.2.95
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.d.ts +9 -0
- package/dist/DomainStack.js +31 -3
- package/dist/__tests__/bootstrap-invite-registration.test.d.ts +1 -0
- package/dist/__tests__/bootstrap-invite-registration.test.js +115 -0
- package/dist/__tests__/cross-domain-wiring.test.d.ts +1 -0
- package/dist/__tests__/cross-domain-wiring.test.js +126 -0
- package/dist/__tests__/grouped-lambda-factory.test.js +27 -3
- package/dist/grouped-lambda-factory.d.ts +6 -0
- package/dist/grouped-lambda-factory.js +18 -0
- package/dist/pack-domain.d.ts +6 -0
- package/dist/pack-domain.js +1 -0
- package/package.json +1 -1
package/dist/DomainStack.d.ts
CHANGED
|
@@ -84,6 +84,15 @@ 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.
|
package/dist/DomainStack.js
CHANGED
|
@@ -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,
|
|
@@ -439,6 +454,16 @@ export class DomainStack extends cdk.Stack {
|
|
|
439
454
|
// Grant IAM permissions for this queue
|
|
440
455
|
const queuePolicies = iamBuilder.forSubscriber({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
|
|
441
456
|
queuePolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
457
|
+
// Cross-domain action dispatch: subscribers (e.g. auth's
|
|
458
|
+
// on-member-invited) call other domains' internal-action grouped
|
|
459
|
+
// Lambdas via `ctx.actions` (#5226).
|
|
460
|
+
const crossDomainActionArnList = Object.values(crossDomainActionArns);
|
|
461
|
+
if (crossDomainActionArnList.length > 0) {
|
|
462
|
+
fn.addToRolePolicy(new iam.PolicyStatement({
|
|
463
|
+
actions: ['lambda:InvokeFunction'],
|
|
464
|
+
resources: crossDomainActionArnList,
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
442
467
|
// Create EventBridge rule targeting SQS queue
|
|
443
468
|
new events.Rule(this, `${pascalId}Rule`, {
|
|
444
469
|
eventBus,
|
|
@@ -564,9 +589,12 @@ export class DomainStack extends cdk.Stack {
|
|
|
564
589
|
const actionLambdaById = actionHandlers.byId;
|
|
565
590
|
actionLambdas = actionHandlers.lambdas;
|
|
566
591
|
this.lambdaArns[`${domainId}-action`] = actionLambdas[0].functionArn;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
592
|
+
// Cross-domain invoke grants: each domain's action/subscriber Lambdas
|
|
593
|
+
// may call other domains' internal-action grouped Lambdas via
|
|
594
|
+
// `ctx.actions`. Use the resolved ARN list (deterministic function
|
|
595
|
+
// names) instead of a stale wildcard pattern (#5226).
|
|
596
|
+
const crossDomainActionArnList = Object.values(crossDomainActionArns);
|
|
597
|
+
const iamPolicies = iamBuilder.forAction(crossDomainActionArnList);
|
|
570
598
|
actionLambdas.forEach((fn) => {
|
|
571
599
|
iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
572
600
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|
|
@@ -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: '
|
|
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 {
|
|
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/
|
|
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,
|
package/dist/pack-domain.d.ts
CHANGED
|
@@ -49,6 +49,12 @@ 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;
|
|
54
60
|
}
|
package/dist/pack-domain.js
CHANGED
|
@@ -59,6 +59,7 @@ 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,
|
|
63
64
|
});
|
|
64
65
|
}
|