@mettlecast/domain-cli 0.2.59 → 0.2.61

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 (119) hide show
  1. package/dist/builder/build-registry.d.ts +1 -1
  2. package/dist/builder/build-registry.js +129 -49
  3. package/dist/builder/build-types.d.ts +1 -1
  4. package/dist/builder/load-module.d.ts +1 -1
  5. package/dist/builder/load-module.js +3 -3
  6. package/dist/cli.js +3 -3
  7. package/dist/commands/add-api.js +2 -2
  8. package/dist/commands/add-domain.js +4 -4
  9. package/dist/commands/add-fixture-factory.js +5 -6
  10. package/dist/commands/build-catalog.d.ts +8 -24
  11. package/dist/commands/build-catalog.js +12 -23
  12. package/dist/commands/build-flows.js +1 -1
  13. package/dist/commands/build.js +7 -6
  14. package/dist/commands/check-hashes.js +2 -2
  15. package/dist/commands/create-project.js +1 -1
  16. package/dist/commands/dev.js +1 -1
  17. package/dist/commands/doctor.d.ts +5 -7
  18. package/dist/commands/doctor.js +110 -202
  19. package/dist/commands/explain.js +13 -13
  20. package/dist/commands/generate-openapi.d.ts +10 -1
  21. package/dist/commands/generate-openapi.js +19 -33
  22. package/dist/commands/power-tune.js +2 -2
  23. package/dist/commands/show-dns.d.ts +1 -1
  24. package/dist/commands/show-dns.js +5 -5
  25. package/dist/commands/show.d.ts +2 -3
  26. package/dist/commands/show.js +0 -2
  27. package/dist/commands/test.js +0 -1
  28. package/dist/commands/upgrade-backend.js +3 -3
  29. package/dist/commands/upgrade.js +17 -11
  30. package/dist/commands/validate.js +144 -39
  31. package/dist/server/api-server.d.ts +1 -1
  32. package/dist/server/mount-routes.d.ts +11 -2
  33. package/dist/server/mount-routes.js +20 -8
  34. package/dist/templates/api-skeleton.d.ts +5 -0
  35. package/dist/templates/api-skeleton.js +28 -27
  36. package/dist/templates/claude-md.js +1 -1
  37. package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
  38. package/dist/templates/patterns/api/create-with-event.js +38 -32
  39. package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
  40. package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
  41. package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
  42. package/dist/templates/patterns/api/paginated-list.js +30 -24
  43. package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
  44. package/dist/templates/patterns/api/simple-crud.js +46 -35
  45. package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
  46. package/dist/templates/patterns/api/streaming-list.js +46 -41
  47. package/dist/templates/patterns/api/system-admin.d.ts +4 -0
  48. package/dist/templates/patterns/api/system-admin.js +59 -52
  49. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
  50. package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
  51. package/dist/types.d.ts +100 -0
  52. package/dist/types.js +1 -0
  53. package/dist/utils/file-helpers.d.ts +0 -2
  54. package/dist/utils/file-helpers.js +2 -3
  55. package/dist/utils/header-inject.js +2 -2
  56. package/dist/utils/install-file.d.ts +1 -1
  57. package/dist/utils/install-file.js +1 -1
  58. package/dist/utils/manifest.js +1 -2
  59. package/dist/utils/scaffold-config.d.ts +8 -2
  60. package/dist/utils/scaffold-config.js +3 -1
  61. package/package.json +1 -1
  62. package/src/__tests__/build-registry.test.ts +43 -20
  63. package/src/__tests__/build-types.test.ts +4 -7
  64. package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
  65. package/src/__tests__/commands/add-api.test.ts +12 -10
  66. package/src/__tests__/commands/add-domain.test.ts +8 -5
  67. package/src/__tests__/commands/check-hashes.test.ts +9 -9
  68. package/src/__tests__/commands/create-project.test.ts +5 -5
  69. package/src/__tests__/commands/dev.test.ts +0 -1
  70. package/src/__tests__/commands/upgrade.test.ts +7 -7
  71. package/src/__tests__/doctor.test.ts +60 -67
  72. package/src/__tests__/mount-routes.test.ts +64 -23
  73. package/src/__tests__/package-freshness.test.ts +94 -0
  74. package/src/__tests__/scaffold-src/part-a-layout.test.ts +10 -10
  75. package/src/__tests__/scripts/package-scaffold.test.ts +5 -5
  76. package/src/__tests__/smoke/scaffold.test.ts +13 -15
  77. package/src/__tests__/utils/install-file.test.ts +2 -2
  78. package/src/__tests__/utils/manifest.test.ts +2 -2
  79. package/src/__tests__/validate.test.ts +570 -1
  80. package/src/builder/build-registry.ts +154 -59
  81. package/src/builder/build-types.ts +1 -1
  82. package/src/builder/load-module.ts +3 -3
  83. package/src/cli.ts +4 -4
  84. package/src/commands/add-api.ts +2 -2
  85. package/src/commands/add-domain.ts +4 -4
  86. package/src/commands/add-fixture-factory.ts +5 -6
  87. package/src/commands/build-catalog.ts +18 -40
  88. package/src/commands/build-flows.ts +1 -1
  89. package/src/commands/build.ts +8 -7
  90. package/src/commands/check-hashes.ts +2 -2
  91. package/src/commands/create-project.ts +1 -1
  92. package/src/commands/dev.ts +1 -1
  93. package/src/commands/doctor.ts +120 -218
  94. package/src/commands/explain.ts +13 -13
  95. package/src/commands/generate-openapi.ts +30 -52
  96. package/src/commands/power-tune.ts +2 -2
  97. package/src/commands/show-dns.ts +5 -5
  98. package/src/commands/show.ts +2 -5
  99. package/src/commands/test.ts +0 -1
  100. package/src/commands/upgrade-backend.ts +3 -3
  101. package/src/commands/upgrade.ts +16 -10
  102. package/src/commands/validate.ts +180 -40
  103. package/src/server/api-server.ts +1 -1
  104. package/src/server/mount-routes.ts +21 -10
  105. package/src/templates/api-skeleton.ts +29 -28
  106. package/src/templates/claude-md.ts +1 -1
  107. package/src/templates/patterns/api/create-with-event.ts +39 -33
  108. package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
  109. package/src/templates/patterns/api/paginated-list.ts +31 -25
  110. package/src/templates/patterns/api/simple-crud.ts +47 -36
  111. package/src/templates/patterns/api/streaming-list.ts +47 -42
  112. package/src/templates/patterns/api/system-admin.ts +60 -53
  113. package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
  114. package/src/types.ts +128 -0
  115. package/src/utils/file-helpers.ts +2 -5
  116. package/src/utils/header-inject.ts +2 -2
  117. package/src/utils/install-file.ts +1 -1
  118. package/src/utils/manifest.ts +1 -2
  119. package/src/utils/scaffold-config.ts +12 -3
