@k2b/cloud 0.45.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 (71) hide show
  1. package/package.json +5 -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/ai/capabilities.ts +4 -2
  9. package/src/ai/chat/map-messages.ts +270 -0
  10. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  11. package/src/ai/chat/map-notebook.tsx +301 -0
  12. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  13. package/src/ai/chat/map-widget.css +107 -0
  14. package/src/ai/chat/map-widget.tsx +1279 -0
  15. package/src/ai/chat/message-utils.ts +1 -0
  16. package/src/ai/chat/tool-names.ts +2 -0
  17. package/src/ai/chat/turn-layout.ts +2 -1
  18. package/src/ai/chat/turn-view.tsx +17 -1
  19. package/src/ai/default-tools.ts +3 -0
  20. package/src/ai/executor.ts +13 -1
  21. package/src/ai/map-contracts.ts +209 -0
  22. package/src/ai/map-execution.ts +14 -0
  23. package/src/ai/map-notebook-save.ts +276 -0
  24. package/src/ai/map-notebook.ts +32 -0
  25. package/src/ai/map-opening-hours.ts +40 -0
  26. package/src/ai/map-routes.ts +84 -0
  27. package/src/ai/map-service.ts +288 -0
  28. package/src/ai/map-store.ts +137 -0
  29. package/src/ai/map-tool.ts +73 -0
  30. package/src/ai/migrate.ts +3 -0
  31. package/src/ai/protocol.ts +3 -1
  32. package/src/ai/routes.ts +2 -0
  33. package/src/ai/store.ts +19 -6
  34. package/src/ai/tools.ts +14 -0
  35. package/src/ai/types.ts +3 -0
  36. package/src/api/capabilities.ts +90 -12
  37. package/src/api/mcp.ts +4 -2
  38. package/src/browser/notifications.ts +25 -8
  39. package/src/capabilities/discussion-providers.ts +99 -0
  40. package/src/capabilities/executions.ts +2 -2
  41. package/src/capabilities/migrate.ts +10 -1
  42. package/src/capabilities/resource-readers.ts +58 -0
  43. package/src/capabilities/server-invoke.ts +23 -13
  44. package/src/capabilities/server.ts +17 -6
  45. package/src/capabilities/testing.ts +13 -0
  46. package/src/contracts/capabilities.ts +137 -4
  47. package/src/contracts/discussions.ts +91 -0
  48. package/src/contracts/geo-document.ts +60 -0
  49. package/src/contracts/geo-snapshot.ts +86 -0
  50. package/src/contracts/geo.ts +14 -0
  51. package/src/contracts/index.ts +2 -0
  52. package/src/contracts/outgoing-mail.ts +1 -1
  53. package/src/contracts/resource-types.ts +21 -0
  54. package/src/geo/AddressAutocomplete.tsx +213 -0
  55. package/src/geo/messages.ts +29 -0
  56. package/src/geo/navigation.ts +25 -0
  57. package/src/geo/ui.ts +2 -0
  58. package/src/services/accounts/app.ts +28 -6
  59. package/src/services/accounts/entities.ts +11 -4
  60. package/src/services/accounts/groups.ts +10 -4
  61. package/src/services/accounts/local-groups.ts +87 -38
  62. package/src/services/accounts/machine-group-management.ts +94 -0
  63. package/src/services/geo/cache.ts +2 -1
  64. package/src/services/geo/geoapify.ts +3 -0
  65. package/src/services/identity/invocation-token.ts +110 -0
  66. package/src/services/pdf/geo-snapshot.ts +107 -0
  67. package/src/services/pdf/index.ts +2 -0
  68. package/src/ssr/AdminArea.tsx +2 -2
  69. package/src/ssr/AdminLayout.tsx +1 -1
  70. package/src/ssr/AdminSidebar.tsx +1 -1
  71. package/src/styles/global.css +2 -0
