@k2b/cloud 0.46.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 (68) 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/define-app.ts +38 -0
  5. package/src/_internal/heartbeat.ts +19 -0
  6. package/src/_internal/postgres-application-name.ts +11 -0
  7. package/src/_internal/process-sync.ts +9 -0
  8. package/src/_internal/readiness.ts +6 -1
  9. package/src/_internal/resource-principals.ts +4 -34
  10. package/src/_internal/workload-credential.ts +153 -0
  11. package/src/_internal/workload-protocol.ts +36 -0
  12. package/src/ai/application-tasks.ts +5 -2
  13. package/src/ai/chart-block.ts +36 -711
  14. package/src/ai/default-tool-contracts.ts +21 -259
  15. package/src/api/admin-app-keys.ts +66 -0
  16. package/src/api/admin-core-settings.ts +2 -0
  17. package/src/api/admin-identity.ts +27 -1
  18. package/src/api/admin-settings-env.ts +120 -0
  19. package/src/api/capabilities.ts +9 -3
  20. package/src/api/index.ts +3 -0
  21. package/src/api/settings-ops.ts +35 -0
  22. package/src/app-key.ts +125 -0
  23. package/src/browser/CloudResourceSearch.tsx +48 -41
  24. package/src/capabilities/discussion-providers.ts +3 -2
  25. package/src/capabilities/resource-readers.ts +13 -2
  26. package/src/capabilities/response.ts +4 -1
  27. package/src/capabilities/server-invoke.ts +46 -28
  28. package/src/capabilities/server.ts +1 -1
  29. package/src/capabilities/types.ts +1 -1
  30. package/src/cli/admin/app-credentials.ts +32 -7
  31. package/src/cli/admin/app-keys.ts +226 -0
  32. package/src/cli/admin/index.ts +7 -1
  33. package/src/cli/admin/settings.ts +62 -0
  34. package/src/config/define-env.ts +63 -8
  35. package/src/config/env.ts +27 -21
  36. package/src/config/index.ts +12 -1
  37. package/src/config/workflow-ai.ts +28 -0
  38. package/src/contracts/settings-env.ts +21 -0
  39. package/src/contracts/settings-types.ts +6 -0
  40. package/src/index.ts +9 -0
  41. package/src/runtime/clients.ts +72 -0
  42. package/src/runtime/connections.ts +37 -0
  43. package/src/runtime/resolve-connections.ts +91 -0
  44. package/src/server/client-ip.ts +1 -1
  45. package/src/services/app-keys.ts +298 -0
  46. package/src/services/gateway.ts +6 -0
  47. package/src/services/identity/index.ts +3 -0
  48. package/src/services/identity/resource-readers.ts +80 -0
  49. package/src/services/identity/runtime-config.ts +14 -6
  50. package/src/services/identity/workload-runtime.ts +253 -0
  51. package/src/services/index.ts +4 -1
  52. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  53. package/src/services/service-account-credentials.ts +63 -21
  54. package/src/services/settings/api.ts +3 -3
  55. package/src/services/settings/binding.ts +45 -0
  56. package/src/services/settings/core-settings.ts +99 -1
  57. package/src/services/settings/defaults.ts +16 -2
  58. package/src/services/settings/env-status.ts +69 -0
  59. package/src/services/settings/index.ts +69 -26
  60. package/src/services/settings/store.ts +73 -8
  61. package/src/services/settings/validation.ts +40 -0
  62. package/src/shared/index.ts +11 -10
  63. package/src/shared/markdown/extensions/tables.ts +10 -1
  64. package/src/start.ts +36 -0
  65. package/src/workflows/ai/index.ts +1 -0
  66. package/src/workflows/ai/runtime.ts +16 -8
  67. package/src/workflows/ai/settings.ts +5 -0
  68. package/src/shared/markdown/formula.ts +0 -1283
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { chartDataIssue, chartDateTime, chartDrawingIssue, chartSteppingIssue, cloudAiChartMarks } from "./chart-block";
2
+ import { chartDataIssue, chartDrawingIssue, cloudAiChartMarks } from "./chart-block";
3
3
 
4
4
  const ToneSchema = z.enum(["neutral", "blue", "teal", "green", "amber", "red"]);
5
5
 
