@k2b/cloud 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/package.json +11 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/app-queries.ts +73 -0
  5. package/src/_internal/capabilities.ts +137 -9
  6. package/src/_internal/define-app.ts +40 -0
  7. package/src/_internal/heartbeat.ts +19 -0
  8. package/src/_internal/postgres-application-name.ts +11 -0
  9. package/src/_internal/process-sync.ts +9 -0
  10. package/src/_internal/readiness.ts +6 -1
  11. package/src/_internal/registry-validation.ts +17 -4
  12. package/src/_internal/registry.ts +32 -4
  13. package/src/_internal/resource-principals.ts +6 -0
  14. package/src/_internal/workload-credential.ts +153 -0
  15. package/src/_internal/workload-protocol.ts +36 -0
  16. package/src/ai/application-tasks.ts +5 -2
  17. package/src/ai/capabilities.ts +4 -2
  18. package/src/ai/chart-block.ts +36 -711
  19. package/src/ai/chat/map-messages.ts +270 -0
  20. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  21. package/src/ai/chat/map-notebook.tsx +301 -0
  22. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  23. package/src/ai/chat/map-widget.css +107 -0
  24. package/src/ai/chat/map-widget.tsx +1279 -0
  25. package/src/ai/chat/message-utils.ts +1 -0
  26. package/src/ai/chat/tool-names.ts +2 -0
  27. package/src/ai/chat/turn-layout.ts +2 -1
  28. package/src/ai/chat/turn-view.tsx +17 -1
  29. package/src/ai/default-tool-contracts.ts +21 -259
  30. package/src/ai/default-tools.ts +3 -0
  31. package/src/ai/executor.ts +13 -1
  32. package/src/ai/map-contracts.ts +209 -0
  33. package/src/ai/map-execution.ts +14 -0
  34. package/src/ai/map-notebook-save.ts +276 -0
  35. package/src/ai/map-notebook.ts +32 -0
  36. package/src/ai/map-opening-hours.ts +40 -0
  37. package/src/ai/map-routes.ts +84 -0
  38. package/src/ai/map-service.ts +288 -0
  39. package/src/ai/map-store.ts +137 -0
  40. package/src/ai/map-tool.ts +73 -0
  41. package/src/ai/migrate.ts +3 -0
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/routes.ts +2 -0
  44. package/src/ai/store.ts +19 -6
  45. package/src/ai/tools.ts +14 -0
  46. package/src/ai/types.ts +3 -0
  47. package/src/api/admin-app-keys.ts +66 -0
  48. package/src/api/admin-core-settings.ts +2 -0
  49. package/src/api/admin-identity.ts +27 -1
  50. package/src/api/admin-settings-env.ts +120 -0
  51. package/src/api/capabilities.ts +99 -15
  52. package/src/api/index.ts +3 -0
  53. package/src/api/mcp.ts +4 -2
  54. package/src/api/settings-ops.ts +35 -0
  55. package/src/app-key.ts +125 -0
  56. package/src/browser/CloudResourceSearch.tsx +48 -41
  57. package/src/browser/notifications.ts +25 -8
  58. package/src/capabilities/discussion-providers.ts +100 -0
  59. package/src/capabilities/executions.ts +2 -2
  60. package/src/capabilities/migrate.ts +10 -1
  61. package/src/capabilities/resource-readers.ts +69 -0
  62. package/src/capabilities/response.ts +4 -1
  63. package/src/capabilities/server-invoke.ts +64 -36
  64. package/src/capabilities/server.ts +17 -6
  65. package/src/capabilities/testing.ts +13 -0
  66. package/src/capabilities/types.ts +1 -1
  67. package/src/cli/admin/app-credentials.ts +32 -7
  68. package/src/cli/admin/app-keys.ts +226 -0
  69. package/src/cli/admin/index.ts +7 -1
  70. package/src/cli/admin/settings.ts +62 -0
  71. package/src/config/define-env.ts +63 -8
  72. package/src/config/env.ts +27 -21
  73. package/src/config/index.ts +12 -1
  74. package/src/config/workflow-ai.ts +28 -0
  75. package/src/contracts/capabilities.ts +137 -4
  76. package/src/contracts/discussions.ts +91 -0
  77. package/src/contracts/geo-document.ts +60 -0
  78. package/src/contracts/geo-snapshot.ts +86 -0
  79. package/src/contracts/geo.ts +14 -0
  80. package/src/contracts/index.ts +2 -0
  81. package/src/contracts/outgoing-mail.ts +1 -1
  82. package/src/contracts/resource-types.ts +21 -0
  83. package/src/contracts/settings-env.ts +21 -0
  84. package/src/contracts/settings-types.ts +6 -0
  85. package/src/geo/AddressAutocomplete.tsx +213 -0
  86. package/src/geo/messages.ts +29 -0
  87. package/src/geo/navigation.ts +25 -0
  88. package/src/geo/ui.ts +2 -0
  89. package/src/index.ts +9 -0
  90. package/src/runtime/clients.ts +72 -0
  91. package/src/runtime/connections.ts +37 -0
  92. package/src/runtime/resolve-connections.ts +91 -0
  93. package/src/server/client-ip.ts +1 -1
  94. package/src/services/accounts/app.ts +28 -6
  95. package/src/services/accounts/entities.ts +11 -4
  96. package/src/services/accounts/groups.ts +10 -4
  97. package/src/services/accounts/local-groups.ts +87 -38
  98. package/src/services/accounts/machine-group-management.ts +94 -0
  99. package/src/services/app-keys.ts +298 -0
  100. package/src/services/gateway.ts +6 -0
  101. package/src/services/geo/cache.ts +2 -1
  102. package/src/services/geo/geoapify.ts +3 -0
  103. package/src/services/identity/index.ts +3 -0
  104. package/src/services/identity/invocation-token.ts +110 -0
  105. package/src/services/identity/resource-readers.ts +80 -0
  106. package/src/services/identity/runtime-config.ts +14 -6
  107. package/src/services/identity/workload-runtime.ts +253 -0
  108. package/src/services/index.ts +4 -1
  109. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  110. package/src/services/pdf/geo-snapshot.ts +107 -0
  111. package/src/services/pdf/index.ts +2 -0
  112. package/src/services/service-account-credentials.ts +63 -21
  113. package/src/services/settings/api.ts +3 -3
  114. package/src/services/settings/binding.ts +45 -0
  115. package/src/services/settings/core-settings.ts +99 -1
  116. package/src/services/settings/defaults.ts +16 -2
  117. package/src/services/settings/env-status.ts +69 -0
  118. package/src/services/settings/index.ts +69 -26
  119. package/src/services/settings/store.ts +73 -8
  120. package/src/services/settings/validation.ts +40 -0
  121. package/src/shared/index.ts +11 -10
  122. package/src/shared/markdown/extensions/tables.ts +10 -1
  123. package/src/ssr/AdminArea.tsx +2 -2
  124. package/src/ssr/AdminLayout.tsx +1 -1
  125. package/src/ssr/AdminSidebar.tsx +1 -1
  126. package/src/start.ts +36 -0
  127. package/src/styles/global.css +2 -0
  128. package/src/workflows/ai/index.ts +1 -0
  129. package/src/workflows/ai/runtime.ts +16 -8
  130. package/src/workflows/ai/settings.ts +5 -0
  131. package/src/shared/markdown/formula.ts +0 -1283
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.45.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.1",
128
- "@k2b/ui": "0.35.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)};\nexport { default } from ${JSON.stringify(resolve(appDir, "src/index.ts"))};\n`,
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 {
@@ -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: CapabilityQueryDefinition;
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: CapabilityQueryDefinition, label: string) => {
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: CapabilityQueryDefinition | CapabilityActionDefinition, label: string) => {
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 ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
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
- const invoked = await operation.definition.run(input.data, context);
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
+ };