@bkazemi/verily 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/instance/auth.js +6 -9
- package/dist/instance/cloudflare.js +3 -2
- package/dist/instance/dashboard.d.ts +41 -0
- package/dist/instance/dashboard.js +334 -0
- package/dist/instance/env.d.ts +2 -0
- package/dist/instance/env.js +1 -0
- package/dist/instance/index.d.ts +16 -1
- package/dist/instance/index.js +224 -83
- package/dist/instance/limits.d.ts +5 -3
- package/dist/instance/limits.js +6 -5
- package/dist/instance/owner-sites.d.ts +17 -0
- package/dist/instance/owner-sites.js +130 -0
- package/dist/instance/page.d.ts +11 -1
- package/dist/instance/page.js +22 -2
- package/dist/instance/register.d.ts +44 -0
- package/dist/instance/register.js +254 -0
- package/dist/instance/routes.js +14 -0
- package/dist/instance/sites.d.ts +135 -4
- package/dist/instance/sites.js +326 -57
- package/dist/server/email.js +134 -46
- package/dist/server/html.d.ts +34 -0
- package/dist/server/html.js +67 -0
- package/dist/server/index.js +364 -163
- package/dist/server/render.d.ts +10 -7
- package/dist/server/render.js +71 -21
- package/dist/server/service.d.ts +3 -1
- package/dist/server/service.js +5 -1
- package/dist/server/style.d.ts +1 -1
- package/dist/server/style.js +37 -1
- package/dist/site/index.d.ts +6 -0
- package/dist/site/index.js +8 -1
- package/dist/verily.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/instance/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { dialogPath, dialogRoute, lastProved, localSide, routeOf, routes, statusLabel, } from '../core/index.js';
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
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 = () =>
|
|
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',
|
|
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
|
|
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
|
-
|
|
197
|
+
this.dashboard = new Dashboard(this.store, this.sites, options.providers, origin.host, options.reportUrl);
|
|
198
|
+
this.ownerSites = new OwnerSites(this.store, this.sites);
|
|
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
|
-
|
|
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
|
|
230
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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, [
|
|
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
|
-
?
|
|
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
|
|
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(
|
|
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
|
-
?
|
|
390
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
|
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
|
|
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
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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">Registered 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
|
|
653
|
-
|
|
654
|
-
|
|
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
|
|
660
|
-
|
|
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
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
716
|
-
|
|
717
|
-
|
|
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
|
|
751
|
-
acts &&
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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) =>
|
|
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
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
|
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>;
|
package/dist/instance/limits.js
CHANGED
|
@@ -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
|
|
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,17 @@
|
|
|
1
|
+
import { type Sites } from './sites.js';
|
|
2
|
+
import type { Store } from './store.js';
|
|
3
|
+
/**
|
|
4
|
+
* Where the instance owner oversees the sites registered here: suspends one by hand, sets
|
|
5
|
+
* its request limit, and approves it for YouTube. Sites in `SITES` are the host's, and are
|
|
6
|
+
* not listed.
|
|
7
|
+
*/
|
|
8
|
+
export declare class OwnerSites {
|
|
9
|
+
private readonly store;
|
|
10
|
+
private readonly sites;
|
|
11
|
+
constructor(store: Store, sites: Sites);
|
|
12
|
+
/** Every stored site, with how it stands and the owner's controls over it. */
|
|
13
|
+
page(): Promise<Response>;
|
|
14
|
+
/** One of the page's forms, sent to `path`. Every one returns to the page. */
|
|
15
|
+
change(path: string, request: Request): Promise<Response>;
|
|
16
|
+
private row;
|
|
17
|
+
}
|