@k2b/cloud 0.39.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 +8 -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 +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +36 -15
- package/src/_internal/font-preloads.ts +2 -1
- 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.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 +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/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 +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 +190 -39
- package/src/ai/index.ts +2 -0
- package/src/ai/migrate.ts +11 -2
- 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/routes.ts +145 -1
- 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 +227 -24
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +24 -2
- package/src/ai/types.ts +28 -3
- 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.ts +46 -5
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +2 -0
- 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 +6 -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/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 +120 -13
- 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/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/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +7 -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 +11 -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/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/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/Layout.tsx +25 -2
- 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/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/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 +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +2 -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 +53 -4
- package/src/ssr/platform-messages.ts +18 -0
- package/src/ssr/shell-data.tsx +2 -1
- package/src/styles/effects.css +198 -9
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +49 -12
- package/src/styles/panel.css +47 -4
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +17 -11
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +17 -22
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
|
+
import type { z } from "zod";
|
|
2
3
|
import { lazySync } from "../_internal/process-sync";
|
|
3
4
|
import { env } from "../config/env";
|
|
4
5
|
import { type AccountCategory, accountCategory } from "../contracts/account-categories";
|
|
@@ -9,20 +10,30 @@ import {
|
|
|
9
10
|
APP_APPROVAL_PROTOCOL,
|
|
10
11
|
type AppActionSummary,
|
|
11
12
|
type AppActionView,
|
|
13
|
+
type AppApprovalAccept,
|
|
14
|
+
type AppDeviceChange,
|
|
15
|
+
type AppDeviceChangeStatus,
|
|
12
16
|
type AppDevicePublicKey,
|
|
13
17
|
AppDevicePublicKeySchema,
|
|
14
18
|
type AppDeviceRequest,
|
|
19
|
+
type AppDeviceRevokeResultSchema,
|
|
15
20
|
type AppDeviceView,
|
|
21
|
+
AppLoginContextSchema,
|
|
22
|
+
type AppPairingConfirmResultSchema,
|
|
16
23
|
type AppPendingLogin,
|
|
17
24
|
appDeviceProofMessage,
|
|
18
25
|
appPairingProofMessage,
|
|
19
26
|
APP_APPROVAL_LIMITS as limits,
|
|
20
27
|
} from "../contracts/app-approval";
|
|
28
|
+
import { addressPrefix } from "../server/request-context/address";
|
|
21
29
|
import { publicCloudOrigin } from "../shared/app-url";
|
|
22
30
|
import { isAccountCategoryAllowed } from "./account-category-policy";
|
|
23
31
|
import { appApprovalText } from "./approvals/device";
|
|
24
32
|
import { audit } from "./audit";
|
|
33
|
+
import { countryHistory, findBrowser, trustState } from "./known-browsers";
|
|
25
34
|
import { logger } from "./logging";
|
|
35
|
+
import { type AppLoginAddressStore, appLoginAddressMatches, appLoginAddressProof, valkeyAppLoginAddressStore } from "./login-address-store";
|
|
36
|
+
import { type AppLoginRequestContext, buildLoginContext, trimAppLoginResponse } from "./login-context";
|
|
26
37
|
import { pairingSecret } from "./pairing-secret";
|
|
27
38
|
import { toPgTextArray } from "./postgres";
|
|
28
39
|
import { requireEligibleAccount, requireRecentWebSession } from "./session/recent";
|
|
@@ -130,7 +141,30 @@ type PairingRow = {
|
|
|
130
141
|
device_id: string | null;
|
|
131
142
|
comparison: string | null;
|
|
132
143
|
};
|
|
144
|
+
type ChangeRow = {
|
|
145
|
+
id: string;
|
|
146
|
+
issuer: string;
|
|
147
|
+
user_id: string;
|
|
148
|
+
kind: "pair" | "revoke";
|
|
149
|
+
pairing_id: string | null;
|
|
150
|
+
device_id: string | null;
|
|
151
|
+
end_sessions: boolean;
|
|
152
|
+
requested_by_sid: string;
|
|
153
|
+
auth_epoch: string;
|
|
154
|
+
state: "pending" | "approved" | "denied" | "cancelled";
|
|
155
|
+
created_at: Date;
|
|
156
|
+
expires_at: Date;
|
|
157
|
+
};
|
|
158
|
+
const changeStatus = (row: ChangeRow): AppDeviceChangeStatus => ({
|
|
159
|
+
state: row.state === "pending" && new Date(row.expires_at).getTime() <= Date.now() ? "expired" : row.state,
|
|
160
|
+
expiresAt: iso(row.expires_at),
|
|
161
|
+
});
|
|
162
|
+
const changeName = (name: string) =>
|
|
163
|
+
Array.from(name.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ""))
|
|
164
|
+
.slice(0, 80)
|
|
165
|
+
.join("");
|
|
133
166
|
type LoginRow = {
|
|
167
|
+
context: unknown;
|
|
134
168
|
kind: "browser" | "terminal";
|
|
135
169
|
device_authorization_id: string | null;
|
|
136
170
|
client_id: string | null;
|
|
@@ -189,7 +223,7 @@ export type AppTerminalLogins = {
|
|
|
189
223
|
input: {
|
|
190
224
|
deviceAuthorizationId: string;
|
|
191
225
|
clientId: string;
|
|
192
|
-
user: Pick<AccountRow, "id" | "uid" | "provider" | "profile">;
|
|
226
|
+
user: Pick<AccountRow, "id" | "uid" | "provider" | "profile" | "auth_epoch">;
|
|
193
227
|
decision: "approve" | "deny";
|
|
194
228
|
},
|
|
195
229
|
): Promise<boolean>;
|
|
@@ -262,7 +296,25 @@ export const createAppApprovalService = (
|
|
|
262
296
|
configuration = (requireEnabled = true) => readAppApprovalConfig(db, requireEnabled),
|
|
263
297
|
hints: AppLoginDecisionHints = syncLoginDecisionHints,
|
|
264
298
|
push: AppLoginPushSender = fetchPushSender,
|
|
299
|
+
addresses: AppLoginAddressStore = valkeyAppLoginAddressStore,
|
|
265
300
|
) => {
|
|
301
|
+
// Error messages may contain their inputs; log only the error class, never addresses or proofs.
|
|
302
|
+
const addressFailure = (error: unknown) =>
|
|
303
|
+
log.warn("App login address hint unavailable", { error: error instanceof Error ? error.name : "UnknownError" });
|
|
304
|
+
const addSameNetwork = async (logins: AppPendingLogin[], address?: string) => {
|
|
305
|
+
const prefix = addressPrefix(address ?? "unknown");
|
|
306
|
+
const browserLogins = logins.filter((login) => !login.terminal && login.context);
|
|
307
|
+
if (!prefix || !browserLogins.length) return;
|
|
308
|
+
try {
|
|
309
|
+
const proofs = await addresses.read(browserLogins.map((login) => login.requestId));
|
|
310
|
+
for (const login of browserLogins) {
|
|
311
|
+
const proof = proofs.get(login.requestId);
|
|
312
|
+
if (proof && login.context && appLoginAddressMatches(proof, prefix)) login.context.sameNetwork = true;
|
|
313
|
+
}
|
|
314
|
+
} catch (error) {
|
|
315
|
+
addressFailure(error);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
266
318
|
const config = async (requireEnabled = true) => {
|
|
267
319
|
const value = await configuration(requireEnabled);
|
|
268
320
|
if (requireEnabled && (!value.enabled || !value.appOrigin)) return reject("UNAVAILABLE", 503);
|
|
@@ -276,12 +328,14 @@ export const createAppApprovalService = (
|
|
|
276
328
|
const cleanup = async () => {
|
|
277
329
|
// Bounded opportunistic maintenance: each accepted write retires up to 100
|
|
278
330
|
// expired rows per table, so bursts cannot trigger an unbounded sweep.
|
|
279
|
-
await db`DELETE FROM auth.
|
|
331
|
+
await db`DELETE FROM auth.app_device_changes WHERE id IN (SELECT id FROM auth.app_device_changes WHERE expires_at < now() - interval '1 minute' ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
332
|
+
await db`DELETE FROM auth.app_pairings WHERE id IN (SELECT p.id FROM auth.app_pairings p
|
|
333
|
+
WHERE p.expires_at < now() - interval '1 minute' AND NOT EXISTS (SELECT 1 FROM auth.app_device_changes c WHERE c.pairing_id=p.id)
|
|
334
|
+
ORDER BY p.expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
280
335
|
await db`DELETE FROM auth.app_logins WHERE id IN (SELECT id FROM auth.app_logins WHERE expires_at < now() AND NOT (kind='terminal' AND state='approved') ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
281
336
|
await db`DELETE FROM auth.app_device_proofs WHERE (device_id,jti) IN (SELECT device_id,jti FROM auth.app_device_proofs WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
282
337
|
};
|
|
283
|
-
const
|
|
284
|
-
await fresh(tx, actor);
|
|
338
|
+
const pairingOwner = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
|
|
285
339
|
if (
|
|
286
340
|
pairing.initiated_by !== actor.userId ||
|
|
287
341
|
pairing.initiator_sid !== actor.sid ||
|
|
@@ -290,6 +344,10 @@ export const createAppApprovalService = (
|
|
|
290
344
|
return reject("FORBIDDEN", 403);
|
|
291
345
|
return eligible(tx, pairing.user_id);
|
|
292
346
|
};
|
|
347
|
+
const authorizePairing = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
|
|
348
|
+
await fresh(tx, actor);
|
|
349
|
+
return pairingOwner(tx, actor, pairing, cfg);
|
|
350
|
+
};
|
|
293
351
|
const activeDevice = async (tx: SQL, id: string, issuer: string): Promise<DeviceRow> => {
|
|
294
352
|
const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices WHERE id = ${id}::uuid AND issuer = ${issuer} FOR UPDATE`;
|
|
295
353
|
if (!device || device.revoked_at) return reject("FORBIDDEN", 403);
|
|
@@ -297,17 +355,65 @@ export const createAppApprovalService = (
|
|
|
297
355
|
return device;
|
|
298
356
|
};
|
|
299
357
|
|
|
358
|
+
const enrollPairing = async (tx: SQL, p: PairingRow, issuer: string) => {
|
|
359
|
+
if (!p.device_id || !p.name) return reject("CONFLICT", 409);
|
|
360
|
+
const [count] = await tx<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_devices
|
|
361
|
+
WHERE issuer=${issuer} AND user_id=${p.user_id}::uuid AND revoked_at IS NULL`;
|
|
362
|
+
if ((count?.count ?? 0) >= limits.devicesPerAccount) return reject("LIMIT_REACHED", 429);
|
|
363
|
+
const key = AppDevicePublicKeySchema.parse(p.public_key);
|
|
364
|
+
const existing =
|
|
365
|
+
await tx`SELECT id FROM auth.app_devices WHERE issuer=${issuer} AND user_id=${p.user_id}::uuid AND public_key=${JSON.stringify(key)}::text::jsonb`;
|
|
366
|
+
if (existing.length) return reject("CONFLICT", 409);
|
|
367
|
+
await tx`INSERT INTO auth.app_devices(id,issuer,user_id,name,public_key,assisted,enrolled_by)
|
|
368
|
+
VALUES (${p.device_id}::uuid,${issuer},${p.user_id}::uuid,${p.name},${JSON.stringify(key)}::text::jsonb,${p.assisted},${p.initiated_by}::uuid)`;
|
|
369
|
+
await tx`UPDATE auth.app_pairings SET state='confirmed' WHERE id=${p.id}::uuid`;
|
|
370
|
+
await record(tx, "device.enroll", p.initiated_by, p.device_id, { targetUserId: p.user_id, assisted: p.assisted });
|
|
371
|
+
};
|
|
372
|
+
const createChange = async (
|
|
373
|
+
tx: SQL,
|
|
374
|
+
cfg: AppApprovalConfig,
|
|
375
|
+
actor: AppApprovalActor,
|
|
376
|
+
epoch: string,
|
|
377
|
+
kind: ChangeRow["kind"],
|
|
378
|
+
targetId: string,
|
|
379
|
+
pairingId: string | null,
|
|
380
|
+
endSessions: boolean,
|
|
381
|
+
) => {
|
|
382
|
+
const [count] = await tx<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_device_changes
|
|
383
|
+
WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND state='pending' AND expires_at>now()`;
|
|
384
|
+
if ((count?.count ?? 0) >= limits.pendingPerAccount) return reject("LIMIT_REACHED", 429);
|
|
385
|
+
const id = crypto.randomUUID(),
|
|
386
|
+
expiresAt = future(limits.loginSeconds);
|
|
387
|
+
const [row] = await tx<ChangeRow[]>`INSERT INTO auth.app_device_changes
|
|
388
|
+
(id,issuer,user_id,kind,pairing_id,device_id,end_sessions,requested_by_sid,auth_epoch,expires_at)
|
|
389
|
+
VALUES (${id}::uuid,${cfg.issuer},${actor.userId}::uuid,${kind},${pairingId}::uuid,
|
|
390
|
+
${kind === "revoke" ? targetId : null}::uuid,${endSessions},${actor.sid}::uuid,${epoch},${expiresAt}) RETURNING *`;
|
|
391
|
+
// Audit metadata redacts keys that name sessions, so the choice is recorded as `endSignIns`.
|
|
392
|
+
await record(tx, "device_change.request", actor.userId, targetId, { changeId: id, kind, endSignIns: endSessions });
|
|
393
|
+
return row!;
|
|
394
|
+
};
|
|
395
|
+
const ownedChange = async (tx: SQL, cfg: AppApprovalConfig, actor: AppApprovalActor, id: string) => {
|
|
396
|
+
const [row] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes
|
|
397
|
+
WHERE id=${id}::uuid AND issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND requested_by_sid=${actor.sid}::uuid`;
|
|
398
|
+
if (!row) return reject("UNAVAILABLE", 404);
|
|
399
|
+
return row;
|
|
400
|
+
};
|
|
401
|
+
|
|
300
402
|
/** Runs after the login commit and never delays or fails it. The body names only
|
|
301
403
|
* this Cloud and the request id; a 410 means the phone's subscription is gone. */
|
|
302
|
-
const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string, options?: {
|
|
404
|
+
const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string, options?: { accept: AppApprovalAccept }) => {
|
|
405
|
+
if (!cfg.enabled || !cfg.appOrigin) return;
|
|
303
406
|
try {
|
|
407
|
+
// A withdrawn device change keeps its window, so withdrawing and asking again cannot push without bound.
|
|
304
408
|
const rows = await db<{ push_token: string }[]>`
|
|
305
409
|
UPDATE auth.app_devices d SET login_wake_at=now(), login_wake_ref=${requestId}::uuid
|
|
306
410
|
WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL
|
|
307
|
-
AND (${options?.
|
|
411
|
+
AND (${options?.accept ?? null}::text IS NULL OR ${options?.accept ?? null}::text=ANY(d.accepts))
|
|
308
412
|
AND (d.login_wake_at IS NULL OR d.login_wake_at <= now() - ${WAKE_SECONDS} * interval '1 second'
|
|
309
|
-
OR NOT EXISTS (SELECT 1 FROM auth.app_logins w
|
|
310
|
-
WHERE w.id=d.login_wake_ref AND w.state='pending' AND w.expires_at > now())
|
|
413
|
+
OR (NOT EXISTS (SELECT 1 FROM auth.app_logins w
|
|
414
|
+
WHERE w.id=d.login_wake_ref AND w.state='pending' AND w.expires_at > now())
|
|
415
|
+
AND NOT EXISTS (SELECT 1 FROM auth.app_device_changes w
|
|
416
|
+
WHERE w.id=d.login_wake_ref AND w.state IN ('pending','cancelled') AND w.expires_at > now())))
|
|
311
417
|
RETURNING push_token`;
|
|
312
418
|
await Promise.all(
|
|
313
419
|
Array.from(new Set(rows.map((row) => row.push_token)), async (token) => {
|
|
@@ -448,7 +554,9 @@ export const createAppApprovalService = (
|
|
|
448
554
|
pairingResult: async (id: string, token: string, key: AppDevicePublicKey) => {
|
|
449
555
|
const cfg = await config();
|
|
450
556
|
const [p] = await db<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
|
|
451
|
-
if (!p || !matches(token, p.secret_hash)
|
|
557
|
+
if (!p || !matches(token, p.secret_hash)) return reject("FORBIDDEN", 403);
|
|
558
|
+
const age = Date.now() - new Date(p.expires_at).getTime();
|
|
559
|
+
if (age >= 0 && !(age < 60_000 && (p.state === "confirmed" || p.state === "cancelled"))) return reject("FORBIDDEN", 403);
|
|
452
560
|
if (p.public_key) {
|
|
453
561
|
const claimed = AppDevicePublicKeySchema.parse(p.public_key);
|
|
454
562
|
if (claimed.x !== key.x || claimed.y !== key.y) return reject("FORBIDDEN", 403);
|
|
@@ -459,16 +567,33 @@ export const createAppApprovalService = (
|
|
|
459
567
|
const cfg = await config();
|
|
460
568
|
return db.begin(async (tx) => {
|
|
461
569
|
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
|
|
462
|
-
if (!p
|
|
570
|
+
if (!p) return reject("UNAVAILABLE", 404);
|
|
571
|
+
const [change] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes WHERE pairing_id=${p.id}::uuid`;
|
|
572
|
+
const age = Date.now() - new Date(p.expires_at).getTime();
|
|
573
|
+
if (age >= 0 && !(age < 60_000 && (p.state === "confirmed" || change !== undefined))) return reject("UNAVAILABLE", 404);
|
|
463
574
|
// A pairing from before the account was signed out everywhere reads as expired.
|
|
464
|
-
|
|
465
|
-
|
|
575
|
+
const user = await (change ? pairingOwner(tx, actor, p, cfg) : authorizePairing(tx, actor, p, cfg));
|
|
576
|
+
if (user.auth_epoch !== p.auth_epoch) return reject("UNAVAILABLE", 404);
|
|
577
|
+
return {
|
|
578
|
+
state: p.state,
|
|
579
|
+
name: p.name,
|
|
580
|
+
comparison: p.comparison,
|
|
581
|
+
deviceId: p.device_id,
|
|
582
|
+
userId: p.user_id,
|
|
583
|
+
approval: change ? changeStatus(change) : null,
|
|
584
|
+
};
|
|
466
585
|
});
|
|
467
586
|
},
|
|
468
|
-
confirmPairing: async (actor: AppApprovalActor, id: string, code: string) => {
|
|
587
|
+
confirmPairing: async (actor: AppApprovalActor, id: string, code: string): Promise<z.infer<typeof AppPairingConfirmResultSchema>> => {
|
|
469
588
|
const cfg = await config();
|
|
470
|
-
|
|
471
|
-
|
|
589
|
+
let wakeId: string | undefined;
|
|
590
|
+
const result = await db.begin(async (tx) => {
|
|
591
|
+
const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
592
|
+
if (!snapshot) return reject("CONFLICT", 409);
|
|
593
|
+
await tx`SELECT id FROM auth.users WHERE id=${snapshot.user_id}::uuid FOR NO KEY UPDATE`;
|
|
594
|
+
await tx`SELECT id FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${snapshot.user_id}::uuid AND revoked_at IS NULL ORDER BY id FOR UPDATE`;
|
|
595
|
+
const [change] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes WHERE pairing_id=${id}::uuid FOR UPDATE`;
|
|
596
|
+
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
|
|
472
597
|
if (
|
|
473
598
|
!p ||
|
|
474
599
|
p.state !== "claimed" ||
|
|
@@ -478,36 +603,66 @@ export const createAppApprovalService = (
|
|
|
478
603
|
!p.name
|
|
479
604
|
)
|
|
480
605
|
return reject("CONFLICT", 409);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
if (
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
await tx
|
|
496
|
-
|
|
497
|
-
return { deviceId: p.device_id };
|
|
606
|
+
const user = await authorizePairing(tx, actor, p, cfg);
|
|
607
|
+
if (user.auth_epoch !== p.auth_epoch) return reject("CONFLICT", 409);
|
|
608
|
+
if (change) {
|
|
609
|
+
if (changeStatus(change).state !== "pending") return reject("CONFLICT", 409);
|
|
610
|
+
return { deviceId: p.device_id, approval: changeStatus(change) };
|
|
611
|
+
}
|
|
612
|
+
const [active] =
|
|
613
|
+
await tx`SELECT id FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${p.user_id}::uuid AND revoked_at IS NULL LIMIT 1`;
|
|
614
|
+
if (!p.assisted && active) {
|
|
615
|
+
const requested = await createChange(tx, cfg, actor, user.auth_epoch, "pair", p.device_id, p.id, false);
|
|
616
|
+
await tx`UPDATE auth.app_pairings SET expires_at=GREATEST(expires_at,${requested.expires_at}) WHERE id=${p.id}::uuid`;
|
|
617
|
+
wakeId = requested.id;
|
|
618
|
+
return { deviceId: p.device_id, approval: changeStatus(requested) };
|
|
619
|
+
}
|
|
620
|
+
await enrollPairing(tx, p, cfg.issuer);
|
|
621
|
+
return { deviceId: p.device_id, approval: null };
|
|
498
622
|
});
|
|
623
|
+
if (wakeId) void wake(cfg, actor.userId, wakeId, { accept: "device-changes" });
|
|
624
|
+
return result;
|
|
499
625
|
},
|
|
500
626
|
cancelPairing: async (actor: AppApprovalActor, id: string) => {
|
|
501
627
|
const cfg = await config();
|
|
502
628
|
return db.begin(async (tx) => {
|
|
503
|
-
const [
|
|
629
|
+
const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
630
|
+
if (!snapshot) return reject("UNAVAILABLE", 404);
|
|
631
|
+
await tx`SELECT id FROM auth.users WHERE id=${snapshot.user_id}::uuid FOR NO KEY UPDATE`;
|
|
632
|
+
const [change] = await tx`SELECT id FROM auth.app_device_changes WHERE pairing_id=${id}::uuid FOR UPDATE`;
|
|
633
|
+
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
|
|
504
634
|
if (!p) return reject("UNAVAILABLE", 404);
|
|
505
|
-
await
|
|
635
|
+
if (change) await pairingOwner(tx, actor, p, cfg);
|
|
636
|
+
else await authorizePairing(tx, actor, p, cfg);
|
|
506
637
|
if (p.state === "confirmed") return reject("CONFLICT", 409);
|
|
638
|
+
await tx`UPDATE auth.app_device_changes SET state='cancelled' WHERE pairing_id=${id}::uuid AND state='pending'`;
|
|
507
639
|
await tx`UPDATE auth.app_pairings SET state='cancelled' WHERE id=${id}::uuid`;
|
|
508
640
|
await record(tx, "pairing.cancel", actor.userId, id);
|
|
509
641
|
});
|
|
510
642
|
},
|
|
643
|
+
/** This session's newest removal of a device, so a browser that lost the answer can follow or withdraw it. */
|
|
644
|
+
findRemoval: async (actor: AppApprovalActor, lookup: { changeId: string } | { deviceId: string }) => {
|
|
645
|
+
const cfg = await config(false);
|
|
646
|
+
const changeId = "changeId" in lookup ? lookup.changeId : null,
|
|
647
|
+
deviceId = "deviceId" in lookup ? lookup.deviceId : null;
|
|
648
|
+
const [row] = await db<ChangeRow[]>`SELECT * FROM auth.app_device_changes
|
|
649
|
+
WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND requested_by_sid=${actor.sid}::uuid AND kind='revoke'
|
|
650
|
+
AND (${changeId}::uuid IS NULL OR id=${changeId}::uuid) AND (${deviceId}::uuid IS NULL OR device_id=${deviceId}::uuid)
|
|
651
|
+
ORDER BY created_at DESC, id DESC LIMIT 1`;
|
|
652
|
+
if (!row) return reject("UNAVAILABLE", 404);
|
|
653
|
+
return { changeId: row.id, endSessions: row.end_sessions, ...changeStatus(row) };
|
|
654
|
+
},
|
|
655
|
+
cancelChange: async (actor: AppApprovalActor, id: string) => {
|
|
656
|
+
const cfg = await config(false);
|
|
657
|
+
return db.begin(async (tx) => {
|
|
658
|
+
await tx`SELECT id FROM auth.users WHERE id=${actor.userId}::uuid FOR NO KEY UPDATE`;
|
|
659
|
+
const change = await ownedChange(tx, cfg, actor, id);
|
|
660
|
+
if (changeStatus(change).state !== "pending") return reject("CONFLICT", 409);
|
|
661
|
+
await tx`UPDATE auth.app_device_changes SET state='cancelled' WHERE id=${id}::uuid`;
|
|
662
|
+
if (change.pairing_id)
|
|
663
|
+
await tx`UPDATE auth.app_pairings SET state='cancelled' WHERE id=${change.pairing_id}::uuid AND state='claimed'`;
|
|
664
|
+
});
|
|
665
|
+
},
|
|
511
666
|
listDevices: async (actor: AppApprovalActor, after?: string) => {
|
|
512
667
|
const cfg = await config(false);
|
|
513
668
|
const rows = await db<
|
|
@@ -518,18 +673,40 @@ export const createAppApprovalService = (
|
|
|
518
673
|
nextCursor: rows.length > limits.pageSize ? rows[limits.pageSize - 1]!.id : null,
|
|
519
674
|
};
|
|
520
675
|
},
|
|
521
|
-
mutateDevice: async (
|
|
676
|
+
mutateDevice: async (
|
|
677
|
+
actor: AppApprovalActor,
|
|
678
|
+
id: string,
|
|
679
|
+
name?: string,
|
|
680
|
+
endSessions = true,
|
|
681
|
+
): Promise<z.infer<typeof AppDeviceRevokeResultSchema> | undefined> => {
|
|
522
682
|
const cfg = await config(false);
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
>`SELECT * FROM auth.app_devices
|
|
683
|
+
let wakeId: string | undefined;
|
|
684
|
+
const result = await db.begin(async (tx) => {
|
|
685
|
+
await tx`SELECT id FROM auth.users WHERE id=${actor.userId}::uuid FOR NO KEY UPDATE`;
|
|
686
|
+
const user = await fresh(tx, actor);
|
|
687
|
+
const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices
|
|
688
|
+
WHERE id=${id}::uuid AND issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid FOR UPDATE`;
|
|
528
689
|
if (!device) return reject("UNAVAILABLE", 404);
|
|
529
|
-
if (name !== undefined)
|
|
530
|
-
|
|
531
|
-
|
|
690
|
+
if (name !== undefined) {
|
|
691
|
+
await tx`UPDATE auth.app_devices SET name=${name} WHERE id=${id}::uuid`;
|
|
692
|
+
await record(tx, "device.rename", actor.userId, id);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (device.revoked_at) return { state: "revoked" as const };
|
|
696
|
+
// Only a configured Cloud can ask its devices; without one, an administrator revokes.
|
|
697
|
+
if (!cfg.enabled || !cfg.appOrigin) return reject("UNAVAILABLE", 503);
|
|
698
|
+
// A retry after a lost answer gets the same request; a changed session choice replaces it.
|
|
699
|
+
const [open] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes
|
|
700
|
+
WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND kind='revoke' AND device_id=${id}::uuid
|
|
701
|
+
AND requested_by_sid=${actor.sid}::uuid AND state='pending' AND expires_at>now() FOR UPDATE`;
|
|
702
|
+
if (open?.end_sessions === endSessions) return { state: "pending" as const, changeId: open.id, expiresAt: iso(open.expires_at) };
|
|
703
|
+
if (open) await tx`UPDATE auth.app_device_changes SET state='cancelled' WHERE id=${open.id}::uuid`;
|
|
704
|
+
const requested = await createChange(tx, cfg, actor, user.auth_epoch, "revoke", id, null, endSessions);
|
|
705
|
+
wakeId = requested.id;
|
|
706
|
+
return { state: "pending" as const, changeId: requested.id, expiresAt: iso(requested.expires_at) };
|
|
532
707
|
});
|
|
708
|
+
if (wakeId) void wake(cfg, actor.userId, wakeId, { accept: "device-changes" });
|
|
709
|
+
return result;
|
|
533
710
|
},
|
|
534
711
|
/** Active devices of any account, for administrators. Bounded by the active-device limit. */
|
|
535
712
|
listUserDevices: async (actor: AppDeviceAdministrator, userId: string): Promise<AppDeviceView[]> => {
|
|
@@ -556,7 +733,7 @@ export const createAppApprovalService = (
|
|
|
556
733
|
return { device: view(row!), revoked: true };
|
|
557
734
|
});
|
|
558
735
|
},
|
|
559
|
-
startLogin: async (identifier: string, category: AccountCategory) => {
|
|
736
|
+
startLogin: async (identifier: string, category: AccountCategory, request?: AppLoginRequestContext) => {
|
|
560
737
|
const cfg = await config();
|
|
561
738
|
await cleanup();
|
|
562
739
|
const id = crypto.randomUUID(),
|
|
@@ -568,12 +745,25 @@ export const createAppApprovalService = (
|
|
|
568
745
|
await db.begin(async (tx) => {
|
|
569
746
|
let candidate = await loginCandidate(tx, identifier, category);
|
|
570
747
|
if (candidate && !(await loginBudget(tx, cfg.issuer, candidate.id))) candidate = undefined;
|
|
571
|
-
|
|
572
|
-
//
|
|
573
|
-
await tx
|
|
574
|
-
|
|
748
|
+
const now = new Date();
|
|
749
|
+
// These reads also run for decoys; the helpers use random account IDs when absent.
|
|
750
|
+
const browser = await findBrowser(tx, { userId: candidate?.id, cookie: request?.cookie ?? null, now });
|
|
751
|
+
const trust = await trustState(tx, now);
|
|
752
|
+
const countries = await countryHistory(tx, { userId: candidate?.id, country: request?.country ?? null, now });
|
|
753
|
+
const context = buildLoginContext({ request, browser, trust, countries, now });
|
|
754
|
+
// Unknown, ambiguous, disabled and throttled accounts keep the identical public shape.
|
|
755
|
+
await tx`INSERT INTO auth.app_logins(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at,context)
|
|
756
|
+
VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},${category},${hash(browserSecret)},${challenge},${code},${expiresAt},${candidate && context ? JSON.stringify(context) : null}::text::jsonb)`;
|
|
575
757
|
owner = candidate?.id;
|
|
576
758
|
});
|
|
759
|
+
// No cache I/O in SQL transactions; decoys do the same address work as real requests.
|
|
760
|
+
if (request?.prefix) {
|
|
761
|
+
try {
|
|
762
|
+
await addresses.remember(id, appLoginAddressProof(request.prefix), expiresAt);
|
|
763
|
+
} catch (error) {
|
|
764
|
+
addressFailure(error);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
577
767
|
// Not awaited: decoy and real requests answer alike, and push is only a wake-up.
|
|
578
768
|
if (owner) void wake(cfg, owner, id);
|
|
579
769
|
return { requestId: id, browserSecret, comparison: code, expiresAt: iso(expiresAt), pollAfterSeconds: limits.pollSeconds };
|
|
@@ -606,10 +796,15 @@ export const createAppApprovalService = (
|
|
|
606
796
|
if (!inserted.length) return reject("CONFLICT", 409);
|
|
607
797
|
owner = candidate?.id;
|
|
608
798
|
});
|
|
609
|
-
if (owner) void wake(cfg, owner, id, {
|
|
799
|
+
if (owner) void wake(cfg, owner, id, { accept: "terminal-logins" });
|
|
610
800
|
return { comparison: code, expiresAt: iso(expiresAt) };
|
|
611
801
|
},
|
|
612
|
-
deviceCommand: async (
|
|
802
|
+
deviceCommand: async (
|
|
803
|
+
request: AppDeviceRequest,
|
|
804
|
+
actions: AppDeviceActions,
|
|
805
|
+
terminal?: AppTerminalLogins,
|
|
806
|
+
options?: { address?: string },
|
|
807
|
+
) => {
|
|
613
808
|
const cfg = await config();
|
|
614
809
|
const { proof, signature } = request;
|
|
615
810
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -623,8 +818,19 @@ export const createAppApprovalService = (
|
|
|
623
818
|
)
|
|
624
819
|
return reject("FORBIDDEN", 403);
|
|
625
820
|
await cleanup();
|
|
821
|
+
let decidedBrowserId: string | undefined;
|
|
626
822
|
const outcome = await db.begin(async (tx) => {
|
|
823
|
+
let changeUserId: string | undefined;
|
|
824
|
+
if (proof.command.operation === "change") {
|
|
825
|
+
const [snapshot] = await tx<
|
|
826
|
+
Pick<DeviceRow, "user_id">[]
|
|
827
|
+
>`SELECT user_id FROM auth.app_devices WHERE id=${proof.deviceId}::uuid AND issuer=${cfg.issuer}`;
|
|
828
|
+
if (!snapshot) return reject("FORBIDDEN", 403);
|
|
829
|
+
changeUserId = snapshot.user_id;
|
|
830
|
+
await tx`SELECT id FROM auth.users WHERE id=${changeUserId}::uuid FOR NO KEY UPDATE`;
|
|
831
|
+
}
|
|
627
832
|
const device = await activeDevice(tx, proof.deviceId, cfg.issuer);
|
|
833
|
+
if (changeUserId && device.user_id !== changeUserId) return reject("CONFLICT", 409);
|
|
628
834
|
if (!(await verify(AppDevicePublicKeySchema.parse(device.public_key), appDeviceProofMessage(proof), signature)))
|
|
629
835
|
return reject("FORBIDDEN", 403);
|
|
630
836
|
const used =
|
|
@@ -632,6 +838,77 @@ export const createAppApprovalService = (
|
|
|
632
838
|
if (!used.length) return reject("CONFLICT", 409);
|
|
633
839
|
await tx`UPDATE auth.app_devices SET last_used_at=now() WHERE id=${device.id}::uuid`;
|
|
634
840
|
const command = proof.command;
|
|
841
|
+
if (command.operation === "change") {
|
|
842
|
+
// Like terminal sign-ins, only a device whose latest inbox opted in may decide device changes.
|
|
843
|
+
if (!device.accepts?.includes("device-changes")) return reject("CONFLICT", 409);
|
|
844
|
+
let target: DeviceRow | undefined;
|
|
845
|
+
if (command.kind === "revoke") {
|
|
846
|
+
[target] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices
|
|
847
|
+
WHERE id=${command.targetId}::uuid AND issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid FOR UPDATE`;
|
|
848
|
+
}
|
|
849
|
+
const [change] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes
|
|
850
|
+
WHERE id=${command.changeId}::uuid AND issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid FOR UPDATE`;
|
|
851
|
+
const user = await eligible(tx, device.user_id);
|
|
852
|
+
if (
|
|
853
|
+
!change ||
|
|
854
|
+
changeStatus(change).state !== "pending" ||
|
|
855
|
+
change.kind !== command.kind ||
|
|
856
|
+
change.end_sessions !== command.endSessions ||
|
|
857
|
+
change.auth_epoch !== user.auth_epoch
|
|
858
|
+
)
|
|
859
|
+
return reject("CONFLICT", 409);
|
|
860
|
+
let pairing: PairingRow | undefined;
|
|
861
|
+
if (change.kind === "pair") {
|
|
862
|
+
[pairing] = await tx<
|
|
863
|
+
PairingRow[]
|
|
864
|
+
>`SELECT * FROM auth.app_pairings WHERE id=${change.pairing_id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
|
|
865
|
+
if (
|
|
866
|
+
!pairing ||
|
|
867
|
+
pairing.user_id !== user.id ||
|
|
868
|
+
pairing.state !== "claimed" ||
|
|
869
|
+
new Date(pairing.expires_at).getTime() <= Date.now() ||
|
|
870
|
+
pairing.auth_epoch !== user.auth_epoch ||
|
|
871
|
+
pairing.device_id !== command.targetId
|
|
872
|
+
)
|
|
873
|
+
return reject("CONFLICT", 409);
|
|
874
|
+
const [initiator] = await tx`SELECT sid FROM auth.session_families WHERE sid=${pairing.initiator_sid}::uuid
|
|
875
|
+
AND user_id=${pairing.initiated_by}::uuid AND auth_epoch=${user.auth_epoch} AND revoked_at IS NULL AND expires_at>now() FOR UPDATE`;
|
|
876
|
+
if (!initiator) return reject("CONFLICT", 409);
|
|
877
|
+
} else {
|
|
878
|
+
if (!target || change.device_id !== command.targetId) return reject("CONFLICT", 409);
|
|
879
|
+
if (command.decision === "deny" && device.id === change.device_id) return reject("CONFLICT", 409);
|
|
880
|
+
const [requester] = await tx`SELECT sid FROM auth.session_families WHERE sid=${change.requested_by_sid}::uuid
|
|
881
|
+
AND user_id=${change.user_id}::uuid AND auth_epoch=${user.auth_epoch} AND revoked_at IS NULL AND expires_at>now() FOR UPDATE`;
|
|
882
|
+
if (!requester) return reject("CONFLICT", 409);
|
|
883
|
+
}
|
|
884
|
+
const state = command.decision === "approve" ? ("approved" as const) : ("denied" as const);
|
|
885
|
+
// Locks above may outlive the request. Expiry wins before applying either decision.
|
|
886
|
+
const decided = await tx`UPDATE auth.app_device_changes SET state=${state},decided_by=${device.id}::uuid
|
|
887
|
+
WHERE id=${change.id}::uuid AND state='pending' AND expires_at > clock_timestamp() RETURNING id`;
|
|
888
|
+
if (!decided.length) return reject("CONFLICT", 409);
|
|
889
|
+
let ended = 0;
|
|
890
|
+
if (state === "denied") {
|
|
891
|
+
if (pairing) await tx`UPDATE auth.app_pairings SET state='cancelled' WHERE id=${pairing.id}::uuid`;
|
|
892
|
+
} else if (pairing) {
|
|
893
|
+
await enrollPairing(tx, pairing, cfg.issuer);
|
|
894
|
+
} else {
|
|
895
|
+
await tx`UPDATE auth.app_devices SET revoked_at=COALESCE(revoked_at,now()) WHERE id=${command.targetId}::uuid`;
|
|
896
|
+
if (change.end_sessions) {
|
|
897
|
+
const families = await tx`UPDATE auth.session_families SET revoked_at=now(), revocation_reason='app_device_removed'
|
|
898
|
+
WHERE user_id=${user.id}::uuid AND app_device_id=${command.targetId}::uuid AND sid<>${change.requested_by_sid}::uuid
|
|
899
|
+
AND revoked_at IS NULL AND expires_at>now() AND auth_epoch=${user.auth_epoch} RETURNING sid`;
|
|
900
|
+
ended = families.length;
|
|
901
|
+
}
|
|
902
|
+
await record(tx, "device.revoke", user.id, command.targetId, { changeId: change.id });
|
|
903
|
+
}
|
|
904
|
+
await record(tx, `device_change.${command.decision}`, user.id, command.targetId, {
|
|
905
|
+
changeId: change.id,
|
|
906
|
+
kind: change.kind,
|
|
907
|
+
endSignIns: change.end_sessions,
|
|
908
|
+
ended,
|
|
909
|
+
});
|
|
910
|
+
return { state };
|
|
911
|
+
}
|
|
635
912
|
if (command.operation === "push") {
|
|
636
913
|
await tx`UPDATE auth.app_devices SET push_token=${command.token} WHERE id=${device.id}::uuid`;
|
|
637
914
|
return { state: "updated" as const };
|
|
@@ -686,11 +963,42 @@ export const createAppApprovalService = (
|
|
|
686
963
|
lifetimeSeconds: row.lifetime_seconds,
|
|
687
964
|
};
|
|
688
965
|
}
|
|
966
|
+
if (row.kind === "browser" && command.accept?.includes("login-context")) {
|
|
967
|
+
const parsed = AppLoginContextSchema.safeParse(row.context);
|
|
968
|
+
if (parsed.success) {
|
|
969
|
+
// sameNetwork is transient, even if an unexpected stored value contains it.
|
|
970
|
+
const { sameNetwork: _transient, ...context } = parsed.data;
|
|
971
|
+
login.context = context;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
689
974
|
requests.push(login);
|
|
690
975
|
}
|
|
691
976
|
if (command.operation === "inbox") {
|
|
692
977
|
await tx`UPDATE auth.app_devices SET actions_at=now(), accepts=${toPgTextArray(command.accept ?? [])}::text[] WHERE id=${device.id}::uuid`;
|
|
693
|
-
|
|
978
|
+
let changes: AppDeviceChange[] | undefined;
|
|
979
|
+
if (command.accept?.includes("device-changes")) {
|
|
980
|
+
const rows = await tx<
|
|
981
|
+
(ChangeRow & { target_id: string; name: string })[]
|
|
982
|
+
>`SELECT c.*, COALESCE(p.device_id,d.id) AS target_id, COALESCE(p.name,d.name) AS name
|
|
983
|
+
FROM auth.app_device_changes c LEFT JOIN auth.app_pairings p ON p.id=c.pairing_id LEFT JOIN auth.app_devices d ON d.id=c.device_id
|
|
984
|
+
WHERE c.issuer=${cfg.issuer} AND c.user_id=${device.user_id}::uuid AND c.state='pending' AND c.expires_at>now() AND c.auth_epoch=${user.auth_epoch}
|
|
985
|
+
AND ((c.kind='revoke' AND EXISTS (SELECT 1 FROM auth.session_families f
|
|
986
|
+
WHERE f.sid=c.requested_by_sid AND f.user_id=c.user_id
|
|
987
|
+
AND f.revoked_at IS NULL AND f.expires_at>now() AND f.auth_epoch=${user.auth_epoch}))
|
|
988
|
+
OR (p.state='claimed' AND p.expires_at>now() AND p.auth_epoch=${user.auth_epoch}
|
|
989
|
+
AND EXISTS (SELECT 1 FROM auth.session_families f WHERE f.sid=p.initiator_sid AND f.user_id=p.initiated_by
|
|
990
|
+
AND f.revoked_at IS NULL AND f.expires_at>now() AND f.auth_epoch=${user.auth_epoch})))
|
|
991
|
+
ORDER BY c.created_at,c.id LIMIT ${limits.pendingPerAccount}`;
|
|
992
|
+
changes = rows.map((row) => ({
|
|
993
|
+
changeId: row.id,
|
|
994
|
+
kind: row.kind,
|
|
995
|
+
device: { id: row.target_id, name: changeName(row.name) },
|
|
996
|
+
endSessions: row.end_sessions,
|
|
997
|
+
createdAt: iso(row.created_at),
|
|
998
|
+
expiresAt: iso(row.expires_at),
|
|
999
|
+
}));
|
|
1000
|
+
}
|
|
1001
|
+
return { operation: "inbox" as const, userId: device.user_id, logins: requests, changes };
|
|
694
1002
|
}
|
|
695
1003
|
return { requests, pollAfterSeconds: limits.pollSeconds };
|
|
696
1004
|
}
|
|
@@ -727,23 +1035,27 @@ export const createAppApprovalService = (
|
|
|
727
1035
|
const decided = await tx`UPDATE auth.app_logins SET state=${state},approved_by=${device.id}::uuid
|
|
728
1036
|
WHERE id=${row.id}::uuid AND expires_at > clock_timestamp() RETURNING id`;
|
|
729
1037
|
if (!decided.length) return reject("CONFLICT", 409);
|
|
1038
|
+
const storedContext = row.kind === "browser" ? AppLoginContextSchema.safeParse(row.context) : null;
|
|
730
1039
|
await record(tx, `login.${command.decision}`, device.user_id, device.id, {
|
|
731
1040
|
requestId: row.id,
|
|
732
1041
|
...(row.kind === "terminal" ? { kind: "terminal", clientId: row.client_id, deviceGrantId: row.device_authorization_id } : {}),
|
|
1042
|
+
...(storedContext?.success ? { level: storedContext.data.level } : {}),
|
|
733
1043
|
});
|
|
1044
|
+
if (row.kind === "browser") decidedBrowserId = row.id;
|
|
734
1045
|
return { state };
|
|
735
1046
|
});
|
|
736
1047
|
if (outcome.operation !== undefined) {
|
|
737
1048
|
const command = proof.command;
|
|
738
1049
|
const input = { userId: outcome.userId, issuer: cfg.issuer };
|
|
739
1050
|
if (outcome.operation === "inbox") {
|
|
740
|
-
const inbox = {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
}
|
|
746
|
-
|
|
1051
|
+
const inbox = {
|
|
1052
|
+
logins: outcome.logins,
|
|
1053
|
+
...(outcome.changes === undefined ? {} : { changes: outcome.changes, moreChanges: 0 }),
|
|
1054
|
+
...(await actions.inbox(input)),
|
|
1055
|
+
pollAfterSeconds: limits.pollSeconds,
|
|
1056
|
+
};
|
|
1057
|
+
await addSameNetwork(inbox.logins, options?.address);
|
|
1058
|
+
return trimAppLoginResponse(inbox);
|
|
747
1059
|
}
|
|
748
1060
|
if (command.operation === "action") {
|
|
749
1061
|
const view = await actions.view({ ...input, actionId: command.actionId });
|
|
@@ -763,8 +1075,19 @@ export const createAppApprovalService = (
|
|
|
763
1075
|
}
|
|
764
1076
|
return reject("INVALID_REQUEST", 400);
|
|
765
1077
|
}
|
|
1078
|
+
if (outcome.requests !== undefined) {
|
|
1079
|
+
await addSameNetwork(outcome.requests, options?.address);
|
|
1080
|
+
return trimAppLoginResponse(outcome);
|
|
1081
|
+
}
|
|
766
1082
|
// Only after commit: the waiting browser re-reads Postgres, so a hint never grants anything.
|
|
767
1083
|
if (proof.command.operation === "decide") hints.publish(proof.command.requestId);
|
|
1084
|
+
if (decidedBrowserId) {
|
|
1085
|
+
try {
|
|
1086
|
+
await addresses.forget(decidedBrowserId);
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
addressFailure(error);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
768
1091
|
return outcome;
|
|
769
1092
|
},
|
|
770
1093
|
/** With `hold`, a pending request is answered on its decision, its expiry, or after `hold.ms`, whichever comes first. */
|
|
@@ -825,7 +1148,7 @@ export const createAppApprovalService = (
|
|
|
825
1148
|
await record(tx, "login.consume", user.id, device.id, { requestId: id });
|
|
826
1149
|
// This commit is the authorization boundary. A later issuance/network
|
|
827
1150
|
// failure requires a new login, never replay of the consumed approval.
|
|
828
|
-
return user.id;
|
|
1151
|
+
return { userId: user.id, authEpoch: Number(row.auth_epoch), deviceId: device.id, deviceName: device.name };
|
|
829
1152
|
});
|
|
830
1153
|
},
|
|
831
1154
|
};
|