@@ -28,261 +28,30 @@ export const CLOUD_AI_VIEW_MAX_MARKS = CLOUD_AI_CHART_MAX_SERIES * CLOUD_AI_CHAR
28
28
  */
29
29
  export const CLOUD_AI_GROUP_MAX_CHARTS = 6;
30
30
 
31
- // Every chart schema is built by a function whose call is marked pure, so a bundle that imports neither chart contract
32
- // leaves them out. App frames include the app contract through `cloud.chart()`.
33
- export const cloudAiNumberFormatSchema = () =>
34
- z
35
- .strictObject({
36
- style: z.enum(["number", "percent", "currency"]).optional(),
37
- currency: z
38
- .string()
39
- .regex(/^[A-Z]{3}$/)
40
- .optional(),
41
- // Units fit after a value in the shared renderer, like the existing gauge unit.
42
- unit: z.string().trim().min(1).max(12).optional(),
43
- // Intl.NumberFormat supports this precision; table formats already use 0..10.
44
- decimals: z.number().int().min(0).max(10).optional(),
45
- compact: z.boolean().optional(),
46
- })
47
- .superRefine((value, ctx) => {
48
- if ((value.style === "currency") !== (value.currency !== undefined))
49
- ctx.addIssue({ code: "custom", path: ["currency"], message: "Currency is required exactly when style is currency." });
50
- });
51
- const dateFormatSchema = () => z.strictObject({ date: z.enum(["day", "minute", "second"]) });
52
- export const cloudAiFormatSchema = () => z.union([cloudAiNumberFormatSchema(), dateFormatSchema()]);
53
- export type CloudAiNumberFormat = z.output<ReturnType<typeof cloudAiNumberFormatSchema>>;
54
- export type CloudAiDateFormat = z.output<ReturnType<typeof dateFormatSchema>>;
55
- export type CloudAiFormat = CloudAiNumberFormat | CloudAiDateFormat;
31
+ import {
32
+ type ChartInput as CloudAiChartInput,
33
+ type ChartDateFormat as CloudAiDateFormat,
34
+ type ChartFormat as CloudAiFormat,
35
+ type ChartNumberFormat as CloudAiNumberFormat,
36
+ chartKinds,
37
+ chartParts,
38
+ chartSchema,
39
+ chartKindSchema as cloudAiChartKindSchema,
40
+ chartFormatSchema as cloudAiFormatSchema,
41
+ chartNumberFormatSchema as cloudAiNumberFormatSchema,
42
+ } from "@k2b/editor/charts";
56
43
 
57
- export const cloudAiChartKindSchema = () =>
58
- z.enum(["bar", "line", "scatter", "pie", "donut", "histogram", "gauge", "sparkline", "boxplot", "heatmap", "barGauge", "stat"]);
44
+ export type { CloudAiChartInput, CloudAiDateFormat, CloudAiFormat, CloudAiNumberFormat };
45
+ // Portable formats and chart types retain their existing Cloud export names.
46
+ export { cloudAiChartKindSchema, cloudAiFormatSchema, cloudAiNumberFormatSchema };
59
47
 
