@timqi/pier 0.1.6 → 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.
- package/dist/agent/credentials.js +5 -2
- package/dist/agent/pi.js +1 -1
- package/dist/boards/pier.css +58 -39
- package/dist/channels/handoff.js +1 -1
- package/dist/cli.js +8 -0
- package/dist/db.js +13 -0
- package/dist/main.js +7 -2
- package/dist/secrets.js +15 -1
- package/dist/settings.js +28 -0
- package/dist/socket.js +13 -1
- package/dist/web/auth.js +152 -29
- package/dist/web/instance.js +19 -4
- package/dist/web/passkeys.js +438 -0
- package/dist/web/public/assets/activity-CImcJ4xF.js +5 -0
- package/dist/web/public/assets/activity-CImcJ4xF.js.br +0 -0
- package/dist/web/public/assets/activity-CImcJ4xF.js.gz +0 -0
- package/dist/web/public/assets/boards-43luxbPd.js +1 -0
- package/dist/web/public/assets/boards-43luxbPd.js.br +0 -0
- package/dist/web/public/assets/boards-43luxbPd.js.gz +0 -0
- package/dist/web/public/assets/explorer-uSHcxQMy.js +4 -0
- package/dist/web/public/assets/explorer-uSHcxQMy.js.br +0 -0
- package/dist/web/public/assets/explorer-uSHcxQMy.js.gz +0 -0
- package/dist/web/public/assets/{index-T-vLXPch.js → index-Dje7tHE4.js} +15 -15
- package/dist/web/public/assets/index-Dje7tHE4.js.br +0 -0
- package/dist/web/public/assets/index-Dje7tHE4.js.gz +0 -0
- package/dist/web/public/assets/index-sQWFDUPu.css +2 -0
- package/dist/web/public/assets/index-sQWFDUPu.css.br +0 -0
- package/dist/web/public/assets/index-sQWFDUPu.css.gz +0 -0
- package/dist/web/public/assets/runs-rggAaD0k.js +1 -0
- package/dist/web/public/assets/runs-rggAaD0k.js.br +0 -0
- package/dist/web/public/assets/runs-rggAaD0k.js.gz +0 -0
- package/dist/web/public/assets/settings-DN20Mj9n.js +5 -0
- package/dist/web/public/assets/settings-DN20Mj9n.js.br +0 -0
- package/dist/web/public/assets/settings-DN20Mj9n.js.gz +0 -0
- package/dist/web/public/assets/task-runs-BF79aZgL.js +3 -0
- package/dist/web/public/assets/task-runs-BF79aZgL.js.br +0 -0
- package/dist/web/public/assets/task-runs-BF79aZgL.js.gz +0 -0
- package/dist/web/public/assets/tasks-MCHgTBcD.js +4 -0
- package/dist/web/public/assets/tasks-MCHgTBcD.js.br +0 -0
- package/dist/web/public/assets/tasks-MCHgTBcD.js.gz +0 -0
- package/dist/web/public/icon-32-amber.png +0 -0
- package/dist/web/public/icon-32-emerald.png +0 -0
- package/dist/web/public/icon-32-rose.png +0 -0
- package/dist/web/public/icon-32-teal.png +0 -0
- package/dist/web/public/icon-32-violet.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192-amber.png +0 -0
- package/dist/web/public/icon-touch-192-emerald.png +0 -0
- package/dist/web/public/icon-touch-192-rose.png +0 -0
- package/dist/web/public/icon-touch-192-teal.png +0 -0
- package/dist/web/public/icon-touch-192-violet.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +3 -1
- package/dist/web/public/index.html +14 -10
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +10 -10
- package/dist/web/public/sw.js +5 -5
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +15 -3
- package/dist/web/server.js +70 -11
- package/docs/deploy.md +19 -2
- package/package.json +1 -1
- package/dist/web/public/assets/activity-BWpiRicQ.js +0 -5
- package/dist/web/public/assets/activity-BWpiRicQ.js.br +0 -0
- package/dist/web/public/assets/activity-BWpiRicQ.js.gz +0 -0
- package/dist/web/public/assets/boards-BnRMjBln.js +0 -1
- package/dist/web/public/assets/boards-BnRMjBln.js.br +0 -0
- package/dist/web/public/assets/boards-BnRMjBln.js.gz +0 -0
- package/dist/web/public/assets/explorer-DLZ0ccgE.js +0 -4
- package/dist/web/public/assets/explorer-DLZ0ccgE.js.br +0 -0
- package/dist/web/public/assets/explorer-DLZ0ccgE.js.gz +0 -0
- package/dist/web/public/assets/index-DMq4awU8.css +0 -2
- package/dist/web/public/assets/index-DMq4awU8.css.br +0 -0
- package/dist/web/public/assets/index-DMq4awU8.css.gz +0 -0
- package/dist/web/public/assets/index-T-vLXPch.js.br +0 -0
- package/dist/web/public/assets/index-T-vLXPch.js.gz +0 -0
- package/dist/web/public/assets/runs-oFQ162um.js +0 -1
- package/dist/web/public/assets/runs-oFQ162um.js.br +0 -0
- package/dist/web/public/assets/runs-oFQ162um.js.gz +0 -0
- package/dist/web/public/assets/settings-_t0Js-DJ.js +0 -5
- package/dist/web/public/assets/settings-_t0Js-DJ.js.br +0 -0
- package/dist/web/public/assets/settings-_t0Js-DJ.js.gz +0 -0
- package/dist/web/public/assets/task-runs-m3ufvOQI.js +0 -3
- package/dist/web/public/assets/task-runs-m3ufvOQI.js.br +0 -0
- package/dist/web/public/assets/task-runs-m3ufvOQI.js.gz +0 -0
- package/dist/web/public/assets/tasks-C1ATvPUc.js +0 -4
- package/dist/web/public/assets/tasks-C1ATvPUc.js.br +0 -0
- package/dist/web/public/assets/tasks-C1ATvPUc.js.gz +0 -0
- package/dist/web/public/icon.svg.br +0 -0
- package/dist/web/public/icon.svg.gz +0 -0
- package/dist/web/public/manifest.webmanifest.br +0 -0
- 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
|
-
|
|
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
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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
|
-
|
|
427
|
-
|
|
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
|
}
|
package/dist/web/instance.js
CHANGED
|
@@ -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
|
|
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)
|