@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.
- package/package.json +11 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +40 -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/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +6 -0
- 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/capabilities.ts +4 -2
- package/src/ai/chart-block.ts +36 -711
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tool-contracts.ts +21 -259
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/executor.ts +13 -1
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/protocol.ts +3 -1
- package/src/ai/routes.ts +2 -0
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -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/capabilities.ts +99 -15
- package/src/api/index.ts +3 -0
- package/src/api/mcp.ts +4 -2
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/browser/notifications.ts +25 -8
- package/src/capabilities/discussion-providers.ts +100 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +69 -0
- package/src/capabilities/response.ts +4 -1
- package/src/capabilities/server-invoke.ts +64 -36
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- 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/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -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/accounts/app.ts +28 -6
- package/src/services/accounts/entities.ts +11 -4
- package/src/services/accounts/groups.ts +10 -4
- package/src/services/accounts/local-groups.ts +87 -38
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/app-keys.ts +298 -0
- package/src/services/gateway.ts +6 -0
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/index.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -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/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- 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/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/start.ts +36 -0
- package/src/styles/global.css +2 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -21,15 +21,19 @@
|
|
|
21
21
|
"!src/**/*.test-fixture.ts",
|
|
22
22
|
"!src/**/*.test-fixture.tsx",
|
|
23
23
|
"!scripts/**/*.test.ts",
|
|
24
|
+
"!scripts/**/*.test-fixture.ts",
|
|
24
25
|
"!scripts/runtime-recovery-*.ts"
|
|
25
26
|
],
|
|
26
27
|
"exports": {
|
|
27
28
|
".": "./src/index.ts",
|
|
29
|
+
"./app-key": "./src/app-key.ts",
|
|
30
|
+
"./start": "./src/start.ts",
|
|
28
31
|
"./desktop": "./src/desktop/index.ts",
|
|
29
32
|
"./desktop/solid": "./src/desktop/solid.tsx",
|
|
30
33
|
"./server": "./src/server/index.ts",
|
|
31
34
|
"./server/*": "./src/server/*",
|
|
32
35
|
"./geo/ui": "./src/geo/ui.ts",
|
|
36
|
+
"./geo/document": "./src/contracts/geo-document.ts",
|
|
33
37
|
"./browser": "./src/server/api-client.ts",
|
|
34
38
|
"./browser/session": "./src/browser/session.ts",
|
|
35
39
|
"./browser/shared-text": "./src/browser/shared-text.ts",
|
|
@@ -66,6 +70,7 @@
|
|
|
66
70
|
"./shared": "./src/shared/index.ts",
|
|
67
71
|
"./shared-text": "./src/shared-text/index.ts",
|
|
68
72
|
"./services": "./src/services/index.ts",
|
|
73
|
+
"./services/pdf": "./src/services/pdf/index.ts",
|
|
69
74
|
"./services/image-metadata": "./src/services/image-metadata.ts",
|
|
70
75
|
"./services/document-extraction": "./src/services/document-extraction.ts",
|
|
71
76
|
"./services/outbox": "./src/services/outbox.ts",
|
|
@@ -94,6 +99,7 @@
|
|
|
94
99
|
"./config": "./src/config/index.ts",
|
|
95
100
|
"./config/*": "./src/config/*",
|
|
96
101
|
"./contracts": "./src/contracts/index.ts",
|
|
102
|
+
"./contracts/geo-snapshot": "./src/contracts/geo-snapshot.ts",
|
|
97
103
|
"./contracts/notifications": "./src/contracts/notification-types.ts",
|
|
98
104
|
"./contracts/*": "./src/contracts/*",
|
|
99
105
|
"./events/testing": "./src/events/testing.ts",
|
|
@@ -124,8 +130,8 @@
|
|
|
124
130
|
"@tabler/icons": "3.48.0",
|
|
125
131
|
"@tailwindcss/typography": "0.5.20",
|
|
126
132
|
"@k2b/nessi": "0.18.0",
|
|
127
|
-
"@k2b/ssr": "0.20.
|
|
128
|
-
"@k2b/ui": "0.
|
|
133
|
+
"@k2b/ssr": "0.20.2",
|
|
134
|
+
"@k2b/ui": "0.37.0",
|
|
129
135
|
"@k2b/stdlib": "0.30.0",
|
|
130
136
|
"@k2b/sync": "7.0.1",
|
|
131
137
|
"@nats-io/jetstream": "3.4.0",
|
|
@@ -156,7 +162,8 @@
|
|
|
156
162
|
"yjs": "13.6.33",
|
|
157
163
|
"hash-wasm": "4.12.0",
|
|
158
164
|
"@types/sanitize-html": "2.16.1",
|
|
159
|
-
"@types/web-push": "3.6.4"
|
|
165
|
+
"@types/web-push": "3.6.4",
|
|
166
|
+
"@k2b/editor": "0.1.0"
|
|
160
167
|
},
|
|
161
168
|
"devDependencies": {
|
|
162
169
|
"@babel/core": "8.0.6",
|
package/scripts/build.ts
CHANGED
|
@@ -116,9 +116,13 @@ const { app, plugin } = await import(configPath);
|
|
|
116
116
|
// every production app without requiring app-owned bootstrap imports.
|
|
117
117
|
const serverEntry = resolve(dist, ".server-entry.ts");
|
|
118
118
|
const reflectMetadata = Bun.resolveSync("reflect-metadata", frameworkDir);
|
|
119
|
+
const platformStart = resolve(frameworkDir, "src/start.ts");
|
|
120
|
+
const declaredAppId = app?.meta.id ?? appId;
|
|
121
|
+
if (declaredAppId !== appId)
|
|
122
|
+
throw new Error(`APP_ID ${JSON.stringify(appId)} does not match declared app ${JSON.stringify(declaredAppId)}`);
|
|
119
123
|
await writeFile(
|
|
120
124
|
serverEntry,
|
|
121
|
-
`import ${JSON.stringify(reflectMetadata)};\
|
|
125
|
+
`import ${JSON.stringify(reflectMetadata)};\nimport { startPlatform } from ${JSON.stringify(platformStart)};\nconst application = await startPlatform({ appId: ${JSON.stringify(declaredAppId)}, load: () => import(${JSON.stringify(resolve(appDir, "src/index.ts"))}) });\nexport default application.default;\n`,
|
|
122
126
|
);
|
|
123
127
|
let server: Awaited<ReturnType<typeof Bun.build>>;
|
|
124
128
|
try {
|
package/scripts/preload.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { cp, mkdir } from "node:fs/promises";
|
|
|
22
22
|
import { dirname, resolve } from "node:path";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import tailwind from "bun-plugin-tailwind";
|
|
25
|
+
import { startPlatform } from "../src/start";
|
|
25
26
|
import { writeAppFavicon } from "./app-favicon";
|
|
26
27
|
import { buildBrowserPerformance } from "./browser-performance";
|
|
27
28
|
import { writeInstallableIcons } from "./installable-assets";
|
|
@@ -43,7 +44,10 @@ if (!existsSync(appDir)) throw new Error(`Unknown app dir: ${appDir} (set APP_DI
|
|
|
43
44
|
// Share the resolved source directory with defineApp before importing config.
|
|
44
45
|
process.env.APP_DIR = appDir;
|
|
45
46
|
|
|
46
|
-
const { app, plugin } = await import(resolve(appDir, "src/config"));
|
|
47
|
+
const { app, plugin } = await startPlatform({ appId, load: () => import(resolve(appDir, "src/config")) });
|
|
48
|
+
if (app && app.meta.id !== appId) {
|
|
49
|
+
throw new Error(`APP_ID ${JSON.stringify(appId)} does not match declared app ${JSON.stringify(app.meta.id)}`);
|
|
50
|
+
}
|
|
47
51
|
Bun.plugin((app?.plugin ?? plugin)());
|
|
48
52
|
|
|
49
53
|
// ── Build CSS ───────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { CAPABILITY_MAX_REQUEST_BYTES } from "../contracts/capabilities";
|
|
4
|
+
import { resolveLocale } from "../server/locale";
|
|
5
|
+
import { verifyAppQueryInvocation } from "../services/identity/invocation-token";
|
|
6
|
+
import { get } from "../services/settings";
|
|
7
|
+
import { readBoundedJson } from "./bounded-json";
|
|
8
|
+
import { type CompiledCapabilities, invokeCompiledCapability, serializeCapabilityProviderResult } from "./capabilities";
|
|
9
|
+
import { withCapabilityRequestTimeout } from "./capability-timeout";
|
|
10
|
+
|
|
11
|
+
/** Framework-only route: never installs an app actor in the ordinary auth middleware. */
|
|
12
|
+
export const createAppQueryRoutes = (compiled: CompiledCapabilities) =>
|
|
13
|
+
new Hono().post("/:capabilityId", (c) =>
|
|
14
|
+
withCapabilityRequestTimeout(c, "query", async () => {
|
|
15
|
+
const operation = compiled.queries.get(c.req.param("capabilityId"));
|
|
16
|
+
const deny = () => c.json({ code: "FORBIDDEN", message: "A permitted app query invocation is required" }, 403);
|
|
17
|
+
if (operation?.manifest.appOnly !== true || operation.manifest.stream) return deny();
|
|
18
|
+
const parsed = await readBoundedJson(c.req.raw, CAPABILITY_MAX_REQUEST_BYTES);
|
|
19
|
+
if (
|
|
20
|
+
!parsed.ok ||
|
|
21
|
+
!parsed.data ||
|
|
22
|
+
typeof parsed.data !== "object" ||
|
|
23
|
+
Array.isArray(parsed.data) ||
|
|
24
|
+
Object.keys(parsed.data).length !== 1 ||
|
|
25
|
+
!("input" in parsed.data)
|
|
26
|
+
)
|
|
27
|
+
return c.json({ code: "VALIDATION_FAILED", message: "Expected a bounded input object" }, 400);
|
|
28
|
+
const token = c.req.header("authorization")?.match(/^Bearer (\S+)$/)?.[1];
|
|
29
|
+
if (!token) return deny();
|
|
30
|
+
let claims: Awaited<ReturnType<typeof verifyAppQueryInvocation>>;
|
|
31
|
+
try {
|
|
32
|
+
claims = await verifyAppQueryInvocation(token, {
|
|
33
|
+
appId: compiled.manifest.appId,
|
|
34
|
+
capabilityId: operation.manifest.localId,
|
|
35
|
+
schemaHash: operation.manifest.schemaHash,
|
|
36
|
+
input: parsed.data.input,
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
return c.json({ code: "APP_UNAVAILABLE", message: "Invocation verification unavailable" }, 503);
|
|
40
|
+
}
|
|
41
|
+
if (!claims) return deny();
|
|
42
|
+
// Revoking/expiring the workload credential also invalidates already-issued query tokens.
|
|
43
|
+
const [active] = await sql<{ id: string }[]>`
|
|
44
|
+
SELECT c.id FROM auth.service_account_credentials c JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
45
|
+
WHERE c.id = ${claims.credential_id}::uuid AND sa.id = ${claims.service_account_id}::uuid
|
|
46
|
+
AND c.kind = 'api_token' AND c.status = 'active' AND (c.expires_at IS NULL OR c.expires_at > now())
|
|
47
|
+
AND 'identity:invoke' = ANY(c.scopes) AND sa.status = 'active' AND sa.kind = 'resource_bound'
|
|
48
|
+
AND sa.delegated_user_id IS NULL AND sa.app_id = ${claims.sub} AND sa.resource_type = 'cloud.app' AND sa.resource_id = ${claims.sub}
|
|
49
|
+
`;
|
|
50
|
+
if (!active) return deny();
|
|
51
|
+
const result = await invokeCompiledCapability({
|
|
52
|
+
compiled,
|
|
53
|
+
kind: "query",
|
|
54
|
+
localId: operation.manifest.localId,
|
|
55
|
+
input: parsed.data.input,
|
|
56
|
+
expectedSchemaHash: claims.schema_hash,
|
|
57
|
+
context: {
|
|
58
|
+
actor: { kind: "app", appId: claims.sub },
|
|
59
|
+
accessSubject: { type: "none" },
|
|
60
|
+
user: null,
|
|
61
|
+
locale: resolveLocale(c.req.raw.headers, await get<string>("app.locale")),
|
|
62
|
+
requestId: claims.request_id,
|
|
63
|
+
origin: "app",
|
|
64
|
+
signal: c.req.raw.signal,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const response = serializeCapabilityProviderResult(result);
|
|
68
|
+
return new Response(response.body, {
|
|
69
|
+
status: response.status,
|
|
70
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
71
|
+
});
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
@@ -30,12 +30,14 @@ import {
|
|
|
30
30
|
CapabilityActionPolicySchema,
|
|
31
31
|
type CapabilityActionReviewResult,
|
|
32
32
|
type CapabilityActionSentences,
|
|
33
|
+
type CapabilityAppExecutionContext,
|
|
33
34
|
type CapabilityCalendarSourceDeclaration,
|
|
34
35
|
CapabilityCalendarSourceManifestSchema,
|
|
35
36
|
type CapabilityCommandDefinition,
|
|
36
37
|
type CapabilityCommandManifest,
|
|
37
38
|
CapabilityCommandManifestSchema,
|
|
38
39
|
type CapabilityDefinitions,
|
|
40
|
+
CapabilityDiscussionsManifestSchema,
|
|
39
41
|
type CapabilityError,
|
|
40
42
|
CapabilityErrorSchema,
|
|
41
43
|
type CapabilityExecutionContext,
|
|
@@ -51,16 +53,21 @@ import {
|
|
|
51
53
|
type CapabilityQueryDefinition,
|
|
52
54
|
type CapabilityQueryManifest,
|
|
53
55
|
type CapabilityResourceTypeDefinition,
|
|
56
|
+
type CapabilityResourceTypeModel,
|
|
54
57
|
type CapabilitySearchTagDefinition,
|
|
55
58
|
type CapabilityStreamDefinition,
|
|
56
59
|
type CapabilityUniversalSearchDefinition,
|
|
57
60
|
CloudResourceViewSchema,
|
|
58
61
|
capabilityActionPolicy,
|
|
59
62
|
capabilityResultSchema,
|
|
63
|
+
ResourceReadableDataSchema,
|
|
64
|
+
ResourceReadableInputSchema,
|
|
60
65
|
UniversalSearchDataSchema,
|
|
61
66
|
UniversalSearchInputSchema,
|
|
62
67
|
} from "../contracts/capabilities";
|
|
68
|
+
import { discussionsIssues } from "../contracts/discussions";
|
|
63
69
|
import { fileProviderIssues } from "../contracts/file-provider";
|
|
70
|
+
import { typeViewIssues } from "../contracts/resource-types";
|
|
64
71
|
import { canonicalLocale, localeFallbackChain, normalizeLocale } from "../shared/locale";
|
|
65
72
|
import { capabilityActionReceiptIssues, capabilityActionReviewIssues, capabilityQueryChecksActionOutcome } from "./capability-reviews";
|
|
66
73
|
import {
|
|
@@ -71,6 +78,10 @@ import {
|
|
|
71
78
|
parseCapabilitySentence,
|
|
72
79
|
resolveCapabilityActionSentences,
|
|
73
80
|
} from "./capability-sentences";
|
|
81
|
+
import { activeResourcePrincipals } from "./resource-principals";
|
|
82
|
+
|
|
83
|
+
/** App-only queries never enter a person catalog. */
|
|
84
|
+
export const isPersonVisibleCapabilityQuery = (query: CapabilityQueryManifest): boolean => query.appOnly !== true;
|
|
74
85
|
|
|
75
86
|
type JsonSchema = Record<string, unknown>;
|
|
76
87
|
const MAX_CAPABILITY_MANIFEST_BYTES = 256 * 1024;
|
|
@@ -96,6 +107,7 @@ const DECLARATION_FIELDS = {
|
|
|
96
107
|
commands: true,
|
|
97
108
|
fileProvider: true,
|
|
98
109
|
calendarSource: true,
|
|
110
|
+
discussions: true,
|
|
99
111
|
events: true,
|
|
100
112
|
subscribes: true,
|
|
101
113
|
} satisfies DefinedFields<CapabilityDefinitions>;
|
|
@@ -114,8 +126,11 @@ const TYPE_FIELDS = {
|
|
|
114
126
|
color: true,
|
|
115
127
|
reader: true,
|
|
116
128
|
pages: true,
|
|
129
|
+
container: true,
|
|
130
|
+
access: true,
|
|
117
131
|
} satisfies DefinedFields<CapabilityResourceTypeDefinition>;
|
|
118
132
|
const QUERY_FIELDS = {
|
|
133
|
+
appOnly: true,
|
|
119
134
|
title: true,
|
|
120
135
|
description: true,
|
|
121
136
|
input: true,
|
|
@@ -138,7 +153,7 @@ const ACTION_FIELDS = {
|
|
|
138
153
|
policy: true,
|
|
139
154
|
review: true,
|
|
140
155
|
run: true,
|
|
141
|
-
} satisfies DefinedFields<CapabilityActionDefinition
|
|
156
|
+
} satisfies DefinedFields<Omit<CapabilityActionDefinition, "appOnly">>;
|
|
142
157
|
const COMMAND_FIELDS = {
|
|
143
158
|
title: true,
|
|
144
159
|
description: true,
|
|
@@ -170,8 +185,14 @@ const assertDefinedStreamFields = (stream: CapabilityStreamDefinition | undefine
|
|
|
170
185
|
if (stream) assertDefinedFields(stream, stream.direction === "write" ? WRITE_STREAM_FIELDS : READ_STREAM_FIELDS, `${label} stream`);
|
|
171
186
|
};
|
|
172
187
|
|
|
188
|
+
type AppQueryDefinition = Omit<CapabilityQueryDefinition, "appOnly" | "run"> & {
|
|
189
|
+
appOnly: true;
|
|
190
|
+
run: (input: unknown, context: CapabilityAppExecutionContext) => Promise<CapabilityInvocationResult<unknown>>;
|
|
191
|
+
};
|
|
192
|
+
type QueryDefinition = CapabilityQueryDefinition | AppQueryDefinition;
|
|
193
|
+
|
|
173
194
|
export type CompiledCapabilityQuery = {
|
|
174
|
-
definition:
|
|
195
|
+
definition: QueryDefinition;
|
|
175
196
|
manifest: CapabilityQueryManifest;
|
|
176
197
|
resultSchema: z.ZodType;
|
|
177
198
|
};
|
|
@@ -621,7 +642,7 @@ const assertLocalId = (localId: string, label: string): void => {
|
|
|
621
642
|
if (!parsed.success) throw new Error(`${label} key "${localId}" is invalid: ${parsed.error.issues[0]?.message ?? "invalid id"}`);
|
|
622
643
|
};
|
|
623
644
|
|
|
624
|
-
const normalizeSearchTags = (definition:
|
|
645
|
+
const normalizeSearchTags = (definition: QueryDefinition, label: string) => {
|
|
625
646
|
if (!definition.universalSearch) return undefined;
|
|
626
647
|
assertDefinedFields(definition.universalSearch, UNIVERSAL_SEARCH_FIELDS, `${label} universalSearch`);
|
|
627
648
|
const seen = new Set<string>();
|
|
@@ -647,7 +668,7 @@ const normalizeSearchTags = (definition: CapabilityQueryDefinition, label: strin
|
|
|
647
668
|
return { tags, ...(definition.universalSearch.scopeTypes ? { scopeTypes: [...new Set(definition.universalSearch.scopeTypes)] } : {}) };
|
|
648
669
|
};
|
|
649
670
|
|
|
650
|
-
const compileOperationSchemas = (definition:
|
|
671
|
+
const compileOperationSchemas = (definition: QueryDefinition | CapabilityActionDefinition, label: string) => {
|
|
651
672
|
assertText(definition.title, `${label} title`, 120);
|
|
652
673
|
assertText(definition.description, `${label} description`, 1000);
|
|
653
674
|
const inputSchema = projectSchema(definition.input, `${label} input`, "input");
|
|
@@ -667,6 +688,7 @@ const compileOperationSchemas = (definition: CapabilityQueryDefinition | Capabil
|
|
|
667
688
|
schemaHash: capabilityHash({
|
|
668
689
|
inputSchema,
|
|
669
690
|
dataSchema,
|
|
691
|
+
...("appOnly" in definition && definition.appOnly !== undefined ? { appOnly: definition.appOnly } : {}),
|
|
670
692
|
...(definition.stream ? { stream: { direction: definition.stream.direction, maxBytes: definition.stream.maxBytes } } : {}),
|
|
671
693
|
}),
|
|
672
694
|
};
|
|
@@ -688,6 +710,26 @@ export const compileCapabilities = (
|
|
|
688
710
|
}
|
|
689
711
|
if (definitions.fileProvider) assertDefinedFields(definitions.fileProvider, FILE_PROVIDER_FIELDS, "fileProvider");
|
|
690
712
|
|
|
713
|
+
if (definitions.discussions !== undefined) CapabilityDiscussionsManifestSchema.parse(definitions.discussions);
|
|
714
|
+
for (const [localId, type] of Object.entries(definitions.types ?? {})) {
|
|
715
|
+
if (type.access !== undefined) {
|
|
716
|
+
assertDefinedFields(type.access, { readable: true }, `Resource type ${localId} access`);
|
|
717
|
+
if (type.access.readable !== undefined && typeof type.access.readable !== "function")
|
|
718
|
+
throw new Error(`Resource type ${localId} access.readable must be a function`);
|
|
719
|
+
}
|
|
720
|
+
const visited = new Set([localId]);
|
|
721
|
+
let container = type.container;
|
|
722
|
+
while (container !== undefined) {
|
|
723
|
+
CapabilityLocalIdSchema.parse(container);
|
|
724
|
+
const parent = definitions.types?.[container];
|
|
725
|
+
if (!parent || !Object.hasOwn(definitions.types ?? {}, container))
|
|
726
|
+
throw new Error(`Resource type ${localId}: unknown container ${container}`);
|
|
727
|
+
if (visited.has(container)) throw new Error(`Resource type ${localId}: container cycle at ${container}`);
|
|
728
|
+
visited.add(container);
|
|
729
|
+
container = parent.container;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
691
733
|
const localIds = new Set<string>();
|
|
692
734
|
const registerLocalId = (localId: string, kind: string): void => {
|
|
693
735
|
assertLocalId(localId, kind);
|
|
@@ -722,11 +764,55 @@ export const compileCapabilities = (
|
|
|
722
764
|
});
|
|
723
765
|
const typeIds = new Set(types.map((type) => qualifiedId(appId, type.localId)));
|
|
724
766
|
|
|
767
|
+
for (const [localId, definition] of Object.entries(definitions.queries ?? {}))
|
|
768
|
+
if (Object.hasOwn(definition, "appOnly")) throw new Error(`Query ${localId}: appOnly is reserved for platform-generated reader checks`);
|
|
769
|
+
const generated: Record<string, AppQueryDefinition> = {};
|
|
770
|
+
for (const [localId, type] of Object.entries(definitions.types ?? {})) {
|
|
771
|
+
const readable = type.access?.readable;
|
|
772
|
+
if (!readable) continue;
|
|
773
|
+
const queryId = `${localId}.readable`;
|
|
774
|
+
if (Object.hasOwn(definitions.queries ?? {}, queryId)) throw new Error(`Generated Query ${queryId} collides with a declared Query`);
|
|
775
|
+
const container = type.container;
|
|
776
|
+
const input = container
|
|
777
|
+
? ResourceReadableInputSchema.extend({
|
|
778
|
+
container: ResourceReadableInputSchema.shape.container
|
|
779
|
+
.unwrap()
|
|
780
|
+
.extend({ type: z.literal(qualifiedId(appId, container)).describe("Declared container type.") })
|
|
781
|
+
.describe("Expected current container of the resource."),
|
|
782
|
+
})
|
|
783
|
+
: ResourceReadableInputSchema;
|
|
784
|
+
generated[queryId] = {
|
|
785
|
+
title: "Check resource readers",
|
|
786
|
+
description: `Check current principal readability of ${type.title} without granting content access.`,
|
|
787
|
+
input,
|
|
788
|
+
data: ResourceReadableDataSchema,
|
|
789
|
+
openWorld: false,
|
|
790
|
+
appOnly: true,
|
|
791
|
+
run: async (value: unknown, context) => {
|
|
792
|
+
if (!isAppCapabilityContext(context))
|
|
793
|
+
return { ok: false, error: { code: "FORBIDDEN", message: "App authority required", status: 403 } };
|
|
794
|
+
const parsed = input.parse(value);
|
|
795
|
+
const principals = await activeResourcePrincipals(parsed.principals);
|
|
796
|
+
const key = (principal: (typeof principals)[number]) => JSON.stringify(principal);
|
|
797
|
+
const requested = new Set(principals.map(key));
|
|
798
|
+
const result = ResourceReadableDataSchema.parse({ readable: await readable({ ...parsed, principals }, context) });
|
|
799
|
+
if (result.readable.some((principal) => !requested.has(key(principal))))
|
|
800
|
+
throw new Error(`Resource type ${localId} readable returned an unrequested principal`);
|
|
801
|
+
return { ok: true, data: { data: result } };
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
}
|
|
725
805
|
const queries = new Map<string, CompiledCapabilityQuery>();
|
|
726
|
-
for (const [localId, definition] of Object.entries(definitions.queries
|
|
806
|
+
for (const [localId, definition] of Object.entries({ ...definitions.queries, ...generated }).sort(([left], [right]) =>
|
|
807
|
+
left.localeCompare(right),
|
|
808
|
+
)) {
|
|
727
809
|
registerLocalId(localId, "Query");
|
|
728
810
|
const label = `Query ${localId}`;
|
|
729
811
|
assertDefinedFields(definition, QUERY_FIELDS, label);
|
|
812
|
+
if (definition.appOnly !== undefined) {
|
|
813
|
+
z.literal(true).parse(definition.appOnly);
|
|
814
|
+
if (definition.stream) throw new Error(`${label}: app-only queries cannot use streams`);
|
|
815
|
+
}
|
|
730
816
|
assertDefinedStreamFields(definition.stream, label);
|
|
731
817
|
if (
|
|
732
818
|
definition.stream &&
|
|
@@ -756,6 +842,7 @@ export const compileCapabilities = (
|
|
|
756
842
|
...(definition.stream ? { stream: { direction: definition.stream.direction, maxBytes: definition.stream.maxBytes } } : {}),
|
|
757
843
|
openWorld: definition.openWorld,
|
|
758
844
|
universalSearch: normalizeSearchTags(definition, label),
|
|
845
|
+
...(definition.appOnly !== undefined ? { appOnly: definition.appOnly } : {}),
|
|
759
846
|
} satisfies CapabilityQueryManifest;
|
|
760
847
|
for (const type of manifest.universalSearch?.scopeTypes ?? []) {
|
|
761
848
|
if (!typeIds.has(qualifiedId(appId, type))) throw new Error(`${label} declares an unknown search scope type ${type}`);
|
|
@@ -770,6 +857,7 @@ export const compileCapabilities = (
|
|
|
770
857
|
if (!type.reader) continue;
|
|
771
858
|
const reader = queries.get(type.reader);
|
|
772
859
|
if (!reader) throw new Error(`Resource type ${type.localId} reader ${type.reader} must name an existing Query`);
|
|
860
|
+
if (reader.manifest.appOnly) throw new Error(`Resource type ${type.localId} reader cannot be app-only`);
|
|
773
861
|
assertCanonicalReaderInput(reader.manifest.inputSchema, `Resource type ${type.localId} reader ${type.reader} input`);
|
|
774
862
|
}
|
|
775
863
|
const actions = new Map<string, CompiledCapabilityAction>();
|
|
@@ -931,7 +1019,25 @@ export const compileCapabilities = (
|
|
|
931
1019
|
const resourcePages = Object.entries(definitions.types ?? {})
|
|
932
1020
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
933
1021
|
.flatMap(([type, definition]) => (definition.pages ?? []).map((path) => ({ type, path })));
|
|
1022
|
+
const resourceTypes: CapabilityResourceTypeModel[] = types.flatMap((type) => {
|
|
1023
|
+
const declaration = definitions.types?.[type.localId];
|
|
1024
|
+
const model: CapabilityResourceTypeModel = {
|
|
1025
|
+
type: type.localId,
|
|
1026
|
+
...(declaration?.container ? { container: declaration.container } : {}),
|
|
1027
|
+
...(declaration?.access?.readable ? { readable: true } : {}),
|
|
1028
|
+
};
|
|
1029
|
+
const reader = type.reader ? queries.get(type.reader) : undefined;
|
|
1030
|
+
if (
|
|
1031
|
+
reader &&
|
|
1032
|
+
typeViewIssues({ types, queries: [...queries.values()].map((query) => query.manifest), resourceTypes: [model] }, type.localId)
|
|
1033
|
+
.length === 0
|
|
1034
|
+
)
|
|
1035
|
+
model.view = true;
|
|
1036
|
+
return model.container || model.readable || model.view ? [model] : [];
|
|
1037
|
+
});
|
|
934
1038
|
const manifestBase = {
|
|
1039
|
+
...(resourceTypes.length ? { resourceTypes } : {}),
|
|
1040
|
+
...(definitions.discussions ? { discussions: definitions.discussions } : {}),
|
|
935
1041
|
...(resourcePages.length ? { resourcePages } : {}),
|
|
936
1042
|
protocolVersion: CAPABILITY_PROTOCOL_VERSION,
|
|
937
1043
|
appId,
|
|
@@ -952,6 +1058,9 @@ export const compileCapabilities = (
|
|
|
952
1058
|
...manifestBase,
|
|
953
1059
|
manifestHash: capabilityHash(manifestBase),
|
|
954
1060
|
});
|
|
1061
|
+
const discussionIssues = discussionsIssues(manifest);
|
|
1062
|
+
if (discussionIssues.length)
|
|
1063
|
+
throw new Error(`discussions: ${discussionIssues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
|
|
955
1064
|
const calendarIssues = calendarSourceIssues(manifest);
|
|
956
1065
|
if (calendarIssues.length)
|
|
957
1066
|
throw new Error(
|
|
@@ -1124,6 +1233,8 @@ export const resolveCapabilityManifestPresentation = (
|
|
|
1124
1233
|
}
|
|
1125
1234
|
: {}),
|
|
1126
1235
|
...(current.subscribes ? { subscribes: current.subscribes } : {}),
|
|
1236
|
+
...(current.resourceTypes ? { resourceTypes: current.resourceTypes } : {}),
|
|
1237
|
+
...(current.discussions ? { discussions: current.discussions } : {}),
|
|
1127
1238
|
...(current.resourcePages ? { resourcePages: current.resourcePages } : {}),
|
|
1128
1239
|
...(current.calendarSource ? { calendarSource: current.calendarSource } : {}),
|
|
1129
1240
|
...(current.fileProvider ? { fileProvider: current.fileProvider } : {}),
|
|
@@ -1157,9 +1268,11 @@ const assertUsableOperation = (operation: CapabilityQueryManifest | CapabilityAc
|
|
|
1157
1268
|
if (operation.stream?.direction === "write" && (!("idempotency" in operation) || operation.idempotency !== "required")) {
|
|
1158
1269
|
throw new Error(`Operation ${operation.localId} write streams require an idempotent Action`);
|
|
1159
1270
|
}
|
|
1271
|
+
if ("appOnly" in operation && operation.appOnly !== undefined && operation.stream) throw new Error("App-only queries cannot use streams");
|
|
1160
1272
|
const expectedSchemaHash = capabilityHash({
|
|
1161
1273
|
inputSchema: operation.inputSchema,
|
|
1162
1274
|
dataSchema: operation.dataSchema,
|
|
1275
|
+
...("appOnly" in operation && operation.appOnly !== undefined ? { appOnly: operation.appOnly } : {}),
|
|
1163
1276
|
...(operation.stream ? { stream: operation.stream } : {}),
|
|
1164
1277
|
});
|
|
1165
1278
|
if (operation.schemaHash !== expectedSchemaHash) throw new Error(`Operation ${operation.localId} schemaHash does not match its schemas`);
|
|
@@ -1542,13 +1655,17 @@ const cancelledInvocation = (locale: string) => ({
|
|
|
1542
1655
|
},
|
|
1543
1656
|
});
|
|
1544
1657
|
|
|
1658
|
+
const isAppCapabilityContext = (
|
|
1659
|
+
context: CapabilityExecutionContext | CapabilityAppExecutionContext,
|
|
1660
|
+
): context is CapabilityAppExecutionContext => context.actor.kind === "app";
|
|
1661
|
+
|
|
1545
1662
|
export const invokeCompiledCapability = async (params: {
|
|
1546
1663
|
compiled: CompiledCapabilities;
|
|
1547
1664
|
kind: "query" | "action";
|
|
1548
1665
|
localId: string;
|
|
1549
1666
|
input: unknown;
|
|
1550
1667
|
expectedSchemaHash: string | null;
|
|
1551
|
-
context: CapabilityExecutionContext;
|
|
1668
|
+
context: CapabilityExecutionContext | CapabilityAppExecutionContext;
|
|
1552
1669
|
onUnexpectedError?: (error: unknown) => void;
|
|
1553
1670
|
}): Promise<CapabilityInvocationResult<unknown>> => {
|
|
1554
1671
|
if (!hasOAuthScope(params.context.authority, params.kind === "query" ? "read" : "write", "admin"))
|
|
@@ -1564,6 +1681,8 @@ export const invokeCompiledCapability = async (params: {
|
|
|
1564
1681
|
},
|
|
1565
1682
|
};
|
|
1566
1683
|
}
|
|
1684
|
+
if (!isAppCapabilityContext(params.context) && "appOnly" in operation.definition && operation.definition.appOnly !== undefined)
|
|
1685
|
+
return { ok: false, error: { code: "FORBIDDEN", message: "App authority required", status: 403 } };
|
|
1567
1686
|
if (params.expectedSchemaHash !== operation.manifest.schemaHash) {
|
|
1568
1687
|
return {
|
|
1569
1688
|
ok: false,
|
|
@@ -1575,7 +1694,7 @@ export const invokeCompiledCapability = async (params: {
|
|
|
1575
1694
|
},
|
|
1576
1695
|
};
|
|
1577
1696
|
}
|
|
1578
|
-
let context: CapabilityExecutionContext = {
|
|
1697
|
+
let context: CapabilityExecutionContext | CapabilityAppExecutionContext = {
|
|
1579
1698
|
...params.context,
|
|
1580
1699
|
via: capabilityVia(params.context.origin),
|
|
1581
1700
|
};
|
|
@@ -1624,7 +1743,7 @@ export const invokeCompiledCapability = async (params: {
|
|
|
1624
1743
|
},
|
|
1625
1744
|
};
|
|
1626
1745
|
}
|
|
1627
|
-
context = { ...context, idempotencyKey: idempotencyKey.data };
|
|
1746
|
+
if (!isAppCapabilityContext(context)) context = { ...context, idempotencyKey: idempotencyKey.data };
|
|
1628
1747
|
}
|
|
1629
1748
|
const input = operation.definition.input.safeParse(params.input);
|
|
1630
1749
|
if (!input.success) {
|
|
@@ -1652,7 +1771,16 @@ export const invokeCompiledCapability = async (params: {
|
|
|
1652
1771
|
// cross into application code for the first time with an aborted invocation.
|
|
1653
1772
|
if (context.signal.aborted) return cancelledInvocation(context.locale);
|
|
1654
1773
|
try {
|
|
1655
|
-
|
|
1774
|
+
let invoked: CapabilityInvocationResult<unknown>;
|
|
1775
|
+
if (isAppCapabilityContext(context)) {
|
|
1776
|
+
const definition = operation.definition;
|
|
1777
|
+
if (params.kind !== "query" || !("appOnly" in definition) || definition.appOnly !== true)
|
|
1778
|
+
return { ok: false, error: { code: "FORBIDDEN", message: "App caller is not allowed", status: 403 } };
|
|
1779
|
+
invoked = await definition.run(input.data, context);
|
|
1780
|
+
} else {
|
|
1781
|
+
if (operation.definition.appOnly) return { ok: false, error: { code: "FORBIDDEN", message: "App authority required", status: 403 } };
|
|
1782
|
+
invoked = await operation.definition.run(input.data, context);
|
|
1783
|
+
}
|
|
1656
1784
|
if (!invoked.ok) {
|
|
1657
1785
|
return normalizeProviderError(invoked.error, "Capability returned an invalid error", params.onUnexpectedError);
|
|
1658
1786
|
}
|
|
@@ -2,6 +2,7 @@ import { type AppSkillDefinition, appSkillManifestHash, registerAppSkills, valid
|
|
|
2
2
|
import { createOwnedAppAccessRoutes } from "../api/app-access";
|
|
3
3
|
import { createAppEventDeliveryRoutes } from "../api/app-event-delivery";
|
|
4
4
|
import { createResourceAppAccessRoutes } from "../api/app-resource-access";
|
|
5
|
+
import { createSettingsOpsRoutes, settingsInvocationOperation } from "../api/settings-ops";
|
|
5
6
|
import { type AppAccessDeclaration, compileAppAccess } from "../contracts/app-access";
|
|
6
7
|
import { startAppEventDelivery } from "../events/app-event-delivery";
|
|
7
8
|
import { bindAppEvents } from "../events/app-events";
|
|
@@ -12,6 +13,7 @@ import { registerPageSwap } from "../ssr/page-swap";
|
|
|
12
13
|
import { preloadPanelConsent } from "../ssr/panel-consent";
|
|
13
14
|
import { appAccessManagementPath } from "./app-access-path";
|
|
14
15
|
import { bindResourceAppAccess, RESOURCE_ACCESS_PATH } from "./app-access-resources";
|
|
16
|
+
import { createAppQueryRoutes } from "./app-queries";
|
|
15
17
|
import { registerApplicationPackage, resolveApplicationPackage } from "./application-package";
|
|
16
18
|
import { invokeCapabilityStream } from "./capability-streams";
|
|
17
19
|
import { withCapabilityRequestTimeout } from "./capability-timeout";
|
|
@@ -118,6 +120,7 @@ import { publicFileVersion, servePublicAsset, sharedStylesheetVersion, versioned
|
|
|
118
120
|
import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
|
|
119
121
|
import { WEB_VITALS_ASSET_HREF } from "./web-vitals-asset";
|
|
120
122
|
import { compileWidgetDeclarations } from "./widget-declarations";
|
|
123
|
+
import { createAppWorkloadCredential } from "./workload-credential";
|
|
121
124
|
|
|
122
125
|
/** Only development links use this start stamp to pick up rebuilds. */
|
|
123
126
|
const developmentStamp = Date.now();
|
|
@@ -690,6 +693,10 @@ export const defineApp = <
|
|
|
690
693
|
// ── 3. start() — builds and boots the Hono server ────────────────────
|
|
691
694
|
const start = async (startOpts: StartOptions): Promise<StartResult> => {
|
|
692
695
|
const port = startOpts.port ?? 3000;
|
|
696
|
+
if (env.GOTENBERG_URL)
|
|
697
|
+
console.warn("[deprecation] GOTENBERG_URL only bootstraps gotenberg.url; remove it after cld admin settings status --env succeeds.");
|
|
698
|
+
if (env.CLOUD_IDENTITY_JWKS_ORIGIN)
|
|
699
|
+
console.warn("[deprecation] CLOUD_IDENTITY_JWKS_ORIGIN is deprecated; use CLOUD_CORE_INTERNAL_ORIGIN instead.");
|
|
693
700
|
const baseUrl = opts.baseUrl;
|
|
694
701
|
const log = logger("app");
|
|
695
702
|
const http = createHttpDrain();
|
|
@@ -863,6 +870,7 @@ export const defineApp = <
|
|
|
863
870
|
await capabilityHeartbeat?.stop();
|
|
864
871
|
});
|
|
865
872
|
cleanup.push(() => heartbeat.stop());
|
|
873
|
+
cleanup.push(() => membership.leave());
|
|
866
874
|
|
|
867
875
|
// Runtime context — start the registry watcher so middleware.runtime() and
|
|
868
876
|
// the lifecycle context below see populated cluster state. Idempotent: the
|
|
@@ -908,6 +916,18 @@ export const defineApp = <
|
|
|
908
916
|
if (!c.get("user")?.roles.includes("admin")) return c.json({ message: "Insufficient permissions" }, 403);
|
|
909
917
|
return next();
|
|
910
918
|
})
|
|
919
|
+
.use(
|
|
920
|
+
"/_internal/settings/*",
|
|
921
|
+
requireInvocation(
|
|
922
|
+
(c) => ({
|
|
923
|
+
targetAppId: meta.id,
|
|
924
|
+
operation: settingsInvocationOperation(c.req.method, new URL(c.req.url).pathname.slice("/_internal/settings/".length)),
|
|
925
|
+
schemaHash: null,
|
|
926
|
+
}),
|
|
927
|
+
invocationApp,
|
|
928
|
+
),
|
|
929
|
+
)
|
|
930
|
+
.route("/_internal/settings", createSettingsOpsRoutes(Object.keys(opts.settings ?? {})))
|
|
911
931
|
.route("/_internal/sync", createSyncOpsRoutes(getProcessSync));
|
|
912
932
|
|
|
913
933
|
if (installable) server.route("/", createInstallableRoutes(meta.id, installable));
|
|
@@ -916,6 +936,7 @@ export const defineApp = <
|
|
|
916
936
|
server.route("/api/_internal/events/v1", createAppEventDeliveryRoutes(compiledCapabilities));
|
|
917
937
|
|
|
918
938
|
if (compiledCapabilities) {
|
|
939
|
+
server.route("/api/_internal/capabilities/v1/app-queries", createAppQueryRoutes(compiledCapabilities));
|
|
919
940
|
const invoke = async (c: Context<AuthContext>, kind: "query" | "action" | "review") =>
|
|
920
941
|
withCapabilityRequestTimeout(c, kind, async () => {
|
|
921
942
|
// Framework-owned endpoints run before the app's settings middleware,
|
|
@@ -1143,6 +1164,8 @@ export const defineApp = <
|
|
|
1143
1164
|
|
|
1144
1165
|
let stopNotificationRegistration: (() => void) | undefined;
|
|
1145
1166
|
cleanup.push(() => stopNotificationRegistration?.());
|
|
1167
|
+
let workloadCredential: ReturnType<typeof createAppWorkloadCredential> | undefined;
|
|
1168
|
+
cleanup.push(() => workloadCredential?.stop());
|
|
1146
1169
|
const stopLifecycle = startOpts.lifecycle?.stop;
|
|
1147
1170
|
if (stopLifecycle) cleanup.push(() => stopLifecycle(cloudCtx));
|
|
1148
1171
|
|
|
@@ -1167,6 +1190,23 @@ export const defineApp = <
|
|
|
1167
1190
|
|
|
1168
1191
|
await loadSettingsCache();
|
|
1169
1192
|
|
|
1193
|
+
// The instance can obtain its credential before workers run; routes remain undiscoverable.
|
|
1194
|
+
await membership.register();
|
|
1195
|
+
workloadCredential = createAppWorkloadCredential({
|
|
1196
|
+
appId: meta.id,
|
|
1197
|
+
instanceId: membership.instanceId,
|
|
1198
|
+
onRecovered: () => log.info("App workload credential refresh recovered", { appId: meta.id }),
|
|
1199
|
+
onUnavailable: (reason) => {
|
|
1200
|
+
if (reason === "release_unavailable") {
|
|
1201
|
+
log.warn("Workload credential release was not confirmed; Core will reconcile after removal", { appId: meta.id });
|
|
1202
|
+
} else {
|
|
1203
|
+
log.error("App workload credential unavailable; background invocation is disabled", { appId: meta.id, reason });
|
|
1204
|
+
}
|
|
1205
|
+
},
|
|
1206
|
+
});
|
|
1207
|
+
// The NATS request has a five-second timeout; an outage leaves workers fail-closed.
|
|
1208
|
+
await workloadCredential.start();
|
|
1209
|
+
|
|
1170
1210
|
if (startOpts.lifecycle?.start) {
|
|
1171
1211
|
log.info(`Start: ${meta.id}`);
|
|
1172
1212
|
await startOpts.lifecycle.start(cloudCtx);
|
|
@@ -94,6 +94,25 @@ export const createRegistryMembership = (
|
|
|
94
94
|
};
|
|
95
95
|
|
|
96
96
|
return {
|
|
97
|
+
instanceId,
|
|
98
|
+
// Establish process identity before lifecycle workers start, without advertising routes yet.
|
|
99
|
+
register: async () => {
|
|
100
|
+
let timer: Timer | undefined;
|
|
101
|
+
// This runs before the ordinary heartbeat supplies its write deadline.
|
|
102
|
+
const write = upsert().finally(async () => {
|
|
103
|
+
if (left) await options.registry().delete({ key });
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
await Promise.race([
|
|
107
|
+
write,
|
|
108
|
+
new Promise<never>((_, reject) => {
|
|
109
|
+
timer = setTimeout(() => reject(new Error(`Instance registration timed out after ${writeTimeoutMs}ms`)), writeTimeoutMs);
|
|
110
|
+
}),
|
|
111
|
+
]);
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
97
116
|
join: async (sharedKey: string, sharedValue: unknown, entry: SharedEntry): Promise<void> => {
|
|
98
117
|
left = false;
|
|
99
118
|
await prepareShared(sharedKey);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Slice 7 removes this legacy env-URL rewrite together with Bun-global client imports.
|
|
1
2
|
/** Name Bun's default pool before its first use without changing connection ownership. */
|
|
2
3
|
export const configurePostgresApplicationName = (
|
|
3
4
|
applicationId: string,
|
|
@@ -28,3 +29,13 @@ export const configurePostgresApplicationName = (
|
|
|
28
29
|
url.searchParams.set("application_name", `cloud:${applicationId}`);
|
|
29
30
|
environment[variable] = url.toString();
|
|
30
31
|
};
|
|
32
|
+
|
|
33
|
+
/** Preserve operator URL options; otherwise name every connection through native client options. */
|
|
34
|
+
export const postgresApplicationOptions = (applicationId: string, connectionUrl: string): Record<string, string> => {
|
|
35
|
+
const url = new URL(connectionUrl);
|
|
36
|
+
let operatorConfigured = false;
|
|
37
|
+
url.searchParams.forEach((_value, key) => {
|
|
38
|
+
if (key.toLowerCase() === "application_name" || key.toLowerCase() === "options") operatorConfigured = true;
|
|
39
|
+
});
|
|
40
|
+
return operatorConfigured ? {} : { application_name: `cloud:${applicationId}` };
|
|
41
|
+
};
|