@bkazemi/verily 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { dialogPath, dialogRoute, lastProved, localSide, routeOf, routes, statusLabel, } from '../core/index.js';
2
- import { escape } from '../server/escape.js';
2
+ import { html, raw } from '../server/html.js';
3
3
  import { card, date, distance, externalCard, marked, page, verdict } from '../server/render.js';
4
4
  import { toneOf } from '../marks.js';
5
5
  import { version } from '../version.js';
@@ -8,8 +8,11 @@ import { recordStorage } from './store.js';
8
8
  import { OwnerAuth } from './auth.js';
9
9
  import { sessionCookie, Sites, peek } from './sites.js';
10
10
  import { allow, client, limits, prune } from './limits.js';
11
+ import { recheckSites, Registrations } from './register.js';
12
+ import { Dashboard } from './dashboard.js';
13
+ import { OwnerSites } from './owner-sites.js';
11
14
  import { own } from './routes.js';
12
- import { html, safeHeaders, unavailable } from './page.js';
15
+ import { respond, safeHeaders, see, unavailable } from './page.js';
13
16
  export { fromEnv } from './env.js';
14
17
  export { registry } from './sites.js';
15
18
  export { recordStorage } from './store.js';
@@ -18,16 +21,19 @@ export { recordStorage } from './store.js';
18
21
  * reaches it either holds the owner key or has no business past it, so it carries one
19
22
  * field and no account of what lies behind.
20
23
  */
21
- const signIn = (message = '', status = 200) => html('Sign in', `${message}<form action="/login" method="post"><label>Owner key <input name="key" type="password" autocomplete="current-password" required></label><button>Sign in</button></form>`, status, 'single');
24
+ const signIn = (message, status = 200) => respond('Sign in', html `
25
+ ${message}
26
+ <form action="/login" method="post">
27
+ <label>
28
+ Owner key
29
+ <input name="key" type="password" autocomplete="current-password" required>
30
+ </label>
31
+ <button>Sign in</button>
32
+ </form>
33
+ `, status, 'single');
22
34
  const redirect = (cookie) => see('/', [cookie]);
23
- function see(location, cookies = []) {
24
- const headers = new Headers({ ...safeHeaders, Location: location });
25
- for (const cookie of cookies)
26
- headers.append('Set-Cookie', cookie);
27
- return new Response(null, { status: 303, headers });
28
- }
29
35
  /** Every refused handoff looks the same, so nobody can learn which check it failed. */
30
- const refused = () => html('Link expired', '<p>This link has expired or was already used. Start again from the site that sent you.</p>', 403, 'single');
36
+ const refused = () => respond('Link expired', html `<p>This link has expired or was already used. Start again from the site that sent you.</p>`, 403, 'single');
31
37
  /**
32
38
  * Whether a request is for one of the library's own management routes, which a site
33
39
  * session does not get. Which routes those are is the library's to say.
@@ -102,7 +108,7 @@ function parsed(text) {
102
108
  * belongs to, so a page elsewhere cannot have someone sign in to a flow it started.
103
109
  */
104
110
  const enterScript = `const status=document.getElementById('status');const fail=()=>{status.textContent='This window could not start the sign-in. Close it and try again.'};let taken=false;if(!window.opener)fail();else{window.addEventListener('message',async(event)=>{const d=event.data;if(taken||event.source!==window.opener||!d||d.type!=='verily-enter'||typeof d.flow!=='string'||typeof d.binding!=='string'||typeof d.url!=='string')return;taken=true;let target;try{target=new URL(d.url)}catch{return fail()}if(target.protocol!=='https:')return fail();const response=await fetch('/site/enter',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({flow:d.flow,binding:d.binding,origin:event.origin})}).catch(()=>undefined);if(!response||!response.ok)return fail();location.replace(target.href)});window.opener.postMessage({type:'verily-enter'},'*')}`;
