@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.
- package/package.json +8 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +11 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +10 -0
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +460 -66
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +46 -3
- package/src/ai/readable-html.ts +1057 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/help.ts +23 -4
- package/src/api/index.ts +2 -1
- package/src/api/mcp.ts +8 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +17 -2
- package/src/cli/admin/workflows.ts +1 -0
- package/src/config/env.ts +14 -0
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/help.ts +136 -10
- package/src/server/services/access.ts +5 -1
- package/src/server/services/geo.ts +21 -4
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -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 +22 -3
- package/src/services/notifications/browser.ts +16 -5
- 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/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +67 -43
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- 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/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +62 -11
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +72 -25
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/index.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/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +34 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +11 -0
- package/src/ssr/shell-shortcuts.ts +19 -0
- package/src/styles/effects.css +689 -185
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/resource-search.css +25 -0
- package/src/styles/tokens.css +23 -6
- package/src/styles/utilities-navigation.css +5 -21
- 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/contracts/app.ts
CHANGED
|
@@ -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
|
|
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([
|
|
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[];
|
package/src/contracts/shared.ts
CHANGED
|
@@ -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;
|
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
|
});
|
|
@@ -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
|
-
|
|
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
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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
|
+
};
|