@k2b/cloud 0.37.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.
Files changed (64) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/help-catalog.ts +123 -4
  3. package/src/_internal/help.ts +27 -6
  4. package/src/account/Avatar.tsx +20 -2
  5. package/src/account/ui.ts +1 -1
  6. package/src/ai/browser.ts +1 -0
  7. package/src/ai/chart-block.ts +44 -13
  8. package/src/ai/chat/chart-block.tsx +2 -11
  9. package/src/ai/chat/messages.ts +10 -0
  10. package/src/ai/chat/web-tools.tsx +23 -6
  11. package/src/ai/chat-tasks.ts +65 -1
  12. package/src/ai/default-tool-contracts.ts +30 -13
  13. package/src/ai/fetch-file-tool.ts +37 -3
  14. package/src/ai/migrate.ts +19 -2
  15. package/src/ai/readable-html.ts +1057 -0
  16. package/src/api/help.ts +23 -4
  17. package/src/api/mcp.ts +8 -2
  18. package/src/browser/FileChooser.tsx +11 -3
  19. package/src/browser/choose-files.browser-harness.tsx +4 -1
  20. package/src/browser/file-chooser-messages.ts +4 -0
  21. package/src/browser/file-providers.ts +14 -1
  22. package/src/browser/provider-folder.tsx +58 -5
  23. package/src/browser/resource-picker.tsx +5 -1
  24. package/src/cli/admin/workflows.ts +1 -0
  25. package/src/config/env.ts +14 -0
  26. package/src/contracts/file-provider.ts +48 -1
  27. package/src/contracts/outgoing-mail.ts +8 -1
  28. package/src/server/help.ts +136 -10
  29. package/src/server/services/geo.ts +21 -4
  30. package/src/services/allowed-network-fetch.ts +189 -0
  31. package/src/services/help/index.ts +46 -5
  32. package/src/services/help/store.ts +26 -5
  33. package/src/services/help/types.ts +10 -2
  34. package/src/services/index.ts +1 -0
  35. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  36. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  37. package/src/services/outgoing-mail/test-send.ts +18 -3
  38. package/src/services/outgoing-mail/transport.ts +24 -13
  39. package/src/services/settings/index.ts +48 -26
  40. package/src/shared/help.ts +41 -0
  41. package/src/shared/index.ts +9 -1
  42. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  43. package/src/shared/markdown/formula.ts +8 -8
  44. package/src/shared/markdown/index.ts +72 -11
  45. package/src/ssr/Layout.tsx +20 -2
  46. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  47. package/src/ssr/LayoutHelp.tsx +569 -410
  48. package/src/ssr/ShellRuntime.island.tsx +17 -25
  49. package/src/ssr/help-messages.ts +60 -38
  50. package/src/ssr/help.ts +3 -0
  51. package/src/ssr/layout-help-article.ts +41 -0
  52. package/src/ssr/layout-help-navigation.ts +0 -12
  53. package/src/ssr/layout-help-registry.tsx +4 -11
  54. package/src/ssr/layout-help-search.ts +9 -12
  55. package/src/ssr/layout-help-start.ts +68 -0
  56. package/src/ssr/platform-messages.ts +4 -0
  57. package/src/ssr/shell-data.tsx +7 -0
  58. package/src/ssr/shell-shortcuts.ts +8 -0
  59. package/src/styles/effects.css +688 -185
  60. package/src/styles/resource-search.css +25 -0
  61. package/src/workflows/store/events.ts +24 -0
  62. package/src/workflows/store/index.ts +8 -0
  63. package/src/workflows/store/observability.ts +6 -1
  64. package/src/workflows/store/retention.ts +157 -0
@@ -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 { type HelpDocumentManifest, type HelpDocumentPayload, type HelpSearchPayload, helpLocaleChain } from "../shared/help";
5
- import { markdownToPlainText, renderHelpMarkdown } from "../shared/markdown";
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 = Omit<HelpDefinitionDocument, "order"> & { order?: number };
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: renderHelpMarkdown(markdown, locale),
51
- searchText: markdownToPlainText(markdown),
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) return { ...document, order: document.order ?? 100 };
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
- return { ...document, icon: base.icon, description: document.description ?? base.description, order: base.order };
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 fetch(`${baseUrlResult.data}/geo/search?${params}`, { signal: geoSignal(config.signal) });
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 fetch(`${baseUrlResult.data}/geo/reverse?lat=${config.lat}&lng=${config.lon}`, { signal: geoSignal() });
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 select = async (appId?: string): Promise<HelpSelection[]> => {
40
- const apps = appId ? [await (dependencies.getApp ?? getApp)(appId)] : await (dependencies.listApps ?? listApps)();
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
- return row?.markdown === undefined ? null : { ...metadata(row), markdown: row.markdown };
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 { ensureSchema } from "../postgres";
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 $5" : ""}`,
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
  );