@bkazemi/verily 0.6.0 → 0.8.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.
Files changed (62) hide show
  1. package/README.md +197 -91
  2. package/dist/browser/connect-dialog.js +3 -4
  3. package/dist/browser/evidence-dialog.d.ts +7 -2
  4. package/dist/browser/evidence-dialog.js +62 -26
  5. package/dist/browser/index.js +3 -0
  6. package/dist/browser/mark.d.ts +2 -6
  7. package/dist/browser/mark.js +6 -12
  8. package/dist/browser/provider-mark.d.ts +3 -3
  9. package/dist/browser/provider-mark.js +15 -90
  10. package/dist/core/index.d.ts +78 -2
  11. package/dist/core/index.js +26 -2
  12. package/dist/instance/auth.d.ts +14 -0
  13. package/dist/instance/auth.js +80 -0
  14. package/dist/instance/bin.d.ts +2 -0
  15. package/dist/instance/bin.js +36 -0
  16. package/dist/instance/cloudflare.d.ts +57 -0
  17. package/dist/instance/cloudflare.js +71 -0
  18. package/dist/instance/env.d.ts +51 -0
  19. package/dist/instance/env.js +84 -0
  20. package/dist/instance/index.d.ts +136 -0
  21. package/dist/instance/index.js +777 -0
  22. package/dist/instance/init.d.ts +6 -0
  23. package/dist/instance/init.js +28 -0
  24. package/dist/instance/limits.d.ts +30 -0
  25. package/dist/instance/limits.js +45 -0
  26. package/dist/instance/node.d.ts +13 -0
  27. package/dist/instance/node.js +43 -0
  28. package/dist/instance/page.d.ts +18 -0
  29. package/dist/instance/page.js +30 -0
  30. package/dist/instance/postgres.d.ts +19 -0
  31. package/dist/instance/postgres.js +83 -0
  32. package/dist/instance/routes.d.ts +7 -0
  33. package/dist/instance/routes.js +22 -0
  34. package/dist/instance/serve.d.ts +31 -0
  35. package/dist/instance/serve.js +49 -0
  36. package/dist/instance/sites.d.ts +169 -0
  37. package/dist/instance/sites.js +413 -0
  38. package/dist/instance/starter.d.ts +6 -0
  39. package/dist/instance/starter.js +99 -0
  40. package/dist/instance/store.d.ts +20 -0
  41. package/dist/instance/store.js +11 -0
  42. package/dist/instance/undici.d.ts +8 -0
  43. package/dist/instance/undici.js +9 -0
  44. package/dist/marks.d.ts +44 -0
  45. package/dist/marks.js +98 -0
  46. package/dist/server/discord.js +3 -0
  47. package/dist/server/domain.js +37 -10
  48. package/dist/server/github-gist.js +16 -4
  49. package/dist/server/github.js +3 -0
  50. package/dist/server/index.js +20 -47
  51. package/dist/server/link.js +9 -4
  52. package/dist/server/render.d.ts +49 -0
  53. package/dist/server/render.js +104 -0
  54. package/dist/server/service.d.ts +44 -1
  55. package/dist/server/service.js +225 -19
  56. package/dist/server/style.d.ts +1 -1
  57. package/dist/server/style.js +132 -2
  58. package/dist/server/youtube.js +5 -0
  59. package/dist/verily.js +155 -124
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +30 -2
package/README.md CHANGED
@@ -16,24 +16,35 @@
16
16
  <img src="docs/images/badge.png" alt="A profile card with a Verily badge showing a GitHub account" width="480" />
17
17
  </p>
18
18
 
