@k2b/cloud 0.33.0 → 0.35.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 +3 -3
- package/src/_internal/app-presentation.ts +40 -11
- package/src/_internal/define-app.ts +1 -0
- package/src/_internal/registry.ts +10 -1
- package/src/_internal/widget-declarations.ts +16 -3
- package/src/_internal/widget-settings.ts +93 -0
- package/src/ai/capabilities.ts +130 -16
- package/src/ai/firecrawl-tools.ts +3 -30
- package/src/ai/resource-refs.ts +35 -3
- package/src/ai/system-prompt.ts +3 -2
- package/src/api/index.ts +2 -0
- package/src/api/mcp.ts +48 -17
- package/src/api/pwa.ts +7 -2
- package/src/api/resource-links.ts +43 -0
- package/src/api/widgets.ts +163 -82
- package/src/browser/CloudResourceSearch.tsx +6 -6
- package/src/browser/command-shortcuts.ts +2 -11
- package/src/browser/resource-picker.tsx +1 -0
- package/src/browser/resource-search.ts +7 -2
- package/src/browser/widgets.ts +23 -5
- package/src/capabilities/testing.ts +13 -1
- package/src/cli/capabilities.ts +61 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/resource-links.ts +13 -0
- package/src/contracts/app.ts +7 -1
- package/src/contracts/capabilities.ts +25 -5
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/resource-href.ts +7 -0
- package/src/contracts/widgets.ts +80 -6
- package/src/desktop/index.ts +3 -2
- package/src/desktop/solid.tsx +7 -5
- package/src/server/widget-request.ts +34 -5
- package/src/services/app-approval.ts +4 -2
- package/src/services/approvals/index.ts +40 -11
- package/src/services/approvals/list.ts +1 -0
- package/src/services/approvals/migrate.ts +10 -0
- package/src/services/network-security.ts +33 -9
- package/src/services/pdf/markdown.ts +11 -7
- package/src/services/pwa-devices.ts +68 -15
- package/src/shared/app-presentation.ts +49 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/markdown/callout-sanitizer.ts +37 -0
- package/src/shared/markdown/index.ts +8 -2
- package/src/shared/network-address.ts +46 -45
- package/src/ssr/Layout.tsx +3 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +320 -183
- package/src/ssr/LayoutHelpTrigger.tsx +1 -3
- package/src/ssr/LayoutPreferences.tsx +15 -5
- package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
- package/src/ssr/MobileProfileActions.tsx +3 -8
- package/src/ssr/ProfilePreferences.tsx +8 -10
- package/src/ssr/ShellRuntime.island.tsx +1 -5
- package/src/ssr/ThemeCycleButton.tsx +46 -0
- package/src/ssr/app-appearance.ts +6 -1
- package/src/ssr/layout-help-url.ts +39 -0
- package/src/ssr/platform-messages.ts +0 -8
- package/src/ssr/preference-controller.ts +53 -19
- package/src/ssr/profile-actions.ts +12 -16
- package/src/ssr/profile-preferences-messages.ts +4 -2
- package/src/ssr/rail-navigation.ts +3 -0
- package/src/ssr/shell-data.tsx +2 -2
- package/src/styles/effects.css +13 -3
- package/src/styles/global.css +47 -12
- package/src/styles/input.css +0 -8
- package/src/styles/tokens.css +0 -2
- package/src/styles/utilities-navigation.css +10 -13
- package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
package/src/browser/widgets.ts
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
|
-
import type { DashboardWidgetSize, WidgetStreamLine } from "../contracts/widgets";
|
|
1
|
+
import type { DashboardWidgetSize, WidgetSettings, WidgetStreamLine } from "../contracts/widgets";
|
|
2
2
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
3
3
|
import { NDJSON_CONTENT_TYPE, readNdjsonLines } from "./ndjson";
|
|
4
4
|
|
|
5
|
-
export type {
|
|
5
|
+
export type {
|
|
6
|
+
DashboardWidgetSize,
|
|
7
|
+
WidgetResponse,
|
|
8
|
+
WidgetSettingField,
|
|
9
|
+
WidgetSettings,
|
|
10
|
+
WidgetSettingsDeclaration,
|
|
11
|
+
WidgetSettingValue,
|
|
12
|
+
WidgetStreamLine,
|
|
13
|
+
WidgetStreamStatus,
|
|
14
|
+
} from "../contracts/widgets";
|
|
6
15
|
|
|
7
16
|
/**
|
|
8
17
|
* Asks Core for dashboard widgets and hands over each one as soon as it answers: `start` names the widgets, one
|
|
9
18
|
* `widget` line follows per widget in the order they finish, then `done`. `keys` (`<appId>/<widgetId>`) limits the
|
|
10
19
|
* request, for example to retry one widget; without it Core asks every declared widget. `sizes` names the size to ask
|
|
11
20
|
* a widget for; a widget without one, or with a size it does not offer, answers in its default size. Aborting `signal`
|
|
12
|
-
* stops every widget Core still waits for.
|
|
21
|
+
* stops every widget Core still waits for. When `requests` is given, POST sends one invocation per entry with its
|
|
22
|
+
* settings, ignoring `keys` and `sizes`; start and widget lines identify entries by id. Rejects when the request fails
|
|
23
|
+
* or the stream breaks off.
|
|
13
24
|
*/
|
|
14
25
|
export const streamWidgets = async (options: {
|
|
26
|
+
requests?: readonly { id: string; key: string; size?: DashboardWidgetSize; settings?: WidgetSettings }[];
|
|
15
27
|
keys?: readonly string[];
|
|
16
28
|
sizes?: Readonly<Record<string, DashboardWidgetSize>>;
|
|
17
29
|
signal: AbortSignal;
|
|
@@ -19,11 +31,17 @@ export const streamWidgets = async (options: {
|
|
|
19
31
|
onLine: (line: WidgetStreamLine) => void;
|
|
20
32
|
}): Promise<void> => {
|
|
21
33
|
const query = new URLSearchParams();
|
|
22
|
-
for (const key of options.
|
|
34
|
+
for (const key of options.requests ? [] : (options.keys ?? []))
|
|
35
|
+
query.append("widget", options.sizes?.[key] ? `${key}@${options.sizes[key]}` : key);
|
|
23
36
|
const search = query.toString();
|
|
24
37
|
const response = await fetch(`/api/widgets/v1${search ? `?${search}` : ""}`, {
|
|
38
|
+
...(options.requests ? { method: "POST", body: JSON.stringify({ widgets: options.requests }) } : {}),
|
|
25
39
|
signal: options.signal,
|
|
26
|
-
headers: {
|
|
40
|
+
headers: {
|
|
41
|
+
accept: NDJSON_CONTENT_TYPE,
|
|
42
|
+
[LOCALE_HEADER]: options.locale,
|
|
43
|
+
...(options.requests ? { "content-type": "application/json" } : {}),
|
|
44
|
+
},
|
|
27
45
|
});
|
|
28
46
|
if (!response.ok) throw new Error(`Widgets failed with ${response.status}`);
|
|
29
47
|
await readNdjsonLines(response, options.onLine, "Widgets");
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { capabilityManifestEvolutionIssues, compileCapabilities } from "../_internal/capabilities";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
type CapabilityDefinitions,
|
|
4
|
+
type CapabilityManifest,
|
|
5
|
+
type CapabilityResult,
|
|
6
|
+
CapabilitySemanticLinkSchema,
|
|
7
|
+
} from "../contracts/capabilities";
|
|
3
8
|
|
|
4
9
|
/** Compile and validate the public manifest an app would register at startup. */
|
|
5
10
|
export const compileCapabilityManifest = (appId: string, definitions: CapabilityDefinitions): CapabilityManifest =>
|
|
@@ -10,3 +15,10 @@ export const assertCapabilityManifestEvolution = (previous: CapabilityManifest,
|
|
|
10
15
|
const issues = capabilityManifestEvolutionIssues(previous, next);
|
|
11
16
|
if (issues.length > 0) throw new Error(`Breaking Capability manifest evolution:\n- ${issues.join("\n- ")}`);
|
|
12
17
|
};
|
|
18
|
+
|
|
19
|
+
/** Assert that a reader result exposes a valid destination for its resource permalink. */
|
|
20
|
+
export const assertResourceOpenLink = (result: Pick<CapabilityResult<unknown>, "links">) => {
|
|
21
|
+
const link = result.links?.find((link) => link.rel === "open");
|
|
22
|
+
if (!link) throw new Error("Resource reader must return a top-level open link");
|
|
23
|
+
return CapabilitySemanticLinkSchema.parse(link);
|
|
24
|
+
};
|
package/src/cli/capabilities.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
CAPABILITY_MAX_RESULT_BYTES,
|
|
7
7
|
CapabilityCatalogSchema,
|
|
8
8
|
CapabilityErrorSchema,
|
|
9
|
+
type CapabilityManifest,
|
|
10
|
+
CapabilitySemanticLinkSchema,
|
|
9
11
|
CapabilityStreamSchema,
|
|
10
12
|
CloudResourceRefSchema,
|
|
11
13
|
capabilityResultSchema,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
} from "../contracts/capabilities";
|
|
15
17
|
import { saveCapabilityStream } from "./capability-streams";
|
|
16
18
|
import { arg, type CliInputFlagValue, type CloudCliContext, command, defineCliCommands, flag, readCliInput } from "./index";
|
|
19
|
+
import { cloudResourceUrl } from "./resource-links";
|
|
17
20
|
|
|
18
21
|
const parseInput = async (input: CliInputFlagValue): Promise<unknown> => {
|
|
19
22
|
const raw = await readCliInput(input, {
|
|
@@ -65,6 +68,26 @@ const readCapabilityJson = async (response: Response, maxBytes: number): Promise
|
|
|
65
68
|
return parsed.data;
|
|
66
69
|
};
|
|
67
70
|
|
|
71
|
+
const findCatalogApps = async (ctx: CloudCliContext, appIds: ReadonlySet<string>) => {
|
|
72
|
+
const wantedApps = new Set(appIds);
|
|
73
|
+
const apps: z.infer<typeof CapabilityCatalogSchema>["apps"] = [];
|
|
74
|
+
let cursor: string | undefined;
|
|
75
|
+
const seenCursors = new Set<string>();
|
|
76
|
+
while (wantedApps.size) {
|
|
77
|
+
const query = new URLSearchParams({ limit: "25" });
|
|
78
|
+
if (cursor) query.set("cursor", cursor);
|
|
79
|
+
const page = CapabilityCatalogSchema.parse(
|
|
80
|
+
await readCapabilityJson(await ctx.fetch(`/api/capabilities/v1/catalog?${query}`), CAPABILITY_MAX_CATALOG_BYTES),
|
|
81
|
+
);
|
|
82
|
+
for (const app of page.apps) if (wantedApps.delete(app.appId)) apps.push(app);
|
|
83
|
+
if (!wantedApps.size || !page.page.hasMore) break;
|
|
84
|
+
cursor = page.page.nextCursor;
|
|
85
|
+
if (seenCursors.has(cursor)) throw new Error("Capability catalog returned a repeated cursor.");
|
|
86
|
+
seenCursors.add(cursor);
|
|
87
|
+
}
|
|
88
|
+
return apps;
|
|
89
|
+
};
|
|
90
|
+
|
|
68
91
|
const invoke = async (config: {
|
|
69
92
|
ctx: CloudCliContext;
|
|
70
93
|
kind: "queries" | "actions";
|
|
@@ -72,6 +95,7 @@ const invoke = async (config: {
|
|
|
72
95
|
capabilityId: string;
|
|
73
96
|
input?: CliInputFlagValue;
|
|
74
97
|
inputValue?: unknown;
|
|
98
|
+
manifest?: CapabilityManifest;
|
|
75
99
|
idempotencyKey?: string;
|
|
76
100
|
}) => {
|
|
77
101
|
const headers = new Headers({ "content-type": "application/json" });
|
|
@@ -113,7 +137,40 @@ const invoke = async (config: {
|
|
|
113
137
|
}
|
|
114
138
|
throw error;
|
|
115
139
|
}
|
|
116
|
-
|
|
140
|
+
const result = capabilityResultSchema(z.unknown()).parse(body);
|
|
141
|
+
const manifests = config.manifest ? [config.manifest] : [];
|
|
142
|
+
const wantedApps = new Set((result.refs ?? []).map(cloudResourceRefAppId));
|
|
143
|
+
for (const manifest of manifests) wantedApps.delete(manifest.appId);
|
|
144
|
+
if (wantedApps.size) {
|
|
145
|
+
try {
|
|
146
|
+
const apps = await findCatalogApps(config.ctx, wantedApps);
|
|
147
|
+
manifests.push(...apps.map((app) => app.manifest));
|
|
148
|
+
} catch {
|
|
149
|
+
config.ctx.error("The capability catalog is unavailable; some resource permalinks could not be added.");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const absoluteLinks = (value: unknown): unknown => {
|
|
153
|
+
if (Array.isArray(value)) return value.map(absoluteLinks);
|
|
154
|
+
if (!value || typeof value !== "object") return value;
|
|
155
|
+
const link = CapabilitySemanticLinkSchema.safeParse(value);
|
|
156
|
+
if (link.success) return { ...link.data, href: new URL(link.data.href, config.ctx.options.server).href };
|
|
157
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, absoluteLinks(nested)]));
|
|
158
|
+
};
|
|
159
|
+
printGenericResult(
|
|
160
|
+
config.ctx,
|
|
161
|
+
absoluteLinks({
|
|
162
|
+
...result,
|
|
163
|
+
...(result.refs
|
|
164
|
+
? {
|
|
165
|
+
refs: result.refs.map((ref) =>
|
|
166
|
+
ref.id.isWellFormed() && manifests.some((manifest) => resolveCapabilityResourceReader(manifest, ref))
|
|
167
|
+
? { ...ref, permalink: cloudResourceUrl(config.ctx, ref) }
|
|
168
|
+
: ref,
|
|
169
|
+
),
|
|
170
|
+
}
|
|
171
|
+
: {}),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
117
174
|
};
|
|
118
175
|
|
|
119
176
|
export default defineCliCommands({
|
|
@@ -230,25 +287,12 @@ export default defineCliCommands({
|
|
|
230
287
|
run: async ({ ctx, args }) => {
|
|
231
288
|
const ref = CloudResourceRefSchema.parse({ type: args.type, id: args.id });
|
|
232
289
|
const appId = cloudResourceRefAppId(ref);
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
let app: z.infer<typeof CapabilityCatalogSchema>["apps"][number] | undefined;
|
|
236
|
-
do {
|
|
237
|
-
const query = new URLSearchParams({ limit: "25" });
|
|
238
|
-
if (cursor) query.set("cursor", cursor);
|
|
239
|
-
const page = CapabilityCatalogSchema.parse(
|
|
240
|
-
await readCapabilityJson(await ctx.fetch(`/api/capabilities/v1/catalog?${query}`), CAPABILITY_MAX_CATALOG_BYTES),
|
|
241
|
-
);
|
|
242
|
-
app = page.apps.find((candidate) => candidate.appId === appId);
|
|
243
|
-
const nextCursor = page.page.hasMore ? page.page.nextCursor : undefined;
|
|
244
|
-
if (nextCursor && seenCursors.has(nextCursor)) throw new Error("Capability catalog returned a repeated cursor.");
|
|
245
|
-
if (nextCursor) seenCursors.add(nextCursor);
|
|
246
|
-
cursor = nextCursor;
|
|
247
|
-
} while (!app && cursor);
|
|
290
|
+
const apps = await findCatalogApps(ctx, new Set([appId]));
|
|
291
|
+
const app = apps.find((candidate) => candidate.appId === appId);
|
|
248
292
|
if (!app) throw new Error(`Capability app ${appId} is unavailable.`);
|
|
249
293
|
const reader = resolveCapabilityResourceReader(app.manifest, ref);
|
|
250
294
|
if (!reader) throw new Error(`Cloud resource type ${ref.type} is unknown or has no reader.`);
|
|
251
|
-
await invoke({ ctx, kind: "queries", appId, capabilityId: reader.localId, inputValue: { id: ref.id } });
|
|
295
|
+
await invoke({ ctx, kind: "queries", appId, capabilityId: reader.localId, inputValue: { id: ref.id }, manifest: app.manifest });
|
|
252
296
|
},
|
|
253
297
|
}),
|
|
254
298
|
command("action", {
|
package/src/cli/index.ts
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CloudResourceRef } from "../contracts/capabilities";
|
|
2
|
+
import { cloudResourceHref } from "../contracts/resource-href";
|
|
3
|
+
import type { CloudCliContext } from "./index";
|
|
4
|
+
|
|
5
|
+
export const cloudResourceUrl = (ctx: Pick<CloudCliContext, "options">, ref: CloudResourceRef): string =>
|
|
6
|
+
new URL(cloudResourceHref(ref), ctx.options.server).href;
|
|
7
|
+
|
|
8
|
+
/** The resource id must be the id its Type's reader accepts. */
|
|
9
|
+
export const withCloudResourcePermalink = <T extends { id: string }>(
|
|
10
|
+
ctx: Pick<CloudCliContext, "options">,
|
|
11
|
+
type: CloudResourceRef["type"],
|
|
12
|
+
resource: T,
|
|
13
|
+
) => ({ ...resource, permalink: cloudResourceUrl(ctx, { type, id: resource.id }) });
|
package/src/contracts/app.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Sync } from "@k2b/sync";
|
|
|
2
2
|
import type { PlatformPermission } from "./outgoing-mail";
|
|
3
3
|
import type { AppRegistryHelpSummary } from "./registry";
|
|
4
4
|
import type { Role } from "./shared";
|
|
5
|
-
import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
|
|
5
|
+
import type { DashboardWidgetPresentation, DashboardWidgetSize, WidgetSettingsDeclaration } from "./widgets";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* One link entry contributed by an app to the global legal/info footer
|
|
@@ -73,9 +73,12 @@ export type AppPresentationTranslation = {
|
|
|
73
73
|
widgets?: Readonly<Record<string, AppWidgetTranslation>>;
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
/** Translated widget presentation, keyed by stable widget, setting, and option identifiers. */
|
|
76
77
|
export type AppWidgetTranslation = {
|
|
77
78
|
title?: string;
|
|
78
79
|
description?: string;
|
|
80
|
+
/** Setting presentation keyed by setting name, with option labels keyed by option value. */
|
|
81
|
+
settings?: Readonly<Record<string, { label?: string; placeholder?: string; options?: Readonly<Record<string, string>> }>>;
|
|
79
82
|
};
|
|
80
83
|
|
|
81
84
|
export type AppPresentationCatalog = {
|
|
@@ -163,6 +166,7 @@ export type AppMeta = {
|
|
|
163
166
|
*/
|
|
164
167
|
export type AppNavBadge = { count: number };
|
|
165
168
|
|
|
169
|
+
/** A widget endpoint and its board presentation; the handler owns resource authorization. */
|
|
166
170
|
export type WidgetEndpoint = {
|
|
167
171
|
/** Unique-within-the-app id, e.g. "open-requests". */
|
|
168
172
|
id: string;
|
|
@@ -179,6 +183,8 @@ export type WidgetEndpoint = {
|
|
|
179
183
|
sizes?: readonly DashboardWidgetSize[];
|
|
180
184
|
/** Size the widget starts in, one of `sizes`. Defaults to the largest offered size. */
|
|
181
185
|
defaultSize?: DashboardWidgetSize;
|
|
186
|
+
/** Optional per-entry settings; handlers must work without them and authorize selected resources. */
|
|
187
|
+
settings?: WidgetSettingsDeclaration;
|
|
182
188
|
/**
|
|
183
189
|
* Put the widget on the default board of everyone who may see it and list it under "Suggested for you" in the
|
|
184
190
|
* gallery. Suggest only widgets that are useful without any setup.
|
|
@@ -15,6 +15,8 @@ export const CAPABILITY_PROTOCOL_VERSION = 2 as const;
|
|
|
15
15
|
export const CAPABILITY_MAX_REQUEST_BYTES = 256 * 1024;
|
|
16
16
|
export const CAPABILITY_MAX_RESULT_BYTES = 256 * 1024;
|
|
17
17
|
export const CAPABILITY_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
export const CAPABILITY_QUALIFIED_ID_MAX_LENGTH = 180;
|
|
19
|
+
export const CLOUD_RESOURCE_ID_MAX_LENGTH = 512;
|
|
18
20
|
|
|
19
21
|
export const CAPABILITY_FRAMEWORK_ERROR_CODES = {
|
|
20
22
|
appUnavailable: "APP_UNAVAILABLE",
|
|
@@ -75,18 +77,20 @@ export const CapabilityLocalIdSchema = z
|
|
|
75
77
|
export const CapabilityQualifiedIdSchema = z
|
|
76
78
|
.string()
|
|
77
79
|
.min(3)
|
|
78
|
-
.max(
|
|
80
|
+
.max(CAPABILITY_QUALIFIED_ID_MAX_LENGTH)
|
|
79
81
|
.regex(CAPABILITY_QUALIFIED_ID_PATTERN, "Expected a namespaced capability id such as contacts.contact");
|
|
80
82
|
|
|
81
83
|
export const CloudResourceRefSchema = z
|
|
82
84
|
.object({
|
|
83
85
|
type: CapabilityQualifiedIdSchema.describe("Namespaced resource type declared by the owning app."),
|
|
84
|
-
id: z.string().min(1).max(
|
|
86
|
+
id: z.string().min(1).max(CLOUD_RESOURCE_ID_MAX_LENGTH).describe("Stable app-owned resource identifier."),
|
|
85
87
|
})
|
|
86
88
|
.strict();
|
|
87
89
|
|
|
88
90
|
export type CloudResourceRef = z.infer<typeof CloudResourceRefSchema>;
|
|
89
91
|
|
|
92
|
+
export { cloudResourceHref } from "./resource-href";
|
|
93
|
+
|
|
90
94
|
export const CloudResourceReferenceSchema = CloudResourceRefSchema.extend({
|
|
91
95
|
title: z.string().min(1).max(500).optional().describe("Optional current user-facing resource label."),
|
|
92
96
|
preview: z.string().max(2000).optional().describe("Optional short plain-text context for the resource."),
|
|
@@ -95,6 +99,13 @@ export const CloudResourceReferenceSchema = CloudResourceRefSchema.extend({
|
|
|
95
99
|
|
|
96
100
|
export type CloudResourceReference = z.infer<typeof CloudResourceReferenceSchema>;
|
|
97
101
|
|
|
102
|
+
// Consumer permalinks may encode all 512 UTF-16 units of an id (up to 4,608 characters).
|
|
103
|
+
export const CloudResourceHrefSchema = z.string().min(1).max(8192).regex(CLOUD_PATH_PATTERN);
|
|
104
|
+
|
|
105
|
+
export const LinkedCloudResourceReferenceSchema = CloudResourceReferenceSchema.extend({
|
|
106
|
+
href: z.union([CloudResourceHrefSchema, z.url({ protocol: /^https?$/ }).max(8192)]).optional(),
|
|
107
|
+
}).strict();
|
|
108
|
+
|
|
98
109
|
export const CapabilitySemanticLinkSchema = z
|
|
99
110
|
.object({
|
|
100
111
|
rel: z.enum(["open", "edit", "status", "preview", "download"]).describe("Semantic relationship; clients decide how to present it."),
|
|
@@ -219,7 +230,10 @@ export const capabilityResultSchema = <T extends z.ZodType>(
|
|
|
219
230
|
data,
|
|
220
231
|
stream: CapabilityStreamSchema.optional(),
|
|
221
232
|
summary: z.string().trim().min(1).max(500).optional(),
|
|
222
|
-
refs: z
|
|
233
|
+
refs: z
|
|
234
|
+
.array(options?.consumer ? LinkedCloudResourceReferenceSchema : CloudResourceReferenceSchema)
|
|
235
|
+
.max(100)
|
|
236
|
+
.optional(),
|
|
223
237
|
page: CapabilityPageSchema.optional(),
|
|
224
238
|
links: z.array(CapabilitySemanticLinkSchema).max(20).optional(),
|
|
225
239
|
presentation: options?.consumer
|
|
@@ -241,8 +255,14 @@ export const capabilityResultSchema = <T extends z.ZodType>(
|
|
|
241
255
|
}
|
|
242
256
|
}) as unknown as z.ZodType<CapabilityResult<z.output<T>>>;
|
|
243
257
|
|
|
244
|
-
export const capabilityResultJsonSchema = (
|
|
245
|
-
|
|
258
|
+
export const capabilityResultJsonSchema = (
|
|
259
|
+
dataSchema: Record<string, unknown>,
|
|
260
|
+
options?: { consumer?: boolean },
|
|
261
|
+
): Record<string, unknown> =>
|
|
262
|
+
z.toJSONSchema(capabilityResultSchema(z.fromJSONSchema(structuredClone(dataSchema)), options), { io: "output" }) as Record<
|
|
263
|
+
string,
|
|
264
|
+
unknown
|
|
265
|
+
>;
|
|
246
266
|
|
|
247
267
|
export const CapabilityErrorSchema = z
|
|
248
268
|
.object({
|
|
@@ -2,7 +2,7 @@ import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPre
|
|
|
2
2
|
import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
|
|
3
3
|
import type { PlatformPermission } from "./outgoing-mail";
|
|
4
4
|
import type { Role } from "./shared";
|
|
5
|
-
import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
|
|
5
|
+
import type { DashboardWidgetPresentation, DashboardWidgetSize, WidgetSettingsDeclaration } from "./widgets";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* App-registry entry type. Populated internally by `defineApp()` + the
|
|
@@ -54,6 +54,8 @@ export type AppRegistryLegalLink = {
|
|
|
54
54
|
|
|
55
55
|
/** A widget declaration as the registry carries it; see `WidgetEndpoint`. Older apps send only `id` and `path`. */
|
|
56
56
|
export type AppRegistryWidget = {
|
|
57
|
+
/** Per-entry setting declaration, validated again when listed. */
|
|
58
|
+
settings?: WidgetSettingsDeclaration;
|
|
57
59
|
id: string;
|
|
58
60
|
/** Absolute path on the app's HTTP service, e.g. "/api/quotes/widget/random". */
|
|
59
61
|
path: string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CloudResourceRef } from "./capabilities";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The permanent Cloud path of a resource. The fixed `~` keeps the ids `.` and `..` from being read as dot segments.
|
|
5
|
+
* This module stays free of runtime imports: the `cld` host loads it at startup.
|
|
6
|
+
*/
|
|
7
|
+
export const cloudResourceHref = (ref: CloudResourceRef): string => `/open/${ref.type}/~${encodeURIComponent(ref.id)}`;
|
package/src/contracts/widgets.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { CAPABILITY_QUALIFIED_ID_MAX_LENGTH, CLOUD_RESOURCE_ID_MAX_LENGTH, type CloudResourceRef } from "./capabilities";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Widget JSON contract — what an app's widget endpoint must return when the
|
|
@@ -64,6 +65,59 @@ export type DashboardWidgetSize = (typeof DASHBOARD_WIDGET_SIZES)[number];
|
|
|
64
65
|
export const WIDGET_TITLE_MAX_LENGTH = 80;
|
|
65
66
|
/** Longest declared widget description, in characters: one sentence in the widget gallery. */
|
|
66
67
|
export const WIDGET_DESCRIPTION_MAX_LENGTH = 200;
|
|
68
|
+
/**
|
|
69
|
+
* Core signs `widget.read:<widgetId>`; the invocation token's OperationSchema limits operations to 200 characters
|
|
70
|
+
* matching /^[a-z][a-z0-9:._-]*$/ (services/identity/invocation-token.ts). Longer IDs or other characters cannot be invoked.
|
|
71
|
+
*/
|
|
72
|
+
export const WIDGET_ID_MAX_LENGTH = 188;
|
|
73
|
+
/** `<appId>/<widgetId>`; the invocation token's audience rule limits app IDs to 80 characters. */
|
|
74
|
+
export const WIDGET_KEY_MAX_LENGTH = 80 + 1 + WIDGET_ID_MAX_LENGTH;
|
|
75
|
+
|
|
76
|
+
/** Maximum settings a widget can declare. */
|
|
77
|
+
export const WIDGET_SETTINGS_MAX_FIELDS = 4;
|
|
78
|
+
/** Maximum setting name length, matching /^[a-z][a-zA-Z0-9]{0,31}$/. */
|
|
79
|
+
export const WIDGET_SETTING_NAME_MAX_LENGTH = 32;
|
|
80
|
+
/** Maximum options in a choice setting; at least one is required. */
|
|
81
|
+
export const WIDGET_SETTING_MAX_OPTIONS = 6;
|
|
82
|
+
/** Maximum text setting length, also used when no maxLength is declared. */
|
|
83
|
+
export const WIDGET_SETTING_TEXT_MAX_LENGTH = 200;
|
|
84
|
+
/** Maximum board entries in one widget stream request. */
|
|
85
|
+
export const WIDGET_REQUEST_MAX_ENTRIES = 100;
|
|
86
|
+
/** Maximum board entry identifier length, in characters. */
|
|
87
|
+
export const WIDGET_REQUEST_MAX_ID_LENGTH = 120;
|
|
88
|
+
const jsonCodeUnitMaxBytes = "\\u001f".length;
|
|
89
|
+
const resourceRefMaxBytes =
|
|
90
|
+
'{"type":"","id":""}'.length + CAPABILITY_QUALIFIED_ID_MAX_LENGTH + CLOUD_RESOURCE_ID_MAX_LENGTH * jsonCodeUnitMaxBytes;
|
|
91
|
+
const settingMemberMaxBytes = '"":'.length + WIDGET_SETTING_NAME_MAX_LENGTH + resourceRefMaxBytes;
|
|
92
|
+
const settingsMaxBytes = "{}".length + WIDGET_SETTINGS_MAX_FIELDS * settingMemberMaxBytes + (WIDGET_SETTINGS_MAX_FIELDS - 1) * ",".length;
|
|
93
|
+
const sizeNameMaxBytes = Math.max(...DASHBOARD_WIDGET_SIZES.map((size) => size.length));
|
|
94
|
+
const requestEntryMaxBytes =
|
|
95
|
+
'{"id":"","key":"","size":"","settings":}'.length +
|
|
96
|
+
WIDGET_REQUEST_MAX_ID_LENGTH * jsonCodeUnitMaxBytes +
|
|
97
|
+
WIDGET_KEY_MAX_LENGTH +
|
|
98
|
+
sizeNameMaxBytes +
|
|
99
|
+
settingsMaxBytes;
|
|
100
|
+
/**
|
|
101
|
+
* Largest compact JSON POST body in UTF-8: 100 entries with maximum IDs, keys, size names and four settings.
|
|
102
|
+
* Free-form entry and resource IDs need up to six bytes per UTF-16 code unit (e.g. `\u001f`); keys, size names,
|
|
103
|
+
* setting names and resource types are ASCII. A resource reference is larger than 200 text characters at six bytes
|
|
104
|
+
* each. Include every object's punctuation and the commas between entries and settings.
|
|
105
|
+
*/
|
|
106
|
+
export const WIDGET_REQUEST_MAX_BYTES =
|
|
107
|
+
'{"widgets":['.length + WIDGET_REQUEST_MAX_ENTRIES * requestEntryMaxBytes + (WIDGET_REQUEST_MAX_ENTRIES - 1) * ",".length + "]}".length;
|
|
108
|
+
|
|
109
|
+
/** A widget's editable setting; resource values carry identity, never authorization. */
|
|
110
|
+
export type WidgetSettingField =
|
|
111
|
+
| { kind: "choice"; label: string; options: readonly { label: string; value: string }[]; default?: string }
|
|
112
|
+
| { kind: "resource"; label: string; type: string }
|
|
113
|
+
| { kind: "toggle"; label: string; default?: boolean }
|
|
114
|
+
| { kind: "text"; label: string; placeholder?: string; maxLength?: number };
|
|
115
|
+
/** Settings keyed by short camelCase names, shared by discovery and request validation. */
|
|
116
|
+
export type WidgetSettingsDeclaration = Readonly<Record<string, WidgetSettingField>>;
|
|
117
|
+
/** A validated setting value; a handler must still authorize every resource reference. */
|
|
118
|
+
export type WidgetSettingValue = string | boolean | CloudResourceRef;
|
|
119
|
+
/** Validated widget settings; handlers must also work when values are missing. */
|
|
120
|
+
export type WidgetSettings = Readonly<Record<string, WidgetSettingValue>>;
|
|
67
121
|
|
|
68
122
|
export const isDashboardWidgetSize = (value: unknown): value is DashboardWidgetSize =>
|
|
69
123
|
DASHBOARD_WIDGET_SIZES.some((size) => size === value);
|
|
@@ -89,10 +143,15 @@ export const fitWidgetSize = (
|
|
|
89
143
|
widget: { sizes: readonly DashboardWidgetSize[]; defaultSize: DashboardWidgetSize },
|
|
90
144
|
): DashboardWidgetSize => (isDashboardWidgetSize(requested) && widget.sizes.includes(requested) ? requested : widget.defaultSize);
|
|
91
145
|
|
|
92
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* What the dashboard asks a widget handler for, read with `getWidgetRequest(c)`. Settings are validated against the
|
|
148
|
+
* declaration; handlers must still authorize every chosen resource and work without settings.
|
|
149
|
+
*/
|
|
93
150
|
export type WidgetRequest = {
|
|
94
151
|
/** The size the widget is shown in, always one the widget declares. */
|
|
95
152
|
size: DashboardWidgetSize;
|
|
153
|
+
/** Declared, validated values with choice and toggle defaults filled. */
|
|
154
|
+
settings: WidgetSettings;
|
|
96
155
|
};
|
|
97
156
|
|
|
98
157
|
export const WidgetAccentSchema = z
|
|
@@ -237,12 +296,27 @@ export const widgetKey = (appId: string, widgetId: string): string => `${appId}/
|
|
|
237
296
|
export type WidgetStreamStatus = "ok" | "empty" | "forbidden" | "timeout" | "error";
|
|
238
297
|
|
|
239
298
|
/**
|
|
240
|
-
* One line of `GET /api/widgets/v1` (`application/x-ndjson`)
|
|
241
|
-
* order
|
|
242
|
-
* in `start` gets exactly one line.
|
|
299
|
+
* One line of `GET` or `POST /api/widgets/v1` (`application/x-ndjson`). GET names widget keys in registry order; POST
|
|
300
|
+
* names entry ids in request order. One `widget` line follows per entry in the order they finish; `done` closes the
|
|
301
|
+
* stream. Every name in `start` gets exactly one line. Only POST widget lines include a board entry id.
|
|
243
302
|
*/
|
|
244
303
|
export type WidgetStreamLine =
|
|
245
304
|
| { type: "start"; widgets: string[] }
|
|
246
|
-
| {
|
|
247
|
-
|
|
305
|
+
| {
|
|
306
|
+
type: "widget";
|
|
307
|
+
/** Board entry id, set only for POST entries. */
|
|
308
|
+
id?: string;
|
|
309
|
+
key: string;
|
|
310
|
+
status: "ok";
|
|
311
|
+
widget: WidgetResponse;
|
|
312
|
+
ms: number;
|
|
313
|
+
}
|
|
314
|
+
| {
|
|
315
|
+
type: "widget";
|
|
316
|
+
/** Board entry id, set only for POST entries. */
|
|
317
|
+
id?: string;
|
|
318
|
+
key: string;
|
|
319
|
+
status: Exclude<WidgetStreamStatus, "ok">;
|
|
320
|
+
ms: number;
|
|
321
|
+
}
|
|
248
322
|
| { type: "done"; status: "complete" | "partial" };
|
package/src/desktop/index.ts
CHANGED
|
@@ -304,8 +304,9 @@ let sqlInstance: DesktopSql | null = null;
|
|
|
304
304
|
|
|
305
305
|
const getSql = (): DesktopSql => {
|
|
306
306
|
if (sqlInstance) return sqlInstance;
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
// Checked before `runtimeRequire`: its `Function` probe would report a `script-src` violation under a CSP without 'unsafe-eval'.
|
|
308
|
+
const req = typeof window === "undefined" ? runtimeRequire() : null;
|
|
309
|
+
if (!req) throw new Error("desktop.sql is only available in the Bun desktop process.");
|
|
309
310
|
const requireFn = req as (id: string) => unknown;
|
|
310
311
|
|
|
311
312
|
const { Database } = requireFn("bun:sqlite") as { Database: new (path: string) => any };
|
package/src/desktop/solid.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { suppressTextSelection } from "@k2b/ui";
|
|
2
|
-
import {
|
|
2
|
+
import { fromJSON, toJSON } from "seroval";
|
|
3
3
|
import type { Component, JSX, ParentProps } from "solid-js";
|
|
4
4
|
import { children, createContext, createMemo, createSignal, For, onCleanup, onMount, Show, useContext } from "solid-js";
|
|
5
5
|
import { Dynamic, Portal } from "solid-js/web";
|
|
@@ -48,7 +48,7 @@ export const defineDesktopWindows = <Definitions extends Record<string, DesktopW
|
|
|
48
48
|
({
|
|
49
49
|
kind: desktopWindowDescriptorKind,
|
|
50
50
|
name,
|
|
51
|
-
props:
|
|
51
|
+
props: JSON.stringify(toJSON(props ?? {})),
|
|
52
52
|
}) satisfies DesktopWindowDescriptor as unknown as JSX.Element;
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -69,12 +69,14 @@ export function DesktopWindowHost<Definitions extends Record<string, DesktopWind
|
|
|
69
69
|
const descriptor = createMemo(() => nativeDescriptor());
|
|
70
70
|
const component = createMemo(() => {
|
|
71
71
|
const current = descriptor();
|
|
72
|
-
|
|
72
|
+
if (!current) return null;
|
|
73
|
+
// The name comes from the URL: only a registered window resolves, never an inherited member such as `constructor`.
|
|
74
|
+
const definitions = props.windows.__desktopWindowDefinitions;
|
|
75
|
+
return Object.hasOwn(definitions, current.name) ? definitions[current.name] : null;
|
|
73
76
|
});
|
|
74
77
|
const windowProps = createMemo(() => {
|
|
75
78
|
const current = descriptor();
|
|
76
|
-
|
|
77
|
-
return deserializeProps<Record<string, unknown>>(current.props);
|
|
79
|
+
return current && component() ? fromJSON<Record<string, unknown>>(JSON.parse(current.props)) : {};
|
|
78
80
|
});
|
|
79
81
|
|
|
80
82
|
return createMemo(() => {
|
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
import type { Context } from "hono";
|
|
2
|
-
import {
|
|
2
|
+
import { parseWidgetSettings } from "../_internal/widget-settings";
|
|
3
|
+
import {
|
|
4
|
+
type DashboardWidgetSize,
|
|
5
|
+
fitWidgetSize,
|
|
6
|
+
resolveWidgetSizes,
|
|
7
|
+
type WidgetRequest,
|
|
8
|
+
type WidgetSettingsDeclaration,
|
|
9
|
+
} from "../contracts/widgets";
|
|
3
10
|
|
|
4
11
|
/** The query parameter Core uses to ask a widget handler for one size. */
|
|
5
12
|
export const WIDGET_SIZE_PARAM = "size";
|
|
13
|
+
/** The query parameter Core uses to send one entry's validated settings. */
|
|
14
|
+
export const WIDGET_SETTINGS_PARAM = "settings";
|
|
15
|
+
/**
|
|
16
|
+
* Bun's default maximum request head is 16 KiB (a 17,000-character query was verified to return 431). The invocation
|
|
17
|
+
* JWT uses up to 4 KiB (IDENTITY_MAX_COMPACT_TOKEN_BYTES), and the path and other forwarded headers use about 1–2 KiB,
|
|
18
|
+
* leaving headroom for 8 KiB of URL-encoded settings. Core measures that encoding and never forwards more. A decoded
|
|
19
|
+
* value is never longer than its encoding, so the app never drops settings Core sends; longer input counts as missing.
|
|
20
|
+
*/
|
|
21
|
+
export const WIDGET_SETTINGS_PARAM_MAX_LENGTH = 8 * 1024;
|
|
6
22
|
|
|
7
23
|
const requests = new WeakMap<Request, WidgetRequest>();
|
|
8
24
|
|
|
@@ -12,9 +28,21 @@ const requests = new WeakMap<Request, WidgetRequest>();
|
|
|
12
28
|
*/
|
|
13
29
|
export const setWidgetRequest = (
|
|
14
30
|
c: Context,
|
|
15
|
-
declaration: { sizes?: readonly DashboardWidgetSize[]; defaultSize?: DashboardWidgetSize },
|
|
31
|
+
declaration: { sizes?: readonly DashboardWidgetSize[]; defaultSize?: DashboardWidgetSize; settings?: WidgetSettingsDeclaration },
|
|
16
32
|
): WidgetRequest => {
|
|
17
|
-
const
|
|
33
|
+
const param = c.req.query(WIDGET_SETTINGS_PARAM);
|
|
34
|
+
let raw: unknown;
|
|
35
|
+
if (param !== undefined && param.length <= WIDGET_SETTINGS_PARAM_MAX_LENGTH) {
|
|
36
|
+
try {
|
|
37
|
+
raw = JSON.parse(param);
|
|
38
|
+
} catch {
|
|
39
|
+
/* Malformed settings are treated as missing values. */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const request = {
|
|
43
|
+
size: fitWidgetSize(c.req.query(WIDGET_SIZE_PARAM), resolveWidgetSizes(declaration)),
|
|
44
|
+
settings: parseWidgetSettings(declaration.settings, raw),
|
|
45
|
+
};
|
|
18
46
|
requests.set(c.req.raw, request);
|
|
19
47
|
return request;
|
|
20
48
|
};
|
|
@@ -22,6 +50,7 @@ export const setWidgetRequest = (
|
|
|
22
50
|
/**
|
|
23
51
|
* What the dashboard asks this widget handler for. `size` is always one of the sizes the widget declares; return the
|
|
24
52
|
* content that fits it, for example only the one number in `small`. Outside a dashboard invocation, such as a request
|
|
25
|
-
* to the handler's own public route, the request is `large
|
|
53
|
+
* to the handler's own public route, the request is `{ size: "large", settings: {} }`. Settings are validated against
|
|
54
|
+
* the declaration. The handler must still authorize every chosen resource on every call and work without settings.
|
|
26
55
|
*/
|
|
27
|
-
export const getWidgetRequest = (c: Context): WidgetRequest => requests.get(c.req.raw) ?? { size: "large" };
|
|
56
|
+
export const getWidgetRequest = (c: Context): WidgetRequest => requests.get(c.req.raw) ?? { size: "large", settings: {} };
|
|
@@ -296,7 +296,8 @@ export const createAppApprovalService = (
|
|
|
296
296
|
UPDATE auth.app_devices d SET action_wake_at=now(), action_wake_ref=${approvalId}::uuid
|
|
297
297
|
WHERE d.id IN (
|
|
298
298
|
SELECT device.id FROM auth.app_devices device JOIN approvals.requests r ON r.approver_user_id=device.user_id
|
|
299
|
-
|
|
299
|
+
JOIN auth.users u ON u.id=r.approver_user_id
|
|
300
|
+
WHERE r.id=${approvalId}::uuid AND r.status='pending' AND r.expires_at > now() AND r.approver_auth_epoch=u.auth_epoch
|
|
300
301
|
AND EXISTS (SELECT 1 FROM ai.pending_actions p JOIN ai.turns t ON t.id=p.turn_id
|
|
301
302
|
WHERE p.approval_id=r.id AND p.status='pending' AND t.run_config->>'signedInSession' IS NULL)
|
|
302
303
|
AND device.issuer=${cfg.issuer} AND device.revoked_at IS NULL AND device.push_token IS NOT NULL
|
|
@@ -661,7 +662,8 @@ export const createAppApprovalService = (
|
|
|
661
662
|
};
|
|
662
663
|
const result = (state: string) => ({ state, pollAfterSeconds: limits.pollSeconds });
|
|
663
664
|
if (!hold) return result((await read()).state);
|
|
664
|
-
// Listen before the first read so a decision committed in between
|
|
665
|
+
// Listen before the first read so a decision committed in between usually wakes this request.
|
|
666
|
+
// live() has no subscription-ready barrier, so the hint is best effort; the deadline re-read is the guarantee.
|
|
665
667
|
const waiting = new AbortController();
|
|
666
668
|
const ended = new Promise((resolve) => waiting.signal.addEventListener("abort", resolve, { once: true }));
|
|
667
669
|
const stop = () => waiting.abort();
|