@k2b/cloud 0.41.0 → 0.42.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 +6 -5
- package/src/_internal/bounded-json.ts +14 -2
- package/src/_internal/capabilities.ts +87 -8
- package/src/_internal/capability-reviews.ts +6 -0
- package/src/_internal/capability-timeout.ts +71 -0
- package/src/_internal/define-app.ts +145 -89
- package/src/_internal/http-drain.ts +56 -0
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +16 -17
- package/src/access/PrincipalPicker.tsx +45 -10
- package/src/access/messages.ts +8 -0
- package/src/access/principal-label.ts +18 -0
- package/src/access/ui.ts +1 -0
- package/src/account/EntitySearch.tsx +5 -4
- package/src/ai/action-review.ts +2 -1
- package/src/ai/app-skill-store.ts +1 -1
- package/src/ai/capabilities.ts +21 -3
- package/src/ai/capability-execution.ts +14 -12
- package/src/ai/chat/action-card.ts +7 -1
- package/src/ai/chat/action-messages.ts +14 -0
- package/src/ai/chat/action-question.browser-harness.tsx +12 -0
- package/src/ai/chat/action-question.tsx +202 -22
- package/src/ai/chat/action-text.ts +1 -1
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/client/controller.ts +150 -32
- package/src/ai/client/transport.ts +14 -2
- package/src/ai/code-capability-routes.ts +27 -23
- package/src/ai/code-runtime-tools.ts +14 -16
- package/src/ai/code-source-tools.ts +16 -18
- package/src/ai/executor.ts +56 -21
- package/src/ai/fetch-file-tool.ts +67 -76
- package/src/ai/http.ts +1 -0
- package/src/ai/migrate.ts +13 -0
- package/src/ai/open-questions.ts +4 -8
- package/src/ai/projects.ts +1 -2
- package/src/ai/protocol.ts +2 -2
- package/src/ai/recent-resources.ts +12 -5
- package/src/ai/review-content.ts +77 -0
- package/src/ai/routes.ts +69 -31
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skill-tool.ts +17 -6
- package/src/ai/skills-routes.ts +72 -27
- package/src/ai/skills.ts +139 -60
- package/src/ai/store.ts +168 -33
- package/src/ai/types.ts +10 -3
- package/src/ai/usage.ts +2 -2
- package/src/api/admin-core-settings.ts +15 -2
- package/src/api/admin-geo.ts +54 -0
- package/src/api/admin-linux-identities.ts +6 -3
- package/src/api/admin-outgoing-mail.ts +29 -15
- package/src/api/approvals-loader.ts +3 -3
- package/src/api/approvals.ts +51 -0
- package/src/api/apps.ts +4 -2
- package/src/api/capabilities.ts +258 -181
- package/src/api/capability-streams.ts +21 -13
- package/src/api/cli-plugins.ts +4 -1
- package/src/api/geo-glyphs.ts +11 -0
- package/src/api/geo.ts +128 -0
- package/src/api/help.ts +12 -4
- package/src/api/index.ts +5 -0
- package/src/api/mcp.ts +46 -38
- package/src/api/me-panel-consent.ts +8 -5
- package/src/api/search.ts +21 -14
- package/src/api/sync-ops.ts +19 -13
- package/src/api/widgets.ts +42 -18
- package/src/approvals/Approvals.tsx +5 -3
- package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
- package/src/browser/CloudResourceSearch.tsx +3 -13
- package/src/browser/PanelLeaveNotice.tsx +18 -0
- package/src/browser/navigation-search.ts +78 -25
- package/src/browser/panel-leave.ts +69 -0
- package/src/browser/panel-messages.ts +12 -0
- package/src/browser/panel-slot.ts +173 -24
- package/src/browser/panel.ts +3 -0
- package/src/browser/resource-search-messages.ts +2 -0
- package/src/capabilities/client.ts +28 -1
- package/src/capabilities/executions.ts +22 -3
- package/src/capabilities/geo-client.ts +57 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/review-storage.ts +24 -0
- package/src/capabilities/reviews.ts +17 -3
- package/src/capabilities/server.ts +36 -16
- package/src/cli/access.ts +15 -23
- package/src/cli/admin/linux.ts +13 -2
- package/src/cli/admin/outgoing-mail.ts +26 -4
- package/src/cli/capabilities.ts +19 -0
- package/src/config/env.ts +0 -6
- package/src/contracts/app-access.ts +48 -0
- package/src/contracts/app.ts +4 -1
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/avatar.ts +186 -0
- package/src/contracts/capabilities.ts +148 -54
- package/src/contracts/geo-admin.ts +127 -0
- package/src/contracts/geo-map.ts +19 -0
- package/src/contracts/geo-route.ts +168 -0
- package/src/contracts/geo.ts +248 -0
- package/src/contracts/index.ts +4 -0
- package/src/contracts/outgoing-mail.ts +17 -4
- package/src/contracts/registry.ts +2 -0
- package/src/events/app-access.ts +68 -0
- package/src/events/live-delivery.ts +128 -0
- package/src/events/live.ts +14 -130
- package/src/geo/CloudMap.tsx +95 -0
- package/src/geo/map-messages.ts +17 -0
- package/src/geo/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/client-ip.ts +36 -0
- package/src/server/index.ts +3 -4
- package/src/server/middleware/app-access.ts +312 -0
- package/src/server/middleware/auth.ts +1 -1
- package/src/server/middleware/invocation.ts +11 -0
- package/src/server/middleware/rate-limit.ts +4 -4
- package/src/server/services/access.ts +13 -9
- package/src/server/services/index.ts +0 -2
- package/src/server/services/services.ts +0 -2
- package/src/services/accounts/posix.ts +21 -7
- package/src/services/allowed-network-fetch.ts +16 -10
- package/src/services/app-access-metrics.ts +24 -0
- package/src/services/app-access.ts +123 -0
- package/src/services/app-discovery.ts +27 -0
- package/src/services/approvals/content.ts +40 -0
- package/src/services/approvals/index.ts +14 -6
- package/src/services/approvals/list.ts +6 -11
- package/src/services/approvals/question.ts +11 -7
- package/src/services/downstream-budget.ts +87 -0
- package/src/services/geo/admin.ts +262 -0
- package/src/services/geo/bucket.ts +67 -0
- package/src/services/geo/cache.ts +57 -0
- package/src/services/geo/costs.ts +5 -0
- package/src/services/geo/errors.ts +41 -0
- package/src/services/geo/fixtures/README.md +17 -0
- package/src/services/geo/fixtures/along.json +355 -0
- package/src/services/geo/fixtures/autocomplete.json +218 -0
- package/src/services/geo/fixtures/geocode.json +129 -0
- package/src/services/geo/fixtures/places.json +355 -0
- package/src/services/geo/fixtures/reverse.json +61 -0
- package/src/services/geo/fixtures/routing.json +57 -0
- package/src/services/geo/geoapify-route.ts +93 -0
- package/src/services/geo/geoapify.ts +281 -0
- package/src/services/geo/ledger.ts +122 -0
- package/src/services/geo/map.ts +160 -0
- package/src/services/geo/migrate.ts +25 -0
- package/src/services/geo/polyline.ts +184 -0
- package/src/services/geo/route-chain.ts +145 -0
- package/src/services/geo/route-costs.ts +9 -0
- package/src/services/geo/route-geometry.ts +39 -0
- package/src/services/geo/route-plan.ts +172 -0
- package/src/services/geo/route-service.ts +433 -0
- package/src/services/geo/service.ts +300 -0
- package/src/services/geo/settings.ts +49 -0
- package/src/services/help/index.ts +16 -7
- package/src/services/help/types.ts +6 -1
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/issue-invocation.ts +67 -0
- package/src/services/identity/key-ring.ts +5 -3
- package/src/services/identity/runtime-config.ts +23 -0
- package/src/services/index.ts +9 -0
- package/src/services/mandates/index.ts +40 -3
- package/src/services/network-security.ts +6 -3
- package/src/services/network-transport.ts +99 -0
- package/src/services/notifications/catalog.ts +3 -2
- package/src/services/notifications/runtime.ts +1 -0
- package/src/services/notifications/web-push-transport.ts +45 -38
- package/src/services/outgoing-mail/dispatcher.ts +6 -1
- package/src/services/outgoing-mail/index.ts +55 -1
- package/src/services/outgoing-mail/messages.ts +82 -3
- package/src/services/outgoing-mail/resolve-host.ts +2 -2
- package/src/services/outgoing-mail/retention.ts +13 -1
- package/src/services/outgoing-mail/store.ts +36 -6
- package/src/services/outgoing-mail/test-send.ts +34 -4
- package/src/services/outgoing-mail/transport.ts +1 -1
- package/src/services/pdf/gotenberg.ts +11 -38
- package/src/services/pdf/template-preview.ts +1 -1
- package/src/services/public-http.ts +28 -40
- package/src/services/settings/app.ts +4 -1
- package/src/services/settings/core-settings.ts +88 -0
- package/src/services/settings/defaults.ts +8 -2
- package/src/services/settings/errors.ts +11 -0
- package/src/services/settings/geo-budget.ts +40 -0
- package/src/services/settings/index.ts +4 -3
- package/src/services/settings/store.ts +25 -17
- package/src/services/weather/geo.ts +29 -111
- package/src/services/weather/index.ts +4 -3
- package/src/services/weather/locations.ts +8 -5
- package/src/services/weather/migrate.ts +1 -0
- package/src/services/weather/settings.ts +0 -16
- package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
- package/src/shared/avatar-art.ts +400 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/network-address.ts +22 -3
- package/src/shared/template-rendering.ts +36 -4
- package/src/shared-text/documents.ts +1 -1
- package/src/shared-text/index.ts +2 -0
- package/src/shared-text/log.ts +51 -3
- package/src/shared-text/snapshots.ts +340 -0
- package/src/ssr/AdminLayout.tsx +4 -2
- package/src/ssr/CloudShell.island.tsx +6 -0
- package/src/ssr/Layout.tsx +65 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
- package/src/ssr/LayoutHelp.tsx +3 -2
- package/src/ssr/PanelRuntime.island.tsx +11 -7
- package/src/ssr/PwaLayout.tsx +2 -2
- package/src/ssr/RailApps.tsx +6 -0
- package/src/ssr/ShellRuntime.island.tsx +23 -7
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
- package/src/ssr/admin-navigation.ts +1 -0
- package/src/ssr/app-badges.ts +6 -1
- package/src/ssr/compact-shell.browser-harness.ts +44 -5
- package/src/ssr/help.ts +3 -2
- package/src/ssr/index.ts +1 -1
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/mobile-menu-history.ts +4 -0
- package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
- package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
- package/src/ssr/page-swap.browser-harness.tsx +177 -0
- package/src/ssr/page-swap.ts +28 -0
- package/src/ssr/page-trail-runtime.ts +32 -13
- package/src/ssr/panel-consent.ts +2 -2
- package/src/ssr/panel-slot.browser-harness.tsx +3 -0
- package/src/ssr/platform-messages.ts +8 -0
- package/src/ssr/runtime.ts +17 -0
- package/src/ssr/shell-data.tsx +10 -2
- package/src/styles/global.css +70 -6
- package/src/server/services/geo.ts +0 -215
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { attachResizeSeparator, LocaleProvider, parkWorkspaceDetailDrawers } from "@k2b/ui";
|
|
2
|
-
import { createComponent } from "solid-js";
|
|
2
|
+
import { createComponent, createSignal } from "solid-js";
|
|
3
3
|
import { render } from "solid-js/web";
|
|
4
4
|
import { PANEL_MAX_WIDTH_REM, PANEL_MIN_WIDTH_REM, readPanelCookie, serializePanelCookie } from "../_internal/panel-cookie";
|
|
5
5
|
import { CloudResourceRefSchema } from "../contracts/capabilities";
|
|
6
6
|
import type { PanelPageContext } from "../contracts/panel-context";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
isPanelPageContext,
|
|
9
|
+
isPanelShortcut,
|
|
10
|
+
PANEL_CONTEXT_PARAM,
|
|
11
|
+
panelProviderNeedsConsent,
|
|
12
|
+
withPanelContext,
|
|
13
|
+
} from "../contracts/panel-context-plain";
|
|
8
14
|
import { helpShortcutGroups } from "../ssr/layout-help-start";
|
|
9
15
|
import { platformMessages } from "../ssr/platform-messages";
|
|
10
16
|
import { readShellData } from "../ssr/shell-data";
|
|
11
17
|
import { COMMANDS_CHANGED, contextCommandsWithShortcuts } from "./command-shortcuts";
|
|
18
|
+
import PanelLeaveNotice from "./PanelLeaveNotice";
|
|
12
19
|
import type { PanelRequest } from "./panel";
|
|
13
20
|
import {
|
|
14
21
|
PAGE_CONTEXT_CHANGED,
|
|
@@ -17,8 +24,9 @@ import {
|
|
|
17
24
|
type PanelConsentRequest,
|
|
18
25
|
type PanelState,
|
|
19
26
|
} from "./panel-context-bridge";
|
|
27
|
+
import { panelCanLeave } from "./panel-leave";
|
|
20
28
|
import PanelMenu from "./panel-menu";
|
|
21
|
-
import { isPanelCloseMessage, isPanelReadyMessage, isPanelSearchMessage } from "./panel-messages";
|
|
29
|
+
import { isPanelCloseMessage, isPanelNavigateMessage, isPanelReadyMessage, isPanelSearchMessage } from "./panel-messages";
|
|
22
30
|
import { importOnDemand } from "./reload";
|
|
23
31
|
|
|
24
32
|
export const PANEL_READY_TIMEOUT_MS = 10_000;
|
|
@@ -53,13 +61,100 @@ export function installPanelSlot(): () => void {
|
|
|
53
61
|
let provider = panel.providers.find((item) => item.id === slot.dataset.provider) ?? panel.providers[0]!;
|
|
54
62
|
let frame = slot.querySelector<HTMLIFrameElement>("[data-cloud-panel-frame]");
|
|
55
63
|
let disposed = false;
|
|
56
|
-
|
|
64
|
+
const [leaveBlocked, setLeaveBlocked] = createSignal(false);
|
|
65
|
+
const transitionTo = (action: () => void) => {
|
|
66
|
+
const target = frame?.contentWindow;
|
|
67
|
+
if (target && !panelCanLeave(target)) {
|
|
68
|
+
setLeaveBlocked(true);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
setLeaveBlocked(false);
|
|
72
|
+
action();
|
|
73
|
+
};
|
|
74
|
+
const leaveHost = document.createElement("div");
|
|
75
|
+
body.before(leaveHost);
|
|
76
|
+
const disposeLeave = render(
|
|
77
|
+
() =>
|
|
78
|
+
createComponent(LocaleProvider, {
|
|
79
|
+
locale: lang,
|
|
80
|
+
get children() {
|
|
81
|
+
return createComponent(PanelLeaveNotice, {
|
|
82
|
+
get blocked() {
|
|
83
|
+
return leaveBlocked();
|
|
84
|
+
},
|
|
85
|
+
locale: lang,
|
|
86
|
+
resume: () => {
|
|
87
|
+
setLeaveBlocked(false);
|
|
88
|
+
frame?.focus();
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
leaveHost,
|
|
94
|
+
);
|
|
95
|
+
let base = panel.context ?? null;
|
|
96
|
+
let context = base;
|
|
97
|
+
let pageRoot = document.querySelector("[data-nav-root]");
|
|
98
|
+
let documentPageId = context?.pageId;
|
|
99
|
+
let lastSeq = context?.seq ?? 0;
|
|
100
|
+
// The retained provider may have locked onto this document before a swap and this lazy installer.
|
|
101
|
+
try {
|
|
102
|
+
const raw = frame && new URL(frame.src, location.href).searchParams.get(PANEL_CONTEXT_PARAM);
|
|
103
|
+
const initial: unknown = raw ? JSON.parse(raw) : null;
|
|
104
|
+
if (isPanelPageContext(initial)) {
|
|
105
|
+
documentPageId = initial.pageId;
|
|
106
|
+
lastSeq = context?.pageId === documentPageId ? Math.max(lastSeq, initial.seq) : initial.seq;
|
|
107
|
+
if (context && context.pageId !== documentPageId) context = { ...context, pageId: documentPageId, seq: ++lastSeq };
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// A malformed or absent URL context leaves the shown page's context in charge.
|
|
111
|
+
}
|
|
112
|
+
// Set when a swapped page's context has not been delivered yet; cleared by the next delivery.
|
|
113
|
+
let pageUnsent = false;
|
|
114
|
+
const followPage = () => {
|
|
115
|
+
const nextRoot = document.querySelector("[data-nav-root]");
|
|
116
|
+
if (nextRoot === pageRoot) return;
|
|
117
|
+
pageRoot = nextRoot;
|
|
118
|
+
pageUnsent = true;
|
|
119
|
+
const arriving = readShellData()?.panel;
|
|
120
|
+
// The arriving page carries the server's current consent answer, as a reload would; a newer revision wins.
|
|
121
|
+
const consent = arriving?.consent;
|
|
122
|
+
if (consent) {
|
|
123
|
+
// Page observations fence pending choices even when a read already learned this revision or the provider changed.
|
|
124
|
+
if (consent.revision > (observedRevisions.get(consent.provider) ?? 0)) observedRevisions.set(consent.provider, consent.revision);
|
|
125
|
+
if (consent.revision > (revisions.get(consent.provider) ?? 0)) {
|
|
126
|
+
answers.set(consent.provider, consent.allowed);
|
|
127
|
+
revisions.set(consent.provider, consent.revision);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (consent && consent.provider === provider.id) {
|
|
131
|
+
if (consent.allowed !== answers.get(consent.provider) || consent.revision !== revisions.get(consent.provider)) {
|
|
132
|
+
// An older or unreadable answer (`allowed: null`) is what a reload would show: stop and read again.
|
|
133
|
+
void revalidate();
|
|
134
|
+
}
|
|
135
|
+
} else if (provider.consent && isOpen()) {
|
|
136
|
+
// The server sends an answer only while the shared cookie shows this provider open; without it, read again.
|
|
137
|
+
void revalidate();
|
|
138
|
+
} else if (consentPending === 0) {
|
|
139
|
+
// A reload would read these answers again when a consent provider opens; so does the next open here.
|
|
140
|
+
for (const item of panel.providers) if (item.consent) answers.delete(item.id);
|
|
141
|
+
answersRead = undefined;
|
|
142
|
+
}
|
|
143
|
+
base = arriving?.context ?? null;
|
|
144
|
+
if (base) {
|
|
145
|
+
documentPageId ??= base.pageId;
|
|
146
|
+
context = { ...base, pageId: documentPageId, seq: ++lastSeq };
|
|
147
|
+
} else context = null;
|
|
148
|
+
};
|
|
57
149
|
let loadVersion = 0;
|
|
58
150
|
const answers = new Map<string, boolean | null>();
|
|
59
151
|
const revisions = new Map<string, number>();
|
|
152
|
+
// A newer server page supersedes choices made before it, including queued saves and conflict retries.
|
|
153
|
+
const observedRevisions = new Map<string, number>();
|
|
60
154
|
if (panel.consent) {
|
|
61
155
|
answers.set(panel.consent.provider, panel.consent.allowed);
|
|
62
156
|
revisions.set(panel.consent.provider, panel.consent.revision);
|
|
157
|
+
observedRevisions.set(panel.consent.provider, panel.consent.revision);
|
|
63
158
|
}
|
|
64
159
|
const saving = new Set<string>();
|
|
65
160
|
const errors = new Set<string>();
|
|
@@ -78,13 +173,13 @@ export function installPanelSlot(): () => void {
|
|
|
78
173
|
// Whether the current frame has seen a context, in its URL or by message; only such a frame needs a clearing null.
|
|
79
174
|
let disclosed = !!frame && new URL(frame.src, location.origin).searchParams.has(PANEL_CONTEXT_PARAM);
|
|
80
175
|
// The frame may have navigated on its own; only the selected provider's page below its panel path may receive context.
|
|
81
|
-
const showsProvider = () => {
|
|
176
|
+
const showsProvider = (requireReady = true) => {
|
|
82
177
|
try {
|
|
83
178
|
const shown = frame?.contentWindow?.location;
|
|
84
179
|
const scope = new URL(provider.href, location.origin).pathname.replace(/\/$/, "");
|
|
85
180
|
return (
|
|
86
181
|
!!shown &&
|
|
87
|
-
frame?.contentDocument?.documentElement.dataset.cloudPanelReady === provider.id &&
|
|
182
|
+
(!requireReady || frame?.contentDocument?.documentElement.dataset.cloudPanelReady === provider.id) &&
|
|
88
183
|
(shown.pathname === scope || shown.pathname.startsWith(`${scope}/`))
|
|
89
184
|
);
|
|
90
185
|
} catch {
|
|
@@ -106,6 +201,7 @@ export function installPanelSlot(): () => void {
|
|
|
106
201
|
let lastShortcutsJson: string | undefined;
|
|
107
202
|
const canSend = () => isOpen() && allowed() && !!context && slot.dataset.state === "ready" && !!frame?.contentWindow && showsProvider();
|
|
108
203
|
const sendContext = () => {
|
|
204
|
+
pageUnsent = false;
|
|
109
205
|
if (!canSend()) return;
|
|
110
206
|
const list = !panelProviderNeedsConsent(provider.id) ? shortcuts() : undefined;
|
|
111
207
|
frame?.contentWindow?.postMessage(
|
|
@@ -158,15 +254,16 @@ export function installPanelSlot(): () => void {
|
|
|
158
254
|
if (forgetUrlContext()) disclosed = false;
|
|
159
255
|
};
|
|
160
256
|
const collectContext = () => {
|
|
257
|
+
followPage();
|
|
161
258
|
if (!context) return;
|
|
162
259
|
const collection: PageContextCollection = { sources: [] };
|
|
163
260
|
window.dispatchEvent(new CustomEvent(PAGE_CONTEXT_COLLECT, { detail: collection }));
|
|
164
261
|
const source = collection.sources.findLast((source) => source.scope !== undefined);
|
|
165
262
|
const parsed = source ? CloudResourceRefSchema.safeParse(source.scope) : undefined;
|
|
166
|
-
const scope = source ? (parsed?.success ? parsed.data : undefined) :
|
|
263
|
+
const scope = source ? (parsed?.success ? parsed.data : undefined) : base?.scope;
|
|
167
264
|
if (scope?.type === context.scope?.type && scope?.id === context.scope?.id) return;
|
|
168
|
-
const { scope: _previous, ...
|
|
169
|
-
context = { ...
|
|
265
|
+
const { scope: _previous, ...previous } = context;
|
|
266
|
+
context = { ...previous, seq: ++lastSeq, ...(scope ? { scope } : {}) };
|
|
170
267
|
sendContext();
|
|
171
268
|
};
|
|
172
269
|
// Share or stop sharing after every consent step; an open question or its absence follows in update().
|
|
@@ -220,8 +317,13 @@ export function installPanelSlot(): () => void {
|
|
|
220
317
|
}
|
|
221
318
|
for (const item of panel.providers) {
|
|
222
319
|
if (!item.consent) continue;
|
|
320
|
+
const knownRevision = revisions.get(item.id) ?? 0;
|
|
321
|
+
const readRevision = nextRevisions.get(item.id);
|
|
322
|
+
// A page swap can observe a newer answer while this request is in flight. Never rewind that answer.
|
|
323
|
+
if (readRevision !== undefined && readRevision < knownRevision) continue;
|
|
223
324
|
answers.set(item.id, next.get(item.id) ?? null);
|
|
224
|
-
|
|
325
|
+
// An unreadable answer still stops sharing, but must not forget the newest observed revision.
|
|
326
|
+
revisions.set(item.id, readRevision ?? knownRevision);
|
|
225
327
|
}
|
|
226
328
|
answersStale = false;
|
|
227
329
|
};
|
|
@@ -330,7 +432,7 @@ export function installPanelSlot(): () => void {
|
|
|
330
432
|
clearTimer();
|
|
331
433
|
slot.dataset.state = "ready";
|
|
332
434
|
if (status) status.textContent = "";
|
|
333
|
-
if (allowed()) {
|
|
435
|
+
if (allowed() && context) {
|
|
334
436
|
sendContext();
|
|
335
437
|
return;
|
|
336
438
|
}
|
|
@@ -427,6 +529,8 @@ export function installPanelSlot(): () => void {
|
|
|
427
529
|
if (!item || typeof request.allowed !== "boolean" || saving.has(item.id)) return;
|
|
428
530
|
saving.add(item.id);
|
|
429
531
|
errors.delete(item.id);
|
|
532
|
+
const observedRevision = observedRevisions.get(item.id);
|
|
533
|
+
const current = () => observedRevisions.get(item.id) === observedRevision;
|
|
430
534
|
const questionHadFocus = slot.querySelector("[data-cloud-panel-consent]")?.contains(document.activeElement);
|
|
431
535
|
// Enqueueing blocks sharing at once, so a withdrawal stops disclosure before its save even starts.
|
|
432
536
|
// keepalive: leaving the page does not cancel the save. A save the browser gives up on (timeout) is rolled back
|
|
@@ -441,11 +545,14 @@ export function installPanelSlot(): () => void {
|
|
|
441
545
|
});
|
|
442
546
|
void enqueueConsent(async () => {
|
|
443
547
|
try {
|
|
548
|
+
if (!current()) return;
|
|
444
549
|
let response = await put();
|
|
550
|
+
if (!current()) return;
|
|
445
551
|
// Changed meanwhile (another tab, or a read that failed): the person's choice now is the newest one, so read
|
|
446
552
|
// the current revision and save it once more. A second conflict shows the question again.
|
|
447
553
|
if (response.status === 409) {
|
|
448
554
|
await readAnswersNow();
|
|
555
|
+
if (!current()) return;
|
|
449
556
|
response = await put();
|
|
450
557
|
}
|
|
451
558
|
if (!response.ok) throw new Error("Panel consent was not saved");
|
|
@@ -463,13 +570,17 @@ export function installPanelSlot(): () => void {
|
|
|
463
570
|
saved.revision <= 0
|
|
464
571
|
)
|
|
465
572
|
throw new Error("Invalid panel consent answer");
|
|
466
|
-
|
|
467
|
-
|
|
573
|
+
if (current() && saved.revision >= (revisions.get(item.id) ?? 0)) {
|
|
574
|
+
answers.set(item.id, request.allowed);
|
|
575
|
+
revisions.set(item.id, saved.revision);
|
|
576
|
+
}
|
|
468
577
|
} catch {
|
|
578
|
+
// A superseded request cannot invalidate the newer page's answer either.
|
|
579
|
+
if (!current()) return;
|
|
469
580
|
// Not saved: nothing is shared until the person answers again; the read learns the current revision.
|
|
470
581
|
errors.add(item.id);
|
|
471
582
|
await readAnswersNow();
|
|
472
|
-
answers.set(item.id, null);
|
|
583
|
+
if (current()) answers.set(item.id, null);
|
|
473
584
|
} finally {
|
|
474
585
|
saving.delete(item.id);
|
|
475
586
|
}
|
|
@@ -509,9 +620,10 @@ export function installPanelSlot(): () => void {
|
|
|
509
620
|
}
|
|
510
621
|
main.focus();
|
|
511
622
|
};
|
|
512
|
-
const
|
|
623
|
+
const closeNow = () => {
|
|
513
624
|
if (!isOpen()) return;
|
|
514
625
|
canvas.removeAttribute(attribute());
|
|
626
|
+
if (!isPhone()) canvas.removeAttribute("data-panel-sheet");
|
|
515
627
|
disposeResize();
|
|
516
628
|
disposeResize = attachResize();
|
|
517
629
|
unload();
|
|
@@ -520,7 +632,8 @@ export function installPanelSlot(): () => void {
|
|
|
520
632
|
update();
|
|
521
633
|
returnFocus();
|
|
522
634
|
};
|
|
523
|
-
const
|
|
635
|
+
const close = () => transitionTo(closeNow);
|
|
636
|
+
const openNow = (id: string, nextSearch?: string) => {
|
|
524
637
|
const next = panel.providers.find((item) => item.id === id);
|
|
525
638
|
if (!next) return;
|
|
526
639
|
parkWorkspaceDetailDrawers(document);
|
|
@@ -542,6 +655,7 @@ export function installPanelSlot(): () => void {
|
|
|
542
655
|
if (frame) focusPanel();
|
|
543
656
|
else focusPending = true;
|
|
544
657
|
};
|
|
658
|
+
const open = (id: string, nextSearch?: string) => transitionTo(() => openNow(id, nextSearch));
|
|
545
659
|
const request = (request: PanelRequest) => {
|
|
546
660
|
if (request.action === "open") open(request.provider, request.search);
|
|
547
661
|
else if (request.action === "close" || isOpen()) close();
|
|
@@ -551,7 +665,17 @@ export function installPanelSlot(): () => void {
|
|
|
551
665
|
if (!frame || event.source !== frame.contentWindow || event.origin !== location.origin) return;
|
|
552
666
|
if (isPanelReadyMessage(event.data, provider.id)) markReady();
|
|
553
667
|
else if (isPanelCloseMessage(event.data)) close();
|
|
554
|
-
else if (
|
|
668
|
+
else if (isPanelNavigateMessage(event.data)) {
|
|
669
|
+
if (!URL.canParse(event.data.href)) return;
|
|
670
|
+
const url = new URL(event.data.href, location.origin);
|
|
671
|
+
if (url.origin !== location.origin || !showsProvider(false)) return;
|
|
672
|
+
const scope = new URL(provider.href, location.origin).pathname.replace(/\/$/, "");
|
|
673
|
+
const internal = url.pathname === scope || url.pathname.startsWith(`${scope}/`);
|
|
674
|
+
transitionTo(() => {
|
|
675
|
+
if (internal) frame?.contentWindow?.location.assign(url.href);
|
|
676
|
+
else window.location.assign(url.href);
|
|
677
|
+
});
|
|
678
|
+
} else if (isPanelSearchMessage(event.data)) {
|
|
555
679
|
const command = contextCommandsWithShortcuts().find((command) => command.id === "cloud.search" && command.shortcut);
|
|
556
680
|
if (command) void importOnDemand(() => import("./commands")).then((commands) => commands?.runContextAwareCommand(command));
|
|
557
681
|
}
|
|
@@ -562,8 +686,10 @@ export function installPanelSlot(): () => void {
|
|
|
562
686
|
if (answer && isOpen()) void saveConsent({ provider: provider.id, allowed: answer.dataset.cloudPanelConsentAnswer === "allow" });
|
|
563
687
|
else if (event.target.closest("[data-cloud-panel-close]")) close();
|
|
564
688
|
else if (event.target.closest("[data-cloud-panel-retry]") && isOpen()) {
|
|
565
|
-
|
|
566
|
-
|
|
689
|
+
transitionTo(() => {
|
|
690
|
+
load(true);
|
|
691
|
+
frame?.focus();
|
|
692
|
+
});
|
|
567
693
|
} else if (event.target.closest("[data-cloud-panel-grow]") && isPhone()) {
|
|
568
694
|
const full = slot.dataset.panelSize !== "full";
|
|
569
695
|
if (full) slot.dataset.panelSize = "full";
|
|
@@ -599,17 +725,18 @@ export function installPanelSlot(): () => void {
|
|
|
599
725
|
if (isOpen()) {
|
|
600
726
|
seedWidth();
|
|
601
727
|
load();
|
|
728
|
+
} else if (frame) {
|
|
729
|
+
// A viewport change must not hide an editor while its leave decision is blocked.
|
|
730
|
+
canvas.setAttribute(attribute(), "open");
|
|
731
|
+
transitionTo(closeNow);
|
|
602
732
|
} else unload();
|
|
603
733
|
disposeResize = attachResize();
|
|
604
734
|
update();
|
|
605
735
|
};
|
|
606
736
|
const closeSheet = () => {
|
|
607
737
|
const wasOpen = canvas.dataset.panelSheet === "open";
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
unload();
|
|
611
|
-
update();
|
|
612
|
-
}
|
|
738
|
+
if (wasOpen && isPhone()) close();
|
|
739
|
+
else canvas.removeAttribute("data-panel-sheet");
|
|
613
740
|
};
|
|
614
741
|
const pageShow = (event: PageTransitionEvent) => {
|
|
615
742
|
if (!event.persisted) return;
|
|
@@ -657,6 +784,12 @@ export function installPanelSlot(): () => void {
|
|
|
657
784
|
committedWidth = value;
|
|
658
785
|
writeCookie();
|
|
659
786
|
},
|
|
787
|
+
onReset: () => {
|
|
788
|
+
committedWidth = undefined;
|
|
789
|
+
canvas.style.removeProperty("--cloud-panel-width-set");
|
|
790
|
+
writeCookie();
|
|
791
|
+
seedWidth();
|
|
792
|
+
},
|
|
660
793
|
onCollapseIntent: (armed) => {
|
|
661
794
|
if (armed) slot.dataset.closing = "true";
|
|
662
795
|
else delete slot.dataset.closing;
|
|
@@ -680,7 +813,18 @@ export function installPanelSlot(): () => void {
|
|
|
680
813
|
})
|
|
681
814
|
: () => {};
|
|
682
815
|
let disposeResize = attachResize();
|
|
816
|
+
// A column island may already have published its place while it mounted; each page's context goes out once.
|
|
817
|
+
const swapped = () => {
|
|
818
|
+
collectContext();
|
|
819
|
+
if (pageUnsent) {
|
|
820
|
+
pageUnsent = false;
|
|
821
|
+
if (context && allowed()) sendContext();
|
|
822
|
+
else stopSharing();
|
|
823
|
+
} else if (provider.consent && !allowed()) stopSharing();
|
|
824
|
+
update();
|
|
825
|
+
};
|
|
683
826
|
collectContext();
|
|
827
|
+
window.addEventListener("ssr:nav-swapped", swapped);
|
|
684
828
|
window.addEventListener(PAGE_CONTEXT_CHANGED, collectContext);
|
|
685
829
|
window.addEventListener(COMMANDS_CHANGED, commandsChanged);
|
|
686
830
|
window.addEventListener("cloud.panel.consent", consentRequest);
|
|
@@ -693,6 +837,7 @@ export function installPanelSlot(): () => void {
|
|
|
693
837
|
// Restored from the back/forward cache before this lazy module arrived: the server's answers may be outdated.
|
|
694
838
|
if (document.documentElement.hasAttribute("data-cloud-page-restored")) void revalidate();
|
|
695
839
|
window.addEventListener("popstate", closeSheet);
|
|
840
|
+
window.addEventListener("ssr:nav-popstate", closeSheet);
|
|
696
841
|
window.navigation?.addEventListener("currententrychange", entryChange);
|
|
697
842
|
window.addEventListener("resize", seedWidth);
|
|
698
843
|
// An SSR iframe can have posted its one-shot ready before this lazy module
|
|
@@ -730,6 +875,8 @@ export function installPanelSlot(): () => void {
|
|
|
730
875
|
const dispose = () => {
|
|
731
876
|
if (disposed) return;
|
|
732
877
|
disposed = true;
|
|
878
|
+
disposeLeave();
|
|
879
|
+
leaveHost.remove();
|
|
733
880
|
disposeMenu();
|
|
734
881
|
requests.abort();
|
|
735
882
|
++loadVersion;
|
|
@@ -737,6 +884,7 @@ export function installPanelSlot(): () => void {
|
|
|
737
884
|
clearTimer();
|
|
738
885
|
disposeResize();
|
|
739
886
|
removeFallbackFocus();
|
|
887
|
+
window.removeEventListener("ssr:nav-swapped", swapped);
|
|
740
888
|
window.removeEventListener(PAGE_CONTEXT_CHANGED, collectContext);
|
|
741
889
|
window.removeEventListener(COMMANDS_CHANGED, commandsChanged);
|
|
742
890
|
window.removeEventListener("cloud.panel.consent", consentRequest);
|
|
@@ -747,6 +895,7 @@ export function installPanelSlot(): () => void {
|
|
|
747
895
|
column.removeEventListener("change", modeChange);
|
|
748
896
|
window.removeEventListener("pageshow", pageShow);
|
|
749
897
|
window.removeEventListener("popstate", closeSheet);
|
|
898
|
+
window.removeEventListener("ssr:nav-popstate", closeSheet);
|
|
750
899
|
window.navigation?.removeEventListener("currententrychange", entryChange);
|
|
751
900
|
window.removeEventListener("resize", seedWidth);
|
|
752
901
|
installed = undefined;
|
package/src/browser/panel.ts
CHANGED
|
@@ -4,6 +4,7 @@ export const resourceSearchMessages = i18n.define({
|
|
|
4
4
|
baseLocale: "en",
|
|
5
5
|
messages: {
|
|
6
6
|
en: {
|
|
7
|
+
app: "App",
|
|
7
8
|
actions: "Actions",
|
|
8
9
|
contextActions: "On this page",
|
|
9
10
|
commandPlaceholder: "Find an action…",
|
|
@@ -54,6 +55,7 @@ export const resourceSearchMessages = i18n.define({
|
|
|
54
55
|
loadingTags: "Loading filters…",
|
|
55
56
|
},
|
|
56
57
|
de: {
|
|
58
|
+
app: "App",
|
|
57
59
|
actions: "Aktionen",
|
|
58
60
|
contextActions: "Auf dieser Seite",
|
|
59
61
|
commandPlaceholder: "Aktion finden…",
|
|
@@ -2,8 +2,13 @@ import { z } from "zod";
|
|
|
2
2
|
import {
|
|
3
3
|
CAPABILITY_FRAMEWORK_ERROR_CODES,
|
|
4
4
|
CAPABILITY_MAX_CATALOG_BYTES,
|
|
5
|
+
CAPABILITY_MAX_REVIEW_CONTENT_BYTES,
|
|
6
|
+
CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
|
|
5
7
|
CapabilityActionReviewDataSchema,
|
|
6
8
|
CapabilityCatalogSchema,
|
|
9
|
+
type CapabilityReviewContentRef,
|
|
10
|
+
CapabilityReviewContentRefSchema,
|
|
11
|
+
CapabilityReviewContentSchema,
|
|
7
12
|
capabilityResultSchema,
|
|
8
13
|
} from "../contracts/capabilities";
|
|
9
14
|
import { capabilityMessages } from "../shared/capability-messages";
|
|
@@ -142,7 +147,29 @@ export const reviewCapabilityAction = async <TInput = unknown>(
|
|
|
142
147
|
body: JSON.stringify({ input: invocation.input }),
|
|
143
148
|
signal: invocation.signal,
|
|
144
149
|
});
|
|
145
|
-
return readCapabilityResponse(
|
|
150
|
+
return await readCapabilityResponse(
|
|
151
|
+
response,
|
|
152
|
+
CapabilityActionReviewDataSchema,
|
|
153
|
+
CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
|
|
154
|
+
invocation.signal,
|
|
155
|
+
);
|
|
156
|
+
} catch (cause) {
|
|
157
|
+
return unavailable(cause, undefined, clientLocale(options.headers));
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Load exactly one stored snapshot using the caller's normal Cloud authentication. A locator grants no access. */
|
|
162
|
+
export const readCapabilityReviewContent = async (
|
|
163
|
+
reference: CapabilityReviewContentRef,
|
|
164
|
+
options: CapabilityHttpOptions & { signal?: AbortSignal } = {},
|
|
165
|
+
) => {
|
|
166
|
+
try {
|
|
167
|
+
const ref = CapabilityReviewContentRefSchema.parse(reference);
|
|
168
|
+
const response = await (options.fetch ?? globalThis.fetch)(
|
|
169
|
+
requestUrl(options.baseUrl ?? "/api", `${ref.href.slice(4)}?digest=${encodeURIComponent(ref.digest)}`),
|
|
170
|
+
{ headers: options.headers, credentials: "same-origin", cache: "no-store", signal: options.signal },
|
|
171
|
+
);
|
|
172
|
+
return await readCapabilityResponse(response, CapabilityReviewContentSchema, CAPABILITY_MAX_REVIEW_CONTENT_BYTES, options.signal);
|
|
146
173
|
} catch (cause) {
|
|
147
174
|
return unavailable(cause, undefined, clientLocale(options.headers));
|
|
148
175
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
|
+
import { createCapabilityDeadline } from "../_internal/capability-timeout";
|
|
2
3
|
import type { CapabilityOrigin } from "../contracts/capabilities";
|
|
4
|
+
import { REQUEST_IDLE_TIMEOUT_SECONDS } from "../server/request-timeout";
|
|
3
5
|
import { pruneApprovalRequests } from "../services/approvals";
|
|
4
6
|
import { logger } from "../services/logging";
|
|
5
7
|
import { CAPABILITY_CLAIM_RETENTION_HOURS, pruneCapabilityIdempotencyClaims } from "./claims";
|
|
@@ -101,7 +103,17 @@ export type CapabilityExecution = {
|
|
|
101
103
|
/** Insert one execution record. Called only by the capability dispatcher and the surfaces that deny before it. */
|
|
102
104
|
export const recordCapabilityExecution = async (input: CapabilityExecutionInput): Promise<string> => {
|
|
103
105
|
const durationMs = Math.max(0, Math.round(input.completedAt.getTime() - input.startedAt.getTime()));
|
|
104
|
-
|
|
106
|
+
// An observational write has its own transport-headroom budget, even when
|
|
107
|
+
// the invocation already expired. Do not hold its caller on a dead database.
|
|
108
|
+
const deadline = createCapabilityDeadline(REQUEST_IDLE_TIMEOUT_SECONDS * 1000, new AbortController().signal);
|
|
109
|
+
try {
|
|
110
|
+
return await deadline.wait(() =>
|
|
111
|
+
sql.begin(async (db) => {
|
|
112
|
+
// A connection obtained after the deadline must never start the insert.
|
|
113
|
+
deadline.check();
|
|
114
|
+
await db`SELECT set_config('statement_timeout', ${`${deadline.remainingMs()}ms`}, true)`;
|
|
115
|
+
deadline.check();
|
|
116
|
+
const [row] = await db<{ id: string }[]>`
|
|
105
117
|
INSERT INTO capabilities.executions (
|
|
106
118
|
request_id, origin, app_id, capability, kind, destructive,
|
|
107
119
|
actor_kind, actor_id, user_id, access_subject_type, access_subject_id,
|
|
@@ -117,8 +129,15 @@ export const recordCapabilityExecution = async (input: CapabilityExecutionInput)
|
|
|
117
129
|
${input.idempotencyKey ?? null}, ${input.replayed ?? false}, ${input.startedAt}, ${input.completedAt}, ${durationMs}
|
|
118
130
|
)
|
|
119
131
|
RETURNING id::text AS id
|
|
120
|
-
|
|
121
|
-
|
|
132
|
+
`;
|
|
133
|
+
// Roll back a late result rather than commit an abandoned transaction.
|
|
134
|
+
deadline.check();
|
|
135
|
+
return row!.id;
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
} finally {
|
|
139
|
+
deadline.clear();
|
|
140
|
+
}
|
|
122
141
|
};
|
|
123
142
|
|
|
124
143
|
export type CapabilityExecutionFilter = {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { geoContract } from "../contracts/geo";
|
|
3
|
+
import { geoMapContract } from "../contracts/geo-map";
|
|
4
|
+
import { geoRouteContract } from "../contracts/geo-route";
|
|
5
|
+
import { type CapabilityCaller, invokeCapabilityWithDataSchema } from "./server";
|
|
6
|
+
|
|
7
|
+
/** Calls Core using the caller's interactive identity, invocation transport, or durable mandate. */
|
|
8
|
+
export const geoClient = {
|
|
9
|
+
mapStyle: (input: z.input<typeof geoMapContract.style.input>, caller: CapabilityCaller) =>
|
|
10
|
+
invokeCapabilityWithDataSchema(
|
|
11
|
+
{ kind: "query", appId: "core", capabilityId: geoMapContract.style.id, input },
|
|
12
|
+
geoMapContract.style.data,
|
|
13
|
+
caller,
|
|
14
|
+
),
|
|
15
|
+
calculateRoute: (input: z.input<typeof geoRouteContract.calculateRoute.input>, caller: CapabilityCaller) =>
|
|
16
|
+
invokeCapabilityWithDataSchema(
|
|
17
|
+
{ kind: "query", appId: "core", capabilityId: geoRouteContract.calculateRoute.id, input },
|
|
18
|
+
geoRouteContract.calculateRoute.data,
|
|
19
|
+
caller,
|
|
20
|
+
),
|
|
21
|
+
updateRoute: (input: z.input<typeof geoRouteContract.updateRoute.input>, caller: CapabilityCaller) =>
|
|
22
|
+
invokeCapabilityWithDataSchema(
|
|
23
|
+
{ kind: "query", appId: "core", capabilityId: geoRouteContract.updateRoute.id, input },
|
|
24
|
+
geoRouteContract.updateRoute.data,
|
|
25
|
+
caller,
|
|
26
|
+
),
|
|
27
|
+
autocomplete: (input: z.input<typeof geoContract.autocomplete.input>, caller: CapabilityCaller) =>
|
|
28
|
+
invokeCapabilityWithDataSchema(
|
|
29
|
+
{ kind: "query", appId: "core", capabilityId: geoContract.autocomplete.id, input },
|
|
30
|
+
geoContract.autocomplete.data,
|
|
31
|
+
caller,
|
|
32
|
+
),
|
|
33
|
+
geocode: (input: z.input<typeof geoContract.geocode.input>, caller: CapabilityCaller) =>
|
|
34
|
+
invokeCapabilityWithDataSchema(
|
|
35
|
+
{ kind: "query", appId: "core", capabilityId: geoContract.geocode.id, input },
|
|
36
|
+
geoContract.geocode.data,
|
|
37
|
+
caller,
|
|
38
|
+
),
|
|
39
|
+
reverse: (input: z.input<typeof geoContract.reverse.input>, caller: CapabilityCaller) =>
|
|
40
|
+
invokeCapabilityWithDataSchema(
|
|
41
|
+
{ kind: "query", appId: "core", capabilityId: geoContract.reverse.id, input },
|
|
42
|
+
geoContract.reverse.data,
|
|
43
|
+
caller,
|
|
44
|
+
),
|
|
45
|
+
searchPlaces: (input: z.input<typeof geoContract.searchPlaces.input>, caller: CapabilityCaller) =>
|
|
46
|
+
invokeCapabilityWithDataSchema(
|
|
47
|
+
{ kind: "query", appId: "core", capabilityId: geoContract.searchPlaces.id, input },
|
|
48
|
+
geoContract.searchPlaces.data,
|
|
49
|
+
caller,
|
|
50
|
+
),
|
|
51
|
+
availability: (caller: CapabilityCaller) =>
|
|
52
|
+
invokeCapabilityWithDataSchema(
|
|
53
|
+
{ kind: "query", appId: "core", capabilityId: geoContract.availability.id, input: {} },
|
|
54
|
+
geoContract.availability.data,
|
|
55
|
+
caller,
|
|
56
|
+
),
|
|
57
|
+
};
|
|
@@ -7,8 +7,9 @@ export const readCapabilityResponse = async <T>(
|
|
|
7
7
|
response: Response,
|
|
8
8
|
schema: z.ZodType<T>,
|
|
9
9
|
maxBytes = CAPABILITY_MAX_RESULT_BYTES,
|
|
10
|
+
signal?: AbortSignal,
|
|
10
11
|
): Promise<CapabilityResultState<T>> => {
|
|
11
|
-
const parsedBody = await readBoundedJson(response, maxBytes);
|
|
12
|
+
const parsedBody = await readBoundedJson(response, maxBytes, signal);
|
|
12
13
|
if (!parsedBody.ok && parsedBody.reason === "too_large") {
|
|
13
14
|
return {
|
|
14
15
|
ok: false,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { CapabilityReviewContentSchema } from "../contracts/capabilities";
|
|
2
|
+
|
|
3
|
+
/** JSONB cannot store NUL or lone UTF-16 surrogates. Nest the content's JSON as
|
|
4
|
+
* text in the existing snapshot, preserving every code unit and the public digest. */
|
|
5
|
+
export const serializeReviewSnapshot = (review: unknown): string => {
|
|
6
|
+
if (review && typeof review === "object" && "content" in review && review.content !== undefined)
|
|
7
|
+
return JSON.stringify({ ...review, content: JSON.stringify(CapabilityReviewContentSchema.parse(review.content)) });
|
|
8
|
+
return JSON.stringify(review);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const parseStoredReviewContent = (value: unknown) => {
|
|
12
|
+
try {
|
|
13
|
+
return CapabilityReviewContentSchema.safeParse(typeof value === "string" ? JSON.parse(value) : value);
|
|
14
|
+
} catch {
|
|
15
|
+
return CapabilityReviewContentSchema.safeParse(null);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const restoreReviewSnapshot = (review: unknown): unknown => {
|
|
20
|
+
if (!review || typeof review !== "object" || !("content" in review)) return review;
|
|
21
|
+
const content = parseStoredReviewContent(review.content);
|
|
22
|
+
// Invalid stored content must fail validation, never become a content-free review.
|
|
23
|
+
return { ...review, content: content.success ? content.data : null };
|
|
24
|
+
};
|
|
@@ -14,10 +14,11 @@ export const capabilityPreviewIssues = (
|
|
|
14
14
|
preview: CapabilityPreview | undefined,
|
|
15
15
|
links: readonly CapabilitySemanticLink[] | undefined,
|
|
16
16
|
sends = false,
|
|
17
|
+
completeContent = false,
|
|
17
18
|
): string[] => {
|
|
18
19
|
if (!preview) return [];
|
|
19
20
|
const issues: string[] = [];
|
|
20
|
-
const open = links?.some((link) => link.rel === "open");
|
|
21
|
+
const open = completeContent || links?.some((link) => link.rel === "open");
|
|
21
22
|
if ("truncated" in preview && preview.truncated && !open) issues.push("A truncated preview requires an open link");
|
|
22
23
|
if (preview.kind === "message") {
|
|
23
24
|
if ((Object.values(preview.more ?? {}).some((count) => count > 0) || (preview.moreAttachments ?? 0) > 0) && !open)
|
|
@@ -33,9 +34,22 @@ export const capabilityReviewFormIssues = (review: CapabilityActionCallReview):
|
|
|
33
34
|
for (const form of ["confirm", "decline"] as const)
|
|
34
35
|
if (Array.from(capabilityReviewSentence(review, form, { timeZone: "UTC" })).length > 40)
|
|
35
36
|
issues.push(`${form} exceeds 40 rendered characters`);
|
|
36
|
-
issues.push(...capabilityPreviewIssues(review.preview, review.links, review.ask === "sends"));
|
|
37
|
+
issues.push(...capabilityPreviewIssues(review.preview, review.links, review.ask === "sends", Boolean(review.content)));
|
|
38
|
+
if (
|
|
39
|
+
review.content &&
|
|
40
|
+
review.preview &&
|
|
41
|
+
review.content.kind !== review.preview.kind &&
|
|
42
|
+
!(review.content.kind === "message" && review.preview.kind === "page")
|
|
43
|
+
)
|
|
44
|
+
issues.push("Full content must have the same kind as its preview");
|
|
45
|
+
if (review.content?.kind === "message" && review.ask === "sends") {
|
|
46
|
+
if (!review.content.from) issues.push("Full sends content requires from");
|
|
47
|
+
if (!review.content.to.length && !review.content.cc?.length && !review.content.bcc?.length)
|
|
48
|
+
issues.push("Full sends content requires a recipient");
|
|
49
|
+
}
|
|
37
50
|
try {
|
|
38
|
-
|
|
51
|
+
const { content: _content, ...compact } = review;
|
|
52
|
+
if (new TextEncoder().encode(JSON.stringify(compact)).byteLength > CAPABILITY_MAX_REVIEW_BYTES) issues.push("Review exceeds 32 KiB");
|
|
39
53
|
} catch {
|
|
40
54
|
issues.push("Review is not JSON serializable");
|
|
41
55
|
}
|