@timqi/pier 0.1.7 → 0.1.9

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 (111) 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 +31 -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-DE9Dct4L.js +5 -0
  15. package/dist/web/public/assets/activity-DE9Dct4L.js.br +0 -0
  16. package/dist/web/public/assets/activity-DE9Dct4L.js.gz +0 -0
  17. package/dist/web/public/assets/boards-il6ceDEs.js +1 -0
  18. package/dist/web/public/assets/boards-il6ceDEs.js.br +0 -0
  19. package/dist/web/public/assets/boards-il6ceDEs.js.gz +0 -0
  20. package/dist/web/public/assets/explorer-JDO21aAs.js +4 -0
  21. package/dist/web/public/assets/explorer-JDO21aAs.js.br +0 -0
  22. package/dist/web/public/assets/explorer-JDO21aAs.js.gz +0 -0
  23. package/dist/web/public/assets/{index-b8jrYmUt.js → index-BNjWeodx.js} +15 -15
  24. package/dist/web/public/assets/index-BNjWeodx.js.br +0 -0
  25. package/dist/web/public/assets/index-BNjWeodx.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-CsINd08H.js +1 -0
  30. package/dist/web/public/assets/runs-CsINd08H.js.br +0 -0
  31. package/dist/web/public/assets/runs-CsINd08H.js.gz +0 -0
  32. package/dist/web/public/assets/settings-ChwAWwE_.js +5 -0
  33. package/dist/web/public/assets/settings-ChwAWwE_.js.br +0 -0
  34. package/dist/web/public/assets/settings-ChwAWwE_.js.gz +0 -0
  35. package/dist/web/public/assets/task-runs-I0eOy3HT.js +3 -0
  36. package/dist/web/public/assets/task-runs-I0eOy3HT.js.br +0 -0
  37. package/dist/web/public/assets/task-runs-I0eOy3HT.js.gz +0 -0
  38. package/dist/web/public/assets/tasks-D3i9Kc5-.js +4 -0
  39. package/dist/web/public/assets/tasks-D3i9Kc5-.js.br +0 -0
  40. package/dist/web/public/assets/tasks-D3i9Kc5-.js.gz +0 -0
  41. package/dist/web/public/icon-192-amber.png +0 -0
  42. package/dist/web/public/icon-192-emerald.png +0 -0
  43. package/dist/web/public/icon-192-rose.png +0 -0
  44. package/dist/web/public/icon-192-teal.png +0 -0
  45. package/dist/web/public/icon-192-violet.png +0 -0
  46. package/dist/web/public/icon-192.png +0 -0
  47. package/dist/web/public/icon-32-amber.png +0 -0
  48. package/dist/web/public/icon-32-emerald.png +0 -0
  49. package/dist/web/public/icon-32-rose.png +0 -0
  50. package/dist/web/public/icon-32-teal.png +0 -0
  51. package/dist/web/public/icon-32-violet.png +0 -0
  52. package/dist/web/public/icon-32.png +0 -0
  53. package/dist/web/public/icon-512-amber.png +0 -0
  54. package/dist/web/public/icon-512-emerald.png +0 -0
  55. package/dist/web/public/icon-512-rose.png +0 -0
  56. package/dist/web/public/icon-512-teal.png +0 -0
  57. package/dist/web/public/icon-512-violet.png +0 -0
  58. package/dist/web/public/icon-512.png +0 -0
  59. package/dist/web/public/icon-maskable-512-amber.png +0 -0
  60. package/dist/web/public/icon-maskable-512-emerald.png +0 -0
  61. package/dist/web/public/icon-maskable-512-rose.png +0 -0
  62. package/dist/web/public/icon-maskable-512-teal.png +0 -0
  63. package/dist/web/public/icon-maskable-512-violet.png +0 -0
  64. package/dist/web/public/icon-maskable-512.png +0 -0
  65. package/dist/web/public/icon-touch-192-amber.png +0 -0
  66. package/dist/web/public/icon-touch-192-emerald.png +0 -0
  67. package/dist/web/public/icon-touch-192-rose.png +0 -0
  68. package/dist/web/public/icon-touch-192-teal.png +0 -0
  69. package/dist/web/public/icon-touch-192-violet.png +0 -0
  70. package/dist/web/public/icon-touch-192.png +0 -0
  71. package/dist/web/public/index.html +14 -10
  72. package/dist/web/public/index.html.br +0 -0
  73. package/dist/web/public/index.html.gz +0 -0
  74. package/dist/web/public/manifest.webmanifest +10 -10
  75. package/dist/web/public/sw.js +5 -5
  76. package/dist/web/public/sw.js.br +0 -0
  77. package/dist/web/public/sw.js.gz +0 -0
  78. package/dist/web/push.js +15 -3
  79. package/dist/web/server.js +69 -11
  80. package/docs/deploy.md +19 -2
  81. package/package.json +1 -1
  82. package/dist/web/public/assets/activity-DR5gTwpG.js +0 -5
  83. package/dist/web/public/assets/activity-DR5gTwpG.js.br +0 -0
  84. package/dist/web/public/assets/activity-DR5gTwpG.js.gz +0 -0
  85. package/dist/web/public/assets/boards-DBO30B54.js +0 -1
  86. package/dist/web/public/assets/boards-DBO30B54.js.br +0 -0
  87. package/dist/web/public/assets/boards-DBO30B54.js.gz +0 -0
  88. package/dist/web/public/assets/explorer-ZeVls41v.js +0 -4
  89. package/dist/web/public/assets/explorer-ZeVls41v.js.br +0 -0
  90. package/dist/web/public/assets/explorer-ZeVls41v.js.gz +0 -0
  91. package/dist/web/public/assets/index-DMq4awU8.css +0 -2
  92. package/dist/web/public/assets/index-DMq4awU8.css.br +0 -0
  93. package/dist/web/public/assets/index-DMq4awU8.css.gz +0 -0
  94. package/dist/web/public/assets/index-b8jrYmUt.js.br +0 -0
  95. package/dist/web/public/assets/index-b8jrYmUt.js.gz +0 -0
  96. package/dist/web/public/assets/runs-C0GaHPSU.js +0 -1
  97. package/dist/web/public/assets/runs-C0GaHPSU.js.br +0 -0
  98. package/dist/web/public/assets/runs-C0GaHPSU.js.gz +0 -0
  99. package/dist/web/public/assets/settings-avLEQHdY.js +0 -5
  100. package/dist/web/public/assets/settings-avLEQHdY.js.br +0 -0
  101. package/dist/web/public/assets/settings-avLEQHdY.js.gz +0 -0
  102. package/dist/web/public/assets/task-runs-BCbKn_zb.js +0 -3
  103. package/dist/web/public/assets/task-runs-BCbKn_zb.js.br +0 -0
  104. package/dist/web/public/assets/task-runs-BCbKn_zb.js.gz +0 -0
  105. package/dist/web/public/assets/tasks-CReIwEMw.js +0 -4
  106. package/dist/web/public/assets/tasks-CReIwEMw.js.br +0 -0
  107. package/dist/web/public/assets/tasks-CReIwEMw.js.gz +0 -0
  108. package/dist/web/public/icon.svg.br +0 -0
  109. package/dist/web/public/icon.svg.gz +0 -0
  110. package/dist/web/public/manifest.webmanifest.br +0 -0
  111. package/dist/web/public/manifest.webmanifest.gz +0 -0
