@k2b/cloud 0.44.0 → 0.46.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 (137) hide show
  1. package/package.json +9 -2
  2. package/src/_internal/app-queries.ts +73 -0
  3. package/src/_internal/capabilities.ts +137 -9
  4. package/src/_internal/define-app.ts +2 -0
  5. package/src/_internal/registry-validation.ts +17 -4
  6. package/src/_internal/registry.ts +32 -4
  7. package/src/_internal/resource-principals.ts +36 -0
  8. package/src/access/GroupCoverage.tsx +20 -5
  9. package/src/access/PermissionEditor.tsx +11 -2
  10. package/src/access/PrincipalAvatar.tsx +30 -21
  11. package/src/access/messages.ts +4 -4
  12. package/src/account/EntitySearch.tsx +12 -0
  13. package/src/ai/capabilities.ts +4 -2
  14. package/src/ai/chat/map-messages.ts +270 -0
  15. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  16. package/src/ai/chat/map-notebook.tsx +301 -0
  17. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  18. package/src/ai/chat/map-widget.css +107 -0
  19. package/src/ai/chat/map-widget.tsx +1279 -0
  20. package/src/ai/chat/message-utils.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +2 -1
  23. package/src/ai/chat/turn-view.tsx +17 -1
  24. package/src/ai/default-tools.ts +3 -0
  25. package/src/ai/dictation-runtime.ts +6 -2
  26. package/src/ai/executor.ts +22 -4
  27. package/src/ai/index.ts +1 -0
  28. package/src/ai/map-contracts.ts +209 -0
  29. package/src/ai/map-execution.ts +14 -0
  30. package/src/ai/map-notebook-save.ts +276 -0
  31. package/src/ai/map-notebook.ts +32 -0
  32. package/src/ai/map-opening-hours.ts +40 -0
  33. package/src/ai/map-routes.ts +84 -0
  34. package/src/ai/map-service.ts +288 -0
  35. package/src/ai/map-store.ts +137 -0
  36. package/src/ai/map-tool.ts +73 -0
  37. package/src/ai/migrate.ts +3 -0
  38. package/src/ai/project-tool.ts +4 -4
  39. package/src/ai/projects-authorization.ts +53 -0
  40. package/src/ai/projects-routes.ts +56 -46
  41. package/src/ai/projects.ts +100 -79
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/recent-resources.ts +3 -3
  44. package/src/ai/routes.ts +32 -30
  45. package/src/ai/skill-tool.ts +4 -4
  46. package/src/ai/skills-routes.ts +27 -26
  47. package/src/ai/skills.ts +120 -81
  48. package/src/ai/store.ts +19 -6
  49. package/src/ai/tools.ts +14 -0
  50. package/src/ai/types.ts +3 -0
  51. package/src/api/accounts-entities.ts +6 -3
  52. package/src/api/admin-ai-skills.ts +4 -1
  53. package/src/api/auth.ts +9 -2
  54. package/src/api/capabilities.ts +90 -12
  55. package/src/api/mcp.ts +4 -2
  56. package/src/browser/notifications.ts +25 -8
  57. package/src/browser/session.ts +39 -0
  58. package/src/browser/shared-text-history-validation.ts +151 -0
  59. package/src/browser/shared-text-history.ts +464 -0
  60. package/src/browser/shared-text-offline-authorized.ts +29 -0
  61. package/src/browser/shared-text-offline.ts +351 -0
  62. package/src/browser/shared-text-provider.ts +714 -0
  63. package/src/browser/shared-text-table-contract.ts +150 -0
  64. package/src/browser/shared-text.ts +19 -0
  65. package/src/capabilities/discussion-providers.ts +99 -0
  66. package/src/capabilities/executions.ts +2 -2
  67. package/src/capabilities/migrate.ts +10 -1
  68. package/src/capabilities/resource-readers.ts +58 -0
  69. package/src/capabilities/server-invoke.ts +23 -13
  70. package/src/capabilities/server.ts +17 -6
  71. package/src/capabilities/testing.ts +13 -0
  72. package/src/contracts/capabilities.ts +137 -4
  73. package/src/contracts/discussions.ts +91 -0
  74. package/src/contracts/geo-document.ts +60 -0
  75. package/src/contracts/geo-snapshot.ts +86 -0
  76. package/src/contracts/geo.ts +14 -0
  77. package/src/contracts/index.ts +2 -0
  78. package/src/contracts/outgoing-mail.ts +1 -1
  79. package/src/contracts/resource-types.ts +21 -0
  80. package/src/contracts/shared.ts +2 -1
  81. package/src/geo/AddressAutocomplete.tsx +213 -0
  82. package/src/geo/messages.ts +29 -0
  83. package/src/geo/navigation.ts +25 -0
  84. package/src/geo/ui.ts +2 -0
  85. package/src/server/api/respond.ts +10 -2
  86. package/src/server/index.ts +1 -0
  87. package/src/server/services/access.ts +23 -10
  88. package/src/server/services/index.ts +1 -0
  89. package/src/services/accounts/app.ts +56 -17
  90. package/src/services/accounts/entities.ts +36 -16
  91. package/src/services/accounts/group-sql.ts +23 -0
  92. package/src/services/accounts/groups.ts +26 -17
  93. package/src/services/accounts/identities.ts +8 -1
  94. package/src/services/accounts/identity-reconciliation.ts +3 -1
  95. package/src/services/accounts/local-groups.ts +141 -27
  96. package/src/services/accounts/machine-group-management.ts +94 -0
  97. package/src/services/accounts/users.ts +38 -8
  98. package/src/services/geo/cache.ts +2 -1
  99. package/src/services/geo/geoapify.ts +3 -0
  100. package/src/services/identity/invocation-token.ts +110 -0
  101. package/src/services/mandates/index.ts +2 -2
  102. package/src/services/mandates/policy.ts +1 -0
  103. package/src/services/pdf/geo-snapshot.ts +107 -0
  104. package/src/services/pdf/index.ts +2 -0
  105. package/src/services/session/index.ts +12 -3
  106. package/src/shared/email-html.ts +1 -0
  107. package/src/shared/markdown/extensions/katex.ts +8 -1
  108. package/src/shared/markdown/extensions/tables.ts +14 -1
  109. package/src/shared/markdown/index.ts +22 -11
  110. package/src/shared-text/anchors.ts +29 -0
  111. package/src/shared-text/awareness.ts +87 -0
  112. package/src/shared-text/changes.ts +27 -0
  113. package/src/shared-text/documents.ts +428 -77
  114. package/src/shared-text/events.ts +12 -2
  115. package/src/shared-text/index.ts +16 -1
  116. package/src/shared-text/log.ts +24 -5
  117. package/src/shared-text/snapshots.ts +69 -23
  118. package/src/shared-text/structural-history.ts +377 -0
  119. package/src/shared-text/table-actions.ts +432 -0
  120. package/src/shared-text/types.ts +24 -5
  121. package/src/ssr/AdminArea.tsx +2 -2
  122. package/src/ssr/AdminLayout.tsx +1 -1
  123. package/src/ssr/AdminSidebar.tsx +1 -1
  124. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  125. package/src/ssr/CloudShell.island.tsx +19 -3
  126. package/src/ssr/Layout.tsx +1 -1
  127. package/src/ssr/MobileProfileActions.tsx +2 -2
  128. package/src/ssr/ProfilePreferences.tsx +2 -1
  129. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  130. package/src/ssr/admin-navigation.ts +1 -11
  131. package/src/ssr/app-launchpad.tsx +6 -1
  132. package/src/ssr/platform-messages.ts +2 -0
  133. package/src/ssr/profile-actions.ts +3 -6
  134. package/src/ssr/shell-data.tsx +1 -1
  135. package/src/styles/global.css +2 -0
  136. package/src/workflows/language/expressions.ts +3 -2
  137. package/src/workflows/runtime/executor.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.44.0",
