@bkazemi/verily 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@
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, 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.
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.
20
20
 
21
21
  It doesn't sign anyone into your site, and it doesn't establish legal identity.
22
22
 
@@ -86,15 +86,15 @@ const verily = createVerily({
86
86
  });
87
87
  ```
88
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. |
89
+ | Option | Meaning |
90
+ | ---------------- | ----------------------------------------------------------------------------------------- |
91
+ | `providers` | How users prove an external account, as a list; 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
98
 
99
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
100
 
@@ -145,7 +145,7 @@ import { init } from '@bkazemi/verily'; // also registers <verily-badge>
145
145
  const client = init({ backendUrl: '/api/verily' });
146
146
  ```
147
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`.
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.3.0/dist/verily.js`. The script defines a global `Verily`.
149
149
 
150
150
  Then place the badge wherever the account appears:
151
151
 
@@ -168,6 +168,8 @@ A user with several accounts gets one badge for all of them. Give it every conne
168
168
 
169
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
170
 
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.
172
+
171
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.
172
174
 
173
175
  Add `stacked` to name the accounts in the badge itself, one to a row:
@@ -189,8 +191,11 @@ await client.openConnect(); // the connect dialog; call from a click
189
191
  await client.connect({ provider: 'github' }); // opens <baseUrl>/verify in a popup; call from a click
190
192
  await client.mountBadge(element, { connectionId }); // draws once; call again to refresh
191
193
  await client.disconnect(connectionId);
194
+ await client.mark(connectionId, 'preferred'); // or 'unused', 'current', 'retired'
192
195
  ```
193
196
 
197
+ `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.
198
+
194
199
  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
200
 
196
201
  ## Proof methods
@@ -207,18 +212,18 @@ import {
207
212
  } from '@bkazemi/verily';
208
213
  ```
209
214
 
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. |
215
+ | Provider | The user proves it by | Setup |
216
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------- |
217
+ | `githubProvider()` | signing in with GitHub. | A GitHub OAuth app. |
218
+ | `discordProvider()` | signing in with Discord. | A Discord application. |
219
+ | `youtubeProvider()` | signing in with Google and choosing their YouTube channel. | A Google OAuth client. |
220
+ | `githubGistProvider()` | publishing a public gist containing a line Verily gives them. | None. |
221
+ | `linkProvider()` | adding a `rel="me"` link to their profile on a page they control. | The local account needs a `profileUrl`. |
222
+ | `githubLinkProvider()` | putting their profile URL in the website field of their GitHub profile, then entering their GitHub username. | The local account needs a `profileUrl`. |
223
+ | `pgpProvider()` | signing a line Verily gives them with their OpenPGP key, then pasting it and the key. | None. |
224
+ | `emailProvider()` | pressing a button, or entering a code, that Verily mails to their address. | A function that sends one message. |
220
225
 
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`.
226
+ 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 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
227
 
223
228
  **Several at once.** List more than one, and `/verify` offers each method as its own button:
224
229
 
@@ -228,8 +233,16 @@ providers: [githubProvider({ clientId, clientSecret }), githubLinkProvider()],
228
233
 
229
234
  Proving the same account a second way adds that proof to the existing record instead of creating another.
230
235
 
236
+ **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.
237
+
238
+ **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.
239
+
231
240
  **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
241
 
242
+ **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.
243
+
244
+ 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.
245
+
233
246
  **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
247
 
235
248
  **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:
