@mettlecast/domain-cli 0.2.60 → 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.
- package/dist/builder/build-registry.d.ts +1 -1
- package/dist/builder/build-registry.js +1 -36
- package/dist/builder/build-types.d.ts +1 -1
- package/dist/builder/load-module.d.ts +1 -1
- package/dist/cli.js +1 -1
- package/dist/commands/add-api.js +2 -2
- package/dist/commands/add-domain.js +4 -4
- package/dist/commands/add-fixture-factory.js +5 -6
- package/dist/commands/build-catalog.d.ts +6 -22
- package/dist/commands/build-catalog.js +7 -18
- package/dist/commands/build.js +2 -1
- package/dist/commands/dev.js +1 -1
- package/dist/commands/doctor.js +66 -37
- package/dist/commands/explain.js +13 -13
- package/dist/commands/generate-openapi.d.ts +10 -1
- package/dist/commands/generate-openapi.js +19 -33
- package/dist/commands/show.d.ts +2 -3
- package/dist/commands/show.js +0 -2
- package/dist/commands/test.js +0 -1
- package/dist/commands/upgrade-backend.js +3 -3
- package/dist/commands/validate.js +12 -90
- package/dist/server/api-server.d.ts +1 -1
- package/dist/server/mount-routes.d.ts +11 -2
- package/dist/server/mount-routes.js +20 -8
- package/dist/templates/api-skeleton.d.ts +5 -0
- package/dist/templates/api-skeleton.js +28 -27
- package/dist/templates/claude-md.js +1 -1
- package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
- package/dist/templates/patterns/api/create-with-event.js +38 -32
- package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
- package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
- package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
- package/dist/templates/patterns/api/paginated-list.js +30 -24
- package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
- package/dist/templates/patterns/api/simple-crud.js +46 -35
- package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
- package/dist/templates/patterns/api/streaming-list.js +46 -41
- package/dist/templates/patterns/api/system-admin.d.ts +4 -0
- package/dist/templates/patterns/api/system-admin.js +59 -52
- package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
- package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
- package/dist/types.d.ts +100 -0
- package/dist/types.js +1 -0
- package/dist/utils/file-helpers.d.ts +0 -2
- package/dist/utils/file-helpers.js +2 -3
- package/dist/utils/scaffold-config.d.ts +6 -0
- package/dist/utils/scaffold-config.js +2 -0
- package/package.json +1 -1
- package/src/__tests__/build-registry.test.ts +43 -20
- package/src/__tests__/build-types.test.ts +4 -7
- package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
- package/src/__tests__/commands/add-api.test.ts +12 -10
- package/src/__tests__/commands/add-domain.test.ts +8 -5
- package/src/__tests__/commands/create-project.test.ts +5 -5
- package/src/__tests__/commands/dev.test.ts +0 -1
- package/src/__tests__/mount-routes.test.ts +64 -23
- package/src/__tests__/package-freshness.test.ts +1 -21
- package/src/__tests__/smoke/scaffold.test.ts +13 -15
- package/src/__tests__/validate.test.ts +21 -103
- package/src/builder/build-registry.ts +7 -44
- package/src/builder/build-types.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/add-api.ts +2 -2
- package/src/commands/add-domain.ts +4 -4
- package/src/commands/add-fixture-factory.ts +5 -6
- package/src/commands/build-catalog.ts +13 -35
- package/src/commands/build.ts +3 -2
- package/src/commands/dev.ts +1 -1
- package/src/commands/doctor.ts +68 -37
- package/src/commands/explain.ts +13 -13
- package/src/commands/generate-openapi.ts +30 -52
- package/src/commands/show.ts +2 -5
- package/src/commands/test.ts +0 -1
- package/src/commands/upgrade-backend.ts +3 -3
- package/src/commands/validate.ts +11 -96
- package/src/server/api-server.ts +1 -1
- package/src/server/mount-routes.ts +21 -10
- package/src/templates/api-skeleton.ts +29 -28
- package/src/templates/claude-md.ts +1 -1
- package/src/templates/patterns/api/create-with-event.ts +39 -33
- package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
- package/src/templates/patterns/api/paginated-list.ts +31 -25
- package/src/templates/patterns/api/simple-crud.ts +47 -36
- package/src/templates/patterns/api/streaming-list.ts +47 -42
- package/src/templates/patterns/api/system-admin.ts +60 -53
- package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
- package/src/types.ts +128 -0
- package/src/utils/file-helpers.ts +2 -5
- package/src/utils/scaffold-config.ts +9 -0
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Webhook-receiver-style API pattern template.
|
|
3
3
|
* Produces: POST with signature verification, provider enum.
|
|
4
|
+
*
|
|
5
|
+
* Action-first: HTTP endpoints are declared with `defineAction` +
|
|
6
|
+
* `exposure: { type: 'api', ... }`. The legacy `defineApi` factory was
|
|
7
|
+
* removed in #4689.
|
|
4
8
|
*/
|
|
5
9
|
function camelCase(s) {
|
|
6
10
|
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
7
11
|
}
|
|
8
12
|
export function webhookReceiverStyleTemplate(domain, id, tenancy) {
|
|
9
13
|
const varName = camelCase(id);
|
|
14
|
+
// Webhook receivers are typically public; pair `auth: 'none'` with a
|
|
15
|
+
// securityException.reason so the validator does not flag the route.
|
|
16
|
+
const securityException = `\n securityException: { reason: 'webhook receiver; signature verification handled by authorizer' },`;
|
|
10
17
|
return `import { z } from 'zod';
|
|
11
|
-
import {
|
|
12
|
-
import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
|
|
18
|
+
import { defineAction, initOtel } from '@mettlecast/domain-runtime';
|
|
13
19
|
import type { Result, AppError } from '@mettlecast/domain-runtime';
|
|
14
20
|
import { ok, err, notFound } from '@mettlecast/domain-runtime';
|
|
15
21
|
|
|
@@ -47,46 +53,48 @@ const ${varName}Output = z.object({
|
|
|
47
53
|
provider: 'stripe',
|
|
48
54
|
});
|
|
49
55
|
|
|
50
|
-
// ──
|
|
56
|
+
// ── Action definition (API exposure) ─────────────────────────
|
|
51
57
|
|
|
52
|
-
export const ${varName} =
|
|
58
|
+
export const ${varName} = defineAction({
|
|
53
59
|
id: '${id}',
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
backendAccess: 'domain',
|
|
61
|
+
exposure: {
|
|
62
|
+
type: 'api',
|
|
63
|
+
path: '/webhooks/${domain}/${id}',
|
|
64
|
+
method: 'POST',
|
|
65
|
+
auth: 'none',${securityException}
|
|
66
|
+
tenancy: '${tenancy}',
|
|
67
|
+
},
|
|
56
68
|
outboundAccess: 'internal',
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// Signature verification belongs in a Lambda authorizer (x-webhook-signature header
|
|
64
|
-
// is not accessible here). Wire an HttpLambdaAuthorizer in DomainStack for production.
|
|
69
|
+
idempotent: false,
|
|
70
|
+
input: ${varName}Input,
|
|
71
|
+
output: ${varName}Output,
|
|
72
|
+
handler: async (input, ctx) => {
|
|
73
|
+
// Signature verification belongs in a Lambda authorizer (x-webhook-signature header
|
|
74
|
+
// is not accessible here). Wire an HttpLambdaAuthorizer in DomainStack for production.
|
|
65
75
|
|
|
66
|
-
|
|
76
|
+
const eventId = crypto.randomUUID();
|
|
67
77
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
// ── Publish event for downstream processing ───────────
|
|
79
|
+
await ctx.publish('${domain}.webhook.received', {
|
|
80
|
+
eventId,
|
|
81
|
+
provider: input.provider,
|
|
82
|
+
eventType: input.eventType,
|
|
83
|
+
payload: input.payload,
|
|
84
|
+
tenantId: ctx.tenant.id,
|
|
85
|
+
}, 1);
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
await ctx.audit.log('${id}.webhook.received', ctx.tenant.id, {
|
|
88
|
+
provider: input.provider,
|
|
89
|
+
eventType: input.eventType,
|
|
90
|
+
eventId,
|
|
91
|
+
});
|
|
82
92
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
},
|
|
89
|
-
},
|
|
93
|
+
return {
|
|
94
|
+
received: true,
|
|
95
|
+
eventId,
|
|
96
|
+
provider: input.provider,
|
|
97
|
+
};
|
|
90
98
|
},
|
|
91
99
|
});
|
|
92
100
|
`;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/domain-cli/src/types.ts
|
|
3
|
+
*
|
|
4
|
+
* Local re-declaration of registry types used by the CLI's
|
|
5
|
+
* build/dev/mount-routes pipeline.
|
|
6
|
+
*
|
|
7
|
+
* Issue #4689 removed the legacy `defineApi` primitive and the
|
|
8
|
+
* `registry.apis` field in favour of the action-first model (#4619).
|
|
9
|
+
* The CLI's own source defines the action-first shape here so the
|
|
10
|
+
* pipeline can compile even when the published
|
|
11
|
+
* `@mettlecast/domain-cdk-packer` on npm still carries the legacy
|
|
12
|
+
* shape (i.e. `DomainRegistry` still requires `apis`, and
|
|
13
|
+
* `ActionRegistryEntry` lacks the `exposure` field).
|
|
14
|
+
*
|
|
15
|
+
* Once the npm-published packer catches up, the two local types below
|
|
16
|
+
* can collapse back to a single re-export from
|
|
17
|
+
* `@mettlecast/domain-cdk-packer`.
|
|
18
|
+
*/
|
|
19
|
+
import type { DomainRegistry as PackerDomainRegistry, ActionRegistryEntry as PackerActionRegistryEntry, SchemaSnapshot, WebhookRegistryEntry, SubscriberRegistryEntry, ScheduleRegistryEntry, JobRegistryEntry, IntegrationRegistryEntry, EventRegistryEntry, DomainRegistryEntry, SerialDeploymentConfig, BaseRegistryEntry } from '@mettlecast/domain-cdk-packer';
|
|
20
|
+
export type { SchemaSnapshot, WebhookRegistryEntry, SubscriberRegistryEntry, ScheduleRegistryEntry, JobRegistryEntry, IntegrationRegistryEntry, EventRegistryEntry, DomainRegistryEntry, SerialDeploymentConfig, BaseRegistryEntry, };
|
|
21
|
+
/**
|
|
22
|
+
* Backend invocation permission for an action. Re-declared locally
|
|
23
|
+
* because the legacy published packer does not export this union yet
|
|
24
|
+
* (it was introduced as part of the action-first migration #4619).
|
|
25
|
+
*/
|
|
26
|
+
export type ActionBackendAccess = 'private' | 'domain' | 'platform';
|
|
27
|
+
/**
|
|
28
|
+
* API-exposure metadata for an action whose `exposure.type === 'api'`.
|
|
29
|
+
* Carries the route, method, auth, tenancy, and any documented security
|
|
30
|
+
* exception that downstream CDK/contract generation needs to wire the
|
|
31
|
+
* route safely. Mirrors the canonical `ActionApiExposure` shape from
|
|
32
|
+
* `@mettlecast/domain-cdk-packer`.
|
|
33
|
+
*/
|
|
34
|
+
export interface ActionApiExposure {
|
|
35
|
+
/** Discriminant. */
|
|
36
|
+
type: 'api';
|
|
37
|
+
/** HTTP route path, e.g. '/v1/tenants/{tenantId}/billing/invoices'. */
|
|
38
|
+
path: string;
|
|
39
|
+
/** HTTP method. One of: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. */
|
|
40
|
+
method: string;
|
|
41
|
+
/** Authentication requirement for the route. */
|
|
42
|
+
auth: 'required' | 'none' | 'service';
|
|
43
|
+
/** Tenancy requirement for the route. */
|
|
44
|
+
tenancy: 'required' | 'none' | 'system';
|
|
45
|
+
/** Required role(s) for `auth: 'required'` routes (system tenancy requires this). */
|
|
46
|
+
roles?: string[];
|
|
47
|
+
/** Documented exception when auth or tenancy is intentionally not required. */
|
|
48
|
+
securityException?: {
|
|
49
|
+
reason: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* True if the source code explicitly declared `auth`. False if the
|
|
53
|
+
* builder fell back to the safe default (`'required'`). Validation-only.
|
|
54
|
+
*/
|
|
55
|
+
authDeclared?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* True if the source code explicitly declared `tenancy`. False if the
|
|
58
|
+
* builder fell back to the safe default (`'required'`). Validation-only.
|
|
59
|
+
*/
|
|
60
|
+
tenancyDeclared?: boolean;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Discriminated union for an action's external exposure shape.
|
|
64
|
+
* `internal` actions are reachable only through `ctx.actions`. `api`
|
|
65
|
+
* actions are exposed via API Gateway and carry the route + auth
|
|
66
|
+
* metadata needed for safe route generation.
|
|
67
|
+
*/
|
|
68
|
+
export type ActionExposure = ActionApiExposure | {
|
|
69
|
+
type: 'internal';
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Local `DomainRegistry` for the CLI. Drops the legacy `apis` slot
|
|
73
|
+
* that was removed in issue #4689's action-first migration, and pins
|
|
74
|
+
* the `actions` array to the local `ActionRegistryEntry` shape (which
|
|
75
|
+
* carries the action-first `exposure` field). The CLI builds an object
|
|
76
|
+
* literal that satisfies this type and then serialises it to
|
|
77
|
+
* `.mc/<domain>-registry.json`; downstream consumers (CDK packer,
|
|
78
|
+
* OpenAPI generator, dev server) only ever see the JSON.
|
|
79
|
+
*/
|
|
80
|
+
export type DomainRegistry = Omit<PackerDomainRegistry, 'apis' | 'actions'> & {
|
|
81
|
+
/** All callable actions (local shape with action-first `exposure`). */
|
|
82
|
+
actions: ActionRegistryEntry[];
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Local `ActionRegistryEntry` for the CLI. Augments the packer's shape
|
|
86
|
+
* with the `exposure` field and its `exposureDeclared` flag from the
|
|
87
|
+
* action-first migration (#4619) so the build/dev/mount-routes
|
|
88
|
+
* pipeline can read these properties regardless of which packer
|
|
89
|
+
* version is linked. The `backendAccess` field is also surfaced here
|
|
90
|
+
* (the CLI is the canonical writer of this field) even when the
|
|
91
|
+
* linked packer only exposes the legacy `visibility` alias.
|
|
92
|
+
*/
|
|
93
|
+
export type ActionRegistryEntry = PackerActionRegistryEntry & {
|
|
94
|
+
/** External exposure shape for the action (see ActionExposure). */
|
|
95
|
+
exposure: ActionExposure;
|
|
96
|
+
/** True if the source code explicitly declared `exposure`. */
|
|
97
|
+
exposureDeclared?: boolean;
|
|
98
|
+
/** Backend invocation permission for the action. */
|
|
99
|
+
backendAccess?: ActionBackendAccess;
|
|
100
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -7,8 +7,6 @@
|
|
|
7
7
|
export interface DomainFilePaths {
|
|
8
8
|
/** Path to domain.config.ts if present. */
|
|
9
9
|
domain: string | undefined;
|
|
10
|
-
/** API handler file paths under api/. */
|
|
11
|
-
apis: string[];
|
|
12
10
|
/** Webhook handler file paths under webhooks/. */
|
|
13
11
|
webhooks: string[];
|
|
14
12
|
/** Subscriber handler file paths under subscribers/. */
|
|
@@ -44,9 +44,8 @@ export async function walkDomainDir(domainRoot) {
|
|
|
44
44
|
return undefined;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
const [domain,
|
|
47
|
+
const [domain, webhooks, subscribers, actions, schedules, jobs, integrations, publishes] = await Promise.all([
|
|
48
48
|
singleTs(join(domainRoot, 'domain.config.ts')),
|
|
49
|
-
tsFiles(join(domainRoot, 'api')),
|
|
50
49
|
tsFiles(join(domainRoot, 'webhooks')),
|
|
51
50
|
tsFiles(join(domainRoot, 'subscribers')),
|
|
52
51
|
tsFiles(join(domainRoot, 'actions')),
|
|
@@ -55,5 +54,5 @@ export async function walkDomainDir(domainRoot) {
|
|
|
55
54
|
tsFiles(join(domainRoot, 'integrations')),
|
|
56
55
|
singleTs(join(domainRoot, 'publishes', 'events.ts')),
|
|
57
56
|
]);
|
|
58
|
-
return { domain,
|
|
57
|
+
return { domain, webhooks, subscribers, actions, schedules, jobs, integrations, publishes };
|
|
59
58
|
}
|
|
@@ -29,10 +29,14 @@ export interface CostOptions {
|
|
|
29
29
|
xRaySamplingRate?: number;
|
|
30
30
|
reservedConcurrencyPerDomain?: Record<string, number>;
|
|
31
31
|
}
|
|
32
|
+
/** NAT instance type options for cost control. */
|
|
33
|
+
export type NatInstanceType = 't4g.nano' | 't4g.micro' | 't4g.small';
|
|
32
34
|
/** Monitoring / observability feature opt-ins. */
|
|
33
35
|
export interface MonitoringOptions {
|
|
34
36
|
/** Replace basic MonitoringStack with full ObservabilityStack (Grafana + Cost Explorer + CloudWatch). ~$9–18/month */
|
|
35
37
|
enhanced: boolean;
|
|
38
|
+
/** Disable auto-generated per-domain CloudWatch dashboards. Removes existing dashboards on next deploy. */
|
|
39
|
+
disableCloudWatchDashboards?: boolean;
|
|
36
40
|
}
|
|
37
41
|
/** Lifecycle tunables — Phase B. Schema only in Phase A. */
|
|
38
42
|
export interface LifecycleOptions {
|
|
@@ -92,6 +96,8 @@ export interface ScaffoldConfig {
|
|
|
92
96
|
environments?: EnvironmentsConfig;
|
|
93
97
|
/** When true, CORS responses include Allow-Credentials: true (requires non-wildcard origins). */
|
|
94
98
|
allowCredentials?: boolean;
|
|
99
|
+
/** NAT instance type for the shared NAT instance. Defaults to the smallest: t4g.nano. */
|
|
100
|
+
natInstanceType?: NatInstanceType;
|
|
95
101
|
}
|
|
96
102
|
export declare function readScaffoldConfig(projectRoot: string): Promise<ScaffoldConfig>;
|
|
97
103
|
export declare function writeScaffoldConfig(projectRoot: string, config: ScaffoldConfig): Promise<void>;
|
|
@@ -18,7 +18,9 @@ const DEFAULT_SCAFFOLD_CONFIG = {
|
|
|
18
18
|
logRetentionDays: 30,
|
|
19
19
|
monitoringOptions: {
|
|
20
20
|
enhanced: false,
|
|
21
|
+
disableCloudWatchDashboards: false,
|
|
21
22
|
},
|
|
23
|
+
natInstanceType: 't4g.nano',
|
|
22
24
|
};
|
|
23
25
|
const CONFIG_PATH = '.mc/scaffold-config.json';
|
|
24
26
|
export async function readScaffoldConfig(projectRoot) {
|
package/package.json
CHANGED
|
@@ -24,7 +24,6 @@ describe('buildRegistry', () => {
|
|
|
24
24
|
it('throws if domain.config.ts is missing', async () => {
|
|
25
25
|
mockWalkDomainDir.mockResolvedValue({
|
|
26
26
|
domain: undefined,
|
|
27
|
-
apis: [],
|
|
28
27
|
webhooks: [],
|
|
29
28
|
subscribers: [],
|
|
30
29
|
actions: [],
|
|
@@ -40,7 +39,6 @@ describe('buildRegistry', () => {
|
|
|
40
39
|
it('throws if domain.config.ts has no domain export', async () => {
|
|
41
40
|
mockWalkDomainDir.mockResolvedValue({
|
|
42
41
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
43
|
-
apis: [],
|
|
44
42
|
webhooks: [],
|
|
45
43
|
subscribers: [],
|
|
46
44
|
actions: [],
|
|
@@ -54,13 +52,15 @@ describe('buildRegistry', () => {
|
|
|
54
52
|
await expect(buildRegistry(DOMAIN_ROOT)).rejects.toThrow("no 'domain' export found");
|
|
55
53
|
});
|
|
56
54
|
|
|
57
|
-
it('builds a registry with domain + one
|
|
55
|
+
it('builds a registry with domain + one API-exposed action', async () => {
|
|
56
|
+
// Issue #4689: defineApi was removed; the canonical HTTP endpoint
|
|
57
|
+
// surface is `defineAction({ exposure: { type: 'api', ... } })`.
|
|
58
|
+
// The action is recorded under `registry.actions` with API exposure metadata.
|
|
58
59
|
mockWalkDomainDir.mockResolvedValue({
|
|
59
60
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
60
|
-
apis: ['/fake/domains/billing/api/get-invoice.ts'],
|
|
61
61
|
webhooks: [],
|
|
62
62
|
subscribers: [],
|
|
63
|
-
actions: [],
|
|
63
|
+
actions: ['/fake/domains/billing/actions/get-invoice.ts'],
|
|
64
64
|
schedules: [],
|
|
65
65
|
jobs: [],
|
|
66
66
|
integrations: [],
|
|
@@ -72,7 +72,20 @@ describe('buildRegistry', () => {
|
|
|
72
72
|
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
73
73
|
])
|
|
74
74
|
.mockResolvedValueOnce([
|
|
75
|
-
{
|
|
75
|
+
{
|
|
76
|
+
_kind: 'action',
|
|
77
|
+
_exportName: 'getInvoice',
|
|
78
|
+
id: 'get-invoice',
|
|
79
|
+
backendAccess: 'domain',
|
|
80
|
+
exposure: {
|
|
81
|
+
type: 'api',
|
|
82
|
+
path: '/v1/tenants/{tenantId}/billing/invoices/{id}',
|
|
83
|
+
method: 'GET',
|
|
84
|
+
auth: 'required',
|
|
85
|
+
tenancy: 'required',
|
|
86
|
+
},
|
|
87
|
+
idempotent: true,
|
|
88
|
+
},
|
|
76
89
|
]);
|
|
77
90
|
|
|
78
91
|
const { registry, warnings } = await buildRegistry(DOMAIN_ROOT);
|
|
@@ -80,21 +93,18 @@ describe('buildRegistry', () => {
|
|
|
80
93
|
expect(warnings).toHaveLength(0);
|
|
81
94
|
expect(registry.schemaVersion).toBe('1');
|
|
82
95
|
expect(registry.domain.id).toBe('billing');
|
|
83
|
-
expect(registry.
|
|
84
|
-
expect(registry.
|
|
85
|
-
expect(registry.
|
|
86
|
-
expect(registry.
|
|
87
|
-
expect(registry.apis[0]!.handlerFile.replaceAll('\\', '/')).toBe('api/get-invoice.ts');
|
|
88
|
-
expect(registry.apis[0]!.outboundAccess).toBe('internal');
|
|
96
|
+
expect(registry.actions).toHaveLength(1);
|
|
97
|
+
expect(registry.actions[0]!.id).toBe('get-invoice');
|
|
98
|
+
expect(registry.actions[0]!.backendAccess).toBe('domain');
|
|
99
|
+
expect(registry.actions[0]!.exposure.type).toBe('api');
|
|
89
100
|
});
|
|
90
101
|
|
|
91
|
-
it('preserves explicit outboundAccess on
|
|
102
|
+
it('preserves explicit outboundAccess on actions', async () => {
|
|
92
103
|
mockWalkDomainDir.mockResolvedValue({
|
|
93
104
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
94
|
-
apis: ['/fake/domains/billing/api/sync-invoice.ts'],
|
|
95
105
|
webhooks: [],
|
|
96
106
|
subscribers: [],
|
|
97
|
-
actions: [],
|
|
107
|
+
actions: ['/fake/domains/billing/actions/sync-invoice.ts'],
|
|
98
108
|
schedules: [],
|
|
99
109
|
jobs: [],
|
|
100
110
|
integrations: [],
|
|
@@ -106,21 +116,34 @@ describe('buildRegistry', () => {
|
|
|
106
116
|
{ _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
|
|
107
117
|
])
|
|
108
118
|
.mockResolvedValueOnce([
|
|
109
|
-
{
|
|
119
|
+
{
|
|
120
|
+
_kind: 'action',
|
|
121
|
+
_exportName: 'syncInvoice',
|
|
122
|
+
id: 'sync-invoice',
|
|
123
|
+
backendAccess: 'domain',
|
|
124
|
+
exposure: {
|
|
125
|
+
type: 'api',
|
|
126
|
+
path: '/v1/tenants/{tenantId}/billing/invoices/sync',
|
|
127
|
+
method: 'POST',
|
|
128
|
+
auth: 'required',
|
|
129
|
+
tenancy: 'required',
|
|
130
|
+
},
|
|
131
|
+
outboundAccess: 'internet',
|
|
132
|
+
idempotent: false,
|
|
133
|
+
},
|
|
110
134
|
]);
|
|
111
135
|
|
|
112
136
|
const { registry } = await buildRegistry(DOMAIN_ROOT);
|
|
113
137
|
|
|
114
|
-
expect(registry.
|
|
138
|
+
expect(registry.actions[0]!.outboundAccess).toBe('internet');
|
|
115
139
|
});
|
|
116
140
|
|
|
117
141
|
it('collects warnings when a file fails to load', async () => {
|
|
118
142
|
mockWalkDomainDir.mockResolvedValue({
|
|
119
143
|
domain: '/fake/domains/billing/domain.config.ts',
|
|
120
|
-
apis: ['/fake/domains/billing/api/broken.ts'],
|
|
121
144
|
webhooks: [],
|
|
122
145
|
subscribers: [],
|
|
123
|
-
actions: [],
|
|
146
|
+
actions: ['/fake/domains/billing/actions/broken.ts'],
|
|
124
147
|
schedules: [],
|
|
125
148
|
jobs: [],
|
|
126
149
|
integrations: [],
|
|
@@ -137,6 +160,6 @@ describe('buildRegistry', () => {
|
|
|
137
160
|
|
|
138
161
|
expect(warnings).toHaveLength(1);
|
|
139
162
|
expect(warnings[0]).toContain('tsx error');
|
|
140
|
-
expect(registry.
|
|
163
|
+
expect(registry.actions).toHaveLength(0);
|
|
141
164
|
});
|
|
142
165
|
});
|
|
@@ -8,7 +8,6 @@ describe('buildActionsTypes', () => {
|
|
|
8
8
|
schemaVersion: '1',
|
|
9
9
|
domainRoot: '/test/auth',
|
|
10
10
|
domain: { id: 'auth', kind: 'domain', name: 'Auth', tenancy: 'required' },
|
|
11
|
-
apis: [],
|
|
12
11
|
webhooks: [],
|
|
13
12
|
subscribers: [],
|
|
14
13
|
schedules: [],
|
|
@@ -18,7 +17,8 @@ describe('buildActionsTypes', () => {
|
|
|
18
17
|
id: 'verify-token',
|
|
19
18
|
kind: 'action',
|
|
20
19
|
handlerFile: 'verify-token.ts',
|
|
21
|
-
|
|
20
|
+
backendAccess: 'domain',
|
|
21
|
+
exposure: { type: 'internal' },
|
|
22
22
|
idempotent: false,
|
|
23
23
|
},
|
|
24
24
|
],
|
|
@@ -39,13 +39,12 @@ describe('buildActionsTypes', () => {
|
|
|
39
39
|
schemaVersion: '1',
|
|
40
40
|
domainRoot: '/test/auth',
|
|
41
41
|
domain: { id: 'auth', kind: 'domain', name: 'Auth', tenancy: 'required' },
|
|
42
|
-
apis: [],
|
|
43
42
|
webhooks: [],
|
|
44
43
|
subscribers: [],
|
|
45
44
|
schedules: [],
|
|
46
45
|
jobs: [],
|
|
47
46
|
actions: [
|
|
48
|
-
{ id: 'verify', kind: 'action', handlerFile: 'verify.ts',
|
|
47
|
+
{ id: 'verify', kind: 'action', handlerFile: 'verify.ts', backendAccess: 'platform', exposure: { type: 'internal' }, idempotent: true },
|
|
49
48
|
],
|
|
50
49
|
integrations: [],
|
|
51
50
|
events: [],
|
|
@@ -55,13 +54,12 @@ describe('buildActionsTypes', () => {
|
|
|
55
54
|
schemaVersion: '1',
|
|
56
55
|
domainRoot: '/test/payments',
|
|
57
56
|
domain: { id: 'payments', kind: 'domain', name: 'Payments', tenancy: 'required' },
|
|
58
|
-
apis: [],
|
|
59
57
|
webhooks: [],
|
|
60
58
|
subscribers: [],
|
|
61
59
|
schedules: [],
|
|
62
60
|
jobs: [],
|
|
63
61
|
actions: [
|
|
64
|
-
{ id: 'charge', kind: 'action', handlerFile: 'charge.ts',
|
|
62
|
+
{ id: 'charge', kind: 'action', handlerFile: 'charge.ts', backendAccess: 'domain', exposure: { type: 'internal' }, idempotent: false },
|
|
65
63
|
],
|
|
66
64
|
integrations: [],
|
|
67
65
|
events: [],
|
|
@@ -78,7 +76,6 @@ describe('buildActionsTypes', () => {
|
|
|
78
76
|
schemaVersion: '1',
|
|
79
77
|
domainRoot: '/test',
|
|
80
78
|
domain: { id: 'test', kind: 'domain', name: 'Test', tenancy: 'none' },
|
|
81
|
-
apis: [],
|
|
82
79
|
webhooks: [],
|
|
83
80
|
subscribers: [],
|
|
84
81
|
schedules: [],
|
|
@@ -19,59 +19,59 @@ describe('walkDomainDir recursive walk', () => {
|
|
|
19
19
|
}
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
it('should find .ts files at root of
|
|
23
|
-
const
|
|
24
|
-
await mkdir(
|
|
25
|
-
await writeFile(join(
|
|
22
|
+
it('should find .ts files at root of actions directory', async () => {
|
|
23
|
+
const actionDir = join(tmpDir, 'actions');
|
|
24
|
+
await mkdir(actionDir, { recursive: true });
|
|
25
|
+
await writeFile(join(actionDir, 'simple.ts'), 'export const action = {}');
|
|
26
26
|
|
|
27
27
|
const paths = await walkDomainDir(tmpDir);
|
|
28
28
|
|
|
29
|
-
expect(paths.
|
|
29
|
+
expect(paths.actions).toContain(join(actionDir, 'simple.ts'));
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
it('should recursively find .ts files in nested
|
|
33
|
-
const nestedDir = join(tmpDir, '
|
|
32
|
+
it('should recursively find .ts files in nested action directories', async () => {
|
|
33
|
+
const nestedDir = join(tmpDir, 'actions', 'users', 'v1');
|
|
34
34
|
await mkdir(nestedDir, { recursive: true });
|
|
35
|
-
await writeFile(join(nestedDir, 'get.ts'), 'export const
|
|
35
|
+
await writeFile(join(nestedDir, 'get.ts'), 'export const action = {}');
|
|
36
36
|
|
|
37
37
|
const paths = await walkDomainDir(tmpDir);
|
|
38
38
|
|
|
39
|
-
expect(paths.
|
|
39
|
+
expect(paths.actions).toContain(join(nestedDir, 'get.ts'));
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
it('should skip __tests__ directories', async () => {
|
|
43
|
-
const testDir = join(tmpDir, '
|
|
43
|
+
const testDir = join(tmpDir, 'actions', '__tests__');
|
|
44
44
|
await mkdir(testDir, { recursive: true });
|
|
45
45
|
await writeFile(join(testDir, 'test.ts'), 'export const test = {}');
|
|
46
46
|
|
|
47
47
|
const paths = await walkDomainDir(tmpDir);
|
|
48
48
|
|
|
49
|
-
expect(paths.
|
|
49
|
+
expect(paths.actions).not.toContain(join(testDir, 'test.ts'));
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
it('should skip dist directories', async () => {
|
|
53
|
-
const distDir = join(tmpDir, '
|
|
53
|
+
const distDir = join(tmpDir, 'actions', 'dist');
|
|
54
54
|
await mkdir(distDir, { recursive: true });
|
|
55
55
|
await writeFile(join(distDir, 'bundle.ts'), 'export const bundle = {}');
|
|
56
56
|
|
|
57
57
|
const paths = await walkDomainDir(tmpDir);
|
|
58
58
|
|
|
59
|
-
expect(paths.
|
|
59
|
+
expect(paths.actions).not.toContain(join(distDir, 'bundle.ts'));
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it('should skip node_modules directories', async () => {
|
|
63
|
-
const nodeModulesDir = join(tmpDir, '
|
|
63
|
+
const nodeModulesDir = join(tmpDir, 'actions', 'node_modules');
|
|
64
64
|
await mkdir(nodeModulesDir, { recursive: true });
|
|
65
65
|
await writeFile(join(nodeModulesDir, 'dep.ts'), 'export const dep = {}');
|
|
66
66
|
|
|
67
67
|
const paths = await walkDomainDir(tmpDir);
|
|
68
68
|
|
|
69
|
-
expect(paths.
|
|
69
|
+
expect(paths.actions).not.toContain(join(nodeModulesDir, 'dep.ts'));
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it('should discover files in all domain primitive directories', async () => {
|
|
73
73
|
// Create files in all conventional directories
|
|
74
|
-
const dirs = ['
|
|
74
|
+
const dirs = ['webhooks', 'subscribers', 'actions', 'schedules', 'jobs', 'integrations'];
|
|
75
75
|
|
|
76
76
|
for (const dir of dirs) {
|
|
77
77
|
const dirPath = join(tmpDir, dir, 'nested', 'deep');
|
|
@@ -82,7 +82,6 @@ describe('walkDomainDir recursive walk', () => {
|
|
|
82
82
|
const paths = await walkDomainDir(tmpDir);
|
|
83
83
|
|
|
84
84
|
// Verify files are found in correct slots
|
|
85
|
-
expect(paths.apis.some(p => p.includes('api-handler.ts'))).toBe(true);
|
|
86
85
|
expect(paths.webhooks.some(p => p.includes('webhooks-handler.ts'))).toBe(true);
|
|
87
86
|
expect(paths.subscribers.some(p => p.includes('subscribers-handler.ts'))).toBe(true);
|
|
88
87
|
expect(paths.actions.some(p => p.includes('actions-handler.ts'))).toBe(true);
|
|
@@ -112,7 +111,6 @@ describe('walkDomainDir recursive walk', () => {
|
|
|
112
111
|
it('should return empty arrays for missing directories', async () => {
|
|
113
112
|
const paths = await walkDomainDir(tmpDir);
|
|
114
113
|
|
|
115
|
-
expect(paths.apis).toEqual([]);
|
|
116
114
|
expect(paths.webhooks).toEqual([]);
|
|
117
115
|
expect(paths.subscribers).toEqual([]);
|
|
118
116
|
expect(paths.actions).toEqual([]);
|
|
@@ -129,12 +127,12 @@ describe('walkDomainDir recursive walk', () => {
|
|
|
129
127
|
});
|
|
130
128
|
|
|
131
129
|
it('should handle deeply nested files', async () => {
|
|
132
|
-
const deepPath = join(tmpDir, '
|
|
130
|
+
const deepPath = join(tmpDir, 'actions', 'v1', 'users', 'admin', 'permissions');
|
|
133
131
|
await mkdir(deepPath, { recursive: true });
|
|
134
|
-
await writeFile(join(deepPath, 'check.ts'), 'export const
|
|
132
|
+
await writeFile(join(deepPath, 'check.ts'), 'export const action = {}');
|
|
135
133
|
|
|
136
134
|
const paths = await walkDomainDir(tmpDir);
|
|
137
135
|
|
|
138
|
-
expect(paths.
|
|
136
|
+
expect(paths.actions.some(p => p.includes('check.ts'))).toBe(true);
|
|
139
137
|
});
|
|
140
138
|
});
|
|
@@ -11,7 +11,7 @@ describe('add-api command', () => {
|
|
|
11
11
|
beforeEach(async () => {
|
|
12
12
|
tmpDir = await mkdtemp(join(tmpdir(), 'tib-add-api-test-'));
|
|
13
13
|
domainDir = join(tmpDir, 'demo');
|
|
14
|
-
await mkdir(join(domainDir, '
|
|
14
|
+
await mkdir(join(domainDir, 'actions'), { recursive: true });
|
|
15
15
|
await writeFile(
|
|
16
16
|
join(domainDir, 'domain.config.ts'),
|
|
17
17
|
`export default { id: 'demo', tenancy: 'required' };\n`,
|
|
@@ -34,8 +34,8 @@ describe('add-api command', () => {
|
|
|
34
34
|
domainsDir: tmpDir,
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
const apiFile = join(domainDir, '
|
|
38
|
-
const fixtureFile = join(domainDir, '
|
|
37
|
+
const apiFile = join(domainDir, 'actions', 'ping-thing.ts');
|
|
38
|
+
const fixtureFile = join(domainDir, 'actions', '__tests__', 'ping-thing.fixture.json');
|
|
39
39
|
|
|
40
40
|
const apiContent = await readFile(apiFile, 'utf8');
|
|
41
41
|
const fixtureContent = await readFile(fixtureFile, 'utf8');
|
|
@@ -45,9 +45,11 @@ describe('add-api command', () => {
|
|
|
45
45
|
const parsed = JSON.parse(fixtureContent) as { event: { body: string } };
|
|
46
46
|
expect(parsed.event.body).toBe('{}');
|
|
47
47
|
|
|
48
|
-
// The API file must contain .default() on input
|
|
48
|
+
// The API file must contain .default() on input. Output uses a variable.
|
|
49
49
|
expect(apiContent).toMatch(/input:\s*z\.object\(\{\}\)\.default\(\{\}\)/);
|
|
50
|
-
|
|
50
|
+
// The output schema is assigned to a variable then referenced via `output: outputSchema`.
|
|
51
|
+
expect(apiContent).toMatch(/output:\s*outputSchema/);
|
|
52
|
+
expect(apiContent).toMatch(/const outputSchema = z\.object\(\{\s*ok:\s*z\.boolean\(\)\s*\}\)\.default\(\{\s*ok:\s*true\s*\}\)/);
|
|
51
53
|
});
|
|
52
54
|
|
|
53
55
|
it('scaffolds a simple-crud pattern API with .default() on schemas', async () => {
|
|
@@ -59,8 +61,8 @@ describe('add-api command', () => {
|
|
|
59
61
|
domainsDir: tmpDir,
|
|
60
62
|
});
|
|
61
63
|
|
|
62
|
-
const apiFile = join(domainDir, '
|
|
63
|
-
const fixtureFile = join(domainDir, '
|
|
64
|
+
const apiFile = join(domainDir, 'actions', 'widget.ts');
|
|
65
|
+
const fixtureFile = join(domainDir, 'actions', '__tests__', 'widget.fixture.json');
|
|
64
66
|
|
|
65
67
|
const apiContent = await readFile(apiFile, 'utf8');
|
|
66
68
|
const fixtureContent = await readFile(fixtureFile, 'utf8');
|
|
@@ -83,8 +85,8 @@ describe('add-api command', () => {
|
|
|
83
85
|
domainsDir: tmpDir,
|
|
84
86
|
});
|
|
85
87
|
|
|
86
|
-
const apiFile = join(domainDir, '
|
|
87
|
-
const fixtureFile = join(domainDir, '
|
|
88
|
+
const apiFile = join(domainDir, 'actions', 'order.ts');
|
|
89
|
+
const fixtureFile = join(domainDir, 'actions', '__tests__', 'order.fixture.json');
|
|
88
90
|
|
|
89
91
|
const apiContent = await readFile(apiFile, 'utf8');
|
|
90
92
|
const fixtureContent = await readFile(fixtureFile, 'utf8');
|
|
@@ -106,7 +108,7 @@ describe('add-api command', () => {
|
|
|
106
108
|
domainsDir: tmpDir,
|
|
107
109
|
});
|
|
108
110
|
|
|
109
|
-
const apiFile = join(domainDir, '
|
|
111
|
+
const apiFile = join(domainDir, 'actions', 'console.ts');
|
|
110
112
|
const apiContent = await readFile(apiFile, 'utf8');
|
|
111
113
|
|
|
112
114
|
// First API: input defaults to command: 'status'; output defaults to { ok: true }.
|