3
+ "version": "0.46.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": {
@@ -30,7 +30,10 @@
30
30
  "./server": "./src/server/index.ts",
31
31
  "./server/*": "./src/server/*",
32
32
  "./geo/ui": "./src/geo/ui.ts",
33
+ "./geo/document": "./src/contracts/geo-document.ts",
33
34
  "./browser": "./src/server/api-client.ts",
35
+ "./browser/session": "./src/browser/session.ts",
36
+ "./browser/shared-text": "./src/browser/shared-text.ts",
34
37
  "./browser/app-approval": "./src/browser/app-approval.ts",
35
38
  "./browser/app-badges": "./src/browser/app-badges.ts",
36
39
  "./browser/live": "./src/browser/live.ts",
@@ -64,6 +67,7 @@
64
67
  "./shared": "./src/shared/index.ts",
65
68
  "./shared-text": "./src/shared-text/index.ts",
66
69
  "./services": "./src/services/index.ts",
70
+ "./services/pdf": "./src/services/pdf/index.ts",
67
71
  "./services/image-metadata": "./src/services/image-metadata.ts",
68
72
  "./services/document-extraction": "./src/services/document-extraction.ts",
69
73
  "./services/outbox": "./src/services/outbox.ts",
@@ -92,6 +96,7 @@
92
96
  "./config": "./src/config/index.ts",
93
97
  "./config/*": "./src/config/*",
94
98
  "./contracts": "./src/contracts/index.ts",
99
+ "./contracts/geo-snapshot": "./src/contracts/geo-snapshot.ts",
95
100
  "./contracts/notifications": "./src/contracts/notification-types.ts",
96
101
  "./contracts/*": "./src/contracts/*",
97
102
  "./events/testing": "./src/events/testing.ts",
@@ -123,7 +128,7 @@
123
128
  "@tailwindcss/typography": "0.5.20",
124
129
  "@k2b/nessi": "0.18.0",
125
130
  "@k2b/ssr": "0.20.1",
126
- "@k2b/ui": "0.34.0",
131
+ "@k2b/ui": "0.36.0",
127
132
  "@k2b/stdlib": "0.30.0",
128
133
  "@k2b/sync": "7.0.1",
129
134
  "@nats-io/jetstream": "3.4.0",
@@ -136,6 +141,7 @@
136
141
  "htmlparser2": "12.0.0",
137
142
  "jose": "6.2.12",
138
143
  "katex": "0.18.9",
144
+ "lib0": "0.2.117",
139
145
  "liquidjs": "10.29.0",
140
146
  "marked": "18.0.14",
141
147
  "mermaid": "12.0.0",
@@ -149,6 +155,7 @@
149
155
  "web-push": "3.6.7",
150
156
  "web-vitals": "6.2.2",
151
157
  "yaml": "2.9.1",
158
+ "y-protocols": "1.0.7",
152
159
  "yjs": "13.6.33",
153
160
  "hash-wasm": "4.12.0",
154
161
  "@types/sanitize-html": "2.16.1",
@@ -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
  }
