@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/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>
@@ -13,6 +13,8 @@ export type BrowserNotificationState = {
13
13
  permission: NotificationPermission;
14
14
  enabled: boolean;
15
15
  reason?: string;
16
+ /** On iPhone and iPad outside the Home Screen app: notifications start working once Cloud is added to the Home Screen. */
17
+ homeScreenRequired?: true;
16
18
  };
17
19
 
18
20
  const isIos = (): boolean =>
@@ -21,7 +23,15 @@ const isIos = (): boolean =>
21
23
  const isStandalone = (): boolean =>
22
24
  window.matchMedia("(display-mode: standalone)").matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
23
25
 
24
- const support = (): { supported: boolean; reason?: string } => {
26
+ const support = (): { supported: boolean; reason?: string; homeScreenRequired?: true } => {
27
+ // Safari on iPhone and iPad offers push only to the Home Screen app, so it lacks the APIs checked below.
28
+ if (typeof window !== "undefined" && isIos() && !isStandalone()) {
29
+ return {
30
+ supported: false,
31
+ reason: "On iPhone and iPad, add Cloud to your Home Screen before enabling browser notifications.",
32
+ homeScreenRequired: true,
33
+ };
34
+ }
25
35
  if (
26
36
  typeof window === "undefined" ||
27
37
  !window.isSecureContext ||
@@ -31,12 +41,6 @@ const support = (): { supported: boolean; reason?: string } => {
31
41
  ) {
32
42
  return { supported: false, reason: "Browser notifications are not supported in this browser." };
33
43
  }
34
- if (isIos() && !isStandalone()) {
35
- return {
36
- supported: false,
37
- reason: "On iPhone and iPad, add Cloud to your Home Screen before enabling browser notifications.",
38
- };
39
- }
40
44
  return { supported: true };
41
45
  };
42
46
 
@@ -101,7 +105,13 @@ const pushSubscriptionError = (cause: unknown): Error => {
101
105
  const currentState = async (registration?: ServiceWorkerRegistration): Promise<BrowserNotificationState> => {
102
106
  const capability = support();
103
107
  if (!capability.supported) {
104
- return { supported: false, permission: "denied", enabled: false, reason: capability.reason };
108
+ return {
109
+ supported: false,
110
+ permission: "denied",
111
+ enabled: false,
112
+ reason: capability.reason,
113
+ ...(capability.homeScreenRequired ? { homeScreenRequired: true } : {}),
114
+ };
105
115
  }
106
116
  return withNotificationTimeout(
107
117
  (async () => {
@@ -131,6 +141,13 @@ export const browserNotificationClient = {
131
141
  return (await response.json()).state;
132
142
  },
133
143
 
144
+ /** End do not disturb now, for every app. Quiet hours keep their schedule. Returns the quiet state afterwards. */
145
+ endDoNotDisturb: async (): Promise<NotificationQuietState> => {
146
+ const response = await apiClient.me.notifications.quiet.$patch({ json: { doNotDisturbUntil: null } });
147
+ if (!response.ok) throw await responseError(response, "Failed to end do not disturb.");
148
+ return (await response.json()).state;
149
+ },
150
+
134
151
  /** Close this application's group on this device without opting into notifications. */
135
152
  closeGroup: async (appId: string, group: string): Promise<void> => {
136
153
  const tag = notificationGroupTag(appId, group);
@@ -0,0 +1,100 @@
1
+ import { discussionsIssues } from "../contracts/discussions";
2
+ import { type CapabilityCaller, listCapabilityCatalog } from "./server-invoke";
3
+ import type { CapabilityCatalogApp } from "./types";
4
+
5
+ // Same interactive discovery budget as the calendar reader. Cursor pages are sequential (one in flight).
6
+ const MAX_CALLS = 60;
7
+ const MAX_BYTES = 4 * 1024 * 1024;
8
+ export type DiscussionProvider = {
9
+ app: CapabilityCatalogApp;
10
+ itemType: string;
11
+ containerType: string;
12
+ containers: string;
13
+ activity?: string;
14
+ };
15
+
16
+ /** Request-local discovery. Failure, truncation, and cancellation throw; no partial success or retained authority. */
17
+ export const discussionProviders = async (
18
+ caller: CapabilityCaller,
19
+ options: { deadline: number; signal?: AbortSignal; page?: <T>(read: () => Promise<T>) => Promise<T> },
20
+ ): Promise<DiscussionProvider[]> => {
21
+ if (!Number.isFinite(options.deadline)) throw new RangeError("Discussion deadline must be a finite epoch time in milliseconds");
22
+ const controller = new AbortController();
23
+ const signal = AbortSignal.any([
24
+ controller.signal,
25
+ ...[caller.signal, options.signal].filter((signal): signal is AbortSignal => !!signal),
26
+ ]);
27
+ const timer = setTimeout(() => controller.abort(new Error("DISCUSSION_DEADLINE")), Math.max(0, options.deadline - Date.now()));
28
+ let bytes = 0;
29
+ const addBytes = (size: number) => {
30
+ bytes += size;
31
+ if (bytes > MAX_BYTES) {
32
+ controller.abort(new Error("DISCUSSION_BYTE_BUDGET"));
33
+ signal.throwIfAborted();
34
+ }
35
+ };
36
+ const credentials: CapabilityCaller = {
37
+ ...caller,
38
+ signal,
39
+ ...(!caller.authority
40
+ ? {
41
+ transport: async (request: Request) => {
42
+ const response = await (caller.transport ?? ((request: Request) => fetch(request, { redirect: "error" })))(request);
43
+ if (!response.body) return response;
44
+ const body = response.body.pipeThrough(
45
+ new TransformStream<Uint8Array, Uint8Array>({
46
+ transform(chunk, stream) {
47
+ addBytes(chunk.byteLength);
48
+ stream.enqueue(chunk);
49
+ },
50
+ }),
51
+ { signal },
52
+ );
53
+ return new Response(body, { status: response.status, headers: response.headers });
54
+ },
55
+ }
56
+ : {}),
57
+ };
58
+ const providers: DiscussionProvider[] = [];
59
+ const seen = new Set<string>();
60
+ const appIds = new Set<string>();
61
+ let cursor: string | undefined;
62
+ let onAbort: (() => void) | undefined;
63
+ const cancelled = new Promise<never>((_, reject) => {
64
+ onAbort = () => reject(signal.reason);
65
+ signal.addEventListener("abort", onAbort, { once: true });
66
+ });
67
+ try {
68
+ for (let calls = 0; calls < MAX_CALLS; calls++) {
69
+ if (options.deadline <= Date.now()) controller.abort(new Error("DISCUSSION_DEADLINE"));
70
+ signal.throwIfAborted();
71
+ const read = () => listCapabilityCatalog({ caller: credentials, cursor, limit: 25 });
72
+ const page = await Promise.race([options.page ? options.page(read) : read(), cancelled]);
73
+ signal.throwIfAborted();
74
+ if (!page.ok) throw new Error(`Discussion discovery unavailable: ${page.error.code}`);
75
+ if (caller.authority) addBytes(new TextEncoder().encode(JSON.stringify(page.data)).byteLength);
76
+ for (const app of page.data.apps) {
77
+ const role = app.manifest.discussions;
78
+ const model = app.manifest.resourceTypes?.find((model) => model.type === role?.item);
79
+ if (!role || !model?.container || discussionsIssues(app.manifest).length || appIds.has(app.appId)) continue;
80
+ appIds.add(app.appId);
81
+ providers.push({
82
+ app,
83
+ itemType: `${app.appId}.${role.item}`,
84
+ containerType: `${app.appId}.${model.container}`,
85
+ containers: role.containers,
86
+ ...(role.activity ? { activity: role.activity } : {}),
87
+ });
88
+ }
89
+ if (!page.data.page.hasMore) return providers;
90
+ cursor = page.data.page.nextCursor;
91
+ if (seen.has(cursor)) throw new Error("DISCUSSION_CURSOR_LOOP");
92
+ seen.add(cursor);
93
+ }
94
+ throw new Error("DISCUSSION_CALL_BUDGET");
95
+ } finally {
96
+ clearTimeout(timer);
97
+ if (onAbort) signal.removeEventListener("abort", onAbort);
98
+ controller.abort();
99
+ }
100
+ };
@@ -63,7 +63,7 @@ export type CapabilityExecutionInput = {
63
63
  capability: string;
64
64
  kind: "query" | "action";
65
65
  destructive: boolean;
66
- actorKind?: "user" | "service_account" | null;
66
+ actorKind?: "user" | "service_account" | "app" | null;
67
67
  actorId?: string | null;
68
68
  userId?: string | null;
69
69
  /** Recorded only when the access subject differs from the acting principal. */
@@ -90,7 +90,7 @@ export type CapabilityExecution = {
90
90
  capability: string;
91
91
  kind: "query" | "action";
92
92
  destructive: boolean;
93
- actorKind: "user" | "service_account" | null;
93
+ actorKind: "user" | "service_account" | "app" | null;
94
94
  actorId: string | null;
95
95
  userId: string | null;
96
96
  accessSubjectType: "user" | "service_account" | null;
@@ -35,7 +35,7 @@ export const migrateCloudCapabilities = async (): Promise<void> => {
35
35
  duration_ms INTEGER NOT NULL DEFAULT 0,
36
36
  CONSTRAINT capabilities_executions_origin_check CHECK (origin IN ('assistant', 'mcp', 'http', 'app')),
37
37
  CONSTRAINT capabilities_executions_kind_check CHECK (kind IN ('query', 'action')),
38
- CONSTRAINT capabilities_executions_actor_kind_check CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account')),
38
+ CONSTRAINT capabilities_executions_actor_kind_check CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account', 'app')),
39
39
  CONSTRAINT capabilities_executions_subject_check CHECK (access_subject_type IS NULL OR access_subject_type IN ('user', 'service_account')),
40
40
  CONSTRAINT capabilities_executions_status_check CHECK (status IN ('succeeded', 'failed', 'denied', 'invalid_input', 'timed_out', 'rejected'))
41
41
  )
@@ -59,6 +59,15 @@ export const migrateCloudCapabilities = async (): Promise<void> => {
59
59
  await sql`ALTER TABLE capabilities.executions ADD COLUMN IF NOT EXISTS parent_invocation_id UUID`.simple();
60
60
  await sql`ALTER TABLE capabilities.executions ADD COLUMN IF NOT EXISTS via_app_id TEXT`.simple();
61
61
 
62
+ await sql`DO $$ BEGIN
63
+ IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'capabilities.executions'::regclass
64
+ AND conname = 'capabilities_executions_actor_kind_check' AND position('''app''' in pg_get_constraintdef(oid)) = 0) THEN
65
+ ALTER TABLE capabilities.executions DROP CONSTRAINT capabilities_executions_actor_kind_check;
66
+ ALTER TABLE capabilities.executions ADD CONSTRAINT capabilities_executions_actor_kind_check
67
+ CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account', 'app'));
68
+ END IF;
69
+ END $$`.simple();
70
+
62
71
  // Platform-owned idempotency claims. One row per
63
72
  // (app, capability, principal, key); the dispatcher claims before it
64
73
  // forwards and resolves the row once the outcome is known. Response bodies
@@ -0,0 +1,69 @@
1
+ import { getProcessApplicationId } from "../_internal/process-identity";
2
+ import { getCapability } from "../_internal/registry";
3
+ import { getAppWorkloadCredential } from "../_internal/workload-credential";
4
+ import { env } from "../config/env";
5
+ import {
6
+ CloudResourceRefSchema,
7
+ type ResourcePrincipal,
8
+ ResourceReadableDataSchema,
9
+ ResourceReadableInputSchema,
10
+ } from "../contracts/capabilities";
11
+ import { invokeCapabilityWithDataSchema } from "./server-invoke";
12
+
13
+ export type ResourceReadersResult =
14
+ | { state: "checked"; readable: ResourcePrincipal[] }
15
+ | { state: "not_configured"; reason: "no_readable" | "no_credential" | "no_internal_origin" | "no_app_identity" | "not_admitted" };
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
+
24
+ /** An empty checked subset means not readable; an unavailable check throws for the caller's retry. */
25
+ export const readResourcePrincipals = async (options: {
26
+ ref: { type: string; id: string };
27
+ container?: { type: string; id: string };
28
+ principals: readonly ResourcePrincipal[];
29
+ signal?: AbortSignal;
30
+ locale?: string;
31
+ requestId?: string;
32
+ }): Promise<ResourceReadersResult> => {
33
+ const ref = CloudResourceRefSchema.parse(options.ref);
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" };
37
+ if (!env.CLOUD_CORE_INTERNAL_ORIGIN) return { state: "not_configured", reason: "no_internal_origin" };
38
+ const callerAppId = getProcessApplicationId();
39
+ if (!callerAppId) return { state: "not_configured", reason: "no_app_identity" };
40
+ options.signal?.throwIfAborted();
41
+ const separator = ref.type.indexOf(".");
42
+ const appId = ref.type.slice(0, separator);
43
+ const localType = ref.type.slice(separator + 1);
44
+ const entry = await getCapability(appId);
45
+ if (!entry) throw new Error("Resource reader provider registry entry unavailable");
46
+ const model = entry.manifest.resourceTypes?.find((model) => model.type === localType);
47
+ if (!model?.readable || !entry.manifest.queries.some((query) => query.localId === `${localType}.readable` && query.appOnly))
48
+ return { state: "not_configured", reason: "no_readable" };
49
+ const input = ResourceReadableInputSchema.parse({ id: ref.id, container: options.container, principals: options.principals });
50
+ if (model.container && input.container?.type !== `${appId}.${model.container}`)
51
+ throw new Error("Reader check requires a reference to the declared container type");
52
+ const result = await invokeCapabilityWithDataSchema(
53
+ { appId, capabilityId: `${localType}.readable`, kind: "query", input },
54
+ ResourceReadableDataSchema,
55
+ {
56
+ workload: { appId: callerAppId },
57
+ authorization: `Bearer ${credential}`,
58
+ signal: options.signal,
59
+ locale: options.locale,
60
+ requestId: options.requestId,
61
+ },
62
+ );
63
+ if (!result.ok) {
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);
66
+ throw new Error(`Resource reader check unavailable: ${result.error.code}`);
67
+ }
68
+ return { state: "checked", readable: result.data.data.readable };
69
+ };
@@ -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
  }