60
- const chartParts = (limits = true) => {
61
- const ChartLabel = limits ? z.string().trim().min(1).max(80) : z.union([z.string(), z.number().transform(String)]);
62
- const ChartTooltip = z
63
- .strictObject({ label: ChartLabel.optional(), format: cloudAiNumberFormatSchema().optional() })
64
- .describe("All kinds: what tooltips and the data table call the value and how they format it; axes keep their own format.");
65
- // Missing observations stay numeric so the renderer can omit them with its finite-value checks.
66
- const ChartNumber = limits
67
- ? z.number()
68
- : z
69
- .custom<number>((value) => typeof value === "number", "Expected a number, null or undefined.")
70
- .nullable()
71
- .optional()
72
- .transform((value) => value ?? Number.NaN);
73
- const list = <T extends z.ZodType>(item: T, min: number, max?: number, message?: string) => {
74
- const array = z.array(item);
75
- const bounded = limits && min > 0 ? array.min(min) : array;
76
- return limits && max !== undefined ? bounded.max(max, message) : bounded;
77
- };
78
- const ChartDate = z
79
- .string()
80
- .regex(/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?)?$/)
81
- .refine((value) => chartDateTime(value) !== undefined, "Not a valid date.");
82
- const ChartPoint = z.object({
83
- x: z.union([ChartNumber, ChartDate]).describe("line, scatter: number or YYYY-MM-DD[THH:mm[:ss]], wall-clock time without an offset."),
84
- y: ChartNumber.describe("line, scatter: value."),
85
- });
86
- // As many lines as series a reader can tell apart.
87
- const ChartReferences = list(
88
- z.object({ value: z.union([z.number(), ChartDate]), axis: z.enum(["x", "y"]).optional(), label: ChartLabel.optional() }),
89
- 0,
90
- CLOUD_AI_CHART_MAX_SERIES,
91
- `Use at most ${CLOUD_AI_CHART_MAX_SERIES} references.`,
92
- );
93
- // One palette color per band.
94
- const ChartThresholds = list(
95
- z.object({ value: z.number(), label: ChartLabel.optional() }),
96
- 1,
97
- CLOUD_AI_CHART_MAX_SERIES,
98
- `Use at most ${CLOUD_AI_CHART_MAX_SERIES} thresholds.`,
99
- );
100
- return {
101
- ChartLabel,
102
- ChartTooltip,
103
- ChartNumber,
104
- list,
105
- ChartReferences,
106
- ChartThresholds,
107
- ChartAxis: z.object({
108
- label: ChartLabel.optional(),
109
- domain: z.array(z.number()).length(2).optional().describe("Exact [min, max]; must contain every value and reference."),
110
- ticks: (limits ? z.number().int().min(2).max(12) : z.number().int().min(2)).optional(),
111
- scale: z.enum(["linear", "log"]).optional().describe("log needs values above zero."),
112
- format: cloudAiFormatSchema().optional(),
113
- }),
114
- ChartCategory: z.object({ label: ChartLabel, value: ChartNumber }),
115
- ChartSlice: z.object({ label: ChartLabel, value: limits ? z.number().min(0) : ChartNumber }),
116
- ChartSeries: (points: z.ZodArray<typeof ChartPoint>) =>
117
- z.object({
118
- label: ChartLabel.optional(),
119
- data: points,
120
- role: z.literal("reference").optional(),
121
- }),
122
- ChartPoints: list(ChartPoint, 0, CLOUD_AI_CHART_MAX_VALUES),
123
- ChartBins: z
124
- .union([
125
- limits ? z.number().int().min(1).max(CLOUD_AI_CHART_MAX_VALUES) : z.number().int().min(1),
126
- limits
127
- ? z
128
- .array(z.number())
129
- .min(2)
130
- .max(CLOUD_AI_CHART_MAX_VALUES + 1)
131
- : z.array(z.number()).min(2),
132
- ])
133
- .describe("histogram: a bin count or ascending bin edges."),
134
- ChartCommon: {
135
- title: (limits ? z.string().trim().min(1).max(160) : z.string()).describe("All kinds and groups: chart title."),
136
- subtitle: (limits ? z.string().trim().min(1).max(300) : z.string())
137
- .optional()
138
- .describe("All kinds and groups: unit, period or source."),
139
- },
140
- ChartUnit: limits ? z.string().trim().min(1).max(12) : z.string(),
141
- ChartBox: z.object({ label: ChartLabel, values: list(ChartNumber, 1) }),
142
- ChartCell: z.object({ x: ChartLabel, y: ChartLabel, value: ChartNumber }),
143
- ChartBarGauge: z.object({ label: ChartLabel, value: ChartNumber, min: z.number().optional(), max: z.number().optional() }),
144
- };
145
- };
146
-
147
- const chartKinds = (limits = true) => {
148
- const {
149
- ChartNumber,
150
- list,
151
- ChartAxis,
152
- ChartBins,
153
- ChartCategory,
154
- ChartCommon,
155
- ChartTooltip,
156
- ChartLabel,
157
- ChartPoints,
158
- ChartSeries,
159
- ChartSlice,
160
- ChartReferences,
161
- ChartThresholds,
162
- ChartUnit,
163
- ChartBox,
164
- ChartCell,
165
- ChartBarGauge,
166
- } = chartParts(limits);
167
- const ChartKindCommon = { ...ChartCommon, tooltip: ChartTooltip.optional() };
168
- const numberFormat = cloudAiNumberFormatSchema();
169
- return z.discriminatedUnion("kind", [
170
- z.object({
171
- kind: z.literal("bar"),
172
- ...ChartKindCommon,
173
- data: list(ChartCategory, 1, CLOUD_AI_CHART_MAX_VALUES),
174
- xAxis: ChartAxis.pick({ label: true }).strict().optional(),
175
- yAxis: ChartAxis.optional(),
176
- colorByBar: z.boolean().optional(),
177
- showValues: z.boolean().optional(),
178
- legend: z.boolean().optional(),
179
- references: ChartReferences.optional(),
180
- }),
181
- z.object({
182
- kind: z.literal("line"),
183
- ...ChartKindCommon,
184
- series: list(
185
- ChartSeries(
186
- limits
187
- ? ChartPoints.min(2, "A line needs at least two points per series; show single values as a bar or scatter chart.")
188
- : ChartPoints,
189
- ).extend({ style: z.enum(["solid", "dashed", "dotted"]).optional() }),
190
- 1,
191
- CLOUD_AI_CHART_MAX_SERIES,
192
- ),
193
- xAxis: ChartAxis.optional(),
194
- yAxis: ChartAxis.optional(),
195
- legend: z.boolean().optional(),
196
- area: z.boolean().optional(),
197
- smooth: z.boolean().optional(),
198
- references: ChartReferences.optional(),
199
- }),
200
- z.object({
201
- kind: z.literal("scatter"),
202
- ...ChartKindCommon,
203
- series: list(ChartSeries(limits ? ChartPoints.min(1) : ChartPoints), 1, CLOUD_AI_CHART_MAX_SERIES),
204
- xAxis: ChartAxis.optional(),
205
- yAxis: ChartAxis.optional(),
206
- legend: z.boolean().optional(),
207
- references: ChartReferences.optional(),
208
- }),
209
- z.object({
210
- kind: z.enum(["pie", "donut"]),
211
- ...ChartKindCommon,
212
- data: list(ChartSlice, 1, CLOUD_AI_CHART_MAX_VALUES),
213
- legend: z.boolean().optional(),
214
- showLabels: z.boolean().optional(),
215
- format: numberFormat.optional(),
216
- }),
217
- z.object({
218
- kind: z.literal("histogram"),
219
- ...ChartKindCommon,
220
- data: list(ChartNumber, 1, CLOUD_AI_CHART_MAX_VALUES),
221
- bins: ChartBins.optional(),
222
- references: ChartReferences.optional(),
223
- }),
224
- z.object({
225
- kind: z.literal("gauge"),
226
- ...ChartKindCommon,
227
- value: ChartNumber,
228
- min: z.number().optional(),
229
- max: z.number().optional(),
230
- label: ChartLabel.optional(),
231
- unit: ChartUnit.optional(),
232
- format: numberFormat.optional(),
233
- thresholds: ChartThresholds.optional(),
234
- }),
235
- z.object({
236
- kind: z.literal("sparkline"),
237
- ...ChartKindCommon,
238
- data: list(ChartNumber, 2, CLOUD_AI_CHART_MAX_VALUES),
239
- area: z.boolean().optional(),
240
- }),
241
- z.object({
242
- kind: z.literal("boxplot"),
243
- ...ChartKindCommon,
244
- data: list(ChartBox, 1, CLOUD_AI_CHART_MAX_VALUES),
245
- yAxis: ChartAxis.optional(),
246
- references: ChartReferences.optional(),
247
- }),
248
- z.object({
249
- kind: z.literal("heatmap"),
250
- ...ChartKindCommon,
251
- data: list(ChartCell, 1, CLOUD_AI_VIEW_MAX_MARKS),
252
- format: numberFormat.optional(),
253
- showValues: z.boolean().optional(),
254
- min: z.number().optional(),
255
- max: z.number().optional(),
256
- }),
257
- z.object({
258
- kind: z.literal("barGauge"),
259
- ...ChartKindCommon,
260
- // The shared renderer draws 280 units high and a bar gauge row is 28 units, so 8 rows fit without a taller drawing.
261
- data: list(ChartBarGauge, 1, CLOUD_AI_CHART_MAX_SERIES, `Use at most ${CLOUD_AI_CHART_MAX_SERIES} bar gauge items.`),
262
- min: z.number().optional(),
263
- max: z.number().optional(),
264
- unit: ChartUnit.optional(),
265
- format: numberFormat.optional(),
266
- thresholds: ChartThresholds.optional(),
267
- }),
268
- z.object({
269
- kind: z.literal("stat"),
270
- ...ChartKindCommon,
271
- value: ChartNumber,
272
- format: numberFormat.optional(),
273
- unit: ChartUnit.optional(),
274
- delta: limits ? z.number().optional() : ChartNumber,
275
- better: z.enum(["up", "down"]).optional(),
276
- sparkline: list(ChartNumber, 2, CLOUD_AI_CHART_MAX_VALUES).optional(),
277
- }),
278
- ]);
279
- };
48
+ const chartLimits = { maxSeries: CLOUD_AI_CHART_MAX_SERIES, maxValues: CLOUD_AI_CHART_MAX_VALUES, maxMarks: CLOUD_AI_VIEW_MAX_MARKS };
280
49
 
