@saadnvd1/agent-os 0.3.560 → 0.3.564

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.
Files changed (62) hide show
  1. package/README.md +1 -0
  2. package/app/api/devices/[id]/route.ts +3 -6
  3. package/app/api/devices/me/route.ts +2 -2
  4. package/app/api/presence/device-key/options/route.ts +36 -0
  5. package/app/api/presence/device-key/route.ts +55 -0
  6. package/app/api/presence/enroll/route.ts +7 -15
  7. package/app/api/presence/options/route.ts +20 -0
  8. package/app/api/presence/passkeys/[id]/route.ts +11 -9
  9. package/app/api/shares/[id]/route.ts +23 -0
  10. package/app/api/shares/preview/route.ts +30 -0
  11. package/app/api/shares/route.test.ts +455 -0
  12. package/app/api/shares/route.ts +101 -0
  13. package/app/api/workspaces/[id]/orchestrator/asks/[askId]/route.ts +15 -10
  14. package/app/api/workspaces/[id]/orchestrator/pause/route.ts +12 -9
  15. package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +3 -3
  16. package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +10 -7
  17. package/app/api/workspaces/[id]/orchestrator/rules/route.ts +5 -5
  18. package/app/page.tsx +5 -0
  19. package/components/Chat/Artifact.tsx +17 -1
  20. package/components/Devices/PasskeysSection.tsx +4 -2
  21. package/components/Pane/MobileTabBar.tsx +19 -0
  22. package/components/Pane/PaneMenu.tsx +16 -0
  23. package/components/SessionList/SessionRowMenu.tsx +16 -0
  24. package/components/Settings/SettingsDialog.test.ts +3 -0
  25. package/components/Settings/SettingsDialog.tsx +9 -0
  26. package/components/Settings/commands.ts +9 -0
  27. package/components/Settings/entry-points.test.ts +1 -0
  28. package/components/Share/ShareDialog.tsx +336 -0
  29. package/components/Share/format.test.ts +20 -0
  30. package/components/Share/format.ts +33 -0
  31. package/components/Shares/SharedLinksPanel.tsx +147 -0
  32. package/data/shares/index.ts +2 -0
  33. package/data/shares/keys.ts +4 -0
  34. package/data/shares/queries.ts +111 -0
  35. package/lib/chat/drivers/claude.ts +5 -4
  36. package/lib/connect/control-api.ts +84 -0
  37. package/lib/db/migrations-legacy-checksums.json +90 -0
  38. package/lib/db/migrations.ts +120 -23
  39. package/lib/orchestrator/passkey-asks.ts +14 -0
  40. package/lib/orchestrator/untrusted.ts +12 -2
  41. package/lib/security/demo.ts +1 -0
  42. package/lib/security/device-key-message.ts +27 -0
  43. package/lib/security/device-keys.ts +75 -0
  44. package/lib/security/passkeys.ts +11 -4
  45. package/lib/security/presence-http.ts +49 -1
  46. package/lib/security/presence.ts +184 -0
  47. package/lib/security/soft-authenticator.ts +21 -0
  48. package/lib/session-audit/normalize.ts +1 -1
  49. package/lib/settings-url.ts +1 -0
  50. package/lib/share/connect.ts +86 -0
  51. package/lib/share/context.ts +50 -0
  52. package/lib/share/redact.ts +190 -0
  53. package/lib/share/render.tsx +161 -0
  54. package/lib/share/route.ts +33 -0
  55. package/lib/share/service.ts +159 -0
  56. package/lib/share/snapshot.ts +261 -0
  57. package/lib/share/static-markup.ts +16 -0
  58. package/lib/share/store.ts +39 -0
  59. package/lib/share/styles.ts +83 -0
  60. package/lib/sidebar/shelves.ts +10 -5
  61. package/package.json +1 -1
  62. package/stores/shareUi.ts +17 -0
