@empire-builder-kit/nx 1.0.0-rc.40 → 1.0.0-rc.42

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.
Files changed (58) hide show
  1. package/README.md +26 -10
  2. package/package.json +3 -3
  3. package/src/blueprints/external-sandbox.d.ts +1 -1
  4. package/src/cli/deploy-runtime.d.ts +25 -1
  5. package/src/cli/deploy-runtime.js +120 -4
  6. package/src/cli/dev-command.d.ts +2 -0
  7. package/src/cli/dev-command.js +8 -0
  8. package/src/cli/main.js +20 -6
  9. package/src/cli/personal-secret-runtime.d.ts +12 -0
  10. package/src/cli/personal-secret-runtime.js +18 -3
  11. package/src/cli/secret-terminal.js +1 -1
  12. package/src/container-images.d.ts +1 -1
  13. package/src/container-images.js +1 -1
  14. package/src/developer-agent/operation-registry.d.ts +10 -0
  15. package/src/developer-agent/operation-registry.js +60 -13
  16. package/src/developer-agent/planning.d.ts +8 -1
  17. package/src/developer-agent/planning.js +23 -3
  18. package/src/executors/local-deploy/local-approval.js +1 -1
  19. package/src/executors/sst-lifecycle/governed-native-operation.js +3 -2
  20. package/src/executors/sst-lifecycle/run-sst-command.d.ts +6 -0
  21. package/src/executors/sst-lifecycle/run-sst-command.js +3 -33
  22. package/src/foundation/cost-estimate.d.ts +170 -0
  23. package/src/foundation/cost-estimate.js +683 -0
  24. package/src/generators/preset/tool-versions.d.ts +0 -2
  25. package/src/generators/preset/tool-versions.js +1 -3
  26. package/src/generators/preset/workspace-files-adopter-skills.js +39 -18
  27. package/src/generators/preset/workspace-files-developer-agent.js +83 -27
  28. package/src/generators/preset/workspace-files-shared-operation-claims.d.ts +10 -0
  29. package/src/generators/preset/workspace-files-shared-operation-claims.js +59 -0
  30. package/src/generators/preset/workspace-files-stage-authority.js +13 -2
  31. package/src/generators/preset/workspace-files-thin-deploy.d.ts +10 -0
  32. package/src/generators/preset/workspace-files-thin-deploy.js +54 -20
  33. package/src/generators/preset/workspace-files-thin-docs.js +32 -14
  34. package/src/generators/preset/workspace-files.js +28 -8
  35. package/src/generators/slice/built-in-plan-api-contract-files.js +7 -4
  36. package/src/generators/slice/built-in-plan-authorization-files.js +10 -5
  37. package/src/generators/slice/built-in-plan-authorization-infrastructure-files.js +933 -249
  38. package/src/generators/slice/built-in-plan-data-files.js +17 -8
  39. package/src/generators/slice/built-in-plan-database-retirement-rehearsal-files.js +5 -1
  40. package/src/generators/slice/built-in-plan-frontend-files.js +7 -0
  41. package/src/generators/slice/built-in-plan-infrastructure-files.js +0 -1
  42. package/src/generators/slice/built-in-plan-infrastructure-safety-files.js +8 -3
  43. package/src/generators/slice/built-in-plan-metadata-files.js +2 -1
  44. package/src/generators/slice/built-in-plan-reliability-files.js +4 -1
  45. package/src/generators/slice/built-in-plan-shared.d.ts +2 -2
  46. package/src/generators/slice/built-in-plan-shared.js +2 -2
  47. package/src/generators/slice/built-in-plan-testing-persistence-event-files.d.ts +7 -0
  48. package/src/generators/slice/built-in-plan-testing-persistence-event-files.js +487 -0
  49. package/src/generators/slice/built-in-plan-testing-persistence-files.js +2 -0
  50. package/src/generators/slice/built-in-plan.js +3 -1
  51. package/src/generators/workload/frozen-container.d.ts +1 -1
  52. package/src/git-policy/github-bootstrap.d.ts +9 -0
  53. package/src/git-policy/github-bootstrap.js +16 -2
  54. package/src/local-dev/fleet-inventory.js +18 -10
  55. package/src/local-dev/fleet-launcher.js +2 -2
  56. package/src/secrets/personal-command.d.ts +18 -2
  57. package/src/secrets/personal-command.js +66 -12
  58. package/src/secrets/verification.js +6 -1
