@k2b/cloud 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +12 -5
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +63 -16
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +32 -7
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/background-review.ts +72 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +45 -163
- package/src/ai/chat/view-block.tsx +22 -7
- package/src/ai/chat-task-contracts.ts +31 -3
- package/src/ai/chat-task-reasons-migrate.ts +75 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +438 -78
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +14 -0
- package/src/ai/migrate.ts +39 -4
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +172 -2
- package/src/ai/runtime.ts +13 -27
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +232 -27
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +67 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +40 -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-core-settings.ts +190 -139
- 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 +6 -2
- package/src/api/app-approval.ts +54 -6
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +3 -1
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +7 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +60 -6
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +145 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +160 -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 +35 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +756 -0
- package/src/browser/panel.ts +65 -0
- package/src/browser/resource-picker.tsx +12 -1
- 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 +159 -12
- package/src/contracts/app.ts +14 -0
- 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/registry.ts +3 -1
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +2 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +533 -90
- package/src/services/approvals/device.ts +9 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +32 -3
- 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/browser.ts +16 -5
- 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/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/services/webauthn.ts +12 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared/redirect.ts +12 -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 +95 -10
- package/src/ssr/Layout.tsx +66 -10
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +35 -0
- package/src/ssr/PanelRuntime.island.tsx +94 -0
- package/src/ssr/PanelSlot.tsx +158 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +69 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +105 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +28 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +439 -0
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +159 -0
- package/src/ssr/platform-messages.ts +48 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +5 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +201 -11
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +314 -63
- package/src/styles/panel.css +416 -0
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +36 -13
- 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 +22 -43
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APP_APPROVAL_LIMITS,
|
|
3
|
+
type AppDeviceInbox,
|
|
4
|
+
type AppDeviceInboxWithChanges,
|
|
5
|
+
type AppLoginContext,
|
|
6
|
+
AppLoginContextSchema,
|
|
7
|
+
type AppPendingLogin,
|
|
8
|
+
} from "../contracts/app-approval";
|
|
9
|
+
import { truncateUtf8 } from "../server/request-context/geoip";
|
|
10
|
+
import { KNOWN_BROWSER_LIMITS } from "./known-browsers";
|
|
11
|
+
|
|
12
|
+
export type AppLoginRequestContext = Pick<AppLoginContext, "browser" | "os" | "country" | "city" | "network"> & {
|
|
13
|
+
cookie: string | null;
|
|
14
|
+
prefix: string | null;
|
|
15
|
+
};
|
|
16
|
+
export type LoginContextInput = {
|
|
17
|
+
request?: AppLoginRequestContext;
|
|
18
|
+
browser: { firstSeenAt: Date } | null;
|
|
19
|
+
countries: { seen: boolean; any: boolean };
|
|
20
|
+
trust: { introActive: boolean; geoipSince: Date | null };
|
|
21
|
+
now: Date;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Core's only level rule. Descriptions and same-network hints do not affect it. */
|
|
25
|
+
export const loginContextLevel = (input: {
|
|
26
|
+
introActive: boolean;
|
|
27
|
+
knownBrowser: boolean;
|
|
28
|
+
newCountry: boolean;
|
|
29
|
+
anyCountry: boolean;
|
|
30
|
+
}): AppLoginContext["level"] => {
|
|
31
|
+
if (input.introActive) return "info";
|
|
32
|
+
if (input.knownBrowser) return input.newCountry ? "hint" : "info";
|
|
33
|
+
return input.newCountry && input.anyCountry ? "warn" : "hint";
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** JSONB adds a space after each colon and comma. Two bytes per entry bound
|
|
37
|
+
* both separators, including nested objects/arrays, without counting punctuation in strings. */
|
|
38
|
+
export const loginContextStorageBytes = (value: unknown): number => {
|
|
39
|
+
const separators = (item: unknown): number => {
|
|
40
|
+
if (!item || typeof item !== "object") return 0;
|
|
41
|
+
const values = Object.values(item);
|
|
42
|
+
return 2 * values.length + values.reduce<number>((sum, entry) => sum + separators(entry), 0);
|
|
43
|
+
};
|
|
44
|
+
return new TextEncoder().encode(JSON.stringify(value)).length + separators(value);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Never store sameNetwork. Oversized or invalid context cannot prevent a sign-in. */
|
|
48
|
+
export const boundLoginContext = (input: AppLoginContext): AppLoginContext | null => {
|
|
49
|
+
const { sameNetwork: _transient, ...context } = input;
|
|
50
|
+
if (loginContextStorageBytes(context) > 512) {
|
|
51
|
+
context.city = null;
|
|
52
|
+
context.network = null;
|
|
53
|
+
}
|
|
54
|
+
if (loginContextStorageBytes(context) > 512) return null;
|
|
55
|
+
const parsed = AppLoginContextSchema.safeParse(context);
|
|
56
|
+
return parsed.success ? parsed.data : null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const buildLoginContext = ({ request, browser, countries, trust, now }: LoginContextInput): AppLoginContext | null => {
|
|
60
|
+
const country = request?.country ?? null;
|
|
61
|
+
const newCountry = country !== null && trust.geoipSince !== null && !countries.seen;
|
|
62
|
+
const name = (value: string | null | undefined) =>
|
|
63
|
+
value
|
|
64
|
+
? truncateUtf8(
|
|
65
|
+
value
|
|
66
|
+
.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ")
|
|
67
|
+
.replace(/\p{Cf}/gu, "")
|
|
68
|
+
.replace(/\s+/gu, " ")
|
|
69
|
+
.trim(),
|
|
70
|
+
96,
|
|
71
|
+
) || null
|
|
72
|
+
: null;
|
|
73
|
+
return boundLoginContext({
|
|
74
|
+
level: loginContextLevel({ introActive: trust.introActive, knownBrowser: browser !== null, newCountry, anyCountry: countries.any }),
|
|
75
|
+
browser: request?.browser ?? "other",
|
|
76
|
+
os: request?.os ?? "other",
|
|
77
|
+
country,
|
|
78
|
+
city: name(request?.city),
|
|
79
|
+
network: name(request?.network),
|
|
80
|
+
history: trust.introActive
|
|
81
|
+
? null
|
|
82
|
+
: {
|
|
83
|
+
knownSince: browser?.firstSeenAt.toISOString() ?? null,
|
|
84
|
+
newCountry,
|
|
85
|
+
countriesSince:
|
|
86
|
+
trust.geoipSince && now.getTime() - trust.geoipSince.getTime() < KNOWN_BROWSER_LIMITS.retentionDays * 86_400_000
|
|
87
|
+
? trust.geoipSince.toISOString()
|
|
88
|
+
: null,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type LoginListResponse = AppDeviceInbox | AppDeviceInboxWithChanges | { requests: AppPendingLogin[]; pollAfterSeconds: number };
|
|
94
|
+
/** Keep every login: trim Actions first, then device changes, then context from the last login backwards.
|
|
95
|
+
* Trimmed changes stay pending and show once the inbox has room.
|
|
96
|
+
* Return a copy so callers and tests can retain the original snapshot. */
|
|
97
|
+
export const trimAppLoginResponse = <T extends LoginListResponse>(response: T, budget: number = APP_APPROVAL_LIMITS.bodyBytes): T => {
|
|
98
|
+
const result = { ...response };
|
|
99
|
+
const bytes = () => new TextEncoder().encode(JSON.stringify(result)).length;
|
|
100
|
+
if ("logins" in result) {
|
|
101
|
+
result.actions = [...result.actions];
|
|
102
|
+
result.logins = result.logins.map((login) => ({ ...login }));
|
|
103
|
+
while (result.actions.length && bytes() > budget) {
|
|
104
|
+
result.actions.pop();
|
|
105
|
+
result.moreActions++;
|
|
106
|
+
}
|
|
107
|
+
if ("changes" in result) {
|
|
108
|
+
result.changes = [...result.changes];
|
|
109
|
+
while (result.changes.length && bytes() > budget) {
|
|
110
|
+
result.changes.pop();
|
|
111
|
+
result.moreChanges++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else result.requests = result.requests.map((login) => ({ ...login }));
|
|
115
|
+
const logins = "logins" in result ? result.logins : result.requests;
|
|
116
|
+
for (let index = logins.length - 1; index >= 0 && bytes() > budget; index--) delete logins[index]!.context;
|
|
117
|
+
return result;
|
|
118
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { err, fail, ok, type PageParams, type Paginated, paginate, type Result } from "@k2b/stdlib";
|
|
2
2
|
import { type SQL, sql } from "bun";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import type { CapabilityActionPolicy } from "../../contracts/capabilities";
|
|
4
5
|
import { CapabilityAppIdSchema } from "../../contracts/capabilities";
|
|
5
6
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
6
7
|
import { audit } from "../audit";
|
|
@@ -626,6 +627,7 @@ export const validateMandateIssueAuthority = async (
|
|
|
626
627
|
operation: string;
|
|
627
628
|
actionApproval?: "none" | "approved";
|
|
628
629
|
input?: unknown;
|
|
630
|
+
capabilityPolicy?: CapabilityActionPolicy;
|
|
629
631
|
capabilityApproval?: "none" | "rememberable" | "always";
|
|
630
632
|
expectedRevision?: number;
|
|
631
633
|
requestId?: string;
|
|
@@ -687,6 +689,7 @@ export const validateMandateIssueAuthority = async (
|
|
|
687
689
|
actionApproval: input.actionApproval ?? "none",
|
|
688
690
|
input: input.input,
|
|
689
691
|
capabilityApproval: input.capabilityApproval,
|
|
692
|
+
capabilityPolicy: input.capabilityPolicy,
|
|
690
693
|
})
|
|
691
694
|
) {
|
|
692
695
|
return denyMandateIssue(err.forbidden(MANDATE_POLICY_DENIED_MESSAGE));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { CapabilityAppIdSchema, CapabilityLocalIdSchema } from "../../contracts/capabilities";
|
|
2
|
+
import { type CapabilityActionPolicy, CapabilityAppIdSchema, CapabilityLocalIdSchema } from "../../contracts/capabilities";
|
|
3
3
|
|
|
4
4
|
export const MANDATE_POLICY_VERSION = 1 as const;
|
|
5
5
|
export const MANDATE_POLICY_MAX_IDENTIFIERS = 100;
|
|
@@ -201,6 +201,7 @@ export const mandatePolicyAllows = (
|
|
|
201
201
|
operation: string;
|
|
202
202
|
actionApproval: "none" | "approved";
|
|
203
203
|
input?: unknown;
|
|
204
|
+
capabilityPolicy?: CapabilityActionPolicy;
|
|
204
205
|
capabilityApproval?: "none" | "rememberable" | "always";
|
|
205
206
|
},
|
|
206
207
|
): boolean => {
|
|
@@ -239,7 +240,11 @@ export const mandatePolicyAllows = (
|
|
|
239
240
|
)
|
|
240
241
|
)
|
|
241
242
|
return false;
|
|
242
|
-
if (kind === "action"
|
|
243
|
+
if (kind === "action") {
|
|
244
|
+
if (input.capabilityPolicy) {
|
|
245
|
+
if (!("silent" in input.capabilityPolicy) || input.capabilityPolicy.routines === false) return false;
|
|
246
|
+
} else if (input.capabilityApproval !== "none" && input.capabilityApproval !== "rememberable") return false;
|
|
247
|
+
}
|
|
243
248
|
}
|
|
244
249
|
if (!input.operation.startsWith("capability.action.run:")) return true;
|
|
245
250
|
if (policy.actions === "deny") return false;
|
|
@@ -250,13 +255,20 @@ export const mandatePolicyAllows = (
|
|
|
250
255
|
/** Discovery ignores fixed inputs: at least one unattended call must fit the policy. */
|
|
251
256
|
export const mandatePolicyCanPermitCapability = (
|
|
252
257
|
policy: MandatePolicyV1,
|
|
253
|
-
input: {
|
|
258
|
+
input: {
|
|
259
|
+
appId: string;
|
|
260
|
+
capabilityId: string;
|
|
261
|
+
kind: "query" | "action";
|
|
262
|
+
capabilityPolicy?: CapabilityActionPolicy;
|
|
263
|
+
approval?: "none" | "rememberable" | "always";
|
|
264
|
+
},
|
|
254
265
|
): boolean => {
|
|
255
266
|
const call = {
|
|
256
267
|
appId: input.appId,
|
|
257
268
|
operation: `capability.${input.kind === "query" ? "query" : "action.run"}:${input.capabilityId}`,
|
|
258
269
|
actionApproval: "none" as const,
|
|
259
270
|
capabilityApproval: input.approval,
|
|
271
|
+
capabilityPolicy: input.capabilityPolicy,
|
|
260
272
|
};
|
|
261
273
|
if (policy.grants === undefined) return mandatePolicyAllows(policy, call);
|
|
262
274
|
return policy.grants.some(
|
|
@@ -7,6 +7,7 @@ import { isSafeNotificationTargetHref } from "../../contracts/notification-types
|
|
|
7
7
|
import { type BrowserPushSubscription, BrowserPushSubscriptionSchema } from "../../contracts/user-notifications";
|
|
8
8
|
import { decryptSecret, encryptSecret } from "../secrets";
|
|
9
9
|
import { coreSettings } from "../settings/api";
|
|
10
|
+
import { SettingUnreadableError } from "../settings/store";
|
|
10
11
|
import { type NotificationDestination, registerNotificationChannel } from "./channels";
|
|
11
12
|
import { NOTIFICATION_PREVIEW_LIMIT } from "./preview";
|
|
12
13
|
import { sendPinnedWebPush } from "./web-push-transport";
|
|
@@ -76,10 +77,16 @@ const ensureVapidConfig = (): Promise<{ publicKey: string; privateKey: string }>
|
|
|
76
77
|
vapidConfigPromise = sql
|
|
77
78
|
.begin(async (tx) => {
|
|
78
79
|
await tx`SELECT pg_advisory_xact_lock(hashtextextended('notifications:web-push-vapid', 0))`;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const keys = ["notifications.web_push_public_key", "notifications.web_push_private_key"] as const;
|
|
81
|
+
const states = await Promise.all(keys.map((key) => coreSettings.read<string>(key)));
|
|
82
|
+
for (const [index, state] of states.entries()) {
|
|
83
|
+
if (state.stored === "unreadable") {
|
|
84
|
+
const key = keys[index]!;
|
|
85
|
+
console.warn(`[notifications] cannot read setting "${key}"; keeping stored VAPID keys unchanged`);
|
|
86
|
+
throw new SettingUnreadableError(key);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let [publicKey, privateKey] = states.map((state) => state.value);
|
|
83
90
|
if (!publicKey || !privateKey) {
|
|
84
91
|
const generated = webpush.generateVAPIDKeys();
|
|
85
92
|
publicKey = generated.publicKey;
|
|
@@ -240,7 +247,11 @@ unregisterDriver = registerNotificationChannel(browserDriver);
|
|
|
240
247
|
export const browserNotifications = {
|
|
241
248
|
start: async (): Promise<void> => {
|
|
242
249
|
unregisterDriver ??= registerNotificationChannel(browserDriver);
|
|
243
|
-
|
|
250
|
+
try {
|
|
251
|
+
await ensureWebPushConfigured();
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (!(error instanceof SettingUnreadableError)) throw error;
|
|
254
|
+
}
|
|
244
255
|
},
|
|
245
256
|
stop: (): void => {
|
|
246
257
|
unregisterDriver?.();
|
|
@@ -8,7 +8,7 @@ const sanitizeContent = (content: string): string => sanitizeHtml(content, { all
|
|
|
8
8
|
// sanitize-html decodes entities before escaping &, < and > again. Decode only
|
|
9
9
|
// those escapes once, so literal entity text is not recursively interpreted.
|
|
10
10
|
const htmlText = (html: string): string =>
|
|
11
|
-
sanitizeContent(html.replace(/<br\b[^>]*>|<\/(?:p|pre|blockquote|div|h[1-6]|li|tr|td|th|ul|ol|table)>/gi, " "))
|
|
11
|
+
sanitizeContent(html.replace(/<(?:br|hr)\b[^>]*>|<\/(?:p|pre|blockquote|div|h[1-6]|li|tr|td|th|ul|ol|table)>/gi, " "))
|
|
12
12
|
.replace(/&(amp|lt|gt);/g, (entity) => (entity === "&" ? "&" : entity === "<" ? "<" : ">"))
|
|
13
13
|
.replace(/\s+/g, " ")
|
|
14
14
|
.trim();
|
|
@@ -86,6 +86,18 @@ const mapServiceAccount = (row: ServiceAccountActorRow): ServiceAccount => ({
|
|
|
86
86
|
createdAt: new Date(row.service_account_created_at).toISOString(),
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
/** A user access token is current only while its `auth_epoch` claim equals the account's live epoch. */
|
|
90
|
+
export const isOAuthTokenEpochCurrent = (payload: JWTPayload, liveEpoch: unknown): boolean => {
|
|
91
|
+
const claim = payload.auth_epoch;
|
|
92
|
+
return (
|
|
93
|
+
typeof claim === "number" &&
|
|
94
|
+
Number.isSafeInteger(claim) &&
|
|
95
|
+
claim >= 0 &&
|
|
96
|
+
(typeof liveEpoch === "number" || typeof liveEpoch === "bigint" || typeof liveEpoch === "string") &&
|
|
97
|
+
String(liveEpoch) === String(claim)
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
89
101
|
/** Resolve the live OAuth client/principal, then check current account-category policy. */
|
|
90
102
|
export const resolveOAuthTokenActor = async (
|
|
91
103
|
payload: JWTPayload,
|
|
@@ -136,13 +148,14 @@ export const resolveOAuthTokenActor = async (
|
|
|
136
148
|
if (!userId && !uid) return null;
|
|
137
149
|
const predicate = userId ? sql`u.id = ${userId}::uuid` : sql`u.uid = ${uid}`;
|
|
138
150
|
const rows = await query<Record<string, unknown>[]>`
|
|
139
|
-
SELECT ${userProjectionSql(groupsAdmin)}
|
|
151
|
+
SELECT u.auth_epoch, ${userProjectionSql(groupsAdmin)}
|
|
140
152
|
FROM oauth.clients c
|
|
141
153
|
JOIN auth.users u ON ${predicate}
|
|
142
154
|
${userProjectionJoin()}
|
|
143
155
|
WHERE c.client_id = ${clientId}
|
|
144
156
|
`;
|
|
145
|
-
|
|
157
|
+
if (!rows[0] || !isOAuthTokenEpochCurrent(payload, rows[0].auth_epoch)) return null;
|
|
158
|
+
const user = buildProjectedUser(rows[0]);
|
|
146
159
|
if (!user || isAccountExpired(user.accountExpires)) return null;
|
|
147
160
|
if (!(await isAccountCategoryAllowed(user, query))) return null;
|
|
148
161
|
return { kind: "user", payload, user, scopes };
|
|
@@ -206,4 +219,5 @@ export const clearOAuthVerifierCachesForTest = (): void => {
|
|
|
206
219
|
|
|
207
220
|
export const oauthTokens = {
|
|
208
221
|
verifyAccessToken,
|
|
222
|
+
resolveOAuthTokenActor,
|
|
209
223
|
};
|