@bkazemi/verily 0.1.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +503 -0
  3. package/dist/browser/badge.d.ts +39 -0
  4. package/dist/browser/badge.js +440 -0
  5. package/dist/browser/connect-dialog.d.ts +83 -0
  6. package/dist/browser/connect-dialog.js +514 -0
  7. package/dist/browser/evidence-dialog.d.ts +59 -0
  8. package/dist/browser/evidence-dialog.js +541 -0
  9. package/dist/browser/index.d.ts +59 -0
  10. package/dist/browser/index.js +809 -0
  11. package/dist/browser/logo.d.ts +2 -0
  12. package/dist/browser/logo.js +17 -0
  13. package/dist/browser/mark.d.ts +19 -0
  14. package/dist/browser/mark.js +77 -0
  15. package/dist/browser/provider-mark.d.ts +6 -0
  16. package/dist/browser/provider-mark.js +99 -0
  17. package/dist/browser/signed.d.ts +40 -0
  18. package/dist/browser/signed.js +219 -0
  19. package/dist/browser.d.ts +3 -0
  20. package/dist/browser.js +3 -0
  21. package/dist/core/index.d.ts +454 -0
  22. package/dist/core/index.js +128 -0
  23. package/dist/core/signed.d.ts +49 -0
  24. package/dist/core/signed.js +113 -0
  25. package/dist/index.d.ts +5 -0
  26. package/dist/index.js +5 -0
  27. package/dist/logo.d.ts +21 -0
  28. package/dist/logo.js +38 -0
  29. package/dist/postgres/index.d.ts +12 -0
  30. package/dist/postgres/index.js +76 -0
  31. package/dist/server/addresses.d.ts +2 -0
  32. package/dist/server/addresses.js +61 -0
  33. package/dist/server/body.d.ts +11 -0
  34. package/dist/server/body.js +49 -0
  35. package/dist/server/copy.d.ts +8 -0
  36. package/dist/server/copy.js +42 -0
  37. package/dist/server/discord.d.ts +7 -0
  38. package/dist/server/discord.js +69 -0
  39. package/dist/server/email-logo.d.ts +11 -0
  40. package/dist/server/email-logo.js +122 -0
  41. package/dist/server/email.d.ts +46 -0
  42. package/dist/server/email.js +145 -0
  43. package/dist/server/escape.d.ts +2 -0
  44. package/dist/server/escape.js +2 -0
  45. package/dist/server/github-gist.d.ts +11 -0
  46. package/dist/server/github-gist.js +68 -0
  47. package/dist/server/github.d.ts +7 -0
  48. package/dist/server/github.js +65 -0
  49. package/dist/server/index.d.ts +70 -0
  50. package/dist/server/index.js +862 -0
  51. package/dist/server/link.d.ts +64 -0
  52. package/dist/server/link.js +458 -0
  53. package/dist/server/openpgp.d.ts +49 -0
  54. package/dist/server/openpgp.js +158 -0
  55. package/dist/server/pgp.d.ts +29 -0
  56. package/dist/server/pgp.js +198 -0
  57. package/dist/server/public-fetch.d.ts +23 -0
  58. package/dist/server/public-fetch.js +62 -0
  59. package/dist/server/resend.d.ts +13 -0
  60. package/dist/server/resend.js +37 -0
  61. package/dist/server/service.d.ts +311 -0
  62. package/dist/server/service.js +1178 -0
  63. package/dist/server/signing.d.ts +16 -0
  64. package/dist/server/signing.js +56 -0
  65. package/dist/server/style.d.ts +17 -0
  66. package/dist/server/style.js +244 -0
  67. package/dist/server/wkd.d.ts +7 -0
  68. package/dist/server/wkd.js +65 -0
  69. package/dist/server/youtube.d.ts +11 -0
  70. package/dist/server/youtube.js +99 -0
  71. package/dist/site/index.d.ts +139 -0
  72. package/dist/site/index.js +239 -0
  73. package/dist/verily.js +11907 -0
  74. package/dist/version.d.ts +10 -0
  75. package/dist/version.js +10 -0
  76. package/docs/licenses/gpl-3.0.txt +232 -0
  77. package/docs/licenses/octicons.txt +21 -0
  78. package/docs/licenses/openpgp.txt +172 -0
  79. package/docs/licenses/rubik.txt +93 -0
  80. package/docs/licenses/simple-icons.txt +8 -0
  81. package/package.json +86 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Verily contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,503 @@