@@ -0,0 +1,58 @@
1
+ import { getProcessApplicationId } from "../_internal/process-identity";
2
+ import { getCapability } from "../_internal/registry";
3
+ import { env } from "../config/env";
4
+ import {
5
+ CloudResourceRefSchema,
6
+ type ResourcePrincipal,
7
+ ResourceReadableDataSchema,
8
+ ResourceReadableInputSchema,
9
+ } from "../contracts/capabilities";
10
+ import { invokeCapabilityWithDataSchema } from "./server-invoke";
11
+
12
+ export type ResourceReadersResult =
13
+ | { state: "checked"; readable: ResourcePrincipal[] }
14
+ | { state: "not_configured"; reason: "no_readable" | "no_credential" | "no_internal_origin" | "no_app_identity" | "not_admitted" };
15
+
16
+ /** An empty checked subset means not readable; an unavailable check throws for the caller's retry. */
17
+ export const readResourcePrincipals = async (options: {
18
+ ref: { type: string; id: string };
19
+ container?: { type: string; id: string };
20
+ principals: readonly ResourcePrincipal[];
21
+ signal?: AbortSignal;
22
+ locale?: string;
23
+ requestId?: string;
24
+ }): Promise<ResourceReadersResult> => {
25
+ const ref = CloudResourceRefSchema.parse(options.ref);
26
+ if (!env.CLOUD_APP_CREDENTIAL) return { state: "not_configured", reason: "no_credential" };
27
+ if (!env.CLOUD_CORE_INTERNAL_ORIGIN) return { state: "not_configured", reason: "no_internal_origin" };
28
+ const callerAppId = getProcessApplicationId();
29
+ if (!callerAppId) return { state: "not_configured", reason: "no_app_identity" };
30
+ options.signal?.throwIfAborted();
31
+ const separator = ref.type.indexOf(".");
32
+ const appId = ref.type.slice(0, separator);
33
+ const localType = ref.type.slice(separator + 1);
34
+ const entry = await getCapability(appId);
35
+ if (!entry) throw new Error("Resource reader provider registry entry unavailable");
36
+ const model = entry.manifest.resourceTypes?.find((model) => model.type === localType);
37
+ if (!model?.readable || !entry.manifest.queries.some((query) => query.localId === `${localType}.readable` && query.appOnly))
38
+ return { state: "not_configured", reason: "no_readable" };
39
+ const input = ResourceReadableInputSchema.parse({ id: ref.id, container: options.container, principals: options.principals });
40
+ if (model.container && input.container?.type !== `${appId}.${model.container}`)
41
+ throw new Error("Reader check requires a reference to the declared container type");
42
+ const result = await invokeCapabilityWithDataSchema(
43
+ { appId, capabilityId: `${localType}.readable`, kind: "query", input },
44
+ ResourceReadableDataSchema,
45
+ {
46
+ workload: { appId: callerAppId },
47
+ authorization: `Bearer ${env.CLOUD_APP_CREDENTIAL}`,
48
+ signal: options.signal,
49
+ locale: options.locale,
50
+ requestId: options.requestId,
51
+ },
52
+ );
53
+ if (!result.ok) {
54
+ if (result.error.code === "APP_QUERY_NOT_ADMITTED") return { state: "not_configured", reason: "not_admitted" };
55
+ throw new Error(`Resource reader check unavailable: ${result.error.code}`);
56
+ }
57
+ return { state: "checked", readable: result.data.data.readable };
58
+ };
@@ -35,6 +35,8 @@ export type CapabilityCaller = {
35
35
  /** BCP 47 locale of the originating request, forwarded as invocation metadata. */
36
36
  locale?: string | null;
37
37
  signal?: AbortSignal;
38
+ /** App-only query authority. The workload credential goes only to Core. */
39
+ workload?: { appId: string };
38
40
  /** Durable background authority. `authorization` must be the owning app's workload credential. */
39
41
  mandate?: { id: string; revision: number; callingAppId: string };
40
42
  };
