@k2b/cloud 0.39.0 → 0.41.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 +11 -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 +217 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +49 -18
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/setup-lock.ts +103 -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 +464 -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 +680 -0
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +456 -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 +198 -39
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/index.ts +3 -0
- package/src/ai/migrate.ts +26 -2
- package/src/ai/open-questions.ts +172 -0
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/question-text.ts +8 -0
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/routes.ts +195 -5
- 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 +245 -24
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +36 -8
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +43 -4
- 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-actions.ts +31 -7
- 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/schemas.ts +3 -0
- package/src/api/auth.ts +67 -12
- 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 +30 -3
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +159 -36
- 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 +166 -262
- package/src/approvals/approval-card.ts +68 -0
- 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 +123 -16
- 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/contracts/shared.ts +3 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +13 -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/accounts/app.ts +23 -3
- package/src/services/accounts/users.ts +12 -1
- package/src/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +49 -31
- package/src/services/approvals/index.ts +7 -5
- package/src/services/approvals/list.ts +27 -6
- package/src/services/approvals/question.ts +68 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/auth-flows/password-reset.ts +32 -6
- 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 +13 -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/pdf/markdown.ts +160 -2
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +296 -41
- 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/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/Layout.tsx +25 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +222 -419
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -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/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- 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 +21 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-idle.ts +9 -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/approvals.css +6 -0
- package/src/styles/effects.css +213 -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,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APP_APPROVAL_LIMITS,
|
|
3
|
+
type AppDeviceInbox,
|
|
4
|
+
type AppDeviceInboxWithChanges,
|
|
5
|
+
type AppLoginContext,
|
|
6
|
+
AppLoginContextSchema,
|
|
7
|
+
type AppPendingLogin,
|
|
8
|
+
} from "../contracts/app-approval";
|
|
9
|
+
import { truncateUtf8 } from "../server/request-context/geoip";
|
|
10
|
+
import { KNOWN_BROWSER_LIMITS } from "./known-browsers";
|
|
11
|
+
|
|
12
|
+
export type AppLoginRequestContext = Pick<AppLoginContext, "browser" | "os" | "country" | "city" | "network"> & {
|
|
13
|
+
cookie: string | null;
|
|
14
|
+
prefix: string | null;
|
|
15
|
+
};
|
|
16
|
+
export type LoginContextInput = {
|
|
17
|
+
request?: AppLoginRequestContext;
|
|
18
|
+
browser: { firstSeenAt: Date } | null;
|
|
19
|
+
countries: { seen: boolean; any: boolean };
|
|
20
|
+
trust: { introActive: boolean; geoipSince: Date | null };
|
|
21
|
+
now: Date;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Core's only level rule. Descriptions and same-network hints do not affect it. */
|
|
25
|
+
export const loginContextLevel = (input: {
|
|
26
|
+
introActive: boolean;
|
|
27
|
+
knownBrowser: boolean;
|
|
28
|
+
newCountry: boolean;
|
|
29
|
+
anyCountry: boolean;
|
|
30
|
+
}): AppLoginContext["level"] => {
|
|
31
|
+
if (input.introActive) return "info";
|
|
32
|
+
if (input.knownBrowser) return input.newCountry ? "hint" : "info";
|
|
33
|
+
return input.newCountry && input.anyCountry ? "warn" : "hint";
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** JSONB adds a space after each colon and comma. Two bytes per entry bound
|
|
37
|
+
* both separators, including nested objects/arrays, without counting punctuation in strings. */
|
|
38
|
+
export const loginContextStorageBytes = (value: unknown): number => {
|
|
39
|
+
const separators = (item: unknown): number => {
|
|
40
|
+
if (!item || typeof item !== "object") return 0;
|
|
41
|
+
const values = Object.values(item);
|
|
42
|
+
return 2 * values.length + values.reduce<number>((sum, entry) => sum + separators(entry), 0);
|
|
43
|
+
};
|
|
44
|
+
return new TextEncoder().encode(JSON.stringify(value)).length + separators(value);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Never store sameNetwork. Oversized or invalid context cannot prevent a sign-in. */
|
|
48
|
+
export const boundLoginContext = (input: AppLoginContext): AppLoginContext | null => {
|
|
49
|
+
const { sameNetwork: _transient, ...context } = input;
|
|
50
|
+
if (loginContextStorageBytes(context) > 512) {
|
|
51
|
+
context.city = null;
|
|
52
|
+
context.network = null;
|
|
53
|
+
}
|
|
54
|
+
if (loginContextStorageBytes(context) > 512) return null;
|
|
55
|
+
const parsed = AppLoginContextSchema.safeParse(context);
|
|
56
|
+
return parsed.success ? parsed.data : null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const buildLoginContext = ({ request, browser, countries, trust, now }: LoginContextInput): AppLoginContext | null => {
|
|
60
|
+
const country = request?.country ?? null;
|
|
61
|
+
const newCountry = country !== null && trust.geoipSince !== null && !countries.seen;
|
|
62
|
+
const name = (value: string | null | undefined) =>
|
|
63
|
+
value
|
|
64
|
+
? truncateUtf8(
|
|
65
|
+
value
|
|
66
|
+
.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ")
|
|
67
|
+
.replace(/\p{Cf}/gu, "")
|
|
68
|
+
.replace(/\s+/gu, " ")
|
|
69
|
+
.trim(),
|
|
70
|
+
96,
|
|
71
|
+
) || null
|
|
72
|
+
: null;
|
|
73
|
+
return boundLoginContext({
|
|
74
|
+
level: loginContextLevel({ introActive: trust.introActive, knownBrowser: browser !== null, newCountry, anyCountry: countries.any }),
|
|
75
|
+
browser: request?.browser ?? "other",
|
|
76
|
+
os: request?.os ?? "other",
|
|
77
|
+
country,
|
|
78
|
+
city: name(request?.city),
|
|
79
|
+
network: name(request?.network),
|
|
80
|
+
history: trust.introActive
|
|
81
|
+
? null
|
|
82
|
+
: {
|
|
83
|
+
knownSince: browser?.firstSeenAt.toISOString() ?? null,
|
|
84
|
+
newCountry,
|
|
85
|
+
countriesSince:
|
|
86
|
+
trust.geoipSince && now.getTime() - trust.geoipSince.getTime() < KNOWN_BROWSER_LIMITS.retentionDays * 86_400_000
|
|
87
|
+
? trust.geoipSince.toISOString()
|
|
88
|
+
: null,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type LoginListResponse = AppDeviceInbox | AppDeviceInboxWithChanges | { requests: AppPendingLogin[]; pollAfterSeconds: number };
|
|
94
|
+
/** Keep every login: trim Actions first, then device changes, then context from the last login backwards.
|
|
95
|
+
* Trimmed changes stay pending and show once the inbox has room.
|
|
96
|
+
* Return a copy so callers and tests can retain the original snapshot. */
|
|
97
|
+
export const trimAppLoginResponse = <T extends LoginListResponse>(response: T, budget: number = APP_APPROVAL_LIMITS.bodyBytes): T => {
|
|
98
|
+
const result = { ...response };
|
|
99
|
+
const bytes = () => new TextEncoder().encode(JSON.stringify(result)).length;
|
|
100
|
+
if ("logins" in result) {
|
|
101
|
+
result.actions = [...result.actions];
|
|
102
|
+
result.logins = result.logins.map((login) => ({ ...login }));
|
|
103
|
+
while (result.actions.length && bytes() > budget) {
|
|
104
|
+
result.actions.pop();
|
|
105
|
+
result.moreActions++;
|
|
106
|
+
}
|
|
107
|
+
if ("changes" in result) {
|
|
108
|
+
result.changes = [...result.changes];
|
|
109
|
+
while (result.changes.length && bytes() > budget) {
|
|
110
|
+
result.changes.pop();
|
|
111
|
+
result.moreChanges++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else result.requests = result.requests.map((login) => ({ ...login }));
|
|
115
|
+
const logins = "logins" in result ? result.logins : result.requests;
|
|
116
|
+
for (let index = logins.length - 1; index >= 0 && bytes() > budget; index--) delete logins[index]!.context;
|
|
117
|
+
return result;
|
|
118
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { err, fail, ok, type PageParams, type Paginated, paginate, type Result } from "@k2b/stdlib";
|
|
2
2
|
import { type SQL, sql } from "bun";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import type { CapabilityActionPolicy } from "../../contracts/capabilities";
|
|
4
5
|
import { CapabilityAppIdSchema } from "../../contracts/capabilities";
|
|
5
6
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
6
7
|
import { audit } from "../audit";
|
|
@@ -626,6 +627,7 @@ export const validateMandateIssueAuthority = async (
|
|
|
626
627
|
operation: string;
|
|
627
628
|
actionApproval?: "none" | "approved";
|
|
628
629
|
input?: unknown;
|
|
630
|
+
capabilityPolicy?: CapabilityActionPolicy;
|
|
629
631
|
capabilityApproval?: "none" | "rememberable" | "always";
|
|
630
632
|
expectedRevision?: number;
|
|
631
633
|
requestId?: string;
|
|
@@ -687,6 +689,7 @@ export const validateMandateIssueAuthority = async (
|
|
|
687
689
|
actionApproval: input.actionApproval ?? "none",
|
|
688
690
|
input: input.input,
|
|
689
691
|
capabilityApproval: input.capabilityApproval,
|
|
692
|
+
capabilityPolicy: input.capabilityPolicy,
|
|
690
693
|
})
|
|
691
694
|
) {
|
|
692
695
|
return denyMandateIssue(err.forbidden(MANDATE_POLICY_DENIED_MESSAGE));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { CapabilityAppIdSchema, CapabilityLocalIdSchema } from "../../contracts/capabilities";
|
|
2
|
+
import { type CapabilityActionPolicy, CapabilityAppIdSchema, CapabilityLocalIdSchema } from "../../contracts/capabilities";
|
|
3
3
|
|
|
4
4
|
export const MANDATE_POLICY_VERSION = 1 as const;
|
|
5
5
|
export const MANDATE_POLICY_MAX_IDENTIFIERS = 100;
|
|
@@ -201,6 +201,7 @@ export const mandatePolicyAllows = (
|
|
|
201
201
|
operation: string;
|
|
202
202
|
actionApproval: "none" | "approved";
|
|
203
203
|
input?: unknown;
|
|
204
|
+
capabilityPolicy?: CapabilityActionPolicy;
|
|
204
205
|
capabilityApproval?: "none" | "rememberable" | "always";
|
|
205
206
|
},
|
|
206
207
|
): boolean => {
|
|
@@ -239,7 +240,11 @@ export const mandatePolicyAllows = (
|
|
|
239
240
|
)
|
|
240
241
|
)
|
|
241
242
|
return false;
|
|
242
|
-
if (kind === "action"
|
|
243
|
+
if (kind === "action") {
|
|
244
|
+
if (input.capabilityPolicy) {
|
|
245
|
+
if (!("silent" in input.capabilityPolicy) || input.capabilityPolicy.routines === false) return false;
|
|
246
|
+
} else if (input.capabilityApproval !== "none" && input.capabilityApproval !== "rememberable") return false;
|
|
247
|
+
}
|
|
243
248
|
}
|
|
244
249
|
if (!input.operation.startsWith("capability.action.run:")) return true;
|
|
245
250
|
if (policy.actions === "deny") return false;
|
|
@@ -250,13 +255,20 @@ export const mandatePolicyAllows = (
|
|
|
250
255
|
/** Discovery ignores fixed inputs: at least one unattended call must fit the policy. */
|
|
251
256
|
export const mandatePolicyCanPermitCapability = (
|
|
252
257
|
policy: MandatePolicyV1,
|
|
253
|
-
input: {
|
|
258
|
+
input: {
|
|
259
|
+
appId: string;
|
|
260
|
+
capabilityId: string;
|
|
261
|
+
kind: "query" | "action";
|
|
262
|
+
capabilityPolicy?: CapabilityActionPolicy;
|
|
263
|
+
approval?: "none" | "rememberable" | "always";
|
|
264
|
+
},
|
|
254
265
|
): boolean => {
|
|
255
266
|
const call = {
|
|
256
267
|
appId: input.appId,
|
|
257
268
|
operation: `capability.${input.kind === "query" ? "query" : "action.run"}:${input.capabilityId}`,
|
|
258
269
|
actionApproval: "none" as const,
|
|
259
270
|
capabilityApproval: input.approval,
|
|
271
|
+
capabilityPolicy: input.capabilityPolicy,
|
|
260
272
|
};
|
|
261
273
|
if (policy.grants === undefined) return mandatePolicyAllows(policy, call);
|
|
262
274
|
return policy.grants.some(
|
|
@@ -8,7 +8,7 @@ const sanitizeContent = (content: string): string => sanitizeHtml(content, { all
|
|
|
8
8
|
// sanitize-html decodes entities before escaping &, < and > again. Decode only
|
|
9
9
|
// those escapes once, so literal entity text is not recursively interpreted.
|
|
10
10
|
const htmlText = (html: string): string =>
|
|
11
|
-
sanitizeContent(html.replace(/<br\b[^>]*>|<\/(?:p|pre|blockquote|div|h[1-6]|li|tr|td|th|ul|ol|table)>/gi, " "))
|
|
11
|
+
sanitizeContent(html.replace(/<(?:br|hr)\b[^>]*>|<\/(?:p|pre|blockquote|div|h[1-6]|li|tr|td|th|ul|ol|table)>/gi, " "))
|
|
12
12
|
.replace(/&(amp|lt|gt);/g, (entity) => (entity === "&" ? "&" : entity === "<" ? "<" : ">"))
|
|
13
13
|
.replace(/\s+/g, " ")
|
|
14
14
|
.trim();
|
|
@@ -86,6 +86,18 @@ const mapServiceAccount = (row: ServiceAccountActorRow): ServiceAccount => ({
|
|
|
86
86
|
createdAt: new Date(row.service_account_created_at).toISOString(),
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
/** A user access token is current only while its `auth_epoch` claim equals the account's live epoch. */
|
|
90
|
+
export const isOAuthTokenEpochCurrent = (payload: JWTPayload, liveEpoch: unknown): boolean => {
|
|
91
|
+
const claim = payload.auth_epoch;
|
|
92
|
+
return (
|
|
93
|
+
typeof claim === "number" &&
|
|
94
|
+
Number.isSafeInteger(claim) &&
|
|
95
|
+
claim >= 0 &&
|
|
96
|
+
(typeof liveEpoch === "number" || typeof liveEpoch === "bigint" || typeof liveEpoch === "string") &&
|
|
97
|
+
String(liveEpoch) === String(claim)
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
89
101
|
/** Resolve the live OAuth client/principal, then check current account-category policy. */
|
|
90
102
|
export const resolveOAuthTokenActor = async (
|
|
91
103
|
payload: JWTPayload,
|
|
@@ -136,13 +148,14 @@ export const resolveOAuthTokenActor = async (
|
|
|
136
148
|
if (!userId && !uid) return null;
|
|
137
149
|
const predicate = userId ? sql`u.id = ${userId}::uuid` : sql`u.uid = ${uid}`;
|
|
138
150
|
const rows = await query<Record<string, unknown>[]>`
|
|
139
|
-
SELECT ${userProjectionSql(groupsAdmin)}
|
|
151
|
+
SELECT u.auth_epoch, ${userProjectionSql(groupsAdmin)}
|
|
140
152
|
FROM oauth.clients c
|
|
141
153
|
JOIN auth.users u ON ${predicate}
|
|
142
154
|
${userProjectionJoin()}
|
|
143
155
|
WHERE c.client_id = ${clientId}
|
|
144
156
|
`;
|
|
145
|
-
|
|
157
|
+
if (!rows[0] || !isOAuthTokenEpochCurrent(payload, rows[0].auth_epoch)) return null;
|
|
158
|
+
const user = buildProjectedUser(rows[0]);
|
|
146
159
|
if (!user || isAccountExpired(user.accountExpires)) return null;
|
|
147
160
|
if (!(await isAccountCategoryAllowed(user, query))) return null;
|
|
148
161
|
return { kind: "user", payload, user, scopes };
|
|
@@ -206,4 +219,5 @@ export const clearOAuthVerifierCachesForTest = (): void => {
|
|
|
206
219
|
|
|
207
220
|
export const oauthTokens = {
|
|
208
221
|
verifyAccessToken,
|
|
222
|
+
resolveOAuthTokenActor,
|
|
209
223
|
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import { CapabilityAppIdSchema } from "../contracts/capabilities";
|
|
3
|
+
|
|
4
|
+
type ConsentAnswer = { allowed: boolean; revision: number };
|
|
5
|
+
export class PanelConsentCancelled extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super("The request was cancelled before the panel consent was saved");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Core owns the table; platform requests read without a process-wide cache. Missing is distinct from denied. Reads
|
|
13
|
+
* take a share lock, so a page rendered while a save is in flight sees that save's outcome, not the answer before it.
|
|
14
|
+
*/
|
|
15
|
+
export const createPanelContextConsentService = (db: SQL = sql) => ({
|
|
16
|
+
async get(userId: string, provider: string): Promise<ConsentAnswer | null> {
|
|
17
|
+
CapabilityAppIdSchema.parse(provider);
|
|
18
|
+
const [row] = await db<ConsentAnswer[]>`
|
|
19
|
+
SELECT allowed, revision FROM auth.panel_context_consent WHERE user_id = ${userId}::uuid AND provider = ${provider}
|
|
20
|
+
FOR SHARE
|
|
21
|
+
`;
|
|
22
|
+
return row ?? null;
|
|
23
|
+
},
|
|
24
|
+
async list(userId: string, providers: string[]): Promise<Record<string, ConsentAnswer>> {
|
|
25
|
+
if (!providers.length) return {};
|
|
26
|
+
for (const provider of providers) CapabilityAppIdSchema.parse(provider);
|
|
27
|
+
const rows = await db<({ provider: string } & ConsentAnswer)[]>`
|
|
28
|
+
SELECT provider, allowed, revision FROM auth.panel_context_consent
|
|
29
|
+
WHERE user_id = ${userId}::uuid AND provider IN ${db(providers)}
|
|
30
|
+
FOR SHARE
|
|
31
|
+
`;
|
|
32
|
+
return Object.fromEntries(rows.map(({ provider, allowed, revision }) => [provider, { allowed, revision }]));
|
|
33
|
+
},
|
|
34
|
+
/**
|
|
35
|
+
* Compare-and-set on the revision the client read. A save whose request was cancelled (the browser gave up or
|
|
36
|
+
* left) is rolled back before commit, so no answer lands after its page stopped waiting.
|
|
37
|
+
*/
|
|
38
|
+
async set(userId: string, provider: string, allowed: boolean, revision: number, signal?: AbortSignal): Promise<ConsentAnswer | null> {
|
|
39
|
+
CapabilityAppIdSchema.parse(provider);
|
|
40
|
+
return db.begin(async (tx) => {
|
|
41
|
+
await tx`SET LOCAL statement_timeout = '5s'`;
|
|
42
|
+
const [row] = await tx<ConsentAnswer[]>`
|
|
43
|
+
INSERT INTO auth.panel_context_consent (user_id, provider, allowed, revision)
|
|
44
|
+
SELECT ${userId}::uuid, ${provider}, ${allowed}, 1
|
|
45
|
+
WHERE ${revision} = 0 OR EXISTS (
|
|
46
|
+
SELECT 1 FROM auth.panel_context_consent WHERE user_id = ${userId}::uuid AND provider = ${provider}
|
|
47
|
+
)
|
|
48
|
+
ON CONFLICT (user_id, provider) DO UPDATE SET
|
|
49
|
+
allowed = EXCLUDED.allowed, revision = auth.panel_context_consent.revision + 1, updated_at = now()
|
|
50
|
+
WHERE auth.panel_context_consent.revision = ${revision}
|
|
51
|
+
RETURNING allowed, revision
|
|
52
|
+
`;
|
|
53
|
+
if (signal?.aborted) throw new PanelConsentCancelled();
|
|
54
|
+
return row ?? null;
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
export const panelContextConsent = createPanelContextConsentService();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { markdownInfoBlocks, markdownLinkReference, renderMarkdownLink } from "@k2b/ui";
|
|
2
|
+
import { decodeHTML } from "entities";
|
|
2
3
|
import { Marked, Renderer, type Tokens } from "marked";
|
|
3
4
|
import postcss from "postcss";
|
|
4
5
|
import {
|
|
@@ -165,6 +166,161 @@ a.k2b-reference { padding: .05em .3em; border-radius: .3em; background: #eeeff2;
|
|
|
165
166
|
.k2b-reference__icon, .k2b-text-link__external { display: none; }
|
|
166
167
|
`;
|
|
167
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Break-anywhere lowers table column width to one character, splitting even
|
|
171
|
+
* short numbers. Cells keep words whole; long runs get explicit break points.
|
|
172
|
+
*/
|
|
173
|
+
const TABLE_CELL_PRINT_CSS = `
|
|
174
|
+
th, td { overflow-wrap: break-word; word-break: normal; }
|
|
175
|
+
`;
|
|
176
|
+
|
|
177
|
+
// Up to 20 characters covers nearly every real word, including German
|
|
178
|
+
// compounds and IBAN-like values below that length. Longer URLs, code, hashes
|
|
179
|
+
// or paths need a 10-character step: even nine columns fit the document
|
|
180
|
+
// preset's narrowest row (about 90 table characters). A 20-character step
|
|
181
|
+
// clipped six columns of long inline code that fitted before this protection.
|
|
182
|
+
const MAX_WHOLE_CELL_RUN = 20;
|
|
183
|
+
const CELL_RUN_BREAK_STEP = 10;
|
|
184
|
+
|
|
185
|
+
// References (including semicolonless ones) and literal code points are raw
|
|
186
|
+
// pieces. Decode for whitespace and grapheme boundaries, but preserve spelling.
|
|
187
|
+
// Limit names to 32 characters, the longest HTML reference name.
|
|
188
|
+
const CHARACTER_REFERENCE = /&(?:#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]{0,31});?/gu;
|
|
189
|
+
const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
190
|
+
|
|
191
|
+
const runUnits = (pieces: { raw: string; decoded: string }[]): string[] => {
|
|
192
|
+
const decoded = pieces.map((piece) => piece.decoded).join("");
|
|
193
|
+
const ends = new Set(Array.from(GRAPHEMES.segment(decoded), ({ index, segment }) => index + segment.length));
|
|
194
|
+
const units: string[] = [];
|
|
195
|
+
let at = 0;
|
|
196
|
+
let unit = "";
|
|
197
|
+
// A boundary must end both a grapheme and a raw piece, even if it decodes
|
|
198
|
+
// to several graphemes (such as &x or an unknown reference-like name).
|
|
199
|
+
for (const piece of pieces) {
|
|
200
|
+
unit += piece.raw;
|
|
201
|
+
at += piece.decoded.length;
|
|
202
|
+
if (ends.has(at)) {
|
|
203
|
+
units.push(unit);
|
|
204
|
+
unit = "";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return units;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Inline formatting does not end a run: `aaa**bbb**ccc` prints one unbroken
|
|
211
|
+
// word. Any other element, such as <br>, <wbr>, an image, a span, or a
|
|
212
|
+
// block, starts a new run, so a short value beside it never breaks.
|
|
213
|
+
const INLINE_FORMATTING = new Set([
|
|
214
|
+
"a",
|
|
215
|
+
"b",
|
|
216
|
+
"code",
|
|
217
|
+
"del",
|
|
218
|
+
"em",
|
|
219
|
+
"i",
|
|
220
|
+
"ins",
|
|
221
|
+
"kbd",
|
|
222
|
+
"mark",
|
|
223
|
+
"s",
|
|
224
|
+
"samp",
|
|
225
|
+
"small",
|
|
226
|
+
"strong",
|
|
227
|
+
"sub",
|
|
228
|
+
"sup",
|
|
229
|
+
"u",
|
|
230
|
+
"var",
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
// Graphics and formulas keep their own layout. In raw-text and RCDATA elements
|
|
234
|
+
// a <wbr> would be literal text, not markup.
|
|
235
|
+
const SKIPPED_IN_CELLS = new Set([
|
|
236
|
+
"svg",
|
|
237
|
+
"math",
|
|
238
|
+
"style",
|
|
239
|
+
"script",
|
|
240
|
+
"textarea",
|
|
241
|
+
"title",
|
|
242
|
+
"xmp",
|
|
243
|
+
"iframe",
|
|
244
|
+
"noembed",
|
|
245
|
+
"noframes",
|
|
246
|
+
"noscript",
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
const breakLongCellRuns = (html: string): string => {
|
|
250
|
+
let skippedDepth = 0;
|
|
251
|
+
let buffer = "";
|
|
252
|
+
// Units of the run that the previous text node in the cell ended with.
|
|
253
|
+
let carried = 0;
|
|
254
|
+
const pieces = new RegExp(`(${CHARACTER_REFERENCE.source})|.`, "gsu");
|
|
255
|
+
const breakText = (text: string): string => {
|
|
256
|
+
let output = "";
|
|
257
|
+
let run: { raw: string; decoded: string }[] = [];
|
|
258
|
+
const flushRun = () => {
|
|
259
|
+
const units = runUnits(run);
|
|
260
|
+
run = [];
|
|
261
|
+
const start = carried;
|
|
262
|
+
carried += units.length;
|
|
263
|
+
// A run that continues in a later text node is counted when it gets
|
|
264
|
+
// there; earlier nodes stay as written, so at most 30 units stay whole.
|
|
265
|
+
if (carried <= MAX_WHOLE_CELL_RUN) {
|
|
266
|
+
output += units.join("");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
output += units
|
|
270
|
+
.map((unit, index) => ((start + index) % CELL_RUN_BREAK_STEP === 0 && start + index > 0 ? `<wbr>${unit}` : unit))
|
|
271
|
+
.join("");
|
|
272
|
+
};
|
|
273
|
+
for (const [raw, reference] of text.matchAll(pieces)) {
|
|
274
|
+
const decoded = reference === undefined ? raw : decodeHTML(raw);
|
|
275
|
+
// Literal and referenced whitespace end runs, except no-break characters.
|
|
276
|
+
if (/^[^\S\u00a0\u2007\u202f\ufeff]+$/u.test(decoded)) {
|
|
277
|
+
flushRun();
|
|
278
|
+
carried = 0;
|
|
279
|
+
output += raw;
|
|
280
|
+
} else {
|
|
281
|
+
run.push({ raw, decoded });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
flushRun();
|
|
285
|
+
return output;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
return new HTMLRewriter()
|
|
289
|
+
.on("td *, th *", {
|
|
290
|
+
element(element) {
|
|
291
|
+
if (INLINE_FORMATTING.has(element.tagName)) return;
|
|
292
|
+
carried = 0;
|
|
293
|
+
// Void elements and self-closing foreign elements have no end tag. HTML
|
|
294
|
+
// ignores the self-closing flag elsewhere: <textarea/> still opens raw text.
|
|
295
|
+
if (!element.canHaveContent) return;
|
|
296
|
+
const skipped = SKIPPED_IN_CELLS.has(element.tagName);
|
|
297
|
+
if (skipped) skippedDepth += 1;
|
|
298
|
+
// One end tag handler per element: a second registration would replace this one.
|
|
299
|
+
element.onEndTag(() => {
|
|
300
|
+
carried = 0;
|
|
301
|
+
if (skipped) skippedDepth -= 1;
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
.on("td, th", {
|
|
306
|
+
element() {
|
|
307
|
+
carried = 0;
|
|
308
|
+
},
|
|
309
|
+
text(chunk) {
|
|
310
|
+
if (skippedDepth) return;
|
|
311
|
+
// HTMLRewriter may split one text node (even an entity) into chunks.
|
|
312
|
+
buffer += chunk.text;
|
|
313
|
+
if (chunk.lastInTextNode) {
|
|
314
|
+
chunk.replace(breakText(buffer), { html: true });
|
|
315
|
+
buffer = "";
|
|
316
|
+
} else {
|
|
317
|
+
chunk.remove();
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
})
|
|
321
|
+
.transform(html);
|
|
322
|
+
};
|
|
323
|
+
|
|
168
324
|
const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
|
|
169
325
|
|
|
170
326
|
const escapeHtml = (value: string): string =>
|
|
@@ -258,7 +414,9 @@ export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
|
|
|
258
414
|
const suppliedCustomCss = input.customCss?.trim() ?? "";
|
|
259
415
|
const customCss = suppliedCustomCss ? validateCustomCss(input.customCss ?? "") : "";
|
|
260
416
|
const presetCss = templateId ? TEMPLATE_CSS[templateId] : customCss ? "" : TEMPLATE_CSS.document;
|
|
261
|
-
const baseCss = [presetCss, INFO_BLOCK_PRINT_CSS, LINK_PRINT_CSS, input.css ? styleText(input.css) : ""]
|
|
417
|
+
const baseCss = [presetCss, INFO_BLOCK_PRINT_CSS, LINK_PRINT_CSS, TABLE_CELL_PRINT_CSS, input.css ? styleText(input.css) : ""]
|
|
418
|
+
.filter(Boolean)
|
|
419
|
+
.join("\n");
|
|
262
420
|
const stylesheet = `${baseCss}${baseCss && customCss ? `\n/* Custom CSS overrides */\n` : ""}${customCss}`;
|
|
263
421
|
return `<!doctype html>
|
|
264
422
|
<html lang="en">
|
|
@@ -268,7 +426,7 @@ export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
|
|
|
268
426
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; img-src 'none'; media-src 'none'; object-src 'none'; script-src 'none'; style-src 'unsafe-inline'">
|
|
269
427
|
<style>${stylesheet}</style>
|
|
270
428
|
</head>
|
|
271
|
-
<body><main class="markdown-document">${input.html}</main></body>
|
|
429
|
+
<body><main class="markdown-document">${breakLongCellRuns(input.html)}</main></body>
|
|
272
430
|
</html>`;
|
|
273
431
|
};
|
|
274
432
|
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type StandaloneServiceAccountKind,
|
|
12
12
|
serviceAccounts,
|
|
13
13
|
} from "./service-accounts";
|
|
14
|
+
import { ConfirmedBrowserSignInError, requireConfirmedBrowserSignIn } from "./session/recent";
|
|
14
15
|
|
|
15
16
|
export type ServiceAccountCredentialStatus = "active" | "revoked";
|
|
16
17
|
export type ServiceAccountCredentialKind = "api_token";
|
|
@@ -388,10 +389,27 @@ export const createApiToken = async (params: {
|
|
|
388
389
|
|
|
389
390
|
export const createUserApiToken = async (params: {
|
|
390
391
|
user: User;
|
|
392
|
+
browserSignIn: { userId: string; sid: string };
|
|
391
393
|
access?: "full" | "models";
|
|
392
394
|
name: string;
|
|
393
395
|
expiresAt?: string | null;
|
|
394
396
|
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
397
|
+
const confirmSignIn = async (db: SQL): Promise<ReturnType<typeof fail> | null> => {
|
|
398
|
+
if (params.browserSignIn.userId !== params.user.id) return fail(err.forbidden("The browser sign-in belongs to another account."));
|
|
399
|
+
try {
|
|
400
|
+
await requireConfirmedBrowserSignIn(db, params.browserSignIn, (code) => {
|
|
401
|
+
throw new ConfirmedBrowserSignInError(code);
|
|
402
|
+
});
|
|
403
|
+
return null;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (error instanceof ConfirmedBrowserSignInError) return fail({ code: error.code, status: 403, message: error.message });
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// Checked before the key holder is created and again where the key is written.
|
|
411
|
+
const early = await confirmSignIn(sql);
|
|
412
|
+
if (early) return early;
|
|
395
413
|
const serviceAccountResult = await getOrCreateUserDelegatedServiceAccount({
|
|
396
414
|
userId: params.user.id,
|
|
397
415
|
createdBy: params.user.id,
|
|
@@ -399,6 +417,9 @@ export const createUserApiToken = async (params: {
|
|
|
399
417
|
if (!serviceAccountResult.ok) return fail(serviceAccountResult.error);
|
|
400
418
|
|
|
401
419
|
return sql.begin(async (tx) => {
|
|
420
|
+
const rejected = await confirmSignIn(tx);
|
|
421
|
+
if (rejected) return rejected;
|
|
422
|
+
|
|
402
423
|
const result = await insertApiToken(tx, {
|
|
403
424
|
serviceAccountId: serviceAccountResult.data.id,
|
|
404
425
|
name: params.name,
|