105
- const enterPage = () => new Response(page('', 'Sign in', '<p id="status">Opening the sign-in page.</p>', 'single', '/site/enter.js'), {
111
+ const enterPage = () => new Response(page('', 'Sign in', html `<p id="status">Opening the sign-in page.</p>`, 'single', '/site/enter.js'), {
106
112
  headers: {
107
113
  ...safeHeaders,
108
114
  'Content-Type': 'text/html; charset=utf-8',
@@ -156,7 +162,7 @@ export async function admit(request, origin) {
156
162
  body = await boundedBody(request);
157
163
  }
158
164
  catch {
159
- return html('Request too large', '', 413, 'single');
165
+ return respond('Request too large', html ``, 413, 'single');
160
166
  }
161
167
  return new Request(request.url, {
162
168
  method: request.method,
@@ -175,6 +181,9 @@ class Server {
175
181
  origin;
176
182
  auth;
177
183
  sites;
184
+ registrations;
185
+ dashboard;
186
+ ownerSites;
178
187
  local;
179
188
  constructor(options) {
180
189
  this.options = options;
@@ -185,7 +194,11 @@ class Server {
185
194
  this.store = options.store;
186
195
  this.auth = new OwnerAuth(this.store, options.ownerKey);
187
196
  this.sites = new Sites(this.store, options.sites ?? new Map());
188
- const siteOrigins = [...this.sites.sites.values()].map((site) => site.origin);
197
+ this.dashboard = new Dashboard(this.store, this.sites, options.providers, origin.host, options.reportUrl);
198
+ this.ownerSites = new OwnerSites(this.store, this.sites, options.providers);
199
+ if (options.registration === 'open')
200
+ this.registrations = new Registrations(this.store, this.sites, this.origin, options.dns);
201
+ const ownerOrigin = new URL(options.owner.profileUrl).origin;
189
202
  // A site with no user accounts must not have its subject described as one, so the
190
203
  // owner's kind is whatever the host gave, absent included.
191
204
  this.local = { id: 'site-owner', ...options.owner };
@@ -198,7 +211,7 @@ class Server {
198
211
  : { signingKey: options.signingKey ?? (() => this.signingKey()) }),
199
212
  // Whether this origin's DNS names its signing key, and whether a site's names this
200
213
  // instance as its verifier. Both go to the resolver the domain method already asks.
201
- dns: true,
214
+ dns: options.dns ?? true,
202
215
  // The whole origin is this instance, so the library's routes sit at its root.
203
216
  baseUrl: this.origin,
204
217
  siteName: options.siteName,
@@ -209,8 +222,9 @@ class Server {
209
222
  // Each published proof is re-read every six hours, so one taken down is noticed soon.
210
223
  recheckMs: options.recheckMs ?? 6 * 3600000,
211
224
  // Every site's origin, since the library checks one list. That a subject's profile
212
- // is on its own site's origin is checked when its handoff arrives.
213
- profileOrigins: [new URL(options.owner.profileUrl).origin, ...siteOrigins],
225
+ // is on its own site's origin is checked when its handoff arrives. Asked each time,
226
+ // since sites registered here come and go while the instance runs.
227
+ profileOrigins: () => [ownerOrigin, ...[...this.sites.sites.values()].map((s) => s.origin)],
214
228
  reportUrl: options.reportUrl,
215
229
  // A site session is the more specific: it was opened for this holder moments ago.
216
230
  authenticate: async (request) => (await this.sites.open(request))?.session.local ??
@@ -226,8 +240,15 @@ class Server {
226
240
  }
227
241
  : undefined;
228
242
  },
229
- // A site's holders get the providers that site chose. The owner has no site.
230
- providersFor: (local) => this.siteOf(local)?.providers,
243
+ // A site's holders get the providers that site chose, less any the owner has not
244
+ // approved it for. A holder whose site is gone gets none, rather than every one. The
245
+ // owner has no site.
246
+ providersFor: (local) => {
247
+ const site = this.siteOf(local);
248
+ if (!site)
249
+ return local.id.includes(':') ? [] : undefined;
250
+ return this.sites.providers(site, options.providers.map((provider) => provider.id));
251
+ },
231
252
  visibilityFor: (local) => this.siteOf(local)?.visibility,
232
253
  finish: ({ id, context, local, result }) => this.sites.finish(id, context, local, result),
233
254
  });
@@ -247,6 +268,7 @@ class Server {
247
268
  await this.app.service.recheck(this.options.recheckBudget ?? 10);
248
269
  await this.auth.prune();
249
270
  await prune(this.store);
271
+ await recheckSites(this.store, this.sites, this.origin, this.options.dns);
250
272
  }
251
273
  /**
252
274
  * Answers one request. `address` is the client's address as the host knows it, which
@@ -256,11 +278,12 @@ class Server {
256
278
  async handle(request, address = null) {
257
279
  let response;
258
280
  try {
281
+ await this.sites.refresh();
259
282
  const admitted = await admit(request, this.origin);
260
283
  response = admitted instanceof Response ? admitted : await this.route(admitted, address);
261
284
  }
262
285
  catch {
263
- response = html('Temporarily unavailable', '<p>Try again shortly.</p>', 503, 'single');
286
+ response = respond('Temporarily unavailable', html `<p>Try again shortly.</p>`, 503, 'single');
264
287
  }
265
288
  // An approval or disconnect form redirects on to the holder's site, and Chromium holds
266
289
  // a form's redirects to form-action. Only a holder that site sent here is told its
@@ -283,7 +306,7 @@ class Server {
283
306
  !request.headers.get('content-type')?.startsWith('application/json'))
284
307
  return unavailable(403);
285
308
  if (!(await this.allowed(request, url, address)))
286
- return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
309
+ return respond('Too many requests', html `<p>Try again later.</p>`, 429, 'single');
287
310
  return this.enter(request);
288
311
  }
289
312
  if (request.method === 'POST' &&
@@ -298,11 +321,13 @@ class Server {
298
321
  if (request.headers.has('authorization') && dialogPath(url.pathname))
299
322
  return shared(await this.dialog(request, url, address), request);
300
323
  if (!(await this.allowed(request, url, address)))
301
- return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
324
+ return respond('Too many requests', html `<p>Try again later.</p>`, 429, 'single');
302
325
  if (request.method === 'POST' && url.pathname === '/site/session') {
303
- const opened = await this.sites.dialog(String((await fields(request)).token ?? ''), request.headers.get('origin'), (local) => this.app.service.validateLocal(local), (site) => allow(this.store, [{ key: `site/${site.id}`, limit: limits.site }]));
326
+ const opened = await this.sites.dialog(String((await fields(request)).token ?? ''), request.headers.get('origin'), (local) => this.app.service.validateLocal(local), (site) => allow(this.store, [
327
+ { key: `site/${site.id}`, limit: this.sites.limit(site.id) ?? limits.site },
328
+ ]));
304
329
  return shared(opened === 'limited'
305
- ? html('Too many requests', '<p>Try again later.</p>', 429, 'single')
330
+ ? respond('Too many requests', html `<p>Try again later.</p>`, 429, 'single')
306
331
  : opened
307
332
  ? data({ session: opened.session })
308
333
  : unavailable(), request);
@@ -316,10 +341,10 @@ class Server {
316
341
  if (request.method === 'POST') {
317
342
  if (url.pathname === '/login') {
318
343
  if (!(await this.auth.allow('login', 10, 15 * 60000)))
319
- return html('Too many requests', '<p>Try again in fifteen minutes.</p>', 429, 'single');
344
+ return respond('Too many requests', html `<p>Try again in fifteen minutes.</p>`, 429, 'single');
320
345
  const key = new URLSearchParams(await request.text()).get('key') ?? '';
321
346
  const cookie = await this.auth.login(key);
322
- return cookie ? redirect(cookie) : signIn('<p>Incorrect owner key.</p>', 403);
347
+ return cookie ? redirect(cookie) : signIn(html `<p>Incorrect owner key.</p>`, 403);
323
348
  }
324
349
  if (url.pathname === '/logout')
325
350
  return redirect(await this.auth.logout(request));
@@ -329,7 +354,37 @@ class Server {
329
354
  return this.disconnect(request);
330
355
  if (url.pathname === '/mark')
331
356
  return this.mark(request);
357
+ if (url.pathname.startsWith('/sites/'))
358
+ return (await this.auth.authenticated(request))
359
+ ? this.ownerSites.change(url.pathname, request)
360
+ : unavailable(403);
361
+ if (url.pathname === '/site/dashboard/login')
362
+ return this.dashboard.login(request);
363
+ if (url.pathname === '/site/dashboard/logout')
364
+ return this.dashboard.logout(request);
365
+ if (url.pathname === '/site/dashboard/settings')
366
+ return this.dashboard.settings(request);
367
+ if (url.pathname === '/site/dashboard/rotate')
368
+ return this.dashboard.rotate(request);
369
+ if (url.pathname === '/site/dashboard/previous-key')
370
+ return this.dashboard.retirePreviousKey(request);
371
+ if (url.pathname === '/site/dashboard/delete')
372
+ return this.dashboard.delete(request);
373
+ if (this.registrations) {
374
+ if (url.pathname === '/register')
375
+ return this.registrations.request(request);
376
+ if (url.pathname === '/register/check')
377
+ return this.registrations.check(request);
378
+ if (url.pathname === '/register/cancel')
379
+ return this.registrations.cancel();
380
+ }
332
381
  }
382
+ if (request.method === 'GET' && url.pathname === '/site/dashboard')
383
+ return this.dashboard.page(request);
384
+ if (request.method === 'GET' && url.pathname === '/sites')
385
+ return (await this.auth.authenticated(request)) ? this.ownerSites.page() : unavailable();
386
+ if (request.method === 'GET' && url.pathname === '/register' && this.registrations)
387
+ return this.registrations.page(request);
333
388
  if (request.method === 'GET' && url.pathname === '/site/connections')
334
389
  return this.read(request, address);
335
390
  if (request.method === 'GET' && url.pathname === '/handoff/request') {
@@ -386,14 +441,27 @@ class Server {
386
441
  if (found?.session.operation && found.session.operation.finishedAt === undefined) {
387
442
  const { connection, mark } = found.session.operation;
388
443
  return mark
389
- ? html('Change not finished', `<p>Changing how this account is listed did not finish.</p><form action="/mark" method="post"><input type="hidden" name="connection" value="${escape(connection)}"><input type="hidden" name="as" value="${escape(mark)}"><button>Try again</button></form>`, 200, 'single')
390
- : html('Disconnect not finished', `<p>Removing this link did not finish.</p><form action="/disconnect" method="post"><input type="hidden" name="connection" value="${escape(connection)}"><button>Try again</button></form>`, 200, 'single');
444
+ ? respond('Change not finished', html `
445
+ <p>Changing how this account is listed did not finish.</p>
446
+ <form action="/mark" method="post">
447
+ <input type="hidden" name="connection" value="${connection}">
448
+ <input type="hidden" name="as" value="${mark}">
449
+ <button>Try again</button>
450
+ </form>
451
+ `, 200, 'single')
452
+ : respond('Disconnect not finished', html `
453
+ <p>Removing this link did not finish.</p>
454
+ <form action="/disconnect" method="post">
455
+ <input type="hidden" name="connection" value="${connection}">
456
+ <button>Try again</button>
457
+ </form>
458
+ `, 200, 'single');
391
459
  }
392
460
  if (await this.auth.authenticated(request))
393
461
  return this.settings(this.local);
394
462
  if (found) {
395
463
  const site = this.site(found.session);
396
- return html('Session ended', `<p>You are finished here. <a href="${escape(site.origin)}">Return to ${escape(site.name)}</a> to start again.</p>`, 200, 'single');
464
+ return respond('Session ended', html `<p>You are finished here. <a href="${site.origin}">Return to ${site.name}</a> to start again.</p>`, 200, 'single');
397
465
  }
398
466
  return signIn();
399
467
  }
@@ -409,7 +477,7 @@ class Server {
409
477
  // here never reaches the counting a served one gets, and each costs a lookup.
410
478
  const turnedAway = async () => (await allow(this.store, [{ key: client(address), limit: limits.client }]))
411
479
  ? unavailable()
412
- : html('Too many requests', '<p>Try again later.</p>', 429, 'single');
480
+ : respond('Too many requests', html `<p>Try again later.</p>`, 429, 'single');
413
481
  const session = request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1] ?? '';
414
482
  const binding = request.headers.get(flowHeader) ?? '';
415
483
  const post = request.method === 'POST';
@@ -444,7 +512,7 @@ class Server {
444
512
  if (!found || this.site(found.session).origin !== request.headers.get('origin'))
445
513
  return turnedAway();
446
514
  if (!(await this.allowed(inner, url, address)))
447
- return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
515
+ return respond('Too many requests', html `<p>Try again later.</p>`, 429, 'single');
448
516
  const response = await this.app.handle(inner);
449
517
  const answer = new Response(response.body, response);
450
518
  const set = response.headers
@@ -489,10 +557,10 @@ class Server {
489
557
  const asked = this.sites.read(request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1] ?? '');
490
558
  if (!(await allow(this.store, [
491
559
  asked
492
- ? { key: `read/${asked.site.id}`, limit: limits.read }
560
+ ? { key: `read/${asked.site.id}`, limit: this.sites.limit(asked.site.id) ?? limits.read }
493
561
  : { key: client(address), limit: limits.client },
494
562
  ])))
495
- return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
563
+ return respond('Too many requests', html `<p>Try again later.</p>`, 429, 'single');
496
564
  if (!asked)
497
565
  return unavailable();
498
566
  // No prototype, so an id such as `__proto__` is an entry like any other.
@@ -545,7 +613,7 @@ class Server {
545
613
  const site = named && this.sites.sites.has(named) ? named : undefined;
546
614
  return allow(this.store, [
547
615
  holder,
548
- ...(site ? [{ key: `site/${site}`, limit: limits.site }] : []),
616
+ ...(site ? [{ key: `site/${site}`, limit: this.sites.limit(site) ?? limits.site }] : []),
549
617
  ]);
550
618
  }
551
619
  /**
@@ -581,7 +649,7 @@ class Server {
581
649
  connection,
582
650
  visibility: current.operation.visibility,
583
651
  finishedAt: current.operation.finishedAt,
584
- }));
652
+ }, session.signer));
585
653
  }
586
654
  /**
587
655
  * Lists an account another way, or retires it, from a site session, and reports it back
@@ -617,7 +685,7 @@ class Server {
617
685
  connection,
618
686
  visibility: current.operation.visibility,
619
687
  finishedAt: current.operation.finishedAt,
620
- }));
688
+ }, session.signer));
621
689
  }
622
690
  /**
623
691
  * The settings page for whoever is signed in: the owner, or a holder a site sent here.
@@ -630,13 +698,28 @@ class Server {
630
698
  const now = Date.now();
631
699
  // The copy button's script, on the one page that has something to copy.
632
700
  const embeds = !site && connections.some((e) => e.visibility === 'public');
633
- return html(site ? 'Your connections' : 'Owner settings', `${card(local.heading, local.value, undefined, subject.reference)}
634
- <p class="links"><a class="button" href="/verify">Verify an account</a>${site ? '' : '<a href="/records">Every connection on this instance</a>'}</p>
635
- ${connections.length
636
- ? `<p class="fine">Renewing a connection extends it and keeps its ID, so anything that shows it goes on working.${site ? '' : ' Only a public connection can be embedded.'}</p>`
637
- : '<p>No connections yet. Verify an account to make the first one.</p>'}
638
- ${connections.map((e) => this.connection(e, now, site)).join('')}
639
- ${site ? `<p><a href="${escape(site.origin)}">Back to ${escape(site.name)}</a></p>` : '<form action="/logout" method="post"><button>Sign out</button></form>'}`, 200, '', embeds ? '/copy.js' : '');
701
+ return respond(site ? 'Your connections' : 'Owner settings', html `
702
+ ${card(local.heading, local.value, undefined, subject.reference)}
703
+ <p class="links">
704
+ <a class="button" href="/verify">Verify an account</a>${!site &&
705
+ html `
706
+ <a href="/records">Every connection on this instance</a>
707
+ <a href="/sites">Sites</a>
708
+ `}
709
+ </p>
710
+ ${connections.length
711
+ ? html `
712
+ <p class="fine">
713
+ Renewing a connection extends it and keeps its ID, so anything that shows it
714
+ goes on working.${site ? '' : ' Only a public connection can be embedded.'}
715
+ </p>
716
+ `
717
+ : html `<p>No connections yet. Verify an account to make the first one.</p>`}
718
+ ${connections.map((e) => this.connection(e, now, site))}
719
+ ${site
720
+ ? html `<p><a href="${site.origin}">Back to ${site.name}</a></p>`
721
+ : html `<form action="/logout" method="post"><button>Sign out</button></form>`}
722
+ `, 200, '', embeds ? '/copy.js' : '');
640
723
  }
641
724
  /**
642
725
  * Every record the instance holds, for its operator: each site's subjects, public and
@@ -649,28 +732,45 @@ class Server {
649
732
  const count = (visibility) => all.filter((e) => e.visibility === visibility).length;
650
733
  const live = (e) => ['Verified', 'Unconfirmed'].includes(statusLabel(e, now));
651
734
  const sites = [...new Set(all.map((e) => e.siteName))];
652
- return html('All connections', `<p class="fine">${all.length} on this instance: ${count('public')} public, ${count('unlisted')} unlisted.</p>
653
- ${sites
654
- .map((name) => {
735
+ return respond('All connections', html `
736
+ <p class="fine">
737
+ ${all.length} on this instance: ${count('public')} public, ${count('unlisted')} unlisted.
738
+ </p>
739
+ ${sites.map((name) => {
655
740
  // Live records first. The sort is stable, so each half keeps the stored order.
656
741
  const records = all
657
742
  .filter((e) => e.siteName === name)
658
743
  .sort((a, b) => Number(live(b)) - Number(live(a)));
659
- return `<h2>${escape(name)} · ${records.length}</h2>${records
660
- .map((e) => {
744
+ return html `
745
+ <h2>${name} · ${records.length}</h2>
746
+ ${records.map((e) => {
661
747
  const state = statusLabel(e, now);
662
748
  const local = e.local.reference && e.local.reference !== e.local.label
663
749
  ? `${e.local.label} · ${e.local.reference}`
664
750
  : e.local.label;
665
751
  // The card the settings page draws, without its controls: this page only reads.
666
- return `<div class="connection">${externalCard(cardHeading(e, ` · ${e.visibility}`), e.external, `<span class="state">${verdict(toneOf(e, now), true)}<span>${escape(state)}</span></span>`, e.visibility === 'public'
667
- ? `<p class="evidence"><a href="${escape(e.evidenceUrl)}">Inspect evidence</a></p>`
668
- : '', `<dl><dt>Linked to</dt><dd>${escape(local)}</dd><dt>Connected</dt><dd>${escape(date(e.connectedAt))}</dd></dl>`)}</div>`;
669
- })
670
- .join('')}`;
671
- })
672
- .join('')}
673
- <p><a href="/">Back to owner settings</a></p>`);
752
+ return html `
753
+ <div class="connection">
754
+ ${externalCard(cardHeading(e, ` · ${e.visibility}`), e.external, html `
755
+ <span class="state">
756
+ ${verdict(toneOf(e, now), true)}<span>${state}</span>
757
+ </span>
758
+ `, e.visibility === 'public' &&
759
+ html `<p class="evidence"><a href="${e.evidenceUrl}">Inspect evidence</a></p>`, html `
760
+ <dl>
761
+ <dt>Linked to</dt>
762
+ <dd>${local}</dd>
763
+ <dt>Connected</dt>
764
+ <dd>${date(e.connectedAt)}</dd>
765
+ </dl>
766
+ `)}
767
+ </div>
768
+ `;
769
+ })}
770
+ `;
771
+ })}
772
+ <p><a href="/">Back to owner settings</a></p>
773
+ `);
674
774
  }
675
775
  /**
676
776
  * Asks before an account is retired or a connection is removed, since neither can be
@@ -694,27 +794,44 @@ class Server {
694
794
  // What the settings page offers for this record, and nothing it does not.
695
795
  if (action === 'retire' ? ['revoked', 'retired'].includes(e.status) : e.status === 'revoked')
696
796
  return unavailable();
697
- const held = site ? `<input type="hidden" name="connection" value="${escape(e.id)}">` : '';
797
+ const held = site && html `<input type="hidden" name="connection" value="${e.id}">`;
698
798
  const [heading, note, form] = action === 'retire'
699
799
  ? [
700
800
  'Retire this account?',
701
801
  retiringNote,
702
- `<form action="${site ? '/mark' : `/connections/${escape(e.id)}/mark`}" method="post">${held}<input type="hidden" name="as" value="retired"><button class="danger">Retire</button></form>`,
802
+ html `
803
+ <form action="${site ? '/mark' : `/connections/${e.id}/mark`}" method="post">
804
+ ${held}
805
+ <input type="hidden" name="as" value="retired">
806
+ <button class="danger">Retire</button>
807
+ </form>
808
+ `,
703
809
  ]
704
810
  : site
705
811
  ? [
706
812
  'Disconnect this account?',
707
813
  `This removes the link between your ${site.name} account and this one. It cannot be undone. To link them again you would verify the account again.`,
708
- `<form action="/disconnect" method="post">${held}<button class="danger">Disconnect</button></form>`,
814
+ html `
815
+ <form action="/disconnect" method="post">
816
+ ${held}
817
+ <button class="danger">Disconnect</button>
818
+ </form>
819
+ `,
709
820
  ]
710
821
  : [
711
822
  'Revoke this connection?',
712
823
  'This ends the connection, and it will read as revoked wherever it is shown. It cannot be undone. Verifying the account again makes a new connection with a new ID.',
713
- `<form action="/connections/${escape(e.id)}/disconnect" method="post"><button class="danger">Revoke connection</button></form>`,
824
+ html `
825
+ <form action="/connections/${e.id}/disconnect" method="post">
826
+ <button class="danger">Revoke connection</button>
827
+ </form>
828
+ `,
714
829
  ];
715
- return html(heading, `${externalCard(cardHeading(e), e.external)}
716
- <p>${escape(note)}</p>
717
- <div class="choice">${form}<a class="button secondary" href="/">Cancel</a></div>`, 200, 'single');
830
+ return respond(heading, html `
831
+ ${externalCard(cardHeading(e), e.external)}
832
+ <p>${note}</p>
833
+ <div class="choice">${form}<a class="button secondary" href="/">Cancel</a></div>
834
+ `, 200, 'single');
718
835
  }
719
836
  /** One connection: the account's card, its state, and what can be done with it. */
720
837
  connection(e, now, site) {
@@ -747,31 +864,55 @@ class Server {
747
864
  // Renewing and changing visibility start a flow on a page of their own, so they are
748
865
  // links, drawn as buttons beside the others that act on the connection.
749
866
  const goes = [
750
- acts ? `<a class="button" href="/renew/${escape(e.id)}">Renew</a>` : '',
751
- acts && !retired && site?.visibility?.length !== 1
752
- ? `<a class="button" href="/visibility/${escape(e.id)}">Change visibility</a>`
753
- : '',
754
- ].join('');
867
+ acts && html `<a class="button" href="/renew/${e.id}">Renew</a>`,
868
+ acts &&
869
+ !retired &&
870
+ site?.visibility?.length !== 1 &&
871
+ html `<a class="button" href="/visibility/${e.id}">Change visibility</a>`,
872
+ ].filter((go) => go !== false);
755
873
  // How the holder lists the account, which acts on every record of it they have. A
756
874
  // site's holder is sent back to the site with the change, as with a disconnect.
757
- const listing = open
758
- ? `<form action="${site ? '/mark' : `/connections/${escape(e.id)}/mark`}" method="post">${site ? `<input type="hidden" name="connection" value="${escape(e.id)}">` : ''}
759
- ${e.mark === 'preferred' ? '' : '<button name="as" value="preferred">Mark as preferred</button>'}
760
- ${e.mark === 'unused' ? '' : '<button name="as" value="unused">Mark as unused</button>'}
761
- ${e.mark === undefined ? '' : '<button name="as" value="current">Mark as current</button>'}</form>`
762
- : '';
875
+ const listing = open &&
876
+ html `
877
+ <form action="${site ? '/mark' : `/connections/${e.id}/mark`}" method="post">
878
+ ${site && html `<input type="hidden" name="connection" value="${e.id}">`}
879
+ ${e.mark !== 'preferred' &&
880
+ html `<button name="as" value="preferred">Mark as preferred</button>`}
881
+ ${e.mark !== 'unused' && html `<button name="as" value="unused">Mark as unused</button>`}
882
+ ${e.mark !== undefined && html `<button name="as" value="current">Mark as current</button>`}
883
+ </form>
884
+ `;
763
885
  // Retiring and removing go by a page that asks first, since neither can be undone.
764
- const asks = (action, label, danger = false) => `<form action="/confirm" method="get"><input type="hidden" name="connection" value="${escape(e.id)}"><button${danger ? ' class="danger"' : ''} name="do" value="${action}">${label}</button></form>`;
886
+ const asks = (action, label, danger = false) => html `
887
+ <form action="/confirm" method="get">
888
+ <input type="hidden" name="connection" value="${e.id}">
889
+ <button${danger && raw(' class="danger"')} name="do" value="${action}">${label}</button>
890
+ </form>
891
+ `;
765
892
  // Retiring and removing cannot be undone, so they sit together on a line of their own
766
893
  // below the rest, where nothing else lines up with them.
767
- const ends = e.status === 'revoked'
768
- ? ''
769
- : `${open ? asks('retire', 'Retire', true) : ''}${asks('remove', site ? 'Disconnect' : 'Revoke', true)}`;
770
- const ending = ends && `<div class="ending">${ends}</div>`;
771
- return `<div class="connection">${externalCard(cardHeading(e), e.external, `<span class="state">${verdict(toneOf(e, now), true)}<span>${escape(state)}</span></span>`, e.visibility === 'public'
772
- ? `<p class="evidence"><a href="${escape(e.evidenceUrl)}">Inspect evidence</a></p>`
773
- : '', `<dl>${rows.map(([label, value]) => `<dt>${label}</dt><dd>${escape(value)}</dd>`).join('')}</dl>`, shown && `<p class="fine">${escape(shown)}</p>`, !site && e.visibility === 'public'
774
- ? `<p class="fine">Embed on your site</p><pre><code>${escape(embed)}</code></pre>`
775
- : '', `${goes || listing ? `<div class="actions">${goes}${listing}</div>` : ''}${ending}`)}</div>`;
894
+ const ending = e.status !== 'revoked' &&
895
+ html `
896
+ <div class="ending">
897
+ ${open && asks('retire', 'Retire', true)}
898
+ ${asks('remove', site ? 'Disconnect' : 'Revoke', true)}
899
+ </div>
900
+ `;
901
+ return html `
902
+ <div class="connection">
903
+ ${externalCard(cardHeading(e), e.external, html `
904
+ <span class="state">${verdict(toneOf(e, now), true)}<span>${state}</span></span>
905
+ `, e.visibility === 'public' &&
906
+ html `<p class="evidence"><a href="${e.evidenceUrl}">Inspect evidence</a></p>`, html `<dl>${rows.map(([label, value]) => html `<dt>${label}</dt><dd>${value}</dd>`)}</dl>`, shown && html `<p class="fine">${shown}</p>`, !site &&
907
+ e.visibility === 'public' &&
908
+ html `
909
+ <p class="fine">Embed on your site</p>
910
+ <pre><code>${embed}</code></pre>
911
+ `, html `
912
+ ${(goes.length > 0 || listing) && html `<div class="actions">${goes}${listing}</div>`}
913
+ ${ending}
914
+ `)}
915
+ </div>
916
+ `;
776
917
  }
777
918
  }
@@ -1,13 +1,15 @@
1
1
  import type { Store } from './store.js';
2
2
  /**
3
3
  * Requests a minute for one signed-in holder, one client without a session, one site's
4
- * holders, and one site's backend reading its subjects' connections.
4
+ * holders, and one site's backend reading its subjects' connections. A site registered
5
+ * here gets `registered` for each of the last two, unless the owner sets it another.
5
6
  */
6
7
  export declare const limits: {
7
8
  session: number;
8
9
  client: number;
9
10
  site: number;
10
11
  read: number;
12
+ registered: number;
11
13
  };
12
14
  /**
13
15
  * Counts a request against every bucket it falls in, and refuses it if any is full. All or
@@ -24,7 +26,7 @@ export declare function allow(storage: Store, buckets: {
24
26
  export declare const client: (address: string | null) => string;
25
27
  /**
26
28
  * Deletes every record the handoff and the limits leave behind once it has expired:
27
- * handoffs that never reached `/handoff/accept`, site sessions, and idle buckets. Each is written
28
- * with its expiry under its own prefix for exactly this.
29
+ * handoffs that never reached `/handoff/accept`, site and dashboard sessions, and idle
30
+ * buckets. Each is written with its expiry under its own prefix for exactly this.
29
31
  */
30
32
  export declare function prune(storage: Store): Promise<void>;
@@ -1,9 +1,10 @@
1
1
  import { hash } from '../server/service.js';
2
2
  /**
3
3
  * Requests a minute for one signed-in holder, one client without a session, one site's
4
- * holders, and one site's backend reading its subjects' connections.
4
+ * holders, and one site's backend reading its subjects' connections. A site registered
5
+ * here gets `registered` for each of the last two, unless the owner sets it another.
5
6
  */
6
- export const limits = { session: 60, client: 30, site: 600, read: 600 };
7
+ export const limits = { session: 60, client: 30, site: 600, read: 600, registered: 60 };
7
8
  const windowMs = 60000;
8
9
  /**
9
10
  * Counts a request against every bucket it falls in, and refuses it if any is full. All or
@@ -32,12 +33,12 @@ export async function allow(storage, buckets) {
32
33
  export const client = (address) => `client/${hash(address ?? 'unknown')}`;
33
34
  /**
34
35
  * Deletes every record the handoff and the limits leave behind once it has expired:
35
- * handoffs that never reached `/handoff/accept`, site sessions, and idle buckets. Each is written
36
- * with its expiry under its own prefix for exactly this.
36
+ * handoffs that never reached `/handoff/accept`, site and dashboard sessions, and idle
37
+ * buckets. Each is written with its expiry under its own prefix for exactly this.
37
38
  */
38
39
  export async function prune(storage) {
39
40
  const now = Date.now();
40
- for (const prefix of ['site/state/', 'site/session/', 'rate/']) {
41
+ for (const prefix of ['site/state/', 'site/session/', 'site/dashboard/', 'rate/']) {
41
42
  for (const [key, value] of await storage.list(prefix))
42
43
  if (!(value.expiresAt > now))
43
44
  await storage.delete(key);
@@ -0,0 +1,24 @@
1
+ import type { Provider } from '../core/index.js';
2
+ import { type Sites } from './sites.js';
3
+ import type { Store } from './store.js';
4
+ /**
5
+ * Where the instance owner oversees the sites registered here: suspends one by hand, sets
6
+ * its request limit, and approves it for YouTube. Sites in `SITES` are listed apart, read
7
+ * only, since they are changed in the configuration.
8
+ */
9
+ export declare class OwnerSites {
10
+ private readonly store;
11
+ private readonly sites;
12
+ private readonly providers;
13
+ constructor(store: Store, sites: Sites, providers: Provider[]);
14
+ /**
15
+ * Every stored site, with how it stands and the owner's controls over it, then every site
16
+ * in `SITES`.
17
+ */
18
+ page(): Promise<Response>;
19
+ /** One of the page's forms, sent to `path`. Every one returns to the page. */
20
+ change(path: string, request: Request): Promise<Response>;
21
+ /** A site in `SITES`, as it is configured. */
22
+ private configuredRow;
23
+ private row;
24
+ }