@@ -0,0 +1,487 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderTestingPersistenceEventFiles = renderTestingPersistenceEventFiles;
4
+ /**
5
+ * Seeded behavioral integration specs that drive the seeded event consumer
6
+ * against the disposable worker stores. They never write DynamoDB outbox rows
7
+ * (integration/record.spec.ts owns the exactly-one-outbox assertion on the
8
+ * shared worker table), assert by seed-scoped key, and never wait in real time.
9
+ */
10
+ function renderTestingPersistenceEventFiles(name, pascalName, sql) {
11
+ const inboxHelpers = `const inboxCount = async (source: string, eventId: string) => {
12
+ // Key-scoped: the inbox sort key is unique to this seed's event, while its
13
+ // partition carries the consumer identity. Never count the shared table.
14
+ const sk = lifecycleIdempotencyKey('event-consumer', [source, eventId]);
15
+ let count = 0;
16
+ let ExclusiveStartKey: Record<string, AttributeValue> | undefined;
17
+ do {
18
+ const page = await localKeyValueClient.send(new ScanCommand({ TableName: localKeyValueTable, ConsistentRead: true,
19
+ FilterExpression: '#sk = :sk AND #kind = :kind', ExpressionAttributeNames: { '#sk': 'sk', '#kind': 'kind' },
20
+ ExpressionAttributeValues: { ':sk': { S: sk }, ':kind': { S: 'inbox' } }, ExclusiveStartKey }));
21
+ count += page.Items?.length ?? 0;
22
+ ExclusiveStartKey = page.LastEvaluatedKey;
23
+ } while (ExclusiveStartKey);
24
+ return count;
25
+ };`;
26
+ const seedHelpers = (spec) => `const seed = process.env.EBK_TEST_SEED;
27
+ if (!seed) throw new Error('EBK_TEST_SEED is required for reproducible integration fixtures.');
28
+ const stage = process.env.EBK_STAGE ?? '';
29
+ const busName = process.env.EVENT_BUS_NAME ?? '';
30
+ // The seeded consumer authorizes its own workload identity on every invocation.
31
+ process.env.EBK_WORKLOAD_IDENTITY = '${name}:event-consumer';
32
+ const scoped = (label: string) =>
33
+ 'b4-' + createHash('sha256').update(seed + '/${spec}/' + label).digest('hex').slice(0, 32);`;
34
+ const files = {
35
+ 'integration/event-consumer-duplicates.spec.ts': `import { createHash } from 'node:crypto';
36
+ import { ScanCommand, type AttributeValue } from '@aws-sdk/client-dynamodb';
37
+ import { createDataLifecycleChangeEvent, lifecycleIdempotencyKey } from '@empire-builder-kit/runtime/data-lifecycle';
38
+ import { buildEventEntry } from '@empire-builder-kit/runtime/events';
39
+ import { afterAll, describe, expect, it, vi } from 'vitest';
40
+ import { handler } from '../api/event-consumer.js';
41
+ import { ${pascalName}Created, ${pascalName}DataLifecycleChanged } from '../contracts/events.js';
42
+ import { localKeyValueClient, localKeyValueStore, localKeyValueTable } from '../testing/local-key-value.js';
43
+
44
+ const observed = vi.hoisted(() => ({ consumed: [] as string[], refusals: [] as { code: string; eventId: string }[] }));
45
+ vi.mock('../data/key-value.js', async () => {
46
+ const { localKeyValueStore } = await import('../testing/local-key-value.js');
47
+ const store = localKeyValueStore();
48
+ // Observe the inbox boundary without changing what the seeded consumer does.
49
+ const consume: typeof store.consume = async (input) => {
50
+ observed.consumed.push(input.eventId);
51
+ try {
52
+ return await store.consume(input);
53
+ } catch (error) {
54
+ observed.refusals.push({ code: String((error as { code?: unknown }).code), eventId: input.eventId });
55
+ throw error;
56
+ }
57
+ };
58
+ return { dataStore: () => ({ ...store, consume }) };
59
+ });
60
+ ${seedHelpers('event-consumer-duplicates')}
61
+ let sequence = 0;
62
+ const messageId = (label: string) => scoped(label) + '-m' + String(++sequence);
63
+ afterAll(() => localKeyValueClient.destroy());
64
+
65
+ function created(eventId: string, occurredAt: string, options: { occurredBy?: string; stage?: string; tenantId?: string } = {}) {
66
+ const built = buildEventEntry(${pascalName}Created, { id: eventId, occurredBy: options.occurredBy ?? 'integration-owner' }, {
67
+ busName, stage: options.stage ?? stage, eventId, occurredAt,
68
+ context: { correlationId: eventId, tenantId: options.tenantId ?? 'application' },
69
+ });
70
+ return JSON.stringify({ source: built.entry.source, 'detail-type': built.entry.detailType, detail: JSON.parse(built.entry.detail) });
71
+ }
72
+ function lifecycle(eventId: string, pseudonymousKey: string, occurredAt: string) {
73
+ const data = createDataLifecycleChangeEvent({ action: 'correction', categoryId: 'product-record', occurredAt,
74
+ policyVersion: '1.0.0', pseudonymousKey, requestRef: lifecycleIdempotencyKey('request', [eventId]), sourceSlice: '${name}' });
75
+ const built = buildEventEntry(${pascalName}DataLifecycleChanged, data, {
76
+ busName, stage, eventId, occurredAt, context: { correlationId: eventId, tenantId: 'application' },
77
+ });
78
+ return JSON.stringify({ source: built.entry.source, 'detail-type': built.entry.detailType, detail: JSON.parse(built.entry.detail) });
79
+ }
80
+ ${inboxHelpers}
81
+ const inboxKey = (eventId: string) => lifecycleIdempotencyKey('event-consumer', ['${name}', eventId]);
82
+
83
+ describe('${name} event consumer duplicate consumption', () => {
84
+ it('consumes a created event delivered repeatedly and twice in one batch exactly once', async () => {
85
+ const eventId = scoped('created');
86
+ const body = created(eventId, new Date().toISOString());
87
+ await expect(handler({ Records: [{ messageId: messageId('created'), body }] })).resolves.toEqual({ batchItemFailures: [] });
88
+ await expect(handler({ Records: [{ messageId: messageId('created'), body }] })).resolves.toEqual({ batchItemFailures: [] });
89
+ await expect(handler({ Records: [{ messageId: messageId('created'), body }, { messageId: messageId('created'), body }] }))
90
+ .resolves.toEqual({ batchItemFailures: [] });
91
+ expect(observed.consumed.filter((id) => id === inboxKey(eventId))).toHaveLength(4);
92
+ await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
93
+ });
94
+
95
+ it('records one receipt and keeps the downstream copy unchanged when a lifecycle event is redelivered', async () => {
96
+ const eventId = scoped('lifecycle');
97
+ const pseudonymousKey = lifecycleIdempotencyKey('record', [scoped('lifecycle-record')]);
98
+ const body = lifecycle(eventId, pseudonymousKey, new Date().toISOString());
99
+ const store = localKeyValueStore();
100
+ const copyId = lifecycleIdempotencyKey('lifecycle-copy', ['${name}', pseudonymousKey, '${name}-lifecycle-consumer']);
101
+ await expect(handler({ Records: [{ messageId: messageId('lifecycle'), body }] })).resolves.toEqual({ batchItemFailures: [] });
102
+ const copy = await store.get('application', 'data-lifecycle-downstream-copies', copyId);
103
+ expect(copy).toMatchObject({ version: 1, value: { status: 'reconciled', action: 'correction' } });
104
+ await expect(handler({ Records: [{ messageId: messageId('lifecycle'), body }] })).resolves.toEqual({ batchItemFailures: [] });
105
+ await expect(store.get('application', 'data-lifecycle-receipts', inboxKey(eventId))).resolves.toMatchObject({ version: 1 });
106
+ await expect(store.get('application', 'data-lifecycle-downstream-copies', copyId)).resolves.toEqual(copy);
107
+ await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
108
+ });
109
+
110
+ it('fails only the message that reuses an event id with changed data', async () => {
111
+ const eventId = scoped('conflict');
112
+ const independent = scoped('conflict-independent');
113
+ const occurredAt = new Date().toISOString();
114
+ await expect(handler({ Records: [{ messageId: messageId('conflict'), body: created(eventId, occurredAt) }] }))
115
+ .resolves.toEqual({ batchItemFailures: [] });
116
+ const changed = messageId('conflict-changed');
117
+ await expect(handler({ Records: [
118
+ { messageId: changed, body: created(eventId, occurredAt, { occurredBy: 'integration-other-owner' }) },
119
+ { messageId: messageId('conflict-independent'), body: created(independent, occurredAt) },
120
+ ] })).resolves.toEqual({ batchItemFailures: [{ itemIdentifier: changed }] });
121
+ expect(observed.refusals).toContainEqual({ code: 'idempotency-conflict', eventId: inboxKey(eventId) });
122
+ await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
123
+ await expect(inboxCount('${name}', independent)).resolves.toBe(1);
124
+ });
125
+
126
+ it('refuses foreign tenant and foreign stage events before any inbox write', async () => {
127
+ const occurredAt = new Date().toISOString();
128
+ const tenant = scoped('foreign-tenant');
129
+ const foreignStage = scoped('foreign-stage');
130
+ const application = scoped('application-tenant');
131
+ const tenantMessage = messageId('foreign-tenant');
132
+ const stageMessage = messageId('foreign-stage');
133
+ await expect(handler({ Records: [
134
+ { messageId: tenantMessage, body: created(tenant, occurredAt, { tenantId: 'foreign-tenant' }) },
135
+ { messageId: stageMessage, body: created(foreignStage, occurredAt, { stage: stage === 'production' ? 'staging' : 'production' }) },
136
+ { messageId: messageId('application-tenant'), body: created(application, occurredAt) },
137
+ ] })).resolves.toEqual({ batchItemFailures: [{ itemIdentifier: tenantMessage }, { itemIdentifier: stageMessage }] });
138
+ expect(observed.consumed).not.toContain(inboxKey(tenant));
139
+ expect(observed.consumed).not.toContain(inboxKey(foreignStage));
140
+ await expect(inboxCount('${name}', tenant)).resolves.toBe(0);
141
+ await expect(inboxCount('${name}', foreignStage)).resolves.toBe(0);
142
+ await expect(inboxCount('${name}', application)).resolves.toBe(1);
143
+ });
144
+ });
145
+ `,
146
+ 'integration/event-consumer-redrive.spec.ts': `import { createHash } from 'node:crypto';
147
+ import { ScanCommand, type AttributeValue, type DynamoDBClient } from '@aws-sdk/client-dynamodb';
148
+ import { createDataLifecycleChangeEvent, lifecycleIdempotencyKey } from '@empire-builder-kit/runtime/data-lifecycle';
149
+ import { buildEventEntry } from '@empire-builder-kit/runtime/events';
150
+ import { afterAll, describe, expect, it, vi } from 'vitest';
151
+ import { handler } from '../api/event-consumer.js';
152
+ import workContract from '../async/work-contract.json' with { type: 'json' };
153
+ import { ${pascalName}Created, ${pascalName}DataLifecycleChanged } from '../contracts/events.js';
154
+ import { localKeyValueClient, localKeyValueStore, localKeyValueTable } from '../testing/local-key-value.js';
155
+
156
+ const control = vi.hoisted(() => ({ outage: false }));
157
+ vi.mock('../data/key-value.js', async () => {
158
+ const { createDynamoPersistence } = await import('@empire-builder-kit/runtime/key-value');
159
+ const { localKeyValueClient, localKeyValueTable } = await import('../testing/local-key-value.js');
160
+ const passthrough = localKeyValueClient.send.bind(localKeyValueClient) as (command: unknown) => Promise<unknown>;
161
+ // A transient dependency outage: every store call fails while it lasts.
162
+ const client = { send: (command: unknown) =>
163
+ control.outage ? Promise.reject(new Error('Integration DynamoDB outage.')) : passthrough(command) };
164
+ const store = createDynamoPersistence({ client: client as unknown as DynamoDBClient, tableName: localKeyValueTable,
165
+ stage: process.env.EBK_STAGE ?? '', slice: '${name}', eventBusName: 'local-integration-events' });
166
+ return { dataStore: () => store };
167
+ });
168
+ ${seedHelpers('event-consumer-redrive')}
169
+ afterAll(() => localKeyValueClient.destroy());
170
+ const maxReceiveCount = workContract.queue.maxReceiveCount;
171
+ if (!Number.isSafeInteger(maxReceiveCount) || maxReceiveCount < 1)
172
+ throw new Error('The async work contract must declare a positive maxReceiveCount.');
173
+
174
+ interface QueuedMessage { body: string; messageId: string; receiveCount: number }
175
+ /**
176
+ * In-test SQS source queue and DLQ with the declared redrive policy. Visibility
177
+ * timeouts elapse instantly; real SQS movement is not proven locally.
178
+ */
179
+ function createQueue(label: string) {
180
+ const source: QueuedMessage[] = [];
181
+ const deadLetter: QueuedMessage[] = [];
182
+ const reported: string[] = [];
183
+ let sequence = 0;
184
+ const send = (body: string) => {
185
+ const messageId = scoped(label) + '-m' + String(++sequence);
186
+ source.push({ body, messageId, receiveCount: 0 });
187
+ return messageId;
188
+ };
189
+ async function drain() {
190
+ while (source.length > 0) {
191
+ const batch = source.splice(0, 10);
192
+ for (const message of batch) message.receiveCount += 1;
193
+ const { batchItemFailures } = await handler({ Records: batch.map(({ body, messageId }) => ({ body, messageId })) });
194
+ const failed = new Set(batchItemFailures.map(({ itemIdentifier }) => itemIdentifier));
195
+ reported.push(...failed);
196
+ for (const message of batch) {
197
+ if (!failed.has(message.messageId)) continue;
198
+ (message.receiveCount >= maxReceiveCount ? deadLetter : source).push(message);
199
+ }
200
+ }
201
+ }
202
+ // An operator redrive returns dead letters as new source messages.
203
+ const redrive = () => { for (const message of deadLetter.splice(0)) send(message.body); };
204
+ return { deadLetter, drain, redrive, reported, send };
205
+ }
206
+ function body(detailType: string, built: { entry: { source: string; detail: string } }) {
207
+ return JSON.stringify({ source: built.entry.source, 'detail-type': detailType, detail: JSON.parse(built.entry.detail) });
208
+ }
209
+ ${inboxHelpers}
210
+
211
+ describe('${name} event consumer poison redrive', () => {
212
+ it('dead-letters a transiently failing event at the declared receive limit and redrives it exactly once', async () => {
213
+ const queue = createQueue('transient');
214
+ const eventId = scoped('transient');
215
+ const occurredAt = new Date().toISOString();
216
+ const pseudonymousKey = lifecycleIdempotencyKey('record', [scoped('transient-record')]);
217
+ const message = body(${pascalName}DataLifecycleChanged.detailType, buildEventEntry(${pascalName}DataLifecycleChanged,
218
+ createDataLifecycleChangeEvent({ action: 'correction', categoryId: 'product-record', occurredAt, policyVersion: '1.0.0',
219
+ pseudonymousKey, requestRef: lifecycleIdempotencyKey('request', [eventId]), sourceSlice: '${name}' }),
220
+ { busName, stage, eventId, occurredAt, context: { correlationId: eventId, tenantId: 'application' } }));
221
+ const store = localKeyValueStore();
222
+ const receiptId = lifecycleIdempotencyKey('event-consumer', ['${name}', eventId]);
223
+ const copyId = lifecycleIdempotencyKey('lifecycle-copy', ['${name}', pseudonymousKey, '${name}-lifecycle-consumer']);
224
+ control.outage = true;
225
+ try {
226
+ queue.send(message);
227
+ await queue.drain();
228
+ } finally {
229
+ control.outage = false;
230
+ }
231
+ expect(queue.deadLetter.map(({ receiveCount }) => receiveCount)).toEqual([maxReceiveCount]);
232
+ await expect(store.get('application', 'data-lifecycle-receipts', receiptId)).resolves.toBeNull();
233
+ await expect(store.get('application', 'data-lifecycle-downstream-copies', copyId)).resolves.toBeNull();
234
+ await expect(inboxCount('${name}', eventId)).resolves.toBe(0);
235
+ queue.redrive();
236
+ await queue.drain();
237
+ expect(queue.deadLetter).toEqual([]);
238
+ await expect(store.get('application', 'data-lifecycle-receipts', receiptId)).resolves.toMatchObject({ version: 1 });
239
+ const copy = await store.get('application', 'data-lifecycle-downstream-copies', copyId);
240
+ expect(copy).toMatchObject({ version: 1, value: { status: 'reconciled' } });
241
+ // A repeated operator redrive or archive replay is a duplicate, not a second effect.
242
+ queue.send(message);
243
+ await queue.drain();
244
+ expect(queue.deadLetter).toEqual([]);
245
+ await expect(store.get('application', 'data-lifecycle-receipts', receiptId)).resolves.toMatchObject({ version: 1 });
246
+ await expect(store.get('application', 'data-lifecycle-downstream-copies', copyId)).resolves.toEqual(copy);
247
+ await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
248
+ });
249
+
250
+ it('keeps a conflicting event poisoned across redrive while independent messages proceed', async () => {
251
+ const queue = createQueue('permanent');
252
+ const eventId = scoped('permanent');
253
+ const independent = scoped('permanent-independent');
254
+ const occurredAt = new Date().toISOString();
255
+ const created = (id: string, occurredBy: string) => body(${pascalName}Created.detailType, buildEventEntry(${pascalName}Created,
256
+ { id, occurredBy }, { busName, stage, eventId: id, occurredAt, context: { correlationId: id, tenantId: 'application' } }));
257
+ queue.send(created(eventId, 'integration-owner'));
258
+ await queue.drain();
259
+ expect(queue.reported).toEqual([]);
260
+ const poison = queue.send(created(eventId, 'integration-other-owner'));
261
+ queue.send(created(independent, 'integration-owner'));
262
+ await queue.drain();
263
+ expect(queue.reported).toEqual(Array.from({ length: maxReceiveCount }, () => poison));
264
+ expect(queue.deadLetter).toHaveLength(1);
265
+ await expect(inboxCount('${name}', independent)).resolves.toBe(1);
266
+ queue.redrive();
267
+ await queue.drain();
268
+ expect(queue.deadLetter.map(({ receiveCount }) => receiveCount)).toEqual([maxReceiveCount]);
269
+ expect(queue.reported).toHaveLength(2 * maxReceiveCount);
270
+ await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
271
+ });
272
+ });
273
+ `,
274
+ };
275
+ if (!sql)
276
+ return files;
277
+ const sqlHelpers = `vi.mock('../data/key-value.js', async () => {
278
+ const { localKeyValueStore } = await import('../testing/local-key-value.js');
279
+ const store = localKeyValueStore();
280
+ // Observe the inbox boundary without changing what the seeded consumer does.
281
+ const consume: typeof store.consume = async (input) => {
282
+ observed.consumed.push(input.eventId);
283
+ return store.consume(input);
284
+ };
285
+ return { dataStore: () => ({ ...store, consume }) };
286
+ });
287
+ // The deployed credential adapter and EventBridge transport never run locally.
288
+ vi.mock('../database/runtime-client', () => ({
289
+ withPrimaryDatabase: () => { throw new Error('Integration must not resolve deployed database credentials.'); },
290
+ }));
291
+ vi.mock('@aws-sdk/client-eventbridge', () => ({
292
+ EventBridgeClient: class { send() { throw new Error('Integration must not publish to a real event bus.'); } },
293
+ PutEventsCommand: class {},
294
+ }));
295
+ `;
296
+ const sqlOutbox = `const published: PutEventsRequestEntry[] = [];
297
+ const outbox = createSqlEventOutbox({ scope: () => ({ stage, busName, enabled: true }), withDatabase: withLocalDatabase,
298
+ publish: async (entry) => { published.push(entry); } });
299
+ const eventIdOf = (entry: PutEventsRequestEntry) =>
300
+ (JSON.parse(entry.Detail ?? '{}') as { meta?: { eventId?: string } }).meta?.eventId;
301
+ const publishedFor = (eventId: string) =>
302
+ published.filter((entry) => entry.DetailType === ${pascalName}Created.detailType && eventIdOf(entry) === eventId);
303
+ const record = (entry: PutEventsRequestEntry, messageId: string) => ({ messageId,
304
+ body: JSON.stringify({ source: entry.Source, 'detail-type': entry.DetailType, detail: JSON.parse(entry.Detail ?? 'null') }) });
305
+ const intents = (eventId: string) => withLocalDatabase({ correlationId: eventId }, 'api-database-records', async (client) =>
306
+ (await client.query<{ attempts: number; last_error_code: string | null; published: boolean }>(
307
+ "SELECT attempts, last_error_code, published_at IS NOT NULL AS published FROM data_lifecycle_outbox WHERE (event_json->>'Detail')::jsonb->'meta'->>'eventId' = $1",
308
+ [eventId])).rows);
309
+ const owned = new Set<string>();
310
+ afterAll(async () => {
311
+ // Test-owned intents leave the shared worker database so later relays see none of them.
312
+ await withLocalDatabase({ correlationId: 'integration-cleanup' }, 'api-database-records', async (client) => {
313
+ await client.query("DELETE FROM data_lifecycle_outbox WHERE (event_json->>'Detail')::jsonb->'meta'->>'eventId' = ANY($1)", [[...owned]]);
314
+ });
315
+ localKeyValueClient.destroy();
316
+ });`;
317
+ const sqlImports = `import { createHash } from 'node:crypto';
318
+ import type { PutEventsRequestEntry } from '@aws-sdk/client-eventbridge';
319
+ import { ScanCommand, type AttributeValue } from '@aws-sdk/client-dynamodb';
320
+ import { lifecycleIdempotencyKey } from '@empire-builder-kit/runtime/data-lifecycle';
321
+ import { afterAll, describe, expect, it, vi } from 'vitest';
322
+ import { handler } from '../api/event-consumer.js';
323
+ import { ${pascalName}Created } from '../contracts/events.js';
324
+ import { createSqlEventOutbox } from '../database/sql-events';`;
325
+ return {
326
+ ...files,
327
+ 'testing/local-sql.ts': `import pg, { type Client as PgClient } from 'pg';
328
+
329
+ const databaseUrl = new URL(process.env.DATABASE_URL ?? 'postgresql://invalid');
330
+ if (!['postgres:', 'postgresql:'].includes(databaseUrl.protocol) || databaseUrl.hostname !== '127.0.0.1' ||
331
+ !databaseUrl.port || databaseUrl.search || databaseUrl.hash ||
332
+ !/^\\/ebk_test_[0-9a-f]{24}$/.test(databaseUrl.pathname)) {
333
+ throw new Error('Integration requires the runner-owned loopback PostgreSQL worker database.');
334
+ }
335
+ const connectionString = databaseUrl.href;
336
+
337
+ /** Same call shape as withPrimaryDatabase, bound to the disposable worker database. */
338
+ export async function withLocalDatabase<Result>(
339
+ _context: { correlationId: string },
340
+ _workload: string,
341
+ operation: (client: PgClient) => Promise<Result>,
342
+ ): Promise<Result> {
343
+ const client = new pg.Client({ connectionString });
344
+ await client.connect();
345
+ try {
346
+ return await operation(client);
347
+ } finally {
348
+ await client.end();
349
+ }
350
+ }
351
+ `,
352
+ 'integration/dual-store.spec.ts': `${sqlImports}
353
+ import { localKeyValueClient, localKeyValueStore, localKeyValueTable } from '../testing/local-key-value.js';
354
+ import { withLocalDatabase } from '../testing/local-sql.js';
355
+
356
+ const observed = vi.hoisted(() => ({ consumed: [] as string[] }));
357
+ ${sqlHelpers}${seedHelpers('dual-store')}
358
+ ${sqlOutbox}
359
+ ${inboxHelpers}
360
+ const ledger = (id: string) => withLocalDatabase({ correlationId: id }, 'api-database-records', async (client) => {
361
+ await client.query('CREATE TABLE IF NOT EXISTS integration_dual_store_ledger (id text PRIMARY KEY, amount integer NOT NULL)');
362
+ return (await client.query<{ amount: number }>('SELECT amount FROM integration_dual_store_ledger WHERE id = $1', [id])).rows;
363
+ });
364
+ const recordCreated = (eventId: string, fail = false) =>
365
+ outbox.transaction({ correlationId: eventId }, 'api-database-records', async (client, emit) => {
366
+ owned.add(eventId);
367
+ await client.query('CREATE TABLE IF NOT EXISTS integration_dual_store_ledger (id text PRIMARY KEY, amount integer NOT NULL)');
368
+ await client.query('INSERT INTO integration_dual_store_ledger (id, amount) VALUES ($1, $2)', [eventId, 100]);
369
+ await emit(${pascalName}Created, { id: eventId, occurredBy: 'integration-owner' }, { eventId, occurredAt: new Date().toISOString() });
370
+ if (fail) throw new Error('Integration domain refusal.');
371
+ return eventId;
372
+ });
373
+
374
+ describe('${name} dual-store DynamoDB and SQL event delivery', () => {
375
+ it('commits a relational row with its intent, relays it and consumes it once into the DynamoDB inbox', async () => {
376
+ const eventId = scoped('committed');
377
+ await expect(recordCreated(eventId)).resolves.toEqual({ value: eventId, queued: 1, notification: 'notified' });
378
+ expect(publishedFor(eventId)).toEqual([]);
379
+ await expect(ledger(eventId)).resolves.toEqual([{ amount: 100 }]);
380
+ await expect(intents(eventId)).resolves.toEqual([{ attempts: 0, last_error_code: null, published: false }]);
381
+ await outbox.reconcile({ correlationId: eventId });
382
+ const [entry] = publishedFor(eventId);
383
+ expect(publishedFor(eventId)).toHaveLength(1);
384
+ await expect(intents(eventId)).resolves.toEqual([{ attempts: 1, last_error_code: null, published: true }]);
385
+ await expect(handler({ Records: [record(entry, scoped('delivery'))] })).resolves.toEqual({ batchItemFailures: [] });
386
+ // A publish/acknowledgement interruption redelivers the same event: a duplicate.
387
+ await expect(handler({ Records: [record(entry, scoped('redelivery'))] })).resolves.toEqual({ batchItemFailures: [] });
388
+ await outbox.reconcile({ correlationId: eventId });
389
+ expect(publishedFor(eventId)).toHaveLength(1);
390
+ expect(observed.consumed).toContain(lifecycleIdempotencyKey('event-consumer', ['${name}', eventId]));
391
+ await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
392
+ });
393
+
394
+ it('rolls back the relational row and its intent together without reaching DynamoDB', async () => {
395
+ const eventId = scoped('rolled-back');
396
+ await expect(recordCreated(eventId, true)).rejects.toThrow('Integration domain refusal.');
397
+ await expect(ledger(eventId)).resolves.toEqual([]);
398
+ await expect(intents(eventId)).resolves.toEqual([]);
399
+ await outbox.reconcile({ correlationId: eventId });
400
+ expect(publishedFor(eventId)).toEqual([]);
401
+ await expect(inboxCount('${name}', eventId)).resolves.toBe(0);
402
+ });
403
+
404
+ it('commits each store in its own transaction without a cross-store guarantee', async () => {
405
+ const id = scoped('independent');
406
+ const store = localKeyValueStore();
407
+ await store.commit({ tenantId: 'application', changes: [{ action: 'put', collection: 'integration-dual-store', id,
408
+ expectedVersion: 0, value: { amount: 100 } }] });
409
+ await expect(recordCreated(id, true)).rejects.toThrow('Integration domain refusal.');
410
+ // The SQL rollback cannot undo the committed DynamoDB write, and vice versa.
411
+ await expect(store.get('application', 'integration-dual-store', id)).resolves.toEqual({ value: { amount: 100 }, version: 1 });
412
+ await expect(ledger(id)).resolves.toEqual([]);
413
+ await expect(intents(id)).resolves.toEqual([]);
414
+ });
415
+ });
416
+ `,
417
+ 'integration/sql-tenant-denial.spec.ts': `${sqlImports}
418
+ import { localKeyValueClient, localKeyValueTable } from '../testing/local-key-value.js';
419
+ import { withLocalDatabase } from '../testing/local-sql.js';
420
+
421
+ const observed = vi.hoisted(() => ({ consumed: [] as string[] }));
422
+ ${sqlHelpers}${seedHelpers('sql-tenant-denial')}
423
+ ${sqlOutbox}
424
+ ${inboxHelpers}
425
+ const emitCreated = (eventId: string, context: { correlationId: string }) =>
426
+ outbox.transaction(context, 'api-database-records', async (_client, emit) => {
427
+ owned.add(eventId);
428
+ await emit(${pascalName}Created, { id: eventId, occurredBy: 'integration-owner' }, { eventId, occurredAt: new Date().toISOString() });
429
+ });
430
+ const rewrite = (eventId: string, sql: string, values: unknown[]) =>
431
+ withLocalDatabase({ correlationId: eventId }, 'api-database-records', async (client) => {
432
+ const result = await client.query(sql + " WHERE (event_json->>'Detail')::jsonb->'meta'->>'eventId' = $1", [eventId, ...values]);
433
+ if (result.rowCount !== 1) throw new Error('Expected exactly one stored intent to rewrite.');
434
+ });
435
+
436
+ describe('${name} SQL-originated tenant and scope denial', () => {
437
+ it('refuses stored intents rewritten to a foreign stage, bus or Slice at the relay', async () => {
438
+ const ids = { bus: scoped('foreign-bus'), slice: scoped('foreign-slice'), stage: scoped('foreign-stage') };
439
+ for (const eventId of Object.values(ids)) await emitCreated(eventId, { correlationId: eventId });
440
+ // Simulate stored intents that no longer match this relay's exact scope.
441
+ await rewrite(ids.stage, "UPDATE data_lifecycle_outbox SET event_json = jsonb_set(event_json, '{Detail}', to_jsonb(replace(event_json->>'Detail', $2, $3)))",
442
+ ['"stage":"' + stage + '"', '"stage":"' + (stage === 'production' ? 'staging' : 'production') + '"']);
443
+ await rewrite(ids.bus, "UPDATE data_lifecycle_outbox SET event_json = jsonb_set(event_json, '{EventBusName}', to_jsonb($2::text))", ['foreign-bus']);
444
+ await rewrite(ids.slice, "UPDATE data_lifecycle_outbox SET event_json = jsonb_set(jsonb_set(event_json, '{Source}', to_jsonb($2::text)), '{Detail}', to_jsonb(replace(event_json->>'Detail', $3, $4)))",
445
+ ['foreign-slice', '"slice":"${name}"', '"slice":"foreign-slice"']);
446
+ const result = await outbox.reconcile({ correlationId: ids.stage });
447
+ expect(result.failed).toBeGreaterThanOrEqual(3);
448
+ for (const eventId of Object.values(ids)) {
449
+ expect(publishedFor(eventId)).toEqual([]);
450
+ await expect(intents(eventId)).resolves.toEqual([{ attempts: 1, last_error_code: 'event-publication-failed', published: false }]);
451
+ await expect(inboxCount('${name}', eventId)).resolves.toBe(0);
452
+ }
453
+ });
454
+
455
+ it('refuses a foreign-Slice event at emission and rolls back its transaction', async () => {
456
+ const eventId = scoped('foreign-emission');
457
+ await expect(outbox.transaction({ correlationId: eventId }, 'api-database-records', async (_client, emit) => {
458
+ owned.add(eventId);
459
+ await emit({ ...${pascalName}Created, source: 'foreign-slice' }, { id: eventId, occurredBy: 'integration-owner' },
460
+ { eventId, occurredAt: new Date().toISOString() });
461
+ })).rejects.toThrow('SQL event differs from its Slice/bus scope.');
462
+ await expect(intents(eventId)).resolves.toEqual([]);
463
+ });
464
+
465
+ it('refuses a foreign-tenant SQL event at the seeded consumer before any inbox write', async () => {
466
+ const foreign = scoped('foreign-tenant');
467
+ const application = scoped('application-tenant');
468
+ // The outbox context is typed { correlationId }; a widened runtime context can still carry tenant metadata.
469
+ const foreignContext = { correlationId: foreign, tenantId: 'foreign-tenant' };
470
+ await emitCreated(foreign, foreignContext);
471
+ await emitCreated(application, { correlationId: application });
472
+ await outbox.reconcile({ correlationId: foreign });
473
+ const [foreignEntry] = publishedFor(foreign);
474
+ const [applicationEntry] = publishedFor(application);
475
+ expect(JSON.parse(foreignEntry.Detail ?? '{}').meta.tenantId).toBe('foreign-tenant');
476
+ const refused = scoped('foreign-tenant-delivery');
477
+ await expect(handler({ Records: [record(foreignEntry, refused), record(applicationEntry, scoped('application-delivery'))] }))
478
+ .resolves.toEqual({ batchItemFailures: [{ itemIdentifier: refused }] });
479
+ expect(observed.consumed).not.toContain(lifecycleIdempotencyKey('event-consumer', ['${name}', foreign]));
480
+ await expect(inboxCount('${name}', foreign)).resolves.toBe(0);
481
+ await expect(inboxCount('${name}', application)).resolves.toBe(1);
482
+ });
483
+ });
484
+ `,
485
+ };
486
+ }
487
+ //# sourceMappingURL=built-in-plan-testing-persistence-event-files.js.map
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderTestingPersistenceFiles = renderTestingPersistenceFiles;
4
+ const built_in_plan_testing_persistence_event_files_js_1 = require("./built-in-plan-testing-persistence-event-files.js");
4
5
  /** Disposable integration resources are independent of fleet or deployed data. */