281
50
  /** Checks run on complete input only, so out-of-range input fails on its bounds before anything is computed or drawn. */
282
51
  const whenValid = { when: (payload: { issues: readonly unknown[] }) => payload.issues.length === 0 };
283
52
 
284
53
  export const cloudAiChartSchema = () =>
285
- chartKinds().superRefine((chart, context) => {
54
+ chartKinds(chartLimits).superRefine((chart, context) => {
286
55
  const total = cloudAiChartMarks(chart);
287
56
  const issue =
288
57
  total > CLOUD_AI_VIEW_MAX_MARKS
@@ -291,16 +60,9 @@ export const cloudAiChartSchema = () =>
291
60
  if (issue) context.addIssue({ code: "custom", message: issue });
292
61
  }, whenValid);
293
62
  /** Apps retain the renderer's permissive data contract, except values that would stall tick generation. */
294
- export const cloudAiAppChartSchema = () =>
295
- chartKinds(false).superRefine((chart, context) => {
296
- const issue = chartSteppingIssue(chart);
297
- if (issue) context.addIssue({ code: "custom", message: issue });
298
- }, whenValid);
63
+ export const cloudAiAppChartSchema = chartSchema;
299
64
  const ChartSchema = /* @__PURE__ */ cloudAiChartSchema();
300
65
 
301
- /** One chart as the renderers read it: a `cloud.chart()` kind with its options. */
302
- export type CloudAiChartInput = z.output<ReturnType<typeof chartKinds>>;
303
-
304
66
  /**
305
67
  * Reads one chart without drawing it; undefined when it is not a chart. Writers have drawn it once already, so
306
68
  * renderers draw only for display.
@@ -311,7 +73,7 @@ export const parseCloudAiChartInput = (value: unknown): CloudAiChartInput | unde
311
73
  };
312
74
 
313
75
  export const cloudAiGroupSchema = () => {
314
- const { ChartCommon } = chartParts();
76
+ const { ChartCommon } = chartParts(chartLimits);
315
77
  return z
316
78
  .strictObject({
317
79
  ...ChartCommon,
@@ -354,7 +116,7 @@ const flatChartSchema = () => {
354
116
  ChartThresholds,
355
117
  ChartUnit,
356
118
  ChartTooltip,
357
- } = chartParts();
119
+ } = chartParts(chartLimits);
358
120
  return z.object({
359
121
  kind: cloudAiChartKindSchema().optional().describe("Single charts: kind. Omit for groups."),
360
122
  ...ChartCommon,
@@ -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
+ };
@@ -260,11 +260,17 @@ const errorResponse = (code: string, message: string, status: number, details?:
260
260
  status,
261
261
  });
262
262
 
263
- const capabilityJsonResponse = (body: unknown, status: number): Response =>
264
- 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, {
265
267
  status,
266
- headers: { "content-type": "application/json" },
268
+ headers: {
269
+ "content-type": "application/json",
270
+ ...(limited?.success ? { "retry-after": String(limited.data.details.retryAfter) } : {}),
271
+ },
267
272
  });
273
+ };
268
274
 
269
275
  export type CapabilityDispatchParams = {
270
276
  /** Trusted Core continuation binding; never read from client headers. */
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,
@@ -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
+ });