sealkeep 0.5.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.
Files changed (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
@@ -0,0 +1,3 @@
1
+ export function isV2(record) {
2
+ return record.version === 2;
3
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The name to show a person, as opposed to the address to bind.
3
+ *
4
+ * `http://127.0.0.1:54142` is a correct URL and a terrible one: it looks like
5
+ * something has gone wrong, it cannot be read out loud, and the port changed
6
+ * every run because the server asked the kernel for whatever was free. The
7
+ * server still binds the loopback address — nothing is exposed by naming it —
8
+ * but the URL people see, bookmark and type is a name and a fixed port.
9
+ *
10
+ * `localhost` rather than something prettier because it is the only name that
11
+ * resolves everywhere without touching the system. `vault.localhost` is
12
+ * resolved internally by Chrome but not by the macOS resolver, so Safari would
13
+ * fail on it; `vault.line` needs an /etc/hosts line, which needs a password,
14
+ * which is not something this should take. `sealkeep ui hostname` prints that
15
+ * line for anyone who wants the nicer name and is happy to install it.
16
+ */
17
+ export declare const DISPLAY_HOST = "localhost";
18
+ /**
19
+ * A fixed default port, so the address is the same tomorrow as it was today.
20
+ *
21
+ * 7477 spells "VLIN"-ish on a keypad, sits well above the privileged range, and
22
+ * is not claimed by anything in the IANA registry. When it is busy the server
23
+ * still falls back to a free port rather than refusing to start: a working
24
+ * window on an odd port beats a correct error.
25
+ */
26
+ export declare const DEFAULT_UI_PORT = 7477;
27
+ /** The hostnames this service answers to, mirrored by ALLOWED_HOSTS in local-api.ts. */
28
+ export declare const UI_HOSTNAMES: readonly ["localhost", "127.0.0.1", "vault.line", "vault.localhost"];
29
+ /**
30
+ * Where a running server announces itself, so a second `sealkeep ui` finds it.
31
+ *
32
+ * It lives in the vault's own data dir, which is what makes it per-vault: two
33
+ * vaults on one machine have two data dirs and never see each other's server.
34
+ * It holds a port and a pid — no secret — but keeps the 0600 of everything
35
+ * else in there rather than being the one loose file.
36
+ */
37
+ export declare const uiRecordPath: (dataDir: string) => string;
38
+ export type UiRecord = {
39
+ port: number;
40
+ pid: number;
41
+ startedAt: string;
42
+ };
43
+ /** Anything unreadable, truncated, or not describing a usable port reads as "no server", never as an error. */
44
+ export declare function readUiRecord(dataDir: string): Promise<UiRecord | null>;
45
+ /**
46
+ * Written in place rather than through a temp file and a rename: a half-written
47
+ * record fails to parse, and failing to parse already means "start a fresh
48
+ * server", which is the safe answer. Atomicity would buy nothing.
49
+ */
50
+ export declare function writeUiRecord(dataDir: string, record: UiRecord): Promise<void>;
51
+ /**
52
+ * Removes the record only when it still names this server's port. Two servers
53
+ * can exist at once (a second one starts whenever the first is unreachable),
54
+ * and the first to shut down must not delete the survivor's announcement.
55
+ */
56
+ export declare function clearUiRecord(dataDir: string, port?: number): Promise<void>;
57
+ /** Where a person goes. Bound on loopback, named so it can be read and typed. */
58
+ export declare const uiOrigin: (port: number, host?: string) => string;
59
+ /** Where this process talks to itself. Always the address, never a name that has to resolve. */
60
+ export declare const uiProbeOrigin: (port: number) => string;
61
+ /**
62
+ * How the browser gets authenticated: the token rides in the URL fragment.
63
+ *
64
+ * A fragment is the only part of a URL a browser never transmits. It is not in
65
+ * the request line, so it cannot reach an access log, a proxy, or a Referer
66
+ * header on any link the page later follows — and `web/app.js` reads it once,
67
+ * moves it into sessionStorage, and history.replaceState()s it out of the
68
+ * address bar, so it does not survive in browser history either. A `?token=`
69
+ * query string would have failed every one of those.
70
+ *
71
+ * The alternative — print the token and have someone paste it into the gate —
72
+ * is the terminal step this whole command exists to remove, and it puts the
73
+ * secret in scrollback, in `script` logs, and in any pasted transcript.
74
+ *
75
+ * What this does cost: the URL is an argv of the opener process, so it is
76
+ * visible to `ps` for the few milliseconds that process lives. That is
77
+ * accepted rather than overlooked — the token grants nothing that a local
78
+ * process running as this user does not already have, since it can read
79
+ * <data-dir>/local-api-token directly. Scrollback is the leak that outlives
80
+ * the session; argv is not.
81
+ */
82
+ export declare const uiUrl: (port: number, token: string) => string;
83
+ export type BrowserResult = {
84
+ opened: boolean;
85
+ /** The opener that ran, for a message that names what failed rather than "something went wrong". */
86
+ command: string | null;
87
+ problem?: string;
88
+ /** True when no browser was wanted (`--no-browser`), which is a choice, not a failure to warn about. */
89
+ declined?: boolean;
90
+ };
91
+ /**
92
+ * Which command opens a URL here.
93
+ *
94
+ * Three platforms, three answers, and one of them is not an executable:
95
+ * Windows `start` is a cmd.exe builtin, so it has to be invoked through cmd —
96
+ * spawning "start" directly fails with ENOENT. The empty "" that follows is
97
+ * `start`'s window-title argument, which it would otherwise take from a quoted
98
+ * URL and then open nothing.
99
+ *
100
+ * `BROWSER` is honoured first because it is the existing convention for
101
+ * exactly this (xdg-open reads it, as do most tools that open pages), and
102
+ * `BROWSER=none` is how a headless box or a CI job says "do not launch
103
+ * anything" — answered here with null, which callers degrade to printing the
104
+ * URL.
105
+ */
106
+ export declare function openerFor(url: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): {
107
+ command: string;
108
+ args: string[];
109
+ } | null;
110
+ /**
111
+ * Launches a browser without ever waiting for it.
112
+ *
113
+ * The child is detached and unref'd because `xdg-open` can stay in the
114
+ * foreground for the browser's entire life on some desktops: waiting for it
115
+ * would hold "here is your URL, Ctrl-C to stop" hostage until the person
116
+ * closed their browser, and killing it on a timeout could take the browser
117
+ * with it. So only an *immediate* failure is worth listening for — a missing
118
+ * opener (ENOENT) or one that gives up straight away (xdg-open's exit 3, "no
119
+ * method available") — and after a short grace period the window is assumed to
120
+ * be on its way.
121
+ *
122
+ * Nothing here throws. A browser that will not open is a message to print, not
123
+ * a reason to fail a command that has already started a working server.
124
+ */
125
+ export declare function openInBrowser(url: string, options?: {
126
+ env?: NodeJS.ProcessEnv;
127
+ platform?: NodeJS.Platform;
128
+ graceMs?: number;
129
+ }): Promise<BrowserResult>;
130
+ /**
131
+ * Is a Sealkeep local API still listening on `port`, and is it *this* vault's?
132
+ *
133
+ * Two questions, because a recorded port answers neither. A port is reused by
134
+ * whatever process grabs it next, so `/health` first establishes that the
135
+ * listener is one of ours at all. Then a `/v1/status` call carrying this
136
+ * vault's token establishes that it reads the same token file — that is, that
137
+ * it serves this data dir and not a second vault's. 401 is the only answer
138
+ * that means "not this vault": every other status got past the token check,
139
+ * including the 404 a data dir with no vault yet would return.
140
+ */
141
+ export declare function servesThisVault(port: number, token: string, fetchImpl?: typeof fetch): Promise<boolean>;
142
+ export type UiHandle = {
143
+ port: number;
144
+ origin: string;
145
+ /**
146
+ * The address actually bound, which is the thing that decides who can reach
147
+ * this — as opposed to `origin`, which is only the name shown to a person.
148
+ * Exposed so "loopback only" can be asserted rather than inferred from how
149
+ * the URL happens to be spelled.
150
+ */
151
+ address: string;
152
+ /** The full URL including the token fragment. Print it only when no browser could be opened. */
153
+ url: string;
154
+ /** True when an already-running server was reused; nothing new was started and close() is a no-op. */
155
+ reused: boolean;
156
+ /** Set when reused, so a caller can say who owns the server it just pointed at. */
157
+ running: UiRecord | null;
158
+ browser: BrowserResult;
159
+ close: () => Promise<void>;
160
+ };
161
+ export type UiOptions = {
162
+ /** 0 — the default — asks the OS for an ephemeral port, so two vaults never fight over one. */
163
+ port?: number;
164
+ /** False for a headless surface that serves the API but has no window to open. */
165
+ openBrowser?: boolean;
166
+ /** Injected by tests so a suite never launches a real browser. */
167
+ open?: (url: string) => Promise<BrowserResult>;
168
+ /** Injected by tests that need to control what the reuse probe sees. */
169
+ fetchImpl?: typeof fetch;
170
+ /**
171
+ * False for `sealkeep api`, whose port is a stable address other tools are
172
+ * pointed at — handing it back an ephemeral one because a window happened to
173
+ * be open would break the thing that makes it useful. It still announces
174
+ * itself, so a later `sealkeep ui` finds it rather than starting a second.
175
+ */
176
+ reuse?: boolean;
177
+ };
178
+ /**
179
+ * Starts the local UI, or points at the one already running for this vault.
180
+ *
181
+ * Reuse is not an optimisation: a second server would mint no second token but
182
+ * would leave two listeners and two records for one vault, and whichever one
183
+ * the person closed first would look like "the UI stopped working". Finding
184
+ * the running one and opening a browser at it is the behaviour that matches
185
+ * what someone typing the command twice actually meant.
186
+ */
187
+ export declare function startUi(dataDir: string, options?: UiOptions): Promise<UiHandle>;
@@ -0,0 +1,293 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { createLocalApiServer, localApiToken } from "./local-api.js";
5
+ /**
6
+ * The GUI, reachable without typing a command.
7
+ *
8
+ * PRODUCT_ARCHITECTURE.md §5: the terminal is the free tier's interface, not
9
+ * the product's. `src/local-api.ts` already serves the settings surface and
10
+ * `web/` already draws it — what was missing was a way to get a browser
11
+ * pointed at it, authenticated, without a person copying a token by hand.
12
+ *
13
+ * This module is the plumbing for that and nothing else. It starts (or finds)
14
+ * the loopback server, builds the URL that carries the token, and launches a
15
+ * browser. Every line printed to a terminal lives in cli.ts, so this stays
16
+ * usable by any surface that is not a terminal — the native shell in §5 v2
17
+ * being the obvious one.
18
+ */
19
+ const LOOPBACK_HOST = "127.0.0.1";
20
+ /**
21
+ * The name to show a person, as opposed to the address to bind.
22
+ *
23
+ * `http://127.0.0.1:54142` is a correct URL and a terrible one: it looks like
24
+ * something has gone wrong, it cannot be read out loud, and the port changed
25
+ * every run because the server asked the kernel for whatever was free. The
26
+ * server still binds the loopback address — nothing is exposed by naming it —
27
+ * but the URL people see, bookmark and type is a name and a fixed port.
28
+ *
29
+ * `localhost` rather than something prettier because it is the only name that
30
+ * resolves everywhere without touching the system. `vault.localhost` is
31
+ * resolved internally by Chrome but not by the macOS resolver, so Safari would
32
+ * fail on it; `vault.line` needs an /etc/hosts line, which needs a password,
33
+ * which is not something this should take. `sealkeep ui hostname` prints that
34
+ * line for anyone who wants the nicer name and is happy to install it.
35
+ */
36
+ export const DISPLAY_HOST = "localhost";
37
+ /**
38
+ * A fixed default port, so the address is the same tomorrow as it was today.
39
+ *
40
+ * 7477 spells "VLIN"-ish on a keypad, sits well above the privileged range, and
41
+ * is not claimed by anything in the IANA registry. When it is busy the server
42
+ * still falls back to a free port rather than refusing to start: a working
43
+ * window on an odd port beats a correct error.
44
+ */
45
+ export const DEFAULT_UI_PORT = 7477;
46
+ /** The hostnames this service answers to, mirrored by ALLOWED_HOSTS in local-api.ts. */
47
+ export const UI_HOSTNAMES = ["localhost", "127.0.0.1", "vault.line", "vault.localhost"];
48
+ /** Loopback round trips are sub-millisecond; anything slower is a listener that has stopped answering. */
49
+ const PROBE_TIMEOUT_MS = 1_500;
50
+ /** How long to wait for an opener to fail before assuming the window is on its way. See openInBrowser. */
51
+ const OPENER_GRACE_MS = 400;
52
+ /**
53
+ * Where a running server announces itself, so a second `sealkeep ui` finds it.
54
+ *
55
+ * It lives in the vault's own data dir, which is what makes it per-vault: two
56
+ * vaults on one machine have two data dirs and never see each other's server.
57
+ * It holds a port and a pid — no secret — but keeps the 0600 of everything
58
+ * else in there rather than being the one loose file.
59
+ */
60
+ export const uiRecordPath = (dataDir) => join(dataDir, "ui-server.json");
61
+ /** Anything unreadable, truncated, or not describing a usable port reads as "no server", never as an error. */
62
+ export async function readUiRecord(dataDir) {
63
+ try {
64
+ const saved = JSON.parse(await readFile(uiRecordPath(dataDir), "utf8"));
65
+ const port = Number(saved?.port);
66
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
67
+ return null;
68
+ return { port, pid: Number(saved?.pid) || 0, startedAt: String(saved?.startedAt ?? "") };
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /**
75
+ * Written in place rather than through a temp file and a rename: a half-written
76
+ * record fails to parse, and failing to parse already means "start a fresh
77
+ * server", which is the safe answer. Atomicity would buy nothing.
78
+ */
79
+ export async function writeUiRecord(dataDir, record) {
80
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
81
+ await writeFile(uiRecordPath(dataDir), JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
82
+ }
83
+ /**
84
+ * Removes the record only when it still names this server's port. Two servers
85
+ * can exist at once (a second one starts whenever the first is unreachable),
86
+ * and the first to shut down must not delete the survivor's announcement.
87
+ */
88
+ export async function clearUiRecord(dataDir, port) {
89
+ if (port !== undefined) {
90
+ const recorded = await readUiRecord(dataDir);
91
+ if (recorded && recorded.port !== port)
92
+ return;
93
+ }
94
+ await rm(uiRecordPath(dataDir), { force: true });
95
+ }
96
+ /** Where a person goes. Bound on loopback, named so it can be read and typed. */
97
+ export const uiOrigin = (port, host = DISPLAY_HOST) => `http://${host}:${port}`;
98
+ /** Where this process talks to itself. Always the address, never a name that has to resolve. */
99
+ export const uiProbeOrigin = (port) => `http://${LOOPBACK_HOST}:${port}`;
100
+ /**
101
+ * How the browser gets authenticated: the token rides in the URL fragment.
102
+ *
103
+ * A fragment is the only part of a URL a browser never transmits. It is not in
104
+ * the request line, so it cannot reach an access log, a proxy, or a Referer
105
+ * header on any link the page later follows — and `web/app.js` reads it once,
106
+ * moves it into sessionStorage, and history.replaceState()s it out of the
107
+ * address bar, so it does not survive in browser history either. A `?token=`
108
+ * query string would have failed every one of those.
109
+ *
110
+ * The alternative — print the token and have someone paste it into the gate —
111
+ * is the terminal step this whole command exists to remove, and it puts the
112
+ * secret in scrollback, in `script` logs, and in any pasted transcript.
113
+ *
114
+ * What this does cost: the URL is an argv of the opener process, so it is
115
+ * visible to `ps` for the few milliseconds that process lives. That is
116
+ * accepted rather than overlooked — the token grants nothing that a local
117
+ * process running as this user does not already have, since it can read
118
+ * <data-dir>/local-api-token directly. Scrollback is the leak that outlives
119
+ * the session; argv is not.
120
+ */
121
+ export const uiUrl = (port, token) => `${uiOrigin(port)}/#token=${encodeURIComponent(token)}`;
122
+ /**
123
+ * Which command opens a URL here.
124
+ *
125
+ * Three platforms, three answers, and one of them is not an executable:
126
+ * Windows `start` is a cmd.exe builtin, so it has to be invoked through cmd —
127
+ * spawning "start" directly fails with ENOENT. The empty "" that follows is
128
+ * `start`'s window-title argument, which it would otherwise take from a quoted
129
+ * URL and then open nothing.
130
+ *
131
+ * `BROWSER` is honoured first because it is the existing convention for
132
+ * exactly this (xdg-open reads it, as do most tools that open pages), and
133
+ * `BROWSER=none` is how a headless box or a CI job says "do not launch
134
+ * anything" — answered here with null, which callers degrade to printing the
135
+ * URL.
136
+ */
137
+ export function openerFor(url, env = process.env, platform = process.platform) {
138
+ const preference = env.BROWSER?.trim();
139
+ if (preference)
140
+ return preference.toLowerCase() === "none" ? null : { command: preference, args: [url] };
141
+ if (platform === "darwin")
142
+ return { command: "open", args: [url] };
143
+ if (platform === "win32")
144
+ return { command: "cmd", args: ["/c", "start", "", url] };
145
+ return { command: "xdg-open", args: [url] };
146
+ }
147
+ /**
148
+ * Launches a browser without ever waiting for it.
149
+ *
150
+ * The child is detached and unref'd because `xdg-open` can stay in the
151
+ * foreground for the browser's entire life on some desktops: waiting for it
152
+ * would hold "here is your URL, Ctrl-C to stop" hostage until the person
153
+ * closed their browser, and killing it on a timeout could take the browser
154
+ * with it. So only an *immediate* failure is worth listening for — a missing
155
+ * opener (ENOENT) or one that gives up straight away (xdg-open's exit 3, "no
156
+ * method available") — and after a short grace period the window is assumed to
157
+ * be on its way.
158
+ *
159
+ * Nothing here throws. A browser that will not open is a message to print, not
160
+ * a reason to fail a command that has already started a working server.
161
+ */
162
+ export async function openInBrowser(url, options = {}) {
163
+ const opener = openerFor(url, options.env ?? process.env, options.platform ?? process.platform);
164
+ if (!opener)
165
+ return { opened: false, command: null, problem: "BROWSER is set to none, so nothing was launched" };
166
+ return new Promise((resolve) => {
167
+ let settled = false;
168
+ let timer;
169
+ const finish = (result) => {
170
+ if (settled)
171
+ return;
172
+ settled = true;
173
+ if (timer)
174
+ clearTimeout(timer);
175
+ resolve(result);
176
+ };
177
+ let child;
178
+ try {
179
+ child = spawn(opener.command, opener.args, { detached: true, stdio: "ignore", windowsHide: true });
180
+ }
181
+ catch (error) {
182
+ finish({ opened: false, command: opener.command, problem: error instanceof Error ? error.message : "could not start a browser" });
183
+ return;
184
+ }
185
+ timer = setTimeout(() => finish({ opened: true, command: opener.command }), options.graceMs ?? OPENER_GRACE_MS);
186
+ child.once("error", (error) => finish({ opened: false, command: opener.command, problem: error.message }));
187
+ child.once("exit", (code) => {
188
+ if (code !== null && code !== 0)
189
+ finish({ opened: false, command: opener.command, problem: `${opener.command} exited ${code}` });
190
+ });
191
+ child.unref();
192
+ });
193
+ }
194
+ /**
195
+ * Is a Sealkeep local API still listening on `port`, and is it *this* vault's?
196
+ *
197
+ * Two questions, because a recorded port answers neither. A port is reused by
198
+ * whatever process grabs it next, so `/health` first establishes that the
199
+ * listener is one of ours at all. Then a `/v1/status` call carrying this
200
+ * vault's token establishes that it reads the same token file — that is, that
201
+ * it serves this data dir and not a second vault's. 401 is the only answer
202
+ * that means "not this vault": every other status got past the token check,
203
+ * including the 404 a data dir with no vault yet would return.
204
+ */
205
+ export async function servesThisVault(port, token, fetchImpl = fetch) {
206
+ try {
207
+ const health = await fetchImpl(`${uiProbeOrigin(port)}/health`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
208
+ if (!health.ok)
209
+ return false;
210
+ const body = await health.json().catch(() => null);
211
+ if (body?.mode !== "local-api")
212
+ return false;
213
+ const authorized = await fetchImpl(`${uiProbeOrigin(port)}/v1/status`, {
214
+ headers: { authorization: `Bearer ${token}` },
215
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
216
+ });
217
+ return authorized.status !== 401;
218
+ }
219
+ catch {
220
+ return false;
221
+ }
222
+ }
223
+ /**
224
+ * Starts the local UI, or points at the one already running for this vault.
225
+ *
226
+ * Reuse is not an optimisation: a second server would mint no second token but
227
+ * would leave two listeners and two records for one vault, and whichever one
228
+ * the person closed first would look like "the UI stopped working". Finding
229
+ * the running one and opening a browser at it is the behaviour that matches
230
+ * what someone typing the command twice actually meant.
231
+ */
232
+ export async function startUi(dataDir, options = {}) {
233
+ const token = await localApiToken(dataDir);
234
+ const open = options.open ?? ((url) => openInBrowser(url));
235
+ const wantsBrowser = options.openBrowser ?? true;
236
+ const noBrowser = { opened: false, command: null, declined: true };
237
+ /**
238
+ * A browser is the last step and the least important one: by the time it
239
+ * runs there is a working server that a person can still reach by hand. So
240
+ * no failure here — not even an opener that throws rather than reporting —
241
+ * is allowed to escape and leave that server running with nobody told about it.
242
+ */
243
+ const launch = async (url) => {
244
+ if (!wantsBrowser)
245
+ return noBrowser;
246
+ try {
247
+ return await open(url);
248
+ }
249
+ catch (error) {
250
+ return { opened: false, command: null, problem: error instanceof Error ? error.message : "could not open a browser" };
251
+ }
252
+ };
253
+ const recorded = (options.reuse ?? true) ? await readUiRecord(dataDir) : null;
254
+ if (recorded && await servesThisVault(recorded.port, token, options.fetchImpl ?? fetch)) {
255
+ const url = uiUrl(recorded.port, token);
256
+ return {
257
+ port: recorded.port, origin: uiOrigin(recorded.port), address: LOOPBACK_HOST, url, reused: true, running: recorded,
258
+ browser: await launch(url),
259
+ // Another process owns that server; stopping it from here would close a
260
+ // window somebody else's terminal claims to be holding open.
261
+ close: async () => { }
262
+ };
263
+ }
264
+ const server = createLocalApiServer(dataDir, token);
265
+ // The stable port is a preference, not a requirement. Something else holding
266
+ // 7477 is not this user's problem to solve before they can see their vault,
267
+ // so a busy port falls back to whatever is free rather than failing.
268
+ const wanted = options.port ?? DEFAULT_UI_PORT;
269
+ await new Promise((ready, broken) => {
270
+ const settle = () => { server.off("error", retry); ready(); };
271
+ const retry = (error) => {
272
+ if (wanted === 0 || (error.code !== "EADDRINUSE" && error.code !== "EACCES")) {
273
+ broken(error);
274
+ return;
275
+ }
276
+ server.once("error", broken);
277
+ server.listen(0, LOOPBACK_HOST, settle);
278
+ };
279
+ server.once("error", retry);
280
+ server.listen(wanted, LOOPBACK_HOST, settle);
281
+ });
282
+ const { port } = server.address();
283
+ await writeUiRecord(dataDir, { port, pid: process.pid, startedAt: new Date().toISOString() });
284
+ const url = uiUrl(port, token);
285
+ return {
286
+ port, origin: uiOrigin(port), address: server.address().address, url, reused: false, running: null,
287
+ browser: await launch(url),
288
+ close: async () => {
289
+ await clearUiRecord(dataDir, port);
290
+ await new Promise((done) => server.close(() => done()));
291
+ }
292
+ };
293
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Terminal presentation.
3
+ *
4
+ * Every command prints for a person by default and for a machine with `--json`.
5
+ * Colour is used to encode one thing only — state — so output stays readable when
6
+ * it is piped, redirected, or read by someone who cannot distinguish the hues.
7
+ */
8
+ export declare const bold: (value: string) => string;
9
+ export declare const dim: (value: string) => string;
10
+ export declare const blue: (value: string) => string;
11
+ export declare const green: (value: string) => string;
12
+ export declare const amber: (value: string) => string;
13
+ export declare const red: (value: string) => string;
14
+ export declare const underline: (value: string) => string;
15
+ /** State markers. Text-first so they survive `NO_COLOR` and screen readers. */
16
+ export declare const mark: {
17
+ ok: () => string;
18
+ warn: () => string;
19
+ fail: () => string;
20
+ bullet: () => string;
21
+ };
22
+ export declare function heading(text: string): string;
23
+ /** The recurring device: the line past which plaintext does not travel. */
24
+ export declare function vaultline(label?: string): string;
25
+ export declare function keyValue(pairs: [string, string][], indent?: string): string;
26
+ export declare function bytes(value: number): string;
27
+ export declare function relativeTime(iso: string): string;
28
+ /** Shortens a path from the left, so the filename always survives. */
29
+ export declare function shortPath(path: string, width?: number): string;
30
+ export type Column<T> = {
31
+ header: string;
32
+ get: (row: T) => string;
33
+ align?: "left" | "right";
34
+ };
35
+ export declare function table<T>(rows: readonly T[], columns: Column<T>[], empty: string): string;
36
+ export declare function stripAnsi(value: string): string;
37
+ /** Frames something the user must copy down before continuing. */
38
+ export declare function callout(title: string, body: string[]): string;
39
+ export declare function steps(items: string[]): string;
40
+ export declare function hint(text: string): string;
41
+ export declare function command(text: string): string;
package/dist/src/ui.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Terminal presentation.
3
+ *
4
+ * Every command prints for a person by default and for a machine with `--json`.
5
+ * Colour is used to encode one thing only — state — so output stays readable when
6
+ * it is piped, redirected, or read by someone who cannot distinguish the hues.
7
+ */
8
+ const NO_COLOR = process.env.NO_COLOR !== undefined || process.env.TERM === "dumb";
9
+ const useColor = () => process.stdout.isTTY === true && !NO_COLOR;
10
+ const wrap = (open, close) => (value) => (useColor() ? `[${open}m${value}[${close}m` : value);
11
+ export const bold = wrap("1", "22");
12
+ export const dim = wrap("2", "22");
13
+ export const blue = wrap("38;5;33", "39");
14
+ export const green = wrap("38;5;29", "39");
15
+ export const amber = wrap("38;5;172", "39");
16
+ export const red = wrap("38;5;160", "39");
17
+ export const underline = wrap("4", "24");
18
+ /** State markers. Text-first so they survive `NO_COLOR` and screen readers. */
19
+ export const mark = {
20
+ ok: () => green("ok"),
21
+ warn: () => amber("warn"),
22
+ fail: () => red("fail"),
23
+ bullet: () => dim("·")
24
+ };
25
+ export function heading(text) {
26
+ return `\n${bold(text)}\n${dim("─".repeat(Math.min(text.length + 12, 60)))}`;
27
+ }
28
+ /** The recurring device: the line past which plaintext does not travel. */
29
+ export function vaultline(label = "plaintext ends here") {
30
+ return `${dim("─".repeat(4))}${dim("┤")} ${blue(label)} ${dim("├")}${dim("─".repeat(Math.max(4, 52 - label.length)))}`;
31
+ }
32
+ export function keyValue(pairs, indent = " ") {
33
+ const width = Math.max(...pairs.map(([key]) => key.length));
34
+ return pairs.map(([key, value]) => `${indent}${dim(key.padEnd(width))} ${value}`).join("\n");
35
+ }
36
+ export function bytes(value) {
37
+ if (!Number.isFinite(value))
38
+ return "—";
39
+ const units = ["B", "KB", "MB", "GB", "TB"];
40
+ let size = value;
41
+ let unit = 0;
42
+ while (size >= 1024 && unit < units.length - 1) {
43
+ size /= 1024;
44
+ unit += 1;
45
+ }
46
+ return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`;
47
+ }
48
+ export function relativeTime(iso) {
49
+ const delta = Date.now() - Date.parse(iso);
50
+ if (!Number.isFinite(delta))
51
+ return "—";
52
+ const minutes = Math.round(delta / 60_000);
53
+ if (minutes < 1)
54
+ return "just now";
55
+ if (minutes < 60)
56
+ return `${minutes}m ago`;
57
+ const hours = Math.round(minutes / 60);
58
+ if (hours < 24)
59
+ return `${hours}h ago`;
60
+ const days = Math.round(hours / 24);
61
+ return days < 30 ? `${days}d ago` : `${Math.round(days / 30)}mo ago`;
62
+ }
63
+ /** Shortens a path from the left, so the filename always survives. */
64
+ export function shortPath(path, width = 46) {
65
+ const home = process.env.HOME;
66
+ const display = home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
67
+ return display.length <= width ? display : `…${display.slice(-(width - 1))}`;
68
+ }
69
+ export function table(rows, columns, empty) {
70
+ if (rows.length === 0)
71
+ return ` ${dim(empty)}`;
72
+ const cells = rows.map((row) => columns.map((column) => column.get(row)));
73
+ const widths = columns.map((column, index) => Math.max(column.header.length, ...cells.map((row) => stripAnsi(row[index]).length)));
74
+ const pad = (value, width, align) => {
75
+ const padding = " ".repeat(Math.max(0, width - stripAnsi(value).length));
76
+ return align === "right" ? padding + value : value + padding;
77
+ };
78
+ const header = ` ${columns.map((column, index) => dim(pad(column.header.toUpperCase(), widths[index], column.align))).join(" ")}`;
79
+ const body = cells.map((row) => ` ${row.map((value, index) => pad(value, widths[index], columns[index].align)).join(" ")}`);
80
+ return [header, ...body].join("\n");
81
+ }
82
+ export function stripAnsi(value) {
83
+ // eslint-disable-next-line no-control-regex
84
+ return value.replace(/\[[0-9;]*m/g, "");
85
+ }
86
+ /** Frames something the user must copy down before continuing. */
87
+ export function callout(title, body) {
88
+ const width = Math.max(title.length, ...body.map((line) => stripAnsi(line).length)) + 4;
89
+ const top = `┌${"─".repeat(width)}┐`;
90
+ const bottom = `└${"─".repeat(width)}┘`;
91
+ const line = (value) => `│ ${value}${" ".repeat(Math.max(0, width - stripAnsi(value).length - 4))} │`;
92
+ return [blue(top), line(bold(title)), line(""), ...body.map(line), blue(bottom)].join("\n");
93
+ }
94
+ export function steps(items) {
95
+ return items.map((item, index) => ` ${blue(String(index + 1))} ${item}`).join("\n");
96
+ }
97
+ export function hint(text) {
98
+ return `${dim("→")} ${text}`;
99
+ }
100
+ export function command(text) {
101
+ return bold(text);
102
+ }
@@ -0,0 +1,30 @@
1
+ export type UpdateArtifact = {
2
+ name: string;
3
+ sha256: string;
4
+ bytes: number;
5
+ };
6
+ export type UpdateManifest = {
7
+ version: 1;
8
+ product: "vaultline";
9
+ release: string;
10
+ publishedAt: string;
11
+ artifacts: UpdateArtifact[];
12
+ };
13
+ export type SignedManifest = {
14
+ manifest: UpdateManifest;
15
+ keyId: string;
16
+ algorithm: "ed25519";
17
+ signature: string;
18
+ };
19
+ /** Deterministic bytes to sign: object keys sorted, no incidental whitespace. */
20
+ export declare function canonicalize(value: unknown): string;
21
+ export declare function signManifest(manifest: UpdateManifest, privateKeyPem: string, keyId: string): SignedManifest;
22
+ /**
23
+ * Verifies a release manifest against a pinned key set. An unknown key id fails
24
+ * closed: a signature is only meaningful against a key the client already trusts.
25
+ */
26
+ export declare function verifyManifest(signed: SignedManifest, trustedKeys: Record<string, string>): UpdateManifest;
27
+ /** Blocks rollback: a signed but older manifest must never be applied automatically. */
28
+ export declare function assertNotDowngrade(candidate: string, current: string): void;
29
+ /** Confirms a downloaded artifact matches the signed manifest entry byte for byte. */
30
+ export declare function verifyArtifact(path: string, entry: UpdateArtifact): Promise<void>;