@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
package/src/ai/tools.ts CHANGED
@@ -4,6 +4,7 @@ import type { z } from "zod";
4
4
  import type { CapabilityActionCallReview } from "../contracts/capabilities";
5
5
  import type { RequestActor } from "../server";
6
6
  import { aiToolNeedsApproval } from "./approvals";
7
+ import { resolveAiToolResultMaxChars } from "./tool-result-budget";
7
8
  import type {
8
9
  AiApprovalTarget,
9
10
  AiDataBoundary,
@@ -64,6 +65,8 @@ export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType
64
65
  timeZone?: string;
65
66
  /** Localized short status; never include payloads or secrets. */
66
67
  reportProgress?: (message: string) => Promise<void>;
68
+ /** A validated result snapshot while a server tool is still running. */
69
+ reportResult?: (result: z.infer<TOutput>) => Promise<void>;
67
70
  /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
68
71
  requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
69
72
  /** Relays a decision for an effect another platform component consumes and executes. */
@@ -117,6 +120,8 @@ export type AiToolPreparationContext = {
117
120
  locale?: string;
118
121
  timeZone?: string;
119
122
  reportToolProgress?: (callId: string, message: string) => Promise<void>;
123
+ reportToolResult?: (callId: string, result: unknown) => Promise<void>;
124
+ maxToolResultChars?: number;
120
125
  /** Uses the approved snapshot immediately before the tool executes its effect. */
121
126
  consumeApproval?: (approvalCallId: string, input: unknown) => Promise<AiApprovalUse>;
122
127
  /** Links a relayed decision to the record the effect owner will consume. */
@@ -241,6 +246,15 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
241
246
  locale: input.locale,
242
247
  timeZone: input.timeZone,
243
248
  reportProgress: callId && report ? (message) => report(callId, message) : undefined,
249
+ reportResult:
250
+ callId && input.reportToolResult
251
+ ? async (result) => {
252
+ const validated = tool.def.outputSchema.parse(result);
253
+ const maximum = input.maxToolResultChars ?? resolveAiToolResultMaxChars({ configuredMaxChars: Number.MAX_SAFE_INTEGER });
254
+ if (JSON.stringify(validated).length > maximum) throw new Error("AI_TOOL_INTERIM_RESULT_TOO_LARGE");
255
+ await input.reportToolResult!(callId, validated);
256
+ }
257
+ : undefined,
244
258
  reportWebsiteReceipts:
245
259
  callId && input.reportWebsiteReceipts ? (receipts) => input.reportWebsiteReceipts!(callId, receipts) : undefined,
246
260
  });