@@ -49,7 +51,7 @@ export const callerRequest = async (
49
51
  ): Promise<Request> => {
50
52
  const headers = new Headers();
51
53
  if (caller.authorization) headers.set("authorization", caller.authorization);
52
- if (!caller.mandate) {
54
+ if (!caller.mandate && !caller.workload) {
53
55
  if (caller.cookie) headers.set("cookie", caller.cookie);
54
56
  if (caller.requestId) headers.set("x-request-id", caller.requestId);
55
57
  if (caller.traceparent) headers.set("traceparent", caller.traceparent);
@@ -57,10 +59,14 @@ export const callerRequest = async (
57
59
  if (caller.locale) headers.set(LOCALE_HEADER, caller.locale);
58
60
  if (idempotencyKey) headers.set("idempotency-key", idempotencyKey);
59
61
  } else {
60
- headers.set("x-cloud-app-id", caller.mandate.callingAppId);
62
+ headers.set("x-cloud-app-id", caller.mandate?.callingAppId ?? caller.workload!.appId);
61
63
  }
62
64
  const signal = combineCapabilitySignals(caller.signal, invocationSignal);
63
- return new Request(new URL(path, await coreOrigin(caller.mandate !== undefined)), { method: "POST", headers, signal });
65
+ return new Request(new URL(path, await coreOrigin(caller.mandate !== undefined || caller.workload !== undefined)), {
66
+ method: "POST",
67
+ headers,
68
+ signal,
69
+ });
64
70
  };
65
71
 
66
72
  export const unavailable = (
@@ -96,12 +102,15 @@ const invokeCapabilityWithResultSchema = async <TDataSchema extends z.ZodType, T
96
102
  caller: CapabilityCaller,
97
103
  ): Promise<CapabilityClientResult<z.output<TDataSchema>>> => {
98
104
  try {
99
- const path = caller.mandate
100
- ? "/api/_internal/identity/v1/invoke"
101
- : `/api/capabilities/v1/${invocation.kind === "query" ? "queries" : "actions"}/${encodeURIComponent(invocation.appId)}/${encodeURIComponent(invocation.capabilityId)}`;
105
+ if (caller.workload && (caller.mandate || caller.cookie || invocation.kind !== "query"))
106
+ throw new Error("Workload authority permits only queries without a mandate or cookie");
107
+ const path =
108
+ caller.mandate || caller.workload
109
+ ? "/api/_internal/identity/v1/invoke"
110
+ : `/api/capabilities/v1/${invocation.kind === "query" ? "queries" : "actions"}/${encodeURIComponent(invocation.appId)}/${encodeURIComponent(invocation.capabilityId)}`;
102
111
  const request = await callerRequest(caller, path, invocation.idempotencyKey, invocation.signal);
103
112
  if (caller.authority) {
104
- if (caller.authorization || caller.cookie || caller.mandate || caller.transport)
113
+ if (caller.authorization || caller.cookie || caller.mandate || caller.workload || caller.transport)
105
114
  throw new Error("Use exactly one capability authority source");
106
115
  const outcome = await dispatchCapability({
107
116
  request,
@@ -115,17 +124,18 @@ const invokeCapabilityWithResultSchema = async <TDataSchema extends z.ZodType, T
115
124
  });
116
125
  return readCapabilityResponse(outcome, capabilityResultSchema(dataSchema, { consumer: true }));
117
126
  }
118
- const response = await (caller.transport ?? fetch)(
127
+ const response = await (
128
+ caller.transport ?? ((request: Request) => fetch(request, caller.workload ? { redirect: "error" } : undefined))
129
+ )(
119
130
  new Request(request, {
120
131
  body: JSON.stringify(
121
- caller.mandate
132
+ caller.mandate || caller.workload
122
133
  ? {
123
134
  kind: invocation.kind,
124
135
  targetApp: invocation.appId,
125
136
  capabilityId: invocation.capabilityId,
126
137
  input: invocation.input,
127
- mandateId: caller.mandate.id,
128
- mandateRevision: caller.mandate.revision,
138
+ ...(caller.mandate ? { mandateId: caller.mandate.id, mandateRevision: caller.mandate.revision } : {}),
129
139
  ...(invocation.idempotencyKey ? { idempotencyKey: invocation.idempotencyKey } : {}),
130
140
  ...(caller.requestId || caller.traceparent || caller.tracestate || caller.locale
131
141
  ? {
@@ -174,14 +184,14 @@ export const listCapabilityCatalog = async (
174
184
  if (options.caller) {
175
185
  const caller = options.caller;
176
186
  if (options.authority) throw new Error("Use one catalog authority source");
177
- if (caller.authority && (caller.authorization || caller.cookie || caller.mandate || caller.transport))
187
+ if (caller.authority && (caller.authorization || caller.cookie || caller.mandate || caller.workload || caller.transport))
178
188
  throw new Error("Use exactly one capability authority source");
179
189
  if (caller.authority)
180
190
  return {
181
191
  ok: true,
182
192
  data: await loadCapabilityCatalogPage({ cursor: options.cursor, limit }, {}, caller.locale ?? options.locale, caller.authority),
183
193
  };
184
- if (caller.mandate) throw new Error("Calendar discovery requires request credentials");
194
+ if (caller.mandate || caller.workload) throw new Error("Calendar discovery requires request credentials");
185
195
  const query = new URLSearchParams({ limit: String(limit) });
186
196
  if (options.cursor) query.set("cursor", options.cursor);
187
197
  const request = new Request(await callerRequest(caller, `/api/capabilities/v1/catalog?${query}`), { method: "GET" });
@@ -1,4 +1,8 @@
1
- import { resolveCapabilityActionWording, resolveCapabilityManifestPresentation } from "../_internal/capabilities";
1
+ import {
2
+ isPersonVisibleCapabilityQuery,
3
+ resolveCapabilityActionWording,
4
+ resolveCapabilityManifestPresentation,
5
+ } from "../_internal/capabilities";
2
6
  import type { CapabilityActionWording } from "../_internal/capability-sentences";
3
7
  import { getApp, getCapability } from "../_internal/registry";
4
8
  import { dispatchCapability } from "../api/capabilities";
@@ -37,18 +41,22 @@ export const getCapabilityCatalogApp = async (
37
41
  try {
38
42
  const [capability, registeredApp] = await Promise.all([getCapability(appId), getApp(appId)]);
39
43
  const presentedApp = registeredApp && locale ? resolveAppPresentation(registeredApp, locale) : registeredApp;
44
+ const manifest =
45
+ capability &&
46
+ (locale ? resolveCapabilityManifestPresentation(capability.manifest, capability.presentation, locale) : capability.manifest);
40
47
  return {
41
48
  ok: true,
42
49
  data:
43
- capability && registeredApp && (await canUseApp(authority, registeredApp))
50
+ capability && registeredApp && manifest && (await canUseApp(authority, registeredApp))
44
51
  ? {
45
52
  appId: capability.appId,
46
53
  appName: presentedApp?.name ?? capability.appName,
47
54
  appIcon: capability.appIcon,
48
55
  appDescription: presentedApp?.description ?? capability.appDescription,
49
- manifest: locale
50
- ? resolveCapabilityManifestPresentation(capability.manifest, capability.presentation, locale)
51
- : capability.manifest,
56
+ manifest: {
57
+ ...manifest,
58
+ queries: manifest.queries.filter(isPersonVisibleCapabilityQuery),
59
+ },
52
60
  }
53
61
  : null,
54
62
  };
@@ -81,6 +89,7 @@ export const reviewCapabilityAction = async <TInput = unknown>(
81
89
  caller: CapabilityCaller,
82
90
  ): Promise<CapabilityReviewClientResult> => {
83
91
  try {
92
+ if (caller.workload) throw new Error("App query authority cannot review Actions");
84
93
  if (caller.authority) {
85
94
  if (caller.authorization || caller.cookie || caller.mandate || caller.transport)
86
95
  throw new Error("Use exactly one capability authority source");
@@ -132,7 +141,7 @@ export async function transferCapabilityStream(
132
141
  caller: CapabilityCaller,
133
142
  body?: BodyInit,
134
143
  ): Promise<Response> {
135
- if (caller.mandate) throw new Error("Capability streams do not support background mandates yet");
144
+ if (caller.mandate || caller.workload) throw new Error("Capability streams require request authority");
136
145
  const path = `/api/capabilities/v1/streams/${verb}`;
137
146
  const headers = new Headers();
138
147
  headers.set("x-cloud-stream-id", stream.id);
@@ -172,6 +181,8 @@ export {
172
181
  listCalendarSources,
173
182
  readCalendarEvents,
174
183
  } from "./calendar-sources";
184
+ export { type DiscussionProvider, discussionProviders } from "./discussion-providers";
185
+ export { type ResourceReadersResult, readResourcePrincipals } from "./resource-readers";
175
186
  export {
176
187
  actionReview,
177
188
  type CapabilityReviewForms,
@@ -14,6 +14,8 @@ import {
14
14
  type CapabilityResult,
15
15
  CapabilitySemanticLinkSchema,
16
16
  type CapabilitySlot,
17
+ CloudResourceReaderViewSchema,
18
+ type CloudResourceRef,
17
19
  } from "../contracts/capabilities";
18
20
 
19
21
  /** Compile and validate the public manifest an app would register at startup. */
@@ -36,6 +38,17 @@ export const assertResourceOpenLink = (result: Pick<CapabilityResult<unknown>, "
36
38
  return CapabilitySemanticLinkSchema.parse(link);
37
39
  };
38
40
 
41
+ /** Assert the canonical reader presentation and, for contained types, its exact container. */
42
+ export const assertResourceView = (result: Pick<CapabilityResult<unknown>, "data">, container?: CloudResourceRef) => {
43
+ const data = result.data;
44
+ const view = CloudResourceReaderViewSchema.parse(
45
+ data !== null && typeof data === "object" ? Object.getOwnPropertyDescriptor(data, "view")?.value : undefined,
46
+ );
47
+ if (container && (view.container?.ref.type !== container.type || view.container.ref.id !== container.id))
48
+ throw new Error("Resource reader view must identify the expected container");
49
+ return view;
50
+ };
51
+
39
52
  /** Validate each localized review in application tests, with optional declaration context. */
40
53
  export const assertCapabilityReview = (review: unknown, context?: CapabilityReviewContext): void => {
41
54
  const parsed = CapabilityActionReviewDataSchema.safeParse(review);
@@ -13,6 +13,7 @@ import type { RequestAuthority } from "../server/middleware/auth";
13
13
  import type { AppEventDefinition, AppEventSubscriptionDefinition } from "./app-events";
14
14
  import { CommandPathSchema } from "./commands";
15
15
  import type { AccessSubject, RequestActor, User } from "./shared";
16
+ import { AuthenticatedPrincipalSchema } from "./shared";
16
17
 
17
18
  export const CAPABILITY_PROTOCOL_VERSION = 2 as const;
18
19
  export const CAPABILITY_MAX_REQUEST_BYTES = 256 * 1024;
@@ -121,6 +122,38 @@ export const CloudResourceReferenceSchema = CloudResourceRefSchema.extend({
121
122
 
122
123
  export type CloudResourceReference = z.infer<typeof CloudResourceReferenceSchema>;
123
124
 
125
+ /** Authorized presentation returned as `data.view` by a canonical resource reader. */
126
+ export const CloudResourceReaderViewSchema = z
127
+ .object({
128
+ title: z.string().min(1).max(500),
129
+ context: CloudResourceReferenceSchema.shape.context,
130
+ status: CloudResourceReferenceSchema.shape.status,
131
+ done: z.boolean().optional(),
132
+ permission: z.enum(["read", "write", "admin"]),
133
+ container: z
134
+ .object({ ref: CloudResourceRefSchema, title: z.string().min(1).max(500) })
135
+ .strict()
136
+ .optional(),
137
+ })
138
+ .strict();
139
+ export type CloudResourceReaderView = z.infer<typeof CloudResourceReaderViewSchema>;
140
+
141
+ /** Concrete accounts, reusing the grant principal identities; never a group or wildcard. */
142
+ export const ResourcePrincipalSchema = z.discriminatedUnion("type", [
143
+ AuthenticatedPrincipalSchema.options[0].strict(),
144
+ AuthenticatedPrincipalSchema.options[2].strict(),
145
+ ]);
146
+ export type ResourcePrincipal = z.infer<typeof ResourcePrincipalSchema>;
147
+ export const ResourceReadableInputSchema = z
148
+ .object({
149
+ id: CloudResourceRefSchema.shape.id,
150
+ container: CloudResourceRefSchema.optional().describe("Expected current container; required for a contained type."),
151
+ principals: z.array(ResourcePrincipalSchema).max(500).describe("Concrete accounts whose current resource readability is checked."),
152
+ })
153
+ .strict();
154
+ export type ResourceReadableInput = z.output<typeof ResourceReadableInputSchema>;
155
+ export const ResourceReadableDataSchema = z.object({ readable: z.array(ResourcePrincipalSchema).max(500) }).strict();
156
+
124
157
  // Consumer permalinks may encode all 512 UTF-16 units of an id (up to 4,608 characters).
125
158
  export const CloudResourceHrefSchema = z.string().min(1).max(8192).regex(CLOUD_PATH_PATTERN);
126
159
 
@@ -832,6 +865,15 @@ export type CapabilityResourceTypeDefinition = {
832
865
  /** One colour per kind of resource, so a card or list shows at a glance what it stands for. */
833
866
  color?: CapabilityResourceColor;
834
867
  reader?: string;
868
+ /** Local type in the same application. Describes containment, never inherited authority. */
869
+ container?: string;
870
+ access?: {
871
+ /** Return only readable input principals, using current grants and account status, never credential scopes. */
872
+ readable?: (
873
+ input: ResourceReadableInput,
874
+ context: CapabilityAppExecutionContext,
875
+ ) => readonly ResourcePrincipal[] | Promise<readonly ResourcePrincipal[]>;
876
+ };
835
877
  /** Root-relative pages recognized when a person pastes a Cloud link. Requires a reader. */
836
878
  pages?: readonly string[];
837
879
  };
@@ -911,7 +953,23 @@ export type CapabilityPresentationCatalog = {
911
953
  translations: Readonly<Record<string, CapabilityPresentationTranslation>>;
912
954
  };
913
955
 
956
+ /** App authority has no grant-bearing subject, user, nested exchange, or interactive credential. */
957
+ export type CapabilityAppExecutionContext = {
958
+ actor: { kind: "app"; appId: string };
959
+ accessSubject: { type: "none" };
960
+ user: null;
961
+ authority?: never;
962
+ query?: never;
963
+ idempotencyKey?: never;
964
+ locale: string;
965
+ requestId: string;
966
+ origin: CapabilityOrigin;
967
+ via?: CapabilityVia;
968
+ signal: AbortSignal;
969
+ };
970
+
914
971
  export type CapabilityQueryDefinition<Input extends z.ZodType = z.ZodType<any>, Data extends z.ZodType = z.ZodType<any>> = {
972
+ appOnly?: never;
915
973
  title: string;
916
974
  description: string;
917
975
  input: Input;
@@ -929,6 +987,7 @@ export type CapabilityIdempotencyPolicy = "none" | "required";
929
987
  export type CapabilityActionApprovalPolicy = "rememberable" | "none";
930
988
 
931
989
  export type CapabilityActionDefinition<Input extends z.ZodType = z.ZodType<any>, Data extends z.ZodType = z.ZodType<any>> = {
990
+ appOnly?: never;
932
991
  title: string;
933
992
  description: string;
934
993
  input: Input;
@@ -980,6 +1039,8 @@ export type CapabilityFileProviderDeclaration = { list: string; read: string; sa
980
1039
 
981
1040
  export type CapabilityCalendarSourceDeclaration = { calendars: string; events: string; update?: string; move?: string; delete?: string };
982
1041
 
1042
+ export type CapabilityDiscussionsDeclaration = { item: string; containers: string; activity?: string };
1043
+
983
1044
  export type CapabilityDefinitions = {
984
1045
  protocolVersion: typeof CAPABILITY_PROTOCOL_VERSION;
985
1046
  presentation?: CapabilityPresentationCatalog;
@@ -989,6 +1050,7 @@ export type CapabilityDefinitions = {
989
1050
  commands?: CapabilityDefinitionCatalog<CapabilityCommandDefinition>;
990
1051
  fileProvider?: CapabilityFileProviderDeclaration;
991
1052
  calendarSource?: CapabilityCalendarSourceDeclaration;
1053
+ discussions?: CapabilityDiscussionsDeclaration;
992
1054
  events?: CapabilityDefinitionCatalog<AppEventDefinition>;
993
1055
  subscribes?: CapabilityDefinitionCatalog<AppEventSubscriptionDefinition>;
994
1056
  };
@@ -1044,6 +1106,7 @@ const CapabilityOperationManifestBaseSchema = z
1044
1106
  .strict();
1045
1107
 
1046
1108
  export const CapabilityQueryManifestSchema = CapabilityOperationManifestBaseSchema.extend({
1109
+ appOnly: z.literal(true).optional(),
1047
1110
  openWorld: z.boolean(),
1048
1111
  universalSearch: z
1049
1112
  .object({ tags: z.array(CapabilitySearchTagManifestSchema).max(100), scopeTypes: z.array(CapabilityLocalIdSchema).max(100).optional() })
@@ -1116,6 +1179,24 @@ export const CapabilityResourcePageManifestSchema = z
1116
1179
  })
1117
1180
  .strict();
1118
1181
 
1182
+ export const CapabilityDiscussionsManifestSchema = z
1183
+ .object({
1184
+ item: CapabilityLocalIdSchema,
1185
+ containers: CapabilityLocalIdSchema,
1186
+ activity: CapabilityLocalIdSchema.optional(),
1187
+ })
1188
+ .strict();
1189
+ export const CapabilityResourceTypeModelSchema = z
1190
+ .object({
1191
+ type: CapabilityLocalIdSchema,
1192
+ container: CapabilityLocalIdSchema.optional(),
1193
+ view: z.literal(true).optional(),
1194
+ /** Availability claim, retained in person catalogs even though the generated query is hidden. */
1195
+ readable: z.literal(true).optional(),
1196
+ })
1197
+ .strict();
1198
+ export type CapabilityResourceTypeModel = z.infer<typeof CapabilityResourceTypeModelSchema>;
1199
+
1119
1200
  /** Keeps the sent entries this release reads completely. */
1120
1201
  const readEntries = <T extends z.ZodType>(values: unknown, entry: T): z.output<T>[] | null => {
1121
1202
  // Not a bounded list: leave the value to the manifest schema, which rejects it.
@@ -1146,7 +1227,7 @@ const readEntries = <T extends z.ZodType>(values: unknown, entry: T): z.output<T
1146
1227
  * pages and pages without a readable Type/reader are omitted; live prefix ownership is checked separately.
1147
1228
  * Every other entry stays available.
1148
1229
  */
1149
- const readableManifest = (value: unknown): unknown => {
1230
+ const readableManifest = (value: unknown, personCatalog = false): unknown => {
1150
1231
  if (!value || typeof value !== "object" || Array.isArray(value)) return value;
1151
1232
  const sent = value as Record<string, unknown>;
1152
1233
  const types = readEntries(sent.types, CapabilityResourceTypeManifestSchema);
@@ -1180,7 +1261,54 @@ const readableManifest = (value: unknown): unknown => {
1180
1261
  policies[action.localId] = policy;
1181
1262
  return true;
1182
1263
  });
1183
- const { calendarSource: sentCalendarSource, fileProvider: sentProvider, policies: _policies, resourcePages: sentPages, ...rest } = sent;
1264
+ const {
1265
+ calendarSource: sentCalendarSource,
1266
+ fileProvider: sentProvider,
1267
+ policies: _policies,
1268
+ resourcePages: sentPages,
1269
+ resourceTypes: sentModels,
1270
+ discussions: sentDiscussions,
1271
+ ...rest
1272
+ } = sent;
1273
+ const models = readEntries(sentModels ?? [], CapabilityResourceTypeModelSchema) ?? [];
1274
+ const resourceTypes = models.flatMap((model) => {
1275
+ const type = types.find((type) => type.localId === model.type);
1276
+ if (!type || (model.container && !typeIds.has(model.container))) return [];
1277
+ const seen = new Set([model.type]);
1278
+ let parent = model.container;
1279
+ while (parent) {
1280
+ if (seen.has(parent)) return [];
1281
+ seen.add(parent);
1282
+ parent = models.find((candidate) => candidate.type === parent)?.container;
1283
+ }
1284
+ if (models.filter((candidate) => candidate.type === model.type).length !== 1) return [];
1285
+ const { view, readable, ...rest } = model;
1286
+ return [
1287
+ {
1288
+ ...rest,
1289
+ ...(readable &&
1290
+ (readableQueries.some((query) => query.localId === `${model.type}.readable` && query.appOnly === true) ||
1291
+ // Core validates the full manifest before hiding app-only queries from person catalogs.
1292
+ (personCatalog &&
1293
+ Array.isArray(sent.queries) &&
1294
+ !sent.queries.some(
1295
+ (query: unknown) =>
1296
+ typeof query === "object" && query !== null && "localId" in query && query.localId === `${model.type}.readable`,
1297
+ )))
1298
+ ? { readable }
1299
+ : {}),
1300
+ ...(view && type.reader && queryIds.has(type.reader) ? { view } : {}),
1301
+ },
1302
+ ];
1303
+ });
1304
+ const role = CapabilityDiscussionsManifestSchema.safeParse(sentDiscussions);
1305
+ const discussions =
1306
+ role.success &&
1307
+ typeIds.has(role.data.item) &&
1308
+ queryIds.has(role.data.containers) &&
1309
+ (!role.data.activity || queryIds.has(role.data.activity))
1310
+ ? role.data
1311
+ : undefined;
1184
1312
  const provider = CapabilityFileProviderManifestSchema.safeParse(sentProvider);
1185
1313
  const calendar = CapabilityCalendarSourceManifestSchema.safeParse(sentCalendarSource);
1186
1314
  const pageCounts = new Map<string, number>();
@@ -1198,6 +1326,8 @@ const readableManifest = (value: unknown): unknown => {
1198
1326
  : [];
1199
1327
  return {
1200
1328
  ...rest,
1329
+ ...(sentModels !== undefined ? { resourceTypes } : {}),
1330
+ ...(discussions ? { discussions } : {}),
1201
1331
  ...(sentPages !== undefined ? { resourcePages } : {}),
1202
1332
  ...(provider.success ? { fileProvider: provider.data } : {}),
1203
1333
  ...(calendar.success ? { calendarSource: calendar.data } : {}),
@@ -1228,7 +1358,7 @@ const readableManifest = (value: unknown): unknown => {
1228
1358
  * Producers stay strict: `app.start()` registers exactly the manifest its release defines.
1229
1359
  */
1230
1360
  export const CapabilityManifestSchema = z.preprocess(
1231
- readableManifest,
1361
+ (value) => readableManifest(value),
1232
1362
  z.object({
1233
1363
  protocolVersion: z.literal(CAPABILITY_PROTOCOL_VERSION),
1234
1364
  appId: CapabilityAppIdSchema,
@@ -1240,6 +1370,8 @@ export const CapabilityManifestSchema = z.preprocess(
1240
1370
  // Optional without a default: a manifest without a provider keeps the shape and hash of earlier releases.
1241
1371
  fileProvider: CapabilityFileProviderManifestSchema.optional(),
1242
1372
  calendarSource: CapabilityCalendarSourceManifestSchema.optional(),
1373
+ discussions: CapabilityDiscussionsManifestSchema.optional(),
1374
+ resourceTypes: z.array(CapabilityResourceTypeModelSchema).max(MAX_MANIFEST_ENTRIES).optional(),
1243
1375
  policies: z.record(CapabilityLocalIdSchema, CapabilityActionPolicySchema).optional(),
1244
1376
  events: z.array(CapabilityEventManifestSchema).max(MAX_MANIFEST_ENTRIES).optional(),
1245
1377
  subscribes: z.array(CapabilitySubscriptionManifestSchema).max(MAX_MANIFEST_ENTRIES).optional(),
@@ -1297,7 +1429,8 @@ export const CapabilityCatalogAppSchema = z.object({
1297
1429
  appName: z.string().min(1).max(200),
1298
1430
  appIcon: z.string().min(1).max(120),
1299
1431
  appDescription: z.string().max(1000),
1300
- manifest: CapabilityManifestSchema,
1432
+ // This is Core's person projection, whose app-only queries have already been hidden.
1433
+ manifest: z.preprocess((value) => readableManifest(value, true), CapabilityManifestSchema.out),
1301
1434
  events: z.array(CapabilityEventCatalogEntrySchema).max(MAX_MANIFEST_ENTRIES).optional(),
1302
1435
  });
1303
1436
 
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ import { type CapabilityManifest, CapabilitySemanticLinkSchema, CloudResourceRefSchema } from "./capabilities";
3
+ import { type CapabilityContractIssue, capabilityContractIssues } from "./capability-compatibility";
4
+ import { typeViewIssues } from "./resource-types";
5
+
6
+ /** Same page bound as resource lists and owner activity pages. */
7
+ export const DISCUSSION_PAGE_LIMIT = 100;
8
+ const cursor = z.string().min(1).max(2048).describe("Opaque continuation cursor.");
9
+ export const DiscussionContainersInputSchema = z
10
+ .object({
11
+ query: z.string().max(500).optional().describe("Optional container search text."),
12
+ cursor: cursor.optional(),
13
+ limit: z.number().int().min(1).max(DISCUSSION_PAGE_LIMIT).default(50).describe("Maximum containers in this page."),
14
+ })
15
+ .strict();
16
+ export const DiscussionContainerSchema = z
17
+ .object({
18
+ ref: CloudResourceRefSchema,
19
+ title: z.string().min(1).max(500),
20
+ })
21
+ .strict();
22
+ export const DiscussionContainersDataSchema = z
23
+ .object({
24
+ items: z.array(DiscussionContainerSchema).max(DISCUSSION_PAGE_LIMIT),
25
+ next: cursor.nullable(),
26
+ })
27
+ .strict();
28
+
29
+ /** Shared owner activity, independent of the application or discussion consumer. */
30
+ export const ResourceActivityEventSchema = z.object({
31
+ id: z.string().max(100),
32
+ at: z.string().datetime({ offset: true }),
33
+ ref: CloudResourceRefSchema.strip().nullable(),
34
+ title: z.string().max(500).nullable().optional(),
35
+ actor: z.object({ type: z.enum(["user", "service_account", "system"]), id: z.uuid().nullable(), name: z.string().max(1000) }),
36
+ icon: z
37
+ .string()
38
+ .regex(/^ti ti-[a-z0-9-]+$/)
39
+ .max(80),
40
+ sentence: z.string().max(4000),
41
+ href: CapabilitySemanticLinkSchema.shape.href.optional(),
42
+ count: z.number().int().min(1),
43
+ });
44
+ export type ResourceActivityEvent = z.infer<typeof ResourceActivityEventSchema>;
45
+ export const DiscussionActivityInputSchema = z
46
+ .object({
47
+ container: CloudResourceRefSchema.shape.id.describe("Container whose authorized activity is requested."),
48
+ item: CloudResourceRefSchema.shape.id.optional().describe("Optional item inside that container."),
49
+ timeZone: z.string().min(1).max(200).optional().describe("IANA display time zone."),
50
+ before: z.string().datetime({ offset: true }).optional().describe("Exclusive upper timestamp bound."),
51
+ after: z.string().datetime({ offset: true }).optional().describe("Exclusive lower timestamp bound."),
52
+ cursor: cursor.optional(),
53
+ limit: z.number().int().min(1).max(DISCUSSION_PAGE_LIMIT).default(50).describe("Maximum events in this page."),
54
+ })
55
+ .strict();
56
+ export const DiscussionActivityDataSchema = z.array(ResourceActivityEventSchema).max(DISCUSSION_PAGE_LIMIT);
57
+ // The wire contract accepts additive fields; the public consumer schema strips them.
58
+ // Zod's output JSON Schema is closed even for stripping objects, so use an explicit
59
+ // open acceptance shape here rather than treating stripped fields as forbidden.
60
+ const DiscussionActivityWireSchema = z
61
+ .array(
62
+ ResourceActivityEventSchema.loose().extend({
63
+ actor: ResourceActivityEventSchema.shape.actor.loose(),
64
+ ref: CloudResourceRefSchema.loose().nullable(),
65
+ }),
66
+ )
67
+ .max(DISCUSSION_PAGE_LIMIT);
68
+ export type DiscussionContainer = z.infer<typeof DiscussionContainerSchema>;
69
+
70
+ /** Startup/registry check: discovery is never permission to read content. */
71
+ export const discussionsIssues = (manifest: CapabilityManifest): CapabilityContractIssue[] => {
72
+ const role = manifest.discussions;
73
+ if (!role) return [];
74
+ const model = manifest.resourceTypes?.find((model) => model.type === role.item);
75
+ const issues: CapabilityContractIssue[] = [];
76
+ if (!model?.container) issues.push({ code: "kind", path: "$.item", message: "Discussion item must have a container type" });
77
+ for (const localId of [role.item, ...(model?.container ? [model.container] : [])]) issues.push(...typeViewIssues(manifest, localId));
78
+ for (const [localId, input, data] of [
79
+ [role.containers, DiscussionContainersInputSchema, DiscussionContainersDataSchema],
80
+ ...(role.activity ? [[role.activity, DiscussionActivityInputSchema, DiscussionActivityWireSchema] as const] : []),
81
+ ] as const) {
82
+ const query = manifest.queries.find((query) => query.localId === localId);
83
+ if (!query) issues.push({ code: "kind", path: "$", message: `Missing discussion Query ${localId}` });
84
+ else {
85
+ issues.push(...capabilityContractIssues({ kind: "query", input, data }, { kind: "query", operation: query }));
86
+ if (query.appOnly || query.openWorld)
87
+ issues.push({ code: "kind", path: "$", message: `Discussion Query ${localId} must be closed-world and person-readable` });
88
+ }
89
+ }
90
+ return issues;
91
+ };
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+ import { GeoPlaceSchema, GeoPointSchema, GeoProvenanceSchema } from "./geo";
3
+ import { GeoRouteSchema } from "./geo-route";
4
+
5
+ // One place.search result fits a saved map. Routes carry their own bounded stops.
6
+ export const GEO_DOCUMENT_MAX_PINS = 20;
7
+ const shortId = z.string().regex(/^[A-Za-z0-9]{6}$/);
8
+ const label = z.string().trim().min(1).max(300);
9
+ export const GeoDocumentLinkSchema = z.discriminatedUnion("type", [
10
+ z.object({ type: z.literal("url"), url: z.url({ protocol: /^https?$/ }).max(2000), label }).strict(),
11
+ z.object({ type: z.literal("note"), noteId: shortId, label }).strict(),
12
+ z.object({ type: z.literal("heading"), noteId: shortId, heading: z.string().min(1).max(300), label }).strict(),
13
+ ]);
14
+ export const GeoDocumentPinSchema = z
15
+ .object({
16
+ id: GeoPlaceSchema.shape.id,
17
+ label,
18
+ point: GeoPointSchema,
19
+ provenance: GeoProvenanceSchema,
20
+ link: GeoDocumentLinkSchema.optional(),
21
+ })
22
+ .strict();
23
+ export const GeoDocumentSchema = z
24
+ .object({
25
+ version: z.literal(1),
26
+ title: label,
27
+ savedAt: z.iso.datetime().optional(),
28
+ pins: z.array(GeoDocumentPinSchema).max(GEO_DOCUMENT_MAX_PINS),
29
+ route: GeoRouteSchema.optional(),
30
+ })
31
+ .strict()
32
+ .superRefine((document, context) => {
33
+ // Each coordinate consists of up to ten continuation bytes and one terminator.
34
+ // Complete latitude/longitude pairs keep malformed authored geometry out of map renderers.
35
+ if (document.route && !/^(?:[_-~]{0,10}[?-^][_-~]{0,10}[?-^])+$/.test(document.route.polyline6)) {
36
+ context.addIssue({
37
+ code: "custom",
38
+ path: ["route", "polyline6"],
39
+ message: "Route geometry must contain complete polyline6 coordinate pairs.",
40
+ });
41
+ }
42
+ const ids = new Set<string>();
43
+ document.pins.forEach((pin, index) => {
44
+ if (ids.has(pin.id)) context.addIssue({ code: "custom", path: ["pins", index, "id"], message: "Pin IDs must be unique." });
45
+ ids.add(pin.id);
46
+ });
47
+ });
48
+ export type GeoDocument = z.infer<typeof GeoDocumentSchema>;
49
+ export type GeoDocumentPin = z.infer<typeof GeoDocumentPinSchema>;
50
+ export type GeoDocumentLink = z.infer<typeof GeoDocumentLinkSchema>;
51
+
52
+ /** A portable Notebook block. Escaped JSON cannot close the directive or create HTML. */
53
+ export function serializeGeoDocument(input: GeoDocument): string {
54
+ const document = GeoDocumentSchema.parse(input);
55
+ const json = JSON.stringify(document).replace(
56
+ /[<>&\u2028\u2029]/g,
57
+ (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
58
+ );
59
+ return `:::map\n${json}\n:::`;
60
+ }