@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,30 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { GEOIP_MAX_DATABASE_BYTES, type GeoIpKind } from "../../contracts/geoip";
|
|
3
|
+
import type { GeoIpUploads } from "../../server/request-context/geoip";
|
|
4
|
+
|
|
5
|
+
const SLICE_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
export const createGeoIpUploads = (db: typeof sql = sql): GeoIpUploads => ({
|
|
7
|
+
async versions() {
|
|
8
|
+
const rows = await db<{ kind: GeoIpKind; sha256: string }[]>`SELECT kind, sha256 FROM geoip.databases`;
|
|
9
|
+
return Object.fromEntries(rows.map((row) => [row.kind, row.sha256]));
|
|
10
|
+
},
|
|
11
|
+
async read(kind, sha256) {
|
|
12
|
+
const [row] = await db<{ size: number }[]>`
|
|
13
|
+
SELECT octet_length(data) AS size FROM geoip.databases WHERE kind = ${kind} AND sha256 = ${sha256}`;
|
|
14
|
+
if (!row) return null;
|
|
15
|
+
if (row.size < 1 || row.size > GEOIP_MAX_DATABASE_BYTES) throw new Error("Invalid GeoIP database size");
|
|
16
|
+
const bytes = Buffer.allocUnsafe(row.size);
|
|
17
|
+
for (let offset = 0; offset < bytes.length; offset += SLICE_BYTES) {
|
|
18
|
+
const length = Math.min(SLICE_BYTES, bytes.length - offset);
|
|
19
|
+
const [slice] = await db<{ data: Uint8Array }[]>`
|
|
20
|
+
SELECT substring(data FROM ${offset + 1} FOR ${length}) AS data
|
|
21
|
+
FROM geoip.databases WHERE kind = ${kind} AND sha256 = ${sha256}`;
|
|
22
|
+
if (!slice) return null;
|
|
23
|
+
if (slice.data.byteLength !== length) throw new Error("Incomplete GeoIP database slice");
|
|
24
|
+
bytes.set(slice.data, offset);
|
|
25
|
+
}
|
|
26
|
+
return bytes;
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const geoIpUploads = createGeoIpUploads();
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { createGunzip } from "node:zlib";
|
|
3
|
+
import { Reader } from "mmdb-lib";
|
|
4
|
+
import { GEOIP_MAX_DATABASE_BYTES, GEOIP_MAX_REQUEST_BYTES, type GeoIpErrorCode, type GeoIpKind } from "../../contracts/geoip";
|
|
5
|
+
import { hasBoundedMmdbMetadata, openGeoIpDatabase, truncateUtf8 } from "../../server/request-context/geoip";
|
|
6
|
+
|
|
7
|
+
const probeAddresses = [
|
|
8
|
+
...Array.from({ length: 512 }, (_, i) => [`${i >> 1}.${(i % 2) * 128}.0.0`, `${(0x2000 + i * 16).toString(16)}::`]).flat(),
|
|
9
|
+
"1.1.1.1",
|
|
10
|
+
"8.8.8.8",
|
|
11
|
+
"9.9.9.9",
|
|
12
|
+
"2001:4860:4860::8888",
|
|
13
|
+
"2606:4700:4700::1111",
|
|
14
|
+
"2a01:4f8::1",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const errors = {
|
|
18
|
+
geoip_request_too_large: { status: 413, message: "The request body exceeds 128 MiB. Upload the .mmdb.gz file." },
|
|
19
|
+
geoip_too_large: { status: 413, message: "The GeoIP database exceeds 192 MiB unpacked." },
|
|
20
|
+
geoip_empty: { status: 400, message: "The GeoIP upload is empty." },
|
|
21
|
+
geoip_invalid: { status: 422, message: "Upload a valid IPv6 MMDB database with a build date." },
|
|
22
|
+
geoip_unsupported_type: { status: 422, message: "Upload a City or ASN MMDB database." },
|
|
23
|
+
geoip_upload_busy: { status: 409, message: "Another GeoIP upload is being processed. Try again shortly." },
|
|
24
|
+
} as const;
|
|
25
|
+
export class GeoIpError extends Error {
|
|
26
|
+
readonly status;
|
|
27
|
+
constructor(public readonly code: GeoIpErrorCode) {
|
|
28
|
+
super(errors[code].message);
|
|
29
|
+
this.status = errors[code].status;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Backpressure reaches the request; both limits are enforced before retaining a chunk. */
|
|
34
|
+
export const readGeoIpUpload = async (body: ReadableStream<Uint8Array>, contentLength?: number): Promise<Buffer> => {
|
|
35
|
+
if (contentLength !== undefined && contentLength > GEOIP_MAX_REQUEST_BYTES) throw new GeoIpError("geoip_request_too_large");
|
|
36
|
+
const reader = body.getReader();
|
|
37
|
+
let requestBytes = 0;
|
|
38
|
+
async function* counted() {
|
|
39
|
+
while (true) {
|
|
40
|
+
const chunk = await reader.read();
|
|
41
|
+
if (chunk.done) return;
|
|
42
|
+
requestBytes += chunk.value.byteLength;
|
|
43
|
+
if (requestBytes > GEOIP_MAX_REQUEST_BYTES) throw new GeoIpError("geoip_request_too_large");
|
|
44
|
+
if (chunk.value.byteLength) yield chunk.value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let source: Readable | undefined;
|
|
48
|
+
let gunzip: ReturnType<typeof createGunzip> | undefined;
|
|
49
|
+
try {
|
|
50
|
+
const chunks = counted();
|
|
51
|
+
const prefix: Uint8Array[] = [];
|
|
52
|
+
let prefixBytes = 0;
|
|
53
|
+
while (prefixBytes < 2) {
|
|
54
|
+
const next = await chunks.next();
|
|
55
|
+
if (next.done) break;
|
|
56
|
+
prefix.push(next.value);
|
|
57
|
+
prefixBytes += next.value.byteLength;
|
|
58
|
+
}
|
|
59
|
+
if (!prefixBytes) throw new GeoIpError("geoip_empty");
|
|
60
|
+
const first = prefix[0]!;
|
|
61
|
+
const gzip = first[0] === 0x1f && (first.byteLength > 1 ? first[1] : prefix[1]?.[0]) === 0x8b;
|
|
62
|
+
source = Readable.from(
|
|
63
|
+
(async function* () {
|
|
64
|
+
yield* prefix;
|
|
65
|
+
yield* chunks;
|
|
66
|
+
})(),
|
|
67
|
+
);
|
|
68
|
+
let output = source;
|
|
69
|
+
if (gzip) {
|
|
70
|
+
gunzip = createGunzip();
|
|
71
|
+
source.on("error", (error) => gunzip?.destroy(error));
|
|
72
|
+
output = source.pipe(gunzip);
|
|
73
|
+
}
|
|
74
|
+
// A resizable backing store avoids retaining all chunks plus a second full-size concatenation.
|
|
75
|
+
const storage = new ArrayBuffer(0, { maxByteLength: GEOIP_MAX_DATABASE_BYTES });
|
|
76
|
+
let size = 0;
|
|
77
|
+
for await (const chunk of output) {
|
|
78
|
+
if (!(chunk instanceof Uint8Array)) throw new GeoIpError("geoip_invalid");
|
|
79
|
+
if (size + chunk.byteLength > GEOIP_MAX_DATABASE_BYTES) throw new GeoIpError("geoip_too_large");
|
|
80
|
+
const needed = size + chunk.byteLength;
|
|
81
|
+
if (needed > storage.byteLength)
|
|
82
|
+
storage.resize(Math.min(GEOIP_MAX_DATABASE_BYTES, Math.ceil(needed / (8 * 1024 * 1024)) * 8 * 1024 * 1024));
|
|
83
|
+
new Uint8Array(storage).set(chunk, size);
|
|
84
|
+
size = needed;
|
|
85
|
+
}
|
|
86
|
+
if (!size) throw new GeoIpError("geoip_empty");
|
|
87
|
+
storage.resize(size);
|
|
88
|
+
return Buffer.from(storage);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof GeoIpError) throw error;
|
|
91
|
+
throw new GeoIpError("geoip_invalid");
|
|
92
|
+
} finally {
|
|
93
|
+
source?.destroy();
|
|
94
|
+
gunzip?.destroy();
|
|
95
|
+
await reader.cancel().catch(() => {});
|
|
96
|
+
reader.releaseLock();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const parseGeoIpUpload = (bytes: Uint8Array) => {
|
|
101
|
+
if (!hasBoundedMmdbMetadata(bytes)) throw new GeoIpError("geoip_invalid");
|
|
102
|
+
try {
|
|
103
|
+
const metadata = new Reader(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)).metadata;
|
|
104
|
+
const type = metadata.databaseType.toLowerCase();
|
|
105
|
+
const kind: GeoIpKind | null = type.includes("city") ? "city" : type.includes("asn") ? "asn" : null;
|
|
106
|
+
if (!kind) throw new GeoIpError("geoip_unsupported_type");
|
|
107
|
+
const database = openGeoIpDatabase(kind, bytes, "uploaded");
|
|
108
|
+
if (!database || Buffer.byteLength(metadata.databaseType) > 128) throw new GeoIpError("geoip_invalid");
|
|
109
|
+
const description: unknown = metadata.description;
|
|
110
|
+
const english =
|
|
111
|
+
typeof description === "object" && description !== null && "en" in description && typeof description.en === "string"
|
|
112
|
+
? description.en
|
|
113
|
+
: "";
|
|
114
|
+
const languages: unknown = metadata.languages;
|
|
115
|
+
const subset = {
|
|
116
|
+
databaseType: metadata.databaseType,
|
|
117
|
+
buildEpoch: metadata.buildEpoch.toISOString(),
|
|
118
|
+
ipVersion: metadata.ipVersion,
|
|
119
|
+
// `languages` is optional in MMDB metadata; mmdb-lib leaves it undefined despite its type.
|
|
120
|
+
languages: (Array.isArray(languages) ? languages : [])
|
|
121
|
+
.filter((language): language is string => typeof language === "string")
|
|
122
|
+
.slice(0, 16)
|
|
123
|
+
.map((language) => truncateUtf8(language, 32)),
|
|
124
|
+
description: { en: truncateUtf8(english, 256) },
|
|
125
|
+
nodeCount: metadata.nodeCount,
|
|
126
|
+
recordSize: metadata.recordSize,
|
|
127
|
+
size: bytes.byteLength,
|
|
128
|
+
};
|
|
129
|
+
if (
|
|
130
|
+
subset.databaseType.includes("\0") ||
|
|
131
|
+
subset.description.en.includes("\0") ||
|
|
132
|
+
subset.languages.some((language) => language.includes("\0"))
|
|
133
|
+
)
|
|
134
|
+
throw new GeoIpError("geoip_invalid");
|
|
135
|
+
// Pretty JSON is larger than PostgreSQL jsonb text, including its separator spaces.
|
|
136
|
+
if (Buffer.byteLength(JSON.stringify(subset, null, 1)) > 4096) throw new GeoIpError("geoip_invalid");
|
|
137
|
+
// A bounded probe catches gross corruption, not every bad record; request lookups still fail closed. Each
|
|
138
|
+
// address is one lookup that keeps nothing: at most 32 KiB of decode work and one decoded record in memory.
|
|
139
|
+
// A crafted file with records at that cap can block this Core process for about ten seconds; normal files take
|
|
140
|
+
// milliseconds.
|
|
141
|
+
for (const address of probeAddresses) {
|
|
142
|
+
const record = database.get(address);
|
|
143
|
+
if (record !== null && (typeof record !== "object" || Array.isArray(record))) throw new GeoIpError("geoip_invalid");
|
|
144
|
+
}
|
|
145
|
+
return { kind, database, metadata: subset };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error instanceof GeoIpError) throw error;
|
|
148
|
+
throw new GeoIpError("geoip_invalid");
|
|
149
|
+
}
|
|
150
|
+
};
|
|
@@ -2,9 +2,13 @@ import { type Context, Hono } from "hono";
|
|
|
2
2
|
import { CLOUD_INVOCATION_JWKS_PATH, CLOUD_SESSION_JWKS_PATH, IDENTITY_JWKS_MAX_AGE_SECONDS } from "./constants";
|
|
3
3
|
import { listIdentityJwks } from "./key-ring";
|
|
4
4
|
|
|
5
|
+
// If-None-Match uses weak comparison, including lists and the wildcard.
|
|
6
|
+
const matchesEtag = (header: string | undefined, etag: string): boolean =>
|
|
7
|
+
header?.trim() === "*" || (header?.match(/(?:W\/)?"[^"]*"/g) ?? []).some((tag) => tag.replace(/^W\//, "") === etag.replace(/^W\//, ""));
|
|
8
|
+
|
|
5
9
|
const serveJwks = (purpose: "session" | "invocation") => async (c: Context) => {
|
|
6
10
|
const jwks = await listIdentityJwks(purpose);
|
|
7
|
-
if (c.req.header("if-none-match")
|
|
11
|
+
if (matchesEtag(c.req.header("if-none-match"), jwks.etag)) {
|
|
8
12
|
c.header("ETag", jwks.etag);
|
|
9
13
|
c.header("Cache-Control", `public, max-age=${IDENTITY_JWKS_MAX_AGE_SECONDS}, must-revalidate`);
|
|
10
14
|
return c.body(null, 304);
|
package/src/services/index.ts
CHANGED
|
@@ -246,3 +246,16 @@ export {
|
|
|
246
246
|
resolveAllowedNetworkAddresses,
|
|
247
247
|
} from "./network-security";
|
|
248
248
|
export type { NetworkLookup, PublicNetworkAddress } from "./network-security";
|
|
249
|
+
|
|
250
|
+
export { geoip } from "./geoip";
|
|
251
|
+
export {
|
|
252
|
+
knownBrowsers,
|
|
253
|
+
KNOWN_BROWSER_LIMITS,
|
|
254
|
+
SIGN_IN_METHODS,
|
|
255
|
+
type SignInMethod,
|
|
256
|
+
type NewBrowserSignInNotice,
|
|
257
|
+
type NewBrowserSignInNotifier,
|
|
258
|
+
} from "./known-browsers";
|
|
259
|
+
export { LASTING_ACCESS_SIGN_IN_SECONDS, requireConfirmedBrowserSignIn } from "./session/recent";
|
|
260
|
+
|
|
261
|
+
export { deviceOffersApproval } from "./approvals/device";
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { env } from "../config/env";
|
|
6
|
+
import { APP_LOGIN_CONTEXT_BROWSERS, APP_LOGIN_CONTEXT_OSES } from "../contracts/app-approval";
|
|
7
|
+
import { type BrowserCode, type OsCode, truncateUtf8 } from "../server/request-context";
|
|
8
|
+
import { logger } from "./logging";
|
|
9
|
+
|
|
10
|
+
export const SIGN_IN_METHODS = [
|
|
11
|
+
"password",
|
|
12
|
+
"passkey",
|
|
13
|
+
"email_code",
|
|
14
|
+
"cloud_login",
|
|
15
|
+
"password_reset",
|
|
16
|
+
"expired_password",
|
|
17
|
+
"admin_token",
|
|
18
|
+
] as const;
|
|
19
|
+
export type SignInMethod = (typeof SIGN_IN_METHODS)[number];
|
|
20
|
+
export type NewBrowserSignInNotice = {
|
|
21
|
+
browserId: string;
|
|
22
|
+
userId: string;
|
|
23
|
+
firstName: string | null;
|
|
24
|
+
browser: BrowserCode;
|
|
25
|
+
os: OsCode;
|
|
26
|
+
country: string | null;
|
|
27
|
+
city: string | null;
|
|
28
|
+
signedInAt: string;
|
|
29
|
+
method: SignInMethod;
|
|
30
|
+
device: string | null;
|
|
31
|
+
locale: string | null;
|
|
32
|
+
};
|
|
33
|
+
export type NewBrowserSignInNotifier = (notice: NewBrowserSignInNotice) => Promise<unknown>;
|
|
34
|
+
const log = logger("known-browsers");
|
|
35
|
+
const noticeSnapshot = z.object({
|
|
36
|
+
browser: z.enum(APP_LOGIN_CONTEXT_BROWSERS),
|
|
37
|
+
os: z.enum(APP_LOGIN_CONTEXT_OSES),
|
|
38
|
+
country: z
|
|
39
|
+
.string()
|
|
40
|
+
.regex(/^[A-Z]{2}$/)
|
|
41
|
+
.nullable(),
|
|
42
|
+
city: z.string().max(96).nullable(),
|
|
43
|
+
signedInAt: z.string().datetime(),
|
|
44
|
+
method: z.enum(SIGN_IN_METHODS),
|
|
45
|
+
device: z.string().min(1).max(80).nullable(),
|
|
46
|
+
locale: z.string().min(1).max(35).nullable(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** The greeting name, as the notice carries it: trimmed and at most 100 characters; profile names have no length limit. */
|
|
50
|
+
export const noticeFirstName = (name: string | null | undefined): string | null =>
|
|
51
|
+
Array.from(name?.trim() ?? "")
|
|
52
|
+
.slice(0, 100)
|
|
53
|
+
.join("")
|
|
54
|
+
.trim() || null;
|
|
55
|
+
|
|
56
|
+
export const KNOWN_BROWSER_LIMITS = {
|
|
57
|
+
retentionDays: 90,
|
|
58
|
+
browsersPerAccount: 100,
|
|
59
|
+
countriesPerAccount: 50,
|
|
60
|
+
introDays: 14,
|
|
61
|
+
noticeBatch: 100,
|
|
62
|
+
cleanupBatch: 1000,
|
|
63
|
+
cleanupBatchesPerRun: 10,
|
|
64
|
+
} as const;
|
|
65
|
+
const DAY_MS = 86_400_000;
|
|
66
|
+
const cutoffAt = (now: Date) => new Date(now.getTime() - KNOWN_BROWSER_LIMITS.retentionDays * DAY_MS);
|
|
67
|
+
const cookieName = (development: boolean) => (development ? "cloud_browser" : "__Host-cloud_browser");
|
|
68
|
+
const validCookie = (value: string | undefined): value is string =>
|
|
69
|
+
value !== undefined && value.length === 43 && /^[A-Za-z0-9_-]{43}$/.test(value);
|
|
70
|
+
|
|
71
|
+
export const readBrowserCookie = (c: Context, development = env.IS_DEVELOPMENT): string | null => {
|
|
72
|
+
const value = getCookie(c, cookieName(development));
|
|
73
|
+
return validCookie(value) ? value : null;
|
|
74
|
+
};
|
|
75
|
+
export const newBrowserCookie = (): string => Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url");
|
|
76
|
+
export const setBrowserCookie = (c: Context, value: string, development = env.IS_DEVELOPMENT): void => {
|
|
77
|
+
setCookie(c, cookieName(development), value, {
|
|
78
|
+
httpOnly: true,
|
|
79
|
+
secure: !development,
|
|
80
|
+
sameSite: "Strict",
|
|
81
|
+
path: "/",
|
|
82
|
+
maxAge: KNOWN_BROWSER_LIMITS.retentionDays * 86_400,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** SHA-256 over canonical UUID text followed immediately by the cookie text; stored as raw bytea. */
|
|
87
|
+
export const browserHash = (userId: string, cookie: string): Buffer =>
|
|
88
|
+
new Bun.CryptoHasher("sha256").update(userId.toLowerCase()).update(cookie).digest();
|
|
89
|
+
|
|
90
|
+
type SignIn = {
|
|
91
|
+
userId: string;
|
|
92
|
+
cookie: string;
|
|
93
|
+
firstName: string | null;
|
|
94
|
+
method: SignInMethod;
|
|
95
|
+
device: string | null;
|
|
96
|
+
locale: string | null;
|
|
97
|
+
browser: BrowserCode;
|
|
98
|
+
os: OsCode;
|
|
99
|
+
country: string | null;
|
|
100
|
+
city: string | null;
|
|
101
|
+
now: Date;
|
|
102
|
+
announce: boolean;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Caller holds the existing session issuance lock on this account's auth.users row. */
|
|
106
|
+
export const rememberSignIn = async (tx: SQL, input: SignIn): Promise<NewBrowserSignInNotice | null> => {
|
|
107
|
+
const { userId, cookie, browser, os, now } = input;
|
|
108
|
+
const country = input.country?.length === 2 && /^[A-Z]{2}$/.test(input.country) ? input.country : null;
|
|
109
|
+
const city = input.city === null ? null : truncateUtf8(input.city, 96);
|
|
110
|
+
const cutoff = cutoffAt(now);
|
|
111
|
+
const [row] = await tx<{ id: string; first_seen_at: Date }[]>`
|
|
112
|
+
INSERT INTO auth.known_browsers (user_id, browser_hash, browser, os, country, city, first_seen_at, last_seen_at)
|
|
113
|
+
VALUES (${userId}::uuid, ${browserHash(userId, cookie)}, ${browser}, ${os}, ${country}, ${city}, ${now}, ${now})
|
|
114
|
+
ON CONFLICT (user_id, browser_hash) DO UPDATE SET
|
|
115
|
+
browser = EXCLUDED.browser, os = EXCLUDED.os, country = EXCLUDED.country, city = EXCLUDED.city,
|
|
116
|
+
last_seen_at = EXCLUDED.last_seen_at,
|
|
117
|
+
first_seen_at = CASE WHEN known_browsers.last_seen_at <= ${cutoff} THEN EXCLUDED.first_seen_at ELSE known_browsers.first_seen_at END,
|
|
118
|
+
notified_at = CASE WHEN known_browsers.last_seen_at <= ${cutoff} THEN NULL ELSE known_browsers.notified_at END
|
|
119
|
+
RETURNING id, first_seen_at
|
|
120
|
+
`;
|
|
121
|
+
await tx`
|
|
122
|
+
DELETE FROM auth.known_browsers WHERE user_id = ${userId}::uuid AND (
|
|
123
|
+
last_seen_at <= ${cutoff} OR id IN (
|
|
124
|
+
SELECT id FROM auth.known_browsers WHERE user_id = ${userId}::uuid
|
|
125
|
+
ORDER BY last_seen_at DESC, id DESC OFFSET ${KNOWN_BROWSER_LIMITS.browsersPerAccount}
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
`;
|
|
129
|
+
if (country) {
|
|
130
|
+
await tx`
|
|
131
|
+
INSERT INTO auth.sign_in_countries (user_id, country, last_seen_at) VALUES (${userId}::uuid, ${country}, ${now})
|
|
132
|
+
ON CONFLICT (user_id, country) DO UPDATE SET last_seen_at = EXCLUDED.last_seen_at
|
|
133
|
+
`;
|
|
134
|
+
await tx`
|
|
135
|
+
DELETE FROM auth.sign_in_countries WHERE user_id = ${userId}::uuid AND (
|
|
136
|
+
last_seen_at <= ${cutoff} OR country IN (
|
|
137
|
+
SELECT country FROM auth.sign_in_countries WHERE user_id = ${userId}::uuid
|
|
138
|
+
ORDER BY last_seen_at DESC, country OFFSET ${KNOWN_BROWSER_LIMITS.countriesPerAccount}
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
`;
|
|
142
|
+
await tx`UPDATE auth.device_trust_state SET geoip_since = ${now} WHERE id = true AND geoip_since IS NULL`;
|
|
143
|
+
}
|
|
144
|
+
if (!row || row.first_seen_at.getTime() !== now.getTime()) return null;
|
|
145
|
+
if (!input.announce || (await trustState(tx, now)).introActive) {
|
|
146
|
+
await tx`UPDATE auth.known_browsers SET notified_at = ${now}, notice = NULL, notice_attempted_at = NULL WHERE id = ${row.id}::uuid`;
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const snapshot = {
|
|
150
|
+
browser,
|
|
151
|
+
os,
|
|
152
|
+
country,
|
|
153
|
+
city,
|
|
154
|
+
signedInAt: now.toISOString(),
|
|
155
|
+
method: input.method,
|
|
156
|
+
device: input.method === "cloud_login" && input.device ? truncateUtf8(input.device, 80) : null,
|
|
157
|
+
locale: input.locale && input.locale.length <= 35 ? input.locale : null,
|
|
158
|
+
};
|
|
159
|
+
await tx`UPDATE auth.known_browsers SET notified_at = NULL, notice = ${JSON.stringify(snapshot)}::text::jsonb, notice_attempted_at = NULL WHERE id = ${row.id}::uuid`;
|
|
160
|
+
return { ...snapshot, browserId: row.id, userId, firstName: input.firstName };
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** A failed handoff stays pending. Only the error class enters logs. */
|
|
164
|
+
export const announceNewBrowser = async (
|
|
165
|
+
notify: NewBrowserSignInNotifier,
|
|
166
|
+
notice: NewBrowserSignInNotice,
|
|
167
|
+
db: SQL = sql,
|
|
168
|
+
): Promise<boolean> => {
|
|
169
|
+
try {
|
|
170
|
+
await notify(notice);
|
|
171
|
+
await db`UPDATE auth.known_browsers SET notified_at = now(), notice = NULL, notice_attempted_at = NULL WHERE id = ${notice.browserId}::uuid AND notified_at IS NULL`;
|
|
172
|
+
return true;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
log.warn("New-browser notice failed", { error: error instanceof Error ? error.name : "UnknownError" });
|
|
175
|
+
// The next batch starts with notices tried least recently, so failing ones cannot hold back the others.
|
|
176
|
+
try {
|
|
177
|
+
await db`UPDATE auth.known_browsers SET notice_attempted_at = now() WHERE id = ${notice.browserId}::uuid AND notified_at IS NULL`;
|
|
178
|
+
} catch {
|
|
179
|
+
// Without the mark the notice keeps its place in the queue; it stays pending either way.
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const maintainNotices = async (
|
|
186
|
+
db: SQL,
|
|
187
|
+
notify: NewBrowserSignInNotifier,
|
|
188
|
+
{ signal, batchSize = KNOWN_BROWSER_LIMITS.noticeBatch }: { signal?: AbortSignal; batchSize?: number } = {},
|
|
189
|
+
): Promise<number> => {
|
|
190
|
+
if (!Number.isSafeInteger(batchSize) || batchSize < 1) throw new Error("New-browser notices require a positive batch size");
|
|
191
|
+
signal?.throwIfAborted();
|
|
192
|
+
const rows = await db<{ id: string; user_id: string; notice: unknown; given_name: string | null }[]>`
|
|
193
|
+
SELECT kb.id, kb.user_id, kb.notice, u.given_name FROM auth.known_browsers kb JOIN auth.users u ON u.id = kb.user_id
|
|
194
|
+
WHERE kb.notified_at IS NULL AND kb.notice IS NOT NULL AND kb.last_seen_at > ${cutoffAt(new Date())}
|
|
195
|
+
ORDER BY kb.notice_attempted_at NULLS FIRST, kb.first_seen_at, kb.id LIMIT ${batchSize}
|
|
196
|
+
`;
|
|
197
|
+
let handedOver = 0;
|
|
198
|
+
for (const row of rows) {
|
|
199
|
+
signal?.throwIfAborted();
|
|
200
|
+
const parsed = noticeSnapshot.safeParse(row.notice);
|
|
201
|
+
if (!parsed.success) {
|
|
202
|
+
await db`UPDATE auth.known_browsers SET notified_at = now(), notice = NULL, notice_attempted_at = NULL WHERE id = ${row.id}::uuid AND notified_at IS NULL`;
|
|
203
|
+
log.warn("Invalid new-browser notice discarded", { error: "InvalidSnapshot" });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (
|
|
207
|
+
await announceNewBrowser(
|
|
208
|
+
notify,
|
|
209
|
+
{ ...parsed.data, browserId: row.id, userId: row.user_id, firstName: noticeFirstName(row.given_name) },
|
|
210
|
+
db,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
handedOver++;
|
|
214
|
+
}
|
|
215
|
+
return handedOver;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export const findBrowser = async (
|
|
219
|
+
db: SQL,
|
|
220
|
+
{ userId, cookie, now }: { userId?: string | null; cookie: string | null; now: Date },
|
|
221
|
+
): Promise<{ firstSeenAt: Date } | null> => {
|
|
222
|
+
const id = userId ?? crypto.randomUUID();
|
|
223
|
+
const [row] = await db<{ first_seen_at: Date }[]>`
|
|
224
|
+
SELECT first_seen_at FROM auth.known_browsers
|
|
225
|
+
WHERE user_id = ${id}::uuid AND browser_hash = ${browserHash(id, cookie ?? newBrowserCookie())} AND last_seen_at > ${cutoffAt(now)}
|
|
226
|
+
`;
|
|
227
|
+
return userId && cookie && row ? { firstSeenAt: row.first_seen_at } : null;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export const countrySeen = async (
|
|
231
|
+
db: SQL,
|
|
232
|
+
{ userId, country, now }: { userId?: string | null; country: string | null; now: Date },
|
|
233
|
+
): Promise<boolean> => {
|
|
234
|
+
const [row] = await db<{ present: boolean }[]>`
|
|
235
|
+
SELECT EXISTS (SELECT 1 FROM auth.sign_in_countries
|
|
236
|
+
WHERE user_id = ${userId ?? crypto.randomUUID()}::uuid AND country = ${country} AND last_seen_at > ${cutoffAt(now)}) AS present
|
|
237
|
+
`;
|
|
238
|
+
return Boolean(userId && row?.present);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** Both country facts use the same retention window and one query, including decoys. */
|
|
242
|
+
export const countryHistory = async (
|
|
243
|
+
db: SQL,
|
|
244
|
+
{ userId, country, now }: { userId?: string | null; country: string | null; now: Date },
|
|
245
|
+
): Promise<{ seen: boolean; any: boolean }> => {
|
|
246
|
+
const [row] = await db<{ seen: boolean; any: boolean }[]>`
|
|
247
|
+
SELECT COALESCE(bool_or(country = ${country}), false) AS seen, count(*) > 0 AS "any"
|
|
248
|
+
FROM auth.sign_in_countries WHERE user_id = ${userId ?? crypto.randomUUID()}::uuid AND last_seen_at > ${cutoffAt(now)}
|
|
249
|
+
`;
|
|
250
|
+
return { seen: Boolean(userId && row?.seen), any: Boolean(userId && row?.any) };
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export const trustState = async (
|
|
254
|
+
db: SQL,
|
|
255
|
+
now: Date,
|
|
256
|
+
): Promise<{ startedAt: Date | null; geoipSince: Date | null; introActive: boolean }> => {
|
|
257
|
+
const [row] = await db<{ started_at: Date; geoip_since: Date | null }[]>`
|
|
258
|
+
SELECT started_at, geoip_since FROM auth.device_trust_state WHERE id = true
|
|
259
|
+
`;
|
|
260
|
+
return {
|
|
261
|
+
startedAt: row?.started_at ?? null,
|
|
262
|
+
geoipSince: row?.geoip_since ?? null,
|
|
263
|
+
introActive: !row || now.getTime() < row.started_at.getTime() + KNOWN_BROWSER_LIMITS.introDays * DAY_MS,
|
|
264
|
+
};
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
export const cleanup = async (
|
|
268
|
+
db: SQL,
|
|
269
|
+
{
|
|
270
|
+
now = new Date(),
|
|
271
|
+
signal,
|
|
272
|
+
batchSize = KNOWN_BROWSER_LIMITS.cleanupBatch,
|
|
273
|
+
maxBatches = KNOWN_BROWSER_LIMITS.cleanupBatchesPerRun,
|
|
274
|
+
}: { now?: Date; signal?: AbortSignal; batchSize?: number; maxBatches?: number } = {},
|
|
275
|
+
): Promise<{ browsers: number; countries: number }> => {
|
|
276
|
+
if (!Number.isSafeInteger(batchSize) || batchSize < 1 || !Number.isSafeInteger(maxBatches) || maxBatches < 0)
|
|
277
|
+
throw new Error("Known-browser cleanup requires a positive batch size and a non-negative batch count");
|
|
278
|
+
const cutoff = cutoffAt(now);
|
|
279
|
+
const counts = { browsers: 0, countries: 0 };
|
|
280
|
+
// Cleanup never waits for a sign-in: rows a sign-in holds are skipped (waiting could deadlock with its own
|
|
281
|
+
// eviction of the account's other expired rows), and the outer cutoff re-checks a row refreshed meanwhile.
|
|
282
|
+
// A short batch can therefore mean such a skip; the next run continues the backlog. The batch is a
|
|
283
|
+
// materialized CTE because a locking LIMIT subquery inside IN (...) may run more than once and exceed it.
|
|
284
|
+
for (let batch = 0; batch < maxBatches && !signal?.aborted; batch++) {
|
|
285
|
+
const rows = await db`
|
|
286
|
+
WITH batch AS MATERIALIZED (
|
|
287
|
+
SELECT id FROM auth.known_browsers WHERE last_seen_at <= ${cutoff} ORDER BY last_seen_at LIMIT ${batchSize}
|
|
288
|
+
FOR UPDATE SKIP LOCKED
|
|
289
|
+
)
|
|
290
|
+
DELETE FROM auth.known_browsers kb USING batch
|
|
291
|
+
WHERE kb.id = batch.id AND kb.last_seen_at <= ${cutoff} RETURNING kb.id
|
|
292
|
+
`;
|
|
293
|
+
counts.browsers += rows.length;
|
|
294
|
+
if (rows.length < batchSize) break;
|
|
295
|
+
}
|
|
296
|
+
for (let batch = 0; batch < maxBatches && !signal?.aborted; batch++) {
|
|
297
|
+
const rows = await db`
|
|
298
|
+
WITH batch AS MATERIALIZED (
|
|
299
|
+
SELECT user_id, country FROM auth.sign_in_countries WHERE last_seen_at <= ${cutoff} ORDER BY last_seen_at LIMIT ${batchSize}
|
|
300
|
+
FOR UPDATE SKIP LOCKED
|
|
301
|
+
)
|
|
302
|
+
DELETE FROM auth.sign_in_countries sc USING batch
|
|
303
|
+
WHERE sc.user_id = batch.user_id AND sc.country = batch.country AND sc.last_seen_at <= ${cutoff} RETURNING sc.user_id
|
|
304
|
+
`;
|
|
305
|
+
counts.countries += rows.length;
|
|
306
|
+
if (rows.length < batchSize) break;
|
|
307
|
+
}
|
|
308
|
+
return counts;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/** Core-internal persistence and maintenance; no request surface. */
|
|
312
|
+
export const knownBrowsers = { findBrowser, countrySeen, countryHistory, trustState, cleanup, maintainNotices };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { requestCacheRedis } from "./request-cache-redis";
|
|
4
|
+
|
|
5
|
+
const AddressProofSchema = z.object({ salt: z.string().regex(/^[a-f0-9]{32}$/), digest: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
6
|
+
export type AppLoginAddressProof = z.infer<typeof AddressProofSchema>;
|
|
7
|
+
export type AppLoginAddressStore = {
|
|
8
|
+
remember(requestId: string, proof: AppLoginAddressProof, expiresAt: Date): Promise<void>;
|
|
9
|
+
read(requestIds: string[]): Promise<Map<string, AppLoginAddressProof>>;
|
|
10
|
+
forget(requestId: string): Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
export const appLoginAddressKey = (requestId: string) => `cloud:app-login-address:${requestId}`;
|
|
13
|
+
const digest = (salt: string, prefix: string) => new Bun.CryptoHasher("sha256").update(Buffer.from(salt, "hex")).update(prefix).digest();
|
|
14
|
+
export const appLoginAddressProof = (prefix: string): AppLoginAddressProof => {
|
|
15
|
+
const salt = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("hex");
|
|
16
|
+
return { salt, digest: digest(salt, prefix).toString("hex") };
|
|
17
|
+
};
|
|
18
|
+
export const appLoginAddressMatches = (proof: AppLoginAddressProof, prefix: string): boolean => {
|
|
19
|
+
const parsed = AddressProofSchema.safeParse(proof);
|
|
20
|
+
return parsed.success && timingSafeEqual(Buffer.from(parsed.data.digest, "hex"), digest(parsed.data.salt, prefix));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Fail-fast cache client; service callers catch every failure after their SQL commit. */
|
|
24
|
+
export const valkeyAppLoginAddressStore: AppLoginAddressStore = {
|
|
25
|
+
remember: async (requestId, proof, expiresAt) => {
|
|
26
|
+
await (await requestCacheRedis()).send("SET", [
|
|
27
|
+
appLoginAddressKey(requestId),
|
|
28
|
+
JSON.stringify(proof),
|
|
29
|
+
"PXAT",
|
|
30
|
+
String(expiresAt.getTime()),
|
|
31
|
+
]);
|
|
32
|
+
},
|
|
33
|
+
read: async (requestIds) => {
|
|
34
|
+
const proofs = new Map<string, AppLoginAddressProof>();
|
|
35
|
+
if (!requestIds.length) return proofs;
|
|
36
|
+
const values = await (await requestCacheRedis()).mget(...requestIds.map(appLoginAddressKey));
|
|
37
|
+
for (const [index, value] of values.entries()) {
|
|
38
|
+
if (!value) continue;
|
|
39
|
+
try {
|
|
40
|
+
const parsed = AddressProofSchema.safeParse(JSON.parse(value));
|
|
41
|
+
if (parsed.success) proofs.set(requestIds[index]!, parsed.data);
|
|
42
|
+
} catch {
|
|
43
|
+
// Invalid cache entries are absent hints, never sign-in failures.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return proofs;
|
|
47
|
+
},
|
|
48
|
+
forget: async (requestId) => {
|
|
49
|
+
await (await requestCacheRedis()).del(appLoginAddressKey(requestId));
|
|
50
|
+
},
|
|
51
|
+
};
|