@k2b/cloud 0.22.0 → 0.24.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/package.json +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +90 -7
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +20 -4
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/registry.ts +78 -3
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +80 -21
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/migrate.ts +35 -16
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/runtime.ts +1 -6
- package/src/ai/solid.ts +0 -5
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/help.ts +2 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +168 -0
- package/src/api/pwa.ts +179 -0
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- package/src/browser/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +830 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/help.ts +3 -3
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +114 -80
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +640 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -23
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +70 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/global.css +6 -0
- package/src/styles/resource-search.css +19 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- package/src/shared/markdown/extensions/info-blocks.ts +0 -108
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { type SQL, sql } from "bun";
|
|
3
|
+
import type { Context } from "hono";
|
|
4
|
+
import { PWA_LIMITS as limits, type PwaDeviceView, type PwaErrorCode, type PwaPairingState, type PwaPlatform } from "../contracts/pwa";
|
|
5
|
+
import { isAccountCategoryAllowed } from "./account-category-policy";
|
|
6
|
+
import { audit } from "./audit";
|
|
7
|
+
import { logger } from "./logging";
|
|
8
|
+
import { pairingSecret } from "./pairing-secret";
|
|
9
|
+
import { type AuthenticatedSession, session } from "./session";
|
|
10
|
+
import { requireRecentWebSession } from "./session/recent";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Paired phones of the mobile app (preview). A phone holds a device key that only Core
|
|
14
|
+
* receives and a 24-hour app session that every application validates like a web session.
|
|
15
|
+
* Lock order everywhere: the user row, then the pairing or device row.
|
|
16
|
+
*/
|
|
17
|
+
export class PwaError extends Error {
|
|
18
|
+
constructor(
|
|
19
|
+
public code: PwaErrorCode,
|
|
20
|
+
public status: 400 | 401 | 403 | 404 | 409 | 410 | 429 | 503,
|
|
21
|
+
public attemptsLeft?: number,
|
|
22
|
+
) {
|
|
23
|
+
super(code);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const fail = (code: PwaErrorCode, status: PwaError["status"], attemptsLeft?: number): never => {
|
|
28
|
+
throw new PwaError(code, status, attemptsLeft);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The initiating web session of a pairing. */
|
|
32
|
+
export type PwaWebActor = { userId: string; sid: string };
|
|
33
|
+
/** Administration of another account's phones needs current admin authority. */
|
|
34
|
+
export type PwaDeviceAdministrator = { userId: string; admin: boolean };
|
|
35
|
+
export type PwaRevocationReason = "user" | "unpaired" | "admin" | "replaced" | "account_expired";
|
|
36
|
+
/**
|
|
37
|
+
* The "new phone paired" notice, sent once per device after its pairing committed. It carries no
|
|
38
|
+
* pairing code, secret or key. `locale` is the completing request's locale, if one was stored.
|
|
39
|
+
*/
|
|
40
|
+
export type PwaDevicePairedNotice = {
|
|
41
|
+
deviceId: string;
|
|
42
|
+
userId: string;
|
|
43
|
+
name: string;
|
|
44
|
+
platform: PwaPlatform;
|
|
45
|
+
pairedAt: string;
|
|
46
|
+
locale: string | null;
|
|
47
|
+
};
|
|
48
|
+
export type PwaDeviceNotifier = (notice: PwaDevicePairedNotice) => Promise<unknown>;
|
|
49
|
+
|
|
50
|
+
type PairingRow = {
|
|
51
|
+
id: string;
|
|
52
|
+
user_id: string;
|
|
53
|
+
initiator_sid: string;
|
|
54
|
+
auth_epoch: string;
|
|
55
|
+
comparison: string | null;
|
|
56
|
+
platform: PwaPlatform | null;
|
|
57
|
+
device_name: string | null;
|
|
58
|
+
failed_attempts: number;
|
|
59
|
+
state: PwaPairingState;
|
|
60
|
+
device_id: string | null;
|
|
61
|
+
claim_until: Date;
|
|
62
|
+
expires_at: Date;
|
|
63
|
+
};
|
|
64
|
+
type DeviceRow = {
|
|
65
|
+
id: string;
|
|
66
|
+
user_id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
platform: PwaPlatform;
|
|
69
|
+
auth_epoch: string;
|
|
70
|
+
secret_hash: string;
|
|
71
|
+
previous_secret_hash: string | null;
|
|
72
|
+
rotated_at: Date;
|
|
73
|
+
created_at: Date;
|
|
74
|
+
last_used_at: Date;
|
|
75
|
+
revoked_at: Date | null;
|
|
76
|
+
locale: string | null;
|
|
77
|
+
notified_at: Date | null;
|
|
78
|
+
};
|
|
79
|
+
type UserRow = {
|
|
80
|
+
id: string;
|
|
81
|
+
uid: string;
|
|
82
|
+
display_name: string | null;
|
|
83
|
+
provider: "local" | "ipa";
|
|
84
|
+
profile: "guest" | "user";
|
|
85
|
+
auth_epoch: string;
|
|
86
|
+
account_expires: Date | null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const log = logger("pwa-devices");
|
|
90
|
+
const iso = (value: Date | string) => new Date(value).toISOString();
|
|
91
|
+
const passed = (value: Date | string) => new Date(value).getTime() <= Date.now();
|
|
92
|
+
const DEVICE_KEY = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([A-Za-z0-9_-]{43})$/;
|
|
93
|
+
|
|
94
|
+
/** `<deviceId>.<secret>` from the `pwa_device` cookie, or null when malformed. */
|
|
95
|
+
export const parseDeviceKey = (value: string | null | undefined): { deviceId: string; secret: string } | null => {
|
|
96
|
+
const match = value ? DEVICE_KEY.exec(value) : null;
|
|
97
|
+
return match ? { deviceId: match[1]!, secret: match[2]! } : null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Untranslated default names; the person renames the phone in the app. */
|
|
101
|
+
export const defaultDeviceName = (platform: PwaPlatform, userAgent: string | undefined): string =>
|
|
102
|
+
platform === "ios" ? (/\biPad\b/.test(userAgent ?? "") ? "iPad" : "iPhone") : platform === "android" ? "Android" : "Phone";
|
|
103
|
+
|
|
104
|
+
const accountName = (user: Pick<UserRow, "display_name" | "uid">) => user.display_name?.trim() || user.uid;
|
|
105
|
+
|
|
106
|
+
const sameCode = (typed: string, expected: string | null) =>
|
|
107
|
+
expected !== null && typed.length === expected.length && timingSafeEqual(Buffer.from(typed), Buffer.from(expected));
|
|
108
|
+
|
|
109
|
+
const view = (row: DeviceRow, currentDeviceId?: string | null): PwaDeviceView => ({
|
|
110
|
+
id: row.id,
|
|
111
|
+
name: row.name,
|
|
112
|
+
platform: row.platform,
|
|
113
|
+
createdAt: iso(row.created_at),
|
|
114
|
+
lastUsedAt: iso(row.last_used_at),
|
|
115
|
+
current: row.id === currentDeviceId,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const lockUser = async (tx: SQL, userId: string): Promise<UserRow | undefined> => {
|
|
119
|
+
const [user] = await tx<UserRow[]>`
|
|
120
|
+
SELECT id, uid, display_name, provider, profile, auth_epoch, account_expires FROM auth.users WHERE id = ${userId}::uuid FOR UPDATE
|
|
121
|
+
`;
|
|
122
|
+
return user;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** An active phone (alias `d`): not revoked, used within the idle window, and enrolled in the current account epoch. */
|
|
126
|
+
const activeDevice = () => sql`d.revoked_at IS NULL
|
|
127
|
+
AND d.rotated_at > now() - ${limits.idleDays} * interval '1 day'
|
|
128
|
+
AND d.auth_epoch = (SELECT auth_epoch FROM auth.users WHERE id = d.user_id)`;
|
|
129
|
+
|
|
130
|
+
/** The web family that started a pairing is still valid (the session validator's predicate). */
|
|
131
|
+
const initiatorValid = async (tx: SQL, pairing: PairingRow): Promise<boolean> => {
|
|
132
|
+
const [row] = await tx`
|
|
133
|
+
SELECT 1 FROM auth.session_families sf
|
|
134
|
+
JOIN auth.users u ON u.id = sf.user_id
|
|
135
|
+
JOIN auth.signing_keys sk ON sk.kid = sf.signing_kid AND sk.state <> 'revoked'
|
|
136
|
+
WHERE sf.sid = ${pairing.initiator_sid}::uuid AND sf.user_id = ${pairing.user_id}::uuid
|
|
137
|
+
AND sf.revoked_at IS NULL AND sf.expires_at > now() AND sf.auth_epoch = u.auth_epoch AND sf.pwa_device_id IS NULL
|
|
138
|
+
`;
|
|
139
|
+
return Boolean(row);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const accountExpired = (user: UserRow) => Boolean(user.account_expires && passed(user.account_expires));
|
|
143
|
+
|
|
144
|
+
/** `name` is the one the owner confirmed; the phone can rename itself once it is paired. */
|
|
145
|
+
const pairedNotice = (device: DeviceRow, name: string): PwaDevicePairedNotice => ({
|
|
146
|
+
deviceId: device.id,
|
|
147
|
+
userId: device.user_id,
|
|
148
|
+
name,
|
|
149
|
+
platform: device.platform,
|
|
150
|
+
pairedAt: iso(device.created_at),
|
|
151
|
+
locale: device.locale,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Sends the "new phone paired" notice and marks it sent. The notification owner deduplicates by
|
|
156
|
+
* device id, so a crash between the two sends nothing twice. A failure leaves it to maintenance.
|
|
157
|
+
*/
|
|
158
|
+
const announce = async (notify: PwaDeviceNotifier, notice: PwaDevicePairedNotice) => {
|
|
159
|
+
try {
|
|
160
|
+
await notify(notice);
|
|
161
|
+
await sql`UPDATE auth.pwa_devices SET notified_at = now() WHERE id = ${notice.deviceId}::uuid AND notified_at IS NULL`;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
log.warn("App device notice failed", { error: error instanceof Error ? error.name : "UnknownError" });
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const legalAccepted = async (tx: SQL, userId: string) =>
|
|
168
|
+
(await tx`SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid`).length > 0;
|
|
169
|
+
|
|
170
|
+
const record = (
|
|
171
|
+
tx: SQL,
|
|
172
|
+
action: string,
|
|
173
|
+
target: { type: "pwa_pairing" | "pwa_device"; id: string },
|
|
174
|
+
actorUserId: string | null,
|
|
175
|
+
metadata: Record<string, unknown> = {},
|
|
176
|
+
) =>
|
|
177
|
+
audit.record(
|
|
178
|
+
{
|
|
179
|
+
action: `auth.pwa.${action}`,
|
|
180
|
+
outcome: "allowed",
|
|
181
|
+
...(actorUserId ? { actor: { userId: actorUserId } } : {}),
|
|
182
|
+
target,
|
|
183
|
+
metadata: actorUserId ? metadata : { ...metadata, provenance: "system" },
|
|
184
|
+
},
|
|
185
|
+
tx,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
/** Ends a device and every app session it holds, in the caller's transaction. */
|
|
189
|
+
const revokeDevice = async (
|
|
190
|
+
tx: SQL,
|
|
191
|
+
device: Pick<DeviceRow, "id" | "user_id">,
|
|
192
|
+
reason: PwaRevocationReason,
|
|
193
|
+
actorUserId: string | null,
|
|
194
|
+
): Promise<boolean> => {
|
|
195
|
+
const revoked = await tx`
|
|
196
|
+
UPDATE auth.pwa_devices SET revoked_at = now(), revocation_reason = ${reason}
|
|
197
|
+
WHERE id = ${device.id}::uuid AND revoked_at IS NULL RETURNING id
|
|
198
|
+
`;
|
|
199
|
+
if (!revoked.length) return false;
|
|
200
|
+
await tx`
|
|
201
|
+
UPDATE auth.session_families SET revoked_at = now(), revocation_reason = 'app_device_revoked'
|
|
202
|
+
WHERE pwa_device_id = ${device.id}::uuid AND revoked_at IS NULL
|
|
203
|
+
`;
|
|
204
|
+
await record(tx, "device.revoke", { type: "pwa_device", id: device.id }, actorUserId, {
|
|
205
|
+
reason,
|
|
206
|
+
...(actorUserId !== device.user_id ? { targetUserId: device.user_id } : {}),
|
|
207
|
+
});
|
|
208
|
+
return true;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
type KeyMatch = "current" | "previous" | null;
|
|
212
|
+
const matchKey = (device: DeviceRow, secret: string): KeyMatch =>
|
|
213
|
+
pairingSecret.matches(secret, device.secret_hash)
|
|
214
|
+
? "current"
|
|
215
|
+
: device.previous_secret_hash && pairingSecret.matches(secret, device.previous_secret_hash)
|
|
216
|
+
? "previous"
|
|
217
|
+
: null;
|
|
218
|
+
|
|
219
|
+
/** Reads a presented device key without locks. Null unless it is a key of an active phone. */
|
|
220
|
+
const presentedDevice = async (key: string | null | undefined): Promise<{ device: DeviceRow; secret: string } | null> => {
|
|
221
|
+
const parsed = parseDeviceKey(key);
|
|
222
|
+
if (!parsed) return null;
|
|
223
|
+
const [device] = await sql<DeviceRow[]>`SELECT d.* FROM auth.pwa_devices d WHERE d.id = ${parsed.deviceId}::uuid AND ${activeDevice()}`;
|
|
224
|
+
return device && matchKey(device, parsed.secret) ? { device, secret: parsed.secret } : null;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const isActive = (device: DeviceRow, user: UserRow) =>
|
|
228
|
+
!device.revoked_at &&
|
|
229
|
+
Date.now() - new Date(device.rotated_at).getTime() < limits.idleDays * 86_400_000 &&
|
|
230
|
+
device.auth_epoch === user.auth_epoch;
|
|
231
|
+
|
|
232
|
+
/** Cookie values to set after a successful pairing, recovery or renewal. */
|
|
233
|
+
export type PwaCredentials = { deviceKey: string; sessionToken: string };
|
|
234
|
+
|
|
235
|
+
export type PwaRenewal =
|
|
236
|
+
| { outcome: "renewed"; userId: string; credentials: PwaCredentials }
|
|
237
|
+
| { outcome: "current"; userId: string }
|
|
238
|
+
| { outcome: "missing" }
|
|
239
|
+
| { outcome: "ended" }
|
|
240
|
+
| { outcome: "blocked" };
|
|
241
|
+
|
|
242
|
+
export type PwaCompletion =
|
|
243
|
+
| { state: "paired"; credentials: PwaCredentials | null; platform: PwaPlatform }
|
|
244
|
+
| { state: "waiting"; code: string; account: { name: string }; expiresAt: string };
|
|
245
|
+
|
|
246
|
+
export const createPwaDeviceService = () => {
|
|
247
|
+
const appSession = (deviceId: string) => ({ ttlSeconds: limits.sessionSeconds, pwaDeviceId: deviceId, recordLogin: false });
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A new device key. `rotate` keeps the old key as the previous one until the phone presents the
|
|
251
|
+
* new one; `recover` keeps the previous key as it is.
|
|
252
|
+
*/
|
|
253
|
+
const newKey = async (tx: SQL, deviceId: string, mode: "rotate" | "recover") => {
|
|
254
|
+
const secret = pairingSecret.create();
|
|
255
|
+
const hash = pairingSecret.hash(secret);
|
|
256
|
+
if (mode === "rotate")
|
|
257
|
+
await tx`UPDATE auth.pwa_devices SET previous_secret_hash = secret_hash, secret_hash = ${hash},
|
|
258
|
+
rotated_at = now(), last_used_at = now() WHERE id = ${deviceId}::uuid`;
|
|
259
|
+
else await tx`UPDATE auth.pwa_devices SET secret_hash = ${hash}, rotated_at = now(), last_used_at = now() WHERE id = ${deviceId}::uuid`;
|
|
260
|
+
return `${deviceId}.${secret}`;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const renewal = async (
|
|
264
|
+
c: Context,
|
|
265
|
+
input: { deviceKey: string | null | undefined; appSession: AuthenticatedSession | null },
|
|
266
|
+
): Promise<PwaRenewal> => {
|
|
267
|
+
const parsed = parseDeviceKey(input.deviceKey);
|
|
268
|
+
if (!parsed) return { outcome: "missing" };
|
|
269
|
+
const [snapshot] = await sql<DeviceRow[]>`SELECT * FROM auth.pwa_devices WHERE id = ${parsed.deviceId}::uuid`;
|
|
270
|
+
if (!snapshot) return { outcome: "ended" };
|
|
271
|
+
type Step = { outcome: "ended" | "blocked" } | { outcome: "current" } | { outcome: "renewed"; deviceKey: string };
|
|
272
|
+
const { value, token } = await session.issueInTransaction<Step>(c, async (tx, issue) => {
|
|
273
|
+
const user = await lockUser(tx, snapshot.user_id);
|
|
274
|
+
const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.pwa_devices WHERE id = ${parsed.deviceId}::uuid FOR UPDATE`;
|
|
275
|
+
if (!user || !device || !isActive(device, user)) return { outcome: "ended" };
|
|
276
|
+
const match = matchKey(device, parsed.secret);
|
|
277
|
+
if (!match) return { outcome: "ended" };
|
|
278
|
+
if (accountExpired(user)) {
|
|
279
|
+
// Extending the account later must not revive the phone.
|
|
280
|
+
await revokeDevice(tx, device, "account_expired", null);
|
|
281
|
+
return { outcome: "ended" };
|
|
282
|
+
}
|
|
283
|
+
if (!(await isAccountCategoryAllowed(user, tx)) || !(await legalAccepted(tx, user.id))) return { outcome: "blocked" };
|
|
284
|
+
if (match === "current") {
|
|
285
|
+
// The phone uses the newest key, so the older one stops working.
|
|
286
|
+
if (device.previous_secret_hash) await tx`UPDATE auth.pwa_devices SET previous_secret_hash = NULL WHERE id = ${device.id}::uuid`;
|
|
287
|
+
const live = input.appSession;
|
|
288
|
+
const remaining = live ? new Date(live.data.expiresAt).getTime() - Date.now() : 0;
|
|
289
|
+
if (live?.data.deviceId === device.id && live.user.id === user.id && remaining > limits.renewWithinSeconds * 1000) {
|
|
290
|
+
await tx`UPDATE auth.pwa_devices SET last_used_at = now() WHERE id = ${device.id}::uuid AND last_used_at < date_trunc('day', now())`;
|
|
291
|
+
return { outcome: "current" };
|
|
292
|
+
}
|
|
293
|
+
const deviceKey = await newKey(tx, device.id, "rotate");
|
|
294
|
+
await issue(user.id, appSession(device.id));
|
|
295
|
+
return { outcome: "renewed", deviceKey };
|
|
296
|
+
}
|
|
297
|
+
// The previous key: a parallel renewal is running, or the phone missed the last answer.
|
|
298
|
+
if (Date.now() - new Date(device.rotated_at).getTime() < limits.rotationGraceSeconds * 1000) return { outcome: "current" };
|
|
299
|
+
const deviceKey = await newKey(tx, device.id, "recover");
|
|
300
|
+
await issue(user.id, appSession(device.id));
|
|
301
|
+
await record(tx, "device.recover", { type: "pwa_device", id: device.id }, user.id);
|
|
302
|
+
return { outcome: "renewed", deviceKey };
|
|
303
|
+
});
|
|
304
|
+
if (value.outcome === "renewed") {
|
|
305
|
+
if (!token) throw new Error("App session was not issued");
|
|
306
|
+
return { outcome: "renewed", userId: snapshot.user_id, credentials: { deviceKey: value.deviceKey, sessionToken: token } };
|
|
307
|
+
}
|
|
308
|
+
if (value.outcome === "current") return { outcome: "current", userId: snapshot.user_id };
|
|
309
|
+
return value;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
// ---------- Web side: the initiating web session ----------
|
|
314
|
+
|
|
315
|
+
startPairing: async (actor: PwaWebActor) => {
|
|
316
|
+
const id = crypto.randomUUID();
|
|
317
|
+
const secret = pairingSecret.create();
|
|
318
|
+
const now = Date.now();
|
|
319
|
+
const claimUntil = new Date(now + limits.linkClaimSeconds * 1000);
|
|
320
|
+
const expiresAt = new Date(now + limits.pairingSeconds * 1000);
|
|
321
|
+
await sql.begin(async (tx) => {
|
|
322
|
+
await lockUser(tx, actor.userId);
|
|
323
|
+
const account = await requireRecentWebSession(tx, actor, limits.recentSessionSeconds, (code) => fail(code, 403));
|
|
324
|
+
const [open] = await tx<{ count: number }[]>`
|
|
325
|
+
SELECT count(*)::int AS count FROM auth.pwa_pairings
|
|
326
|
+
WHERE user_id = ${actor.userId}::uuid AND expires_at > now() AND state IN ('pending', 'claimed', 'confirmed')
|
|
327
|
+
`;
|
|
328
|
+
if ((open?.count ?? 0) >= limits.pendingPerAccount) fail("LIMIT_REACHED", 429);
|
|
329
|
+
await tx`INSERT INTO auth.pwa_pairings (id, user_id, initiator_sid, auth_epoch, link_secret_hash, state, claim_until, expires_at)
|
|
330
|
+
VALUES (${id}::uuid, ${actor.userId}::uuid, ${actor.sid}::uuid, ${account.auth_epoch}, ${pairingSecret.hash(secret)}, 'pending', ${claimUntil}, ${expiresAt})`;
|
|
331
|
+
await record(tx, "pairing.start", { type: "pwa_pairing", id }, actor.userId);
|
|
332
|
+
});
|
|
333
|
+
return { id, secret, claimUntil: iso(claimUntil), expiresAt: iso(expiresAt) };
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
/** Never returns the code: the person types what the phone shows. */
|
|
337
|
+
inspectPairing: async (actor: PwaWebActor, id: string) => {
|
|
338
|
+
const [p] = await sql<(PairingRow & { current_epoch: string })[]>`
|
|
339
|
+
SELECT p.*, u.auth_epoch AS current_epoch FROM auth.pwa_pairings p JOIN auth.users u ON u.id = p.user_id
|
|
340
|
+
WHERE p.id = ${id}::uuid AND p.user_id = ${actor.userId}::uuid AND p.initiator_sid = ${actor.sid}::uuid
|
|
341
|
+
`;
|
|
342
|
+
if (!p) return fail("NOT_FOUND", 404);
|
|
343
|
+
if (passed(p.expires_at) || p.current_epoch !== p.auth_epoch || (p.state === "pending" && passed(p.claim_until)))
|
|
344
|
+
return fail("EXPIRED", 410);
|
|
345
|
+
return {
|
|
346
|
+
state: p.state,
|
|
347
|
+
claimUntil: iso(p.claim_until),
|
|
348
|
+
expiresAt: iso(p.expires_at),
|
|
349
|
+
device: p.state === "pending" || !p.device_name || !p.platform ? null : { name: p.device_name, platform: p.platform },
|
|
350
|
+
attemptsLeft: Math.max(0, limits.confirmAttempts - p.failed_attempts),
|
|
351
|
+
};
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
confirmPairing: async (actor: PwaWebActor, id: string, code: string) => {
|
|
355
|
+
type Outcome = { ok: true } | { ok: false; code: PwaErrorCode; status: PwaError["status"]; attemptsLeft?: number };
|
|
356
|
+
const outcome = await sql.begin(async (tx): Promise<Outcome> => {
|
|
357
|
+
const user = await lockUser(tx, actor.userId);
|
|
358
|
+
const [p] = await tx<PairingRow[]>`
|
|
359
|
+
SELECT * FROM auth.pwa_pairings WHERE id = ${id}::uuid AND user_id = ${actor.userId}::uuid AND initiator_sid = ${actor.sid}::uuid FOR UPDATE
|
|
360
|
+
`;
|
|
361
|
+
if (!user || !p) return { ok: false, code: "NOT_FOUND", status: 404 };
|
|
362
|
+
if (p.state === "cancelled" || passed(p.expires_at) || user.auth_epoch !== p.auth_epoch || !(await initiatorValid(tx, p)))
|
|
363
|
+
return { ok: false, code: "EXPIRED", status: 410 };
|
|
364
|
+
if (p.state !== "claimed") return { ok: false, code: "CONFLICT", status: 409 };
|
|
365
|
+
if (accountExpired(user) || !(await isAccountCategoryAllowed(user, tx))) return { ok: false, code: "FORBIDDEN", status: 403 };
|
|
366
|
+
if (!sameCode(code, p.comparison)) {
|
|
367
|
+
const failed = p.failed_attempts + 1;
|
|
368
|
+
if (failed >= limits.confirmAttempts) {
|
|
369
|
+
// A guessed or photographed code gets three tries; then the link is spent.
|
|
370
|
+
await tx`UPDATE auth.pwa_pairings SET failed_attempts = ${failed}, state = 'cancelled' WHERE id = ${p.id}::uuid`;
|
|
371
|
+
await record(tx, "pairing.cancel", { type: "pwa_pairing", id: p.id }, actor.userId, { reason: "wrong_code" });
|
|
372
|
+
return { ok: false, code: "EXPIRED", status: 410, attemptsLeft: 0 };
|
|
373
|
+
}
|
|
374
|
+
await tx`UPDATE auth.pwa_pairings SET failed_attempts = ${failed} WHERE id = ${p.id}::uuid`;
|
|
375
|
+
return { ok: false, code: "WRONG_CODE", status: 409, attemptsLeft: limits.confirmAttempts - failed };
|
|
376
|
+
}
|
|
377
|
+
await tx`UPDATE auth.pwa_pairings SET state = 'confirmed' WHERE id = ${p.id}::uuid`;
|
|
378
|
+
await record(tx, "pairing.confirm", { type: "pwa_pairing", id: p.id }, actor.userId, { platform: p.platform });
|
|
379
|
+
return { ok: true };
|
|
380
|
+
});
|
|
381
|
+
if (!outcome.ok) fail(outcome.code, outcome.status, outcome.attemptsLeft);
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
cancelPairing: async (actor: PwaWebActor, id: string) => {
|
|
385
|
+
await sql.begin(async (tx) => {
|
|
386
|
+
await lockUser(tx, actor.userId);
|
|
387
|
+
const [p] = await tx<PairingRow[]>`
|
|
388
|
+
SELECT * FROM auth.pwa_pairings WHERE id = ${id}::uuid AND user_id = ${actor.userId}::uuid AND initiator_sid = ${actor.sid}::uuid FOR UPDATE
|
|
389
|
+
`;
|
|
390
|
+
if (!p) return fail("NOT_FOUND", 404);
|
|
391
|
+
if (p.state === "completed") return fail("CONFLICT", 409);
|
|
392
|
+
if (p.state === "cancelled") return;
|
|
393
|
+
await tx`UPDATE auth.pwa_pairings SET state = 'cancelled' WHERE id = ${p.id}::uuid`;
|
|
394
|
+
await record(tx, "pairing.cancel", { type: "pwa_pairing", id: p.id }, actor.userId, { reason: "user" });
|
|
395
|
+
});
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
/** Active phones of the person. `currentDeviceId` marks the phone this request comes from. */
|
|
399
|
+
list: async (actor: { userId: string }, currentDeviceId?: string | null): Promise<PwaDeviceView[]> => {
|
|
400
|
+
const rows = await sql<DeviceRow[]>`
|
|
401
|
+
SELECT d.* FROM auth.pwa_devices d WHERE d.user_id = ${actor.userId}::uuid AND ${activeDevice()}
|
|
402
|
+
ORDER BY d.created_at, d.id LIMIT ${limits.devicesPerAccount}
|
|
403
|
+
`;
|
|
404
|
+
return rows.map((row) => view(row, currentDeviceId));
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
/** Idempotent removal of the person's own phone. */
|
|
408
|
+
revoke: async (actor: { userId: string }, deviceId: string) => {
|
|
409
|
+
await sql.begin(async (tx) => {
|
|
410
|
+
await lockUser(tx, actor.userId);
|
|
411
|
+
const [device] = await tx<DeviceRow[]>`
|
|
412
|
+
SELECT * FROM auth.pwa_devices WHERE id = ${deviceId}::uuid AND user_id = ${actor.userId}::uuid FOR UPDATE
|
|
413
|
+
`;
|
|
414
|
+
if (!device) return fail("NOT_FOUND", 404);
|
|
415
|
+
await revokeDevice(tx, device, "user", actor.userId);
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
// ---------- Phone side: the installed app ----------
|
|
420
|
+
|
|
421
|
+
/** Single use. Returns the completion secret for the `pwa_pairing` cookie and the code the phone shows. */
|
|
422
|
+
claimPairing: async (input: { secret: string; platform: PwaPlatform; userAgent?: string }) => {
|
|
423
|
+
const [snapshot] = await sql<PairingRow[]>`
|
|
424
|
+
SELECT * FROM auth.pwa_pairings WHERE link_secret_hash = ${pairingSecret.hash(input.secret)}
|
|
425
|
+
`;
|
|
426
|
+
if (!snapshot) return fail("EXPIRED", 410);
|
|
427
|
+
const completionSecret = pairingSecret.create();
|
|
428
|
+
const code = pairingSecret.code();
|
|
429
|
+
const result = await sql.begin(async (tx) => {
|
|
430
|
+
const user = await lockUser(tx, snapshot.user_id);
|
|
431
|
+
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot.id}::uuid FOR UPDATE`;
|
|
432
|
+
if (!user || !p || p.state === "cancelled" || passed(p.expires_at) || passed(p.claim_until)) return fail("EXPIRED", 410);
|
|
433
|
+
if (p.state !== "pending") return fail("ALREADY_USED", 409);
|
|
434
|
+
if (user.auth_epoch !== p.auth_epoch || accountExpired(user) || !(await isAccountCategoryAllowed(user, tx)))
|
|
435
|
+
return fail("EXPIRED", 410);
|
|
436
|
+
await tx`UPDATE auth.pwa_pairings SET state = 'claimed', completion_secret_hash = ${pairingSecret.hash(completionSecret)},
|
|
437
|
+
comparison = ${code}, platform = ${input.platform}, device_name = ${defaultDeviceName(input.platform, input.userAgent)}
|
|
438
|
+
WHERE id = ${p.id}::uuid`;
|
|
439
|
+
return { account: { name: accountName(user) }, expiresAt: p.expires_at };
|
|
440
|
+
});
|
|
441
|
+
return { code, account: result.account, expiresAt: iso(result.expiresAt), completionSecret };
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Finishes a confirmed pairing in the app. `webUserId` is the owner of a valid web session in
|
|
446
|
+
* the same cookie jar; `appSession` is a valid app session presented with the request. The
|
|
447
|
+
* "new phone paired" notice goes out before the phone receives its credentials, so the phone
|
|
448
|
+
* can neither turn it off, rename itself in it, nor sign out before it is sent.
|
|
449
|
+
*/
|
|
450
|
+
completePairing: async (
|
|
451
|
+
c: Context,
|
|
452
|
+
input: {
|
|
453
|
+
completionSecret: string | null | undefined;
|
|
454
|
+
deviceKey: string | null | undefined;
|
|
455
|
+
appSession: AuthenticatedSession | null;
|
|
456
|
+
webUserId: string | null;
|
|
457
|
+
/** The request locale, kept for the "new phone paired" notice. */
|
|
458
|
+
locale?: string;
|
|
459
|
+
notify: PwaDeviceNotifier;
|
|
460
|
+
},
|
|
461
|
+
): Promise<PwaCompletion> => {
|
|
462
|
+
if (!input.completionSecret) return fail("EXPIRED", 410);
|
|
463
|
+
const [snapshot] = await sql<PairingRow[]>`
|
|
464
|
+
SELECT * FROM auth.pwa_pairings WHERE completion_secret_hash = ${pairingSecret.hash(input.completionSecret)}
|
|
465
|
+
`;
|
|
466
|
+
if (!snapshot || snapshot.state === "cancelled" || passed(snapshot.expires_at)) return fail("EXPIRED", 410);
|
|
467
|
+
if (snapshot.state === "claimed") {
|
|
468
|
+
const [user] = await sql<UserRow[]>`SELECT * FROM auth.users WHERE id = ${snapshot.user_id}::uuid`;
|
|
469
|
+
// Signing out on the web, or everywhere, voids the pairing at once.
|
|
470
|
+
if (!user || !snapshot.comparison || user.auth_epoch !== snapshot.auth_epoch || !(await initiatorValid(sql, snapshot)))
|
|
471
|
+
return fail("EXPIRED", 410);
|
|
472
|
+
return { state: "waiting", code: snapshot.comparison, account: { name: accountName(user) }, expiresAt: iso(snapshot.expires_at) };
|
|
473
|
+
}
|
|
474
|
+
if (snapshot.state !== "confirmed" && snapshot.state !== "completed") return fail("EXPIRED", 410);
|
|
475
|
+
// The device key of this app, read before the locks; it is rechecked under them.
|
|
476
|
+
const presented = await presentedDevice(input.deviceKey);
|
|
477
|
+
type Step = { kind: "already" } | { kind: "issued"; deviceKey: string; notice: PwaDevicePairedNotice | null };
|
|
478
|
+
const { value, token } = await session.issueInTransaction<Step>(c, async (tx, issue) => {
|
|
479
|
+
const user = await lockUser(tx, snapshot.user_id);
|
|
480
|
+
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot.id}::uuid FOR UPDATE`;
|
|
481
|
+
if (!user || !p || passed(p.expires_at) || user.auth_epoch !== p.auth_epoch) return fail("EXPIRED", 410);
|
|
482
|
+
const eligible = async () =>
|
|
483
|
+
!accountExpired(user) && (await isAccountCategoryAllowed(user, tx)) && (await legalAccepted(tx, user.id));
|
|
484
|
+
if (p.state === "completed" && p.device_id) {
|
|
485
|
+
// A lost response: answer again without creating a second device.
|
|
486
|
+
const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.pwa_devices WHERE id = ${p.device_id}::uuid FOR UPDATE`;
|
|
487
|
+
if (!device || !isActive(device, user)) return fail("EXPIRED", 410);
|
|
488
|
+
if (input.appSession?.data.deviceId === device.id) return { kind: "already" };
|
|
489
|
+
if (!(await eligible())) return fail("ACCOUNT_BLOCKED", 403);
|
|
490
|
+
await tx`UPDATE auth.session_families SET revoked_at = now(), revocation_reason = 'app_device_rotated'
|
|
491
|
+
WHERE pwa_device_id = ${device.id}::uuid AND revoked_at IS NULL`;
|
|
492
|
+
// Rotate, so whichever of two overlapping answers the phone keeps, its key still renews (§7.8).
|
|
493
|
+
const deviceKey = await newKey(tx, device.id, "rotate");
|
|
494
|
+
await issue(user.id, appSession(device.id));
|
|
495
|
+
await record(tx, "device.recover", { type: "pwa_device", id: device.id }, user.id);
|
|
496
|
+
// The first answer may have been lost before its notice went out.
|
|
497
|
+
const notice = device.notified_at ? null : pairedNotice(device, p.device_name ?? device.name);
|
|
498
|
+
return { kind: "issued", deviceKey, notice };
|
|
499
|
+
}
|
|
500
|
+
if (p.state !== "confirmed" || !p.platform || !p.device_name) return fail("EXPIRED", 410);
|
|
501
|
+
if (!(await initiatorValid(tx, p))) return fail("EXPIRED", 410);
|
|
502
|
+
if (!(await eligible())) return fail("ACCOUNT_BLOCKED", 403);
|
|
503
|
+
if (presented && presented.device.user_id !== user.id) return fail("ALREADY_PAIRED", 409);
|
|
504
|
+
if (presented) {
|
|
505
|
+
// Pairing again in the same app replaces its phone instead of adding a second one.
|
|
506
|
+
const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.pwa_devices WHERE id = ${presented.device.id}::uuid FOR UPDATE`;
|
|
507
|
+
if (device && matchKey(device, presented.secret)) await revokeDevice(tx, device, "replaced", user.id);
|
|
508
|
+
}
|
|
509
|
+
// iOS keeps the app's cookies apart from Safari, so a web session there is only a copy.
|
|
510
|
+
if (p.platform !== "ios" && input.webUserId && input.webUserId !== user.id) return fail("ACCOUNT_MISMATCH", 409);
|
|
511
|
+
const [active] = await tx<{ count: number }[]>`
|
|
512
|
+
SELECT count(*)::int AS count FROM auth.pwa_devices d WHERE d.user_id = ${user.id}::uuid AND ${activeDevice()}
|
|
513
|
+
`;
|
|
514
|
+
if ((active?.count ?? 0) >= limits.devicesPerAccount) return fail("LIMIT_REACHED", 429);
|
|
515
|
+
const deviceId = crypto.randomUUID();
|
|
516
|
+
const secret = pairingSecret.create();
|
|
517
|
+
// An unusually long locale tag is dropped; the notice then uses the operator's locale.
|
|
518
|
+
const locale = input.locale && input.locale.length <= 35 ? input.locale : null;
|
|
519
|
+
const [device] = await tx<DeviceRow[]>`
|
|
520
|
+
INSERT INTO auth.pwa_devices (id, user_id, name, platform, auth_epoch, secret_hash, rotated_at, locale)
|
|
521
|
+
VALUES (${deviceId}::uuid, ${user.id}::uuid, ${p.device_name}, ${p.platform}, ${user.auth_epoch}, ${pairingSecret.hash(secret)}, now(), ${locale})
|
|
522
|
+
RETURNING *`;
|
|
523
|
+
await issue(user.id, appSession(deviceId));
|
|
524
|
+
await tx`UPDATE auth.pwa_pairings SET state = 'completed', device_id = ${deviceId}::uuid WHERE id = ${p.id}::uuid`;
|
|
525
|
+
await record(tx, "device.enroll", { type: "pwa_device", id: deviceId }, user.id, { platform: p.platform, pairingId: p.id });
|
|
526
|
+
return { kind: "issued", deviceKey: `${deviceId}.${secret}`, notice: pairedNotice(device!, p.device_name) };
|
|
527
|
+
});
|
|
528
|
+
const platform = snapshot.platform ?? "other";
|
|
529
|
+
if (value.kind === "already") return { state: "paired", credentials: null, platform };
|
|
530
|
+
if (!token) throw new Error("App session was not issued");
|
|
531
|
+
if (value.notice) await announce(input.notify, value.notice);
|
|
532
|
+
return { state: "paired", credentials: { deviceKey: value.deviceKey, sessionToken: token }, platform };
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
/** Renewal with device-key rotation; `launch` and `renew` share it. */
|
|
536
|
+
renew: renewal,
|
|
537
|
+
|
|
538
|
+
rename: async (deviceKey: string | null | undefined, name: string) => {
|
|
539
|
+
const presented = await presentedDevice(deviceKey);
|
|
540
|
+
if (!presented) return fail("UNPAIRED", 401);
|
|
541
|
+
await sql.begin(async (tx) => {
|
|
542
|
+
const user = await lockUser(tx, presented.device.user_id);
|
|
543
|
+
const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.pwa_devices WHERE id = ${presented.device.id}::uuid FOR UPDATE`;
|
|
544
|
+
if (!user || !device || !isActive(device, user) || !matchKey(device, presented.secret)) return fail("UNPAIRED", 401);
|
|
545
|
+
await tx`UPDATE auth.pwa_devices SET name = ${name} WHERE id = ${device.id}::uuid`;
|
|
546
|
+
await record(tx, "device.rename", { type: "pwa_device", id: device.id }, user.id);
|
|
547
|
+
});
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
/** "Sign out of the app". Idempotent. */
|
|
551
|
+
unpair: async (deviceKey: string | null | undefined) => {
|
|
552
|
+
const presented = await presentedDevice(deviceKey);
|
|
553
|
+
if (!presented) return;
|
|
554
|
+
await sql.begin(async (tx) => {
|
|
555
|
+
await lockUser(tx, presented.device.user_id);
|
|
556
|
+
const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.pwa_devices WHERE id = ${presented.device.id}::uuid FOR UPDATE`;
|
|
557
|
+
if (!device || !matchKey(device, presented.secret)) return;
|
|
558
|
+
await revokeDevice(tx, device, "unpaired", device.user_id);
|
|
559
|
+
});
|
|
560
|
+
},
|
|
561
|
+
|
|
562
|
+
/** The phone of this request's app session, for the app's Settings. */
|
|
563
|
+
current: async (c: Context): Promise<PwaDeviceView | null> => {
|
|
564
|
+
const token = session.getAppToken(c);
|
|
565
|
+
const authenticated = token ? await session.authenticateRequest(c, token) : null;
|
|
566
|
+
if (authenticated?.data.kind !== "app" || !authenticated.data.deviceId) return null;
|
|
567
|
+
const [row] = await sql<DeviceRow[]>`
|
|
568
|
+
SELECT d.* FROM auth.pwa_devices d WHERE d.id = ${authenticated.data.deviceId}::uuid AND ${activeDevice()}
|
|
569
|
+
`;
|
|
570
|
+
return row ? view(row, row.id) : null;
|
|
571
|
+
},
|
|
572
|
+
|
|
573
|
+
// ---------- Administration ----------
|
|
574
|
+
|
|
575
|
+
listUserDevices: async (admin: PwaDeviceAdministrator, userId: string): Promise<PwaDeviceView[]> => {
|
|
576
|
+
if (!admin.admin) return fail("FORBIDDEN", 403);
|
|
577
|
+
const rows = await sql<DeviceRow[]>`
|
|
578
|
+
SELECT d.* FROM auth.pwa_devices d WHERE d.user_id = ${userId}::uuid AND ${activeDevice()}
|
|
579
|
+
ORDER BY d.created_at, d.id LIMIT ${limits.devicesPerAccount}
|
|
580
|
+
`;
|
|
581
|
+
return rows.map((row) => view(row));
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
/** Idempotent: `revoked` is true only for the call that revoked the phone. */
|
|
585
|
+
revokeUserDevice: async (admin: PwaDeviceAdministrator, userId: string, deviceId: string) => {
|
|
586
|
+
if (!admin.admin) return fail("FORBIDDEN", 403);
|
|
587
|
+
return sql.begin(async (tx) => {
|
|
588
|
+
await lockUser(tx, userId);
|
|
589
|
+
const [device] = await tx<DeviceRow[]>`
|
|
590
|
+
SELECT * FROM auth.pwa_devices WHERE id = ${deviceId}::uuid AND user_id = ${userId}::uuid FOR UPDATE
|
|
591
|
+
`;
|
|
592
|
+
if (!device) return fail("NOT_FOUND", 404);
|
|
593
|
+
return { revoked: await revokeDevice(tx, device, "admin", admin.userId) };
|
|
594
|
+
});
|
|
595
|
+
},
|
|
596
|
+
|
|
597
|
+
// ---------- Maintenance ----------
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Bounded to 100 rows per step; runs every minute from Core's scheduler. `notify` retries the
|
|
601
|
+
* "new phone paired" notices that completion could not send.
|
|
602
|
+
*/
|
|
603
|
+
maintain: async (notify: PwaDeviceNotifier, signal?: AbortSignal) => {
|
|
604
|
+
await sql`DELETE FROM auth.pwa_pairings WHERE id IN (
|
|
605
|
+
SELECT id FROM auth.pwa_pairings WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
606
|
+
signal?.throwIfAborted();
|
|
607
|
+
const expired = await sql<{ id: string; user_id: string }[]>`
|
|
608
|
+
SELECT d.id, d.user_id FROM auth.pwa_devices d JOIN auth.users u ON u.id = d.user_id
|
|
609
|
+
WHERE d.revoked_at IS NULL AND u.account_expires IS NOT NULL AND u.account_expires <= now()
|
|
610
|
+
ORDER BY d.id LIMIT 100
|
|
611
|
+
`;
|
|
612
|
+
for (const row of expired) {
|
|
613
|
+
signal?.throwIfAborted();
|
|
614
|
+
await sql.begin(async (tx) => {
|
|
615
|
+
const user = await lockUser(tx, row.user_id);
|
|
616
|
+
if (user && accountExpired(user)) await revokeDevice(tx, row, "account_expired", null);
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
signal?.throwIfAborted();
|
|
620
|
+
// Families are purged a day after expiry; a device row goes once none refers to it.
|
|
621
|
+
await sql`DELETE FROM auth.pwa_devices WHERE id IN (
|
|
622
|
+
SELECT d.id FROM auth.pwa_devices d
|
|
623
|
+
WHERE (d.revoked_at < now() - interval '30 days' OR d.rotated_at < now() - ${limits.idleDays + 30} * interval '1 day')
|
|
624
|
+
AND NOT EXISTS (SELECT 1 FROM auth.session_families f WHERE f.pwa_device_id = d.id)
|
|
625
|
+
ORDER BY d.id LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
626
|
+
signal?.throwIfAborted();
|
|
627
|
+
// Every pairing is announced, also of a phone removed since: it had access to the account.
|
|
628
|
+
const pending = await sql<DeviceRow[]>`
|
|
629
|
+
SELECT * FROM auth.pwa_devices WHERE notified_at IS NULL ORDER BY created_at, id LIMIT 100
|
|
630
|
+
`;
|
|
631
|
+
for (const row of pending) {
|
|
632
|
+
signal?.throwIfAborted();
|
|
633
|
+
// A failing notice stays pending for the next run and does not hold back the others.
|
|
634
|
+
await announce(notify, pairedNotice(row, row.name));
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
export const pwaDevices = createPwaDeviceService();
|