@mesh-tech/mesh-cli 0.32.0 → 0.33.0

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.
@@ -8,6 +8,12 @@ import * as pulumi from "@pulumi/pulumi";
8
8
  import * as path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { mesh } from "@mesh-tech/app-kit/infra";
11
+ {{#if events}}
12
+ import { compileOpsHubMetadata } from "@mesh-tech/authz";
13
+ // The authz schema is the single source of truth, shared by the api (enforces
14
+ // it) and this program (compiles it to ops-hub metadata + Zitadel roles).
15
+ import schemaDef from "./schema.js";
16
+ {{/if}}
11
17
 
12
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
19
  const { tenant, platform, stack, deploy } = mesh.apps.getConfig(pulumi);
@@ -16,6 +22,32 @@ const { tenant, platform, stack, deploy } = mesh.apps.getConfig(pulumi);
16
22
  const TASK_QUEUE = "{{name}}";
17
23
  {{/if}}
18
24
 
25
+ {{#if events}}
26
+ // =============================================================================
27
+ // Identity — this app's OWN Zitadel project, BEFORE the environment
28
+ // =============================================================================
29
+ //
30
+ // The events service authorizes bearer callers by this project's roles claim,
31
+ // so the two events roles are project roles here. They are spread rather than
32
+ // written out: `mesh.apps.EVENTS_PROJECT_ROLES` is the vocabulary the service
33
+ // itself reads, and a hand-copied spelling that drifts is a partner refused
34
+ // with no explanation. The primitive cannot add them for you — this identity
35
+ // builds its roles now, before the events service exists.
36
+
37
+ const connector = new mesh.auth.ZitadelConnector("idp", { tenant, env: platform.env });
38
+
39
+ const identity = new mesh.auth.ZitadelAppIdentity("identity", {
40
+ connector,
41
+ tenant,
42
+ env: platform.env,
43
+ project: { name: "{{name}}" },
44
+ roles: [...schemaDef.coarseRoles, ...mesh.apps.EVENTS_PROJECT_ROLES],
45
+ applications: {
46
+ api: { type: "api" },
47
+ },
48
+ });
49
+
50
+ {{/if}}
19
51
  // =============================================================================
20
52
  // Environment
21
53
  // =============================================================================
@@ -28,6 +60,17 @@ const env = new mesh.apps.AppEnvironment("env", {
28
60
  appName: "{{name}}",
29
61
  namespace: true,
30
62
  rootDir: __dirname,
63
+ {{#if events}}
64
+ // Bind the env to the app's OWN project. Without it env.zitadel silently
65
+ // falls back to the shared platform project, whose roles are not this app's.
66
+ zitadelAppProjectId: identity.projectId,
67
+ // The events service's delivery worker is one Temporal workflow per event.
68
+ temporal: {
69
+ codecServer: {
70
+ ingressSubdomain: "codec-{{name}}",
71
+ },
72
+ },
73
+ {{else}}
31
74
  {{#if temporal}}
32
75
  temporal: {
33
76
  codecServer: {
@@ -35,6 +78,7 @@ const env = new mesh.apps.AppEnvironment("env", {
35
78
  },
36
79
  },
37
80
  {{/if}}
81
+ {{/if}}
38
82
  });
39
83
 
40
84
  // =============================================================================
@@ -51,6 +95,41 @@ const uploads = new mesh.apps.Bucket("uploads", {
51
95
  forceDestroy: stack !== "prod",
52
96
  });
53
97
  {{/if}}
98
+ {{#if events}}
99
+
100
+ // The knobs an operator tunes from the Hub's Config tab. The events service's
101
+ // own tunables are spread in rather than declared here, and the primitive
102
+ // deliberately creates no config of its own: app config values are singular
103
+ // per app — one value store, one synced Secret, one published schema — so a
104
+ // second component declaring them would collide with this one.
105
+ const config = new mesh.apps.AppConfig("config", {
106
+ env,
107
+ fields: {
108
+ ...mesh.apps.eventsConfigFields(),
109
+ },
110
+ });
111
+
112
+ // Events and webhooks: the service, its own database, an app-scoped KMS key
113
+ // for endpoint credentials, the publisher key, the Zitadel introspection app,
114
+ // the delivery task queue and the registry surface — all children of this one
115
+ // declaration.
116
+ //
117
+ // A knob written HERE is pinned in code: Kubernetes keeps an explicit `env`
118
+ // entry over the `envFrom` the app values arrive through, so a value in this
119
+ // block wins over whatever an operator saves in the Hub. Leave one out to hand
120
+ // it over; an app value nobody has set arrives empty and the service reads
121
+ // that as "use the default".
122
+ const events = new mesh.apps.EventsService("events", {
123
+ env,
124
+ identity: { connector, projectId: identity.projectId },
125
+ config,
126
+ // "thin" sends identifiers only, so nothing the API would mask reaches a
127
+ // third-party endpoint and the consumer follows the identifiers back to the
128
+ // API, where field policy applies as on any read. Switch to "full" only for
129
+ // an app whose events carry no policy-governed fields.
130
+ payload: "thin",
131
+ });
132
+ {{/if}}
54
133
 
55
134
  // =============================================================================
56
135
  // Services
@@ -76,6 +155,11 @@ const api = new mesh.apps.Service("api", {
76
155
  {{/if}}
77
156
  {{#if temporal}}
78
157
  env.temporal!.link({ environmentPrefix: "TEMPORAL", taskQueue: TASK_QUEUE }),
158
+ {{/if}}
159
+ {{#if events}}
160
+ // EVENTS_SERVICE_URL + EVENTS_API_KEY — the only two values that cross
161
+ // between this process and the events service.
162
+ events.link(),
79
163
  {{/if}}
80
164
  ],
81
165
  ingress: env.buildIngress({ subdomain: "{{name}}" }),
@@ -151,6 +235,20 @@ new mesh.apps.ApiDocs("docs", {
151
235
  // Outputs
152
236
  // =============================================================================
153
237
 
238
+ {{#if events}}
239
+ // =============================================================================
240
+ // Access — LAST. The pointer is what the Hub's App -> Access tab reads; without
241
+ // it the tab is blank and no one can be granted a role, including the two the
242
+ // events service authorizes its subscribers by.
243
+ // =============================================================================
244
+
245
+ new mesh.auth.AppAuthzPointer("authz", {
246
+ env,
247
+ mode: "role-gating",
248
+ opsHubMetadata: compileOpsHubMetadata(schemaDef),
249
+ });
250
+
251
+ {{/if}}
154
252
  export const app = env.register({
155
253
  // The line under this app's name on the Hub's Apps list, read by the
156
254
  // client's own operators more often than by anyone on the team. One
@@ -175,3 +273,9 @@ export const apiUrl = api.url;
175
273
  {{#if temporal}}
176
274
  export const workerTaskQueue = worker.taskQueue;
177
275
  {{/if}}
276
+ {{#if events}}
277
+ /** The events service's base URL — where subscribers register and publishers post. */
278
+ export const eventsUrl = events.url;
279
+ /** This app's own Zitadel project id — grant events:subscriber against it. */
280
+ export const zitadelProjectId = identity.projectId;
281
+ {{/if}}
@@ -9,13 +9,23 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@mesh-tech/app-kit": "{{#if workspaceDeps}}workspace:*{{else}}{{meshRange}}{{/if}}",
12
+ {{#if events}}
13
+ "@mesh-tech/authz": "{{#if workspaceDeps}}workspace:*{{else}}{{authzRange}}{{/if}}",
14
+ "@mesh-tech/events-service": "{{#if workspaceDeps}}workspace:*{{else}}{{eventsServiceRange}}{{/if}}",
15
+ {{/if}}
12
16
  "@mesh-tech/infra-components": "{{#if workspaceDeps}}workspace:*{{else}}{{meshRange}}{{/if}}",
17
+ {{#if events}}
18
+ "@temporalio/worker": "^1.17.0",
19
+ {{/if}}
13
20
  "@pulumi/aws": "6.66.2",
14
21
  "@pulumi/kubernetes": "4.24.1",
15
22
  "@pulumi/pulumi": "3.214.1"
16
23
  },
17
24
  "devDependencies": {
18
25
  "@types/node": "{{#if workspaceDeps}}catalog:{{else}}{{typesNodeRange}}{{/if}}",
26
+ {{#if events}}
27
+ "esbuild": "^0.25.8",
28
+ {{/if}}
19
29
  "tsx": "{{#if workspaceDeps}}catalog:{{else}}{{tsxRange}}{{/if}}",
20
30
  "typescript": "{{#if workspaceDeps}}catalog:{{else}}{{typescriptRange}}{{/if}}"
21
31
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The publisher side of {{titleCase name}}'s events.
3
+ *
4
+ * Two environment variables reach this process through the events service's
5
+ * `link()` — `EVENTS_SERVICE_URL` and `EVENTS_API_KEY` — and nothing else
6
+ * crosses between the app and the events service.
7
+ *
8
+ * Publishing is **fail-open and fire-and-forget**: a publish never fails the
9
+ * operation that emitted the event, and a process whose environment is not
10
+ * wired gets a client that resolves every call to nothing. That is the design,
11
+ * not a shortcut — making it strict would couple every request this app serves
12
+ * to the events service being up. The guarantee it buys is stated plainly:
13
+ * before the service accepts an event, delivery is at-most-once; after it, the
14
+ * workflow id dedupes and the retry ladder guarantees attempts.
15
+ *
16
+ * @example
17
+ * // in a handler, AFTER the write commits
18
+ * void publish("{{name}}.thing.created", { thing_id: id }, `{{name}}:${requestId}`);
19
+ */
20
+
21
+ import { createLogger } from "@mesh-tech/logger";
22
+
23
+ const log = createLogger({ service: "{{name}}-api" });
24
+
25
+ const SERVICE_URL = process.env.EVENTS_SERVICE_URL;
26
+ const API_KEY = process.env.EVENTS_API_KEY;
27
+
28
+ if (!SERVICE_URL || !API_KEY) {
29
+ log.warn(
30
+ "EVENTS_SERVICE_URL or EVENTS_API_KEY is unset — publishes are no-ops in this process",
31
+ );
32
+ }
33
+
34
+ /**
35
+ * The wait before each successive registration attempt, in milliseconds.
36
+ *
37
+ * Registration is idempotent and additive, so it retries forever rather than
38
+ * giving up — the design's contract is that a service can boot before its
39
+ * events service is reachable. The ladder is bounded and then capped, so a
40
+ * long outage settles at one attempt every five minutes instead of hammering.
41
+ */
42
+ const REGISTER_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 60_000, 300_000];
43
+
44
+ /**
45
+ * How long to wait after `attemptsMade` failed attempts.
46
+ *
47
+ * The index trails the attempt COUNT by one, deliberately: after the first
48
+ * attempt fails the wait is the FIRST rung. They are two different counters,
49
+ * and handing one across as the other is what makes a ladder's first rung
50
+ * unreachable while every document describing it still reads correctly.
51
+ *
52
+ * @example
53
+ * registerBackoffMs(1); // => 1000 — the first rung, after the first failure
54
+ * registerBackoffMs(2); // => 2000
55
+ * registerBackoffMs(99); // => 300000 — saturated at the cap
56
+ */
57
+ export function registerBackoffMs(attemptsMade: number): number {
58
+ const index = Math.min(Math.max(attemptsMade - 1, 0), REGISTER_BACKOFF_MS.length - 1);
59
+ return REGISTER_BACKOFF_MS[index]!;
60
+ }
61
+
62
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
63
+
64
+ /** The event catalog this service publishes. Names are `{service}.{resource}.{action}`. */
65
+ export const EVENT_TYPES = {
66
+ thingCreated: "{{name}}.thing.created",
67
+ } as const;
68
+
69
+ /**
70
+ * Register this service's catalog with the events service. Additive and
71
+ * first-registrar-wins: removing a type here never deletes the row, and
72
+ * subscribers keep working across a deploy. Call it once at boot.
73
+ *
74
+ * **This never rejects.** A transient failure — the events pod still starting,
75
+ * a connection refused, a DNS miss — retries in the background on
76
+ * {@link registerBackoffMs} forever, because registration is idempotent. That
77
+ * is not politeness: the caller is `void registerEvents()` at module scope, so
78
+ * a rejection would be unhandled, and Node turns an unhandled rejection into
79
+ * an uncaught exception and exits — this API's pod would CrashLoopBackOff on a
80
+ * condition in a sibling service.
81
+ */
82
+ export async function registerEvents(): Promise<void> {
83
+ if (!SERVICE_URL || !API_KEY) return;
84
+ for (let attemptsMade = 1; ; attemptsMade++) {
85
+ try {
86
+ await attemptRegister();
87
+ if (attemptsMade > 1) log.info({ attemptsMade }, "event catalog registered");
88
+ return;
89
+ } catch (error) {
90
+ const waitMs = registerBackoffMs(attemptsMade);
91
+ // Escalate as the ladder REACHES its cap, not one attempt after it:
92
+ // by here the wait is already the longest this will ever be, and that
93
+ // is the moment an operator wants to hear about.
94
+ const saturated = attemptsMade >= REGISTER_BACKOFF_MS.length;
95
+ const line = { err: error, attemptsMade, retryInMs: waitMs };
96
+ if (saturated) log.error(line, "event catalog registration still failing");
97
+ else log.warn(line, "event catalog registration failed, retrying");
98
+ await sleep(waitMs);
99
+ }
100
+ }
101
+ }
102
+
103
+ /** One registration attempt. Throws; {@link registerEvents} owns the retrying. */
104
+ async function attemptRegister(): Promise<void> {
105
+ await post("/events/registry/register", {
106
+ service: "{{name}}",
107
+ events: [
108
+ {
109
+ name: EVENT_TYPES.thingCreated,
110
+ description: "A thing reached a terminal created state",
111
+ payload_schema: {
112
+ type: "object",
113
+ properties: { thing_id: { type: "string" } },
114
+ required: ["thing_id"],
115
+ // Identifiers the thin envelope carries when a delivery has no body.
116
+ // The keys must be snake_case: they become keys in a snake_case
117
+ // envelope, and registration refuses anything else.
118
+ "x-mesh-resource": { type: "thing", id: "thing_id" },
119
+ },
120
+ },
121
+ ],
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Publish one event, after the write it describes has committed.
127
+ *
128
+ * `idempotencyKey` is what makes a retry safe: the service persists one event
129
+ * row and starts one delivery workflow per key, and a replay answers 409,
130
+ * which is success.
131
+ */
132
+ export async function publish(
133
+ eventType: string,
134
+ payload: Record<string, unknown>,
135
+ idempotencyKey: string,
136
+ ): Promise<void> {
137
+ if (!SERVICE_URL || !API_KEY) return;
138
+ try {
139
+ await post("/events/publish", {
140
+ event_type: eventType,
141
+ source_service: "{{name}}",
142
+ payload,
143
+ idempotency_key: idempotencyKey,
144
+ });
145
+ } catch (error) {
146
+ // A publish never fails the operation it describes.
147
+ log.warn({ err: error, eventType }, "publish failed");
148
+ }
149
+ }
150
+
151
+ async function post(path: string, body: unknown): Promise<void> {
152
+ const response = await fetch(`${SERVICE_URL}${path}`, {
153
+ method: "POST",
154
+ headers: { "content-type": "application/json", "x-api-key": API_KEY! },
155
+ body: JSON.stringify(body),
156
+ });
157
+ // A replay of an already-accepted event is a 409 and is success.
158
+ if (!response.ok && response.status !== 409) {
159
+ throw new Error(`${path} answered ${response.status}`);
160
+ }
161
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Authorization schema — the single source of truth (@mesh-tech/authz).
3
+ *
4
+ * mode "role-gating": enforcement is a coarse-role check on the caller's
5
+ * Zitadel project roles. `coarseRoles` is the whole vocabulary — it becomes
6
+ * this app's Zitadel PROJECT ROLES (declared in `index.ts`), what the Hub's
7
+ * Access tab grants, and what the create-key wizard lists.
8
+ *
9
+ * The events service's own two roles are NOT listed here. They are the
10
+ * platform's vocabulary, not this app's, so `index.ts` spreads
11
+ * `mesh.apps.EVENTS_PROJECT_ROLES` alongside these — the roles end up in the
12
+ * same project either way, and keeping them out of the schema means the app's
13
+ * own enforcement never has to reason about them.
14
+ */
15
+
16
+ import { defineSchema } from "@mesh-tech/authz/schema";
17
+
18
+ export default defineSchema({
19
+ namespace: "{{name}}",
20
+ version: "0.1.0",
21
+ mode: "role-gating",
22
+ coarseRoles: ["admin", "viewer"] as const,
23
+ });
@@ -11,6 +11,9 @@
11
11
  {{/if}}
12
12
  {{#if bucket}}
13
13
  * - UPLOADS_BUCKET, UPLOADS_REGION
14
+ {{/if}}
15
+ {{#if events}}
16
+ * - EVENTS_SERVICE_URL, EVENTS_API_KEY
14
17
  {{/if}}
15
18
  */
16
19
 
@@ -20,6 +23,9 @@ import { createLogger, requestLogger } from "@mesh-tech/logger";
20
23
  {{#if temporal}}
21
24
  import { createTemporalClient } from "@mesh-tech/app-kit/temporal-client";
22
25
  {{/if}}
26
+ {{#if events}}
27
+ import { EVENT_TYPES, publish, registerEvents } from "./events.js";
28
+ {{/if}}
23
29
 
24
30
  const PORT = parseInt(process.env.PORT ?? "3000", 10);
25
31
  // Every service logs through @mesh-tech/logger (the Mesh app contract, gate
@@ -31,6 +37,15 @@ app.use("*", requestLogger(log));
31
37
  {{#if temporal}}
32
38
  const { withClient } = await createTemporalClient();
33
39
  {{/if}}
40
+ {{#if events}}
41
+ // Register this service's catalog at boot. Fire-and-forget by design: the
42
+ // call is idempotent and additive, and a boot that waited on the events
43
+ // service would couple this process's startup to it. `registerEvents` retries
44
+ // in the background and never rejects; the `.catch` is the belt to that
45
+ // braces — an unhandled rejection here would exit the process and turn a
46
+ // sibling service's outage into this one's CrashLoopBackOff.
47
+ void registerEvents().catch((err) => log.error({ err }, "event registration gave up"));
48
+ {{/if}}
34
49
 
35
50
  // Health check
36
51
  app.get("/health", (c) => c.json({ status: "ok" }));
@@ -54,6 +69,18 @@ app.post("/workflow", async (c) => {
54
69
  });
55
70
  {{/if}}
56
71
 
72
+ {{#if events}}
73
+ // Publish AFTER the write commits — never before, and never in its
74
+ // transaction. The idempotency key is what makes a retry of this handler
75
+ // produce one event and one delivery run.
76
+ app.post("/things", async (c) => {
77
+ const body = await c.req.json<{ id: string }>();
78
+ // ...create the thing here...
79
+ void publish(EVENT_TYPES.thingCreated, { thing_id: body.id }, `{{name}}:${body.id}`);
80
+ return c.json({ id: body.id }, 201);
81
+ });
82
+
83
+ {{/if}}
57
84
  // Start server
58
85
  serve({ fetch: app.fetch, port: PORT, hostname: "0.0.0.0" }, (info) => {
59
86
  log.info({ port: info.port }, "{{titleCase name}} API listening");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mesh-tech/mesh-cli",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "CLI for Mesh platform development utilities",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@mesh-tech/agent-contracts": "0.2.0",
45
- "@mesh-tech/tsconfig": "2.26.0",
45
+ "@mesh-tech/tsconfig": "2.27.0",
46
46
  "@mesh-tech/workflow-viz": "0.3.0",
47
47
  "@tanstack/intent": "^0.3.6",
48
48
  "@types/node": "^20.0.0",