@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
package/src/app-key.ts ADDED
@@ -0,0 +1,125 @@
1
+ /** Portable Ed25519 app-key format. No server, environment, or barrel dependencies. */
2
+ const PREFIX = "cloud-app-key-v1";
3
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
4
+ const APP_ID = /^[a-z][a-z0-9-]{0,79}$/;
5
+ const PKCS8_PREFIX = new Uint8Array([48, 46, 2, 1, 0, 48, 5, 6, 3, 43, 101, 112, 4, 34, 4, 32]);
6
+
7
+ export type AppKey = { installationId: string; appId: string; keyId: string; seed: string };
8
+
9
+ export const validateAppKeyAppId = (appId: string): string => {
10
+ if (!APP_ID.test(appId) || appId.trim() !== appId) throw new Error("App ID must match ^[a-z][a-z0-9-]{0,79}$");
11
+ if (appId === "core" || appId === "gateway") throw new Error("Core and Gateway cannot have app keys");
12
+ return appId;
13
+ };
14
+
15
+ const encode = (bytes: Uint8Array): string =>
16
+ btoa(String.fromCharCode(...bytes))
17
+ .replaceAll("+", "-")
18
+ .replaceAll("/", "_")
19
+ .replace(/=+$/, "");
20
+
21
+ const decode32 = (value: string, label: string): Uint8Array<ArrayBuffer> => {
22
+ if (!/^[A-Za-z0-9_-]{43}$/.test(value)) throw new Error(`${label} must be 32 bytes in unpadded base64url`);
23
+ const bytes = Uint8Array.from(atob(`${value.replaceAll("-", "+").replaceAll("_", "/")}=`), (c) => c.charCodeAt(0));
24
+ if (bytes.length !== 32 || encode(bytes) !== value) throw new Error(`${label} must use canonical base64url`);
25
+ return bytes;
26
+ };
27
+
28
+ export const parseAppKey = (value: string): AppKey => {
29
+ const parts = value.split(".");
30
+ if (parts.length !== 5 || parts[0] !== PREFIX) throw new Error("Expected cloud-app-key-v1.<installationId>.<appId>.<keyId>.<seed>");
31
+ const [, installationId, appId, keyId, seed] = parts;
32
+ if (!installationId || installationId.length !== 36 || !UUID.test(installationId))
33
+ throw new Error("Invalid app-key installation ID (expected canonical UUID)");
34
+ if (!keyId || keyId.length !== 36 || !UUID.test(keyId)) throw new Error("Invalid app-key key ID (expected canonical UUID)");
35
+ validateAppKeyAppId(appId ?? "");
36
+ decode32(seed ?? "", "App-key seed");
37
+ return { installationId, appId: appId!, keyId, seed: seed! };
38
+ };
39
+
40
+ export const formatAppKey = (key: AppKey): string => {
41
+ const value = `${PREFIX}.${key.installationId}.${key.appId}.${key.keyId}.${key.seed}`;
42
+ parseAppKey(value);
43
+ return value;
44
+ };
45
+
46
+ export const importAppKeySeed = async (seed: string): Promise<CryptoKey> => {
47
+ const der = new Uint8Array(48);
48
+ der.set(PKCS8_PREFIX);
49
+ der.set(decode32(seed, "App-key seed"), 16);
50
+ return crypto.subtle.importKey("pkcs8", der, "Ed25519", true, ["sign"]);
51
+ };
52
+
53
+ export const deriveAppPublicKey = async (seed: string): Promise<string> => {
54
+ const jwk = await crypto.subtle.exportKey("jwk", await importAppKeySeed(seed));
55
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x) throw new Error("Ed25519 public key export failed");
56
+ decode32(jwk.x, "App public key");
57
+ return jwk.x;
58
+ };
59
+
60
+ // WebCrypto raw import accepts arbitrary 32-byte strings in Bun. Check the
61
+ // canonical compressed Edwards point as well, including small-order points.
62
+ const P = (1n << 255n) - 19n;
63
+ const mod = (x: bigint): bigint => ((x % P) + P) % P;
64
+ const pow = (x: bigint, exponent: bigint): bigint => {
65
+ let result = 1n;
66
+ for (let n = exponent; n > 0n; n >>= 1n) {
67
+ if (n & 1n) result = mod(result * x);
68
+ x = mod(x * x);
69
+ }
70
+ return result;
71
+ };
72
+ const D = mod(-121665n * pow(121666n, P - 2n));
73
+ const validPoint = (bytes: Uint8Array): boolean => {
74
+ let compressed = 0n;
75
+ for (let i = 31; i >= 0; i--) compressed = (compressed << 8n) | BigInt(bytes[i]!);
76
+ const sign = compressed >> 255n;
77
+ const y = compressed & ((1n << 255n) - 1n);
78
+ if (y >= P) return false;
79
+ const y2 = mod(y * y);
80
+ const x2 = mod((y2 - 1n) * pow(mod(D * y2 + 1n), P - 2n));
81
+ let x = pow(x2, (P + 3n) / 8n);
82
+ if (mod(x * x) !== x2) x = mod(x * pow(2n, (P - 1n) / 4n));
83
+ if (mod(x * x) !== x2 || (x === 0n && sign !== 0n)) return false;
84
+ if ((x & 1n) !== sign) x = P - x;
85
+ // Three projective doublings: [8]P must not be the identity.
86
+ let X = x;
87
+ let Y = y;
88
+ let Z = 1n;
89
+ for (let i = 0; i < 3; i++) {
90
+ const a = mod(X * X),
91
+ b = mod(Y * Y),
92
+ c = mod(2n * Z * Z),
93
+ d = mod(-a);
94
+ const e = mod((X + Y) ** 2n - a - b),
95
+ g = mod(d + b),
96
+ f = mod(g - c),
97
+ h = mod(d - b);
98
+ X = mod(e * f);
99
+ Y = mod(g * h);
100
+ Z = mod(f * g);
101
+ }
102
+ return X !== 0n || mod(Y - Z) !== 0n;
103
+ };
104
+
105
+ export const importAppPublicKey = async (publicKey: string): Promise<CryptoKey> => {
106
+ const bytes = decode32(publicKey, "App public key");
107
+ if (!validPoint(bytes)) throw new Error("App public key must be a canonical Ed25519 point of non-small order");
108
+ return crypto.subtle.importKey("raw", bytes, "Ed25519", true, ["verify"]);
109
+ };
110
+
111
+ /** First 16 bytes of SHA-256(raw public key), displayed as 32 lowercase hex characters. */
112
+ export const appKeyFingerprint = async (publicKey: string): Promise<string> => {
113
+ await importAppPublicKey(publicKey);
114
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", decode32(publicKey, "App public key")));
115
+ return Array.from(hash.subarray(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("");
116
+ };
117
+
118
+ export const generateAppKeyPair = async (): Promise<{ seed: string; publicKey: string }> => {
119
+ const pair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
120
+ const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
121
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.d || !jwk.x) throw new Error("Ed25519 key generation failed");
122
+ decode32(jwk.d, "App-key seed");
123
+ await importAppPublicKey(jwk.x);
124
+ return { seed: jwk.d, publicKey: jwk.x };
125
+ };
@@ -247,6 +247,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
247
247
  // Instant rows come first: streamed app sections are appended below them and never push them down.
248
248
  return [...commands, ...results()];
249
249
  });
