@k2b/cloud 0.37.0 → 0.39.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 (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -58,6 +58,7 @@ export type AppAdminNavigationGroup = {
58
58
 
59
59
  export type AppPresentationTranslation = {
60
60
  name?: string;
61
+ panelLabel?: string;
61
62
  description?: string;
62
63
  /** Group labels keyed by `adminNav[].id`. */
63
64
  adminGroups?: Readonly<Record<string, string>>;
@@ -107,6 +108,18 @@ export type AppPwaPart = {
107
108
  requiresRoles?: Role[];
108
109
  };
109
110
 
111
+ /** One same-origin panel page contributed by an application. Routes and services still authorize. */
112
+ export type AppPanel = {
113
+ /** GET page below the app's routes, rendered with PanelLayout. */
114
+ href: string;
115
+ /** Panel name in the app's base locale; translated with presentation.translations[locale].panelLabel. */
116
+ label: string;
117
+ /** Icon class, for example "ti ti-sparkles". */
118
+ icon: string;
119
+ /** Coarse visibility like nav.requiresRoles; the slot grants no access. */
120
+ requiresRoles?: Role[];
121
+ };
122
+
110
123
  export type AppMeta = {
111
124
  platformPermissions?: readonly PlatformPermission[];
112
125
  id: string;
@@ -142,6 +155,7 @@ export type AppMeta = {
142
155
  searchLinks?: readonly AppSearchLink[];
143
156
  /** The app's pages in the installable mobile app (preview), at `href` = `/pwa/<id>`. */
144
157
  pwa?: AppPwaPart;
158
+ panel?: AppPanel;
145
159
  /**
146
160
  * Dashboard widget endpoints this app exposes. Each entry references an
147
161
  * HTTP endpoint that returns a `WidgetResponse` (see `contracts/widgets.ts`).
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { CapabilityManifest } from "./capabilities";
2
+ import { type CapabilityManifest, CloudResourceRefSchema } from "./capabilities";
3
3
  import { type CapabilityContract, type CapabilityContractIssue, capabilityContractIssues } from "./capability-compatibility";
4
4
 
5
5
  /**
@@ -34,6 +34,26 @@ export const FileProviderListInputSchema = z
34
34
  })
35
35
  .strict();
36
36
 
37
+ /** Caller hints are bounded independently of provider pagination. */
38
+ export const FILE_PROVIDER_CONTEXT_MAX_REFS = 20;
39
+ export const FileProviderContextSchema = z
40
+ .object({
41
+ refs: z
42
+ .array(CloudResourceRefSchema)
43
+ .max(FILE_PROVIDER_CONTEXT_MAX_REFS)
44
+ .describe(
45
+ "Hints about what the caller works on, for example the contacts a mail goes to; never a permission. Empty means nothing resolved.",
46
+ ),
47
+ })
48
+ .strict()
49
+ .describe("Caller resource hints, never a permission or proof of access.");
50
+ export const FileProviderContextListInputSchema = FileProviderListInputSchema.extend({
51
+ context: FileProviderContextSchema.optional().describe(
52
+ "Optional caller context; keep it unchanged with cursor like parent, query and limit.",
53
+ ),
54
+ }).strict();
55
+ export type FileProviderContext = z.output<typeof FileProviderContextSchema>;
56
+
37
57
  const entryFacts = {
38
58
  id: ProviderIdSchema,
39
59
  name: z.string().min(1).max(255),
@@ -123,6 +143,33 @@ export const fileProvider = {
123
143
  },
124
144
  } as const satisfies Record<string, CapabilityContract>;
125
145
 
146
+ /** Optional context-aware list contract; the base provider contract stays unchanged. */
147
+ export const fileProviderContextList = { ...fileProvider.list, input: FileProviderContextListInputSchema } satisfies CapabilityContract;
148
+
149
+ /** Only explicitly named, compatible context inputs opt in; open input objects do not. */
150
+ export const fileProviderAcceptsContext = (manifest: CapabilityManifest): boolean => {
151
+ if (!manifest.fileProvider) return false;
152
+ const operation = manifest.queries.find((query) => query.localId === manifest.fileProvider?.list);
153
+ if (!operation) return false;
154
+ const root = operation.inputSchema;
155
+ let schema: unknown = root;
156
+ const visited = new Set<unknown>();
157
+ const object = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
158
+ while (object(schema) && typeof schema.$ref === "string") {
159
+ if (visited.has(schema)) return false;
160
+ visited.add(schema);
161
+ if (!schema.$ref.startsWith("#/$defs/")) return false;
162
+ const defs = root.$defs;
163
+ schema = object(defs) ? defs[schema.$ref.slice(8)] : undefined;
164
+ }
165
+ return (
166
+ object(schema) &&
167
+ object(schema.properties) &&
168
+ Object.hasOwn(schema.properties, "context") &&
169
+ capabilityContractIssues(fileProviderContextList, { kind: "query", operation }).length === 0
170
+ );
171
+ };
172
+
126
173
  export type FileProviderFunction = keyof typeof fileProvider;
127
174
  export const FILE_PROVIDER_FUNCTIONS = ["list", "read", "save"] as const satisfies readonly FileProviderFunction[];
128
175
 
@@ -96,7 +96,14 @@ export const MailErrorCodeSchema = z.enum([
96
96
  "smtp_failed",
97
97
  ]);
98
98
  export type MailErrorCode = z.infer<typeof MailErrorCodeSchema>;
99
- export const MailBounceErrorCodeSchema = z.enum(["open_failed", "search_failed", "apply_failed", "save_failed", "interrupted"]);
99
+ export const MailBounceErrorCodeSchema = z.enum([
100
+ "open_failed",
101
+ "target_blocked",
102
+ "search_failed",
103
+ "apply_failed",
104
+ "save_failed",
105
+ "interrupted",
106
+ ]);
100
107
  export type MailBounceErrorCode = z.infer<typeof MailBounceErrorCodeSchema>;
101
108
  export const AdminMailProfileSchema = MailProfileInputSchema.omit({ smtpPassword: true, revision: true, imap: true }).extend({
102
109
  key: MailProfileKeySchema,
@@ -1,4 +1,4 @@
1
- import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPresentationCatalog, AppSearchLink } from "./app";
1
+ import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPanel, AppPresentationCatalog, AppSearchLink } from "./app";
2
2
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
3
3
  import type { PlatformPermission } from "./outgoing-mail";
4
4
  import type { Role } from "./shared";
@@ -103,6 +103,8 @@ export type AppRegistryEntry = {
103
103
  searchLinks?: readonly AppSearchLink[];
104
104
  /** The app's part of the mobile app (preview); `href` is `/pwa/<id>`. Older readers ignore it. */
105
105
  pwa?: AppRegistryPwaPart;
106
+ /** Optional panel declaration; older readers ignore it. */
107
+ panel?: AppPanel;
106
108
  widgets?: AppRegistryWidget[];
107
109
  /** Setting keys declared by this app. Used by admin tooling to avoid treating live app-owned settings as legacy. */
108
110
  settingKeys?: readonly string[];
@@ -401,6 +401,8 @@ export type SettingEntry = {
401
401
  valueSource: SettingValueSource;
402
402
  resetValueSource: Exclude<SettingValueSource, "custom">;
403
403
  isCustom: boolean;
404
+ /** A stored row exists but this application cannot decrypt or validate it; `value` is the fallback in use. */
405
+ unreadable: boolean;
404
406
  templateVars?: string[];
405
407
  options?: SettingOption[];
406
408
  min?: number;
@@ -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
  });
@@ -629,7 +629,11 @@ export const listUsersWithAccess = async (params: {
629
629
  OR LOWER(uid) LIKE ${pattern} ESCAPE '\\'
630
630
  OR LOWER(group_names) LIKE ${pattern} ESCAPE '\\'
631
631
  )
632
- ORDER BY LOWER(display_name), id
632
+ -- A limit must never hide an exact login-name match.
633
+ ORDER BY
634
+ CASE WHEN LOWER(uid) = ${query || null}::text THEN 0 ELSE 1 END,
635
+ CASE WHEN LOWER(display_name) = ${query || null}::text THEN 0 ELSE 1 END,
636
+ LOWER(display_name), id
633
637
  LIMIT ${limit}
634
638
  `;
635
639
 
@@ -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
  };
@@ -329,7 +329,8 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
329
329
  // Same order as provisioning; settings write upgrades this lock in the same transaction.
330
330
  await lock(tx);
331
331
  if (parsed.data.enabled) await checkRange(tx, parsed.data, ranges);
332
- await settings.set(CONFIG_KEY, JSON.stringify(parsed.data), tx);
332
+ // complete value from explicit admin input; nothing is merged over a read (#1303)
333
+ await settings.set(CONFIG_KEY, JSON.stringify(parsed.data), tx, { replaceUnreadable: true });
333
334
  await audit.record(
334
335
  {
335
336
  action: "accounts.linux.configure",
@@ -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
+ };