@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
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { listApps } from "../../_internal/registry";
|
|
2
|
+
import type { ActionCard } from "../../ai/chat/action-card";
|
|
3
|
+
import { type ActionQuestionText, actionCardText, actionNoteLabel } from "../../ai/chat/action-text";
|
|
1
4
|
import {
|
|
2
5
|
APP_APPROVAL_LIMITS,
|
|
3
6
|
type AppActionSummary,
|
|
@@ -5,9 +8,12 @@ import {
|
|
|
5
8
|
appActionDigest,
|
|
6
9
|
APP_ACTION_LIMITS as limits,
|
|
7
10
|
} from "../../contracts/app-approval";
|
|
8
|
-
import { CapabilityActionReviewSchema } from "../../contracts/capabilities";
|
|
9
11
|
import { DEFAULT_LOCALE } from "../../server/locale";
|
|
12
|
+
import { resolveAppIdentityPresentation } from "../../shared/app-presentation";
|
|
13
|
+
import { get } from ".";
|
|
10
14
|
import type { PendingApprovalRow } from "./list";
|
|
15
|
+
import { listPending } from "./list";
|
|
16
|
+
import { type ApprovalActionPolicy, loadApprovalActionPolicies, pendingApprovalCard } from "./question";
|
|
11
17
|
|
|
12
18
|
const plain = (value: string): string =>
|
|
13
19
|
value
|
|
@@ -21,12 +27,26 @@ const cut = (value: string, limit: number): string => {
|
|
|
21
27
|
return points.length > limit ? `${points.slice(0, limit - 1).join("")}…` : value;
|
|
22
28
|
};
|
|
23
29
|
|
|
30
|
+
export { pendingApprovalCard };
|
|
31
|
+
|
|
24
32
|
export const appApprovalText = (value: string, limit: number): string => cut(plain(value), limit);
|
|
25
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Incomplete sends must be decided with the complete review in the browser. Without the declared policy Cloud cannot
|
|
36
|
+
* rule out `sends`, so an incomplete question then stays in the browser too.
|
|
37
|
+
*/
|
|
38
|
+
export const appActionOffered = (view: AppActionView, question: ActionCard | ActionQuestionText, policy?: ApprovalActionPolicy): boolean =>
|
|
39
|
+
!(
|
|
40
|
+
view.truncated &&
|
|
41
|
+
(policy === null ||
|
|
42
|
+
("reasonCode" in question ? question.reasonCode : question.reason.code) === "sends" ||
|
|
43
|
+
(policy && "ask" in policy && policy.ask === "sends"))
|
|
44
|
+
);
|
|
45
|
+
|
|
26
46
|
/** A deterministic, plain-text snapshot; the digest binds the device's choice to everything it shows. */
|
|
27
47
|
export const appActionView = async (
|
|
28
48
|
row: PendingApprovalRow,
|
|
29
|
-
options: { issuer: string; appName: (appId: string, locale: string) => string },
|
|
49
|
+
options: { issuer: string; appName: (appId: string, locale: string) => string; policy?: ApprovalActionPolicy },
|
|
30
50
|
): Promise<AppActionView> => {
|
|
31
51
|
let truncated = false;
|
|
32
52
|
const text = (value: string, limit: number) => {
|
|
@@ -43,40 +63,20 @@ export const appActionView = async (
|
|
|
43
63
|
// The snapshot, so renaming the chat does not change what the device signs.
|
|
44
64
|
title: text(row.requesterTitle ?? "", limits.titleChars) || null,
|
|
45
65
|
};
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
const prefix = `${appName}: `;
|
|
49
|
-
const sentence = first.startsWith(prefix) && first.length > prefix.length ? first.slice(prefix.length) : first;
|
|
50
|
-
const review = CapabilityActionReviewSchema.safeParse(row.details);
|
|
51
|
-
let message: string | null;
|
|
66
|
+
const question = actionCardText(pendingApprovalCard(row, options.appName, options.policy));
|
|
67
|
+
truncated ||= question.incomplete;
|
|
52
68
|
const details: AppActionView["details"] = [];
|
|
53
|
-
|
|
69
|
+
for (const field of question.rows) {
|
|
54
70
|
if (details.length >= limits.details) {
|
|
55
71
|
truncated = true;
|
|
56
|
-
|
|
72
|
+
break;
|
|
57
73
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
details.push({ label
|
|
61
|
-
};
|
|
62
|
-
const messages: string[] = [];
|
|
63
|
-
const reviewLines = new Set<string>();
|
|
64
|
-
if (review.success) {
|
|
65
|
-
const cleaned = plain(review.data.message);
|
|
66
|
-
if (cleaned !== sentence) messages.push(cleaned);
|
|
67
|
-
reviewLines.add(cleaned);
|
|
68
|
-
for (const line of review.data.message.split(/\r?\n/)) reviewLines.add(plain(line));
|
|
69
|
-
for (const field of review.data.details ?? [])
|
|
70
|
-
detail(field.label, field.value, field.format === "date" || field.format === "date-time" ? field.format : undefined);
|
|
71
|
-
}
|
|
72
|
-
for (const line of lines.slice(1)) {
|
|
73
|
-
if (reviewLines.has(line)) continue;
|
|
74
|
-
const match = /^([^:]{1,40}):\s+(.+)$/.exec(line);
|
|
75
|
-
if (match) detail(match[1]!, match[2]!);
|
|
76
|
-
else messages.push(line);
|
|
74
|
+
// The device protocol requires labels; a line an old review left unlabelled is shown as a note, not dropped.
|
|
75
|
+
const label = text(field.label, limits.labelChars) || text(actionNoteLabel(question.locale), limits.labelChars);
|
|
76
|
+
details.push({ label, value: text(field.value, limits.valueChars), ...(field.format ? { format: field.format } : {}) });
|
|
77
77
|
}
|
|
78
|
-
message = text(
|
|
79
|
-
const boundedSentence = text(
|
|
78
|
+
const message = text(question.reason, limits.messageChars) || null;
|
|
79
|
+
const boundedSentence = text(question.question, limits.sentenceChars);
|
|
80
80
|
const view: AppActionView = {
|
|
81
81
|
actionId: row.id,
|
|
82
82
|
app,
|
|
@@ -112,3 +112,21 @@ export const appActionSummary = (view: AppActionView): AppActionSummary => ({
|
|
|
112
112
|
createdAt: view.createdAt,
|
|
113
113
|
expiresAt: view.expiresAt,
|
|
114
114
|
});
|
|
115
|
+
|
|
116
|
+
/** Whether a pending approval can wake a device under the same mapping used by its inbox and detail view. */
|
|
117
|
+
export const deviceOffersApproval = async (approvalId: string): Promise<boolean> => {
|
|
118
|
+
const request = await get(approvalId);
|
|
119
|
+
if (!request) return false;
|
|
120
|
+
const { items } = await listPending({ approverUserId: request.approverUserId, id: approvalId, limit: 1 });
|
|
121
|
+
const row = items[0];
|
|
122
|
+
if (!row) return false;
|
|
123
|
+
const apps = await listApps();
|
|
124
|
+
const appName = (id: string, locale: string) => {
|
|
125
|
+
const app = apps.find((entry) => entry.id === id);
|
|
126
|
+
return app ? resolveAppIdentityPresentation(app, locale).name : id;
|
|
127
|
+
};
|
|
128
|
+
const policy = (await loadApprovalActionPolicies([row]))(row);
|
|
129
|
+
// Offering does not sign a decision; the issuer has no effect on truncation or eligibility.
|
|
130
|
+
const view = await appActionView(row, { issuer: "", appName, policy });
|
|
131
|
+
return appActionOffered(view, pendingApprovalCard(row, appName, policy), policy);
|
|
132
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
2
|
import { capabilityRequestHash } from "../../capabilities/claims";
|
|
3
3
|
import { APPROVAL_ACP_CLIENT } from "../../contracts/approvals";
|
|
4
|
-
import type {
|
|
4
|
+
import type { CapabilityActionReviewData } from "../../contracts/capabilities";
|
|
5
5
|
import { isAppSessionActor } from "../../server/actor";
|
|
6
6
|
import type { RequestActor, RequestCredentialKind } from "../../server/middleware/auth";
|
|
7
7
|
import { audit } from "../audit";
|
|
@@ -9,6 +9,7 @@ import { toPgTextArray } from "../postgres";
|
|
|
9
9
|
|
|
10
10
|
export { countPending, listPending, presentPendingApproval } from "./list";
|
|
11
11
|
export { migrateApprovals } from "./migrate";
|
|
12
|
+
export { type ApprovalActionPolicy, loadApprovalActionPolicies, pendingApprovalCard } from "./question";
|
|
12
13
|
|
|
13
14
|
// Derived from chat's AI_ACTION_BUDGET_MS and Studio's one-day abandonment of pending calls.
|
|
14
15
|
export const APPROVAL_MAX_WAIT_MS = 24 * 60 * 60_000;
|
|
@@ -23,7 +24,8 @@ export type ApprovalAssurance = "standard" | "confirm";
|
|
|
23
24
|
*/
|
|
24
25
|
export const approvalAssurance = (rememberable: boolean): ApprovalAssurance => (rememberable ? "standard" : "confirm");
|
|
25
26
|
export const approvalArgsDigest = (input: unknown): string => capabilityRequestHash(input);
|
|
26
|
-
export const approvalReviewDigest = (review:
|
|
27
|
+
export const approvalReviewDigest = (review: CapabilityActionReviewData | null | undefined): string =>
|
|
28
|
+
capabilityRequestHash(review ?? null);
|
|
27
29
|
export const approvalDecisionChannel = (input: {
|
|
28
30
|
credentialKind?: RequestCredentialKind;
|
|
29
31
|
actor?: RequestActor;
|
|
@@ -50,7 +52,7 @@ export type ApprovalRequestInput = {
|
|
|
50
52
|
approverUserId: string;
|
|
51
53
|
assurance: ApprovalAssurance;
|
|
52
54
|
sentence: string;
|
|
53
|
-
review?:
|
|
55
|
+
review?: CapabilityActionReviewData | null;
|
|
54
56
|
input: unknown;
|
|
55
57
|
expiresInMs: number;
|
|
56
58
|
};
|
|
@@ -67,7 +69,7 @@ export type ApprovalRequest = {
|
|
|
67
69
|
approverUserId: string;
|
|
68
70
|
assurance: ApprovalAssurance;
|
|
69
71
|
sentence: string;
|
|
70
|
-
details:
|
|
72
|
+
details: CapabilityActionReviewData | null;
|
|
71
73
|
argsDigest: string;
|
|
72
74
|
reviewDigest: string;
|
|
73
75
|
status: ApprovalStatus;
|
|
@@ -211,7 +213,7 @@ export type ApprovalConsumeResult =
|
|
|
211
213
|
| { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" | "REVOKED" };
|
|
212
214
|
export const consume = async (
|
|
213
215
|
id: string,
|
|
214
|
-
input: { input: unknown; review:
|
|
216
|
+
input: { input: unknown; review: CapabilityActionReviewData | null; requester: { kind: ApprovalRequesterKind; ref: string } },
|
|
215
217
|
db?: SQL,
|
|
216
218
|
): Promise<ApprovalConsumeResult> => {
|
|
217
219
|
const argsDigest = approvalArgsDigest(input.input),
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
|
+
import { type AiStoredActionReview, storedActionReview } from "../../ai/action-review";
|
|
2
3
|
import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsRememberedApprovals } from "../../ai/approvals";
|
|
3
4
|
import type { AiTurnRunConfig } from "../../ai/types";
|
|
4
5
|
import type { RuntimeAppMeta } from "../../contracts/app";
|
|
5
6
|
import { APPROVALS_PAGE_SIZE, type ApprovalCounts, type PendingApproval } from "../../contracts/approvals";
|
|
6
|
-
import {
|
|
7
|
+
import { type CapabilityActionPolicy, CapabilityActionReviewDataSchema } from "../../contracts/capabilities";
|
|
7
8
|
import type { ApprovalAssurance } from ".";
|
|
8
9
|
import { decodeApprovalCursor, encodeApprovalCursor } from "./cursor";
|
|
9
10
|
|
|
@@ -18,6 +19,7 @@ export type PendingApprovalRow = {
|
|
|
18
19
|
assurance: ApprovalAssurance;
|
|
19
20
|
sentence: string;
|
|
20
21
|
details: unknown;
|
|
22
|
+
storedReview: AiStoredActionReview | null;
|
|
21
23
|
createdAt: Date;
|
|
22
24
|
expiresAt: Date;
|
|
23
25
|
cursorCreatedAt: string;
|
|
@@ -31,7 +33,7 @@ export type PendingApprovalRow = {
|
|
|
31
33
|
};
|
|
32
34
|
|
|
33
35
|
// Start with this person's indexed pending rows; a Studio page alone cannot continue a chat.
|
|
34
|
-
const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
|
|
36
|
+
export const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
|
|
35
37
|
WHERE r.approver_user_id=${approverUserId}::uuid AND r.status='pending' AND r.expires_at > now()
|
|
36
38
|
AND r.approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${approverUserId}::uuid)
|
|
37
39
|
AND (${id ?? null}::uuid IS NULL OR r.id=${id ?? null}::uuid)
|
|
@@ -61,10 +63,21 @@ export const listPending = async (input: {
|
|
|
61
63
|
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "cursorCreatedAt",
|
|
62
64
|
question.short_id AS "conversationShortId", question.title AS "conversationTitle",
|
|
63
65
|
question.allow_chat AS "allowChat", question.allow_always AS "allowAlways",
|
|
64
|
-
question.remember_tool_name AS "rememberToolName", question.tool_name AS "toolName", question.run_config AS "runConfig"
|
|
66
|
+
question.remember_tool_name AS "rememberToolName", question.tool_name AS "toolName", question.run_config AS "runConfig",
|
|
67
|
+
question.stored_review AS "storedReview"
|
|
65
68
|
FROM open r JOIN LATERAL (
|
|
66
|
-
SELECT c.short_id, c.title, p.allow_chat, p.allow_always, p.remember_tool_name, p.tool_name, t.run_config
|
|
69
|
+
SELECT c.short_id, c.title, p.allow_chat, p.allow_always, p.remember_tool_name, p.tool_name, t.run_config,
|
|
70
|
+
(SELECT m.meta->'toolReviews'->review.call_id FROM (
|
|
71
|
+
SELECT meta, conversation_id, loop_id, role, seq FROM ai.messages
|
|
72
|
+
UNION ALL
|
|
73
|
+
SELECT meta, conversation_id, loop_id, role, seq FROM ai.task_messages
|
|
74
|
+
) m WHERE m.conversation_id=p.conversation_id AND m.loop_id=p.turn_id::text AND m.role='assistant'
|
|
75
|
+
AND m.meta->'toolReviews'->review.call_id IS NOT NULL
|
|
76
|
+
ORDER BY m.seq DESC LIMIT 1) AS stored_review
|
|
67
77
|
FROM ai.pending_actions p JOIN ai.conversations c ON c.id=p.conversation_id JOIN ai.turns t ON t.id=p.turn_id
|
|
78
|
+
-- A capability's own approval waits as "<call>-approval-<n>"; its review is stored under the call itself.
|
|
79
|
+
CROSS JOIN LATERAL (SELECT CASE WHEN p.kind = 'custom_approval'
|
|
80
|
+
THEN COALESCE(substring(p.call_id FROM '^(.*)-approval-[0-9]+$'), p.call_id) ELSE p.call_id END AS call_id) review
|
|
68
81
|
WHERE p.approval_id=r.id AND p.status='pending' ORDER BY p.created_at DESC, p.id DESC LIMIT 1
|
|
69
82
|
) question ON true
|
|
70
83
|
WHERE ${matches(input.appId, input.link)}
|
|
@@ -93,9 +106,17 @@ export const countPending = async (input: { approverUserId: string }): Promise<A
|
|
|
93
106
|
export const presentPendingApproval = (
|
|
94
107
|
row: PendingApprovalRow,
|
|
95
108
|
apps: readonly Pick<RuntimeAppMeta, "id" | "name" | "icon">[],
|
|
109
|
+
policy?: CapabilityActionPolicy,
|
|
96
110
|
): PendingApproval => {
|
|
97
111
|
const app = apps.find((entry) => entry.id === row.appId);
|
|
98
|
-
const review =
|
|
112
|
+
const review = CapabilityActionReviewDataSchema.nullable().safeParse(row.details);
|
|
113
|
+
const originalReview = review.success ? review.data : null;
|
|
114
|
+
const displayReview =
|
|
115
|
+
originalReview && "sentence" in originalReview
|
|
116
|
+
? row.storedReview && "sentence" in row.storedReview
|
|
117
|
+
? row.storedReview
|
|
118
|
+
: storedActionReview(originalReview, policy)
|
|
119
|
+
: originalReview;
|
|
99
120
|
const remember = row.assurance === "standard" && aiTurnAllowsRememberedApprovals(row.runConfig);
|
|
100
121
|
return {
|
|
101
122
|
id: row.id,
|
|
@@ -103,7 +124,7 @@ export const presentPendingApproval = (
|
|
|
103
124
|
action: row.action,
|
|
104
125
|
assurance: row.assurance,
|
|
105
126
|
sentence: row.sentence,
|
|
106
|
-
review:
|
|
127
|
+
review: displayReview && "legacy" in displayReview ? originalReview : displayReview,
|
|
107
128
|
requester: {
|
|
108
129
|
kind: "chat",
|
|
109
130
|
appId: row.requesterAppId,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { listCapabilities } from "../../_internal/registry";
|
|
2
|
+
import { type AiStoredActionReview, storedActionReview } from "../../ai/action-review";
|
|
3
|
+
import type { ActionCard } from "../../ai/chat/action-card";
|
|
4
|
+
import { approvalCard } from "../../approvals/approval-card";
|
|
5
|
+
import {
|
|
6
|
+
CapabilityActionCallReviewSchema,
|
|
7
|
+
type CapabilityActionPolicy,
|
|
8
|
+
CapabilityActionReviewDataSchema,
|
|
9
|
+
capabilityActionPolicy,
|
|
10
|
+
} from "../../contracts/capabilities";
|
|
11
|
+
import { DEFAULT_LOCALE } from "../../server/locale";
|
|
12
|
+
import type { PendingApprovalRow } from "./list";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One open approval as the question card words it, for places outside the browser: Cloud Login and notifications.
|
|
16
|
+
* The approvals page builds the same card from the same rows.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The declared policy of a row's Action, needed for new-shape reviews' device eligibility and fallback reason:
|
|
21
|
+
* `undefined` when none is needed or declared, `null` when the registry could not say.
|
|
22
|
+
*/
|
|
23
|
+
export type ApprovalActionPolicy = CapabilityActionPolicy | null | undefined;
|
|
24
|
+
const needsPolicy = (row: PendingApprovalRow): boolean => {
|
|
25
|
+
if (row.storedReview && "sentence" in row.storedReview) return true;
|
|
26
|
+
const review = CapabilityActionCallReviewSchema.safeParse(row.details);
|
|
27
|
+
return review.success;
|
|
28
|
+
};
|
|
29
|
+
/** Reads the registry once for the rows that need their Action's declared policy, as the approvals page does. */
|
|
30
|
+
export const loadApprovalActionPolicies = async (
|
|
31
|
+
rows: readonly PendingApprovalRow[],
|
|
32
|
+
): Promise<(row: PendingApprovalRow) => ApprovalActionPolicy> => {
|
|
33
|
+
if (!rows.some(needsPolicy)) return () => undefined;
|
|
34
|
+
const capabilities = await listCapabilities().catch(() => null);
|
|
35
|
+
return (row) => {
|
|
36
|
+
if (!needsPolicy(row)) return undefined;
|
|
37
|
+
if (capabilities === null) return null;
|
|
38
|
+
// An app that is not registered right now cannot say why its Action asks either.
|
|
39
|
+
const manifest = capabilities.find((entry) => entry.appId === row.appId)?.manifest;
|
|
40
|
+
return manifest ? capabilityActionPolicy(manifest, row.action) : null;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Prefer the turn's snapshot, then the linked request's review. */
|
|
45
|
+
export const pendingApprovalReview = (
|
|
46
|
+
row: Pick<PendingApprovalRow, "storedReview" | "details">,
|
|
47
|
+
policy?: ApprovalActionPolicy,
|
|
48
|
+
): AiStoredActionReview | null => {
|
|
49
|
+
const parsed = CapabilityActionReviewDataSchema.safeParse(row.details);
|
|
50
|
+
return row.storedReview ?? (parsed.success ? storedActionReview(parsed.data, policy ?? undefined) : null);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Resolve the recorded question in the language and time zone of its originating turn. */
|
|
54
|
+
export const pendingApprovalCard = (
|
|
55
|
+
row: PendingApprovalRow,
|
|
56
|
+
appName: (appId: string, locale: string) => string,
|
|
57
|
+
policy?: ApprovalActionPolicy,
|
|
58
|
+
fallbackLocale = DEFAULT_LOCALE,
|
|
59
|
+
): ActionCard => {
|
|
60
|
+
const locale = row.runConfig?.kind === "compact" ? fallbackLocale : (row.runConfig?.locale ?? fallbackLocale);
|
|
61
|
+
const timeZone = row.runConfig?.kind === "compact" ? "UTC" : (row.runConfig?.timeZone ?? "UTC");
|
|
62
|
+
const review = pendingApprovalReview(row, policy);
|
|
63
|
+
return approvalCard(
|
|
64
|
+
{ sentence: row.sentence, action: row.action, appName: appName(row.appId, locale), appIcon: null, review },
|
|
65
|
+
locale,
|
|
66
|
+
timeZone,
|
|
67
|
+
);
|
|
68
|
+
};
|
|
@@ -122,6 +122,7 @@ const SELF_SERVICE_ACTION_LABELS = {
|
|
|
122
122
|
"webauthn_credential.authenticate": "Passkey used",
|
|
123
123
|
"auth.pwa.device.enroll": "App paired",
|
|
124
124
|
"auth.pwa.device.revoke": "App removed",
|
|
125
|
+
"auth.session.sign_out_others": "Signed out everywhere else",
|
|
125
126
|
} as const satisfies Record<string, string>;
|
|
126
127
|
const SELF_SERVICE_ACTIONS = Object.keys(SELF_SERVICE_ACTION_LABELS);
|
|
127
128
|
|
|
@@ -4,6 +4,7 @@ import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
|
4
4
|
import { accounts } from "../accounts";
|
|
5
5
|
import { logger } from "../logging";
|
|
6
6
|
import { providers } from "../providers";
|
|
7
|
+
import { session } from "../session";
|
|
7
8
|
|
|
8
9
|
type IpaLoginFailure =
|
|
9
10
|
| { ok: false; status: 403; reason: "category_disabled"; message: string }
|
|
@@ -19,6 +20,8 @@ type IpaLoginSuccess = {
|
|
|
19
20
|
ok: true;
|
|
20
21
|
userId: string;
|
|
21
22
|
user: User;
|
|
23
|
+
/** Read before the password check; session issuance refuses a newer one. */
|
|
24
|
+
authEpoch: number;
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
export type IpaLoginFlowResult = IpaLoginSuccess | IpaLoginFailure;
|
|
@@ -96,6 +99,13 @@ export const login = async (params: { username: string; password: string }): Pro
|
|
|
96
99
|
return failInvalidCredentials({ identifier: params.username, password: params.password });
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
// Read before the password check so a concurrent password change or sign-out ends this sign-in too.
|
|
103
|
+
const [account] = await sql<Array<{ auth_epoch: string | number | bigint }>>`
|
|
104
|
+
SELECT auth_epoch FROM auth.users WHERE provider = 'ipa' AND uid = ${uid}
|
|
105
|
+
`;
|
|
106
|
+
const authEpoch = account ? Number(account.auth_epoch) : null;
|
|
107
|
+
if (authEpoch !== null && (!Number.isSafeInteger(authEpoch) || authEpoch < 0)) throw new Error("User auth_epoch is invalid");
|
|
108
|
+
|
|
99
109
|
const loginResult = await providers.ipa.auth.login(uid, params.password).catch((error) => {
|
|
100
110
|
log.error("FreeIPA login transport failed", {
|
|
101
111
|
uid,
|
|
@@ -168,11 +178,21 @@ export const login = async (params: { username: string; password: string }): Pro
|
|
|
168
178
|
|
|
169
179
|
const userResult = await loadSyncedIpaUser(uid);
|
|
170
180
|
if (!userResult.ok) return userResult;
|
|
181
|
+
// An account synced in only after the read has no epoch this sign-in is bound to; the next attempt has one.
|
|
182
|
+
if (authEpoch === null) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
status: 400,
|
|
186
|
+
reason: "user_not_synced",
|
|
187
|
+
message: "Your account is not yet available. Please try again in a few minutes.",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
171
190
|
|
|
172
191
|
return {
|
|
173
192
|
ok: true,
|
|
174
193
|
userId: userResult.userId,
|
|
175
194
|
user: userResult.user,
|
|
195
|
+
authEpoch,
|
|
176
196
|
};
|
|
177
197
|
};
|
|
178
198
|
|
|
@@ -180,7 +200,11 @@ export const changeExpiredPassword = async (params: {
|
|
|
180
200
|
username: string;
|
|
181
201
|
currentPassword: string;
|
|
182
202
|
newPassword: string;
|
|
183
|
-
}): Promise<
|
|
203
|
+
}): Promise<
|
|
204
|
+
| (IpaLoginSuccess & { signOutOthersFailed: boolean })
|
|
205
|
+
| IpaLoginFailure
|
|
206
|
+
| { ok: false; status: number; reason: "change_failed"; message: string }
|
|
207
|
+
> => {
|
|
184
208
|
if (!(await isAccountCategoryAllowed({ provider: "ipa", profile: "user" })))
|
|
185
209
|
return { ok: false, status: 403, reason: "category_disabled", message: "FreeIPA accounts are disabled. Contact an administrator." };
|
|
186
210
|
const changeResult = await providers.ipa.auth.changeExpiredPassword(params);
|
|
@@ -193,5 +217,18 @@ export const changeExpiredPassword = async (params: {
|
|
|
193
217
|
};
|
|
194
218
|
}
|
|
195
219
|
|
|
196
|
-
|
|
220
|
+
// Resolve the local account here, before the caller issues its new browser session, even if login later fails.
|
|
221
|
+
const uid = await resolveIpaLoginUid(params.username);
|
|
222
|
+
const [user] = uid ? await sql<Array<{ id: string }>>`SELECT id FROM auth.users WHERE provider = 'ipa' AND uid = ${uid}` : [];
|
|
223
|
+
let signOutOthersFailed = false;
|
|
224
|
+
if (user) {
|
|
225
|
+
try {
|
|
226
|
+
await session.endOtherSignIns(user.id, { reason: "password_change" });
|
|
227
|
+
} catch {
|
|
228
|
+
log.warn("Password changed, but ending other sign-ins failed", { userId: user.id });
|
|
229
|
+
signOutOthersFailed = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const loginResult = await login({ username: params.username, password: params.newPassword });
|
|
233
|
+
return loginResult.ok ? { ...loginResult, signOutOthersFailed } : loginResult;
|
|
197
234
|
};
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import type { NewBrowserSignInNotifier } from "../known-browsers";
|
|
2
|
+
|
|
1
3
|
export type AuthNotificationDeliveryResult = {
|
|
2
4
|
id: string;
|
|
3
5
|
status: "queued" | "delivered" | "suppressed" | "error";
|
|
4
6
|
};
|
|
5
7
|
|
|
6
8
|
export type AuthNotificationSender = {
|
|
9
|
+
sendNewBrowserSignIn: NewBrowserSignInNotifier;
|
|
7
10
|
sendMagicLink: (input: { email: string; token: string; magicLink: string; locale?: string }) => Promise<AuthNotificationDeliveryResult>;
|
|
8
11
|
sendIpaLoginHint: (input: { email: string; loginUrl: string; locale?: string }) => Promise<AuthNotificationDeliveryResult>;
|
|
9
12
|
sendPasswordReset: (input: { email: string; resetLink: string; locale?: string }) => Promise<AuthNotificationDeliveryResult>;
|
|
@@ -29,6 +29,8 @@ type ResetAttemptSuccess = {
|
|
|
29
29
|
ok: true;
|
|
30
30
|
userId: string;
|
|
31
31
|
user: User;
|
|
32
|
+
authEpoch: number;
|
|
33
|
+
signOutOthersFailed: boolean;
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
type ResetAttemptFailure =
|
|
@@ -154,13 +156,24 @@ const changeTemporaryPassword = async (params: {
|
|
|
154
156
|
};
|
|
155
157
|
}
|
|
156
158
|
|
|
157
|
-
|
|
159
|
+
// The old password stayed valid while FreeIPA changed it, so a sign-in or device paired in that time outlives the
|
|
160
|
+
// sign-out before the change. Signing out everywhere again ends them, and everything the change already ended.
|
|
161
|
+
// Without it, this browser gets no session: the retry for other sign-ins keeps devices, a new reset link does not.
|
|
162
|
+
// Bind issuance to this final sign-out so a later sign-out also ends this sign-in.
|
|
163
|
+
let authEpoch: number;
|
|
164
|
+
try {
|
|
165
|
+
authEpoch = await session.revokeAllForUser(changeResult.userId);
|
|
166
|
+
} catch {
|
|
167
|
+
log.warn("Password reset: signing out everywhere after the change failed", { userId: changeResult.userId });
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
status: 500,
|
|
171
|
+
reason: "reset_failed",
|
|
172
|
+
message: "Your password was changed, but signing you out everywhere did not work. Request a new reset link to finish.",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
158
175
|
|
|
159
|
-
return {
|
|
160
|
-
ok: true,
|
|
161
|
-
userId: changeResult.userId,
|
|
162
|
-
user: changeResult.user,
|
|
163
|
-
};
|
|
176
|
+
return { ok: true, userId: changeResult.userId, user: changeResult.user, authEpoch, signOutOthersFailed: false };
|
|
164
177
|
};
|
|
165
178
|
|
|
166
179
|
type ResetRequest = { email: string; redirectTo?: string; locale?: string };
|
|
@@ -258,6 +271,19 @@ export const complete = async (params: { token?: string; newPassword: string }):
|
|
|
258
271
|
};
|
|
259
272
|
}
|
|
260
273
|
|
|
274
|
+
// The consumed reset token authorizes signing out everywhere; unpair phones before changing the password.
|
|
275
|
+
try {
|
|
276
|
+
await session.revokeAllForUser(target.userId);
|
|
277
|
+
} catch {
|
|
278
|
+
log.warn("Password reset stopped: signing out everywhere failed", { userId: target.userId });
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
status: 500,
|
|
282
|
+
reason: "reset_failed",
|
|
283
|
+
message: "Signing you out everywhere did not work, so your password was not changed. Request a new reset link.",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
261
287
|
const resetResult = await providers.ipa.users.resetPassword({
|
|
262
288
|
ipaSession: serviceSession.data,
|
|
263
289
|
id: target.userId,
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { HTTPException } from "hono/http-exception";
|
|
3
|
+
import {
|
|
4
|
+
DBIP_ATTRIBUTION,
|
|
5
|
+
GEOIP_MAX_DATABASE_BYTES,
|
|
6
|
+
GEOIP_MAX_REQUEST_BYTES,
|
|
7
|
+
type GeoIpAdminDatabase,
|
|
8
|
+
type GeoIpAdminStatus,
|
|
9
|
+
type GeoIpKind,
|
|
10
|
+
} from "../../contracts/geoip";
|
|
11
|
+
import { createGeoIp, type GeoIp, type GeoIpDatabaseInfo, getGeoIp } from "../../server/request-context/geoip";
|
|
12
|
+
import { audit } from "../audit";
|
|
13
|
+
import { GeoIpError, parseGeoIpUpload, readGeoIpUpload } from "./upload";
|
|
14
|
+
|
|
15
|
+
export { GEOIP_MAX_DATABASE_BYTES, GEOIP_MAX_REQUEST_BYTES } from "../../contracts/geoip";
|
|
16
|
+
export { GeoIpError } from "./upload";
|
|
17
|
+
|
|
18
|
+
type Actor = { id: string; roles: readonly string[]; uid?: string; provider?: string };
|
|
19
|
+
export type GeoIpUploadInfo = {
|
|
20
|
+
kind: GeoIpKind;
|
|
21
|
+
sha256: string;
|
|
22
|
+
size: number;
|
|
23
|
+
database_type: string;
|
|
24
|
+
build_epoch: Date;
|
|
25
|
+
description: string | null;
|
|
26
|
+
uploaded_at: Date;
|
|
27
|
+
uploaded_by: string | null;
|
|
28
|
+
uploader_name: string | null;
|
|
29
|
+
};
|
|
30
|
+
const requireAdmin = (actor: Actor) => {
|
|
31
|
+
if (!actor.roles.includes("admin")) throw new HTTPException(403, { message: "Administrator access required." });
|
|
32
|
+
};
|
|
33
|
+
let bundledInfo: ReturnType<GeoIp["status"]> | undefined;
|
|
34
|
+
const getBundledInfo = () => (bundledInfo ??= createGeoIp().status());
|
|
35
|
+
const isDbIp = (value: string) => /db[\s-]?ip/i.test(value);
|
|
36
|
+
|
|
37
|
+
export const geoIpAdminStatus = (
|
|
38
|
+
rows: GeoIpUploadInfo[],
|
|
39
|
+
bundled: { city: GeoIpDatabaseInfo | null; asn: GeoIpDatabaseInfo | null },
|
|
40
|
+
now: number,
|
|
41
|
+
): GeoIpAdminStatus => {
|
|
42
|
+
const database = (kind: GeoIpKind): GeoIpAdminDatabase => {
|
|
43
|
+
const row = rows.find((row) => row.kind === kind);
|
|
44
|
+
const info = bundled[kind];
|
|
45
|
+
const buildDate = row?.build_epoch ?? info?.buildDate;
|
|
46
|
+
return {
|
|
47
|
+
kind,
|
|
48
|
+
source: row ? "uploaded" : (info?.source ?? null),
|
|
49
|
+
bundled: info !== null,
|
|
50
|
+
databaseType: row?.database_type ?? info?.databaseType ?? null,
|
|
51
|
+
buildDate: buildDate?.toISOString() ?? null,
|
|
52
|
+
stale: buildDate !== undefined && now - buildDate.getTime() > 183 * 24 * 60 * 60 * 1000,
|
|
53
|
+
dbIp: row ? isDbIp(`${row.database_type} ${row.description ?? ""}`) : info !== null,
|
|
54
|
+
upload: row
|
|
55
|
+
? {
|
|
56
|
+
sha256: row.sha256,
|
|
57
|
+
size: row.size,
|
|
58
|
+
uploadedAt: row.uploaded_at.toISOString(),
|
|
59
|
+
uploadedBy: row.uploaded_by ? { id: row.uploaded_by, name: row.uploader_name ?? row.uploaded_by } : null,
|
|
60
|
+
}
|
|
61
|
+
: null,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
const databases: GeoIpAdminStatus["databases"] = [database("city"), database("asn")];
|
|
65
|
+
return {
|
|
66
|
+
databases,
|
|
67
|
+
attribution: databases.some((item) => item.dbIp) ? DBIP_ATTRIBUTION : null,
|
|
68
|
+
limits: { maxDatabaseBytes: GEOIP_MAX_DATABASE_BYTES, maxRequestBytes: GEOIP_MAX_REQUEST_BYTES },
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
let uploading = false;
|
|
73
|
+
// Each write and its reader install run one after another in this process, so the local reader follows the
|
|
74
|
+
// commit order; other instances follow through their refresh.
|
|
75
|
+
let writes: Promise<unknown> = Promise.resolve();
|
|
76
|
+
const serialized = <T>(write: () => Promise<T>): Promise<T> => {
|
|
77
|
+
const run = writes.then(write, write);
|
|
78
|
+
writes = run.catch(() => {});
|
|
79
|
+
return run;
|
|
80
|
+
};
|
|
81
|
+
export const createGeoIpService = (options: { db?: typeof sql; reader?: GeoIp; bundled?: () => ReturnType<GeoIp["status"]> } = {}) => {
|
|
82
|
+
const db = options.db ?? sql;
|
|
83
|
+
const reader = () => options.reader ?? getGeoIp();
|
|
84
|
+
const status = async (actor: Actor, config: { now?: () => number } = {}): Promise<GeoIpAdminStatus> => {
|
|
85
|
+
requireAdmin(actor);
|
|
86
|
+
const rows = await db<GeoIpUploadInfo[]>`
|
|
87
|
+
SELECT d.kind, d.sha256, (d.metadata->>'size')::int AS size, d.database_type, d.build_epoch,
|
|
88
|
+
d.metadata->'description'->>'en' AS description, d.uploaded_at, d.uploaded_by,
|
|
89
|
+
COALESCE(NULLIF(u.display_name, ''), u.uid) AS uploader_name
|
|
90
|
+
FROM geoip.databases d LEFT JOIN auth.users u ON u.id = d.uploaded_by`;
|
|
91
|
+
return geoIpAdminStatus(rows, (options.bundled ?? getBundledInfo)(), (config.now ?? Date.now)());
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
status,
|
|
95
|
+
async upload(actor: Actor, body: ReadableStream<Uint8Array>, config: { contentLength?: number; signal?: AbortSignal }) {
|
|
96
|
+
requireAdmin(actor);
|
|
97
|
+
if (uploading) throw new GeoIpError("geoip_upload_busy");
|
|
98
|
+
uploading = true;
|
|
99
|
+
try {
|
|
100
|
+
const bytes = await readGeoIpUpload(body, config.contentLength);
|
|
101
|
+
const { kind, database, metadata } = parseGeoIpUpload(bytes);
|
|
102
|
+
const sha256 = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
|
103
|
+
database.info.sha256 = sha256;
|
|
104
|
+
config.signal?.throwIfAborted();
|
|
105
|
+
await serialized(async () => {
|
|
106
|
+
await db.begin(async (tx) => {
|
|
107
|
+
await tx`
|
|
108
|
+
INSERT INTO geoip.databases (kind, data, sha256, database_type, build_epoch, metadata, uploaded_by)
|
|
109
|
+
VALUES (${kind}, ${bytes}, ${sha256}, ${metadata.databaseType}, ${metadata.buildEpoch}::timestamptz,
|
|
110
|
+
${JSON.stringify(metadata)}::jsonb, ${actor.id}::uuid)
|
|
111
|
+
ON CONFLICT (kind) DO UPDATE SET data = EXCLUDED.data, sha256 = EXCLUDED.sha256,
|
|
112
|
+
database_type = EXCLUDED.database_type, build_epoch = EXCLUDED.build_epoch, metadata = EXCLUDED.metadata,
|
|
113
|
+
uploaded_by = EXCLUDED.uploaded_by, uploaded_at = now()`;
|
|
114
|
+
await audit.record(
|
|
115
|
+
{
|
|
116
|
+
action: "core.geoip.upload",
|
|
117
|
+
outcome: "allowed",
|
|
118
|
+
actor: { ...actor, userId: actor.id },
|
|
119
|
+
metadata: { kind, databaseType: metadata.databaseType, buildDate: metadata.buildEpoch, sha256, size: bytes.length },
|
|
120
|
+
},
|
|
121
|
+
tx,
|
|
122
|
+
);
|
|
123
|
+
config.signal?.throwIfAborted();
|
|
124
|
+
});
|
|
125
|
+
reader().install(kind, database);
|
|
126
|
+
});
|
|
127
|
+
return { ...(await status(actor)), kind };
|
|
128
|
+
} finally {
|
|
129
|
+
uploading = false;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
async reset(actor: Actor, kind: GeoIpKind) {
|
|
133
|
+
requireAdmin(actor);
|
|
134
|
+
const changed = await serialized(async () => {
|
|
135
|
+
const deleted = await db.begin(async (tx) => {
|
|
136
|
+
const [row] = await tx<{ sha256: string; database_type: string; build_epoch: Date; size: number }[]>`
|
|
137
|
+
DELETE FROM geoip.databases WHERE kind = ${kind}
|
|
138
|
+
RETURNING sha256, database_type, build_epoch, octet_length(data) AS size`;
|
|
139
|
+
if (!row) return false;
|
|
140
|
+
await audit.record(
|
|
141
|
+
{
|
|
142
|
+
action: "core.geoip.reset",
|
|
143
|
+
outcome: "allowed",
|
|
144
|
+
actor: { ...actor, userId: actor.id },
|
|
145
|
+
metadata: {
|
|
146
|
+
kind,
|
|
147
|
+
databaseType: row.database_type,
|
|
148
|
+
buildDate: row.build_epoch.toISOString(),
|
|
149
|
+
sha256: row.sha256,
|
|
150
|
+
size: row.size,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
tx,
|
|
154
|
+
);
|
|
155
|
+
return true;
|
|
156
|
+
});
|
|
157
|
+
reader().install(kind, null);
|
|
158
|
+
return deleted;
|
|
159
|
+
});
|
|
160
|
+
return { ...(await status(actor)), changed };
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
export const geoip = createGeoIpService();
|