250
+ const itemsByKey = createMemo(() => new Map(items().map((item) => [itemKey(item), item])));
250
251
  const selectable = (item: SearchItem) =>
251
252
  !props.disabled &&
252
253
  !choosingTag() &&
@@ -627,48 +628,54 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
627
628
  </Show>
628
629
  <Show when={showList()}>
629
630
  <section id={`${id}-options`} role="listbox" aria-label={t().searchCloudResources} class="cloud-resource-search__results">
630
- <For each={items()}>
631
- {(item, index) => (
632
- <>
633
- <Show when={index() === 0 || items()[index() - 1]?.appId !== item.appId}>
634
- <div class="cloud-resource-search__group" role="presentation">
635
- {item.appName}
636
- </div>
637
- </Show>
638
- <button
639
- type="button"
640
- id={`${id}-result-${index()}`}
641
- data-result-index={index()}
642
- role="option"
643
- aria-selected={props.selectionMode ? selectedKey() === itemKey(item) : activeIndex() === index()}
644
- aria-disabled={!selectable(item)}
645
- class="cloud-resource-search__result"
646
- classList={{
647
- "is-active": activeIndex() === index(),
648
- "is-selected": props.selectionMode && selectedKey() === itemKey(item),
649
- }}
650
- onFocus={() => selectIndex(index())}
651
- onMouseEnter={() => {
652
- if (!props.selectionMode) selectIndex(index());
653
- }}
654
- onClick={(event) => {
655
- if ((event.metaKey || event.ctrlKey) && props.onOpenInNewTab && !props.selectionMode) {
656
- if (selectable(item)) openNewTab(item);
657
- } else chooseItem(item);
658
- }}
659
- >
660
- <i class={item.icon ?? item.appIcon} aria-hidden="true" />
661
- <span class="cloud-resource-search__result-copy">
662
- <span>{item.title}</span>
663
- <small>{isApp(item) ? t().app : (item.preview ?? item.appName)}</small>
664
- </span>
665
- <Show when={commandFor(item)?.shortcut}>{(shortcut) => <Kbd keys={shortcut()} />}</Show>
666
- <Show when={props.selectionMode && selectedKey() === itemKey(item)}>
667
- <i class="ti ti-check" aria-hidden="true" />
631
+ {/* Keyed by result, not object: commands become new objects whenever results stream in, and a row
632
+ replaced between press and release would swallow the click. */}
633
+ <For each={items().map(itemKey)}>
634
+ {(key, index) => {
635
+ const first = itemsByKey().get(key)!;
636
+ const item = () => itemsByKey().get(key) ?? first;
637
+ return (
638
+ <>
639
+ <Show when={index() === 0 || items()[index() - 1]?.appId !== item().appId}>
640
+ <div class="cloud-resource-search__group" role="presentation">
641
+ {item().appName}
642
+ </div>
668
643
  </Show>
669
- </button>
670
- </>
671
- )}
644
+ <button
645
+ type="button"
646
+ id={`${id}-result-${index()}`}
647
+ data-result-index={index()}
648
+ role="option"
649
+ aria-selected={props.selectionMode ? selectedKey() === itemKey(item()) : activeIndex() === index()}
650
+ aria-disabled={!selectable(item())}
651
+ class="cloud-resource-search__result"
652
+ classList={{
653
+ "is-active": activeIndex() === index(),
654
+ "is-selected": props.selectionMode && selectedKey() === itemKey(item()),
655
+ }}
656
+ onFocus={() => selectIndex(index())}
657
+ onMouseEnter={() => {
658
+ if (!props.selectionMode) selectIndex(index());
659
+ }}
660
+ onClick={(event) => {
661
+ if ((event.metaKey || event.ctrlKey) && props.onOpenInNewTab && !props.selectionMode) {
662
+ if (selectable(item())) openNewTab(item());
663
+ } else chooseItem(item());
664
+ }}
665
+ >
666
+ <i class={item().icon ?? item().appIcon} aria-hidden="true" />
667
+ <span class="cloud-resource-search__result-copy">
668
+ <span>{item().title}</span>
669
+ <small>{isApp(item()) ? t().app : (item().preview ?? item().appName)}</small>
670
+ </span>
671
+ <Show when={commandFor(item())?.shortcut}>{(shortcut) => <Kbd keys={shortcut()} />}</Show>
672
+ <Show when={props.selectionMode && selectedKey() === itemKey(item())}>
673
+ <i class="ti ti-check" aria-hidden="true" />
674
+ </Show>
675
+ </button>
676
+ </>
677
+ );
678
+ }}
672
679
  </For>
