@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,380 @@
|
|
|
1
|
+
import { Reader } from "mmdb-lib";
|
|
2
|
+
import type { GeoIpKind } from "../../contracts/geoip";
|
|
3
|
+
import { geoIpUploads } from "../../services/geoip/store";
|
|
4
|
+
import { logger } from "../../services/logging";
|
|
5
|
+
import { publicClientAddress } from "./address";
|
|
6
|
+
|
|
7
|
+
export const BUNDLED_GEOIP_PATHS = {
|
|
8
|
+
cityPath: "/app/geoip/dbip-city-lite.mmdb",
|
|
9
|
+
asnPath: "/app/geoip/dbip-asn-lite.mmdb",
|
|
10
|
+
} as const;
|
|
11
|
+
export { DBIP_ATTRIBUTION } from "../../contracts/geoip";
|
|
12
|
+
|
|
13
|
+
// Both names fit the 512-byte request context budget of the later slice.
|
|
14
|
+
const NAME_MAX_BYTES = 96;
|
|
15
|
+
export type GeoIpDatabaseInfo = { databaseType: string; buildDate: Date; source: "bundled" | "uploaded"; sha256?: string };
|
|
16
|
+
|
|
17
|
+
export const truncateUtf8 = (value: string, maxBytes: number): string => {
|
|
18
|
+
let result = "";
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
for (const char of value) {
|
|
21
|
+
const point = char.codePointAt(0)!;
|
|
22
|
+
// Drop lone surrogates rather than carrying invalid Unicode into stored context.
|
|
23
|
+
if (point >= 0xd800 && point <= 0xdfff) continue;
|
|
24
|
+
const size = point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
|
|
25
|
+
if (bytes + size > maxBytes) break;
|
|
26
|
+
result += char;
|
|
27
|
+
bytes += size;
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const cleanName = (value: unknown): string | null => {
|
|
33
|
+
if (typeof value !== "string") return null;
|
|
34
|
+
return (
|
|
35
|
+
truncateUtf8(
|
|
36
|
+
value
|
|
37
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "")
|
|
38
|
+
.replace(/\s+/g, " ")
|
|
39
|
+
.trim(),
|
|
40
|
+
NAME_MAX_BYTES,
|
|
41
|
+
) || null
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
const record = (value: unknown): Record<string, unknown> =>
|
|
45
|
+
typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {};
|
|
46
|
+
|
|
47
|
+
const METADATA_MARKER = Buffer.from("abcdef4d61784d696e642e636f6d", "hex");
|
|
48
|
+
const isBoundedMmdbValue = (
|
|
49
|
+
bytes: Buffer,
|
|
50
|
+
start: number,
|
|
51
|
+
base: number,
|
|
52
|
+
end: number,
|
|
53
|
+
checked: Set<number>,
|
|
54
|
+
expandedLimit?: number,
|
|
55
|
+
): boolean => {
|
|
56
|
+
if (start < base || start >= end) return false;
|
|
57
|
+
let budget = expandedLimit === undefined ? Math.min(end - base, RECORD_MAX_WORK) : end - base;
|
|
58
|
+
let offset = start;
|
|
59
|
+
let expansionBudget = expandedLimit ?? Infinity;
|
|
60
|
+
const costs = expandedLimit === undefined ? undefined : new Map<number, number>();
|
|
61
|
+
const active = new Set([start]);
|
|
62
|
+
checked.add(start);
|
|
63
|
+
const stack: { remaining: number; map?: boolean; resume?: number; target?: number; expansionStart?: number }[] = [{ remaining: 1 }];
|
|
64
|
+
while (stack.length) {
|
|
65
|
+
const frame = stack[stack.length - 1]!;
|
|
66
|
+
if (!frame.remaining) {
|
|
67
|
+
stack.pop();
|
|
68
|
+
if (frame.target !== undefined) {
|
|
69
|
+
active.delete(frame.target);
|
|
70
|
+
costs?.set(frame.target, frame.expansionStart! - expansionBudget);
|
|
71
|
+
}
|
|
72
|
+
offset = frame.resume ?? offset;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const key = frame.map && frame.remaining % 2 === 0;
|
|
76
|
+
frame.remaining--;
|
|
77
|
+
if (--budget < 0 || --expansionBudget < 0 || offset < base || offset >= end) return false;
|
|
78
|
+
const control = bytes[offset++]!;
|
|
79
|
+
let type = control >> 5;
|
|
80
|
+
if (key && type !== 2 && type !== 1) return false;
|
|
81
|
+
if (type === 1) {
|
|
82
|
+
const size = (control >> 3) & 3;
|
|
83
|
+
if (offset + size + 1 > end) return false;
|
|
84
|
+
let target = size === 3 ? 0 : control & 7;
|
|
85
|
+
for (let i = 0; i <= size; i++) target = target * 256 + bytes[offset++]!;
|
|
86
|
+
target += base + [0, 2048, 526336, 0][size]!;
|
|
87
|
+
if (target < base || target >= end || active.has(target)) return false;
|
|
88
|
+
// A key may point directly to a string, never another pointer or a container.
|
|
89
|
+
if (key && bytes[target]! >> 5 !== 2) return false;
|
|
90
|
+
if (checked.has(target)) {
|
|
91
|
+
expansionBudget -= costs?.get(target) ?? 0;
|
|
92
|
+
if (expansionBudget < 0) return false;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
checked.add(target);
|
|
96
|
+
active.add(target);
|
|
97
|
+
stack.push({ remaining: 1, resume: offset, target, expansionStart: expansionBudget });
|
|
98
|
+
offset = target;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (type === 0) {
|
|
102
|
+
if (offset >= end) return false;
|
|
103
|
+
type = bytes[offset++]! + 7;
|
|
104
|
+
if (type < 8 || type > 15) return false;
|
|
105
|
+
}
|
|
106
|
+
let size = control & 31;
|
|
107
|
+
if (size >= 29) {
|
|
108
|
+
const extra = size - 28;
|
|
109
|
+
if (offset + extra > end) return false;
|
|
110
|
+
size = [29, 285, 65821][extra - 1]! + bytes.readUIntBE(offset, extra);
|
|
111
|
+
offset += extra;
|
|
112
|
+
}
|
|
113
|
+
if (type === 7 || type === 11) {
|
|
114
|
+
const remaining = size * (type === 7 ? 2 : 1);
|
|
115
|
+
if (remaining > budget) return false;
|
|
116
|
+
stack.push({ remaining, map: type === 7 });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
switch (type) {
|
|
120
|
+
case 2:
|
|
121
|
+
case 4:
|
|
122
|
+
break;
|
|
123
|
+
case 3:
|
|
124
|
+
if (size !== 8) return false;
|
|
125
|
+
break;
|
|
126
|
+
case 5:
|
|
127
|
+
case 6:
|
|
128
|
+
case 8:
|
|
129
|
+
case 9:
|
|
130
|
+
case 10:
|
|
131
|
+
if (size > ({ 5: 2, 6: 4, 8: 4, 9: 8, 10: 16 } as const)[type]) return false;
|
|
132
|
+
break;
|
|
133
|
+
case 14:
|
|
134
|
+
if (size > 1) return false;
|
|
135
|
+
size = 0;
|
|
136
|
+
break;
|
|
137
|
+
case 15:
|
|
138
|
+
if (size !== 4) return false;
|
|
139
|
+
break;
|
|
140
|
+
default:
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
// Count scalar payload bytes on first visit for records, and on every expansion for uncached metadata.
|
|
144
|
+
expansionBudget -= size;
|
|
145
|
+
if (expandedLimit === undefined) budget -= size;
|
|
146
|
+
if (offset + size > end || expansionBudget < 0 || budget < 0) return false;
|
|
147
|
+
offset += size;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// The MMDB spec caps the metadata section, marker included, at 128 KiB.
|
|
153
|
+
const METADATA_MAX_BYTES = 128 * 1024;
|
|
154
|
+
// One lookup decodes at most this many value nodes plus payload bytes on first visit.
|
|
155
|
+
// DB-IP City Lite's largest record needs under 1 KiB. A crafted record at the cap costs about 10 ms per synchronous lookup.
|
|
156
|
+
const RECORD_MAX_WORK = 32 * 1024;
|
|
157
|
+
const boundedMmdbMetadataStart = (bytes: Buffer): number => {
|
|
158
|
+
// Mirror mmdb-lib's backward marker scan exactly: it can skip a marker after a partial match, and the checked
|
|
159
|
+
// section must be the one Reader decodes.
|
|
160
|
+
const floor = bytes.byteLength - METADATA_MAX_BYTES;
|
|
161
|
+
const last = METADATA_MARKER.length - 1;
|
|
162
|
+
let found = 0;
|
|
163
|
+
let position = bytes.byteLength - 1;
|
|
164
|
+
while (found <= last && position-- > 0) {
|
|
165
|
+
if (position < floor) return -1;
|
|
166
|
+
found += bytes[position] === METADATA_MARKER[last - found] ? 1 : -found;
|
|
167
|
+
}
|
|
168
|
+
if (found <= last) return -1;
|
|
169
|
+
const metaStart = position + found;
|
|
170
|
+
// Metadata decoding has no cache: count repeated expansion, in nodes and payload bytes, using memoized target costs.
|
|
171
|
+
// Shared strings may expand beyond the encoded section, up to the size the spec allows for the whole section.
|
|
172
|
+
return isBoundedMmdbValue(bytes, metaStart, metaStart, bytes.length, new Set(), METADATA_MAX_BYTES) ? metaStart : -1;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const hasBoundedMmdbMetadata = (bytes: Uint8Array): boolean =>
|
|
176
|
+
boundedMmdbMetadataStart(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)) !== -1;
|
|
177
|
+
|
|
178
|
+
export const openGeoIpDatabase = (kind: GeoIpKind, bytes: Uint8Array, source: "bundled" | "uploaded" = "bundled") => {
|
|
179
|
+
// This Buffer is a view: Buffer.from(bytes) would copy the entire mmap.
|
|
180
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
181
|
+
const metadataStart = boundedMmdbMetadataStart(buffer);
|
|
182
|
+
if (metadataStart === -1) return null;
|
|
183
|
+
const dataEnd = metadataStart - METADATA_MARKER.length;
|
|
184
|
+
try {
|
|
185
|
+
const cursors = new Map<string | number, unknown>();
|
|
186
|
+
const checked = new Set<number>();
|
|
187
|
+
// decodeMap coerces non-string keys, which can expand shared arrays exponentially.
|
|
188
|
+
// mmdb-lib asks the cache before decoding each record and pointer target.
|
|
189
|
+
const cache = {
|
|
190
|
+
get(key: string | number) {
|
|
191
|
+
if (typeof key !== "number" || (!checked.has(key) && !isBoundedMmdbValue(buffer, key, dataStart, dataEnd, checked)))
|
|
192
|
+
throw new Error("Invalid MMDB data value");
|
|
193
|
+
return cursors.get(key);
|
|
194
|
+
},
|
|
195
|
+
set(key: string | number, value: unknown) {
|
|
196
|
+
cursors.set(key, value);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
const reader = new Reader(buffer, { cache });
|
|
200
|
+
const metadata = reader.metadata;
|
|
201
|
+
const dataStart = metadata.searchTreeSize + 16;
|
|
202
|
+
if (
|
|
203
|
+
!Number.isSafeInteger(metadata.nodeCount) ||
|
|
204
|
+
metadata.nodeCount <= 0 ||
|
|
205
|
+
!(dataStart <= dataEnd) ||
|
|
206
|
+
metadata.binaryFormatMajorVersion !== 2
|
|
207
|
+
)
|
|
208
|
+
return null;
|
|
209
|
+
// An IPv4-only tree answers IPv6 lookups with unrelated IPv4 records, so only IPv6 databases qualify.
|
|
210
|
+
if (metadata.ipVersion !== 6 || !metadata.databaseType.toLowerCase().includes(kind)) return null;
|
|
211
|
+
const info: GeoIpDatabaseInfo = { databaseType: metadata.databaseType, buildDate: metadata.buildEpoch, source };
|
|
212
|
+
if (!Number.isFinite(info.buildDate.getTime())) return null;
|
|
213
|
+
const clear = () => {
|
|
214
|
+
cursors.clear();
|
|
215
|
+
checked.clear();
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
info,
|
|
219
|
+
get(address: string) {
|
|
220
|
+
try {
|
|
221
|
+
return reader.get(address);
|
|
222
|
+
} finally {
|
|
223
|
+
clear();
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export type GeoIpDatabase = NonNullable<ReturnType<typeof openGeoIpDatabase>>;
|
|
233
|
+
export type GeoIpLookup = {
|
|
234
|
+
cityDatabase: boolean;
|
|
235
|
+
asnDatabase: boolean;
|
|
236
|
+
country: string | null;
|
|
237
|
+
city: string | null;
|
|
238
|
+
network: string | null;
|
|
239
|
+
};
|
|
240
|
+
export type GeoIpUploads = {
|
|
241
|
+
versions(): Promise<Partial<Record<GeoIpKind, string>>>;
|
|
242
|
+
read(kind: GeoIpKind, sha256: string): Promise<Uint8Array | null>;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export type GeoIp = {
|
|
246
|
+
refresh(): Promise<void>;
|
|
247
|
+
install(kind: GeoIpKind, database: GeoIpDatabase | null): void;
|
|
248
|
+
lookup(address: string | null, locale?: string): GeoIpLookup;
|
|
249
|
+
status(): { city: GeoIpDatabaseInfo | null; asn: GeoIpDatabaseInfo | null };
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
type GeoIpPaths = { cityPath: string; asnPath: string };
|
|
253
|
+
export const createGeoIp = (
|
|
254
|
+
options: { paths?: GeoIpPaths; uploads?: GeoIpUploads; refreshIntervalMs?: number; now?: () => number } | GeoIpPaths = {},
|
|
255
|
+
): GeoIp => {
|
|
256
|
+
const {
|
|
257
|
+
paths = BUNDLED_GEOIP_PATHS,
|
|
258
|
+
uploads,
|
|
259
|
+
refreshIntervalMs = 60_000,
|
|
260
|
+
now = Date.now,
|
|
261
|
+
} = "cityPath" in options ? { paths: options } : options;
|
|
262
|
+
const databases: Partial<Record<GeoIpKind, GeoIpDatabase | null>> = {};
|
|
263
|
+
const generations = { city: 0, asn: 0 };
|
|
264
|
+
const unreadable: Partial<Record<GeoIpKind, string>> = {};
|
|
265
|
+
let lastCheck = -Infinity;
|
|
266
|
+
let pending: Promise<void> | undefined;
|
|
267
|
+
const logged = new Set<string>();
|
|
268
|
+
const logOnce = (key: string, message: string) => {
|
|
269
|
+
if (logged.has(key)) return;
|
|
270
|
+
logged.add(key);
|
|
271
|
+
// Logging failure must never prevent a sign-in. Do not include lookup addresses or decoder errors.
|
|
272
|
+
try {
|
|
273
|
+
logger("request-context").warn(message);
|
|
274
|
+
} catch {}
|
|
275
|
+
};
|
|
276
|
+
const open = (kind: "city" | "asn", path: string): GeoIpDatabase | null => {
|
|
277
|
+
try {
|
|
278
|
+
// A private mapping: pages load on demand and a stray write never reaches the file.
|
|
279
|
+
const database = openGeoIpDatabase(kind, Bun.mmap(path, { shared: false }));
|
|
280
|
+
if (!database)
|
|
281
|
+
logOnce(kind, `GeoIP ${kind} snapshot is invalid; sign-in ${kind === "city" ? "locations" : "network names"} are unavailable`);
|
|
282
|
+
return database;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
const missing = error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
285
|
+
logOnce(
|
|
286
|
+
kind,
|
|
287
|
+
`GeoIP ${kind} snapshot ${missing ? "not found" : "could not be opened"}; sign-in ${kind === "city" ? "locations" : "network names"} are unavailable`,
|
|
288
|
+
);
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const load = (kind: GeoIpKind): GeoIpDatabase | null => {
|
|
293
|
+
if (databases[kind] === undefined) databases[kind] = open(kind, paths[kind === "city" ? "cityPath" : "asnPath"]);
|
|
294
|
+
return databases[kind] ?? null;
|
|
295
|
+
};
|
|
296
|
+
const install = (kind: GeoIpKind, database: GeoIpDatabase | null) => {
|
|
297
|
+
generations[kind]++;
|
|
298
|
+
// Undefined opens the bundled mapping lazily, without retaining a second reader.
|
|
299
|
+
if (database) databases[kind] = database;
|
|
300
|
+
else delete databases[kind];
|
|
301
|
+
};
|
|
302
|
+
const refresh = (): Promise<void> => {
|
|
303
|
+
if (!uploads) return Promise.resolve();
|
|
304
|
+
if (pending) return pending;
|
|
305
|
+
const started = { ...generations };
|
|
306
|
+
pending = Promise.resolve().then(async () => {
|
|
307
|
+
try {
|
|
308
|
+
const versions = await uploads.versions();
|
|
309
|
+
const replacements: Partial<Record<GeoIpKind, GeoIpDatabase | null>> = {};
|
|
310
|
+
for (const kind of ["city", "asn"] as const) {
|
|
311
|
+
if (started[kind] !== generations[kind]) continue;
|
|
312
|
+
const sha256 = versions[kind];
|
|
313
|
+
if (sha256 === databases[kind]?.info.sha256 || (sha256 && sha256 === unreadable[kind])) continue;
|
|
314
|
+
if (!sha256) replacements[kind] = null;
|
|
315
|
+
else {
|
|
316
|
+
const bytes = await uploads.read(kind, sha256);
|
|
317
|
+
if (!bytes) continue; // Replaced or reset during the sliced read; retry next interval.
|
|
318
|
+
const database = openGeoIpDatabase(kind, bytes, "uploaded");
|
|
319
|
+
if (!database) {
|
|
320
|
+
// Keep the current reader and do not read the same blob again every interval.
|
|
321
|
+
unreadable[kind] = sha256;
|
|
322
|
+
logOnce(`${kind}-stored`, `Stored GeoIP ${kind} upload could not be opened; keeping the current database`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
database.info.sha256 = sha256;
|
|
326
|
+
replacements[kind] = database;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
for (const kind of ["city", "asn"] as const) {
|
|
330
|
+
if (started[kind] !== generations[kind] || !(kind in replacements)) continue;
|
|
331
|
+
install(kind, replacements[kind] ?? null);
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
logOnce("uploads", "GeoIP uploads could not be refreshed; keeping current sign-in databases");
|
|
335
|
+
} finally {
|
|
336
|
+
lastCheck = now();
|
|
337
|
+
pending = undefined;
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
return pending;
|
|
341
|
+
};
|
|
342
|
+
const read = (kind: "city" | "asn", database: GeoIpDatabase | null, address: string) => {
|
|
343
|
+
try {
|
|
344
|
+
return record(database?.get(address));
|
|
345
|
+
} catch {
|
|
346
|
+
logOnce(`${kind}-lookup`, `GeoIP ${kind} lookup failed; sign-in details are unavailable for this request`);
|
|
347
|
+
return {};
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
return {
|
|
351
|
+
refresh,
|
|
352
|
+
install,
|
|
353
|
+
status() {
|
|
354
|
+
const city = load("city");
|
|
355
|
+
const asn = load("asn");
|
|
356
|
+
const info = (database: GeoIpDatabase | null): GeoIpDatabaseInfo | null =>
|
|
357
|
+
database ? { ...database.info, buildDate: new Date(database.info.buildDate.getTime()) } : null;
|
|
358
|
+
return { city: info(city), asn: info(asn) };
|
|
359
|
+
},
|
|
360
|
+
lookup(address, locale = "en") {
|
|
361
|
+
if (now() - lastCheck >= refreshIntervalMs) void refresh();
|
|
362
|
+
const city = load("city");
|
|
363
|
+
const asn = load("asn");
|
|
364
|
+
const result: GeoIpLookup = { cityDatabase: city !== null, asnDatabase: asn !== null, country: null, city: null, network: null };
|
|
365
|
+
const publicAddress = publicClientAddress(address);
|
|
366
|
+
if (!publicAddress) return result;
|
|
367
|
+
const cityRecord = read("city", city, publicAddress);
|
|
368
|
+
const country = record(cityRecord.country).iso_code;
|
|
369
|
+
if (typeof country === "string" && /^[a-z]{2}$/i.test(country) && country.toUpperCase() !== "ZZ")
|
|
370
|
+
result.country = country.toUpperCase();
|
|
371
|
+
const names = record(record(cityRecord.city).names);
|
|
372
|
+
result.city = cleanName(names[locale]) ?? cleanName(names[locale.split("-")[0] ?? "en"]) ?? cleanName(names.en);
|
|
373
|
+
result.network = cleanName(read("asn", asn, publicAddress).autonomous_system_organization);
|
|
374
|
+
return result;
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
let instance: GeoIp | undefined;
|
|
380
|
+
export const getGeoIp = (): GeoIp => (instance ??= createGeoIp({ uploads: geoIpUploads }));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { clientIp } from "../client-ip";
|
|
3
|
+
import { getLocale } from "../locale";
|
|
4
|
+
import { publicClientAddress } from "./address";
|
|
5
|
+
import { type GeoIp, getGeoIp } from "./geoip";
|
|
6
|
+
import { type BrowserCode, describeBrowser, type OsCode } from "./user-agent";
|
|
7
|
+
|
|
8
|
+
export { clientIp } from "../client-ip";
|
|
9
|
+
export { addressPrefix, publicClientAddress } from "./address";
|
|
10
|
+
export {
|
|
11
|
+
BUNDLED_GEOIP_PATHS,
|
|
12
|
+
createGeoIp,
|
|
13
|
+
DBIP_ATTRIBUTION,
|
|
14
|
+
type GeoIp,
|
|
15
|
+
type GeoIpDatabase,
|
|
16
|
+
type GeoIpDatabaseInfo,
|
|
17
|
+
type GeoIpLookup,
|
|
18
|
+
type GeoIpUploads,
|
|
19
|
+
getGeoIp,
|
|
20
|
+
openGeoIpDatabase,
|
|
21
|
+
truncateUtf8,
|
|
22
|
+
} from "./geoip";
|
|
23
|
+
export { type BrowserCode, describeBrowser, type OsCode } from "./user-agent";
|
|
24
|
+
|
|
25
|
+
export const describeRequest = (c: Context, options?: { locale?: string; geoIp?: GeoIp }) => {
|
|
26
|
+
let browser: { browser: BrowserCode; os: OsCode } = { browser: "other", os: "other" };
|
|
27
|
+
try {
|
|
28
|
+
browser = describeBrowser(c.req.raw.headers);
|
|
29
|
+
return { ...browser, ...(options?.geoIp ?? getGeoIp()).lookup(publicClientAddress(clientIp(c)), options?.locale ?? getLocale(c)) };
|
|
30
|
+
} catch {
|
|
31
|
+
return { ...browser, cityDatabase: false, asnDatabase: false, country: null, city: null, network: null };
|
|
32
|
+
}
|
|
33
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { AppLoginContext } from "../../contracts/app-approval";
|
|
2
|
+
|
|
3
|
+
export type BrowserCode = AppLoginContext["browser"];
|
|
4
|
+
export type OsCode = AppLoginContext["os"];
|
|
5
|
+
|
|
6
|
+
// Bound work on untrusted headers before parsing; normal browser headers fit these budgets.
|
|
7
|
+
const USER_AGENT_BUDGET = 512;
|
|
8
|
+
const HINT_BUDGET = 256;
|
|
9
|
+
const BRAND_LIMIT = 16;
|
|
10
|
+
|
|
11
|
+
const hintBrowser = (hint: string): BrowserCode | undefined => {
|
|
12
|
+
const brands: string[] = [];
|
|
13
|
+
let offset = 0;
|
|
14
|
+
for (let count = 0; count < BRAND_LIMIT && offset < hint.length; count++) {
|
|
15
|
+
const end = hint.indexOf(",", offset);
|
|
16
|
+
const entry = hint.slice(offset, end < 0 ? hint.length : end).trim();
|
|
17
|
+
const match = /^"([^"\\]*)";v="[^"\\]*"$/.exec(entry);
|
|
18
|
+
if (match?.[1]) brands.push(match[1]);
|
|
19
|
+
if (end < 0) break;
|
|
20
|
+
offset = end + 1;
|
|
21
|
+
}
|
|
22
|
+
if (brands.includes("Microsoft Edge")) return "edge";
|
|
23
|
+
if (brands.includes("Opera") || brands.includes("Opera GX")) return "opera";
|
|
24
|
+
if (brands.includes("Samsung Internet")) return "samsung";
|
|
25
|
+
// GREASE varies punctuation and spacing around "Not A Brand".
|
|
26
|
+
const named = brands.filter((brand) => brand.replace(/[^a-z]/gi, "").toLowerCase() !== "notabrand");
|
|
27
|
+
if (named.some((brand) => brand !== "Chromium" && brand !== "Google Chrome")) return "other";
|
|
28
|
+
if (named.includes("Google Chrome") || named.includes("Chromium")) return "chrome";
|
|
29
|
+
return undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const hintOs = (hint: string): OsCode | undefined => {
|
|
33
|
+
switch (hint.trim()) {
|
|
34
|
+
case '"Windows"':
|
|
35
|
+
return "windows";
|
|
36
|
+
case '"macOS"':
|
|
37
|
+
return "macos";
|
|
38
|
+
case '"Linux"':
|
|
39
|
+
return "linux";
|
|
40
|
+
case '"Android"':
|
|
41
|
+
return "android";
|
|
42
|
+
case '"Chrome OS"':
|
|
43
|
+
case '"Chromium OS"':
|
|
44
|
+
return "chromeos";
|
|
45
|
+
case '"iOS"':
|
|
46
|
+
return "ios";
|
|
47
|
+
default:
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const describeBrowser = (headers: Headers): { browser: BrowserCode; os: OsCode } => {
|
|
53
|
+
try {
|
|
54
|
+
const brands = (headers.get("sec-ch-ua") ?? "").slice(0, HINT_BUDGET);
|
|
55
|
+
const platform = (headers.get("sec-ch-ua-platform") ?? "").slice(0, HINT_BUDGET);
|
|
56
|
+
const ua = (headers.get("user-agent") ?? "").slice(0, USER_AGENT_BUDGET);
|
|
57
|
+
const apple =
|
|
58
|
+
ua.includes("Macintosh") || ua.includes("Mac OS X") || ua.includes("iPhone") || ua.includes("iPad") || ua.includes("iPod");
|
|
59
|
+
const uaBrowser: BrowserCode = ua.includes("SamsungBrowser/")
|
|
60
|
+
? "samsung"
|
|
61
|
+
: ["Edg/", "EdgA/", "EdgiOS/", "Edge/"].some((token) => ua.includes(token))
|
|
62
|
+
? "edge"
|
|
63
|
+
: ["OPR/", "OPT/", "OPiOS/", "Opera"].some((token) => ua.includes(token))
|
|
64
|
+
? "opera"
|
|
65
|
+
: ua.includes("Firefox/") || ua.includes("FxiOS/")
|
|
66
|
+
? "firefox"
|
|
67
|
+
: ["CriOS/", "Chrome/", "Chromium/"].some((token) => ua.includes(token))
|
|
68
|
+
? "chrome"
|
|
69
|
+
: apple && ua.includes("Safari/") && ua.includes("Version/")
|
|
70
|
+
? "safari"
|
|
71
|
+
: "other";
|
|
72
|
+
const browser = hintBrowser(brands) ?? uaBrowser;
|
|
73
|
+
const appleWebKit =
|
|
74
|
+
ua.includes("AppleWebKit/") &&
|
|
75
|
+
(uaBrowser === "safari" || ["CriOS/", "FxiOS/", "EdgiOS/", "OPiOS/"].some((token) => ua.includes(token)));
|
|
76
|
+
const uaOs: OsCode = ua.includes("Windows")
|
|
77
|
+
? "windows"
|
|
78
|
+
: ua.includes("CrOS")
|
|
79
|
+
? "chromeos"
|
|
80
|
+
: ua.includes("Android")
|
|
81
|
+
? "android"
|
|
82
|
+
: ["iPhone", "iPod", "iPad"].some((token) => ua.includes(token))
|
|
83
|
+
? "ios"
|
|
84
|
+
: ua.includes("Macintosh") || ua.includes("Mac OS X")
|
|
85
|
+
? appleWebKit
|
|
86
|
+
? "mac-or-ipad"
|
|
87
|
+
: "macos"
|
|
88
|
+
: ua.includes("Linux") || ua.includes("X11")
|
|
89
|
+
? "linux"
|
|
90
|
+
: "other";
|
|
91
|
+
return { browser, os: hintOs(platform) ?? uaOs };
|
|
92
|
+
} catch {
|
|
93
|
+
return { browser: "other", os: "other" };
|
|
94
|
+
}
|
|
95
|
+
};
|
|
@@ -31,6 +31,7 @@ import { getFreeIpaConfig } from "../freeipa-config";
|
|
|
31
31
|
import { type LogEntry, logger, logging } from "../logging";
|
|
32
32
|
import { isUniqueViolation } from "../postgres";
|
|
33
33
|
import { providers } from "../providers";
|
|
34
|
+
import { session } from "../session";
|
|
34
35
|
import { get as getSetting } from "../settings";
|
|
35
36
|
import { type AccountsActor, canMutateManagedGroup, hasOnlySelfUpdateFields, isAdminActor, isSelfTarget } from "./authz";
|
|
36
37
|
import { listDuplicateEmails } from "./duplicate-emails";
|
|
@@ -838,7 +839,12 @@ export const accountsAppService = {
|
|
|
838
839
|
* admin app is UI only and must not dispatch on provider or speak to
|
|
839
840
|
* FreeIPA directly.
|
|
840
841
|
*/
|
|
841
|
-
changeOwnPassword: async (config: {
|
|
842
|
+
changeOwnPassword: async (config: {
|
|
843
|
+
user: User;
|
|
844
|
+
currentPassword: string;
|
|
845
|
+
newPassword: string;
|
|
846
|
+
keep?: { sid: string; authEpoch: number } | null;
|
|
847
|
+
}): Promise<Result<{ kept: { token: string; expiresAt: Date } | null }>> => {
|
|
842
848
|
const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
|
|
843
849
|
if (!(await getFreeIpaConfig()).enabled) {
|
|
844
850
|
const result = fail(err.badInput("FreeIPA is disabled."));
|
|
@@ -875,8 +881,22 @@ export const accountsAppService = {
|
|
|
875
881
|
uid: config.user.uid,
|
|
876
882
|
newPassword: config.newPassword,
|
|
877
883
|
});
|
|
878
|
-
|
|
879
|
-
|
|
884
|
+
let serviceResult: Result<{ kept: { token: string; expiresAt: Date } | null }>;
|
|
885
|
+
if (!result.ok) {
|
|
886
|
+
serviceResult = fail(toServiceError(result.status, result.error));
|
|
887
|
+
} else {
|
|
888
|
+
try {
|
|
889
|
+
serviceResult = ok(await session.endOtherSignIns(config.user.id, { keep: config.keep, reason: "password_change" }));
|
|
890
|
+
} catch {
|
|
891
|
+
serviceResult = fail({
|
|
892
|
+
code: "SIGN_OUT_OTHERS_FAILED",
|
|
893
|
+
status: 500,
|
|
894
|
+
message: "Password changed. Signing out your other sign-ins did not work.",
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
const record = result.ok ? audit.recordResultAfterSideEffect : audit.recordResult;
|
|
899
|
+
return record({
|
|
880
900
|
action: "accounts.user.change_own_password",
|
|
881
901
|
actor: auditActor(actor),
|
|
882
902
|
target: userTarget(config.user),
|
|
@@ -675,10 +675,21 @@ export const resetPassword = async (params: { id: string }): Promise<MutationRes
|
|
|
675
675
|
const serviceSession = await getServiceIpaSession();
|
|
676
676
|
if (!serviceSession.ok) return serviceSession;
|
|
677
677
|
|
|
678
|
-
|
|
678
|
+
const result = await providers.ipa.users.resetPassword({
|
|
679
679
|
ipaSession: serviceSession.data,
|
|
680
680
|
id: params.id,
|
|
681
681
|
});
|
|
682
|
+
if (!result.ok) return result;
|
|
683
|
+
try {
|
|
684
|
+
await session.endOtherSignIns(params.id, { reason: "password_reset" });
|
|
685
|
+
} catch {
|
|
686
|
+
return {
|
|
687
|
+
ok: false,
|
|
688
|
+
error: "The password was reset, but signing out the person's other sign-ins did not work. Reset the password again.",
|
|
689
|
+
status: 500,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
return result;
|
|
682
693
|
};
|
|
683
694
|
|
|
684
695
|
export const switchProvider = async (params: { id: string; provider: UserProvider }): Promise<MutationResult<void>> => {
|