1
+ <h1 align="center">
2
+ <img src="docs/design/verily-logo-plate.svg" alt="Verily" width="320" />
3
+ </h1>
4
+
5
+ <p align="center">
6
+ Let your users prove they own an account elsewhere, and show it on their profile with a badge anyone can check.
7
+ </p>
8
+
9
+ <p align="center">
10
+ <a href="https://github.com/bkazemi/verily/actions/workflows/ci.yml"><img src="https://github.com/bkazemi/verily/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
11
+ <a href="https://www.npmjs.com/package/@bkazemi/verily"><img src="https://img.shields.io/npm/v/@bkazemi/verily" alt="npm" /></a>
12
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" /></a>
13
+ </p>
14
+
15
+ <p align="center">
16
+ <img src="docs/images/badge.png" alt="A profile card with a Verily badge showing a GitHub account" width="480" />
17
+ </p>
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, publishing a gist, linking back with `rel="me"`, or signing with an OpenPGP key. They approve the connection, and Verily publishes it as a record anyone can inspect, with a badge for their profile.
20
+
21
+ It doesn't sign anyone into your site, and it doesn't establish legal identity.
22
+
23
+ ## How it works
24
+
25
+ 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.
28
+ 4. A `<verily-badge>` on their profile shows the external account. Clicking it opens the evidence.
29
+ 5. Verily keeps the record honest: published proofs are re-read, records expire, and either side can revoke.
30
+
31
+ ## Two ways to use it
32
+
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).
35
+
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.
37
+
38
+ ## Install
39
+
40
+ ```sh
41
+ npm install @bkazemi/verily
42
+ ```
43
+
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.
45
+
46
+ ## Set up the server
47
+
48
+ ### 1. Storage
49
+
50
+ ```ts
51
+ import { Pool, PostgresStorage } from '@bkazemi/verily';
52
+
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
55
+ ```
56
+
57
+ ### 2. Create the handler
58
+
59
+ ```ts
60
+ import { createVerily, githubProvider } from '@bkazemi/verily';
61
+
62
+ const verily = createVerily({
63
+ storage,
64
+ providers: [
65
+ githubProvider({
66
+ clientId: process.env.GITHUB_CLIENT_ID!,
67
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!,
68
+ }),
69
+ ],
70
+ baseUrl: 'https://community.example/api/verily',
71
+ siteName: 'Example Community',
72
+ verifierName: 'community.example',
73
+ profileOrigins: ['https://community.example'],
74
+ reportUrl: 'mailto:reports@community.example',
75
+ authenticate: async (request) => {
76
+ const user = await getSignedInUser(request); // your own session lookup
77
+ if (!user) return undefined;
78
+
79
+ return {
80
+ id: user.id, // private, stable, never reused
81
+ label: user.displayName,
82
+ reference: user.username, // public and durable; never an email
83
+ profileUrl: `https://community.example/users/${user.username}`, // optional
84
+ };
85
+ },
86
+ });
87
+ ```
88
+
89
+ | Option | Meaning |
90
+ | ---------------- | --------------------------------------------------------------------------------------------------- |
91
+ | `provider` | How users prove an external account. One provider or an array; see [Proof methods](#proof-methods). |
92
+ | `baseUrl` | The public URL you mount Verily at. Every route it serves is under this path. |
93
+ | `siteName` | Your site's name, shown on evidence. |
94
+ | `verifierName` | Who vouches for the record, shown on evidence. Usually the backend's domain. |
95
+ | `profileOrigins` | The origins a local `profileUrl` may be on. |
96
+ | `reportUrl` | Where readers report a bad record (`https:` or `mailto:`). |
97
+ | `authenticate` | Returns the signed-in local account for a request, or `undefined` if nobody is signed in. |
98
+
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.
100
+
101
+ - **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
+ - **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.
104
+
105
+ 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
+
107
+ ### 3. Mount it
108
+
109
+ `verily.handle` takes a `Request` and returns a `Response`, so it plugs into any fetch-style router. For `node:http`, wrap it with `nodeHandler` and route the requests under `baseUrl` to it:
110
+
111
+ ```ts
112
+ import { createServer } from 'node:http';
113
+ import { nodeHandler } from '@bkazemi/verily';
114
+
115
+ const handleVerily = nodeHandler(verily.handle, 'https://community.example');
116
+
117
+ createServer((req, res) => {
118
+ if (req.url?.startsWith('/api/verily')) return handleVerily(req, res);
119
+ // ...the rest of your site
120
+ }).listen(3000);
121
+ ```
122
+
123
+ ### 4. Let users connect an account
124
+
125
+ Put the connect pill on your settings page. Clicking it opens a dialog where the user picks a method, proves it and approves the connection, without leaving the page:
126
+
127
+ ```html
128
+ <script src="/assets/verily.js" defer></script>
129
+ <verily-connect backend-url="/api/verily"></verily-connect>
130
+ ```
131
+
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.
133
+
134
+ 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
+
136
+ Then schedule the upkeep described under [Operations](#operations).
137
+
138
+ ## Show the badge
139
+
140
+ With a bundler:
141
+
142
+ ```js
143
+ import { init } from '@bkazemi/verily'; // also registers <verily-badge>
144
+
145
+ const client = init({ backendUrl: '/api/verily' });
146
+ ```
147
+
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.1.0/dist/verily.js`. The script defines a global `Verily`.
149
+
150
+ Then place the badge wherever the account appears:
151
+
152
+ ```html
153
+ <script src="/assets/verily.js" defer></script>
154
+ <verily-badge backend-url="/api/verily" connection-id="CONNECTION_ID"></verily-badge>
155
+ ```
156
+
157
+ Clicking the badge opens the verification details:
158
+
159
+ <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" />
161
+ </p>
162
+
163
+ A user with several accounts gets one badge for all of them. Give it every connection id, in any order:
164
+
165
+ ```html
166
+ <verily-badge backend-url="/api/verily" connection-ids="ID_ONE ID_TWO ID_THREE"></verily-badge>
167
+ ```
168
+
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.
170
+
171
+ 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.
172
+
173
+ Add `stacked` to name the accounts in the badge itself, one to a row:
174
+
175
+ ```html
176
+ <verily-badge
177
+ backend-url="/api/verily"
178
+ connection-ids="ID_ONE ID_TWO ID_THREE"
179
+ stacked
180
+ ></verily-badge>
181
+ ```
182
+
183
+ 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 }`.
184
+
185
+ 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:
186
+
187
+ ```js
188
+ await client.openConnect(); // the connect dialog; call from a click
189
+ await client.connect({ provider: 'github' }); // opens <baseUrl>/verify in a popup; call from a click
190
+ await client.mountBadge(element, { connectionId }); // draws once; call again to refresh
191
+ await client.disconnect(connectionId);
192
+ ```
193
+
194
+ 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`.
195
+
196
+ ## Proof methods
197
+
198
+ ```ts
199
+ import {
200
+ githubProvider,
201
+ discordProvider,
202
+ youtubeProvider,
203
+ githubGistProvider,
204
+ linkProvider,
205
+ githubLinkProvider,
206
+ pgpProvider,
207
+ } from '@bkazemi/verily';
208
+ ```
209
+
210
+ | Provider | The user proves it by | Setup |
211
+ | ---------------------- | ------------------------------------------------------------------------------------- | --------------------------------------- |
212
+ | `githubProvider()` | signing in with GitHub. | A GitHub OAuth app. |
213
+ | `discordProvider()` | signing in with Discord. | A Discord application. |
214
+ | `youtubeProvider()` | signing in with Google and choosing their YouTube channel. | A Google OAuth client. |
215
+ | `githubGistProvider()` | publishing a public gist containing a line Verily gives them. | None. |
216
+ | `linkProvider()` | adding a `rel="me"` link to their profile on a page they control. | The local account needs a `profileUrl`. |
217
+ | `githubLinkProvider()` | putting their profile URL in the website field of their GitHub profile. | The local account needs a `profileUrl`. |
218
+ | `pgpProvider()` | signing a line Verily gives them with their OpenPGP key, then pasting it and the key. | None. |
219
+ | `emailProvider()` | entering a code Verily mails to their address. | A function that sends one message. |
220
+
221
+ All methods except the sign-ins and the mailed code let the user publish the proof in their own time and come back. Gists and link-backs 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`.
222
+
223
+ **Several at once.** List more than one, and `/verify` offers each method as its own button:
224
+
225
+ ```ts
226
+ providers: [githubProvider({ clientId, clientSecret }), githubLinkProvider()],
227
+ ```
228
+
229
+ Proving the same account a second way adds that proof to the existing record instead of creating another.
230
+
231
+ **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.
232
+
233
+ **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.
234
+
235
+ **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:
236
+
237
+ ```ts
238
+ emailProvider({
239
+ send: ({ to, subject, text, html, images }) =>
240
+ mailer.send({
241
+ from: 'verify@community.example',
242
+ to,
243
+ subject,
244
+ text,
245
+ html,
246
+ // The logo travels in the message. Attach each image inline under its content id.
247
+ attachments: images.map((image) => ({
248
+ filename: image.filename,
249
+ content: Buffer.from(image.content, 'base64'),
250
+ contentType: image.contentType,
251
+ cid: image.contentId,
252
+ })),
253
+ }),
254
+ }),
255
+ ```
256
+
257
+ 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.
258
+
259
+ 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, and pressing **Confirm** there proves the mailbox; the page the user started on then carries on to the approval. Opening the link alone confirms nothing, so a mail scanner that follows links cannot answer for anyone. 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.
260
+
261
+ 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.
262
+
263
+ `resendSender({ apiKey, from })` is a ready-made `send` for [Resend](https://resend.com), using only `fetch`:
264
+
265
+ ```ts
266
+ emailProvider({
267
+ send: resendSender({ apiKey: process.env.RESEND_API_KEY!, from: 'Verily <verify@community.example>' }),
268
+ }),
269
+ ```
270
+
271
+ **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.
272
+
273
+ ## A badge on a static site
274
+
275
+ 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:
276
+
277
+ 1. Clone this repository, run `npm ci`, and copy `wrangler.example.jsonc` to `wrangler.jsonc`. Fill in your account, origin and owner details.
278
+ 2. Run `wrangler secret put` for `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` and `OWNER_KEY`, then `npm run cloudflare:deploy`.
279
+ 3. On the backend's site, sign in with `OWNER_KEY`, verify, and approve a **public** connection. Its id is listed at `/api/verily/mine`.
280
+ 4. Add the script and the badge to your site:
281
+
282
+ ```html
283
+ <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.1.0/dist/verily.js" defer></script>
284
+ <verily-badge
285
+ backend-url="https://your-backend.example/api/verily"
286
+ connection-id="CONNECTION_ID"
287
+ ></verily-badge>
288
+ ```
289
+
290
+ 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`.
291
+
292
+ ## Using a hosted instance
293
+
294
+ 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.
295
+
296
+ 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.
297
+
298
+ ```ts
299
+ import { createSiteClient } from '@bkazemi/verily/site';
300
+
301
+ const verily = createSiteClient({
302
+ instance: 'https://verily.example',
303
+ site: 'your-site-id',
304
+ key: process.env.VERILY_SITE_KEY,
305
+ });
306
+ ```
307
+
308
+ 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.
309
+
310
+ **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:
311
+
312
+ ```ts
313
+ const handle = verily.handler({
314
+ authenticate: async (request) => {
315
+ const user = await yourSession(request);
316
+ if (!user) return undefined;
317
+
318
+ return {
319
+ id: user.id, // private and stable; the instance never shows it
320
+ label: user.displayName,
321
+ reference: user.handle, // durable and safe to show; never an email
322
+ };
323
+ },
324
+ signInUrl: '/login', // where a signed-out user is sent
325
+ returnUrl: '/settings', // where a user lands when they come back
326
+ });
327
+
328
+ // GET and POST /api/verily/*
329
+ app.all('/api/verily/*', (request) => handle(request));
330
+ ```
331
+
332
+ The authorize and return URLs the instance's operator registers for you are `<prefix>/authorize` and `<prefix>/return`.
333
+
334
+ **2. Show the connect pill.** The user verifies in a dialog on your page, without leaving it. Only the provider's own sign-in opens in a small window:
335
+
336
+ ```html
337
+ <script src="/assets/verily.js" defer></script>
338
+ <verily-connect
339
+ backend-url="https://verily.example/api/verily"
340
+ handoff-url="/api/verily/handoff"
341
+ ></verily-connect>
342
+ ```
343
+
344
+ The element fires `verily-result` when a link is made, so read the user's connections again then.
345
+
346
+ Once a user has a link, show their badge in place of the pill, and give it the same two attributes. On the user's own page only: they mark the reader as the holder.
347
+
348
+ ```html
349
+ <verily-badge
350
+ connections="[...the records, as JSON...]"
351
+ backend-url="https://verily.example/api/verily"
352
+ handoff-url="/api/verily/handoff"
353
+ ></verily-badge>
354
+ ```
355
+
356
+ The badge's dialog then has Renew and Remove under each account, and Add account below them, which opens the verify flow in the dialog's place with a way back. 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.
357
+
358
+ **3. Read connections where you show them.**
359
+
360
+ ```ts
361
+ const { [user.id]: connections } = await verily.connections([user.id]);
362
+ const verified = connections.filter((c) => c.status === 'verified');
363
+ ```
364
+
365
+ 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:
366
+
367
+ ```html
368
+ <verily-badge connections="[...the records, as JSON...]"></verily-badge>
369
+ ```
370
+
371
+ 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.
372
+
373
+ 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.
374
+
375
+ If you would rather your user ids never left your site, give the client a secret:
376
+
377
+ ```ts
378
+ const verily = createSiteClient({ instance, site, key, idSecret: process.env.VERILY_ID_SECRET });
379
+ ```
380
+
381
+ 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.
382
+
383
+ 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.
384
+
385
+ **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.
386
+
387
+ ## Serving other sites
388
+
389
+ 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.
390
+
391
+ Three options tie a flow to the site that sent the holder:
392
+
393
+ | Option | Meaning |
394
+ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
395
+ | `context(request)` | What to store on a flow when it starts, such as which site sent the holder. Never called for removal from the external side. |
396
+ | `finish({ id, context, local, result })` | Where to send the holder once a flow has ended, or `undefined` for the result page. It runs each time the result page loads, so it must depend only on what it is given. |
397
+ | `formTargets` | Origins `finish` may send a holder to. Browsers hold a form's redirects to `form-action`, and the approval form is what redirects, so these origins are added to the policy. |
398
+
399
+ 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.
400
+
401
+ `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).
402
+
403
+ ## Signed records
404
+
405
+ 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.
406
+
407
+ ```ts
408
+ import { createVerily, generateSigningKey } from '@bkazemi/verily';
409
+
410
+ // Make the key once and keep it with your other secrets. It is an armored OpenPGP
411
+ // private key, so it spans several lines.
412
+ console.log(await generateSigningKey('verifier.example'));
413
+
414
+ createVerily({
415
+ // ...
416
+ signingKey: process.env.VERILY_SIGNING_KEY,
417
+ });
418
+ ```
419
+
420
+ With a key set:
421
+
422
+ - **`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.
423
+ - **`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`.
424
+ - **`<baseUrl>/check`** is a page where a saved file can be pasted and read back.
425
+ - 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.
426
+ - The approval step tells the holder that a saved signed record outlives removal.
427
+
428
+ To check a file yourself:
429
+
430
+ ```sh
431
+ curl https://verifier.example/api/verily/keys.asc | gpg --import
432
+ gpg --verify verily-<id>.asc
433
+ ```
434
+
435
+ Or in code, which runs in a browser too:
436
+
437
+ ```ts
438
+ import { verifySigned } from '@bkazemi/verily';
439
+
440
+ const { keys } = await (await fetch('https://verifier.example/api/verily/keys')).json();
441
+ const record = await verifySigned(file, keys); // undefined if no key signed it
442
+ ```
443
+
444
+ 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.
445
+
446
+ `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.
447
+
448
+ 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.
449
+
450
+ ## Operations
451
+
452
+ Verification lasts 30 days, flows 10 minutes, and sharing links 7 days. Change them with `validityMs`, `flowTtlMs` and `shareTtlMs`. Evidence is never cached.
453
+
454
+ Run the upkeep on a schedule, for example hourly:
455
+
456
+ ```ts
457
+ setInterval(
458
+ async () => {
459
+ try {
460
+ await verily.service.prune();
461
+ await verily.service.recheck(); // only needed if you use a method other than signing in
462
+ } catch (error) {
463
+ console.error('Verily upkeep failed', error);
464
+ }
465
+ },
466
+ 60 * 60 * 1000,
467
+ );
468
+ ```
469
+
470
+ - **`prune()`** deletes expired flows, and deletes expired or revoked records after 90 days.
471
+ - **`recheck()`** re-reads gists and link-backs 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.
472
+
473
+ **If you never call `recheck()`, set `freshnessMs: Infinity`.** Otherwise connections proved by gist, link-back or PGP lapse after a week.
474
+
475
+ ## Development
476
+
477
+ Clone the repository and run `npm ci`. `npm run preview` then shows the badge in every state at http://localhost:3001, with no other setup.
478
+
479
+ The full example also needs Postgres and a GitHub OAuth app, a Discord application or a Google OAuth client (any of them) with the callback URL `http://localhost:3000/api/verily/callback`:
480
+
481
+ ```sh
482
+ npm run build
483
+ docker compose up -d --wait
484
+ cp example/.env.example .env # add credentials for at least one sign-in, and a long EXAMPLE_PASSWORD
485
+ node --env-file=.env --import tsx example/server.ts
486
+ ```
487
+
488
+ Checks and tests:
489
+
490
+ ```sh
491
+ npm run build
492
+ npm run check
493
+ npm run format:check # npm run format fixes formatting
494
+ npm test
495
+ TEST_DATABASE_URL=postgres://verily:verily@localhost:5432/verily npm test
496
+ npm run test:consumer
497
+ ```
498
+
499
+ 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.
500
+
501
+ ## License
502
+
503
+ 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).
@@ -0,0 +1,39 @@
1
+ import { type Evidence } from '../core/index.js';
2
+ /** Whether a host is currently presenting a badge of its own. */
3
+ export declare function badgeShown(element: HTMLElement): boolean;
4
+ /**
5
+ * The pill before there is anything to say in it: its own frame and mark, drawn in the
6
+ * page's text colour because no verification has been read yet, and a spinner standing in
7
+ * for the account this is about. Nothing here is replaced when the answer arrives.
8
+ */
9
+ export declare function renderBadgePending(element: HTMLElement): void;
10
+ export declare function renderBadgeMessage(element: HTMLElement, message: string): void;
11
+ /**
12
+ * Draws the pill, or returns null when the host already shows exactly this evidence:
13
+ * a periodic refresh that changes nothing must not disturb what is on screen.
14
+ *
15
+ * `more` is how many other accounts of the same subject stand behind the one shown, said
16
+ * as a count beside it. `linked` is whether the record has a page of its own a reader can
17
+ * open: an unlisted one has none, so its pill is a button and never a link to nowhere.
18
+ *
19
+ * `rows` are those of the other accounts to name, in order. Stacked, each has a row of
20
+ * its own in the pill, which is then as tall as its rows. Otherwise the pill stays short
21
+ * and, with `peek`, names them in a panel that opens beside it while a pointer rests on it
22
+ * or the keyboard is on it. Either way the accounts past the rows are a count.
23
+ */
24
+ export declare function renderBadge(element: HTMLElement, evidence: Evidence, { more, linked, rows, peek, }?: {
25
+ more?: number;
26
+ linked?: boolean;
27
+ rows?: Evidence[];
28
+ peek?: boolean;
29
+ }): HTMLElement | null;
30
+ /**
31
+ * Shuts a host's panel and lets go of what it was listening for. For a host leaving the
32
+ * page: the browser shuts its popover then, but the wait for a scroll would stay behind.
33
+ */
34
+ export declare function quietBadge(element: HTMLElement): void;
35
+ /**
36
+ * The pill that starts a connection, in the same frame as the badges it will produce. Its
37
+ * mark claims nothing yet, so it is drawn in the text colour as a pending one is.
38
+ */
39
+ export declare function renderConnectPill(element: HTMLElement): HTMLButtonElement;