package/dist/web/auth.js CHANGED
@@ -20,6 +20,10 @@ const MAX_AGE_MS = 90 * 24 * 60 * 60_000;
20
20
  const TOUCH_MS = 5 * 60_000;
21
21
  /** Not an id any row can have. */
22
22
  export const ALL = "*";
23
+ /** A `pier login` link is walked to a browser within this; single use. */
24
+ const LINK_TTL_MS = 2 * 60_000;
25
+ /** Outstanding links at once — an operator minting a handful, not a queue. */
26
+ const MAX_LINKS = 10;
23
27
  const MAX_FAILURES = 10;
24
28
  const WINDOW_MS = 15 * 60_000;
25
29
  /** Distinct throttle buckets retained at once; the last is shared overflow. */
@@ -45,6 +49,8 @@ export class AuthStore {
45
49
  #db;
46
50
  #sql;
47
51
  #revokeListeners = new Set();
52
+ /** In memory only: a link outlives neither the process nor two minutes. */
53
+ #links = new Map();
48
54
  constructor(db = pierDb(), print = (m) => log.info(m)) {
49
55
  this.#db = db;
50
56
  this.#sql = statements(db);
@@ -97,6 +103,7 @@ export class AuthStore {
97
103
  .run(salt, hash(password, salt), Date.now());
98
104
  this.#dropSessions();
99
105
  });
106
+ this.#links.clear();
100
107
  this.#revoked(ALL);
101
108
  }
