@mettlecast/domain-cdk-packer 0.2.110 → 0.2.112
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 +6 -2
- package/dist/DomainStack.js +20 -2
- package/dist/__tests__/cross-domain-action-arn-naming.test.d.ts +1 -0
- package/dist/__tests__/cross-domain-action-arn-naming.test.js +180 -0
- package/dist/__tests__/deploy-entry-naming-parity.test.d.ts +1 -0
- package/dist/__tests__/deploy-entry-naming-parity.test.js +130 -0
- package/dist/__tests__/invitation-delivery-wiring.test.d.ts +1 -0
- package/dist/__tests__/invitation-delivery-wiring.test.js +170 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/naming.d.ts +18 -0
- package/dist/naming.js +23 -0
- package/package.json +1 -1
package/dist/DomainStack.d.ts
CHANGED
|
@@ -89,8 +89,12 @@ export interface DomainStackProps extends cdk.StackProps {
|
|
|
89
89
|
* Injected into this domain's subscriber/action Lambdas (via the
|
|
90
90
|
* `TIB_ACTION_LAMBDA_ARNS` env var) so `ctx.actions.otherDomain.someAction()`
|
|
91
91
|
* dispatches cross-domain through the runtime's Lambda invoke envelope
|
|
92
|
-
* (#5226). ARNs
|
|
93
|
-
*
|
|
92
|
+
* (#5226). ARNs MUST be built with `domainActionLambdaArn(projectId,
|
|
93
|
+
* envCode, domainId)` — the single canonical source that matches the
|
|
94
|
+
* deterministic physical name this stack assigns to the grouped internal
|
|
95
|
+
* action Lambda (`${projectId}-${envCode}-${domainId}-action`, lowercased).
|
|
96
|
+
* Hand-assembled ARN strings drift from that name and make every
|
|
97
|
+
* cross-domain invoke target a phantom function (#5381).
|
|
94
98
|
*/
|
|
95
99
|
crossDomainActionArns?: Record<string, string>;
|
|
96
100
|
/**
|
package/dist/DomainStack.js
CHANGED
|
@@ -20,7 +20,7 @@ import { CmkConstruct } from './constructs/cmk-construct.js';
|
|
|
20
20
|
import { WafConstruct } from './constructs/waf-construct.js';
|
|
21
21
|
import { AlarmConstruct } from './constructs/alarm-construct.js';
|
|
22
22
|
import { CanaryConstruct } from './constructs/canary-construct.js';
|
|
23
|
-
import { domainResourceName } from './naming.js';
|
|
23
|
+
import { domainResourceName, domainActionLambdaName } from './naming.js';
|
|
24
24
|
import { SecurityAssertionAspect } from './aspects/security-assertion-aspect.js';
|
|
25
25
|
import { validateRegistry } from './validate-registry.js';
|
|
26
26
|
/** L1 helper: adds a CfnRoute + CfnIntegration to an existing HTTP API. */
|
|
@@ -259,7 +259,9 @@ export class DomainStack extends cdk.Stack {
|
|
|
259
259
|
}
|
|
260
260
|
// Deterministic physical name for THIS domain's internal-action grouped
|
|
261
261
|
// Lambda so other stacks can construct its ARN for cross-domain calls.
|
|
262
|
-
|
|
262
|
+
// Canonical deterministic name shared with every cross-domain ARN
|
|
263
|
+
// builder (naming.ts) — never hand-assemble this pattern elsewhere.
|
|
264
|
+
const actionLambdaName = domainActionLambdaName(projectId, envCode, domainId);
|
|
263
265
|
const resolveSubnetSelection = (access) => {
|
|
264
266
|
if (access === 'internet')
|
|
265
267
|
return internetSubnetSelection ?? internalSubnetSelection;
|
|
@@ -662,6 +664,22 @@ export class DomainStack extends cdk.Stack {
|
|
|
662
664
|
const queuePolicies = iamBuilder.forJob({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
|
|
663
665
|
queuePolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
664
666
|
}
|
|
667
|
+
// Cross-domain action dispatch from jobs (#5389): job consumers are
|
|
668
|
+
// first-class orchestrators — the claim-guarded delivery job invokes
|
|
669
|
+
// `email.send-template-email` through `ctx.actions`, exactly like the
|
|
670
|
+
// subscriber path (#5226). Without this grant the durable delivery
|
|
671
|
+
// path would fail with AccessDenied on every attempt and land
|
|
672
|
+
// everything in the job DLQ. Same resolved deterministic-ARN list the
|
|
673
|
+
// subscriber wiring uses — never a wildcard.
|
|
674
|
+
const jobCrossDomainActionArns = Object.values(crossDomainActionArns);
|
|
675
|
+
if (jobCrossDomainActionArns.length > 0) {
|
|
676
|
+
jobLambdas.forEach((fn) => {
|
|
677
|
+
fn.addToRolePolicy(new iam.PolicyStatement({
|
|
678
|
+
actions: ['lambda:InvokeFunction'],
|
|
679
|
+
resources: jobCrossDomainActionArns,
|
|
680
|
+
}));
|
|
681
|
+
});
|
|
682
|
+
}
|
|
665
683
|
// Add dbSecretArn grant if provided
|
|
666
684
|
if (dbSecretArn) {
|
|
667
685
|
jobLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #5381 — the cross-domain action ARN map MUST resolve to the physical name of
|
|
3
|
+
* the internal-action grouped Lambda that DomainStack actually deploys.
|
|
4
|
+
*
|
|
5
|
+
* Production evidence (run 33969906049): auth's `on-member-invited`
|
|
6
|
+
* subscriber called `ctx.actions.email['send-template-email']` and the email
|
|
7
|
+
* send never started (no email_send_audit row). The map consumed by the
|
|
8
|
+
* subscriber Lambda had never been reconciled against the deployed function
|
|
9
|
+
* name: the generated app hand-assembled
|
|
10
|
+
* `${projectId}-${envCode}-domain-${other}-action` while DomainStack names
|
|
11
|
+
* the grouped internal-action Lambda
|
|
12
|
+
* `${projectId}-${envCode}-${domainId}-action` (naming.ts, lowercased). Every
|
|
13
|
+
* cross-domain invoke — and the IAM grant backing it — pointed at a phantom
|
|
14
|
+
* function. The existing cross-domain-wiring.test.ts could not catch this
|
|
15
|
+
* because it injected a synthetic ARN prop and asserted the SAME string came
|
|
16
|
+
* back out; it never compared the map against the deployed FunctionName.
|
|
17
|
+
*
|
|
18
|
+
* These tests close that loop with the REAL constructs:
|
|
19
|
+
* 1. synthesize an email DomainStack and read the physical FunctionName of
|
|
20
|
+
* its grouped internal-action Lambda out of the template;
|
|
21
|
+
* 2. synthesize an auth DomainStack whose crossDomainActionArns map is built
|
|
22
|
+
* exactly the way the generated deploy entries build it — through the
|
|
23
|
+
* shared `domainActionLambdaArn` helper — and assert the subscriber's
|
|
24
|
+
* TIB_ACTION_LAMBDA_ARNS value resolves to that same function name;
|
|
25
|
+
* 3. assert the subscriber's lambda:InvokeFunction grant targets the same
|
|
26
|
+
* function resource (a valid map with a missing grant is equally
|
|
27
|
+
* undeliverable).
|
|
28
|
+
*/
|
|
29
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
30
|
+
import * as cdk from 'aws-cdk-lib';
|
|
31
|
+
import { Template } from 'aws-cdk-lib/assertions';
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { DomainStack } from '../DomainStack.js';
|
|
35
|
+
import { domainActionLambdaName, domainActionLambdaArn } from '../naming.js';
|
|
36
|
+
const REPO_CWD = process.cwd();
|
|
37
|
+
const EVENT_BUS_ARN = 'arn:aws:events:eu-north-1:123456789012:event-bus/test-bus';
|
|
38
|
+
const PROJECT_ID = 'MTC';
|
|
39
|
+
const ENV_CODE = 'Dev';
|
|
40
|
+
function fixtureRegistry(domainId, opts) {
|
|
41
|
+
const root = path.join(REPO_CWD, `.test-arn-naming-${domainId}`);
|
|
42
|
+
return {
|
|
43
|
+
schemaVersion: '1',
|
|
44
|
+
domainRoot: root,
|
|
45
|
+
domain: { id: domainId, kind: 'domain', name: domainId, tenancy: 'none' },
|
|
46
|
+
webhooks: [],
|
|
47
|
+
subscribers: opts.subscribers
|
|
48
|
+
? [
|
|
49
|
+
{
|
|
50
|
+
id: 'on-member-invited',
|
|
51
|
+
kind: 'subscriber',
|
|
52
|
+
handlerFile: 'subscribers/on-member-invited.ts',
|
|
53
|
+
event: 'auth.member.invited',
|
|
54
|
+
semverRange: '^1',
|
|
55
|
+
outboundAccess: 'internal',
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
: [],
|
|
59
|
+
schedules: [],
|
|
60
|
+
jobs: [],
|
|
61
|
+
actions: opts.internalActions.map((id) => ({
|
|
62
|
+
id,
|
|
63
|
+
kind: 'action',
|
|
64
|
+
handlerFile: `actions/${id}.ts`,
|
|
65
|
+
backendAccess: 'domain',
|
|
66
|
+
exposure: { type: 'internal' },
|
|
67
|
+
idempotent: false,
|
|
68
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
69
|
+
outputSchema: { type: 'object', properties: {}, required: [] },
|
|
70
|
+
})),
|
|
71
|
+
integrations: [],
|
|
72
|
+
events: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Create the handler stubs + grouped-Lambda asset dirs DomainStack expects. */
|
|
76
|
+
function materializeFixture(domainId, registry) {
|
|
77
|
+
const root = registry.domainRoot;
|
|
78
|
+
fs.mkdirSync(path.join(root, 'subscribers'), { recursive: true });
|
|
79
|
+
fs.mkdirSync(path.join(root, 'actions'), { recursive: true });
|
|
80
|
+
for (const sub of registry.subscribers) {
|
|
81
|
+
fs.writeFileSync(path.join(root, sub.handlerFile), `export const onMemberInvited = { id: "${sub.id}", event: "${sub.event}", semverRange: "^1", handler: async () => {} };\n`);
|
|
82
|
+
}
|
|
83
|
+
for (const action of registry.actions) {
|
|
84
|
+
const exportName = action.id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
85
|
+
fs.writeFileSync(path.join(root, action.handlerFile), `export const ${exportName} = { id: "${action.id}", backendAccess: "domain", exposure: { type: "internal" }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n`);
|
|
86
|
+
}
|
|
87
|
+
for (const prim of ['subscriber', 'action']) {
|
|
88
|
+
fs.mkdirSync(path.join(REPO_CWD, 'dist', 'domains', domainId, prim), { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a (possibly tokenized) CFN value to a plain string, replacing
|
|
93
|
+
* pseudo-parameter refs with stable placeholders. The JSON map injected as
|
|
94
|
+
* TIB_ACTION_LAMBDA_ARNS synthesizes to an Fn::Join of literal segments and
|
|
95
|
+
* {Ref: AWS::Region}/{Ref: AWS::AccountId} tokens; the region/account do not
|
|
96
|
+
* affect the function-name segment we assert on.
|
|
97
|
+
*/
|
|
98
|
+
function flattenCfn(value) {
|
|
99
|
+
if (typeof value === 'string')
|
|
100
|
+
return value;
|
|
101
|
+
if (value && typeof value === 'object') {
|
|
102
|
+
const rec = value;
|
|
103
|
+
if (Array.isArray(rec['Fn::Join'])) {
|
|
104
|
+
const [delim, parts] = rec['Fn::Join'];
|
|
105
|
+
return parts.map(flattenCfn).join(delim);
|
|
106
|
+
}
|
|
107
|
+
if (typeof rec['Ref'] === 'string')
|
|
108
|
+
return `__${rec['Ref']}__`;
|
|
109
|
+
if (Array.isArray(value))
|
|
110
|
+
return value.map(flattenCfn).join('');
|
|
111
|
+
}
|
|
112
|
+
return String(value);
|
|
113
|
+
}
|
|
114
|
+
function findDomainLambdas(template) {
|
|
115
|
+
return template.findResources('AWS::Lambda::Function');
|
|
116
|
+
}
|
|
117
|
+
beforeAll(() => {
|
|
118
|
+
materializeFixture('email', fixtureRegistry('email', { subscribers: false, internalActions: ['send-template-email'] }));
|
|
119
|
+
materializeFixture('auth', fixtureRegistry('auth', { subscribers: true, internalActions: ['verify-token'] }));
|
|
120
|
+
});
|
|
121
|
+
afterAll(() => {
|
|
122
|
+
for (const d of ['email', 'auth']) {
|
|
123
|
+
fs.rmSync(path.join(REPO_CWD, `.test-arn-naming-${d}`), { recursive: true, force: true });
|
|
124
|
+
fs.rmSync(path.join(REPO_CWD, 'dist', 'domains', d), { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
describe('cross-domain action ARN ⇔ deployed Lambda name (#5381)', () => {
|
|
128
|
+
it('the grouped internal-action Lambda carries the helper-derived physical name', () => {
|
|
129
|
+
const app = new cdk.App();
|
|
130
|
+
const registry = fixtureRegistry('email', { subscribers: false, internalActions: ['send-template-email'] });
|
|
131
|
+
const stack = new DomainStack(app, 'MTC-Dev-domain-email', {
|
|
132
|
+
registry,
|
|
133
|
+
eventBusArn: EVENT_BUS_ARN,
|
|
134
|
+
projectId: PROJECT_ID,
|
|
135
|
+
envCode: ENV_CODE,
|
|
136
|
+
});
|
|
137
|
+
const template = Template.fromStack(stack);
|
|
138
|
+
const lambdas = findDomainLambdas(template);
|
|
139
|
+
const physical = Object.values(lambdas).map((l) => l.Properties.FunctionName).filter(Boolean);
|
|
140
|
+
// The deployed name has NO 'domain' segment — this exact fact is what the
|
|
141
|
+
// old hand-assembled app.ts ARN got wrong.
|
|
142
|
+
expect(physical).toContain('mtc-dev-email-action');
|
|
143
|
+
});
|
|
144
|
+
it('a subscriber map built via domainActionLambdaArn resolves to the deployed email action function name', () => {
|
|
145
|
+
// Build the map the way BOTH generated deploy entries now build it.
|
|
146
|
+
const crossDomainActionArns = {
|
|
147
|
+
email: domainActionLambdaArn(PROJECT_ID, ENV_CODE, 'email'),
|
|
148
|
+
};
|
|
149
|
+
const app = new cdk.App();
|
|
150
|
+
const registry = fixtureRegistry('auth', { subscribers: true, internalActions: ['verify-token'] });
|
|
151
|
+
const stack = new DomainStack(app, 'MTC-Dev-domain-auth', {
|
|
152
|
+
registry,
|
|
153
|
+
eventBusArn: EVENT_BUS_ARN,
|
|
154
|
+
projectId: PROJECT_ID,
|
|
155
|
+
envCode: ENV_CODE,
|
|
156
|
+
crossDomainActionArns,
|
|
157
|
+
});
|
|
158
|
+
const template = Template.fromStack(stack);
|
|
159
|
+
// 1) Locate the grouped subscriber Lambda by its env-injected map.
|
|
160
|
+
const lambdas = findDomainLambdas(template);
|
|
161
|
+
const subscriberEntries = Object.entries(lambdas).filter(([id, l]) => /subscriber/i.test(id) && l.Properties.Environment?.Variables?.TIB_ACTION_LAMBDA_ARNS);
|
|
162
|
+
expect(subscriberEntries.length).toBeGreaterThanOrEqual(1);
|
|
163
|
+
for (const [, l] of subscriberEntries) {
|
|
164
|
+
const raw = l.Properties.Environment.Variables.TIB_ACTION_LAMBDA_ARNS;
|
|
165
|
+
const arns = JSON.parse(flattenCfn(raw));
|
|
166
|
+
const functionSegment = arns.email.split(':function:')[1];
|
|
167
|
+
// 2) THE invariant that was broken in production: the map's function
|
|
168
|
+
// segment must equal the physical name of the deployed email
|
|
169
|
+
// internal-action Lambda, not a hand-assembled approximation.
|
|
170
|
+
expect(functionSegment).toBe(domainActionLambdaName(PROJECT_ID, ENV_CODE, 'email'));
|
|
171
|
+
expect(functionSegment).toBe('mtc-dev-email-action');
|
|
172
|
+
}
|
|
173
|
+
// 3) The subscriber role must actually be granted invoke on THAT function
|
|
174
|
+
// (env correct but IAM phantom = still undeliverable).
|
|
175
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
176
|
+
const hasInvokeOnRealName = policies.some((p) => p.Properties.PolicyDocument.Statement.some((s) => s.Action?.includes('lambda:InvokeFunction') &&
|
|
177
|
+
flattenCfn(s.Resource).includes(`:function:${domainActionLambdaName(PROJECT_ID, ENV_CODE, 'email')}`)));
|
|
178
|
+
expect(hasInvokeOnRealName).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #5385 — the COMMITTED generated deploy entries (infra/modules/app.ts and
|
|
3
|
+
* app-domain.ts) run in CI against the PUBLISHED @mettlecast/domain-cdk-packer:
|
|
4
|
+
* the deploy jobs `npm ci` in infra/modules and `npx tsx` loads the installed
|
|
5
|
+
* ESM tarball. Template v172 (#5384) imported `domainActionLambdaArn` — a
|
|
6
|
+
* member that exists only in this package's SOURCE — so every domain deploy
|
|
7
|
+
* died at link time with
|
|
8
|
+
* `SyntaxError: The requested module '@mettlecast/domain-cdk-packer' does
|
|
9
|
+
* not provide an export named 'domainActionLambdaArn'`
|
|
10
|
+
* (TIB Deploy run 34000848210) before the #5381 ARN repair could ship.
|
|
11
|
+
* Template v173 defines the ARN builder LOCALLY in the generated entries, on
|
|
12
|
+
* top of the published `domainResourceName` primitive.
|
|
13
|
+
*
|
|
14
|
+
* This test pins both halves of that contract:
|
|
15
|
+
* 1. the committed deploy entries value-import ONLY members verified to
|
|
16
|
+
* exist on the published packer export surface (npm tarballs 0.2.96 and
|
|
17
|
+
* 0.2.110 were checked: `packDomain`, `domainResourceName`);
|
|
18
|
+
* 2. the emitted local helper — extracted and evaluated with the REAL
|
|
19
|
+
* `domainResourceName` — produces the exact function name this package's
|
|
20
|
+
* canonical `domainActionLambdaName` assigns the deployed grouped
|
|
21
|
+
* internal-action Lambda. The local mirror must not fork away from
|
|
22
|
+
* naming.ts; if it drifts, this fails. (Region/account halves are CDK
|
|
23
|
+
* pseudo-parameters — asserted structurally, not by token identity.)
|
|
24
|
+
*/
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { join, dirname } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { domainResourceName, domainActionLambdaName } from '../naming.js';
|
|
30
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
|
|
31
|
+
/** Deploy entries the TIB Deploy jobs execute via `npx tsx`. */
|
|
32
|
+
const DEPLOY_ENTRIES = ['infra/modules/app.ts', 'infra/modules/app-domain.ts'];
|
|
33
|
+
/**
|
|
34
|
+
* Value exports VERIFIED present on every published packer the generated
|
|
35
|
+
* package.json range (^0.2.96) can resolve, including the lockfile-pinned
|
|
36
|
+
* 0.2.110. Widening this set requires first proving the member ships on the
|
|
37
|
+
* published tarball — that proof is exactly what #5385 lacked.
|
|
38
|
+
*/
|
|
39
|
+
const PUBLISHED_VALUE_EXPORTS = new Set(['packDomain', 'domainResourceName']);
|
|
40
|
+
/** Stub pseudo-parameter values for evaluating the emitted helper offline. */
|
|
41
|
+
const STUB_REGION = '__AWS_REGION__';
|
|
42
|
+
const STUB_ACCOUNT = '__AWS_ACCOUNT_ID__';
|
|
43
|
+
/**
|
|
44
|
+
* Extract the local `domainActionLambdaArn` function from a deploy entry and
|
|
45
|
+
* return it as a callable, with `cdk.Aws.*` stubbed and the REAL published
|
|
46
|
+
* `domainResourceName` primitive injected.
|
|
47
|
+
*/
|
|
48
|
+
function extractLocalArnHelper(src, label) {
|
|
49
|
+
const match = src.match(/function domainActionLambdaArn\([^)]*\)\s*:\s*string\s*\{[\s\S]*?\n\}/);
|
|
50
|
+
expect(match, `${label}: must define the local domainActionLambdaArn helper (template v173, #5385)`).toBeTruthy();
|
|
51
|
+
const stubCdk = { Aws: { REGION: STUB_REGION, ACCOUNT_ID: STUB_ACCOUNT } };
|
|
52
|
+
// The emitted helper is TypeScript; `new Function` parses plain JS. The
|
|
53
|
+
// function body contains no `: string` sequences, so stripping the four
|
|
54
|
+
// signature annotations is safe and keeps the executed logic verbatim.
|
|
55
|
+
const jsSource = match[0].replace(/: string/g, '');
|
|
56
|
+
// eslint-disable-next-line no-new-func -- executing the generated mirror IS the parity check
|
|
57
|
+
const factory = new Function('cdk', 'domainResourceName', `${jsSource}\nreturn domainActionLambdaArn;`);
|
|
58
|
+
return factory(stubCdk, domainResourceName);
|
|
59
|
+
}
|
|
60
|
+
/** Named value imports from '@mettlecast/domain-cdk-packer' (type imports erased). */
|
|
61
|
+
function packerValueImports(src) {
|
|
62
|
+
const names = [];
|
|
63
|
+
for (const line of src.split('\n')) {
|
|
64
|
+
const t = line.trim();
|
|
65
|
+
if (!t.startsWith('import') || !t.includes('@mettlecast/domain-cdk-packer'))
|
|
66
|
+
continue;
|
|
67
|
+
if (t.startsWith('import type'))
|
|
68
|
+
continue;
|
|
69
|
+
const braceOpen = t.indexOf('{');
|
|
70
|
+
const braceClose = t.indexOf('}');
|
|
71
|
+
if (braceOpen === -1 || braceClose === -1)
|
|
72
|
+
continue;
|
|
73
|
+
for (const raw of t.slice(braceOpen + 1, braceClose).split(',')) {
|
|
74
|
+
const name = raw.trim().split(/\s+as\s+/)[0].trim();
|
|
75
|
+
if (name)
|
|
76
|
+
names.push(name);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return names;
|
|
80
|
+
}
|
|
81
|
+
describe('deploy entries import only published packer members (#5385)', () => {
|
|
82
|
+
for (const rel of DEPLOY_ENTRIES) {
|
|
83
|
+
it(`${rel} value-imports nothing the installed packer lacks`, () => {
|
|
84
|
+
const src = readFileSync(join(repoRoot, rel), 'utf8');
|
|
85
|
+
const imported = packerValueImports(src);
|
|
86
|
+
// The regression itself: an unexported member crashes `npx tsx` at link
|
|
87
|
+
// time, so every deploy stack fails BEFORE synthesis. Fail loudly here.
|
|
88
|
+
expect(imported.length, `${rel} must import packDomain from the packer`).toBeGreaterThan(0);
|
|
89
|
+
for (const name of imported) {
|
|
90
|
+
expect(PUBLISHED_VALUE_EXPORTS.has(name), `${rel} imports { ${name} } from @mettlecast/domain-cdk-packer, but that member is not on the published export surface (SyntaxError at deploy time — #5385)`).toBe(true);
|
|
91
|
+
}
|
|
92
|
+
expect(imported).not.toContain('domainActionLambdaArn');
|
|
93
|
+
expect(imported).not.toContain('domainActionLambdaName');
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
describe('local ARN mirror ≡ canonical naming.ts (#5381/#5385)', () => {
|
|
98
|
+
it('naming.ts itself derives the action name from domainResourceName + "-action"', () => {
|
|
99
|
+
// The local mirror builds its ARN from the SAME published primitive; this
|
|
100
|
+
// pins the canonical contract the mirror is allowed to reproduce.
|
|
101
|
+
expect(domainActionLambdaName('MTC', 'Dev', 'email')).toBe(`${domainResourceName('MTC', 'Dev', 'email')}-action`);
|
|
102
|
+
expect(domainActionLambdaName('mtc', 'dev', 'email')).toBe('mtc-dev-email-action');
|
|
103
|
+
});
|
|
104
|
+
const fixtures = [
|
|
105
|
+
['MTC', 'Dev', 'email'],
|
|
106
|
+
['MTC', 'prod', 'data-management'],
|
|
107
|
+
['MyProj', 'Staging', 'billing'],
|
|
108
|
+
['MTC', 'Dev', 'auth'],
|
|
109
|
+
];
|
|
110
|
+
for (const rel of DEPLOY_ENTRIES) {
|
|
111
|
+
const src = readFileSync(join(repoRoot, rel), 'utf8');
|
|
112
|
+
const local = extractLocalArnHelper(src, rel);
|
|
113
|
+
for (const [projectId, envCode, domainId] of fixtures) {
|
|
114
|
+
it(`${rel}: domainActionLambdaArn('${projectId}', '${envCode}', '${domainId}') resolves to the deployed function name`, () => {
|
|
115
|
+
const generatedArn = local(projectId, envCode, domainId);
|
|
116
|
+
// Region/account halves are CFN pseudo-parameters (asserted by shape);
|
|
117
|
+
// the FUNCTION NAME is what drifted in production and what this guards.
|
|
118
|
+
expect(generatedArn.startsWith(`arn:aws:lambda:${STUB_REGION}:${STUB_ACCOUNT}:function:`)).toBe(true);
|
|
119
|
+
const functionSegment = generatedArn.split(':function:')[1];
|
|
120
|
+
expect(functionSegment).toBe(domainActionLambdaName(projectId, envCode, domainId));
|
|
121
|
+
// No phantom 'domain' segment (#5381's original bug shape).
|
|
122
|
+
expect(functionSegment).not.toContain('-domain-');
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
it(`${rel}: helper exists exactly once (no forked duplicates)`, () => {
|
|
126
|
+
const defs = src.match(/function domainActionLambdaArn\(/g) ?? [];
|
|
127
|
+
expect(defs.length).toBe(1);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, 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
|
+
* #5389 — synth-level contract for the durable invitation-delivery wiring
|
|
9
|
+
* in the auth domain stack:
|
|
10
|
+
*
|
|
11
|
+
* 1. the domain declares `auth.outbox`, so every Lambda gets
|
|
12
|
+
* TIB_OUTBOX_TABLE (the dispatcher schedule needs a real ctx.outbox);
|
|
13
|
+
* 2. the claim-guarded `deliver-invitation-email` job gets its encrypted
|
|
14
|
+
* queue + DLQ, redrive `maxReceiveCount = maxRetries + 1`, and the
|
|
15
|
+
* standard DLQ depth/age alarms wired to the SNS topic;
|
|
16
|
+
* 3. the JOB Lambda role receives the cross-domain
|
|
17
|
+
* `lambda:InvokeFunction` grant scoped to the resolved internal-action
|
|
18
|
+
* ARNs — without it, the job's ctx.actions.email['send-template-email']
|
|
19
|
+
* call would AccessDenied on every attempt (the gap this issue found:
|
|
20
|
+
* jobs were never granted what subscribers already had);
|
|
21
|
+
* 4. reconciliation + dispatch schedules synthesize EventBridge Scheduler
|
|
22
|
+
* rules.
|
|
23
|
+
*/
|
|
24
|
+
const DOMAIN_ROOT = path.join(process.cwd(), '.test-domain-cdk-packer-5389');
|
|
25
|
+
const handlerDir = path.join(DOMAIN_ROOT, 'subscribers');
|
|
26
|
+
fs.mkdirSync(handlerDir, { recursive: true });
|
|
27
|
+
fs.mkdirSync(path.join(DOMAIN_ROOT, 'jobs'), { recursive: true });
|
|
28
|
+
fs.mkdirSync(path.join(DOMAIN_ROOT, 'schedules'), { recursive: true });
|
|
29
|
+
fs.writeFileSync(path.join(handlerDir, 'on-member-invited.ts'), 'export const onMemberInvited = { id: "on-member-invited", _kind: "subscriber", event: "auth.member.invited", semverRange: ">=1", handler: async () => undefined };\n');
|
|
30
|
+
fs.writeFileSync(path.join(DOMAIN_ROOT, 'jobs', 'deliver-invitation-email.ts'), 'export const deliverInvitationEmail = { id: "deliver-invitation-email", _kind: "job", maxRetries: 5, visibilityTimeoutSeconds: 300, handler: async () => undefined };\n');
|
|
31
|
+
fs.writeFileSync(path.join(DOMAIN_ROOT, 'schedules', 'dispatch-invitation-outbox.ts'), 'export const dispatchInvitationOutbox = { id: "dispatch-invitation-outbox", _kind: "schedule", cron: "rate(1 minute)", enabled: true, handler: async () => undefined };\n');
|
|
32
|
+
fs.writeFileSync(path.join(DOMAIN_ROOT, 'schedules', 'reconcile-invitation-delivery.ts'), 'export const reconcileInvitationDelivery = { id: "reconcile-invitation-delivery", _kind: "schedule", cron: "rate(15 minutes)", enabled: true, handler: async () => undefined };\n');
|
|
33
|
+
// Grouped Lambdas use Code.fromAsset('dist/domains/{domain}/{type}') — stub
|
|
34
|
+
// the asset dirs so Template.fromStack can synth without a real bundle.
|
|
35
|
+
const assetDirs = ['subscriber', 'schedule', 'job'].map(type => path.join(process.cwd(), 'dist', 'domains', 'auth', type));
|
|
36
|
+
for (const dir of assetDirs) {
|
|
37
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
38
|
+
fs.writeFileSync(path.join(dir, 'index.js'), 'exports.handler = async () => ({});\n');
|
|
39
|
+
}
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
|
|
42
|
+
for (const dir of assetDirs) {
|
|
43
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
const EMAIL_ACTION_ARN = 'arn:aws:lambda:eu-north-1:123456789012:function:MTC-Dev-domain-email-actionapi';
|
|
47
|
+
const authRegistry = {
|
|
48
|
+
schemaVersion: '1',
|
|
49
|
+
domainRoot: DOMAIN_ROOT,
|
|
50
|
+
domain: {
|
|
51
|
+
id: 'auth',
|
|
52
|
+
kind: 'domain',
|
|
53
|
+
name: 'Auth',
|
|
54
|
+
tenancy: 'required',
|
|
55
|
+
outboxTableName: 'auth.outbox',
|
|
56
|
+
},
|
|
57
|
+
webhooks: [],
|
|
58
|
+
subscribers: [
|
|
59
|
+
{
|
|
60
|
+
id: 'on-member-invited', kind: 'subscriber', handlerFile: 'subscribers/on-member-invited.ts',
|
|
61
|
+
event: 'auth.member.invited', semverRange: '>=1',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
schedules: [
|
|
65
|
+
{
|
|
66
|
+
id: 'dispatch-invitation-outbox', kind: 'schedule', handlerFile: 'schedules/dispatch-invitation-outbox.ts',
|
|
67
|
+
cron: 'rate(1 minute)', enabled: true,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'reconcile-invitation-delivery', kind: 'schedule', handlerFile: 'schedules/reconcile-invitation-delivery.ts',
|
|
71
|
+
cron: 'rate(15 minutes)', enabled: true,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
jobs: [
|
|
75
|
+
{
|
|
76
|
+
id: 'deliver-invitation-email', kind: 'job', handlerFile: 'jobs/deliver-invitation-email.ts',
|
|
77
|
+
maxRetries: 5, visibilityTimeoutSeconds: 300,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
actions: [],
|
|
81
|
+
integrations: [],
|
|
82
|
+
events: [],
|
|
83
|
+
};
|
|
84
|
+
const app = new cdk.App();
|
|
85
|
+
const stack = new DomainStack(app, 'InvitationDelivery5389Stack', {
|
|
86
|
+
registry: authRegistry,
|
|
87
|
+
eventBusArn: 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus',
|
|
88
|
+
projectId: 'Test',
|
|
89
|
+
envCode: 'Dev',
|
|
90
|
+
alarmSnsTopicArn: 'arn:aws:sns:eu-north-1:123456789012:alerts',
|
|
91
|
+
crossDomainActionArns: { email: EMAIL_ACTION_ARN },
|
|
92
|
+
});
|
|
93
|
+
const template = Template.fromStack(stack);
|
|
94
|
+
/**
|
|
95
|
+
* Identify the delivery JOB Lambda via its SQS event-source mapping (only
|
|
96
|
+
* the job consumes the JobQueue; env-var maps are injected into every
|
|
97
|
+
* domain Lambda, so they cannot identify it), then resolve its execution
|
|
98
|
+
* role logical id through the Lambda's Fn::GetAtt.
|
|
99
|
+
*/
|
|
100
|
+
function jobLambdaRoleLogicalId() {
|
|
101
|
+
const mappings = Object.values(template.findResources('AWS::Lambda::EventSourceMapping'));
|
|
102
|
+
const jobMapping = mappings.find(m => JSON.stringify(m.Properties.EventSourceArn ?? '').includes('JobQueue'));
|
|
103
|
+
expect(jobMapping, 'job SQS event-source mapping must exist').toBeDefined();
|
|
104
|
+
const jobLambdaLogicalId = jobMapping.Properties.FunctionName.Ref;
|
|
105
|
+
const lambdas = template.findResources('AWS::Lambda::Function');
|
|
106
|
+
const jobLambda = lambdas[jobLambdaLogicalId];
|
|
107
|
+
expect(jobLambda, `job Lambda resource ${jobLambdaLogicalId} must exist`).toBeDefined();
|
|
108
|
+
const roleRef = jobLambda.Properties.Role;
|
|
109
|
+
expect(roleRef['Fn::GetAtt'], 'job Lambda role must be a GetAtt reference').toBeDefined();
|
|
110
|
+
return roleRef['Fn::GetAtt'][0];
|
|
111
|
+
}
|
|
112
|
+
describe('DomainStack invitation-delivery wiring (#5389)', () => {
|
|
113
|
+
it('injects TIB_OUTBOX_TABLE=auth.outbox into the domain Lambdas', () => {
|
|
114
|
+
const envs = Object.values(template.findResources('AWS::Lambda::Function'))
|
|
115
|
+
.map(res => (res.Properties.Environment?.Variables ?? {}))
|
|
116
|
+
.filter(env => env.TIB_OUTBOX_TABLE !== undefined);
|
|
117
|
+
expect(envs.length).toBeGreaterThan(0);
|
|
118
|
+
for (const env of envs) {
|
|
119
|
+
expect(env.TIB_OUTBOX_TABLE).toBe('auth.outbox');
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
it('gives the delivery job queue+DLQ with maxReceiveCount = maxRetries + 1 (6)', () => {
|
|
123
|
+
const queues = Object.values(template.findResources('AWS::SQS::Queue'));
|
|
124
|
+
const redrive = queues
|
|
125
|
+
.map(q => q.Properties.RedrivePolicy?.maxReceiveCount)
|
|
126
|
+
.filter(v => v !== undefined)
|
|
127
|
+
.map(v => String(v));
|
|
128
|
+
expect(redrive).toContain('6');
|
|
129
|
+
// the job queue keeps its configured visibility timeout
|
|
130
|
+
expect(queues.some(q => q.Properties.VisibilityTimeout === 300)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
it('creates DLQ depth + oldest-age alarms for the delivery job wired to SNS', () => {
|
|
133
|
+
const alarms = Object.values(template.findResources('AWS::CloudWatch::Alarm'));
|
|
134
|
+
const depth = alarms.find(a => a.Properties.AlarmName?.includes('-job-deliver-invitation-email-dlq-depth'));
|
|
135
|
+
const age = alarms.find(a => a.Properties.AlarmName?.includes('-job-deliver-invitation-email-dlq-oldest-age'));
|
|
136
|
+
expect(depth).toBeDefined();
|
|
137
|
+
expect(age).toBeDefined();
|
|
138
|
+
expect(JSON.stringify(depth.Properties.AlarmActions)).toContain('alerts');
|
|
139
|
+
});
|
|
140
|
+
it('grants the JOB Lambda role cross-domain lambda:InvokeFunction on the resolved action ARNs (#5389 gap)', () => {
|
|
141
|
+
const jobRoleLogicalId = jobLambdaRoleLogicalId();
|
|
142
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
143
|
+
const jobPolicy = policies.find(p => {
|
|
144
|
+
const roles = p.Properties.Roles;
|
|
145
|
+
return roles.some(r => (typeof r === 'string' ? r : r.Ref) === jobRoleLogicalId);
|
|
146
|
+
});
|
|
147
|
+
expect(jobPolicy, 'IAM policy attached to the job Lambda role must exist').toBeDefined();
|
|
148
|
+
const statements = jobPolicy.Properties.PolicyDocument.Statement;
|
|
149
|
+
const invokeStatements = statements.filter(s => (Array.isArray(s.Action) ? s.Action : [s.Action]).includes('lambda:InvokeFunction'));
|
|
150
|
+
expect(invokeStatements.length).toBeGreaterThan(0);
|
|
151
|
+
const resources = JSON.stringify(invokeStatements.map(s => s.Resource));
|
|
152
|
+
expect(resources).toContain('MTC-Dev-domain-email-actionapi');
|
|
153
|
+
// scoped to exact ARNs, never a wildcard
|
|
154
|
+
expect(resources).not.toContain('"*"');
|
|
155
|
+
});
|
|
156
|
+
it('synthesizes EventBridge Scheduler rules for the dispatcher and reconciliation schedules', () => {
|
|
157
|
+
const schedules = Object.values(template.findResources('AWS::Scheduler::Schedule'));
|
|
158
|
+
const expressions = schedules.map(s => s.Properties.ScheduleExpression);
|
|
159
|
+
expect(expressions).toContain('rate(1 minute)');
|
|
160
|
+
expect(expressions).toContain('rate(15 minutes)');
|
|
161
|
+
});
|
|
162
|
+
it('keeps the subscriber cross-domain grant (delivery mirror path) intact', () => {
|
|
163
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
164
|
+
const invokeForSubscriber = policies.some(p => {
|
|
165
|
+
const statements = p.Properties.PolicyDocument.Statement;
|
|
166
|
+
return statements.some(s => (Array.isArray(s.Action) ? s.Action : [s.Action]).includes('lambda:InvokeFunction'));
|
|
167
|
+
});
|
|
168
|
+
expect(invokeForSubscriber).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -36,4 +36,4 @@ export type { DomainActionFlowNode, StepFunctionsTaskState } from './step-functi
|
|
|
36
36
|
export type { FlowRegistry, FlowRegistryEntry, SerialFlowStep, SerialDomainActionStep, SerialDomainApiStep, SerialDomainEventStep, SerialDomainQueryStep, SerialAwsServiceStep, SerialFlowControlStep } from './flow-registry.js';
|
|
37
37
|
export { FlowsStack, packFlows } from './pack-flows.js';
|
|
38
38
|
export type { PackFlowsOptions } from './pack-flows.js';
|
|
39
|
-
export { domainResourceName } from './naming.js';
|
|
39
|
+
export { domainResourceName, domainActionLambdaName, domainActionLambdaArn } from './naming.js';
|
package/dist/index.js
CHANGED
|
@@ -16,4 +16,4 @@ export { packDomain } from './pack-domain.js';
|
|
|
16
16
|
export { validateRegistry } from './validate-registry.js';
|
|
17
17
|
export { StepFunctionsCodegen } from './step-functions-codegen.js';
|
|
18
18
|
export { FlowsStack, packFlows } from './pack-flows.js';
|
|
19
|
-
export { domainResourceName } from './naming.js';
|
|
19
|
+
export { domainResourceName, domainActionLambdaName, domainActionLambdaArn } from './naming.js';
|
package/dist/naming.d.ts
CHANGED
|
@@ -9,3 +9,21 @@
|
|
|
9
9
|
* (pack-flows domain-query grants) so the two can never drift.
|
|
10
10
|
*/
|
|
11
11
|
export declare function domainResourceName(projectId: string, envCode: string, domainId: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Deterministic physical name of a domain's internal-action grouped Lambda —
|
|
14
|
+
* the invoke target for cross-domain `ctx.actions.<domain>.<action>()`
|
|
15
|
+
* dispatch (#5226). DomainStack assigns this exact name via `functionName`,
|
|
16
|
+
* and callers building cross-domain ARNs MUST derive them from here rather
|
|
17
|
+
* than hand-assembling the pattern: the generated app constructed
|
|
18
|
+
* `…-domain-<id>-action`, which never matched the deployed `…-<id>-action`,
|
|
19
|
+
* so every subscriber cross-domain invoke targeted a phantom function
|
|
20
|
+
* (bootstrap invitation email dispatch, #5381).
|
|
21
|
+
*/
|
|
22
|
+
export declare function domainActionLambdaName(projectId: string, envCode: string, domainId: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Cross-domain ARN for a domain's internal-action grouped Lambda, using
|
|
25
|
+
* account/region pseudo-parameters so it can be synthesized before the
|
|
26
|
+
* target stack exists. This is the canonical way to populate
|
|
27
|
+
* `PackDomainOptions.crossDomainActionArns`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function domainActionLambdaArn(projectId: string, envCode: string, domainId: string): string;
|
package/dist/naming.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as cdk from 'aws-cdk-lib';
|
|
1
2
|
/**
|
|
2
3
|
* Canonical physical-name builder for per-domain AWS resources.
|
|
3
4
|
*
|
|
@@ -11,3 +12,25 @@
|
|
|
11
12
|
export function domainResourceName(projectId, envCode, domainId) {
|
|
12
13
|
return `${projectId}-${envCode}-${domainId}`.toLowerCase();
|
|
13
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Deterministic physical name of a domain's internal-action grouped Lambda —
|
|
17
|
+
* the invoke target for cross-domain `ctx.actions.<domain>.<action>()`
|
|
18
|
+
* dispatch (#5226). DomainStack assigns this exact name via `functionName`,
|
|
19
|
+
* and callers building cross-domain ARNs MUST derive them from here rather
|
|
20
|
+
* than hand-assembling the pattern: the generated app constructed
|
|
21
|
+
* `…-domain-<id>-action`, which never matched the deployed `…-<id>-action`,
|
|
22
|
+
* so every subscriber cross-domain invoke targeted a phantom function
|
|
23
|
+
* (bootstrap invitation email dispatch, #5381).
|
|
24
|
+
*/
|
|
25
|
+
export function domainActionLambdaName(projectId, envCode, domainId) {
|
|
26
|
+
return `${domainResourceName(projectId, envCode, domainId)}-action`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Cross-domain ARN for a domain's internal-action grouped Lambda, using
|
|
30
|
+
* account/region pseudo-parameters so it can be synthesized before the
|
|
31
|
+
* target stack exists. This is the canonical way to populate
|
|
32
|
+
* `PackDomainOptions.crossDomainActionArns`.
|
|
33
|
+
*/
|
|
34
|
+
export function domainActionLambdaArn(projectId, envCode, domainId) {
|
|
35
|
+
return `arn:aws:lambda:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:function:${domainActionLambdaName(projectId, envCode, domainId)}`;
|
|
36
|
+
}
|