@k2b/cloud 0.46.0 → 0.48.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.
Files changed (104) hide show
  1. package/package.json +8 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/app-access-manager-sql.ts +10 -0
  5. package/src/_internal/bounded-json.ts +7 -1
  6. package/src/_internal/capabilities.ts +8 -0
  7. package/src/_internal/define-app.ts +69 -2
  8. package/src/_internal/heartbeat.ts +21 -0
  9. package/src/_internal/nats-connection.ts +10 -9
  10. package/src/_internal/postgres-application-name.ts +11 -0
  11. package/src/_internal/process-sync.ts +15 -6
  12. package/src/_internal/public-surface.ts +76 -0
  13. package/src/_internal/purpose-name.ts +7 -0
  14. package/src/_internal/readiness.ts +6 -1
  15. package/src/_internal/registry-validation.ts +1 -0
  16. package/src/_internal/registry.ts +49 -3
  17. package/src/_internal/resource-principals.ts +4 -34
  18. package/src/_internal/workload-credential.ts +154 -0
  19. package/src/_internal/workload-protocol.ts +36 -0
  20. package/src/ai/application-tasks.ts +5 -2
  21. package/src/ai/chart-block.ts +36 -711
  22. package/src/ai/default-tool-contracts.ts +21 -259
  23. package/src/api/admin-app-domains.ts +62 -0
  24. package/src/api/admin-app-keys.ts +66 -0
  25. package/src/api/admin-core-settings.ts +2 -0
  26. package/src/api/admin-identity.ts +27 -1
  27. package/src/api/admin-settings-env.ts +120 -0
  28. package/src/api/app-bootstrap.ts +131 -0
  29. package/src/api/capabilities.ts +17 -4
  30. package/src/api/index.ts +8 -0
  31. package/src/api/settings-ops.ts +35 -0
  32. package/src/app-key.ts +125 -0
  33. package/src/browser/CloudResourceSearch.tsx +48 -41
  34. package/src/capabilities/discussion-providers.ts +3 -2
  35. package/src/capabilities/resource-readers.ts +13 -2
  36. package/src/capabilities/response.ts +6 -2
  37. package/src/capabilities/server-invoke.ts +73 -29
  38. package/src/capabilities/server.ts +1 -1
  39. package/src/capabilities/types.ts +1 -1
  40. package/src/cli/admin/app-credentials.ts +32 -7
  41. package/src/cli/admin/app-domains.ts +140 -0
  42. package/src/cli/admin/app-keys.ts +226 -0
  43. package/src/cli/admin/index.ts +109 -84
  44. package/src/cli/admin/settings.ts +62 -0
  45. package/src/config/define-env.ts +63 -8
  46. package/src/config/env.ts +34 -22
  47. package/src/config/index.ts +12 -1
  48. package/src/config/workflow-ai.ts +28 -0
  49. package/src/contracts/app-domains.ts +91 -0
  50. package/src/contracts/app-status.ts +104 -0
  51. package/src/contracts/capabilities.ts +22 -0
  52. package/src/contracts/index.ts +1 -0
  53. package/src/contracts/registry.ts +2 -0
  54. package/src/contracts/settings-env.ts +21 -0
  55. package/src/contracts/settings-types.ts +6 -0
  56. package/src/index.ts +10 -0
  57. package/src/runtime/bootstrap-client.ts +195 -0
  58. package/src/runtime/bootstrap-protocol.ts +60 -0
  59. package/src/runtime/clients.ts +72 -0
  60. package/src/runtime/connections.ts +46 -0
  61. package/src/runtime/resolve-connections.ts +91 -0
  62. package/src/server/app-key.ts +14 -0
  63. package/src/server/client-ip.ts +1 -1
  64. package/src/server/index.ts +1 -0
  65. package/src/services/app-access-management.ts +1 -8
  66. package/src/services/app-admin-facts.ts +160 -0
  67. package/src/services/app-bootstrap.ts +134 -0
  68. package/src/services/app-domain-check.ts +152 -0
  69. package/src/services/app-domains.ts +172 -0
  70. package/src/services/app-keys.ts +301 -0
  71. package/src/services/downstream-budget.ts +2 -2
  72. package/src/services/gateway.ts +6 -0
  73. package/src/services/identity/index.ts +3 -0
  74. package/src/services/identity/resource-readers.ts +80 -0
  75. package/src/services/identity/runtime-config.ts +14 -6
  76. package/src/services/identity/workload-runtime.ts +253 -0
  77. package/src/services/index.ts +8 -1
  78. package/src/services/links/index.ts +16 -0
  79. package/src/services/links/runtime.ts +44 -0
  80. package/src/services/links/store.ts +105 -0
  81. package/src/services/logging/index.ts +7 -5
  82. package/src/services/logging/redaction.ts +6 -1
  83. package/src/services/logging/trace.ts +8 -8
  84. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  85. package/src/services/request-cache-redis.ts +2 -2
  86. package/src/services/service-account-credentials.ts +63 -21
  87. package/src/services/settings/api.ts +3 -3
  88. package/src/services/settings/binding.ts +45 -0
  89. package/src/services/settings/core-settings.ts +119 -1
  90. package/src/services/settings/crypto.ts +2 -2
  91. package/src/services/settings/defaults.ts +16 -2
  92. package/src/services/settings/env-status.ts +69 -0
  93. package/src/services/settings/index.ts +69 -26
  94. package/src/services/settings/store.ts +78 -8
  95. package/src/services/settings/validation.ts +40 -0
  96. package/src/shared/index.ts +11 -10
  97. package/src/shared/markdown/extensions/tables.ts +10 -1
  98. package/src/ssr/admin-navigation.ts +0 -1
  99. package/src/ssr/platform-messages.ts +0 -2
  100. package/src/start.ts +57 -0
  101. package/src/workflows/ai/index.ts +1 -0
  102. package/src/workflows/ai/runtime.ts +16 -8
  103. package/src/workflows/ai/settings.ts +5 -0
  104. package/src/shared/markdown/formula.ts +0 -1283