5
6
  function renderTestingPersistenceFiles(name, pascalName, sql) {
6
7
  const labels = ` labels:
@@ -173,6 +174,7 @@ describe('${name} optional SQL migrations', () => {
173
174
  `,
174
175
  }
175
176
  : {}),
177
+ ...(0, built_in_plan_testing_persistence_event_files_js_1.renderTestingPersistenceEventFiles)(name, pascalName, sql),
176
178
  };
177
179
  }
178
180
  //# sourceMappingURL=built-in-plan-testing-persistence-files.js.map
@@ -239,7 +239,8 @@ function classifyPaths(files, sql) {
239
239
  'api/generated/contract.json',
240
240
  'api/generated/openapi.json',
241
241
  'authorization/machine-authorization-proof.ts',
242
- 'authorization/machine-client-provisioner.py',
242
+ 'authorization/machine-client-provisioner.spec.ts',
243
+ 'authorization/machine-client-provisioner.ts',
243
244
  'authorization/runtime.ts',
244
245
  'authorization/record-store.ts',
245
246
  'authorization/groups.mjs',
@@ -250,6 +251,7 @@ function classifyPaths(files, sql) {
250
251
  'testing/quarantine-runtime.mjs',
251
252
  'testing/strict-json-reporter.mjs',
252
253
  'testing/local-key-value.ts',
254
+ 'testing/local-sql.ts',
253
255
  'e2e/frontend-accessibility.spec.ts',
254
256
  ]);
