@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/sites.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { expired, } from '../core/index.js';
|
|
3
|
+
import { limits } from './limits.js';
|
|
4
|
+
import { readCookie, setCookie } from './page.js';
|
|
3
5
|
import { hash, secret } from '../server/service.js';
|
|
4
6
|
/** How long a handoff may take, from `/handoff/request` to `/handoff/accept`, and how far ahead a token may run. */
|
|
5
7
|
export const handoffMs = 5 * 60000;
|
|
@@ -18,6 +20,56 @@ function secureOrigin(value) {
|
|
|
18
20
|
(url.protocol === 'https:' ||
|
|
19
21
|
(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))));
|
|
20
22
|
}
|
|
23
|
+
/** Where a site registered on the instance itself is kept, under its id. */
|
|
24
|
+
const registeredPrefix = 'site/registered/';
|
|
25
|
+
/** Where a site's operators are kept, under its id. */
|
|
26
|
+
const operatorsPrefix = 'site/operators/';
|
|
27
|
+
/** Where an id that has been used is kept once its site is gone, so it is never reused. */
|
|
28
|
+
const retiredPrefix = 'site/retired/';
|
|
29
|
+
/** How long a stored site stays suspended before it is deleted. */
|
|
30
|
+
export const lapseMs = 30 * 24 * 3600000;
|
|
31
|
+
/** Where the instance owner's controls over a stored site are kept, under its id. */
|
|
32
|
+
const controlsPrefix = 'site/controls/';
|
|
33
|
+
/** Providers a stored site's holders get only once the owner approves the site. */
|
|
34
|
+
const approvalNeeded = ['youtube'];
|
|
35
|
+
/** Where the last DNS check of a stored site is kept, under its id. */
|
|
36
|
+
export const dnsPrefix = 'site/dns/';
|
|
37
|
+
/** A site's id: what prefixes its subjects and names it in every token. */
|
|
38
|
+
export const validId = (id) => typeof id === 'string' && /^[a-z0-9-]{1,32}$/.test(id);
|
|
39
|
+
/** A key: base64url, of at least 32 bytes. */
|
|
40
|
+
export const validKey = (key) => typeof key === 'string' && /^[A-Za-z0-9_-]{43,}$/.test(key);
|
|
41
|
+
/** A site entry checked field by field, without its key. Throws on the first thing wrong. */
|
|
42
|
+
export function checked(entry) {
|
|
43
|
+
const { id, name, origin, authorizeUrl, returnUrl, providers, visibility } = entry ?? {};
|
|
44
|
+
if (!validId(id))
|
|
45
|
+
throw new Error('Each site needs an id of lowercase letters, digits and hyphens');
|
|
46
|
+
if (typeof name !== 'string' || !name || name.length > 100)
|
|
47
|
+
throw new Error(`Site ${id} needs a name`);
|
|
48
|
+
if (typeof origin !== 'string' || !secureOrigin(origin))
|
|
49
|
+
throw new Error(`Site ${id} needs an HTTPS origin without a trailing slash`);
|
|
50
|
+
for (const url of [authorizeUrl, returnUrl])
|
|
51
|
+
if (typeof url !== 'string' || new URL(url).origin !== origin)
|
|
52
|
+
throw new Error(`Site ${id} must authorize and return on its own origin`);
|
|
53
|
+
if (providers !== undefined &&
|
|
54
|
+
(!Array.isArray(providers) ||
|
|
55
|
+
!providers.length ||
|
|
56
|
+
providers.some((p) => typeof p !== 'string' || !p)))
|
|
57
|
+
throw new Error(`Site ${id} must list at least one provider id, or none at all`);
|
|
58
|
+
if (visibility !== undefined &&
|
|
59
|
+
(!Array.isArray(visibility) ||
|
|
60
|
+
!visibility.length ||
|
|
61
|
+
visibility.some((v) => v !== 'public' && v !== 'unlisted')))
|
|
62
|
+
throw new Error(`Site ${id} must list "public", "unlisted" or both, or no visibility at all`);
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
name,
|
|
66
|
+
origin,
|
|
67
|
+
authorizeUrl: authorizeUrl,
|
|
68
|
+
returnUrl: returnUrl,
|
|
69
|
+
...(providers ? { providers: providers } : {}),
|
|
70
|
+
...(visibility ? { visibility: visibility } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
21
73
|
/**
|
|
22
74
|
* The registered sites, from the `SITES` variable, each with its key from the secret
|
|
23
75
|
* `SITE_<ID>_KEY`. Anything wrong in the list refuses to start rather than serving a site
|
|
@@ -29,43 +81,17 @@ export function registry(config, secrets) {
|
|
|
29
81
|
throw new Error('SITES must be a list');
|
|
30
82
|
const sites = new Map();
|
|
31
83
|
for (const entry of list) {
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
throw new Error(
|
|
35
|
-
const key = secrets[`SITE_${id.toUpperCase().replace(/-/g, '_')}_KEY`];
|
|
36
|
-
if (typeof name !== 'string' || !name || name.length > 100)
|
|
37
|
-
throw new Error(`Site ${id} needs a name`);
|
|
38
|
-
if (typeof origin !== 'string' || !secureOrigin(origin))
|
|
39
|
-
throw new Error(`Site ${id} needs an HTTPS origin without a trailing slash`);
|
|
40
|
-
for (const url of [authorizeUrl, returnUrl])
|
|
41
|
-
if (typeof url !== 'string' || new URL(url).origin !== origin)
|
|
42
|
-
throw new Error(`Site ${id} must authorize and return on its own origin`);
|
|
43
|
-
if (providers !== undefined &&
|
|
44
|
-
(!Array.isArray(providers) ||
|
|
45
|
-
!providers.length ||
|
|
46
|
-
providers.some((p) => typeof p !== 'string' || !p)))
|
|
47
|
-
throw new Error(`Site ${id} must list at least one provider id, or none at all`);
|
|
48
|
-
if (visibility !== undefined &&
|
|
49
|
-
(!Array.isArray(visibility) ||
|
|
50
|
-
!visibility.length ||
|
|
51
|
-
visibility.some((v) => v !== 'public' && v !== 'unlisted')))
|
|
52
|
-
throw new Error(`Site ${id} must list "public", "unlisted" or both, or no visibility at all`);
|
|
84
|
+
const site = checked(entry);
|
|
85
|
+
if (sites.has(site.id))
|
|
86
|
+
throw new Error(`Site ${site.id} is listed twice`);
|
|
87
|
+
const key = secrets[`SITE_${site.id.toUpperCase().replace(/-/g, '_')}_KEY`];
|
|
53
88
|
// A secret is set apart from a deployment and cannot be checked before one. A wrong
|
|
54
89
|
// one leaves its own site unserved, and never takes the rest of the instance with it.
|
|
55
|
-
if (
|
|
56
|
-
console.error(`Site ${id} is not served: it needs a base64url key of at least 32 bytes`);
|
|
90
|
+
if (!validKey(key)) {
|
|
91
|
+
console.error(`Site ${site.id} is not served: it needs a base64url key of at least 32 bytes`);
|
|
57
92
|
continue;
|
|
58
93
|
}
|
|
59
|
-
sites.set(id, {
|
|
60
|
-
id,
|
|
61
|
-
name,
|
|
62
|
-
origin,
|
|
63
|
-
authorizeUrl: authorizeUrl,
|
|
64
|
-
returnUrl: returnUrl,
|
|
65
|
-
key,
|
|
66
|
-
...(providers ? { providers: providers } : {}),
|
|
67
|
-
...(visibility ? { visibility: visibility } : {}),
|
|
68
|
-
});
|
|
94
|
+
sites.set(site.id, { ...site, key });
|
|
69
95
|
}
|
|
70
96
|
return sites;
|
|
71
97
|
}
|
|
@@ -99,13 +125,32 @@ export function open(key, token) {
|
|
|
99
125
|
return undefined;
|
|
100
126
|
return peek(token);
|
|
101
127
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
107
|
-
|
|
108
|
-
|
|
128
|
+
/** How long a replaced site key is still accepted, so the site has time to deploy the new one. */
|
|
129
|
+
export const rotationMs = 24 * 3600000;
|
|
130
|
+
/** The site's keys that are accepted now: its key, and its previous key while in date. */
|
|
131
|
+
const accepted = (site) => [
|
|
132
|
+
site.key,
|
|
133
|
+
...(site.previousKey && (site.previousUntil ?? 0) > Date.now() ? [site.previousKey] : []),
|
|
134
|
+
];
|
|
135
|
+
/**
|
|
136
|
+
* Whether the key a session was opened with is still accepted. A key replaced at once, or
|
|
137
|
+
* whose 24 hours are over or were ended early, takes its sessions with it, so a leaked key
|
|
138
|
+
* is no use to whoever already opened a session with it. A session from before sessions
|
|
139
|
+
* named their key has none, and lasts its hour.
|
|
140
|
+
*/
|
|
141
|
+
const keyAccepted = (site, session) => session.signer === undefined || accepted(site).some((key) => hash(key) === session.signer);
|
|
142
|
+
/**
|
|
143
|
+
* The payload of a token signed by one of the site's accepted keys, and the hash of the
|
|
144
|
+
* key that signed it.
|
|
145
|
+
*/
|
|
146
|
+
function verified(site, token) {
|
|
147
|
+
for (const key of accepted(site)) {
|
|
148
|
+
const payload = open(key, token);
|
|
149
|
+
if (payload)
|
|
150
|
+
return { payload, signer: hash(key) };
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
109
154
|
const string = (value, max = 500) => typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
110
155
|
/**
|
|
111
156
|
* The subject a handoff vouches for, as the library will hold it, or undefined if the
|
|
@@ -143,17 +188,224 @@ function subject(site, payload, validate) {
|
|
|
143
188
|
*/
|
|
144
189
|
export class Sites {
|
|
145
190
|
storage;
|
|
191
|
+
pinned;
|
|
192
|
+
/**
|
|
193
|
+
* Every site served: the pinned ones, then the ones kept in the store, as `refresh()`
|
|
194
|
+
* last read them.
|
|
195
|
+
*/
|
|
146
196
|
sites;
|
|
147
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Stored sites whose DNS no longer names this instance, or that the owner suspended. They
|
|
199
|
+
* start nothing new, but their backends can still read their connections, so they can
|
|
200
|
+
* move elsewhere.
|
|
201
|
+
*/
|
|
202
|
+
suspended = new Set();
|
|
203
|
+
/** What the instance owner has set for each stored site, as `refresh()` last read it. */
|
|
204
|
+
controls = new Map();
|
|
205
|
+
/**
|
|
206
|
+
* `pinned` are the sites the host configures, from `SITES`. They cannot be changed here,
|
|
207
|
+
* and a stored site with the same id or origin is not served.
|
|
208
|
+
*/
|
|
209
|
+
constructor(storage, pinned) {
|
|
148
210
|
this.storage = storage;
|
|
211
|
+
this.pinned = pinned;
|
|
212
|
+
this.sites = new Map(pinned);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Reads the stored sites again. The instance does this on every request, so a site
|
|
216
|
+
* registered, changed or removed by another process is served as it now is.
|
|
217
|
+
*/
|
|
218
|
+
async refresh() {
|
|
219
|
+
const sites = new Map(this.pinned);
|
|
220
|
+
const origins = new Set([...this.pinned.values()].map((site) => site.origin));
|
|
221
|
+
for (const [, site] of await this.storage.list(registeredPrefix)) {
|
|
222
|
+
if (sites.has(site.id) || origins.has(site.origin))
|
|
223
|
+
continue;
|
|
224
|
+
sites.set(site.id, site);
|
|
225
|
+
origins.add(site.origin);
|
|
226
|
+
}
|
|
227
|
+
const suspended = new Set();
|
|
228
|
+
const controls = new Map();
|
|
229
|
+
for (const [key, set] of await this.storage.list(controlsPrefix)) {
|
|
230
|
+
const id = key.slice(controlsPrefix.length);
|
|
231
|
+
controls.set(id, set);
|
|
232
|
+
if (set.suspendedAt !== undefined && !this.pinned.has(id))
|
|
233
|
+
suspended.add(id);
|
|
234
|
+
}
|
|
235
|
+
for (const [key, check] of await this.storage.list(dnsPrefix)) {
|
|
236
|
+
const id = key.slice(dnsPrefix.length);
|
|
237
|
+
if (check.absentSince !== undefined && !this.pinned.has(id))
|
|
238
|
+
suspended.add(id);
|
|
239
|
+
}
|
|
149
240
|
this.sites = sites;
|
|
241
|
+
this.suspended = suspended;
|
|
242
|
+
this.controls = controls;
|
|
243
|
+
}
|
|
244
|
+
/** The sites kept in the store and served, which are the ones whose DNS is checked. */
|
|
245
|
+
stored() {
|
|
246
|
+
return [...this.sites.values()].filter((site) => !this.pinned.has(site.id));
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Requests a minute for a site, counted once for its holders and once for its backend's
|
|
250
|
+
* reads. Undefined for a pinned site, which gets the instance's default.
|
|
251
|
+
*/
|
|
252
|
+
limit(id) {
|
|
253
|
+
if (this.pinned.has(id))
|
|
254
|
+
return undefined;
|
|
255
|
+
return this.controls.get(id)?.limit ?? limits.registered;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The provider ids a site's holders may use, from those the instance `offers`. A stored
|
|
259
|
+
* site goes without YouTube until the owner approves it. Undefined means every one.
|
|
260
|
+
*/
|
|
261
|
+
providers(site, offers) {
|
|
262
|
+
const held = this.withheld(site.id);
|
|
263
|
+
return held.length
|
|
264
|
+
? (site.providers ?? offers).filter((id) => !held.includes(id))
|
|
265
|
+
: site.providers;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The provider ids the owner has not approved a site for, whichever its operators chose.
|
|
269
|
+
* None for a pinned site.
|
|
270
|
+
*/
|
|
271
|
+
withheld(id) {
|
|
272
|
+
return this.pinned.has(id) || this.controls.get(id)?.youtubeApprovedAt ? [] : approvalNeeded;
|
|
273
|
+
}
|
|
274
|
+
/** Changes what the instance owner has set for a stored site. */
|
|
275
|
+
async control(id, change) {
|
|
276
|
+
if (this.pinned.has(id))
|
|
277
|
+
throw new Error(`Site ${id} is set by the host`);
|
|
278
|
+
await this.storage.transaction(async (tx) => {
|
|
279
|
+
if (!(await tx.get(`${registeredPrefix}${id}`)))
|
|
280
|
+
throw new Error(`Site ${id} is not registered`);
|
|
281
|
+
const key = `${controlsPrefix}${id}`;
|
|
282
|
+
await tx.put(key, change((await tx.get(key)) ?? {}));
|
|
283
|
+
});
|
|
284
|
+
await this.refresh();
|
|
285
|
+
}
|
|
286
|
+
/** A site that may start something new: served, and not suspended. */
|
|
287
|
+
active(id) {
|
|
288
|
+
return this.suspended.has(id) ? undefined : this.sites.get(id);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Keeps a new site in the store with its first operator. Refused if the entry is
|
|
292
|
+
* malformed, or its origin belongs to another site, or its id is in use or has ever been.
|
|
293
|
+
*/
|
|
294
|
+
async register(site, first) {
|
|
295
|
+
const entry = { ...checked(site), key: site.key };
|
|
296
|
+
if (!validKey(entry.key))
|
|
297
|
+
throw new Error(`Site ${entry.id} needs a base64url key`);
|
|
298
|
+
if (this.pinned.has(entry.id))
|
|
299
|
+
throw new Error(`Site ${entry.id} is set by the host`);
|
|
300
|
+
await this.storage.transaction(async (tx) => {
|
|
301
|
+
if ((await tx.get(`${registeredPrefix}${entry.id}`)) ||
|
|
302
|
+
(await tx.get(`${retiredPrefix}${entry.id}`)))
|
|
303
|
+
throw new Error(`The id ${entry.id} is taken`);
|
|
304
|
+
for (const [, other] of [
|
|
305
|
+
...this.pinned.entries(),
|
|
306
|
+
...(await tx.list(registeredPrefix)),
|
|
307
|
+
])
|
|
308
|
+
if (other.origin === entry.origin)
|
|
309
|
+
throw new Error(`${entry.origin} is already registered`);
|
|
310
|
+
await tx.put(`${registeredPrefix}${entry.id}`, entry);
|
|
311
|
+
await tx.put(`${operatorsPrefix}${entry.id}`, [first]);
|
|
312
|
+
});
|
|
313
|
+
await this.refresh();
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Changes a stored site's settings. The rest of the site, its keys above all, is read in
|
|
317
|
+
* the same transaction and kept as it is, so a change made from a page loaded before a
|
|
318
|
+
* key was replaced cannot bring the old key back. Refused if the result is malformed.
|
|
319
|
+
*/
|
|
320
|
+
async update(id, settings) {
|
|
321
|
+
if (this.pinned.has(id))
|
|
322
|
+
throw new Error(`Site ${id} is set by the host`);
|
|
323
|
+
await this.storage.transaction(async (tx) => {
|
|
324
|
+
const stored = await tx.get(`${registeredPrefix}${id}`);
|
|
325
|
+
if (!stored)
|
|
326
|
+
throw new Error(`Site ${id} is not registered`);
|
|
327
|
+
const { key, previousKey, previousUntil, providers, visibility, ...rest } = stored;
|
|
328
|
+
const changed = checked({
|
|
329
|
+
...rest,
|
|
330
|
+
authorizeUrl: settings.authorizeUrl,
|
|
331
|
+
returnUrl: settings.returnUrl,
|
|
332
|
+
...(settings.providers ? { providers: settings.providers } : {}),
|
|
333
|
+
...(settings.visibility ? { visibility: settings.visibility } : {}),
|
|
334
|
+
});
|
|
335
|
+
await tx.put(`${registeredPrefix}${id}`, {
|
|
336
|
+
...changed,
|
|
337
|
+
key,
|
|
338
|
+
...(previousKey && previousUntil ? { previousKey, previousUntil } : {}),
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
await this.refresh();
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Gives a stored site a new key and returns it. With `keep`, the old key is still
|
|
345
|
+
* accepted for `rotationMs`, for a routine change. Without it, the old key stops working
|
|
346
|
+
* at once, for a key that may have leaked. Any key before the old one stops either way.
|
|
347
|
+
*
|
|
348
|
+
* With `from`, the hash of the key the caller saw, nothing changes unless that is still
|
|
349
|
+
* the site's key, so a form sent twice replaces the key once. Returns undefined then.
|
|
350
|
+
*/
|
|
351
|
+
async rotate(id, keep, from) {
|
|
352
|
+
const key = secret();
|
|
353
|
+
const replaced = await this.storage.transaction(async (tx) => {
|
|
354
|
+
const stored = await tx.get(`${registeredPrefix}${id}`);
|
|
355
|
+
if (!stored)
|
|
356
|
+
throw new Error(`Site ${id} is not registered`);
|
|
357
|
+
if (from !== undefined && hash(stored.key) !== from)
|
|
358
|
+
return false;
|
|
359
|
+
const { previousKey, previousUntil, ...site } = stored;
|
|
360
|
+
await tx.put(`${registeredPrefix}${id}`, {
|
|
361
|
+
...site,
|
|
362
|
+
key,
|
|
363
|
+
...(keep ? { previousKey: stored.key, previousUntil: Date.now() + rotationMs } : {}),
|
|
364
|
+
});
|
|
365
|
+
return true;
|
|
366
|
+
});
|
|
367
|
+
await this.refresh();
|
|
368
|
+
return replaced ? key : undefined;
|
|
369
|
+
}
|
|
370
|
+
/** Stops accepting a stored site's previous key, once the new one is deployed. */
|
|
371
|
+
async retirePreviousKey(id) {
|
|
372
|
+
await this.storage.transaction(async (tx) => {
|
|
373
|
+
const stored = await tx.get(`${registeredPrefix}${id}`);
|
|
374
|
+
if (!stored)
|
|
375
|
+
throw new Error(`Site ${id} is not registered`);
|
|
376
|
+
const { previousKey, previousUntil, ...site } = stored;
|
|
377
|
+
await tx.put(`${registeredPrefix}${id}`, site);
|
|
378
|
+
});
|
|
379
|
+
await this.refresh();
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Removes a stored site with its operators, DNS state and owner's controls, and keeps its
|
|
383
|
+
* id so no other site is ever given it. Its users' records stay, but their site starts nothing new.
|
|
384
|
+
*/
|
|
385
|
+
async retire(id, reason) {
|
|
386
|
+
if (this.pinned.has(id))
|
|
387
|
+
throw new Error(`Site ${id} is set by the host`);
|
|
388
|
+
await this.storage.transaction(async (tx) => {
|
|
389
|
+
if (!(await tx.get(`${registeredPrefix}${id}`)))
|
|
390
|
+
throw new Error(`Site ${id} is not registered`);
|
|
391
|
+
await tx.put(`${retiredPrefix}${id}`, { retiredAt: Date.now(), reason });
|
|
392
|
+
await tx.delete(`${registeredPrefix}${id}`);
|
|
393
|
+
await tx.delete(`${operatorsPrefix}${id}`);
|
|
394
|
+
await tx.delete(`${dnsPrefix}${id}`);
|
|
395
|
+
await tx.delete(`${controlsPrefix}${id}`);
|
|
396
|
+
});
|
|
397
|
+
await this.refresh();
|
|
398
|
+
}
|
|
399
|
+
/** The operators of a stored site. */
|
|
400
|
+
async operators(id) {
|
|
401
|
+
return (await this.storage.get(`${operatorsPrefix}${id}`)) ?? [];
|
|
150
402
|
}
|
|
151
403
|
/**
|
|
152
404
|
* Starts a handoff: a state stored with the site and purpose, set in this browser, and
|
|
153
405
|
* sent to the site to sign into its token. Returns where to go and the cookie to set.
|
|
154
406
|
*/
|
|
155
407
|
async begin(site, purpose) {
|
|
156
|
-
const registered = this.
|
|
408
|
+
const registered = this.active(site ?? '');
|
|
157
409
|
if (!registered || !['connect', 'manage'].includes(purpose ?? ''))
|
|
158
410
|
return undefined;
|
|
159
411
|
const state = secret();
|
|
@@ -172,12 +424,12 @@ export class Sites {
|
|
|
172
424
|
* check of the subject, which the union of every site's origin would not make alone.
|
|
173
425
|
*/
|
|
174
426
|
async start(request, token, validate) {
|
|
175
|
-
const site = this.
|
|
427
|
+
const site = this.active(String(peek(token)?.site ?? ''));
|
|
176
428
|
if (!site)
|
|
177
429
|
return undefined;
|
|
178
|
-
const payload =
|
|
430
|
+
const { payload, signer } = verified(site, token) ?? {};
|
|
179
431
|
const now = Date.now();
|
|
180
|
-
const held =
|
|
432
|
+
const held = readCookie(request, stateCookie);
|
|
181
433
|
if (!payload ||
|
|
182
434
|
payload.site !== site.id ||
|
|
183
435
|
typeof payload.exp !== 'number' ||
|
|
@@ -207,6 +459,7 @@ export class Sites {
|
|
|
207
459
|
txn,
|
|
208
460
|
local,
|
|
209
461
|
expiresAt: now + sessionMs,
|
|
462
|
+
signer,
|
|
210
463
|
});
|
|
211
464
|
return state.purpose;
|
|
212
465
|
});
|
|
@@ -231,10 +484,10 @@ export class Sites {
|
|
|
231
484
|
* not admitted is 'limited', and is not used up.
|
|
232
485
|
*/
|
|
233
486
|
async dialog(token, origin, validate, admit) {
|
|
234
|
-
const site = this.
|
|
487
|
+
const site = this.active(String(peek(token)?.site ?? ''));
|
|
235
488
|
if (!site || origin !== site.origin)
|
|
236
489
|
return undefined;
|
|
237
|
-
const payload =
|
|
490
|
+
const { payload, signer } = verified(site, token) ?? {};
|
|
238
491
|
const now = Date.now();
|
|
239
492
|
if (!payload ||
|
|
240
493
|
payload.site !== site.id ||
|
|
@@ -264,6 +517,7 @@ export class Sites {
|
|
|
264
517
|
local,
|
|
265
518
|
expiresAt: now + sessionMs,
|
|
266
519
|
dialog: true,
|
|
520
|
+
signer,
|
|
267
521
|
});
|
|
268
522
|
return true;
|
|
269
523
|
});
|
|
@@ -279,7 +533,7 @@ export class Sites {
|
|
|
279
533
|
const site = this.sites.get(String(peek(token)?.site ?? ''));
|
|
280
534
|
if (!site)
|
|
281
535
|
return undefined;
|
|
282
|
-
const payload =
|
|
536
|
+
const payload = verified(site, token)?.payload;
|
|
283
537
|
const now = Date.now();
|
|
284
538
|
if (!payload ||
|
|
285
539
|
payload.site !== site.id ||
|
|
@@ -296,12 +550,13 @@ export class Sites {
|
|
|
296
550
|
}
|
|
297
551
|
/** This browser's site session, whether or not it has returned yet. */
|
|
298
552
|
async session(request) {
|
|
299
|
-
const token =
|
|
553
|
+
const token = readCookie(request, sessionCookie);
|
|
300
554
|
if (!token)
|
|
301
555
|
return undefined;
|
|
302
556
|
const key = `site/session/${hash(token)}`;
|
|
303
557
|
const session = await this.storage.get(key);
|
|
304
|
-
|
|
558
|
+
const site = session && this.sites.get(session.site);
|
|
559
|
+
if (!session || !site || expired(session, Date.now()) || !keyAccepted(site, session))
|
|
305
560
|
return undefined;
|
|
306
561
|
return { key, session };
|
|
307
562
|
}
|
|
@@ -312,7 +567,12 @@ export class Sites {
|
|
|
312
567
|
*/
|
|
313
568
|
async open(request) {
|
|
314
569
|
const found = await this.session(request);
|
|
315
|
-
return found &&
|
|
570
|
+
return found &&
|
|
571
|
+
!found.session.closed &&
|
|
572
|
+
!found.session.operation &&
|
|
573
|
+
!this.suspended.has(found.session.site)
|
|
574
|
+
? found
|
|
575
|
+
: undefined;
|
|
316
576
|
}
|
|
317
577
|
/**
|
|
318
578
|
* Where a flow that ended goes, as a function of the stored result alone: the same
|
|
@@ -326,9 +586,14 @@ export class Sites {
|
|
|
326
586
|
return undefined;
|
|
327
587
|
if (!['connect', 'renew', 'visibility'].includes(result.kind))
|
|
328
588
|
return undefined;
|
|
589
|
+
const session = context.session?.startsWith('site/session/')
|
|
590
|
+
? await this.storage.get(context.session)
|
|
591
|
+
: undefined;
|
|
329
592
|
// The sign-in window of a dialog shows the flow's own result page and goes nowhere.
|
|
330
|
-
if (
|
|
331
|
-
|
|
593
|
+
if (session?.dialog)
|
|
594
|
+
return undefined;
|
|
595
|
+
// Nothing is signed for a session whose key has since been withdrawn.
|
|
596
|
+
if (session && !keyAccepted(site, session))
|
|
332
597
|
return undefined;
|
|
333
598
|
// Another flow's return, or a disconnect, already speaks for this transaction. This
|
|
334
599
|
// flow ended all the same, however it ended, and its holder sees the result page.
|
|
@@ -340,7 +605,7 @@ export class Sites {
|
|
|
340
605
|
connection: result.connectionId,
|
|
341
606
|
visibility: result.visibility,
|
|
342
607
|
finishedAt: result.finishedAt,
|
|
343
|
-
});
|
|
608
|
+
}, session?.signer);
|
|
344
609
|
}
|
|
345
610
|
/**
|
|
346
611
|
* Takes the session's one return for a flow, and closes the session with it. A site
|
|
@@ -362,12 +627,16 @@ export class Sites {
|
|
|
362
627
|
return true;
|
|
363
628
|
});
|
|
364
629
|
}
|
|
365
|
-
/**
|
|
366
|
-
|
|
630
|
+
/**
|
|
631
|
+
* The signed result a holder carries back to their site, under the key that opened its
|
|
632
|
+
* session while that key is still accepted, and under the site's key otherwise.
|
|
633
|
+
*/
|
|
634
|
+
returnUrl(site, local, txn, result, signer) {
|
|
367
635
|
if (!local.id.startsWith(`${site.id}:`))
|
|
368
636
|
throw new Error('Subject from another site');
|
|
369
637
|
const complete = result.outcome === 'complete';
|
|
370
|
-
const
|
|
638
|
+
const key = accepted(site).find((candidate) => hash(candidate) === signer) ?? site.key;
|
|
639
|
+
const token = sign(key, {
|
|
371
640
|
site: site.id,
|
|
372
641
|
id: local.id.slice(site.id.length + 1),
|
|
373
642
|
operation: result.operation,
|