@k2b/cloud 0.45.0 → 0.47.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 (131) hide show
  1. package/package.json +11 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/app-queries.ts +73 -0
  5. package/src/_internal/capabilities.ts +137 -9
  6. package/src/_internal/define-app.ts +40 -0
  7. package/src/_internal/heartbeat.ts +19 -0
  8. package/src/_internal/postgres-application-name.ts +11 -0
  9. package/src/_internal/process-sync.ts +9 -0
  10. package/src/_internal/readiness.ts +6 -1
  11. package/src/_internal/registry-validation.ts +17 -4
  12. package/src/_internal/registry.ts +32 -4
  13. package/src/_internal/resource-principals.ts +6 -0
  14. package/src/_internal/workload-credential.ts +153 -0
  15. package/src/_internal/workload-protocol.ts +36 -0
  16. package/src/ai/application-tasks.ts +5 -2
  17. package/src/ai/capabilities.ts +4 -2
  18. package/src/ai/chart-block.ts +36 -711
  19. package/src/ai/chat/map-messages.ts +270 -0
  20. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  21. package/src/ai/chat/map-notebook.tsx +301 -0
  22. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  23. package/src/ai/chat/map-widget.css +107 -0
  24. package/src/ai/chat/map-widget.tsx +1279 -0
  25. package/src/ai/chat/message-utils.ts +1 -0
  26. package/src/ai/chat/tool-names.ts +2 -0
  27. package/src/ai/chat/turn-layout.ts +2 -1
  28. package/src/ai/chat/turn-view.tsx +17 -1
  29. package/src/ai/default-tool-contracts.ts +21 -259
  30. package/src/ai/default-tools.ts +3 -0
  31. package/src/ai/executor.ts +13 -1
  32. package/src/ai/map-contracts.ts +209 -0
  33. package/src/ai/map-execution.ts +14 -0
  34. package/src/ai/map-notebook-save.ts +276 -0
  35. package/src/ai/map-notebook.ts +32 -0
  36. package/src/ai/map-opening-hours.ts +40 -0
  37. package/src/ai/map-routes.ts +84 -0
  38. package/src/ai/map-service.ts +288 -0
  39. package/src/ai/map-store.ts +137 -0
  40. package/src/ai/map-tool.ts +73 -0
  41. package/src/ai/migrate.ts +3 -0
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/routes.ts +2 -0
  44. package/src/ai/store.ts +19 -6
  45. package/src/ai/tools.ts +14 -0
  46. package/src/ai/types.ts +3 -0
  47. package/src/api/admin-app-keys.ts +66 -0
  48. package/src/api/admin-core-settings.ts +2 -0
  49. package/src/api/admin-identity.ts +27 -1
  50. package/src/api/admin-settings-env.ts +120 -0
  51. package/src/api/capabilities.ts +99 -15
  52. package/src/api/index.ts +3 -0
  53. package/src/api/mcp.ts +4 -2
  54. package/src/api/settings-ops.ts +35 -0
  55. package/src/app-key.ts +125 -0
  56. package/src/browser/CloudResourceSearch.tsx +48 -41
  57. package/src/browser/notifications.ts +25 -8
  58. package/src/capabilities/discussion-providers.ts +100 -0
  59. package/src/capabilities/executions.ts +2 -2
  60. package/src/capabilities/migrate.ts +10 -1
  61. package/src/capabilities/resource-readers.ts +69 -0
  62. package/src/capabilities/response.ts +4 -1
  63. package/src/capabilities/server-invoke.ts +64 -36
  64. package/src/capabilities/server.ts +17 -6
  65. package/src/capabilities/testing.ts +13 -0
  66. package/src/capabilities/types.ts +1 -1
  67. package/src/cli/admin/app-credentials.ts +32 -7
  68. package/src/cli/admin/app-keys.ts +226 -0
  69. package/src/cli/admin/index.ts +7 -1
  70. package/src/cli/admin/settings.ts +62 -0
  71. package/src/config/define-env.ts +63 -8
  72. package/src/config/env.ts +27 -21
  73. package/src/config/index.ts +12 -1
  74. package/src/config/workflow-ai.ts +28 -0
  75. package/src/contracts/capabilities.ts +137 -4
  76. package/src/contracts/discussions.ts +91 -0
  77. package/src/contracts/geo-document.ts +60 -0
  78. package/src/contracts/geo-snapshot.ts +86 -0
  79. package/src/contracts/geo.ts +14 -0
  80. package/src/contracts/index.ts +2 -0
  81. package/src/contracts/outgoing-mail.ts +1 -1
  82. package/src/contracts/resource-types.ts +21 -0
  83. package/src/contracts/settings-env.ts +21 -0
  84. package/src/contracts/settings-types.ts +6 -0
  85. package/src/geo/AddressAutocomplete.tsx +213 -0
  86. package/src/geo/messages.ts +29 -0
  87. package/src/geo/navigation.ts +25 -0
  88. package/src/geo/ui.ts +2 -0
  89. package/src/index.ts +9 -0
  90. package/src/runtime/clients.ts +72 -0
  91. package/src/runtime/connections.ts +37 -0
  92. package/src/runtime/resolve-connections.ts +91 -0
  93. package/src/server/client-ip.ts +1 -1
  94. package/src/services/accounts/app.ts +28 -6
  95. package/src/services/accounts/entities.ts +11 -4
  96. package/src/services/accounts/groups.ts +10 -4
  97. package/src/services/accounts/local-groups.ts +87 -38
  98. package/src/services/accounts/machine-group-management.ts +94 -0
  99. package/src/services/app-keys.ts +298 -0
  100. package/src/services/gateway.ts +6 -0
  101. package/src/services/geo/cache.ts +2 -1
  102. package/src/services/geo/geoapify.ts +3 -0
  103. package/src/services/identity/index.ts +3 -0
  104. package/src/services/identity/invocation-token.ts +110 -0
  105. package/src/services/identity/resource-readers.ts +80 -0
  106. package/src/services/identity/runtime-config.ts +14 -6
  107. package/src/services/identity/workload-runtime.ts +253 -0
  108. package/src/services/index.ts +4 -1
  109. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  110. package/src/services/pdf/geo-snapshot.ts +107 -0
  111. package/src/services/pdf/index.ts +2 -0
  112. package/src/services/service-account-credentials.ts +63 -21
  113. package/src/services/settings/api.ts +3 -3
  114. package/src/services/settings/binding.ts +45 -0
  115. package/src/services/settings/core-settings.ts +99 -1
  116. package/src/services/settings/defaults.ts +16 -2
  117. package/src/services/settings/env-status.ts +69 -0
  118. package/src/services/settings/index.ts +69 -26
  119. package/src/services/settings/store.ts +73 -8
  120. package/src/services/settings/validation.ts +40 -0
  121. package/src/shared/index.ts +11 -10
  122. package/src/shared/markdown/extensions/tables.ts +10 -1
  123. package/src/ssr/AdminArea.tsx +2 -2
  124. package/src/ssr/AdminLayout.tsx +1 -1
  125. package/src/ssr/AdminSidebar.tsx +1 -1
  126. package/src/start.ts +36 -0
  127. package/src/styles/global.css +2 -0
  128. package/src/workflows/ai/index.ts +1 -0
  129. package/src/workflows/ai/runtime.ts +16 -8
  130. package/src/workflows/ai/settings.ts +5 -0
  131. package/src/shared/markdown/formula.ts +0 -1283
