@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.
- package/package.json +9 -2
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +2 -0
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +36 -0
- package/src/access/GroupCoverage.tsx +20 -5
- package/src/access/PermissionEditor.tsx +11 -2
- package/src/access/PrincipalAvatar.tsx +30 -21
- package/src/access/messages.ts +4 -4
- package/src/account/EntitySearch.tsx +12 -0
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/dictation-runtime.ts +6 -2
- package/src/ai/executor.ts +22 -4
- package/src/ai/index.ts +1 -0
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/project-tool.ts +4 -4
- package/src/ai/projects-authorization.ts +53 -0
- package/src/ai/projects-routes.ts +56 -46
- package/src/ai/projects.ts +100 -79
- package/src/ai/protocol.ts +3 -1
- package/src/ai/recent-resources.ts +3 -3
- package/src/ai/routes.ts +32 -30
- package/src/ai/skill-tool.ts +4 -4
- package/src/ai/skills-routes.ts +27 -26
- package/src/ai/skills.ts +120 -81
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/accounts-entities.ts +6 -3
- package/src/api/admin-ai-skills.ts +4 -1
- package/src/api/auth.ts +9 -2
- package/src/api/capabilities.ts +90 -12
- package/src/api/mcp.ts +4 -2
- package/src/browser/notifications.ts +25 -8
- package/src/browser/session.ts +39 -0
- package/src/browser/shared-text-history-validation.ts +151 -0
- package/src/browser/shared-text-history.ts +464 -0
- package/src/browser/shared-text-offline-authorized.ts +29 -0
- package/src/browser/shared-text-offline.ts +351 -0
- package/src/browser/shared-text-provider.ts +714 -0
- package/src/browser/shared-text-table-contract.ts +150 -0
- package/src/browser/shared-text.ts +19 -0
- package/src/capabilities/discussion-providers.ts +99 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +58 -0
- package/src/capabilities/server-invoke.ts +23 -13
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/contracts/shared.ts +2 -1
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/server/api/respond.ts +10 -2
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +23 -10
- package/src/server/services/index.ts +1 -0
- package/src/services/accounts/app.ts +56 -17
- package/src/services/accounts/entities.ts +36 -16
- package/src/services/accounts/group-sql.ts +23 -0
- package/src/services/accounts/groups.ts +26 -17
- package/src/services/accounts/identities.ts +8 -1
- package/src/services/accounts/identity-reconciliation.ts +3 -1
- package/src/services/accounts/local-groups.ts +141 -27
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/accounts/users.ts +38 -8
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/mandates/index.ts +2 -2
- package/src/services/mandates/policy.ts +1 -0
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/session/index.ts +12 -3
- package/src/shared/email-html.ts +1 -0
- package/src/shared/markdown/extensions/katex.ts +8 -1
- package/src/shared/markdown/extensions/tables.ts +14 -1
- package/src/shared/markdown/index.ts +22 -11
- package/src/shared-text/anchors.ts +29 -0
- package/src/shared-text/awareness.ts +87 -0
- package/src/shared-text/changes.ts +27 -0
- package/src/shared-text/documents.ts +428 -77
- package/src/shared-text/events.ts +12 -2
- package/src/shared-text/index.ts +16 -1
- package/src/shared-text/log.ts +24 -5
- package/src/shared-text/snapshots.ts +69 -23
- package/src/shared-text/structural-history.ts +377 -0
- package/src/shared-text/table-actions.ts +432 -0
- package/src/shared-text/types.ts +24 -5
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/CloudShell.island.tsx +19 -3
- package/src/ssr/Layout.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +2 -2
- package/src/ssr/ProfilePreferences.tsx +2 -1
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-navigation.ts +1 -11
- package/src/ssr/app-launchpad.tsx +6 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/profile-actions.ts +3 -6
- package/src/ssr/shell-data.tsx +1 -1
- package/src/styles/global.css +2 -0
- package/src/workflows/language/expressions.ts +3 -2
- 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.
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 (
|
|
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 {
|
|
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:
|
|
50
|
-
|
|
51
|
-
:
|
|
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
|
|
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);
|