@timqi/pier 0.1.7 → 0.1.8

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 (93) hide show
  1. package/dist/agent/credentials.js +5 -2
  2. package/dist/agent/pi.js +1 -1
  3. package/dist/boards/pier.css +58 -39
  4. package/dist/channels/handoff.js +1 -1
  5. package/dist/cli.js +8 -0
  6. package/dist/db.js +13 -0
  7. package/dist/main.js +7 -2
  8. package/dist/secrets.js +15 -1
  9. package/dist/settings.js +28 -0
  10. package/dist/socket.js +13 -1
  11. package/dist/web/auth.js +152 -29
  12. package/dist/web/instance.js +19 -4
  13. package/dist/web/passkeys.js +438 -0
  14. package/dist/web/public/assets/activity-CImcJ4xF.js +5 -0
  15. package/dist/web/public/assets/activity-CImcJ4xF.js.br +0 -0
  16. package/dist/web/public/assets/activity-CImcJ4xF.js.gz +0 -0
  17. package/dist/web/public/assets/boards-43luxbPd.js +1 -0
  18. package/dist/web/public/assets/boards-43luxbPd.js.br +0 -0
  19. package/dist/web/public/assets/boards-43luxbPd.js.gz +0 -0
  20. package/dist/web/public/assets/explorer-uSHcxQMy.js +4 -0
  21. package/dist/web/public/assets/explorer-uSHcxQMy.js.br +0 -0
  22. package/dist/web/public/assets/explorer-uSHcxQMy.js.gz +0 -0
  23. package/dist/web/public/assets/{index-b8jrYmUt.js → index-Dje7tHE4.js} +15 -15
  24. package/dist/web/public/assets/index-Dje7tHE4.js.br +0 -0
  25. package/dist/web/public/assets/index-Dje7tHE4.js.gz +0 -0
  26. package/dist/web/public/assets/index-sQWFDUPu.css +2 -0
  27. package/dist/web/public/assets/index-sQWFDUPu.css.br +0 -0
  28. package/dist/web/public/assets/index-sQWFDUPu.css.gz +0 -0
  29. package/dist/web/public/assets/runs-rggAaD0k.js +1 -0
  30. package/dist/web/public/assets/runs-rggAaD0k.js.br +0 -0
  31. package/dist/web/public/assets/runs-rggAaD0k.js.gz +0 -0
  32. package/dist/web/public/assets/settings-DN20Mj9n.js +5 -0
  33. package/dist/web/public/assets/settings-DN20Mj9n.js.br +0 -0
  34. package/dist/web/public/assets/settings-DN20Mj9n.js.gz +0 -0
  35. package/dist/web/public/assets/task-runs-BF79aZgL.js +3 -0
  36. package/dist/web/public/assets/task-runs-BF79aZgL.js.br +0 -0
  37. package/dist/web/public/assets/task-runs-BF79aZgL.js.gz +0 -0
  38. package/dist/web/public/assets/tasks-MCHgTBcD.js +4 -0
  39. package/dist/web/public/assets/tasks-MCHgTBcD.js.br +0 -0
  40. package/dist/web/public/assets/tasks-MCHgTBcD.js.gz +0 -0
  41. package/dist/web/public/icon-32-amber.png +0 -0
  42. package/dist/web/public/icon-32-emerald.png +0 -0
  43. package/dist/web/public/icon-32-rose.png +0 -0
  44. package/dist/web/public/icon-32-teal.png +0 -0
  45. package/dist/web/public/icon-32-violet.png +0 -0
  46. package/dist/web/public/icon-32.png +0 -0
  47. package/dist/web/public/icon-touch-192-amber.png +0 -0
  48. package/dist/web/public/icon-touch-192-emerald.png +0 -0
  49. package/dist/web/public/icon-touch-192-rose.png +0 -0
  50. package/dist/web/public/icon-touch-192-teal.png +0 -0
  51. package/dist/web/public/icon-touch-192-violet.png +0 -0
  52. package/dist/web/public/icon-touch-192.png +0 -0
  53. package/dist/web/public/index.html +14 -10
  54. package/dist/web/public/index.html.br +0 -0
  55. package/dist/web/public/index.html.gz +0 -0
  56. package/dist/web/public/manifest.webmanifest +10 -10
  57. package/dist/web/public/sw.js +5 -5
  58. package/dist/web/public/sw.js.br +0 -0
  59. package/dist/web/public/sw.js.gz +0 -0
  60. package/dist/web/push.js +15 -3
  61. package/dist/web/server.js +70 -11
  62. package/docs/deploy.md +19 -2
  63. package/package.json +1 -1
  64. package/dist/web/public/assets/activity-DR5gTwpG.js +0 -5
  65. package/dist/web/public/assets/activity-DR5gTwpG.js.br +0 -0
  66. package/dist/web/public/assets/activity-DR5gTwpG.js.gz +0 -0
  67. package/dist/web/public/assets/boards-DBO30B54.js +0 -1
  68. package/dist/web/public/assets/boards-DBO30B54.js.br +0 -0
  69. package/dist/web/public/assets/boards-DBO30B54.js.gz +0 -0
  70. package/dist/web/public/assets/explorer-ZeVls41v.js +0 -4
  71. package/dist/web/public/assets/explorer-ZeVls41v.js.br +0 -0
  72. package/dist/web/public/assets/explorer-ZeVls41v.js.gz +0 -0
  73. package/dist/web/public/assets/index-DMq4awU8.css +0 -2
  74. package/dist/web/public/assets/index-DMq4awU8.css.br +0 -0
  75. package/dist/web/public/assets/index-DMq4awU8.css.gz +0 -0
  76. package/dist/web/public/assets/index-b8jrYmUt.js.br +0 -0
  77. package/dist/web/public/assets/index-b8jrYmUt.js.gz +0 -0
  78. package/dist/web/public/assets/runs-C0GaHPSU.js +0 -1
  79. package/dist/web/public/assets/runs-C0GaHPSU.js.br +0 -0
  80. package/dist/web/public/assets/runs-C0GaHPSU.js.gz +0 -0
  81. package/dist/web/public/assets/settings-avLEQHdY.js +0 -5
  82. package/dist/web/public/assets/settings-avLEQHdY.js.br +0 -0
  83. package/dist/web/public/assets/settings-avLEQHdY.js.gz +0 -0
  84. package/dist/web/public/assets/task-runs-BCbKn_zb.js +0 -3
  85. package/dist/web/public/assets/task-runs-BCbKn_zb.js.br +0 -0
  86. package/dist/web/public/assets/task-runs-BCbKn_zb.js.gz +0 -0
  87. package/dist/web/public/assets/tasks-CReIwEMw.js +0 -4
  88. package/dist/web/public/assets/tasks-CReIwEMw.js.br +0 -0
  89. package/dist/web/public/assets/tasks-CReIwEMw.js.gz +0 -0
  90. package/dist/web/public/icon.svg.br +0 -0
  91. package/dist/web/public/icon.svg.gz +0 -0
  92. package/dist/web/public/manifest.webmanifest.br +0 -0
  93. package/dist/web/public/manifest.webmanifest.gz +0 -0
