detached 0ab5a9d

feat(web): sign in with a passkey and manage passkeys on the web

commit
0ab5a9deb6f2e939b5b2eadfb786a111b469a63b
author
brice · · 2026-10-12 15:16 UTC
parent
0818b8d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
45 files · +1949 -144
M internal/adapters/web/ASSETS.md +1 -0
@@ -10,6 +10,7 @@ content-hashed names. Nothing is fetched at runtime.
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
1111 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
1212 | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
13+| `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
1314 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
1415 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1516 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
@@ -10,6 +10,7 @@ content-hashed names. Nothing is fetched at runtime.
10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |11 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
12 | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |12 | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
13+| `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
13 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |14 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
14 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |15 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
15 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |16 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
M internal/adapters/web/assets.go +2 -1
@@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/"
2323 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2424 // the content, so they can be cached for ever.
2525 type Assets struct {
26- CSS, HTMX, Live, SSE, App, Files, Icon string
26+ CSS, HTMX, Live, SSE, App, Files, Passkey, Icon string
2727 // Font is the UI font, preloaded by every page.
2828 Font string
2929 // Rules is the speculation rules file named by the Speculation-Rules header.
@@ -56,6 +56,7 @@ var scripts = []struct {
5656 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
5757 {"ermite.js", func(a *Assets, u string) { a.App = u }},
5858 {"files.js", func(a *Assets, u string) { a.Files = u }},
59+ {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
5960 }
6061
6162 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
@@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/"
23 // Assets are the URLs of the assets a page loads. Their names carry a hash of23 // Assets are the URLs of the assets a page loads. Their names carry a hash of
24 // the content, so they can be cached for ever.24 // the content, so they can be cached for ever.
25 type Assets struct {25 type Assets struct {
26- CSS, HTMX, Live, SSE, App, Files, Icon string26+ CSS, HTMX, Live, SSE, App, Files, Passkey, Icon string
27 // Font is the UI font, preloaded by every page.27 // Font is the UI font, preloaded by every page.
28 Font string28 Font string
29 // Rules is the speculation rules file named by the Speculation-Rules header.29 // Rules is the speculation rules file named by the Speculation-Rules header.
@@ -56,6 +56,7 @@ var scripts = []struct {
56 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},56 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
57 {"ermite.js", func(a *Assets, u string) { a.App = u }},57 {"ermite.js", func(a *Assets, u string) { a.App = u }},
58 {"files.js", func(a *Assets, u string) { a.Files = u }},58 {"files.js", func(a *Assets, u string) { a.Files = u }},
59+ {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
59 }60 }
60 61
61 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):62 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
A internal/adapters/web/jstest/passkey.test.mjs +215 -0
@@ -0,0 +1,215 @@
1+// Tests of static/js/passkey.js on a tiny hand-rolled DOM, with fetch and
2+// navigator.credentials stubbed. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test, beforeEach } from 'node:test';
6+import { dom, el, installDom, settle } from './dom.mjs';
7+
8+const doc = installDom({ parseHTML: () => [] });
9+doc.addEventListener = () => { dom.listened = true; };
10+// Node defines navigator as a getter: replace it, as a browser test would.
11+const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });
12+setNavigator({ credentials: {} });
13+globalThis.PublicKeyCredential = undefined;
14+await import('../static/js/passkey.js');
15+const P = window.ermitePasskey;
16+
17+const OPTIONS = { publicKey: { challenge: 'abc' } };
18+const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };
19+
20+let fetches; // {url, init}
21+let answers; // url -> {ok, status, body}
22+let parsed; // [kind, publicKey]
23+let nav; // where the page was sent: 'assign:/' or 'reload'
24+let status;
25+let name;
26+
27+function page(kind) {
28+ dom.body = el('body');
29+ status = el('p', { attrs: { 'data-passkey-status': '' } });
30+ name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });
31+ const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } });
32+ dom.body.append(status, name, btn);
33+ return btn;
34+}
35+
36+beforeEach(() => {
37+ fetches = [];
38+ parsed = [];
39+ nav = '';
40+ answers = {
41+ '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
42+ '/finish': { ok: true, status: 204, body: null },
43+ };
44+ globalThis.fetch = async (url, init) => {
45+ fetches.push({ url, init });
46+ const a = answers[url];
47+ return { ok: a.ok, status: a.status, json: async () => { if (a.body === null) throw new Error('no body'); return a.body; } };
48+ };
49+ globalThis.PublicKeyCredential = {
50+ parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; },
51+ parseCreationOptionsFromJSON: (o) => { parsed.push(['create', o]); return { parsedCreate: o }; },
52+ };
53+ setNavigator({
54+ credentials: {
55+ get: async (o) => { navigator.got = o; return CRED; },
56+ create: async (o) => { navigator.created = o; return CRED; },
57+ },
58+ });
59+ globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } };
60+});
61+
62+const clickOn = (target) => P.click({ target, preventDefault() {} });
63+
64+test('login: begin, get, finish with toJSON(), then home', async () => {
65+ const btn = page('login');
66+ await clickOn(btn);
67+
68+ assert.equal(fetches.length, 2);
69+ assert.equal(fetches[0].url, '/begin');
70+ assert.equal(fetches[0].init.method, 'POST');
71+ assert.equal(fetches[0].init.credentials, 'same-origin');
72+ assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]);
73+ assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } });
74+
75+ assert.equal(fetches[1].url, '/finish');
76+ assert.equal(fetches[1].init.method, 'POST');
77+ assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');
78+ assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } });
79+ assert.equal(nav, 'assign:/');
80+ assert.equal(status.textContent, '');
81+ assert.equal(btn.disabled, true, 'stays disabled while the page navigates');
82+});
83+
84+test('register: create with the trimmed name, then reload', async () => {
85+ const btn = page('register');
86+ await clickOn(btn);
87+
88+ assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);
89+ assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });
90+ assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } });
91+ assert.equal(nav, 'reload');
92+});
93+
94+test('a click inside the button reaches it, other clicks do nothing', async () => {
95+ const btn = page('login');
96+ const inner = el('span');
97+ btn.append(inner);
98+ await clickOn(inner);
99+ assert.equal(fetches.length, 2);
100+
101+ fetches = [];
102+ await clickOn(el('div'));
103+ assert.equal(fetches.length, 0);
104+});
105+
106+test('begin refused: the server reason is shown, nothing else is called', async () => {
107+ const btn = page('login');
108+ answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };
109+ await clickOn(btn);
110+ assert.equal(status.textContent, 'too many attempts');
111+ assert.equal(fetches.length, 1);
112+ assert.equal(nav, '');
113+ assert.equal(btn.disabled, false, 'the button is usable again');
114+});
115+
116+test('finish refused: the reason is shown and the page stays', async () => {
117+ const btn = page('login');
118+ answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };
119+ await clickOn(btn);
120+ assert.equal(status.textContent, 'passkey not accepted');
121+ assert.equal(nav, '');
122+ assert.equal(btn.disabled, false);
123+});
124+
125+test('a refusal without a JSON body falls back to the status', async () => {
126+ const btn = page('register');
127+ answers['/finish'] = { ok: false, status: 502, body: null };
128+ await clickOn(btn);
129+ assert.equal(status.textContent, 'the server answered 502');
130+ assert.equal(nav, '');
131+});
132+
133+test('the owner cancels the prompt', async () => {
134+ const btn = page('login');
135+ navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };
136+ await clickOn(btn);
137+ assert.match(status.textContent, /cancelled, or it took too long/);
138+ assert.equal(fetches.length, 1, 'finish is not called');
139+ assert.equal(btn.disabled, false);
140+});
141+
142+test('an authenticator that already holds a passkey', async () => {
143+ const btn = page('register');
144+ navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };
145+ await clickOn(btn);
146+ assert.match(status.textContent, /already holds a passkey/);
147+ assert.equal(fetches.length, 1);
148+});
149+
150+test('a network failure is reported', async () => {
151+ const btn = page('login');
152+ globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); };
153+ await clickOn(btn);
154+ assert.equal(status.textContent, 'Failed to fetch');
155+ assert.equal(btn.disabled, false);
156+});
157+
158+test('a null credential is not sent', async () => {
159+ const btn = page('login');
160+ navigator.credentials.get = async () => null;
161+ await clickOn(btn);
162+ assert.equal(status.textContent, 'no passkey was returned');
163+ assert.equal(fetches.length, 1);
164+});
165+
166+test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {
167+ const login = page('login');
168+ const register = el('button', { attrs: { 'data-passkey': 'register' } });
169+ dom.body.append(register);
170+ globalThis.PublicKeyCredential = {}; // no parse*OptionsFromJSON
171+ P.init();
172+ assert.equal(login.disabled, true);
173+ assert.equal(register.disabled, true);
174+ assert.equal(status.textContent, 'this browser does not support passkeys');
175+
176+ login.disabled = false;
177+ await clickOn(login);
178+ assert.equal(fetches.length, 0);
179+ assert.equal(status.textContent, 'this browser does not support passkeys');
180+});
181+
182+test('no PublicKeyCredential at all', async () => {
183+ const btn = page('login');
184+ globalThis.PublicKeyCredential = undefined;
185+ P.init();
186+ assert.equal(btn.disabled, true);
187+ assert.equal(status.textContent, 'this browser does not support passkeys');
188+});
189+
190+test('a supported browser keeps the buttons enabled', () => {
191+ const btn = page('login');
192+ P.init();
193+ assert.equal(btn.disabled, undefined);
194+ assert.equal(status.textContent, '');
195+});
196+
197+test('a disabled button ignores clicks', async () => {
198+ const btn = page('login');
199+ btn.disabled = true;
200+ await clickOn(btn);
201+ assert.equal(fetches.length, 0);
202+});
203+
204+test('only login supports needs parseRequestOptionsFromJSON, register parseCreationOptionsFromJSON', async () => {
205+ const btn = page('register');
206+ delete PublicKeyCredential.parseCreationOptionsFromJSON;
207+ await clickOn(btn);
208+ assert.equal(fetches.length, 0);
209+ assert.equal(status.textContent, 'this browser does not support passkeys');
210+});
211+
212+test('the script registers one delegated click listener', () => {
213+ assert.equal(dom.listened, true);
214+ void settle;
215+});
@@ -0,0 +1,215 @@
1+// Tests of static/js/passkey.js on a tiny hand-rolled DOM, with fetch and
2+// navigator.credentials stubbed. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test, beforeEach } from 'node:test';
6+import { dom, el, installDom, settle } from './dom.mjs';
7+
8+const doc = installDom({ parseHTML: () => [] });
9+doc.addEventListener = () => { dom.listened = true; };
10+// Node defines navigator as a getter: replace it, as a browser test would.
11+const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });
12+setNavigator({ credentials: {} });
13+globalThis.PublicKeyCredential = undefined;
14+await import('../static/js/passkey.js');
15+const P = window.ermitePasskey;
16+
17+const OPTIONS = { publicKey: { challenge: 'abc' } };
18+const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };
19+
20+let fetches; // {url, init}
21+let answers; // url -> {ok, status, body}
22+let parsed; // [kind, publicKey]
23+let nav; // where the page was sent: 'assign:/' or 'reload'
24+let status;
25+let name;
26+
27+function page(kind) {
28+ dom.body = el('body');
29+ status = el('p', { attrs: { 'data-passkey-status': '' } });
30+ name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });
31+ const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } });
32+ dom.body.append(status, name, btn);
33+ return btn;
34+}
35+
36+beforeEach(() => {
37+ fetches = [];
38+ parsed = [];
39+ nav = '';
40+ answers = {
41+ '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
42+ '/finish': { ok: true, status: 204, body: null },
43+ };
44+ globalThis.fetch = async (url, init) => {
45+ fetches.push({ url, init });
46+ const a = answers[url];
47+ return { ok: a.ok, status: a.status, json: async () => { if (a.body === null) throw new Error('no body'); return a.body; } };
48+ };
49+ globalThis.PublicKeyCredential = {
50+ parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; },
51+ parseCreationOptionsFromJSON: (o) => { parsed.push(['create', o]); return { parsedCreate: o }; },
52+ };
53+ setNavigator({
54+ credentials: {
55+ get: async (o) => { navigator.got = o; return CRED; },
56+ create: async (o) => { navigator.created = o; return CRED; },
57+ },
58+ });
59+ globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } };
60+});
61+
62+const clickOn = (target) => P.click({ target, preventDefault() {} });
63+
64+test('login: begin, get, finish with toJSON(), then home', async () => {
65+ const btn = page('login');
66+ await clickOn(btn);
67+
68+ assert.equal(fetches.length, 2);
69+ assert.equal(fetches[0].url, '/begin');
70+ assert.equal(fetches[0].init.method, 'POST');
71+ assert.equal(fetches[0].init.credentials, 'same-origin');
72+ assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]);
73+ assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } });
74+
75+ assert.equal(fetches[1].url, '/finish');
76+ assert.equal(fetches[1].init.method, 'POST');
77+ assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');
78+ assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } });
79+ assert.equal(nav, 'assign:/');
80+ assert.equal(status.textContent, '');
81+ assert.equal(btn.disabled, true, 'stays disabled while the page navigates');
82+});
83+
84+test('register: create with the trimmed name, then reload', async () => {
85+ const btn = page('register');
86+ await clickOn(btn);
87+
88+ assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);
89+ assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });
90+ assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } });
91+ assert.equal(nav, 'reload');
92+});
93+
94+test('a click inside the button reaches it, other clicks do nothing', async () => {
95+ const btn = page('login');
96+ const inner = el('span');
97+ btn.append(inner);
98+ await clickOn(inner);
99+ assert.equal(fetches.length, 2);
100+
101+ fetches = [];
102+ await clickOn(el('div'));
103+ assert.equal(fetches.length, 0);
104+});
105+
106+test('begin refused: the server reason is shown, nothing else is called', async () => {
107+ const btn = page('login');
108+ answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };
109+ await clickOn(btn);
110+ assert.equal(status.textContent, 'too many attempts');
111+ assert.equal(fetches.length, 1);
112+ assert.equal(nav, '');
113+ assert.equal(btn.disabled, false, 'the button is usable again');
114+});
115+
116+test('finish refused: the reason is shown and the page stays', async () => {
117+ const btn = page('login');
118+ answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };
119+ await clickOn(btn);
120+ assert.equal(status.textContent, 'passkey not accepted');
121+ assert.equal(nav, '');
122+ assert.equal(btn.disabled, false);
123+});
124+
125+test('a refusal without a JSON body falls back to the status', async () => {
126+ const btn = page('register');
127+ answers['/finish'] = { ok: false, status: 502, body: null };
128+ await clickOn(btn);
129+ assert.equal(status.textContent, 'the server answered 502');
130+ assert.equal(nav, '');
131+});
132+
133+test('the owner cancels the prompt', async () => {
134+ const btn = page('login');
135+ navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };
136+ await clickOn(btn);
137+ assert.match(status.textContent, /cancelled, or it took too long/);
138+ assert.equal(fetches.length, 1, 'finish is not called');
139+ assert.equal(btn.disabled, false);
140+});
141+
142+test('an authenticator that already holds a passkey', async () => {
143+ const btn = page('register');
144+ navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };
145+ await clickOn(btn);
146+ assert.match(status.textContent, /already holds a passkey/);
147+ assert.equal(fetches.length, 1);
148+});
149+
150+test('a network failure is reported', async () => {
151+ const btn = page('login');
152+ globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); };
153+ await clickOn(btn);
154+ assert.equal(status.textContent, 'Failed to fetch');
155+ assert.equal(btn.disabled, false);
156+});
157+
158+test('a null credential is not sent', async () => {
159+ const btn = page('login');
160+ navigator.credentials.get = async () => null;
161+ await clickOn(btn);
162+ assert.equal(status.textContent, 'no passkey was returned');
163+ assert.equal(fetches.length, 1);
164+});
165+
166+test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {
167+ const login = page('login');
168+ const register = el('button', { attrs: { 'data-passkey': 'register' } });
169+ dom.body.append(register);
170+ globalThis.PublicKeyCredential = {}; // no parse*OptionsFromJSON
171+ P.init();
172+ assert.equal(login.disabled, true);
173+ assert.equal(register.disabled, true);
174+ assert.equal(status.textContent, 'this browser does not support passkeys');
175+
176+ login.disabled = false;
177+ await clickOn(login);
178+ assert.equal(fetches.length, 0);
179+ assert.equal(status.textContent, 'this browser does not support passkeys');
180+});
181+
182+test('no PublicKeyCredential at all', async () => {
183+ const btn = page('login');
184+ globalThis.PublicKeyCredential = undefined;
185+ P.init();
186+ assert.equal(btn.disabled, true);
187+ assert.equal(status.textContent, 'this browser does not support passkeys');
188+});
189+
190+test('a supported browser keeps the buttons enabled', () => {
191+ const btn = page('login');
192+ P.init();
193+ assert.equal(btn.disabled, undefined);
194+ assert.equal(status.textContent, '');
195+});
196+
197+test('a disabled button ignores clicks', async () => {
198+ const btn = page('login');
199+ btn.disabled = true;
200+ await clickOn(btn);
201+ assert.equal(fetches.length, 0);
202+});
203+
204+test('only login supports needs parseRequestOptionsFromJSON, register parseCreationOptionsFromJSON', async () => {
205+ const btn = page('register');
206+ delete PublicKeyCredential.parseCreationOptionsFromJSON;
207+ await clickOn(btn);
208+ assert.equal(fetches.length, 0);
209+ assert.equal(status.textContent, 'this browser does not support passkeys');
210+});
211+
212+test('the script registers one delegated click listener', () => {
213+ assert.equal(dom.listened, true);
214+ void settle;
215+});
M internal/adapters/web/pages.templ +86 -0
@@ -182,3 +182,89 @@ templ Login(l Layout, p LoginPage) {
182182 </section>
183183 }
184184 }
185+
186+// PasskeyLogin is the sign-in page: a button the script (passkey.js) drives.
187+// Without script the button is hidden and the page says what to do instead.
188+templ PasskeyLogin(l Layout, p PasskeyLoginPage) {
189+ @Shell(l, nil) {
190+ <section class="state" aria-labelledby="state-title">
191+ <h1 id="state-title">{ p.Title }</h1>
192+ <p class="state-act">
193+ <button type="button" class="btn primary" data-js data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button>
194+ </p>
195+ <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
196+ for _, line := range p.Lines {
197+ <p class="dim">{ line }</p>
198+ }
199+ <p class="state-back"><a href="/">Back to home</a></p>
200+ </section>
201+ }
202+}
203+
204+// Passkeys lists the owner's passkeys, removes them and adds one.
205+templ Passkeys(l Layout, p PasskeysPage) {
206+ @Shell(l, nil) {
207+ <h2 class="rule-h"><span>Passkeys</span><span class="count">{ itoa(len(p.Rows)) }</span></h2>
208+ <p class="dim">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>
209+ if len(p.Rows) > 0 {
210+ <table class="list passkeys" aria-label="Passkeys">
211+ <thead>
212+ <tr>
213+ <th class="cur"></th>
214+ <th class="c-name">Name</th>
215+ <th class="c-when">Created</th>
216+ <th class="c-when">Last used</th>
217+ <th class="c-sync">Synced</th>
218+ <th class="c-act"><span class="sr">Remove</span></th>
219+ </tr>
220+ </thead>
221+ <tbody>
222+ for _, r := range p.Rows {
223+ <tr>
224+ <td class="cur" aria-hidden="true"></td>
225+ <td class="c-name">{ r.Name }</td>
226+ <td class="c-when nowrap">
227+ <time
228+ if r.CreatedAt != "" {
229+ datetime={ r.CreatedAt }
230+ }
231+ >{ r.Created }</time>
232+ </td>
233+ <td class="c-when nowrap">
234+ <time
235+ if r.LastUsedAt != "" {
236+ datetime={ r.LastUsedAt }
237+ }
238+ >{ r.LastUsed }</time>
239+ </td>
240+ <td class="c-sync">
241+ if r.Synced {
242+ yes
243+ } else {
244+ no
245+ }
246+ </td>
247+ <td class="c-act">
248+ <form method="post" action={ templ.SafeURL(r.Remove) }>
249+ <button type="submit" class="btn danger" aria-label={ "Remove passkey " + r.Name }>Remove</button>
250+ </form>
251+ </td>
252+ </tr>
253+ }
254+ </tbody>
255+ </table>
256+ } else {
257+ <p class="lede dim">no passkeys yet.</p>
258+ }
259+ if p.Full {
260+ <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p>
261+ } else {
262+ <div class="passkey-add">
263+ <label for="passkey-name" class="sr">Name of the new passkey</label>
264+ <input id="passkey-name" type="text" maxlength="64" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/>
265+ <button type="button" class="btn primary" data-js data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button>
266+ </div>
267+ }
268+ <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
269+ }
270+}
@@ -182,3 +182,89 @@ templ Login(l Layout, p LoginPage) {
182 </section>182 </section>
183 }183 }
184 }184 }
185+
186+// PasskeyLogin is the sign-in page: a button the script (passkey.js) drives.
187+// Without script the button is hidden and the page says what to do instead.
188+templ PasskeyLogin(l Layout, p PasskeyLoginPage) {
189+ @Shell(l, nil) {
190+ <section class="state" aria-labelledby="state-title">
191+ <h1 id="state-title">{ p.Title }</h1>
192+ <p class="state-act">
193+ <button type="button" class="btn primary" data-js data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button>
194+ </p>
195+ <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
196+ for _, line := range p.Lines {
197+ <p class="dim">{ line }</p>
198+ }
199+ <p class="state-back"><a href="/">Back to home</a></p>
200+ </section>
201+ }
202+}
203+
204+// Passkeys lists the owner's passkeys, removes them and adds one.
205+templ Passkeys(l Layout, p PasskeysPage) {
206+ @Shell(l, nil) {
207+ <h2 class="rule-h"><span>Passkeys</span><span class="count">{ itoa(len(p.Rows)) }</span></h2>
208+ <p class="dim">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>
209+ if len(p.Rows) > 0 {
210+ <table class="list passkeys" aria-label="Passkeys">
211+ <thead>
212+ <tr>
213+ <th class="cur"></th>
214+ <th class="c-name">Name</th>
215+ <th class="c-when">Created</th>
216+ <th class="c-when">Last used</th>
217+ <th class="c-sync">Synced</th>
218+ <th class="c-act"><span class="sr">Remove</span></th>
219+ </tr>
220+ </thead>
221+ <tbody>
222+ for _, r := range p.Rows {
223+ <tr>
224+ <td class="cur" aria-hidden="true"></td>
225+ <td class="c-name">{ r.Name }</td>
226+ <td class="c-when nowrap">
227+ <time
228+ if r.CreatedAt != "" {
229+ datetime={ r.CreatedAt }
230+ }
231+ >{ r.Created }</time>
232+ </td>
233+ <td class="c-when nowrap">
234+ <time
235+ if r.LastUsedAt != "" {
236+ datetime={ r.LastUsedAt }
237+ }
238+ >{ r.LastUsed }</time>
239+ </td>
240+ <td class="c-sync">
241+ if r.Synced {
242+ yes
243+ } else {
244+ no
245+ }
246+ </td>
247+ <td class="c-act">
248+ <form method="post" action={ templ.SafeURL(r.Remove) }>
249+ <button type="submit" class="btn danger" aria-label={ "Remove passkey " + r.Name }>Remove</button>
250+ </form>
251+ </td>
252+ </tr>
253+ }
254+ </tbody>
255+ </table>
256+ } else {
257+ <p class="lede dim">no passkeys yet.</p>
258+ }
259+ if p.Full {
260+ <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p>
261+ } else {
262+ <div class="passkey-add">
263+ <label for="passkey-name" class="sr">Name of the new passkey</label>
264+ <input id="passkey-name" type="text" maxlength="64" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/>
265+ <button type="button" class="btn primary" data-js data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button>
266+ </div>
267+ }
268+ <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
269+ }
270+}
M internal/adapters/web/pages_templ.go +390 -0 generated file: collapsed
A internal/adapters/web/passkeys.go +304 -0
@@ -0,0 +1,304 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "encoding/json"
9+ "errors"
10+ "io"
11+ "mime"
12+ "net/http"
13+ "strconv"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/clientlimit"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// The owner's passkeys on the web (ADR 0026).
24+//
25+// The pages are GET: /-/login (sign in, anonymous) and /-/passkeys (the
26+// owner's list). The ceremonies are JSON POSTs the script static/js/passkey.js
27+// makes, each in two steps (begin, finish). Every POST passes the gate: an
28+// Origin equal to the public origin, and a session, except the two login
29+// endpoints, which are how a session starts. The finish endpoints also need
30+// "Content-Type: application/json", which a cross-site form cannot send.
31+
32+const (
33+ passkeyLoginPath = "/-/login"
34+ passkeysPath = "/-/passkeys"
35+
36+ loginBeginPath = passkeysPath + "/login/begin"
37+ loginFinishPath = passkeysPath + "/login/finish"
38+
39+ // maxCeremonyBytes bounds the body of a finish: a response of an
40+ // authenticator is well under 10 KiB.
41+ maxCeremonyBytes = 64 << 10
42+)
43+
44+// passkeysOn tells that the passkey routes exist.
45+func (s *Server) passkeysOn() bool { return s.cfg.Passkeys != nil && s.cfg.Sessions != nil }
46+
47+func (s *Server) passkeyRoutes(mux *http.ServeMux) {
48+ mux.HandleFunc("GET "+passkeyLoginPath, s.passkeyLoginPage)
49+ mux.HandleFunc("POST "+loginBeginPath, s.passkeyLoginBegin)
50+ mux.HandleFunc("POST "+loginFinishPath, s.passkeyLoginFinish)
51+ mux.HandleFunc("GET "+passkeysPath, s.page(s.passkeysPage))
52+ mux.HandleFunc("POST "+passkeysPath+"/register/begin", s.passkeyRegisterBegin)
53+ mux.HandleFunc("POST "+passkeysPath+"/register/finish", s.passkeyRegisterFinish)
54+ mux.HandleFunc("POST "+passkeysPath+"/{id}/remove", s.passkeyRemove)
55+}
56+
57+// isPasskeyLoginPath tells whether u is exactly one of the two endpoints an
58+// anonymous browser may post to. Like isLoginPath it looks at the escaped path,
59+// which is what the router matches.
60+func isPasskeyLoginPath(path string) bool {
61+ return path == loginBeginPath || path == loginFinishPath
62+}
63+
64+// jsonReply writes a JSON answer that no cache keeps.
65+func jsonReply(w http.ResponseWriter, status int, v any) {
66+ h := w.Header()
67+ h.Set("Content-Type", "application/json")
68+ h.Set("Cache-Control", noStore)
69+ w.WriteHeader(status)
70+ _ = json.NewEncoder(w).Encode(v)
71+}
72+
73+type jsonMessage struct {
74+ Error string `json:"error"`
75+}
76+
77+func jsonError(w http.ResponseWriter, status int, msg string) {
78+ jsonReply(w, status, jsonMessage{Error: msg})
79+}
80+
81+// jsonInternal answers a failure that is not the caller's to fix: an id for the
82+// log, and no detail.
83+func (s *Server) jsonInternal(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
84+ id := requestID()
85+ log := zerowrap.FromCtx(ctx)
86+ log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web passkey request failed")
87+ jsonError(w, http.StatusInternalServerError, "internal error, request id "+id)
88+}
89+
90+// jsonBody checks the content type of a finish and reads its body, bounded. It
91+// answers 415 or 413 and returns false when it cannot.
92+func jsonBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
93+ mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
94+ if err != nil || mt != "application/json" {
95+ jsonError(w, http.StatusUnsupportedMediaType, "send application/json")
96+ return nil, false
97+ }
98+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCeremonyBytes))
99+ if err != nil {
100+ var tooBig *http.MaxBytesError
101+ if errors.As(err, &tooBig) {
102+ jsonError(w, http.StatusRequestEntityTooLarge, "request too large")
103+ } else {
104+ jsonError(w, http.StatusBadRequest, "request could not be read")
105+ }
106+ return nil, false
107+ }
108+ return body, true
109+}
110+
111+// ceremonyReply is the answer of a begin.
112+type ceremonyReply struct {
113+ Ceremony string `json:"ceremony"`
114+ Options json.RawMessage `json:"options"`
115+}
116+
117+// blocked answers 429 when the client has failed too many sign-ins.
118+func (s *Server) blocked(w http.ResponseWriter, r *http.Request) (addr string, blocked bool) {
119+ addr = clientlimit.ClientAddr(r, s.cfg.TrustedProxies)
120+ if is, wait := s.cfg.Limiter.Blocked(r.Context(), addr); is {
121+ w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
122+ jsonError(w, http.StatusTooManyRequests, "too many attempts")
123+ return addr, true
124+ }
125+ return addr, false
126+}
127+
128+// passkeyLoginPage is GET /-/login: the sign-in page. A signed-in visitor goes
129+// home.
130+func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) {
131+ if SignedIn(r.Context()) {
132+ seeOther(w, r, "/")
133+ return
134+ }
135+ l := s.stateLayout("sign in", []Crumb{{Label: "sign in", Current: true}})
136+ s.secretPage(w, r, http.StatusOK, PasskeyLogin(l, PasskeyLoginPage{
137+ Title: "sign in",
138+ Button: "Sign in with a passkey",
139+ Lines: []string{"or run `ermite web login` in a terminal."},
140+ Begin: loginBeginPath,
141+ Finish: loginFinishPath,
142+ }))
143+}
144+
145+// passkeyLoginBegin is POST /-/passkeys/login/begin.
146+func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
147+ if _, blocked := s.blocked(w, r); blocked {
148+ return
149+ }
150+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
151+ defer cancel()
152+ ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx)
153+ if err != nil {
154+ s.jsonInternal(ctx, w, r, err)
155+ return
156+ }
157+ jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
158+}
159+
160+// finishRequest is the body of a finish.
161+type finishRequest struct {
162+ Ceremony string `json:"ceremony"`
163+ Name string `json:"name"`
164+ Credential json.RawMessage `json:"credential"`
165+}
166+
167+// passkeyLoginFinish is POST /-/passkeys/login/finish: it checks the response
168+// of the authenticator and sets the session cookie, the same as a login link.
169+// A refusal counts as a failed authentication of the client, whatever the
170+// reason.
171+func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
172+ body, ok := jsonBody(w, r)
173+ if !ok {
174+ return
175+ }
176+ addr, blocked := s.blocked(w, r)
177+ if blocked {
178+ return
179+ }
180+ var req finishRequest
181+ if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
182+ jsonError(w, http.StatusBadRequest, "malformed request")
183+ return
184+ }
185+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
186+ defer cancel()
187+ token, lifetime, err := s.cfg.Passkeys.FinishLogin(ctx, req.Ceremony, req.Credential)
188+ switch {
189+ case errors.Is(err, ports.ErrLinkRejected):
190+ s.cfg.Limiter.Fail(ctx, addr)
191+ jsonError(w, http.StatusUnauthorized, "passkey not accepted")
192+ return
193+ case err != nil:
194+ s.jsonInternal(ctx, w, r, err)
195+ return
196+ }
197+ h := w.Header()
198+ h.Del("Set-Cookie") // a stale cookie cleared by withSession
199+ http.SetCookie(w, s.cookie(token, int(lifetime.Seconds())))
200+ h.Set("Cache-Control", noStore)
201+ w.WriteHeader(http.StatusNoContent)
202+}
203+
204+// passkeysPage is GET /-/passkeys: the owner's list. Anybody else gets the 404
205+// page, as for any page that does not exist for them.
206+func (s *Server) passkeysPage(ctx context.Context, _ *http.Request) (templ.Component, int, error) {
207+ if !SignedIn(ctx) {
208+ return nil, 0, errNotFound
209+ }
210+ list, err := s.cfg.Passkeys.List(ctx, principal(ctx))
211+ if err != nil {
212+ return nil, 0, err
213+ }
214+ now := s.cfg.Now()
215+ rows := make([]PasskeyRow, 0, len(list))
216+ for _, p := range list {
217+ rows = append(rows, PasskeyRow{
218+ Name: p.Name,
219+ Created: ago(now, p.CreatedAt), CreatedAt: rfc3339(p.CreatedAt),
220+ LastUsed: ago(now, p.LastUsedAt), LastUsedAt: rfc3339(p.LastUsedAt),
221+ Synced: p.BackupState,
222+ Remove: passkeysPath + "/" + base64.RawURLEncoding.EncodeToString(p.ID) + "/remove",
223+ })
224+ }
225+ l := s.base()
226+ l.Title = "passkeys · " + s.instance
227+ l.Crumbs = []Crumb{{Label: "passkeys", Current: true}}
228+ l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
229+ return Passkeys(l, PasskeysPage{
230+ Rows: rows, Full: len(list) >= domain.MaxPasskeys, Max: domain.MaxPasskeys,
231+ Begin: passkeysPath + "/register/begin", Finish: passkeysPath + "/register/finish",
232+ }), http.StatusOK, nil
233+}
234+
235+// registerError answers a refused registration step.
236+func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
237+ switch {
238+ case errors.Is(err, context.Canceled):
239+ // The browser is gone.
240+ case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated) && !SignedIn(ctx):
241+ jsonError(w, http.StatusForbidden, "forbidden")
242+ case errors.Is(err, domain.ErrAlreadyExists):
243+ jsonError(w, http.StatusConflict, "this passkey is already registered")
244+ case errors.Is(err, domain.ErrResourceExhausted):
245+ jsonError(w, http.StatusTooManyRequests, rule(err.Error(), "a limit is reached"))
246+ case domain.IsInvalid(err):
247+ jsonError(w, http.StatusBadRequest, rule(err.Error(), "the name is not valid"))
248+ case errors.Is(err, domain.ErrUnauthenticated):
249+ jsonError(w, http.StatusBadRequest, "passkey not accepted, try again")
250+ default:
251+ s.jsonInternal(ctx, w, r, err)
252+ }
253+}
254+
255+// passkeyRegisterBegin is POST /-/passkeys/register/begin.
256+func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
257+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
258+ defer cancel()
259+ ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx))
260+ if err != nil {
261+ s.registerError(ctx, w, r, err)
262+ return
263+ }
264+ jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
265+}
266+
267+// passkeyRegisterFinish is POST /-/passkeys/register/finish.
268+func (s *Server) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
269+ body, ok := jsonBody(w, r)
270+ if !ok {
271+ return
272+ }
273+ var req finishRequest
274+ if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
275+ jsonError(w, http.StatusBadRequest, "malformed request")
276+ return
277+ }
278+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
279+ defer cancel()
280+ if err := s.cfg.Passkeys.FinishRegistration(ctx, principal(ctx), req.Ceremony, req.Name, req.Credential); err != nil {
281+ s.registerError(ctx, w, r, err)
282+ return
283+ }
284+ w.Header().Set("Cache-Control", noStore)
285+ w.WriteHeader(http.StatusNoContent)
286+}
287+
288+// passkeyRemove is POST /-/passkeys/{id}/remove: it removes the passkey, which
289+// signs every browser out, this one included: the answer sends it to the
290+// sign-in page.
291+func (s *Server) passkeyRemove(w http.ResponseWriter, r *http.Request) {
292+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
293+ defer cancel()
294+ id, err := base64.RawURLEncoding.DecodeString(r.PathValue("id"))
295+ if err != nil || len(id) == 0 {
296+ s.actionFailed(ctx, w, r, errNotFound, passkeysPath)
297+ return
298+ }
299+ if err := s.cfg.Passkeys.Remove(ctx, principal(ctx), id); err != nil {
300+ s.actionFailed(ctx, w, r, err, passkeysPath)
301+ return
302+ }
303+ seeOther(w, r, passkeyLoginPath)
304+}
@@ -0,0 +1,304 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "encoding/json"
9+ "errors"
10+ "io"
11+ "mime"
12+ "net/http"
13+ "strconv"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/clientlimit"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// The owner's passkeys on the web (ADR 0026).
24+//
25+// The pages are GET: /-/login (sign in, anonymous) and /-/passkeys (the
26+// owner's list). The ceremonies are JSON POSTs the script static/js/passkey.js
27+// makes, each in two steps (begin, finish). Every POST passes the gate: an
28+// Origin equal to the public origin, and a session, except the two login
29+// endpoints, which are how a session starts. The finish endpoints also need
30+// "Content-Type: application/json", which a cross-site form cannot send.
31+
32+const (
33+ passkeyLoginPath = "/-/login"
34+ passkeysPath = "/-/passkeys"
35+
36+ loginBeginPath = passkeysPath + "/login/begin"
37+ loginFinishPath = passkeysPath + "/login/finish"
38+
39+ // maxCeremonyBytes bounds the body of a finish: a response of an
40+ // authenticator is well under 10 KiB.
41+ maxCeremonyBytes = 64 << 10
42+)
43+
44+// passkeysOn tells that the passkey routes exist.
45+func (s *Server) passkeysOn() bool { return s.cfg.Passkeys != nil && s.cfg.Sessions != nil }
46+
47+func (s *Server) passkeyRoutes(mux *http.ServeMux) {
48+ mux.HandleFunc("GET "+passkeyLoginPath, s.passkeyLoginPage)
49+ mux.HandleFunc("POST "+loginBeginPath, s.passkeyLoginBegin)
50+ mux.HandleFunc("POST "+loginFinishPath, s.passkeyLoginFinish)
51+ mux.HandleFunc("GET "+passkeysPath, s.page(s.passkeysPage))
52+ mux.HandleFunc("POST "+passkeysPath+"/register/begin", s.passkeyRegisterBegin)
53+ mux.HandleFunc("POST "+passkeysPath+"/register/finish", s.passkeyRegisterFinish)
54+ mux.HandleFunc("POST "+passkeysPath+"/{id}/remove", s.passkeyRemove)
55+}
56+
57+// isPasskeyLoginPath tells whether u is exactly one of the two endpoints an
58+// anonymous browser may post to. Like isLoginPath it looks at the escaped path,
59+// which is what the router matches.
60+func isPasskeyLoginPath(path string) bool {
61+ return path == loginBeginPath || path == loginFinishPath
62+}
63+
64+// jsonReply writes a JSON answer that no cache keeps.
65+func jsonReply(w http.ResponseWriter, status int, v any) {
66+ h := w.Header()
67+ h.Set("Content-Type", "application/json")
68+ h.Set("Cache-Control", noStore)
69+ w.WriteHeader(status)
70+ _ = json.NewEncoder(w).Encode(v)
71+}
72+
73+type jsonMessage struct {
74+ Error string `json:"error"`
75+}
76+
77+func jsonError(w http.ResponseWriter, status int, msg string) {
78+ jsonReply(w, status, jsonMessage{Error: msg})
79+}
80+
81+// jsonInternal answers a failure that is not the caller's to fix: an id for the
82+// log, and no detail.
83+func (s *Server) jsonInternal(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
84+ id := requestID()
85+ log := zerowrap.FromCtx(ctx)
86+ log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web passkey request failed")
87+ jsonError(w, http.StatusInternalServerError, "internal error, request id "+id)
88+}
89+
90+// jsonBody checks the content type of a finish and reads its body, bounded. It
91+// answers 415 or 413 and returns false when it cannot.
92+func jsonBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
93+ mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
94+ if err != nil || mt != "application/json" {
95+ jsonError(w, http.StatusUnsupportedMediaType, "send application/json")
96+ return nil, false
97+ }
98+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCeremonyBytes))
99+ if err != nil {
100+ var tooBig *http.MaxBytesError
101+ if errors.As(err, &tooBig) {
102+ jsonError(w, http.StatusRequestEntityTooLarge, "request too large")
103+ } else {
104+ jsonError(w, http.StatusBadRequest, "request could not be read")
105+ }
106+ return nil, false
107+ }
108+ return body, true
109+}
110+
111+// ceremonyReply is the answer of a begin.
112+type ceremonyReply struct {
113+ Ceremony string `json:"ceremony"`
114+ Options json.RawMessage `json:"options"`
115+}
116+
117+// blocked answers 429 when the client has failed too many sign-ins.
118+func (s *Server) blocked(w http.ResponseWriter, r *http.Request) (addr string, blocked bool) {
119+ addr = clientlimit.ClientAddr(r, s.cfg.TrustedProxies)
120+ if is, wait := s.cfg.Limiter.Blocked(r.Context(), addr); is {
121+ w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
122+ jsonError(w, http.StatusTooManyRequests, "too many attempts")
123+ return addr, true
124+ }
125+ return addr, false
126+}
127+
128+// passkeyLoginPage is GET /-/login: the sign-in page. A signed-in visitor goes
129+// home.
130+func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) {
131+ if SignedIn(r.Context()) {
132+ seeOther(w, r, "/")
133+ return
134+ }
135+ l := s.stateLayout("sign in", []Crumb{{Label: "sign in", Current: true}})
136+ s.secretPage(w, r, http.StatusOK, PasskeyLogin(l, PasskeyLoginPage{
137+ Title: "sign in",
138+ Button: "Sign in with a passkey",
139+ Lines: []string{"or run `ermite web login` in a terminal."},
140+ Begin: loginBeginPath,
141+ Finish: loginFinishPath,
142+ }))
143+}
144+
145+// passkeyLoginBegin is POST /-/passkeys/login/begin.
146+func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
147+ if _, blocked := s.blocked(w, r); blocked {
148+ return
149+ }
150+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
151+ defer cancel()
152+ ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx)
153+ if err != nil {
154+ s.jsonInternal(ctx, w, r, err)
155+ return
156+ }
157+ jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
158+}
159+
160+// finishRequest is the body of a finish.
161+type finishRequest struct {
162+ Ceremony string `json:"ceremony"`
163+ Name string `json:"name"`
164+ Credential json.RawMessage `json:"credential"`
165+}
166+
167+// passkeyLoginFinish is POST /-/passkeys/login/finish: it checks the response
168+// of the authenticator and sets the session cookie, the same as a login link.
169+// A refusal counts as a failed authentication of the client, whatever the
170+// reason.
171+func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
172+ body, ok := jsonBody(w, r)
173+ if !ok {
174+ return
175+ }
176+ addr, blocked := s.blocked(w, r)
177+ if blocked {
178+ return
179+ }
180+ var req finishRequest
181+ if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
182+ jsonError(w, http.StatusBadRequest, "malformed request")
183+ return
184+ }
185+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
186+ defer cancel()
187+ token, lifetime, err := s.cfg.Passkeys.FinishLogin(ctx, req.Ceremony, req.Credential)
188+ switch {
189+ case errors.Is(err, ports.ErrLinkRejected):
190+ s.cfg.Limiter.Fail(ctx, addr)
191+ jsonError(w, http.StatusUnauthorized, "passkey not accepted")
192+ return
193+ case err != nil:
194+ s.jsonInternal(ctx, w, r, err)
195+ return
196+ }
197+ h := w.Header()
198+ h.Del("Set-Cookie") // a stale cookie cleared by withSession
199+ http.SetCookie(w, s.cookie(token, int(lifetime.Seconds())))
200+ h.Set("Cache-Control", noStore)
201+ w.WriteHeader(http.StatusNoContent)
202+}
203+
204+// passkeysPage is GET /-/passkeys: the owner's list. Anybody else gets the 404
205+// page, as for any page that does not exist for them.
206+func (s *Server) passkeysPage(ctx context.Context, _ *http.Request) (templ.Component, int, error) {
207+ if !SignedIn(ctx) {
208+ return nil, 0, errNotFound
209+ }
210+ list, err := s.cfg.Passkeys.List(ctx, principal(ctx))
211+ if err != nil {
212+ return nil, 0, err
213+ }
214+ now := s.cfg.Now()
215+ rows := make([]PasskeyRow, 0, len(list))
216+ for _, p := range list {
217+ rows = append(rows, PasskeyRow{
218+ Name: p.Name,
219+ Created: ago(now, p.CreatedAt), CreatedAt: rfc3339(p.CreatedAt),
220+ LastUsed: ago(now, p.LastUsedAt), LastUsedAt: rfc3339(p.LastUsedAt),
221+ Synced: p.BackupState,
222+ Remove: passkeysPath + "/" + base64.RawURLEncoding.EncodeToString(p.ID) + "/remove",
223+ })
224+ }
225+ l := s.base()
226+ l.Title = "passkeys · " + s.instance
227+ l.Crumbs = []Crumb{{Label: "passkeys", Current: true}}
228+ l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
229+ return Passkeys(l, PasskeysPage{
230+ Rows: rows, Full: len(list) >= domain.MaxPasskeys, Max: domain.MaxPasskeys,
231+ Begin: passkeysPath + "/register/begin", Finish: passkeysPath + "/register/finish",
232+ }), http.StatusOK, nil
233+}
234+
235+// registerError answers a refused registration step.
236+func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
237+ switch {
238+ case errors.Is(err, context.Canceled):
239+ // The browser is gone.
240+ case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated) && !SignedIn(ctx):
241+ jsonError(w, http.StatusForbidden, "forbidden")
242+ case errors.Is(err, domain.ErrAlreadyExists):
243+ jsonError(w, http.StatusConflict, "this passkey is already registered")
244+ case errors.Is(err, domain.ErrResourceExhausted):
245+ jsonError(w, http.StatusTooManyRequests, rule(err.Error(), "a limit is reached"))
246+ case domain.IsInvalid(err):
247+ jsonError(w, http.StatusBadRequest, rule(err.Error(), "the name is not valid"))
248+ case errors.Is(err, domain.ErrUnauthenticated):
249+ jsonError(w, http.StatusBadRequest, "passkey not accepted, try again")
250+ default:
251+ s.jsonInternal(ctx, w, r, err)
252+ }
253+}
254+
255+// passkeyRegisterBegin is POST /-/passkeys/register/begin.
256+func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
257+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
258+ defer cancel()
259+ ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx))
260+ if err != nil {
261+ s.registerError(ctx, w, r, err)
262+ return
263+ }
264+ jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
265+}
266+
267+// passkeyRegisterFinish is POST /-/passkeys/register/finish.
268+func (s *Server) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
269+ body, ok := jsonBody(w, r)
270+ if !ok {
271+ return
272+ }
273+ var req finishRequest
274+ if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
275+ jsonError(w, http.StatusBadRequest, "malformed request")
276+ return
277+ }
278+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
279+ defer cancel()
280+ if err := s.cfg.Passkeys.FinishRegistration(ctx, principal(ctx), req.Ceremony, req.Name, req.Credential); err != nil {
281+ s.registerError(ctx, w, r, err)
282+ return
283+ }
284+ w.Header().Set("Cache-Control", noStore)
285+ w.WriteHeader(http.StatusNoContent)
286+}
287+
288+// passkeyRemove is POST /-/passkeys/{id}/remove: it removes the passkey, which
289+// signs every browser out, this one included: the answer sends it to the
290+// sign-in page.
291+func (s *Server) passkeyRemove(w http.ResponseWriter, r *http.Request) {
292+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
293+ defer cancel()
294+ id, err := base64.RawURLEncoding.DecodeString(r.PathValue("id"))
295+ if err != nil || len(id) == 0 {
296+ s.actionFailed(ctx, w, r, errNotFound, passkeysPath)
297+ return
298+ }
299+ if err := s.cfg.Passkeys.Remove(ctx, principal(ctx), id); err != nil {
300+ s.actionFailed(ctx, w, r, err, passkeysPath)
301+ return
302+ }
303+ seeOther(w, r, passkeyLoginPath)
304+}
A internal/adapters/web/passkeys_test.go +547 -0
@@ -0,0 +1,547 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+)
23+
24+const forgeOrigin = "https://forge.example"
25+
26+// blockingLimiter is a FailureLimiter that can be told to block.
27+type blockingLimiter struct {
28+ blocked bool
29+ fails int
30+}
31+
32+func (l *blockingLimiter) Blocked(context.Context, string) (bool, time.Duration) {
33+ if l.blocked {
34+ return true, 90 * time.Second
35+ }
36+ return false, 0
37+}
38+func (l *blockingLimiter) Fail(context.Context, string) { l.fails++ }
39+
40+type passkeyFixture struct {
41+ srv *Server
42+ sessions *mocks.WebSessionService
43+ passkeys *mocks.WebPasskeyService
44+ lim *blockingLimiter
45+}
46+
47+func newPasskeyServer(t *testing.T) *passkeyFixture {
48+ t.Helper()
49+ f := &passkeyFixture{
50+ sessions: newSessions(t, "tok"),
51+ passkeys: mocks.NewWebPasskeyService(t),
52+ lim: &blockingLimiter{},
53+ }
54+ f.srv = newTestServer(t, Config{
55+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
56+ PublicURL: forgeOrigin, Sessions: f.sessions, Passkeys: f.passkeys, Limiter: f.lim,
57+ })
58+ return f
59+}
60+
61+type req struct {
62+ method, target string
63+ origin string // "" sends none
64+ cookie string
65+ contentType string
66+ body string
67+}
68+
69+func (f *passkeyFixture) do(t *testing.T, r req) *httptest.ResponseRecorder {
70+ t.Helper()
71+ hr := httptest.NewRequest(r.method, r.target, strings.NewReader(r.body))
72+ hr.Host = "forge.example"
73+ if r.origin != "" {
74+ hr.Header.Set("Origin", r.origin)
75+ }
76+ if r.cookie != "" {
77+ hr.AddCookie(&http.Cookie{Name: CookieName, Value: r.cookie})
78+ }
79+ if r.contentType != "" {
80+ hr.Header.Set("Content-Type", r.contentType)
81+ }
82+ rec := httptest.NewRecorder()
83+ f.srv.ServeHTTP(rec, hr)
84+ return rec
85+}
86+
87+func postJSON(target, cookie, body string) req {
88+ return req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: "application/json", body: body}
89+}
90+
91+const finishBody = `{"ceremony":"c1","name":"Pixel 9","credential":{"id":"x","type":"public-key"}}`
92+
93+var (
94+ beginLogin = "/-/passkeys/login/begin"
95+ finishLogin = "/-/passkeys/login/finish"
96+ beginRegister = "/-/passkeys/register/begin"
97+ finishRegister = "/-/passkeys/register/finish"
98+)
99+
100+// Every new POST needs the Origin of the public URL.
101+func TestPasskeyPostsNeedTheOrigin(t *testing.T) {
102+ t.Parallel()
103+ f := newPasskeyServer(t) // the mocks fail the test on any call
104+ removeTarget := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("a")) + "/remove"
105+ for _, target := range []string{beginLogin, finishLogin, beginRegister, finishRegister, removeTarget} {
106+ for _, cookie := range []string{"", "tok"} {
107+ for _, origin := range []string{"", "null", "https://evil.example", "http://forge.example", "https://forge.example.evil.example"} {
108+ rec := f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/json", body: finishBody})
109+ assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, origin, cookie)
110+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
111+ }
112+ }
113+ }
114+}
115+
116+// An anonymous browser may post to the two login endpoints only.
117+func TestPasskeyAnonymousPostsAreExact(t *testing.T) {
118+ t.Parallel()
119+ f := newPasskeyServer(t)
120+ id := base64.RawURLEncoding.EncodeToString([]byte("a"))
121+ for _, target := range []string{
122+ beginRegister, finishRegister, "/-/passkeys/" + id + "/remove",
123+ "/-/passkeys", "/-/passkeys/", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/login/begin/x",
124+ "/-/passkeys/login/finish/x", "/-/passkeys%2Flogin%2Fbegin", "/-%2Fpasskeys/login/begin",
125+ "/-/passkeys/login%2Ffinish", "/-/passkeys/login/./begin", "/-/passkeys/x/../login/begin",
126+ "/-/passkeys/%6Cogin/begin", "//-/passkeys/login/begin", "/-/passkeys/login/begin%2f",
127+ } {
128+ rec := f.do(t, postJSON(target, "", finishBody))
129+ assert.Equal(t, http.StatusForbidden, rec.Code, target)
130+ }
131+ // A GET is never a login.
132+ rec := f.do(t, req{method: "GET", target: beginLogin, origin: forgeOrigin})
133+ assert.NotEqual(t, http.StatusOK, rec.Code)
134+}
135+
136+func TestPasskeyLoginBegin(t *testing.T) {
137+ t.Parallel()
138+ f := newPasskeyServer(t)
139+ f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once()
140+
141+ rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
142+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
143+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
144+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
145+ assert.JSONEq(t, `{"ceremony":"cer1","options":{"publicKey":{"challenge":"abc"}}}`, rec.Body.String())
146+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
147+}
148+
149+func TestPasskeyLoginBeginFailure(t *testing.T) {
150+ t.Parallel()
151+ f := newPasskeyServer(t)
152+ f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("", nil, errors.New("secret detail")).Once()
153+ rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
154+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
155+ assert.NotContains(t, rec.Body.String(), "secret detail")
156+ assert.Contains(t, rec.Body.String(), "request id")
157+}
158+
159+func TestPasskeyLoginBlocked(t *testing.T) {
160+ t.Parallel()
161+ f := newPasskeyServer(t) // no use case call is expected
162+ f.lim.blocked = true
163+ for _, r := range []req{
164+ {method: "POST", target: beginLogin, origin: forgeOrigin},
165+ postJSON(finishLogin, "", finishBody),
166+ } {
167+ rec := f.do(t, r)
168+ assert.Equal(t, http.StatusTooManyRequests, rec.Code, r.target)
169+ assert.JSONEq(t, `{"error":"too many attempts"}`, rec.Body.String())
170+ assert.Equal(t, "91", rec.Header().Get("Retry-After"))
171+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
172+ }
173+ assert.Zero(t, f.lim.fails, "a blocked client is not counted again")
174+}
175+
176+func TestPasskeyLoginFinishSetsTheSession(t *testing.T) {
177+ t.Parallel()
178+ f := newPasskeyServer(t)
179+ f.passkeys.EXPECT().FinishLogin(mock.Anything, "c1", mock.MatchedBy(func(b []byte) bool {
180+ return string(b) == `{"id":"x","type":"public-key"}`
181+ })).Return("tok", 12*time.Hour, nil).Once()
182+
183+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
184+ require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
185+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
186+ assert.Zero(t, f.lim.fails)
187+
188+ cookies := rec.Result().Cookies()
189+ require.Len(t, cookies, 1)
190+ c := cookies[0]
191+ assert.Equal(t, CookieName, c.Name)
192+ assert.Equal(t, "tok", c.Value)
193+ assert.True(t, c.Secure)
194+ assert.True(t, c.HttpOnly)
195+ assert.Equal(t, http.SameSiteStrictMode, c.SameSite)
196+ assert.Equal(t, "/", c.Path)
197+ assert.Equal(t, 12*3600, c.MaxAge)
198+ assert.Empty(t, c.Domain)
199+ set := rec.Header().Get("Set-Cookie")
200+ for _, attr := range []string{"Secure", "HttpOnly", "SameSite=Strict", "Path=/"} {
201+ assert.Contains(t, set, attr)
202+ }
203+}
204+
205+// A stale cookie that withSession cleared is replaced, not doubled.
206+func TestPasskeyLoginFinishReplacesAStaleCookie(t *testing.T) {
207+ t.Parallel()
208+ f := newPasskeyServer(t)
209+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
210+ rec := f.do(t, postJSON(finishLogin, "stale", finishBody))
211+ // A stale cookie is not a session: the login endpoint still lets it through.
212+ require.Equal(t, http.StatusNoContent, rec.Code)
213+ require.Len(t, rec.Result().Cookies(), 1)
214+ assert.Equal(t, "tok", rec.Result().Cookies()[0].Value)
215+}
216+
217+func TestPasskeyLoginFinishRefusal(t *testing.T) {
218+ t.Parallel()
219+ f := newPasskeyServer(t)
220+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Twice()
221+
222+ for range 2 {
223+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
224+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
225+ assert.JSONEq(t, `{"error":"passkey not accepted"}`, rec.Body.String())
226+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
227+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
228+ }
229+ assert.Equal(t, 2, f.lim.fails, "every refusal counts")
230+}
231+
232+func TestPasskeyLoginFinishInternalError(t *testing.T) {
233+ t.Parallel()
234+ f := newPasskeyServer(t)
235+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), errors.New("kv down")).Once()
236+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
237+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
238+ assert.NotContains(t, rec.Body.String(), "kv down")
239+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
240+ assert.Zero(t, f.lim.fails, "a server failure is not the client's")
241+}
242+
243+func TestPasskeyFinishNeedsJSON(t *testing.T) {
244+ t.Parallel()
245+ f := newPasskeyServer(t) // no use case call is expected
246+ for _, target := range []string{finishLogin, finishRegister} {
247+ for _, ct := range []string{"application/x-www-form-urlencoded", "text/plain", "multipart/form-data; boundary=x", "", "application/jsonx", "json"} {
248+ cookie := ""
249+ if target == finishRegister {
250+ cookie = "tok"
251+ }
252+ rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: ct, body: finishBody})
253+ assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code, "%s %q", target, ct)
254+ }
255+ }
256+ assert.Zero(t, f.lim.fails)
257+}
258+
259+func TestPasskeyFinishAcceptsJSONWithCharset(t *testing.T) {
260+ t.Parallel()
261+ f := newPasskeyServer(t)
262+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
263+ rec := f.do(t, req{method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "application/json; charset=utf-8", body: finishBody})
264+ assert.Equal(t, http.StatusNoContent, rec.Code)
265+}
266+
267+func TestPasskeyFinishBodyLimit(t *testing.T) {
268+ t.Parallel()
269+ f := newPasskeyServer(t)
270+ big := `{"ceremony":"c1","credential":"` + strings.Repeat("a", 64<<10) + `"}`
271+ for _, target := range []string{finishLogin, finishRegister} {
272+ rec := f.do(t, postJSON(target, "tok", big))
273+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, target)
274+ }
275+ // Right at the limit is read (and then refused as malformed, not as too large).
276+ exact := `{"ceremony":"` + strings.Repeat("a", 64<<10-len(`{"ceremony":""}`)) + `"}`
277+ require.Len(t, exact, 64<<10)
278+ rec := f.do(t, postJSON(finishLogin, "", exact))
279+ assert.Equal(t, http.StatusBadRequest, rec.Code)
280+ assert.Zero(t, f.lim.fails, "a malformed body is not a failed authentication")
281+}
282+
283+func TestPasskeyFinishMalformed(t *testing.T) {
284+ t.Parallel()
285+ f := newPasskeyServer(t)
286+ for _, body := range []string{``, `not json`, `{}`, `{"ceremony":"c1"}`, `{"credential":{}}`, `{"ceremony":"","credential":{}}`, `[]`} {
287+ for _, target := range []string{finishLogin, finishRegister} {
288+ rec := f.do(t, postJSON(target, "tok", body))
289+ assert.Equal(t, http.StatusBadRequest, rec.Code, "%s %q", target, body)
290+ }
291+ }
292+}
293+
294+// The registration endpoints are the owner's.
295+func TestPasskeyRegistration(t *testing.T) {
296+ t.Parallel()
297+ f := newPasskeyServer(t)
298+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner()).Return("cer9", []byte(`{"publicKey":{}}`), nil).Once()
299+ rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})
300+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
301+ assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String())
302+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
303+
304+ f.passkeys.EXPECT().FinishRegistration(mock.Anything, domain.Owner(), "c1", "Pixel 9", mock.MatchedBy(func(b []byte) bool {
305+ return string(b) == `{"id":"x","type":"public-key"}`
306+ })).Return(nil).Once()
307+ rec = f.do(t, postJSON(finishRegister, "tok", finishBody))
308+ assert.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
309+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
310+ assert.Empty(t, rec.Header().Values("Set-Cookie"), "registering does not touch the session")
311+}
312+
313+func TestPasskeyRegistrationErrors(t *testing.T) {
314+ t.Parallel()
315+ for name, tc := range map[string]struct {
316+ err error
317+ status int
318+ body string
319+ }{
320+ "duplicate": {domain.ErrAlreadyExists, 409, "already registered"},
321+ "limit": {domain.ErrResourceExhausted, 429, ""},
322+ "bad name": {&domain.TextError{Reason: "contains the control character U+0009"}, 400, "control character"},
323+ "refused": {domain.ErrUnauthenticated, 400, "passkey not accepted"},
324+ "not owner": {domain.ErrForbidden, 403, ""},
325+ "server error": {errors.New("kv down"), 500, ""},
326+ } {
327+ t.Run(name, func(t *testing.T) {
328+ t.Parallel()
329+ f := newPasskeyServer(t)
330+ f.passkeys.EXPECT().FinishRegistration(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.err).Once()
331+ rec := f.do(t, postJSON(finishRegister, "tok", finishBody))
332+ assert.Equal(t, tc.status, rec.Code)
333+ assert.Contains(t, rec.Body.String(), `"error"`)
334+ assert.Contains(t, rec.Body.String(), tc.body)
335+ assert.NotContains(t, rec.Body.String(), "kv down")
336+ assert.Zero(t, f.lim.fails, "the owner's errors are not login failures")
337+ })
338+ }
339+
340+ f := newPasskeyServer(t)
341+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once()
342+ rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})
343+ assert.Equal(t, http.StatusTooManyRequests, rec.Code)
344+}
345+
346+func TestPasskeysPage(t *testing.T) {
347+ t.Parallel()
348+ f := newPasskeyServer(t)
349+ now := time.Now()
350+ list := []domain.Passkey{
351+ {ID: []byte{0xfb, 0xff, 'a'}, Name: "Pixel <9>", CreatedAt: now.Add(-72 * time.Hour), LastUsedAt: now.Add(-time.Hour), BackupState: true},
352+ {ID: []byte("hw"), Name: "yubikey", CreatedAt: now.Add(-time.Hour)},
353+ }
354+ f.passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(list, nil).Once()
355+
356+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
357+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
358+ assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
359+ body := rec.Body.String()
360+ assert.Contains(t, body, "Pixel &lt;9&gt;", "names are escaped")
361+ assert.NotContains(t, body, "Pixel <9>")
362+ assert.Contains(t, body, "yubikey")
363+ assert.Contains(t, body, "3d ago")
364+ assert.Contains(t, body, "never")
365+ assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`)
366+ assert.Contains(t, body, `data-passkey="register"`)
367+ assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`)
368+ assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`)
369+ assert.Contains(t, body, "data-passkey-status")
370+ assert.Contains(t, body, `aria-live="polite"`)
371+}
372+
373+func TestPasskeysPageFull(t *testing.T) {
374+ t.Parallel()
375+ f := newPasskeyServer(t)
376+ list := make([]domain.Passkey, domain.MaxPasskeys)
377+ for i := range list {
378+ list[i] = domain.Passkey{ID: []byte{byte(i + 1)}, Name: "k", CreatedAt: time.Now()}
379+ }
380+ f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(list, nil).Once()
381+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
382+ require.Equal(t, http.StatusOK, rec.Code)
383+ assert.NotContains(t, rec.Body.String(), `data-passkey="register"`)
384+ assert.Contains(t, rec.Body.String(), "Remove one to add another")
385+}
386+
387+// Anybody but the owner gets the 404 page, and the list is not read.
388+func TestPasskeysPageIsTheOwners(t *testing.T) {
389+ t.Parallel()
390+ f := newPasskeyServer(t)
391+ for _, cookie := range []string{"", "stale"} {
392+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: cookie})
393+ assert.Equal(t, http.StatusNotFound, rec.Code, cookie)
394+ assert.NotContains(t, rec.Body.String(), "Passkeys")
395+ assert.NotContains(t, rec.Body.String(), "data-passkey")
396+ }
397+}
398+
399+func TestPasskeyRemove(t *testing.T) {
400+ t.Parallel()
401+ f := newPasskeyServer(t)
402+ id := []byte{0xfb, 0xff, 'a'}
403+ target := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString(id) + "/remove"
404+ f.passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(nil).Once()
405+
406+ rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"})
407+ assert.Equal(t, http.StatusSeeOther, rec.Code)
408+ assert.Equal(t, "/-/login", rec.Header().Get("Location"), "the epoch bump signed this browser out too")
409+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
410+}
411+
412+func TestPasskeyRemoveErrors(t *testing.T) {
413+ t.Parallel()
414+ f := newPasskeyServer(t)
415+ // Ids that are not unpadded base64url never reach the use case (the mock
416+ // fails the test on a call).
417+ for _, id := range []string{"not%20base64%21", "%2B%2F8B", "YQ%3D%3D", "%C3%A9"} {
418+ rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + id + "/remove", origin: forgeOrigin, cookie: "tok"})
419+ assert.Equal(t, http.StatusNotFound, rec.Code, id)
420+ }
421+
422+ f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("zz")).Return(domain.ErrNotFound).Once()
423+ rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("zz")) + "/remove", origin: forgeOrigin, cookie: "tok"})
424+ assert.Equal(t, http.StatusNotFound, rec.Code)
425+
426+ f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("yy")).Return(errors.New("kv down")).Once()
427+ rec = f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("yy")) + "/remove", origin: forgeOrigin, cookie: "tok"})
428+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
429+ assert.NotContains(t, rec.Body.String(), "kv down")
430+}
431+
432+func TestPasskeyLoginPage(t *testing.T) {
433+ t.Parallel()
434+ f := newPasskeyServer(t)
435+ rec := f.do(t, req{method: "GET", target: "/-/login"})
436+ require.Equal(t, http.StatusOK, rec.Code)
437+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
438+ assert.Equal(t, "strict-origin", rec.Header().Get("Referrer-Policy"))
439+ body := rec.Body.String()
440+ assert.Contains(t, body, "<title>sign in")
441+ assert.Contains(t, body, "Sign in with a passkey")
442+ assert.Contains(t, body, `data-passkey="login"`)
443+ assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`)
444+ assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`)
445+ assert.Contains(t, body, "ermite web login")
446+ assert.NotContains(t, body, "<script>", "no inline script")
447+
448+ // A signed-in visitor goes home.
449+ rec = f.do(t, req{method: "GET", target: "/-/login", cookie: "tok"})
450+ assert.Equal(t, http.StatusSeeOther, rec.Code)
451+ assert.Equal(t, "/", rec.Header().Get("Location"))
452+}
453+
454+// The header links: "Sign in" for a visitor, "Passkeys" for the owner.
455+func TestPasskeyHeaderLinks(t *testing.T) {
456+ t.Parallel()
457+ f := newPasskeyServer(t)
458+ rec := f.do(t, req{method: "GET", target: "/-/login"})
459+ assert.Contains(t, rec.Body.String(), `href="/-/login"`)
460+ assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
461+ assert.Contains(t, rec.Body.String(), "passkey.", "the script is loaded")
462+
463+ f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(nil, nil).Once()
464+ rec = f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
465+ assert.Contains(t, rec.Body.String(), `href="/-/passkeys"`)
466+ assert.Contains(t, rec.Body.String(), `action="/-/logout"`)
467+ assert.NotContains(t, rec.Body.String(), `>Sign in</a>`)
468+}
469+
470+// Without the passkey service there is no route, no link and no script
471+// behavior to reach: the routes answer like any unknown path.
472+func TestPasskeyRoutesAbsentWithoutTheService(t *testing.T) {
473+ t.Parallel()
474+ lim := &blockingLimiter{}
475+ srv := newTestServer(t, Config{
476+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
477+ PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: lim,
478+ })
479+ do := func(method, target, cookie string) *httptest.ResponseRecorder {
480+ r := httptest.NewRequest(method, target, strings.NewReader(finishBody))
481+ r.Header.Set("Origin", forgeOrigin)
482+ r.Header.Set("Content-Type", "application/json")
483+ if cookie != "" {
484+ r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
485+ }
486+ rec := httptest.NewRecorder()
487+ srv.ServeHTTP(rec, r)
488+ return rec
489+ }
490+ for _, target := range []string{"/-/login", "/-/passkeys"} {
491+ assert.Equal(t, http.StatusNotFound, do("GET", target, "").Code, target)
492+ assert.Equal(t, http.StatusNotFound, do("GET", target, "tok").Code, target)
493+ }
494+ // A POST to a path without a route gets what any unknown path gets for a
495+ // POST (the catch-all page route is GET only).
496+ unknown := do("POST", "/-/nothing-here", "tok").Code
497+ assert.Equal(t, http.StatusMethodNotAllowed, unknown)
498+ for _, target := range []string{beginLogin, finishLogin} {
499+ assert.Equal(t, http.StatusForbidden, do("POST", target, "").Code, target+": the gate still refuses an anonymous POST")
500+ assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
501+ }
502+ for _, target := range []string{beginRegister, finishRegister, "/-/passkeys/AQ/remove"} {
503+ assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
504+ }
505+ // And no link in the header.
506+ rec := do("GET", "/", "")
507+ assert.NotContains(t, rec.Body.String(), `href="/-/login"`)
508+ rec = do("GET", "/", "tok")
509+ assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
510+}
511+
512+// Passkeys need sessions: with the service alone nothing is served.
513+func TestPasskeyRoutesNeedSessions(t *testing.T) {
514+ t.Parallel()
515+ srv := newTestServer(t, Config{
516+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
517+ PublicURL: forgeOrigin, Passkeys: mocks.NewWebPasskeyService(t),
518+ })
519+ rec := httptest.NewRecorder()
520+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/-/login", nil))
521+ assert.Equal(t, http.StatusNotFound, rec.Code)
522+ r := httptest.NewRequest("POST", beginLogin, nil)
523+ r.Header.Set("Origin", forgeOrigin)
524+ rec = httptest.NewRecorder()
525+ srv.ServeHTTP(rec, r)
526+ assert.Equal(t, http.StatusForbidden, rec.Code)
527+}
528+
529+// The existing login links keep working next to the passkey routes.
530+func TestPasskeyRoutesLeaveLoginLinksAlone(t *testing.T) {
531+ t.Parallel()
532+ f := newPasskeyServer(t)
533+ rec := f.do(t, req{method: "GET", target: "/-/login/" + nonceA})
534+ assert.Equal(t, http.StatusOK, rec.Code)
535+ assert.Contains(t, rec.Body.String(), "sign in as the owner")
536+ rec = f.do(t, req{method: "POST", target: "/-/login/" + nonceA, origin: forgeOrigin})
537+ assert.Equal(t, http.StatusSeeOther, rec.Code)
538+}
539+
540+func TestIsPasskeyLoginPath(t *testing.T) {
541+ t.Parallel()
542+ assert.True(t, isPasskeyLoginPath("/-/passkeys/login/begin"))
543+ assert.True(t, isPasskeyLoginPath("/-/passkeys/login/finish"))
544+ for _, p := range []string{"", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/register/begin", "/-/passkeys/Login/begin", "/x/-/passkeys/login/begin"} {
545+ assert.False(t, isPasskeyLoginPath(p), p)
546+ }
547+}
@@ -0,0 +1,547 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+)
23+
24+const forgeOrigin = "https://forge.example"
25+
26+// blockingLimiter is a FailureLimiter that can be told to block.
27+type blockingLimiter struct {
28+ blocked bool
29+ fails int
30+}
31+
32+func (l *blockingLimiter) Blocked(context.Context, string) (bool, time.Duration) {
33+ if l.blocked {
34+ return true, 90 * time.Second
35+ }
36+ return false, 0
37+}
38+func (l *blockingLimiter) Fail(context.Context, string) { l.fails++ }
39+
40+type passkeyFixture struct {
41+ srv *Server
42+ sessions *mocks.WebSessionService
43+ passkeys *mocks.WebPasskeyService
44+ lim *blockingLimiter
45+}
46+
47+func newPasskeyServer(t *testing.T) *passkeyFixture {
48+ t.Helper()
49+ f := &passkeyFixture{
50+ sessions: newSessions(t, "tok"),
51+ passkeys: mocks.NewWebPasskeyService(t),
52+ lim: &blockingLimiter{},
53+ }
54+ f.srv = newTestServer(t, Config{
55+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
56+ PublicURL: forgeOrigin, Sessions: f.sessions, Passkeys: f.passkeys, Limiter: f.lim,
57+ })
58+ return f
59+}
60+
61+type req struct {
62+ method, target string
63+ origin string // "" sends none
64+ cookie string
65+ contentType string
66+ body string
67+}
68+
69+func (f *passkeyFixture) do(t *testing.T, r req) *httptest.ResponseRecorder {
70+ t.Helper()
71+ hr := httptest.NewRequest(r.method, r.target, strings.NewReader(r.body))
72+ hr.Host = "forge.example"
73+ if r.origin != "" {
74+ hr.Header.Set("Origin", r.origin)
75+ }
76+ if r.cookie != "" {
77+ hr.AddCookie(&http.Cookie{Name: CookieName, Value: r.cookie})
78+ }
79+ if r.contentType != "" {
80+ hr.Header.Set("Content-Type", r.contentType)
81+ }
82+ rec := httptest.NewRecorder()
83+ f.srv.ServeHTTP(rec, hr)
84+ return rec
85+}
86+
87+func postJSON(target, cookie, body string) req {
88+ return req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: "application/json", body: body}
89+}
90+
91+const finishBody = `{"ceremony":"c1","name":"Pixel 9","credential":{"id":"x","type":"public-key"}}`
92+
93+var (
94+ beginLogin = "/-/passkeys/login/begin"
95+ finishLogin = "/-/passkeys/login/finish"
96+ beginRegister = "/-/passkeys/register/begin"
97+ finishRegister = "/-/passkeys/register/finish"
98+)
99+
100+// Every new POST needs the Origin of the public URL.
101+func TestPasskeyPostsNeedTheOrigin(t *testing.T) {
102+ t.Parallel()
103+ f := newPasskeyServer(t) // the mocks fail the test on any call
104+ removeTarget := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("a")) + "/remove"
105+ for _, target := range []string{beginLogin, finishLogin, beginRegister, finishRegister, removeTarget} {
106+ for _, cookie := range []string{"", "tok"} {
107+ for _, origin := range []string{"", "null", "https://evil.example", "http://forge.example", "https://forge.example.evil.example"} {
108+ rec := f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/json", body: finishBody})
109+ assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, origin, cookie)
110+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
111+ }
112+ }
113+ }
114+}
115+
116+// An anonymous browser may post to the two login endpoints only.
117+func TestPasskeyAnonymousPostsAreExact(t *testing.T) {
118+ t.Parallel()
119+ f := newPasskeyServer(t)
120+ id := base64.RawURLEncoding.EncodeToString([]byte("a"))
121+ for _, target := range []string{
122+ beginRegister, finishRegister, "/-/passkeys/" + id + "/remove",
123+ "/-/passkeys", "/-/passkeys/", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/login/begin/x",
124+ "/-/passkeys/login/finish/x", "/-/passkeys%2Flogin%2Fbegin", "/-%2Fpasskeys/login/begin",
125+ "/-/passkeys/login%2Ffinish", "/-/passkeys/login/./begin", "/-/passkeys/x/../login/begin",
126+ "/-/passkeys/%6Cogin/begin", "//-/passkeys/login/begin", "/-/passkeys/login/begin%2f",
127+ } {
128+ rec := f.do(t, postJSON(target, "", finishBody))
129+ assert.Equal(t, http.StatusForbidden, rec.Code, target)
130+ }
131+ // A GET is never a login.
132+ rec := f.do(t, req{method: "GET", target: beginLogin, origin: forgeOrigin})
133+ assert.NotEqual(t, http.StatusOK, rec.Code)
134+}
135+
136+func TestPasskeyLoginBegin(t *testing.T) {
137+ t.Parallel()
138+ f := newPasskeyServer(t)
139+ f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once()
140+
141+ rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
142+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
143+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
144+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
145+ assert.JSONEq(t, `{"ceremony":"cer1","options":{"publicKey":{"challenge":"abc"}}}`, rec.Body.String())
146+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
147+}
148+
149+func TestPasskeyLoginBeginFailure(t *testing.T) {
150+ t.Parallel()
151+ f := newPasskeyServer(t)
152+ f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("", nil, errors.New("secret detail")).Once()
153+ rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
154+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
155+ assert.NotContains(t, rec.Body.String(), "secret detail")
156+ assert.Contains(t, rec.Body.String(), "request id")
157+}
158+
159+func TestPasskeyLoginBlocked(t *testing.T) {
160+ t.Parallel()
161+ f := newPasskeyServer(t) // no use case call is expected
162+ f.lim.blocked = true
163+ for _, r := range []req{
164+ {method: "POST", target: beginLogin, origin: forgeOrigin},
165+ postJSON(finishLogin, "", finishBody),
166+ } {
167+ rec := f.do(t, r)
168+ assert.Equal(t, http.StatusTooManyRequests, rec.Code, r.target)
169+ assert.JSONEq(t, `{"error":"too many attempts"}`, rec.Body.String())
170+ assert.Equal(t, "91", rec.Header().Get("Retry-After"))
171+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
172+ }
173+ assert.Zero(t, f.lim.fails, "a blocked client is not counted again")
174+}
175+
176+func TestPasskeyLoginFinishSetsTheSession(t *testing.T) {
177+ t.Parallel()
178+ f := newPasskeyServer(t)
179+ f.passkeys.EXPECT().FinishLogin(mock.Anything, "c1", mock.MatchedBy(func(b []byte) bool {
180+ return string(b) == `{"id":"x","type":"public-key"}`
181+ })).Return("tok", 12*time.Hour, nil).Once()
182+
183+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
184+ require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
185+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
186+ assert.Zero(t, f.lim.fails)
187+
188+ cookies := rec.Result().Cookies()
189+ require.Len(t, cookies, 1)
190+ c := cookies[0]
191+ assert.Equal(t, CookieName, c.Name)
192+ assert.Equal(t, "tok", c.Value)
193+ assert.True(t, c.Secure)
194+ assert.True(t, c.HttpOnly)
195+ assert.Equal(t, http.SameSiteStrictMode, c.SameSite)
196+ assert.Equal(t, "/", c.Path)
197+ assert.Equal(t, 12*3600, c.MaxAge)
198+ assert.Empty(t, c.Domain)
199+ set := rec.Header().Get("Set-Cookie")
200+ for _, attr := range []string{"Secure", "HttpOnly", "SameSite=Strict", "Path=/"} {
201+ assert.Contains(t, set, attr)
202+ }
203+}
204+
205+// A stale cookie that withSession cleared is replaced, not doubled.
206+func TestPasskeyLoginFinishReplacesAStaleCookie(t *testing.T) {
207+ t.Parallel()
208+ f := newPasskeyServer(t)
209+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
210+ rec := f.do(t, postJSON(finishLogin, "stale", finishBody))
211+ // A stale cookie is not a session: the login endpoint still lets it through.
212+ require.Equal(t, http.StatusNoContent, rec.Code)
213+ require.Len(t, rec.Result().Cookies(), 1)
214+ assert.Equal(t, "tok", rec.Result().Cookies()[0].Value)
215+}
216+
217+func TestPasskeyLoginFinishRefusal(t *testing.T) {
218+ t.Parallel()
219+ f := newPasskeyServer(t)
220+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Twice()
221+
222+ for range 2 {
223+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
224+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
225+ assert.JSONEq(t, `{"error":"passkey not accepted"}`, rec.Body.String())
226+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
227+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
228+ }
229+ assert.Equal(t, 2, f.lim.fails, "every refusal counts")
230+}
231+
232+func TestPasskeyLoginFinishInternalError(t *testing.T) {
233+ t.Parallel()
234+ f := newPasskeyServer(t)
235+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), errors.New("kv down")).Once()
236+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
237+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
238+ assert.NotContains(t, rec.Body.String(), "kv down")
239+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
240+ assert.Zero(t, f.lim.fails, "a server failure is not the client's")
241+}
242+
243+func TestPasskeyFinishNeedsJSON(t *testing.T) {
244+ t.Parallel()
245+ f := newPasskeyServer(t) // no use case call is expected
246+ for _, target := range []string{finishLogin, finishRegister} {
247+ for _, ct := range []string{"application/x-www-form-urlencoded", "text/plain", "multipart/form-data; boundary=x", "", "application/jsonx", "json"} {
248+ cookie := ""
249+ if target == finishRegister {
250+ cookie = "tok"
251+ }
252+ rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: ct, body: finishBody})
253+ assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code, "%s %q", target, ct)
254+ }
255+ }
256+ assert.Zero(t, f.lim.fails)
257+}
258+
259+func TestPasskeyFinishAcceptsJSONWithCharset(t *testing.T) {
260+ t.Parallel()
261+ f := newPasskeyServer(t)
262+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
263+ rec := f.do(t, req{method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "application/json; charset=utf-8", body: finishBody})
264+ assert.Equal(t, http.StatusNoContent, rec.Code)
265+}
266+
267+func TestPasskeyFinishBodyLimit(t *testing.T) {
268+ t.Parallel()
269+ f := newPasskeyServer(t)
270+ big := `{"ceremony":"c1","credential":"` + strings.Repeat("a", 64<<10) + `"}`
271+ for _, target := range []string{finishLogin, finishRegister} {
272+ rec := f.do(t, postJSON(target, "tok", big))
273+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, target)
274+ }
275+ // Right at the limit is read (and then refused as malformed, not as too large).
276+ exact := `{"ceremony":"` + strings.Repeat("a", 64<<10-len(`{"ceremony":""}`)) + `"}`
277+ require.Len(t, exact, 64<<10)
278+ rec := f.do(t, postJSON(finishLogin, "", exact))
279+ assert.Equal(t, http.StatusBadRequest, rec.Code)
280+ assert.Zero(t, f.lim.fails, "a malformed body is not a failed authentication")
281+}
282+
283+func TestPasskeyFinishMalformed(t *testing.T) {
284+ t.Parallel()
285+ f := newPasskeyServer(t)
286+ for _, body := range []string{``, `not json`, `{}`, `{"ceremony":"c1"}`, `{"credential":{}}`, `{"ceremony":"","credential":{}}`, `[]`} {
287+ for _, target := range []string{finishLogin, finishRegister} {
288+ rec := f.do(t, postJSON(target, "tok", body))
289+ assert.Equal(t, http.StatusBadRequest, rec.Code, "%s %q", target, body)
290+ }
291+ }
292+}
293+
294+// The registration endpoints are the owner's.
295+func TestPasskeyRegistration(t *testing.T) {
296+ t.Parallel()
297+ f := newPasskeyServer(t)
298+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner()).Return("cer9", []byte(`{"publicKey":{}}`), nil).Once()
299+ rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})
300+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
301+ assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String())
302+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
303+
304+ f.passkeys.EXPECT().FinishRegistration(mock.Anything, domain.Owner(), "c1", "Pixel 9", mock.MatchedBy(func(b []byte) bool {
305+ return string(b) == `{"id":"x","type":"public-key"}`
306+ })).Return(nil).Once()
307+ rec = f.do(t, postJSON(finishRegister, "tok", finishBody))
308+ assert.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
309+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
310+ assert.Empty(t, rec.Header().Values("Set-Cookie"), "registering does not touch the session")
311+}
312+
313+func TestPasskeyRegistrationErrors(t *testing.T) {
314+ t.Parallel()
315+ for name, tc := range map[string]struct {
316+ err error
317+ status int
318+ body string
319+ }{
320+ "duplicate": {domain.ErrAlreadyExists, 409, "already registered"},
321+ "limit": {domain.ErrResourceExhausted, 429, ""},
322+ "bad name": {&domain.TextError{Reason: "contains the control character U+0009"}, 400, "control character"},
323+ "refused": {domain.ErrUnauthenticated, 400, "passkey not accepted"},
324+ "not owner": {domain.ErrForbidden, 403, ""},
325+ "server error": {errors.New("kv down"), 500, ""},
326+ } {
327+ t.Run(name, func(t *testing.T) {
328+ t.Parallel()
329+ f := newPasskeyServer(t)
330+ f.passkeys.EXPECT().FinishRegistration(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.err).Once()
331+ rec := f.do(t, postJSON(finishRegister, "tok", finishBody))
332+ assert.Equal(t, tc.status, rec.Code)
333+ assert.Contains(t, rec.Body.String(), `"error"`)
334+ assert.Contains(t, rec.Body.String(), tc.body)
335+ assert.NotContains(t, rec.Body.String(), "kv down")
336+ assert.Zero(t, f.lim.fails, "the owner's errors are not login failures")
337+ })
338+ }
339+
340+ f := newPasskeyServer(t)
341+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once()
342+ rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})
343+ assert.Equal(t, http.StatusTooManyRequests, rec.Code)
344+}
345+
346+func TestPasskeysPage(t *testing.T) {
347+ t.Parallel()
348+ f := newPasskeyServer(t)
349+ now := time.Now()
350+ list := []domain.Passkey{
351+ {ID: []byte{0xfb, 0xff, 'a'}, Name: "Pixel <9>", CreatedAt: now.Add(-72 * time.Hour), LastUsedAt: now.Add(-time.Hour), BackupState: true},
352+ {ID: []byte("hw"), Name: "yubikey", CreatedAt: now.Add(-time.Hour)},
353+ }
354+ f.passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(list, nil).Once()
355+
356+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
357+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
358+ assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
359+ body := rec.Body.String()
360+ assert.Contains(t, body, "Pixel &lt;9&gt;", "names are escaped")
361+ assert.NotContains(t, body, "Pixel <9>")
362+ assert.Contains(t, body, "yubikey")
363+ assert.Contains(t, body, "3d ago")
364+ assert.Contains(t, body, "never")
365+ assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`)
366+ assert.Contains(t, body, `data-passkey="register"`)
367+ assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`)
368+ assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`)
369+ assert.Contains(t, body, "data-passkey-status")
370+ assert.Contains(t, body, `aria-live="polite"`)
371+}
372+
373+func TestPasskeysPageFull(t *testing.T) {
374+ t.Parallel()
375+ f := newPasskeyServer(t)
376+ list := make([]domain.Passkey, domain.MaxPasskeys)
377+ for i := range list {
378+ list[i] = domain.Passkey{ID: []byte{byte(i + 1)}, Name: "k", CreatedAt: time.Now()}
379+ }
380+ f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(list, nil).Once()
381+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
382+ require.Equal(t, http.StatusOK, rec.Code)
383+ assert.NotContains(t, rec.Body.String(), `data-passkey="register"`)
384+ assert.Contains(t, rec.Body.String(), "Remove one to add another")
385+}
386+
387+// Anybody but the owner gets the 404 page, and the list is not read.
388+func TestPasskeysPageIsTheOwners(t *testing.T) {
389+ t.Parallel()
390+ f := newPasskeyServer(t)
391+ for _, cookie := range []string{"", "stale"} {
392+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: cookie})
393+ assert.Equal(t, http.StatusNotFound, rec.Code, cookie)
394+ assert.NotContains(t, rec.Body.String(), "Passkeys")
395+ assert.NotContains(t, rec.Body.String(), "data-passkey")
396+ }
397+}
398+
399+func TestPasskeyRemove(t *testing.T) {
400+ t.Parallel()
401+ f := newPasskeyServer(t)
402+ id := []byte{0xfb, 0xff, 'a'}
403+ target := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString(id) + "/remove"
404+ f.passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(nil).Once()
405+
406+ rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"})
407+ assert.Equal(t, http.StatusSeeOther, rec.Code)
408+ assert.Equal(t, "/-/login", rec.Header().Get("Location"), "the epoch bump signed this browser out too")
409+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
410+}
411+
412+func TestPasskeyRemoveErrors(t *testing.T) {
413+ t.Parallel()
414+ f := newPasskeyServer(t)
415+ // Ids that are not unpadded base64url never reach the use case (the mock
416+ // fails the test on a call).
417+ for _, id := range []string{"not%20base64%21", "%2B%2F8B", "YQ%3D%3D", "%C3%A9"} {
418+ rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + id + "/remove", origin: forgeOrigin, cookie: "tok"})
419+ assert.Equal(t, http.StatusNotFound, rec.Code, id)
420+ }
421+
422+ f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("zz")).Return(domain.ErrNotFound).Once()
423+ rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("zz")) + "/remove", origin: forgeOrigin, cookie: "tok"})
424+ assert.Equal(t, http.StatusNotFound, rec.Code)
425+
426+ f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("yy")).Return(errors.New("kv down")).Once()
427+ rec = f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("yy")) + "/remove", origin: forgeOrigin, cookie: "tok"})
428+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
429+ assert.NotContains(t, rec.Body.String(), "kv down")
430+}
431+
432+func TestPasskeyLoginPage(t *testing.T) {
433+ t.Parallel()
434+ f := newPasskeyServer(t)
435+ rec := f.do(t, req{method: "GET", target: "/-/login"})
436+ require.Equal(t, http.StatusOK, rec.Code)
437+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
438+ assert.Equal(t, "strict-origin", rec.Header().Get("Referrer-Policy"))
439+ body := rec.Body.String()
440+ assert.Contains(t, body, "<title>sign in")
441+ assert.Contains(t, body, "Sign in with a passkey")
442+ assert.Contains(t, body, `data-passkey="login"`)
443+ assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`)
444+ assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`)
445+ assert.Contains(t, body, "ermite web login")
446+ assert.NotContains(t, body, "<script>", "no inline script")
447+
448+ // A signed-in visitor goes home.
449+ rec = f.do(t, req{method: "GET", target: "/-/login", cookie: "tok"})
450+ assert.Equal(t, http.StatusSeeOther, rec.Code)
451+ assert.Equal(t, "/", rec.Header().Get("Location"))
452+}
453+
454+// The header links: "Sign in" for a visitor, "Passkeys" for the owner.
455+func TestPasskeyHeaderLinks(t *testing.T) {
456+ t.Parallel()
457+ f := newPasskeyServer(t)
458+ rec := f.do(t, req{method: "GET", target: "/-/login"})
459+ assert.Contains(t, rec.Body.String(), `href="/-/login"`)
460+ assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
461+ assert.Contains(t, rec.Body.String(), "passkey.", "the script is loaded")
462+
463+ f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(nil, nil).Once()
464+ rec = f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
465+ assert.Contains(t, rec.Body.String(), `href="/-/passkeys"`)
466+ assert.Contains(t, rec.Body.String(), `action="/-/logout"`)
467+ assert.NotContains(t, rec.Body.String(), `>Sign in</a>`)
468+}
469+
470+// Without the passkey service there is no route, no link and no script
471+// behavior to reach: the routes answer like any unknown path.
472+func TestPasskeyRoutesAbsentWithoutTheService(t *testing.T) {
473+ t.Parallel()
474+ lim := &blockingLimiter{}
475+ srv := newTestServer(t, Config{
476+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
477+ PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: lim,
478+ })
479+ do := func(method, target, cookie string) *httptest.ResponseRecorder {
480+ r := httptest.NewRequest(method, target, strings.NewReader(finishBody))
481+ r.Header.Set("Origin", forgeOrigin)
482+ r.Header.Set("Content-Type", "application/json")
483+ if cookie != "" {
484+ r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
485+ }
486+ rec := httptest.NewRecorder()
487+ srv.ServeHTTP(rec, r)
488+ return rec
489+ }
490+ for _, target := range []string{"/-/login", "/-/passkeys"} {
491+ assert.Equal(t, http.StatusNotFound, do("GET", target, "").Code, target)
492+ assert.Equal(t, http.StatusNotFound, do("GET", target, "tok").Code, target)
493+ }
494+ // A POST to a path without a route gets what any unknown path gets for a
495+ // POST (the catch-all page route is GET only).
496+ unknown := do("POST", "/-/nothing-here", "tok").Code
497+ assert.Equal(t, http.StatusMethodNotAllowed, unknown)
498+ for _, target := range []string{beginLogin, finishLogin} {
499+ assert.Equal(t, http.StatusForbidden, do("POST", target, "").Code, target+": the gate still refuses an anonymous POST")
500+ assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
501+ }
502+ for _, target := range []string{beginRegister, finishRegister, "/-/passkeys/AQ/remove"} {
503+ assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
504+ }
505+ // And no link in the header.
506+ rec := do("GET", "/", "")
507+ assert.NotContains(t, rec.Body.String(), `href="/-/login"`)
508+ rec = do("GET", "/", "tok")
509+ assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
510+}
511+
512+// Passkeys need sessions: with the service alone nothing is served.
513+func TestPasskeyRoutesNeedSessions(t *testing.T) {
514+ t.Parallel()
515+ srv := newTestServer(t, Config{
516+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
517+ PublicURL: forgeOrigin, Passkeys: mocks.NewWebPasskeyService(t),
518+ })
519+ rec := httptest.NewRecorder()
520+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/-/login", nil))
521+ assert.Equal(t, http.StatusNotFound, rec.Code)
522+ r := httptest.NewRequest("POST", beginLogin, nil)
523+ r.Header.Set("Origin", forgeOrigin)
524+ rec = httptest.NewRecorder()
525+ srv.ServeHTTP(rec, r)
526+ assert.Equal(t, http.StatusForbidden, rec.Code)
527+}
528+
529+// The existing login links keep working next to the passkey routes.
530+func TestPasskeyRoutesLeaveLoginLinksAlone(t *testing.T) {
531+ t.Parallel()
532+ f := newPasskeyServer(t)
533+ rec := f.do(t, req{method: "GET", target: "/-/login/" + nonceA})
534+ assert.Equal(t, http.StatusOK, rec.Code)
535+ assert.Contains(t, rec.Body.String(), "sign in as the owner")
536+ rec = f.do(t, req{method: "POST", target: "/-/login/" + nonceA, origin: forgeOrigin})
537+ assert.Equal(t, http.StatusSeeOther, rec.Code)
538+}
539+
540+func TestIsPasskeyLoginPath(t *testing.T) {
541+ t.Parallel()
542+ assert.True(t, isPasskeyLoginPath("/-/passkeys/login/begin"))
543+ assert.True(t, isPasskeyLoginPath("/-/passkeys/login/finish"))
544+ for _, p := range []string{"", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/register/begin", "/-/passkeys/Login/begin", "/x/-/passkeys/login/begin"} {
545+ assert.False(t, isPasskeyLoginPath(p), p)
546+ }
547+}
M internal/adapters/web/session.go +12 -1
@@ -180,13 +180,24 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
180180 denyForbidden(w)
181181 return false
182182 }
183- if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) {
183+ if !SignedIn(r.Context()) && !s.anonymousPost(r) {
184184 denyForbidden(w)
185185 return false
186186 }
187187 return true
188188 }
189189
190+// anonymousPost tells whether a POST without a session may pass the gate: the
191+// requests that start a session. They are the confirmation of a login link and,
192+// when passkeys are on, the two endpoints of a passkey sign-in; each is matched
193+// exactly.
194+func (s *Server) anonymousPost(r *http.Request) bool {
195+ if r.Method != http.MethodPost {
196+ return false
197+ }
198+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
199+}
200+
190201 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
191202 // the right shape and nothing after it. It looks at the escaped path, which is
192203 // what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass
@@ -180,13 +180,24 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
180 denyForbidden(w)180 denyForbidden(w)
181 return false181 return false
182 }182 }
183- if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) {183+ if !SignedIn(r.Context()) && !s.anonymousPost(r) {
184 denyForbidden(w)184 denyForbidden(w)
185 return false185 return false
186 }186 }
187 return true187 return true
188 }188 }
189 189
190+// anonymousPost tells whether a POST without a session may pass the gate: the
191+// requests that start a session. They are the confirmation of a login link and,
192+// when passkeys are on, the two endpoints of a passkey sign-in; each is matched
193+// exactly.
194+func (s *Server) anonymousPost(r *http.Request) bool {
195+ if r.Method != http.MethodPost {
196+ return false
197+ }
198+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
199+}
200+
190 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of201 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
191 // the right shape and nothing after it. It looks at the escaped path, which is202 // the right shape and nothing after it. It looks at the escaped path, which is
192 // what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass203 // what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass
M internal/adapters/web/shell.templ +6 -0
@@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) {
2222 <script src={ l.Assets.SSE } defer></script>
2323 <script src={ l.Assets.App } defer></script>
2424 <script src={ l.Assets.Files } defer></script>
25+ <script src={ l.Assets.Passkey } defer></script>
2526 </head>
2627 <body
2728 data-ref={ l.RefPath }
@@ -123,8 +124,13 @@ templ Header(l Layout) {
123124 if SignedIn(ctx) {
124125 <form class="owner" method="post" action="/-/logout">
125126 <span class="owner-badge" title="signed in as the owner">owner</span>
127+ if l.Passkeys {
128+ <a class="btn" href="/-/passkeys" title="add or remove the passkeys that sign you in">Passkeys</a>
129+ }
126130 <button type="submit" class="btn" title="sign out of this browser">Sign out</button>
127131 </form>
132+ } else if l.Passkeys {
133+ <a class="btn" href="/-/login" title="sign in with a passkey">Sign in</a>
128134 }
129135 </div>
130136 </div>
@@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) {
22 <script src={ l.Assets.SSE } defer></script>22 <script src={ l.Assets.SSE } defer></script>
23 <script src={ l.Assets.App } defer></script>23 <script src={ l.Assets.App } defer></script>
24 <script src={ l.Assets.Files } defer></script>24 <script src={ l.Assets.Files } defer></script>
25+ <script src={ l.Assets.Passkey } defer></script>
25 </head>26 </head>
26 <body27 <body
27 data-ref={ l.RefPath }28 data-ref={ l.RefPath }
@@ -123,8 +124,13 @@ templ Header(l Layout) {
123 if SignedIn(ctx) {124 if SignedIn(ctx) {
124 <form class="owner" method="post" action="/-/logout">125 <form class="owner" method="post" action="/-/logout">
125 <span class="owner-badge" title="signed in as the owner">owner</span>126 <span class="owner-badge" title="signed in as the owner">owner</span>
127+ if l.Passkeys {
128+ <a class="btn" href="/-/passkeys" title="add or remove the passkeys that sign you in">Passkeys</a>
129+ }
126 <button type="submit" class="btn" title="sign out of this browser">Sign out</button>130 <button type="submit" class="btn" title="sign out of this browser">Sign out</button>
127 </form>131 </form>
132+ } else if l.Passkeys {
133+ <a class="btn" href="/-/login" title="sign in with a passkey">Sign in</a>
128 }134 }
129 </div>135 </div>
130 </div>136 </div>
M internal/adapters/web/shell_templ.go +170 -142
@@ -151,69 +151,82 @@ func Shell(l Layout, rail templ.Component) templ.Component {
151151 if templ_7745c5c3_Err != nil {
152152 return templ_7745c5c3_Err
153153 }
154- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" defer></script></head><body data-ref=\"")
154+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" defer></script><script src=\"")
155155 if templ_7745c5c3_Err != nil {
156156 return templ_7745c5c3_Err
157157 }
158158 var templ_7745c5c3_Var11 string
159- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)
159+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Passkey)
160160 if templ_7745c5c3_Err != nil {
161- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 27, Col: 23}
161+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 25, Col: 33}
162162 }
163163 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
164164 if templ_7745c5c3_Err != nil {
165165 return templ_7745c5c3_Err
166166 }
167- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-sha=\"")
167+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" defer></script></head><body data-ref=\"")
168168 if templ_7745c5c3_Err != nil {
169169 return templ_7745c5c3_Err
170170 }
171171 var templ_7745c5c3_Var12 string
172- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)
172+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)
173173 if templ_7745c5c3_Err != nil {
174- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 19}
174+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 23}
175175 }
176176 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
177177 if templ_7745c5c3_Err != nil {
178178 return templ_7745c5c3_Err
179179 }
180- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-sel=\"\" data-nav=\"\"")
180+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-sha=\"")
181+ if templ_7745c5c3_Err != nil {
182+ return templ_7745c5c3_Err
183+ }
184+ var templ_7745c5c3_Var13 string
185+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)
186+ if templ_7745c5c3_Err != nil {
187+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 29, Col: 19}
188+ }
189+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
190+ if templ_7745c5c3_Err != nil {
191+ return templ_7745c5c3_Err
192+ }
193+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" data-sel=\"\" data-nav=\"\"")
181194 if templ_7745c5c3_Err != nil {
182195 return templ_7745c5c3_Err
183196 }
184197 if l.PathsURL != "" {
185- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " data-paths=\"")
198+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " data-paths=\"")
186199 if templ_7745c5c3_Err != nil {
187200 return templ_7745c5c3_Err
188201 }
189- var templ_7745c5c3_Var13 string
190- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)
202+ var templ_7745c5c3_Var14 string
203+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)
191204 if templ_7745c5c3_Err != nil {
192- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 32, Col: 27}
205+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 27}
193206 }
194- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
207+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
195208 if templ_7745c5c3_Err != nil {
196209 return templ_7745c5c3_Err
197210 }
198- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-blob=\"")
211+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-blob=\"")
199212 if templ_7745c5c3_Err != nil {
200213 return templ_7745c5c3_Err
201214 }
202- var templ_7745c5c3_Var14 string
203- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)
215+ var templ_7745c5c3_Var15 string
216+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)
204217 if templ_7745c5c3_Err != nil {
205- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 26}
218+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 26}
206219 }
207- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
220+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
208221 if templ_7745c5c3_Err != nil {
209222 return templ_7745c5c3_Err
210223 }
211- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"")
224+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"")
212225 if templ_7745c5c3_Err != nil {
213226 return templ_7745c5c3_Err
214227 }
215228 }
216- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "><a class=\"skip\" href=\"#main\">skip to content</a>")
229+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "><a class=\"skip\" href=\"#main\">skip to content</a>")
217230 if templ_7745c5c3_Err != nil {
218231 return templ_7745c5c3_Err
219232 }
@@ -221,48 +234,48 @@ func Shell(l Layout, rail templ.Component) templ.Component {
221234 if templ_7745c5c3_Err != nil {
222235 return templ_7745c5c3_Err
223236 }
224- var templ_7745c5c3_Var15 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}
225- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
237+ var templ_7745c5c3_Var16 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}
238+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...)
226239 if templ_7745c5c3_Err != nil {
227240 return templ_7745c5c3_Err
228241 }
229- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"")
242+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"")
230243 if templ_7745c5c3_Err != nil {
231244 return templ_7745c5c3_Err
232245 }
233- var templ_7745c5c3_Var16 string
234- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String())
246+ var templ_7745c5c3_Var17 string
247+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String())
235248 if templ_7745c5c3_Err != nil {
236249 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0}
237250 }
238- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
251+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
239252 if templ_7745c5c3_Err != nil {
240253 return templ_7745c5c3_Err
241254 }
242- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
255+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">")
243256 if templ_7745c5c3_Err != nil {
244257 return templ_7745c5c3_Err
245258 }
246259 if l.Lede != "" {
247- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")
260+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")
248261 if templ_7745c5c3_Err != nil {
249262 return templ_7745c5c3_Err
250263 }
251- var templ_7745c5c3_Var17 string
252- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)
264+ var templ_7745c5c3_Var18 string
265+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)
253266 if templ_7745c5c3_Err != nil {
254- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 40, Col: 81}
267+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 41, Col: 81}
255268 }
256- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
269+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
257270 if templ_7745c5c3_Err != nil {
258271 return templ_7745c5c3_Err
259272 }
260- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p></section>")
273+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p></section>")
261274 if templ_7745c5c3_Err != nil {
262275 return templ_7745c5c3_Err
263276 }
264277 }
265- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<main id=\"main\" tabindex=\"-1\">")
278+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<main id=\"main\" tabindex=\"-1\">")
266279 if templ_7745c5c3_Err != nil {
267280 return templ_7745c5c3_Err
268281 }
@@ -270,12 +283,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
270283 if templ_7745c5c3_Err != nil {
271284 return templ_7745c5c3_Err
272285 }
273- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</main>")
286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</main>")
274287 if templ_7745c5c3_Err != nil {
275288 return templ_7745c5c3_Err
276289 }
277290 if l.HasRail && rail != nil {
278- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<aside class=\"rail\" aria-label=\"Context\">")
291+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<aside class=\"rail\" aria-label=\"Context\">")
279292 if templ_7745c5c3_Err != nil {
280293 return templ_7745c5c3_Err
281294 }
@@ -283,12 +296,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
283296 if templ_7745c5c3_Err != nil {
284297 return templ_7745c5c3_Err
285298 }
286- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</aside>")
299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</aside>")
287300 if templ_7745c5c3_Err != nil {
288301 return templ_7745c5c3_Err
289302 }
290303 }
291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>")
304+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div>")
292305 if templ_7745c5c3_Err != nil {
293306 return templ_7745c5c3_Err
294307 }
@@ -316,7 +329,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
316329 if templ_7745c5c3_Err != nil {
317330 return templ_7745c5c3_Err
318331 }
319- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</body></html>")
332+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</body></html>")
320333 if templ_7745c5c3_Err != nil {
321334 return templ_7745c5c3_Err
322335 }
@@ -342,273 +355,288 @@ func Header(l Layout) templ.Component {
342355 }()
343356 }
344357 ctx = templ.InitializeContext(ctx)
345- templ_7745c5c3_Var18 := templ.GetChildren(ctx)
346- if templ_7745c5c3_Var18 == nil {
347- templ_7745c5c3_Var18 = templ.NopComponent
358+ templ_7745c5c3_Var19 := templ.GetChildren(ctx)
359+ if templ_7745c5c3_Var19 == nil {
360+ templ_7745c5c3_Var19 = templ.NopComponent
348361 }
349362 ctx = templ.ClearChildren(ctx)
350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span> <span class=\"brand-name\">")
363+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span> <span class=\"brand-name\">")
351364 if templ_7745c5c3_Err != nil {
352365 return templ_7745c5c3_Err
353366 }
354- var templ_7745c5c3_Var19 string
355- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(l.Instance)
367+ var templ_7745c5c3_Var20 string
368+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(l.Instance)
356369 if templ_7745c5c3_Err != nil {
357- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 68, Col: 41}
370+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 69, Col: 41}
358371 }
359- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
372+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
360373 if templ_7745c5c3_Err != nil {
361374 return templ_7745c5c3_Err
362375 }
363- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span></a> ")
376+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span></a> ")
364377 if templ_7745c5c3_Err != nil {
365378 return templ_7745c5c3_Err
366379 }
367380 if len(l.Crumbs) > 0 {
368- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
381+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
369382 if templ_7745c5c3_Err != nil {
370383 return templ_7745c5c3_Err
371384 }
372385 for _, c := range l.Crumbs {
373- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li>")
386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<li>")
374387 if templ_7745c5c3_Err != nil {
375388 return templ_7745c5c3_Err
376389 }
377390 if c.Href != "" {
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a href=\"")
391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"")
379392 if templ_7745c5c3_Err != nil {
380393 return templ_7745c5c3_Err
381394 }
382- var templ_7745c5c3_Var20 templ.SafeURL
383- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
395+ var templ_7745c5c3_Var21 templ.SafeURL
396+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
384397 if templ_7745c5c3_Err != nil {
385- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 77, Col: 38}
398+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 78, Col: 38}
386399 }
387- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
400+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
388401 if templ_7745c5c3_Err != nil {
389402 return templ_7745c5c3_Err
390403 }
391- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"")
404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"")
392405 if templ_7745c5c3_Err != nil {
393406 return templ_7745c5c3_Err
394407 }
395408 if c.Current {
396- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " aria-current=\"page\"")
409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " aria-current=\"page\"")
397410 if templ_7745c5c3_Err != nil {
398411 return templ_7745c5c3_Err
399412 }
400413 }
401- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">")
414+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">")
402415 if templ_7745c5c3_Err != nil {
403416 return templ_7745c5c3_Err
404417 }
405- var templ_7745c5c3_Var21 string
406- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
418+ var templ_7745c5c3_Var22 string
419+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
407420 if templ_7745c5c3_Err != nil {
408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 81, Col: 19}
421+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 82, Col: 19}
409422 }
410- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
423+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
411424 if templ_7745c5c3_Err != nil {
412425 return templ_7745c5c3_Err
413426 }
414- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</a>")
427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</a>")
415428 if templ_7745c5c3_Err != nil {
416429 return templ_7745c5c3_Err
417430 }
418431 } else {
419- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<span")
432+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span")
420433 if templ_7745c5c3_Err != nil {
421434 return templ_7745c5c3_Err
422435 }
423436 if c.Current {
424- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " aria-current=\"page\"")
437+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " aria-current=\"page\"")
425438 if templ_7745c5c3_Err != nil {
426439 return templ_7745c5c3_Err
427440 }
428441 }
429- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">")
442+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">")
430443 if templ_7745c5c3_Err != nil {
431444 return templ_7745c5c3_Err
432445 }
433- var templ_7745c5c3_Var22 string
434- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
446+ var templ_7745c5c3_Var23 string
447+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
435448 if templ_7745c5c3_Err != nil {
436- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 87, Col: 19}
449+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 88, Col: 19}
437450 }
438- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
451+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
439452 if templ_7745c5c3_Err != nil {
440453 return templ_7745c5c3_Err
441454 }
442- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span>")
455+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span>")
443456 if templ_7745c5c3_Err != nil {
444457 return templ_7745c5c3_Err
445458 }
446459 }
447- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</li>")
460+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</li>")
448461 if templ_7745c5c3_Err != nil {
449462 return templ_7745c5c3_Err
450463 }
451464 }
452- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</ol></nav>")
465+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</ol></nav>")
453466 if templ_7745c5c3_Err != nil {
454467 return templ_7745c5c3_Err
455468 }
456469 }
457- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<div class=\"hdr-acts\">")
470+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"hdr-acts\">")
458471 if templ_7745c5c3_Err != nil {
459472 return templ_7745c5c3_Err
460473 }
461474 if l.Ref != "" {
462- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<a class=\"btn ref\" href=\"")
475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a class=\"btn ref\" href=\"")
463476 if templ_7745c5c3_Err != nil {
464477 return templ_7745c5c3_Err
465478 }
466- var templ_7745c5c3_Var23 templ.SafeURL
467- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
479+ var templ_7745c5c3_Var24 templ.SafeURL
480+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
468481 if templ_7745c5c3_Err != nil {
469- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 98, Col: 52}
482+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 99, Col: 52}
470483 }
471- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
484+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
472485 if templ_7745c5c3_Err != nil {
473486 return templ_7745c5c3_Err
474487 }
475- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
488+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
476489 if templ_7745c5c3_Err != nil {
477490 return templ_7745c5c3_Err
478491 }
479- var templ_7745c5c3_Var24 string
480- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
492+ var templ_7745c5c3_Var25 string
493+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
481494 if templ_7745c5c3_Err != nil {
482- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 104, Col: 36}
495+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 105, Col: 36}
483496 }
484- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
497+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
485498 if templ_7745c5c3_Err != nil {
486499 return templ_7745c5c3_Err
487500 }
488- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span> <span class=\"sha\">")
501+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span> <span class=\"sha\">")
489502 if templ_7745c5c3_Err != nil {
490503 return templ_7745c5c3_Err
491504 }
492- var templ_7745c5c3_Var25 string
493- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
505+ var templ_7745c5c3_Var26 string
506+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
494507 if templ_7745c5c3_Err != nil {
495- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 105, Col: 33}
508+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 106, Col: 33}
496509 }
497- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
510+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
498511 if templ_7745c5c3_Err != nil {
499512 return templ_7745c5c3_Err
500513 }
501- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span></a> ")
514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span></a> ")
502515 if templ_7745c5c3_Err != nil {
503516 return templ_7745c5c3_Err
504517 }
505518 }
506- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\" aria-label=\"Keyboard shortcuts\" title=\"keyboard shortcuts (?)\">?</button> ")
519+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\" aria-label=\"Keyboard shortcuts\" title=\"keyboard shortcuts (?)\">?</button> ")
507520 if templ_7745c5c3_Err != nil {
508521 return templ_7745c5c3_Err
509522 }
510523 if SignedIn(ctx) {
511- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\">owner</span> <button type=\"submit\" class=\"btn\" title=\"sign out of this browser\">Sign out</button></form>")
524+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\">owner</span> ")
525+ if templ_7745c5c3_Err != nil {
526+ return templ_7745c5c3_Err
527+ }
528+ if l.Passkeys {
529+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a class=\"btn\" href=\"/-/passkeys\" title=\"add or remove the passkeys that sign you in\">Passkeys</a> ")
530+ if templ_7745c5c3_Err != nil {
531+ return templ_7745c5c3_Err
532+ }
533+ }
534+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<button type=\"submit\" class=\"btn\" title=\"sign out of this browser\">Sign out</button></form>")
535+ if templ_7745c5c3_Err != nil {
536+ return templ_7745c5c3_Err
537+ }
538+ } else if l.Passkeys {
539+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<a class=\"btn\" href=\"/-/login\" title=\"sign in with a passkey\">Sign in</a>")
512540 if templ_7745c5c3_Err != nil {
513541 return templ_7745c5c3_Err
514542 }
515543 }
516- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div></div>")
544+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div></div>")
517545 if templ_7745c5c3_Err != nil {
518546 return templ_7745c5c3_Err
519547 }
520548 if len(l.Sections) > 0 {
521- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
549+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
522550 if templ_7745c5c3_Err != nil {
523551 return templ_7745c5c3_Err
524552 }
525553 for _, s := range l.Sections {
526- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<li><a href=\"")
554+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<li><a href=\"")
527555 if templ_7745c5c3_Err != nil {
528556 return templ_7745c5c3_Err
529557 }
530- var templ_7745c5c3_Var26 templ.SafeURL
531- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
558+ var templ_7745c5c3_Var27 templ.SafeURL
559+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
532560 if templ_7745c5c3_Err != nil {
533- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 36}
561+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 36}
534562 }
535- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
563+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
536564 if templ_7745c5c3_Err != nil {
537565 return templ_7745c5c3_Err
538566 }
539- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" data-key=\"")
567+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" data-key=\"")
540568 if templ_7745c5c3_Err != nil {
541569 return templ_7745c5c3_Err
542570 }
543- var templ_7745c5c3_Var27 string
544- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
571+ var templ_7745c5c3_Var28 string
572+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
545573 if templ_7745c5c3_Err != nil {
546- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 138, Col: 24}
574+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 144, Col: 24}
547575 }
548- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
576+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
549577 if templ_7745c5c3_Err != nil {
550578 return templ_7745c5c3_Err
551579 }
552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" aria-keyshortcuts=\"")
580+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" aria-keyshortcuts=\"")
553581 if templ_7745c5c3_Err != nil {
554582 return templ_7745c5c3_Err
555583 }
556- var templ_7745c5c3_Var28 string
557- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
584+ var templ_7745c5c3_Var29 string
585+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
558586 if templ_7745c5c3_Err != nil {
559- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 139, Col: 33}
587+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 145, Col: 33}
560588 }
561- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
589+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
562590 if templ_7745c5c3_Err != nil {
563591 return templ_7745c5c3_Err
564592 }
565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"")
593+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"")
566594 if templ_7745c5c3_Err != nil {
567595 return templ_7745c5c3_Err
568596 }
569597 if s.Current {
570- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " aria-current=\"page\"")
598+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " aria-current=\"page\"")
571599 if templ_7745c5c3_Err != nil {
572600 return templ_7745c5c3_Err
573601 }
574602 }
575- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">")
603+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, ">")
576604 if templ_7745c5c3_Err != nil {
577605 return templ_7745c5c3_Err
578606 }
579- var templ_7745c5c3_Var29 string
580- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
607+ var templ_7745c5c3_Var30 string
608+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
581609 if templ_7745c5c3_Err != nil {
582- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 17}
610+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 149, Col: 17}
583611 }
584- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
612+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
585613 if templ_7745c5c3_Err != nil {
586614 return templ_7745c5c3_Err
587615 }
588- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<kbd aria-hidden=\"true\">")
616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<kbd aria-hidden=\"true\">")
589617 if templ_7745c5c3_Err != nil {
590618 return templ_7745c5c3_Err
591619 }
592- var templ_7745c5c3_Var30 string
593- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
620+ var templ_7745c5c3_Var31 string
621+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
594622 if templ_7745c5c3_Err != nil {
595- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 50}
623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 149, Col: 50}
596624 }
597- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
625+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
598626 if templ_7745c5c3_Err != nil {
599627 return templ_7745c5c3_Err
600628 }
601- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</kbd></a></li>")
629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</kbd></a></li>")
602630 if templ_7745c5c3_Err != nil {
603631 return templ_7745c5c3_Err
604632 }
605633 }
606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</ul></nav>")
634+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</ul></nav>")
607635 if templ_7745c5c3_Err != nil {
608636 return templ_7745c5c3_Err
609637 }
610638 }
611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</header>")
639+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</header>")
612640 if templ_7745c5c3_Err != nil {
613641 return templ_7745c5c3_Err
614642 }
@@ -634,38 +662,38 @@ func Footer(l Layout) templ.Component {
634662 }()
635663 }
636664 ctx = templ.InitializeContext(ctx)
637- templ_7745c5c3_Var31 := templ.GetChildren(ctx)
638- if templ_7745c5c3_Var31 == nil {
639- templ_7745c5c3_Var31 = templ.NopComponent
665+ templ_7745c5c3_Var32 := templ.GetChildren(ctx)
666+ if templ_7745c5c3_Var32 == nil {
667+ templ_7745c5c3_Var32 = templ.NopComponent
640668 }
641669 ctx = templ.ClearChildren(ctx)
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<footer class=\"foot\"><span>ermite ")
670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<footer class=\"foot\"><span>ermite ")
643671 if templ_7745c5c3_Err != nil {
644672 return templ_7745c5c3_Err
645673 }
646- var templ_7745c5c3_Var32 string
647- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
674+ var templ_7745c5c3_Var33 string
675+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
648676 if templ_7745c5c3_Err != nil {
649- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 156, Col: 26}
677+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 162, Col: 26}
650678 }
651- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
679+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
652680 if templ_7745c5c3_Err != nil {
653681 return templ_7745c5c3_Err
654682 }
655- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span> <a href=\"")
683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span> <a href=\"")
656684 if templ_7745c5c3_Err != nil {
657685 return templ_7745c5c3_Err
658686 }
659- var templ_7745c5c3_Var33 templ.SafeURL
660- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
687+ var templ_7745c5c3_Var34 templ.SafeURL
688+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
661689 if templ_7745c5c3_Err != nil {
662- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 38}
690+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 163, Col: 38}
663691 }
664- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
692+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
665693 if templ_7745c5c3_Err != nil {
666694 return templ_7745c5c3_Err
667695 }
668- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <span class=\"foot-keys\"><kbd>?</kbd> keyboard shortcuts</span></footer>")
696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <span class=\"foot-keys\"><kbd>?</kbd> keyboard shortcuts</span></footer>")
669697 if templ_7745c5c3_Err != nil {
670698 return templ_7745c5c3_Err
671699 }
@@ -693,12 +721,12 @@ func Hint(l Layout) templ.Component {
693721 }()
694722 }
695723 ctx = templ.InitializeContext(ctx)
696- templ_7745c5c3_Var34 := templ.GetChildren(ctx)
697- if templ_7745c5c3_Var34 == nil {
698- templ_7745c5c3_Var34 = templ.NopComponent
724+ templ_7745c5c3_Var35 := templ.GetChildren(ctx)
725+ if templ_7745c5c3_Var35 == nil {
726+ templ_7745c5c3_Var35 = templ.NopComponent
699727 }
700728 ctx = templ.ClearChildren(ctx)
701- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
729+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
702730 if templ_7745c5c3_Err != nil {
703731 return templ_7745c5c3_Err
704732 }
@@ -706,7 +734,7 @@ func Hint(l Layout) templ.Component {
706734 if templ_7745c5c3_Err != nil {
707735 return templ_7745c5c3_Err
708736 }
709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</div>")
737+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div>")
710738 if templ_7745c5c3_Err != nil {
711739 return templ_7745c5c3_Err
712740 }
@@ -151,69 +151,82 @@ func Shell(l Layout, rail templ.Component) templ.Component {
151 if templ_7745c5c3_Err != nil {151 if templ_7745c5c3_Err != nil {
152 return templ_7745c5c3_Err152 return templ_7745c5c3_Err
153 }153 }
154- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" defer></script></head><body data-ref=\"")154+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" defer></script><script src=\"")
155 if templ_7745c5c3_Err != nil {155 if templ_7745c5c3_Err != nil {
156 return templ_7745c5c3_Err156 return templ_7745c5c3_Err
157 }157 }
158 var templ_7745c5c3_Var11 string158 var templ_7745c5c3_Var11 string
159- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)159+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Passkey)
160 if templ_7745c5c3_Err != nil {160 if templ_7745c5c3_Err != nil {
161- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 27, Col: 23}161+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 25, Col: 33}
162 }162 }
163 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)163 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
164 if templ_7745c5c3_Err != nil {164 if templ_7745c5c3_Err != nil {
165 return templ_7745c5c3_Err165 return templ_7745c5c3_Err
166 }166 }
167- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-sha=\"")167+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" defer></script></head><body data-ref=\"")
168 if templ_7745c5c3_Err != nil {168 if templ_7745c5c3_Err != nil {
169 return templ_7745c5c3_Err169 return templ_7745c5c3_Err
170 }170 }
171 var templ_7745c5c3_Var12 string171 var templ_7745c5c3_Var12 string
172- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)172+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)
173 if templ_7745c5c3_Err != nil {173 if templ_7745c5c3_Err != nil {
174- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 19}174+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 23}
175 }175 }
176 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)176 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
177 if templ_7745c5c3_Err != nil {177 if templ_7745c5c3_Err != nil {
178 return templ_7745c5c3_Err178 return templ_7745c5c3_Err
179 }179 }
180- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-sel=\"\" data-nav=\"\"")180+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-sha=\"")
181+ if templ_7745c5c3_Err != nil {
182+ return templ_7745c5c3_Err
183+ }
184+ var templ_7745c5c3_Var13 string
185+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)
186+ if templ_7745c5c3_Err != nil {
187+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 29, Col: 19}
188+ }
189+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
190+ if templ_7745c5c3_Err != nil {
191+ return templ_7745c5c3_Err
192+ }
193+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" data-sel=\"\" data-nav=\"\"")
181 if templ_7745c5c3_Err != nil {194 if templ_7745c5c3_Err != nil {
182 return templ_7745c5c3_Err195 return templ_7745c5c3_Err
183 }196 }
184 if l.PathsURL != "" {197 if l.PathsURL != "" {
185- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " data-paths=\"")198+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " data-paths=\"")
186 if templ_7745c5c3_Err != nil {199 if templ_7745c5c3_Err != nil {
187 return templ_7745c5c3_Err200 return templ_7745c5c3_Err
188 }201 }
189- var templ_7745c5c3_Var13 string202+ var templ_7745c5c3_Var14 string
190- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)203+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)
191 if templ_7745c5c3_Err != nil {204 if templ_7745c5c3_Err != nil {
192- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 32, Col: 27}205+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 27}
193 }206 }
194- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)207+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
195 if templ_7745c5c3_Err != nil {208 if templ_7745c5c3_Err != nil {
196 return templ_7745c5c3_Err209 return templ_7745c5c3_Err
197 }210 }
198- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-blob=\"")211+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-blob=\"")
199 if templ_7745c5c3_Err != nil {212 if templ_7745c5c3_Err != nil {
200 return templ_7745c5c3_Err213 return templ_7745c5c3_Err
201 }214 }
202- var templ_7745c5c3_Var14 string215+ var templ_7745c5c3_Var15 string
203- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)216+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)
204 if templ_7745c5c3_Err != nil {217 if templ_7745c5c3_Err != nil {
205- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 26}218+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 26}
206 }219 }
207- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)220+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
208 if templ_7745c5c3_Err != nil {221 if templ_7745c5c3_Err != nil {
209 return templ_7745c5c3_Err222 return templ_7745c5c3_Err
210 }223 }
211- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"")224+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"")
212 if templ_7745c5c3_Err != nil {225 if templ_7745c5c3_Err != nil {
213 return templ_7745c5c3_Err226 return templ_7745c5c3_Err
214 }227 }
215 }228 }
216- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "><a class=\"skip\" href=\"#main\">skip to content</a>")229+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "><a class=\"skip\" href=\"#main\">skip to content</a>")
217 if templ_7745c5c3_Err != nil {230 if templ_7745c5c3_Err != nil {
218 return templ_7745c5c3_Err231 return templ_7745c5c3_Err
219 }232 }
@@ -221,48 +234,48 @@ func Shell(l Layout, rail templ.Component) templ.Component {
221 if templ_7745c5c3_Err != nil {234 if templ_7745c5c3_Err != nil {
222 return templ_7745c5c3_Err235 return templ_7745c5c3_Err
223 }236 }
224- var templ_7745c5c3_Var15 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}237+ var templ_7745c5c3_Var16 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}
225- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)238+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...)
226 if templ_7745c5c3_Err != nil {239 if templ_7745c5c3_Err != nil {
227 return templ_7745c5c3_Err240 return templ_7745c5c3_Err
228 }241 }
229- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"")242+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"")
230 if templ_7745c5c3_Err != nil {243 if templ_7745c5c3_Err != nil {
231 return templ_7745c5c3_Err244 return templ_7745c5c3_Err
232 }245 }
233- var templ_7745c5c3_Var16 string246+ var templ_7745c5c3_Var17 string
234- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String())247+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String())
235 if templ_7745c5c3_Err != nil {248 if templ_7745c5c3_Err != nil {
236 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0}249 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0}
237 }250 }
238- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)251+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
239 if templ_7745c5c3_Err != nil {252 if templ_7745c5c3_Err != nil {
240 return templ_7745c5c3_Err253 return templ_7745c5c3_Err
241 }254 }
242- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")255+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">")
243 if templ_7745c5c3_Err != nil {256 if templ_7745c5c3_Err != nil {
244 return templ_7745c5c3_Err257 return templ_7745c5c3_Err
245 }258 }
246 if l.Lede != "" {259 if l.Lede != "" {
247- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")260+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")
248 if templ_7745c5c3_Err != nil {261 if templ_7745c5c3_Err != nil {
249 return templ_7745c5c3_Err262 return templ_7745c5c3_Err
250 }263 }
251- var templ_7745c5c3_Var17 string264+ var templ_7745c5c3_Var18 string
252- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)265+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)
253 if templ_7745c5c3_Err != nil {266 if templ_7745c5c3_Err != nil {
254- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 40, Col: 81}267+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 41, Col: 81}
255 }268 }
256- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))269+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
257 if templ_7745c5c3_Err != nil {270 if templ_7745c5c3_Err != nil {
258 return templ_7745c5c3_Err271 return templ_7745c5c3_Err
259 }272 }
260- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p></section>")273+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p></section>")
261 if templ_7745c5c3_Err != nil {274 if templ_7745c5c3_Err != nil {
262 return templ_7745c5c3_Err275 return templ_7745c5c3_Err
263 }276 }
264 }277 }
265- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<main id=\"main\" tabindex=\"-1\">")278+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<main id=\"main\" tabindex=\"-1\">")
266 if templ_7745c5c3_Err != nil {279 if templ_7745c5c3_Err != nil {
267 return templ_7745c5c3_Err280 return templ_7745c5c3_Err
268 }281 }
@@ -270,12 +283,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
270 if templ_7745c5c3_Err != nil {283 if templ_7745c5c3_Err != nil {
271 return templ_7745c5c3_Err284 return templ_7745c5c3_Err
272 }285 }
273- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</main>")286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</main>")
274 if templ_7745c5c3_Err != nil {287 if templ_7745c5c3_Err != nil {
275 return templ_7745c5c3_Err288 return templ_7745c5c3_Err
276 }289 }
277 if l.HasRail && rail != nil {290 if l.HasRail && rail != nil {
278- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<aside class=\"rail\" aria-label=\"Context\">")291+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<aside class=\"rail\" aria-label=\"Context\">")
279 if templ_7745c5c3_Err != nil {292 if templ_7745c5c3_Err != nil {
280 return templ_7745c5c3_Err293 return templ_7745c5c3_Err
281 }294 }
@@ -283,12 +296,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
283 if templ_7745c5c3_Err != nil {296 if templ_7745c5c3_Err != nil {
284 return templ_7745c5c3_Err297 return templ_7745c5c3_Err
285 }298 }
286- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</aside>")299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</aside>")
287 if templ_7745c5c3_Err != nil {300 if templ_7745c5c3_Err != nil {
288 return templ_7745c5c3_Err301 return templ_7745c5c3_Err
289 }302 }
290 }303 }
291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>")304+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div>")
292 if templ_7745c5c3_Err != nil {305 if templ_7745c5c3_Err != nil {
293 return templ_7745c5c3_Err306 return templ_7745c5c3_Err
294 }307 }
@@ -316,7 +329,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
316 if templ_7745c5c3_Err != nil {329 if templ_7745c5c3_Err != nil {
317 return templ_7745c5c3_Err330 return templ_7745c5c3_Err
318 }331 }
319- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</body></html>")332+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</body></html>")
320 if templ_7745c5c3_Err != nil {333 if templ_7745c5c3_Err != nil {
321 return templ_7745c5c3_Err334 return templ_7745c5c3_Err
322 }335 }
@@ -342,273 +355,288 @@ func Header(l Layout) templ.Component {
342 }()355 }()
343 }356 }
344 ctx = templ.InitializeContext(ctx)357 ctx = templ.InitializeContext(ctx)
345- templ_7745c5c3_Var18 := templ.GetChildren(ctx)358+ templ_7745c5c3_Var19 := templ.GetChildren(ctx)
346- if templ_7745c5c3_Var18 == nil {359+ if templ_7745c5c3_Var19 == nil {
347- templ_7745c5c3_Var18 = templ.NopComponent360+ templ_7745c5c3_Var19 = templ.NopComponent
348 }361 }
349 ctx = templ.ClearChildren(ctx)362 ctx = templ.ClearChildren(ctx)
350- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span> <span class=\"brand-name\">")363+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span> <span class=\"brand-name\">")
351 if templ_7745c5c3_Err != nil {364 if templ_7745c5c3_Err != nil {
352 return templ_7745c5c3_Err365 return templ_7745c5c3_Err
353 }366 }
354- var templ_7745c5c3_Var19 string367+ var templ_7745c5c3_Var20 string
355- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(l.Instance)368+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(l.Instance)
356 if templ_7745c5c3_Err != nil {369 if templ_7745c5c3_Err != nil {
357- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 68, Col: 41}370+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 69, Col: 41}
358 }371 }
359- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))372+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
360 if templ_7745c5c3_Err != nil {373 if templ_7745c5c3_Err != nil {
361 return templ_7745c5c3_Err374 return templ_7745c5c3_Err
362 }375 }
363- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span></a> ")376+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span></a> ")
364 if templ_7745c5c3_Err != nil {377 if templ_7745c5c3_Err != nil {
365 return templ_7745c5c3_Err378 return templ_7745c5c3_Err
366 }379 }
367 if len(l.Crumbs) > 0 {380 if len(l.Crumbs) > 0 {
368- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")381+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
369 if templ_7745c5c3_Err != nil {382 if templ_7745c5c3_Err != nil {
370 return templ_7745c5c3_Err383 return templ_7745c5c3_Err
371 }384 }
372 for _, c := range l.Crumbs {385 for _, c := range l.Crumbs {
373- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li>")386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<li>")
374 if templ_7745c5c3_Err != nil {387 if templ_7745c5c3_Err != nil {
375 return templ_7745c5c3_Err388 return templ_7745c5c3_Err
376 }389 }
377 if c.Href != "" {390 if c.Href != "" {
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a href=\"")391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"")
379 if templ_7745c5c3_Err != nil {392 if templ_7745c5c3_Err != nil {
380 return templ_7745c5c3_Err393 return templ_7745c5c3_Err
381 }394 }
382- var templ_7745c5c3_Var20 templ.SafeURL395+ var templ_7745c5c3_Var21 templ.SafeURL
383- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))396+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
384 if templ_7745c5c3_Err != nil {397 if templ_7745c5c3_Err != nil {
385- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 77, Col: 38}398+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 78, Col: 38}
386 }399 }
387- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))400+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
388 if templ_7745c5c3_Err != nil {401 if templ_7745c5c3_Err != nil {
389 return templ_7745c5c3_Err402 return templ_7745c5c3_Err
390 }403 }
391- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"")404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"")
392 if templ_7745c5c3_Err != nil {405 if templ_7745c5c3_Err != nil {
393 return templ_7745c5c3_Err406 return templ_7745c5c3_Err
394 }407 }
395 if c.Current {408 if c.Current {
396- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " aria-current=\"page\"")409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " aria-current=\"page\"")
397 if templ_7745c5c3_Err != nil {410 if templ_7745c5c3_Err != nil {
398 return templ_7745c5c3_Err411 return templ_7745c5c3_Err
399 }412 }
400 }413 }
401- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">")414+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">")
402 if templ_7745c5c3_Err != nil {415 if templ_7745c5c3_Err != nil {
403 return templ_7745c5c3_Err416 return templ_7745c5c3_Err
404 }417 }
405- var templ_7745c5c3_Var21 string418+ var templ_7745c5c3_Var22 string
406- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)419+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
407 if templ_7745c5c3_Err != nil {420 if templ_7745c5c3_Err != nil {
408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 81, Col: 19}421+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 82, Col: 19}
409 }422 }
410- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))423+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
411 if templ_7745c5c3_Err != nil {424 if templ_7745c5c3_Err != nil {
412 return templ_7745c5c3_Err425 return templ_7745c5c3_Err
413 }426 }
414- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</a>")427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</a>")
415 if templ_7745c5c3_Err != nil {428 if templ_7745c5c3_Err != nil {
416 return templ_7745c5c3_Err429 return templ_7745c5c3_Err
417 }430 }
418 } else {431 } else {
419- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<span")432+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span")
420 if templ_7745c5c3_Err != nil {433 if templ_7745c5c3_Err != nil {
421 return templ_7745c5c3_Err434 return templ_7745c5c3_Err
422 }435 }
423 if c.Current {436 if c.Current {
424- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " aria-current=\"page\"")437+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " aria-current=\"page\"")
425 if templ_7745c5c3_Err != nil {438 if templ_7745c5c3_Err != nil {
426 return templ_7745c5c3_Err439 return templ_7745c5c3_Err
427 }440 }
428 }441 }
429- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">")442+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">")
430 if templ_7745c5c3_Err != nil {443 if templ_7745c5c3_Err != nil {
431 return templ_7745c5c3_Err444 return templ_7745c5c3_Err
432 }445 }
433- var templ_7745c5c3_Var22 string446+ var templ_7745c5c3_Var23 string
434- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)447+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
435 if templ_7745c5c3_Err != nil {448 if templ_7745c5c3_Err != nil {
436- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 87, Col: 19}449+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 88, Col: 19}
437 }450 }
438- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))451+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
439 if templ_7745c5c3_Err != nil {452 if templ_7745c5c3_Err != nil {
440 return templ_7745c5c3_Err453 return templ_7745c5c3_Err
441 }454 }
442- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span>")455+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span>")
443 if templ_7745c5c3_Err != nil {456 if templ_7745c5c3_Err != nil {
444 return templ_7745c5c3_Err457 return templ_7745c5c3_Err
445 }458 }
446 }459 }
447- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</li>")460+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</li>")
448 if templ_7745c5c3_Err != nil {461 if templ_7745c5c3_Err != nil {
449 return templ_7745c5c3_Err462 return templ_7745c5c3_Err
450 }463 }
451 }464 }
452- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</ol></nav>")465+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</ol></nav>")
453 if templ_7745c5c3_Err != nil {466 if templ_7745c5c3_Err != nil {
454 return templ_7745c5c3_Err467 return templ_7745c5c3_Err
455 }468 }
456 }469 }
457- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<div class=\"hdr-acts\">")470+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"hdr-acts\">")
458 if templ_7745c5c3_Err != nil {471 if templ_7745c5c3_Err != nil {
459 return templ_7745c5c3_Err472 return templ_7745c5c3_Err
460 }473 }
461 if l.Ref != "" {474 if l.Ref != "" {
462- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<a class=\"btn ref\" href=\"")475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a class=\"btn ref\" href=\"")
463 if templ_7745c5c3_Err != nil {476 if templ_7745c5c3_Err != nil {
464 return templ_7745c5c3_Err477 return templ_7745c5c3_Err
465 }478 }
466- var templ_7745c5c3_Var23 templ.SafeURL479+ var templ_7745c5c3_Var24 templ.SafeURL
467- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))480+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
468 if templ_7745c5c3_Err != nil {481 if templ_7745c5c3_Err != nil {
469- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 98, Col: 52}482+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 99, Col: 52}
470 }483 }
471- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))484+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
472 if templ_7745c5c3_Err != nil {485 if templ_7745c5c3_Err != nil {
473 return templ_7745c5c3_Err486 return templ_7745c5c3_Err
474 }487 }
475- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")488+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
476 if templ_7745c5c3_Err != nil {489 if templ_7745c5c3_Err != nil {
477 return templ_7745c5c3_Err490 return templ_7745c5c3_Err
478 }491 }
479- var templ_7745c5c3_Var24 string492+ var templ_7745c5c3_Var25 string
480- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)493+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
481 if templ_7745c5c3_Err != nil {494 if templ_7745c5c3_Err != nil {
482- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 104, Col: 36}495+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 105, Col: 36}
483 }496 }
484- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))497+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
485 if templ_7745c5c3_Err != nil {498 if templ_7745c5c3_Err != nil {
486 return templ_7745c5c3_Err499 return templ_7745c5c3_Err
487 }500 }
488- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span> <span class=\"sha\">")501+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span> <span class=\"sha\">")
489 if templ_7745c5c3_Err != nil {502 if templ_7745c5c3_Err != nil {
490 return templ_7745c5c3_Err503 return templ_7745c5c3_Err
491 }504 }
492- var templ_7745c5c3_Var25 string505+ var templ_7745c5c3_Var26 string
493- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)506+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
494 if templ_7745c5c3_Err != nil {507 if templ_7745c5c3_Err != nil {
495- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 105, Col: 33}508+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 106, Col: 33}
496 }509 }
497- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))510+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
498 if templ_7745c5c3_Err != nil {511 if templ_7745c5c3_Err != nil {
499 return templ_7745c5c3_Err512 return templ_7745c5c3_Err
500 }513 }
501- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span></a> ")514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span></a> ")
502 if templ_7745c5c3_Err != nil {515 if templ_7745c5c3_Err != nil {
503 return templ_7745c5c3_Err516 return templ_7745c5c3_Err
504 }517 }
505 }518 }
506- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\" aria-label=\"Keyboard shortcuts\" title=\"keyboard shortcuts (?)\">?</button> ")519+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\" aria-label=\"Keyboard shortcuts\" title=\"keyboard shortcuts (?)\">?</button> ")
507 if templ_7745c5c3_Err != nil {520 if templ_7745c5c3_Err != nil {
508 return templ_7745c5c3_Err521 return templ_7745c5c3_Err
509 }522 }
510 if SignedIn(ctx) {523 if SignedIn(ctx) {
511- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\">owner</span> <button type=\"submit\" class=\"btn\" title=\"sign out of this browser\">Sign out</button></form>")524+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\">owner</span> ")
525+ if templ_7745c5c3_Err != nil {
526+ return templ_7745c5c3_Err
527+ }
528+ if l.Passkeys {
529+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a class=\"btn\" href=\"/-/passkeys\" title=\"add or remove the passkeys that sign you in\">Passkeys</a> ")
530+ if templ_7745c5c3_Err != nil {
531+ return templ_7745c5c3_Err
532+ }
533+ }
534+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<button type=\"submit\" class=\"btn\" title=\"sign out of this browser\">Sign out</button></form>")
535+ if templ_7745c5c3_Err != nil {
536+ return templ_7745c5c3_Err
537+ }
538+ } else if l.Passkeys {
539+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<a class=\"btn\" href=\"/-/login\" title=\"sign in with a passkey\">Sign in</a>")
512 if templ_7745c5c3_Err != nil {540 if templ_7745c5c3_Err != nil {
513 return templ_7745c5c3_Err541 return templ_7745c5c3_Err
514 }542 }
515 }543 }
516- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div></div>")544+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div></div>")
517 if templ_7745c5c3_Err != nil {545 if templ_7745c5c3_Err != nil {
518 return templ_7745c5c3_Err546 return templ_7745c5c3_Err
519 }547 }
520 if len(l.Sections) > 0 {548 if len(l.Sections) > 0 {
521- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")549+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
522 if templ_7745c5c3_Err != nil {550 if templ_7745c5c3_Err != nil {
523 return templ_7745c5c3_Err551 return templ_7745c5c3_Err
524 }552 }
525 for _, s := range l.Sections {553 for _, s := range l.Sections {
526- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<li><a href=\"")554+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<li><a href=\"")
527 if templ_7745c5c3_Err != nil {555 if templ_7745c5c3_Err != nil {
528 return templ_7745c5c3_Err556 return templ_7745c5c3_Err
529 }557 }
530- var templ_7745c5c3_Var26 templ.SafeURL558+ var templ_7745c5c3_Var27 templ.SafeURL
531- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))559+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
532 if templ_7745c5c3_Err != nil {560 if templ_7745c5c3_Err != nil {
533- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 36}561+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 36}
534 }562 }
535- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))563+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
536 if templ_7745c5c3_Err != nil {564 if templ_7745c5c3_Err != nil {
537 return templ_7745c5c3_Err565 return templ_7745c5c3_Err
538 }566 }
539- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" data-key=\"")567+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" data-key=\"")
540 if templ_7745c5c3_Err != nil {568 if templ_7745c5c3_Err != nil {
541 return templ_7745c5c3_Err569 return templ_7745c5c3_Err
542 }570 }
543- var templ_7745c5c3_Var27 string571+ var templ_7745c5c3_Var28 string
544- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)572+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
545 if templ_7745c5c3_Err != nil {573 if templ_7745c5c3_Err != nil {
546- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 138, Col: 24}574+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 144, Col: 24}
547 }575 }
548- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)576+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
549 if templ_7745c5c3_Err != nil {577 if templ_7745c5c3_Err != nil {
550 return templ_7745c5c3_Err578 return templ_7745c5c3_Err
551 }579 }
552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" aria-keyshortcuts=\"")580+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" aria-keyshortcuts=\"")
553 if templ_7745c5c3_Err != nil {581 if templ_7745c5c3_Err != nil {
554 return templ_7745c5c3_Err582 return templ_7745c5c3_Err
555 }583 }
556- var templ_7745c5c3_Var28 string584+ var templ_7745c5c3_Var29 string
557- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)585+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
558 if templ_7745c5c3_Err != nil {586 if templ_7745c5c3_Err != nil {
559- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 139, Col: 33}587+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 145, Col: 33}
560 }588 }
561- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)589+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
562 if templ_7745c5c3_Err != nil {590 if templ_7745c5c3_Err != nil {
563 return templ_7745c5c3_Err591 return templ_7745c5c3_Err
564 }592 }
565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"")593+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"")
566 if templ_7745c5c3_Err != nil {594 if templ_7745c5c3_Err != nil {
567 return templ_7745c5c3_Err595 return templ_7745c5c3_Err
568 }596 }
569 if s.Current {597 if s.Current {
570- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " aria-current=\"page\"")598+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " aria-current=\"page\"")
571 if templ_7745c5c3_Err != nil {599 if templ_7745c5c3_Err != nil {
572 return templ_7745c5c3_Err600 return templ_7745c5c3_Err
573 }601 }
574 }602 }
575- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">")603+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, ">")
576 if templ_7745c5c3_Err != nil {604 if templ_7745c5c3_Err != nil {
577 return templ_7745c5c3_Err605 return templ_7745c5c3_Err
578 }606 }
579- var templ_7745c5c3_Var29 string607+ var templ_7745c5c3_Var30 string
580- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)608+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
581 if templ_7745c5c3_Err != nil {609 if templ_7745c5c3_Err != nil {
582- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 17}610+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 149, Col: 17}
583 }611 }
584- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))612+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
585 if templ_7745c5c3_Err != nil {613 if templ_7745c5c3_Err != nil {
586 return templ_7745c5c3_Err614 return templ_7745c5c3_Err
587 }615 }
588- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<kbd aria-hidden=\"true\">")616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<kbd aria-hidden=\"true\">")
589 if templ_7745c5c3_Err != nil {617 if templ_7745c5c3_Err != nil {
590 return templ_7745c5c3_Err618 return templ_7745c5c3_Err
591 }619 }
592- var templ_7745c5c3_Var30 string620+ var templ_7745c5c3_Var31 string
593- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)621+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
594 if templ_7745c5c3_Err != nil {622 if templ_7745c5c3_Err != nil {
595- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 50}623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 149, Col: 50}
596 }624 }
597- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))625+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
598 if templ_7745c5c3_Err != nil {626 if templ_7745c5c3_Err != nil {
599 return templ_7745c5c3_Err627 return templ_7745c5c3_Err
600 }628 }
601- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</kbd></a></li>")629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</kbd></a></li>")
602 if templ_7745c5c3_Err != nil {630 if templ_7745c5c3_Err != nil {
603 return templ_7745c5c3_Err631 return templ_7745c5c3_Err
604 }632 }
605 }633 }
606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</ul></nav>")634+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</ul></nav>")
607 if templ_7745c5c3_Err != nil {635 if templ_7745c5c3_Err != nil {
608 return templ_7745c5c3_Err636 return templ_7745c5c3_Err
609 }637 }
610 }638 }
611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</header>")639+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</header>")
612 if templ_7745c5c3_Err != nil {640 if templ_7745c5c3_Err != nil {
613 return templ_7745c5c3_Err641 return templ_7745c5c3_Err
614 }642 }
@@ -634,38 +662,38 @@ func Footer(l Layout) templ.Component {
634 }()662 }()
635 }663 }
636 ctx = templ.InitializeContext(ctx)664 ctx = templ.InitializeContext(ctx)
637- templ_7745c5c3_Var31 := templ.GetChildren(ctx)665+ templ_7745c5c3_Var32 := templ.GetChildren(ctx)
638- if templ_7745c5c3_Var31 == nil {666+ if templ_7745c5c3_Var32 == nil {
639- templ_7745c5c3_Var31 = templ.NopComponent667+ templ_7745c5c3_Var32 = templ.NopComponent
640 }668 }
641 ctx = templ.ClearChildren(ctx)669 ctx = templ.ClearChildren(ctx)
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<footer class=\"foot\"><span>ermite ")670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<footer class=\"foot\"><span>ermite ")
643 if templ_7745c5c3_Err != nil {671 if templ_7745c5c3_Err != nil {
644 return templ_7745c5c3_Err672 return templ_7745c5c3_Err
645 }673 }
646- var templ_7745c5c3_Var32 string674+ var templ_7745c5c3_Var33 string
647- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)675+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
648 if templ_7745c5c3_Err != nil {676 if templ_7745c5c3_Err != nil {
649- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 156, Col: 26}677+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 162, Col: 26}
650 }678 }
651- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))679+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
652 if templ_7745c5c3_Err != nil {680 if templ_7745c5c3_Err != nil {
653 return templ_7745c5c3_Err681 return templ_7745c5c3_Err
654 }682 }
655- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span> <a href=\"")683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span> <a href=\"")
656 if templ_7745c5c3_Err != nil {684 if templ_7745c5c3_Err != nil {
657 return templ_7745c5c3_Err685 return templ_7745c5c3_Err
658 }686 }
659- var templ_7745c5c3_Var33 templ.SafeURL687+ var templ_7745c5c3_Var34 templ.SafeURL
660- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))688+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
661 if templ_7745c5c3_Err != nil {689 if templ_7745c5c3_Err != nil {
662- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 38}690+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 163, Col: 38}
663 }691 }
664- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))692+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
665 if templ_7745c5c3_Err != nil {693 if templ_7745c5c3_Err != nil {
666 return templ_7745c5c3_Err694 return templ_7745c5c3_Err
667 }695 }
668- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <span class=\"foot-keys\"><kbd>?</kbd> keyboard shortcuts</span></footer>")696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <span class=\"foot-keys\"><kbd>?</kbd> keyboard shortcuts</span></footer>")
669 if templ_7745c5c3_Err != nil {697 if templ_7745c5c3_Err != nil {
670 return templ_7745c5c3_Err698 return templ_7745c5c3_Err
671 }699 }
@@ -693,12 +721,12 @@ func Hint(l Layout) templ.Component {
693 }()721 }()
694 }722 }
695 ctx = templ.InitializeContext(ctx)723 ctx = templ.InitializeContext(ctx)
696- templ_7745c5c3_Var34 := templ.GetChildren(ctx)724+ templ_7745c5c3_Var35 := templ.GetChildren(ctx)
697- if templ_7745c5c3_Var34 == nil {725+ if templ_7745c5c3_Var35 == nil {
698- templ_7745c5c3_Var34 = templ.NopComponent726+ templ_7745c5c3_Var35 = templ.NopComponent
699 }727 }
700 ctx = templ.ClearChildren(ctx)728 ctx = templ.ClearChildren(ctx)
701- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")729+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
702 if templ_7745c5c3_Err != nil {730 if templ_7745c5c3_Err != nil {
703 return templ_7745c5c3_Err731 return templ_7745c5c3_Err
704 }732 }
@@ -706,7 +734,7 @@ func Hint(l Layout) templ.Component {
706 if templ_7745c5c3_Err != nil {734 if templ_7745c5c3_Err != nil {
707 return templ_7745c5c3_Err735 return templ_7745c5c3_Err
708 }736 }
709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</div>")737+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div>")
710 if templ_7745c5c3_Err != nil {738 if templ_7745c5c3_Err != nil {
711 return templ_7745c5c3_Err739 return templ_7745c5c3_Err
712 }740 }
M internal/adapters/web/static/css/30-list.css +15 -0
@@ -47,6 +47,21 @@
4747 & .c-sub { width: auto; }
4848 & .c-who { width: 130px; }
4949 }
50+ .list.passkeys {
51+ & .c-name { width: auto; font-weight: 600; }
52+ & .c-when { width: 120px; font-size: var(--fs-s); }
53+ & .c-sync { width: 80px; font-size: var(--fs-s); }
54+ & .c-act { width: 110px; text-align: right; }
55+ }
56+ .passkey-add {
57+ display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; margin-top: var(--s4);
58+ & input {
59+ flex: 1; min-width: 12ch; max-width: 320px; height: 32px; padding: 0 var(--s3);
60+ background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-s);
61+ }
62+ & input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; border-color: transparent; }
63+ & input::placeholder { color: var(--ink-mute); opacity: 1; }
64+ }
5065 .list.commits, .list.refs {
5166 & .c-sha { width: 96px; }
5267 & .c-who { width: 140px; }
@@ -47,6 +47,21 @@
47 & .c-sub { width: auto; }47 & .c-sub { width: auto; }
48 & .c-who { width: 130px; }48 & .c-who { width: 130px; }
49 }49 }
50+ .list.passkeys {
51+ & .c-name { width: auto; font-weight: 600; }
52+ & .c-when { width: 120px; font-size: var(--fs-s); }
53+ & .c-sync { width: 80px; font-size: var(--fs-s); }
54+ & .c-act { width: 110px; text-align: right; }
55+ }
56+ .passkey-add {
57+ display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; margin-top: var(--s4);
58+ & input {
59+ flex: 1; min-width: 12ch; max-width: 320px; height: 32px; padding: 0 var(--s3);
60+ background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-s);
61+ }
62+ & input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; border-color: transparent; }
63+ & input::placeholder { color: var(--ink-mute); opacity: 1; }
64+ }
50 .list.commits, .list.refs {65 .list.commits, .list.refs {
51 & .c-sha { width: 96px; }66 & .c-sha { width: 96px; }
52 & .c-who { width: 140px; }67 & .c-who { width: 140px; }
A internal/adapters/web/static/js/passkey.js +97 -0
@@ -0,0 +1,97 @@
1+// passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys).
2+//
3+// One delegated click handler on [data-passkey="login"|"register"]. The button
4+// carries the two endpoints (data-begin, data-finish). The flow is the one of
5+// the WebAuthn JSON API: POST begin, hand options.publicKey to the browser
6+// (parse*OptionsFromJSON, then navigator.credentials.get|create), POST the
7+// result (toJSON()) to finish. Nothing is inline: the CSP only allows scripts
8+// from this origin. The core is tested in Node by jstest/passkey.test.mjs.
9+window.ermitePasskey = (() => {
10+ const UNSUPPORTED = 'this browser does not support passkeys';
11+
12+ // The parse function each ceremony needs; absent in a browser that predates
13+ // the WebAuthn JSON API.
14+ const parser = { login: 'parseRequestOptionsFromJSON', register: 'parseCreationOptionsFromJSON' };
15+ const supported = (kind) => {
16+ const pkc = window.PublicKeyCredential;
17+ return !!pkc && typeof pkc[parser[kind]] === 'function' && !!navigator.credentials;
18+ };
19+
20+ const statusEl = () => document.querySelector('[data-passkey-status]');
21+ const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };
22+
23+ // messageOf turns what a step threw into one line for the owner.
24+ function messageOf(e) {
25+ if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again';
26+ if (e && e.name === 'InvalidStateError') return 'this authenticator already holds a passkey for this site';
27+ if (e && e.name === 'AbortError') return 'cancelled';
28+ return (e && e.message) || 'something went wrong; try again';
29+ }
30+
31+ // failure reads the error of a refused request: the server's one-line
32+ // reason when it sent one, the status otherwise.
33+ async function failure(res) {
34+ let reason = '';
35+ try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }
36+ return new Error(reason || 'the server answered ' + res.status);
37+ }
38+
39+ async function run(btn) {
40+ const kind = btn.dataset.passkey;
41+ if (!parser[kind]) return;
42+ if (!supported(kind)) { say(UNSUPPORTED); return; }
43+ btn.disabled = true;
44+ say('');
45+ let done = false;
46+ try {
47+ const begun = await fetch(btn.dataset.begin, { method: 'POST', credentials: 'same-origin' });
48+ if (!begun.ok) throw await failure(begun);
49+ const { ceremony, options } = await begun.json();
50+ const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);
51+ const cred = kind === 'login'
52+ ? await navigator.credentials.get({ publicKey })
53+ : await navigator.credentials.create({ publicKey });
54+ if (!cred) throw new Error('no passkey was returned');
55+ const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;
56+ const finished = await fetch(btn.dataset.finish, {
57+ method: 'POST',
58+ credentials: 'same-origin',
59+ headers: { 'Content-Type': 'application/json' },
60+ body: JSON.stringify({ ceremony, name: nameEl ? nameEl.value.trim() : '', credential: cred.toJSON() }),
61+ });
62+ if (!finished.ok) throw await failure(finished);
63+ done = true;
64+ if (kind === 'login') location.assign('/');
65+ else location.reload();
66+ } catch (e) {
67+ say(messageOf(e));
68+ } finally {
69+ if (!done) btn.disabled = false;
70+ }
71+ }
72+
73+ function click(ev) {
74+ const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]');
75+ if (!btn || btn.disabled) return;
76+ if (ev.preventDefault) ev.preventDefault();
77+ return run(btn);
78+ }
79+
80+ // init disables the buttons of a browser that cannot do passkeys, and says why.
81+ function init() {
82+ let missing = false;
83+ document.querySelectorAll('[data-passkey]').forEach((btn) => {
84+ if (parser[btn.dataset.passkey] && !supported(btn.dataset.passkey)) {
85+ btn.disabled = true;
86+ missing = true;
87+ }
88+ });
89+ if (missing) say(UNSUPPORTED);
90+ }
91+
92+ if (typeof document.addEventListener === 'function') {
93+ document.addEventListener('click', click);
94+ init();
95+ }
96+ return { click, init, run };
97+})();
@@ -0,0 +1,97 @@
1+// passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys).
2+//
3+// One delegated click handler on [data-passkey="login"|"register"]. The button
4+// carries the two endpoints (data-begin, data-finish). The flow is the one of
5+// the WebAuthn JSON API: POST begin, hand options.publicKey to the browser
6+// (parse*OptionsFromJSON, then navigator.credentials.get|create), POST the
7+// result (toJSON()) to finish. Nothing is inline: the CSP only allows scripts
8+// from this origin. The core is tested in Node by jstest/passkey.test.mjs.
9+window.ermitePasskey = (() => {
10+ const UNSUPPORTED = 'this browser does not support passkeys';
11+
12+ // The parse function each ceremony needs; absent in a browser that predates
13+ // the WebAuthn JSON API.
14+ const parser = { login: 'parseRequestOptionsFromJSON', register: 'parseCreationOptionsFromJSON' };
15+ const supported = (kind) => {
16+ const pkc = window.PublicKeyCredential;
17+ return !!pkc && typeof pkc[parser[kind]] === 'function' && !!navigator.credentials;
18+ };
19+
20+ const statusEl = () => document.querySelector('[data-passkey-status]');
21+ const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };
22+
23+ // messageOf turns what a step threw into one line for the owner.
24+ function messageOf(e) {
25+ if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again';
26+ if (e && e.name === 'InvalidStateError') return 'this authenticator already holds a passkey for this site';
27+ if (e && e.name === 'AbortError') return 'cancelled';
28+ return (e && e.message) || 'something went wrong; try again';
29+ }
30+
31+ // failure reads the error of a refused request: the server's one-line
32+ // reason when it sent one, the status otherwise.
33+ async function failure(res) {
34+ let reason = '';
35+ try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }
36+ return new Error(reason || 'the server answered ' + res.status);
37+ }
38+
39+ async function run(btn) {
40+ const kind = btn.dataset.passkey;
41+ if (!parser[kind]) return;
42+ if (!supported(kind)) { say(UNSUPPORTED); return; }
43+ btn.disabled = true;
44+ say('');
45+ let done = false;
46+ try {
47+ const begun = await fetch(btn.dataset.begin, { method: 'POST', credentials: 'same-origin' });
48+ if (!begun.ok) throw await failure(begun);
49+ const { ceremony, options } = await begun.json();
50+ const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);
51+ const cred = kind === 'login'
52+ ? await navigator.credentials.get({ publicKey })
53+ : await navigator.credentials.create({ publicKey });
54+ if (!cred) throw new Error('no passkey was returned');
55+ const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;
56+ const finished = await fetch(btn.dataset.finish, {
57+ method: 'POST',
58+ credentials: 'same-origin',
59+ headers: { 'Content-Type': 'application/json' },
60+ body: JSON.stringify({ ceremony, name: nameEl ? nameEl.value.trim() : '', credential: cred.toJSON() }),
61+ });
62+ if (!finished.ok) throw await failure(finished);
63+ done = true;
64+ if (kind === 'login') location.assign('/');
65+ else location.reload();
66+ } catch (e) {
67+ say(messageOf(e));
68+ } finally {
69+ if (!done) btn.disabled = false;
70+ }
71+ }
72+
73+ function click(ev) {
74+ const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]');
75+ if (!btn || btn.disabled) return;
76+ if (ev.preventDefault) ev.preventDefault();
77+ return run(btn);
78+ }
79+
80+ // init disables the buttons of a browser that cannot do passkeys, and says why.
81+ function init() {
82+ let missing = false;
83+ document.querySelectorAll('[data-passkey]').forEach((btn) => {
84+ if (parser[btn.dataset.passkey] && !supported(btn.dataset.passkey)) {
85+ btn.disabled = true;
86+ missing = true;
87+ }
88+ });
89+ if (missing) say(UNSUPPORTED);
90+ }
91+
92+ if (typeof document.addEventListener === 'function') {
93+ document.addEventListener('click', click);
94+ init();
95+ }
96+ return { click, init, run };
97+})();
M internal/adapters/web/view.go +33 -0
@@ -60,6 +60,9 @@ type Layout struct {
6060 // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;
6161 // both are "" when the page has no commit to search.
6262 PathsURL, BlobBase string
63+ // Passkeys tells that the passkey routes exist: the header then links to
64+ // them.
65+ Passkeys bool
6366 }
6467
6568 // Link is a plain label + href pair.
@@ -190,3 +193,33 @@ type LoginPage struct {
190193 Action string // where the form posts
191194 Button string
192195 }
196+
197+// PasskeyLoginPage is the sign-in page: one button the script drives.
198+type PasskeyLoginPage struct {
199+ Title string
200+ Lines []string
201+ Button string
202+ // Begin and Finish are the endpoints the script posts to.
203+ Begin, Finish string
204+}
205+
206+// PasskeyRow is one passkey on the passkeys page.
207+type PasskeyRow struct {
208+ Name string
209+ // Created and LastUsed are relative ("3d ago"); the At fields are the
210+ // RFC 3339 forms, "" when never.
211+ Created, CreatedAt string
212+ LastUsed, LastUsedAt string
213+ Synced bool
214+ // Remove is where the remove form posts.
215+ Remove string
216+}
217+
218+// PasskeysPage lists the passkeys and adds one.
219+type PasskeysPage struct {
220+ Rows []PasskeyRow
221+ Begin, Finish string
222+ // Full tells that no more passkeys can be added.
223+ Full bool
224+ Max int
225+}
@@ -60,6 +60,9 @@ type Layout struct {
60 // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;60 // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;
61 // both are "" when the page has no commit to search.61 // both are "" when the page has no commit to search.
62 PathsURL, BlobBase string62 PathsURL, BlobBase string
63+ // Passkeys tells that the passkey routes exist: the header then links to
64+ // them.
65+ Passkeys bool
63 }66 }
64 67
65 // Link is a plain label + href pair.68 // Link is a plain label + href pair.
@@ -190,3 +193,33 @@ type LoginPage struct {
190 Action string // where the form posts193 Action string // where the form posts
191 Button string194 Button string
192 }195 }
196+
197+// PasskeyLoginPage is the sign-in page: one button the script drives.
198+type PasskeyLoginPage struct {
199+ Title string
200+ Lines []string
201+ Button string
202+ // Begin and Finish are the endpoints the script posts to.
203+ Begin, Finish string
204+}
205+
206+// PasskeyRow is one passkey on the passkeys page.
207+type PasskeyRow struct {
208+ Name string
209+ // Created and LastUsed are relative ("3d ago"); the At fields are the
210+ // RFC 3339 forms, "" when never.
211+ Created, CreatedAt string
212+ LastUsed, LastUsedAt string
213+ Synced bool
214+ // Remove is where the remove form posts.
215+ Remove string
216+}
217+
218+// PasskeysPage lists the passkeys and adds one.
219+type PasskeysPage struct {
220+ Rows []PasskeyRow
221+ Begin, Finish string
222+ // Full tells that no more passkeys can be added.
223+ Full bool
224+ Max int
225+}
M internal/adapters/web/web.go +9 -0
@@ -73,6 +73,11 @@ type Config struct {
7373 // Sessions signs the owner in (ADR 0018). Without it there are no login
7474 // routes and every visitor is anonymous.
7575 Sessions ports.WebSessionService
76+ // Passkeys signs the owner in with a passkey and manages the passkeys
77+ // (ADR 0026). Without it, or without Sessions, there are no passkey
78+ // routes and no link to them. Leave it unset rather than assigning a typed
79+ // nil pointer.
80+ Passkeys ports.WebPasskeyService
7681 // Limiter counts the failed logins; required with Sessions. Share it with
7782 // the API and Git, so that a client has one budget of failures.
7883 Limiter ports.FailureLimiter
@@ -135,6 +140,9 @@ func (s *Server) routes() *http.ServeMux {
135140 mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
136141 mux.HandleFunc("POST /-/logout", s.logout)
137142 }
143+ if s.passkeysOn() {
144+ s.passkeyRoutes(mux)
145+ }
138146 mux.HandleFunc("GET /{$}", s.page(s.home))
139147 mux.HandleFunc("GET /{repo}", s.page(s.summary))
140148 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -291,6 +299,7 @@ func (s *Server) base() Layout {
291299 Version: s.cfg.Version,
292300 SourceURL: s.cfg.SourceURL,
293301 Assets: s.assets.urls,
302+ Passkeys: s.passkeysOn(),
294303 }
295304 }
296305
@@ -73,6 +73,11 @@ type Config struct {
73 // Sessions signs the owner in (ADR 0018). Without it there are no login73 // Sessions signs the owner in (ADR 0018). Without it there are no login
74 // routes and every visitor is anonymous.74 // routes and every visitor is anonymous.
75 Sessions ports.WebSessionService75 Sessions ports.WebSessionService
76+ // Passkeys signs the owner in with a passkey and manages the passkeys
77+ // (ADR 0026). Without it, or without Sessions, there are no passkey
78+ // routes and no link to them. Leave it unset rather than assigning a typed
79+ // nil pointer.
80+ Passkeys ports.WebPasskeyService
76 // Limiter counts the failed logins; required with Sessions. Share it with81 // Limiter counts the failed logins; required with Sessions. Share it with
77 // the API and Git, so that a client has one budget of failures.82 // the API and Git, so that a client has one budget of failures.
78 Limiter ports.FailureLimiter83 Limiter ports.FailureLimiter
@@ -135,6 +140,9 @@ func (s *Server) routes() *http.ServeMux {
135 mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)140 mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
136 mux.HandleFunc("POST /-/logout", s.logout)141 mux.HandleFunc("POST /-/logout", s.logout)
137 }142 }
143+ if s.passkeysOn() {
144+ s.passkeyRoutes(mux)
145+ }
138 mux.HandleFunc("GET /{$}", s.page(s.home))146 mux.HandleFunc("GET /{$}", s.page(s.home))
139 mux.HandleFunc("GET /{repo}", s.page(s.summary))147 mux.HandleFunc("GET /{repo}", s.page(s.summary))
140 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))148 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -291,6 +299,7 @@ func (s *Server) base() Layout {
291 Version: s.cfg.Version,299 Version: s.cfg.Version,
292 SourceURL: s.cfg.SourceURL,300 SourceURL: s.cfg.SourceURL,
293 Assets: s.assets.urls,301 Assets: s.assets.urls,
302+ Passkeys: s.passkeysOn(),
294 }303 }
295 }304 }
296 305
M internal/app/testdata/web/blob-binary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-code.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-empty.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-image.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-markdown.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-svg.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-text.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-too-large.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-trojan.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-big.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-binary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-merge.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify-split.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-rename.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-trojan.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-page2.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-path.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-small.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/empty.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/home.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/not-found-page.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/not-found.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/refs.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/summary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-nested.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-slash-ref.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">25 <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-subdir.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close