255
257
  for (const path of built_in_plan_performance_files_js_1.PRODUCT_PERFORMANCE_SEEDED_PATHS)
@@ -1,4 +1,4 @@
1
- export declare const WORKLOAD_CONTAINER_NODE_IMAGE: "node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2";
1
+ export declare const WORKLOAD_CONTAINER_NODE_IMAGE: "node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402";
2
2
  export declare const WORKLOAD_CONTAINER_PNPM_VERSION: "11.20.0";
3
3
  export declare const WORKLOAD_CONTAINER_PNPM_DESCRIPTOR: "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee";
4
4
  export declare const WORKLOAD_CONTAINER_PNPM_STORE = "/workspace/.ebk/cache/pnpm-store";
@@ -15,6 +15,13 @@ export declare const EBK_PROTECTED_MAIN_RULESET_NAME: "EBK protected main (trunk
15
15
  export declare const EBK_STAGING_PROMOTION_PULL_ROLE_SECRET = "AWS_STAGING_PROMOTION_PULL_ROLE_ARN";
16
16
  export declare const EBK_PROTECTED_ENVIRONMENT_SECRETS: readonly ["AWS_ASYNC_RECOVERY_ROLE_ARN", "AWS_ECR_INSPECTOR_EVIDENCE_ROLE_ARN", "AWS_DEPLOY_ROLE_ARN", "AWS_STAGING_PROMOTION_PULL_ROLE_ARN"];