19
- Verily is a self-hosted library for Node.js. A signed-in user of your site proves they control an external account, by signing in with GitHub, Discord or YouTube, publishing a gist, linking back with `rel="me"`, signing with an OpenPGP key, or answering a mailed link. They approve the connection, and Verily publishes it as a record anyone can inspect, with a badge for their profile.
19
+ Verily comes as a library you embed in your own backend, or as a ready-made server you run on Node or Cloudflare. A signed-in user of your site proves they control an external account, such as a GitHub account, a domain or an email address, by one of several [proof methods](#proof-methods). They approve the connection and choose who can read it. A public one is a record anyone can inspect, with a badge for their profile. An unlisted one is kept off public pages.
20
20
 
21
21
  It doesn't sign anyone into your site, and it doesn't establish legal identity.
22
22
 
23
+ ## Contents
24
+
25
+ - **Start here:** [How it works](#how-it-works) · [Ways to use it](#ways-to-use-it)
26
+ - **Embedding the backend:** [Install](#install) · [Embed the backend in your app](#embed-the-backend-in-your-app) · [Show the badge](#show-the-badge) · [Proof methods](#proof-methods) · [Operations](#operations)
27
+ - **Running an instance:** [Run an instance](#run-an-instance) · [A badge on a static site](#a-badge-on-a-static-site)
28
+ - **Using someone else's instance:** [Using a hosted instance](#using-a-hosted-instance)
29
+ - **Reference:** [Serving other sites](#serving-other-sites) · [Signed records](#signed-records) · [What DNS says of the verifier](#what-dns-says-of-the-verifier) · [Development](#development) · [License](#license)
30
+
23
31
  ## How it works
24
32
 
25
33
  1. Your site tells Verily who is signed in. Verily never handles your logins.
26
- 2. The user opens `<baseUrl>/verify`, picks a method, and proves they control the external account.
27
- 3. They approve the connection. Verily stores it and publishes the evidence at a public URL.
34
+ 2. The user opens Verily's connect dialog on your site, picks a method, and proves they control the external account.
35
+ 3. They approve the connection and choose who can read it. Verily stores it, and publishes the evidence of a public one at a public URL.
28
36
  4. A `<verily-badge>` on their profile shows the external account. Clicking it opens the evidence.
29
37
  5. Verily keeps the record honest: published proofs are re-read, records expire, and either side can revoke.
30
38
 
31
- ## Two ways to use it
39
+ ## Ways to use it
40
+
41
+ Verily's backend comes in two forms, and a site can also use one that someone else runs.
32
42
 
33
- - **Run it yourself.** Your site hosts Verily beside its own backend and registers its own app with each sign-in provider. Everything from [Install](#install) to [Operations](#operations) is this. Nothing else is needed, and nothing depends on anyone else's server.
34
- - **Use an instance someone else runs.** The instance holds the provider apps and the records, and your site registers nothing with any provider. Your backend makes two calls. See [Using a hosted instance](#using-a-hosted-instance).
43
+ - **Embed it in your app.** `createVerily` runs inside your backend and links your app's own accounts. Your app says who is signed in and registers with each sign-in provider. See [Embed the backend in your app](#embed-the-backend-in-your-app).
44
+ - **Run an instance.** `createInstance` is a complete app with one owner, who signs in with a key. Other sites can send their users to it. A running one is called an instance. See [Run an instance](#run-an-instance).
45
+ - **Use an instance someone else runs.** It holds the provider apps and the records. Your backend makes two calls. See [Using a hosted instance](#using-a-hosted-instance).
35
46
 
36
- Both give your backend the same records in the same shape, so the code that shows them does not change if you move from one to the other.
47
+ All three give your backend the same records, so the code that shows them stays the same if you switch.
37
48
 
38
49
  ## Install
39
50
 
@@ -41,19 +52,24 @@ Both give your backend the same records in the same shape, so the code that show
41
52
  npm install @bkazemi/verily
42
53
  ```
43
54
 
44
- Requires Node.js 22.13+ or 24+. In Node, `@bkazemi/verily` exports the server. Browser bundlers get a browser-only entry with no server or database code. With TypeScript, use `moduleResolution: "NodeNext"` for the server and `"Bundler"` for the browser.
55
+ Requires Node.js 22.13+ or 24+. In Node, `@bkazemi/verily` exports the server. Browser bundlers get a browser-only entry with no server or database code. `@bkazemi/verily/instance` is the standalone backend, and `@bkazemi/verily/site` is the client for a site that uses someone else's instance. With TypeScript, use `moduleResolution: "NodeNext"` for the server and `"Bundler"` for the browser.
45
56
 
46
- ## Set up the server
57
+ ## Embed the backend in your app
47
58
 
48
59
  ### 1. Storage
49
60
 
50
61
  ```ts
51
62
  import { Pool, PostgresStorage } from '@bkazemi/verily';
52
63
 
53
- const storage = new PostgresStorage(new Pool({ connectionString: process.env.DATABASE_URL }));
54
- await storage.migrate(); // creates its one table if it doesn't exist
64
+ const storage = new PostgresStorage(
65
+ new Pool({ connectionString: process.env.DATABASE_URL }),
66
+ );
67
+ // creates its one table if it doesn't exist
68
+ await storage.migrate();
55
69
  ```
56
70
 
71
+ Postgres ships with the package. Anything implementing the `Storage` interface in [`src/core/index.ts`](src/core/index.ts) works too.
72
+
57
73
  ### 2. Create the handler
58
74
 
59
75
  ```ts
@@ -73,14 +89,18 @@ const verily = createVerily({
73
89
  profileOrigins: ['https://community.example'],
74
90
  reportUrl: 'mailto:reports@community.example',
75
91
  authenticate: async (request) => {
76
- const user = await getSignedInUser(request); // your own session lookup
92
+ // your own session lookup
93
+ const user = await getSignedInUser(request);
77
94
  if (!user) return undefined;
78
95
 
79
96
  return {
80
- id: user.id, // private, stable, never reused
97
+ // private, stable, never reused
98
+ id: user.id,
81
99
  label: user.displayName,
82
- reference: user.username, // public and durable; never an email
83
- profileUrl: `https://community.example/users/${user.username}`, // optional
100
+ // public and durable; never an email
101
+ reference: user.username,
102
+ // optional
103
+ profileUrl: `https://community.example/users/${user.username}`,
84
104
  };
85
105
  },
86
106
  });
@@ -96,11 +116,11 @@ const verily = createVerily({
96
116
  | `reportUrl` | Where readers report a bad record (`https:` or `mailto:`). |
97
117
  | `authenticate` | Returns the signed-in local account for a request, or `undefined` if nobody is signed in. |
98
118
 
99
- Sign-in methods need an app registered with the provider, and each site running Verily registers its own. The callback URL is always `<baseUrl>/callback`, so for `baseUrl: 'https://example.com/api/verily'` it is `https://example.com/api/verily/callback`. The provider redirects only to URLs registered in advance, so each place you run Verily, a local one for development included, needs its callback registered.
119
+ Sign-in methods need an app registered with the provider. The callback URL is always `<baseUrl>/callback`, and every place you run Verily, development included, needs its own registered.
100
120
 
101
121
  - **GitHub:** [register an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) and set its callback URL. An OAuth app has only one, so development and production each need their own app.
102
122
  - **Discord:** create an application in the [Discord developer portal](https://discord.com/developers/applications) and add the callback URL under **OAuth2 → Redirects**. One application can list several. Discord shows the client secret only when you reset it.
103
- - **YouTube:** in the [Google Cloud console](https://console.cloud.google.com/), enable the YouTube Data API v3, create an OAuth client of type **Web application**, and add the callback URL as an authorized redirect URI. One client can list several. The `youtube.readonly` scope is sensitive: until Google verifies your app, users see an unverified-app warning and the project can only ever be used by 100 accounts, so apply for verification before offering it publicly. Reading the channel is free, within the default quota of 10,000 units a day.
123
+ - **YouTube:** in the [Google Cloud console](https://console.cloud.google.com/), enable the YouTube Data API v3, create an OAuth client of type **Web application**, and add the callback URL as an authorized redirect URI. One client can list several. The `youtube.readonly` scope is sensitive: until Google verifies your app, users see a warning and only 100 accounts can ever use it, so apply for verification before offering it publicly.
104
124
 
105
125
  Pass the app's client id and secret to `githubProvider()`, `discordProvider()` or `youtubeProvider()`, and keep them on the server. The other methods need no registration.
106
126
 
@@ -129,10 +149,12 @@ Put the connect pill on your settings page. Clicking it opens a dialog where the
129
149
  <verily-connect backend-url="/api/verily"></verily-connect>
130
150
  ```
131
151
 
132
- GitHub sign-in still happens on GitHub, in a small window. The dialog picks the flow back up when the user returns. When a connection is recorded, the element fires a `verily-result` event whose `detail` holds the new `connectionId`. The backend must be on the same origin as the page.
152
+ A provider's sign-in opens in a small window, and the dialog carries on when the user returns. When a connection is recorded, the element fires `verily-result`, whose `detail` holds the new `connectionId`. The backend must be on the page's origin.
133
153
 
134
154
  The full-page flow at `<baseUrl>/verify` still works, for links and for browsers without JavaScript. `<baseUrl>/mine` returns the user's connections as JSON, for your settings page.
135
155
 
156
+ **Public or unlisted.** At approval the user chooses who can read the connection; unlisted is the default. A public connection has an evidence page anyone can open, which is what a badge with a `connection-id` reads. An unlisted one has none. Its holder can make one sharing link at a time, good for 7 days (`shareTtlMs`), with `client.issueShare(id)`, and end it with `client.revokeShare(id)`. The user changes visibility at `<baseUrl>/visibility/<id>`, and `service.mine(account)` returns a user's own records, unlisted included.
157
+
136
158
  Then schedule the upkeep described under [Operations](#operations).
137
159
 
138
160
  ## Show the badge
@@ -140,37 +162,44 @@ Then schedule the upkeep described under [Operations](#operations).
140
162
  With a bundler:
141
163
 
142
164
  ```js
143
- import { init } from '@bkazemi/verily'; // also registers <verily-badge>
165
+ // also registers <verily-badge>
166
+ import { init } from '@bkazemi/verily';
144
167
 
145
168
  const client = init({ backendUrl: '/api/verily' });
146
169
  ```
147
170
 
148
- 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.6.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.8.0/dist/verily.js`. The script defines a global `Verily`.
149
172
 
150
173
  Then place the badge wherever the account appears:
151
174
 
152
175
  ```html
153
176
  <script src="/assets/verily.js" defer></script>
154
- <verily-badge backend-url="/api/verily" connection-id="CONNECTION_ID"></verily-badge>
177
+ <verily-badge
178
+ backend-url="/api/verily"
179
+ connection-id="CONNECTION_ID"
180
+ ></verily-badge>
155
181
  ```
156
182
 
157
183
  Clicking the badge opens the verification details:
158
184
 
159
185
  <p align="center">
160
- <img src="docs/images/dialog.png" alt="The verification details dialog: the local account, the linked GitHub account, its status, and when it was approved, expires and was last checked" width="448" />
186
+ <img src="docs/images/dialog.png" alt="The verification details dialog: the local account, the linked GitHub account and when it joined, its status, how it was shown, and when it was approved and expires" width="448" />
161
187
  </p>
162
188
 
163
189
  A user with several accounts gets one badge for all of them. Give it every connection id, in any order:
164
190
 
165
191
  ```html
166
- <verily-badge backend-url="/api/verily" connection-ids="ID_ONE ID_TWO ID_THREE"></verily-badge>
192
+ <verily-badge
193
+ backend-url="/api/verily"
194
+ connection-ids="ID_ONE ID_TWO ID_THREE"
195
+ ></verily-badge>
167
196
  ```
168
197
 
169
- It shows the account that was connected first, then how many more there are, such as `+2`. Clicking it opens the same details, with each account on its own card in the order they were connected. Only accounts that are verified now are counted, and one of those leads if the first has lapsed. An account that appears on more than one record, because it was shown a second way or removed and connected again, is shown once. The ids must all belong to the same user.
198
+ It shows the first account connected, then a count of the rest, such as `+2`. Clicking it opens the details with one card per account. Only accounts verified now are counted, and an account on more than one record is shown once. The ids must all belong to the same user.
170
199
 
171
- A holder can change that order. The account they mark as preferred comes first and leads the badge, and the ones they mark as unused come after the rest; an unused account is still verified and still counted. An account they retire is kept as history: it is never counted, sits in a group of its own at the foot of the details, and reads as retired with the date it was last verified. A badge with nothing but retired accounts is a quiet pill that names the first and opens the same details. If you would rather show nothing, leave retired records out of what you give the badge.
200
+ A holder can change the order. An account marked preferred leads the badge, and ones marked unused come last but still count. A retired account is kept as history: never counted, grouped at the foot of the details, and shown with the date it was last verified. A badge with only retired accounts is a quiet pill; to show nothing, leave retired records out.
172
201
 
173
- On a desktop, resting the pointer on that badge, or reaching it with the keyboard, opens a small panel beside it naming the accounts the number stands for: up to eight, then the rest as a count. The panel floats over the page, so nothing moves, and clicking still opens the details. It needs a browser with the Popover API; without one the badge simply has no panel. Add `peek="off"` to the badge to leave it out.
202
+ On a desktop, hovering or focusing the badge opens a small panel naming up to eight of the accounts. It needs the Popover API. Add `peek="off"` to leave it out.
174
203
 
175
204
  Add `stacked` to name the accounts in the badge itself, one to a row:
176
205
 
@@ -182,21 +211,27 @@ Add `stacked` to name the accounts in the badge itself, one to a row:
182
211
  ></verily-badge>
183
212
  ```
184
213
 
185
- It lists up to four verified accounts in the order they were connected, then counts the rest as `+2 more`. It is still one badge that opens the one set of details. It is as tall as its rows, so give it a line of its own. With one verified account it is the ordinary badge. `mountBadges` and `presentConnections` take the same choices as `{ stacked: true }` and `{ peek: false }`.
214
+ <img src="docs/images/stacked.png" alt="A stacked badge listing four accounts, one to a row, then +2 more" width="213" />
215
+
216
+ It lists up to four verified accounts, then counts the rest, such as `+2 more`. It is as tall as its rows, so give it a line of its own. `mountBadges` and `presentConnections` take the same choices as `{ stacked: true }` and `{ peek: false }`.
186
217
 
187
- In the details of several accounts, each account is a short card: its name, its status and who verified it. How it was shown and its dates sit behind a Details toggle on the card, and retired accounts are folded into a group at the foot. Add `details-open` to the badge, or pass `{ detailsOpen: true }`, to start every card open where your readers come for the proof. A reader can still open or shut each one. A single account always shows everything.
218
+ With several accounts, each card shows the name, status and verifier, with the method and dates behind a Details toggle. Add `details-open`, or pass `{ detailsOpen: true }`, to start every card open.
188
219
 
189
- The badge refreshes every 30 seconds, and only ever reads public evidence. The client can also start and end connections, and draw a badge into an element of your own:
220
+ The badge refreshes every 30 seconds and reads only public evidence. The client can also start and end connections, and draw a badge into your own element:
190
221
 
191
222
  ```js
192
- await client.openConnect(); // the connect dialog; call from a click
193
- await client.connect({ provider: 'github' }); // opens <baseUrl>/verify in a popup; call from a click
194
- await client.mountBadge(element, { connectionId }); // draws once; call again to refresh
223
+ // the connect dialog; call from a click
224
+ await client.openConnect();
225
+ // opens <baseUrl>/verify in a popup; call from a click
226
+ await client.connect({ provider: 'github' });
227
+ // draws once; call again to refresh
228
+ await client.mountBadge(element, { connectionId });
195
229
  await client.disconnect(connectionId);
196
- await client.mark(connectionId, 'preferred'); // or 'unused', 'current', 'retired'
230
+ // or 'unused', 'current', 'retired'
231
+ await client.mark(connectionId, 'preferred');
197
232
  ```
198
233
 
199
- `mark` names one record and acts on the account, on every record of it the user has. Preferring one account takes the mark from whichever had it. Retiring freezes the records at their last proof: they are never rechecked, never read as verified, cannot change visibility, and are kept until removed. Only a renewal by the method a record was first shown by brings it back.
234
+ `mark` acts on the account, across every record of it the user has. Preferring one account takes the mark from another. Retiring freezes its records: they are never rechecked or read as verified, cannot change visibility, and are kept until removed. Only a renewal by a record's first method brings it back.
200
235
 
201
236
  To match your site's look, set any of these CSS variables on an ancestor: `--verily-surface`, `--verily-text`, `--verily-border`, `--verily-muted`, `--verily-hover`, `--verily-font-family`, `--verily-font-size`.
202
237
 
@@ -213,6 +248,7 @@ import {
213
248
  dnsProvider,
214
249
  wellKnownProvider,
215
250
  pgpProvider,
251
+ emailProvider,
216
252
  } from '@bkazemi/verily';
217
253
  ```
218
254
 
@@ -229,7 +265,9 @@ import {
229
265
  | `pgpProvider()` | signing a line Verily gives them with their OpenPGP key, then pasting it and the key. | None. |
230
266
  | `emailProvider()` | pressing a button, or entering a code, that Verily mails to their address. | A function that sends one message. |
231
267
 
232
- All methods except the sign-ins and the mailed code let the user publish the proof in their own time and come back. A proof that does not check out can be put right and handed back on the same step, up to five times, so a mistyped address never means publishing or signing again. Gists, link-backs, DNS records and files can be taken down later, so Verily re-reads them on a schedule. A PGP signature is kept by Verily and published at `<baseUrl>/connections/<id>/proof`.
268
+ Apart from the sign-ins and the mailed code, the user publishes the proof in their own time and comes back. A proof that fails can be fixed and resubmitted up to five times. Gists, link-backs, DNS records and files can be taken down later, so Verily re-reads them on a schedule. A PGP signature is kept by Verily and published at `<baseUrl>/connections/<id>/proof`.
269
+
270
+ **How old an account is.** Signing in with GitHub, Discord or YouTube also reads when the account was made. It is kept on the record as `external.createdAt`, in milliseconds, shown as "Joined" beside the account's name, and covered by a signed record. The other methods learn no date, so a record first shown by one of them has none until its holder also signs in. A provider of your own sets it by returning `createdAt` from `authenticate()`.
233
271
 
234
272
  **Several at once.** List more than one, and `/verify` offers each method as its own button:
235
273
 
@@ -239,21 +277,21 @@ providers: [githubProvider({ clientId, clientSecret }), githubLinkProvider()],
239
277
 
240
278
  Proving the same account a second way adds that proof to the existing record instead of creating another.
241
279
 
242
- **Approval only where there is something to decide.** A new connection always ends with the user choosing who can read it and confirming. Renewing a connection, or proving an account already linked a second way, does not: the account has been checked against the record and the record keeps its visibility, so it is recorded as soon as the proof checks out. This applies to flows started from your own pages, which is every flow the dialog and Verily's forms start. A flow entered by following a link to `GET <baseUrl>/sessions` still ends on an approval, because any site can send a browser down a link.
280
+ **Approval only where there is something to decide.** A new connection ends with the user choosing who can read it and confirming. A renewal, or a second proof of an account already linked, is recorded as soon as the proof checks out. That holds for flows started from your own pages. A flow entered through a link to `GET <baseUrl>/sessions` still ends on an approval, because any site can send a browser down a link.
243
281
 
244
- **A link-back found at sign-in.** With `githubProvider()` and `githubLinkProvider()` both listed, signing in with GitHub also reads the profile that signed in. If its website field already links to the user's `profileUrl`, the approval step says so, and confirming records the link-back beside the sign-in. The user never picks the second method or types a username. A link with `http://` in place of `https://` counts; a different host, port or path does not.
282
+ If you call `service.start()` yourself, pass `true` as its last argument only when the request provably came from the user's own page, as a same-origin POST does. Without it the flow always ends on an approval.
245
283
 
246
- **Link-backs.** Many people already have one, since GitHub and Mastodon mark profile links `rel="me"`. By default the page may be on any public host, and the account is named by the page's address. This mode needs Node, because Verily checks every connection it makes to stop the page's address from pointing inside your network. Pass `hosts` to read only certain hosts (required on Cloudflare Workers), and `profile` to name the account by handle, as `githubLinkProvider()` does for github.com. If you pass your own `fetch`, it replaces that network check, so it must enforce the same rule itself. Only real `<a>` and `<link>` elements in HTML pages, or a `Link:` header, count; [`src/server/link.ts`](src/server/link.ts) has the exact rules.
284
+ **A link-back found at sign-in.** With `githubProvider()` and `githubLinkProvider()` both listed, signing in with GitHub also reads the profile that signed in. If its website field already links to the user's `profileUrl`, the approval step says so, and confirming records the link-back beside the sign-in. A link with `http://` in place of `https://` counts; a different host, port or path does not.
247
285
 
248
- **Domains.** `dnsProvider()` proves a domain that serves no page of its own, or only forwards elsewhere. The user adds a TXT record named `_verily` whose value is their `profileUrl`, and enters the domain. Verily looks up `_verily.<domain>` through `https://dns.google/resolve`, a fixed resolver, so it runs on Cloudflare Workers, and the evidence links to that lookup for a reader to repeat. `wellKnownProvider()` reads `https://<domain>/.well-known/verily.txt` instead and looks for the `profileUrl` on a line of its own. It fetches a host the user names, so it follows the link-back's rules: Node, or `hosts`. Both name the account by the exact domain entered, and say nothing about its subdomains. Where the resolver validated the DNS answer with DNSSEC, the proof records it and the evidence says so; a domain that does not sign its zone is proved all the same, on the resolver's own lookup. `recheck()` updates the flag at each read. A local account with no `profileUrl` has no address to publish, so the user is given a random value, `verily-proof=…`, instead. It names neither the account nor your site, since DNS is public, and a renewal asks for the same value, so the record is added once. List both and a domain proved one way picks up the other if it is already there.
286
+ **Link-backs.** GitHub and Mastodon already mark profile links `rel="me"`. By default the page may be on any public host, and the account is named by the page's address. That needs Node, because Verily checks each connection so the address cannot point inside your network. Pass `hosts` to read only certain hosts (required on Cloudflare Workers), and `profile` to name the account by handle. Your own `fetch` replaces that network check, so it must enforce the same rule. Only `<a>` and `<link>` elements in HTML, or a `Link:` header, count; [`src/server/link.ts`](src/server/link.ts) has the exact rules.
249
287
 
250
- **Your own method.** A provider is an object, and [`src/core/index.ts`](src/core/index.ts) documents each shape. An artifact provider may also set `field` and `input` to name what it asks for, `resolve` to turn something short such as a username into the address to read, and `known` to say where to look for an account already on record, which is what lets a proof be found without asking. A sign-in provider's `authorizationUrl` is passed `account` on a renewal or visibility change, so it can ask for that account by name.
288
+ **Domains.** `dnsProvider()` proves a domain, including one that serves no page. The user adds a TXT record named `_verily` whose value is their `profileUrl`, and enters the domain. Verily looks it up through `https://dns.google/resolve`, so it runs on Cloudflare Workers, and the evidence links to that lookup. `wellKnownProvider()` reads `https://<domain>/.well-known/verily.txt` instead. It fetches a host the user names, so it follows the link-back's rules: Node, or `hosts`. Both name the exact domain entered and say nothing about its subdomains. Where the resolver validated the answer with DNSSEC, the evidence says so. A local account with no `profileUrl` is given a random value, `verily-proof=…`, to publish instead, and a renewal asks for the same one. List both providers and a domain proved one way picks up the other if it is already there.
251
289
 
252
- If you start flows by calling `service.start()` yourself, pass `true` as its last argument only where you have shown the request came from the user's own page, as a same-origin POST does. Without it the flow reads nothing at its start and always ends on an approval.
290
+ **Your own method.** A provider is an object, and [`src/core/index.ts`](src/core/index.ts) documents each shape. An artifact provider may also set `field` and `input` to name what it asks for, `resolve` to turn something short such as a username into the address to read, and `known` to say where to look for an account already on record. A sign-in provider's `authorizationUrl` is passed `account` on a renewal or visibility change, so it can ask for that account by name.
253
291
 
254
292
  **OpenPGP.** The key's fingerprint is the identity. An email address is shown only when the key signed it **and** either keys.openpgp.org has confirmed it or the address's domain publishes the key in its [web key directory](https://datatracker.ietf.org/doc/draft-koch-openpgp-webkey-service/). The key must be valid when the proof is checked (not expired or revoked), SHA-1 signatures are refused, and a signing subkey must be properly bound to its key.
255
293
 
256
- **Email.** Verily sends no mail itself. Give `emailProvider()` a `send` function and it hands that one message per flow, written as both HTML and plain text, through whatever already sends your mail:
294
+ **Email.** Verily sends no mail itself. Give `emailProvider()` a `send` function and it hands it one message per flow, as HTML and plain text:
257
295
 
258
296
  ```ts
259
297
  emailProvider({
@@ -264,7 +302,8 @@ emailProvider({
264
302
  subject,
265
303
  text,
266
304
  html,
267
- // The logo travels in the message. Attach each image inline under its content id.
305
+ // The logo travels in the message. Attach each image inline under
306
+ // its content id.
268
307
  attachments: images.map((image) => ({
269
308
  filename: image.filename,
270
309
  content: Buffer.from(image.content, 'base64'),
@@ -275,44 +314,76 @@ emailProvider({
275
314
  }),
276
315
  ```
277
316
 
278
- The HTML loads nothing from anywhere, so nothing in it is blocked and it tells nobody it was opened. Its one image, the Verily logo, is attached to the message; a sender that drops `images` still sends a whole message, with the word in the logo's place.
317
+ The HTML loads nothing remote. Its one image, the Verily logo, is attached; a sender that drops `images` still sends a complete message.
279
318
 
280
- The user types an address and Verily mails it a button to press, with an eight-character code beneath it as the other way in. The button opens a page on your backend, in whatever browser the mail is read in. In the browser that started the flow, opening it proves the mailbox, with nothing more to press. In any other browser the page asks for one press of **Confirm**, because there opening the link alone must confirm nothing: a mail scanner that follows links cannot then answer for anyone. Either way the page the user started on carries on by itself. Both the link and the code last the flow's ten minutes, and the code gets five tries. The address is the account's name on the record, so a public link shows it to everyone; the user is told before the code is sent. It proves somebody could read that mailbox on the day, like a sign-in, and nothing is published or re-read later.
319
+ The user types an address and Verily mails it a button, with an eight-character code beneath as the other way in. In the browser that started the flow, opening the link proves the mailbox. In any other browser the page asks for one press of **Confirm**, so a mail scanner that follows links confirms nothing. The link and the code last the flow's ten minutes, and the code gets five tries. The address is the account's name on the record, so a public link shows it to everyone; the user is told before the code is sent. Like a sign-in, it proves the mailbox could be read that day, and nothing is re-read later.
281
320
 
282
- A visitor chooses where the message goes, so Verily counts what it sends: at most 100 messages in any twenty-four hours, and at most 5 to any one address. Past either, the flow fails and tells the user to try again tomorrow. Set `sendLimits: { day, address }` beside `providers` to change them, or `Infinity` to lift one. The day's count is shared, so one visitor can still spend it; rate limit `POST <baseUrl>/sessions` and `POST <baseUrl>/flows/*/submit` as well.
321
+ A visitor chooses where the message goes, so Verily sends at most 100 messages in twenty-four hours and 5 to any one address. Set `sendLimits: { day, address }` to change them, or `Infinity` to lift one. The day's count is shared, so also rate limit `POST <baseUrl>/sessions` and `POST <baseUrl>/flows/*/submit`.
283
322
 
284
323
  `resendSender({ apiKey, from })` is a ready-made `send` for [Resend](https://resend.com), using only `fetch`:
285
324
 
286
325
  ```ts
287
326
  emailProvider({
288
- send: resendSender({ apiKey: process.env.RESEND_API_KEY!, from: 'Verily <verify@community.example>' }),
327
+ send: resendSender({
328
+ apiKey: process.env.RESEND_API_KEY!,
329
+ from: 'Verily <verify@community.example>',
330
+ }),
289
331
  }),
290
332
  ```
291
333
 
292
334
  **Matching accounts.** Two methods agree on an account when the provider's account ids match. A link-back only knows an address, so it's matched by profile URL instead, and only in flows the user starts themselves. Removing a connection from the external side always needs a matching provider account id.
293
335
 
336
+ ## Run an instance
337
+
338
+ An instance runs on Cloudflare or on Node from the same settings. `PUBLIC_ORIGIN`, `SITE_NAME`, `OWNER_LABEL`, `OWNER_REFERENCE`, `OWNER_PROFILE_URL`, `REPORT_URL` and `OWNER_KEY` are required. `OWNER_KEY` is the secret you sign in with: at least 32 random bytes, base64url.
339
+
340
+ ```sh
341
+ node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
342
+ ```
343
+
344
+ A link back from a GitHub profile, a DNS record and an OpenPGP signature are always offered. GitHub, Discord and YouTube sign-in and email are each offered once their secrets are set. Each sign-in app needs `<PUBLIC_ORIGIN>/callback` as a redirect. [`src/instance/env.ts`](src/instance/env.ts) lists every setting.
345
+
346
+ **On Cloudflare.**
347
+
348
+ ```sh
349
+ npx @bkazemi/verily init cloudflare my-instance
350
+ cd my-instance && npm install
351
+ ```
352
+
353
+ Fill in `wrangler.jsonc`, run `npx wrangler secret put OWNER_KEY`, then `npm run deploy`. To take a new release, update the dependency and deploy again.
354
+
355
+ **On Node.** Install the package, set the settings and `DATABASE_URL` in the environment, and run `npx verily serve`. It keeps everything in one Postgres table, which it creates, and listens on `127.0.0.1:3000` for a proxy that terminates TLS. `HOST` and `PORT` change where. Set `CLIENT_ADDRESS_HEADER` to the header your proxy sets to the client's address, such as `X-Real-IP`. Without it every visitor is counted as the proxy and shares one rate limit.
356
+
357
+ **In your own server.** `createInstance` from `@bkazemi/verily/instance` takes the same options and a store, and returns a request handler and an `upkeep()` to run hourly.
358
+
359
+ Cloudflare and `verily serve` both run the hourly upkeep themselves.
360
+
361
+ **Serving other sites.** List each site in `SITES`, as JSON, with `id`, `name`, `origin`, `authorizeUrl` and `returnUrl`, and put its key in `SITE_<ID>_KEY`. Give the site its id and key, and it uses the client under [Using a hosted instance](#using-a-hosted-instance). A site may also carry `providers` and `visibility`, to narrow what its users are offered.
362
+
294
363
  ## A badge on a static site
295
364
 
296
- A static site only needs the script and the badge markup; the backend runs somewhere else over HTTPS. The quickest backend is the Cloudflare Worker in this repository, which fits the free plan:
365
+ A static site only needs the script and the badge markup; the backend runs somewhere else over HTTPS. The quickest backend is an instance of your own on Cloudflare:
297
366
 
298
- 1. Clone this repository, run `npm ci`, and copy `wrangler.example.jsonc` to `wrangler.jsonc`. Fill in your account, origin and owner details.
299
- 2. Run `wrangler secret put` for `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` and `OWNER_KEY`, then `npm run cloudflare:deploy`.
300
- 3. On the backend's site, sign in with `OWNER_KEY`, verify, and approve a **public** connection. Its id is listed at `/mine`.
301
- 4. Add the script and the badge to your site:
367
+ 1. Set one up and deploy it, as [Run an instance](#run-an-instance) describes.
368
+ 2. On the instance, sign in with `OWNER_KEY`, verify, and approve a **public** connection. Its id is on your settings page there, in the markup to embed.
369
+ 3. Add the script and the badge to your site:
302
370
 
303
371
  ```html
304
- <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.6.0/dist/verily.js" defer></script>
372
+ <script
373
+ src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.8.0/dist/verily.js"
374
+ defer
375
+ ></script>
305
376
  <verily-badge
306
- backend-url="https://your-backend.example"
377
+ backend-url="https://verily.your-site.example"
307
378
  connection-id="CONNECTION_ID"
308
379
  ></verily-badge>
309
380
  ```
310
381
 
311
- Run the backend on a domain readers can connect to your site, because `PUBLIC_ORIGIN` is shown as the verifier. If your site sets a Content Security Policy, allow the backend in `connect-src`.
382
+ Run the instance on a domain readers can connect to your site, because `PUBLIC_ORIGIN` is shown as the verifier. If your site sets a Content Security Policy, allow the instance in `connect-src`.
312
383
 
313
384
  ## Using a hosted instance
314
385
 
315
- A site can use an instance someone else runs. The instance's operator registers your site and gives you its id and a key. You register nothing with any sign-in provider, store no Verily records and run no Verily backend. Your backend sends a signed-in user to the instance and reads their connections back.
386
+ The instance's operator registers your site and gives you its id and a key. You register with no sign-in provider, store no records and run no backend. Your backend sends a signed-in user to the instance and reads their connections back.
316
387
 
317
388
  You are trusting that operator. They can read every record made through their instance, unlisted ones included, and for sign-in and email methods a reader has only the operator's word that the account was verified. Link-back, gist and PGP proofs can be rechecked by anyone. A site that does not want to rely on an operator runs its own instance.
318
389
 
@@ -337,13 +408,17 @@ const handle = verily.handler({
337
408
  if (!user) return undefined;
338
409
 
339
410
  return {
340
- id: user.id, // private and stable; the instance never shows it
411
+ // private and stable; the instance never shows it
412
+ id: user.id,
341
413
  label: user.displayName,
342
- reference: user.handle, // durable and safe to show; never an email
414
+ // durable and safe to show; never an email
415
+ reference: user.handle,
343
416
  };
344
417
  },
345
- signInUrl: '/login', // where a signed-out user is sent
346
- returnUrl: '/settings', // where a user lands when they come back
418
+ // where a signed-out user is sent
419
+ signInUrl: '/login',
420
+ // where a user lands when they come back
421
+ returnUrl: '/settings',
347
422
  });
348
423
 
349
424
  // GET and POST /api/verily/*
@@ -374,7 +449,7 @@ Once a user has a link, show their badge in place of the pill, and give it the s
374
449
  ></verily-badge>
375
450
  ```
376
451
 
377
- The badge's dialog then has Renew and Remove under each account, the controls that mark it as preferred or unused or retire it, and Add account below them, which opens the verify flow in the dialog's place with a way back. Renew shows the same account again and extends that record under its id, so an embed that names it keeps working. A retired account is renewed by the method it was first shown by. It fires `verily-result` on each change. `verily.beginUrl('manage')` still opens a user's links on the instance, for a site that would rather link there.
452
+ The badge's dialog then has Renew and Remove under each account, the controls that mark or retire it, and Add account. Renew extends the record under the same id, so embeds keep working. It fires `verily-result` on each change. `verily.beginUrl('manage')` opens a user's links on the instance, for a site that would rather link there.
378
453
 
379
454
  **3. Read connections where you show them.**
380
455
 
@@ -383,7 +458,7 @@ const { [user.id]: connections } = await verily.connections([user.id]);
383
458
  const verified = connections.filter((c) => c.status === 'verified');
384
459
  ```
385
460
 
386
- Show only `verified` records as verified. A `retired` record is an account the user said is finished: it carries `retiredAt` and can be shown as history, never as a current account. `mark` is `preferred` or `unused` where the user set one, which is their word on how to order their accounts and nothing that was checked.
461
+ Show only `verified` records as verified. A `retired` record carries `retiredAt` and can be shown as history, never as a current account. `mark` is `preferred` or `unused` where the user set one; it is their ordering, not something checked.
387
462
 
388
463
  To show them, hand the records to the badge. It draws them as given and fetches nothing, so it works for unlisted links, which a browser cannot read for itself:
389
464
 
@@ -393,23 +468,28 @@ To show them, hand the records to the badge. It draws them as given and fetches
393
468
 
394
469
  From script, set the property instead: `badge.connections = records`. Send a page only the records its reader may see: whatever is in the page, the reader has. Several records become one badge, as under [Show the badge](#show-the-badge), and an unlisted one opens its details without linking anywhere.
395
470
 
396
- Each record has the provider, the external account's handle and profile address, the visibility and the expiry. Unlisted records are included, and only your site can read them, so you can show a link to your own users without making it public. `connections()` takes any number of ids, so read a whole page of users in one call. Pass `cacheMs` to the client to reuse an answer for that long, and `{ fresh: true }` to a call that must not.
471
+ Unlisted records are included, and only your site can read them. `connections()` takes any number of ids, so read a whole page of users in one call. Every call reads the instance, so a removed or expired link stops showing on the next page load. `cacheMs` reuses an answer for that long, during which a revoked connection still shows as verified, so leave it unset or keep it to a few seconds. `{ fresh: true }` makes one call read anyway.
397
472
 
398
473
  If you would rather your user ids never left your site, give the client a secret:
399
474
 
400
475
  ```ts
401
- const verily = createSiteClient({ instance, site, key, idSecret: process.env.VERILY_ID_SECRET });
476
+ const verily = createSiteClient({
477
+ instance,
478
+ site,
479
+ key,
480
+ idSecret: process.env.VERILY_ID_SECRET,
481
+ });
402
482
  ```
403
483
 
404
- Every id you pass is then your own, and the instance is sent a stand-in. A subject with no `reference` gets one made the same way. The same secret and user always give the same stand-in, so nothing is stored. That secret must never change, so keep one for this alone.
484
+ The instance is then sent a stand-in for each id, and for a subject with no `reference`. The same secret and user always give the same stand-in, so nothing is stored. The secret must never change, so keep one for this alone.
405
485
 
406
486
  A site that would rather write its own endpoints can: `verily.authorize(state, subject)` signs the redirect handoff, `verily.handoff(subject)` the pill's, and `verily.result(token)` checks what a user returns with.
407
487
 
408
- **Moving to your own instance.** Hosting Verily yourself replaces `verily.connections([id])` with `service.mine(account)`, which returns the same records, and the three steps above with the handler from [Set up the server](#set-up-the-server). Records are not moved between instances, so users verify again.
488
+ **Moving off someone else's instance.** To [run an instance](#run-an-instance) of your own, register your site with it and change the client's `instance`, `site` and `key`; the code above stays. To embed the backend, replace `verily.connections([id])` with `service.mine(account)`, which returns the same records, and the three steps above with the handler from [Embed the backend in your app](#embed-the-backend-in-your-app). Either way records are not moved, so users verify again.
409
489
 
410
490
  ## Serving other sites
411
491
 
412
- One backend can serve sites that don't run Verily themselves, so none of them registers anything with a sign-in provider. A subject from such a site names it in `LocalAccount.siteName`, and every page, proof line and record describing that subject uses the name in place of the `siteName` option. The backend stays the verifier.
492
+ One backend can serve sites that don't run Verily themselves, so none of them registers anything with a sign-in provider. An instance does this already, from its `SITES` setting. This section is for an embedded backend that does it itself. A subject from such a site names it in `LocalAccount.siteName`, and every page, proof line and record describing that subject uses the name in place of the `siteName` option. The backend stays the verifier.
413
493
 
414
494
  Three options tie a flow to the site that sent the holder:
415
495
 
@@ -421,17 +501,17 @@ Three options tie a flow to the site that sent the holder:
421
501
 
422
502
  Two more narrow what a subject is offered, for a backend whose sites don't all want the same. `providersFor(local)` returns the provider ids a subject may use: it is offered those alone, in that order, and a flow for any other is refused. `visibilityFor(local)` returns the visibilities it may choose: with one, the approval page states it and offers no choice.
423
503
 
424
- `result` is recorded when the flow ends and never changes: its kind, how it ended, the connection and its visibility at that moment, and `finishedAt`. The Cloudflare Worker uses these hooks to serve registered sites ([`cloudflare/sites.ts`](cloudflare/sites.ts)). The sites it serves use the client under [Using a hosted instance](#using-a-hosted-instance).
504
+ `result` is recorded when the flow ends and never changes: its kind, how it ended, the connection and its visibility at that moment, and `finishedAt`. An instance is built on these hooks, and [`src/instance/sites.ts`](src/instance/sites.ts) is a worked example of them.
425
505
 
426
506
  ## Signed records
427
507
 
428
- Evidence is normally believed because the verifier's own origin serves it. Set a signing key and each public record can also be downloaded as a file signed with OpenPGP, which anyone can check later with `gpg`, without the instance being up.
508
+ Evidence is normally believed because the verifier's own origin serves it. Set a signing key and each public record can also be downloaded as a file signed with OpenPGP, which anyone can check later with `gpg`, without the verifier being up.
429
509
 
430
510
  ```ts
431
511
  import { createVerily, generateSigningKey } from '@bkazemi/verily';
432
512
 
433
- // Make the key once and keep it with your other secrets. It is an armored OpenPGP
434
- // private key, so it spans several lines.
513
+ // Make the key once and keep it with your other secrets. It is an armored
514
+ // OpenPGP private key, so it spans several lines.
435
515
  console.log(await generateSigningKey('verifier.example'));
436
516
 
437
517
  createVerily({
@@ -443,9 +523,9 @@ createVerily({
443
523
  With a key set:
444
524
 
445
525
  - **`GET <baseUrl>/connections/<id>?format=signed`** returns the signed file for a public record that is currently verified: the record as JSON under an OpenPGP cleartext signature. It is signed when asked for, so it says the record stood at that moment. An unlisted, revoked or expired record has no signed form. A public retired record has one, signed as `version: 2` with `status: "retired"` and `retiredAt`, so a reader that knows only version 1 refuses it and never takes it for a record that stood.
446
- - **`GET <baseUrl>/keys.asc`** is the public key as `gpg --import` reads it. **`GET <baseUrl>/keys`** lists the same keys as JSON, each with its fingerprint. The Cloudflare Worker also serves that list at `/.well-known/verily-keys.json`.
526
+ - **`GET <baseUrl>/keys.asc`** is the public key as `gpg --import` reads it. **`GET <baseUrl>/keys`** lists the same keys as JSON, each with its fingerprint. An instance also serves that list at `/.well-known/verily-keys.json`.
447
527
  - **`<baseUrl>/check`** is a page where a saved file can be pasted and read back.
448
- - The evidence page says the record is signed and links to the signed record. The badge's dialog checks the signature in the browser with OpenPGP.js, which it starts only when a record is signed, marks the record `signed ✓`, and links to that page. A record whose signature fails is shown as unconfirmed.
528
+ - The evidence page links to the signed record. The badge's dialog checks the signature in the browser and shows a `signed` pill after the verifier. A record whose signature fails is shown as unconfirmed, with `bad signature` in the pill's place.
449
529
  - The approval step tells the holder that a saved signed record outlives removal.
450
530
 
451
531
  To check a file yourself:
@@ -460,15 +540,18 @@ Or in code, which runs in a browser too:
460
540
  ```ts
461
541
  import { verifySigned } from '@bkazemi/verily';
462
542
 
463
- const { keys } = await (await fetch('https://verifier.example/api/verily/keys')).json();
464
- const record = await verifySigned(file, keys); // undefined if no key signed it
543
+ const { keys } = await (
544
+ await fetch('https://verifier.example/api/verily/keys')
545
+ ).json();
546
+ // undefined if no key signed it
547
+ const record = await verifySigned(file, keys);
465
548
  ```
466
549
 
467
550
  A signed record proves what the verifier claimed and when (`issuedAt`). It does not prove the claim was true, and it does not say whether the record still stands: ask `evidenceUrl` for that. Save the keys with the file if it has to outlive the verifier's domain. When you change keys, list the old key from `/keys` in `retiredKeys` so what it signed still checks.
468
551
 
469
552
  `generateSigningKey()` makes an Ed25519 key in the OpenPGP form GnuPG reads, with no subkeys. Any OpenPGP key that signs with its primary key works as `signingKey`; one that is revoked, expired or passphrase-protected is refused. Checking is done by OpenPGP.js under the format's own rules: a key is judged as it stood when the record was signed, so an expired key, or one revoked as retired or superseded, still answers for records it signed before then, and a key revoked as compromised signs nothing at all.
470
553
 
471
- The Cloudflare Worker signs by default, with a key it makes and stores itself. Set the `SIGNING_KEY` secret to supply your own, or `SIGNING` to `off` to sign nothing.
554
+ An instance signs by default, with a key it makes and stores itself. Set `SIGNING_KEY` to supply your own, or `SIGNING` to `off` to sign nothing.
472
555
 
473
556
  ## What DNS says of the verifier
474
557
 
@@ -484,41 +567,48 @@ Two TXT records let a reader check the verifier against DNS instead of taking it
484
567
  ```ts
485
568
  import { dnsKeys, signedBy, verifySigned } from '@bkazemi/verily';
486
569
 
487
- const record = await verifySigned(file, keys); // keys saved with the file, or from /keys
570
+ // keys saved with the file, or from /keys
571
+ const record = await verifySigned(file, keys);
488
572
  const named = await dnsKeys(new URL(record.evidenceUrl).hostname);
489
573
 
490
- named.ids.includes(await signedBy(file)); // the verifier's domain names the key that signed it
491
- named.dnssec; // whether the resolver validated that answer
574
+ // the verifier's domain names the key that signed it
575
+ named.ids.includes(await signedBy(file));
576
+ // whether the resolver validated that answer
577
+ named.dnssec;
492
578
  ```
493
579
 
494
580
  **The site's verifier.** A record's local side is the verifier's statement of what the site told it. On the site's own pages that is enough, since the site put the badge there. On a shared evidence link or a saved signed record, nothing shows the site chose this verifier. A site whose verifier is on another domain publishes `verily-verifier=<host>` on the domain of its `profileUrl`, with the port if `baseUrl` has one. `dnsVerifiers(domain)` reads it back. A verifier on the site's own domain, or a subdomain of it, needs no record and none is looked for.
495
581
 
496
- Set `dns: true` and the instance reads both through `https://dns.google/resolve`, the resolver the domain method asks:
582
+ Set `dns: true` and the backend reads both through `https://dns.google/resolve`, the resolver the domain method asks:
497
583
 
498
584
  ```ts
499
585
  createVerily({
500
586
  // ...
501
- dns: true, // or { fetch, timeoutMs }
587
+ // or { fetch, timeoutMs }
588
+ dns: true,
502
589
  });
503
590
  ```
504
591
 
505
592
  - The evidence page and `<baseUrl>/check` say the signing key is named in the verifier's DNS, with a link to the lookup, once it is. The answer is kept for ten minutes.
506
593
  - When a link is approved, the site's domain is looked up. If it names this verifier, the record's local side carries the lookup as `artifactUrl` and the record as `expect`, the evidence reads "Stated by … whose DNS names this verifier" and links to the lookup, and the signed record includes it. `recheck()` reads each site again every `recheckMs`, one lookup for all of a site's records. A record loses the proof when the site stops naming the verifier, or when the lookup has gone unread for `freshnessMs`; its status is unaffected either way.
507
594
 
508
- The Cloudflare Worker sets `dns: true`.
595
+ An instance sets `dns: true`.
509
596
 
510
597
  ## Operations
511
598
 
512
599
  Verification lasts 30 days, flows 10 minutes, and sharing links 7 days. Change them with `validityMs`, `flowTtlMs` and `shareTtlMs`. Evidence is never cached.
513
600
 
514
- Run the upkeep on a schedule, for example hourly:
601
+ A user's DNS setup is saved for seven days. If propagation outlasts a flow's ten minutes, they choose DNS again and get the same TXT value and the last domain entered. Each return reads the proof afresh. The TXT record has to stay after verification, for the ongoing checks.
602
+
603
+ Run the upkeep on a schedule, for example hourly. An instance does this itself.
515
604
 
516
605
  ```ts
517
606
  setInterval(
518
607
  async () => {
519
608
  try {
520
609
  await verily.service.prune();
521
- await verily.service.recheck(); // only needed if you use a method other than signing in
610
+ // only needed if you use a method other than signing in
611
+ await verily.service.recheck();
522
612
  } catch (error) {
523
613
  console.error('Verily upkeep failed', error);
524
614
  }
@@ -527,10 +617,23 @@ setInterval(
527
617
  );
528
618
  ```
529
619
 
530
- - **`prune()`** deletes expired flows, and deletes expired or revoked records after 90 days. A retired record is kept until it is removed.
531
- - **`recheck()`** re-reads gists, link-backs, DNS records and files that are due, up to 5 per run (its `budget` argument). Each proof is re-read every 24 hours (`recheckMs`) and stays current for 7 days after its last successful read (`freshnessMs`); after that it shows as unconfirmed until a read succeeds. A failed read changes nothing. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
620
+ - **`prune()`** deletes expired flows and DNS drafts, and deletes expired or revoked records after 90 days. A retired record is kept until it is removed.
621
+ - **`recheck()`** re-reads gists, link-backs, DNS records and files that are due, up to 5 per provider per run (its `budget` argument), each every 24 hours (`recheckMs`). A read that finds the proof gone makes the connection unconfirmed at once. A read that fails, such as a timeout, changes nothing, and the proof stays current for 7 days after its last successful read (`freshnessMs`). Either way it is read again after a quarter of `recheckMs`, and the connection is verified again as soon as a read finds the proof. A provider of your own says a proof is gone by throwing `Absent` from `verify`. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
622
+
623
+ - **`onChange`** is an option, not a call. Set it to hear when a record's status, visibility, expiry or mark changes, so your site can update its own copy without polling:
624
+
625
+ ```ts
626
+ createVerily({
627
+ // ...
628
+ onChange: async ({ evidence, local, revision }) => {
629
+ await db.saveConnectionIfNewer(local.id, evidence, revision);
630
+ },
631
+ });
632
+ ```
633
+
634
+ `evidence` is the record now, `local` is your subject with its private id, `previous` is what was last delivered, and `revision` counts that record's deliveries. Ignore a change whose `revision` is not higher than the last you applied, since a slow call can finish after a newer one. A change made by a request is delivered before the request returns, so keep the call quick. Changes that come with time alone, such as expiry, are delivered by `recheck()`, or by `service.announce()` if you do not recheck. Delivery is at least once: a call that throws is retried on the next run, and turning the option on reports every existing record once, 50 per run. A record removed by `prune()` is not reported.
532
635
 
533
- **If you never call `recheck()`, set `freshnessMs: Infinity`.** Otherwise connections proved by gist, link-back or PGP lapse after a week.
636
+ **If you never call `recheck()`, set `freshnessMs: Infinity`.** Otherwise connections proved by gist, link-back, DNS record, file or PGP lapse after a week.
534
637
 
535
638
  ## Development
536
639
 
@@ -541,7 +644,8 @@ The full example also needs Postgres and a GitHub OAuth app, a Discord applicati
541
644
  ```sh
542
645
  npm run build
543
646
  docker compose up -d --wait
544
- cp example/.env.example .env # add credentials for at least one sign-in, and a long EXAMPLE_PASSWORD
647
+ # add credentials for at least one sign-in, and a long EXAMPLE_PASSWORD
648
+ cp example/.env.example .env
545
649
  node --env-file=.env --import tsx example/server.ts
546
650
  ```
547
651
 
@@ -550,14 +654,16 @@ Checks and tests:
550
654
  ```sh
551
655
  npm run build
552
656
  npm run check
553
- npm run format:check # npm run format fixes formatting
657
+ # npm run format fixes formatting
658
+ npm run format:check
554
659
  npm test
555
660
  TEST_DATABASE_URL=postgres://verily:verily@localhost:5432/verily npm test
556
661
  npm run test:consumer
662
+ npm run starter:check
557
663
  ```
558
664
 
559
- The Postgres and example tests only run when `TEST_DATABASE_URL` is set. The other tests use a fake provider and in-memory storage, so a live GitHub sign-in still has to be tried by hand. `test:consumer` installs the packed package into a separate project and checks its Node and browser imports. CI runs all of it, Postgres included, on Node 22.13, 24 and 26.
665
+ The Postgres and example tests only run when `TEST_DATABASE_URL` is set. The other tests use a fake provider and in-memory storage, so a live GitHub sign-in still has to be tried by hand. `test:consumer` installs the packed package into a separate project and checks its Node and browser imports. `starter:check` writes the project `verily init cloudflare` gives a new instance and bundles it as a deploy would, without deploying. CI runs all of it, Postgres included, on Node 22.13, 24 and 26.
560
666
 
561
667
  ## License
562
668
 
563
- MIT. Signed records and `pgpProvider()` use [OpenPGP.js](https://github.com/openpgpjs/openpgpjs), which is LGPL-3.0-or-later. The badge script `verily.js` includes it, unmodified, to check signatures; its licence is in [`docs/licenses/openpgp.txt`](docs/licenses/openpgp.txt).
669
+ MIT. Signed records and `pgpProvider()` use [OpenPGP.js](https://github.com/openpgpjs/openpgpjs), which is LGPL-3.0-or-later. The badge script `verily.js` includes it, unmodified, to check signatures; its license is in [`docs/licenses/openpgp.txt`](docs/licenses/openpgp.txt).