@@ -256,7 +269,7 @@ emailProvider({
256
269
 
257
270
  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
271
 
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.
272
+ 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.
260
273
 
261
274
  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
275
 
@@ -280,7 +293,7 @@ A static site only needs the script and the badge markup; the backend runs somew
280
293
  4. Add the script and the badge to your site:
281
294
 
282
295
  ```html
283
- <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.1.0/dist/verily.js" defer></script>
296
+ <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.3.0/dist/verily.js" defer></script>
284
297
  <verily-badge
285
298
  backend-url="https://your-backend.example/api/verily"
286
299
  connection-id="CONNECTION_ID"
@@ -353,7 +366,7 @@ Once a user has a link, show their badge in place of the pill, and give it the s
353
366
  ></verily-badge>
354
367
  ```
355
368
 
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.
369
+ 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.
357
370
 
358
371
  **3. Read connections where you show them.**
359
372
 
@@ -362,6 +375,8 @@ const { [user.id]: connections } = await verily.connections([user.id]);
362
375
  const verified = connections.filter((c) => c.status === 'verified');
363
376
  ```
364
377
 
378
+ 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.
379
+
365
380
  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
381
 
367
382
  ```html
@@ -419,7 +434,7 @@ createVerily({
419
434
 
420
435
  With a key set:
421
436
 
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.
437
+ - **`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.
423
438
  - **`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
439
  - **`<baseUrl>/check`** is a page where a saved file can be pasted and read back.
425
440
  - 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.
@@ -467,7 +482,7 @@ setInterval(
467
482
  );
468
483
  ```
469
484
 
470
- - **`prune()`** deletes expired flows, and deletes expired or revoked records after 90 days.
485
+ - **`prune()`** deletes expired flows, and deletes expired or revoked records after 90 days. A retired record is kept until it is removed.
471
486
  - **`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
487
 
473
488
  **If you never call `recheck()`, set `freshnessMs: Infinity`.** Otherwise connections proved by gist, link-back or PGP lapse after a week.
@@ -1,5 +1,5 @@
1
1
  import { externalName, statusLabel } from '../core/index.js';
2
- import { clockMark, dashMark, markStyles, paintMark, verificationMark } from './mark.js';
2
+ import { boxMark, clockMark, dashMark, markStyles, paintMark, verificationMark } from './mark.js';
3
3
  import { providerMark } from './provider-mark.js';
4
4
  const styles = `
5
5
  :host { display: inline-block; max-width: 100%; vertical-align: middle; }
@@ -53,7 +53,9 @@ const styles = `
53
53
  .peek .via { display: block; margin-top: 6px; font-size: 11px; color: var(--verily-muted, #65726c); }
54
54
  .provider { display: block; flex-shrink: 0; width: 14px; height: 14px; }
55
55
  .expired .icon { color: #815a12; background: #fbefce; }
56
- .revoked .icon, .message .icon { color: #626d69; background: #edf0ee; }
56
+ .revoked .icon, .retired .icon, .message .icon { color: #626d69; background: #edf0ee; }
57
+ /* Nothing ran out and nothing wants attention: the holder chose this, so it is only quiet. */
58
+ .retired .name { color: var(--verily-muted, #65726c); }
57
59
  .message { color: var(--verily-muted, #65726c); }
58
60
  .add { font-weight: 600; }
59
61
  @media (prefers-reduced-motion: no-preference) {
@@ -227,7 +229,14 @@ export function renderBadgeMessage(element, message) {
227
229
  export function renderBadge(element, evidence, { more = 0, linked = true, rows = [], peek = false, } = {}) {
228
230
  const provider = evidence.providerName;
229
231
  const current = evidence.status === 'verified' && evidence.expiresAt > Date.now();
230
- const state = current ? 'verified' : evidence.status === 'revoked' ? 'revoked' : 'expired';
232
+ const retired = evidence.status === 'retired';
233
+ const state = current
234
+ ? 'verified'
235
+ : retired
236
+ ? 'retired'
237
+ : evidence.status === 'revoked'
238
+ ? 'revoked'
239
+ : 'expired';
231
240
  const label = statusLabel(evidence, Date.now());
232
241
  const key = renderKey(evidence, current, label, more, linked, rows, peek);
233
242
  if (intact(element) && shown.get(element) === key)
@@ -237,7 +246,9 @@ export function renderBadge(element, evidence, { more = 0, linked = true, rows =
237
246
  const unnamed = Math.max(more - rows.length, 0);
238
247
  const named = rows.map((row) => `, ${row.providerName} ${externalName(row.external)}`).join('');
239
248
  const others = `${named}${unnamed ? ` and ${unnamed} more` : ''}`;
240
- paintMark(mark, current ? 'current' : 'inactive');
249
+ // A retired account claims nothing now, so its mark is drawn as one waiting to be read
250
+ // is: in the page's text colour, and not as a verification that failed.
251
+ paintMark(mark, current ? 'current' : retired ? 'pending' : 'inactive');
241
252
  if (linked) {
242
253
  badge.href = evidence.evidenceUrl;
243
254
  badge.rel = 'noreferrer';
@@ -282,7 +293,7 @@ export function renderBadge(element, evidence, { more = 0, linked = true, rows =
282
293
  }
283
294
  if (!current) {
284
295
  const icon = span('icon', '');
285
- icon.append(state === 'expired' ? clockMark() : dashMark());
296
+ icon.append(retired ? boxMark() : state === 'expired' ? clockMark() : dashMark());
286
297
  icon.setAttribute('aria-hidden', 'true');
287
298
  badge.append(span('label', label), icon);
288
299
  }
@@ -30,6 +30,12 @@ export interface FlowView {
30
30
  instructions?: Instruction[];
31
31
  artifact?: 'location' | 'document';
32
32
  field?: string;
33
+ /** How a `location` is asked for. Absent, from an older backend, means an address. */
34
+ input?: 'url' | 'text';
35
+ /** What to offer in the field, where a record of the holder's already says. */
36
+ suggested?: string;
37
+ /** Said where what was last handed back was refused and the flow still waits for it. */
38
+ refused?: string;
33
39
  note?: string;
34
40
  /**
35
41
  * A mailed code: what to ask the holder for, and once a code has gone, where it went,
@@ -47,6 +53,8 @@ export interface FlowView {
47
53
  joined?: {
48
54
  visibility: 'public' | 'unlisted';
49
55
  };
56
+ /** Said where the method used found another proof standing, which confirming records too. */
57
+ standingNote?: string;
50
58
  /** What the holder may choose. Absent, from an older backend, means either. */
51
59
  visibilities?: ('public' | 'unlisted')[];
52
60
  /** Said beside the public choice, where the backend signs its public records. */
@@ -56,7 +64,8 @@ export interface FlowView {
56
64
  }
57
65
  export interface ConnectApi {
58
66
  methods(): Promise<Methods>;
59
- start(provider: string, method: string): Promise<FlowView>;
67
+ /** Starts a flow by one method: a new link, or a renewal of the record `renew` names. */
68
+ start(provider: string, method: string, renew?: string): Promise<FlowView>;
60
69
  read(id: string): Promise<FlowView>;
61
70
  submit(id: string, artifact: string): Promise<FlowView>;
62
71
  approve(id: string, visibility: 'public' | 'unlisted', cancel: boolean): Promise<Result>;
@@ -79,5 +88,15 @@ export declare function openConnectDialog(opener: HTMLElement, api: ConnectApi,
79
88
  */
80
89
  back?: () => void,
81
90
  /** The provider to start on, where the holder already said which account they mean. */
82
- provider?: string): Promise<Result>;
91
+ provider?: string,
92
+ /**
93
+ * The record to renew, where the holder asked to show one of their accounts again: each
94
+ * flow started then extends that record under its id. `method` is the one method that
95
+ * may, for a retired record. Where the holder is not offered it, a new link is the way
96
+ * back, and the dialog connects as it would with no record named.
97
+ */
98
+ renew?: {
99
+ id: string;
100
+ method?: string;
101
+ }): Promise<Result>;
83
102
  export {};
@@ -1,7 +1,6 @@
1
1
  import { externalLink, externalName } from '../core/index.js';
2
- import { accountCard, linkMark, node, outward, styles } from './evidence-dialog.js';
2
+ import { accountCard, linkMark, node, outward, stampLink, styles } from './evidence-dialog.js';
3
3
  import { providerMark } from './provider-mark.js';
4
- import { verilyLogo } from './logo.js';
5
4
  import { version } from '../version.js';
6
5
  const connectStyles = `
7
6
  ${styles}
@@ -91,7 +90,14 @@ export function openConnectDialog(opener, api,
91
90
  */
92
91
  back,
93
92
  /** The provider to start on, where the holder already said which account they mean. */
94
- provider) {
93
+ provider,
94
+ /**
95
+ * The record to renew, where the holder asked to show one of their accounts again: each
96
+ * flow started then extends that record under its id. `method` is the one method that
97
+ * may, for a retired record. Where the holder is not offered it, a new link is the way
98
+ * back, and the dialog connects as it would with no record named.
99
+ */
100
+ renew) {
95
101
  const existing = openDialogs.get(opener);
96
102
  if (existing?.open) {
97
103
  existing.focus();
@@ -113,7 +119,7 @@ provider) {
113
119
  close.type = 'button';
114
120
  close.setAttribute('aria-label', 'Close');
115
121
  content.setAttribute('aria-live', 'polite');
116
- stamp.append(verilyLogo(), node('span', version));
122
+ stamp.append(stampLink(), node('span', version));
117
123
  header.append(heading, close);
118
124
  dialog.append(header, content, stamp);
119
125
  root.append(dialog);
@@ -208,7 +214,11 @@ provider) {
208
214
  }
209
215
  if (!current(asked))
210
216
  return;
211
- const all = methods.methods;
217
+ // A record's renewal stays on its provider, by the one method named where one is.
218
+ const renewing = methods.methods.filter((m) => m.provider === provider && (renew?.method === undefined || m.method === renew.method));
219
+ if (renew && !renewing.length)
220
+ renew = undefined;
221
+ const all = renew ? renewing : methods.methods;
212
222
  // A provider asked for that this holder is not offered leaves every option open.
213
223
  if (group !== undefined && !all.some((m) => m.provider === group))
214
224
  group = undefined;
@@ -243,7 +253,7 @@ provider) {
243
253
  return;
244
254
  }
245
255
  }
246
- void busy(control, () => api.start(choice.provider, choice.method), show);
256
+ void busy(control, () => api.start(choice.provider, choice.method, renew?.id), show);
247
257
  };
248
258
  list.append(control);
249
259
  }
@@ -255,7 +265,7 @@ provider) {
255
265
  : 'Choose how to show you control the other account.', 'explanation'),
256
266
  list,
257
267
  ];
258
- if (group !== undefined) {
268
+ if (group !== undefined && !renew) {
259
269
  const back = button('‹ All options');
260
270
  back.onclick = () => {
261
271
  group = undefined;
@@ -331,9 +341,14 @@ provider) {
331
341
  function publish(flow) {
332
342
  const input = flow.artifact === 'document'
333
343
  ? node('textarea')
334
- : Object.assign(node('input'), { type: 'url' });
344
+ : Object.assign(node('input'), {
345
+ type: flow.input ?? 'url',
346
+ autocapitalize: 'none',
347
+ spellcheck: false,
348
+ });
335
349
  input.name = 'artifact';
336
350
  input.required = true;
351
+ input.value = flow.suggested ?? '';
337
352
  if (input instanceof HTMLTextAreaElement)
338
353
  input.rows = 10;
339
354
  const label = node('label', flow.field ?? 'Your proof');
@@ -352,7 +367,7 @@ provider) {
352
367
  ? node('p', part)
353
368
  : Array.isArray(part)
354
369
  ? paragraph(part)
355
- : codeBlock(part.code)), label, row(back, check), ...(flow.note ? [node('p', flow.note, 'muted')] : []));
370
+ : codeBlock(part.code)), ...(flow.refused ? [node('p', flow.refused, 'error')] : []), label, row(back, check), ...(flow.note ? [node('p', flow.note, 'muted')] : []));
356
371
  }
357
372
  /**
358
373
  * A mailed code, in two steps on the one flow: the holder names an address, then enters
@@ -445,6 +460,8 @@ provider) {
445
460
  }
446
461
  parts.push(choice);
447
462
  }
463
+ if (flow.standingNote)
464
+ parts.push(node('p', flow.standingNote));
448
465
  if (methods)
449
466
  parts.push(node('p', `${methods.siteName} receives the result. Verified via ${methods.verifierName}.`, 'muted'));
450
467
  const confirm = button(flow.joined ? 'Add to connection' : 'Confirm connection', true);
@@ -479,12 +496,10 @@ provider) {
479
496
  cancel.onclick = () => void decide(cancel, true);
480
497
  content.replaceChildren(...parts, row(cancel, confirm));
481
498
  }
499
+ /** The result is on the page behind the dialog, so there is nothing left to say here. */
482
500
  function done() {
483
501
  settle();
484
- const finish = button('Done', true);
485
- finish.onclick = () => dialog.close();
486
- content.replaceChildren(node('p', 'The connection is recorded.', 'state'), row(finish));
487
- finish.focus();
502
+ dialog.close();
488
503
  }
489
504
  close.addEventListener('click', () => dialog.close());
490
505
  dialog.addEventListener('click', (event) => {
@@ -1,5 +1,7 @@
1
1
  import { type Evidence } from '../core/index.js';
2
- export declare const styles = "\n * { box-sizing: border-box; }\n dialog { width: min(460px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); margin: auto; padding: 24px; border: 1px solid #dce2de; border-radius: 16px; background: #fff; color: #23312b; box-shadow: 0 24px 90px #10201935; font: 13px/1.6 system-ui, sans-serif; }\n dialog::backdrop { background: #15271f66; }\n header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }\n h2 { margin: 0; font-size: 18px; font-weight: 650; letter-spacing: -.3px; }\n button { width: 32px; height: 32px; border: 1px solid #dce2de; border-radius: 8px; background: #fff; color: #52645a; font: 20px system-ui, sans-serif; cursor: pointer; }\n button:hover { background: #f2f5f1; }\n a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }\n a:focus-visible, button:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }\n .summary { display: flex; align-items: center; gap: 8px; margin-top: 16px; }\n .mark { width: 36px; height: 36px; flex-shrink: 0; }\n .provider { width: 14px; height: 14px; }\n .state { font-size: 14px; font-weight: 650; line-height: 1.4; }\n .muted { color: #6b786f; font-size: 12px; }\n .account { position: relative; }\n .veiled > :not(.checking) { filter: blur(4px); opacity: .55; user-select: none; }\n .checking { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: #23312b; font-size: 13px; font-weight: 600; }\n .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }\n @keyframes spin { to { transform: rotate(360deg); } }\n @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }\n .caution { display: inline-block; margin-left: 4px; color: #a15c00; cursor: help; vertical-align: middle; }\n .caution svg { display: block; width: 12px; height: 12px; }\n .bad { color: #b3261e; font-weight: 650; text-decoration: underline dotted; text-underline-offset: 3px; cursor: help; }\n .account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }\n .account h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 3px; color: #6b786f; font-size: 11px; font-weight: 550; }\n .account a, .account strong { font-weight: 650; font-size: 14px; }\n /* The account's own name carries that weight; a link inside a line of prose does not. */\n .summary a, .method a, .additional a { font: inherit; }\n .reference { margin-top: 2px; }\n .method { margin-top: 8px; }\n .additional { margin-top: 2px; padding-left: 12px; }\n .joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }\n dl { margin: 16px 0 0; padding-top: 12px; border-top: 1px solid #e5e9e3; display: grid; grid-template-columns: auto 1fr; gap: 5px 16px; font-size: 11px; }\n dt { color: #6b786f; }\n dd { margin: 0; text-align: right; overflow-wrap: anywhere; }\n .explanation { margin-top: 16px; }\n .action { width: auto; height: auto; padding: 9px 14px; font: 600 13px/1.3 system-ui, sans-serif; color: #23312b; text-align: left; }\n .action.primary { border-color: #245f43; background: #245f43; color: #fff; }\n .action.primary:hover { background: #1d4f37; }\n .action:disabled { opacity: .6; cursor: progress; }\n .action.danger:hover { border-color: #b3261e; background: #fdecea; color: #b3261e; }\n .row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }\n .account .row { align-items: center; justify-content: start; margin-top: 12px; }\n .account .action { padding: 5px 10px; font-size: 12px; }\n footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin-top: 14px; color: #9aa9a0; font-size: 11px; }\n .logo { display: block; height: 13px; }\n \n @media (prefers-reduced-motion: no-preference) {\n .mark.current path:last-child { animation: verily-draw .8s cubic-bezier(.65, 0, .35, 1) .1s backwards; }\n }\n @keyframes verily-draw { from { stroke-dasharray: 0 176; } to { stroke-dasharray: 108 176; } }\n\n";
2
+ /** The logotype as a link to the project, as both dialogs carry it in their footer. */
3
+ export declare function stampLink(): HTMLAnchorElement;
4
+ export declare const styles = "\n * { box-sizing: border-box; }\n dialog { width: min(460px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); margin: auto; padding: 24px; border: 1px solid #dce2de; border-radius: 16px; background: #fff; color: #23312b; box-shadow: 0 24px 90px #10201935; font: 13px/1.6 system-ui, sans-serif; }\n dialog::backdrop { background: #15271f66; }\n header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }\n h2 { margin: 0; font-size: 18px; font-weight: 650; letter-spacing: -.3px; }\n button { width: 32px; height: 32px; border: 1px solid #dce2de; border-radius: 8px; background: #fff; color: #52645a; font: 20px system-ui, sans-serif; cursor: pointer; }\n button:hover { background: #f2f5f1; }\n a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }\n a:focus-visible, button:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }\n .summary { display: flex; align-items: center; gap: 8px; margin-top: 16px; }\n .mark { width: 36px; height: 36px; flex-shrink: 0; }\n .provider { width: 14px; height: 14px; }\n .state { font-size: 14px; font-weight: 650; line-height: 1.4; }\n .muted { color: #6b786f; font-size: 12px; }\n .account { position: relative; }\n .veiled > :not(.checking) { filter: blur(4px); opacity: .55; user-select: none; }\n .checking { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: #23312b; font-size: 13px; font-weight: 600; }\n .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }\n @keyframes spin { to { transform: rotate(360deg); } }\n @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }\n .caution { display: inline-block; margin-left: 4px; color: #a15c00; cursor: help; vertical-align: middle; }\n .caution svg { display: block; width: 12px; height: 12px; }\n .bad { color: #b3261e; font-weight: 650; text-decoration: underline dotted; text-underline-offset: 3px; cursor: help; }\n .account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }\n .account h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 3px; color: #6b786f; font-size: 11px; font-weight: 550; }\n .account a, .account strong { font-weight: 650; font-size: 14px; }\n /* The account's own name carries that weight; a link inside a line of prose does not. */\n .summary a, .method a, .additional a { font: inherit; }\n .reference { margin-top: 2px; }\n .method { margin-top: 8px; }\n /* In the corner the heading leaves empty, apart from everything that was checked. */\n .listing { position: absolute; top: 12px; right: 14px; padding: 1px 8px; border: 1px solid #e0e6df; border-radius: 999px; color: #6b786f; font-size: 11px; font-weight: 550; }\n .listing.preferred { border-color: #245f43; color: #245f43; }\n /* Quiet, as its pill is: the surface and the name step back, and the controls do not. */\n .account.retired { background: #f6f8f5; }\n .account.retired > a, .account.retired > strong { color: #6b786f; }\n .account.retired .mark { opacity: .45; }\n .group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }\n .additional { margin-top: 2px; padding-left: 12px; }\n .joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }\n dl { margin: 16px 0 0; padding-top: 12px; border-top: 1px solid #e5e9e3; display: grid; grid-template-columns: auto 1fr; gap: 5px 16px; font-size: 11px; }\n dt { color: #6b786f; }\n dd { margin: 0; text-align: right; overflow-wrap: anywhere; }\n .explanation { margin-top: 16px; }\n .action { width: auto; height: auto; padding: 9px 14px; font: 600 13px/1.3 system-ui, sans-serif; color: #23312b; text-align: left; }\n .action.primary { border-color: #245f43; background: #245f43; color: #fff; }\n .action.primary:hover { background: #1d4f37; }\n .action:disabled { opacity: .6; cursor: progress; }\n .action.danger:hover { border-color: #b3261e; background: #fdecea; color: #b3261e; }\n .row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }\n .account .row { align-items: center; justify-content: start; flex-wrap: wrap; margin-top: 12px; }\n .account .action { padding: 5px 10px; font-size: 12px; }\n footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin: 20px 0 -8px; color: #9aa9a0; font-size: 11px; }\n footer a { display: flex; color: inherit; }\n .logo { display: block; height: 13px; }\n \n @media (prefers-reduced-motion: no-preference) {\n .mark.current path:last-child { animation: verily-draw .8s cubic-bezier(.65, 0, .35, 1) .1s backwards; }\n }\n @keyframes verily-draw { from { stroke-dasharray: 0 176; } to { stroke-dasharray: 108 176; } }\n\n";
3
5
  export declare function node<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string): HTMLElementTagNameMap[K];
4
6
  /**
5
7
  * Points an anchor out of the dialog. Every link here opens in a new tab: the dialog is
@@ -28,6 +30,11 @@ export type Account = Evidence & {
28
30
  */
29
31
  sources?: Evidence[];
30
32
  };
33
+ /**
34
+ * How a holder may have an account listed: the one to lead with, an ordinary one, one they
35
+ * no longer use, or one that is finished and kept only as history.
36
+ */
37
+ export type Listing = 'preferred' | 'current' | 'unused' | 'retired';
31
38
  /**
32
39
  * What the holder of the accounts shown may do from the dialog, where the page that drew
33
40
  * the badge said its reader is that holder.
@@ -35,8 +42,19 @@ export type Account = Evidence & {
35
42
  export interface Manage {
36
43
  /** Opens what connects an account, in this dialog's place. Given a provider, starts on it. */
37
44
  connect(provider?: string): void;
45
+ /**
46
+ * Opens what renews the record named, in this dialog's place: the same account shown
47
+ * again, which extends that record under its id. `method` is the one method that may.
48
+ * Absent where the page gave no way to, and Renew then connects on the provider.
49
+ */
50
+ renew?(id: string, provider: string, method?: string): void;
38
51
  /** Removes the links named. Rejects if any could not be removed. */
39
52
  remove(ids: string[]): Promise<void>;
53
+ /**
54
+ * Lists the account one link names as asked, every record of it at once. Rejects if it
55
+ * could not be. Absent where the page gave no way to, and the card then offers none.
56
+ */
57
+ mark?(id: string, as: Listing): Promise<void>;
40
58
  }
41
59
  /**
42
60
  * Fetch fresh, permitted evidence; native dialog supplies focus containment and Escape