@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
@@ -0,0 +1,150 @@
1
+ import { encoding } from "@k2b/stdlib";
2
+ import type { TableActionIntent, TableFocusWitness } from "../shared-text/table-actions";
3
+
4
+ const { fromBase64, toBase64 } = encoding;
5
+
6
+ import * as Y from "yjs";
7
+ import { z } from "zod";
8
+
9
+ /** Approved editor limit, measured in UTF-16 code units. Rejection must preserve the existing text. */
10
+ export const TEXT_TABLE_ACTION_MAX_LENGTH = 2_097_152;
11
+
12
+ // An item relative position is one tag, two safe-integer varuints (at most
13
+ // ceil(53 / 7) bytes each), and the zero-association byte. No root-name/sentinel
14
+ // positions are accepted. The base64 bound follows this Yjs wire format.
15
+ const ITEM_ANCHOR_MAX_BYTES = 1 + 2 * Math.ceil(53 / 7) + 1;
16
+ const ITEM_ANCHOR_MAX_BASE64_LENGTH = 4 * Math.ceil(ITEM_ANCHOR_MAX_BYTES / 3);
17
+ const AnchorSchema = z
18
+ .string()
19
+ .min(1)
20
+ .max(ITEM_ANCHOR_MAX_BASE64_LENGTH)
21
+ .refine((encoded) => {
22
+ if (encoded.length > ITEM_ANCHOR_MAX_BASE64_LENGTH) return false;
23
+ try {
24
+ const bytes = fromBase64(encoded);
25
+ const position = Y.decodeRelativePosition(bytes);
26
+ return (
27
+ bytes.length <= ITEM_ANCHOR_MAX_BYTES &&
28
+ position.item !== null &&
29
+ position.assoc === 0 &&
30
+ Number.isSafeInteger(position.item.client) &&
31
+ position.item.client >= 0 &&
32
+ Number.isSafeInteger(position.item.clock) &&
33
+ position.item.clock >= 0 &&
34
+ toBase64(Y.encodeRelativePosition(position)) === encoded
35
+ );
36
+ } catch {
37
+ return false;
38
+ }
39
+ }, "Expected a canonical Yjs character anchor");
40
+
41
+ const TableSchema = z.object({ headerBreak: AnchorSchema, separatorHyphen: AnchorSchema }).strict();
42
+ const RowSchema = z.discriminatedUnion("kind", [
43
+ z.object({ kind: z.literal("header") }).strict(),
44
+ z.object({ kind: z.literal("body"), beforeBreak: AnchorSchema, inside: AnchorSchema, insideKind: z.enum(["pipe", "break"]) }).strict(),
45
+ ]);
46
+ const ColumnSchema = z.object({ hyphen: AnchorSchema, left: AnchorSchema, right: AnchorSchema }).strict();
47
+
48
+ export const TextTableFocusSchema = z.object({ table: TableSchema, row: RowSchema, column: ColumnSchema }).strict();
49
+ export type EncodedTableFocus = z.infer<typeof TextTableFocusSchema>;
50
+ export const encodeTableFocus = (focus: TableFocusWitness): EncodedTableFocus => ({
51
+ table: { headerBreak: toBase64(focus.table.headerBreak), separatorHyphen: toBase64(focus.table.separatorHyphen) },
52
+ row:
53
+ focus.row.kind === "header"
54
+ ? focus.row
55
+ : { ...focus.row, beforeBreak: toBase64(focus.row.beforeBreak), inside: toBase64(focus.row.inside) },
56
+ column: { hyphen: toBase64(focus.column.hyphen), left: toBase64(focus.column.left), right: toBase64(focus.column.right) },
57
+ });
58
+ export const decodeTableFocus = (focus: EncodedTableFocus): TableFocusWitness => ({
59
+ table: { headerBreak: fromBase64(focus.table.headerBreak), separatorHyphen: fromBase64(focus.table.separatorHyphen) },
60
+ row:
61
+ focus.row.kind === "header"
62
+ ? focus.row
63
+ : { ...focus.row, beforeBreak: fromBase64(focus.row.beforeBreak), inside: fromBase64(focus.row.inside) },
64
+ column: { hyphen: fromBase64(focus.column.hyphen), left: fromBase64(focus.column.left), right: fromBase64(focus.column.right) },
65
+ });
66
+
67
+ export const EncodedTableActionIntentSchema = z.discriminatedUnion("action", [
68
+ z.object({ action: z.enum(["moveRowUp", "moveRowDown"]), table: TableSchema, row: RowSchema, neighbor: RowSchema }).strict(),
69
+ z.object({ action: z.enum(["moveColLeft", "moveColRight"]), table: TableSchema, column: ColumnSchema, neighbor: ColumnSchema }).strict(),
70
+ z.object({ action: z.enum(["undo", "redo"]), receiptCursor: z.string().min(1).max(256) }).strict(),
71
+ z
72
+ .object({
73
+ action: z.literal("replaceSource"),
74
+ table: TableSchema,
75
+ expected: z.string().max(TEXT_TABLE_ACTION_MAX_LENGTH),
76
+ source: z.string().max(TEXT_TABLE_ACTION_MAX_LENGTH),
77
+ })
78
+ .strict(),
79
+ z.object({ action: z.enum(["completeColumns", "toggleHeader", "deleteTable"]), table: TableSchema }).strict(),
80
+ z.object({ action: z.enum(["rowAbove", "rowBelow", "deleteRow"]), table: TableSchema, row: RowSchema }).strict(),
81
+ z
82
+ .object({
83
+ action: z.enum(["colLeft", "colRight", "deleteCol", "alignLeft", "alignCenter", "alignRight"]),
84
+ table: TableSchema,
85
+ column: ColumnSchema,
86
+ })
87
+ .strict(),
88
+ ]);
89
+
90
+ export const TextTableActionRequestSchema = z
91
+ .object({
92
+ // The application stores this token in a nonnegative Postgres bigint.
93
+ restoreRevision: z
94
+ .string()
95
+ .min(1)
96
+ .max(19)
97
+ .regex(/^(0|[1-9]\d*)$/)
98
+ .refine((value) => value.length <= 19 && /^(0|[1-9]\d*)$/.test(value) && BigInt(value) <= 9_223_372_036_854_775_807n),
99
+ actionId: z.uuid(),
100
+ intent: EncodedTableActionIntentSchema,
101
+ focus: TextTableFocusSchema.optional(),
102
+ caret: z
103
+ .object({
104
+ row: z.number().int().nonnegative().max(TEXT_TABLE_ACTION_MAX_LENGTH),
105
+ column: z.number().int().nonnegative().max(TEXT_TABLE_ACTION_MAX_LENGTH),
106
+ })
107
+ .strict()
108
+ .optional(),
109
+ })
110
+ .strict();
111
+
112
+ export type EncodedTableActionIntent = z.infer<typeof EncodedTableActionIntentSchema>;
113
+ export type TextTableActionRequest = z.infer<typeof TextTableActionRequestSchema>;
114
+
115
+ /** Decode only schema-validated transport; planning still validates identity and current context. */
116
+ export type TextTableIntent = TableActionIntent | { action: "undo" | "redo"; receiptCursor: string };
117
+ export const decodeTableActionIntent = (intent: EncodedTableActionIntent): TextTableIntent => {
118
+ if ("receiptCursor" in intent) return intent;
119
+ const table = { headerBreak: fromBase64(intent.table.headerBreak), separatorHyphen: fromBase64(intent.table.separatorHyphen) };
120
+ if ("source" in intent) return { action: intent.action, table, expected: intent.expected, source: intent.source };
121
+ if (!("row" in intent) && !("column" in intent)) return { action: intent.action, table };
122
+ const decodeRow = (row: z.infer<typeof RowSchema>) =>
123
+ row.kind === "header"
124
+ ? row
125
+ : { kind: "body" as const, beforeBreak: fromBase64(row.beforeBreak), inside: fromBase64(row.inside), insideKind: row.insideKind };
126
+ const decodeColumn = (column: z.infer<typeof ColumnSchema>) => ({
127
+ hyphen: fromBase64(column.hyphen),
128
+ left: fromBase64(column.left),
129
+ right: fromBase64(column.right),
130
+ });
131
+ if ("row" in intent) {
132
+ const row = decodeRow(intent.row);
133
+ return "neighbor" in intent
134
+ ? { action: intent.action, table, row, neighbor: decodeRow(intent.neighbor) }
135
+ : { action: intent.action, table, row };
136
+ }
137
+ const column = decodeColumn(intent.column);
138
+ return "neighbor" in intent
139
+ ? { action: intent.action, table, column, neighbor: decodeColumn(intent.neighbor) }
140
+ : { action: intent.action, table, column };
141
+ };
142
+
143
+ export const TextTableActionReceiptSchema = z.object({ actionId: z.uuid(), update: z.string() });
144
+ export const TextTableActionResponseSchema = z.object({
145
+ changed: z.boolean(),
146
+ focus: TextTableFocusSchema.nullable().optional(),
147
+ cursor: z.string().nullable(),
148
+ receipt: TextTableActionReceiptSchema.nullable(),
149
+ });
150
+ export type TextTableActionResponse = z.infer<typeof TextTableActionResponseSchema>;
@@ -0,0 +1,19 @@
1
+ /** Browser-safe identity helpers; no server log, storage, or process state. */
2
+ export { captureTextAnchor, resolveTextAnchor } from "../shared-text/anchors";
3
+ export {
4
+ captureTableAction,
5
+ captureTableFocus,
6
+ captureTableSource,
7
+ prepareTableActionFocus,
8
+ resolveTableFocus,
9
+ type TableActionIntent,
10
+ TableActionLimitError,
11
+ TableActionRejectedError,
12
+ type TableFocusWitness,
13
+ } from "../shared-text/table-actions";
14
+ export { createSharedTextActionHistory, type SharedTextInverseRequest } from "./shared-text-history";
15
+ export * from "./shared-text-offline";
16
+ export { openAuthorizedSharedTextOffline } from "./shared-text-offline-authorized";
17
+ export * from "./shared-text-provider";
18
+
19
+ export * from "./shared-text-table-contract";
@@ -0,0 +1,99 @@
1
+ import { discussionsIssues } from "../contracts/discussions";
2
+ import { type CapabilityCaller, listCapabilityCatalog } from "./server-invoke";
3
+ import type { CapabilityCatalogApp } from "./types";
4
+
5
+ // Same interactive discovery budget as the calendar reader. Cursor pages are sequential (one in flight).
6
+ const MAX_CALLS = 60;
7
+ const MAX_BYTES = 4 * 1024 * 1024;
8
+ export type DiscussionProvider = {
9
+ app: CapabilityCatalogApp;
10
+ itemType: string;
11
+ containerType: string;
12
+ containers: string;
13
+ activity?: string;
14
+ };
15
+
16
+ /** Request-local discovery. Failure, truncation, and cancellation throw; no partial success or retained authority. */
17
+ export const discussionProviders = async (
18
+ caller: CapabilityCaller,
19
+ options: { deadline: number; signal?: AbortSignal },
20
+ ): Promise<DiscussionProvider[]> => {
21
+ if (!Number.isFinite(options.deadline)) throw new RangeError("Discussion deadline must be a finite epoch time in milliseconds");
22
+ const controller = new AbortController();
23
+ const signal = AbortSignal.any([
24
+ controller.signal,
25
+ ...[caller.signal, options.signal].filter((signal): signal is AbortSignal => !!signal),
26
+ ]);
27
+ const timer = setTimeout(() => controller.abort(new Error("DISCUSSION_DEADLINE")), Math.max(0, options.deadline - Date.now()));
28
+ let bytes = 0;
29
+ const addBytes = (size: number) => {
30
+ bytes += size;
31
+ if (bytes > MAX_BYTES) {
32
+ controller.abort(new Error("DISCUSSION_BYTE_BUDGET"));
33
+ signal.throwIfAborted();
34
+ }
35
+ };
36
+ const credentials: CapabilityCaller = {
37
+ ...caller,
38
+ signal,
39
+ ...(!caller.authority
40
+ ? {
41
+ transport: async (request: Request) => {
42
+ const response = await (caller.transport ?? ((request: Request) => fetch(request, { redirect: "error" })))(request);
43
+ if (!response.body) return response;
44
+ const body = response.body.pipeThrough(
45
+ new TransformStream<Uint8Array, Uint8Array>({
46
+ transform(chunk, stream) {
47
+ addBytes(chunk.byteLength);
48
+ stream.enqueue(chunk);
49
+ },
50
+ }),
51
+ { signal },
52
+ );
53
+ return new Response(body, { status: response.status, headers: response.headers });
54
+ },
55
+ }
56
+ : {}),
57
+ };
58
+ const providers: DiscussionProvider[] = [];
59
+ const seen = new Set<string>();
60
+ const appIds = new Set<string>();
61
+ let cursor: string | undefined;
62
+ let onAbort: (() => void) | undefined;
63
+ const cancelled = new Promise<never>((_, reject) => {
64
+ onAbort = () => reject(signal.reason);
65
+ signal.addEventListener("abort", onAbort, { once: true });
66
+ });
67
+ try {
68
+ for (let calls = 0; calls < MAX_CALLS; calls++) {
69
+ if (options.deadline <= Date.now()) controller.abort(new Error("DISCUSSION_DEADLINE"));
70
+ signal.throwIfAborted();
71
+ const page = await Promise.race([listCapabilityCatalog({ caller: credentials, cursor, limit: 25 }), cancelled]);
72
+ signal.throwIfAborted();
73
+ if (!page.ok) throw new Error(`Discussion discovery unavailable: ${page.error.code}`);
74
+ if (caller.authority) addBytes(new TextEncoder().encode(JSON.stringify(page.data)).byteLength);
75
+ for (const app of page.data.apps) {
76
+ const role = app.manifest.discussions;
77
+ const model = app.manifest.resourceTypes?.find((model) => model.type === role?.item);
78
+ if (!role || !model?.container || discussionsIssues(app.manifest).length || appIds.has(app.appId)) continue;
79
+ appIds.add(app.appId);
80
+ providers.push({
81
+ app,
82
+ itemType: `${app.appId}.${role.item}`,
83
+ containerType: `${app.appId}.${model.container}`,
84
+ containers: role.containers,
85
+ ...(role.activity ? { activity: role.activity } : {}),
86
+ });
87
+ }
88
+ if (!page.data.page.hasMore) return providers;
89
+ cursor = page.data.page.nextCursor;
90
+ if (seen.has(cursor)) throw new Error("DISCUSSION_CURSOR_LOOP");
91
+ seen.add(cursor);
92
+ }
93
+ throw new Error("DISCUSSION_CALL_BUDGET");
94
+ } finally {
95
+ clearTimeout(timer);
96
+ if (onAbort) signal.removeEventListener("abort", onAbort);
97
+ controller.abort();
98
+ }
99
+ };
@@ -63,7 +63,7 @@ export type CapabilityExecutionInput = {
63
63
  capability: string;
64
64
  kind: "query" | "action";
65
65
  destructive: boolean;
66
- actorKind?: "user" | "service_account" | null;
66
+ actorKind?: "user" | "service_account" | "app" | null;
67
67
  actorId?: string | null;
68
68
  userId?: string | null;
69
69
  /** Recorded only when the access subject differs from the acting principal. */
@@ -90,7 +90,7 @@ export type CapabilityExecution = {
90
90
  capability: string;
91
91
  kind: "query" | "action";
92
92
  destructive: boolean;
93
- actorKind: "user" | "service_account" | null;
93
+ actorKind: "user" | "service_account" | "app" | null;
94
94
  actorId: string | null;
95
95
  userId: string | null;
96
96
  accessSubjectType: "user" | "service_account" | null;
@@ -35,7 +35,7 @@ export const migrateCloudCapabilities = async (): Promise<void> => {
35
35
  duration_ms INTEGER NOT NULL DEFAULT 0,
36
36
  CONSTRAINT capabilities_executions_origin_check CHECK (origin IN ('assistant', 'mcp', 'http', 'app')),
37
37
  CONSTRAINT capabilities_executions_kind_check CHECK (kind IN ('query', 'action')),
38
- CONSTRAINT capabilities_executions_actor_kind_check CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account')),
38
+ CONSTRAINT capabilities_executions_actor_kind_check CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account', 'app')),
39
39
  CONSTRAINT capabilities_executions_subject_check CHECK (access_subject_type IS NULL OR access_subject_type IN ('user', 'service_account')),
40
40
  CONSTRAINT capabilities_executions_status_check CHECK (status IN ('succeeded', 'failed', 'denied', 'invalid_input', 'timed_out', 'rejected'))
41
41
  )
@@ -59,6 +59,15 @@ export const migrateCloudCapabilities = async (): Promise<void> => {
59
59
  await sql`ALTER TABLE capabilities.executions ADD COLUMN IF NOT EXISTS parent_invocation_id UUID`.simple();
60
60
  await sql`ALTER TABLE capabilities.executions ADD COLUMN IF NOT EXISTS via_app_id TEXT`.simple();
61
61
 
62
+ await sql`DO $$ BEGIN
63
+ IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'capabilities.executions'::regclass
64
+ AND conname = 'capabilities_executions_actor_kind_check' AND position('''app''' in pg_get_constraintdef(oid)) = 0) THEN
65
+ ALTER TABLE capabilities.executions DROP CONSTRAINT capabilities_executions_actor_kind_check;
66
+ ALTER TABLE capabilities.executions ADD CONSTRAINT capabilities_executions_actor_kind_check
67
+ CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account', 'app'));
68
+ END IF;
69
+ END $$`.simple();
70
+
62
71
  // Platform-owned idempotency claims. One row per
63
72
  // (app, capability, principal, key); the dispatcher claims before it
64
73
  // forwards and resolves the row once the outcome is known. Response bodies
@@ -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);