@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
|
@@ -5,6 +5,8 @@ import { HTTPException } from "hono/http-exception";
|
|
|
5
5
|
import { env } from "../../config/env";
|
|
6
6
|
import { PWA_AUTH_PATH, PWA_COOKIES } from "../../contracts/pwa";
|
|
7
7
|
import type { User } from "../../contracts/shared";
|
|
8
|
+
import { getLocale } from "../../server/locale";
|
|
9
|
+
import { describeRequest } from "../../server/request-context";
|
|
8
10
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
9
11
|
import { isAccountExpired } from "../account-model";
|
|
10
12
|
import { audit } from "../audit";
|
|
@@ -12,9 +14,23 @@ import { IDENTITY_CLOCK_TOLERANCE_SECONDS, IDENTITY_ROLLOUT_MARGIN_MS } from "..
|
|
|
12
14
|
import { invalidateIdentitySignerCache, type PreparedIdentitySigner, prepareIdentitySigner } from "../identity/key-ring";
|
|
13
15
|
import { getIdentityRuntimeConfig } from "../identity/runtime-config";
|
|
14
16
|
import { type CloudSessionClaims, isSessionJwtCandidate, signSessionToken, verifySessionToken } from "../identity/session-token";
|
|
17
|
+
import {
|
|
18
|
+
announceNewBrowser,
|
|
19
|
+
type NewBrowserSignInNotice,
|
|
20
|
+
type NewBrowserSignInNotifier,
|
|
21
|
+
newBrowserCookie,
|
|
22
|
+
noticeFirstName,
|
|
23
|
+
readBrowserCookie,
|
|
24
|
+
rememberSignIn,
|
|
25
|
+
type SignInMethod,
|
|
26
|
+
setBrowserCookie,
|
|
27
|
+
} from "../known-browsers";
|
|
28
|
+
import { logger } from "../logging";
|
|
15
29
|
import * as settings from "../settings";
|
|
16
30
|
import { loadJwtSession, loadJwtSessionIdentity } from "./user";
|
|
17
31
|
|
|
32
|
+
const log = logger("auth:session");
|
|
33
|
+
|
|
18
34
|
export type SessionKind = "web" | "app";
|
|
19
35
|
|
|
20
36
|
export type SessionData = {
|
|
@@ -77,12 +93,13 @@ const verifyForRequest = (c: Context, token: string): Promise<CloudSessionClaims
|
|
|
77
93
|
/** Moves the account's authentication epoch, which ends every session issued before it.
|
|
78
94
|
* Request-time expiry uses only this: device operations check expiry themselves and an
|
|
79
95
|
* administrator can lift it, so revoking devices would depend on which request came first. */
|
|
80
|
-
const endSessions = async (db: SQL, userId: string): Promise<
|
|
81
|
-
const [updated] = await db<Array<{
|
|
96
|
+
const endSessions = async (db: SQL, userId: string): Promise<number> => {
|
|
97
|
+
const [updated] = await db<Array<{ auth_epoch: string | number | bigint }>>`
|
|
82
98
|
UPDATE auth.users SET auth_epoch = auth_epoch + 1
|
|
83
|
-
WHERE id = ${userId}::uuid RETURNING
|
|
99
|
+
WHERE id = ${userId}::uuid RETURNING auth_epoch
|
|
84
100
|
`;
|
|
85
101
|
if (!updated) throw new Error("Cannot revoke sessions for an unknown user");
|
|
102
|
+
return Number(updated.auth_epoch);
|
|
86
103
|
};
|
|
87
104
|
|
|
88
105
|
const authenticateVerified = async (credential: CloudSessionClaims): Promise<AuthenticatedSession | null> => {
|
|
@@ -144,12 +161,24 @@ const revokeToken = async (token: string, requestId?: string | null): Promise<vo
|
|
|
144
161
|
}
|
|
145
162
|
};
|
|
146
163
|
|
|
164
|
+
export class SessionEpochChangedError extends Error {}
|
|
165
|
+
/** The Cloud Login device that approved a sign-in was removed before the session was issued. */
|
|
166
|
+
export class SessionAppDeviceInactiveError extends Error {}
|
|
167
|
+
|
|
147
168
|
export type SessionIssueOptions = {
|
|
169
|
+
authEpoch?: number;
|
|
148
170
|
ttlSeconds: number;
|
|
171
|
+
/** Only web session.create supplies browser persistence, never mobile issuance. */
|
|
172
|
+
knownBrowser?: Pick<
|
|
173
|
+
Parameters<typeof rememberSignIn>[1],
|
|
174
|
+
"cookie" | "browser" | "os" | "country" | "city" | "method" | "device" | "locale" | "announce"
|
|
175
|
+
>;
|
|
149
176
|
/** Pre-chosen family id, for callers that record it in the same transaction. */
|
|
150
177
|
sid?: string;
|
|
151
178
|
/** Binds the family to a paired phone of the mobile app; the device must be active. */
|
|
152
179
|
pwaDeviceId?: string | null;
|
|
180
|
+
/** The active Cloud Login device that approved this web sign-in. */
|
|
181
|
+
appDeviceId?: string;
|
|
153
182
|
/** Web sign-ins update `last_login_local`; app renewals track use per device instead. */
|
|
154
183
|
recordLogin?: boolean;
|
|
155
184
|
};
|
|
@@ -162,12 +191,14 @@ type PreparedFamily = {
|
|
|
162
191
|
expiresAt: Date;
|
|
163
192
|
signer: PreparedIdentitySigner;
|
|
164
193
|
options: SessionIssueOptions;
|
|
194
|
+
notice: NewBrowserSignInNotice | null;
|
|
165
195
|
};
|
|
166
196
|
|
|
167
197
|
const SIGNER_INACTIVE = "Prepared Cloud session signer is no longer active";
|
|
168
198
|
const isSignerInactive = (error: unknown) => error instanceof Error && error.message === SIGNER_INACTIVE;
|
|
169
199
|
|
|
170
|
-
/** Inserts one family in `tx`. Locks the user row first, like every caller that locks more rows
|
|
200
|
+
/** Inserts one family in `tx`. Locks the user row first, like every caller that locks more rows; `NO KEY UPDATE`
|
|
201
|
+
* serializes with every other account lock but lets foreign-key checks of concurrent writes pass. */
|
|
171
202
|
const insertFamily = async (
|
|
172
203
|
tx: SQL,
|
|
173
204
|
c: Context,
|
|
@@ -175,19 +206,22 @@ const insertFamily = async (
|
|
|
175
206
|
signer: PreparedIdentitySigner,
|
|
176
207
|
options: SessionIssueOptions,
|
|
177
208
|
): Promise<PreparedFamily> => {
|
|
178
|
-
const issuedAt = new Date();
|
|
179
|
-
const expiresAt = new Date(issuedAt.getTime() + options.ttlSeconds * 1_000);
|
|
180
209
|
const sid = options.sid ?? crypto.randomUUID();
|
|
181
210
|
const requestId = c.req.header("x-request-id")?.slice(0, 64) ?? null;
|
|
182
211
|
const userAgent = c.req.header("user-agent")?.slice(0, 256) ?? null;
|
|
183
|
-
const [user] = await tx<
|
|
184
|
-
|
|
212
|
+
const [user] = await tx<
|
|
213
|
+
Array<{ given_name: string | null; auth_epoch: string | number | bigint; provider: "local" | "ipa"; profile: "guest" | "user" }>
|
|
214
|
+
>`
|
|
215
|
+
SELECT given_name, auth_epoch, provider, profile FROM auth.users WHERE id = ${userId}::uuid FOR NO KEY UPDATE
|
|
185
216
|
`;
|
|
186
217
|
if (!user) throw new Error("Cannot create a session for an unknown user");
|
|
218
|
+
const issuedAt = new Date();
|
|
219
|
+
const expiresAt = new Date(issuedAt.getTime() + options.ttlSeconds * 1_000);
|
|
187
220
|
if (!(await isAccountCategoryAllowed(user, tx)))
|
|
188
221
|
throw new HTTPException(403, { message: "This account category is disabled. Contact an administrator." });
|
|
189
222
|
const epoch = Number(user.auth_epoch);
|
|
190
223
|
if (!Number.isSafeInteger(epoch) || epoch < 0) throw new Error("User auth_epoch is invalid");
|
|
224
|
+
if (options.authEpoch !== undefined && epoch !== options.authEpoch) throw new SessionEpochChangedError();
|
|
191
225
|
if (options.pwaDeviceId) {
|
|
192
226
|
// Device revocation locks the same user row first, so a revoked device never gets a new family.
|
|
193
227
|
const [device] = await tx`
|
|
@@ -195,6 +229,14 @@ const insertFamily = async (
|
|
|
195
229
|
`;
|
|
196
230
|
if (!device) throw new Error("Cannot create an app session for an inactive device");
|
|
197
231
|
}
|
|
232
|
+
if (options.appDeviceId) {
|
|
233
|
+
// A device that decides a terminal sign-in holds its row and then takes a key-share lock on this user row, so the
|
|
234
|
+
// user row above is held without blocking key-share locks. Removals that end the device's sign-ins hold the user
|
|
235
|
+
// row too, so they wait for this family.
|
|
236
|
+
const [device] = await tx`SELECT id FROM auth.app_devices
|
|
237
|
+
WHERE id=${options.appDeviceId}::uuid AND user_id=${userId}::uuid AND revoked_at IS NULL`;
|
|
238
|
+
if (!device) throw new SessionAppDeviceInactiveError("Cannot create a session for an inactive sign-in device");
|
|
239
|
+
}
|
|
198
240
|
const [activeKey] = await tx<Array<{ kid: string }>>`
|
|
199
241
|
UPDATE auth.signing_keys
|
|
200
242
|
SET verify_until = GREATEST(verify_until, ${new Date(expiresAt.getTime() + IDENTITY_CLOCK_TOLERANCE_SECONDS * 1_000 + IDENTITY_ROLLOUT_MARGIN_MS)})
|
|
@@ -204,12 +246,15 @@ const insertFamily = async (
|
|
|
204
246
|
if (!activeKey) throw new Error(SIGNER_INACTIVE);
|
|
205
247
|
await tx`
|
|
206
248
|
INSERT INTO auth.session_families (
|
|
207
|
-
sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending, pwa_device_id
|
|
249
|
+
sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending, pwa_device_id, app_device_id
|
|
208
250
|
) VALUES (${sid}, ${userId}, ${epoch}, ${signer.kid}, ${issuedAt}, ${expiresAt}, ${requestId}, ${userAgent},
|
|
209
|
-
NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid), ${options.pwaDeviceId ?? null})
|
|
251
|
+
NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid), ${options.pwaDeviceId ?? null}, ${options.appDeviceId ?? null})
|
|
210
252
|
`;
|
|
253
|
+
const notice = options.knownBrowser
|
|
254
|
+
? await rememberSignIn(tx, { ...options.knownBrowser, userId, firstName: noticeFirstName(user.given_name), now: issuedAt })
|
|
255
|
+
: null;
|
|
211
256
|
if (options.recordLogin !== false) await tx`UPDATE auth.users SET last_login_local = ${issuedAt} WHERE id = ${userId}::uuid`;
|
|
212
|
-
return { userId, sid, authEpoch: epoch, issuedAt, expiresAt, signer, options };
|
|
257
|
+
return { userId, sid, authEpoch: epoch, issuedAt, expiresAt, signer, options, notice };
|
|
213
258
|
};
|
|
214
259
|
|
|
215
260
|
/** Signs a committed family. Null when the signer was revoked meanwhile; the family is revoked then. */
|
|
@@ -244,7 +289,13 @@ const signFamily = async (family: PreparedFamily): Promise<string | null> => {
|
|
|
244
289
|
};
|
|
245
290
|
|
|
246
291
|
/** One issuance attempt is retried once when the signing key changes underneath it. */
|
|
247
|
-
const issueJwtSession = async (
|
|
292
|
+
const issueJwtSession = async (
|
|
293
|
+
c: Context,
|
|
294
|
+
userId: string,
|
|
295
|
+
options: SessionIssueOptions,
|
|
296
|
+
attempt = 0,
|
|
297
|
+
committedNotice: NewBrowserSignInNotice | null = null,
|
|
298
|
+
): Promise<{ token: string; expiresAt: Date; notice: NewBrowserSignInNotice | null }> => {
|
|
248
299
|
const signer = await prepareIdentitySigner("session");
|
|
249
300
|
let family: PreparedFamily;
|
|
250
301
|
try {
|
|
@@ -252,13 +303,14 @@ const issueJwtSession = async (c: Context, userId: string, options: SessionIssue
|
|
|
252
303
|
} catch (error) {
|
|
253
304
|
if (attempt === 0 && isSignerInactive(error)) {
|
|
254
305
|
invalidateIdentitySignerCache("session", signer.kid);
|
|
255
|
-
return issueJwtSession(c, userId, options, 1);
|
|
306
|
+
return issueJwtSession(c, userId, options, 1, committedNotice);
|
|
256
307
|
}
|
|
257
308
|
throw error;
|
|
258
309
|
}
|
|
259
310
|
const token = await signFamily(family);
|
|
260
|
-
|
|
261
|
-
if (
|
|
311
|
+
const notice = committedNotice ?? family.notice;
|
|
312
|
+
if (token) return { token, expiresAt: family.expiresAt, notice };
|
|
313
|
+
if (attempt === 0) return issueJwtSession(c, userId, { ...options, sid: undefined }, 1, notice);
|
|
262
314
|
throw new Error("Cloud session signer changed repeatedly during issuance");
|
|
263
315
|
};
|
|
264
316
|
|
|
@@ -281,7 +333,7 @@ const issueInTransaction = async <T>(
|
|
|
281
333
|
value = await sql.begin((tx) =>
|
|
282
334
|
work(tx, async (userId, options) => {
|
|
283
335
|
if (family) throw new Error("Only one session family per transaction");
|
|
284
|
-
family = await insertFamily(tx, c, userId, signer, options);
|
|
336
|
+
family = await insertFamily(tx, c, userId, signer, { ...options, knownBrowser: undefined });
|
|
285
337
|
return family.sid;
|
|
286
338
|
}),
|
|
287
339
|
);
|
|
@@ -294,7 +346,177 @@ const issueInTransaction = async <T>(
|
|
|
294
346
|
}
|
|
295
347
|
if (!family) return { value, token: null };
|
|
296
348
|
const token = await signFamily(family);
|
|
297
|
-
return { value, token: token ?? (await issueJwtSession(c, family.userId, { ...family.options, sid: undefined }, 1)) };
|
|
349
|
+
return { value, token: token ?? (await issueJwtSession(c, family.userId, { ...family.options, sid: undefined }, 1)).token };
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/** Revokes the account's active mobile app phones and records each phone. */
|
|
354
|
+
const revokePhones = async (
|
|
355
|
+
tx: SQL,
|
|
356
|
+
userId: string,
|
|
357
|
+
reason: "sign_out_everywhere" | "sign_out_others" | "password_change" | "password_reset",
|
|
358
|
+
): Promise<void> => {
|
|
359
|
+
const phones = await tx<Array<{ id: string }>>`
|
|
360
|
+
UPDATE auth.pwa_devices SET revoked_at = now(), revocation_reason = ${reason}
|
|
361
|
+
WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
|
|
362
|
+
`;
|
|
363
|
+
for (const phone of phones)
|
|
364
|
+
await audit.record(
|
|
365
|
+
{
|
|
366
|
+
action: "auth.pwa.device.revoke",
|
|
367
|
+
outcome: "allowed",
|
|
368
|
+
target: { type: "pwa_device", id: phone.id },
|
|
369
|
+
metadata: { targetUserId: userId, provenance: "system", reason },
|
|
370
|
+
},
|
|
371
|
+
tx,
|
|
372
|
+
);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/** Copies a web family to a new sid, epoch and signer; sign-in time, expiry, origin and the Cloud Login device
|
|
376
|
+
* that approved it stay the same, so removing that device still ends the copy. */
|
|
377
|
+
const copyFamily = async (tx: SQL, from: string, to: { sid: string; authEpoch: number; kid: string }): Promise<void> => {
|
|
378
|
+
await tx`
|
|
379
|
+
INSERT INTO auth.session_families (
|
|
380
|
+
sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending,
|
|
381
|
+
pwa_device_id, app_device_id
|
|
382
|
+
)
|
|
383
|
+
SELECT ${to.sid}::uuid, user_id, ${to.authEpoch}, ${to.kid}, issued_at, expires_at,
|
|
384
|
+
created_request_id, created_user_agent, legal_pending, pwa_device_id, app_device_id
|
|
385
|
+
FROM auth.session_families WHERE sid = ${from}::uuid
|
|
386
|
+
`;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/** Ends other sign-ins and moves the chosen browser to a new session id with its sign-in time and expiry.
|
|
390
|
+
* The epoch change ends the account's other browser sessions, its OAuth sign-ins and access tokens, and pending
|
|
391
|
+
* Cloud Login pairings and device changes; mobile app phones are revoked with an audit entry per phone. Unlike
|
|
392
|
+
* `revokeAllForUser`, Cloud Login devices stay paired. API keys, passkeys and mandates stay valid.
|
|
393
|
+
* `keep` is the browser sign-in the request authenticated, with the epoch it was authenticated at. The kept
|
|
394
|
+
* family is revoked and replaced, so every check by session id rejects old copies of its cookie, and a
|
|
395
|
+
* request that a concurrent change already ended keeps nothing.
|
|
396
|
+
* If preparing the signer fails or it stays inactive after one retry, ends all sign-ins without keeping a browser.
|
|
397
|
+
* If its signer changes after commit, retries once with a new family unless another sign-out intervened.
|
|
398
|
+
* `kept` is null when no browser was kept: no `keep`, its epoch is no longer current, the family was not
|
|
399
|
+
* a current active web session of the account, or signing it after the change failed. That browser must
|
|
400
|
+
* sign in again. */
|
|
401
|
+
const endOtherSignIns = async (
|
|
402
|
+
userId: string,
|
|
403
|
+
options: { keep?: { sid: string; authEpoch: number } | null; reason: "password_change" | "password_reset" | "sign_out_others" },
|
|
404
|
+
): Promise<{ kept: { token: string; expiresAt: Date } | null }> => {
|
|
405
|
+
const extendSigner = async (tx: SQL, signer: PreparedIdentitySigner, expiresAt: Date): Promise<void> => {
|
|
406
|
+
const [activeKey] = await tx<Array<{ kid: string }>>`
|
|
407
|
+
UPDATE auth.signing_keys
|
|
408
|
+
SET verify_until = GREATEST(verify_until, ${new Date(expiresAt.getTime() + IDENTITY_CLOCK_TOLERANCE_SECONDS * 1_000 + IDENTITY_ROLLOUT_MARGIN_MS)})
|
|
409
|
+
WHERE kid = ${signer.kid} AND purpose = 'session' AND state = 'active' AND sign_until > now()
|
|
410
|
+
RETURNING kid
|
|
411
|
+
`;
|
|
412
|
+
if (!activeKey) throw new Error(SIGNER_INACTIVE);
|
|
413
|
+
};
|
|
414
|
+
let keep = options.keep ?? null;
|
|
415
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
416
|
+
let signer: PreparedIdentitySigner | null = null;
|
|
417
|
+
if (keep) {
|
|
418
|
+
try {
|
|
419
|
+
signer = await prepareIdentitySigner("session");
|
|
420
|
+
} catch {
|
|
421
|
+
log.warn("Could not prepare session signer; ending all sign-ins", { userId });
|
|
422
|
+
keep = null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
let family: PreparedFamily | null;
|
|
426
|
+
try {
|
|
427
|
+
family = await sql.begin(async (tx): Promise<PreparedFamily | null> => {
|
|
428
|
+
const [user] = await tx<Array<{ auth_epoch: string | number | bigint }>>`
|
|
429
|
+
SELECT auth_epoch FROM auth.users WHERE id = ${userId}::uuid FOR NO KEY UPDATE
|
|
430
|
+
`;
|
|
431
|
+
if (!user) throw new Error("Cannot revoke sessions for an unknown user");
|
|
432
|
+
const [kept] =
|
|
433
|
+
keep && Number(user.auth_epoch) === keep.authEpoch
|
|
434
|
+
? // Locking the key orders this keep before a concurrent emergency key revocation. It is the
|
|
435
|
+
// lock extendSigner takes for the same current key, so concurrent keeps queue instead of deadlocking.
|
|
436
|
+
await tx<Array<{ sid: string; issued_at: Date; expires_at: Date }>>`
|
|
437
|
+
SELECT sf.sid, sf.issued_at, sf.expires_at FROM auth.session_families sf
|
|
438
|
+
JOIN auth.signing_keys sk ON sk.kid = sf.signing_kid AND sk.state <> 'revoked'
|
|
439
|
+
WHERE sf.sid = ${keep.sid}::uuid AND sf.user_id = ${userId}::uuid
|
|
440
|
+
AND sf.pwa_device_id IS NULL AND sf.revoked_at IS NULL AND sf.expires_at > now()
|
|
441
|
+
AND sf.auth_epoch = ${user.auth_epoch}
|
|
442
|
+
FOR UPDATE OF sf FOR NO KEY UPDATE OF sk
|
|
443
|
+
`
|
|
444
|
+
: [];
|
|
445
|
+
const authEpoch = await endSessions(tx, userId);
|
|
446
|
+
let prepared: PreparedFamily | null = null;
|
|
447
|
+
if (kept && signer) {
|
|
448
|
+
const issuedAt = new Date(kept.issued_at);
|
|
449
|
+
const expiresAt = new Date(kept.expires_at);
|
|
450
|
+
await extendSigner(tx, signer, expiresAt);
|
|
451
|
+
const sid = crypto.randomUUID();
|
|
452
|
+
await copyFamily(tx, kept.sid, { sid, authEpoch, kid: signer.kid });
|
|
453
|
+
await tx`
|
|
454
|
+
UPDATE auth.session_families SET revoked_at = now(), revocation_reason = 'rotated'
|
|
455
|
+
WHERE sid = ${kept.sid}::uuid
|
|
456
|
+
`;
|
|
457
|
+
prepared = {
|
|
458
|
+
userId,
|
|
459
|
+
sid,
|
|
460
|
+
authEpoch,
|
|
461
|
+
issuedAt,
|
|
462
|
+
expiresAt,
|
|
463
|
+
signer,
|
|
464
|
+
options: { ttlSeconds: (expiresAt.getTime() - issuedAt.getTime()) / 1_000, recordLogin: false },
|
|
465
|
+
notice: null,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
await revokePhones(tx, userId, options.reason);
|
|
469
|
+
return prepared;
|
|
470
|
+
});
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if (signer && isSignerInactive(error)) {
|
|
473
|
+
if (attempt === 0) {
|
|
474
|
+
invalidateIdentitySignerCache("session", signer.kid);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (attempt === 1) {
|
|
478
|
+
log.warn("Session signer stayed inactive; ending all sign-ins", { userId });
|
|
479
|
+
keep = null;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
if (!family) return { kept: null };
|
|
486
|
+
try {
|
|
487
|
+
const token = await signFamily(family);
|
|
488
|
+
if (token) return { kept: { token, expiresAt: family.expiresAt } };
|
|
489
|
+
const freshSigner = await prepareIdentitySigner("session");
|
|
490
|
+
const replacement = await sql.begin(async (tx): Promise<PreparedFamily | null> => {
|
|
491
|
+
const [user] = await tx<Array<{ auth_epoch: string | number | bigint }>>`
|
|
492
|
+
SELECT auth_epoch FROM auth.users WHERE id = ${userId}::uuid FOR NO KEY UPDATE
|
|
493
|
+
`;
|
|
494
|
+
if (!user || Number(user.auth_epoch) !== family.authEpoch) return null;
|
|
495
|
+
// Device removal can skip this already-revoked family, so the replacement must recheck its device.
|
|
496
|
+
const [revoked] = await tx<Array<{ sid: string }>>`
|
|
497
|
+
SELECT sf.sid FROM auth.session_families sf
|
|
498
|
+
WHERE sf.sid = ${family.sid}::uuid AND sf.user_id = ${userId}::uuid
|
|
499
|
+
AND sf.revocation_reason = 'signer_revoked_during_issuance'
|
|
500
|
+
AND (sf.app_device_id IS NULL OR EXISTS (
|
|
501
|
+
SELECT 1 FROM auth.app_devices d WHERE d.id = sf.app_device_id AND d.revoked_at IS NULL
|
|
502
|
+
)) FOR UPDATE
|
|
503
|
+
`;
|
|
504
|
+
if (!revoked) return null;
|
|
505
|
+
await extendSigner(tx, freshSigner, family.expiresAt);
|
|
506
|
+
const sid = crypto.randomUUID();
|
|
507
|
+
await copyFamily(tx, revoked.sid, { sid, authEpoch: family.authEpoch, kid: freshSigner.kid });
|
|
508
|
+
return { ...family, sid, signer: freshSigner };
|
|
509
|
+
});
|
|
510
|
+
if (!replacement) return { kept: null };
|
|
511
|
+
const replacementToken = await signFamily(replacement);
|
|
512
|
+
if (!replacementToken) throw new Error("Cloud session signer changed repeatedly during issuance");
|
|
513
|
+
return { kept: { token: replacementToken, expiresAt: replacement.expiresAt } };
|
|
514
|
+
} catch (error) {
|
|
515
|
+
log.warn("Could not keep browser signed in after ending other sign-ins", {
|
|
516
|
+
error: error instanceof Error ? error.message : String(error),
|
|
517
|
+
});
|
|
518
|
+
return { kept: null };
|
|
519
|
+
}
|
|
298
520
|
}
|
|
299
521
|
};
|
|
300
522
|
|
|
@@ -349,22 +571,67 @@ export const session = {
|
|
|
349
571
|
|
|
350
572
|
getBearerToken: (c: Context): string | null => parseBearer(c.req.header("Authorization")),
|
|
351
573
|
|
|
352
|
-
create: async (
|
|
574
|
+
create: async (
|
|
575
|
+
c: Context,
|
|
576
|
+
userId: string,
|
|
577
|
+
signIn: {
|
|
578
|
+
method: SignInMethod;
|
|
579
|
+
device?: string | null;
|
|
580
|
+
/** Null means this sign-in announces nothing, including during maintenance. */
|
|
581
|
+
notify: NewBrowserSignInNotifier | null;
|
|
582
|
+
/** Account epoch an earlier approval was bound to; issuance fails when sign-out everywhere moved it. */
|
|
583
|
+
authEpoch?: number;
|
|
584
|
+
/** The Cloud Login device that approved this sign-in; removing the device can end the session. */
|
|
585
|
+
appDeviceId?: string;
|
|
586
|
+
},
|
|
587
|
+
): Promise<string> => {
|
|
353
588
|
const expiryHours = await settings.get<number>("user.session.expiry_hours");
|
|
354
589
|
if (!Number.isFinite(expiryHours) || expiryHours < 1) throw new Error("user.session.expiry_hours must be a positive number");
|
|
355
590
|
const ttlSeconds = Math.floor(expiryHours * 60 * 60);
|
|
356
|
-
const
|
|
591
|
+
const cookie = readBrowserCookie(c) ?? newBrowserCookie();
|
|
592
|
+
const { browser, os, country, city } = describeRequest(c);
|
|
593
|
+
const {
|
|
594
|
+
token: clientToken,
|
|
595
|
+
expiresAt,
|
|
596
|
+
notice,
|
|
597
|
+
} = await issueJwtSession(c, userId, {
|
|
598
|
+
ttlSeconds,
|
|
599
|
+
authEpoch: signIn.authEpoch,
|
|
600
|
+
appDeviceId: signIn.appDeviceId,
|
|
601
|
+
knownBrowser: {
|
|
602
|
+
cookie,
|
|
603
|
+
browser,
|
|
604
|
+
os,
|
|
605
|
+
country,
|
|
606
|
+
city,
|
|
607
|
+
method: signIn.method,
|
|
608
|
+
device: signIn.device ?? null,
|
|
609
|
+
locale: getLocale(c),
|
|
610
|
+
announce: signIn.notify !== null,
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
// Handoff precedes the response so the person signing in cannot turn the notice off first.
|
|
614
|
+
if (signIn.notify !== null && notice) await announceNewBrowser(signIn.notify, notice);
|
|
357
615
|
|
|
358
|
-
|
|
616
|
+
session.setWebCookie(c, clientToken, expiresAt);
|
|
617
|
+
setBrowserCookie(c, cookie);
|
|
618
|
+
return clientToken;
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
/** Sets the `session_token` cookie the way `create` does. Use it for the token that `endOtherSignIns` keeps:
|
|
622
|
+
* the max age runs to `expiresAt`, so that browser keeps its original expiry. */
|
|
623
|
+
setWebCookie: (c: Context, token: string, expiresAt: Date): void => {
|
|
624
|
+
setCookie(c, "session_token", token, {
|
|
359
625
|
httpOnly: true,
|
|
360
626
|
secure: !env.IS_DEVELOPMENT,
|
|
361
627
|
sameSite: "Lax",
|
|
362
|
-
maxAge:
|
|
628
|
+
maxAge: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1_000)),
|
|
363
629
|
path: "/",
|
|
364
630
|
});
|
|
365
|
-
return clientToken;
|
|
366
631
|
},
|
|
367
632
|
|
|
633
|
+
endOtherSignIns,
|
|
634
|
+
|
|
368
635
|
revoke: revokeToken,
|
|
369
636
|
|
|
370
637
|
/** Signs out the web session (or an explicit bearer session). The mobile app's session is unaffected. */
|
|
@@ -376,25 +643,12 @@ export const session = {
|
|
|
376
643
|
|
|
377
644
|
issueInTransaction,
|
|
378
645
|
|
|
379
|
-
/** Signs the account out everywhere: every session, every paired sign-in device and every paired phone, audited per device.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
UPDATE auth.pwa_devices SET revoked_at = now(), revocation_reason = 'sign_out_everywhere'
|
|
386
|
-
WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
|
|
387
|
-
`;
|
|
388
|
-
for (const phone of phones)
|
|
389
|
-
await audit.record(
|
|
390
|
-
{
|
|
391
|
-
action: "auth.pwa.device.revoke",
|
|
392
|
-
outcome: "allowed",
|
|
393
|
-
target: { type: "pwa_device", id: phone.id },
|
|
394
|
-
metadata: { targetUserId: userId, provenance: "system", reason: "sign_out_everywhere" },
|
|
395
|
-
},
|
|
396
|
-
tx,
|
|
397
|
-
);
|
|
646
|
+
/** Signs the account out everywhere: every session, every paired sign-in device and every paired phone, audited per device.
|
|
647
|
+
* Returns the new authentication epoch so a following sign-in can stay bound to this sign-out. */
|
|
648
|
+
revokeAllForUser: async (userId: string): Promise<number> => {
|
|
649
|
+
return sql.begin(async (tx) => {
|
|
650
|
+
const authEpoch = await endSessions(tx, userId);
|
|
651
|
+
await revokePhones(tx, userId, "sign_out_everywhere");
|
|
398
652
|
// A paired device approves new sign-ins on its own, so a lost one would outlive the epoch change.
|
|
399
653
|
const devices = await tx<Array<{ id: string }>>`
|
|
400
654
|
UPDATE auth.app_devices SET revoked_at = now() WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
|
|
@@ -409,6 +663,7 @@ export const session = {
|
|
|
409
663
|
},
|
|
410
664
|
tx,
|
|
411
665
|
);
|
|
666
|
+
return authEpoch;
|
|
412
667
|
});
|
|
413
668
|
},
|
|
414
669
|
|
|
@@ -11,6 +11,15 @@ export type EligibleAccount = {
|
|
|
11
11
|
account_expires: Date | null;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
/** New personal access requires a browser sign-in from the last ten minutes. */
|
|
15
|
+
export const LASTING_ACCESS_SIGN_IN_SECONDS = 600;
|
|
16
|
+
|
|
17
|
+
export class ConfirmedBrowserSignInError extends Error {
|
|
18
|
+
constructor(readonly code: "REAUTHENTICATE" | "FORBIDDEN") {
|
|
19
|
+
super(code === "REAUTHENTICATE" ? "Sign in again to confirm it is you." : "This account cannot create new access.");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
type Reject = (code: "REAUTHENTICATE" | "FORBIDDEN") => never;
|
|
15
24
|
|
|
16
25
|
/** The account exists, has not expired and its category is enabled. */
|
|
@@ -40,3 +49,7 @@ export const requireRecentWebSession = async (
|
|
|
40
49
|
if (!row) return reject("REAUTHENTICATE");
|
|
41
50
|
return requireEligibleAccount(tx, actor.userId, reject);
|
|
42
51
|
};
|
|
52
|
+
|
|
53
|
+
/** Checks the browser session inside the transaction that creates personal access. */
|
|
54
|
+
export const requireConfirmedBrowserSignIn = (tx: SQL, signIn: { userId: string; sid: string }, reject: Reject): Promise<EligibleAccount> =>
|
|
55
|
+
requireRecentWebSession(tx, signIn, LASTING_ACCESS_SIGN_IN_SECONDS, reject);
|
package/src/services/webauthn.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { accounts } from "./accounts";
|
|
|
16
16
|
import { audit } from "./audit";
|
|
17
17
|
import { logger } from "./logging";
|
|
18
18
|
import { isUniqueViolation, toPgTextArray } from "./postgres";
|
|
19
|
+
import { ConfirmedBrowserSignInError, requireConfirmedBrowserSignIn } from "./session/recent";
|
|
19
20
|
import { coreSettings } from "./settings/api";
|
|
20
21
|
|
|
21
22
|
const CHALLENGE_TTL_SECONDS = 300;
|
|
@@ -185,6 +186,7 @@ export const beginRegistration = async (params: { user: User }): Promise<Result<
|
|
|
185
186
|
|
|
186
187
|
export const finishRegistration = async (params: {
|
|
187
188
|
user: User;
|
|
189
|
+
browserSignIn: { userId: string; sid: string };
|
|
188
190
|
name: string;
|
|
189
191
|
response: RegistrationResponseJSON;
|
|
190
192
|
}): Promise<Result<WebAuthnPasskey>> => {
|
|
@@ -221,6 +223,16 @@ export const finishRegistration = async (params: {
|
|
|
221
223
|
|
|
222
224
|
try {
|
|
223
225
|
return sql.begin(async (tx) => {
|
|
226
|
+
if (params.browserSignIn.userId !== params.user.id) return fail(err.forbidden("The browser sign-in belongs to another account."));
|
|
227
|
+
try {
|
|
228
|
+
await requireConfirmedBrowserSignIn(tx, params.browserSignIn, (code) => {
|
|
229
|
+
throw new ConfirmedBrowserSignInError(code);
|
|
230
|
+
});
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error instanceof ConfirmedBrowserSignInError) return fail({ code: error.code, status: 403, message: error.message });
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
|
|
224
236
|
const [row] = await tx<DbPasskeyRow[]>`
|
|
225
237
|
INSERT INTO auth.webauthn_credentials (
|
|
226
238
|
user_id,
|
package/src/shared/email-html.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const EMAIL_HTML_TAGS = [
|
|
|
10
10
|
"h1",
|
|
11
11
|
"h2",
|
|
12
12
|
"h3",
|
|
13
|
+
"hr",
|
|
13
14
|
"li",
|
|
14
15
|
"ol",
|
|
15
16
|
"p",
|
|
@@ -33,6 +34,7 @@ export const EMAIL_HTML_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
|
|
33
34
|
h1: ["style"],
|
|
34
35
|
h2: ["style"],
|
|
35
36
|
h3: ["style"],
|
|
37
|
+
hr: ["style"],
|
|
36
38
|
li: ["style"],
|
|
37
39
|
ol: ["style"],
|
|
38
40
|
p: ["style"],
|
package/src/shared/help.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { localeFallbackChain } from "./locale";
|
|
2
2
|
|
|
3
|
+
/** Maximum number of page-specific Help articles shared with the panel. */
|
|
4
|
+
export const HELP_VIEW_LIMIT = 3;
|
|
5
|
+
|
|
3
6
|
export const HELP_KINDS = ["start", "task", "reference", "troubleshoot"] as const;
|
|
4
7
|
export type HelpKind = (typeof HELP_KINDS)[number];
|
|
5
8
|
/** The glossary's access levels; Help names only these, never app-specific permissions. */
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const STREAM_CURSOR_REGEX = /^s6t\.[A-Za-z0-9_-]+\.\d+$/;
|
|
2
|
+
|
|
3
|
+
export const parseStreamCursor = (cursor: string | null | undefined): { resource: string; seq: number } | null => {
|
|
4
|
+
if (!cursor || !STREAM_CURSOR_REGEX.test(cursor)) return null;
|
|
5
|
+
const [, resource, sequence] = cursor.split(".");
|
|
6
|
+
const seq = Number(sequence);
|
|
7
|
+
if (!resource || !Number.isSafeInteger(seq) || seq < 0) return null;
|
|
8
|
+
return { resource, seq };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const compareStreamCursor = (left: string, right: string): number => {
|
|
12
|
+
const l = parseStreamCursor(left);
|
|
13
|
+
const r = parseStreamCursor(right);
|
|
14
|
+
if (!l || !r || l.resource !== r.resource) throw new Error(`Invalid stream cursor comparison: "${left}" vs "${right}"`);
|
|
15
|
+
return l.seq - r.seq;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const maxStreamCursor = (a: string | null, b: string | null): string | null => {
|
|
19
|
+
if (!a) return b;
|
|
20
|
+
if (!b) return a;
|
|
21
|
+
return compareStreamCursor(a, b) >= 0 ? a : b;
|
|
22
|
+
};
|