@k2b/cloud 0.46.0 → 0.48.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/app-access-manager-sql.ts +10 -0
- package/src/_internal/bounded-json.ts +7 -1
- package/src/_internal/capabilities.ts +8 -0
- package/src/_internal/define-app.ts +69 -2
- package/src/_internal/heartbeat.ts +21 -0
- package/src/_internal/nats-connection.ts +10 -9
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +15 -6
- package/src/_internal/public-surface.ts +76 -0
- package/src/_internal/purpose-name.ts +7 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/registry-validation.ts +1 -0
- package/src/_internal/registry.ts +49 -3
- package/src/_internal/resource-principals.ts +4 -34
- package/src/_internal/workload-credential.ts +154 -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-domains.ts +62 -0
- 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/app-bootstrap.ts +131 -0
- package/src/api/capabilities.ts +17 -4
- package/src/api/index.ts +8 -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 +6 -2
- package/src/capabilities/server-invoke.ts +73 -29
- 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-domains.ts +140 -0
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +109 -84
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +34 -22
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- package/src/contracts/app-domains.ts +91 -0
- package/src/contracts/app-status.ts +104 -0
- package/src/contracts/capabilities.ts +22 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/index.ts +10 -0
- package/src/runtime/bootstrap-client.ts +195 -0
- package/src/runtime/bootstrap-protocol.ts +60 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +46 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/app-key.ts +14 -0
- package/src/server/client-ip.ts +1 -1
- package/src/server/index.ts +1 -0
- package/src/services/app-access-management.ts +1 -8
- package/src/services/app-admin-facts.ts +160 -0
- package/src/services/app-bootstrap.ts +134 -0
- package/src/services/app-domain-check.ts +152 -0
- package/src/services/app-domains.ts +172 -0
- package/src/services/app-keys.ts +301 -0
- package/src/services/downstream-budget.ts +2 -2
- 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 +8 -1
- package/src/services/links/index.ts +16 -0
- package/src/services/links/runtime.ts +44 -0
- package/src/services/links/store.ts +105 -0
- package/src/services/logging/index.ts +7 -5
- package/src/services/logging/redaction.ts +6 -1
- package/src/services/logging/trace.ts +8 -8
- package/src/services/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/request-cache-redis.ts +2 -2
- 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 +119 -1
- package/src/services/settings/crypto.ts +2 -2
- 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 +78 -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/ssr/admin-navigation.ts +0 -1
- package/src/ssr/platform-messages.ts +0 -2
- package/src/start.ts +57 -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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { Hono, type MiddlewareHandler } from "hono";
|
|
3
|
+
import { getConnInfo } from "hono/bun";
|
|
4
|
+
import {
|
|
5
|
+
BOOTSTRAP_BODY,
|
|
6
|
+
BOOTSTRAP_PATH,
|
|
7
|
+
BOOTSTRAP_RESPONSE_BYTES,
|
|
8
|
+
BootstrapConnectionsSchema,
|
|
9
|
+
BootstrapResponseSchema,
|
|
10
|
+
} from "../runtime/bootstrap-protocol";
|
|
11
|
+
import { connections } from "../runtime/connections";
|
|
12
|
+
import { clientIpBucket } from "../server/client-ip";
|
|
13
|
+
import { type RateLimitResult, ratelimit } from "../server/ratelimit";
|
|
14
|
+
import { authenticateBootstrap, BootstrapProofError, bootstrapRateLimit, consumeBootstrapProof } from "../services/app-bootstrap";
|
|
15
|
+
import { touchAppKey } from "../services/app-keys";
|
|
16
|
+
|
|
17
|
+
type BootstrapContext = { Variables: { bootstrapBody: Uint8Array<ArrayBuffer> } };
|
|
18
|
+
|
|
19
|
+
/** Count actual bytes even when Content-Length is missing or forged. Never await cancellation. */
|
|
20
|
+
const limitBody: MiddlewareHandler<BootstrapContext> = async (c, next) => {
|
|
21
|
+
const max = Buffer.byteLength(BOOTSTRAP_BODY);
|
|
22
|
+
const reject = () => c.json({ code: "body_too_large" }, 413);
|
|
23
|
+
if (Number(c.req.header("Content-Length")) > max) {
|
|
24
|
+
void c.req.raw.body?.cancel().catch(() => undefined);
|
|
25
|
+
return reject();
|
|
26
|
+
}
|
|
27
|
+
const reader = c.req.raw.body?.getReader();
|
|
28
|
+
const bytes = new Uint8Array(max);
|
|
29
|
+
let total = 0;
|
|
30
|
+
if (reader) {
|
|
31
|
+
try {
|
|
32
|
+
while (true) {
|
|
33
|
+
const chunk = await reader.read();
|
|
34
|
+
if (chunk.done) break;
|
|
35
|
+
if (total + chunk.value.byteLength > max) {
|
|
36
|
+
void reader.cancel().catch(() => undefined);
|
|
37
|
+
return reject();
|
|
38
|
+
}
|
|
39
|
+
bytes.set(chunk.value, total);
|
|
40
|
+
total += chunk.value.byteLength;
|
|
41
|
+
}
|
|
42
|
+
} finally {
|
|
43
|
+
reader.releaseLock();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
c.set("bootstrapBody", bytes.slice(0, total));
|
|
47
|
+
await next();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type Dependencies = {
|
|
51
|
+
db?: SQL;
|
|
52
|
+
ingress?: MiddlewareHandler;
|
|
53
|
+
admit?: (keyId: string) => Promise<RateLimitResult>;
|
|
54
|
+
};
|
|
55
|
+
const admit = async (kind: "ingress" | "key", identifier: string) => {
|
|
56
|
+
// Use the installation's existing per-second security budget, in separate ingress and authenticated-key buckets.
|
|
57
|
+
const limit = await bootstrapRateLimit();
|
|
58
|
+
return ratelimit({ id: `app-bootstrap-${kind}`, limit }).check(identifier);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const ingress: MiddlewareHandler<BootstrapContext> = async (c, next) => {
|
|
62
|
+
// Bootstrap uses the direct Core socket. Unknown peers share one bounded bucket.
|
|
63
|
+
let peer = "unknown";
|
|
64
|
+
try {
|
|
65
|
+
peer = getConnInfo(c).remote.address ?? peer;
|
|
66
|
+
} catch {
|
|
67
|
+
/* In-process requests have no socket. */
|
|
68
|
+
}
|
|
69
|
+
const rate = await admit("ingress", clientIpBucket(peer));
|
|
70
|
+
if (rate.limited) {
|
|
71
|
+
c.header("Retry-After", String(Math.max(1, Math.ceil(rate.resetIn / 1000))));
|
|
72
|
+
return c.json({ code: "bootstrap_ingress_limited" }, 429);
|
|
73
|
+
}
|
|
74
|
+
await next();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Core-only mount. Capture the startup snapshot once, including NATS file contents. */
|
|
78
|
+
export const createAppBootstrapRoutes = (dependencies: Dependencies = {}) => {
|
|
79
|
+
const { database, valkey, nats, appSecret } = connections();
|
|
80
|
+
const snapshot = BootstrapConnectionsSchema.parse({ database, valkey, nats, appSecret });
|
|
81
|
+
// Refuse an undeliverable Core configuration at startup, using the largest valid identity.
|
|
82
|
+
const largestIdentity = {
|
|
83
|
+
installationId: "11111111-1111-4111-8111-111111111111",
|
|
84
|
+
appId: "a".repeat(80),
|
|
85
|
+
keyId: "22222222-2222-4222-8222-222222222222",
|
|
86
|
+
};
|
|
87
|
+
if (Buffer.byteLength(JSON.stringify({ identity: largestIdentity, connections: snapshot })) > BOOTSTRAP_RESPONSE_BYTES)
|
|
88
|
+
throw new Error("Bootstrap response exceeds protocol budget");
|
|
89
|
+
// The startup snapshot includes appId/sources; only the delivery contract goes on the wire.
|
|
90
|
+
return new Hono<BootstrapContext>()
|
|
91
|
+
.onError(() =>
|
|
92
|
+
Response.json({ code: "bootstrap_unavailable" }, { status: 503, headers: { "Cache-Control": "no-store", "Retry-After": "1" } }),
|
|
93
|
+
)
|
|
94
|
+
.post(
|
|
95
|
+
BOOTSTRAP_PATH,
|
|
96
|
+
async (c, next) => {
|
|
97
|
+
c.header("Cache-Control", "no-store");
|
|
98
|
+
await next();
|
|
99
|
+
},
|
|
100
|
+
// The v1 request is exactly two UTF-8 bytes. This runs before proof parsing, even for invalid proofs.
|
|
101
|
+
limitBody,
|
|
102
|
+
dependencies.ingress ?? ingress,
|
|
103
|
+
async (c) => {
|
|
104
|
+
const url = new URL(c.req.url);
|
|
105
|
+
if (url.search) return c.json({ code: "invalid_request" }, 400);
|
|
106
|
+
const body = c.get("bootstrapBody");
|
|
107
|
+
const header = c.req.header("Authorization") ?? "";
|
|
108
|
+
if (!header.startsWith("Bearer ")) return c.json({ code: "invalid_proof" }, 401);
|
|
109
|
+
let verified: Awaited<ReturnType<typeof authenticateBootstrap>>;
|
|
110
|
+
try {
|
|
111
|
+
verified = await authenticateBootstrap(header.slice(7), { method: c.req.method, path: url.pathname, body }, dependencies.db);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error instanceof BootstrapProofError) return c.json({ code: error.code }, error.code === "invalid_proof" ? 401 : 403);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
if (new TextDecoder().decode(body) !== BOOTSTRAP_BODY) return c.json({ code: "invalid_request" }, 400);
|
|
117
|
+
const { identity, jti, exp } = verified;
|
|
118
|
+
const rate = dependencies.admit ? await dependencies.admit(identity.keyId) : await admit("key", identity.keyId);
|
|
119
|
+
if (rate.limited) {
|
|
120
|
+
c.header("Retry-After", String(Math.max(1, Math.ceil(rate.resetIn / 1000))));
|
|
121
|
+
return c.json({ code: "bootstrap_rate_limited" }, 429);
|
|
122
|
+
}
|
|
123
|
+
if (!(await consumeBootstrapProof(identity.keyId, jti, exp, dependencies.db))) return c.json({ code: "replayed_proof" }, 403);
|
|
124
|
+
await touchAppKey(identity, dependencies.db);
|
|
125
|
+
const response = JSON.stringify(BootstrapResponseSchema.parse({ identity, connections: snapshot }));
|
|
126
|
+
if (Buffer.byteLength(response) > BOOTSTRAP_RESPONSE_BYTES) throw new Error("Bootstrap response exceeds protocol budget");
|
|
127
|
+
c.header("Content-Type", "application/json");
|
|
128
|
+
return c.body(response);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
};
|
package/src/api/capabilities.ts
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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. */
|
|
@@ -291,6 +297,8 @@ export type CapabilityDispatchParams = {
|
|
|
291
297
|
callingAppId?: string;
|
|
292
298
|
/** Internal observation only: called immediately before attempting the app request. */
|
|
293
299
|
onUpstreamAttempt?: () => void;
|
|
300
|
+
/** Core-only aggregate budget, charged on upstream bytes before parsing or validation. */
|
|
301
|
+
consumeResponseBytes?: (bytes: number) => void;
|
|
294
302
|
dependencies?: CapabilityDispatchDependencies;
|
|
295
303
|
};
|
|
296
304
|
|
|
@@ -783,7 +791,12 @@ const runCapabilityDispatch = async (
|
|
|
783
791
|
let upstreamBody: Awaited<ReturnType<typeof readBoundedJson>>;
|
|
784
792
|
try {
|
|
785
793
|
upstreamBody = await deadline.wait(() =>
|
|
786
|
-
readBoundedJson(
|
|
794
|
+
readBoundedJson(
|
|
795
|
+
response,
|
|
796
|
+
params.review ? CAPABILITY_MAX_REVIEW_RESPONSE_BYTES : CAPABILITY_MAX_RESULT_BYTES,
|
|
797
|
+
signal,
|
|
798
|
+
params.consumeResponseBytes,
|
|
799
|
+
),
|
|
787
800
|
);
|
|
788
801
|
} catch {
|
|
789
802
|
if (actionWithoutRetrySafety) return settle(outcomeUnknown(), "uncertain");
|
package/src/api/index.ts
CHANGED
|
@@ -26,6 +26,8 @@ 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 adminAppDomainRoutes from "./admin-app-domains";
|
|
30
|
+
import adminAppKeyRoutes from "./admin-app-keys";
|
|
29
31
|
import adminCoreSettingsRoutes from "./admin-core-settings";
|
|
30
32
|
import adminDirectorySharesRoutes from "./admin-directory-shares";
|
|
31
33
|
import adminGeoRoutes from "./admin-geo";
|
|
@@ -106,6 +108,8 @@ const buildCoreApi = (options: CoreApiOptions) => {
|
|
|
106
108
|
.route("/admin/core/geo", adminGeoRoutes)
|
|
107
109
|
.route("/admin/identity/directory-shares", adminDirectorySharesRoutes)
|
|
108
110
|
.route("/admin/identity", adminIdentityRoutes)
|
|
111
|
+
.route("/admin/apps", adminAppDomainRoutes)
|
|
112
|
+
.route("/admin/apps", adminAppKeyRoutes)
|
|
109
113
|
.route("/admin/sync", createSyncOpsProxyRoutes())
|
|
110
114
|
.route("/admin/lifecycle", adminLifecycleRoutes)
|
|
111
115
|
.route("/ai/approval-preferences", approvalPreferenceRoutes)
|
|
@@ -133,7 +137,11 @@ export const createCoreApiRouter = (options: CoreApiOptions) => {
|
|
|
133
137
|
return { api };
|
|
134
138
|
};
|
|
135
139
|
|
|
140
|
+
export { createAdminAppDomainRoutes } from "./admin-app-domains";
|
|
141
|
+
export { createAdminAppKeyRoutes } from "./admin-app-keys";
|
|
136
142
|
export { createAppAccessRoutes } from "./app-access";
|
|
143
|
+
/** Core-owned internal startup protocol; applications use @k2b/cloud/start. */
|
|
144
|
+
export { createAppBootstrapRoutes } from "./app-bootstrap";
|
|
137
145
|
export {
|
|
138
146
|
CLOUD_MCP_PATH,
|
|
139
147
|
CLOUD_MCP_PROTECTED_RESOURCE_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
|
+
});
|
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 };
|
|
@@ -8,8 +8,9 @@ export const readCapabilityResponse = async <T>(
|
|
|
8
8
|
schema: z.ZodType<T>,
|
|
9
9
|
maxBytes = CAPABILITY_MAX_RESULT_BYTES,
|
|
10
10
|
signal?: AbortSignal,
|
|
11
|
+
consumeBytes?: (bytes: number) => void,
|
|
11
12
|
): Promise<CapabilityResultState<T>> => {
|
|
12
|
-
const parsedBody = await readBoundedJson(response, maxBytes, signal);
|
|
13
|
+
const parsedBody = await readBoundedJson(response, maxBytes, signal, consumeBytes);
|
|
13
14
|
if (!parsedBody.ok && parsedBody.reason === "too_large") {
|
|
14
15
|
return {
|
|
15
16
|
ok: false,
|
|
@@ -23,10 +24,13 @@ export const readCapabilityResponse = async <T>(
|
|
|
23
24
|
const body = parsedBody.ok ? parsedBody.data : null;
|
|
24
25
|
if (!response.ok) {
|
|
25
26
|
const error = CapabilityErrorSchema.safeParse(body);
|
|
27
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
28
|
+
const retryAfterMs =
|
|
29
|
+
retryAfter === null ? NaN : /^\d+$/.test(retryAfter.trim()) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now();
|
|
26
30
|
return {
|
|
27
31
|
ok: false,
|
|
28
32
|
error: error.success
|
|
29
|
-
? { ...error.data, status: response.status }
|
|
33
|
+
? { ...error.data, status: response.status, ...(Number.isSafeInteger(retryAfterMs) && retryAfterMs > 0 ? { retryAfterMs } : {}) }
|
|
30
34
|
: { code: "INVALID_APP_RESPONSE", message: "Cloud returned an invalid capability error", status: response.status },
|
|
31
35
|
};
|
|
32
36
|
}
|