@@ -0,0 +1,147 @@
1
+ "use client";
2
+
3
+ import { toast } from "sonner";
4
+ import { Copy, Link2 } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { writeClipboard } from "@/hooks/useCopyToClipboard";
7
+ import {
8
+ useRevokeShare,
9
+ useSharesQuery,
10
+ type SharedLinkRow,
11
+ } from "@/data/shares";
12
+ import { cn } from "@/lib/utils";
13
+
14
+ const when = (iso: string) =>
15
+ new Date(iso).toLocaleDateString(undefined, {
16
+ month: "short",
17
+ day: "numeric",
18
+ year: "numeric",
19
+ });
20
+
21
+ function Row({ share }: { share: SharedLinkRow }) {
22
+ const revoke = useRevokeShare();
23
+ const live = share.status === "active";
24
+
25
+ const copy = async () => {
26
+ try {
27
+ await writeClipboard(share.url);
28
+ toast.success("Link copied");
29
+ } catch {
30
+ toast.error("Could not copy to clipboard");
31
+ }
32
+ };
33
+ const onRevoke = () => {
34
+ if (
35
+ !window.confirm(
36
+ "Revoke this link? The page is deleted from Connect and the link stops working."
37
+ )
38
+ )
39
+ return;
40
+ revoke.mutate(share.id, {
41
+ onError: (err) => toast.error((err as Error).message),
42
+ });
43
+ };
44
+
45
+ const title =
46
+ share.kind === "chat" && share.sessionId ? (
47
+ <a
48
+ href={`/?session=${encodeURIComponent(share.sessionId)}`}
49
+ className="hover:underline"
50
+ >
51
+ {share.title}
52
+ </a>
53
+ ) : (
54
+ share.title
55
+ );
56
+
57
+ return (
58
+ <li className="bg-muted/40 flex flex-col gap-2 rounded-lg px-3 py-2.5 sm:flex-row sm:items-center">
59
+ <div className="min-w-0 flex-1">
60
+ <div className="truncate text-sm font-medium">{title}</div>
61
+ <div className="text-muted-foreground flex flex-wrap gap-x-3 text-xs">
62
+ <span>{share.kind === "chat" ? "Chat" : "Page"}</span>
63
+ <span>Created {when(share.createdAt)}</span>
64
+ <span>
65
+ {share.status === "revoked"
66
+ ? "Revoked"
67
+ : share.status === "expired"
68
+ ? "Expired"
69
+ : share.expiresAt
70
+ ? `Expires ${when(share.expiresAt)}`
71
+ : "Never expires"}
72
+ </span>
73
+ <span>
74
+ {share.views === null
75
+ ? "Views unavailable"
76
+ : `${share.views} ${share.views === 1 ? "view" : "views"}`}
77
+ </span>
78
+ </div>
79
+ </div>
80
+ <div className="flex shrink-0 gap-2">
81
+ <Button
82
+ variant="outline"
83
+ className={cn("min-h-11 sm:min-h-8", !live && "invisible")}
84
+ onClick={copy}
85
+ disabled={!live}
86
+ >
87
+ <Copy className="h-4 w-4" />
88
+ Copy link
89
+ </Button>
90
+ <Button
91
+ variant="outline"
92
+ className={cn(
93
+ "text-destructive min-h-11 sm:min-h-8",
94
+ !live && "invisible"
95
+ )}
96
+ onClick={onRevoke}
97
+ disabled={!live || revoke.isPending}
98
+ >
99
+ Revoke
100
+ </Button>
101
+ </div>
102
+ </li>
103
+ );
104
+ }
105
+
106
+ // Every snapshot link made from this machine, with views from Connect.
107
+ export function SharedLinksPanel() {
108
+ const { data, isLoading, error } = useSharesQuery();
109
+ return (
110
+ <div className="space-y-3">
111
+ <p className="text-muted-foreground text-sm">
112
+ Read-only snapshots you shared from a chat or a page. Revoking one
113
+ deletes it from Connect straight away.
114
+ </p>
115
+ {data?.connect === "unavailable" && (
116
+ <p className="text-muted-foreground bg-muted/40 rounded-lg px-3 py-2 text-xs">
117
+ Can&apos;t reach Connect, so views are hidden and revoking is
118
+ unavailable until it is back.
119
+ </p>
120
+ )}
121
+ {data?.connect === "unlinked" && data.shares.length > 0 && (
122
+ <p className="text-muted-foreground bg-muted/40 rounded-lg px-3 py-2 text-xs">
123
+ This machine isn&apos;t linked to Connect, so views are hidden and
124
+ revoking is unavailable.
125
+ </p>
126
+ )}
127
+ {error && (
128
+ <p className="text-destructive text-sm">{(error as Error).message}</p>
129
+ )}
130
+ {isLoading ? (
131
+ <p className="text-muted-foreground text-sm">Loading…</p>
132
+ ) : data?.shares.length ? (
133
+ <ul className="space-y-2">
134
+ {data.shares.map((s) => (
135
+ <Row key={s.id} share={s} />
136
+ ))}
137
+ </ul>
138
+ ) : (
139
+ <div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-dashed px-3 py-6 text-sm">
140
+ <Link2 className="h-4 w-4 shrink-0" />
141
+ Nothing shared yet. Use Share… in a chat&apos;s menu, or the share
142
+ button on a page.
143
+ </div>
144
+ )}
145
+ </div>
146
+ );
147
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./keys";
2
+ export * from "./queries";
@@ -0,0 +1,4 @@
1
+ export const shareKeys = {
2
+ all: ["shares"] as const,
3
+ list: () => [...shareKeys.all, "list"] as const,
4
+ };
@@ -0,0 +1,111 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { shareKeys } from "./keys";
3
+
4
+ async function json<T>(res: Response): Promise<T> {
5
+ const data = await res.json().catch(() => ({}));
6
+ if (!res.ok) throw new Error(data.error || "Request failed");
7
+ return data;
8
+ }
9
+
10
+ export type ShareExpiry = "1d" | "7d" | "30d" | "never";
11
+
12
+ export interface ShareTarget {
13
+ kind: "chat" | "artifact";
14
+ sessionId?: string;
15
+ artifactId?: string;
16
+ }
17
+
18
+ export interface PreviewRequest extends ShareTarget {
19
+ includeTools: boolean;
20
+ /** Messages left out of the share. */
21
+ dropIds?: string[];
22
+ }
23
+
24
+ export interface PreviewItem {
25
+ id: string;
26
+ type: string;
27
+ tools: boolean;
28
+ excerpt: string;
29
+ }
30
+
31
+ export interface SharePreview {
32
+ html: string;
33
+ title: string;
34
+ bytes: number;
35
+ hash: string;
36
+ sharedAt: string;
37
+ items: PreviewItem[];
38
+ hits: Record<"secret" | "path" | "ip" | "host" | "email", number>;
39
+ tooBig: boolean;
40
+ connectLinked: boolean;
41
+ }
42
+
43
+ const post = (url: string, body: unknown) =>
44
+ fetch(url, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify(body),
48
+ });
49
+
50
+ /** What would be uploaded, redacted. Local only. */
51
+ export function useSharePreview() {
52
+ return useMutation({
53
+ mutationFn: async (req: PreviewRequest) =>
54
+ json<SharePreview>(await post("/api/shares/preview", req)),
55
+ });
56
+ }
57
+
58
+ export function useCreateShare() {
59
+ const queryClient = useQueryClient();
60
+ return useMutation({
61
+ mutationFn: async (
62
+ req: PreviewRequest & {
63
+ sharedAt: string;
64
+ hash: string;
65
+ expiresIn: ShareExpiry;
66
+ }
67
+ ) =>
68
+ json<{ id: string; url: string; expiresAt: string | null }>(
69
+ await post("/api/shares", req)
70
+ ),
71
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: shareKeys.all }),
72
+ });
73
+ }
74
+
75
+ export interface SharedLinkRow {
76
+ id: string;
77
+ url: string;
78
+ kind: "chat" | "artifact";
79
+ title: string;
80
+ sessionId: string | null;
81
+ artifactId: string | null;
82
+ createdAt: string;
83
+ expiresAt: string | null;
84
+ status: "active" | "expired" | "revoked";
85
+ views: number | null;
86
+ lastViewedAt: string | null;
87
+ }
88
+
89
+ export interface SharesResponse {
90
+ connect: "ok" | "unavailable" | "unlinked";
91
+ shares: SharedLinkRow[];
92
+ }
93
+
94
+ export function useSharesQuery(enabled = true) {
95
+ return useQuery({
96
+ queryKey: shareKeys.list(),
97
+ queryFn: async () => json<SharesResponse>(await fetch("/api/shares")),
98
+ enabled,
99
+ });
100
+ }
101
+
102
+ export function useRevokeShare() {
103
+ const queryClient = useQueryClient();
104
+ return useMutation({
105
+ mutationFn: async (id: string) =>
106
+ json<{ ok: true }>(
107
+ await fetch(`/api/shares/${id}`, { method: "DELETE" })
108
+ ),
109
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: shareKeys.all }),
110
+ });
111
+ }
@@ -19,7 +19,11 @@ import { toChatContext, usageTotals } from "../context";
19
19
  import { cacheTtlMs } from "../stale";