@@ -1,4 +1,5 @@
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,
@@ -35,6 +36,8 @@ export type CapabilityCaller = {
35
36
  /** BCP 47 locale of the originating request, forwarded as invocation metadata. */
36
37
  locale?: string | null;
37
38
  signal?: AbortSignal;
39
+ /** App-only query authority. The workload credential goes only to Core. */
40
+ workload?: { appId: string };
38
41
  /** Durable background authority. `authorization` must be the owning app's workload credential. */
39
42
  mandate?: { id: string; revision: number; callingAppId: string };
40
43
  };
@@ -49,7 +52,7 @@ export const callerRequest = async (
49
52
  ): Promise<Request> => {
50
53
  const headers = new Headers();
51
54
  if (caller.authorization) headers.set("authorization", caller.authorization);
52
- if (!caller.mandate) {
55
+ if (!caller.mandate && !caller.workload) {
53
56
  if (caller.cookie) headers.set("cookie", caller.cookie);
54
57
  if (caller.requestId) headers.set("x-request-id", caller.requestId);
55
58
  if (caller.traceparent) headers.set("traceparent", caller.traceparent);
@@ -57,10 +60,14 @@ export const callerRequest = async (
57
60
  if (caller.locale) headers.set(LOCALE_HEADER, caller.locale);
58
61
  if (idempotencyKey) headers.set("idempotency-key", idempotencyKey);
59
62
  } else {
60
- headers.set("x-cloud-app-id", caller.mandate.callingAppId);
63
+ headers.set("x-cloud-app-id", caller.mandate?.callingAppId ?? caller.workload!.appId);
61
64
  }
62
65
  const signal = combineCapabilitySignals(caller.signal, invocationSignal);
63
- return new Request(new URL(path, await coreOrigin(caller.mandate !== undefined)), { method: "POST", headers, signal });
66
+ return new Request(new URL(path, await coreOrigin(caller.mandate !== undefined || caller.workload !== undefined)), {
67
+ method: "POST",
68
+ headers,
69
+ signal,
70
+ });
64
71
  };
65
72
 
66
73
  export const unavailable = (
@@ -96,12 +103,15 @@ const invokeCapabilityWithResultSchema = async <TDataSchema extends z.ZodType, T
96
103
  caller: CapabilityCaller,
97
104
  ): Promise<CapabilityClientResult<z.output<TDataSchema>>> => {
98
105
  try {
99
- const path = caller.mandate
100
- ? "/api/_internal/identity/v1/invoke"
101
- : `/api/capabilities/v1/${invocation.kind === "query" ? "queries" : "actions"}/${encodeURIComponent(invocation.appId)}/${encodeURIComponent(invocation.capabilityId)}`;
106
+ if (caller.workload && (caller.mandate || caller.cookie || invocation.kind !== "query"))
107
+ throw new Error("Workload authority permits only queries without a mandate or cookie");
108
+ const path =
109
+ caller.mandate || caller.workload
110
+ ? "/api/_internal/identity/v1/invoke"
111
+ : `/api/capabilities/v1/${invocation.kind === "query" ? "queries" : "actions"}/${encodeURIComponent(invocation.appId)}/${encodeURIComponent(invocation.capabilityId)}`;
102
112
  const request = await callerRequest(caller, path, invocation.idempotencyKey, invocation.signal);
103
113
  if (caller.authority) {
104
- if (caller.authorization || caller.cookie || caller.mandate || caller.transport)
114
+ if (caller.authorization || caller.cookie || caller.mandate || caller.workload || caller.transport)
105
115
  throw new Error("Use exactly one capability authority source");
106
116
  const outcome = await dispatchCapability({
107
117
  request,
@@ -115,33 +125,51 @@ const invokeCapabilityWithResultSchema = async <TDataSchema extends z.ZodType, T
115
125
  });
116
126
  return readCapabilityResponse(outcome, capabilityResultSchema(dataSchema, { consumer: true }));
117
127
  }
118
- const response = await (caller.transport ?? fetch)(
119
- new Request(request, {
120
- body: JSON.stringify(
121
- caller.mandate
122
- ? {
123
- kind: invocation.kind,
124
- targetApp: invocation.appId,
125
- capabilityId: invocation.capabilityId,
126
- input: invocation.input,
127
- mandateId: caller.mandate.id,
128
- mandateRevision: caller.mandate.revision,
129
- ...(invocation.idempotencyKey ? { idempotencyKey: invocation.idempotencyKey } : {}),
130
- ...(caller.requestId || caller.traceparent || caller.tracestate || caller.locale
131
- ? {
132
- metadata: {
133
- ...(caller.requestId ? { requestId: caller.requestId } : {}),
134
- ...(caller.traceparent ? { traceparent: caller.traceparent } : {}),
135
- ...(caller.tracestate ? { tracestate: caller.tracestate } : {}),
136
- ...(caller.locale ? { locale: caller.locale } : {}),
137
- },
138
- }
139
- : {}),
140
- }
141
- : { input: invocation.input },
142
- ),
143
- }),
144
- );
128
+ const brokerRequest = new Request(request, {
129
+ body: JSON.stringify(
130
+ caller.mandate || caller.workload
131
+ ? {
132
+ kind: invocation.kind,
133
+ targetApp: invocation.appId,
134
+ capabilityId: invocation.capabilityId,
135
+ input: invocation.input,
136
+ ...(caller.mandate ? { mandateId: caller.mandate.id, mandateRevision: caller.mandate.revision } : {}),
137
+ ...(invocation.idempotencyKey ? { idempotencyKey: invocation.idempotencyKey } : {}),
138
+ ...(caller.requestId || caller.traceparent || caller.tracestate || caller.locale
139
+ ? {
140
+ metadata: {
141
+ ...(caller.requestId ? { requestId: caller.requestId } : {}),
142
+ ...(caller.traceparent ? { traceparent: caller.traceparent } : {}),
143
+ ...(caller.tracestate ? { tracestate: caller.tracestate } : {}),
144
+ ...(caller.locale ? { locale: caller.locale } : {}),
145
+ },
146
+ }
147
+ : {}),
148
+ }
149
+ : { input: invocation.input },
150
+ ),
151
+ });
152
+ const send = caller.transport ?? ((request: Request) => fetch(request, caller.workload ? { redirect: "error" } : undefined));
153
+ // Only Core's pre-dispatch workload rejection is safe to retry, including a non-idempotent action.
154
+ const workloadAppId = caller.mandate?.callingAppId ?? caller.workload?.appId;
155
+ const retryRequest = workloadAppId ? brokerRequest.clone() : undefined;
156
+ let response = await send(brokerRequest);
157
+ const credentialRejected = (reply: Response) => reply.status === 401 && reply.headers.get("x-cloud-workload-auth") === "required";
158
+ if (workloadAppId && retryRequest && credentialRejected(response)) {
159
+ await response.body?.cancel();
160
+ let token: string | undefined;
161
+ try {
162
+ token = await refreshAppWorkloadCredential(workloadAppId);
163
+ } catch {
164
+ // This rejection happened before dispatch; credential state is retryable unavailability.
165
+ }
166
+ if (token) retryRequest.headers.set("authorization", `Bearer ${token}`);
167
+ response = await send(retryRequest);
168
+ if (credentialRejected(response)) {
169
+ await response.body?.cancel();
170
+ return unavailable(undefined, undefined, caller.locale ?? undefined);
171
+ }
172
+ }
145
173
  return readCapabilityResponse(response, capabilityResultSchema(dataSchema, { consumer: true }));
146
174
  } catch (cause) {
147
175
  return unavailable(cause, invocation, caller.locale ?? undefined);
@@ -174,14 +202,14 @@ export const listCapabilityCatalog = async (
174
202
  if (options.caller) {
175
203
  const caller = options.caller;
176
204
  if (options.authority) throw new Error("Use one catalog authority source");
177
- if (caller.authority && (caller.authorization || caller.cookie || caller.mandate || caller.transport))
205
+ if (caller.authority && (caller.authorization || caller.cookie || caller.mandate || caller.workload || caller.transport))
178
206
  throw new Error("Use exactly one capability authority source");
179
207
  if (caller.authority)
180
208
  return {
181
209
  ok: true,
182
210
  data: await loadCapabilityCatalogPage({ cursor: options.cursor, limit }, {}, caller.locale ?? options.locale, caller.authority),
183
211
  };
184
- if (caller.mandate) throw new Error("Calendar discovery requires request credentials");
212
+ if (caller.mandate || caller.workload) throw new Error("Calendar discovery requires request credentials");
185
213
  const query = new URLSearchParams({ limit: String(limit) });
186
214
  if (options.cursor) query.set("cursor", options.cursor);
187
215
  const request = new Request(await callerRequest(caller, `/api/capabilities/v1/catalog?${query}`), { method: "GET" });
@@ -1,4 +1,8 @@
1
- import { resolveCapabilityActionWording, resolveCapabilityManifestPresentation } from "../_internal/capabilities";
1
+ import {
2
+ isPersonVisibleCapabilityQuery,
3
+ resolveCapabilityActionWording,
4
+ resolveCapabilityManifestPresentation,
5
+ } from "../_internal/capabilities";
2
6
  import type { CapabilityActionWording } from "../_internal/capability-sentences";
3
7
  import { getApp, getCapability } from "../_internal/registry";
4
8
  import { dispatchCapability } from "../api/capabilities";
@@ -37,18 +41,22 @@ export const getCapabilityCatalogApp = async (
37
41
  try {
38
42
  const [capability, registeredApp] = await Promise.all([getCapability(appId), getApp(appId)]);
39
43
  const presentedApp = registeredApp && locale ? resolveAppPresentation(registeredApp, locale) : registeredApp;
44
+ const manifest =
45
+ capability &&
46
+ (locale ? resolveCapabilityManifestPresentation(capability.manifest, capability.presentation, locale) : capability.manifest);
40
47
  return {
41
48
  ok: true,
42
49
  data:
43
- capability && registeredApp && (await canUseApp(authority, registeredApp))
50
+ capability && registeredApp && manifest && (await canUseApp(authority, registeredApp))
44
51
  ? {
45
52
  appId: capability.appId,
46
53
  appName: presentedApp?.name ?? capability.appName,
47
54
  appIcon: capability.appIcon,
48
55
  appDescription: presentedApp?.description ?? capability.appDescription,
49
- manifest: locale
50
- ? resolveCapabilityManifestPresentation(capability.manifest, capability.presentation, locale)
51
- : capability.manifest,
56
+ manifest: {
57
+ ...manifest,
58
+ queries: manifest.queries.filter(isPersonVisibleCapabilityQuery),
59
+ },
52
60
  }
53
61
  : null,
54
62
  };
@@ -81,6 +89,7 @@ export const reviewCapabilityAction = async <TInput = unknown>(
81
89
  caller: CapabilityCaller,
82
90
  ): Promise<CapabilityReviewClientResult> => {
83
91
  try {
92
+ if (caller.workload) throw new Error("App query authority cannot review Actions");
84
93
  if (caller.authority) {
85
94
  if (caller.authorization || caller.cookie || caller.mandate || caller.transport)
86
95
  throw new Error("Use exactly one capability authority source");
@@ -132,7 +141,7 @@ export async function transferCapabilityStream(
132
141
  caller: CapabilityCaller,
133
142
  body?: BodyInit,
134
143
  ): Promise<Response> {
135
- if (caller.mandate) throw new Error("Capability streams do not support background mandates yet");
144
+ if (caller.mandate || caller.workload) throw new Error("Capability streams require request authority");
136
145
  const path = `/api/capabilities/v1/streams/${verb}`;
137
146
  const headers = new Headers();
138
147
  headers.set("x-cloud-stream-id", stream.id);
@@ -172,6 +181,8 @@ export {
172
181
  listCalendarSources,
173
182
  readCalendarEvents,
174
183
  } from "./calendar-sources";
184
+ export { type DiscussionProvider, discussionProviders } from "./discussion-providers";
185
+ export { ResourceReaderRateLimited, type ResourceReadersResult, readResourcePrincipals } from "./resource-readers";
175
186
  export {
176
187
  actionReview,
177
188
  type CapabilityReviewForms,
@@ -14,6 +14,8 @@ import {
14
14
  type CapabilityResult,
15
15
  CapabilitySemanticLinkSchema,
16
16
  type CapabilitySlot,
17
+ CloudResourceReaderViewSchema,
18
+ type CloudResourceRef,
17
19
  } from "../contracts/capabilities";
18
20
 
19
21
  /** Compile and validate the public manifest an app would register at startup. */
@@ -36,6 +38,17 @@ export const assertResourceOpenLink = (result: Pick<CapabilityResult<unknown>, "
36
38
  return CapabilitySemanticLinkSchema.parse(link);
37
39
  };
38
40
 
41
+ /** Assert the canonical reader presentation and, for contained types, its exact container. */
42
+ export const assertResourceView = (result: Pick<CapabilityResult<unknown>, "data">, container?: CloudResourceRef) => {
43
+ const data = result.data;
44
+ const view = CloudResourceReaderViewSchema.parse(
45
+ data !== null && typeof data === "object" ? Object.getOwnPropertyDescriptor(data, "view")?.value : undefined,
46
+ );
47
+ if (container && (view.container?.ref.type !== container.type || view.container.ref.id !== container.id))
48
+ throw new Error("Resource reader view must identify the expected container");
49
+ return view;
50
+ };
51
+
39
52
  /** Validate each localized review in application tests, with optional declaration context. */
40
53
  export const assertCapabilityReview = (review: unknown, context?: CapabilityReviewContext): void => {
41
54
  const parsed = CapabilityActionReviewDataSchema.safeParse(review);
@@ -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,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
+ ];
@@ -14,6 +14,7 @@ import { aiQuotaCommands } from "./ai-quotas";
14
14
  import { aiSkillCommands } from "./ai-skills";
15
15
  import { aiUsageCommands } from "./ai-usage";
16
16
  import { appCredentialCommands } from "./app-credentials";
17
+ import { appKeyCommands } from "./app-keys";
17
18
  import { appSignInCommands } from "./app-sign-in";
18
19
  import { dataCommands } from "./data";
19
20
  import { documentationCommands } from "./documentation";
@@ -28,6 +29,7 @@ import { metricsCommands } from "./metrics";
28
29
  import { natsCommands } from "./nats";
29
30
  import { notificationCommands } from "./notifications";
30
31
  import { outgoingMailCommands } from "./outgoing-mail";
32
+ import { settingsCommands } from "./settings";
31
33
  import { syncCommands } from "./sync";
32
34
  import { telemetryCommands } from "./telemetry";
33
35
  import { webhookCommands } from "./webhooks";
@@ -51,7 +53,8 @@ export default defineCliCommands({
51
53
  agents: "Create, list, revoke, and re-key agent accounts with OAuth client credentials, and change their pictures.",
52
54
  "agents avatar": "Show and change an agent's picture: its robot parts or a photo.",
53
55
  "app-sign-in": "Manage app sign-in and pairing.",
54
- apps: "Inspect and remove registered apps.",
56
+ apps: "Inspect registered apps and manage app keys.",
57
+ "apps keys": "Create, list, and revoke Ed25519 app keys.",
55
58
  documentation: "Manage the administration documentation website.",
56
59
  geoip: "Replace and inspect sign-in location databases.",
57
60
  jobs: "Inspect background jobs and their runs.",
@@ -71,6 +74,7 @@ export default defineCliCommands({
71
74
  postgres: "Inspect Postgres diagnostics.",
72
75
  redis: "Inspect Valkey diagnostics.",
73
76
  routes: "Inspect gateway routes.",
77
+ settings: "Inspect environment migration and change administrator settings.",
74
78
  sync: "Inspect Sync resources, schedules, and dead letters.",
75
79
  "sync dead-letters": "Inspect, replay, requeue, or remove dead letters.",
76
80
  "sync resources": "List Sync resource declarations.",
@@ -90,6 +94,7 @@ export default defineCliCommands({
90
94
  },
91
95
  commands: [
92
96
  ...appCredentialCommands,
97
+ ...appKeyCommands,
93
98
  ...agentCommands,
94
99
  ...documentationCommands,
95
100
  ...appSignInCommands,
@@ -115,5 +120,6 @@ export default defineCliCommands({
115
120
  ...metricsCommands,
116
121
  ...natsCommands,
117
122
  ...syncCommands,
123
+ ...settingsCommands,
118
124
  ],
119
125
  });
@@ -0,0 +1,62 @@
1
+ import { EnvSettingsStatusSchema } from "../../contracts/settings-env";
2
+ import { arg, command, confirmFlag, flag, printStructured } from "../index";
3
+ import { apiGet, apiJson } from "./shared";
4
+
5
+ export const settingsCommands = [
6
+ command("settings status", {
7
+ summary: "Check environment-backed settings across registered processes without printing values",
8
+ flags: { env: flag.boolean({ description: "Check stored values against each running application's environment" }) },
9
+ async run({ ctx, flags }) {
10
+ if (!flags.env) throw new Error("Use settings status --env.");
11
+ const result = EnvSettingsStatusSchema.parse(await apiGet(ctx, "/api/admin/core/settings/env/status"));
12
+ if (!printStructured(ctx, result)) {
13
+ for (const process of result.processes) {
14
+ ctx.print(`${process.appId}: ${process.status}`);
15
+ ctx.table(
16
+ process.settings,
17
+ [
18
+ "key",
19
+ "envVariable",
20
+ "storedValuePresent",
21
+ "envValuePresent",
22
+ "conflict",
23
+ "unreadableStoredValue",
24
+ "invalidEnvValue",
25
+ "needsAttention",
26
+ ].map((key) => ({ key })),
27
+ );
28
+ }
29
+ }
30
+ return result.needsAttention ? 1 : 0;
31
+ },
32
+ }),
33
+ command("settings set", {
34
+ summary: "Set a whole administrator setting value",
35
+ args: { key: arg.required() },
36
+ flags: {
37
+ value: flag.string({ required: true, description: "JSON value (quote a text value as JSON)" }),
38
+ app: flag.string({ description: "Owning application ID; omit for platform settings" }),
39
+ replaceUnreadable: flag.boolean({ description: "Explicitly replace an unreadable stored row with this whole value" }),
40
+ yes: confirmFlag("Confirm setting change"),
41
+ },
42
+ async run({ ctx, args, flags }) {
43
+ if (!flags.yes) throw new Error("Setting changes require --yes.");
44
+ let value: unknown;
45
+ try {
46
+ value = JSON.parse(flags.value ?? "");
47
+ } catch {
48
+ throw new Error("--value must be valid JSON.");
49
+ }
50
+ const result = flags.app
51
+ ? await apiJson(ctx, "PUT", `/api/admin/core/settings/env/${encodeURIComponent(flags.app)}/${encodeURIComponent(args.key)}`, {
52
+ value,
53
+ replaceUnreadable: flags.replaceUnreadable,
54
+ })
55
+ : await apiJson(ctx, "PUT", "/api/admin/core/settings", {
56
+ updates: { [args.key]: value },
57
+ replaceUnreadable: flags.replaceUnreadable ? [args.key] : [],
58
+ });
59
+ if (!printStructured(ctx, result)) ctx.print("Setting saved.");
60
+ },
61
+ }),
62
+ ];