@k2b/cloud 0.39.0 → 0.40.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/README.md +8 -0
- package/package.json +8 -4
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +36 -15
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +2 -2
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/background-review.ts +72 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +10 -160
- package/src/ai/chat/view-block.tsx +15 -5
- package/src/ai/chat-task-contracts.ts +4 -1
- package/src/ai/chat-task-reasons-migrate.ts +7 -0
- package/src/ai/chat-task-reasons.ts +1 -1
- package/src/ai/chat-tasks.ts +55 -25
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +2 -0
- package/src/ai/migrate.ts +11 -2
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/routes.ts +145 -1
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +227 -24
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +24 -2
- package/src/ai/types.ts +28 -3
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +2 -0
- package/src/api/app-approval.ts +46 -5
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +2 -0
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +6 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/app-approval.ts +54 -5
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +45 -3
- package/src/browser/composer-insert.ts +10 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +11 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +412 -10
- package/src/browser/panel.ts +37 -0
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +120 -13
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/seen-hints.ts +35 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +7 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +11 -0
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/webauthn.ts +12 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +10 -2
- package/src/ssr/Layout.tsx +25 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +9 -1
- package/src/ssr/PanelRuntime.island.tsx +7 -0
- package/src/ssr/PanelSlot.tsx +53 -3
- package/src/ssr/ShellRuntime.island.tsx +11 -2
- package/src/ssr/app-color.ts +14 -9
- package/src/ssr/app-navigation.ts +16 -3
- package/src/ssr/compact-shell.browser-harness.ts +30 -22
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +2 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +53 -4
- package/src/ssr/platform-messages.ts +18 -0
- package/src/ssr/shell-data.tsx +2 -1
- package/src/styles/effects.css +198 -9
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +49 -12
- package/src/styles/panel.css +47 -4
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +17 -11
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +17 -22
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HELP_SHORTCUT_DESCRIPTION_MAX_LENGTH,
|
|
3
|
+
HELP_SHORTCUT_ID_MAX_LENGTH,
|
|
4
|
+
HELP_SHORTCUT_KEYS_MAX_LENGTH,
|
|
5
|
+
HELP_SHORTCUT_TITLE_MAX_LENGTH,
|
|
6
|
+
HELP_START_SHORTCUT_LIMIT,
|
|
7
|
+
type HelpShortcut,
|
|
8
|
+
} from "../ssr/layout-help-start";
|
|
9
|
+
import type { PanelContextMessage, PanelPageContext } from "./panel-context";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The zod-free half of the panel context contract, for code on every page: islands, the provider runtime and the
|
|
13
|
+
* slot markup must not pull the schema into their first load (shell and Mail first-load guards). The zod schema in
|
|
14
|
+
* ./panel-context stays the contract; a parity test keeps the check below at least as strict.
|
|
15
|
+
*/
|
|
16
|
+
export const PANEL_CONTEXT_PARAM = "cloud_panel_context";
|
|
17
|
+
|
|
18
|
+
/** Core is the platform's own surface. Every other provider asks once per app. */
|
|
19
|
+
export const panelProviderNeedsConsent = (appId: string): boolean => appId !== "core";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Only application-owned same-origin paths. The context must already be valid: Layout and the slot controller build
|
|
23
|
+
* it from validated parts. Null also removes a caller-supplied reserved parameter.
|
|
24
|
+
*/
|
|
25
|
+
export function withPanelContext(href: string, context: PanelPageContext | null): string {
|
|
26
|
+
// The parsed result decides, not the raw text: a query may hold a raw backslash (browsers keep it), while
|
|
27
|
+
// `/\\host` or `/\t/host` resolve to another origin and `/.//host` normalises to a protocol-relative path.
|
|
28
|
+
const base = "https://panel.invalid";
|
|
29
|
+
const url = href.startsWith("/") ? new URL(href, base) : null;
|
|
30
|
+
if (!url || url.origin !== base || url.pathname.startsWith("//")) throw new Error("Expected a same-origin panel path");
|
|
31
|
+
url.searchParams.delete(PANEL_CONTEXT_PARAM);
|
|
32
|
+
if (context) url.searchParams.set(PANEL_CONTEXT_PARAM, JSON.stringify(context));
|
|
33
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const HELP_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
37
|
+
const APP_ID = /^[a-z][a-z0-9-]*$/;
|
|
38
|
+
const QUALIFIED_ID = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
|
|
39
|
+
// RFC 9562 versions 1 to 8 with the RFC variant; the shell always uses crypto.randomUUID().
|
|
40
|
+
const PAGE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
41
|
+
// Exact keys, including symbol and non-enumerable ones, like the other panel messages.
|
|
42
|
+
const exactKeys = (value: unknown, required: string[], optional: string[] = []): value is Record<string, unknown> => {
|
|
43
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
44
|
+
const keys = Reflect.ownKeys(value);
|
|
45
|
+
return (
|
|
46
|
+
required.every((key) => Object.hasOwn(value, key)) &&
|
|
47
|
+
keys.every((key) => typeof key === "string" && (required.includes(key) || optional.includes(key)))
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
const text = (value: unknown, min: number, max: number, pattern?: RegExp): value is string =>
|
|
51
|
+
typeof value === "string" && value.length >= min && value.length <= max && (!pattern || pattern.test(value));
|
|
52
|
+
|
|
53
|
+
export function isPanelPageContext(value: unknown): value is PanelPageContext {
|
|
54
|
+
if (!exactKeys(value, ["v", "seq", "pageId", "app"], ["scope", "help"])) return false;
|
|
55
|
+
const { v, seq, pageId, app, scope, help } = value;
|
|
56
|
+
return (
|
|
57
|
+
v === 1 &&
|
|
58
|
+
typeof seq === "number" &&
|
|
59
|
+
Number.isSafeInteger(seq) &&
|
|
60
|
+
seq >= 0 &&
|
|
61
|
+
text(pageId, 36, 36, PAGE_ID) &&
|
|
62
|
+
text(app, 1, 80, APP_ID) &&
|
|
63
|
+
(scope === undefined || (exactKeys(scope, ["type", "id"]) && text(scope.type, 3, 180, QUALIFIED_ID) && text(scope.id, 1, 512))) &&
|
|
64
|
+
(help === undefined ||
|
|
65
|
+
// Index by index: `every` skips the holes of a sparse array, which the schema rejects.
|
|
66
|
+
(Array.isArray(help) &&
|
|
67
|
+
help.length >= 1 &&
|
|
68
|
+
help.length <= 3 &&
|
|
69
|
+
Array.from({ length: help.length }, (_, index) => index).every((index) => index in help && text(help[index], 0, 180, HELP_ID))))
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isPanelShortcut(value: unknown): value is HelpShortcut {
|
|
74
|
+
return (
|
|
75
|
+
exactKeys(value, ["id", "title", "shortcut"], ["description", "scope"]) &&
|
|
76
|
+
text(value.id, 1, HELP_SHORTCUT_ID_MAX_LENGTH) &&
|
|
77
|
+
text(value.title, 1, HELP_SHORTCUT_TITLE_MAX_LENGTH) &&
|
|
78
|
+
(value.description === undefined || text(value.description, 0, HELP_SHORTCUT_DESCRIPTION_MAX_LENGTH)) &&
|
|
79
|
+
text(value.shortcut, 1, HELP_SHORTCUT_KEYS_MAX_LENGTH) &&
|
|
80
|
+
(value.scope === undefined || value.scope === "selection" || value.scope === "page")
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isPanelContextMessage(value: unknown): value is PanelContextMessage {
|
|
85
|
+
if (!exactKeys(value, ["type", "v", "context"], ["shortcuts"])) return false;
|
|
86
|
+
const { shortcuts } = value;
|
|
87
|
+
return (
|
|
88
|
+
value.type === "cloud.panel.context" &&
|
|
89
|
+
value.v === 1 &&
|
|
90
|
+
(value.context === null || isPanelPageContext(value.context)) &&
|
|
91
|
+
(shortcuts === undefined ||
|
|
92
|
+
(value.context !== null &&
|
|
93
|
+
Array.isArray(shortcuts) &&
|
|
94
|
+
shortcuts.length <= HELP_START_SHORTCUT_LIMIT &&
|
|
95
|
+
Array.from({ length: shortcuts.length }, (_, index) => index).every(
|
|
96
|
+
(index) => index in shortcuts && isPanelShortcut(shortcuts[index]),
|
|
97
|
+
)))
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
isPanelContextMessage,
|
|
5
|
+
isPanelPageContext,
|
|
6
|
+
PANEL_CONTEXT_PARAM,
|
|
7
|
+
panelProviderNeedsConsent,
|
|
8
|
+
withPanelContext,
|
|
9
|
+
} from "./panel-context-plain";
|
|
10
|
+
|
|
11
|
+
import { HELP_VIEW_LIMIT } from "../shared/help";
|
|
12
|
+
import {
|
|
13
|
+
HELP_SHORTCUT_DESCRIPTION_MAX_LENGTH,
|
|
14
|
+
HELP_SHORTCUT_ID_MAX_LENGTH,
|
|
15
|
+
HELP_SHORTCUT_KEYS_MAX_LENGTH,
|
|
16
|
+
HELP_SHORTCUT_TITLE_MAX_LENGTH,
|
|
17
|
+
HELP_START_SHORTCUT_LIMIT,
|
|
18
|
+
} from "../ssr/layout-help-start";
|
|
19
|
+
import {
|
|
20
|
+
CAPABILITY_APP_ID_MAX_LENGTH,
|
|
21
|
+
CAPABILITY_QUALIFIED_ID_MAX_LENGTH,
|
|
22
|
+
CapabilityAppIdSchema,
|
|
23
|
+
CLOUD_RESOURCE_ID_MAX_LENGTH,
|
|
24
|
+
CloudResourceRefSchema,
|
|
25
|
+
} from "./capabilities";
|
|
26
|
+
|
|
27
|
+
/** Local Help document ids, as accepted by Layout.help, bounded for URL transport. */
|
|
28
|
+
export const PANEL_HELP_ID_MAX_LENGTH = 180;
|
|
29
|
+
export const PanelPageContextSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
v: z.literal(1),
|
|
32
|
+
seq: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
33
|
+
pageId: z.uuid(),
|
|
34
|
+
app: CapabilityAppIdSchema,
|
|
35
|
+
scope: CloudResourceRefSchema.optional(),
|
|
36
|
+
help: z
|
|
37
|
+
.array(
|
|
38
|
+
z
|
|
39
|
+
.string()
|
|
40
|
+
.max(PANEL_HELP_ID_MAX_LENGTH)
|
|
41
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
42
|
+
)
|
|
43
|
+
.min(1)
|
|
44
|
+
.max(HELP_VIEW_LIMIT)
|
|
45
|
+
.optional(),
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
48
|
+
/** v1 carries only the place and static Help ids. Later fields are additive; never infer authority from a ref. */
|
|
49
|
+
export type PanelPageContext = z.infer<typeof PanelPageContextSchema>;
|
|
50
|
+
export type PanelConsentSnapshot = { provider: string; allowed: boolean | null; revision: number };
|
|
51
|
+
|
|
52
|
+
// 128 chars cover JSON keys/punctuation, version and a 16-digit safe seq; add UUID (36), app (80),
|
|
53
|
+
// ref type (180), ref id (512), and three Help ids (180 each + quotes/comma).
|
|
54
|
+
// Only ref ids allow JSON escapes: each UTF-16 unit can expand to six characters (\uXXXX).
|
|
55
|
+
export const PANEL_CONTEXT_PARAM_MAX_LENGTH =
|
|
56
|
+
128 +
|
|
57
|
+
36 +
|
|
58
|
+
CAPABILITY_APP_ID_MAX_LENGTH +
|
|
59
|
+
CAPABILITY_QUALIFIED_ID_MAX_LENGTH +
|
|
60
|
+
6 * CLOUD_RESOURCE_ID_MAX_LENGTH +
|
|
61
|
+
HELP_VIEW_LIMIT * (PANEL_HELP_ID_MAX_LENGTH + 3);
|
|
62
|
+
|
|
63
|
+
export const encodePanelContextParam = (context: PanelPageContext): string => JSON.stringify(PanelPageContextSchema.parse(context));
|
|
64
|
+
|
|
65
|
+
export function parsePanelContextParam(value: string | null | undefined): PanelPageContext | null {
|
|
66
|
+
if (!value || value.length > PANEL_CONTEXT_PARAM_MAX_LENGTH) return null;
|
|
67
|
+
try {
|
|
68
|
+
const parsed = PanelPageContextSchema.safeParse(JSON.parse(value));
|
|
69
|
+
return parsed.success ? parsed.data : null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const PanelShortcutSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
id: z.string().min(1).max(HELP_SHORTCUT_ID_MAX_LENGTH),
|
|
78
|
+
title: z.string().min(1).max(HELP_SHORTCUT_TITLE_MAX_LENGTH),
|
|
79
|
+
description: z.string().max(HELP_SHORTCUT_DESCRIPTION_MAX_LENGTH).optional(),
|
|
80
|
+
shortcut: z.string().min(1).max(HELP_SHORTCUT_KEYS_MAX_LENGTH),
|
|
81
|
+
scope: z.enum(["selection", "page"]).optional(),
|
|
82
|
+
})
|
|
83
|
+
.strict();
|
|
84
|
+
export const PanelContextMessageSchema = z
|
|
85
|
+
.object({
|
|
86
|
+
type: z.literal("cloud.panel.context"),
|
|
87
|
+
v: z.literal(1),
|
|
88
|
+
context: PanelPageContextSchema.nullable(),
|
|
89
|
+
shortcuts: z.array(PanelShortcutSchema).max(HELP_START_SHORTCUT_LIMIT).optional(),
|
|
90
|
+
})
|
|
91
|
+
.strict()
|
|
92
|
+
.refine((message) => message.context !== null || message.shortcuts === undefined);
|
|
93
|
+
export type PanelContextMessage = z.infer<typeof PanelContextMessageSchema>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const SEEN_HINTS_COOKIE = "cloud_seen_hints";
|
|
2
|
+
export const SEEN_HINTS_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
|
3
|
+
export const MAX_SEEN_HINTS = 64;
|
|
4
|
+
|
|
5
|
+
const COOKIE_ATTRIBUTES = `; Path=/; Max-Age=${SEEN_HINTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
|
6
|
+
// RFC 6265's 4096-byte budget includes name, value and attributes. Reserve Secure on HTTP too.
|
|
7
|
+
const MAX_COOKIE_VALUE_LENGTH = 4096 - `${SEEN_HINTS_COOKIE}=${COOKIE_ATTRIBUTES}; Secure`.length;
|
|
8
|
+
|
|
9
|
+
export const isHintId = (id: unknown): id is string => typeof id === "string" && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(id);
|
|
10
|
+
|
|
11
|
+
/** Keep the most recent occurrence, with the newest hints last. */
|
|
12
|
+
const normalize = (ids: readonly string[]): string[] => {
|
|
13
|
+
const recent = [...new Set(ids.filter(isHintId).toReversed())].reverse().slice(-MAX_SEEN_HINTS);
|
|
14
|
+
while (recent.join("~").length > MAX_COOKIE_VALUE_LENGTH) recent.shift();
|
|
15
|
+
return recent;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const parseSeenHintsCookieHeader = (header: string | null | undefined): string[] => {
|
|
19
|
+
const value = header
|
|
20
|
+
?.split(";")
|
|
21
|
+
.map((part) => part.trim())
|
|
22
|
+
.find((part) => part.startsWith(`${SEEN_HINTS_COOKIE}=`))
|
|
23
|
+
?.slice(SEEN_HINTS_COOKIE.length + 1);
|
|
24
|
+
return normalize(value?.split("~") ?? []);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const serializeSeenHints = (ids: readonly string[]): string => normalize(ids).join("~");
|
|
28
|
+
|
|
29
|
+
export const mergeSeenHints = (current: readonly string[], id: string): string[] => {
|
|
30
|
+
if (!isHintId(id)) throw new Error(`Invalid hint id: ${id}`);
|
|
31
|
+
return normalize([...current, id]);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const seenHintsSetCookie = (ids: readonly string[], secure: boolean): string =>
|
|
35
|
+
`${SEEN_HINTS_COOKIE}=${serializeSeenHints(ids)}${COOKIE_ATTRIBUTES}${secure ? "; Secure" : ""}`;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
|
|
3
|
+
// The gateway (packages/gateway/src/client-address.ts) resolves this address using GATEWAY_TRUSTED_PROXIES.
|
|
4
|
+
export const clientIp = (c: Context): string =>
|
|
5
|
+
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { keyboardPlatform } from "@k2b/ui";
|
|
2
|
+
import { helpAgentMarkdown } from "../_internal/help-catalog";
|
|
3
|
+
import type { HelpArticle } from "../services/help/types";
|
|
4
|
+
import { type HelpDocumentPayload, parseHelpReference } from "../shared/help";
|
|
5
|
+
import { renderHelpMarkdown } from "../shared/markdown";
|
|
6
|
+
|
|
7
|
+
/** The same authorized article presentation for the API and the server's first frame. */
|
|
8
|
+
export const buildHelpDocumentPayload = (
|
|
9
|
+
document: HelpArticle,
|
|
10
|
+
userAgent: string,
|
|
11
|
+
renderMarkdown: typeof renderHelpMarkdown = renderHelpMarkdown,
|
|
12
|
+
): HelpDocumentPayload => {
|
|
13
|
+
const links = (reference: { appId?: string; documentId: string }) =>
|
|
14
|
+
document.links[`${reference.appId ?? document.appId}/${reference.documentId}`];
|
|
15
|
+
const resolve = (reference: string) => {
|
|
16
|
+
const ref = parseHelpReference(reference);
|
|
17
|
+
return ref ? links(ref) : undefined;
|
|
18
|
+
};
|
|
19
|
+
const payload: HelpDocumentPayload = {
|
|
20
|
+
locale: document.locale,
|
|
21
|
+
id: document.documentId,
|
|
22
|
+
title: document.title,
|
|
23
|
+
markdown: helpAgentMarkdown(document),
|
|
24
|
+
kind: document.kind,
|
|
25
|
+
needs: document.needs,
|
|
26
|
+
next: document.next ? resolve(document.next) : undefined,
|
|
27
|
+
related: document.related.flatMap((reference) => {
|
|
28
|
+
const target = resolve(reference);
|
|
29
|
+
return target ? [target] : [];
|
|
30
|
+
}),
|
|
31
|
+
html: renderMarkdown(document.markdown, {
|
|
32
|
+
locale: document.locale,
|
|
33
|
+
platform: keyboardPlatform(userAgent),
|
|
34
|
+
links,
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
return payload;
|
|
38
|
+
};
|
|
@@ -411,7 +411,16 @@ const getAuthority = (c: Context<AuthContext>): RequestAuthority => {
|
|
|
411
411
|
* True when the request acts through the mobile app's session, directly or through an
|
|
412
412
|
* invocation made for one. Such requests cannot create authority that outlives the phone.
|
|
413
413
|
*/
|
|
414
|
-
const isAppSession = (c: Context<
|
|
414
|
+
const isAppSession = <E extends AuthContext>(c: Context<E>): boolean => isAppSessionActor(c.get("actor"));
|
|
415
|
+
|
|
416
|
+
/** Returns only the browser session identity authenticated for this request. */
|
|
417
|
+
const browserSignIn = async <E extends AuthContext>(c: Context<E>): Promise<{ userId: string; sid: string } | null> => {
|
|
418
|
+
if (c.get("credentialKind") !== "session" || c.get("actor")?.kind !== "user" || isAppSession(c)) return null;
|
|
419
|
+
const token = c.get("sessionToken");
|
|
420
|
+
if (!token) return null;
|
|
421
|
+
const authenticated = await session.authenticateRequest(c, token);
|
|
422
|
+
return authenticated?.data.kind === "web" ? { userId: authenticated.data.userId, sid: authenticated.data.sid } : null;
|
|
423
|
+
};
|
|
415
424
|
|
|
416
425
|
/** The standard answer for actions the mobile app may not take. */
|
|
417
426
|
export const APP_SESSION_FORBIDDEN = { code: "FORBIDDEN", message: "Use Cloud on the web for this." } as const;
|
|
@@ -460,6 +469,7 @@ export const auth = {
|
|
|
460
469
|
requireRole,
|
|
461
470
|
requireUser,
|
|
462
471
|
isAppSession,
|
|
472
|
+
browserSignIn,
|
|
463
473
|
rejectAppSession,
|
|
464
474
|
requireOAuthScope,
|
|
465
475
|
getAuthority,
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* orthogonal to the request lifecycle.
|
|
24
24
|
*/
|
|
25
25
|
import { describeRoute } from "hono-openapi";
|
|
26
|
+
import { noTransform } from "./no-transform";
|
|
26
27
|
import { rateLimit } from "./rate-limit";
|
|
27
28
|
import { requestLogger } from "./request-logger";
|
|
28
29
|
import { routeTemplate } from "./route-template";
|
|
@@ -31,6 +32,14 @@ import { settings } from "./settings";
|
|
|
31
32
|
import { validator } from "./validator";
|
|
32
33
|
|
|
33
34
|
export const middleware = {
|
|
35
|
+
/**
|
|
36
|
+
* Use when another party can repeatedly write text beside unreadable text,
|
|
37
|
+
* personal submissions or secrets worth guessing, including live refetches.
|
|
38
|
+
* Metadata alone and containers every writer can read stay compressed.
|
|
39
|
+
* Mount `.use("*", middleware.noTransform())` first on the app/route-group
|
|
40
|
+
* router, or use it as per-route middleware. Sets Cache-Control: no-transform.
|
|
41
|
+
*/
|
|
42
|
+
noTransform,
|
|
34
43
|
/**
|
|
35
44
|
* Live cluster registry on `c.get("runtime")`. Required for Layout/Sidebar.
|
|
36
45
|
* Also reports the matched route template to gateway telemetry — you do not
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
|
|
3
|
+
export const noTransform = () =>
|
|
4
|
+
createMiddleware(async (c, next) => {
|
|
5
|
+
await next();
|
|
6
|
+
if (c.res.status === 101) return;
|
|
7
|
+
const value = c.res.headers.get("Cache-Control");
|
|
8
|
+
if (
|
|
9
|
+
(value?.match(/(?:[^,"]|"[^"]*")+/g) ?? []).some((directive) => directive.split("=", 1)[0]?.trim().toLowerCase() === "no-transform")
|
|
10
|
+
)
|
|
11
|
+
return;
|
|
12
|
+
const merged = value ? `${value}, no-transform` : "no-transform";
|
|
13
|
+
try {
|
|
14
|
+
c.res.headers.set("Cache-Control", merged);
|
|
15
|
+
} catch {
|
|
16
|
+
c.res = new Response(c.res.body, c.res);
|
|
17
|
+
c.res.headers.set("Cache-Control", merged);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
@@ -2,6 +2,7 @@ import type { Context, MiddlewareHandler } from "hono";
|
|
|
2
2
|
import { createMiddleware } from "hono/factory";
|
|
3
3
|
import type { MessageResponse } from "../../contracts/shared";
|
|
4
4
|
import * as settings from "../../services/settings";
|
|
5
|
+
import { clientIp } from "../client-ip";
|
|
5
6
|
import { ratelimit } from "../ratelimit";
|
|
6
7
|
import { type AuthContext, auth } from "./auth";
|
|
7
8
|
|
|
@@ -29,9 +30,6 @@ const DEFAULT_WINDOW_SECS = 1;
|
|
|
29
30
|
const LIMITER_PREFIX = "cloud:rate-limit";
|
|
30
31
|
const limiterCache = new Map<string, ReturnType<typeof ratelimit>>();
|
|
31
32
|
|
|
32
|
-
const getClientIp = (c: Context): string =>
|
|
33
|
-
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
|
|
34
|
-
|
|
35
33
|
const pathMatches = (requestPath: string, matcher: string | RegExp): boolean => {
|
|
36
34
|
if (matcher instanceof RegExp) return matcher.test(requestPath);
|
|
37
35
|
return requestPath === matcher || requestPath.startsWith(`${matcher}/`);
|
|
@@ -83,13 +81,13 @@ const getLimiter = (limitPerSecond: number, windowSecs: number) => {
|
|
|
83
81
|
};
|
|
84
82
|
|
|
85
83
|
const resolveIdentifier = async (c: Context<AuthContext>, keyBy: RateLimitConfig["keyBy"]): Promise<string> => {
|
|
86
|
-
if (keyBy === "ip") return `ip:${
|
|
84
|
+
if (keyBy === "ip") return `ip:${clientIp(c)}`;
|
|
87
85
|
|
|
88
86
|
const token = auth.session.getToken(c);
|
|
89
|
-
if (!token) return `ip:${
|
|
87
|
+
if (!token) return `ip:${clientIp(c)}`;
|
|
90
88
|
|
|
91
89
|
const userId = await auth.session.getRequestSubject(c, token);
|
|
92
|
-
if (!userId) return `ip:${
|
|
90
|
+
if (!userId) return `ip:${clientIp(c)}`;
|
|
93
91
|
|
|
94
92
|
return `user:${userId}`;
|
|
95
93
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { isUnsafeNetworkAddress, parseNetworkAddress } from "../../shared/network-address";
|
|
2
|
+
|
|
3
|
+
const ADDRESS_BUDGET = 64;
|
|
4
|
+
|
|
5
|
+
const ipv6Groups = (address: string): number[] => {
|
|
6
|
+
const [left = "", right = ""] = address.split("::");
|
|
7
|
+
const leading = left ? left.split(":") : [];
|
|
8
|
+
const trailing = right ? right.split(":") : [];
|
|
9
|
+
return [...leading, ...Array<string>(8 - leading.length - trailing.length).fill("0"), ...trailing].map((group) =>
|
|
10
|
+
Number.parseInt(group, 16),
|
|
11
|
+
);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const publicClientAddress = (raw: string | null | undefined): string | null => {
|
|
15
|
+
try {
|
|
16
|
+
const input = raw?.trim();
|
|
17
|
+
if (!input || input.length > ADDRESS_BUDGET) return null;
|
|
18
|
+
if (input.includes("[") || input.includes("]")) return null;
|
|
19
|
+
const parsed = parseNetworkAddress(input);
|
|
20
|
+
if (!parsed || isUnsafeNetworkAddress(parsed.address)) return null;
|
|
21
|
+
if (parsed.family === 6) {
|
|
22
|
+
const groups = ipv6Groups(parsed.address);
|
|
23
|
+
if (
|
|
24
|
+
[0, 0, 0, 0, 0, 0xffff].every((group, index) => groups[index] === group) ||
|
|
25
|
+
[0x64, 0xff9b, 0, 0, 0, 0].every((group, index) => groups[index] === group)
|
|
26
|
+
) {
|
|
27
|
+
const high = groups[6]!;
|
|
28
|
+
const low = groups[7]!;
|
|
29
|
+
return [high >> 8, high & 255, low >> 8, low & 255].join(".");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return parsed.address;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const addressPrefix = (address: string): string | null => {
|
|
39
|
+
const canonical = publicClientAddress(address);
|
|
40
|
+
if (!canonical) return null;
|
|
41
|
+
if (!canonical.includes(":")) return canonical;
|
|
42
|
+
const groups = ipv6Groups(canonical).map((group) => group.toString(16));
|
|
43
|
+
const prefix = parseNetworkAddress(`${groups.slice(0, 4).join(":")}:0:0:0:0`);
|
|
44
|
+
return prefix ? `${prefix.address}/64` : null;
|
|
45
|
+
};
|