@@ -448,11 +448,10 @@ describe('scaffold smoke test (issue #3831)', () => {
448
448
  expect(tsconfig.compilerOptions.strict).toBe(true);
449
449
  });
450
450
 
451
- it('seeds api handlers from auth that import defineApi from @mettlecast/domain-runtime', async () => {
451
+ it('seeds api handlers from auth that import defineAction + exposure from @mettlecast/domain-runtime', async () => {
452
452
  // The auth module ships a hand-written seed api/me.ts. We
453
453
  // confirm it parses (no obvious syntax errors) and uses the runtime
454
- // primitive. This is a structural check; the wave-2 "import ky + call
455
- // initOtel()" expectation is documented below as a separate soft gate.
454
+ // primitive. This is a structural check.
456
455
  const apiFile = join(projectDir, 'domains', 'auth', 'api', 'me.ts');
457
456
  const s = await stat(apiFile).catch(() => null);
458
457
  if (!s) {
@@ -462,7 +461,8 @@ describe('scaffold smoke test (issue #3831)', () => {
462
461
  }
463
462
  const content = await readFile(apiFile, 'utf-8');
464
463
  expect(content).toMatch(/from\s+['"]@mettlecast\/domain-runtime['"]/);
465
- expect(content).toMatch(/defineApi\s*\(/);
464
+ expect(content).toMatch(/defineAction\s*\(/);
465
+ expect(content).toMatch(/type:\s*['"]api['"]/);
466
466
  });
467
467
 
468
468
  it('seeds initOtel() references in api handlers (wave-2 acceptance gate)', async () => {
@@ -499,20 +499,18 @@ describe('scaffold smoke test (issue #3831)', () => {
499
499
  }
500
500
  });
501
501
 
502
- it('api-skeleton template (add-api) emits createKyClient + initOtel references', async () => {
503
- // The add-api template now imports `createKyClient` and `initOtel`
504
- // from @mettlecast/domain-runtime (the ky-backed runtime client),
505
- // not a direct `ky` npm import. Seed handlers may also import `ky`
506
- // directly if they need a browser-friendly HTTP client.
502
+ it('api-skeleton template (add-api) emits defineAction + exposure.type=api references', async () => {
503
+ // The add-api template now emits defineAction + exposure.type='api' as
504
+ // the canonical action-first HTTP endpoint primitive (#4689).
507
505
  const out = apiSkeletonTemplateForTest('payments', 'charge-card', 'required');
508
- const hasKy = /createKyClient/.test(out) || /from\s+['"]ky['"]/.test(out);
509
- const hasInitOtel = /\binitOtel\s*\(/.test(out);
510
- if (!hasKy || !hasInitOtel) {
506
+ const hasDefineAction = /defineAction\s*\(/.test(out);
507
+ const hasApiExposure = /type:\s*['"]api['"]/.test(out);
508
+ if (!hasDefineAction || !hasApiExposure) {
511
509
  throw new Error(
512
510
  'api-skeleton template (packages/domain-cli/src/templates/api-skeleton.ts) ' +
513
- 'does not emit createKyClient or initOtel()\n' +
514
- ` hasKy=${hasKy} hasInitOtel=${hasInitOtel}\n` +
515
- ' expected: import { createKyClient, initOtel } from "@mettlecast/domain-runtime" AND initOtel()',
511
+ 'does not emit defineAction + exposure.type=api\n' +
512
+ ` hasDefineAction=${hasDefineAction} hasApiExposure=${hasApiExposure}\n` +
513
+ ' expected: import { defineAction } from "@mettlecast/domain-runtime" AND exposure: { type: "api", ... }',
516
514
  );
517
515
  }
518
516
  });
@@ -157,8 +157,8 @@ describe('installScaffoldFile', () => {
157
157
 
158
158
  const result = await installScaffoldFile(filePath, newContent, 'editable', currentEntry);
159
159
  expect(result.status).toBe('update-available');
160
- // Verify NO .tib-upgrade file written
161
- await expect(access(filePath + '.tib-upgrade')).rejects.toThrow();
160
+ // Verify NO .mc-upgrade file written
161
+ await expect(access(filePath + '.mc-upgrade')).rejects.toThrow();
162
162
  // Verify original file NOT overwritten
163
163
  const diskContent = await readFile(filePath, 'utf-8');
164
164
  expect(diskContent).toBe(existingContent);
@@ -57,8 +57,8 @@ describe('manifest utilities', () => {
57
57
  expect(inferPolicyFromPath('infra/main.tf')).toBe('managed');
58
58
  });
59
59
 
60
- it('returns editable for .mc/infra/ paths', () => {
61
- expect(inferPolicyFromPath('.mc/infra/stack.ts')).toBe('editable');
60
+ it('returns editable for unrecognised .mc paths', () => {
61
+ expect(inferPolicyFromPath('.mc/custom/stack.ts')).toBe('editable');
62
62
  });
63
63
 
64
64
  it('returns managed for mc-deploy.yml', () => {
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
 
3
3
  vi.mock('../utils/logger.js', () => ({
4
4
  cliLogger: {
@@ -9,10 +9,579 @@ vi.mock('../utils/logger.js', () => ({
9
9
  },
10
10
  }));
11
11
 
12
+ vi.mock('../builder/load-module.js', () => ({
13
+ loadModuleExports: vi.fn(),
14
+ }));
15
+ vi.mock('../utils/file-helpers.js', () => ({
16
+ walkDomainDir: vi.fn(),
17
+ }));
18
+
12
19
  import { runValidate } from '../commands/validate.js';
20
+ import { buildRegistry } from '../builder/build-registry.js';
21
+ import { loadModuleExports } from '../builder/load-module.js';
22
+ import { walkDomainDir } from '../utils/file-helpers.js';
23
+
24
+ const mockLoadModuleExports = vi.mocked(loadModuleExports);
25
+ const mockWalkDomainDir = vi.mocked(walkDomainDir);
13
26
 
14
27
  describe('runValidate error shape', () => {
15
28
  it('throws when domain root has no domain.config.ts', async () => {
16
29
  await expect(runValidate('/nonexistent-path', false)).rejects.toThrow();
17
30
  });
18
31
  });
32
+
33
+ /**
34
+ * Action-first migration (#4619, Wave 1 Task 1.2):
35
+ *
36
+ * `runValidate` ultimately delegates to `buildRegistry` for the
37
+ * registry assembly. These tests exercise the action mapping directly
38
+ * through `buildRegistry` (mocked file walker + module loader) so we
39
+ * catch regressions in how legacy `visibility` maps to `backendAccess`
40
+ * and how `exposure` is serialized.
41
+ */
42
+ describe('buildRegistry action mapping (action-first migration)', () => {
43
+ const DOMAIN_ROOT = '/fake/domains/billing';
44
+
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ });
48
+
49
+ it('reads backendAccess directly when raw action exposes it', async () => {
50
+ mockWalkDomainDir.mockResolvedValue({
51
+ domain: '/fake/domains/billing/domain.config.ts',
52
+ webhooks: [],
53
+ subscribers: [],
54
+ actions: ['/fake/domains/billing/actions/charge-card.ts'],
55
+ schedules: [],
56
+ jobs: [],
57
+ integrations: [],
58
+ publishes: undefined,
59
+ });
60
+ mockLoadModuleExports
61
+ .mockResolvedValueOnce([
62
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
63
+ ])
64
+ .mockResolvedValueOnce([
65
+ {
66
+ _kind: 'action',
67
+ _exportName: 'chargeCard',
68
+ id: 'charge-card',
69
+ backendAccess: 'domain',
70
+ exposure: {
71
+ type: 'api',
72
+ path: '/v1/tenants/{tenantId}/billing/charges',
73
+ method: 'POST',
74
+ auth: 'required',
75
+ tenancy: 'required',
76
+ },
77
+ idempotent: true,
78
+ },
79
+ ]);
80
+
81
+ const { registry } = await buildRegistry(DOMAIN_ROOT);
82
+
83
+ expect(registry.actions).toHaveLength(1);
84
+ const action = registry.actions[0]!;
85
+ expect(action.id).toBe('charge-card');
86
+ expect(action.backendAccess).toBe('domain');
87
+ expect(action.exposure.type).toBe('api');
88
+ if (action.exposure.type === 'api') {
89
+ expect(action.exposure.path).toBe('/v1/tenants/{tenantId}/billing/charges');
90
+ expect(action.exposure.method).toBe('POST');
91
+ expect(action.exposure.auth).toBe('required');
92
+ expect(action.exposure.tenancy).toBe('required');
93
+ }
94
+ });
95
+
96
+ it('falls back from legacy visibility to backendAccess and collapses workspace -> domain', async () => {
97
+ mockWalkDomainDir.mockResolvedValue({
98
+ domain: '/fake/domains/billing/domain.config.ts',
99
+ webhooks: [],
100
+ subscribers: [],
101
+ actions: [
102
+ '/fake/domains/billing/actions/verify-token.ts',
103
+ '/fake/domains/billing/actions/provision-tenant.ts',
104
+ ],
105
+ schedules: [],
106
+ jobs: [],
107
+ integrations: [],
108
+ publishes: undefined,
109
+ });
110
+ mockLoadModuleExports
111
+ .mockResolvedValueOnce([
112
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
113
+ ])
114
+ // First call resolves the actions array (Promise.all maps in order).
115
+ .mockResolvedValueOnce([
116
+ { _kind: 'action', _exportName: 'verifyToken', id: 'verify-token', visibility: 'workspace', idempotent: false },
117
+ { _kind: 'action', _exportName: 'provisionTenant', id: 'provision-tenant', visibility: 'domain', idempotent: true },
118
+ ]);
119
+
120
+ const { registry } = await buildRegistry(DOMAIN_ROOT);
121
+
122
+ expect(registry.actions).toHaveLength(2);
123
+ const verify = registry.actions.find(a => a.id === 'verify-token');
124
+ const provision = registry.actions.find(a => a.id === 'provision-tenant');
125
+ expect(verify?.backendAccess).toBe('domain');
126
+ expect(verify?.exposure).toEqual({ type: 'internal' });
127
+ expect(provision?.backendAccess).toBe('domain');
128
+ expect(provision?.exposure).toEqual({ type: 'internal' });
129
+ });
130
+
131
+ it('defaults missing exposure to { type: internal } for legacy actions', async () => {
132
+ mockWalkDomainDir.mockResolvedValue({
133
+ domain: '/fake/domains/billing/domain.config.ts',
134
+ webhooks: [],
135
+ subscribers: [],
136
+ actions: ['/fake/domains/billing/actions/legacy.ts'],
137
+ schedules: [],
138
+ jobs: [],
139
+ integrations: [],
140
+ publishes: undefined,
141
+ });
142
+ mockLoadModuleExports
143
+ .mockResolvedValueOnce([
144
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
145
+ ])
146
+ .mockResolvedValueOnce([
147
+ { _kind: 'action', _exportName: 'legacy', id: 'legacy', visibility: 'private', idempotent: false },
148
+ ]);
149
+
150
+ const { registry } = await buildRegistry(DOMAIN_ROOT);
151
+
152
+ expect(registry.actions[0]?.exposure).toEqual({ type: 'internal' });
153
+ expect(registry.actions[0]?.backendAccess).toBe('private');
154
+ });
155
+
156
+ it('preserves inputSchema and outputSchema extraction', async () => {
157
+ mockWalkDomainDir.mockResolvedValue({
158
+ domain: '/fake/domains/billing/domain.config.ts',
159
+ webhooks: [],
160
+ subscribers: [],
161
+ actions: ['/fake/domains/billing/actions/charge-card.ts'],
162
+ schedules: [],
163
+ jobs: [],
164
+ integrations: [],
165
+ publishes: undefined,
166
+ });
167
+ mockLoadModuleExports
168
+ .mockResolvedValueOnce([
169
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
170
+ ])
171
+ .mockResolvedValueOnce([
172
+ {
173
+ _kind: 'action',
174
+ _exportName: 'chargeCard',
175
+ id: 'charge-card',
176
+ backendAccess: 'domain',
177
+ exposure: { type: 'internal' },
178
+ idempotent: true,
179
+ input: { type: 'object', properties: { amount: { type: 'number' } } },
180
+ output: { type: 'object', properties: { id: { type: 'string' } } },
181
+ },
182
+ ]);
183
+
184
+ const { registry } = await buildRegistry(DOMAIN_ROOT);
185
+
186
+ expect(registry.actions[0]?.inputSchema).toEqual({ type: 'object', properties: { amount: { type: 'number' } } });
187
+ expect(registry.actions[0]?.outputSchema).toEqual({ type: 'object', properties: { id: { type: 'string' } } });
188
+ });
189
+ });
190
+
191
+ /**
192
+ * Action-first security validation rules (#4619, Wave 6 Task 6.1).
193
+ *
194
+ * These tests exercise the new validation gates added to `runValidate`.
195
+ * Each test mocks `buildRegistry` consumers so the assertions target the
196
+ * rule codes (`ACTION_EXPOSURE_REQUIRED`, `TENANT_API_PATH_REQUIRED`,
197
+ * `AUTH_NONE_REQUIRES_EXCEPTION`, etc.) without needing a real
198
+ * domain on disk.
199
+ */
200
+ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
201
+ const DOMAIN_ROOT = '/fake/domains/billing';
202
+
203
+ beforeEach(() => {
204
+ vi.clearAllMocks();
205
+ });
206
+
207
+ /**
208
+ * Helper that wires the two file-walker / module-loader mocks so
209
+ * `buildRegistry` returns the supplied actions plus the canonical
210
+ * domain row. Returns the ValidationResult
211
+ * produced by `runValidate`.
212
+ */
213
+ async function runValidateWith({
214
+ actions = [],
215
+ }: {
216
+ actions?: Record<string, unknown>[];
217
+ }): Promise<{ errors: { code: string; message: string }[]; warnings: string[]; valid: boolean; }> {
218
+ mockWalkDomainDir.mockResolvedValue({
219
+ domain: '/fake/domains/billing/domain.config.ts',
220
+ webhooks: [],
221
+ subscribers: [],
222
+ actions: actions.map(() => '/fake/domains/billing/actions/x.ts'),
223
+ schedules: [],
224
+ jobs: [],
225
+ integrations: [],
226
+ publishes: undefined,
227
+ });
228
+ // Mock consumption order inside buildRegistry:
229
+ // 1. domain (paths.domain) -> mock #1
230
+ // 2. webhooks/subscribers/schedules/jobs/integrations -> empty arrays, no calls
231
+ // 3. actions files (paths.actions)-> mock #N (if any)
232
+ // 4. publishes (paths.publishes) -> undefined, no call
233
+ // Each non-empty slot consumes exactly one mock per file (we
234
+ // generate one synthetic file path per item so the slot has length
235
+ // equal to the supplied arrays).
236
+ mockLoadModuleExports.mockResolvedValueOnce([
237
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
238
+ ]);
239
+ if (actions.length > 0) {
240
+ mockLoadModuleExports.mockResolvedValueOnce(actions as never);
241
+ }
242
+
243
+ const result = await runValidate(DOMAIN_ROOT, false);
244
+ return { errors: result.errors, warnings: result.warnings, valid: result.valid };
245
+ }
246
+
247
+ it('ACTION_EXPOSURE_REQUIRED — fires when a non-private action omits exposure', async () => {
248
+ const { errors } = await runValidateWith({
249
+ actions: [
250
+ // `backendAccess: 'platform'` is the strongest "new-style" signal;
251
+ // omitting `exposure` should now fail the build.
252
+ {
253
+ _kind: 'action', _exportName: 'sysReset',
254
+ id: 'sys-reset', backendAccess: 'platform', idempotent: false,
255
+ },
256
+ ],
257
+ });
258
+ const rule = errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED');
259
+ expect(rule).toBeDefined();
260
+ expect(rule?.message).toContain("'sys-reset'");
261
+ });
262
+
263
+ it('ACTION_EXPOSURE_REQUIRED — does NOT fire for explicit internal exposure', async () => {
264
+ const { errors } = await runValidateWith({
265
+ actions: [
266
+ {
267
+ _kind: 'action', _exportName: 'sysReset',
268
+ id: 'sys-reset', backendAccess: 'platform',
269
+ exposure: { type: 'internal' },
270
+ idempotent: false,
271
+ },
272
+ ],
273
+ });
274
+ expect(errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED')).toBeUndefined();
275
+ });
276
+
277
+ it('ACTION_EXPOSURE_REQUIRED — silent on legacy private action (migration compatibility)', async () => {
278
+ // Legacy `visibility: 'private'` actions map to `backendAccess: 'private'`
279
+ // and default `exposure` to `{ type: 'internal' }`. The rule must not
280
+ // flag these so the migration window stays unblocked.
281
+ const { errors } = await runValidateWith({
282
+ actions: [
283
+ {
284
+ _kind: 'action', _exportName: 'legacyPrivate',
285
+ id: 'legacy-private', visibility: 'private', idempotent: false,
286
+ },
287
+ ],
288
+ });
289
+ expect(errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED')).toBeUndefined();
290
+ });
291
+
292
+ it('TENANT_API_PATH_REQUIRED — fires when tenancy is required but path lacks tenant placeholder', async () => {
293
+ const { errors } = await runValidateWith({
294
+ actions: [
295
+ {
296
+ _kind: 'action', _exportName: 'listAll',
297
+ id: 'list-all', backendAccess: 'platform',
298
+ exposure: {
299
+ type: 'api', path: '/v1/admin/users', method: 'GET',
300
+ auth: 'required', tenancy: 'required',
301
+ },
302
+ idempotent: false,
303
+ },
304
+ ],
305
+ });
306
+ const rule = errors.find(e => e.code === 'TENANT_API_PATH_REQUIRED');
307
+ expect(rule).toBeDefined();
308
+ expect(rule?.message).toContain("'list-all'");
309
+ expect(rule?.message).toContain('/v1/tenants/{tenantId}/');
310
+ });
311
+
312
+ it('TENANT_API_PATH_REQUIRED — passes when tenancy is required AND path includes placeholder', async () => {
313
+ const { errors } = await runValidateWith({
314
+ actions: [
315
+ {
316
+ _kind: 'action', _exportName: 'listUsers',
317
+ id: 'list-users', backendAccess: 'domain',
318
+ exposure: {
319
+ type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
320
+ auth: 'required', tenancy: 'required',
321
+ },
322
+ idempotent: false,
323
+ },
324
+ ],
325
+ });
326
+ expect(errors.find(e => e.code === 'TENANT_API_PATH_REQUIRED')).toBeUndefined();
327
+ });
328
+
329
+ it('API_EXPOSURE_AUTH_REQUIRED — fires when auth was defaulted (not declared in source)', async () => {
330
+ // Source did NOT include `auth` — the builder will default to
331
+ // 'required' and the validator must surface the omission.
332
+ const { errors } = await runValidateWith({
333
+ actions: [
334
+ {
335
+ _kind: 'action', _exportName: 'listUsers',
336
+ id: 'list-users', backendAccess: 'domain',
337
+ exposure: {
338
+ type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
339
+ tenancy: 'required',
340
+ // auth omitted on purpose
341
+ },
342
+ idempotent: false,
343
+ },
344
+ ],
345
+ });
346
+ const rule = errors.find(e => e.code === 'API_EXPOSURE_AUTH_REQUIRED');
347
+ expect(rule).toBeDefined();
348
+ expect(rule?.message).toContain("'list-users'");
349
+ });
350
+
351
+ it('API_EXPOSURE_AUTH_REQUIRED — silent when auth is explicitly declared', async () => {
352
+ const { errors } = await runValidateWith({
353
+ actions: [
354
+ {
355
+ _kind: 'action', _exportName: 'listUsers',
356
+ id: 'list-users', backendAccess: 'domain',
357
+ exposure: {
358
+ type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
359
+ auth: 'required', tenancy: 'required',
360
+ },
361
+ idempotent: false,
362
+ },
363
+ ],
364
+ });
365
+ expect(errors.find(e => e.code === 'API_EXPOSURE_AUTH_REQUIRED')).toBeUndefined();
366
+ });
367
+
368
+ it('AUTH_NONE_REQUIRES_EXCEPTION — fires when auth is none without securityException', async () => {
369
+ const { errors } = await runValidateWith({
370
+ actions: [
371
+ {
372
+ _kind: 'action', _exportName: 'health',
373
+ id: 'health', backendAccess: 'platform',
374
+ exposure: {
375
+ type: 'api', path: '/v1/health', method: 'GET',
376
+ auth: 'none', tenancy: 'none',
377
+ },
378
+ idempotent: false,
379
+ },
380
+ ],
381
+ });
382
+ const rule = errors.find(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION');
383
+ expect(rule).toBeDefined();
384
+ expect(rule?.message).toContain("'health'");
385
+ });
386
+
387
+ it('AUTH_NONE_REQUIRES_EXCEPTION — passes when auth is none WITH securityException.reason', async () => {
388
+ const { errors } = await runValidateWith({
389
+ actions: [
390
+ {
391
+ _kind: 'action', _exportName: 'health',
392
+ id: 'health', backendAccess: 'platform',
393
+ exposure: {
394
+ type: 'api', path: '/v1/health', method: 'GET',
395
+ auth: 'none', tenancy: 'none',
396
+ securityException: { reason: 'public liveness probe; no PII; ticket OPS-123' },
397
+ },
398
+ idempotent: false,
399
+ },
400
+ ],
401
+ });
402
+ expect(errors.find(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION')).toBeUndefined();
403
+ });
404
+
405
+ it('AUTH_NONE_REQUIRES_EXCEPTION — fires when securityException.reason is whitespace-only', async () => {
406
+ const { errors } = await runValidateWith({
407
+ actions: [
408
+ {
409
+ _kind: 'action', _exportName: 'health',
410
+ id: 'health', backendAccess: 'platform',
411
+ exposure: {
412
+ type: 'api', path: '/v1/health', method: 'GET',
413
+ auth: 'none', tenancy: 'none',
414
+ securityException: { reason: ' ' },
415
+ },
416
+ idempotent: false,
417
+ },
418
+ ],
419
+ });
420
+ expect(errors.find(e => e.code === 'AUTH_NONE_REQUIRES_EXCEPTION')).toBeDefined();
421
+ });
422
+
423
+ it('TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC — fires when tenancy is none without securityException', async () => {
424
+ const { errors } = await runValidateWith({
425
+ actions: [
426
+ {
427
+ _kind: 'action', _exportName: 'publicLookup',
428
+ id: 'public-lookup', backendAccess: 'platform',
429
+ exposure: {
430
+ type: 'api', path: '/v1/public/lookup', method: 'GET',
431
+ auth: 'required', tenancy: 'none',
432
+ },
433
+ idempotent: false,
434
+ },
435
+ ],
436
+ });
437
+ const rule = errors.find(e => e.code === 'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC');
438
+ expect(rule).toBeDefined();
439
+ expect(rule?.message).toContain("'public-lookup'");
440
+ });
441
+
442
+ it('SYSTEM_API_REQUIRES_ROLE — fires when system tenancy + required auth without roles', async () => {
443
+ const { errors } = await runValidateWith({
444
+ actions: [
445
+ {
446
+ _kind: 'action', _exportName: 'adminPing',
447
+ id: 'admin-ping', backendAccess: 'platform',
448
+ exposure: {
449
+ type: 'api', path: '/v1/admin/ping', method: 'GET',
450
+ auth: 'required', tenancy: 'system',
451
+ },
452
+ idempotent: false,
453
+ },
454
+ ],
455
+ });
456
+ const rule = errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE');
457
+ expect(rule).toBeDefined();
458
+ expect(rule?.message).toContain("'admin-ping'");
459
+ });
460
+
461
+ it('SYSTEM_API_REQUIRES_ROLE — passes when system tenancy has non-empty roles', async () => {
462
+ const { errors } = await runValidateWith({
463
+ actions: [
464
+ {
465
+ _kind: 'action', _exportName: 'adminPing',
466
+ id: 'admin-ping', backendAccess: 'platform',
467
+ exposure: {
468
+ type: 'api', path: '/v1/admin/ping', method: 'GET',
469
+ auth: 'required', tenancy: 'system',
470
+ roles: ['sys-admin'],
471
+ },
472
+ idempotent: false,
473
+ },
474
+ ],
475
+ });
476
+ expect(errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBeUndefined();
477
+ });
478
+
479
+ it('SYSTEM_API_REQUIRES_ROLE — silent when auth is service (service-only route, no role narrowing required)', async () => {
480
+ const { errors } = await runValidateWith({
481
+ actions: [
482
+ {
483
+ _kind: 'action', _exportName: 'replicationHook',
484
+ id: 'replication-hook', backendAccess: 'platform',
485
+ exposure: {
486
+ type: 'api', path: '/v1/admin/replicate', method: 'POST',
487
+ auth: 'service', tenancy: 'system',
488
+ },
489
+ idempotent: false,
490
+ },
491
+ ],
492
+ });
493
+ expect(errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBeUndefined();
494
+ });
495
+
496
+ it('combined: well-formed api action passes every rule', async () => {
497
+ const { errors } = await runValidateWith({
498
+ actions: [
499
+ {
500
+ _kind: 'action', _exportName: 'chargeCard',
501
+ id: 'charge-card', backendAccess: 'domain',
502
+ exposure: {
503
+ type: 'api', path: '/v1/tenants/{tenantId}/billing/charges', method: 'POST',
504
+ auth: 'required', tenancy: 'required',
505
+ roles: ['billing-admin'],
506
+ },
507
+ idempotent: true,
508
+ },
509
+ ],
510
+ });
511
+ const securityCodes = [
512
+ 'ACTION_EXPOSURE_REQUIRED', 'API_EXPOSURE_AUTH_REQUIRED',
513
+ 'TENANT_API_PATH_REQUIRED', 'AUTH_NONE_REQUIRES_EXCEPTION',
514
+ 'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC', 'SYSTEM_API_REQUIRES_ROLE',
515
+ ];
516
+ const firedSecurityRules = errors.filter(e => securityCodes.includes(e.code));
517
+ expect(firedSecurityRules).toHaveLength(0);
518
+ // Non-security rules (e.g. MISSING_HANDLER_FILE on the synthetic
519
+ // mock file path) may legitimately fire; we only assert that the
520
+ // Wave 6 security gates are clean.
521
+ });
522
+ });
523
+
524
+ /**
525
+ * Deployment-time security rules (#4662 Task D, #4689).
526
+ *
527
+ * These mirror the CDK aspect's synth-time checks so CI can gate the
528
+ * build BEFORE synth is attempted. Each rule emits a structured
529
+ * `ValidationError` whose `code` matches the aspect annotation code,
530
+ * making the two layers correlatable.
531
+ *
532
+ * Issue #4689: defineApi and registry.apis were removed. The action-first
533
+ * `TENANT_API_PATH_REQUIRED` rule in the `#4619 / Wave 6` suite now owns
534
+ * the placeholder invariant. The deployment-time rules below assert that
535
+ * no duplicate `SECURITY_MISSING_TENANT_PATH` codes are emitted for the
536
+ * action surface.
537
+ */
538
+ describe('runValidate deployment-time security rules (#4662 Task D)', () => {
539
+ const DOMAIN_ROOT = '/fake/domains/billing';
540
+
541
+ beforeEach(() => {
542
+ vi.clearAllMocks();
543
+ });
544
+
545
+ async function runValidateWith({
546
+ actions = [],
547
+ }: {
548
+ actions?: Record<string, unknown>[];
549
+ }): Promise<{ errors: { code: string; message: string }[]; warnings: string[]; valid: boolean; }> {
550
+ mockWalkDomainDir.mockResolvedValue({
551
+ domain: '/fake/domains/billing/domain.config.ts',
552
+ webhooks: [],
553
+ subscribers: [],
554
+ actions: actions.map(() => '/fake/domains/billing/actions/x.ts'),
555
+ schedules: [],
556
+ jobs: [],
557
+ integrations: [],
558
+ publishes: undefined,
559
+ });
560
+ mockLoadModuleExports.mockResolvedValueOnce([
561
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
562
+ ]);
563
+ if (actions.length > 0) {
564
+ mockLoadModuleExports.mockResolvedValueOnce(actions as never);
565
+ }
566
+
567
+ const result = await runValidate(DOMAIN_ROOT, false);
568
+ return { errors: result.errors, warnings: result.warnings, valid: result.valid };
569
+ }
570
+
571
+ it('does NOT emit SECURITY_MISSING_TENANT_PATH for an action that already includes the tenant placeholder', async () => {
572
+ const { errors } = await runValidateWith({
573
+ actions: [
574
+ {
575
+ _kind: 'action', _exportName: 'listUsers',
576
+ id: 'list-users', backendAccess: 'domain',
577
+ exposure: {
578
+ type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
579
+ auth: 'required', tenancy: 'required',
580
+ },
581
+ idempotent: true,
582
+ },
583
+ ],
584
+ });
585
+ expect(errors.find(e => e.code === 'SECURITY_MISSING_TENANT_PATH')).toBeUndefined();
586
+ });
587
+ });