@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 CHANGED
@@ -112,7 +112,7 @@ const verily = createVerily({
112
112
  | `baseUrl` | The public URL you mount Verily at. Every route it serves is under this path. |
113
113
  | `siteName` | Your site's name, shown on evidence. |
114
114
  | `verifierName` | Who vouches for the record, shown on evidence. Usually the backend's domain. |
115
- | `profileOrigins` | The origins a local `profileUrl` may be on. |
115
+ | `profileOrigins` | The origins a local `profileUrl` may be on, or a function returning them. |
116
116
  | `reportUrl` | Where readers report a bad record (`https:` or `mailto:`). |
117
117
  | `authenticate` | Returns the signed-in local account for a request, or `undefined` if nobody is signed in. |
118
118
 
@@ -168,7 +168,7 @@ import { init } from '@bkazemi/verily';
168
168
  const client = init({ backendUrl: '/api/verily' });
169
169
  ```
170
170
 
171
- Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.8.0/dist/verily.js`. The script defines a global `Verily`.
171
+ Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.9.0/dist/verily.js`. The script defines a global `Verily`.
172
172
 
173
173
  Then place the badge wherever the account appears:
174
174
 
@@ -370,7 +370,7 @@ A static site only needs the script and the badge markup; the backend runs somew
370
370
 
371
371
  ```html
372
372
  <script
373
- src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.8.0/dist/verily.js"
373
+ src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.9.0/dist/verily.js"
374
374
  defer
375
375
  ></script>
376
376
  <verily-badge
@@ -399,6 +399,8 @@ const verily = createSiteClient({
399
399
 
400
400
  The client has no dependencies and uses only Web Crypto and `fetch`, so it runs on Node, Next.js, Workers, Deno and Bun. Use it only on your backend: the key is what the instance believes.
401
401
 
402
+ When your key is replaced and the old one is kept for 24 hours, deploy with the new key as `key` and the old one as `previousKey`. A user who began before the switch comes back with a result signed with the old key, and `result()` accepts either. Nothing is signed with `previousKey`. Remove it on a later deploy, once the 24 hours are over.
403
+
402
404
  **1. Mount the handler.** It serves the endpoints the instance and the pill need. Give it your own session lookup, and mount it on every path under one prefix:
403
405
 
404
406
  ```ts
@@ -1,6 +1,8 @@
1
1
  import { timingSafeEqual } from 'node:crypto';
2
2
  import { expired } from '../core/index.js';
3
3
  import { hash, secret } from '../server/service.js';
4
+ import { readCookie, setCookie } from './page.js';
5
+ import { validKey } from './sites.js';
4
6
  const sessionMs = 8 * 3600000;
5
7
  // Use a normal host-only cookie name. Secure, HttpOnly, SameSite and Path=/
6
8
  // provide the relevant protections while avoiding __Host-prefix rejection by
@@ -12,16 +14,11 @@ export class OwnerAuth {
12
14
  constructor(storage, ownerKey) {
13
15
  this.storage = storage;
14
16
  this.ownerKey = ownerKey;
15
- if (!/^[A-Za-z0-9_-]{43,}$/.test(ownerKey))
17
+ if (!validKey(ownerKey))
16
18
  throw new Error('OWNER_KEY must be a randomly generated base64url secret of at least 32 bytes');
17
19
  }
18
20
  token(request) {
19
- return request.headers
20
- .get('cookie')
21
- ?.split(';')
22
- .map((part) => part.trim())
23
- .find((part) => part.startsWith(`${cookieName}=`))
24
- ?.slice(cookieName.length + 1);
21
+ return readCookie(request, cookieName);
25
22
  }
26
23
  async authenticated(request) {
27
24
  return (await this.session(request)) !== undefined;
@@ -45,13 +42,13 @@ export class OwnerAuth {
45
42
  expiresAt: Date.now() + sessionMs,
46
43
  keyHash: hash(this.ownerKey),
47
44
  });
48
- return `${cookieName}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${sessionMs / 1000}`;
45
+ return setCookie(cookieName, token, sessionMs);
49
46
  }
50
47
  async logout(request) {
51
48
  const token = this.token(request);
52
49
  if (token)
53
50
  await this.storage.delete(`owner/session/${hash(token)}`);
54
- return `${cookieName}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
51
+ return setCookie(cookieName, '', 0);
55
52
  }
56
53
  async allow(bucket, limit, windowMs) {
57
54
  return this.storage.transaction(async (tx) => {
@@ -1,6 +1,7 @@
1
1
  import { admit, createInstance } from './index.js';
2
2
  import { fromEnv } from './env.js';
3
- import { html } from './page.js';
3
+ import { respond } from './page.js';
4
+ import { html } from '../server/html.js';
4
5
  import { recordStorage } from './store.js';
5
6
  /** Values are copied on the way in and out, so a caller never shares one with storage. */
6
7
  const keyValue = (storage) => ({
@@ -44,7 +45,7 @@ export default {
44
45
  });
45
46
  }
46
47
  catch {
47
- return html('Temporarily unavailable', '', 503);
48
+ return respond('Temporarily unavailable', html ``, 503);
48
49
  }
49
50
  },
50
51
  };
@@ -0,0 +1,41 @@
1
+ import { type Provider } from '../core/index.js';
2
+ import { type Sites } from './sites.js';
3
+ import type { Store } from './store.js';
4
+ /**
5
+ * Where the operators of a site registered here change its settings and replace its key.
6
+ * An operator signs in with the site's id and a dashboard key, which is kept only as a
7
+ * hash. Sites in `SITES` have no operators, and are changed by the host.
8
+ */
9
+ export declare class Dashboard {
10
+ private readonly store;
11
+ private readonly sites;
12
+ private readonly providers;
13
+ private readonly host;
14
+ private readonly reportUrl;
15
+ constructor(store: Store, sites: Sites, providers: Provider[], host: string, reportUrl: string);
16
+ /** The signed-in operator's site, if the session and the key it was opened with still hold. */
17
+ private session;
18
+ /** The dashboard for a signed-in operator, or the sign-in form. */
19
+ page(request: Request): Promise<Response>;
20
+ login(request: Request): Promise<Response>;
21
+ logout(request: Request): Promise<Response>;
22
+ /** Saves the site's URLs, providers and visibility. */
23
+ settings(request: Request): Promise<Response>;
24
+ /**
25
+ * Replaces the site key and shows the new one, once, on a page of its own. The operator
26
+ * chooses whether the old key keeps working for a day, for a routine change, or stops at
27
+ * once, for a leak. The form names the key it replaces, so sending it again, from a
28
+ * reload or a second tab, replaces nothing.
29
+ */
30
+ rotate(request: Request): Promise<Response>;
31
+ /** Stops accepting the previous site key before its 24 hours are up, once confirmed. */
32
+ retirePreviousKey(request: Request): Promise<Response>;
33
+ /**
34
+ * Deletes the site, once the operator has typed its id to confirm. Its id is never used
35
+ * again, so the same domain can only come back as a new site.
36
+ */
37
+ delete(request: Request): Promise<Response>;
38
+ /** The provider ids this instance offers, each once, with a name to show. */
39
+ private offered;
40
+ private render;
41
+ }
@@ -0,0 +1,334 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { expired } from '../core/index.js';
3
+ import { html, raw } from '../server/html.js';
4
+ import { date, minute } from '../server/render.js';
5
+ import { hash, secret } from '../server/service.js';
6
+ import { allow } from './limits.js';
7
+ import { respond, readCookie, see, setCookie, unavailable } from './page.js';
8
+ import { dnsPrefix, lapseMs, validId } from './sites.js';
9
+ const cookieName = 'verily_dashboard';
10
+ const sessionMs = 8 * 3600000;
11
+ /** Sign-in attempts a minute for one site, whoever makes them. */
12
+ const loginLimit = 10;
13
+ const sessionPrefix = 'site/dashboard/';
14
+ /** The session cookie, sent only to the dashboard's pages. */
15
+ const sessionSet = (value, ms) => setCookie(cookieName, value, ms, '/site/dashboard');
16
+ const signIn = (message, status = 200) => respond('Site dashboard', html `
17
+ ${message}
18
+ <form action="/site/dashboard/login" method="post">
19
+ <label>Site id <input name="id" autocomplete="username" required></label>
20
+ <label>
21
+ Dashboard key
22
+ <input name="key" type="password" autocomplete="current-password" required>
23
+ </label>
24
+ <button>Sign in</button>
25
+ </form>
26
+ `, status, 'single');
27
+ const back = html `<p><a href="/site/dashboard">Back to the dashboard</a></p>`;
28
+ /**
29
+ * Asks before a key change goes ahead, on a page of its own. Its button sends `fields` to
30
+ * `action` again, with `confirm=yes`.
31
+ */
32
+ const ask = (heading, text, action, fields, button, danger) => respond(heading, html `
33
+ <p>${text}</p>
34
+ <form action="${action}" method="post">
35
+ ${Object.entries(fields).map(([name, value]) => html `<input type="hidden" name="${name}" value="${value}">`)}
36
+ <input type="hidden" name="confirm" value="yes">
37
+ <button${danger && raw(' class="danger"')}>${button}</button>
38
+ </form>
39
+ ${back}
40
+ `, 200, 'single');
41
+ const checkbox = (name, value, label, checked) => html `
42
+ <label>
43
+ <input type="checkbox" name="${name}" value="${value}"${checked && raw(' checked')}> ${label}
44
+ </label>
45
+ `;
46
+ /**
47
+ * Where the operators of a site registered here change its settings and replace its key.
48
+ * An operator signs in with the site's id and a dashboard key, which is kept only as a
49
+ * hash. Sites in `SITES` have no operators, and are changed by the host.
50
+ */
51
+ export class Dashboard {
52
+ store;
53
+ sites;
54
+ providers;
55
+ host;
56
+ reportUrl;
57
+ constructor(store, sites, providers, host, reportUrl) {
58
+ this.store = store;
59
+ this.sites = sites;
60
+ this.providers = providers;
61
+ this.host = host;
62
+ this.reportUrl = reportUrl;
63
+ }
64
+ /** The signed-in operator's site, if the session and the key it was opened with still hold. */
65
+ async session(request) {
66
+ const value = readCookie(request, cookieName);
67
+ if (!value)
68
+ return undefined;
69
+ const key = `${sessionPrefix}${hash(value)}`;
70
+ const session = await this.store.get(key);
71
+ if (!session || expired(session, Date.now()))
72
+ return undefined;
73
+ const site = this.sites.stored().find((stored) => stored.id === session.site);
74
+ const operators = site ? await this.sites.operators(site.id) : [];
75
+ if (!site || !operators.some((operator) => operator.hash === session.operator))
76
+ return undefined;
77
+ return { key, site };
78
+ }
79
+ /** The dashboard for a signed-in operator, or the sign-in form. */
80
+ async page(request) {
81
+ const found = await this.session(request);
82
+ return found ? this.render(found.site) : signIn();
83
+ }
84
+ async login(request) {
85
+ const fields = new URLSearchParams(await request.text());
86
+ const id = fields.get('id')?.trim() ?? '';
87
+ const given = fields.get('key') ?? '';
88
+ if (!validId(id))
89
+ return signIn(html `<p>Incorrect site id or key.</p>`, 403);
90
+ if (!(await allow(this.store, [{ key: `dashboard/${id}`, limit: loginLimit }])))
91
+ return signIn(html `<p>Too many attempts. Try again in a minute.</p>`, 429);
92
+ const operator = (await this.sites.operators(id)).find((candidate) => timingSafeEqual(Buffer.from(candidate.hash), Buffer.from(hash(given))));
93
+ if (!operator || !this.sites.stored().some((site) => site.id === id))
94
+ return signIn(html `<p>Incorrect site id or key.</p>`, 403);
95
+ const session = secret();
96
+ await this.store.put(`${sessionPrefix}${hash(session)}`, {
97
+ site: id,
98
+ operator: operator.hash,
99
+ expiresAt: Date.now() + sessionMs,
100
+ });
101
+ return see('/site/dashboard', [sessionSet(session, sessionMs)]);
102
+ }
103
+ async logout(request) {
104
+ const value = readCookie(request, cookieName);
105
+ if (value)
106
+ await this.store.delete(`${sessionPrefix}${hash(value)}`);
107
+ return see('/site/dashboard', [sessionSet('', 0)]);
108
+ }
109
+ /** Saves the site's URLs, providers and visibility. */
110
+ async settings(request) {
111
+ const found = await this.session(request);
112
+ if (!found)
113
+ return unavailable(403);
114
+ const fields = new URLSearchParams(await request.text());
115
+ const offered = this.offered();
116
+ const providers = fields.getAll('provider').filter((id) => offered.has(id));
117
+ const visibility = fields
118
+ .getAll('visibility')
119
+ .filter((v) => v === 'public' || v === 'unlisted');
120
+ if (!providers.length || !visibility.length)
121
+ return this.render(found.site, html `<p>Choose at least one provider and one visibility.</p>`, 400);
122
+ try {
123
+ await this.sites.update(found.site.id, {
124
+ authorizeUrl: fields.get('authorizeUrl')?.trim() ?? '',
125
+ returnUrl: fields.get('returnUrl')?.trim() ?? '',
126
+ // Every provider chosen is saved as no list, so the site also gets any added later.
127
+ providers: providers.length === offered.size ? undefined : providers,
128
+ visibility: visibility.length === 2 ? undefined : visibility,
129
+ });
130
+ }
131
+ catch {
132
+ return this.render(found.site, html `<p>The authorize and return URLs must be on ${found.site.origin}.</p>`, 400);
133
+ }
134
+ return see('/site/dashboard');
135
+ }
136
+ /**
137
+ * Replaces the site key and shows the new one, once, on a page of its own. The operator
138
+ * chooses whether the old key keeps working for a day, for a routine change, or stops at
139
+ * once, for a leak. The form names the key it replaces, so sending it again, from a
140
+ * reload or a second tab, replaces nothing.
141
+ */
142
+ async rotate(request) {
143
+ const found = await this.session(request);
144
+ if (!found)
145
+ return unavailable(403);
146
+ const fields = new URLSearchParams(await request.text());
147
+ const keep = fields.get('keep') === 'day';
148
+ const from = fields.get('from') ?? '';
149
+ const replaced = () => respond('Key already replaced', html `
150
+ <p>
151
+ The site key was already replaced since this form was loaded, so nothing changed.
152
+ The new key was shown when it was made.
153
+ </p>
154
+ ${back}
155
+ `, 409, 'single');
156
+ if (from !== hash(found.site.key))
157
+ return replaced();
158
+ const overlapping = !!found.site.previousKey && (found.site.previousUntil ?? 0) > Date.now();
159
+ if (fields.get('confirm') !== 'yes')
160
+ return keep
161
+ ? ask('Replace the key?', `Deploy the new key to your backend within 24 hours. After that the old key stops working, and so does your site until it has the new one.${overlapping ? ' The older key still accepted from the last replacement stops working now.' : ''}`, '/site/dashboard/rotate', { keep: 'day', from }, 'Replace, keep the old key for 24 hours', false)
162
+ : ask('Replace the key now?', 'Your site stops sending users here and reading their connections until you deploy the new key. To change keys without interruption, keep the old key for 24 hours instead.', '/site/dashboard/rotate', { keep: 'none', from }, 'Replace now', true);
163
+ const key = await this.sites.rotate(found.site.id, keep, from);
164
+ if (!key)
165
+ return replaced();
166
+ const until = this.sites.sites.get(found.site.id)?.previousUntil;
167
+ return respond('New site key', html `
168
+ <p>This key is shown once. Copy it now.</p>
169
+ <pre><code>${key}</code></pre>
170
+ <p>
171
+ ${keep && until
172
+ ? `The old key keeps working until ${minute(until)}. Deploy the new key to your backend, then stop accepting the old one from the dashboard.`
173
+ : 'The old key no longer works. Deploy the new key to your backend now: until you do, your site cannot send users here or read their connections.'}
174
+ </p>
175
+ ${back}
176
+ `);
177
+ }
178
+ /** Stops accepting the previous site key before its 24 hours are up, once confirmed. */
179
+ async retirePreviousKey(request) {
180
+ const found = await this.session(request);
181
+ if (!found)
182
+ return unavailable(403);
183
+ if (new URLSearchParams(await request.text()).get('confirm') !== 'yes')
184
+ return ask('Stop accepting the old key?', 'Any backend still using the old key stops working at once: it cannot send users here or read their connections. Do this only once the new key is deployed everywhere.', '/site/dashboard/previous-key', {}, 'Stop accepting the old key', true);
185
+ await this.sites.retirePreviousKey(found.site.id);
186
+ return see('/site/dashboard');
187
+ }
188
+ /**
189
+ * Deletes the site, once the operator has typed its id to confirm. Its id is never used
190
+ * again, so the same domain can only come back as a new site.
191
+ */
192
+ async delete(request) {
193
+ const found = await this.session(request);
194
+ if (!found)
195
+ return unavailable(403);
196
+ const typed = new URLSearchParams(await request.text()).get('confirm')?.trim();
197
+ if (typed !== found.site.id)
198
+ return this.render(found.site, html `<p>Type the site id to delete the site.</p>`, 400);
199
+ await this.sites.retire(found.site.id, 'deleted');
200
+ await this.store.delete(found.key);
201
+ const response = respond('Site deleted', html `
202
+ <p>
203
+ ${found.site.name} is deleted and no longer served here.
204
+ Its id, <code>${found.site.id}</code>, cannot be registered again.
205
+ </p>
206
+ `, 200, 'single');
207
+ response.headers.append('Set-Cookie', sessionSet('', 0));
208
+ return response;
209
+ }
210
+ /** The provider ids this instance offers, each once, with a name to show. */
211
+ offered() {
212
+ return new Map(this.providers.map((provider) => [provider.id, provider.name]));
213
+ }
214
+ async render(site, message, status = 200) {
215
+ const check = await this.store.get(`${dnsPrefix}${site.id}`);
216
+ const record = `_verily.${new URL(site.origin).hostname}`;
217
+ const suspended = check?.absentSince !== undefined;
218
+ const controls = this.sites.controls.get(site.id) ?? {};
219
+ const standing = controls.suspendedAt !== undefined
220
+ ? `Suspended by the instance owner since ${date(controls.suspendedAt)}`
221
+ : suspended
222
+ ? `Suspended since ${date(check.absentSince)}`
223
+ : check?.checkedAt !== undefined
224
+ ? `Served · DNS record found ${date(check.checkedAt)}`
225
+ : 'Served · DNS record not checked yet';
226
+ // The one thing on the page the operator has to act on, so the one sentence.
227
+ const restore = suspended &&
228
+ html `
229
+ <p>
230
+ <code>${record}</code> no longer has <code>verily-verifier=${this.host}</code>.
231
+ Add it back and the site is served again within an hour.
232
+ Otherwise the site is deleted on ${date(check.absentSince + lapseMs)}.
233
+ </p>
234
+ `;
235
+ const appeal = controls.suspendedAt !== undefined &&
236
+ html `
237
+ <p>
238
+ The instance owner suspended this site. Your site cannot send users here, but your
239
+ backend can still read their connections. To ask why or appeal,
240
+ <a href="${this.reportUrl}">contact the instance owner</a>.
241
+ </p>
242
+ `;
243
+ const overlapping = !!site.previousKey && (site.previousUntil ?? 0) > Date.now();
244
+ // A provider the owner has not approved this site for stays chosen, and is offered once
245
+ // it is approved.
246
+ const held = this.sites.withheld(site.id);
247
+ const providers = [...this.offered()].map(([id, name]) => checkbox('provider', id, held.includes(id) ? `${name} · awaiting the instance owner's approval` : name, !site.providers || site.providers.includes(id)));
248
+ const visibility = ['public', 'unlisted'].map((v) => checkbox('visibility', v, v[0].toUpperCase() + v.slice(1), !site.visibility || site.visibility.includes(v)));
249
+ return respond('Site dashboard', html `
250
+ ${message}
251
+ <dl>
252
+ <dt>Site</dt>
253
+ <dd>${site.name}</dd>
254
+ <dt>Site id</dt>
255
+ <dd>${site.id}</dd>
256
+ <dt>Status</dt>
257
+ <dd>${standing}</dd>
258
+ <dt>Request limit</dt>
259
+ <dd>${this.sites.limit(site.id)} a minute</dd>
260
+ </dl>
261
+ ${appeal}
262
+ ${restore}
263
+ <h2>Settings</h2>
264
+ <form action="/site/dashboard/settings" method="post">
265
+ <label>
266
+ Authorize URL
267
+ <input name="authorizeUrl" type="url" value="${site.authorizeUrl}" required>
268
+ </label>
269
+ <label>
270
+ Return URL
271
+ <input name="returnUrl" type="url" value="${site.returnUrl}" required>
272
+ </label>
273
+ <fieldset>
274
+ <legend>Providers your users may verify with</legend>
275
+ ${providers}
276
+ </fieldset>
277
+ <fieldset>
278
+ <legend>Visibility your users may choose</legend>
279
+ ${visibility}
280
+ </fieldset>
281
+ <button>Save</button>
282
+ </form>
283
+ <section class="caution">
284
+ <h2>Site key</h2>
285
+ <p class="fine">
286
+ Your backend signs handoffs and reads connections with this key. A new key is shown
287
+ once. Keep the old key working for 24 hours to deploy the new one without
288
+ interruption, or stop it at once if it may have leaked.
289
+ </p>
290
+ ${overlapping &&
291
+ html `
292
+ <section>
293
+ <p>
294
+ The old key is accepted until ${minute(site.previousUntil)}.
295
+ Stop accepting it once the new key is deployed.
296
+ </p>
297
+ <div class="actions">
298
+ <form action="/site/dashboard/previous-key" method="post">
299
+ <button>Stop accepting the old key</button>
300
+ </form>
301
+ </div>
302
+ </section>
303
+ `}
304
+ <div class="actions">
305
+ <form action="/site/dashboard/rotate" method="post">
306
+ <input type="hidden" name="from" value="${hash(site.key)}">
307
+ <button name="keep" value="day">Replace, keep the old key for 24 hours</button>
308
+ <button name="keep" value="none">Replace now</button>
309
+ </form>
310
+ </div>
311
+ </section>
312
+ <section class="danger">
313
+ <h2>Delete the site</h2>
314
+ <p class="fine">
315
+ The site stops being served here at once, and its id can never be registered again.
316
+ To come back, register the domain as a new site. Your users then connect their
317
+ accounts again.
318
+ </p>
319
+ <form action="/site/dashboard/delete" method="post">
320
+ <label>
321
+ Type <code>${site.id}</code> to confirm
322
+ <input name="confirm" autocomplete="off" required>
323
+ </label>
324
+ <button class="danger">Delete the site</button>
325
+ </form>
326
+ </section>
327
+ <footer>
328
+ <div class="actions">
329
+ <form action="/site/dashboard/logout" method="post"><button>Sign out</button></form>
330
+ </div>
331
+ </footer>
332
+ `, status);
333
+ }
334
+ }
@@ -43,6 +43,8 @@ export interface InstanceEnv {
43
43
  */
44
44
  SITES?: string | unknown[];
45
45
  [key: `SITE_${string}_KEY`]: string | undefined;
46
+ /** `open` lets anyone register a site at `/register`. Off unless set. */
47
+ REGISTRATION?: string;
46
48
  }
47
49
  /**
48
50
  * An instance's options from its settings. Every host builds them here, so two hosts given
@@ -80,5 +80,6 @@ export function fromEnv(env) {
80
80
  sites: registry(env.SITES, env),
81
81
  signingKey: env.SIGNING === 'off' ? false : env.SIGNING_KEY || undefined,
82
82
  mailPerDay: dailyMail(env.EMAIL_DAILY_LIMIT),
83
+ registration: env.REGISTRATION === 'open' ? 'open' : 'off',
83
84
  };
84
85
  }
@@ -1,6 +1,8 @@
1
1
  import { type LocalAccount, type Provider } from '../core/index.js';
2
2
  import { type Store } from './store.js';
3
3
  import { type Site } from './sites.js';
4
+ import { type Registration } from './register.js';
5
+ import type { DnsOptions } from '../server/domain.js';
4
6
  export { fromEnv, type InstanceEnv } from './env.js';
5
7
  export { registry, type Site } from './sites.js';
6
8
  export { recordStorage, type KeyValue, type Store } from './store.js';
@@ -20,7 +22,10 @@ export interface InstanceOptions {
20
22
  reportUrl: string;
21
23
  /** The methods offered. A registered site may narrow these for its own holders. */
22
24
  providers: Provider[];
23
- /** The sites that send their users here instead of hosting Verily, as `registry()` reads them. */
25
+ /**
26
+ * The sites the host configures, as `registry()` reads them. Sites registered on the
27
+ * instance itself are kept in the store and served alongside these.
28
+ */
24
29
  sites?: Map<string, Site>;
25
30
  /**
26
31
  * The key public records are signed by, armored. Left out, a key is made on first use and
@@ -29,6 +34,13 @@ export interface InstanceOptions {
29
34
  signingKey?: string | false;
30
35
  /** How many codes may be mailed in a day, where the mail allowance is not the default. */
31
36
  mailPerDay?: number;
37
+ /**
38
+ * Whether anyone may register a site at `/register`. With `open`, a site is served once
39
+ * its domain's DNS shows the requester controls it. `off` by default.
40
+ */
41
+ registration?: Registration;
42
+ /** Replaces how DNS is read, for a host behind an egress proxy, or for tests. */
43
+ dns?: DnsOptions;
32
44
  /** How often each published proof is re-read. Six hours by default. */
33
45
  recheckMs?: number;
34
46
  /** How many proofs one `upkeep()` re-reads per provider. 10 by default. */
@@ -51,6 +63,9 @@ declare class Server {
51
63
  private readonly origin;
52
64
  private readonly auth;
53
65
  private readonly sites;
66
+ private readonly registrations?;
67
+ private readonly dashboard;
68
+ private readonly ownerSites;
54
69
  private readonly local;
55
70
  constructor(options: InstanceOptions);
56
71
  /** The library's service, for a host that needs more than requests and upkeep. */