102
109
  open(ip, agent) {
@@ -135,8 +142,11 @@ export class AuthStore {
135
142
  }
136
143
  /** Listeners hear the same id: a revoked cookie must also close what it opened. */
137
144
  revoke(id) {
138
- if (id === ALL)
145
+ // An unspent link is a session about to exist: "sign everyone out" ends it too.
146
+ if (id === ALL) {
139
147
  this.#dropSessions();
148
+ this.#links.clear();
149
+ }
140
150
  else
141
151
  this.#sql("DELETE FROM web_sessions WHERE id = ?").run(id);
142
152
  this.#revoked(id);
@@ -145,6 +155,25 @@ export class AuthStore {
145
155
  return this.#sql("SELECT id, created_at AS createdAt, seen_at AS seenAt, ip, agent" +
146
156
  " FROM web_sessions WHERE seen_at > ? ORDER BY seen_at DESC").all(Date.now() - TTL_MS);
147
157
  }
158
+ /** A one-time sign-in token for `pier login`: the terminal on Pier's own
159
+ * machine is the door, not the password or a passkey. Oldest evicted at cap. */
160
+ mintLink() {
161
+ const now = Date.now();
162
+ for (const [token, expiresAt] of this.#links)
163
+ if (expiresAt <= now)
164
+ this.#links.delete(token);
165
+ while (this.#links.size >= MAX_LINKS)
166
+ this.#links.delete(this.#links.keys().next().value);
167
+ const token = randomBytes(32).toString("base64url");
168
+ this.#links.set(token, now + LINK_TTL_MS);
169
+ return token;
170
+ }
171
+ /** Consumed on the first look, valid or not. */
172
+ redeemLink(token) {
173
+ const expiresAt = this.#links.get(token);
174
+ this.#links.delete(token);
175
+ return expiresAt !== undefined && expiresAt > Date.now();
176
+ }
148
177
  /** A long-lived authenticated surface (SSE) closes itself when its cookie is revoked. */
149
178
  onRevoke(listener) {
150
179
  this.#revokeListeners.add(listener);
@@ -163,12 +192,17 @@ export class AuthStore {
163
192
  }
164
193
  const hash = (password, salt) => scryptSync(password, salt, KEY_BYTES).toString("hex");
165
194
  const digest = (token) => createHash("sha256").update(token).digest("hex");
166
- /** The login form and `/p/*` — published boards and their stylesheet — are the
167
- * one exempt prefix here (`/config-sync/:token` is mounted before this
168
- * middleware, docs/architecture.md names both); `/boards/*` stays behind. */
195
+ /** The login form, the passkey half of it (web/passkeys.ts) and `/p/*` —
196
+ * published boards and their stylesheet — are the exemptions here
197
+ * (`/config-sync/:token` is mounted before this middleware,
198
+ * docs/architecture.md names both); `/boards/*` stays behind. */
169
199
  function isPublic(method, path) {
170
200
  if (path === "/login")
171
201
  return method === "GET" || method === "HEAD" || method === "POST";
202
+ if (path.startsWith("/login/"))
203
+ return method === "GET";
204
+ if (path === "/api/passkeys/login/options" || path === "/api/passkeys/login/verify")
205
+ return method === "POST";
172
206
  if (method !== "GET" && method !== "HEAD")
173
207
  return false;
174
208
  return path.startsWith("/p/");
@@ -182,10 +216,10 @@ function sameSecret(a, b) {
182
216
  * to it — and they strip whitespace and control characters from a Location
183
217
  * first, which turns `/<TAB>/evil.example` into one as well. Only a plain
184
218
  * path survives; anything else is an open redirect. */
185
- const safeNext = (raw) => typeof raw === "string" && /^\/(?![/\\])\S*$/.test(raw) &&
219
+ export const safeNext = (raw) => typeof raw === "string" && /^\/(?![/\\])\S*$/.test(raw) &&
186
220
  ![...raw].some((ch) => ch <= "\u001f" || ch === "\u007f")
187
221
  ? raw
188
- : "/";
222
+ : "/app/";
189
223
  // In memory: the window is minutes, and the point is to make guessing slow.
190
224
  // Fresh identities spill into one overflow bucket once the cap is reached.
191
225
  const failures = new Map();
@@ -198,7 +232,7 @@ function remoteOf(c) {
198
232
  }
199
233
  /** A forwarded address is trusted only from a local reverse proxy, and only
200
234
  * the rightmost hop, which that proxy appended. */
201
- function clientOf(c) {
235
+ export function clientOf(c) {
202
236
  const remote = remoteOf(c);
203
237
  if (remote && !loopback(remote))
204
238
  return remote;
@@ -209,14 +243,16 @@ function failureClient(client) {
209
243
  return client;
210
244
  return OVERFLOW_CLIENT;
211
245
  }
212
- function throttled(client) {
246
+ /** One throttle for every unauthenticated door: a passkey assertion is
247
+ * guessed at from the same address as a password. */
248
+ export function throttled(client) {
213
249
  const now = Date.now();
214
250
  for (const [id, entry] of failures)
215
251
  if (entry.resetAt <= now)
216
252
  failures.delete(id);
217
253
  return (failures.get(failureClient(client))?.count ?? 0) >= MAX_FAILURES;
218
254
  }
219
- function noteFailure(client) {
255
+ export function noteFailure(client) {
220
256
  client = failureClient(client);
221
257
  const entry = failures.get(client);
222
258
  if (entry && entry.resetAt > Date.now())
@@ -224,6 +260,7 @@ function noteFailure(client) {
224
260
  else
225
261
  failures.set(client, { count: 1, resetAt: Date.now() + WINDOW_MS });
226
262
  }
263
+ export const forgetFailures = (client) => void failures.delete(client);
227
264
  /** Hosts, not schemes: TLS commonly terminates at the reverse proxy. */
228
265
  function originMatches(origin, host) {
229
266
  if (!origin)
@@ -293,8 +330,22 @@ export function requireAuth(store) {
293
330
  sealBoundary(c);
294
331
  };
295
332
  }
296
- export function registerAuthRoutes(app, store) {
297
- app.get("/login", (c) => c.html(loginPage(safeNext(c.req.query("next")))));
333
+ /** Both sign-in doors end here: this browser's previous session is replaced,
334
+ * never added to. Verified first: the id in an unverified cookie is a string
335
+ * the caller chose, and `ALL` is one of them. */
336
+ export function openSession(c, store, client) {
337
+ forgetFailures(client);
338
+ const previous = store.check(getCookie(c, COOKIE));
339
+ if (previous)
340
+ store.revoke(previous.id);
341
+ setSessionCookie(c, store.open(client, c.req.header("user-agent") ?? ""));
342
+ }
343
+ export const PASSWORD_OFF = "Password sign-in is off while a passkey is registered.";
344
+ export function registerAuthRoutes(app, store,
345
+ /** While it answers true the password is refused and the form offers only
346
+ * the passkey; read per request, so removing the last passkey needs no restart. */
347
+ passkeysRegistered = () => false) {
348
+ app.get("/login", (c) => c.html(loginPage(safeNext(c.req.query("next")), undefined, passkeysRegistered())));
298
349
  app.post("/login", async (c) => {
299
350
  const client = clientOf(c);
300
351
  // Before the body is touched: parsing is work, and this is the one write a
@@ -302,7 +353,11 @@ export function registerAuthRoutes(app, store) {
302
353
  if (throttled(client)) {
303
354
  // A burst here is the only warning an operator gets that the port is being knocked on.
304
355
  log.warn(`login throttled for ${client}`);
305
- return c.html(loginPage("/", "Too many attempts. Wait a few minutes."), 429);
356
+ return c.html(loginPage("/app/", "Too many attempts. Wait a few minutes.", passkeysRegistered()), 429);
357
+ }
358
+ if (passkeysRegistered()) {
359
+ log.warn(`password sign-in refused for ${client}: a passkey is registered`);
360
+ return c.html(loginPage("/app/", PASSWORD_OFF, true), 403);
306
361
  }
307
362
  if (!c.req.header("content-type")?.startsWith("application/x-www-form-urlencoded")) {
308
363
  return c.text("expected the sign-in form", 400);
@@ -324,17 +379,35 @@ export function registerAuthRoutes(app, store) {
324
379
  log.warn(`wrong password from ${client}`);
325
380
  return c.html(loginPage(next, "Wrong password."), 401);
326
381
  }
327
- failures.delete(client);
328
382
  log.info(`login from ${client}`);
329
- // Replaces this browser's session rather than adding one. Verified first:
330
- // the id in an unverified cookie is a string the caller chose, and `ALL` is
331
- // one of them.
332
- const previous = store.check(getCookie(c, COOKIE));
333
- if (previous)
334
- store.revoke(previous.id);
335
- setSessionCookie(c, store.open(client, c.req.header("user-agent") ?? ""));
383
+ openSession(c, store, client);
336
384
  return c.redirect(next);
337
385
  });
386
+ // The link `pier login` printed. Both refusals read the same and count on
387
+ // the throttle: a stranger must not learn whether a token was ever minted.
388
+ app.get("/login/:token", (c) => {
389
+ // The URL is the credential: no cache may hold the answer, no referrer carry it.
390
+ c.header("cache-control", "no-store");
391
+ c.header("referrer-policy", "no-referrer");
392
+ // Hono routes HEAD through GET: a link checker with a live cookie would spend the token.
393
+ if (c.req.method !== "GET") {
394
+ c.header("allow", "GET");
395
+ return c.body(null, 405);
396
+ }
397
+ const client = clientOf(c);
398
+ if (throttled(client)) {
399
+ log.warn(`login throttled for ${client}`);
400
+ return c.html(loginPage("/app/", "Too many attempts. Wait a few minutes.", passkeysRegistered()), 429);
401
+ }
402
+ if (!store.redeemLink(c.req.param("token"))) {
403
+ noteFailure(client);
404
+ log.warn(`stale or unknown sign-in link from ${client}`);
405
+ return c.html(loginPage("/app/", "That sign-in link has expired. Run pier login again.", passkeysRegistered()), 401);
406
+ }
407
+ log.info(`login from ${client} by link`);
408
+ openSession(c, store, client);
409
+ return c.redirect("/app/");
410
+ });
338
411
  // Re-authenticates: the boundary requires a live cookie, and knowing a
339
412
  // password is not permission to call APIs.
340
413
  app.post("/api/password", async (c) => {
@@ -344,6 +417,10 @@ export function registerAuthRoutes(app, store) {
344
417
  const next = typeof body?.next === "string" ? body.next : "";
345
418
  if (throttled(client))
346
419
  return c.json({ error: "Too many attempts. Wait a few minutes." }, 429);
420
+ if (passkeysRegistered()) {
421
+ log.warn(`password change refused for ${client}: a passkey is registered`);
422
+ return c.json({ error: PASSWORD_OFF }, 403);
423
+ }
347
424
  if (!store.verify(current)) {
348
425
  noteFailure(client);
349
426
  return c.json({ error: "Wrong current password." }, 403);
@@ -351,7 +428,7 @@ export function registerAuthRoutes(app, store) {
351
428
  if (next.length < MIN_LENGTH) {
352
429
  return c.json({ error: `Use at least ${MIN_LENGTH} characters.` }, 400);
353
430
  }
354
- failures.delete(client);
431
+ forgetFailures(client);
355
432
  store.setPassword(next);
356
433
  // The rotation dropped this caller's row too; clear the dead cookie.
357
434
  deleteCookie(c, COOKIE, { path: "/" });
@@ -396,9 +473,58 @@ function setSessionCookie(c, value) {
396
473
  maxAge: TTL_MS / 1000,
397
474
  });
398
475
  }
399
- /** Self-contained: it links nothing the boundary would refuse to serve. */
400
- function loginPage(next, error) {
476
+ /** Self-contained: it links nothing the boundary would refuse to serve. With a
477
+ * passkey registered the form is the one button; its script does the
478
+ * base64url ↔ bytes conversion by hand, since Safari lacks
479
+ * `parseRequestOptionsFromJSON`. */
480
+ function loginPage(next, error, passkey = false) {
401
481
  const attr = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
482
+ const fields = passkey
483
+ ? `<input type="hidden" name="next" value="${attr(next)}" />
484
+ <button type="submit" id="passkey" autofocus>Sign in with a passkey</button>`
485
+ : `<input type="password" name="password" placeholder="Password" autocomplete="current-password"
486
+ autofocus required />
487
+ <input type="hidden" name="next" value="${attr(next)}" />
488
+ <button type="submit">Sign in</button>`;
489
+ const script = passkey
490
+ ? `<script>
491
+ (() => {
492
+ const form = document.querySelector("form"), button = document.getElementById("passkey");
493
+ const say = (text) => { document.getElementById("err").textContent = text; };
494
+ const bytes = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (ch) => ch.charCodeAt(0));
495
+ const text = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
496
+ if (!window.PublicKeyCredential) { say("This browser has no passkey support."); button.disabled = true; return; }
497
+ form.onsubmit = async (ev) => {
498
+ ev.preventDefault();
499
+ button.disabled = true;
500
+ say("");
501
+ try {
502
+ const opts = await fetch("/api/passkeys/login/options", { method: "POST" });
503
+ const publicKey = await opts.json().catch(() => ({}));
504
+ if (!opts.ok) throw new Error(publicKey.error || "Could not start the sign-in (" + opts.status + ")");
505
+ publicKey.challenge = bytes(publicKey.challenge);
506
+ publicKey.allowCredentials = publicKey.allowCredentials.map((cred) => ({ ...cred, id: bytes(cred.id) }));
507
+ const cred = await navigator.credentials.get({ publicKey });
508
+ const r = cred.response;
509
+ const res = await fetch("/api/passkeys/login/verify", {
510
+ method: "POST",
511
+ headers: { "content-type": "application/json" },
512
+ body: JSON.stringify({
513
+ id: cred.id, type: cred.type, next: form.elements.next.value,
514
+ response: { clientDataJSON: text(r.clientDataJSON), authenticatorData: text(r.authenticatorData), signature: text(r.signature) },
515
+ }),
516
+ });
517
+ const body = await res.json().catch(() => ({}));
518
+ if (!res.ok) throw new Error(body.error || "Sign-in refused (" + res.status + ")");
519
+ location.href = body.next;
520
+ } catch (err) {
521
+ say(String(err && err.message || err));
522
+ button.disabled = false;
523
+ }
524
+ };
525
+ })();
526
+ </script>`
527
+ : "";
402
528
  return `<!doctype html>
403
529
  <html lang="en">
404
530
  <head>
@@ -423,13 +549,10 @@ function loginPage(next, error) {
423
549
  <body>
424
550
  <form method="post" action="/login">
425
551
  <h1>Pier</h1>
426
- ${error ? `<p>${attr(error)}</p>` : ""}
427
- <input type="password" name="password" placeholder="Password" autocomplete="current-password"
428
- autofocus required />
429
- <input type="hidden" name="next" value="${attr(next)}" />
430
- <button type="submit">Sign in</button>
552
+ <p id="err">${error ? attr(error) : ""}</p>
553
+ ${fields}
431
554
  </form>
432
- </body>
555
+ ${script}</body>
433
556
  </html>
434
557
  `;
435
558
  }
@@ -1,7 +1,8 @@
1
1
  // Routes about the Pier instance itself: settings, updates, secrets control,
2
2
  // the browser's error reports. Nothing here touches a session.
3
3
  import { logger } from "../log.js";
4
- import { normalizeModelMenu, normalizeModelRef, normalizePublicUrl, } from "../settings.js";
4
+ import { ACCENTS, normalizeAccent, normalizeModelMenu, normalizeModelRef, normalizePublicUrl, } from "../settings.js";
5
+ import { boundHost } from "./passkeys.js";
5
6
  /** A rule that failed inside the transaction, distinct from a real fault: 400
6
7
  * for a name this Pier does not have, 409 for a collision with current state. */
7
8
  class Refusal extends Error {
@@ -15,7 +16,7 @@ const withName = (current, { name, on }) => on ? [...new Set([...current, name])
15
16
  /** A browser bug can fire in a loop, and the journal is shared. */
16
17
  const CLIENT_LOG_PER_MINUTE = 60;
17
18
  export function registerInstanceRoutes(app, deps) {
18
- const { settings, updates, updater = null, secrets, catalog, names = [], onUnlocked, onSettingsChanged, onToolsChanged, validateCustomTools, } = deps;
19
+ const { settings, passkeys, updates, updater = null, secrets, catalog, names = [], onUnlocked, onSettingsChanged, onToolsChanged, validateCustomTools, } = deps;
19
20
  const updateLog = logger("update");
20
21
  // A busy Pier drains first, which can take minutes, and a response held that
21
22
  // long dies at every proxy: past this cap the answer is "draining".
@@ -47,6 +48,8 @@ export function registerInstanceRoutes(app, deps) {
47
48
  const shown = await catalog?.();
48
49
  return {
49
50
  ...settings.get(),
51
+ /** The presets the swatches are drawn from: one table, the server's. */
52
+ accents: ACCENTS,
50
53
  catalog: shown?.entries ?? [],
51
54
  /** The install and every daily update is one task's history. */
52
55
  toolsTaskId: shown?.toolsTaskId ?? null,
@@ -108,11 +111,11 @@ export function registerInstanceRoutes(app, deps) {
108
111
  app.put("/api/settings", async (c) => {
109
112
  const body = await c.req.json().catch(() => null);
110
113
  const fields = body
111
- ? [body.publicUrl, body.modelMenu, body.titleModel, body.autoUpdate, body.customTools, body.tool]
114
+ ? [body.publicUrl, body.modelMenu, body.titleModel, body.autoUpdate, body.customTools, body.tool, body.accent]
112
115
  : [];
113
116
  if (!fields.some((v) => v !== undefined)) {
114
117
  return c.json({
115
- error: "publicUrl, modelMenu, titleModel, autoUpdate, customTools or tool required",
118
+ error: "publicUrl, modelMenu, titleModel, autoUpdate, customTools, tool or accent required",
116
119
  }, 400);
117
120
  }
118
121
  // One transaction: a new custom tool and the switch that turns it on must
@@ -125,8 +128,20 @@ export function registerInstanceRoutes(app, deps) {
125
128
  const publicUrl = normalizePublicUrl(body.publicUrl);
126
129
  if (publicUrl === null)
127
130
  return refuse("not a URL: expected http(s)://host, no query or fragment");
131
+ const bound = passkeys ? boundHost(passkeys, settings.get().publicUrl) : null;
132
+ if (bound && (!publicUrl.startsWith("https://") || new URL(publicUrl).hostname !== bound)) {
133
+ return refuse(`passkeys are bound to ${bound}; remove them first`);
134
+ }
128
135
  writes.push(() => settings.setPublicUrl(publicUrl));
129
136
  }
137
+ if (body?.accent !== undefined) {
138
+ if (typeof body.accent !== "string")
139
+ return refuse("accent must be a string");
140
+ const accent = normalizeAccent(body.accent);
141
+ if (accent === null)
142
+ return refuse(`accent must be one of ${Object.keys(ACCENTS).join(", ")}, or empty`);
143
+ writes.push(() => settings.setAccent(accent));
144
+ }
130
145
  if (body?.modelMenu !== undefined) {
131
146
  const menu = normalizeModelMenu(body.modelMenu);
132
147
  if (menu === null)