@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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { html } from '../server/html.js';
|
|
2
|
+
import { date } from '../server/render.js';
|
|
3
|
+
import { limits } from './limits.js';
|
|
4
|
+
import { respond, see, unavailable } from './page.js';
|
|
5
|
+
import { dnsPrefix } from './sites.js';
|
|
6
|
+
/** The highest limit the owner may give a site, in requests a minute. */
|
|
7
|
+
const maxLimit = 100000;
|
|
8
|
+
/**
|
|
9
|
+
* Where the instance owner oversees the sites registered here: suspends one by hand, sets
|
|
10
|
+
* its request limit, and approves it for YouTube. Sites in `SITES` are the host's, and are
|
|
11
|
+
* not listed.
|
|
12
|
+
*/
|
|
13
|
+
export class OwnerSites {
|
|
14
|
+
store;
|
|
15
|
+
sites;
|
|
16
|
+
constructor(store, sites) {
|
|
17
|
+
this.store = store;
|
|
18
|
+
this.sites = sites;
|
|
19
|
+
}
|
|
20
|
+
/** Every stored site, with how it stands and the owner's controls over it. */
|
|
21
|
+
async page() {
|
|
22
|
+
const stored = this.sites.stored();
|
|
23
|
+
const rows = await Promise.all(stored.map(async (site) => {
|
|
24
|
+
const check = await this.store.get(`${dnsPrefix}${site.id}`);
|
|
25
|
+
const registered = (await this.sites.operators(site.id))[0]?.addedAt;
|
|
26
|
+
return this.row(site, check, registered);
|
|
27
|
+
}));
|
|
28
|
+
return respond('Registered sites', html `
|
|
29
|
+
${stored.length
|
|
30
|
+
? html `
|
|
31
|
+
<p class="fine">
|
|
32
|
+
A site registered here gets ${limits.registered} requests a minute and no
|
|
33
|
+
YouTube until you change either. A suspended site starts nothing new, but its
|
|
34
|
+
backend can still read its connections.
|
|
35
|
+
</p>
|
|
36
|
+
${rows}
|
|
37
|
+
`
|
|
38
|
+
: html `<p>No sites are registered here.</p>`}
|
|
39
|
+
<p><a href="/">Back to owner settings</a></p>
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
/** One of the page's forms, sent to `path`. Every one returns to the page. */
|
|
43
|
+
async change(path, request) {
|
|
44
|
+
const fields = new URLSearchParams(await request.text());
|
|
45
|
+
const id = fields.get('id') ?? '';
|
|
46
|
+
if (!this.sites.stored().some((site) => site.id === id))
|
|
47
|
+
return unavailable();
|
|
48
|
+
if (path === '/sites/suspend') {
|
|
49
|
+
const suspend = fields.get('suspend') === 'yes';
|
|
50
|
+
await this.sites.control(id, ({ suspendedAt, ...rest }) => suspend ? { ...rest, suspendedAt: suspendedAt ?? Date.now() } : rest);
|
|
51
|
+
}
|
|
52
|
+
else if (path === '/sites/youtube') {
|
|
53
|
+
const approve = fields.get('approve') === 'yes';
|
|
54
|
+
await this.sites.control(id, ({ youtubeApprovedAt, ...rest }) => approve ? { ...rest, youtubeApprovedAt: youtubeApprovedAt ?? Date.now() } : rest);
|
|
55
|
+
}
|
|
56
|
+
else if (path === '/sites/limit') {
|
|
57
|
+
const given = fields.get('limit')?.trim() ?? '';
|
|
58
|
+
const limit = Number(given);
|
|
59
|
+
// Left empty, the site goes back to the default.
|
|
60
|
+
if (given && !(Number.isInteger(limit) && limit >= 1 && limit <= maxLimit))
|
|
61
|
+
return respond('Limit not changed', html `
|
|
62
|
+
<p>A limit is a whole number of requests a minute, from 1 to ${maxLimit}.</p>
|
|
63
|
+
<p><a href="/sites">Back to registered sites</a></p>
|
|
64
|
+
`, 400, 'single');
|
|
65
|
+
await this.sites.control(id, ({ limit: _, ...rest }) => (given ? { ...rest, limit } : rest));
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
return unavailable();
|
|
69
|
+
return see('/sites');
|
|
70
|
+
}
|
|
71
|
+
row(site, check, registered) {
|
|
72
|
+
const controls = this.sites.controls.get(site.id) ?? {};
|
|
73
|
+
const standing = controls.suspendedAt !== undefined
|
|
74
|
+
? `Suspended by you since ${date(controls.suspendedAt)}`
|
|
75
|
+
: check?.absentSince !== undefined
|
|
76
|
+
? `Suspended since ${date(check.absentSince)}: DNS record missing`
|
|
77
|
+
: check?.checkedAt !== undefined
|
|
78
|
+
? `Served · DNS record found ${date(check.checkedAt)}`
|
|
79
|
+
: 'Served · DNS record not checked yet';
|
|
80
|
+
const hidden = html `<input type="hidden" name="id" value="${site.id}">`;
|
|
81
|
+
return html `
|
|
82
|
+
<section>
|
|
83
|
+
<h2>${site.name}</h2>
|
|
84
|
+
<dl>
|
|
85
|
+
<dt>Site id</dt>
|
|
86
|
+
<dd>${site.id}</dd>
|
|
87
|
+
<dt>Origin</dt>
|
|
88
|
+
<dd><a href="${site.origin}">${site.origin}</a></dd>
|
|
89
|
+
${registered !== undefined && html `<dt>Registered</dt><dd>${date(registered)}</dd>`}
|
|
90
|
+
<dt>Status</dt>
|
|
91
|
+
<dd>${standing}</dd>
|
|
92
|
+
<dt>YouTube</dt>
|
|
93
|
+
<dd>
|
|
94
|
+
${controls.youtubeApprovedAt !== undefined
|
|
95
|
+
? `Approved ${date(controls.youtubeApprovedAt)}`
|
|
96
|
+
: 'Withheld'}
|
|
97
|
+
</dd>
|
|
98
|
+
</dl>
|
|
99
|
+
<form action="/sites/limit" method="post">
|
|
100
|
+
${hidden}
|
|
101
|
+
<label>
|
|
102
|
+
Requests a minute
|
|
103
|
+
<input
|
|
104
|
+
name="limit"
|
|
105
|
+
type="number"
|
|
106
|
+
min="1"
|
|
107
|
+
max="${maxLimit}"
|
|
108
|
+
value="${controls.limit ?? ''}"
|
|
109
|
+
placeholder="${limits.registered}">
|
|
110
|
+
</label>
|
|
111
|
+
<button>Save</button>
|
|
112
|
+
</form>
|
|
113
|
+
<div class="actions">
|
|
114
|
+
<form action="/sites/youtube" method="post">
|
|
115
|
+
${hidden}
|
|
116
|
+
${controls.youtubeApprovedAt === undefined
|
|
117
|
+
? html `<button name="approve" value="yes">Approve YouTube</button>`
|
|
118
|
+
: html `<button name="approve" value="no">Withhold YouTube</button>`}
|
|
119
|
+
</form>
|
|
120
|
+
<form action="/sites/suspend" method="post">
|
|
121
|
+
${hidden}
|
|
122
|
+
${controls.suspendedAt === undefined
|
|
123
|
+
? html `<button name="suspend" value="yes" class="danger">Suspend</button>`
|
|
124
|
+
: html `<button name="suspend" value="no">Unsuspend</button>`}
|
|
125
|
+
</form>
|
|
126
|
+
</div>
|
|
127
|
+
</section>
|
|
128
|
+
`;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/dist/instance/page.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Html } from '../server/html.js';
|
|
1
2
|
export declare const safeHeaders: {
|
|
2
3
|
'Cache-Control': string;
|
|
3
4
|
'Referrer-Policy': string;
|
|
@@ -5,12 +6,21 @@ export declare const safeHeaders: {
|
|
|
5
6
|
'X-Content-Type-Options': string;
|
|
6
7
|
'Content-Security-Policy': string;
|
|
7
8
|
};
|
|
9
|
+
/** A cookie's value as the request carries it, or undefined. */
|
|
10
|
+
export declare const readCookie: (request: Request, name: string) => string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* A Set-Cookie value for every cookie the instance sets: never readable by script, sent
|
|
13
|
+
* only over HTTPS, and kept off cross-site requests. A lifetime of 0 clears it.
|
|
14
|
+
*/
|
|
15
|
+
export declare const setCookie: (name: string, value: string, ms: number, path?: string) => string;
|
|
16
|
+
/** A 303 to `location`, setting each of `cookies`. */
|
|
17
|
+
export declare function see(location: string, cookies?: string[]): Response;
|
|
8
18
|
/**
|
|
9
19
|
* Every page here says in its heading what it is, the way the library's pages do. The
|
|
10
20
|
* logotype above already says whose it is, so the heading never has to repeat that.
|
|
11
21
|
* `script` is the path of one script the page runs, which its policy then allows.
|
|
12
22
|
*/
|
|
13
|
-
export declare const
|
|
23
|
+
export declare const respond: (heading: string, body: Html, status?: number, variant?: string, script?: string) => Response;
|
|
14
24
|
/**
|
|
15
25
|
* Every refusal reads the same, so the page says nothing about which check failed. The
|
|
16
26
|
* link goes to the root, which shows whoever asks the page that is theirs.
|
package/dist/instance/page.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { html } from '../server/html.js';
|
|
1
2
|
import { page } from '../server/render.js';
|
|
2
3
|
export const safeHeaders = {
|
|
3
4
|
'Cache-Control': 'no-store',
|
|
@@ -6,12 +7,31 @@ export const safeHeaders = {
|
|
|
6
7
|
'X-Content-Type-Options': 'nosniff',
|
|
7
8
|
'Content-Security-Policy': "default-src 'none'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
|
8
9
|
};
|
|
10
|
+
/** A cookie's value as the request carries it, or undefined. */
|
|
11
|
+
export const readCookie = (request, name) => request.headers
|
|
12
|
+
.get('cookie')
|
|
13
|
+
?.split(';')
|
|
14
|
+
.map((part) => part.trim())
|
|
15
|
+
.find((part) => part.startsWith(`${name}=`))
|
|
16
|
+
?.slice(name.length + 1);
|
|
17
|
+
/**
|
|
18
|
+
* A Set-Cookie value for every cookie the instance sets: never readable by script, sent
|
|
19
|
+
* only over HTTPS, and kept off cross-site requests. A lifetime of 0 clears it.
|
|
20
|
+
*/
|
|
21
|
+
export const setCookie = (name, value, ms, path = '/') => `${name}=${value}; HttpOnly; Secure; SameSite=Lax; Path=${path}; Max-Age=${ms / 1000}`;
|
|
22
|
+
/** A 303 to `location`, setting each of `cookies`. */
|
|
23
|
+
export 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
|
+
}
|
|
9
29
|
/**
|
|
10
30
|
* Every page here says in its heading what it is, the way the library's pages do. The
|
|
11
31
|
* logotype above already says whose it is, so the heading never has to repeat that.
|
|
12
32
|
* `script` is the path of one script the page runs, which its policy then allows.
|
|
13
33
|
*/
|
|
14
|
-
export const
|
|
34
|
+
export const respond = (heading, body, status = 200, variant = '', script = '') => new Response(page('', heading, body, variant, script), {
|
|
15
35
|
status,
|
|
16
36
|
headers: {
|
|
17
37
|
...safeHeaders,
|
|
@@ -27,4 +47,4 @@ export const html = (heading, body, status = 200, variant = '', script = '') =>
|
|
|
27
47
|
* Every refusal reads the same, so the page says nothing about which check failed. The
|
|
28
48
|
* link goes to the root, which shows whoever asks the page that is theirs.
|
|
29
49
|
*/
|
|
30
|
-
export const unavailable = (status = 404) =>
|
|
50
|
+
export const unavailable = (status = 404) => respond('Unavailable', html `<p>This resource is unavailable. <a href="/">Go to the start page</a>.</p>`, status, 'single');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type DnsOptions } from '../server/domain.js';
|
|
2
|
+
import { type Sites } from './sites.js';
|
|
3
|
+
import type { Store } from './store.js';
|
|
4
|
+
/** Whether anyone may register a site here. `open` serves a site once its DNS checks out. */
|
|
5
|
+
export type Registration = 'off' | 'open';
|
|
6
|
+
/**
|
|
7
|
+
* Sites asking to be served here. A claim is held in the requester's browser, signed, and
|
|
8
|
+
* nothing is stored until the domain's DNS shows they control it: a `verily-claim` record
|
|
9
|
+
* with the claim's code, and the `verily-verifier` record naming this instance, which
|
|
10
|
+
* stays up for as long as the site is served.
|
|
11
|
+
*/
|
|
12
|
+
export declare class Registrations {
|
|
13
|
+
private readonly store;
|
|
14
|
+
private readonly sites;
|
|
15
|
+
private readonly dns;
|
|
16
|
+
private readonly host;
|
|
17
|
+
constructor(store: Store, sites: Sites, origin: string, dns?: DnsOptions);
|
|
18
|
+
/** The secret claims are signed with, made the first time one is. */
|
|
19
|
+
private key;
|
|
20
|
+
private claim;
|
|
21
|
+
/** The form, or the records to publish for a claim this browser holds. */
|
|
22
|
+
page(request: Request): Promise<Response>;
|
|
23
|
+
/** Checks the form and gives this browser a claim for it. */
|
|
24
|
+
request(request: Request): Promise<Response>;
|
|
25
|
+
/** What to publish for a claim, and the button that checks it. */
|
|
26
|
+
private records;
|
|
27
|
+
/** Looks the records up and, if both are there, registers the site and shows its keys. */
|
|
28
|
+
check(request: Request): Promise<Response>;
|
|
29
|
+
/** Drops this browser's claim. */
|
|
30
|
+
cancel(): Response;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Reads each stored site's DNS again when it is due, and suspends a site whose DNS no
|
|
34
|
+
* longer names this instance, or lifts the suspension once it does again. A lookup that
|
|
35
|
+
* could not be made changes nothing: only an answer without the record suspends a site.
|
|
36
|
+
* A site whose record is still missing when it is checked `lapseMs` after it went is
|
|
37
|
+
* deleted.
|
|
38
|
+
* Sites in `SITES` are the host's to configure, and are not checked.
|
|
39
|
+
*
|
|
40
|
+
* Every attempt is recorded, answered or not, and the due sites asked longest ago go
|
|
41
|
+
* first. So sites that are always due, suspended or failing, cannot use up every run's
|
|
42
|
+
* budget and leave the rest unchecked.
|
|
43
|
+
*/
|
|
44
|
+
export declare function recheckSites(store: Store, sites: Sites, origin: string, dns?: DnsOptions): Promise<void>;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { html } from '../server/html.js';
|
|
2
|
+
import { domainOf, namedVerifiers, published } from '../server/domain.js';
|
|
3
|
+
import { hash, secret } from '../server/service.js';
|
|
4
|
+
import { respond, readCookie, see, setCookie } from './page.js';
|
|
5
|
+
import { checked, dnsPrefix, lapseMs, open, sign, } from './sites.js';
|
|
6
|
+
/** How long a claim lasts. DNS changes can take up to 72 hours to appear. */
|
|
7
|
+
const claimMs = 72 * 3600000;
|
|
8
|
+
const claimCookie = 'verily_claim';
|
|
9
|
+
const claimPrefix = 'verily-claim=';
|
|
10
|
+
/** The claim cookie, sent only to the registration pages. */
|
|
11
|
+
const claimed = (value, ms) => setCookie(claimCookie, value, ms, '/register');
|
|
12
|
+
const field = (form, name) => (form.get(name) ?? '').trim();
|
|
13
|
+
const form = (message, values = {}) => respond('Register a site', html `
|
|
14
|
+
${message}
|
|
15
|
+
<p>
|
|
16
|
+
Register your site to send its users here to verify their accounts. You will need to add
|
|
17
|
+
two DNS records to your domain.
|
|
18
|
+
</p>
|
|
19
|
+
<form action="/register" method="post">
|
|
20
|
+
<label>
|
|
21
|
+
Domain
|
|
22
|
+
<input name="domain" value="${values.domain ?? ''}" placeholder="example.com" required>
|
|
23
|
+
</label>
|
|
24
|
+
<label>
|
|
25
|
+
Site id
|
|
26
|
+
<input
|
|
27
|
+
name="id"
|
|
28
|
+
value="${values.id ?? ''}"
|
|
29
|
+
pattern="[a-z0-9-]{1,32}"
|
|
30
|
+
placeholder="example"
|
|
31
|
+
required>
|
|
32
|
+
</label>
|
|
33
|
+
<label>
|
|
34
|
+
Authorize URL
|
|
35
|
+
<input
|
|
36
|
+
name="authorizeUrl"
|
|
37
|
+
type="url"
|
|
38
|
+
value="${values.authorizeUrl ?? ''}"
|
|
39
|
+
placeholder="https://example.com/api/verily/authorize"
|
|
40
|
+
required>
|
|
41
|
+
</label>
|
|
42
|
+
<label>
|
|
43
|
+
Return URL
|
|
44
|
+
<input
|
|
45
|
+
name="returnUrl"
|
|
46
|
+
type="url"
|
|
47
|
+
value="${values.returnUrl ?? ''}"
|
|
48
|
+
placeholder="https://example.com/api/verily/return"
|
|
49
|
+
required>
|
|
50
|
+
</label>
|
|
51
|
+
<button>Continue</button>
|
|
52
|
+
</form>
|
|
53
|
+
`, message ? 400 : 200);
|
|
54
|
+
/**
|
|
55
|
+
* Sites asking to be served here. A claim is held in the requester's browser, signed, and
|
|
56
|
+
* nothing is stored until the domain's DNS shows they control it: a `verily-claim` record
|
|
57
|
+
* with the claim's code, and the `verily-verifier` record naming this instance, which
|
|
58
|
+
* stays up for as long as the site is served.
|
|
59
|
+
*/
|
|
60
|
+
export class Registrations {
|
|
61
|
+
store;
|
|
62
|
+
sites;
|
|
63
|
+
dns;
|
|
64
|
+
host;
|
|
65
|
+
constructor(store, sites, origin, dns = {}) {
|
|
66
|
+
this.store = store;
|
|
67
|
+
this.sites = sites;
|
|
68
|
+
this.dns = dns;
|
|
69
|
+
this.host = new URL(origin).host.toLowerCase();
|
|
70
|
+
}
|
|
71
|
+
/** The secret claims are signed with, made the first time one is. */
|
|
72
|
+
async key() {
|
|
73
|
+
return this.store.transaction(async (tx) => {
|
|
74
|
+
const kept = await tx.get('site/claim-key');
|
|
75
|
+
if (kept)
|
|
76
|
+
return kept;
|
|
77
|
+
const made = secret();
|
|
78
|
+
await tx.put('site/claim-key', made);
|
|
79
|
+
return made;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async claim(request) {
|
|
83
|
+
const token = readCookie(request, claimCookie);
|
|
84
|
+
const payload = token ? open(await this.key(), token) : undefined;
|
|
85
|
+
return payload && typeof payload.exp === 'number' && payload.exp * 1000 > Date.now()
|
|
86
|
+
? payload
|
|
87
|
+
: undefined;
|
|
88
|
+
}
|
|
89
|
+
/** The form, or the records to publish for a claim this browser holds. */
|
|
90
|
+
async page(request) {
|
|
91
|
+
const claim = await this.claim(request);
|
|
92
|
+
return claim ? this.records(claim) : form();
|
|
93
|
+
}
|
|
94
|
+
/** Checks the form and gives this browser a claim for it. */
|
|
95
|
+
async request(request) {
|
|
96
|
+
const values = new URLSearchParams(await request.text());
|
|
97
|
+
const domain = field(values, 'domain').toLowerCase().replace(/\.$/, '');
|
|
98
|
+
const shown = Object.fromEntries([...values].map(([name, value]) => [name, value.trim()]));
|
|
99
|
+
if (!domainOf(`https://${domain}`))
|
|
100
|
+
return form(html `<p>Enter a public domain name, such as example.com.</p>`, shown);
|
|
101
|
+
let site;
|
|
102
|
+
try {
|
|
103
|
+
site = checked({
|
|
104
|
+
id: field(values, 'id'),
|
|
105
|
+
name: domain,
|
|
106
|
+
origin: `https://${domain}`,
|
|
107
|
+
authorizeUrl: field(values, 'authorizeUrl'),
|
|
108
|
+
returnUrl: field(values, 'returnUrl'),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return form(html `<p>Use an id of lowercase letters, digits and hyphens, and URLs on your domain.</p>`, shown);
|
|
113
|
+
}
|
|
114
|
+
if (this.sites.sites.has(site.id))
|
|
115
|
+
return form(html `<p>That id is taken. Choose another.</p>`, shown);
|
|
116
|
+
const exp = Math.floor((Date.now() + claimMs) / 1000);
|
|
117
|
+
const token = sign(await this.key(), { site, code: secret(), exp });
|
|
118
|
+
return see('/register', [claimed(token, claimMs)]);
|
|
119
|
+
}
|
|
120
|
+
/** What to publish for a claim, and the button that checks it. */
|
|
121
|
+
records(claim, message, status = 200) {
|
|
122
|
+
const name = `_verily.${new URL(claim.site.origin).hostname}`;
|
|
123
|
+
return respond('Add the DNS records', html `
|
|
124
|
+
${message}
|
|
125
|
+
<p>Add two TXT records named <code>${name}</code>:</p>
|
|
126
|
+
<pre><code>${`${claimPrefix}${claim.code}`}</code></pre>
|
|
127
|
+
<pre><code>${`verily-verifier=${this.host}`}</code></pre>
|
|
128
|
+
<p>
|
|
129
|
+
The first proves you control ${claim.site.name}, and can be removed once the site is
|
|
130
|
+
registered. The second says this instance verifies for your site. Keep it: if it is
|
|
131
|
+
removed, the site stops being served within a day, and is deleted 30 days later.
|
|
132
|
+
</p>
|
|
133
|
+
<p>DNS changes can take up to 72 hours to appear.</p>
|
|
134
|
+
<form action="/register/check" method="post"><button>Check the records</button></form>
|
|
135
|
+
<form action="/register/cancel" method="post"><button>Start over</button></form>
|
|
136
|
+
`, status);
|
|
137
|
+
}
|
|
138
|
+
/** Looks the records up and, if both are there, registers the site and shows its keys. */
|
|
139
|
+
async check(request) {
|
|
140
|
+
const claim = await this.claim(request);
|
|
141
|
+
if (!claim)
|
|
142
|
+
return form(html `<p>That registration expired. Start again.</p>`);
|
|
143
|
+
const domain = new URL(claim.site.origin).hostname;
|
|
144
|
+
const values = await published(domain, this.dns)
|
|
145
|
+
.then((found) => found.values)
|
|
146
|
+
.catch(() => undefined);
|
|
147
|
+
if (!values)
|
|
148
|
+
return this.records(claim, html `<p>DNS could not be read. Try again shortly.</p>`, 503);
|
|
149
|
+
// The code is public once published, so a plain comparison gives nothing away.
|
|
150
|
+
const proved = values.some((value) => value.startsWith(claimPrefix) && value.slice(claimPrefix.length).trim() === claim.code);
|
|
151
|
+
const named = namedVerifiers(values).some(({ host }) => host === this.host);
|
|
152
|
+
if (!proved || !named)
|
|
153
|
+
return this.records(claim, html `
|
|
154
|
+
<p>
|
|
155
|
+
${!proved && !named
|
|
156
|
+
? 'Neither record was'
|
|
157
|
+
: !proved
|
|
158
|
+
? 'The verily-claim record was not'
|
|
159
|
+
: 'The verily-verifier record was not'} found yet.
|
|
160
|
+
</p>
|
|
161
|
+
`, 409);
|
|
162
|
+
const siteKey = secret();
|
|
163
|
+
const dashboardKey = secret();
|
|
164
|
+
try {
|
|
165
|
+
await this.sites.register({ ...claim.site, key: siteKey }, { kind: 'key', hash: hash(dashboardKey), addedAt: Date.now() });
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return respond('Not registered', html `
|
|
169
|
+
<p>That id or domain was registered by someone else first.</p>
|
|
170
|
+
<form action="/register/cancel" method="post"><button>Start over</button></form>
|
|
171
|
+
`, 409);
|
|
172
|
+
}
|
|
173
|
+
const response = respond('Site registered', html `
|
|
174
|
+
<p>
|
|
175
|
+
${claim.site.name} is registered as <code>${claim.site.id}</code>. These keys are shown
|
|
176
|
+
once and cannot be shown again.
|
|
177
|
+
</p>
|
|
178
|
+
<p>Site key, for your backend only. It signs handoffs and reads your users' connections.</p>
|
|
179
|
+
<pre><code>${siteKey}</code></pre>
|
|
180
|
+
<p>Dashboard key, for whoever manages the site here. Keep it in a password manager.</p>
|
|
181
|
+
<pre><code>${dashboardKey}</code></pre>
|
|
182
|
+
<p>
|
|
183
|
+
If both keys are lost, the site cannot be recovered. Registering the domain again makes a
|
|
184
|
+
new site, and your users have to connect their accounts again.
|
|
185
|
+
</p>
|
|
186
|
+
`);
|
|
187
|
+
response.headers.append('Set-Cookie', claimed('', 0));
|
|
188
|
+
return response;
|
|
189
|
+
}
|
|
190
|
+
/** Drops this browser's claim. */
|
|
191
|
+
cancel() {
|
|
192
|
+
return see('/register', [claimed('', 0)]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* How often a stored site's DNS is read again: daily, and hourly while it is suspended or
|
|
197
|
+
* after a lookup that got no answer.
|
|
198
|
+
*/
|
|
199
|
+
const recheckMs = 24 * 3600000;
|
|
200
|
+
const retryMs = 3600000;
|
|
201
|
+
/** How many sites one `recheckSites()` looks up, so upkeep stays short. */
|
|
202
|
+
const recheckBudget = 20;
|
|
203
|
+
/**
|
|
204
|
+
* Reads each stored site's DNS again when it is due, and suspends a site whose DNS no
|
|
205
|
+
* longer names this instance, or lifts the suspension once it does again. A lookup that
|
|
206
|
+
* could not be made changes nothing: only an answer without the record suspends a site.
|
|
207
|
+
* A site whose record is still missing when it is checked `lapseMs` after it went is
|
|
208
|
+
* deleted.
|
|
209
|
+
* Sites in `SITES` are the host's to configure, and are not checked.
|
|
210
|
+
*
|
|
211
|
+
* Every attempt is recorded, answered or not, and the due sites asked longest ago go
|
|
212
|
+
* first. So sites that are always due, suspended or failing, cannot use up every run's
|
|
213
|
+
* budget and leave the rest unchecked.
|
|
214
|
+
*/
|
|
215
|
+
export async function recheckSites(store, sites, origin, dns = {}) {
|
|
216
|
+
const host = new URL(origin).host.toLowerCase();
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
await sites.refresh();
|
|
219
|
+
const checks = new Map((await store.list(dnsPrefix)).map(([key, check]) => [
|
|
220
|
+
key.slice(dnsPrefix.length),
|
|
221
|
+
check,
|
|
222
|
+
]));
|
|
223
|
+
const due = sites
|
|
224
|
+
.stored()
|
|
225
|
+
.map((site) => ({ site, last: checks.get(site.id) }))
|
|
226
|
+
.filter(({ last }) => {
|
|
227
|
+
if (!last)
|
|
228
|
+
return true;
|
|
229
|
+
const answered = last.checkedAt !== undefined && last.checkedAt >= last.attemptedAt;
|
|
230
|
+
const every = answered && last.absentSince === undefined ? recheckMs : retryMs;
|
|
231
|
+
return now - last.attemptedAt >= every;
|
|
232
|
+
})
|
|
233
|
+
.sort((a, b) => (a.last?.attemptedAt ?? 0) - (b.last?.attemptedAt ?? 0))
|
|
234
|
+
.slice(0, recheckBudget);
|
|
235
|
+
for (const { site, last } of due) {
|
|
236
|
+
const values = await published(new URL(site.origin).hostname, dns)
|
|
237
|
+
.then((found) => found.values)
|
|
238
|
+
.catch(() => undefined);
|
|
239
|
+
const named = values && namedVerifiers(values).some((found) => found.host === host);
|
|
240
|
+
const check = values
|
|
241
|
+
? {
|
|
242
|
+
attemptedAt: now,
|
|
243
|
+
checkedAt: now,
|
|
244
|
+
...(named ? {} : { absentSince: last?.absentSince ?? now }),
|
|
245
|
+
}
|
|
246
|
+
: { ...last, attemptedAt: now };
|
|
247
|
+
await store.put(`${dnsPrefix}${site.id}`, check);
|
|
248
|
+
// A site suspended for a month is deleted, and its id is never used again. Only an
|
|
249
|
+
// answer just given can do it: a failed lookup, or one from an earlier run, may be stale.
|
|
250
|
+
if (values && !named && now - check.absentSince >= lapseMs)
|
|
251
|
+
await sites.retire(site.id, 'lapsed');
|
|
252
|
+
}
|
|
253
|
+
await sites.refresh();
|
|
254
|
+
}
|
package/dist/instance/routes.js
CHANGED
|
@@ -12,6 +12,20 @@ export const own = new Set([
|
|
|
12
12
|
'/mark',
|
|
13
13
|
'/confirm',
|
|
14
14
|
'/records',
|
|
15
|
+
'/register',
|
|
16
|
+
'/register/check',
|
|
17
|
+
'/register/cancel',
|
|
18
|
+
'/site/dashboard',
|
|
19
|
+
'/site/dashboard/login',
|
|
20
|
+
'/site/dashboard/logout',
|
|
21
|
+
'/site/dashboard/settings',
|
|
22
|
+
'/site/dashboard/rotate',
|
|
23
|
+
'/site/dashboard/previous-key',
|
|
24
|
+
'/site/dashboard/delete',
|
|
25
|
+
'/sites',
|
|
26
|
+
'/sites/suspend',
|
|
27
|
+
'/sites/limit',
|
|
28
|
+
'/sites/youtube',
|
|
15
29
|
'/handoff/request',
|
|
16
30
|
'/handoff/accept',
|
|
17
31
|
'/site/connections',
|