673
680
  </section>
674
681
  </Show>
@@ -16,7 +16,7 @@ export type DiscussionProvider = {
16
16
  /** Request-local discovery. Failure, truncation, and cancellation throw; no partial success or retained authority. */
17
17
  export const discussionProviders = async (
18
18
  caller: CapabilityCaller,
19
- options: { deadline: number; signal?: AbortSignal },
19
+ options: { deadline: number; signal?: AbortSignal; page?: <T>(read: () => Promise<T>) => Promise<T> },
20
20
  ): Promise<DiscussionProvider[]> => {
21
21
  if (!Number.isFinite(options.deadline)) throw new RangeError("Discussion deadline must be a finite epoch time in milliseconds");
22
22
  const controller = new AbortController();
@@ -68,7 +68,8 @@ export const discussionProviders = async (
68
68
  for (let calls = 0; calls < MAX_CALLS; calls++) {
69
69
  if (options.deadline <= Date.now()) controller.abort(new Error("DISCUSSION_DEADLINE"));
70
70
  signal.throwIfAborted();
71
- const page = await Promise.race([listCapabilityCatalog({ caller: credentials, cursor, limit: 25 }), cancelled]);
71
+ const read = () => listCapabilityCatalog({ caller: credentials, cursor, limit: 25 });
72
+ const page = await Promise.race([options.page ? options.page(read) : read(), cancelled]);
72
73
  signal.throwIfAborted();
73
74
  if (!page.ok) throw new Error(`Discussion discovery unavailable: ${page.error.code}`);
74
75
  if (caller.authority) addBytes(new TextEncoder().encode(JSON.stringify(page.data)).byteLength);
@@ -1,5 +1,6 @@
1
1
  import { getProcessApplicationId } from "../_internal/process-identity";
2
2
  import { getCapability } from "../_internal/registry";
3
+ import { getAppWorkloadCredential } from "../_internal/workload-credential";
3
4
  import { env } from "../config/env";
4
5
  import {
5
6
  CloudResourceRefSchema,
@@ -13,6 +14,13 @@ export type ResourceReadersResult =
13
14
  | { state: "checked"; readable: ResourcePrincipal[] }
14
15
  | { state: "not_configured"; reason: "no_readable" | "no_credential" | "no_internal_origin" | "no_app_identity" | "not_admitted" };
15
16
 
17
+ /** Admission is temporary; consumers may schedule a bounded fresh check after this delay. */
18
+ export class ResourceReaderRateLimited extends Error {
19
+ constructor(readonly retryAfterMs: number) {
20
+ super("Resource reader rate limited");
21
+ }
22
+ }
23
+
16
24
  /** An empty checked subset means not readable; an unavailable check throws for the caller's retry. */
17
25
  export const readResourcePrincipals = async (options: {
18
26
  ref: { type: string; id: string };
@@ -23,7 +31,9 @@ export const readResourcePrincipals = async (options: {
23
31
  requestId?: string;
24
32
  }): Promise<ResourceReadersResult> => {
25
33
  const ref = CloudResourceRefSchema.parse(options.ref);
26
- if (!env.CLOUD_APP_CREDENTIAL) return { state: "not_configured", reason: "no_credential" };
34
+ // Core issues the credential automatically; an explicit CLOUD_APP_CREDENTIAL override wins.
35
+ const credential = getAppWorkloadCredential();
36
+ if (!credential) return { state: "not_configured", reason: "no_credential" };
27
37
  if (!env.CLOUD_CORE_INTERNAL_ORIGIN) return { state: "not_configured", reason: "no_internal_origin" };
28
38
  const callerAppId = getProcessApplicationId();
29
39
  if (!callerAppId) return { state: "not_configured", reason: "no_app_identity" };
@@ -44,7 +54,7 @@ export const readResourcePrincipals = async (options: {
44
54
  ResourceReadableDataSchema,
45
55
  {
46
56
  workload: { appId: callerAppId },
47
- authorization: `Bearer ${env.CLOUD_APP_CREDENTIAL}`,
57
+ authorization: `Bearer ${credential}`,
48
58
  signal: options.signal,
49
59
  locale: options.locale,
50
60
  requestId: options.requestId,
@@ -52,6 +62,7 @@ export const readResourcePrincipals = async (options: {
52
62
  );
53
63
  if (!result.ok) {
54
64
  if (result.error.code === "APP_QUERY_NOT_ADMITTED") return { state: "not_configured", reason: "not_admitted" };
65
+ if (result.error.code === "RATE_LIMITED") throw new ResourceReaderRateLimited(result.error.retryAfterMs ?? 60_000);
55
66
  throw new Error(`Resource reader check unavailable: ${result.error.code}`);
56
67
  }
57
68
  return { state: "checked", readable: result.data.data.readable };
@@ -23,10 +23,13 @@ export const readCapabilityResponse = async <T>(
23
23
  const body = parsedBody.ok ? parsedBody.data : null;
24
24
  if (!response.ok) {
25
25
  const error = CapabilityErrorSchema.safeParse(body);
26
+ const retryAfter = response.headers.get("Retry-After");
27
+ const retryAfterMs =
28
+ retryAfter === null ? NaN : /^\d+$/.test(retryAfter.trim()) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now();
26
29
  return {
27
30
  ok: false,
28
31
  error: error.success
29
- ? { ...error.data, status: response.status }
32
+ ? { ...error.data, status: response.status, ...(Number.isSafeInteger(retryAfterMs) && retryAfterMs > 0 ? { retryAfterMs } : {}) }
30
33
  : { code: "INVALID_APP_RESPONSE", message: "Cloud returned an invalid capability error", status: response.status },
31
34
  };
32
35
  }
@@ -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,
@@ -124,34 +125,51 @@ const invokeCapabilityWithResultSchema = async <TDataSchema extends z.ZodType, T
124
125
  });
125
126
  return readCapabilityResponse(outcome, capabilityResultSchema(dataSchema, { consumer: true }));
126
127
  }
127
- const response = await (
128
- caller.transport ?? ((request: Request) => fetch(request, caller.workload ? { redirect: "error" } : undefined))
129
- )(
130
- new Request(request, {
131
- body: JSON.stringify(
132
- caller.mandate || caller.workload
133
- ? {
134
- kind: invocation.kind,
135
- targetApp: invocation.appId,
136
- capabilityId: invocation.capabilityId,
137
- input: invocation.input,
138
- ...(caller.mandate ? { mandateId: caller.mandate.id, mandateRevision: caller.mandate.revision } : {}),
139
- ...(invocation.idempotencyKey ? { idempotencyKey: invocation.idempotencyKey } : {}),
140
- ...(caller.requestId || caller.traceparent || caller.tracestate || caller.locale
141
- ? {
142
- metadata: {
143
- ...(caller.requestId ? { requestId: caller.requestId } : {}),
144
- ...(caller.traceparent ? { traceparent: caller.traceparent } : {}),
145
- ...(caller.tracestate ? { tracestate: caller.tracestate } : {}),
146
- ...(caller.locale ? { locale: caller.locale } : {}),
147
- },
148
- }
149
- : {}),
150
- }
151
- : { input: invocation.input },
152
- ),
153
- }),
154
- );
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
+ }
155
173
  return readCapabilityResponse(response, capabilityResultSchema(dataSchema, { consumer: true }));
156
174
  } catch (cause) {
157
175
  return unavailable(cause, invocation, caller.locale ?? undefined);
@@ -182,7 +182,7 @@ export {
182
182
  readCalendarEvents,
183
183
  } from "./calendar-sources";
184
184
  export { type DiscussionProvider, discussionProviders } from "./discussion-providers";
185
- export { type ResourceReadersResult, readResourcePrincipals } from "./resource-readers";
185
+ export { ResourceReaderRateLimited, type ResourceReadersResult, readResourcePrincipals } from "./resource-readers";
186
186
  export {
187
187
  actionReview,
188
188
  type CapabilityReviewForms,
@@ -2,7 +2,7 @@ import type { z } from "zod";
2
2
  import type { CapabilityActionReviewData, CapabilityCatalog, CapabilityErrorSchema, CapabilityResult } from "../contracts/capabilities";
3
3
 
4
4
  export type CapabilityKind = "query" | "action";
5
- export type CapabilityClientError = z.infer<typeof CapabilityErrorSchema> & { status: number };
5
+ export type CapabilityClientError = z.infer<typeof CapabilityErrorSchema> & { status: number; retryAfterMs?: number };
6
6
  export type CapabilityResultState<T> = { ok: true; data: T } | { ok: false; error: CapabilityClientError };
7
7
  export type CapabilityClientResult<T> = CapabilityResultState<CapabilityResult<T>>;
8
8
  export type CapabilityReviewClientResult = CapabilityResultState<CapabilityActionReviewData>;
@@ -10,6 +10,7 @@ const metadata = z.object({
10
10
  status: z.string(),
11
11
  tokenPrefix: z.string(),
12
12
  expiresAt: z.string().nullable(),
13
+ serviceAccount: z.object({ status: z.string() }).optional(),
13
14
  });
14
15
 
15
16
  export const appCredentialCommands = [
@@ -33,12 +34,15 @@ export const appCredentialCommands = [
33
34
  .object({ items: z.array(metadata), page: z.number(), perPage: z.number(), total: z.number(), hasNext: z.boolean() })
34
35
  .parse(await apiGet(ctx, `${path(args.app)}?page=${flags.page}&perPage=${flags.perPage}`));
35
36
  if (!printStructured(ctx, listing))
36
- ctx.table(listing.items, [
37
- { key: "id", label: "ID" },
38
- { key: "name", label: "Name" },
39
- { key: "status", label: "Status" },
40
- { key: "expiresAt", label: "Expires" },
41
- ]);
37
+ ctx.table(
38
+ listing.items.map((item) => ({ ...item, status: item.serviceAccount?.status === "disabled" ? "disabled" : item.status })),
39
+ [
40
+ { key: "id", label: "ID" },
41
+ { key: "name", label: "Name" },
42
+ { key: "status", label: "Status" },
43
+ { key: "expiresAt", label: "Expires" },
44
+ ],
45
+ );
42
46
  },
43
47
  }),
44
48
  command("app-credentials create", {
@@ -62,8 +66,29 @@ export const appCredentialCommands = [
62
66
  if (!printStructured(ctx, result)) ctx.print(result.token);
63
67
  },
64
68
  }),
69
+ ...(["enable", "disable"] as const).map((action) =>
70
+ command(`app-credentials ${action}`, {
71
+ summary: `${action === "enable" ? "Enable" : "Disable"} workload issuance and calls for every instance of an app`,
72
+ args: { app: arg.required({ valueLabel: "app" }) },
73
+ flags: { yes: confirmFlag(`${action} this app's workload access`) },
74
+ async run({ ctx, args, flags }) {
75
+ if (!flags.yes)
76
+ throw new Error(
77
+ cliText(ctx, {
78
+ en: "Changing app workload access requires --yes.",
79
+ de: "App-Hintergrundzugriff ändern erfordert --yes.",
80
+ }),
81
+ );
82
+ const result = await apiJson(ctx, "PATCH", `${root}/${encodeURIComponent(args.app)}`, {
83
+ status: action === "enable" ? "active" : "disabled",
84
+ });
85
+ if (!printStructured(ctx, result))
86
+ ctx.print(cliText(ctx, { en: "App workload access updated.", de: "App-Hintergrundzugriff aktualisiert." }));
87
+ },
88
+ }),
89
+ ),
65
90
  command("app-credentials revoke", {
66
- summary: "Revoke one exact app credential; its background calls will stop",
91
+ summary: "Revoke an app credential; runtime credentials also disable app-wide workload access",
67
92
  args: { app: arg.required({ valueLabel: "app" }), credential: arg.required({ valueLabel: "credential" }) },
68
93
  flags: { yes: confirmFlag("Revoke this app credential") },
69
94
  async run({ ctx, args, flags }) {