17
17
  export declare const EBK_PROTECTED_ENVIRONMENT_VARIABLES: readonly ["AWS_EXPECTED_ACCOUNT_ID", "AWS_REGION", "AWS_STAGING_PROMOTION_EXPECTED_ACCOUNT_ID", "AWS_STAGING_PROMOTION_REGION", "EBK_ALLOW_PRODUCTION_REMOVE", "EBK_FLEET_ORIGIN"];
18
+ /**
19
+ * Non-secret stage environment variables an operator may set to request a
20
+ * machine credential generation or its retirement on a workflow deploy. They
21
+ * are never required, and a repository- or organization-scoped copy would
22
+ * silently apply to every stage, so only the stage environment may hold them.
23
+ */
24
+ export declare const EBK_OPTIONAL_STAGE_ENVIRONMENT_VARIABLES: readonly ["EBK_MACHINE_CREDENTIAL_GENERATION", "EBK_MACHINE_RETIRE_PREVIOUS_GENERATION"];
18
25
  export declare const EBK_CODE_OWNER_PLACEHOLDER: "@REPLACE_WITH_GITHUB_CODE_OWNER";
19
26
  type GitHubEnvironmentStage = 'development' | 'production' | 'staging';
20
27
  type GitHubAuxiliaryEnvironment = 'async-recovery' | 'ecr-inspector';