package/src/ai/types.ts CHANGED
@@ -313,6 +313,7 @@ export type AiStoredMessage = {
313
313
  toolPresentations?: Record<string, AiToolPresentation>;
314
314
  toolReviews?: Record<string, AiStoredActionReview>;
315
315
  toolChecks?: Record<string, AiActionCheck>;
316
+ toolInterimResults?: Record<string, unknown>;
316
317
  /** How the person decided an approval; `expired` when the turn ended while it still waited. */
317
318
  toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
318
319
  /** Requests a website approval let through, by tool call and then by `AiWebsiteReceipt.index`. */
@@ -1159,6 +1160,8 @@ export type AiToolRuntime<TInput extends z.ZodType = z.ZodType, TOutput extends
1159
1160
  timeZone?: string;
1160
1161
  /** Localized short status; never include payloads or secrets. */
1161
1162
  reportProgress?: (message: string) => Promise<void>;
1163
+ /** A validated result snapshot while a server tool is still running. */
1164
+ reportResult?: (result: z.infer<TOutput>) => Promise<void>;
1162
1165
  /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
1163
1166
  requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
1164
1167
  /** Relays a decision for an effect another platform component consumes and executes. */
@@ -0,0 +1,66 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import { Hono } from "hono";
3
+ import { describeRoute } from "hono-openapi";
4
+ import { z } from "zod";
5
+ import { validateAppKeyAppId } from "../app-key";
6
+ import { type AuthContext, auth, respond, v } from "../server";
7
+ import { noTransform } from "../server/middleware/no-transform";
8
+ import { appKeys } from "../services/app-keys";
9
+
10
+ const appId = z.string().refine((value) => {
11
+ try {
12
+ validateAppKeyAppId(value);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }, "Invalid or reserved app ID");
18
+ const params = z.object({ appId });
19
+ const pagination = z.object({
20
+ page: z.coerce.number().int().min(1).default(1),
21
+ perPage: z.coerce.number().int().min(1).max(500).default(100),
22
+ });
23
+ const input = z
24
+ .object({ publicKey: z.string().length(43), name: z.string().trim().min(1).max(120).optional(), expiresAt: z.iso.datetime().optional() })
25
+ .strict();
26
+
27
+ export const createAdminAppKeyRoutes = (
28
+ authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin"),
29
+ service: typeof appKeys = appKeys,
30
+ ) =>
31
+ new Hono<AuthContext>()
32
+ .use("*", noTransform())
33
+ .use(authenticate)
34
+ .use("*", async (c, next) => {
35
+ c.header("Cache-Control", "no-store");
36
+ if (["POST", "DELETE"].includes(c.req.method)) return auth.requireOAuthScope("write", "admin")(c, next);
37
+ return next();
38
+ })
39
+ .get(
40
+ "/keys",
41
+ describeRoute({ tags: ["App keys"], summary: "List apps with enrolled keys and the installation ID" }),
42
+ v("query", pagination),
43
+ (c) => respond(c, () => service.listApps({ actor: c.get("user"), ...c.req.valid("query") })),
44
+ )
45
+ .get(
46
+ "/:appId/keys",
47
+ describeRoute({ tags: ["App keys"], summary: "List app public keys and lifecycle metadata" }),
48
+ v("param", params),
49
+ v("query", pagination),
50
+ (c) => respond(c, () => service.list({ actor: c.get("user"), ...c.req.valid("param"), ...c.req.valid("query") })),
51
+ )
52
+ .post(
53
+ "/:appId/keys",
54
+ describeRoute({ tags: ["App keys"], summary: "Idempotently enroll an Ed25519 public key; at most two active keys per app" }),
55
+ v("param", params),
56
+ v("json", input),
57
+ (c) => respond(c, () => service.register({ actor: c.get("user"), ...c.req.valid("param"), ...c.req.valid("json") })),
58
+ )
59
+ .delete(
60
+ "/:appId/keys/:keyId",
61
+ describeRoute({ tags: ["App keys"], summary: "Idempotently revoke an app key" }),
62
+ v("param", params.extend({ keyId: z.uuid() })),
63
+ (c) => respond(c, () => service.revoke({ actor: c.get("user"), ...c.req.valid("param") })),
64
+ );
65
+
66
+ export default createAdminAppKeyRoutes();
@@ -27,6 +27,7 @@ import { SETTINGS_MAP, validateSettingValue } from "../services/settings/default
27
27
  import { assertGeoBudgetPair, GeoBudgetSettingError } from "../services/settings/geo-budget";
28
28
  import { invalidateSettingsCacheForAdmin } from "../services/settings/store";
29
29
  import { assertSettingVersions } from "../services/settings/versions";
30
+ import { adminSettingsEnvRoutes } from "./admin-settings-env";
30
31
 
31
32
  const BulkUpdatesSchema = z
32
33
  .object({
@@ -72,6 +73,7 @@ const invalidateCommittedSettings = async (keys: readonly string[]): Promise<voi
72
73
  const liveSettingKeys = async () => (await listApps()).flatMap((app) => [...(app.settingKeys ?? [])]);
73
74
 
74
75
  const app = new Hono<AuthContext>()
76
+ .route("/env", adminSettingsEnvRoutes)
75
77
  .get("/web-vitals", auth.requireRole("admin"), async (c) =>
76
78
  c.json({ enabled: (await settings.get("observability.web_vitals.enabled")) === true }),
77
79
  )
@@ -86,7 +86,10 @@ type IdentityAdminDependencies = {
86
86
  status: typeof getIdentitySigningKeyStatus;
87
87
  rewrap: typeof rewrapIdentitySigningKeys;
88
88
  revoke: typeof revokeIdentitySigningKey;
89
- serviceAccounts: Pick<typeof serviceAccounts, "getOrCreateResourceBound" | "createStandalone" | "listStandalone" | "get" | "setStatus">;
89
+ serviceAccounts: Pick<
90
+ typeof serviceAccounts,
91
+ "getByResource" | "getOrCreateResourceBound" | "createStandalone" | "listStandalone" | "get" | "setStatus"
92
+ >;
90
93
  credentials: Pick<
91
94
  typeof serviceAccountCredentials,
92
95
  "createResourceApiToken" | "createStandaloneApiToken" | "getOverview" | "listOverview" | "revoke"
@@ -151,6 +154,29 @@ export const createAdminIdentityRoutes = (
151
154
  }),
152
155
  ),
153
156
  )
157
+ .patch(
158
+ "/workloads/:appId",
159
+ describeRoute({ tags: ["App credentials"], summary: "Enable or disable app workload issuance and calls across all instances" }),
160
+ v("param", WorkloadAppParamsSchema),
161
+ v("json", ServiceAccountStatusInputSchema),
162
+ async (c) =>
163
+ respond(c, async () => {
164
+ const { appId } = c.req.valid("param");
165
+ const { status } = c.req.valid("json");
166
+ const account = await service.serviceAccounts.getByResource({ appId, resourceType: "cloud.app", resourceId: appId });
167
+ if (!account) return fail(err.notFound("Workload service account"));
168
+ const updated = await service.serviceAccounts.setStatus({ id: account.id, status });
169
+ if (!updated.ok) return updated;
170
+ await service.audit.record({
171
+ action: status === "disabled" ? "service_account.disable" : "service_account.enable",
172
+ outcome: "allowed",
173
+ actor: auditActor(c.get("user")),
174
+ target: { type: "service_account", id: account.id, label: account.name },
175
+ metadata: { appId, serviceAccountId: account.id, status },
176
+ });
177
+ return ok({ appId, status });
178
+ }),
179
+ )
154
180
  .post(
155
181
  "/workloads/:appId/credentials",
156
182
  describeRoute({ tags: ["App credentials"], summary: "Create an app-bound credential; return its token once" }),
@@ -0,0 +1,120 @@
1
+ import { Hono } from "hono";
2
+ import { describeRoute } from "hono-openapi";
3
+ import { z } from "zod";
4
+ import { readBoundedJson } from "../_internal/bounded-json";
5
+ import { getApp, listApps } from "../_internal/registry";
6
+ import { EnvSettingStatusSchema, type EnvSettingsProcessSchema, EnvSettingsStatusSchema } from "../contracts/settings-env";
7
+ import { type AuthContext, auth, jsonResponse, requiresAdmin, setRequestTimeout, v } from "../server";
8
+ import { listGatewayRouteSnapshots } from "../services/gateway";
9
+ import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
10
+ import { issueInvocationToken } from "../services/identity/issue-invocation";
11
+ import { withActiveIdentitySigner } from "../services/identity/key-ring";
12
+ import { readEnvSettingsStatus } from "../services/settings/env-status";
13
+ import { settingsInvocationOperation } from "./settings-ops";
14
+
15
+ const reply = z.object({ settings: z.array(EnvSettingStatusSchema) });
16
+ export const adminSettingsEnvRoutes = new Hono<AuthContext>()
17
+ .use("*", auth.requireRole("admin"))
18
+ .use("*", auth.requireOAuthScope("admin"))
19
+ .get(
20
+ "/status",
21
+ describeRoute({
22
+ tags: ["Administration"],
23
+ summary: "Check environment-backed settings without returning values",
24
+ ...requiresAdmin,
25
+ responses: { 200: jsonResponse(EnvSettingsStatusSchema, "Migration status") },
26
+ }),
27
+ async (c) => {
28
+ setRequestTimeout(c, 240);
29
+ const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(230_000)]);
30
+ try {
31
+ const processes: z.infer<typeof EnvSettingsProcessSchema>[] = [
32
+ { appId: "core", status: "ok", settings: await readEnvSettingsStatus() },
33
+ ];
34
+ for (const app of await listApps()) {
35
+ if (app.id === "core") continue;
36
+ try {
37
+ const response = await requestSettings(app, "GET", "env", auth.getAuthority(c), undefined, signal);
38
+ const data = await readBoundedJson(response, 4 * 1024 * 1024);
39
+ if (!response.ok || !data.ok) throw new Error("Unavailable");
40
+ processes.push({ appId: app.id, status: "ok", settings: reply.parse(data.data).settings });
41
+ } catch {
42
+ processes.push({ appId: app.id, status: "unavailable", settings: [] });
43
+ }
44
+ }
45
+ const gateways = await listGatewayRouteSnapshots();
46
+ if (!gateways.length) processes.push({ appId: "gateway", status: "unavailable", settings: [] });
47
+ for (const gateway of gateways) {
48
+ const parsed = z.array(EnvSettingStatusSchema).safeParse(gateway.envSettings);
49
+ processes.push({
50
+ appId: "gateway",
51
+ instanceId: gateway.instanceId,
52
+ sampledAt: gateway.updatedAt,
53
+ status: parsed.success ? "ok" : "unavailable",
54
+ settings: parsed.success ? parsed.data : [],
55
+ });
56
+ }
57
+ return c.json(
58
+ { needsAttention: processes.some((p) => p.status !== "ok" || p.settings.some((s) => s.needsAttention)), processes },
59
+ 200,
60
+ { "cache-control": "no-store" },
61
+ );
62
+ } finally {
63
+ setRequestTimeout(c, 10);
64
+ }
65
+ },
66
+ )
67
+ .put("/:appId/:key", v("json", z.object({ value: z.unknown(), replaceUnreadable: z.boolean().optional() })), async (c) => {
68
+ const app = await getApp(c.req.param("appId") ?? "");
69
+ if (!app) return c.json({ message: "App is not registered; start it before changing its settings" }, 404);
70
+ try {
71
+ const response = await requestSettings(
72
+ app,
73
+ "PUT",
74
+ c.req.param("key") ?? "",
75
+ auth.getAuthority(c),
76
+ c.req.valid("json"),
77
+ c.req.raw.signal,
78
+ );
79
+ const body = await readBoundedJson(response, 4 * 1024 * 1024);
80
+ if (!body.ok) throw new Error("Unavailable");
81
+ return Response.json(body.data, { status: response.status, headers: { "cache-control": "no-store" } });
82
+ } catch {
83
+ return c.json({ message: "Application settings unavailable" }, 502);
84
+ }
85
+ });
86
+
87
+ const requestSettings = async (
88
+ app: { id: string; baseUrl: string },
89
+ method: "GET" | "PUT",
90
+ key: string,
91
+ authority: Parameters<typeof invocationAuthorityFromRequest>[0],
92
+ body: unknown,
93
+ parentSignal: AbortSignal,
94
+ ) => {
95
+ const signal = AbortSignal.any([parentSignal, AbortSignal.timeout(5_000)]);
96
+ const signed = await withActiveIdentitySigner(
97
+ "invocation",
98
+ (signer, db) =>
99
+ issueInvocationToken(
100
+ {
101
+ targetAppId: app.id,
102
+ callingAppId: "core",
103
+ operation: settingsInvocationOperation(method, key),
104
+ schemaHash: null,
105
+ authority: invocationAuthorityFromRequest(authority),
106
+ signer,
107
+ issuer: signer.issuer,
108
+ },
109
+ { db, groupsAdmin: signer.groupsAdmin, signal },
110
+ ),
111
+ { signal, timeoutMs: 5_000 },
112
+ );
113
+ return fetch(new URL(`/_internal/settings/${encodeURIComponent(key)}`, app.baseUrl), {
114
+ method,
115
+ headers: { authorization: `Bearer ${signed.token}`, "content-type": "application/json" },
116
+ body: body === undefined ? undefined : JSON.stringify(body),
117
+ signal,
118
+ redirect: "manual",
119
+ });
120
+ };
@@ -3,14 +3,14 @@ import { describeRoute } from "hono-openapi";
3
3
  import { z } from "zod";
4
4
  import { type CatalogSubscription, projectAppEventCatalog, readCatalogSubscriptions } from "../_internal/app-event-catalog";
5
5
  import { readBoundedJson } from "../_internal/bounded-json";
6
- import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
6
+ import { isPersonVisibleCapabilityQuery, resolveCapabilityManifestPresentation } from "../_internal/capabilities";
7
7
  import {
8
8
  capabilityActionReceiptIssues,
9
9
  capabilityActionReviewIssues,
10
10
  capabilityQueryChecksActionOutcome,
11
11
  } from "../_internal/capability-reviews";
12
12
  import { capabilityTimeoutMs, createCapabilityDeadline, withCapabilityRequestTimeout } from "../_internal/capability-timeout";
13
- import { getCapability, listApps } from "../_internal/registry";
13
+ import { getApp, getCapability, listApps } from "../_internal/registry";
14
14
  import { resolveCommandLink } from "../capabilities/command-link";
15
15
  import {
16
16
  type CapabilityClaimScope,
@@ -59,12 +59,13 @@ import { AppAccessDeniedError, appAccessDeniedBody } from "../server/middleware/
59
59
  import { oauthScopeByOperation } from "../server/middleware/auth";
60
60
  import { noTransform } from "../server/middleware/no-transform";
61
61
  import { hasOAuthScope } from "../server/oauth-scope";
62
+ import { ratelimit } from "../server/ratelimit";
62
63
  import { appAccessAuthority, canUseApp } from "../services/app-access";
63
64
  import { discoverableApps } from "../services/app-discovery";
64
65
  import { authorityFromParentInvocation, invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
65
66
  import { capabilityInvocationOperation } from "../services/identity/invocation-operations";
66
67
  import type { CloudInvocationClaims, InvocationAuthority } from "../services/identity/invocation-token";
67
- import { normalizeInvocationRequestId } from "../services/identity/invocation-token";
68
+ import { normalizeInvocationRequestId, signAppQueryInvocation } from "../services/identity/invocation-token";
68
69
  import { issueInvocationToken as signInvocationToken } from "../services/identity/issue-invocation";
69
70
  import { withActiveIdentitySigner } from "../services/identity/key-ring";
70
71
  import {
@@ -73,6 +74,7 @@ import {
73
74
  nestedInvocationLeases,
74
75
  nestedSourceDeadline,
75
76
  } from "../services/identity/nested-invocation";
77
+ import type { AuthenticatedWorkload } from "../services/identity/workload-auth";
76
78
  import { logger } from "../services/logging";
77
79
  import { withMandateIssueAuthority } from "../services/mandates";
78
80
  import * as settings from "../services/settings";
@@ -80,6 +82,9 @@ import { resolveAppPresentation } from "../shared/app-presentation";
80
82
  import { capabilityMessages } from "../shared/capability-messages";
81
83
  import { dispatchCapabilityStream, sealCapabilityStream } from "./capability-streams";
82
84
 
85
+ // Approved reader-check budget, shared across targets for each calling application.
86
+ export const APP_QUERY_REQUESTS_PER_MINUTE = 1_501;
87
+ const appQueryLimit = ratelimit({ id: "app-queries", limit: APP_QUERY_REQUESTS_PER_MINUTE, windowSecs: 60 });
83
88
  const log = logger("capabilities");
84
89
  const DEFAULT_CATALOG_LIMIT = 10;
85
90
  const MAX_CATALOG_LIMIT = 25;
@@ -114,6 +119,7 @@ const CapabilityCatalogQuerySchema = z
114
119
  export type CapabilityRouteDependencies = {
115
120
  listApps?: () => Promise<AppRegistryEntry[]>;
116
121
  eventSubscriptions?: () => Promise<CatalogSubscription[]>;
122
+ getApp?: (appId: string) => Promise<AppRegistryEntry | null>;
117
123
  getCapability?: (appId: string) => Promise<CapabilityRegistryEntry | null>;
118
124
  fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
119
125
  authenticate?: MiddlewareHandler<AuthContext>;
@@ -122,12 +128,15 @@ export type CapabilityRouteDependencies = {
122
128
  withMandateIssueAuthority?: typeof withMandateIssueAuthority;
123
129
  queryTimeoutMs?: number;
124
130
  actionTimeoutMs?: number;
131
+ checkAppQueryRate?: (appId: string) => Promise<{ limited: boolean }>;
125
132
  reviewTimeoutMs?: number;
126
133
  appUrl?: () => Promise<string>;
127
134
  };
128
135
 
129
136
  export type CapabilityDispatchDependencies = Pick<
130
137
  CapabilityRouteDependencies,
138
+ | "checkAppQueryRate"
139
+ | "getApp"
131
140
  | "getCapability"
132
141
  | "fetch"
133
142
  | "queryTimeoutMs"
@@ -223,7 +232,7 @@ export const loadCapabilityCatalogPage = async (
223
232
  appName: presentedEntry.name,
224
233
  appIcon: capability.appIcon,
225
234
  appDescription: presentedEntry.description,
226
- manifest,
235
+ manifest: { ...manifest, queries: manifest.queries.filter(isPersonVisibleCapabilityQuery) },
227
236
  ...(manifest.events ? { events: projectAppEventCatalog(manifest, manifest.events.length ? await loadSubscribers() : []) } : {}),
228
237
  };
229
238
  const projectedBytes = new TextEncoder().encode(JSON.stringify(projected)).byteLength;
@@ -251,11 +260,17 @@ const errorResponse = (code: string, message: string, status: number, details?:
251
260
  status,
252
261
  });
253
262
 
254
- const capabilityJsonResponse = (body: unknown, status: number): Response =>
255
- Response.json(body, {
263
+ const capabilityJsonResponse = (body: unknown, status: number): Response => {
264
+ const limited =
265
+ status === 429 ? z.object({ details: z.object({ retryAfter: z.number().int().positive().safe() }) }).safeParse(body) : null;
266
+ return Response.json(body, {
256
267
  status,
257
- headers: { "content-type": "application/json" },
268
+ headers: {
269
+ "content-type": "application/json",
270
+ ...(limited?.success ? { "retry-after": String(limited.data.details.retryAfter) } : {}),
271
+ },
258
272
  });
273
+ };
259
274
 
260
275
  export type CapabilityDispatchParams = {
261
276
  /** Trusted Core continuation binding; never read from client headers. */
@@ -276,6 +291,8 @@ export type CapabilityDispatchParams = {
276
291
  assistantTurn?: { conversationId: string; turnId: string };
277
292
  /** Core-only absolute child deadline; cannot be extended by the application. */
278
293
  deadline?: number;
294
+ /** Core-only authenticated workload. Never populated from request JSON. */
295
+ workload?: AuthenticatedWorkload;
279
296
  mandate?: MandatedCapabilityAuthority;
280
297
  callingAppId?: string;
281
298
  /** Internal observation only: called immediately before attempting the app request. */
@@ -342,6 +359,43 @@ const runCapabilityDispatch = async (
342
359
  );
343
360
  return error;
344
361
  }
362
+ if ("appOnly" in operation && operation.appOnly !== undefined && !params.workload)
363
+ return errorResponse("FORBIDDEN", "App authority required for this query", 403);
364
+ if (params.workload) {
365
+ if (
366
+ params.authority ||
367
+ params.mandate ||
368
+ params.parentInvocation ||
369
+ params.review ||
370
+ params.kind !== "queries" ||
371
+ !("appOnly" in operation) ||
372
+ operation.appOnly !== true ||
373
+ operation.stream
374
+ )
375
+ return errorResponse("FORBIDDEN", "App caller is not allowed for this query", 403);
376
+ try {
377
+ const caller = await deadline.wait(() => (params.dependencies?.getApp ?? getApp)(params.workload!.appId));
378
+ if (!caller) return errorResponse("APP_UNAVAILABLE", "Calling app registry entry unavailable", 503);
379
+ if (!caller.platformPermissions?.includes("resources:readers"))
380
+ return errorResponse("APP_QUERY_NOT_ADMITTED", "App caller does not declare resources:readers", 403);
381
+ } catch {
382
+ deadline.check();
383
+ return errorResponse("APP_UNAVAILABLE", "App registry unavailable", 503);
384
+ }
385
+ try {
386
+ if (
387
+ (
388
+ await deadline.wait(() =>
389
+ (params.dependencies?.checkAppQueryRate ?? ((appId) => appQueryLimit.check(appId)))(params.workload!.appId),
390
+ )
391
+ ).limited
392
+ )
393
+ return errorResponse("RATE_LIMITED", "App query rate limit exceeded; retry after one minute", 429);
394
+ } catch {
395
+ deadline.check();
396
+ return errorResponse("APP_UNAVAILABLE", "App query rate limiter unavailable", 503);
397
+ }
398
+ }
345
399
  if (params.review && (params.kind !== "actions" || !("review" in operation) || operation.review !== true)) {
346
400
  const error = errorResponse(
347
401
  "CAPABILITY_NOT_FOUND",
@@ -488,6 +542,33 @@ const runCapabilityDispatch = async (
488
542
  });
489
543
  })()
490
544
  : await (async () => {
545
+ if (params.workload) {
546
+ const workload = params.workload;
547
+ return deadline.wait(() =>
548
+ (params.dependencies?.withActiveSigner ?? withActiveIdentitySigner)(
549
+ "invocation",
550
+ async (signer) => {
551
+ deadline.check();
552
+ const signed = await signAppQueryInvocation({
553
+ workload,
554
+ targetAppId: params.appId,
555
+ capabilityId: params.capabilityId,
556
+ schemaHash: operation.schemaHash,
557
+ input: input.data,
558
+ requestId,
559
+ signer,
560
+ });
561
+ observed.invocationId = signed.claims.jti;
562
+ return new Headers({
563
+ "content-type": "application/json",
564
+ accept: "application/json",
565
+ authorization: `Bearer ${signed.token}`,
566
+ });
567
+ },
568
+ { signal, timeoutMs: deadline.remainingMs() },
569
+ ),
570
+ );
571
+ }
491
572
  if (!params.authority) throw new Error("Resolved request authority is required for Cloud invocation issuance");
492
573
  const authority = params.authority;
493
574
  const signed = await deadline.wait(() =>
@@ -665,12 +746,15 @@ const runCapabilityDispatch = async (
665
746
  try {
666
747
  params.onUpstreamAttempt?.();
667
748
  const fetchResponse = () =>
668
- fetchUpstream(`${entry.endpoint}/${params.kind}/${encodeURIComponent(params.capabilityId)}${params.review ? "/review" : ""}`, {
669
- method: "POST",
670
- headers,
671
- body: requestBody,
672
- signal,
673
- });
749
+ fetchUpstream(
750
+ `${entry.endpoint}/${params.workload ? "app-queries" : params.kind}/${encodeURIComponent(params.capabilityId)}${params.review ? "/review" : ""}`,
751
+ {
752
+ method: "POST",
753
+ headers,
754
+ body: requestBody,
755
+ signal,
756
+ },
757
+ );
674
758
  // A read may be released at its deadline; Action effects retain their existing uncertainty handling.
675
759
  response = params.kind === "queries" || params.review ? await deadline.wait(fetchResponse) : await fetchResponse();
676
760
  } catch (error) {
@@ -895,12 +979,12 @@ export const dispatchCapability = async (params: CapabilityDispatchParams): Prom
895
979
  origin: params.origin,
896
980
  invocationId: observed.invocationId,
897
981
  parentInvocationId: params.parentInvocation?.jti,
898
- viaAppId: params.parentInvocation ? params.callingAppId : undefined,
982
+ viaAppId: params.workload?.appId ?? (params.parentInvocation ? params.callingAppId : undefined),
899
983
  appId: params.appId,
900
984
  capability: `${params.appId}.${params.capabilityId}`,
901
985
  kind: params.kind === "queries" ? "query" : "action",
902
986
  destructive: observed.destructive,
903
- actorKind: principal?.type ?? null,
987
+ actorKind: params.workload ? "app" : (principal?.type ?? null),
904
988
  actorId: principal?.id ?? null,
905
989
  userId: actor?.kind === "user" ? actor.user.id : (actor?.delegatedUser?.id ?? (principal?.type === "user" ? principal.id : null)),
906
990
  accessSubject: subject && subject.id !== principal?.id ? subject : null,
package/src/api/index.ts CHANGED
@@ -26,6 +26,7 @@ import adminAiProjectsRoutes from "./admin-ai-projects";
26
26
  import adminAiQuotaRoutes from "./admin-ai-quotas";
27
27
  import adminAiSkillsRoutes from "./admin-ai-skills";
28
28
  import adminAiUsageRoutes from "./admin-ai-usage";
29
+ import adminAppKeyRoutes from "./admin-app-keys";
29
30
  import adminCoreSettingsRoutes from "./admin-core-settings";
30
31
  import adminDirectorySharesRoutes from "./admin-directory-shares";
31
32
  import adminGeoRoutes from "./admin-geo";
@@ -106,6 +107,7 @@ const buildCoreApi = (options: CoreApiOptions) => {
106
107
  .route("/admin/core/geo", adminGeoRoutes)
107
108
  .route("/admin/identity/directory-shares", adminDirectorySharesRoutes)
108
109
  .route("/admin/identity", adminIdentityRoutes)
110
+ .route("/admin/apps", adminAppKeyRoutes)
109
111
  .route("/admin/sync", createSyncOpsProxyRoutes())
110
112
  .route("/admin/lifecycle", adminLifecycleRoutes)
111
113
  .route("/ai/approval-preferences", approvalPreferenceRoutes)
@@ -133,6 +135,7 @@ export const createCoreApiRouter = (options: CoreApiOptions) => {
133
135
  return { api };
134
136
  };
135
137
 
138
+ export { createAdminAppKeyRoutes } from "./admin-app-keys";
136
139
  export { createAppAccessRoutes } from "./app-access";
137
140
  export {
138
141
  CLOUD_MCP_PATH,
package/src/api/mcp.ts CHANGED
@@ -16,7 +16,7 @@ import { Hono, type MiddlewareHandler } from "hono";
16
16
  import { z } from "zod";
17
17
  import { type CatalogSubscription, projectAppEventCatalog, readEventCatalogSubscriptions } from "../_internal/app-event-catalog";
18
18
  import { readBoundedJson } from "../_internal/bounded-json";
19
- import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
19
+ import { isPersonVisibleCapabilityQuery, resolveCapabilityManifestPresentation } from "../_internal/capabilities";
20
20
  import { capabilitySentenceText } from "../_internal/capability-sentences";
21
21
  import { withCapabilityRequestTimeout } from "../_internal/capability-timeout";
22
22
  import {
@@ -311,7 +311,9 @@ const orderedCapabilityEntries = function* (app: CapabilityRegistryEntry): Gener
311
311
  for (const operation of [...app.manifest.actions].sort((left, right) => left.localId.localeCompare(right.localId))) {
312
312
  yield { app, kind: "actions", operation };
313
313
  }
314
- for (const operation of [...app.manifest.queries].sort((left, right) => left.localId.localeCompare(right.localId))) {
314
+ for (const operation of app.manifest.queries
315
+ .filter(isPersonVisibleCapabilityQuery)
316
+ .sort((left, right) => left.localId.localeCompare(right.localId))) {
315
317
  yield { app, kind: "queries", operation };
316
318
  }
317
319
  };
@@ -0,0 +1,35 @@
1
+ import { Hono } from "hono";
2
+ import { z } from "zod";
3
+ // Concrete modules, not the server barrel: defineApp imports this file, and the barrel imports defineApp.
4
+ import { type AuthContext, auth } from "../server/middleware/auth";
5
+ import { v } from "../server/middleware/validator";
6
+ import { settingsService } from "../services/settings/app";
7
+ import { SETTINGS_MAP } from "../services/settings/defaults";
8
+ import { readEnvSettingsStatus } from "../services/settings/env-status";
9
+
10
+ export const settingsInvocationOperation = (method: string, key: string) => {
11
+ try {
12
+ return `settings.${method === "GET" ? "env" : "set"}:${decodeURIComponent(key)}`;
13
+ } catch {
14
+ return "settings.invalid";
15
+ }
16
+ };
17
+
18
+ export const createSettingsOpsRoutes = (keys: readonly string[]) =>
19
+ new Hono<AuthContext>()
20
+ .use("*", async (c, next) => {
21
+ if (!c.get("user")?.roles.includes("admin")) return c.json({ message: "Insufficient permissions" }, 403);
22
+ return next();
23
+ })
24
+ .use("*", auth.requireOAuthScope("admin"))
25
+ .get("/env", async (c) => c.json({ settings: await readEnvSettingsStatus() }, 200, { "cache-control": "no-store" }))
26
+ .put("/:key", v("json", z.object({ value: z.unknown(), replaceUnreadable: z.boolean().optional() })), async (c) => {
27
+ const key = c.req.param("key") ?? "";
28
+ const def = SETTINGS_MAP.get(key);
29
+ if (!keys.includes(key) || !def || !(def.envBootstrap || def.envFallback))
30
+ return c.json({ message: "Unknown application setting" }, 400);
31
+ const body = c.req.valid("json");
32
+ const result = await settingsService.entry.update({ key, value: body.value, replaceUnreadable: body.replaceUnreadable });
33
+ if (!result.ok) return c.json({ message: result.error.message }, 400);
34
+ return c.json({ saved: true });
35
+ });