@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.
- package/package.json +8 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/define-app.ts +38 -0
- package/src/_internal/heartbeat.ts +19 -0
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +9 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/resource-principals.ts +4 -34
- package/src/_internal/workload-credential.ts +153 -0
- package/src/_internal/workload-protocol.ts +36 -0
- package/src/ai/application-tasks.ts +5 -2
- package/src/ai/chart-block.ts +36 -711
- package/src/ai/default-tool-contracts.ts +21 -259
- package/src/api/admin-app-keys.ts +66 -0
- package/src/api/admin-core-settings.ts +2 -0
- package/src/api/admin-identity.ts +27 -1
- package/src/api/admin-settings-env.ts +120 -0
- package/src/api/capabilities.ts +9 -3
- package/src/api/index.ts +3 -0
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/capabilities/discussion-providers.ts +3 -2
- package/src/capabilities/resource-readers.ts +13 -2
- package/src/capabilities/response.ts +4 -1
- package/src/capabilities/server-invoke.ts +46 -28
- package/src/capabilities/server.ts +1 -1
- package/src/capabilities/types.ts +1 -1
- package/src/cli/admin/app-credentials.ts +32 -7
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +7 -1
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +27 -21
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/index.ts +9 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +37 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/client-ip.ts +1 -1
- package/src/services/app-keys.ts +298 -0
- package/src/services/gateway.ts +6 -0
- package/src/services/identity/index.ts +3 -0
- package/src/services/identity/resource-readers.ts +80 -0
- package/src/services/identity/runtime-config.ts +14 -6
- package/src/services/identity/workload-runtime.ts +253 -0
- package/src/services/index.ts +4 -1
- package/src/services/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/service-account-credentials.ts +63 -21
- package/src/services/settings/api.ts +3 -3
- package/src/services/settings/binding.ts +45 -0
- package/src/services/settings/core-settings.ts +99 -1
- package/src/services/settings/defaults.ts +16 -2
- package/src/services/settings/env-status.ts +69 -0
- package/src/services/settings/index.ts +69 -26
- package/src/services/settings/store.ts +73 -8
- package/src/services/settings/validation.ts +40 -0
- package/src/shared/index.ts +11 -10
- package/src/shared/markdown/extensions/tables.ts +10 -1
- package/src/start.ts +36 -0
- package/src/workflows/ai/index.ts +1 -0
- package/src/workflows/ai/runtime.ts +16 -8
- package/src/workflows/ai/settings.ts +5 -0
- 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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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(
|
|
37
|
-
{
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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 }) {
|