@k2b/cloud 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/scripts/README.md +1 -0
- package/scripts/cli-pack.ts +123 -0
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/_internal/shell-focus.ts +11 -0
- package/src/access/PermissionEditor.browser-harness.tsx +46 -0
- package/src/access/PermissionEditor.tsx +38 -9
- package/src/access/ui.ts +1 -0
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/browser.ts +1 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/answer-links.ts +8 -0
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +12 -0
- package/src/ai/chat/primitives.tsx +2 -1
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-tasks.ts +65 -1
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/executor.ts +46 -3
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/migrate.ts +19 -2
- package/src/ai/readable-html.ts +1057 -0
- package/src/ai/types.ts +1 -0
- package/src/api/help.ts +23 -4
- package/src/api/mcp.ts +8 -2
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +5 -1
- package/src/browser/shell-focus.ts +67 -0
- package/src/cli/admin/workflows.ts +1 -0
- package/src/cli/commands.ts +2 -0
- package/src/cli/plugin.ts +5 -5
- package/src/config/env.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/server/help.ts +136 -10
- package/src/server/services/geo.ts +21 -4
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +2 -0
- package/src/services/network-security.ts +28 -0
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- package/src/services/settings/index.ts +48 -26
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/shared/network-address.ts +9 -0
- package/src/ssr/CloudShell.island.tsx +2 -0
- package/src/ssr/GlobalSearchTrigger.tsx +1 -1
- package/src/ssr/Layout.tsx +28 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutHelpTrigger.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -1
- package/src/ssr/ProfilePreferences.tsx +1 -1
- package/src/ssr/RailApps.tsx +1 -1
- package/src/ssr/ShellFocusToggle.tsx +43 -0
- package/src/ssr/ShellRuntime.island.tsx +53 -26
- package/src/ssr/app-launchpad.tsx +1 -1
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/platform-messages.ts +14 -0
- package/src/ssr/shell-data.tsx +7 -0
- package/src/ssr/shell-focus.browser-harness.tsx +83 -0
- package/src/ssr/shell-shortcuts.ts +8 -0
- package/src/styles/effects.css +688 -185
- package/src/styles/global.css +155 -26
- package/src/styles/resource-search.css +25 -0
- package/src/styles/utilities-navigation.css +11 -3
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- package/src/workflows/store/retention.ts +157 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AppRegistryHelpSummary } from "../../contracts/registry";
|
|
2
2
|
import type { RequestActor } from "../../contracts/shared";
|
|
3
|
-
import type { HelpDocumentManifest } from "../../shared/help";
|
|
3
|
+
import type { HelpAccessLevel, HelpArticleLink, HelpDocumentManifest, HelpKind } from "../../shared/help";
|
|
4
4
|
|
|
5
5
|
export type HelpDocument = {
|
|
6
6
|
id: string;
|
|
@@ -8,6 +8,10 @@ export type HelpDocument = {
|
|
|
8
8
|
icon?: string;
|
|
9
9
|
description?: string;
|
|
10
10
|
order: number;
|
|
11
|
+
kind: HelpKind;
|
|
12
|
+
needs?: HelpAccessLevel;
|
|
13
|
+
next?: string;
|
|
14
|
+
related: readonly string[];
|
|
11
15
|
markdown: string;
|
|
12
16
|
searchText: string;
|
|
13
17
|
};
|
|
@@ -30,8 +34,12 @@ export type HelpMetadata = {
|
|
|
30
34
|
description?: string;
|
|
31
35
|
icon?: string;
|
|
32
36
|
order: number;
|
|
37
|
+
kind: HelpKind;
|
|
38
|
+
needs?: HelpAccessLevel;
|
|
39
|
+
next?: string;
|
|
40
|
+
related: readonly string[];
|
|
33
41
|
};
|
|
34
|
-
export type HelpArticle = HelpMetadata & { markdown: string };
|
|
42
|
+
export type HelpArticle = HelpMetadata & { markdown: string; links: Readonly<Record<string, HelpArticleLink>> };
|
|
35
43
|
export type HelpMatch = Pick<HelpMetadata, "appId" | "appName" | "locale" | "documentId" | "title" | "description"> & { kind: "help" };
|
|
36
44
|
export type HelpSearchInput = { query: string; appId?: string; limit?: number };
|
|
37
45
|
export type HelpReadInput = { appId: string; documentId: string };
|
package/src/services/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { accountIdentities, AccountIdentityError } from "./accounts/identities";
|
|
|
8
8
|
export type { AccountIdentityUser, AccountIdentityGroup, AccountIdentityPage, AccountIdentityAvailability } from "./accounts/identities";
|
|
9
9
|
export type { AccountIdentityReconciliation } from "./accounts/identity-reconciliation";
|
|
10
10
|
export { providers } from "./providers";
|
|
11
|
+
export { createAllowedNetworkFetch } from "./allowed-network-fetch";
|
|
11
12
|
export { requestPublicHttps, type PublicHttpInput } from "./public-http";
|
|
12
13
|
export { authFlows } from "./auth-flows";
|
|
13
14
|
export { toPgIntArray, toPgTextArray, toPgUuidArray, escapeLikePattern, isUniqueViolation } from "./postgres";
|
|
@@ -221,6 +222,7 @@ export type {
|
|
|
221
222
|
|
|
222
223
|
export {
|
|
223
224
|
NetworkTargetBlockedError,
|
|
225
|
+
isNetworkTargetBlocked,
|
|
224
226
|
pinnedNetworkLookup,
|
|
225
227
|
resolvePublicNetworkAddresses,
|
|
226
228
|
resolveAllowedNetworkAddresses,
|
|
@@ -52,6 +52,34 @@ export const resolveAllowedNetworkAddresses = async (
|
|
|
52
52
|
return normalized;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
/** Save-time hint only: every request must still resolve, validate and pin its target. */
|
|
56
|
+
export const isNetworkTargetBlocked = async (
|
|
57
|
+
hostname: string,
|
|
58
|
+
allowlist: NetworkAllowlist,
|
|
59
|
+
{
|
|
60
|
+
resolve = resolveAllowedNetworkAddresses,
|
|
61
|
+
timeoutMs = 5000,
|
|
62
|
+
}: {
|
|
63
|
+
resolve?: typeof resolveAllowedNetworkAddresses;
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
} = {},
|
|
66
|
+
): Promise<boolean> => {
|
|
67
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
68
|
+
try {
|
|
69
|
+
await Promise.race([
|
|
70
|
+
resolve(hostname, allowlist),
|
|
71
|
+
new Promise<void>((resolve) => {
|
|
72
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
73
|
+
}),
|
|
74
|
+
]);
|
|
75
|
+
return false;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return error instanceof NetworkTargetBlockedError;
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
55
83
|
export const resolvePublicNetworkAddresses = (rawHostname: string, lookup?: NetworkLookup): Promise<PublicNetworkAddress[]> =>
|
|
56
84
|
resolveAllowedNetworkAddresses(rawHostname, EMPTY_NETWORK_ALLOWLIST, lookup);
|
|
57
85
|
|
|
@@ -12,7 +12,7 @@ import { allowedMailProfile, type MessageRow, messageAttachmentRefs, messageReco
|
|
|
12
12
|
import { OutgoingMailError, resolveMailCredentials } from "./store";
|
|
13
13
|
import { mailAttachments, mailSettled } from "./sync";
|
|
14
14
|
import { smtpFailureMessage } from "./test-send";
|
|
15
|
-
import {
|
|
15
|
+
import { resolveMailTransport } from "./transport";
|
|
16
16
|
|
|
17
17
|
export const cleanupMailAttachments = async (id: string): Promise<void> => {
|
|
18
18
|
const row = await outgoingMailMessages.read(id);
|
|
@@ -136,7 +136,12 @@ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal)
|
|
|
136
136
|
socket.once("connect", () => {
|
|
137
137
|
if (attempt.signal.aborted) socket.destroy();
|
|
138
138
|
});
|
|
139
|
-
const
|
|
139
|
+
const resolved = await resolveMailTransport(credentials, attempt.signal).catch((error: unknown) => {
|
|
140
|
+
socket.destroy();
|
|
141
|
+
throw error;
|
|
142
|
+
});
|
|
143
|
+
// Nodemailer cannot fall back across IP literals; each queued retry rotates to the next checked address.
|
|
144
|
+
const transport = resolved.build(Math.max(0, row.attempt_count - 1), socket);
|
|
140
145
|
const stop = () => {
|
|
141
146
|
socket.destroy();
|
|
142
147
|
transport.close();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { env } from "../../config/env";
|
|
2
|
+
import { networkAddressFamily, normalizeNetworkHostname, parseNetworkAllowlistEnv } from "../../shared/network-address";
|
|
3
|
+
import { NetworkTargetBlockedError, resolveAllowedNetworkAddresses } from "../network-security";
|
|
4
|
+
|
|
5
|
+
export const outgoingMailAllowlist = () => parseNetworkAllowlistEnv("OUTGOING_MAIL_ALLOWED_TARGETS", env.OUTGOING_MAIL_ALLOWED_TARGETS);
|
|
6
|
+
|
|
7
|
+
/** One bounded lookup shared by SMTP and bounce IMAP; clients use only the checked addresses. */
|
|
8
|
+
export const resolveMailHost = async (host: string, signal?: AbortSignal, resolve = resolveAllowedNetworkAddresses) => {
|
|
9
|
+
const hostname = normalizeNetworkHostname(host);
|
|
10
|
+
const deadline = new AbortController();
|
|
11
|
+
const timer = setTimeout(() => deadline.abort(new Error("Outgoing mail DNS lookup timed out.")), 5000);
|
|
12
|
+
const cancellation = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
|
|
13
|
+
let abort: (() => void) | undefined;
|
|
14
|
+
try {
|
|
15
|
+
cancellation.throwIfAborted();
|
|
16
|
+
const addresses = await Promise.race([
|
|
17
|
+
resolve(hostname, outgoingMailAllowlist()),
|
|
18
|
+
new Promise<never>((_, reject) => {
|
|
19
|
+
abort = () => reject(cancellation.reason);
|
|
20
|
+
cancellation.addEventListener("abort", abort, { once: true });
|
|
21
|
+
if (cancellation.aborted) abort();
|
|
22
|
+
}),
|
|
23
|
+
]);
|
|
24
|
+
return {
|
|
25
|
+
host: hostname,
|
|
26
|
+
addresses: [...addresses.filter((entry) => entry.family === 4), ...addresses.filter((entry) => entry.family === 6)],
|
|
27
|
+
servername: networkAddressFamily(hostname) ? undefined : hostname,
|
|
28
|
+
};
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error instanceof NetworkTargetBlockedError)
|
|
31
|
+
throw new NetworkTargetBlockedError(
|
|
32
|
+
"Outgoing mail target is on an internal network. An operator can allow it with OUTGOING_MAIL_ALLOWED_TARGETS.",
|
|
33
|
+
);
|
|
34
|
+
throw error;
|
|
35
|
+
} finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
if (abort) cancellation.removeEventListener("abort", abort);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { audit } from "../audit";
|
|
2
2
|
import { get } from "../settings";
|
|
3
3
|
import { type MailAuditContext, OutgoingMailError, resolveMailCredentials } from "./store";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveMailTransport } from "./transport";
|
|
5
5
|
|
|
6
6
|
export const smtpFailureMessage = (error: unknown, secrets: readonly (string | null)[]): string => {
|
|
7
7
|
let message = error instanceof Error ? error.message : "SMTP delivery failed.";
|
|
@@ -17,8 +17,23 @@ export const outgoingMailTest = {
|
|
|
17
17
|
const event = { ...context, action: "outgoing_mail.profile.test", target: { type: "outgoing_mail_profile", id: key } };
|
|
18
18
|
// Fail before the external effect if audit storage is unavailable.
|
|
19
19
|
await audit.record({ ...event, outcome: "allowed" });
|
|
20
|
-
|
|
20
|
+
let transport: ReturnType<Awaited<ReturnType<typeof resolveMailTransport>>["build"]> | undefined;
|
|
21
21
|
try {
|
|
22
|
+
const resolved = await resolveMailTransport(profile);
|
|
23
|
+
for (let index = 0; index < resolved.count; index++) {
|
|
24
|
+
transport = resolved.build(index);
|
|
25
|
+
try {
|
|
26
|
+
// CONN also labels socket errors after DATA. Only fall back during a
|
|
27
|
+
// preflight that cannot send a message, then send once to that address.
|
|
28
|
+
await transport.verify();
|
|
29
|
+
break;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
transport.close();
|
|
32
|
+
transport = undefined;
|
|
33
|
+
if (!(error instanceof Error) || !("command" in error) || error.command !== "CONN" || index === resolved.count - 1) throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (!transport) throw new Error("No validated SMTP address.");
|
|
22
37
|
await transport.sendMail({
|
|
23
38
|
from: { address: profile.fromAddress, name: profile.fromName ?? (await get<string>("app.name")) },
|
|
24
39
|
to: recipient,
|
|
@@ -34,7 +49,7 @@ export const outgoingMailTest = {
|
|
|
34
49
|
await audit.recordResultAfterSideEffect({ ...event, result: { ok: false, error: { code: "smtp_failed", message, status: 500 } } });
|
|
35
50
|
throw new OutgoingMailError("smtp_failed", message, 502);
|
|
36
51
|
} finally {
|
|
37
|
-
transport
|
|
52
|
+
transport?.close();
|
|
38
53
|
}
|
|
39
54
|
},
|
|
40
55
|
};
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
import type { Socket } from "node:net";
|
|
2
2
|
import { createTransport } from "nodemailer";
|
|
3
|
+
import { resolveMailHost } from "./resolve-host";
|
|
3
4
|
import type { resolveMailCredentials } from "./store";
|
|
4
5
|
|
|
5
|
-
export const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
6
|
+
export const resolveMailTransport = async (profile: Awaited<ReturnType<typeof resolveMailCredentials>>, signal?: AbortSignal) => {
|
|
7
|
+
const target = await resolveMailHost(profile.smtpHost, signal);
|
|
8
|
+
signal?.throwIfAborted();
|
|
9
|
+
const count = target.addresses.length;
|
|
10
|
+
return {
|
|
11
|
+
count,
|
|
12
|
+
build: (index: number, socket?: Socket) =>
|
|
13
|
+
createTransport({
|
|
14
|
+
// Workers own the raw socket so timeout/shutdown can destroy it. Nodemailer
|
|
15
|
+
// still owns SMTP and upgrades this socket for implicit TLS or STARTTLS.
|
|
16
|
+
...(socket ? { socket } : {}),
|
|
17
|
+
host: target.addresses[Math.max(0, index) % count]!.address,
|
|
18
|
+
servername: target.servername,
|
|
19
|
+
tls: { servername: target.servername },
|
|
20
|
+
port: profile.smtpPort,
|
|
21
|
+
secure: profile.smtpSecure,
|
|
22
|
+
...(profile.smtpUser ? { auth: { user: profile.smtpUser, pass: profile.smtpPassword ?? "" } } : {}),
|
|
23
|
+
connectionTimeout: 10000,
|
|
24
|
+
greetingTimeout: 10000,
|
|
25
|
+
socketTimeout: 60000,
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* Custom DB values are encrypted at rest using APP_SECRET. All read/write
|
|
7
7
|
* goes through the Redis cache-aside layer in `./store.ts` (5-minute TTL).
|
|
8
8
|
* `loadCache()` runs once at boot to normalize legacy rows and bootstrap
|
|
9
|
-
* env-backed entries into Postgres.
|
|
9
|
+
* env-backed entries into Postgres. Startup never replaces an existing row;
|
|
10
|
+
* env bootstrap only fills missing keys.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { redis, sql } from "bun";
|
|
@@ -17,11 +18,12 @@ import { bulkRead, deleteKey, readKey, writeKey } from "./store";
|
|
|
17
18
|
type SqlClient = typeof sql;
|
|
18
19
|
|
|
19
20
|
type StoredRow = { key: string; value: string };
|
|
20
|
-
type PendingRow = {
|
|
21
|
+
type PendingRow = { value: unknown; originalCiphertext: string };
|
|
21
22
|
type NormalizationStats = {
|
|
22
23
|
encryptedLoaded: number;
|
|
23
24
|
normalizedUpdated: number;
|
|
24
25
|
envBootstrapped: number;
|
|
26
|
+
undecryptable: number;
|
|
25
27
|
invalidSkipped: number;
|
|
26
28
|
};
|
|
27
29
|
|
|
@@ -58,23 +60,18 @@ const shouldBootstrapValue = (def: SettingDef, value: unknown): boolean => {
|
|
|
58
60
|
}
|
|
59
61
|
};
|
|
60
62
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
ON CONFLICT (key)
|
|
67
|
-
DO UPDATE SET value = ${encryptedValue}, updated_at = now()
|
|
68
|
-
`;
|
|
69
|
-
await redis.del(REDIS_KEY(key));
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const normalizeStoredEntries = async (): Promise<{ rows: Array<{ key: string; value: unknown }>; stats: NormalizationStats }> => {
|
|
63
|
+
const normalizeStoredEntries = async (): Promise<{
|
|
64
|
+
rows: Array<{ key: string; value: unknown }>;
|
|
65
|
+
storedKeys: Set<string>;
|
|
66
|
+
stats: NormalizationStats;
|
|
67
|
+
}> => {
|
|
73
68
|
const rows = await sql<StoredRow[]>`SELECT key, value FROM settings.entries`;
|
|
69
|
+
const storedKeys = new Set(rows.map((row) => row.key));
|
|
74
70
|
const stats: NormalizationStats = {
|
|
75
71
|
encryptedLoaded: 0,
|
|
76
72
|
normalizedUpdated: 0,
|
|
77
73
|
envBootstrapped: 0,
|
|
74
|
+
undecryptable: 0,
|
|
78
75
|
invalidSkipped: 0,
|
|
79
76
|
};
|
|
80
77
|
const pendingRows = new Map<string, PendingRow>();
|
|
@@ -82,11 +79,17 @@ const normalizeStoredEntries = async (): Promise<{ rows: Array<{ key: string; va
|
|
|
82
79
|
for (const row of rows) {
|
|
83
80
|
try {
|
|
84
81
|
const value = await decryptValue(row.value);
|
|
85
|
-
pendingRows.set(row.key, {
|
|
82
|
+
pendingRows.set(row.key, { value, originalCiphertext: row.value });
|
|
86
83
|
stats.encryptedLoaded += 1;
|
|
87
84
|
} catch {
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
// An unreadable value is not a missing value (#1295). Usually this
|
|
86
|
+
// process runs with another APP_SECRET than the writer; applications
|
|
87
|
+
// with the right key still read this row. Keep its key in `storedKeys`
|
|
88
|
+
// so env bootstrap never replaces it, and never log the value.
|
|
89
|
+
console.warn(
|
|
90
|
+
`[settings] cannot decrypt stored value for "${row.key}" with this APP_SECRET; keeping the row unchanged, reads use the environment fallback or default`,
|
|
91
|
+
);
|
|
92
|
+
stats.undecryptable += 1;
|
|
90
93
|
}
|
|
91
94
|
}
|
|
92
95
|
|
|
@@ -109,28 +112,47 @@ const normalizeStoredEntries = async (): Promise<{ rows: Array<{ key: string; va
|
|
|
109
112
|
|
|
110
113
|
normalized.push({ key, value: validated.value });
|
|
111
114
|
|
|
112
|
-
if (
|
|
113
|
-
await
|
|
114
|
-
|
|
115
|
+
if (!isEqual(row.value, validated.value)) {
|
|
116
|
+
const encryptedValue = await encryptValue(validated.value);
|
|
117
|
+
const updated = await sql<{ key: string }[]>`
|
|
118
|
+
UPDATE settings.entries SET value = ${encryptedValue}, updated_at = now()
|
|
119
|
+
WHERE key = ${key} AND value = ${row.originalCiphertext}
|
|
120
|
+
RETURNING key
|
|
121
|
+
`;
|
|
122
|
+
if (updated.length > 0) {
|
|
123
|
+
await redis.del(REDIS_KEY(key));
|
|
124
|
+
stats.normalizedUpdated += 1;
|
|
125
|
+
}
|
|
115
126
|
}
|
|
116
127
|
}
|
|
117
128
|
|
|
118
|
-
return { rows: normalized, stats };
|
|
129
|
+
return { rows: normalized, storedKeys, stats };
|
|
119
130
|
};
|
|
120
131
|
|
|
121
132
|
const bootstrapEnvBackedEntries = async (config: {
|
|
122
133
|
rows: Array<{ key: string; value: unknown }>;
|
|
134
|
+
storedKeys: Set<string>;
|
|
123
135
|
stats: NormalizationStats;
|
|
124
136
|
}): Promise<Array<{ key: string; value: unknown }>> => {
|
|
125
137
|
const rowsByKey = new Map(config.rows.map((row) => [row.key, row.value]));
|
|
126
138
|
|
|
127
139
|
for (const def of SETTINGS) {
|
|
128
|
-
if
|
|
140
|
+
// Existing rows block bootstrap even if unreadable, unknown, or invalid.
|
|
141
|
+
if (config.storedKeys.has(def.key)) continue;
|
|
129
142
|
|
|
130
143
|
const envValue = resolveEnvValue(def, "bootstrap");
|
|
131
144
|
if (!shouldBootstrapValue(def, envValue)) continue;
|
|
132
145
|
|
|
133
|
-
await
|
|
146
|
+
const encryptedValue = await encryptValue(envValue);
|
|
147
|
+
const inserted = await sql<{ key: string }[]>`
|
|
148
|
+
INSERT INTO settings.entries (key, value, updated_at)
|
|
149
|
+
VALUES (${def.key}, ${encryptedValue}, now())
|
|
150
|
+
ON CONFLICT (key) DO NOTHING
|
|
151
|
+
RETURNING key
|
|
152
|
+
`;
|
|
153
|
+
if (inserted.length === 0) continue;
|
|
154
|
+
|
|
155
|
+
await redis.del(REDIS_KEY(def.key));
|
|
134
156
|
rowsByKey.set(def.key, envValue);
|
|
135
157
|
config.stats.envBootstrapped += 1;
|
|
136
158
|
}
|
|
@@ -145,11 +167,11 @@ const bootstrapEnvBackedEntries = async (config: {
|
|
|
145
167
|
*/
|
|
146
168
|
export async function loadCache(): Promise<void> {
|
|
147
169
|
getAppSecret();
|
|
148
|
-
const { rows, stats } = await normalizeStoredEntries();
|
|
149
|
-
const bootstrappedRows = await bootstrapEnvBackedEntries({ rows, stats });
|
|
170
|
+
const { rows, storedKeys, stats } = await normalizeStoredEntries();
|
|
171
|
+
const bootstrappedRows = await bootstrapEnvBackedEntries({ rows, storedKeys, stats });
|
|
150
172
|
|
|
151
173
|
console.log(
|
|
152
|
-
`[settings] loaded ${bootstrappedRows.length} custom setting(s) (${stats.encryptedLoaded} encrypted, ${stats.normalizedUpdated} normalized, ${stats.envBootstrapped} env-bootstrapped, ${stats.invalidSkipped} skipped)`,
|
|
174
|
+
`[settings] loaded ${bootstrappedRows.length} custom setting(s) (${stats.encryptedLoaded} encrypted, ${stats.normalizedUpdated} normalized, ${stats.envBootstrapped} env-bootstrapped, ${stats.undecryptable} undecryptable, ${stats.invalidSkipped} skipped)`,
|
|
153
175
|
);
|
|
154
176
|
}
|
|
155
177
|
|
package/src/shared/help.ts
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
import { localeFallbackChain } from "./locale";
|
|
2
2
|
|
|
3
|
+
export const HELP_KINDS = ["start", "task", "reference", "troubleshoot"] as const;
|
|
4
|
+
export type HelpKind = (typeof HELP_KINDS)[number];
|
|
5
|
+
/** The glossary's access levels; Help names only these, never app-specific permissions. */
|
|
6
|
+
export const HELP_ACCESS_LEVELS = ["view", "edit", "manage"] as const;
|
|
7
|
+
export type HelpAccessLevel = (typeof HELP_ACCESS_LEVELS)[number];
|
|
8
|
+
/** An article another article points to, resolved for the current reader. */
|
|
9
|
+
export type HelpArticleLink = {
|
|
10
|
+
appId: string;
|
|
11
|
+
appName: string;
|
|
12
|
+
/** The target app's accent colour (registry appearance), when it declares one. */
|
|
13
|
+
accent?: string;
|
|
14
|
+
documentId: string;
|
|
15
|
+
title: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
icon?: string;
|
|
18
|
+
/** Canonical page path: `/help/apps/<appId>/<documentId>`. */
|
|
19
|
+
url: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type HelpLinkResolver = (ref: { appId?: string; documentId: string }) => HelpArticleLink | undefined;
|
|
23
|
+
|
|
24
|
+
/** References have the same app-id grammar as capability declarations. */
|
|
25
|
+
export const HELP_REFERENCE_PATTERN = /^(?:[a-z][a-z0-9-]*\/)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
26
|
+
export const parseHelpReference = (value: string): { appId?: string; documentId: string; fragment: string } | undefined => {
|
|
27
|
+
const [reference = "", ...fragments] = value.split("#");
|
|
28
|
+
if (!HELP_REFERENCE_PATTERN.test(reference)) return undefined;
|
|
29
|
+
const parts = reference.split("/");
|
|
30
|
+
return {
|
|
31
|
+
appId: parts.length === 2 ? parts[0] : undefined,
|
|
32
|
+
documentId: parts.at(-1)!,
|
|
33
|
+
fragment: fragments.length ? `#${fragments.join("#")}` : "",
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
3
37
|
export type HelpDocumentManifest = {
|
|
4
38
|
/** Resolved content locale on central Help manifests. */
|
|
5
39
|
locale?: string;
|
|
@@ -8,6 +42,8 @@ export type HelpDocumentManifest = {
|
|
|
8
42
|
icon?: string;
|
|
9
43
|
description?: string;
|
|
10
44
|
order: number;
|
|
45
|
+
/** Where the Help start lists the article; missing means `task`. */
|
|
46
|
+
kind?: HelpKind;
|
|
11
47
|
/** Core-owned endpoint used for debounced full-text search. */
|
|
12
48
|
searchUrl: string;
|
|
13
49
|
url: string;
|
|
@@ -24,8 +60,13 @@ export type HelpDocumentPayload = {
|
|
|
24
60
|
locale: string;
|
|
25
61
|
id: string;
|
|
26
62
|
title: string;
|
|
63
|
+
/** Agent text with Help-only presentation converted to portable Markdown. */
|
|
27
64
|
markdown: string;
|
|
28
65
|
html: string;
|
|
66
|
+
kind: HelpKind;
|
|
67
|
+
needs?: HelpAccessLevel;
|
|
68
|
+
next?: HelpArticleLink;
|
|
69
|
+
related: HelpArticleLink[];
|
|
29
70
|
};
|
|
30
71
|
|
|
31
72
|
/** Exact locale, then BCP 47 ancestors, then the corpus base locale. */
|
package/src/shared/index.ts
CHANGED
|
@@ -46,7 +46,15 @@ export * from "./login-method";
|
|
|
46
46
|
export { markdown } from "./markdown";
|
|
47
47
|
export { markdownCalloutSanitizeOptions } from "./markdown/callout-sanitizer";
|
|
48
48
|
export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "./markdown/formula";
|
|
49
|
-
export {
|
|
49
|
+
export {
|
|
50
|
+
createProgressValue,
|
|
51
|
+
evaluateFormula,
|
|
52
|
+
evaluateTableCell,
|
|
53
|
+
formatValue,
|
|
54
|
+
isFormula,
|
|
55
|
+
isTotalRow,
|
|
56
|
+
parseProgressValue,
|
|
57
|
+
} from "./markdown/formula";
|
|
50
58
|
export type { MockCover, MockCoverIcon, MockCoverOptions, MockCoverTheme } from "./mock-cover";
|
|
51
59
|
export { createMockCover, createMockCoverSvg, parseDataUrl } from "./mock-cover";
|
|
52
60
|
export * from "./network-address";
|