@mesh-tech/mesh-cli 0.35.0 → 0.35.2

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.
@@ -151,16 +151,83 @@ function toEnv(credentials) {
151
151
  AWS_SESSION_TOKEN: SessionToken,
152
152
  };
153
153
  }
154
- function isMaxSessionDurationError(err) {
155
- if (!(err instanceof Error))
154
+ export const ROLE_CHAINING_MAX_SESSION_SECONDS = 3600;
155
+ export const DEFAULT_ASSUME_ROLE_SECONDS = 43200;
156
+ export function isRoleChainedCaller(callerArn) {
157
+ return !!callerArn && /^arn:aws[a-z-]*:sts::\d+:assumed-role\//.test(callerArn);
158
+ }
159
+ export function stsErrorDetail(err) {
160
+ if (!err || typeof err !== "object")
161
+ return { text: String(err) };
162
+ const e = err;
163
+ const str = (v) => (typeof v === "string" && v ? v : undefined);
164
+ const name = str(e.name);
165
+ const typeCode = str(str(e.__type)?.split("#").pop());
166
+ const code = str(e.Code) ??
167
+ typeCode ??
168
+ str(e.Error?.Code) ??
169
+ (name && name !== "Error" ? name : undefined);
170
+ const text = str(e.Error?.Message) ?? str(e.Message) ?? str(e.message) ?? code ?? String(err);
171
+ return { code, text };
172
+ }
173
+ export function isSessionDurationError(err) {
174
+ if (!err || typeof err !== "object")
175
+ return false;
176
+ const { code, text } = stsErrorDetail(err);
177
+ if (code !== undefined && code !== "ValidationError")
156
178
  return false;
157
- const message = err.message ?? "";
158
- return (message.includes("MaxSessionDuration") ||
159
- message.includes("DurationSeconds"));
179
+ return /DurationSeconds|MaxSessionDuration|role chaining/i.test(text);
160
180
  }
161
- export async function assumeRole(roleArn, sessionName = "mesh-cli", durationSeconds = 43200) {
162
- const sts = newStsClient();
163
- for (const duration of [durationSeconds, 3600]) {
181
+ function formatDuration(seconds) {
182
+ return seconds % 3600 === 0 ? `${seconds / 3600}h` : `${seconds}s`;
183
+ }
184
+ export function describeAssumeRoleFailure(opts) {
185
+ const reason = opts.code && opts.code !== opts.text ? `${opts.code}: ${opts.text}` : opts.text;
186
+ const lines = [
187
+ `AssumeRole failed for ${opts.roleArn} — requested a ` +
188
+ `${formatDuration(opts.durationSeconds)} session (DurationSeconds=${opts.durationSeconds}): ${reason}`,
189
+ ];
190
+ if (opts.callerArn)
191
+ lines.push(` Caller: ${opts.callerArn}`);
192
+ const zitadel = `mesh login ${opts.loginContext ?? "<platform>.<env>"}`;
193
+ if (isExpiredAwsTokenMessage(`${opts.code ?? ""} ${opts.text}`)) {
194
+ lines.push(` Your AWS SSO session looks expired or missing — refresh with: ${SSO_LOGIN_FIX}`);
195
+ lines.push(` Or assume the role via Zitadel instead: ${zitadel}`);
196
+ }
197
+ else if (opts.code === "AccessDenied" || /not authorized/i.test(opts.text)) {
198
+ lines.push(" The role does not trust this AWS identity. Assume it via Zitadel instead:");
199
+ lines.push(` ${zitadel}`);
200
+ lines.push(" or switch to an AWS profile the role's trust policy allows.");
201
+ }
202
+ else if (isSessionDurationError({ Code: opts.code, message: opts.text })) {
203
+ lines.push(` The role refused even a ${formatDuration(ROLE_CHAINING_MAX_SESSION_SECONDS)} session. ` +
204
+ "Check its MaxSessionDuration, or assume it via Zitadel instead:");
205
+ lines.push(` ${zitadel}`);
206
+ }
207
+ else {
208
+ lines.push(` Try assuming the role via Zitadel: ${zitadel}`);
209
+ lines.push(` or refresh the AWS session: ${SSO_LOGIN_FIX}`);
210
+ }
211
+ return lines.join("\n");
212
+ }
213
+ export async function assumeRole(roleArn, sessionName = "mesh-cli", durationSeconds = DEFAULT_ASSUME_ROLE_SECONDS, options = {}) {
214
+ const sts = options.sts ?? newStsClient();
215
+ let callerArn;
216
+ try {
217
+ callerArn = (await sts.send(new GetCallerIdentityCommand({}))).Arn;
218
+ }
219
+ catch {
220
+ }
221
+ let requested = durationSeconds;
222
+ if (isRoleChainedCaller(callerArn) && requested > ROLE_CHAINING_MAX_SESSION_SECONDS) {
223
+ requested = ROLE_CHAINING_MAX_SESSION_SECONDS;
224
+ logInfo(`Current AWS identity is an assumed-role session — requesting a ` +
225
+ `${formatDuration(requested)} session (role chaining caps AssumeRole at one hour)`);
226
+ }
227
+ const attempts = requested > ROLE_CHAINING_MAX_SESSION_SECONDS
228
+ ? [requested, ROLE_CHAINING_MAX_SESSION_SECONDS]
229
+ : [requested];
230
+ for (const [i, duration] of attempts.entries()) {
164
231
  try {
165
232
  const resp = await sts.send(new AssumeRoleCommand({
166
233
  RoleArn: roleArn,
@@ -172,14 +239,22 @@ export async function assumeRole(roleArn, sessionName = "mesh-cli", durationSeco
172
239
  return toEnv(resp.Credentials);
173
240
  }
174
241
  catch (err) {
175
- if (!isMaxSessionDurationError(err) || duration === 3600) {
176
- const message = err instanceof Error ? err.message : String(err);
177
- logWarn(`AssumeRole failed: ${message}`);
178
- if (isExpiredAwsTokenMessage(message)) {
179
- logWarn(` Your AWS SSO session looks expired or missing — refresh with: ${SSO_LOGIN_FIX}`);
180
- }
181
- return null;
242
+ const isLast = i === attempts.length - 1;
243
+ if (!isLast && isSessionDurationError(err)) {
244
+ logInfo(`STS refused a ${formatDuration(duration)} session for ${roleArn} — ` +
245
+ `retrying with ${formatDuration(ROLE_CHAINING_MAX_SESSION_SECONDS)}`);
246
+ continue;
182
247
  }
248
+ const { code, text } = stsErrorDetail(err);
249
+ logWarn(describeAssumeRoleFailure({
250
+ roleArn,
251
+ durationSeconds: duration,
252
+ code,
253
+ text,
254
+ callerArn,
255
+ loginContext: options.loginContext,
256
+ }));
257
+ return null;
183
258
  }
184
259
  }
185
260
  return null;
@@ -207,8 +282,9 @@ export async function assumeRoleCredentials(roleArn, idToken, sessionName, durat
207
282
  };
208
283
  }
209
284
  catch (err) {
210
- if (!isMaxSessionDurationError(err) || duration === 3600) {
211
- logWarn(`AssumeRoleWithWebIdentity failed: ${err instanceof Error ? err.message : String(err)}`);
285
+ if (!isSessionDurationError(err) || duration === ROLE_CHAINING_MAX_SESSION_SECONDS) {
286
+ const { code, text } = stsErrorDetail(err);
287
+ logWarn(`AssumeRoleWithWebIdentity failed: ${code && code !== text ? `${code}: ${text}` : text}`);
212
288
  return null;
213
289
  }
214
290
  }
@@ -340,7 +416,9 @@ export async function resolveAwsCredentials(roleArn, appRoot, stack) {
340
416
  }
341
417
  }
342
418
  logInfo(`Assuming ${roleName} via AWS SSO`);
343
- const creds = await assumeRole(roleArn, "mesh-cli");
419
+ const creds = await assumeRole(roleArn, "mesh-cli", DEFAULT_ASSUME_ROLE_SECONDS, {
420
+ loginContext: platformContext ?? undefined,
421
+ });
344
422
  if (creds) {
345
423
  logSuccess(`Assumed ${roleName}`);
346
424
  return { env: creds, method: "sso" };
@@ -112,8 +112,9 @@ const config = new mesh.apps.AppConfig("config", {
112
112
  // Events and webhooks: the service, its own database, an app-scoped KMS key
113
113
  // for endpoint credentials, the publisher key, the Zitadel introspection app,
114
114
  // the delivery task queue, the worker registration that lists the app's
115
- // Temporal namespace on the Hub's Workflows page, and the registry surface —
116
- // all children of this one declaration.
115
+ // Temporal namespace on the Hub's Workflows page (with the service's own
116
+ // housekeeping workflows marked internal, so only staff see them there), and
117
+ // the registry surface — all children of this one declaration.
117
118
  //
118
119
  // Its database's schema migration gates the deploy: `pulumi up` waits for the
119
120
  // migration Job and fails when it fails, and the service's pods are held until
@@ -171,6 +172,9 @@ const api = new mesh.apps.Service("api", {
171
172
  // Drives readiness, liveness and the Hub uptime sampler on BOTH ingress kinds.
172
173
  // `ingress.healthCheckPath` sets the ALB target group's path only, and a
173
174
  // gateway (HTTPRoute) tenant has no target group — so the path belongs here.
175
+ // A boot that can outlast the liveness window (a busy node, a tight CPU
176
+ // limit) takes `healthCheck: { path: "/health", startup: {} }`: a startup
177
+ // probe that holds liveness for up to five minutes of boot.
174
178
  healthCheck: "/health",
175
179
  replicas: deploy ? 2 : 1,
176
180
  resources: { cpu: "100m", memory: "128Mi" },
@@ -205,7 +209,7 @@ const worker = new mesh.apps.TemporalWorker("worker", {
205
209
  // shape: it still deploys, for the sites that are public today, and warns at
206
210
  // every preview. This declaration names none of it, and a new app needs
207
211
  // none. The site's readers hold this app's `docs` project role: declare the
208
- // role on the ZitadelAppIdentity and grant it from Access → People; that same
212
+ // role on the ZitadelAppIdentity and grant it from Access → Users; that same
209
213
  // role is what the Hub reads when a partner holding the Hub's `docs:<tenant>`
210
214
  // role opens this app's Docs tab, and the app then appears in their Apps list.
211
215
  // `mesh app check` flags a docs site put on an ingress instead
@@ -219,7 +223,10 @@ const worker = new mesh.apps.TemporalWorker("worker", {
219
223
  // Hub on the ports it published and refuses it on every other one, so a
220
224
  // registry entry cannot walk the Hub onto a port the surface never opened.
221
225
  // Nothing here declares that — it rides the surface (`hubDialPolicy`, on by
222
- // default wherever the namespace is in the service mesh).
226
+ // default wherever the namespace is in the service mesh). A surface fronting
227
+ // a vendor the tenant already declares names that declaration with
228
+ // `externalService`; its registry entry publishes the name, and the Hub reads
229
+ // the app's Core connection from the vendor's shared health measurement.
223
230
  // =============================================================================
224
231
 
225
232
  new mesh.apps.ApiDocs("docs", {
@@ -17,10 +17,8 @@
17
17
  "@aws-sdk/client-s3": "^3.700.0",
18
18
  {{/if}}
19
19
  "@hono/node-server": "^1.13.0",
20
- "@mesh-tech/logger": "{{#if workspaceDeps}}workspace:*{{else}}{{meshRange}}{{/if}}",
21
- {{#if temporal}}
22
20
  "@mesh-tech/app-kit": "{{#if workspaceDeps}}workspace:*{{else}}{{meshRange}}{{/if}}",
23
- {{/if}}
21
+ "@mesh-tech/logger": "{{#if workspaceDeps}}workspace:*{{else}}{{meshRange}}{{/if}}",
24
22
  {{#if database}}
25
23
  "@prisma/adapter-pg": "^6.0.0",
26
24
  "@prisma/client": "^6.0.0",
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { Hono } from "hono";
21
21
  import { serve } from "@hono/node-server";
22
+ import { configureServerKeepAlive } from "@mesh-tech/app-kit/outbound-http";
22
23
  import { createLogger, requestLogger } from "@mesh-tech/logger";
23
24
  {{#if temporal}}
24
25
  import { createTemporalClient } from "@mesh-tech/app-kit/temporal-client";
@@ -82,6 +83,13 @@ app.post("/things", async (c) => {
82
83
 
83
84
  {{/if}}
84
85
  // Start server
85
- serve({ fetch: app.fetch, port: PORT, hostname: "0.0.0.0" }, (info) => {
86
- log.info({ port: info.port }, "{{titleCase name}} API listening");
87
- });
86
+ // Hold idle keep-alive sockets for 75s rather than Node's 5s default. The
87
+ // proxy in front of this service pools connections to it and keeps them idle
88
+ // longer than 5s (the Istio gateway's Envoy, through ztunnel, or an ALB for
89
+ // 60s), so at the default the server keeps closing sockets the pool may be
90
+ // about to reuse.
91
+ configureServerKeepAlive(
92
+ serve({ fetch: app.fetch, port: PORT, hostname: "0.0.0.0" }, (info) => {
93
+ log.info({ port: info.port }, "{{titleCase name}} API listening");
94
+ }),
95
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mesh-tech/mesh-cli",
3
- "version": "0.35.0",
3
+ "version": "0.35.2",
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.34.0",
45
+ "@mesh-tech/tsconfig": "2.36.0",
46
46
  "@mesh-tech/workflow-viz": "0.3.1",
47
47
  "@tanstack/intent": "^0.3.6",
48
48
  "@types/node": "^20.0.0",
@@ -11,6 +11,7 @@
11
11
  "seed:authz": "tsx scripts/seed-authz.ts"
12
12
  },
13
13
  "dependencies": {
14
+ "@mesh-tech/app-kit": "latest",
14
15
  "@mesh-tech/authn": "latest",
15
16
  "@mesh-tech/authz": "latest",
16
17
  "@mesh-tech/logger": "latest",
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { Hono } from "hono";
17
17
  import { serve } from "@hono/node-server";
18
+ import { configureServerKeepAlive } from "@mesh-tech/app-kit/outbound-http";
18
19
  import { createAuthn } from "@mesh-tech/authn";
19
20
  import { zitadelJwtScheme } from "@mesh-tech/authn/schemes/zitadel-jwt";
20
21
  import { createAuthnMiddleware, type AuthnVariables } from "@mesh-tech/authn/hono";
@@ -102,6 +103,13 @@ app.get("/api/report", m.require("report", "view", { id: () => "quarterly" }), (
102
103
  c.json({ id: "quarterly", title: "Quarterly Report", classification: "internal" }),
103
104
  );
104
105
 
105
- serve({ fetch: app.fetch, port: PORT }, () => {
106
- log.info({ port: PORT, issuer: ZITADEL_ISSUER }, "{{name}} api listening");
107
- });
106
+ // Hold idle keep-alive sockets for 75s rather than Node's 5s default. The
107
+ // proxy in front of this service pools connections to it and keeps them idle
108
+ // longer than 5s (the Istio gateway's Envoy, through ztunnel, or an ALB for
109
+ // 60s), so at the default the server keeps closing sockets the pool may be
110
+ // about to reuse.
111
+ configureServerKeepAlive(
112
+ serve({ fetch: app.fetch, port: PORT }, () => {
113
+ log.info({ port: PORT, issuer: ZITADEL_ISSUER }, "{{name}} api listening");
114
+ }),
115
+ );
@@ -36,4 +36,7 @@ export const schema = defineSchema({
36
36
  },
37
37
  coarseRoles: ["user"] as const,
38
38
  zitadelSync: "coarse",
39
+ // A protected Admin — every action on every resource, locked in the Hub —
40
+ // is one bundle with `admin: true` and no `grants`. Assigning it
41
+ // tenant-wide needs an `isRoot` resource the others `parent`-link to.
39
42
  });
@@ -109,6 +109,9 @@ const api = new mesh.apps.Service("api", {
109
109
  // Drives readiness, liveness and the Hub uptime sampler on BOTH ingress kinds.
110
110
  // `ingress.healthCheckPath` sets the ALB target group's path only, and a
111
111
  // gateway (HTTPRoute) tenant has no target group — so the path belongs here.
112
+ // A boot that can outlast the liveness window (a busy node, a tight CPU
113
+ // limit) takes `healthCheck: { path: "/health", startup: {} }`: a startup
114
+ // probe that holds liveness for up to five minutes of boot.
112
115
  healthCheck: "/health",
113
116
  replicas: 1,
114
117
  resources: { cpu: "100m", memory: "128Mi" },
@@ -14,6 +14,7 @@
14
14
  "check-types": "tsc --noEmit"
15
15
  },
16
16
  "dependencies": {
17
+ "@mesh-tech/app-kit": "latest",
17
18
  "@mesh-tech/authn": "latest",
18
19
  "@mesh-tech/authz": "latest",
19
20
  "@mesh-tech/logger": "latest",
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { serve } from "@hono/node-server";
10
10
  import { Hono } from "hono";
11
+ import { configureServerKeepAlive } from "@mesh-tech/app-kit/outbound-http";
11
12
  import { createAuthnMiddleware, type AuthnVariables } from "@mesh-tech/authn/hono";
12
13
  import { createAuthzMiddleware, type AuthzVariables } from "@mesh-tech/authz/hono";
13
14
  import { createLogger, requestLogger } from "@mesh-tech/logger";
@@ -58,6 +59,11 @@ export const createApp = (deps: { testAuth?: TestAuth } = {}) => {
58
59
 
59
60
  if (process.env.NODE_ENV !== "test" && process.argv[1]?.endsWith("main.js")) {
60
61
  const port = Number(process.env.PORT ?? 3000);
61
- serve({ fetch: createApp().fetch, port });
62
+ // Hold idle keep-alive sockets for 75s rather than Node's 5s default. The
63
+ // proxy in front of this service pools connections to it and keeps them idle
64
+ // longer than 5s (the Istio gateway's Envoy, through ztunnel, or an ALB for
65
+ // 60s), so at the default the server keeps closing sockets the pool may be
66
+ // about to reuse.
67
+ configureServerKeepAlive(serve({ fetch: createApp().fetch, port }));
62
68
  log.info({ port, mode: "role-gating" }, "{{name}} api listening");
63
69
  }
@@ -85,6 +85,9 @@ const api = new mesh.apps.Service("api", {
85
85
  // Drives readiness, liveness and the Hub uptime sampler on BOTH ingress kinds.
86
86
  // `ingress.healthCheckPath` sets the ALB target group's path only, and a
87
87
  // gateway (HTTPRoute) tenant has no target group — so the path belongs here.
88
+ // A boot that can outlast the liveness window (a busy node, a tight CPU
89
+ // limit) takes `healthCheck: { path: "/health", startup: {} }`: a startup
90
+ // probe that holds liveness for up to five minutes of boot.
88
91
  healthCheck: "/health",
89
92
  replicas: 1,
90
93
  resources: { cpu: "100m", memory: "128Mi" },
@@ -17,6 +17,7 @@
17
17
  import { serve } from "@hono/node-server";
18
18
  import { Hono } from "hono";
19
19
  import { resolveCredentials } from "@mesh-tech/app-kit/credentials";
20
+ import { configureServerKeepAlive } from "@mesh-tech/app-kit/outbound-http";
20
21
  import { traceExternalCall } from "@mesh-tech/app-kit/tracing";
21
22
  import { enhanceFetch } from "@mesh-tech/http-client";
22
23
  import { createLogger, requestLogger } from "@mesh-tech/logger";
@@ -81,6 +82,13 @@ app.post("/payments/quote", async (c) => {
81
82
  });
82
83
 
83
84
  const port = Number(process.env.PORT ?? 3000);
84
- serve({ fetch: app.fetch, port }, () => {
85
- log.info({ port }, "{{titleCase name}} API listening");
86
- });
85
+ // Hold idle keep-alive sockets for 75s rather than Node's 5s default. The
86
+ // proxy in front of this service pools connections to it and keeps them idle
87
+ // longer than 5s (the Istio gateway's Envoy, through ztunnel, or an ALB for
88
+ // 60s), so at the default the server keeps closing sockets the pool may be
89
+ // about to reuse.
90
+ configureServerKeepAlive(
91
+ serve({ fetch: app.fetch, port }, () => {
92
+ log.info({ port }, "{{titleCase name}} API listening");
93
+ }),
94
+ );
@@ -75,6 +75,9 @@ const api = new mesh.apps.Service("api", {
75
75
  // Drives readiness, liveness and the Hub uptime sampler on BOTH ingress kinds.
76
76
  // `ingress.healthCheckPath` sets the ALB target group's path only, and a
77
77
  // gateway (HTTPRoute) tenant has no target group — so the path belongs here.
78
+ // A boot that can outlast the liveness window (a busy node, a tight CPU
79
+ // limit) takes `healthCheck: { path: "/health", startup: {} }`: a startup
80
+ // probe that holds liveness for up to five minutes of boot.
78
81
  healthCheck: "/health",
79
82
  replicas: 1,
80
83
  resources: { cpu: "100m", memory: "128Mi" },
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { Hono } from "hono";
13
13
  import { serve } from "@hono/node-server";
14
+ import { configureServerKeepAlive } from "@mesh-tech/app-kit/outbound-http";
14
15
  import { createTemporalClient, type WithClient } from "@mesh-tech/app-kit/temporal-client";
15
16
  import { createLogger, requestLogger } from "@mesh-tech/logger";
16
17
 
@@ -125,9 +126,16 @@ app.get("/workflows/:id", async (c) => {
125
126
  // Start server
126
127
  initClient()
127
128
  .then(() => {
128
- serve({ fetch: app.fetch, port: PORT, hostname: "0.0.0.0" }, () => {
129
- log.info({ port: PORT }, "{{titleCase name}} API listening");
130
- });
129
+ // Hold idle keep-alive sockets for 75s rather than Node's 5s default. The
130
+ // proxy in front of this service pools connections to it and keeps them idle
131
+ // longer than 5s (the Istio gateway's Envoy, through ztunnel, or an ALB for
132
+ // 60s), so at the default the server keeps closing sockets the pool may be
133
+ // about to reuse.
134
+ configureServerKeepAlive(
135
+ serve({ fetch: app.fetch, port: PORT, hostname: "0.0.0.0" }, () => {
136
+ log.info({ port: PORT }, "{{titleCase name}} API listening");
137
+ }),
138
+ );
131
139
  })
132
140
  .catch((err) => {
133
141
  log.error({ err }, "Failed to initialize");
@@ -47,6 +47,9 @@ const api = new mesh.apps.Service("api", {
47
47
  // Drives readiness, liveness and the Hub uptime sampler on BOTH ingress kinds.
48
48
  // `ingress.healthCheckPath` sets the ALB target group's path only, and a
49
49
  // gateway (HTTPRoute) tenant has no target group — so the path belongs here.
50
+ // A boot that can outlast the liveness window (a busy node, a tight CPU
51
+ // limit) takes `healthCheck: { path: "/health", startup: {} }`: a startup
52
+ // probe that holds liveness for up to five minutes of boot.
50
53
  healthCheck: "/health",
51
54
  replicas: 1,
52
55
  resources: { cpu: "100m", memory: "128Mi" },