@@ -25,6 +32,8 @@ export interface GitHubEnvironmentTopologyEntry {
25
32
  additionalBranches?: readonly ('develop' | 'main')[];
26
33
  branch: 'develop' | 'main';
27
34
  name: GitHubEnvironmentName;
35
+ /** Variables the environment may hold but never requires. */
36
+ optionalVariables?: readonly string[];
28
37
  requiredSecrets: readonly string[];
29
38
  requiredVariables: readonly string[];
30
39
  stage: GitHubEnvironmentStage;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.EBK_CODE_OWNER_PLACEHOLDER = exports.EBK_PROTECTED_ENVIRONMENT_VARIABLES = exports.EBK_PROTECTED_ENVIRONMENT_SECRETS = exports.EBK_STAGING_PROMOTION_PULL_ROLE_SECRET = exports.EBK_PROTECTED_MAIN_RULESET_NAME = exports.EBK_RECORD_MAIN_TOKEN_SECRET = exports.EBK_PERFORMANCE_ORIGINS_VARIABLE = exports.EBK_PERFORMANCE_LOAD_ROLE_SECRET = exports.EBK_PERFORMANCE_COST_ROLE_SECRET = exports.EBK_PERFORMANCE_AGGREGATE_ROLE_SECRET = exports.EBK_ASYNC_RECOVERY_ROLE_SECRET = exports.EBK_ECR_INSPECTOR_EVIDENCE_ROLE_SECRET = exports.EBK_RETIRED_DEPLOYMENT_ROLE_SECRET = exports.EBK_REQUIRED_CHECKS = exports.EBK_PROTECTED_BRANCH_RULESET_NAME = void 0;
3
+ exports.EBK_CODE_OWNER_PLACEHOLDER = exports.EBK_OPTIONAL_STAGE_ENVIRONMENT_VARIABLES = exports.EBK_PROTECTED_ENVIRONMENT_VARIABLES = exports.EBK_PROTECTED_ENVIRONMENT_SECRETS = exports.EBK_STAGING_PROMOTION_PULL_ROLE_SECRET = exports.EBK_PROTECTED_MAIN_RULESET_NAME = exports.EBK_RECORD_MAIN_TOKEN_SECRET = exports.EBK_PERFORMANCE_ORIGINS_VARIABLE = exports.EBK_PERFORMANCE_LOAD_ROLE_SECRET = exports.EBK_PERFORMANCE_COST_ROLE_SECRET = exports.EBK_PERFORMANCE_AGGREGATE_ROLE_SECRET = exports.EBK_ASYNC_RECOVERY_ROLE_SECRET = exports.EBK_ECR_INSPECTOR_EVIDENCE_ROLE_SECRET = exports.EBK_RETIRED_DEPLOYMENT_ROLE_SECRET = exports.EBK_REQUIRED_CHECKS = exports.EBK_PROTECTED_BRANCH_RULESET_NAME = void 0;
4
4
  exports.githubEnvironmentTopology = githubEnvironmentTopology;
5
5
  exports.runGitHubBootstrap = runGitHubBootstrap;
6
6
  const index_js_1 = require("../repository-policy/index.js");
@@ -33,6 +33,16 @@ exports.EBK_PROTECTED_ENVIRONMENT_VARIABLES = [
33
33
  'EBK_ALLOW_PRODUCTION_REMOVE',
34
34
  'EBK_FLEET_ORIGIN',
35
35
  ];
36
+ /**
37
+ * Non-secret stage environment variables an operator may set to request a
38
+ * machine credential generation or its retirement on a workflow deploy. They
39
+ * are never required, and a repository- or organization-scoped copy would
40
+ * silently apply to every stage, so only the stage environment may hold them.
41
+ */
42
+ exports.EBK_OPTIONAL_STAGE_ENVIRONMENT_VARIABLES = [
43
+ 'EBK_MACHINE_CREDENTIAL_GENERATION',
44
+ 'EBK_MACHINE_RETIRE_PREVIOUS_GENERATION',
45
+ ];
36
46
  const FORBIDDEN_NPM_TOKEN_NAMES = [
37
47
  'NODE_AUTH_TOKEN',
38
48
  'NPM_CONFIG_TOKEN',
@@ -62,6 +72,7 @@ function stageEnvironmentTopology(stage, deploymentProfile) {
62
72
  return Object.freeze({
63
73
  branch: production ? 'main' : 'develop',
64
74
  name: stage,
75
+ optionalVariables: exports.EBK_OPTIONAL_STAGE_ENVIRONMENT_VARIABLES,
65
76
  requiredSecrets: Object.freeze(production
66
77
  ? deploymentProfile === 'starter'
67
78
  ? []
@@ -808,6 +819,7 @@ function evaluate(repository, state, inspectionBlockers, productionApprovalMode,
808
819
  }
809
820
  for (const variable of [
810
821
  ...exports.EBK_PROTECTED_ENVIRONMENT_VARIABLES,
822
+ ...exports.EBK_OPTIONAL_STAGE_ENVIRONMENT_VARIABLES,
811
823
  ...FORBIDDEN_NPM_TOKEN_NAMES,
812
824
  ]) {
813
825
  if (state.repositoryVariableNames.includes(variable)) {
@@ -880,8 +892,10 @@ function evaluate(repository, state, inspectionBlockers, productionApprovalMode,
880
892
  blockers.push(`${environment} environment is missing required variable ${variable}; bootstrap never creates variable values.`);
881
893
  }
882
894
  }
895
+ const optionalVariables = topology.optionalVariables ?? [];
883
896
  for (const variable of current.variableNames) {
884
- if (!requiredVariables.includes(variable)) {
897
+ if (!requiredVariables.includes(variable) &&
898
+ !optionalVariables.includes(variable)) {
885
899
  blockers.push(`${environment} environment exposes ${variable} outside its exact capability boundary.`);
886
900
  }
887
901
  }