@@ -1,8 +1,10 @@
1
1
  import { z } from "zod";
2
+ import { refreshAppWorkloadCredential } from "../_internal/workload-credential";
2
3
  import { dispatchCapability, loadCapabilityCatalogPage } from "../api/capabilities";
3
4
  import {
4
5
  CAPABILITY_FRAMEWORK_ERROR_CODES,
5
6
  CAPABILITY_MAX_CATALOG_BYTES,
7
+ CAPABILITY_MAX_RESULT_BYTES,
6
8
  CapabilityCatalogSchema,
7
9
  type CapabilityOrigin,
8
10
  capabilityResultSchema,
@@ -35,6 +37,8 @@ export type CapabilityCaller = {
35
37
  /** BCP 47 locale of the originating request, forwarded as invocation metadata. */
36
38
  locale?: string | null;
37
39
  signal?: AbortSignal;
40
+ /** Request-local aggregate response budget; called before result validation for every received chunk. */
41
+ consumeResponseBytes?: (bytes: number) => void;
38
42
  /** App-only query authority. The workload credential goes only to Core. */
39
43
  workload?: { appId: string };
40
44
  /** Durable background authority. `authorization` must be the owning app's workload credential. */
@@ -112,6 +116,8 @@ const invokeCapabilityWithResultSchema = async <TDataSchema extends z.ZodType, T
112
116
  if (caller.authority) {
113
117
  if (caller.authorization || caller.cookie || caller.mandate || caller.workload || caller.transport)
114
118
  throw new Error("Use exactly one capability authority source");
119
+ let charged = false;
120
+ const charge = caller.consumeResponseBytes;
115
121
  const outcome = await dispatchCapability({
116
122
  request,
117
123
  kind: invocation.kind === "query" ? "queries" : "actions",
@@ -121,38 +127,76 @@ const invokeCapabilityWithResultSchema = async <TDataSchema extends z.ZodType, T
121
127
  authority: caller.authority,
122
128
  locale: caller.locale ?? undefined,
123
129
  origin: caller.origin ?? "app",
130
+ ...(charge
131
+ ? {
132
+ consumeResponseBytes: (bytes: number) => {
133
+ charged = true;
134
+ charge(bytes);
135
+ },
136
+ }
137
+ : {}),
124
138
  });
125
- return readCapabilityResponse(outcome, capabilityResultSchema(dataSchema, { consumer: true }));
139
+ return readCapabilityResponse(
140
+ outcome,
141
+ capabilityResultSchema(dataSchema, { consumer: true }),
142
+ CAPABILITY_MAX_RESULT_BYTES,
143
+ // Only a budgeted caller cancels reading a response that already arrived; others keep the received result.
144
+ charge ? combineCapabilitySignals(caller.signal, invocation.signal) : undefined,
145
+ charged ? undefined : charge,
146
+ );
147
+ }
148
+ const brokerRequest = new Request(request, {
149
+ body: JSON.stringify(
150
+ caller.mandate || caller.workload
151
+ ? {
152
+ kind: invocation.kind,
153
+ targetApp: invocation.appId,
154
+ capabilityId: invocation.capabilityId,
155
+ input: invocation.input,
156
+ ...(caller.mandate ? { mandateId: caller.mandate.id, mandateRevision: caller.mandate.revision } : {}),
157
+ ...(invocation.idempotencyKey ? { idempotencyKey: invocation.idempotencyKey } : {}),
158
+ ...(caller.requestId || caller.traceparent || caller.tracestate || caller.locale
159
+ ? {
160
+ metadata: {
161
+ ...(caller.requestId ? { requestId: caller.requestId } : {}),
162
+ ...(caller.traceparent ? { traceparent: caller.traceparent } : {}),
163
+ ...(caller.tracestate ? { tracestate: caller.tracestate } : {}),
164
+ ...(caller.locale ? { locale: caller.locale } : {}),
165
+ },
166
+ }
167
+ : {}),
168
+ }
169
+ : { input: invocation.input },
170
+ ),
171
+ });
172
+ const send = caller.transport ?? ((request: Request) => fetch(request, caller.workload ? { redirect: "error" } : undefined));
173
+ // Only Core's pre-dispatch workload rejection is safe to retry, including a non-idempotent action.
174
+ const workloadAppId = caller.mandate?.callingAppId ?? caller.workload?.appId;
175
+ const retryRequest = workloadAppId ? brokerRequest.clone() : undefined;
176
+ let response = await send(brokerRequest);
177
+ const credentialRejected = (reply: Response) => reply.status === 401 && reply.headers.get("x-cloud-workload-auth") === "required";
178
+ if (workloadAppId && retryRequest && credentialRejected(response)) {
179
+ await response.body?.cancel();
180
+ let token: string | undefined;
181
+ try {
182
+ token = await refreshAppWorkloadCredential(workloadAppId);
183
+ } catch {
184
+ // This rejection happened before dispatch; credential state is retryable unavailability.
185
+ }
186
+ if (token) retryRequest.headers.set("authorization", `Bearer ${token}`);
187
+ response = await send(retryRequest);
188
+ if (credentialRejected(response)) {
189
+ await response.body?.cancel();
190
+ return unavailable(undefined, undefined, caller.locale ?? undefined);
191
+ }
126
192
  }
127
- const response = await (
128
- caller.transport ?? ((request: Request) => fetch(request, caller.workload ? { redirect: "error" } : undefined))
129
- )(
130
- new Request(request, {
131
- body: JSON.stringify(
132
- caller.mandate || caller.workload
133
- ? {
134
- kind: invocation.kind,
135
- targetApp: invocation.appId,
136
- capabilityId: invocation.capabilityId,
137
- input: invocation.input,
138
- ...(caller.mandate ? { mandateId: caller.mandate.id, mandateRevision: caller.mandate.revision } : {}),
139
- ...(invocation.idempotencyKey ? { idempotencyKey: invocation.idempotencyKey } : {}),
140
- ...(caller.requestId || caller.traceparent || caller.tracestate || caller.locale
141
- ? {
142
- metadata: {
143
- ...(caller.requestId ? { requestId: caller.requestId } : {}),
144
- ...(caller.traceparent ? { traceparent: caller.traceparent } : {}),
145
- ...(caller.tracestate ? { tracestate: caller.tracestate } : {}),
146
- ...(caller.locale ? { locale: caller.locale } : {}),
147
- },
148
- }
149
- : {}),
150
- }
151
- : { input: invocation.input },
152
- ),
153
- }),
193
+ return readCapabilityResponse(
194
+ response,
195
+ capabilityResultSchema(dataSchema, { consumer: true }),
196
+ CAPABILITY_MAX_RESULT_BYTES,
197
+ caller.consumeResponseBytes ? combineCapabilitySignals(caller.signal, invocation.signal) : undefined,
198
+ caller.consumeResponseBytes,
154
199
  );
155
- return readCapabilityResponse(response, capabilityResultSchema(dataSchema, { consumer: true }));
156
200
  } catch (cause) {
157
201
  return unavailable(cause, invocation, caller.locale ?? undefined);
158
202
  }
@@ -182,7 +182,7 @@ export {
182
182
  readCalendarEvents,
183
183
  } from "./calendar-sources";
184
184
  export { type DiscussionProvider, discussionProviders } from "./discussion-providers";
185
- export { type ResourceReadersResult, readResourcePrincipals } from "./resource-readers";
185
+ export { ResourceReaderRateLimited, type ResourceReadersResult, readResourcePrincipals } from "./resource-readers";
186
186
  export {
187
187
  actionReview,
188
188
  type CapabilityReviewForms,
@@ -2,7 +2,7 @@ import type { z } from "zod";
2
2
  import type { CapabilityActionReviewData, CapabilityCatalog, CapabilityErrorSchema, CapabilityResult } from "../contracts/capabilities";
3
3
 
4
4
  export type CapabilityKind = "query" | "action";
5
- export type CapabilityClientError = z.infer<typeof CapabilityErrorSchema> & { status: number };
5
+ export type CapabilityClientError = z.infer<typeof CapabilityErrorSchema> & { status: number; retryAfterMs?: number };
6
6
  export type CapabilityResultState<T> = { ok: true; data: T } | { ok: false; error: CapabilityClientError };
7
7
  export type CapabilityClientResult<T> = CapabilityResultState<CapabilityResult<T>>;
8
8
  export type CapabilityReviewClientResult = CapabilityResultState<CapabilityActionReviewData>;
@@ -10,6 +10,7 @@ const metadata = z.object({
10
10
  status: z.string(),
11
11
  tokenPrefix: z.string(),
12
12
  expiresAt: z.string().nullable(),
13
+ serviceAccount: z.object({ status: z.string() }).optional(),
13
14
  });
14
15
 
15
16
  export const appCredentialCommands = [
@@ -33,12 +34,15 @@ export const appCredentialCommands = [
33
34
  .object({ items: z.array(metadata), page: z.number(), perPage: z.number(), total: z.number(), hasNext: z.boolean() })
34
35
  .parse(await apiGet(ctx, `${path(args.app)}?page=${flags.page}&perPage=${flags.perPage}`));
35
36
  if (!printStructured(ctx, listing))
36
- ctx.table(listing.items, [
37
- { key: "id", label: "ID" },
38
- { key: "name", label: "Name" },
39
- { key: "status", label: "Status" },
40
- { key: "expiresAt", label: "Expires" },
41
- ]);
37
+ ctx.table(
38
+ listing.items.map((item) => ({ ...item, status: item.serviceAccount?.status === "disabled" ? "disabled" : item.status })),
39
+ [
40
+ { key: "id", label: "ID" },
41
+ { key: "name", label: "Name" },
42
+ { key: "status", label: "Status" },
43
+ { key: "expiresAt", label: "Expires" },
44
+ ],
45
+ );
42
46
  },
43
47
  }),
44
48
  command("app-credentials create", {
@@ -62,8 +66,29 @@ export const appCredentialCommands = [
62
66
  if (!printStructured(ctx, result)) ctx.print(result.token);
63
67
  },
64
68
  }),
69
+ ...(["enable", "disable"] as const).map((action) =>
70
+ command(`app-credentials ${action}`, {
71
+ summary: `${action === "enable" ? "Enable" : "Disable"} workload issuance and calls for every instance of an app`,
72
+ args: { app: arg.required({ valueLabel: "app" }) },
73
+ flags: { yes: confirmFlag(`${action} this app's workload access`) },
74
+ async run({ ctx, args, flags }) {
75
+ if (!flags.yes)
76
+ throw new Error(
77
+ cliText(ctx, {
78
+ en: "Changing app workload access requires --yes.",
79
+ de: "App-Hintergrundzugriff ändern erfordert --yes.",
80
+ }),
81
+ );
82
+ const result = await apiJson(ctx, "PATCH", `${root}/${encodeURIComponent(args.app)}`, {
83
+ status: action === "enable" ? "active" : "disabled",
84
+ });
85
+ if (!printStructured(ctx, result))
86
+ ctx.print(cliText(ctx, { en: "App workload access updated.", de: "App-Hintergrundzugriff aktualisiert." }));
87
+ },
88
+ }),
89
+ ),
65
90
  command("app-credentials revoke", {
66
- summary: "Revoke one exact app credential; its background calls will stop",
91
+ summary: "Revoke an app credential; runtime credentials also disable app-wide workload access",
67
92
  args: { app: arg.required({ valueLabel: "app" }), credential: arg.required({ valueLabel: "credential" }) },
68
93
  flags: { yes: confirmFlag("Revoke this app credential") },
69
94
  async run({ ctx, args, flags }) {
@@ -0,0 +1,140 @@
1
+ import { z } from "zod";
2
+ import { arg, type CloudCliContext, cliText, command, confirmFlag, flag, localizeCloudCliText, printStructured } from "../index";
3
+ import { apiGet, apiJson } from "./shared";
4
+
5
+ const step = z.object({ ok: z.boolean(), detail: z.string() });
6
+ const status = z.object({ checkedAt: z.string(), dns: step, certificate: step.nullable(), reachable: step.nullable() });
7
+ const domain = z.object({
8
+ host: z.string(),
9
+ redirectTo: z.string().nullable(),
10
+ createdAt: z.string(),
11
+ active: z.boolean(),
12
+ status: status.nullable(),
13
+ });
14
+ const path = (appId: string, host?: string) =>
15
+ `/api/admin/apps/${encodeURIComponent(appId)}/domains${host === undefined ? "" : `/${encodeURIComponent(host)}`}`;
16
+ const appArgs = { appId: arg.required({ valueLabel: "appId" }) };
17
+ const domainArgs = { ...appArgs, host: arg.required({ valueLabel: "host" }) };
18
+ const show = (ctx: CloudCliContext, raw: unknown, domains: z.infer<typeof domain>[]) => {
19
+ if (printStructured(ctx, raw)) return;
20
+ const checked = (result: z.infer<typeof step> | null | undefined) =>
21
+ result == null ? "-" : result.ok ? "ok" : cliText(ctx, { en: "failed", de: "fehlgeschlagen" });
22
+ ctx.table(
23
+ domains.map((item) => ({
24
+ host: item.host,
25
+ redirect: item.redirectTo ?? "-",
26
+ active: item.active,
27
+ dns: checked(item.status?.dns),
28
+ certificate: checked(item.status?.certificate),
29
+ reachable: checked(item.status?.reachable),
30
+ })),
31
+ [
32
+ { key: "host", label: "Host" },
33
+ { key: "redirect", label: cliText(ctx, { en: "Redirect", de: "Weiterleitung" }) },
34
+ { key: "active", label: cliText(ctx, { en: "Active", de: "Aktiv" }) },
35
+ { key: "dns", label: "DNS" },
36
+ { key: "certificate", label: cliText(ctx, { en: "Certificate", de: "Zertifikat" }) },
37
+ { key: "reachable", label: cliText(ctx, { en: "Reachable", de: "Erreichbar" }) },
38
+ ],
39
+ );
40
+ };
41
+
42
+ export const appDomainCommands = (locale?: string) => {
43
+ const t = (en: string, de: string) => localizeCloudCliText(locale, { en, de });
44
+ return [
45
+ command("apps domains ls", {
46
+ summary: t(
47
+ "List app domains, redirects and DNS/TLS/gateway status",
48
+ "App-Domains, Weiterleitungen und DNS/TLS/Gateway-Status auflisten",
49
+ ),
50
+ args: appArgs,
51
+ async run({ ctx, args }) {
52
+ const result = z.object({ domains: z.array(domain) }).parse(await apiGet(ctx, path(args.appId)));
53
+ show(ctx, result, result.domains);
54
+ },
55
+ }),
56
+ command("apps domains add", {
57
+ summary: t(
58
+ "Assign a domain to a registered app and start its first check",
59
+ "Eine Domain einer bekannten App zuordnen und erstmals prüfen",
60
+ ),
61
+ args: domainArgs,
62
+ flags: {
63
+ redirectTo: flag.string({
64
+ description: t("Another domain of this app without a redirect", "Eine andere Domain dieser App ohne Weiterleitung"),
65
+ }),
66
+ },
67
+ async run({ ctx, args, flags }) {
68
+ const result = domain.parse(
69
+ await apiJson(ctx, "POST", path(args.appId), { host: args.host, ...(flags.redirectTo ? { redirectTo: flags.redirectTo } : {}) }),
70
+ );
71
+ show(ctx, result, [result]);
72
+ },
73
+ }),
74
+ command("apps domains set", {
75
+ summary: t("Set or clear an app domain redirect", "Eine Domain-Weiterleitung setzen oder entfernen"),
76
+ args: domainArgs,
77
+ flags: {
78
+ redirectTo: flag.string({
79
+ description: t("Another domain of this app without a redirect", "Eine andere Domain dieser App ohne Weiterleitung"),
80
+ }),
81
+ noRedirect: flag.boolean({ description: t("Clear the redirect", "Die Weiterleitung entfernen") }),
82
+ },
83
+ async run({ ctx, args, flags }) {
84
+ if (Boolean(flags.redirectTo) === flags.noRedirect)
85
+ throw new Error(
86
+ cliText(ctx, {
87
+ en: "Use exactly one of --redirect-to or --no-redirect.",
88
+ de: "Nutze genau eine Option: --redirect-to oder --no-redirect.",
89
+ }),
90
+ );
91
+ const result = domain.parse(
92
+ await apiJson(ctx, "PATCH", path(args.appId, args.host), { redirectTo: flags.noRedirect ? null : flags.redirectTo }),
93
+ );
94
+ show(ctx, result, [result]);
95
+ },
96
+ }),
97
+ command("apps domains rm", {
98
+ summary: t("Remove an unreferenced app domain assignment", "Eine App-Domain ohne eingehende Weiterleitungen entfernen"),
99
+ args: domainArgs,
100
+ flags: { yes: confirmFlag(t("Remove this domain assignment", "Diese Domain-Zuordnung entfernen")) },
101
+ async run({ ctx, args, flags }) {
102
+ if (!flags.yes)
103
+ throw new Error(cliText(ctx, { en: "Removing a domain requires --yes.", de: "Das Entfernen einer Domain erfordert --yes." }));
104
+ const result = await apiJson(ctx, "DELETE", path(args.appId, args.host));
105
+ if (!printStructured(ctx, result)) ctx.print(cliText(ctx, { en: "Domain removed.", de: "Domain entfernt." }));
106
+ },
107
+ }),
108
+ command("apps domains check", {
109
+ summary: t(
110
+ "Check DNS, certificate and gateway reachability, at most once per 60 seconds",
111
+ "DNS, Zertifikat und Gateway-Erreichbarkeit höchstens einmal pro 60 Sekunden prüfen",
112
+ ),
113
+ args: domainArgs,
114
+ async run({ ctx, args }) {
115
+ const result = z.object({ status: status.nullable() }).parse(await apiJson(ctx, "POST", `${path(args.appId, args.host)}/check`));
116
+ if (printStructured(ctx, result)) return;
117
+ if (!result.status) {
118
+ ctx.print(
119
+ cliText(ctx, {
120
+ en: "The first check is running. List the domains again shortly.",
121
+ de: "Die erste Prüfung läuft. Liste die Domains gleich erneut auf.",
122
+ }),
123
+ );
124
+ return;
125
+ }
126
+ ctx.table(
127
+ (["dns", "certificate", "reachable"] as const).map((key) => {
128
+ const value = result.status![key];
129
+ return { step: key, ok: value?.ok ?? "-", detail: value?.detail ?? "-" };
130
+ }),
131
+ [
132
+ { key: "step", label: cliText(ctx, { en: "Step", de: "Schritt" }) },
133
+ { key: "ok", label: "OK" },
134
+ { key: "detail", label: "Detail" },
135
+ ],
136
+ );
137
+ },
138
+ }),
139
+ ];
140
+ };
@@ -0,0 +1,226 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, chown, link, lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { z } from "zod";
5
+ import { appKeyFingerprint, deriveAppPublicKey, formatAppKey, generateAppKeyPair, validateAppKeyAppId } from "../../app-key";
6
+ import { arg, command, confirmFlag, flag, printStructured } from "../index";
7
+ import { apiGet, apiJson } from "./shared";
8
+
9
+ const keyPath = (appId: string) => `/api/admin/apps/${encodeURIComponent(appId)}/keys`;
10
+ const registrationSchema = z.object({
11
+ keyId: z.uuid(),
12
+ installationId: z.uuid(),
13
+ fingerprint: z.string().regex(/^[0-9a-f]{32}$/),
14
+ status: z.enum(["active", "expired", "revoked"]),
15
+ accountStatus: z.enum(["active", "disabled"]),
16
+ });
17
+ const enrollmentSchema = z
18
+ .object({ version: z.literal(1), server: z.string(), appId: z.string(), seed: z.string(), publicKey: z.string() })
19
+ .strict();
20
+ export type AppKeyArtifactFormat = "file" | "env" | "compose" | "k8s-secret";
21
+
22
+ export const renderAppKeyArtifact = (appId: string, privateKey: string, format: AppKeyArtifactFormat): string => {
23
+ validateAppKeyAppId(appId);
24
+ if (format === "file") return `${privateKey}\n`;
25
+ if (format === "env") return `CLOUD_APP_KEY=${privateKey}\n`;
26
+ if (format === "compose") return `services:\n ${appId}:\n environment:\n CLOUD_APP_KEY: ${JSON.stringify(privateKey)}\n`;
27
+ const secretName =
28
+ appId.length <= 50
29
+ ? `${appId}-app-key`
30
+ : `${appId.slice(0, 40)}-${createHash("sha256").update(appId).digest("hex").slice(0, 8)}-app-key`;
31
+ return `apiVersion: v1\nkind: Secret\nmetadata:\n name: ${secretName}\ntype: Opaque\nstringData:\n CLOUD_APP_KEY: ${JSON.stringify(privateKey)}\n`;
32
+ };
33
+
34
+ const isCode = (error: unknown, code: string): boolean => error instanceof Error && "code" in error && error.code === code;
35
+ const owner = async (path: string, uid?: number) => {
36
+ await chmod(path, 0o600);
37
+ if (uid !== undefined) await chown(path, uid, -1);
38
+ };
39
+
40
+ /** Publish the fully written, fsynced artifact atomically. Exclusive publication
41
+ * elects the same provisional key even when two CLI processes start together. */
42
+ const atomicWrite = async (path: string, value: string, options: { exclusive?: boolean; uid?: number } = {}): Promise<boolean> => {
43
+ const temporary = `${path}.${crypto.randomUUID()}.tmp`;
44
+ const handle = await open(temporary, "wx", 0o600);
45
+ try {
46
+ await handle.writeFile(value, "utf8");
47
+ await handle.sync();
48
+ await owner(temporary, options.uid);
49
+ await handle.close();
50
+ let published = true;
51
+ if (options.exclusive) {
52
+ try {
53
+ await link(temporary, path);
54
+ } catch (error) {
55
+ if (!isCode(error, "EEXIST")) throw error;
56
+ published = false;
57
+ }
58
+ } else await rename(temporary, path);
59
+ const directory = await open(resolve(path, ".."), "r");
60
+ try {
61
+ await directory.sync();
62
+ } finally {
63
+ await directory.close();
64
+ }
65
+ return published;
66
+ } finally {
67
+ await handle.close();
68
+ await unlink(temporary).catch((error: unknown) => {
69
+ if (!isCode(error, "ENOENT")) throw error;
70
+ });
71
+ }
72
+ };
73
+
74
+ export const enrollAppKey = async (input: {
75
+ appId: string;
76
+ server: string;
77
+ outDir: string;
78
+ format: AppKeyArtifactFormat;
79
+ uid?: number;
80
+ name?: string;
81
+ register: (body: { publicKey: string; name?: string }) => Promise<unknown>;
82
+ }) => {
83
+ validateAppKeyAppId(input.appId);
84
+ if (input.uid !== undefined && process.getuid?.() !== 0 && process.getuid?.() !== input.uid)
85
+ throw new Error("--uid requires root or the current user ID");
86
+ const outDir = resolve(input.outDir);
87
+ await mkdir(outDir, { recursive: true, mode: 0o700 });
88
+ const provisional = join(outDir, `.${input.appId}.enrollment.json`);
89
+ const extension = { file: "key", env: "env", compose: "compose.yaml", "k8s-secret": "secret.yaml" }[input.format];
90
+ const file = join(outDir, `${input.appId}.${extension}`);
91
+ const server = input.server.replace(/\/$/, "");
92
+ let state: z.infer<typeof enrollmentSchema>;
93
+ try {
94
+ state = enrollmentSchema.parse(JSON.parse(await readFile(provisional, "utf8")));
95
+ } catch (error) {
96
+ if (!isCode(error, "ENOENT"))
97
+ throw new Error(`Cannot read enrollment state for ${input.appId}; preserve it and repair the file before retrying`);
98
+ try {
99
+ await lstat(file);
100
+ throw new Error("Output already exists without an enrollment record; preserve it and use a new output directory");
101
+ } catch (error) {
102
+ if (!isCode(error, "ENOENT")) throw error;
103
+ }
104
+ const pair = await generateAppKeyPair();
105
+ await atomicWrite(provisional, JSON.stringify({ version: 1, server, appId: input.appId, ...pair }), {
106
+ exclusive: true,
107
+ uid: input.uid,
108
+ });
109
+ state = enrollmentSchema.parse(JSON.parse(await readFile(provisional, "utf8")));
110
+ }
111
+ if (state.server !== server || state.appId !== input.appId)
112
+ throw new Error("Enrollment belongs to a different server or app; use another output directory");
113
+ if ((await deriveAppPublicKey(state.seed)) !== state.publicKey) throw new Error("Enrollment public key does not match its seed");
114
+ await owner(provisional, input.uid);
115
+ const registered = registrationSchema.parse(
116
+ await input.register({ publicKey: state.publicKey, ...(input.name ? { name: input.name } : {}) }),
117
+ );
118
+ if (registered.fingerprint !== (await appKeyFingerprint(state.publicKey)))
119
+ throw new Error("Core returned a different app-key fingerprint");
120
+ if (registered.status !== "active")
121
+ throw new Error(`The enrolled key is ${registered.status}; use a new output directory to generate a replacement key`);
122
+ if (registered.accountStatus !== "active") throw new Error("The app service account is disabled; enable it before using this key");
123
+ const privateKey = formatAppKey({
124
+ appId: input.appId,
125
+ installationId: registered.installationId,
126
+ keyId: registered.keyId,
127
+ seed: state.seed,
128
+ });
129
+ await atomicWrite(file, renderAppKeyArtifact(input.appId, privateKey, input.format), { uid: input.uid });
130
+ // Retain the provisional seed for safe repeats after any lost API/file response.
131
+ // A rotation uses a new directory; repeating create never silently rotates.
132
+ return { appId: input.appId, ...registered, file, ...(input.format === "file" ? { environment: { CLOUD_APP_KEY_FILE: file } } : {}) };
133
+ };
134
+
135
+ export const resolveAppKeyApps = async (positional: string[], all: boolean, apps?: string): Promise<string[]> => {
136
+ if (all && (positional.length > 0 || !apps)) throw new Error("--all requires --apps <comma-separated-list|file> and no positional apps");
137
+ if (!all && apps) throw new Error("--apps requires --all");
138
+ let selected = positional;
139
+ if (all && apps) {
140
+ let raw: string;
141
+ try {
142
+ raw = await readFile(apps, "utf8");
143
+ } catch (error) {
144
+ if (!isCode(error, "ENOENT")) throw error;
145
+ raw = apps;
146
+ }
147
+ selected = raw.split(/[\s,]+/).filter(Boolean);
148
+ }
149
+ if (selected.length === 0) throw new Error("Name at least one app or use --all --apps <list|file>");
150
+ return [...new Set(selected.map(validateAppKeyAppId))];
151
+ };
152
+
153
+ export const appKeyCommands = [
154
+ command("apps keys create", {
155
+ summary: "Generate app keys locally and resume enrollment into owner-only files",
156
+ args: { apps: arg.rest({ valueLabel: "app" }) },
157
+ flags: {
158
+ all: flag.boolean({ description: "Enroll the explicit --apps list" }),
159
+ apps: flag.string({ description: "Comma/space-separated app IDs or a file containing that list" }),
160
+ outDir: flag.string({ required: true, description: "Directory for secret artifacts and resumable enrollment state" }),
161
+ format: flag.enum(["file", "env", "compose", "k8s-secret"] as const, { default: "file" }),
162
+ uid: flag.int({ min: 0, description: "File owner UID; requires root or the current UID" }),
163
+ name: flag.string({ description: "Human-readable key name" }),
164
+ },
165
+ async run({ ctx, args, flags }) {
166
+ const apps = await resolveAppKeyApps(args.apps, flags.all, flags.apps);
167
+ const results = [];
168
+ for (const appId of apps)
169
+ results.push(
170
+ await enrollAppKey({
171
+ appId,
172
+ server: ctx.options.server,
173
+ outDir: flags.outDir!,
174
+ format: flags.format ?? "file",
175
+ uid: flags.uid,
176
+ name: flags.name,
177
+ register: (body) => apiJson(ctx, "POST", keyPath(appId), body),
178
+ }),
179
+ );
180
+ if (!printStructured(ctx, results))
181
+ ctx.table(results, [
182
+ { key: "appId", label: "App" },
183
+ { key: "keyId", label: "Key ID" },
184
+ { key: "file", label: "File" },
185
+ ]);
186
+ },
187
+ }),
188
+ command("apps keys list", {
189
+ summary: "List one app's public keys and lifecycle metadata",
190
+ args: { app: arg.required({ valueLabel: "app" }) },
191
+ flags: { page: flag.int({ min: 1, default: 1 }), perPage: flag.int({ min: 1, max: 500, default: 100 }) },
192
+ async run({ ctx, args, flags }) {
193
+ validateAppKeyAppId(args.app);
194
+ const result = z
195
+ .object({
196
+ installationId: z.uuid(),
197
+ items: z.array(
198
+ z
199
+ .object({ keyId: z.string(), name: z.string(), fingerprint: z.string(), status: z.string(), accountStatus: z.string() })
200
+ .passthrough(),
201
+ ),
202
+ })
203
+ .passthrough()
204
+ .parse(await apiGet(ctx, `${keyPath(args.app)}?page=${flags.page}&perPage=${flags.perPage}`));
205
+ if (!printStructured(ctx, result))
206
+ ctx.table(result.items, [
207
+ { key: "keyId", label: "Key ID" },
208
+ { key: "name", label: "Name" },
209
+ { key: "fingerprint", label: "Fingerprint" },
210
+ { key: "status", label: "Status" },
211
+ { key: "accountStatus", label: "Account" },
212
+ ]);
213
+ },
214
+ }),
215
+ command("apps keys revoke", {
216
+ summary: "Revoke one app key idempotently",
217
+ args: { app: arg.required({ valueLabel: "app" }), keyId: arg.required({ valueLabel: "keyId" }) },
218
+ flags: { yes: confirmFlag("Revoke this app key") },
219
+ async run({ ctx, args, flags }) {
220
+ if (!flags.yes) throw new Error("Revoking an app key requires --yes");
221
+ validateAppKeyAppId(args.app);
222
+ const result = await apiJson(ctx, "DELETE", `${keyPath(args.app)}/${z.uuid().parse(args.keyId)}`);
223
+ if (!printStructured(ctx, result)) ctx.print("App key revoked.");
224
+ },
225
+ }),
226
+ ];