@k2b/cloud 0.36.0 → 0.38.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 +5 -3
- package/scripts/README.md +1 -0
- package/scripts/cli-pack.ts +123 -0
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/_internal/shell-focus.ts +11 -0
- package/src/access/PermissionEditor.browser-harness.tsx +46 -0
- package/src/access/PermissionEditor.tsx +38 -9
- package/src/access/ui.ts +1 -0
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/browser.ts +1 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/answer-links.ts +8 -0
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +12 -0
- package/src/ai/chat/primitives.tsx +2 -1
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-tasks.ts +65 -1
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/executor.ts +46 -3
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/migrate.ts +19 -2
- package/src/ai/readable-html.ts +1057 -0
- package/src/ai/types.ts +1 -0
- package/src/api/help.ts +23 -4
- package/src/api/mcp.ts +8 -2
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +5 -1
- package/src/browser/shell-focus.ts +67 -0
- package/src/cli/admin/workflows.ts +1 -0
- package/src/cli/commands.ts +2 -0
- package/src/cli/plugin.ts +5 -5
- package/src/config/env.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/server/help.ts +136 -10
- package/src/server/services/geo.ts +21 -4
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +2 -0
- package/src/services/network-security.ts +28 -0
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- package/src/services/settings/index.ts +48 -26
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/shared/network-address.ts +9 -0
- package/src/ssr/CloudShell.island.tsx +2 -0
- package/src/ssr/GlobalSearchTrigger.tsx +1 -1
- package/src/ssr/Layout.tsx +28 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutHelpTrigger.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -1
- package/src/ssr/ProfilePreferences.tsx +1 -1
- package/src/ssr/RailApps.tsx +1 -1
- package/src/ssr/ShellFocusToggle.tsx +43 -0
- package/src/ssr/ShellRuntime.island.tsx +53 -26
- package/src/ssr/app-launchpad.tsx +1 -1
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/platform-messages.ts +14 -0
- package/src/ssr/shell-data.tsx +7 -0
- package/src/ssr/shell-focus.browser-harness.tsx +83 -0
- package/src/ssr/shell-shortcuts.ts +8 -0
- package/src/styles/effects.css +688 -185
- package/src/styles/global.css +155 -26
- package/src/styles/resource-search.css +25 -0
- package/src/styles/utilities-navigation.css +11 -3
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- package/src/workflows/store/retention.ts +157 -0
package/src/server/help.ts
CHANGED
|
@@ -1,20 +1,48 @@
|
|
|
1
|
+
import { formatShortcut } from "@k2b/ui";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import { parse as parseYaml } from "yaml";
|
|
3
4
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { helpLinkTargets } from "../_internal/help";
|
|
6
|
+
import { helpAgentMarkdown } from "../_internal/help-catalog";
|
|
7
|
+
import {
|
|
8
|
+
HELP_ACCESS_LEVELS,
|
|
9
|
+
HELP_KINDS,
|
|
10
|
+
HELP_REFERENCE_PATTERN,
|
|
11
|
+
type HelpDocumentManifest,
|
|
12
|
+
type HelpDocumentPayload,
|
|
13
|
+
type HelpKind,
|
|
14
|
+
type HelpSearchPayload,
|
|
15
|
+
helpLocaleChain,
|
|
16
|
+
parseHelpReference,
|
|
17
|
+
} from "../shared/help";
|
|
18
|
+
import { helpHtmlToPlainText, lexHelpMarkdown, renderHelpMarkdown, walkHelpTokens } from "../shared/markdown";
|
|
6
19
|
import { auth } from "./middleware/auth";
|
|
7
20
|
|
|
21
|
+
// A help: target in a Markdown link or definition, an autolink, or an attribute value inside an HTML tag.
|
|
22
|
+
const RAW_HELP_TARGET = /(?:(?:\]\(|\]:)\s*<?|<|<[^>]*\s[\w:-]+\s*=\s*["']?)\s*help:/i;
|
|
23
|
+
|
|
8
24
|
const metadataSchema = z.object({
|
|
9
25
|
id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
10
26
|
title: z.string().trim().min(1),
|
|
11
27
|
icon: z.string().trim().min(1).optional(),
|
|
12
28
|
description: z.string().trim().min(1).optional(),
|
|
13
29
|
order: z.number().int().optional(),
|
|
30
|
+
kind: z.enum(HELP_KINDS).optional(),
|
|
31
|
+
needs: z.enum(HELP_ACCESS_LEVELS).optional(),
|
|
32
|
+
next: z
|
|
33
|
+
.string()
|
|
34
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
|
35
|
+
.optional(),
|
|
36
|
+
related: z
|
|
37
|
+
.array(z.string().regex(HELP_REFERENCE_PATTERN))
|
|
38
|
+
.refine((refs) => new Set(refs).size === refs.length, "Duplicate related Help reference")
|
|
39
|
+
.optional(),
|
|
14
40
|
});
|
|
15
41
|
|
|
16
|
-
export type HelpDefinitionDocument = Omit<z.infer<typeof metadataSchema>, "order"> & {
|
|
42
|
+
export type HelpDefinitionDocument = Omit<z.infer<typeof metadataSchema>, "order" | "kind" | "related"> & {
|
|
17
43
|
order: number;
|
|
44
|
+
kind: HelpKind;
|
|
45
|
+
related: readonly string[];
|
|
18
46
|
markdown: string;
|
|
19
47
|
html: string;
|
|
20
48
|
searchText: string;
|
|
@@ -34,7 +62,7 @@ export type HelpCollection = {
|
|
|
34
62
|
getMarkdown: (id: string) => string | undefined;
|
|
35
63
|
};
|
|
36
64
|
|
|
37
|
-
type ParsedHelpDocument =
|
|
65
|
+
type ParsedHelpDocument = z.infer<typeof metadataSchema> & Pick<HelpDefinitionDocument, "markdown" | "html" | "searchText">;
|
|
38
66
|
|
|
39
67
|
const parseSource = (source: string, locale: string): ParsedHelpDocument => {
|
|
40
68
|
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(source);
|
|
@@ -43,12 +71,13 @@ const parseSource = (source: string, locale: string): ParsedHelpDocument => {
|
|
|
43
71
|
const metadata = metadataSchema.parse(parseYaml(match[1]!));
|
|
44
72
|
const markdown = match[2]!.trim();
|
|
45
73
|
if (!markdown) throw new Error(`Help document "${metadata.id}" has no body`);
|
|
74
|
+
const html = renderHelpMarkdown(markdown, { locale });
|
|
46
75
|
|
|
47
76
|
return {
|
|
48
77
|
...metadata,
|
|
49
78
|
markdown,
|
|
50
|
-
html
|
|
51
|
-
searchText:
|
|
79
|
+
html,
|
|
80
|
+
searchText: helpHtmlToPlainText(html),
|
|
52
81
|
};
|
|
53
82
|
};
|
|
54
83
|
|
|
@@ -69,12 +98,27 @@ const parseDocuments = (
|
|
|
69
98
|
.map((source) => parseSource(source, locale))
|
|
70
99
|
.map((document): HelpDefinitionDocument => {
|
|
71
100
|
const base = baseById?.get(document.id);
|
|
72
|
-
if (!baseById)
|
|
101
|
+
if (!baseById)
|
|
102
|
+
return { ...document, order: document.order ?? 100, kind: document.kind ?? "task", related: Object.freeze(document.related ?? []) };
|
|
73
103
|
if (!base) throw new Error(`Help locale "${locale}" contains unknown document id "${document.id}"`);
|
|
74
104
|
if ((document.icon !== undefined && document.icon !== base.icon) || (document.order !== undefined && document.order !== base.order)) {
|
|
75
105
|
throw new Error(`Help locale "${locale}" must preserve icon and order for document "${document.id}"`);
|
|
76
106
|
}
|
|
77
|
-
|
|
107
|
+
for (const field of ["kind", "needs", "next", "related"] as const) {
|
|
108
|
+
if (document[field] !== undefined && JSON.stringify(document[field]) !== JSON.stringify(base[field])) {
|
|
109
|
+
throw new Error(`Help locale "${locale}" must preserve ${field} for document "${document.id}"`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
...document,
|
|
114
|
+
icon: base.icon,
|
|
115
|
+
description: document.description ?? base.description,
|
|
116
|
+
order: base.order,
|
|
117
|
+
kind: base.kind,
|
|
118
|
+
needs: base.needs,
|
|
119
|
+
next: base.next,
|
|
120
|
+
related: base.related,
|
|
121
|
+
};
|
|
78
122
|
})
|
|
79
123
|
.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title))
|
|
80
124
|
.map((document) => Object.freeze(document));
|
|
@@ -116,6 +160,82 @@ export const defineHelp = (
|
|
|
116
160
|
documentsByLocale[locale] = parseDocuments(sources, locale, baseById);
|
|
117
161
|
}
|
|
118
162
|
|
|
163
|
+
for (const [locale, localizedDocuments] of Object.entries(documentsByLocale)) {
|
|
164
|
+
for (const document of localizedDocuments) {
|
|
165
|
+
const validate = (reference: string, field: string) => {
|
|
166
|
+
const ref = parseHelpReference(reference);
|
|
167
|
+
if (!ref || (!ref.appId && !baseById.has(ref.documentId))) {
|
|
168
|
+
throw new Error(`Help locale "${locale}" document "${document.id}" has unknown ${field} reference "${reference}"`);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
if (document.next === document.id)
|
|
172
|
+
throw new Error(`Help locale "${locale}" document "${document.id}" must not reference itself in next`);
|
|
173
|
+
if (document.next) validate(document.next, "next");
|
|
174
|
+
for (const reference of document.related) validate(reference, "related");
|
|
175
|
+
const unreadableLink = () =>
|
|
176
|
+
new Error(
|
|
177
|
+
`Help document "${document.id}" has a help: link that agents cannot read in locale "${locale}"; write it inline as [text](help:id)`,
|
|
178
|
+
);
|
|
179
|
+
const tokens = lexHelpMarkdown(document.markdown, locale);
|
|
180
|
+
const targets: string[] = [];
|
|
181
|
+
if (Object.values(tokens.links).some((link) => /^help:/i.test(link.href) && !link.href.startsWith("help:"))) throw unreadableLink();
|
|
182
|
+
walkHelpTokens(tokens, (token) => {
|
|
183
|
+
if (
|
|
184
|
+
(token.type === "link" && /^help:/i.test(token.href) && (!token.href.startsWith("help:") || !token.raw.startsWith("["))) ||
|
|
185
|
+
(token.type === "image" && /^help:/i.test(token.href)) ||
|
|
186
|
+
(token.type === "html" && RAW_HELP_TARGET.test(token.raw))
|
|
187
|
+
)
|
|
188
|
+
throw unreadableLink();
|
|
189
|
+
if ((token.type === "paragraph" || token.type === "text") && token.tokens?.length) {
|
|
190
|
+
let prose = token.raw;
|
|
191
|
+
// Keep line positions while excluding multiline code spans from the author contract.
|
|
192
|
+
walkHelpTokens(token.tokens, (child) => {
|
|
193
|
+
if (child.type === "codespan") prose = prose.replaceAll(child.raw, child.raw.replace(/[^\r\n]/g, "x"));
|
|
194
|
+
});
|
|
195
|
+
const block = /^ {0,3}:::(details|steps|reference|compare)(?=[ \t\r]|$)/m.exec(prose);
|
|
196
|
+
if (block) {
|
|
197
|
+
const missing =
|
|
198
|
+
block[1] === "details" ? "a blank line before it, a title, or a closing :::" : "a blank line before it or a closing :::";
|
|
199
|
+
throw new Error(`Help document "${document.id}" has a :::${block[1]} block without ${missing} in locale "${locale}"`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (token.type === "link" && token.href.startsWith("help:")) {
|
|
203
|
+
const target = token.href.slice(5);
|
|
204
|
+
validate(target, "help:");
|
|
205
|
+
targets.push(target);
|
|
206
|
+
}
|
|
207
|
+
// Callouts do not render inside details, and a warning must stay where the reader sees it.
|
|
208
|
+
if (token.type === "helpDetails" && token.callout === true) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`Help document "${document.id}" has a callout inside details in locale "${locale}"; place it outside the details`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (token.type === "helpKbd" && formatShortcut(String(token.keys), { platform: "other", locale }).keys.length === 0) {
|
|
214
|
+
throw new Error(`Help document "${document.id}" has an invalid key shortcut "${token.keys}" in locale "${locale}"`);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
// Unconverted Help syntax and targets fail here instead of reaching agents.
|
|
218
|
+
const agentTokens = lexHelpMarkdown(helpAgentMarkdown({ markdown: document.markdown, locale, appId: "", links: {} }, tokens), locale);
|
|
219
|
+
let unconvertedLink = Object.values(agentTokens.links).some((link) => link.href.startsWith("help:"));
|
|
220
|
+
let unconvertedSyntax = false;
|
|
221
|
+
walkHelpTokens(agentTokens, (token) => {
|
|
222
|
+
if (
|
|
223
|
+
(token.type === "link" && token.href.startsWith("help:")) ||
|
|
224
|
+
// Markdown inside raw HTML and callout titles stays text in agent text, so its target would reach readers.
|
|
225
|
+
(token.type === "html" && RAW_HELP_TARGET.test(token.raw)) ||
|
|
226
|
+
(token.type === "markdownInfoBlock" && typeof token.title === "string" && RAW_HELP_TARGET.test(token.title))
|
|
227
|
+
)
|
|
228
|
+
unconvertedLink = true;
|
|
229
|
+
if (token.type === "helpKbd" || token.type === "helpLabel" || token.type === "helpDetails") unconvertedSyntax = true;
|
|
230
|
+
});
|
|
231
|
+
if (unconvertedLink) throw unreadableLink();
|
|
232
|
+
if (unconvertedSyntax) {
|
|
233
|
+
throw new Error(`Help document "${document.id}" has Help syntax that agents cannot read in locale "${locale}"`);
|
|
234
|
+
}
|
|
235
|
+
helpLinkTargets.set(document, targets);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
119
239
|
return {
|
|
120
240
|
baseLocale,
|
|
121
241
|
documentsByLocale: Object.freeze(documentsByLocale),
|
|
@@ -135,6 +255,8 @@ export const defineHelp = (
|
|
|
135
255
|
* IDs, ordering and ownership stay visible in code; no filesystem scanning or
|
|
136
256
|
* build-time convention is required.
|
|
137
257
|
*
|
|
258
|
+
* Its payload includes kind and needs, but no next and an empty related list: it has no page route to link to.
|
|
259
|
+
*
|
|
138
260
|
* @deprecated Declare Help with `defineHelp()` and pass it to `app.start({ help })`. This router only
|
|
139
261
|
* requires sign-in; it does not apply the app's Help visibility, so the mounting app's routes must guard it.
|
|
140
262
|
*/
|
|
@@ -145,12 +267,13 @@ export const defineHelpCollection = (options: { basePath: string; sources: reado
|
|
|
145
267
|
const byId = new Map(documents.map((document) => [document.id, document]));
|
|
146
268
|
|
|
147
269
|
const searchUrl = `${basePath}/search`;
|
|
148
|
-
const manifest = documents.map<HelpDocumentManifest>(({ id, title, icon, description, order }) => ({
|
|
270
|
+
const manifest = documents.map<HelpDocumentManifest>(({ id, title, icon, description, order, kind }) => ({
|
|
149
271
|
id,
|
|
150
272
|
title,
|
|
151
273
|
icon,
|
|
152
274
|
description,
|
|
153
275
|
order,
|
|
276
|
+
kind,
|
|
154
277
|
searchUrl,
|
|
155
278
|
url: `${basePath}/${encodeURIComponent(id)}`,
|
|
156
279
|
}));
|
|
@@ -180,8 +303,11 @@ export const defineHelpCollection = (options: { basePath: string; sources: reado
|
|
|
180
303
|
locale: "en",
|
|
181
304
|
id: document.id,
|
|
182
305
|
title: document.title,
|
|
183
|
-
markdown: document.markdown,
|
|
306
|
+
markdown: helpAgentMarkdown({ markdown: document.markdown, locale: "en", appId: "", links: {} }),
|
|
184
307
|
html: document.html,
|
|
308
|
+
kind: document.kind,
|
|
309
|
+
needs: document.needs,
|
|
310
|
+
related: [],
|
|
185
311
|
};
|
|
186
312
|
return context.json(payload);
|
|
187
313
|
});
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { err, fail, ok, type PageParams, type Paginated, paginate, type Result } from "@k2b/stdlib";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { readBoundedJson } from "../../_internal/bounded-json";
|
|
4
|
+
import { env } from "../../config/env";
|
|
5
|
+
import { createAllowedNetworkFetch } from "../../services/allowed-network-fetch";
|
|
6
|
+
import { logger } from "../../services/logging";
|
|
7
|
+
import { NetworkTargetBlockedError } from "../../services/network-security";
|
|
8
|
+
import { parseNetworkAllowlistEnv } from "../../shared/network-address";
|
|
9
|
+
|
|
10
|
+
export const geoAllowlist = () => parseNetworkAllowlistEnv("GEO_ALLOWED_TARGETS", env.GEO_ALLOWED_TARGETS);
|
|
4
11
|
|
|
5
12
|
const GEO_TIMEOUT_MS = 2_000;
|
|
6
13
|
const GEO_MAX_RESPONSE_BYTES = 256 * 1024;
|
|
@@ -118,8 +125,11 @@ const list = async (config: {
|
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
try {
|
|
121
|
-
const res = await
|
|
128
|
+
const res = await createAllowedNetworkFetch(geoAllowlist())(`${baseUrlResult.data}/geo/search?${params}`, {
|
|
129
|
+
signal: geoSignal(config.signal),
|
|
130
|
+
});
|
|
122
131
|
if (!res.ok) {
|
|
132
|
+
await res.body?.cancel();
|
|
123
133
|
return unavailable();
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -152,7 +162,9 @@ const list = async (config: {
|
|
|
152
162
|
total: filtered.length,
|
|
153
163
|
hasNext: page * perPage < filtered.length,
|
|
154
164
|
});
|
|
155
|
-
} catch {
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error instanceof NetworkTargetBlockedError)
|
|
167
|
+
logger("geo").warn("Geo target is on an internal network. An operator can allow it with GEO_ALLOWED_TARGETS.");
|
|
156
168
|
return unavailable();
|
|
157
169
|
}
|
|
158
170
|
};
|
|
@@ -165,8 +177,11 @@ const get = async (config: { baseUrl: string; lat: number; lon: number }): Promi
|
|
|
165
177
|
if (!baseUrlResult.ok) return baseUrlResult;
|
|
166
178
|
|
|
167
179
|
try {
|
|
168
|
-
const res = await
|
|
180
|
+
const res = await createAllowedNetworkFetch(geoAllowlist())(`${baseUrlResult.data}/geo/reverse?lat=${config.lat}&lng=${config.lon}`, {
|
|
181
|
+
signal: geoSignal(),
|
|
182
|
+
});
|
|
169
183
|
if (!res.ok) {
|
|
184
|
+
await res.body?.cancel();
|
|
170
185
|
return unavailable();
|
|
171
186
|
}
|
|
172
187
|
|
|
@@ -181,7 +196,9 @@ const get = async (config: { baseUrl: string; lat: number; lon: number }): Promi
|
|
|
181
196
|
.find((place): place is GeoPlace => place !== null);
|
|
182
197
|
|
|
183
198
|
return ok(first ?? null);
|
|
184
|
-
} catch {
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error instanceof NetworkTargetBlockedError)
|
|
201
|
+
logger("geo").warn("Geo target is on an internal network. An operator can allow it with GEO_ALLOWED_TARGETS.");
|
|
185
202
|
return unavailable();
|
|
186
203
|
}
|
|
187
204
|
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { type ClientRequest, request as httpRequest, type IncomingMessage, type RequestOptions } from "node:http";
|
|
2
|
+
import { request as httpsRequest } from "node:https";
|
|
3
|
+
import { type NetworkAllowlist, networkAddressFamily, normalizeNetworkHostname } from "../shared/network-address";
|
|
4
|
+
import { type NetworkLookup, pinnedNetworkLookup, resolveAllowedNetworkAddresses } from "./network-security";
|
|
5
|
+
|
|
6
|
+
type RequestFactory = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
|
|
7
|
+
type AllowedNetworkFetchDeps = {
|
|
8
|
+
lookup?: NetworkLookup;
|
|
9
|
+
resolve?: typeof resolveAllowedNetworkAddresses;
|
|
10
|
+
request?: RequestFactory;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fetch-compatible HTTP transport for operator-allowlisted targets. Resolves and
|
|
15
|
+
* pins every request, without proxies, retries, decompression or its own timeout.
|
|
16
|
+
* Never follows redirects: `redirect: "error"` rejects any 3xx; otherwise returns
|
|
17
|
+
* that response unchanged. The caller owns deadlines and response consumption.
|
|
18
|
+
*/
|
|
19
|
+
export const createAllowedNetworkFetch =
|
|
20
|
+
(allowlist: NetworkAllowlist, deps: AllowedNetworkFetchDeps = {}) =>
|
|
21
|
+
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
22
|
+
const request = new Request(input, init);
|
|
23
|
+
const url = new URL(request.url);
|
|
24
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Network target must use HTTP or HTTPS");
|
|
25
|
+
if (url.username || url.password) throw new TypeError("Network target must not include user information");
|
|
26
|
+
const signal = request.signal;
|
|
27
|
+
signal.throwIfAborted();
|
|
28
|
+
const addresses = await new Promise<Awaited<ReturnType<typeof resolveAllowedNetworkAddresses>>>((resolve, reject) => {
|
|
29
|
+
const abort = () => reject(signal.reason);
|
|
30
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
31
|
+
(deps.resolve ?? resolveAllowedNetworkAddresses)(url.hostname, allowlist, deps.lookup)
|
|
32
|
+
.then(resolve, reject)
|
|
33
|
+
.finally(() => signal.removeEventListener("abort", abort));
|
|
34
|
+
if (signal.aborted) abort();
|
|
35
|
+
});
|
|
36
|
+
signal.throwIfAborted();
|
|
37
|
+
// A body of known size goes out with Content-Length; only a caller's stream (in init or an input Request) is chunked.
|
|
38
|
+
const streamed = init?.body != null ? init.body instanceof ReadableStream : input instanceof Request && input.body !== null;
|
|
39
|
+
const payload = request.body && !streamed ? new Uint8Array(await request.arrayBuffer()) : undefined;
|
|
40
|
+
signal.throwIfAborted();
|
|
41
|
+
return new Promise<Response>((resolve, reject) => {
|
|
42
|
+
const headers = new Headers(request.headers);
|
|
43
|
+
headers.set("host", url.host);
|
|
44
|
+
headers.set("accept-encoding", "identity");
|
|
45
|
+
if (payload) headers.set("content-length", String(payload.byteLength));
|
|
46
|
+
let response: IncomingMessage | undefined;
|
|
47
|
+
let upload: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
48
|
+
let bodyController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
49
|
+
const cleanup = () => signal.removeEventListener("abort", abort);
|
|
50
|
+
const fail = (error: Error) => {
|
|
51
|
+
reject(error);
|
|
52
|
+
bodyController?.error(error);
|
|
53
|
+
bodyController = undefined;
|
|
54
|
+
response?.destroy();
|
|
55
|
+
void upload?.cancel(error).catch(() => {});
|
|
56
|
+
outgoing.destroy(error);
|
|
57
|
+
cleanup();
|
|
58
|
+
};
|
|
59
|
+
const abort = () => fail(signal.reason);
|
|
60
|
+
const outgoing = (deps.request ?? (url.protocol === "https:" ? httpsRequest : httpRequest))(
|
|
61
|
+
{
|
|
62
|
+
protocol: url.protocol,
|
|
63
|
+
hostname: normalizeNetworkHostname(url.hostname),
|
|
64
|
+
port: url.port || undefined,
|
|
65
|
+
path: `${url.pathname}${url.search}`,
|
|
66
|
+
method: request.method,
|
|
67
|
+
headers: Object.fromEntries(headers),
|
|
68
|
+
servername: url.protocol === "https:" && !networkAddressFamily(url.hostname) ? normalizeNetworkHostname(url.hostname) : undefined,
|
|
69
|
+
lookup: pinnedNetworkLookup(addresses),
|
|
70
|
+
agent: false,
|
|
71
|
+
} satisfies RequestOptions & { servername?: string },
|
|
72
|
+
(incoming) => {
|
|
73
|
+
response = incoming;
|
|
74
|
+
incoming.once("close", cleanup);
|
|
75
|
+
incoming.once("error", (error) => {
|
|
76
|
+
reject(error);
|
|
77
|
+
bodyController?.error(error);
|
|
78
|
+
bodyController = undefined;
|
|
79
|
+
});
|
|
80
|
+
const status = incoming.statusCode ?? 0;
|
|
81
|
+
if (request.redirect === "error" && status >= 300 && status < 400) {
|
|
82
|
+
fail(new TypeError(`Network target returned HTTP ${status} redirect`));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const encoding = incoming.headers["content-encoding"];
|
|
86
|
+
if (encoding && encoding.trim().toLowerCase() !== "identity") {
|
|
87
|
+
fail(new TypeError("Network target returned an unsupported content encoding"));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const responseHeaders = new Headers();
|
|
91
|
+
for (let i = 0; i < incoming.rawHeaders.length; i += 2)
|
|
92
|
+
responseHeaders.append(incoming.rawHeaders[i]!, incoming.rawHeaders[i + 1]!);
|
|
93
|
+
const empty = request.method === "HEAD" || [204, 205, 304].includes(status);
|
|
94
|
+
const iterator = incoming[Symbol.asyncIterator]();
|
|
95
|
+
const body = empty
|
|
96
|
+
? null
|
|
97
|
+
: new ReadableStream<Uint8Array>({
|
|
98
|
+
start(controller) {
|
|
99
|
+
bodyController = controller;
|
|
100
|
+
},
|
|
101
|
+
async pull(controller) {
|
|
102
|
+
try {
|
|
103
|
+
const chunk = await iterator.next();
|
|
104
|
+
if (!bodyController) return;
|
|
105
|
+
if (chunk.done) {
|
|
106
|
+
bodyController = undefined;
|
|
107
|
+
controller.close();
|
|
108
|
+
cleanup();
|
|
109
|
+
} else controller.enqueue(new Uint8Array(chunk.value));
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (bodyController) {
|
|
112
|
+
bodyController = undefined;
|
|
113
|
+
controller.error(error);
|
|
114
|
+
}
|
|
115
|
+
outgoing.destroy();
|
|
116
|
+
cleanup();
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
cancel() {
|
|
120
|
+
bodyController = undefined;
|
|
121
|
+
incoming.destroy();
|
|
122
|
+
outgoing.destroy();
|
|
123
|
+
cleanup();
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
resolve(new Response(body, { status, statusText: incoming.statusMessage, headers: responseHeaders }));
|
|
128
|
+
if (empty) incoming.resume();
|
|
129
|
+
} catch (error) {
|
|
130
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
outgoing.once("close", () => {
|
|
135
|
+
void upload?.cancel().catch(() => {});
|
|
136
|
+
});
|
|
137
|
+
outgoing.once("error", (error) => {
|
|
138
|
+
reject(error);
|
|
139
|
+
bodyController?.error(error);
|
|
140
|
+
bodyController = undefined;
|
|
141
|
+
response?.destroy();
|
|
142
|
+
cleanup();
|
|
143
|
+
});
|
|
144
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
145
|
+
if (signal.aborted) {
|
|
146
|
+
abort();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (payload) {
|
|
150
|
+
outgoing.end(payload);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
void (async () => {
|
|
154
|
+
const reader = request.body?.getReader();
|
|
155
|
+
upload = reader;
|
|
156
|
+
try {
|
|
157
|
+
if (reader) {
|
|
158
|
+
while (true) {
|
|
159
|
+
const chunk = await reader.read();
|
|
160
|
+
if (chunk.done) break;
|
|
161
|
+
if (outgoing.destroyed) break;
|
|
162
|
+
if (!outgoing.write(chunk.value))
|
|
163
|
+
await new Promise<void>((resolve, reject) => {
|
|
164
|
+
const closed = () => {
|
|
165
|
+
clear();
|
|
166
|
+
reject(new Error("Request closed"));
|
|
167
|
+
};
|
|
168
|
+
const drained = () => {
|
|
169
|
+
clear();
|
|
170
|
+
resolve();
|
|
171
|
+
};
|
|
172
|
+
const clear = () => {
|
|
173
|
+
outgoing.off("close", closed);
|
|
174
|
+
outgoing.off("drain", drained);
|
|
175
|
+
};
|
|
176
|
+
outgoing.once("drain", drained);
|
|
177
|
+
outgoing.once("close", closed);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
outgoing.end();
|
|
182
|
+
} catch (error) {
|
|
183
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
184
|
+
} finally {
|
|
185
|
+
await reader?.cancel().catch(() => {});
|
|
186
|
+
}
|
|
187
|
+
})();
|
|
188
|
+
});
|
|
189
|
+
};
|
|
@@ -4,7 +4,8 @@ import { getApp, listApps } from "../../_internal/registry";
|
|
|
4
4
|
import type { AppRegistryEntry } from "../../contracts/registry";
|
|
5
5
|
import type { RequestActor } from "../../contracts/shared";
|
|
6
6
|
import { resolveAppPresentations } from "../../shared/app-presentation";
|
|
7
|
-
import { helpLocaleChain } from "../../shared/help";
|
|
7
|
+
import { type HelpArticleLink, helpLocaleChain, parseHelpReference } from "../../shared/help";
|
|
8
|
+
import { lexHelpMarkdown, walkHelpTokens } from "../../shared/markdown";
|
|
8
9
|
import { logger } from "../logging";
|
|
9
10
|
import { HELP_RESOURCE_PAGE_SIZE, type HelpSelection, hasHelpBm25, metadata, queryHelp } from "./store";
|
|
10
11
|
import type { HelpReader } from "./types";
|
|
@@ -36,9 +37,8 @@ export const createHelpReader = (
|
|
|
36
37
|
): HelpReader => {
|
|
37
38
|
const db = dependencies.db ?? sql;
|
|
38
39
|
const visible = (app: AppRegistryEntry | null): app is AppRegistryEntry => app !== null && canReadAppHelp(app, viewer);
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
return resolveAppPresentations(apps.filter(visible), locale).flatMap((app) =>
|
|
40
|
+
const selectionFor = (apps: AppRegistryEntry[]): HelpSelection[] =>
|
|
41
|
+
apps.flatMap((app) =>
|
|
42
42
|
app.help
|
|
43
43
|
? [
|
|
44
44
|
{
|
|
@@ -50,6 +50,9 @@ export const createHelpReader = (
|
|
|
50
50
|
]
|
|
51
51
|
: [],
|
|
52
52
|
);
|
|
53
|
+
const select = async (appId?: string): Promise<HelpSelection[]> => {
|
|
54
|
+
const apps = appId ? [await (dependencies.getApp ?? getApp)(appId)] : await (dependencies.listApps ?? listApps)();
|
|
55
|
+
return selectionFor(resolveAppPresentations(apps.filter(visible), locale));
|
|
53
56
|
};
|
|
54
57
|
return {
|
|
55
58
|
async manifest(appId) {
|
|
@@ -80,6 +83,7 @@ export const createHelpReader = (
|
|
|
80
83
|
icon: row.icon ?? undefined,
|
|
81
84
|
description: row.description ?? undefined,
|
|
82
85
|
order: row.sort_order,
|
|
86
|
+
kind: row.kind,
|
|
83
87
|
locale: row.locale,
|
|
84
88
|
searchUrl: `/api/help/v1/${encodeURIComponent(appId)}/search`,
|
|
85
89
|
url: `/api/help/v1/${encodeURIComponent(appId)}/documents/${encodeURIComponent(row.document_id)}`,
|
|
@@ -91,7 +95,44 @@ export const createHelpReader = (
|
|
|
91
95
|
},
|
|
92
96
|
async read(input) {
|
|
93
97
|
const [row] = await queryHelp(await select(input.appId), { documentId: input.documentId, body: true, limit: 1 }, db);
|
|
94
|
-
|
|
98
|
+
if (row?.markdown === undefined) return null;
|
|
99
|
+
const references = new Set<string>();
|
|
100
|
+
const add = (reference: string) => {
|
|
101
|
+
const ref = parseHelpReference(reference);
|
|
102
|
+
if (ref) references.add(`${ref.appId ?? input.appId}/${ref.documentId}`);
|
|
103
|
+
};
|
|
104
|
+
if (row.next_id) add(row.next_id);
|
|
105
|
+
for (const reference of row.related) add(reference);
|
|
106
|
+
walkHelpTokens(lexHelpMarkdown(row.markdown, row.locale), (token) => {
|
|
107
|
+
if (token.type === "link" && token.href.startsWith("help:")) add(token.href.slice(5));
|
|
108
|
+
});
|
|
109
|
+
// Only targets in the current Help of apps this viewer may see become links; the rest stay text.
|
|
110
|
+
const referencedApps = new Set([...references].map((reference) => reference.split("/")[0]));
|
|
111
|
+
const apps = references.size
|
|
112
|
+
? resolveAppPresentations(
|
|
113
|
+
(referencedApps.size === 1 && referencedApps.has(input.appId)
|
|
114
|
+
? [await (dependencies.getApp ?? getApp)(input.appId)]
|
|
115
|
+
: await (dependencies.listApps ?? listApps)()
|
|
116
|
+
).filter((app): app is AppRegistryEntry => visible(app) && referencedApps.has(app.id)),
|
|
117
|
+
locale,
|
|
118
|
+
)
|
|
119
|
+
: [];
|
|
120
|
+
const targets = apps.length ? await queryHelp(selectionFor(apps), { references: [...references] }, db) : [];
|
|
121
|
+
const accents = new Map(apps.map((app) => [app.id, app.appearance?.accent]));
|
|
122
|
+
const links: Record<string, HelpArticleLink> = {};
|
|
123
|
+
for (const target of targets) {
|
|
124
|
+
links[`${target.app_id}/${target.document_id}`] = {
|
|
125
|
+
appId: target.app_id,
|
|
126
|
+
appName: target.app_name,
|
|
127
|
+
accent: accents.get(target.app_id),
|
|
128
|
+
documentId: target.document_id,
|
|
129
|
+
title: target.title,
|
|
130
|
+
description: target.description ?? undefined,
|
|
131
|
+
icon: target.icon ?? undefined,
|
|
132
|
+
url: `/help/apps/${encodeURIComponent(target.app_id)}/${encodeURIComponent(target.document_id)}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { ...metadata(row), markdown: row.markdown, links };
|
|
95
136
|
},
|
|
96
137
|
async search(input) {
|
|
97
138
|
const query = input.query.trim().slice(0, 200);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
2
|
import { APP_REGISTRY_TTL_MS } from "../../_internal/registry";
|
|
3
|
-
import {
|
|
3
|
+
import type { HelpAccessLevel, HelpKind } from "../../shared/help";
|
|
4
|
+
import { ensureSchema, toPgTextArray } from "../postgres";
|
|
4
5
|
import type { HelpCorpus, HelpMetadata } from "./types";
|
|
5
6
|
|
|
6
7
|
// One expired app lease of grace after an app can no longer be discovered.
|
|
@@ -26,6 +27,15 @@ export const migrateHelp = async (db: SQL = sql): Promise<void> => {
|
|
|
26
27
|
PRIMARY KEY (app_id, manifest_hash, locale, document_id),
|
|
27
28
|
FOREIGN KEY (app_id, manifest_hash) REFERENCES help.corpora ON DELETE CASCADE
|
|
28
29
|
)`.simple();
|
|
30
|
+
const columns = await tx`SELECT column_name FROM information_schema.columns
|
|
31
|
+
WHERE table_schema = 'help' AND table_name = 'documents'
|
|
32
|
+
AND column_name IN ('kind', 'needs', 'next_id', 'related')`;
|
|
33
|
+
if (columns.length < 4)
|
|
34
|
+
await tx`ALTER TABLE help.documents
|
|
35
|
+
ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'task',
|
|
36
|
+
ADD COLUMN IF NOT EXISTS needs text,
|
|
37
|
+
ADD COLUMN IF NOT EXISTS next_id text,
|
|
38
|
+
ADD COLUMN IF NOT EXISTS related text[] NOT NULL DEFAULT '{}'`.simple();
|
|
29
39
|
await tx`CREATE INDEX IF NOT EXISTS help_search_idx ON help.documents USING gin(search_document)`.simple();
|
|
30
40
|
});
|
|
31
41
|
// The operator enables the extension; Help never installs server extensions.
|
|
@@ -63,9 +73,9 @@ export const registerHelp = async (corpus: HelpCorpus, db: SQL = sql): Promise<v
|
|
|
63
73
|
for (const document of documents) {
|
|
64
74
|
const text = `${document.title}\n${document.description ?? ""}\n${document.searchText}`;
|
|
65
75
|
await tx`INSERT INTO help.documents(app_id, manifest_hash, locale, document_id, title, description, icon,
|
|
66
|
-
sort_order, markdown, search_text, search_config, search_document)
|
|
76
|
+
sort_order, kind, needs, next_id, related, markdown, search_text, search_config, search_document)
|
|
67
77
|
VALUES (${corpus.appId}, ${corpus.manifestHash}, ${locale}, ${document.id}, ${document.title},
|
|
68
|
-
${document.description ?? null}, ${document.icon ?? null}, ${document.order}, ${document.markdown}, ${text}, ${config},
|
|
78
|
+
${document.description ?? null}, ${document.icon ?? null}, ${document.order}, ${document.kind}, ${document.needs ?? null}, ${document.next ?? null}, ${toPgTextArray([...document.related])}::text[], ${document.markdown}, ${text}, ${config},
|
|
69
79
|
setweight(to_tsvector(${config}::regconfig, ${document.id + " " + document.title}), 'A') ||
|
|
70
80
|
setweight(to_tsvector(${config}::regconfig, ${document.description ?? ""}), 'B') ||
|
|
71
81
|
setweight(to_tsvector(${config}::regconfig, ${document.searchText}), 'D'))`;
|
|
@@ -97,6 +107,10 @@ type Row = {
|
|
|
97
107
|
description: string | null;
|
|
98
108
|
icon: string | null;
|
|
99
109
|
sort_order: number;
|
|
110
|
+
kind: HelpKind;
|
|
111
|
+
needs: HelpAccessLevel | null;
|
|
112
|
+
next_id: string | null;
|
|
113
|
+
related: string[];
|
|
100
114
|
markdown?: string;
|
|
101
115
|
};
|
|
102
116
|
export const metadata = (row: Row): HelpMetadata => ({
|
|
@@ -109,6 +123,10 @@ export const metadata = (row: Row): HelpMetadata => ({
|
|
|
109
123
|
description: row.description ?? undefined,
|
|
110
124
|
icon: row.icon ?? undefined,
|
|
111
125
|
order: row.sort_order,
|
|
126
|
+
kind: row.kind,
|
|
127
|
+
needs: row.needs ?? undefined,
|
|
128
|
+
next: row.next_id ?? undefined,
|
|
129
|
+
related: row.related,
|
|
112
130
|
});
|
|
113
131
|
|
|
114
132
|
export const hasHelpBm25 = async (db: SQL = sql): Promise<boolean> => {
|
|
@@ -130,6 +148,7 @@ export const queryHelp = async (
|
|
|
130
148
|
limit?: number;
|
|
131
149
|
cursor?: string;
|
|
132
150
|
bm25?: boolean;
|
|
151
|
+
references?: readonly string[];
|
|
133
152
|
},
|
|
134
153
|
db: SQL = sql,
|
|
135
154
|
): Promise<Row[]> => {
|
|
@@ -145,10 +164,11 @@ export const queryHelp = async (
|
|
|
145
164
|
const pending = db.unsafe<Row[]>(
|
|
146
165
|
`WITH active AS (
|
|
147
166
|
SELECT * FROM jsonb_to_recordset(($1::text)::jsonb) AS a(app_id text, app_name text, manifest_hash text, locales text[])
|
|
148
|
-
) SELECT d.app_id, a.app_name, d.manifest_hash, d.locale, d.document_id, d.title, d.description, d.icon, d.sort_order
|
|
167
|
+
) SELECT d.app_id, a.app_name, d.manifest_hash, d.locale, d.document_id, d.title, d.description, d.icon, d.sort_order, d.kind, d.needs, d.next_id, d.related
|
|
149
168
|
${input.body ? ", d.markdown" : ""}
|
|
150
169
|
FROM help.documents d JOIN active a ON a.app_id=d.app_id AND a.manifest_hash=d.manifest_hash
|
|
151
170
|
WHERE length($2::text) >= 0 AND d.locale=ANY(a.locales)
|
|
171
|
+
AND ($5::text[] IS NULL OR (d.app_id || '/' || d.document_id)=ANY($5::text[]))
|
|
152
172
|
AND NOT EXISTS (SELECT 1 FROM help.documents preferred
|
|
153
173
|
WHERE preferred.app_id=d.app_id AND preferred.manifest_hash=d.manifest_hash AND preferred.document_id=d.document_id
|
|
154
174
|
AND preferred.locale=ANY(a.locales) AND array_position(a.locales, preferred.locale)<array_position(a.locales, d.locale))
|
|
@@ -162,12 +182,13 @@ export const queryHelp = async (
|
|
|
162
182
|
: ""
|
|
163
183
|
}
|
|
164
184
|
d.app_id COLLATE "C", ${input.cursor !== undefined ? "" : "d.sort_order,"} d.document_id COLLATE "C"
|
|
165
|
-
${input.limit !== undefined ? "LIMIT $
|
|
185
|
+
${input.limit !== undefined ? "LIMIT $6" : ""}`,
|
|
166
186
|
[
|
|
167
187
|
JSON.stringify(selection),
|
|
168
188
|
input.query ?? "",
|
|
169
189
|
input.documentId ?? null,
|
|
170
190
|
input.cursor ?? null,
|
|
191
|
+
input.references ? toPgTextArray([...input.references]) : null,
|
|
171
192
|
...(input.limit !== undefined ? [input.limit] : []),
|
|
172
193
|
],
|
|
173
194
|
);
|