@@ -12,6 +12,7 @@ import { registerPageSwap } from "../ssr/page-swap";
12
12
  import { preloadPanelConsent } from "../ssr/panel-consent";
13
13
  import { appAccessManagementPath } from "./app-access-path";
14
14
  import { bindResourceAppAccess, RESOURCE_ACCESS_PATH } from "./app-access-resources";
15
+ import { createAppQueryRoutes } from "./app-queries";
15
16
  import { registerApplicationPackage, resolveApplicationPackage } from "./application-package";
16
17
  import { invokeCapabilityStream } from "./capability-streams";
17
18
  import { withCapabilityRequestTimeout } from "./capability-timeout";
@@ -916,6 +917,7 @@ export const defineApp = <
916
917
  server.route("/api/_internal/events/v1", createAppEventDeliveryRoutes(compiledCapabilities));
917
918
 
918
919
  if (compiledCapabilities) {
920
+ server.route("/api/_internal/capabilities/v1/app-queries", createAppQueryRoutes(compiledCapabilities));
919
921
  const invoke = async (c: Context<AuthContext>, kind: "query" | "action" | "review") =>
920
922
  withCapabilityRequestTimeout(c, kind, async () => {
921
923
  // Framework-owned endpoints run before the app's settings middleware,
@@ -160,8 +160,8 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
160
160
  return invalid("widgets", "an array of valid widgets");
161
161
  }
162
162
  }
163
- if (value.platformPermissions !== undefined && !PlatformPermissionSchema.array().safeParse(value.platformPermissions).success)
164
- return invalid("platformPermissions", "an array of platform permissions");
163
+ if (value.platformPermissions !== undefined && !isStringArray(value.platformPermissions))
164
+ return invalid("platformPermissions", "an array of permission IDs");
165
165
  if (value.settingKeys !== undefined && !isStringArray(value.settingKeys)) return invalid("settingKeys", "an array of strings");
166
166
  if (
167
167
  value.cliModules !== undefined &&
@@ -198,5 +198,18 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
198
198
  return null;
199
199
  };
200
200
 
201
- export const asAppRegistryEntry = (value: unknown): AppRegistryEntry | null =>
202
- validateAppRegistryEntry(value) === null ? (value as AppRegistryEntry) : null;
201
+ export const asAppRegistryEntry = (value: unknown): AppRegistryEntry | null => {
202
+ if (validateAppRegistryEntry(value) !== null) return null;
203
+ const entry = value as AppRegistryEntry;
204
+ return {
205
+ ...entry,
206
+ ...(entry.platformPermissions !== undefined
207
+ ? {
208
+ platformPermissions: entry.platformPermissions.flatMap((permission) => {
209
+ const parsed = PlatformPermissionSchema.safeParse(permission);
210
+ return parsed.success ? [parsed.data] : [];
211
+ }),
212
+ }
213
+ : {}),
214
+ };
215
+ };
@@ -3,6 +3,7 @@ import type { EphemeralConfig, MutexConfig } from "@k2b/sync";
3
3
  import type { AppAppearanceColor } from "../contracts/app";
4
4
  import { calendarSourceIssues } from "../contracts/calendar-source";
5
5
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
6
+ import { discussionsIssues } from "../contracts/discussions";
6
7
  import { fileProviderIssues } from "../contracts/file-provider";
7
8
  import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
8
9
  import { resourcePageIssue } from "../contracts/resource-pages";
@@ -18,7 +19,7 @@ import {
18
19
  import { resolveAppPresentations } from "../shared/app-presentation";
19
20
  import { compileCapabilityPresentation, parseCapabilityManifest } from "./capabilities";
20
21
  import { lazySync } from "./process-sync";
21
- import { validateAppRegistryEntry } from "./registry-validation";
22
+ import { asAppRegistryEntry, validateAppRegistryEntry } from "./registry-validation";
22
23
  import { watchRegistryChanges } from "./registry-watch";
23
24
  import { WidgetSettingsDeclarationSchema } from "./widget-settings";
24
25
 
@@ -150,7 +151,7 @@ export const readAppRegistrySnapshot = async (): Promise<AppRegistrySnapshot> =>
150
151
  continue;
151
152
  }
152
153
  loggedInvalidReasons.delete(entry.key);
153
- const value = entry.value as AppRegistryEntry;
154
+ const value = asAppRegistryEntry(entry.value)!;
154
155
  const updatedAt = entry.updatedAt.getTime();
155
156
  apps.push({
156
157
  ...value,
@@ -194,7 +195,7 @@ export const getApp = async (appId: string): Promise<AppRegistryEntry | null> =>
194
195
  : validateAppRegistryEntry(entry.value);
195
196
  if (!reason) {
196
197
  loggedInvalidReasons.delete(key);
197
- return entry.value;
198
+ return asAppRegistryEntry(entry.value);
198
199
  }
199
200
  reportInvalidEntry({ key, version: entry.revision, reason });
200
201
  return null;
@@ -237,6 +238,7 @@ const reportLeftOutEntries = (sent: unknown, manifest: CapabilityManifest): void
237
238
  });
238
239
  if (groups.calendarSource !== undefined && !manifest.calendarSource) leftOut.push("calendarSource");
239
240
  if (groups.fileProvider !== undefined && !manifest.fileProvider) leftOut.push("fileProvider");
241
+ if (groups.discussions !== undefined && !manifest.discussions) leftOut.push("discussions");
240
242
  if (leftOut.length === 0) return;
241
243
  console.warn(
242
244
  JSON.stringify({
@@ -320,6 +322,32 @@ const withValidCalendarSource = (manifest: CapabilityManifest): CapabilityManife
320
322
  return rest;
321
323
  };
322
324
 
325
+ const checkedDiscussions = new Map<string, { manifestHash: string; valid: boolean }>();
326
+ const withValidDiscussions = (manifest: CapabilityManifest): CapabilityManifest => {
327
+ if (!manifest.discussions) return manifest;
328
+ const checked = checkedDiscussions.get(manifest.appId);
329
+ let valid = checked?.manifestHash === manifest.manifestHash ? checked.valid : undefined;
330
+ if (valid === undefined) {
331
+ const issues = discussionsIssues(manifest);
332
+ valid = issues.length === 0;
333
+ checkedDiscussions.set(manifest.appId, { manifestHash: manifest.manifestHash, valid });
334
+ if (!valid)
335
+ console.error(
336
+ JSON.stringify({
337
+ level: "error",
338
+ source: "capability-registry",
339
+ message: "Ignored an incompatible discussions provider",
340
+ appId: manifest.appId,
341
+ manifestHash: manifest.manifestHash,
342
+ issues,
343
+ }),
344
+ );
345
+ }
346
+ if (valid) return manifest;
347
+ const { discussions: _ignored, ...rest } = manifest;
348
+ return rest;
349
+ };
350
+
323
351
  /** Ignores record fields from a newer release; the endpoint always comes from the live app registry. */
324
352
  export const resolveLiveCapabilityRegistryEntry = (
325
353
  key: string,
@@ -334,7 +362,7 @@ export const resolveLiveCapabilityRegistryEntry = (
334
362
  if (!endpoint) return null;
335
363
  try {
336
364
  const read = parseCapabilityManifest(record.manifest, app.id);
337
- const supported = withValidCalendarSource(withValidFileProvider(read));
365
+ const supported = withValidDiscussions(withValidCalendarSource(withValidFileProvider(read)));
338
366
  const manifest = supported.resourcePages
339
367
  ? {
340
368
  ...supported,
@@ -0,0 +1,36 @@
1
+ import { sql } from "bun";
2
+ import type { ResourcePrincipal } from "../contracts/capabilities";
3
+ import { decodeAccountCategoryEnabled } from "../services/account-category-policy";
4
+ import { toPgTextArray } from "../services/postgres";
5
+
6
+ /** One bounded identity lookup before any provider-owned readability check. */
7
+ export const activeResourcePrincipals = async (principals: ResourcePrincipal[]): Promise<ResourcePrincipal[]> => {
8
+ if (!principals.length) return [];
9
+ const users = principals.flatMap((principal) => (principal.type === "user" ? [principal.userId] : []));
10
+ const accounts = principals.flatMap((principal) => (principal.type === "service_account" ? [principal.serviceAccountId] : []));
11
+ const rows = await sql<{ type: ResourcePrincipal["type"]; id: string; category_enabled: string | null }[]>`
12
+ SELECT 'user' AS type, u.id, category.value AS category_enabled
13
+ FROM auth.users u
14
+ LEFT JOIN settings.entries category ON category.key = 'user.category.' ||
15
+ CASE WHEN u.provider = 'ipa' THEN 'freeipa' WHEN u.profile = 'guest' THEN 'guest' ELSE 'login' END || '.enabled'
16
+ WHERE u.id = ANY(${toPgTextArray(users)}::uuid[])
17
+ AND (u.account_expires IS NULL OR u.account_expires > clock_timestamp())
18
+ UNION ALL
19
+ SELECT 'service_account', sa.id, category.value
20
+ FROM auth.service_accounts sa
21
+ LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
22
+ LEFT JOIN settings.entries category ON sa.kind = 'user_delegated' AND category.key = 'user.category.' ||
23
+ CASE WHEN du.provider = 'ipa' THEN 'freeipa' WHEN du.profile = 'guest' THEN 'guest' ELSE 'login' END || '.enabled'
24
+ WHERE sa.id = ANY(${toPgTextArray(accounts)}::uuid[]) AND sa.status = 'active'
25
+ AND (sa.kind <> 'user_delegated' OR (du.id IS NOT NULL AND (du.account_expires IS NULL OR du.account_expires > clock_timestamp())))
26
+ `;
27
+ const enabled = new Map<string | null, boolean>();
28
+ const active = new Set<string>();
29
+ for (const row of rows) {
30
+ if (!enabled.has(row.category_enabled)) enabled.set(row.category_enabled, await decodeAccountCategoryEnabled(row.category_enabled));
31
+ if (enabled.get(row.category_enabled)) active.add(`${row.type}:${row.id}`);
32
+ }
33
+ return principals.filter((principal) =>
34
+ active.has(`${principal.type}:${(principal.type === "user" ? principal.userId : principal.serviceAccountId).toLowerCase()}`),
35
+ );
36
+ };