@bkazemi/verily 0.7.0 → 0.8.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.
- package/README.md +183 -94
- package/dist/browser/mark.d.ts +2 -6
- package/dist/browser/mark.js +6 -12
- package/dist/browser/provider-mark.d.ts +3 -3
- package/dist/browser/provider-mark.js +15 -90
- package/dist/instance/auth.d.ts +14 -0
- package/dist/instance/auth.js +80 -0
- package/dist/instance/bin.d.ts +2 -0
- package/dist/instance/bin.js +36 -0
- package/dist/instance/cloudflare.d.ts +57 -0
- package/dist/instance/cloudflare.js +71 -0
- package/dist/instance/env.d.ts +51 -0
- package/dist/instance/env.js +84 -0
- package/dist/instance/index.d.ts +136 -0
- package/dist/instance/index.js +777 -0
- package/dist/instance/init.d.ts +6 -0
- package/dist/instance/init.js +28 -0
- package/dist/instance/limits.d.ts +30 -0
- package/dist/instance/limits.js +45 -0
- package/dist/instance/node.d.ts +13 -0
- package/dist/instance/node.js +43 -0
- package/dist/instance/page.d.ts +18 -0
- package/dist/instance/page.js +30 -0
- package/dist/instance/postgres.d.ts +19 -0
- package/dist/instance/postgres.js +83 -0
- package/dist/instance/routes.d.ts +7 -0
- package/dist/instance/routes.js +22 -0
- package/dist/instance/serve.d.ts +31 -0
- package/dist/instance/serve.js +49 -0
- package/dist/instance/sites.d.ts +169 -0
- package/dist/instance/sites.js +413 -0
- package/dist/instance/starter.d.ts +6 -0
- package/dist/instance/starter.js +99 -0
- package/dist/instance/store.d.ts +20 -0
- package/dist/instance/store.js +11 -0
- package/dist/instance/undici.d.ts +8 -0
- package/dist/instance/undici.js +9 -0
- package/dist/marks.d.ts +44 -0
- package/dist/marks.js +98 -0
- package/dist/server/index.js +46 -67
- package/dist/server/render.d.ts +49 -0
- package/dist/server/render.js +104 -0
- package/dist/server/style.d.ts +1 -1
- package/dist/server/style.js +145 -1
- package/dist/verily.js +96 -104
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +30 -2
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { dialogPath, dialogRoute, lastProved, localSide, routeOf, routes, statusLabel, } from '../core/index.js';
|
|
2
|
+
import { escape } from '../server/escape.js';
|
|
3
|
+
import { card, date, distance, externalCard, marked, page, verdict } from '../server/render.js';
|
|
4
|
+
import { toneOf } from '../marks.js';
|
|
5
|
+
import { version } from '../version.js';
|
|
6
|
+
import { createVerily, generateSigningKey } from '../server/index.js';
|
|
7
|
+
import { recordStorage } from './store.js';
|
|
8
|
+
import { OwnerAuth } from './auth.js';
|
|
9
|
+
import { sessionCookie, Sites, peek } from './sites.js';
|
|
10
|
+
import { allow, client, limits, prune } from './limits.js';
|
|
11
|
+
import { own } from './routes.js';
|
|
12
|
+
import { html, safeHeaders, unavailable } from './page.js';
|
|
13
|
+
export { fromEnv } from './env.js';
|
|
14
|
+
export { registry } from './sites.js';
|
|
15
|
+
export { recordStorage } from './store.js';
|
|
16
|
+
/**
|
|
17
|
+
* The page at the root for anybody not signed in, and the only thing it does. Whoever
|
|
18
|
+
* reaches it either holds the owner key or has no business past it, so it carries one
|
|
19
|
+
* field and no account of what lies behind.
|
|
20
|
+
*/
|
|
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');
|
|
22
|
+
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
|
+
/** 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');
|
|
31
|
+
/**
|
|
32
|
+
* Whether a request is for one of the library's own management routes, which a site
|
|
33
|
+
* session does not get. Which routes those are is the library's to say.
|
|
34
|
+
*/
|
|
35
|
+
const manages = (method, path) => {
|
|
36
|
+
const name = routeOf(method, path);
|
|
37
|
+
return name !== undefined && 'manages' in routes[name];
|
|
38
|
+
};
|
|
39
|
+
/** GETs that create state, and so are limited like every POST. */
|
|
40
|
+
const limitedGets = ['/handoff/request', '/handoff/accept', '/callback'];
|
|
41
|
+
/** Matches the library's own bound: large enough for a pasted key, small enough to buffer. */
|
|
42
|
+
const maxBodyBytes = 65536;
|
|
43
|
+
/** How a holder may have an account listed, as the library's mark route takes it. */
|
|
44
|
+
const listings = ['preferred', 'current', 'unused', 'retired'];
|
|
45
|
+
/** Said on the page that asks before an account is retired. */
|
|
46
|
+
const retiringNote = 'Retiring keeps this record as history. It will no longer read as verified, and who can read it cannot be changed afterwards.';
|
|
47
|
+
/** A connection's card heading: the provider's mark and name, and anything after the name. */
|
|
48
|
+
const cardHeading = (e, after = '') => marked(`${e.providerName ?? e.provider}${after}`, e.provider, e.attestations.external[0]?.method);
|
|
49
|
+
/** Carries a flow's binding for a page on another origin, where the cookie cannot. */
|
|
50
|
+
const flowHeader = 'X-Verily-Flow';
|
|
51
|
+
/** A session or a binding as this instance mints them, and nothing that could bend a header. */
|
|
52
|
+
const opaque = /^[A-Za-z0-9_-]{16,200}$/;
|
|
53
|
+
/**
|
|
54
|
+
* Lets the page that asked read the answer. These requests carry no cookie and are given
|
|
55
|
+
* none, so naming whichever origin asked gives nothing away, least of all which origins
|
|
56
|
+
* are registered sites: what a caller may do is decided by the token it holds.
|
|
57
|
+
*/
|
|
58
|
+
function shared(response, request) {
|
|
59
|
+
const answer = new Response(response.body, response);
|
|
60
|
+
answer.headers.set('Access-Control-Allow-Origin', request.headers.get('origin') ?? '*');
|
|
61
|
+
answer.headers.set('Access-Control-Expose-Headers', flowHeader);
|
|
62
|
+
answer.headers.append('Vary', 'Origin');
|
|
63
|
+
return answer;
|
|
64
|
+
}
|
|
65
|
+
const preflight = (request) => shared(new Response(null, {
|
|
66
|
+
status: 204,
|
|
67
|
+
headers: {
|
|
68
|
+
'Access-Control-Allow-Methods': 'GET, POST',
|
|
69
|
+
'Access-Control-Allow-Headers': `Authorization, Content-Type, ${flowHeader}`,
|
|
70
|
+
'Access-Control-Max-Age': '600',
|
|
71
|
+
},
|
|
72
|
+
}), request);
|
|
73
|
+
const data = (value, headers = {}) => new Response(JSON.stringify(value), {
|
|
74
|
+
headers: { ...safeHeaders, 'Content-Type': 'application/json', ...headers },
|
|
75
|
+
});
|
|
76
|
+
/**
|
|
77
|
+
* A JSON object from a request's body, or an empty one. Only a body sent as JSON is read:
|
|
78
|
+
* a form cannot send that type, so nothing here can be reached by a form on another page.
|
|
79
|
+
*/
|
|
80
|
+
async function fields(request) {
|
|
81
|
+
if (!request.headers.get('content-type')?.startsWith('application/json'))
|
|
82
|
+
return {};
|
|
83
|
+
return parsed(await request.text()) ?? {};
|
|
84
|
+
}
|
|
85
|
+
/** The JSON object a text holds, or undefined if it holds anything else. */
|
|
86
|
+
function parsed(text) {
|
|
87
|
+
try {
|
|
88
|
+
const value = JSON.parse(text);
|
|
89
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
90
|
+
? value
|
|
91
|
+
: undefined;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Run in the sign-in window a site's dialog opens. The window is on this origin, so the
|
|
99
|
+
* flow's binding can be a cookie here, where the provider's callback will look for it. The
|
|
100
|
+
* page that opened the window sends the binding, and this script says which origin that
|
|
101
|
+
* page is on. The instance takes the binding only if that origin is the site the flow
|
|
102
|
+
* belongs to, so a page elsewhere cannot have someone sign in to a flow it started.
|
|
103
|
+
*/
|
|
104
|
+
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'), {
|
|
106
|
+
headers: {
|
|
107
|
+
...safeHeaders,
|
|
108
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
109
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
/** Buffer only bounded request bodies before passing them to the library. */
|
|
113
|
+
async function boundedBody(request) {
|
|
114
|
+
if (!request.body)
|
|
115
|
+
return;
|
|
116
|
+
const reader = request.body.getReader();
|
|
117
|
+
const chunks = [];
|
|
118
|
+
let length = 0;
|
|
119
|
+
while (true) {
|
|
120
|
+
const { value, done } = await reader.read();
|
|
121
|
+
if (done)
|
|
122
|
+
break;
|
|
123
|
+
length += value.byteLength;
|
|
124
|
+
if (length > maxBodyBytes) {
|
|
125
|
+
await reader.cancel();
|
|
126
|
+
throw new Error('Body too large');
|
|
127
|
+
}
|
|
128
|
+
chunks.push(value);
|
|
129
|
+
}
|
|
130
|
+
const body = new Uint8Array(length);
|
|
131
|
+
let offset = 0;
|
|
132
|
+
for (const chunk of chunks) {
|
|
133
|
+
body.set(chunk, offset);
|
|
134
|
+
offset += chunk.byteLength;
|
|
135
|
+
}
|
|
136
|
+
return body.buffer;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The checks that need nothing stored: the origin, the method, and a bound on the body.
|
|
140
|
+
* Gives back the response to send, or the request with its body read. `handle` runs this
|
|
141
|
+
* itself. A host that passes requests on to another process can run it first, so that
|
|
142
|
+
* process is not woken for a request this turns away.
|
|
143
|
+
*/
|
|
144
|
+
export async function admit(request, origin) {
|
|
145
|
+
const url = new URL(request.url);
|
|
146
|
+
if (url.origin !== origin)
|
|
147
|
+
return unavailable();
|
|
148
|
+
// Asked by a browser before a page on another origin may send the dialog's headers.
|
|
149
|
+
if (request.method === 'OPTIONS' &&
|
|
150
|
+
(dialogPath(url.pathname) || url.pathname === '/site/session'))
|
|
151
|
+
return preflight(request);
|
|
152
|
+
if (!['GET', 'POST'].includes(request.method))
|
|
153
|
+
return unavailable(405);
|
|
154
|
+
let body;
|
|
155
|
+
try {
|
|
156
|
+
body = await boundedBody(request);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return html('Request too large', '', 413, 'single');
|
|
160
|
+
}
|
|
161
|
+
return new Request(request.url, {
|
|
162
|
+
method: request.method,
|
|
163
|
+
headers: request.headers,
|
|
164
|
+
body,
|
|
165
|
+
redirect: 'manual',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
export function createInstance(options) {
|
|
169
|
+
return new Server(options);
|
|
170
|
+
}
|
|
171
|
+
class Server {
|
|
172
|
+
options;
|
|
173
|
+
app;
|
|
174
|
+
store;
|
|
175
|
+
origin;
|
|
176
|
+
auth;
|
|
177
|
+
sites;
|
|
178
|
+
local;
|
|
179
|
+
constructor(options) {
|
|
180
|
+
this.options = options;
|
|
181
|
+
const origin = new URL(options.origin);
|
|
182
|
+
if (origin.protocol !== 'https:' || origin.origin !== options.origin)
|
|
183
|
+
throw new Error('The origin must be an HTTPS origin without a trailing slash');
|
|
184
|
+
this.origin = options.origin;
|
|
185
|
+
this.store = options.store;
|
|
186
|
+
this.auth = new OwnerAuth(this.store, options.ownerKey);
|
|
187
|
+
this.sites = new Sites(this.store, options.sites ?? new Map());
|
|
188
|
+
const siteOrigins = [...this.sites.sites.values()].map((site) => site.origin);
|
|
189
|
+
// A site with no user accounts must not have its subject described as one, so the
|
|
190
|
+
// owner's kind is whatever the host gave, absent included.
|
|
191
|
+
this.local = { id: 'site-owner', ...options.owner };
|
|
192
|
+
this.app = createVerily({
|
|
193
|
+
storage: recordStorage(this.store),
|
|
194
|
+
providers: options.providers,
|
|
195
|
+
sendLimits: { day: options.mailPerDay },
|
|
196
|
+
...(options.signingKey === false
|
|
197
|
+
? {}
|
|
198
|
+
: { signingKey: options.signingKey ?? (() => this.signingKey()) }),
|
|
199
|
+
// Whether this origin's DNS names its signing key, and whether a site's names this
|
|
200
|
+
// instance as its verifier. Both go to the resolver the domain method already asks.
|
|
201
|
+
dns: true,
|
|
202
|
+
// The whole origin is this instance, so the library's routes sit at its root.
|
|
203
|
+
baseUrl: this.origin,
|
|
204
|
+
siteName: options.siteName,
|
|
205
|
+
// The verifier is the origin that ran the flow and serves the evidence, which a
|
|
206
|
+
// reader can check. It is not the site name: that host is claimed, not demonstrated.
|
|
207
|
+
// The host alone: how the backend is operated is not something a reader verifies.
|
|
208
|
+
verifierName: origin.host,
|
|
209
|
+
// Each published proof is re-read every six hours, so one taken down is noticed soon.
|
|
210
|
+
recheckMs: options.recheckMs ?? 6 * 3600000,
|
|
211
|
+
// 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],
|
|
214
|
+
reportUrl: options.reportUrl,
|
|
215
|
+
// A site session is the more specific: it was opened for this holder moments ago.
|
|
216
|
+
authenticate: async (request) => (await this.sites.open(request))?.session.local ??
|
|
217
|
+
((await this.auth.authenticated(request)) ? this.local : undefined),
|
|
218
|
+
context: async (request) => {
|
|
219
|
+
const open = await this.sites.open(request);
|
|
220
|
+
return open
|
|
221
|
+
? {
|
|
222
|
+
site: open.session.site,
|
|
223
|
+
purpose: open.session.purpose,
|
|
224
|
+
txn: open.session.txn,
|
|
225
|
+
session: open.key,
|
|
226
|
+
}
|
|
227
|
+
: undefined;
|
|
228
|
+
},
|
|
229
|
+
// A site's holders get the providers that site chose. The owner has no site.
|
|
230
|
+
providersFor: (local) => this.siteOf(local)?.providers,
|
|
231
|
+
visibilityFor: (local) => this.siteOf(local)?.visibility,
|
|
232
|
+
finish: ({ id, context, local, result }) => this.sites.finish(id, context, local, result),
|
|
233
|
+
});
|
|
234
|
+
this.app.service.validateLocal(this.local);
|
|
235
|
+
}
|
|
236
|
+
/** The library's service, for a host that needs more than requests and upkeep. */
|
|
237
|
+
get service() {
|
|
238
|
+
return this.app.service;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* What a host runs on a timer, about hourly: drops what has expired, and re-reads the
|
|
242
|
+
* published proofs that are due. The default of 10 per provider each hour stays inside
|
|
243
|
+
* the rate limit a provider gives an unauthenticated caller.
|
|
244
|
+
*/
|
|
245
|
+
async upkeep() {
|
|
246
|
+
await this.app.service.prune();
|
|
247
|
+
await this.app.service.recheck(this.options.recheckBudget ?? 10);
|
|
248
|
+
await this.auth.prune();
|
|
249
|
+
await prune(this.store);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Answers one request. `address` is the client's address as the host knows it, which
|
|
253
|
+
* rate limits count by. The instance reads no forwarding header itself, since which one
|
|
254
|
+
* can be trusted depends on what the host sits behind.
|
|
255
|
+
*/
|
|
256
|
+
async handle(request, address = null) {
|
|
257
|
+
let response;
|
|
258
|
+
try {
|
|
259
|
+
const admitted = await admit(request, this.origin);
|
|
260
|
+
response = admitted instanceof Response ? admitted : await this.route(admitted, address);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
response = html('Temporarily unavailable', '<p>Try again shortly.</p>', 503, 'single');
|
|
264
|
+
}
|
|
265
|
+
// An approval or disconnect form redirects on to the holder's site, and Chromium holds
|
|
266
|
+
// a form's redirects to form-action. Only a holder that site sent here is told its
|
|
267
|
+
// origin, and only that one: named on every response, the policy would tell any visitor
|
|
268
|
+
// which sites this instance serves.
|
|
269
|
+
const policy = response.headers.get('Content-Security-Policy');
|
|
270
|
+
const found = policy ? await this.sites.session(request).catch(() => undefined) : undefined;
|
|
271
|
+
if (policy && found)
|
|
272
|
+
response.headers.set('Content-Security-Policy', policy.replace(/form-action [^;]*/, (allowed) => `${allowed} ${this.site(found.session).origin}`));
|
|
273
|
+
return response;
|
|
274
|
+
}
|
|
275
|
+
async route(request, address) {
|
|
276
|
+
const url = new URL(request.url);
|
|
277
|
+
// The sign-in window's own script, and nothing else. It sets a cookie that decides
|
|
278
|
+
// whose flow this browser signs in to, so it is held to the origin the browser itself
|
|
279
|
+
// reports, before a missing or opaque one is read as ours below: a form in a sandboxed
|
|
280
|
+
// frame has an opaque origin, and can put any origin it likes in its body.
|
|
281
|
+
if (request.method === 'POST' && url.pathname === '/site/enter') {
|
|
282
|
+
if (request.headers.get('origin') !== this.origin ||
|
|
283
|
+
!request.headers.get('content-type')?.startsWith('application/json'))
|
|
284
|
+
return unavailable(403);
|
|
285
|
+
if (!(await this.allowed(request, url, address)))
|
|
286
|
+
return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
|
|
287
|
+
return this.enter(request);
|
|
288
|
+
}
|
|
289
|
+
if (request.method === 'POST' &&
|
|
290
|
+
!own.has(url.pathname) &&
|
|
291
|
+
(!request.headers.has('origin') || request.headers.get('origin') === 'null')) {
|
|
292
|
+
const headers = new Headers(request.headers);
|
|
293
|
+
headers.set('origin', this.origin);
|
|
294
|
+
request = new Request(request, { headers });
|
|
295
|
+
}
|
|
296
|
+
// The connect dialog on a site's own page, which counts its requests once it knows
|
|
297
|
+
// whose they are.
|
|
298
|
+
if (request.headers.has('authorization') && dialogPath(url.pathname))
|
|
299
|
+
return shared(await this.dialog(request, url, address), request);
|
|
300
|
+
if (!(await this.allowed(request, url, address)))
|
|
301
|
+
return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
|
|
302
|
+
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 }]));
|
|
304
|
+
return shared(opened === 'limited'
|
|
305
|
+
? html('Too many requests', '<p>Try again later.</p>', 429, 'single')
|
|
306
|
+
: opened
|
|
307
|
+
? data({ session: opened.session })
|
|
308
|
+
: unavailable(), request);
|
|
309
|
+
}
|
|
310
|
+
if (request.method === 'GET' && url.pathname === '/site/enter')
|
|
311
|
+
return enterPage();
|
|
312
|
+
if (request.method === 'GET' && url.pathname === '/site/enter.js')
|
|
313
|
+
return new Response(enterScript, {
|
|
314
|
+
headers: { ...safeHeaders, 'Content-Type': 'text/javascript' },
|
|
315
|
+
});
|
|
316
|
+
if (request.method === 'POST') {
|
|
317
|
+
if (url.pathname === '/login') {
|
|
318
|
+
if (!(await this.auth.allow('login', 10, 15 * 60000)))
|
|
319
|
+
return html('Too many requests', '<p>Try again in fifteen minutes.</p>', 429, 'single');
|
|
320
|
+
const key = new URLSearchParams(await request.text()).get('key') ?? '';
|
|
321
|
+
const cookie = await this.auth.login(key);
|
|
322
|
+
return cookie ? redirect(cookie) : signIn('<p>Incorrect owner key.</p>', 403);
|
|
323
|
+
}
|
|
324
|
+
if (url.pathname === '/logout')
|
|
325
|
+
return redirect(await this.auth.logout(request));
|
|
326
|
+
if (request.headers.get('origin') !== this.origin)
|
|
327
|
+
return unavailable(403);
|
|
328
|
+
if (url.pathname === '/disconnect')
|
|
329
|
+
return this.disconnect(request);
|
|
330
|
+
if (url.pathname === '/mark')
|
|
331
|
+
return this.mark(request);
|
|
332
|
+
}
|
|
333
|
+
if (request.method === 'GET' && url.pathname === '/site/connections')
|
|
334
|
+
return this.read(request, address);
|
|
335
|
+
if (request.method === 'GET' && url.pathname === '/handoff/request') {
|
|
336
|
+
const begun = await this.sites.begin(url.searchParams.get('site'), url.searchParams.get('purpose'));
|
|
337
|
+
return begun ? see(begun.location, [begun.cookie]) : unavailable();
|
|
338
|
+
}
|
|
339
|
+
if (request.method === 'GET' && url.pathname === '/handoff/accept') {
|
|
340
|
+
const started = await this.sites.start(request, url.searchParams.get('token') ?? '', (local) => this.app.service.validateLocal(local));
|
|
341
|
+
// Straight on, so the token leaves the address bar.
|
|
342
|
+
return started
|
|
343
|
+
? see(started.purpose === 'connect' ? '/verify' : '/', started.cookies)
|
|
344
|
+
: refused();
|
|
345
|
+
}
|
|
346
|
+
// Where a reader who has only this domain's name finds the keys its records are signed by.
|
|
347
|
+
if (request.method === 'GET' && url.pathname === '/.well-known/verily-keys.json')
|
|
348
|
+
return new Response(JSON.stringify({
|
|
349
|
+
keys: await this.app.service.keys(),
|
|
350
|
+
dns: await this.app.service.keyRecords(),
|
|
351
|
+
}), {
|
|
352
|
+
headers: {
|
|
353
|
+
...safeHeaders,
|
|
354
|
+
'Content-Type': 'application/json',
|
|
355
|
+
'Access-Control-Allow-Origin': '*',
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
// Every path that is not one of the instance's own is the library's to answer.
|
|
359
|
+
if (!own.has(url.pathname)) {
|
|
360
|
+
// A site session removes a link, or lists an account another way, through this
|
|
361
|
+
// instance, which reports it back to the site, and has no sharing links. The library's
|
|
362
|
+
// routes would do neither.
|
|
363
|
+
if (manages(request.method, url.pathname) && (await this.sites.open(request)))
|
|
364
|
+
return unavailable();
|
|
365
|
+
// Some browsers omit Origin on native same-origin form posts. The request
|
|
366
|
+
// already passed the exact public-origin check above; flow cookies are
|
|
367
|
+
// SameSite=Lax and the library still requires the authenticated owner.
|
|
368
|
+
if (request.method === 'POST' && !request.headers.has('origin')) {
|
|
369
|
+
const headers = new Headers(request.headers);
|
|
370
|
+
headers.set('origin', this.origin);
|
|
371
|
+
request = new Request(request, { headers });
|
|
372
|
+
}
|
|
373
|
+
return this.app.handle(request);
|
|
374
|
+
}
|
|
375
|
+
if (request.method === 'GET' && url.pathname === '/confirm')
|
|
376
|
+
return this.confirm(request, url);
|
|
377
|
+
// Every record on the instance, for whoever holds the owner key and nobody else.
|
|
378
|
+
if (request.method === 'GET' && url.pathname === '/records')
|
|
379
|
+
return (await this.auth.authenticated(request)) ? this.records() : unavailable();
|
|
380
|
+
if (request.method !== 'GET' || url.pathname !== '/')
|
|
381
|
+
return unavailable();
|
|
382
|
+
const found = await this.sites.session(request);
|
|
383
|
+
if (found && !found.session.closed && !found.session.operation)
|
|
384
|
+
return this.settings(found.session.local, this.site(found.session));
|
|
385
|
+
// A disconnect or a mark that failed partway is the one thing left to do with this session.
|
|
386
|
+
if (found?.session.operation && found.session.operation.finishedAt === undefined) {
|
|
387
|
+
const { connection, mark } = found.session.operation;
|
|
388
|
+
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');
|
|
391
|
+
}
|
|
392
|
+
if (await this.auth.authenticated(request))
|
|
393
|
+
return this.settings(this.local);
|
|
394
|
+
if (found) {
|
|
395
|
+
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');
|
|
397
|
+
}
|
|
398
|
+
return signIn();
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* One request from the connect dialog on a site's own page. The page holds the session
|
|
402
|
+
* and the flow's binding and sends them as headers, because this instance's cookies are
|
|
403
|
+
* another site's on that page and a browser may not send them. Here they are put where
|
|
404
|
+
* the library looks for them, and a binding the library sets comes back as a header. The
|
|
405
|
+
* session works only from its own site's origin, and only for the dialog's requests.
|
|
406
|
+
*/
|
|
407
|
+
async dialog(request, url, address) {
|
|
408
|
+
// Counted against the sender's address, as a refused read is: a request turned away
|
|
409
|
+
// here never reaches the counting a served one gets, and each costs a lookup.
|
|
410
|
+
const turnedAway = async () => (await allow(this.store, [{ key: client(address), limit: limits.client }]))
|
|
411
|
+
? unavailable()
|
|
412
|
+
: html('Too many requests', '<p>Try again later.</p>', 429, 'single');
|
|
413
|
+
const session = request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1] ?? '';
|
|
414
|
+
const binding = request.headers.get(flowHeader) ?? '';
|
|
415
|
+
const post = request.method === 'POST';
|
|
416
|
+
const body = post ? await request.text() : undefined;
|
|
417
|
+
if (!opaque.test(session) || (binding && !opaque.test(binding)))
|
|
418
|
+
return turnedAway();
|
|
419
|
+
// What the dialogs on a registered site's own page may ask for is the library's to say:
|
|
420
|
+
// the methods, a flow's start, state, proof and approval, and the removal or the
|
|
421
|
+
// listing of one of the holder's own accounts. Everything else stays same-origin.
|
|
422
|
+
const route = dialogRoute(request.method, url.pathname, url.searchParams);
|
|
423
|
+
if (!route)
|
|
424
|
+
return turnedAway();
|
|
425
|
+
if (post) {
|
|
426
|
+
if (!request.headers.get('content-type')?.startsWith('application/json'))
|
|
427
|
+
return turnedAway();
|
|
428
|
+
// The dialog connects an account or renews one. Every other kind of flow has a page
|
|
429
|
+
// of its own here. Parsed as the library parses it, so the kind checked is the kind
|
|
430
|
+
// it will run.
|
|
431
|
+
if (route === 'start' && !['connect', 'renew'].includes(String(parsed(body)?.kind)))
|
|
432
|
+
return turnedAway();
|
|
433
|
+
}
|
|
434
|
+
const inner = new Request(request.url, {
|
|
435
|
+
method: request.method,
|
|
436
|
+
headers: {
|
|
437
|
+
cookie: `${sessionCookie}=${session}${binding ? `; ${this.app.flowCookieName}=${binding}` : ''}`,
|
|
438
|
+
origin: this.origin,
|
|
439
|
+
...(post ? { 'content-type': 'application/json' } : {}),
|
|
440
|
+
},
|
|
441
|
+
body,
|
|
442
|
+
});
|
|
443
|
+
const found = await this.sites.open(inner);
|
|
444
|
+
if (!found || this.site(found.session).origin !== request.headers.get('origin'))
|
|
445
|
+
return turnedAway();
|
|
446
|
+
if (!(await this.allowed(inner, url, address)))
|
|
447
|
+
return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
|
|
448
|
+
const response = await this.app.handle(inner);
|
|
449
|
+
const answer = new Response(response.body, response);
|
|
450
|
+
const set = response.headers
|
|
451
|
+
.getSetCookie()
|
|
452
|
+
.find((cookie) => cookie.startsWith(`${this.app.flowCookieName}=`))
|
|
453
|
+
?.slice(this.app.flowCookieName.length + 1)
|
|
454
|
+
.split(';')[0];
|
|
455
|
+
answer.headers.delete('Set-Cookie');
|
|
456
|
+
if (set)
|
|
457
|
+
answer.headers.set(flowHeader, set);
|
|
458
|
+
return answer;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Gives the sign-in window the binding of a flow a site's dialog started, as the cookie
|
|
462
|
+
* the provider's callback checks. `origin` is where the page that sent the binding is,
|
|
463
|
+
* as the window's own script saw it. It must be the site the flow's subject belongs to.
|
|
464
|
+
*/
|
|
465
|
+
async enter(request) {
|
|
466
|
+
const { flow: id, binding, origin } = await fields(request);
|
|
467
|
+
if (typeof id !== 'string' || typeof binding !== 'string' || !opaque.test(binding))
|
|
468
|
+
return unavailable();
|
|
469
|
+
const flow = await this.app.service.flow(id, binding).catch(() => undefined);
|
|
470
|
+
const site = flow?.local && this.siteOf(flow.local);
|
|
471
|
+
if (!flow || !['connect', 'renew'].includes(flow.kind) || !site || site.origin !== origin)
|
|
472
|
+
return unavailable();
|
|
473
|
+
return new Response(null, {
|
|
474
|
+
status: 204,
|
|
475
|
+
headers: { ...safeHeaders, 'Set-Cookie': this.app.flowCookie(binding) },
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
/** The site a subject belongs to, by its id's prefix. The owner's id has none. */
|
|
479
|
+
siteOf(local) {
|
|
480
|
+
return local.id.includes(':') ? this.sites.sites.get(local.id.split(':')[0]) : undefined;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* A site's backend reading its own subjects' connections, unlisted ones included, which
|
|
484
|
+
* nobody else can read. This is what lets a site show a link to its own users without the
|
|
485
|
+
* link being public. A refused request counts against the caller's address, and a good
|
|
486
|
+
* one against the site's own allowance, apart from the one its holders' handoffs share.
|
|
487
|
+
*/
|
|
488
|
+
async read(request, address) {
|
|
489
|
+
const asked = this.sites.read(request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1] ?? '');
|
|
490
|
+
if (!(await allow(this.store, [
|
|
491
|
+
asked
|
|
492
|
+
? { key: `read/${asked.site.id}`, limit: limits.read }
|
|
493
|
+
: { key: client(address), limit: limits.client },
|
|
494
|
+
])))
|
|
495
|
+
return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
|
|
496
|
+
if (!asked)
|
|
497
|
+
return unavailable();
|
|
498
|
+
// No prototype, so an id such as `__proto__` is an entry like any other.
|
|
499
|
+
const connections = Object.create(null);
|
|
500
|
+
for (const id of asked.ids)
|
|
501
|
+
connections[id] = await this.app.service.mine({
|
|
502
|
+
id: `${asked.site.id}:${id}`,
|
|
503
|
+
});
|
|
504
|
+
return new Response(JSON.stringify({ connections }), {
|
|
505
|
+
headers: { ...safeHeaders, 'Content-Type': 'application/json' },
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
/** This installation's own signing key, made the first time a record is signed. */
|
|
509
|
+
async signingKey() {
|
|
510
|
+
const kept = await this.store.get('signing/pgp');
|
|
511
|
+
if (kept)
|
|
512
|
+
return kept;
|
|
513
|
+
const made = await generateSigningKey(new URL(this.origin).host);
|
|
514
|
+
await this.store.put('signing/pgp', made);
|
|
515
|
+
return made;
|
|
516
|
+
}
|
|
517
|
+
site(session) {
|
|
518
|
+
return this.sites.sites.get(session.site);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Counts a request that creates state: every POST, and the GETs that start a handoff, a
|
|
522
|
+
* session or a flow. A signed-in holder has a bucket of their own and anyone else is
|
|
523
|
+
* counted by address, so one visitor cannot lock everyone else out. A site's requests
|
|
524
|
+
* also share a ceiling, so one site cannot starve another.
|
|
525
|
+
*/
|
|
526
|
+
async allowed(request, url, address) {
|
|
527
|
+
if (request.method !== 'POST' && !limitedGets.includes(url.pathname))
|
|
528
|
+
return true;
|
|
529
|
+
const found = await this.sites.session(request);
|
|
530
|
+
const owner = found ? undefined : await this.auth.session(request);
|
|
531
|
+
const holder = found
|
|
532
|
+
? { key: `session/${found.key.slice('site/session/'.length)}`, limit: limits.session }
|
|
533
|
+
: owner
|
|
534
|
+
? { key: `session/${owner}`, limit: limits.session }
|
|
535
|
+
: { key: client(address), limit: limits.client };
|
|
536
|
+
// A handoff is charged to the site it goes to, whatever session this browser holds.
|
|
537
|
+
// Anything else counts against the site whose session is still in use.
|
|
538
|
+
const named = url.pathname === '/handoff/request'
|
|
539
|
+
? url.searchParams.get('site')
|
|
540
|
+
: url.pathname === '/handoff/accept'
|
|
541
|
+
? String(peek(url.searchParams.get('token') ?? '')?.site ?? '')
|
|
542
|
+
: found && !found.session.closed
|
|
543
|
+
? found.session.site
|
|
544
|
+
: undefined;
|
|
545
|
+
const site = named && this.sites.sites.has(named) ? named : undefined;
|
|
546
|
+
return allow(this.store, [
|
|
547
|
+
holder,
|
|
548
|
+
...(site ? [{ key: `site/${site}`, limit: limits.site }] : []),
|
|
549
|
+
]);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Removes a link from a site session and reports it back to the site. There is no flow to
|
|
553
|
+
* hold the result, so the session holds it, and success is reported only once the
|
|
554
|
+
* revocation has committed: pending first, then revoked, then complete. A retry resumes
|
|
555
|
+
* wherever the last attempt stopped, and one already complete reads what it stored.
|
|
556
|
+
*/
|
|
557
|
+
async disconnect(request) {
|
|
558
|
+
const found = await this.sites.session(request);
|
|
559
|
+
const connection = new URLSearchParams(await request.text()).get('connection') ?? '';
|
|
560
|
+
if (!found || !connection)
|
|
561
|
+
return unavailable();
|
|
562
|
+
const { key, session } = found;
|
|
563
|
+
const held = session.operation;
|
|
564
|
+
let current = held?.connection === connection && !held.mark ? session : undefined;
|
|
565
|
+
if (!current) {
|
|
566
|
+
const owned = (await this.app.service.mine(session.local)).find((e) => e.id === connection);
|
|
567
|
+
if (!owned)
|
|
568
|
+
return unavailable();
|
|
569
|
+
current = await this.sites.pending(key, connection, owned.visibility);
|
|
570
|
+
if (!current)
|
|
571
|
+
return unavailable();
|
|
572
|
+
}
|
|
573
|
+
if (current.operation.finishedAt === undefined) {
|
|
574
|
+
// Idempotent: a connection already revoked is left as it is.
|
|
575
|
+
await this.app.service.revoke(connection, session.local);
|
|
576
|
+
current = await this.sites.completed(key);
|
|
577
|
+
}
|
|
578
|
+
return see(this.sites.returnUrl(this.site(session), session.local, session.txn, {
|
|
579
|
+
operation: 'disconnect',
|
|
580
|
+
outcome: 'complete',
|
|
581
|
+
connection,
|
|
582
|
+
visibility: current.operation.visibility,
|
|
583
|
+
finishedAt: current.operation.finishedAt,
|
|
584
|
+
}));
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Lists an account another way, or retires it, from a site session, and reports it back
|
|
588
|
+
* to the site as a disconnect is reported: pending on the session first, then changed,
|
|
589
|
+
* then complete. Asking again for what already holds changes nothing, so a retry is safe.
|
|
590
|
+
*/
|
|
591
|
+
async mark(request) {
|
|
592
|
+
const found = await this.sites.session(request);
|
|
593
|
+
const fields = new URLSearchParams(await request.text());
|
|
594
|
+
const connection = fields.get('connection') ?? '';
|
|
595
|
+
const as = listings.find((listing) => listing === fields.get('as'));
|
|
596
|
+
if (!found || !connection || !as)
|
|
597
|
+
return unavailable();
|
|
598
|
+
const { key, session } = found;
|
|
599
|
+
const held = session.operation;
|
|
600
|
+
let current = held?.connection === connection && held.mark === as ? session : undefined;
|
|
601
|
+
if (!current) {
|
|
602
|
+
const owned = (await this.app.service.mine(session.local)).find((e) => e.id === connection);
|
|
603
|
+
// Checked before anything is held: what cannot be done must not be left pending.
|
|
604
|
+
if (!owned || owned.status === 'revoked' || (owned.status === 'retired' && as !== 'retired'))
|
|
605
|
+
return unavailable();
|
|
606
|
+
current = await this.sites.pending(key, connection, owned.visibility, as);
|
|
607
|
+
if (!current)
|
|
608
|
+
return unavailable();
|
|
609
|
+
}
|
|
610
|
+
if (current.operation.finishedAt === undefined) {
|
|
611
|
+
await this.app.service.mark(connection, session.local, as);
|
|
612
|
+
current = await this.sites.completed(key);
|
|
613
|
+
}
|
|
614
|
+
return see(this.sites.returnUrl(this.site(session), session.local, session.txn, {
|
|
615
|
+
operation: 'mark',
|
|
616
|
+
outcome: 'complete',
|
|
617
|
+
connection,
|
|
618
|
+
visibility: current.operation.visibility,
|
|
619
|
+
finishedAt: current.operation.finishedAt,
|
|
620
|
+
}));
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* The settings page for whoever is signed in: the owner, or a holder a site sent here.
|
|
624
|
+
* The owner's page carries the embed for their own site; a site's holder has the site to
|
|
625
|
+
* show their badge, and removes a link through a route that tells the site so.
|
|
626
|
+
*/
|
|
627
|
+
async settings(subject, site) {
|
|
628
|
+
const connections = await this.app.service.mine(subject);
|
|
629
|
+
const local = localSide(subject, this.options.siteName);
|
|
630
|
+
const now = Date.now();
|
|
631
|
+
// The copy button's script, on the one page that has something to copy.
|
|
632
|
+
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' : '');
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Every record the instance holds, for its operator: each site's subjects, public and
|
|
643
|
+
* unlisted alike, with what each is linked to and how it stands. Read-only. A public
|
|
644
|
+
* record links to its evidence; an unlisted one has no page that is the operator's to open.
|
|
645
|
+
*/
|
|
646
|
+
async records() {
|
|
647
|
+
const all = await this.app.service.all();
|
|
648
|
+
const now = Date.now();
|
|
649
|
+
const count = (visibility) => all.filter((e) => e.visibility === visibility).length;
|
|
650
|
+
const live = (e) => ['Verified', 'Unconfirmed'].includes(statusLabel(e, now));
|
|
651
|
+
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) => {
|
|
655
|
+
// Live records first. The sort is stable, so each half keeps the stored order.
|
|
656
|
+
const records = all
|
|
657
|
+
.filter((e) => e.siteName === name)
|
|
658
|
+
.sort((a, b) => Number(live(b)) - Number(live(a)));
|
|
659
|
+
return `<h2>${escape(name)} · ${records.length}</h2>${records
|
|
660
|
+
.map((e) => {
|
|
661
|
+
const state = statusLabel(e, now);
|
|
662
|
+
const local = e.local.reference && e.local.reference !== e.local.label
|
|
663
|
+
? `${e.local.label} · ${e.local.reference}`
|
|
664
|
+
: e.local.label;
|
|
665
|
+
// 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>`);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Asks before an account is retired or a connection is removed, since neither can be
|
|
677
|
+
* undone. The page's one button posts to the route that does it.
|
|
678
|
+
*/
|
|
679
|
+
async confirm(request, url) {
|
|
680
|
+
const found = await this.sites.session(request);
|
|
681
|
+
const site = found && !found.session.closed && !found.session.operation
|
|
682
|
+
? this.site(found.session)
|
|
683
|
+
: undefined;
|
|
684
|
+
const subject = site
|
|
685
|
+
? found.session.local
|
|
686
|
+
: (await this.auth.authenticated(request))
|
|
687
|
+
? this.local
|
|
688
|
+
: undefined;
|
|
689
|
+
const action = url.searchParams.get('do');
|
|
690
|
+
const id = url.searchParams.get('connection');
|
|
691
|
+
const e = subject && (await this.app.service.mine(subject)).find((e) => e.id === id);
|
|
692
|
+
if (!e || (action !== 'retire' && action !== 'remove'))
|
|
693
|
+
return unavailable();
|
|
694
|
+
// What the settings page offers for this record, and nothing it does not.
|
|
695
|
+
if (action === 'retire' ? ['revoked', 'retired'].includes(e.status) : e.status === 'revoked')
|
|
696
|
+
return unavailable();
|
|
697
|
+
const held = site ? `<input type="hidden" name="connection" value="${escape(e.id)}">` : '';
|
|
698
|
+
const [heading, note, form] = action === 'retire'
|
|
699
|
+
? [
|
|
700
|
+
'Retire this account?',
|
|
701
|
+
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>`,
|
|
703
|
+
]
|
|
704
|
+
: site
|
|
705
|
+
? [
|
|
706
|
+
'Disconnect this account?',
|
|
707
|
+
`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>`,
|
|
709
|
+
]
|
|
710
|
+
: [
|
|
711
|
+
'Revoke this connection?',
|
|
712
|
+
'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>`,
|
|
714
|
+
];
|
|
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');
|
|
718
|
+
}
|
|
719
|
+
/** One connection: the account's card, its state, and what can be done with it. */
|
|
720
|
+
connection(e, now, site) {
|
|
721
|
+
// The script is the published package's, since a page on another site cannot load a
|
|
722
|
+
// path on this one that the instance does not serve.
|
|
723
|
+
const embed = `<script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@${version.slice(1)}/dist/verily.js" defer></script>\n<verily-badge backend-url="${this.origin}" connection-id="${e.id}"></verily-badge>`;
|
|
724
|
+
const state = statusLabel(e, now);
|
|
725
|
+
const retired = e.status === 'retired';
|
|
726
|
+
const open = !['revoked', 'retired'].includes(e.status);
|
|
727
|
+
const when = (time) => `${date(time)} (${distance(time, now)})`;
|
|
728
|
+
const rows = [
|
|
729
|
+
['Visibility', e.visibility === 'public' ? 'Public' : 'Unlisted'],
|
|
730
|
+
...(retired
|
|
731
|
+
? [
|
|
732
|
+
['Last verified', date(lastProved(e))],
|
|
733
|
+
['Retired', date(e.retiredAt)],
|
|
734
|
+
]
|
|
735
|
+
: [['Expires', when(e.expiresAt)]]),
|
|
736
|
+
...(e.mark === 'preferred' || e.mark === 'unused'
|
|
737
|
+
? [['Listed as', e.mark === 'preferred' ? 'Preferred' : 'No longer used']]
|
|
738
|
+
: []),
|
|
739
|
+
];
|
|
740
|
+
const shown = site
|
|
741
|
+
? e.visibility === 'public'
|
|
742
|
+
? `Anyone can view this connection, on ${site.name} or anywhere else.`
|
|
743
|
+
: `Only ${site.name} can read this connection, and it chooses who there sees it.`
|
|
744
|
+
: '';
|
|
745
|
+
// A site's holder cannot act on a record already revoked.
|
|
746
|
+
const acts = !(site && e.status === 'revoked');
|
|
747
|
+
// Renewing and changing visibility start a flow on a page of their own, so they are
|
|
748
|
+
// links, drawn as buttons beside the others that act on the connection.
|
|
749
|
+
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('');
|
|
755
|
+
// How the holder lists the account, which acts on every record of it they have. A
|
|
756
|
+
// 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
|
+
: '';
|
|
763
|
+
// 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>`;
|
|
765
|
+
// Retiring and removing cannot be undone, so they sit together on a line of their own
|
|
766
|
+
// 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>`;
|
|
776
|
+
}
|
|
777
|
+
}
|