20
20
  import { ClaudeMapper, toCommand, type ClaudeMessage } from "./claude-mapper";
21
21
  import { SuggestionTrace } from "./suggestion-trace";
22
- import { redact } from "../../orchestrator/untrusted";
22
+ import {
23
+ redact,
24
+ SECRET_PARAM,
25
+ URL_USERINFO,
26
+ } from "../../orchestrator/untrusted";
23
27
  import { NO_ATTRIBUTION } from "../../agents/attribution";
24
28
  import { callFromAssistant } from "../../usage/calls";
25
29
 
@@ -52,9 +56,6 @@ const firstLine = (text?: string) => {
52
56
 
53
57
  // A server's error text can quote its URL, and with it a key: shown and
54
58
  // stored redacted, on one line, and short.
55
- const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi;
56
- const SECRET_PARAM =
57
- /([?&][\w.-]*(?:key|token|secret|password|auth|sig|code)[\w.-]*=)[^&\s#]+/gi;
58
59
  function cleanError(text?: string): string | undefined {
59
60
  if (!text) return undefined;
60
61
  const line = redact(
@@ -264,3 +264,87 @@ export async function pushRelay(
264
264
  throw new Error("the Connect service sent no push results");
265
265
  return r.results;
266
266
  }
267
+
268
+ export type ShareStatus = "active" | "expired" | "revoked";
269
+
270
+ /** A snapshot link as Connect reports it. The link itself is never in a list. */
271
+ export interface RemoteShare {
272
+ id: string;
273
+ kind: string;
274
+ title: string;
275
+ status: ShareStatus;
276
+ byte_size: number;
277
+ view_count: number;
278
+ last_viewed_at: string | null;
279
+ created_at: string;
280
+ expires_at: string | null;
281
+ }
282
+
283
+ export type ShareExpiry = "1d" | "7d" | "30d" | "never";
284
+
285
+ const shareFrom = (r: unknown): RemoteShare => {
286
+ const s = (r ?? {}) as Record<string, unknown>;
287
+ if (typeof s.id !== "string" || typeof s.status !== "string")
288
+ throw new Error("the Connect service sent an unusable share");
289
+ return s as unknown as RemoteShare;
290
+ };
291
+
292
+ /** Uploads a rendered snapshot; the answer carries the public link, once. */
293
+ export async function uploadShare(
294
+ config: ConnectConfig,
295
+ privateKey: string,
296
+ share: {
297
+ kind: "chat" | "artifact";
298
+ title: string;
299
+ html: string;
300
+ expiresIn: ShareExpiry;
301
+ }
302
+ ): Promise<RemoteShare & { url: string }> {
303
+ const res = await signed(config, privateKey)(
304
+ "POST",
305
+ `/api/v1/machines/${config.machineId}/shares`,
306
+ {
307
+ kind: share.kind,
308
+ title: share.title,
309
+ html: share.html,
310
+ expires_in: share.expiresIn,
311
+ }
312
+ );
313
+ const r = (await res.json().catch(() => null)) as Record<
314
+ string,
315
+ unknown
316
+ > | null;
317
+ const url = r?.url;
318
+ if (typeof url !== "string" || !/^https?:\/\//.test(url))
319
+ throw new Error("the Connect service sent no link");
320
+ return { ...shareFrom(r), url };
321
+ }
322
+
323
+ export async function listShares(
324
+ config: ConnectConfig,
325
+ privateKey: string
326
+ ): Promise<RemoteShare[]> {
327
+ const res = await signed(config, privateKey)(
328
+ "GET",
329
+ `/api/v1/machines/${config.machineId}/shares`
330
+ );
331
+ const r = (await res.json().catch(() => null)) as {
332
+ shares?: unknown[];
333
+ } | null;
334
+ if (!Array.isArray(r?.shares))
335
+ throw new Error("the Connect service sent no share list");
336
+ return r.shares.map(shareFrom);
337
+ }
338
+
339
+ export async function revokeShare(
340
+ config: ConnectConfig,
341
+ privateKey: string,
342
+ id: string
343
+ ): Promise<RemoteShare> {
344
+ if (!/^[a-z0-9]{12}$/.test(id)) throw new Error("not a share id");
345
+ const res = await signed(config, privateKey)(
346
+ "DELETE",
347
+ `/api/v1/machines/${config.machineId}/shares/${id}`
348
+ );
349
+ return shareFrom(await res.json().catch(() => null));
350
+ }
@@ -0,0 +1,90 @@
1
+ {
2
+ "1": "f6d5756a104c16ec",
3
+ "2": "3699f5f3cd215455",
4
+ "3": "dc4efbc29c2976ef",
5
+ "4": "d3db3af92f123a13",
6
+ "5": "d5937aba3b590fed",
7
+ "6": "0c4668cf9a58cecd",
8
+ "7": "7f535265ae1ad37f",
9
+ "8": "5c24f600f5f73145",
10
+ "9": "99496923fc7ff333",
11
+ "10": "22162670e68726c0",
12
+ "11": "21cd323c1654f9a9",
13
+ "12": "d66b982154dc3ee2",
14
+ "13": "ba1c8c4e2486a74d",
15
+ "14": "2f9575eed78d3284",
16
+ "15": "4c2293b3766c0d84",
17
+ "16": "8e798e52aed97875",
18
+ "17": "a743501f00312926",
19
+ "18": "cb84130b195bfa63",
20
+ "19": "b0ba5d304ca64aba",
21
+ "20": "ef3e125d28bfa506",
22
+ "21": "542664a9bef5f215",
23
+ "22": "195a6a7a87776269",
24
+ "23": "e4b50e60ac0821fa",
25
+ "24": "afb9644f31cc7602",
26
+ "25": "52bb47e1eed4e8d4",
27
+ "26": "9b5247315a01bdbe",
28
+ "27": "9c34c46d26c93cc4",
29
+ "28": "83d816f925aa0aba",
30
+ "29": "5ce556a828f7510b",
31
+ "30": "3d1bc245c30dcf49",
32
+ "31": "250433b280fd8df1",
33
+ "32": "95ec3c8125969681",
34
+ "33": "711226e6a027f6cd",
35
+ "34": "d8c917aa6f3e00b6",
36
+ "35": "7db35660583c2a50",
37
+ "36": "7ff8363149a3952c",
38
+ "37": "b7e72a18d3398da6",
39
+ "38": "cea02e00d1262601",
40
+ "39": "61c77d0f357d71d6",
41
+ "40": "6cedcf70268ddd25",
42
+ "41": "3d9af0548ae91946",
43
+ "42": "410ef4562a046ce6",
44
+ "43": "b0fca4e05ff6499e",
45
+ "44": "06b7f4dc636c9675",
46
+ "45": "73948f256c522959",
47
+ "46": "202f8fd102ef2581",
48
+ "47": "510435d6afa487cd",
49
+ "48": "10727869e8c8dea1",
50
+ "49": "0384a86395f6650a",
51
+ "50": "cc05c3663ca18b2b",
52
+ "51": "b381268fe096fb02",
53
+ "52": "281ad10453d9177a",
54
+ "53": "a7a53074ed49a566",
55
+ "54": "2358b106da3e7b68",
56
+ "55": "478ece72deb01953",
57
+ "56": "d04bafcaa2eda941",
58
+ "57": "0f9bc09a7a31f64d",
59
+ "58": "ccbf7eebccadfa3f",
60
+ "59": "1859ac87155d546b",
61
+ "60": "035e204a4b6c457e",
62
+ "61": "85d9bb65b051409a",
63
+ "62": "723d9c60994dec97",
64
+ "63": "77dd17570476ee17",
65
+ "64": "de5f368a88af93de",
66
+ "65": "081cc6eaf666581b",
67
+ "66": "08eedfefd3d3f3f9",
68
+ "67": "e37ead69c14e2d01",
69
+ "68": "9ab2551e258f1588",
70
+ "69": "55edb197ed55afe1",
71
+ "70": "d8a4faf39318bd1d",
72
+ "71": "45b9b9ff57d2083d",
73
+ "72": "51b77e2e1c5c8ae6",
74
+ "73": "2878b9f0762ab53d",
75
+ "74": "c94cd8f644a8518f",
76
+ "75": "e082428a046f9e6b",
77
+ "76": "17f3160630d658dc",
78
+ "77": "cd54bfd73fbcd168",
79
+ "78": "8ad44a982b1eb58d",
80
+ "79": "0b09d57d6ea8ce0a",
81
+ "80": "941d85f2ef8bd012",
82
+ "81": "ecd85de2bdf45ff1",
83
+ "82": "07f11cdac5f8444a",
84
+ "83": "eabf72b6a8616eac",
85
+ "84": "1ed4fb30b7435f28",
86
+ "85": "a85ac25c0e34f2fb",
87
+ "86": "50b5725a67286dee",
88
+ "87": "6024c62bcdbb186e",
89
+ "88": "bb58ec622ae398e5"
90
+ }
@@ -10,14 +10,43 @@ import {
10
10
  import type Database from "better-sqlite3";
11
11
  import { moveSchedulesToRecipes } from "./schedules-move";
12
12
 
13
- interface Migration {
13
+ // HOW TO ADD A MIGRATION: append an object with a timestamp `key`, never an
14
+ // integer `id`: `key: "YYYYMMDDTHHMM_snake_name"` (UTC, e.g.
15
+ // "20261014T0130_share_links"). Hand-picked numbers collide when two tasks run
16
+ // in parallel and the loser is silently skipped on databases that already
17
+ // applied the winner. Ids 1..88 are legacy: they run first, by number, and
18
+ // never change. Timestamp keys run after them, in lexical order. Never edit a
19
+ // migration that has shipped; add a new one. lib/db/migrations.test.ts
20
+ // enforces all of this.
21
+ interface LegacyMigration {
14
22
  id: number;
23
+ key?: undefined;
15
24
  name: string;
16
25
  up: (db: Database.Database) => void;
17
26
  }
27
+ interface KeyedMigration {
28
+ key: string;
29
+ id?: undefined;
30
+ name?: undefined;
31
+ up: (db: Database.Database) => void;
32
+ }
33
+ export type Migration = LegacyMigration | KeyedMigration;
34
+
35
+ export const migrationKey = (m: Migration): string => m.key ?? String(m.id);
36
+ const migrationName = (m: Migration): string =>
37
+ m.name ?? (m.key as string).replace(/^\d{8}T\d{4}_/, "");
38
+
39
+ // Legacy ids sort before every timestamp key.
40
+ function compareToUpTo(m: Migration, upTo: number | string): number {
41
+ if (typeof upTo === "number") {
42
+ return m.id === undefined ? 1 : m.id - upTo;
43
+ }
44
+ if (m.id !== undefined) return -1;
45
+ return m.key < upTo ? -1 : m.key > upTo ? 1 : 0;
46
+ }
18
47
 
19
48
  // All migrations in order - never modify existing ones, only add new
20
- const migrations: Migration[] = [
49
+ export const migrations: Migration[] = [
21
50
  {
22
51
  id: 1,
23
52
  name: "add_group_path_to_sessions",
@@ -2114,12 +2143,57 @@ const migrations: Migration[] = [
2114
2143
  `);
2115
2144
  },
2116
2145
  },
2146
+ {
2147
+ id: 88,
2148
+ name: "shared_links",
2149
+ up: (db) => {
2150
+ // Snapshot links made through Connect. Connect keeps only a digest of
2151
+ // the token, so this is the one place the link can be copied again.
2152
+ db.exec(`
2153
+ CREATE TABLE IF NOT EXISTS shared_links (
2154
+ id TEXT PRIMARY KEY,
2155
+ url TEXT NOT NULL,
2156
+ kind TEXT NOT NULL,
2157
+ title TEXT NOT NULL,
2158
+ session_id TEXT,
2159
+ artifact_id TEXT,
2160
+ expires_at TEXT,
2161
+ created_at TEXT NOT NULL,
2162
+ revoked_at TEXT
2163
+ );
2164
+ CREATE INDEX IF NOT EXISTS idx_shared_links_created
2165
+ ON shared_links(created_at DESC);
2166
+ `);
2167
+ },
2168
+ },
2169
+ {
2170
+ id: 89,
2171
+ name: "passkeys_device_keys",
2172
+ up: (db) => {
2173
+ // A paired device's own approval key (Secure Enclave, Face ID) sits in
2174
+ // the passkeys table beside the browser passkeys: type 'device', bound
2175
+ // to the device that made it. Additive; existing rows read as webauthn.
2176
+ const columns = db.prepare(`PRAGMA table_info(passkeys)`).all() as {
2177
+ name: string;
2178
+ }[];
2179
+ if (!columns.some((c) => c.name === "type"))
2180
+ db.exec(
2181
+ `ALTER TABLE passkeys ADD COLUMN type TEXT NOT NULL DEFAULT 'webauthn'`
2182
+ );
2183
+ if (!columns.some((c) => c.name === "device_id"))
2184
+ db.exec(`ALTER TABLE passkeys ADD COLUMN device_id TEXT`);
2185
+ db.exec(
2186
+ `CREATE INDEX IF NOT EXISTS idx_passkeys_device ON passkeys(device_id)`
2187
+ );
2188
+ },
2189
+ },
2117
2190
  ];
2118
2191
 
2119
- // `upTo`: stop after this id (tests that start from an older database).
2192
+ // `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
2193
+ // start from an older database).
2120
2194
  export function runMigrations(
2121
2195
  db: Database.Database,
2122
- upTo = Number.POSITIVE_INFINITY
2196
+ upTo?: number | string
2123
2197
  ): void {
2124
2198
  // Create migrations tracking table
2125
2199
  db.exec(`
@@ -2129,31 +2203,59 @@ export function runMigrations(
2129
2203
  applied_at TEXT NOT NULL DEFAULT (datetime('now'))
2130
2204
  )
2131
2205
  `);
2206
+ // Timestamp-keyed rows get an auto id and carry their key here; legacy rows
2207
+ // (key NULL) keep meaning "applied" by their integer id.
2208
+ const cols = db.prepare(`PRAGMA table_info(_migrations)`).all() as {
2209
+ name: string;
2210
+ }[];
2211
+ if (!cols.some((c) => c.name === "key")) {
2212
+ try {
2213
+ db.exec(`ALTER TABLE _migrations ADD COLUMN key TEXT`);
2214
+ } catch (error) {
2215
+ if (!String(error).includes("duplicate column")) throw error;
2216
+ }
2217
+ }
2218
+ db.exec(
2219
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_key ON _migrations(key)`
2220
+ );
2132
2221
 
2133
- // Get already applied migrations
2134
2222
  const applied = new Set(
2135
- (db.prepare(`SELECT id FROM _migrations`).all() as { id: number }[]).map(
2136
- (r) => r.id
2137
- )
2223
+ (
2224
+ db.prepare(`SELECT id, key FROM _migrations`).all() as {
2225
+ id: number;
2226
+ key: string | null;
2227
+ }[]
2228
+ ).map((r) => r.key ?? String(r.id))
2138
2229
  );
2139
2230
 
2140
2231
  // Use INSERT OR IGNORE to handle concurrent workers
2141
- const insertMigration = db.prepare(
2232
+ const insertLegacy = db.prepare(
2142
2233
  `INSERT OR IGNORE INTO _migrations (id, name) VALUES (?, ?)`
2143
2234
  );
2235
+ const insertKeyed = db.prepare(
2236
+ `INSERT OR IGNORE INTO _migrations (key, name) VALUES (?, ?)`
2237
+ );
2238
+ const record = (m: Migration) =>
2239
+ m.key === undefined
2240
+ ? insertLegacy.run(m.id, m.name)
2241
+ : insertKeyed.run(m.key, migrationName(m));
2144
2242
 
2145
2243
  for (const migration of migrations) {
2146
- if (applied.has(migration.id) || migration.id > upTo) continue;
2244
+ const key = migrationKey(migration);
2245
+ const name = migrationName(migration);
2246
+ if (
2247
+ applied.has(key) ||
2248
+ (upTo !== undefined && compareToUpTo(migration, upTo) > 0)
2249
+ )
2250
+ continue;
2147
2251
 
2148
2252
  try {
2149
2253
  migration.up(db);
2150
- const result = insertMigration.run(migration.id, migration.name);
2254
+ const result = record(migration);
2151
2255
  if (result.changes > 0) {
2152
- console.log(`Migration ${migration.id}: ${migration.name} applied`);
2256
+ console.log(`Migration ${key}: ${name} applied`);
2153
2257
  } else {
2154
- console.log(
2155
- `Migration ${migration.id}: ${migration.name} skipped (concurrent apply)`
2156
- );
2258
+ console.log(`Migration ${key}: ${name} skipped (concurrent apply)`);
2157
2259
  }
2158
2260
  } catch (error) {
2159
2261
  // Some migrations may fail if columns already exist (from old system or concurrent worker)
@@ -2163,15 +2265,10 @@ export function runMigrations(
2163
2265
  errorMsg.includes("duplicate column") ||
2164
2266
  errorMsg.includes("already exists")
2165
2267
  ) {
2166
- insertMigration.run(migration.id, migration.name);
2167
- console.log(
2168
- `Migration ${migration.id}: ${migration.name} skipped (already applied)`
2169
- );
2268
+ record(migration);
2269
+ console.log(`Migration ${key}: ${name} skipped (already applied)`);
2170
2270
  } else {
2171
- console.error(
2172
- `Migration ${migration.id}: ${migration.name} failed:`,
2173
- error
2174
- );
2271
+ console.error(`Migration ${key}: ${name} failed:`, error);
2175
2272
  throw error;
2176
2273
  }
2177
2274
  }
@@ -3,8 +3,11 @@
3
3
  // Declining a new passkey's ask revokes it.
4
4
 
5
5
  import type { PasskeyRow } from "../security/passkeys";
6
+ import { revokeDeviceKeys } from "../security/device-keys";
7
+ import { revokeDevice } from "../security/devices";
6
8
  import {
7
9
  AskRefused,
10
+ resolvePasskeyAsks,
8
11
  passkeySubject,
9
12
  raiseAsk,
10
13
  revokedPasskeySubject,
@@ -54,3 +57,14 @@ export function raisePasskeyAsks(
54
57
  }
55
58
  return n;
56
59
  }
60
+
61
+ // Signing a device out takes its approval key with it, and the revoke goes
62
+ // on the asks list like any other.
63
+ export function revokeDeviceAndKeys(deviceId: string, place: string): boolean {
64
+ const changed = revokeDevice(deviceId);
65
+ for (const key of revokeDeviceKeys(deviceId)) {
66
+ resolvePasskeyAsks(key.id, "revoked");
67
+ raisePasskeyAsks(key, place, "revoked");
68
+ }
69
+ return changed;
70
+ }