@@ -0,0 +1,438 @@
1
+ // Passkeys (WebAuthn) beside the password: the store, the CBOR and COSE parsing
2
+ // an attestation needs, the challenge ledger and the routes. Attestation is not
3
+ // verified — the operator registers their own authenticator from a page they
4
+ // are already signed into, so the ceremony proves possession, not provenance.
5
+ // While one passkey exists the password is off (auth.ts asks `any()` live).
6
+ import { createHash, createPublicKey, randomBytes, verify as verifySignature } from "node:crypto";
7
+ import { readCapped } from "../core/inbox.js";
8
+ import { pierDb, statements } from "../db.js";
9
+ import { logger } from "../log.js";
10
+ import { clientOf, noteFailure, openSession, safeNext, throttled, } from "./auth.js";
11
+ const log = logger("passkeys");
12
+ const CHALLENGE_TTL_MS = 5 * 60_000;
13
+ /** Outstanding challenges kept; past it the oldest goes, so a stranger
14
+ * hammering the login options cannot grow the map. */
15
+ const MAX_CHALLENGES = 100;
16
+ /** A `none` attestation is ~200 bytes; a packed one with a cert chain a few KB. */
17
+ const MAX_BODY = 64 * 1024;
18
+ const MAX_LABEL = 80;
19
+ const DEFAULT_LABEL = "a passkey";
20
+ /** The WebAuthn user handle, one operator: minted once, kept in `settings`. */
21
+ const USER_ID_KEY = "passkeyUserId";
22
+ /** COSE algorithms offered and accepted: ES256 and RS256, what every platform
23
+ * authenticator and security key speaks. */
24
+ const ES256 = -7;
25
+ const RS256 = -257;
26
+ const FLAG_UP = 0x01;
27
+ const FLAG_AT = 0x40;
28
+ /** Reads the table live on every call — no cache — so `DELETE FROM passkeys`
29
+ * in sqlite3 re-enables the password without a restart. */
30
+ export class PasskeyStore {
31
+ #sql;
32
+ constructor(db = pierDb()) {
33
+ this.#sql = statements(db);
34
+ }
35
+ list() {
36
+ const rows = this.#sql("SELECT id, label, created_at AS createdAt, last_used_at AS lastUsedAt, transports" +
37
+ " FROM passkeys ORDER BY created_at").all();
38
+ return rows.map((row) => ({ ...row, transports: JSON.parse(row.transports) }));
39
+ }
40
+ any() {
41
+ return this.#sql("SELECT 1 FROM passkeys LIMIT 1").get() !== undefined;
42
+ }
43
+ add(id, publicKey, transports, label) {
44
+ this.#sql("INSERT INTO passkeys(id, public_key, sign_count, transports, label, created_at) VALUES (?, ?, 0, ?, ?, ?)").run(id, publicKey, JSON.stringify(transports), label, Date.now());
45
+ }
46
+ remove(id) {
47
+ return this.#sql("DELETE FROM passkeys WHERE id = ?").run(id).changes > 0;
48
+ }
49
+ credential(id) {
50
+ return this.#sql("SELECT public_key AS publicKey, sign_count AS signCount, label FROM passkeys WHERE id = ?")
51
+ .get(id);
52
+ }
53
+ used(id, signCount) {
54
+ this.#sql("UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?").run(signCount, Date.now(), id);
55
+ }
56
+ userId() {
57
+ const row = this.#sql("SELECT value FROM settings WHERE key = ?").get(USER_ID_KEY);
58
+ if (row)
59
+ return Buffer.from(row.value, "base64url");
60
+ const id = randomBytes(16);
61
+ this.#sql("INSERT INTO settings(key, value) VALUES (?, ?)").run(USER_ID_KEY, id.toString("base64url"));
62
+ return id;
63
+ }
64
+ }
65
+ /** A request refused for what it carried, distinct from a fault. */
66
+ class Refusal extends Error {
67
+ status;
68
+ constructor(message, status) {
69
+ super(message);
70
+ this.status = status;
71
+ }
72
+ }
73
+ // --- the relying party ----------------------------------------------------------
74
+ /** The browser binds a credential to the RP ID and refuses any other host, so
75
+ * only an https public URL can name one; a passkey on a tunnel would be a
76
+ * lockout the moment the address changed. */
77
+ export function relyingParty(publicUrl) {
78
+ if (!publicUrl.startsWith("https://")) {
79
+ return { reason: "passkeys need an https public URL (Settings → Instance)" };
80
+ }
81
+ const url = new URL(publicUrl);
82
+ return { rpId: url.hostname, origin: url.origin };
83
+ }
84
+ // --- challenges -----------------------------------------------------------------
85
+ const challenges = new Map();
86
+ function issueChallenge(kind) {
87
+ const now = Date.now();
88
+ for (const [key, entry] of challenges)
89
+ if (entry.expires <= now)
90
+ challenges.delete(key);
91
+ // Insertion order is age: the first key is the oldest.
92
+ while (challenges.size >= MAX_CHALLENGES)
93
+ challenges.delete(challenges.keys().next().value);
94
+ const challenge = randomBytes(32).toString("base64url");
95
+ challenges.set(challenge, { kind, expires: now + CHALLENGE_TTL_MS });
96
+ return challenge;
97
+ }
98
+ /** Single use: deleted whether or not it was still valid. */
99
+ function consumeChallenge(challenge, kind) {
100
+ const entry = challenges.get(challenge);
101
+ challenges.delete(challenge);
102
+ return entry !== undefined && entry.kind === kind && entry.expires > Date.now();
103
+ }
104
+ /** The subset an attestationObject and a COSE key are written in: integers,
105
+ * byte and text strings, arrays and maps with integer or text keys.
106
+ * Anything else — tags, floats, indefinite lengths — is refused. */
107
+ export function decodeCbor(buf, offset = 0) {
108
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
109
+ const need = (at, n) => {
110
+ if (at + n > buf.length)
111
+ throw new Refusal("truncated CBOR", 400);
112
+ };
113
+ const head = (at) => {
114
+ need(at, 1);
115
+ const major = buf[at] >> 5;
116
+ const info = buf[at] & 0x1f;
117
+ if (info < 24)
118
+ return { major, arg: info, next: at + 1 };
119
+ const size = info === 24 ? 1 : info === 25 ? 2 : info === 26 ? 4 : info === 27 ? 8 : 0;
120
+ if (!size)
121
+ throw new Refusal("unsupported CBOR length", 400);
122
+ need(at + 1, size);
123
+ const arg = size === 1
124
+ ? buf[at + 1]
125
+ : size === 2
126
+ ? view.getUint16(at + 1)
127
+ : size === 4
128
+ ? view.getUint32(at + 1)
129
+ : Number(view.getBigUint64(at + 1));
130
+ if (!Number.isSafeInteger(arg))
131
+ throw new Refusal("CBOR integer out of range", 400);
132
+ return { major, arg, next: at + 1 + size };
133
+ };
134
+ const item = (at) => {
135
+ const { major, arg, next } = head(at);
136
+ switch (major) {
137
+ case 0:
138
+ return { value: arg, end: next };
139
+ case 1:
140
+ return { value: -1 - arg, end: next };
141
+ case 2:
142
+ case 3: {
143
+ need(next, arg);
144
+ const bytes = buf.subarray(next, next + arg);
145
+ return { value: major === 2 ? bytes : new TextDecoder("utf-8", { fatal: true }).decode(bytes), end: next + arg };
146
+ }
147
+ case 4: {
148
+ const list = [];
149
+ let cursor = next;
150
+ for (let i = 0; i < arg; i++) {
151
+ const entry = item(cursor);
152
+ list.push(entry.value);
153
+ cursor = entry.end;
154
+ }
155
+ return { value: list, end: cursor };
156
+ }
157
+ case 5: {
158
+ const map = new Map();
159
+ let cursor = next;
160
+ for (let i = 0; i < arg; i++) {
161
+ const key = item(cursor);
162
+ if (typeof key.value !== "number" && typeof key.value !== "string") {
163
+ throw new Refusal("CBOR map key is not an integer or text", 400);
164
+ }
165
+ const value = item(key.end);
166
+ map.set(key.value, value.value);
167
+ cursor = value.end;
168
+ }
169
+ return { value: map, end: cursor };
170
+ }
171
+ default:
172
+ throw new Refusal(`unsupported CBOR major type ${String(major)}`, 400);
173
+ }
174
+ };
175
+ return item(offset);
176
+ }
177
+ const asMap = (value, what) => {
178
+ if (!(value instanceof Map))
179
+ throw new Refusal(`${what} is not a CBOR map`, 400);
180
+ return value;
181
+ };
182
+ const asBytes = (value, what) => {
183
+ if (!(value instanceof Uint8Array))
184
+ throw new Refusal(`${what} is not a byte string`, 400);
185
+ return value;
186
+ };
187
+ /** COSE_Key → JWK, EC2 P-256 (ES256) or RSA (RS256). `createPublicKey`
188
+ * validates the point / modulus; the JWK is what the store keeps. */
189
+ export function coseToJwk(key) {
190
+ const kty = key.get(1);
191
+ const alg = key.get(3);
192
+ const b64 = (label, what) => Buffer.from(asBytes(key.get(label), what)).toString("base64url");
193
+ let jwk;
194
+ if (kty === 2 && alg === ES256) {
195
+ if (key.get(-1) !== 1)
196
+ throw new Refusal("ES256 key is not on P-256", 400);
197
+ jwk = { kty: "EC", crv: "P-256", x: b64(-2, "x"), y: b64(-3, "y") };
198
+ }
199
+ else if (kty === 3 && alg === RS256) {
200
+ jwk = { kty: "RSA", n: b64(-1, "n"), e: b64(-2, "e") };
201
+ }
202
+ else {
203
+ throw new Refusal(`unsupported key type ${String(kty)} / algorithm ${String(alg)}`, 400);
204
+ }
205
+ try {
206
+ createPublicKey({ key: jwk, format: "jwk" });
207
+ }
208
+ catch (err) {
209
+ throw new Refusal(`public key rejected: ${String(err)}`, 400);
210
+ }
211
+ return jwk;
212
+ }
213
+ /** rpIdHash(32) flags(1) signCount(4) [aaguid(16) idLen(2) id COSE_Key] [extensions]. */
214
+ export function parseAuthData(data) {
215
+ if (data.length < 37)
216
+ throw new Refusal("authenticator data too short", 400);
217
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
218
+ const parsed = { rpIdHash: data.subarray(0, 32), flags: data[32], signCount: view.getUint32(33) };
219
+ if (parsed.flags & FLAG_AT) {
220
+ if (data.length < 55)
221
+ throw new Refusal("attested credential data too short", 400);
222
+ const idLength = view.getUint16(53);
223
+ if (data.length < 55 + idLength)
224
+ throw new Refusal("credential id truncated", 400);
225
+ parsed.credentialId = data.subarray(55, 55 + idLength);
226
+ parsed.publicKey = asMap(decodeCbor(data, 55 + idLength).value, "credential public key");
227
+ }
228
+ return parsed;
229
+ }
230
+ // --- the ceremony's common checks -----------------------------------------------
231
+ const BASE64URL = /^[A-Za-z0-9_-]*$/;
232
+ function bytesOf(value, what) {
233
+ if (typeof value !== "string" || !BASE64URL.test(value))
234
+ throw new Refusal(`${what} must be base64url`, 400);
235
+ return Buffer.from(value, "base64url");
236
+ }
237
+ const sha256 = (data) => createHash("sha256").update(data).digest();
238
+ /** Type, single-use challenge and origin: the three facts the browser signed
239
+ * on the operator's behalf. Consumes the challenge whatever the outcome. */
240
+ function checkClientData(raw, type, origin) {
241
+ let data;
242
+ try {
243
+ data = JSON.parse(new TextDecoder().decode(raw));
244
+ }
245
+ catch {
246
+ throw new Refusal("clientDataJSON is not JSON", 400);
247
+ }
248
+ if (data.type !== type)
249
+ throw new Refusal(`clientDataJSON type is not ${type}`, 400);
250
+ const kind = type === "webauthn.create" ? "create" : "get";
251
+ if (typeof data.challenge !== "string" || !consumeChallenge(data.challenge, kind)) {
252
+ throw new Refusal("challenge unknown, expired or already used", 400);
253
+ }
254
+ if (data.origin !== origin)
255
+ throw new Refusal(`origin ${String(data.origin)} is not ${origin}`, 400);
256
+ }
257
+ function checkRpIdHash(authData, rpId) {
258
+ if (!sha256(rpId).equals(authData.rpIdHash))
259
+ throw new Refusal("rpIdHash does not match the public URL's host", 400);
260
+ if (!(authData.flags & FLAG_UP))
261
+ throw new Refusal("user presence flag not set", 400);
262
+ }
263
+ async function readJson(c) {
264
+ let raw;
265
+ try {
266
+ raw = await readCapped(c.req.raw.body, MAX_BODY);
267
+ }
268
+ catch (err) {
269
+ throw new Refusal(`body refused: ${String(err)}`, 413);
270
+ }
271
+ let body;
272
+ try {
273
+ body = JSON.parse(new TextDecoder().decode(raw));
274
+ }
275
+ catch {
276
+ throw new Refusal("expected a JSON body", 400);
277
+ }
278
+ if (typeof body !== "object" || body === null)
279
+ throw new Refusal("expected a JSON object", 400);
280
+ return body;
281
+ }
282
+ const responseOf = (body) => typeof body.response === "object" && body.response !== null ? body.response : {};
283
+ // --- routes ---------------------------------------------------------------------
284
+ export function registerPasskeyRoutes(app, deps) {
285
+ const { store, auth, publicUrl } = deps;
286
+ const listing = () => {
287
+ const rp = relyingParty(publicUrl());
288
+ return { enabled: "rpId" in rp, ...("reason" in rp ? { reason: rp.reason } : {}), passkeys: store.list() };
289
+ };
290
+ const refused = (c, err) => {
291
+ if (err instanceof Refusal)
292
+ return c.json({ error: err.message }, err.status);
293
+ throw err;
294
+ };
295
+ const rpOrRefuse = () => {
296
+ const rp = relyingParty(publicUrl());
297
+ if ("reason" in rp)
298
+ throw new Refusal(rp.reason, 409);
299
+ return rp;
300
+ };
301
+ app.get("/api/passkeys", (c) => c.json(listing()));
302
+ app.post("/api/passkeys/register/options", (c) => {
303
+ try {
304
+ const { rpId } = rpOrRefuse();
305
+ return c.json({
306
+ rp: { id: rpId, name: "Pier" },
307
+ user: { id: store.userId().toString("base64url"), name: `operator@${rpId}`, displayName: "Pier operator" },
308
+ challenge: issueChallenge("create"),
309
+ pubKeyCredParams: [{ type: "public-key", alg: ES256 }, { type: "public-key", alg: RS256 }],
310
+ timeout: CHALLENGE_TTL_MS,
311
+ excludeCredentials: store.list().map(({ id, transports }) => ({ type: "public-key", id, transports })),
312
+ authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
313
+ attestation: "none",
314
+ });
315
+ }
316
+ catch (err) {
317
+ return refused(c, err);
318
+ }
319
+ });
320
+ app.post("/api/passkeys/register/verify", async (c) => {
321
+ try {
322
+ const { rpId, origin } = rpOrRefuse();
323
+ const body = await readJson(c);
324
+ const response = responseOf(body);
325
+ checkClientData(bytesOf(response.clientDataJSON, "clientDataJSON"), "webauthn.create", origin);
326
+ const attestation = asMap(decodeCbor(bytesOf(response.attestationObject, "attestationObject")).value, "attestationObject");
327
+ // attStmt is ignored whatever `fmt` says: nothing here verifies provenance.
328
+ const authData = parseAuthData(asBytes(attestation.get("authData"), "authData"));
329
+ checkRpIdHash(authData, rpId);
330
+ if (!authData.credentialId || !authData.publicKey)
331
+ throw new Refusal("no attested credential", 400);
332
+ const id = Buffer.from(authData.credentialId).toString("base64url");
333
+ if (body.id !== id)
334
+ throw new Refusal("credential id does not match the attested one", 400);
335
+ const jwk = coseToJwk(authData.publicKey);
336
+ const transports = Array.isArray(response.transports) ? response.transports : [];
337
+ if (transports.length > 8 || !transports.every((t) => typeof t === "string" && t.length <= 32)) {
338
+ throw new Refusal("transports must be a short list of names", 400);
339
+ }
340
+ const label = typeof body.label === "string" && body.label.trim() ? body.label.trim().slice(0, MAX_LABEL) : DEFAULT_LABEL;
341
+ if (store.credential(id))
342
+ throw new Refusal("this passkey is already registered", 409);
343
+ store.add(id, JSON.stringify(jwk), transports, label);
344
+ log.info(`passkey registered: ${label} (${id.slice(0, 8)}…) — password sign-in is now off`);
345
+ return c.json(listing(), 201);
346
+ }
347
+ catch (err) {
348
+ return refused(c, err);
349
+ }
350
+ });
351
+ app.delete("/api/passkeys/:id", (c) => {
352
+ const id = c.req.param("id");
353
+ if (!store.remove(id))
354
+ return c.json({ error: "no such passkey" }, 404);
355
+ log.info(`passkey removed (${id.slice(0, 8)}…)${store.any() ? "" : " — password sign-in is back on"}`);
356
+ return c.json(listing());
357
+ });
358
+ // Unauthenticated, so both share the password's throttle (auth.ts).
359
+ app.post("/api/passkeys/login/options", (c) => {
360
+ const client = clientOf(c);
361
+ if (throttled(client))
362
+ return c.json({ error: "Too many attempts. Wait a few minutes." }, 429);
363
+ try {
364
+ const { rpId } = rpOrRefuse();
365
+ const passkeys = store.list();
366
+ if (!passkeys.length)
367
+ throw new Refusal("no passkey is registered", 409);
368
+ return c.json({
369
+ challenge: issueChallenge("get"),
370
+ rpId,
371
+ allowCredentials: passkeys.map(({ id, transports }) => ({ type: "public-key", id, transports })),
372
+ userVerification: "preferred",
373
+ timeout: CHALLENGE_TTL_MS,
374
+ });
375
+ }
376
+ catch (err) {
377
+ return refused(c, err);
378
+ }
379
+ });
380
+ app.post("/api/passkeys/login/verify", async (c) => {
381
+ const client = clientOf(c);
382
+ if (throttled(client)) {
383
+ log.warn(`passkey login throttled for ${client}`);
384
+ return c.json({ error: "Too many attempts. Wait a few minutes." }, 429);
385
+ }
386
+ try {
387
+ const { rpId, origin } = rpOrRefuse();
388
+ const body = await readJson(c);
389
+ const response = responseOf(body);
390
+ const id = typeof body.id === "string" ? body.id : "";
391
+ const stored = store.credential(id);
392
+ if (!stored)
393
+ throw new Refusal("unknown passkey", 401);
394
+ const clientData = bytesOf(response.clientDataJSON, "clientDataJSON");
395
+ checkClientData(clientData, "webauthn.get", origin);
396
+ const authDataBytes = bytesOf(response.authenticatorData, "authenticatorData");
397
+ const authData = parseAuthData(authDataBytes);
398
+ checkRpIdHash(authData, rpId);
399
+ let key;
400
+ try {
401
+ key = createPublicKey({ key: JSON.parse(stored.publicKey), format: "jwk" });
402
+ }
403
+ catch (err) {
404
+ // A stored key that no longer loads is a database fault, not the caller's.
405
+ log.error(`stored passkey ${id.slice(0, 8)}… has an unreadable public key`, err);
406
+ throw new Refusal("stored passkey is unreadable — remove it", 401);
407
+ }
408
+ const signed = Buffer.concat([authDataBytes, sha256(clientData)]);
409
+ if (!verifySignature("sha256", signed, key, bytesOf(response.signature, "signature"))) {
410
+ throw new Refusal("signature does not verify", 401);
411
+ }
412
+ // A counter that did not advance means two authenticators hold this key;
413
+ // one that never counts (always 0, synced passkeys) is exempt (WebAuthn §7.2).
414
+ if ((authData.signCount || stored.signCount) && authData.signCount <= stored.signCount) {
415
+ log.error(`passkey ${stored.label} (${id.slice(0, 8)}…) presented sign count ${String(authData.signCount)} ≤ stored ${String(stored.signCount)} — cloned authenticator?`);
416
+ throw new Refusal("sign counter did not advance — refusing this passkey", 401);
417
+ }
418
+ store.used(id, authData.signCount);
419
+ log.info(`passkey login from ${client}: ${stored.label}`);
420
+ openSession(c, auth, client);
421
+ return c.json({ next: safeNext(body.next) });
422
+ }
423
+ catch (err) {
424
+ if (err instanceof Refusal) {
425
+ noteFailure(client);
426
+ log.warn(`passkey login refused for ${client}: ${err.message}`);
427
+ }
428
+ return refused(c, err);
429
+ }
430
+ });
431
+ }
432
+ /** For instance.ts: the host every stored passkey is bound to, or null. */
433
+ export const boundHost = (store, publicUrl) => {
434
+ if (!store.any())
435
+ return null;
436
+ const rp = relyingParty(publicUrl);
437
+ return "rpId" in rp ? rp.rpId : null;
438
+ };
@@ -0,0 +1,5 @@
1
+ import{C as e,G as t,J as n,O as r,P as i,Q as a,V as o,Y as s,Z as c,at as l,et as u,t as d,u as f}from"./index-Dje7tHE4.js";var p=[[`path`,{d:`M3 5h.01`}],[`path`,{d:`M3 12h.01`}],[`path`,{d:`M3 19h.01`}],[`path`,{d:`M8 5h13`}],[`path`,{d:`M8 12h13`}],[`path`,{d:`M8 19h13`}]],m=[[`path`,{d:`m10.586 5.414-5.172 5.172`}],[`path`,{d:`m18.586 13.414-5.172 5.172`}],[`path`,{d:`M6 12h12`}],[`circle`,{cx:`12`,cy:`20`,r:`2`}],[`circle`,{cx:`12`,cy:`4`,r:`2`}],[`circle`,{cx:`20`,cy:`12`,r:`2`}],[`circle`,{cx:`4`,cy:`12`,r:`2`}]],h=(e,t={})=>{let n=document.createElementNS(`http://www.w3.org/2000/svg`,e);for(let[e,r]of Object.entries(t))n.setAttribute(e,r);return n},g=e=>e===null?`-`:n(Date.now()-e),_={invocation:{stroke:`stroke-neutral-400`,fill:`fill-neutral-400`},callback:{stroke:`stroke-cyan-500`,fill:`fill-cyan-500`,dash:`6 5`},message:{stroke:`stroke-amber-500`,fill:`fill-amber-500`,dash:`2 5`}},v={scheduler:`border-violet-200 bg-violet-50 text-violet-700`,console:`border-indigo-200 bg-indigo-50 text-indigo-700`,task:`border-amber-200 bg-amber-50 text-amber-700`,process:`border-dashed border-neutral-300 bg-neutral-50 text-neutral-500`,streaming:`border-emerald-200 bg-white font-medium text-neutral-800`,idle:`border-neutral-200 bg-white font-medium text-neutral-600`},y=300,b=4;function x(e,t){let n,r=null,i=!1,a=!1,o=()=>{clearTimeout(n),n=setTimeout(()=>{i||a||!r||r.dataset.closing!==void 0||(f(),r=null)},120)};e.onpointerenter=s=>{s.pointerType!==`touch`&&(i=!0,clearTimeout(n),!(r?.isConnected&&r.dataset.closing===void 0)&&(n=setTimeout(()=>{!e.isConnected||!i||(r=d(e,t),r.onpointerenter=()=>{a=!0,clearTimeout(n)},r.onpointerleave=()=>{a=!1,o()})},y)))},e.onpointerleave=()=>{i=!1,o()},e.onpointerdown=()=>clearTimeout(n)}function S(e){let t,n=!1;e.classList.add(`cursor-grab`),e.onpointerdown=r=>{n=!1,!(r.button!==0||r.pointerType===`touch`||r.target.closest(`[data-node], path`))&&(t={x:r.clientX,y:r.clientY,left:e.scrollLeft,top:e.scrollTop},e.setPointerCapture(r.pointerId),e.classList.add(`cursor-grabbing`,`select-none`))},e.onpointermove=r=>{if(!t)return;let i=r.clientX-t.x,a=r.clientY-t.y;(Math.abs(i)>b||Math.abs(a)>b)&&(n=!0),e.scrollLeft=t.left-i,e.scrollTop=t.top-a};let r=()=>{t=void 0,e.classList.remove(`cursor-grabbing`,`select-none`)};e.onpointerup=r,e.onpointercancel=r,e.addEventListener(`click`,e=>{n&&(e.stopPropagation(),e.preventDefault())},!0)}function C(n,d,f){let y=`sessions`,b=`active`,C={sessions:[],runs:[],messages:[]},w=``,T=a(async()=>{let e=b,t=await u(`/api/activity?scope=${e}`,`Failed to load activity`);if(!t.ok){w=``,n.replaceChildren(s(`p`,`p-4 text-[13px] text-red-600`,t.error));return}if(e!==b)return;let r=JSON.stringify(t.value);if(`${e}:${r}`===w)return;w=`${e}:${r}`;let i=t.value;C={...i,sessions:i.sessions.map(e=>({...e,title:l(e.title)}))},E()});function E(){let e=o(i([[`Sessions`,`sessions`,p],[`Relationships`,`dependencies`,m]],y,e=>{y=e,E()}),s(`span`,`ml-auto text-[11.5px] text-neutral-400`,`${C.sessions.length} sessions · ${C.runs.length} runs`),i([[`Current`,`active`],[`Last 24h`,`recent`]],b,e=>{b=e,E(),T()})),t=s(`div`,`automation-list min-h-0 flex-1 overflow-auto`);y===`sessions`?D(t):k(t),n.replaceChildren(e,t)}function D(e){let t=document.createElement(`table`);t.className=`w-full table-fixed text-left text-[12.5px]`,t.innerHTML=`<thead class="sticky top-0 bg-neutral-50 text-[10.5px] uppercase tracking-wide text-neutral-400 shadow-[inset_0_-1px_0_var(--color-neutral-200)]"><tr>
2
+ <th class="w-[50%] px-4 py-2 font-semibold md:w-[34%]">Session</th>
3
+ <th class="hidden w-[38%] px-2 py-2 font-semibold md:table-cell">Project</th>
4
+ <th class="px-2 py-2 font-semibold md:w-[14%]">State</th>
5
+ <th class="px-2 py-2 font-semibold">In state</th></tr></thead>`;let n=document.createElement(`tbody`);for(let e of C.sessions){let t=document.createElement(`tr`);t.className=`cursor-pointer border-b border-neutral-100 transition-colors hover:bg-neutral-50`,t.onclick=()=>d(e.id);let r=s(`button`,`block w-full cursor-pointer truncate text-left font-medium text-neutral-800`,e.title??c(e.cwd));r.setAttribute(`type`,`button`),r.title=`${e.id}\n${e.cwd}`,r.onclick=t=>{t.stopPropagation(),d(e.id)},t.append(s(`td`,`px-4 py-2.5`,r,s(`div`,`truncate font-mono text-[11px] text-neutral-400 md:hidden`,e.cwd||`-`)),s(`td`,`hidden truncate px-2 py-2.5 font-mono text-[11.5px] text-neutral-500 md:table-cell`,e.cwd||`-`),s(`td`,`px-2 py-2.5`,O(e.state)),s(`td`,`px-2 py-2.5 font-mono text-[11.5px] text-neutral-400`,g(e.stateSince))),n.append(t)}t.append(n),e.append(t),C.sessions.length||e.append(s(`div`,`p-4`,r(b===`active`?`No active sessions.`:`No sessions in the last 24 hours.`)))}function O(t){return t===`streaming`?e(t,`bg-emerald-50 text-emerald-700 ring-emerald-200`,`animate-pulse bg-emerald-500`):e(t,`bg-neutral-100 text-neutral-600 ring-neutral-200`,`bg-neutral-300`)}function k(e){let t=new Map,n=[],i=(e,n,r,i)=>{t.has(e)||t.set(e,{id:e,label:n,kind:r,state:i})};for(let e of C.sessions)i(e.id,e.title??e.id.slice(0,10),`session`,e.state);for(let e of C.runs){let t=e.invokedBySessionId??`scheduler`,r=e.targetSessionId??(e.context.definition.action.type===`bash`?`process:${e.id}`:`task:${e.id}`);i(t,t===`scheduler`?`Scheduler`:t.slice(0,10),t===`scheduler`?`scheduler`:`session`),i(r,e.targetSessionId?e.targetSessionId.slice(0,10):e.context.definition.action.type===`bash`?`Bash process`:`Task`,e.targetSessionId?`session`:e.context.definition.action.type===`bash`?`process`:`task`),n.push({from:t,to:r,run:e,kind:`invocation`}),e.callbackSessionId&&(i(e.callbackSessionId,e.callbackSessionId.slice(0,10),`session`),n.push({from:r,to:e.callbackSessionId,run:e,kind:`callback`}))}for(let e of C.messages){if(!e.fromSessionId||!e.toSessionId||e.fromSessionId===e.toSessionId)continue;let t=C.runs.find(t=>t.id===e.runId);if(t){for(let t of[e.fromSessionId,e.toSessionId])i(t,t===`console`?`Console`:t.slice(0,10),t===`console`?`console`:`session`);n.push({from:e.fromSessionId,to:e.toSessionId,run:t,kind:`message`})}}if(!t.size){e.append(s(`div`,`p-4`,r(b===`active`?`No active dependencies.`:`No dependencies in the last 24 hours.`)));return}let a=[...t.values()],o=new Set(n.map(e=>e.to)),c=new Map,l=a.filter(e=>!o.has(e.id)).map(e=>e.id);l.length||l.push(a[0].id);for(let e of l)c.set(e,0);for(;l.length;){let e=l.shift();for(let t of n)t.from!==e||c.has(t.to)||(c.set(t.to,c.get(e)+1),l.push(t.to))}for(let e of a)c.has(e.id)||c.set(e.id,0);let u=new Map;for(let e of a){let t=c.get(e.id);u.set(t,[...u.get(t)??[],e])}let p=Math.max(...u.keys())+1,m=Math.max(...[...u.values()].map(e=>e.length)),g=n.some(e=>e.from!==e.to&&c.get(e.from)===c.get(e.to))?40:0,y=56+p*180+(p-1)*110+g,w=56+m*46+(m-1)*30,T=new Map;for(let[e,t]of u)t.forEach((t,n)=>T.set(t.id,{x:28+e*290+90,y:28+n*76+23}));let E=s(`div`,`relative`);E.style.width=`${y}px`,E.style.height=`${w}px`;let D=h(`svg`,{width:String(y),height:String(w),viewBox:`0 0 ${y} ${w}`,class:`absolute inset-0`}),O=h(`defs`);for(let[e,t]of Object.entries(_)){let n=h(`marker`,{id:`activity-arrow-${e}`,viewBox:`0 0 10 10`,refX:`9`,refY:`5`,markerWidth:`6`,markerHeight:`6`,orient:`auto-start-reverse`});n.append(h(`path`,{d:`M 0 0 L 10 5 L 0 10 z`,class:t.fill})),O.append(n)}D.append(O);let k=new Set;for(let e of n){let t=`${e.from}→${e.to}:${e.kind}`;if(k.has(t))continue;k.add(t);let n=T.get(e.from),r=T.get(e.to),i=n.x===r.x?`M ${n.x+90} ${n.y} C ${n.x+90+46} ${n.y}, ${r.x+90+46} ${r.y}, ${r.x+90} ${r.y}`:(()=>{let e=r.x>n.x,t=n.x+(e?90:-90),i=r.x+(e?-90:90),a=Math.max(36,Math.abs(i-t)*.45)*(e?1:-1);return`M ${t} ${n.y} C ${t+a} ${n.y}, ${i-a} ${r.y}, ${i} ${r.y}`})(),a=_[e.kind],o=h(`path`,{d:i,fill:`none`,class:`cursor-pointer ${a.stroke}`,"stroke-width":`1.5`,"marker-end":`url(#activity-arrow-${e.kind})`});a.dash&&o.setAttribute(`stroke-dasharray`,a.dash),o.onclick=()=>f(e.run.id),D.append(o)}E.append(D);for(let e of a){let t=T.get(e.id),n=e.kind===`session`?e.state===`streaming`?v.streaming:v.idle:v[e.kind],r=s(e.kind===`session`?`button`:`div`,`absolute flex items-center gap-2 rounded-xl border px-3 text-left text-[11.5px] shadow-xs ${n} ${e.kind===`session`?`cursor-pointer hover:bg-neutral-50`:`cursor-default`}`,...e.kind===`session`?[s(`span`,`inline-block h-1.5 w-1.5 flex-none rounded-full ${e.state===`streaming`?`animate-pulse bg-emerald-500`:`bg-neutral-300`}`)]:[],s(`span`,`min-w-0 truncate`,e.label));if(r.dataset.node=e.id,r.style.left=`${t.x-90}px`,r.style.top=`${t.y-23}px`,r.style.width=`180px`,r.style.height=`46px`,r.title=e.label===e.id?e.id:`${e.label}\n${e.id}`,e.kind===`session`){r.setAttribute(`type`,`button`),r.onclick=()=>d(e.id);let t=C.sessions.find(t=>t.id===e.id);t&&t.createdAt!==null&&x(r,{...t,createdAt:t.createdAt})}E.append(r)}S(e);let A=e=>s(`span`,`inline-block h-2 w-2 rounded-full ${e}`),j=e=>s(`span`,`inline-block h-0 w-6 border-t-2 ${e}`);e.append(s(`div`,`w-max p-4`,E),s(`div`,`flex flex-wrap gap-x-5 gap-y-1.5 border-t border-neutral-200 bg-neutral-50/60 px-4 py-2 text-[11px] text-neutral-500`,s(`span`,`inline-flex items-center gap-2`,j(`border-solid border-neutral-400`),`task invocation`),s(`span`,`inline-flex items-center gap-2 text-cyan-700`,j(`border-dashed border-cyan-500`),`callback`),s(`span`,`inline-flex items-center gap-2 text-amber-700`,j(`border-dotted border-amber-500`),`control message`),s(`span`,`inline-flex items-center gap-1.5`,A(`bg-emerald-500`),`streaming`),s(`span`,`inline-flex items-center gap-1.5`,A(`bg-neutral-300`),`idle`)))}let A=t(n,e=>{e===`dependencies`?y=`dependencies`:e===`sessions`&&(y=`sessions`),T()},()=>{n.replaceChildren(),C={sessions:[],runs:[],messages:[]},w=``});return Object.assign(A,{refresh(){A.visible&&T()}})}export{C as createActivityView};
@@ -0,0 +1 @@
1
+ import{$ as e,B as t,E as n,G as r,K as i,M as a,O as o,X as s,Y as c,et as l,it as u,rt as d,w as f}from"./index-Dje7tHE4.js";var p=e=>e.public&&e.token?`/p/${e.slug}-${e.token}/`:`/boards/${e.slug}/`;function m(m,h){let g=[],_=``,v=c(`header`,`pagehead sticky top-[-8px] z-30 max-md:hidden`,a(`Boards`)),y=c(`div`,`px-4 pb-5 pt-1`);m.append(c(`div`,`min-h-0 flex-1 overflow-y-auto`,v,y));async function b(t,n){let r=await u(`/api/boards/${t.slug}`,{public:n},`PATCH`);if(r.ok)return t.token=(await r.json().catch(()=>({}))).token??``,w();_=await e(r,`Could not change ${t.slug}`),await T()}async function x(e){_=await d(`/api/boards/${e}`,`DELETE`,`Could not delete ${e}`)??``,await T()}function S(e){let n=c(`div`,`group flex flex-col gap-1 py-2.5`),r=document.createElement(`a`);r.className=`min-w-0 flex-1 truncate text-[13px] font-medium text-neutral-800 hover:text-indigo-700 max-md:basis-full`,r.href=p(e),r.target=`_blank`,r.rel=`noreferrer`,r.textContent=e.title;let a=c(`div`,`flex flex-wrap items-center gap-x-2 gap-y-1`,r),o=t(``,``,e.public,t=>{e.public=t,b(e,t)});o.className=`flex flex-none cursor-pointer items-center gap-1.5 text-[11.5px] text-neutral-500`,o.append(c(`span`,``,`Public`));let l=f(`Delete`,`flex-none cursor-pointer text-[11.5px] text-neutral-400 opacity-0 transition-opacity hover:text-red-600 group-hover:opacity-100 pointer-coarse:opacity-100`);l.title=`Renames the folder on disk; nothing is erased`,l.onclick=()=>void x(e.slug);let u=i(`flex-none cursor-pointer text-[11.5px] text-neutral-400 opacity-0 transition-opacity hover:text-indigo-600 group-hover:opacity-100 pointer-coarse:opacity-100`,()=>`${location.origin}${p(e)}`);u.title=`Copy the board's link`,a.append(c(`div`,`ml-auto flex flex-none items-center gap-2`,o,u,l)),n.append(a);let d=c(`div`,`flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-neutral-500`);d.append(c(`span`,`min-w-0 break-all font-mono text-neutral-400`,e.slug)),e.description&&d.append(c(`span`,`min-w-0 truncate`,e.description)),d.append(c(`span`,`flex-none text-neutral-400`,s(Date.parse(e.updatedAt))));for(let t of e.sessions){let e=f(t.slice(0,8),`flex-none cursor-pointer font-mono text-indigo-600 hover:underline`);e.title=`Open session ${t}`,e.onclick=()=>h(t),d.append(e)}if(e.public&&e.token){let t=c(`span`,`min-w-0 break-all font-mono text-neutral-400`,p(e));t.title=`Public URL — anyone holding this link can read the board, no password`,d.append(t)}return n.append(d),n}let C=[{title:`Public`,hint:`Readable at /p/<slug>-<token>/ by anyone holding the link.`,wanted:!0},{title:`Private`,hint:`Reachable from the Console only.`,wanted:!1}];function w(){let e=c(`div`,`mx-auto flex w-full max-w-3xl flex-col gap-4`);g.length===0&&e.append(o(`No boards yet. Ask an agent to build one — it writes a folder under ~/.pier/boards.`));for(let{title:t,hint:r,wanted:i}of C){let a=g.filter(e=>e.public===i);a.length!==0&&e.append(n(t,r,c(`div`,`flex flex-col divide-y divide-neutral-200/70`,...a.map(S))))}y.replaceChildren(..._?[c(`p`,`mb-3 text-[13px] text-red-600`,_)]:[],e)}async function T(){let e=await l(`/api/boards`,`Could not load boards`);if(!e.ok)return _=e.error,w();g=e.value,w(),_=``}let E=r(m,()=>void T());return window.addEventListener(`focus`,()=>{E.visible&&T()}),E}export{m as createBoardsView};
@@ -0,0 +1,4 @@
1
+ import{G as e,M as t,O as n,W as r,X as i,Y as a,a as o,ct as s,f as c,g as l,h as u,l as d,m as f,mt as p,p as m,pt as ee,q as te,st as h,tt as g,u as _,v as ne,w as re,y as ie}from"./index-Dje7tHE4.js";var ae=[[`path`,{d:`M12 5v14`}],[`path`,{d:`m19 12-7 7-7-7`}]],oe=[[`path`,{d:`M12 22v-6`}],[`path`,{d:`M12 8V2`}],[`path`,{d:`M4 12H2`}],[`path`,{d:`M10 12H8`}],[`path`,{d:`M16 12h-2`}],[`path`,{d:`M22 12h-2`}],[`path`,{d:`m15 19-3-3-3 3`}],[`path`,{d:`m15 5-3 3-3-3`}]],se=[[`path`,{d:`M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z`}]],ce=[[`path`,{d:`M15 6a9 9 0 0 0-9 9V3`}],[`circle`,{cx:`18`,cy:`6`,r:`3`}],[`circle`,{cx:`6`,cy:`18`,r:`3`}]],v=[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`}],[`path`,{d:`M3 3v5h5`}]],y=e=>{let t=new Date(e),n=e=>String(e).padStart(2,`0`);return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())} ${n(t.getHours())}:${n(t.getMinutes())}:${n(t.getSeconds())}`},le=e=>[e.subject,e.body,[[e.author,e.email?`<${e.email}>`:``].filter(Boolean).join(` `),e.at?y(e.at):``].filter(Boolean).join(` · `)].filter(Boolean).join(`
2
+
3
+ `),b=null;function x(){b&&(b.remove(),b=null,document.removeEventListener(`pointerdown`,x,!0),window.removeEventListener(`scroll`,x,!0))}function ue(e,t){e.onmouseenter=()=>{x();let n=t();if(!n)return;b=a(`div`,`pointer-events-none fixed z-50 max-w-96 whitespace-pre-wrap rounded-lg bg-neutral-800/95 px-3 py-2 font-sans text-[11.5px] leading-snug text-neutral-100 shadow-lg`),b.textContent=n,document.body.append(b),document.addEventListener(`pointerdown`,x,!0),window.addEventListener(`scroll`,x,!0);let r=e.getBoundingClientRect();b.style.left=`${Math.max(8,Math.min(r.left,window.innerWidth-b.offsetWidth-8))}px`,b.style.top=r.bottom+6+b.offsetHeight>window.innerHeight?`${r.top-b.offsetHeight-6}px`:`${r.bottom+6}px`},e.onmouseleave=x}function S(e,t,n){let r=a(`button`,`flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-left hover:bg-neutral-100`,h(ee,`h-3 w-3 text-indigo-600 ${e.value===t?``:`invisible`}`),a(`span`,`inline-flex flex-none items-center gap-1.5 font-mono text-[11.5px] text-neutral-800`,e.label??e.value));return e.subject&&r.append(a(`span`,`min-w-0 truncate text-[11px] text-neutral-400`,e.subject)),e.author&&r.append(a(`span`,`ml-auto flex-none pl-1.5 text-[10.5px] text-neutral-400`,`${e.author} · ${e.at?i(e.at):``}`)),ue(r,()=>le(e)),r.onclick=()=>n(e.value),r}function de(e,t,n,r,i){let{base:o,head:s}=n,l=()=>s&&o===`${s}~1`?s:``,u=l()?`commit`:`compare`,d=t.commits[0],f=e=>[...e?[{value:``,label:`Working tree`,subject:`the files on disk, uncommitted changes included`}]:[],{...d,value:`HEAD`},...t.refs.map(e=>({value:e.name,subject:e.subject})),...t.commits.map(e=>({...e,value:e.hash}))],p=a(`div`,`flex w-[38rem] max-w-full flex-col`);function m(){let e=(e,t)=>{let n=a(`button`,`cursor-pointer rounded-full px-2.5 py-0.5 text-[11.5px] ${u===t?`bg-indigo-50 font-medium text-indigo-700`:`text-neutral-500 hover:bg-neutral-100`}`,e);return n.onclick=()=>{u=t,m()},n},n=(e,t,n,r)=>a(`div`,`flex min-w-0 flex-1 flex-col`,a(`div`,`flex-none px-2 pb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-neutral-400`,e),a(`div`,`min-h-0 flex-1 overflow-y-auto`,...t.map(e=>S(e,n,r)))),c=u===`compare`?a(`div`,`flex h-72 min-h-0 divide-x divide-neutral-200 pb-1`,n(`base`,f(!1),o,e=>{o=e,m(),r(o,s)}),n(`to`,f(!0),s,e=>{s=e,m(),r(o,s)})):a(`div`,`flex h-72 min-h-0 flex-col overflow-y-auto pb-1`,...t.commits.map(e=>S({value:e.hash,...e},l(),e=>{o=`${e}~1`,s=e,_(),r(o,s)}))),d=a(`div`,`flex-none border-t border-neutral-200`,S({value:i,label:`${i} ↔ Working tree`,subject:`back to the default: the main line's tip vs the files on disk`},o===i&&s===``?i:``,()=>{o=i,s=``,m(),r(o,s)}));d.querySelector(`button > span`).prepend(h(v)),p.replaceChildren(a(`div`,`flex flex-none items-center gap-1 border-b border-neutral-200 px-2 py-1.5`,e(`Compare two points`,`compare`),e(`One commit`,`commit`)),c,...u===`compare`?[d]:[])}m(),c(e,p)}var fe=/\.(png|jpe?g|gif|webp|avif|bmp|svg)$/i,C={A:`text-emerald-600`,M:`text-amber-600`,D:`text-red-600`},pe=30,w=`pier.filesPrefs`,T=50,E=()=>{try{let e=JSON.parse(localStorage.getItem(w)??`{}`);return e&&typeof e==`object`?e:{}}catch{return{}}},me=e=>{let t=e?E()[e]:void 0;return typeof t?.cwd==`string`&&typeof t.base==`string`&&typeof t.head==`string`?{cwd:t.cwd,base:t.base,head:t.head}:null},he=(e,t)=>{let n=E();delete n[e],n[e]=t;let r=Object.keys(n);for(let e of r.slice(0,Math.max(0,r.length-T)))delete n[e];try{localStorage.setItem(w,JSON.stringify(n))}catch(e){console.warn(`Files: could not remember this folder`,e)}},ge=`flex min-w-0 flex-none items-center gap-1.5 rounded-full px-2.5 py-1 font-mono text-[11.5px]`,_e=(e,t=`indigo`)=>re(e,`${ge} cursor-pointer truncate text-left ${t===`indigo`?`bg-indigo-50 font-medium text-indigo-700 hover:bg-indigo-100`:`bg-neutral-100 text-neutral-600 hover:bg-neutral-200`}`);function D(i,c,ee,_){let v=``,y=``,b={branch:null,refs:[],commits:[],worktrees:[]},x=`HEAD`,S=``,w=new Map,T=!0,E=!1,D=[],O=new Set,k=null,A=null,j=null,ve=a(`header`,`pagehead flex-nowrap`),M=a(`div`,`${l} flex-col items-stretch gap-1.5 px-3 py-2.5`),N=a(`div`,`min-h-0 flex-1 overflow-y-auto px-1.5 py-1.5`),ye=a(`aside`,`${ie} w-64 flex-none text-[12.5px] leading-5 max-md:h-1/2 max-md:w-full`,M,N),P=a(`div`,`${l} flex-wrap gap-2 px-4 py-2.5 font-mono text-[11.5px] text-neutral-500`),F=a(`div`,`h-full min-w-0 overflow-auto`),I=a(`div`,`pointer-events-none absolute inset-y-1 right-0.5 z-20 hidden w-2`),be=a(`div`,`relative min-h-0 flex-1`,F,I),xe=a(`section`,`${ie} min-h-0 min-w-0 flex-1`,P,be);i.append(ve,a(`div`,`flex min-h-0 flex-1 gap-3 px-4 pb-4 pt-1 max-md:flex-col`,ye,xe));let L=(e,t)=>`/api/explorer/${e}?${new URLSearchParams({root:v,...t})}`,R=(e,t)=>`/api/fs/${e}?${new URLSearchParams({root:v,...t})}`,z=(e,t=`text-neutral-400`)=>a(`p`,`px-3 py-2 text-[12.5px] ${t}`,e),B=e=>a(`div`,`flex h-full items-center justify-center p-6`,n(e)),Se=()=>T&&b.branch!==null,Ce=e=>{let t=0;for(let n of w.keys())n.startsWith(`${e}/`)&&t++;return t};function we(e,t){let n=new Set(t.map(e=>e.name)),r=e?`${e}/`:``;for(let[e,i]of w){if(i.status!==`D`||!e.startsWith(r))continue;let a=e.slice(r.length).split(`/`)[0];n.has(a)||(n.add(a),t.push({name:a,dir:e.slice(r.length).includes(`/`)}))}let i=t;return Se()&&(i=t.filter(e=>e.dir?Ce(`${r}${e.name}`)>0:w.has(`${r}${e.name}`))),i.sort((e,t)=>Number(t.dir)-Number(e.dir)||e.name.localeCompare(t.name)),i.map(e=>e.dir?Ee(`${r}${e.name}`,e.name):De(`${r}${e.name}`,e.name))}async function Te(e,t){try{let{entries:n}=await g(R(`ls`,{path:t}),`could not list this folder`);e.replaceChildren(...we(t,n)),e.childElementCount||e.append(z(Se()?`No changes.`:`Empty.`))}catch(n){let r=we(t,[]);r.length?e.replaceChildren(...r):e.replaceChildren(z(String(n),`text-red-600`))}}function Ee(e,t){let n=Ce(e),r=[a(`span`,`truncate`,t)];n&&r.push(a(`span`,`ml-auto flex-none rounded-full bg-amber-100/80 px-1.5 font-mono text-[10px] font-semibold text-amber-700`,String(n)));let{el:i,summary:o}=te(``,r);o.className+=` rounded-lg px-2 py-1 transition-colors hover:bg-neutral-100`;let s=a(`div`,`pl-3`);i.append(s);let c=!1,l=()=>{c||(c=!0,s.append(z(`…`)),Te(s,e))};return i.ontoggle=()=>{O[i.open?`add`:`delete`](e),i.open&&l()},((b.branch?T:!e.includes(`/`))&&!E||O.has(e))&&(i.open=!0,l()),i}function V(e){A?.classList.remove(`bg-indigo-50`,`font-medium`,`text-indigo-700`),A=e,e.classList.add(`bg-indigo-50`,`font-medium`,`text-indigo-700`)}function De(e,t){let n=w.get(e)?.status,r=a(`button`,`flex w-full cursor-pointer items-center gap-1.5 rounded-lg py-1 pl-6 pr-2 text-left transition-colors hover:bg-neutral-100`,a(`span`,`truncate`,t));return n&&r.append(a(`span`,`ml-auto flex-none font-mono text-[10.5px] font-semibold ${C[n]??`text-neutral-500`}`,n)),r.title=e,e===k&&V(r),e===j&&(j=null,k=e,V(r),J(e)),r.onclick=()=>{V(r),k=e,J(e)},r}let H=()=>void Te(N,``),U=(e,t)=>[...e?[a(`span`,`flex-none font-mono text-[10.5px] font-semibold text-emerald-600`,`+${e}`)]:[],...t?[a(`span`,`flex-none font-mono text-[10.5px] font-semibold text-red-600`,`−${t}`)]:[]];function W(e,t=!1,n){if(P.classList.toggle(`hidden`,!e),P.classList.toggle(`flex`,!!e),!e)return P.replaceChildren();let i=w.get(e),o=[a(`span`,`min-w-0 truncate`,e)];i&&(o.push(a(`span`,`flex-none font-semibold ${C[i.status]??``}`,i.status)),o.push(...U(i.add,i.del)));let s=a(`div`,`ml-auto flex flex-none items-center gap-2`);if(n&&s.append(n),t){let t=document.createElement(`a`);t.className=`btn flex-none text-[12px]`,t.href=R(`file`,{path:e}),t.download=r(e),t.textContent=`Download`,s.append(t)}P.replaceChildren(...o,s)}function Oe(e){let t=0;for(;t<e.length;){if(e[t].tone!==`del`){t++;continue}let n=t;for(;t<e.length&&e[t].tone===`del`;)t++;let r=t;for(;t<e.length&&e[t].tone===`add`;)t++;let i=Math.min(r-n,t-r);for(let t=0;t<i;t++){let i=e[n+t],a=e[r+t],o=Math.min(i.text.length,a.text.length),s=0;for(;s<o&&i.text[s]===a.text[s];)s++;let c=0;for(;c<o-s&&i.text[i.text.length-1-c]===a.text[a.text.length-1-c];)c++;s+c!==0&&(i.mark=[s,i.text.length-c],a.mark=[s,a.text.length-c])}}}let G=Ie();function K(){I.classList.add(`hidden`),I.replaceChildren(),G.set([],[])}let ke=0,q=e=>e===ke;function J(e){let t=++ke;return w.has(e)?Le(e,t):Ae(e,t)}async function Ae(e,t){if(!q(t))return;K();let n=R(`file`,{path:e}),r=a(`div`,`min-w-0`);if(F.classList.remove(`flex`,`flex-col`),W(e,!0),F.replaceChildren(r),fe.test(e)){let e=a(`img`,`max-w-full p-4`);e.src=n,e.onerror=()=>r.replaceChildren(z(`Could not load image.`,`text-red-600`)),r.append(e);return}if(e.toLowerCase().endsWith(`.pdf`)){let e=document.createElement(`iframe`);e.className=`h-full w-full`,e.src=n,F.replaceChildren(e),F.classList.add(`flex`,`flex-col`);return}r.append(z(`…`));let i=await fetch(n);if(!q(t))return;if(!i.ok){let{error:e}=await i.json().catch(()=>({}));r.replaceChildren(z(e??`HTTP ${i.status}`,`text-red-600`));return}if(!(i.headers.get(`content-type`)??``).startsWith(`text/plain`)){r.replaceChildren(z(`Binary file — use Download.`));return}let o=f(await i.text());if(!q(t))return;let s=await u(e);q(t)&&r.replaceChildren(m(o,s))}function je(e){let t=[],n=0,r=0,i=!1,a=e.split(`
4
+ `);a.at(-1)===``&&a.pop();for(let e of a){let a=/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(e);if(a){i&&t.push({nums:[``,``],text:`⋯`,tone:``}),[n,r]=[Number(a[1]),Number(a[2])],i=!0;continue}!i||e.startsWith(`\\`)||(e.startsWith(`+`)?t.push({nums:[``,r++],text:e.slice(1),tone:`add`}):e.startsWith(`-`)?t.push({nums:[n++,``],text:e.slice(1),tone:`del`}):t.push({nums:[n++,r++],text:e.slice(1),tone:``}))}return t}function Me(e){let t=[];for(let n=0;n<e.length;n++){let r=e[n].tone;if(!r)continue;let i=t.at(-1);i&&i.end===n-1?(i.end=n,(r===`add`&&i.tone===`del`||r===`del`&&i.tone===`add`)&&(i.tone=`mixed`)):t.push({start:n,end:n,tone:r})}return t}let Ne={add:`bg-emerald-400`,del:`bg-red-400`,mixed:`bg-amber-400`};function Pe(e,t,n){I.replaceChildren(...e.map(e=>{let r=a(`button`,`map-mark pointer-events-auto absolute left-1/2 w-1 -translate-x-1/2 cursor-pointer rounded-full ${Ne[e.tone]}`);return r.style.top=`${e.start/t*100}%`,r.style.height=`${Math.max(.4,(e.end-e.start+1)/t*100)}%`,r.title=`line ${Fe(n,e.start)}`,r.onclick=()=>n[e.start].scrollIntoView({block:`center`}),r})),I.classList.remove(`hidden`)}let Fe=(e,t)=>e[t]?.querySelector(`span`)?.textContent?.trim()||String(t+1);function Ie(){let e=[],t=[],n=-1,r=a(`span`,`flex-none text-[10.5px] text-neutral-400`),s=i=>{e.length&&(n=(n+i+e.length)%e.length,t[e[n].start].scrollIntoView({block:`center`}),r.textContent=`${n+1}/${e.length}`)},c=()=>e.length>0&&!i.classList.contains(`hidden`),l=(e,t,n,r)=>{let i=a(`button`,`icon-btn flex-none`,h(e));return i.onclick=()=>s(t),o(i,r,n,()=>s(t),c),i};return{el:a(`div`,`flex flex-none items-center gap-1`,r,l(p,-1,`Previous change`,[`p`,`k`]),l(ae,1,`Next change`,[`n`,`j`])),set(i,a){e=i,t=a,n=-1,r.textContent=`${i.length} change${i.length===1?``:`s`}`}}}async function Le(e,t){K(),F.classList.remove(`flex`,`flex-col`);let n=w.get(e)?.status!==`D`;W(e,n),F.replaceChildren(z(`…`));try{let{diff:r}=await g(L(`diff`,{base:x,head:S,file:e,context:`99999`}),`could not read this diff`);if(!q(t))return;if(!r||/^Binary files /m.test(r))return Ae(e,t);let i=je(r);Oe(i);let a=await u(e);if(!q(t))return;let o=m(i,a),s=[...o.children],c=Me(i);G.set(c,s),W(e,n,G.el),F.replaceChildren(o),Pe(c,i.length,s)}catch(n){if(!q(t))return;W(e),F.replaceChildren(z(String(n),`text-red-600`))}}let Y=()=>S&&x===`${S}~1`?S:``,Re=()=>b.commits.find(e=>e.hash===Y())?.subject??``,ze=()=>Y()?`${Y()} — ${Re()||`commit`}`:`${x} ↔ ${S||`working tree`}`,Be=(e,t,n,r)=>{let i=a(`button`,`icon-btn ${n?`bg-indigo-50 text-indigo-700 hover:bg-indigo-100 hover:text-indigo-700`:``}`);return i.append(h(e)),i.title=t,i.setAttribute(`aria-label`,t),i.setAttribute(`aria-pressed`,String(n)),i.onclick=r,i};function X(){let e=Be(se,`Show only files that differ`,T,()=>{T=!T,X(),H()}),t=Be(oe,E?`Restore folders`:`Collapse all folders`,E,()=>{if(E=!E,E)D=[...O],O.clear();else for(let e of D)O.add(e);X(),H()});if(!b.branch){M.replaceChildren(a(`div`,`flex items-center gap-1.5`,a(`span`,`min-w-0 flex-1 text-[11px] leading-snug text-neutral-400`,`Not a git repository — nothing to compare.`),t));return}let n=re(ze(),`${ne} font-mono`);if(Y()){let e=b.commits.find(e=>e.hash===Y());ue(n,()=>e?le(e):``)}else n.title=`Choose what to diff`;n.onclick=async()=>{await We(),de(n,b,{base:x,head:S},(e,t)=>{x=e,S=t,Z()},$())};let[r,i]=[0,0];for(let e of w.values())r+=e.add,i+=e.del;M.replaceChildren(n,a(`div`,`flex items-center gap-1.5 pl-0.5`,a(`span`,`text-[11px] text-neutral-400`,`${w.size} changed file${w.size===1?``:`s`}`),...U(r,i),a(`span`,`ml-auto`),e,t))}let Ve=()=>{y&&v&&he(y,{cwd:v,base:x,head:S})},He=e=>{let t=e.replace(/~\d+$/,``);return!t||t===`HEAD`||b.refs.some(e=>e.name===t)||b.commits.some(e=>e.hash===t)};async function Ue(){if(w=new Map,!b.branch)return;let{files:e}=await g(L(`diff`,{base:x,head:S}),`could not read what changed`);for(let t of e)w.set(t.path,{status:t.status,add:t.add,del:t.del});if(w.size<=pe)for(let e of w.keys()){let t=e.split(`/`);for(let e=1;e<t.length;e++)O.add(t.slice(0,e).join(`/`))}}async function Z(){Ve();try{await Ue()}catch(e){X(),N.replaceChildren(z(String(e),`text-red-600`));return}X(),H(),k&&J(k)}function Q(){let e=_e(``,`neutral`);e.className+=` max-w-72`,e.append(a(`span`,`truncate max-md:hidden`,v||`Choose a folder…`),a(`span`,`truncate md:hidden`,v?r(v):`Choose a folder…`)),e.title=v?`${v} — switch folder`:`Choose a folder`,e.onclick=()=>d(e,b.worktrees.map(e=>({path:e.path,...e.branch?{hint:e.branch}:{}})),v||void 0,ee);let n=a(`button`,`icon-btn ml-auto`,h(s));n.type=`button`,n.title=`Close Files`,n.setAttribute(`aria-label`,`Close Files`),n.onclick=_;let i=b.branch?[a(`span`,`${ge} bg-neutral-100 text-neutral-600`,h(ce),a(`span`,`truncate`,b.branch))]:[];ve.replaceChildren(t(`Files`),e,...i,n)}async function We(){if(v)try{b=await g(L(`git`,{}),`could not read this repository`),Q()}catch(e){F.replaceChildren(B(String(e)))}}let $=()=>b.refs.some(e=>e.name===`main`)?`main`:b.refs.some(e=>e.name===`master`)?`master`:`HEAD`;async function Ge(e){if(Q(),k=null,A=null,O.clear(),j=e||null,e){T=!1;let t=e.split(`/`);for(let e=1;e<t.length;e++)O.add(t.slice(0,e).join(`/`))}if(K(),F.classList.remove(`flex`,`flex-col`),W(),F.replaceChildren(B(`Select a file.`)),!v){M.replaceChildren(),N.replaceChildren(),F.replaceChildren(B(`No folder yet — pick one from the chip above.`));return}try{b=await g(L(`git`,{}),`could not read this repository`)}catch(e){b={branch:null,refs:[],commits:[],worktrees:[]},F.replaceChildren(B(String(e)))}let t=me(y),n=t&&t.cwd===v&&He(t.base)&&He(t.head)?t:null;x=n?.base??$(),S=n?.head??``,Q(),await Z()}return e(i,(e,t)=>{let n=c(),r=n?.id??``,i=new URLSearchParams(t).get(`select`)??void 0,a=e?.startsWith(`/`)?e:(r===y?v:``)||me(r)?.cwd||n?.cwd||``;if(a===v&&r===y&&N.childElementCount&&(!i||i===k)){Q(),We().then(Z);return}y=r,v=a,Ge(i)},()=>{K(),F.classList.remove(`flex`,`flex-col`),W(),F.replaceChildren(B(`Select a file.`))})}export{D as createExplorerView};