@mesh-tech/mesh-cli 0.32.0 → 0.34.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.
- package/dist/bin/mesh.js +91 -15
- package/dist/bin/mesh.js.map +1 -1
- package/dist/build-info.json +2 -2
- package/dist/src/commands/app-check.d.ts +6 -1
- package/dist/src/commands/app-check.js +70 -12
- package/dist/src/commands/create-app.d.ts +3 -0
- package/dist/src/commands/create-app.js +25 -0
- package/dist/src/commands/temporal.js +1 -1
- package/dist/src/commands/temporal.js.map +1 -1
- package/dist/src/utils/scaffold-versions.d.ts +2 -0
- package/dist/src/utils/scaffold-versions.js +2 -0
- package/fragments/base/index.ts.hbs +104 -0
- package/fragments/base/package.json.hbs +10 -0
- package/fragments/events/api/src/events.ts.hbs +161 -0
- package/fragments/events/schema.ts.hbs +23 -0
- package/fragments/service/api/src/index.ts.hbs +27 -0
- package/package.json +2 -2
- package/templates/api-auth/README.md.hbs +6 -1
- package/templates/api-auth/index.ts.hbs +7 -0
|
@@ -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.
|
|
3
|
+
"version": "0.34.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.
|
|
45
|
+
"@mesh-tech/tsconfig": "2.28.0",
|
|
46
46
|
"@mesh-tech/workflow-viz": "0.3.0",
|
|
47
47
|
"@tanstack/intent": "^0.3.6",
|
|
48
48
|
"@types/node": "^20.0.0",
|
|
@@ -61,7 +61,12 @@ host gets its own callback, and registers a redirect URI per host at the IdP.
|
|
|
61
61
|
`index.ts` owns the whole access surface, so there is nothing to create by
|
|
62
62
|
hand: `ZitadelAppIdentity` makes the app's Zitadel project + roles,
|
|
63
63
|
`SpiceDBSchema` compiles `api/src/schema.ts` into the app's own SpiceDB and
|
|
64
|
-
publishes the ops-hub metadata
|
|
64
|
+
publishes the ops-hub metadata — `mesh deploy up` blocks on that Job and fails
|
|
65
|
+
when it fails, so a green deploy means the schema is live. On a cold first
|
|
66
|
+
deploy the Job can lose the race to SpiceDB's own startup and gives up on
|
|
67
|
+
`backoffLimit`, well inside `timeoutMinutes`; `awaitCompletion: false` is the
|
|
68
|
+
lever for that, not a larger `timeoutMinutes`, which does not extend the
|
|
69
|
+
retries. `AppAuthzPointer` tells the Hub where to
|
|
65
70
|
look. People, roles, API keys and sign-in activity are then administered from
|
|
66
71
|
the Hub's **App → Access** tab — never by a runbook step. The contract behind
|
|
67
72
|
that: `@mesh-tech/app-kit#apps` → `references/app-contract.md`; `mesh app
|
|
@@ -72,6 +72,13 @@ const spicedb = new mesh.auth.SpiceDB("spicedb", { env, httpEnabled: true });
|
|
|
72
72
|
// --- 5. Schema — compile, apply, and publish the ops-hub metadata. ---
|
|
73
73
|
// `opsHubMetadataRef` is the SSM path the pointer below hands to the Hub; without
|
|
74
74
|
// it the Hub's create-key and role catalog answer 409 METADATA_UNPUBLISHED.
|
|
75
|
+
// `mesh deploy up` blocks on the schema-write Job and fails when it fails —
|
|
76
|
+
// every permission check this API makes reads what that Job wrote, so a write
|
|
77
|
+
// that never landed is not a deploy that succeeded. On a cold first deploy the
|
|
78
|
+
// Job can lose the race to SpiceDB's own startup: it retries on backoffLimit
|
|
79
|
+
// and gives up well inside timeoutMinutes, never running for the full budget.
|
|
80
|
+
// If that is your estate, set `awaitCompletion: false` here rather than
|
|
81
|
+
// raising `timeoutMinutes`, which does not extend the retries.
|
|
75
82
|
const schema = new mesh.auth.SpiceDBSchema("schema", {
|
|
76
83
|
env,
|
|
77
84
|
spicedb,
|