@bkazemi/verily 0.6.0 → 0.7.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 +23 -6
- package/dist/browser/connect-dialog.js +3 -4
- package/dist/browser/evidence-dialog.d.ts +7 -2
- package/dist/browser/evidence-dialog.js +62 -26
- package/dist/browser/index.js +3 -0
- package/dist/core/index.d.ts +78 -2
- package/dist/core/index.js +26 -2
- package/dist/server/discord.js +3 -0
- package/dist/server/domain.js +37 -10
- package/dist/server/github-gist.js +16 -4
- package/dist/server/github.js +3 -0
- package/dist/server/index.js +31 -10
- package/dist/server/link.js +9 -4
- package/dist/server/service.d.ts +44 -1
- package/dist/server/service.js +225 -19
- package/dist/server/style.d.ts +1 -1
- package/dist/server/style.js +10 -1
- package/dist/server/youtube.js +5 -0
- package/dist/verily.js +63 -24
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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.
|
|
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.7.0/dist/verily.js`. The script defines a global `Verily`.
|
|
149
149
|
|
|
150
150
|
Then place the badge wherever the account appears:
|
|
151
151
|
|
|
@@ -231,6 +231,8 @@ import {
|
|
|
231
231
|
|
|
232
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`.
|
|
233
233
|
|
|
234
|
+
**How old an account is.** A link shows who controls an account, not how long it has existed. Signing in with GitHub, Discord or YouTube also reads when the account was made: GitHub's `created_at`, the time inside a Discord id, and a YouTube channel's `publishedAt`. It is kept on the record as `external.createdAt`, in milliseconds, shown as "Joined" beside the account's name on evidence and in the badge's dialog, and covered by a signed record. The other methods read an address or a proof and 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()`.
|
|
235
|
+
|
|
234
236
|
**Several at once.** List more than one, and `/verify` offers each method as its own button:
|
|
235
237
|
|
|
236
238
|
```ts
|
|
@@ -301,7 +303,7 @@ A static site only needs the script and the badge markup; the backend runs somew
|
|
|
301
303
|
4. Add the script and the badge to your site:
|
|
302
304
|
|
|
303
305
|
```html
|
|
304
|
-
<script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.
|
|
306
|
+
<script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.7.0/dist/verily.js" defer></script>
|
|
305
307
|
<verily-badge
|
|
306
308
|
backend-url="https://your-backend.example"
|
|
307
309
|
connection-id="CONNECTION_ID"
|
|
@@ -393,7 +395,7 @@ To show them, hand the records to the badge. It draws them as given and fetches
|
|
|
393
395
|
|
|
394
396
|
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
397
|
|
|
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.
|
|
398
|
+
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. Every call reads the instance, so a removed or expired link stops showing on the next page load. Passing `cacheMs` to the client reuses an answer for that long, and a connection revoked in that time still shows as verified until it runs out, so leave it unset or keep it to a few seconds. `{ fresh: true }` makes one call read anyway.
|
|
397
399
|
|
|
398
400
|
If you would rather your user ids never left your site, give the client a secret:
|
|
399
401
|
|
|
@@ -445,7 +447,7 @@ With a key set:
|
|
|
445
447
|
- **`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
448
|
- **`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`.
|
|
447
449
|
- **`<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
|
|
450
|
+
- 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 with a `signed` pill after its verifier, and links to that page. A record whose signature fails is shown as unconfirmed, with `bad signature` in the pill's place.
|
|
449
451
|
- The approval step tells the holder that a saved signed record outlives removal.
|
|
450
452
|
|
|
451
453
|
To check a file yourself:
|
|
@@ -509,6 +511,8 @@ The Cloudflare Worker sets `dns: true`.
|
|
|
509
511
|
|
|
510
512
|
## Operations
|
|
511
513
|
|
|
514
|
+
DNS setup is saved for seven days from its first attempt. If propagation takes longer than a flow’s ten minutes, sign in and choose DNS again to restore the same TXT value and the last domain entered (or restart the same renewal). Each return creates a fresh flow and reads the proof again; the saved setup grants no verification on its own. Only the most recent domain entered is remembered for that operation. Keep the TXT record after verification for ongoing checks. A proof covers the exact domain entered; verify parent domains and subdomains separately.
|
|
515
|
+
|
|
512
516
|
Verification lasts 30 days, flows 10 minutes, and sharing links 7 days. Change them with `validityMs`, `flowTtlMs` and `shareTtlMs`. Evidence is never cached.
|
|
513
517
|
|
|
514
518
|
Run the upkeep on a schedule, for example hourly:
|
|
@@ -527,8 +531,21 @@ setInterval(
|
|
|
527
531
|
);
|
|
528
532
|
```
|
|
529
533
|
|
|
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`)
|
|
534
|
+
- **`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.
|
|
535
|
+
- **`recheck()`** re-reads gists, link-backs, DNS records and files that are due, up to 5 per provider per run (its `budget` argument). Each proof is re-read every 24 hours (`recheckMs`). A read that finds the proof gone, such as a deleted gist, a page without the link or a DNS answer without the record, makes the connection unconfirmed at once. A read that fails, such as a timeout or a server error, changes nothing, and the proof stays current for 7 days after its last successful read (`freshnessMs`). Either way the proof 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`; any other error is a failed read. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
|
|
536
|
+
|
|
537
|
+
- **`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:
|
|
538
|
+
|
|
539
|
+
```ts
|
|
540
|
+
createVerily({
|
|
541
|
+
// ...
|
|
542
|
+
onChange: async ({ evidence, local, revision }) => {
|
|
543
|
+
await db.saveConnectionIfNewer(local.id, evidence, revision);
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
`evidence` is the record as it stands now, `local` is your subject with its private id, `previous` is the status, visibility, expiry and mark last confirmed as delivered, and `revision` counts the deliveries of that record. Keep the `revision` you last applied for each record and ignore a change that is not higher: a call that timed out is not cancelled and can finish after a newer one. A change made by a request is delivered before that request returns, and the call is awaited, so keep it quick. A record that expires or goes unconfirmed changes with time alone, so those are delivered by `recheck()`, or by `service.announce()` if you do not recheck. Delivery is at least once: a call that throws is made again on the next run, after the records that have not failed, and turning the option on reports every existing record once, 50 per run. A record removed by `prune()` is not reported.
|
|
532
549
|
|
|
533
550
|
**If you never call `recheck()`, set `freshnessMs: Infinity`.** Otherwise connections proved by gist, link-back or PGP lapse after a week.
|
|
534
551
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { accountCard, backMark, closeMark, linkMark, node, outward, stampLink, styles, } from './evidence-dialog.js';
|
|
1
|
+
import { accountCard, backMark, closeMark, linkMark, node, namedCard, outward, stampLink, styles, } from './evidence-dialog.js';
|
|
3
2
|
import { providerMark } from './provider-mark.js';
|
|
4
3
|
import { version } from '../version.js';
|
|
5
4
|
const connectStyles = `
|
|
@@ -435,9 +434,9 @@ renew) {
|
|
|
435
434
|
}
|
|
436
435
|
function approve(flow) {
|
|
437
436
|
const logo = providerMark(flow.provider.id, flow.provider.method);
|
|
438
|
-
const external =
|
|
437
|
+
const external = namedCard(logo
|
|
439
438
|
? [logo, document.createTextNode(flow.provider.name)]
|
|
440
|
-
: [document.createTextNode(flow.provider.name)],
|
|
439
|
+
: [document.createTextNode(flow.provider.name)], flow.external);
|
|
441
440
|
const parts = [subjectCard(flow.local), linkMark(), external];
|
|
442
441
|
const choice = node('fieldset');
|
|
443
442
|
const visibilityText = {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { type Evidence } from '../core/index.js';
|
|
1
|
+
import { type Evidence, type ExternalAccount } from '../core/index.js';
|
|
2
2
|
/** The logotype as a link to the project, as both dialogs carry it in their footer. */
|
|
3
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 .icon { display: grid; place-items: center; padding: 0; }\n .icon svg { width: 16px; height: 16px; }\n .title { display: flex; align-items: center; gap: 6px; min-width: 0; }\n .all { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #52645a; }\n .all:hover { background: #f2f5f1; }\n .all svg { width: 16px; height: 16px; }\n a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }\n a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }\n /* An element given its own display would otherwise go on showing while hidden. */\n [hidden] { display: none !important; }\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 .
|
|
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 .icon { display: grid; place-items: center; padding: 0; }\n .icon svg { width: 16px; height: 16px; }\n .title { display: flex; align-items: center; gap: 6px; min-width: 0; }\n .all { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #52645a; }\n .all:hover { background: #f2f5f1; }\n .all svg { width: 16px; height: 16px; }\n a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }\n a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }\n /* An element given its own display would otherwise go on showing while hidden. */\n [hidden] { display: none !important; }\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 /* The \"signed\" pill after the verifier's name, one size smaller than the line it sits on. */\n .summary .signed { display: inline-block; margin-left: 5px; padding: 0 6px; border: 1px solid #cfdcd3; border-radius: 999px; font-size: 10px; font-weight: 550; line-height: 1.3; text-decoration: none; vertical-align: 1px; }\n .summary a.signed:hover { background: #f2f5f1; }\n .summary .signed.bad { border-color: #b3261e; color: #b3261e; font-weight: 650; cursor: help; }\n .caution { display: inline-block; margin-left: 3px; color: #a15c00; vertical-align: -1.5px; }\n .caution svg { display: block; width: 10px; height: 10px; }\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 /* A fingerprint in fours: it breaks between groups, and copies as one unbroken word. */\n .fingerprint { font-family: ui-monospace, monospace; text-wrap: balance; }\n .fingerprint span { display: inline-block; margin-right: .5ch; }\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, .account.retired > .named > a, .account.retired > .named > strong { color: #6b786f; }\n /* The name, then a dot and the account's creation date. The date wraps under a long name. */\n /* Clipped 4px left of the text, which hides the dot when the date wraps to a new line. */\n .named { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 14px; margin-left: -4px; padding-left: 4px; overflow-x: clip; }\n .since { position: relative; white-space: nowrap; }\n /* The dot sits in the gap before the date. */\n .since::before { content: \"\u00B7\"; position: absolute; left: -7px; transform: translateX(-50%); }\n .account.retired .mark { opacity: .45; }\n .group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }\n summary.group { display: flex; align-items: center; gap: 4px; width: fit-content; border-radius: 4px; list-style: none; cursor: pointer; }\n summary::-webkit-details-marker { display: none; }\n .fold { display: flex; align-items: center; gap: 4px; width: auto; height: auto; padding: 2px 8px 2px 4px; margin-left: -4px; border: 0; background: none; font: 550 12px/1.6 system-ui, sans-serif; }\n .fold svg, summary svg { width: 12px; height: 12px; }\n .fold[aria-expanded=\"true\"] svg, details[open] > summary svg { transform: rotate(90deg); }\n @media (prefers-reduced-motion: no-preference) { .fold svg, summary svg { transition: transform .15s; } }\n /* The fold and what the holder may do share a line, until a question needs the whole of it. */\n .bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 10px; }\n .account .bar .row { margin: 0 0 0 auto; }\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 .account .action.dots { display: grid; place-items: center; align-self: stretch; width: 30px; padding: 0; }\n .dots svg { width: 16px; height: 16px; }\n /* Placed under its button each time it opens, above the dialog and clipped by none of it. */\n .menu { position: fixed; inset: auto; margin: 0; min-width: 170px; padding: 6px; border: 1px solid #dce2de; border-radius: 10px; background: #fff; color: #23312b; box-shadow: 0 12px 40px #10201930; }\n .menu:popover-open { display: grid; }\n .account .menu .action { border: 0; border-radius: 6px; padding: 6px 10px; font-weight: 550; }\n .menu .danger { color: #b3261e; }\n .menu hr { width: 100%; margin: 4px 0; border: 0; border-top: 1px solid #e5e9e3; }\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";
|
|
5
5
|
export declare function node<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string): HTMLElementTagNameMap[K];
|
|
6
6
|
/**
|
|
7
7
|
* Points an anchor out of the dialog. Every link here opens in a new tab: the dialog is
|
|
@@ -26,6 +26,11 @@ export declare function linkMark(): SVGSVGElement;
|
|
|
26
26
|
* expires on its own terms, and a second provider on the same subject would differ.
|
|
27
27
|
*/
|
|
28
28
|
export declare function accountCard(heading: Node[], name: string, reference: string | string[] | undefined, url?: string, ...extra: HTMLElement[]): HTMLElement;
|
|
29
|
+
/**
|
|
30
|
+
* The card for an external account: its name, link and id, with the "Joined <date>" text
|
|
31
|
+
* on the name's line when the creation date is known.
|
|
32
|
+
*/
|
|
33
|
+
export declare function namedCard(heading: Node[], external: ExternalAccount, ...extra: HTMLElement[]): HTMLElement;
|
|
29
34
|
/**
|
|
30
35
|
* A record as a card shows it. Where the card speaks for several records of one account,
|
|
31
36
|
* `links` names every one not yet revoked, whether or not it is shown, so removing the
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { attestationLabel, externalId, externalIdGroups, externalLink, externalName, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
|
|
1
|
+
import { attestationLabel, externalId, externalIdGroups, externalLink, externalName, externalSince, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
|
|
2
2
|
import { markStyles, verificationMark } from './mark.js';
|
|
3
3
|
import { providerMark } from './provider-mark.js';
|
|
4
4
|
import { onSignatureResult, signature, standing } from './signed.js';
|
|
@@ -40,9 +40,12 @@ export const styles = `
|
|
|
40
40
|
.spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }
|
|
41
41
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
42
42
|
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
.
|
|
43
|
+
/* The "signed" pill after the verifier's name, one size smaller than the line it sits on. */
|
|
44
|
+
.summary .signed { display: inline-block; margin-left: 5px; padding: 0 6px; border: 1px solid #cfdcd3; border-radius: 999px; font-size: 10px; font-weight: 550; line-height: 1.3; text-decoration: none; vertical-align: 1px; }
|
|
45
|
+
.summary a.signed:hover { background: #f2f5f1; }
|
|
46
|
+
.summary .signed.bad { border-color: #b3261e; color: #b3261e; font-weight: 650; cursor: help; }
|
|
47
|
+
.caution { display: inline-block; margin-left: 3px; color: #a15c00; vertical-align: -1.5px; }
|
|
48
|
+
.caution svg { display: block; width: 10px; height: 10px; }
|
|
46
49
|
.account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }
|
|
47
50
|
.account h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 3px; color: #6b786f; font-size: 11px; font-weight: 550; }
|
|
48
51
|
.account a, .account strong { font-weight: 650; font-size: 14px; }
|
|
@@ -58,7 +61,13 @@ export const styles = `
|
|
|
58
61
|
.listing.preferred { border-color: #245f43; color: #245f43; }
|
|
59
62
|
/* Quiet, as its pill is: the surface and the name step back, and the controls do not. */
|
|
60
63
|
.account.retired { background: #f6f8f5; }
|
|
61
|
-
.account.retired > a, .account.retired > strong { color: #6b786f; }
|
|
64
|
+
.account.retired > a, .account.retired > strong, .account.retired > .named > a, .account.retired > .named > strong { color: #6b786f; }
|
|
65
|
+
/* The name, then a dot and the account's creation date. The date wraps under a long name. */
|
|
66
|
+
/* Clipped 4px left of the text, which hides the dot when the date wraps to a new line. */
|
|
67
|
+
.named { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 14px; margin-left: -4px; padding-left: 4px; overflow-x: clip; }
|
|
68
|
+
.since { position: relative; white-space: nowrap; }
|
|
69
|
+
/* The dot sits in the gap before the date. */
|
|
70
|
+
.since::before { content: "·"; position: absolute; left: -7px; transform: translateX(-50%); }
|
|
62
71
|
.account.retired .mark { opacity: .45; }
|
|
63
72
|
.group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }
|
|
64
73
|
summary.group { display: flex; align-items: center; gap: 4px; width: fit-content; border-radius: 4px; list-style: none; cursor: pointer; }
|
|
@@ -232,13 +241,34 @@ function moment(time) {
|
|
|
232
241
|
* expires on its own terms, and a second provider on the same subject would differ.
|
|
233
242
|
*/
|
|
234
243
|
export function accountCard(heading, name, reference, url, ...extra) {
|
|
244
|
+
return side(heading, name, reference, url, undefined, extra);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The card for an external account: its name, link and id, with the "Joined <date>" text
|
|
248
|
+
* on the name's line when the creation date is known.
|
|
249
|
+
*/
|
|
250
|
+
export function namedCard(heading, external, ...extra) {
|
|
251
|
+
return side(heading, externalName(external),
|
|
252
|
+
// A mailbox's address or a domain is its name already, so there is no second identifier.
|
|
253
|
+
externalIdGroups(external) ?? externalId(external), externalLink(external),
|
|
254
|
+
// In UTC, so the day matches the one the verifier's own pages print.
|
|
255
|
+
externalSince(external, (time) => new Date(time).toLocaleDateString(undefined, { dateStyle: 'medium', timeZone: 'UTC' })), extra);
|
|
256
|
+
}
|
|
257
|
+
function side(heading, name, reference, url, since, extra) {
|
|
235
258
|
const card = node('section', '', 'account');
|
|
236
259
|
const title = node('h3');
|
|
237
260
|
const value = node(url ? 'a' : 'strong', name);
|
|
238
261
|
title.append(...heading);
|
|
239
262
|
if (value instanceof HTMLAnchorElement)
|
|
240
263
|
outward(value, url);
|
|
241
|
-
|
|
264
|
+
if (since) {
|
|
265
|
+
// The name and the date share a line, and the date wraps under a long name.
|
|
266
|
+
const named = node('div', '', 'named');
|
|
267
|
+
named.append(value, node('span', since, 'muted since'));
|
|
268
|
+
card.append(title, named);
|
|
269
|
+
}
|
|
270
|
+
else
|
|
271
|
+
card.append(title, value);
|
|
242
272
|
if (typeof reference === 'string')
|
|
243
273
|
card.append(node('div', reference, 'muted reference'));
|
|
244
274
|
else if (reference) {
|
|
@@ -420,38 +450,45 @@ function signatures(evidence) {
|
|
|
420
450
|
: signature(source)));
|
|
421
451
|
}
|
|
422
452
|
/**
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
453
|
+
* The "signed" pill shown after the verifier's name. It links to the signature section of
|
|
454
|
+
* the verifier's evidence page, where the signed record can be downloaded.
|
|
455
|
+
*
|
|
456
|
+
* The signature is checked here against the verifier's keys:
|
|
457
|
+
* - valid: the pill says "signed"
|
|
458
|
+
* - not yet checked: the pill carries a warning sign
|
|
459
|
+
* - invalid: a warning replaces the pill
|
|
460
|
+
*
|
|
461
|
+
* `warned` is called every time the invalid warning is drawn, so a folded card can open.
|
|
429
462
|
*/
|
|
430
463
|
function signedMark(evidence, later, warned) {
|
|
431
464
|
const mark = node('span');
|
|
432
|
-
const link = outward(node('a', 'signed'), `${evidence.evidenceUrl}#signed`);
|
|
465
|
+
const link = outward(node('a', 'signed', 'signed'), `${evidence.evidenceUrl}#signed`);
|
|
433
466
|
const see = 'See the signature (opens in a new tab)';
|
|
434
467
|
const settle = (results) => {
|
|
435
468
|
const first = results[0];
|
|
436
469
|
if (results.some((result) => result.state === 'invalid')) {
|
|
437
|
-
|
|
470
|
+
// Not a link: the page it would open is the one this record failed to match.
|
|
471
|
+
const warning = node('span', 'bad signature', 'signed bad');
|
|
438
472
|
warning.setAttribute('role', 'alert');
|
|
439
473
|
// What it means, for a reader who has never met a signature, kept off the card.
|
|
440
474
|
warning.title = `This record does not match the signature ${evidence.verifierName} put on it, so it may have been altered. Do not rely on it.`;
|
|
441
|
-
mark.replaceChildren(
|
|
475
|
+
mark.replaceChildren(warning);
|
|
442
476
|
warned();
|
|
443
477
|
return;
|
|
444
478
|
}
|
|
445
479
|
if (first?.state === 'valid' && results.every((result) => result.state === 'valid')) {
|
|
446
|
-
|
|
447
|
-
//
|
|
480
|
+
// No tick: a signature that checks out is what a reader expects, and the pill is
|
|
481
|
+
// marked only when it did not. Setting the word again clears a warning sign left in it.
|
|
482
|
+
link.textContent = 'signed';
|
|
483
|
+
// Names where the keys came from, which is what the check answers for.
|
|
448
484
|
link.title = `Signature checked in this browser against the keys ${new URL(evidence.evidenceUrl).host} publishes (key ${first.keyId}). ${see}`;
|
|
449
|
-
mark.replaceChildren(
|
|
485
|
+
mark.replaceChildren(link);
|
|
450
486
|
return;
|
|
451
487
|
}
|
|
452
|
-
// No verdict was reached. The record is signed and says so, with a warning
|
|
453
|
-
//
|
|
454
|
-
// or could not read what it needed is made again when
|
|
488
|
+
// No verdict was reached. The record is signed and says so, with a warning sign in the
|
|
489
|
+
// pill, which is all that tells it from one that checked out, and why is said on hover.
|
|
490
|
+
// A check that only ran out of time or could not read what it needed is made again when
|
|
491
|
+
// the dialog next reads.
|
|
455
492
|
const why = results.find((result) => result.state === 'unchecked');
|
|
456
493
|
const reason = why?.state === 'unchecked' && why.why === 'timeout'
|
|
457
494
|
? 'Signature check timed out, retrying.'
|
|
@@ -462,8 +499,9 @@ function signedMark(evidence, later, warned) {
|
|
|
462
499
|
caution.setAttribute('aria-label', reason);
|
|
463
500
|
caution.append(cautionMark());
|
|
464
501
|
link.textContent = 'signed';
|
|
502
|
+
link.append(caution);
|
|
465
503
|
link.title = `${reason} ${see}`;
|
|
466
|
-
mark.replaceChildren(
|
|
504
|
+
mark.replaceChildren(link);
|
|
467
505
|
if (why)
|
|
468
506
|
later(() => void signatures(evidence).then(settle));
|
|
469
507
|
};
|
|
@@ -585,12 +623,10 @@ function externalCard(evidence, manage, later = () => { }, fold, warned = () =>
|
|
|
585
623
|
if (fold)
|
|
586
624
|
bar.append(foldToggle([...methods, dates], fold), ...row);
|
|
587
625
|
const logo = providerMark(evidence.provider, evidence.attestations.external[0].method);
|
|
588
|
-
const card =
|
|
626
|
+
const card = namedCard(
|
|
589
627
|
// The mark and the name, or just the name. A mark that falls back to writing the name
|
|
590
628
|
// would print it twice here, since this heading writes it either way.
|
|
591
|
-
logo ? [logo, document.createTextNode(provider)] : [document.createTextNode(provider)],
|
|
592
|
-
// A mailbox's address or a domain is its name already, so there is no second identifier.
|
|
593
|
-
externalIdGroups(evidence.external) ?? externalId(evidence.external), externalLink(evidence.external),
|
|
629
|
+
logo ? [logo, document.createTextNode(provider)] : [document.createTextNode(provider)], evidence.external,
|
|
594
630
|
// Status first, then how it was shown, then when. The proof explains the state
|
|
595
631
|
// above it, so it cannot sit before that state has been given.
|
|
596
632
|
summary, ...(fold ? [] : methods),
|
package/dist/browser/index.js
CHANGED
|
@@ -649,6 +649,9 @@ function validEvidence(value) {
|
|
|
649
649
|
['label', 'reference'].every((k) => typeof value.local[k] === 'string') &&
|
|
650
650
|
['id', 'handle', 'profileUrl'].every((k) => typeof value.external[k] === 'string') &&
|
|
651
651
|
(value.local.profileUrl === undefined || typeof value.local.profileUrl === 'string') &&
|
|
652
|
+
(value.external.createdAt === undefined ||
|
|
653
|
+
(typeof value.external.createdAt === 'number' &&
|
|
654
|
+
Number.isFinite(value.external.createdAt))) &&
|
|
652
655
|
// A signed record lives beside the record it was made from and nowhere else, since the
|
|
653
656
|
// keys it is checked against are read from where the record lives.
|
|
654
657
|
(value.signedUrl === undefined || value.signedUrl === `${value.evidenceUrl}?format=signed`) &&
|
package/dist/core/index.d.ts
CHANGED
|
@@ -45,6 +45,12 @@ export interface ExternalAccount {
|
|
|
45
45
|
handle: string;
|
|
46
46
|
/** Where a reader goes to see the subject. For a mailbox, its `mailto:` address. */
|
|
47
47
|
profileUrl: string;
|
|
48
|
+
/**
|
|
49
|
+
* When the account was created, in milliseconds, as reported by the provider. It lets a
|
|
50
|
+
* reader tell an old account from one opened just to be linked. Only sign-in providers
|
|
51
|
+
* set it. Once a record has it, later proofs by other methods keep it.
|
|
52
|
+
*/
|
|
53
|
+
createdAt?: number;
|
|
48
54
|
}
|
|
49
55
|
/**
|
|
50
56
|
* How control of one side was shown. A provider is who an account belongs to; a method is
|
|
@@ -96,6 +102,13 @@ export interface Attestation {
|
|
|
96
102
|
* is reconfirmed like any other.
|
|
97
103
|
*/
|
|
98
104
|
confirmedAt: number;
|
|
105
|
+
/** When `recheck()` last tried to read the proof, whether or not the read worked. */
|
|
106
|
+
readAt?: number;
|
|
107
|
+
/**
|
|
108
|
+
* When a recheck first read the proof and found it missing. The proof does not count
|
|
109
|
+
* while this is set. Cleared by the next read that finds it.
|
|
110
|
+
*/
|
|
111
|
+
absentAt?: number;
|
|
99
112
|
}
|
|
100
113
|
/** Each side of a link is attested separately, by different parties under different methods. */
|
|
101
114
|
export interface Attestations {
|
|
@@ -137,9 +150,44 @@ export interface Connection {
|
|
|
137
150
|
* last proof: never renewed by another method, never reread, never verified.
|
|
138
151
|
*/
|
|
139
152
|
retiredAt?: number;
|
|
153
|
+
/** The state last delivered to `onChange`. Absent until the first delivery. */
|
|
154
|
+
reported?: Reported;
|
|
155
|
+
/**
|
|
156
|
+
* When the last delivery to `onChange` started, while it has not been confirmed. Set when
|
|
157
|
+
* a delivery starts and cleared when it succeeds, so a record with it set is delivered
|
|
158
|
+
* again even if its state matches `reported`.
|
|
159
|
+
*/
|
|
160
|
+
reportTriedAt?: number;
|
|
161
|
+
/** The revision given to the last delivery. Each delivery of the record adds one. */
|
|
162
|
+
reportRevision?: number;
|
|
163
|
+
}
|
|
164
|
+
/** The parts of a record that `onChange` reports a change in. */
|
|
165
|
+
export interface Reported {
|
|
166
|
+
status: Status;
|
|
167
|
+
visibility: Visibility;
|
|
168
|
+
expiresAt: number;
|
|
169
|
+
mark?: Mark;
|
|
170
|
+
}
|
|
171
|
+
/** One record whose status, visibility, expiry or mark changed, as `onChange` receives it. */
|
|
172
|
+
export interface Change {
|
|
173
|
+
/** The record as it stands now. */
|
|
174
|
+
evidence: Evidence;
|
|
175
|
+
/** The subject the record belongs to, with its private id. */
|
|
176
|
+
local: LocalAccount;
|
|
177
|
+
/** What the last confirmed delivery reported. Absent until one is confirmed. */
|
|
178
|
+
previous?: Reported;
|
|
179
|
+
/**
|
|
180
|
+
* Counts the deliveries of this record, starting at 1. A higher revision always carries
|
|
181
|
+
* a state read later, across processes too. Keep the last one applied for each record
|
|
182
|
+
* and ignore a change whose revision is not higher: a slow or repeated delivery can
|
|
183
|
+
* arrive after a newer one.
|
|
184
|
+
*/
|
|
185
|
+
revision: number;
|
|
140
186
|
}
|
|
141
187
|
export interface Flow {
|
|
142
188
|
id: string;
|
|
189
|
+
/** Saved DNS setup; grants no authority to finish a flow. */
|
|
190
|
+
dnsDraftId?: string;
|
|
143
191
|
stateHash: string;
|
|
144
192
|
bindingHash: string;
|
|
145
193
|
verifier?: string;
|
|
@@ -155,7 +203,8 @@ export interface Flow {
|
|
|
155
203
|
reason?: string;
|
|
156
204
|
/**
|
|
157
205
|
* The string an artifact must contain. Public by design: the holder publishes it. It is
|
|
158
|
-
* unguessable and per-flow
|
|
206
|
+
* normally unguessable and per-flow. DNS setup can retain the same value across
|
|
207
|
+
* fresh flows for the same subject and operation while propagation is pending.
|
|
159
208
|
*/
|
|
160
209
|
expect?: string;
|
|
161
210
|
/** Whether `expect` was minted for a method that had nothing of the subject's to state. */
|
|
@@ -269,7 +318,16 @@ export interface SiteRead {
|
|
|
269
318
|
/** Whether that read got an answer. One that did not is asked again sooner. */
|
|
270
319
|
answered: boolean;
|
|
271
320
|
}
|
|
321
|
+
/** DNS setup retained across short-lived, authenticated flows for seven days. */
|
|
322
|
+
export interface DnsDraft {
|
|
323
|
+
id: string;
|
|
324
|
+
flowId: string;
|
|
325
|
+
expect: string;
|
|
326
|
+
artifact?: string;
|
|
327
|
+
expiresAt: number;
|
|
328
|
+
}
|
|
272
329
|
export interface Records {
|
|
330
|
+
dnsDrafts: DnsDraft;
|
|
273
331
|
connections: Connection;
|
|
274
332
|
flows: Flow;
|
|
275
333
|
shares: Share;
|
|
@@ -349,8 +407,9 @@ export interface ArtifactProvider {
|
|
|
349
407
|
* Told to the holder verbatim. Must name what they are publishing and where. Returned in
|
|
350
408
|
* pieces so a renderer can set a command as one: a command is copied character for
|
|
351
409
|
* character, and prose that reflows is prose a holder cannot safely copy.
|
|
410
|
+
* `artifact` is the last submitted or suggested location, when one is known.
|
|
352
411
|
*/
|
|
353
|
-
instructions(expect: string): Instruction[];
|
|
412
|
+
instructions(expect: string, artifact?: string): Instruction[];
|
|
354
413
|
/**
|
|
355
414
|
* What the holder is asked to hand back, where the method takes something a holder knows
|
|
356
415
|
* by a better name than "an address". `input` says how to ask for it: `text` where it
|
|
@@ -451,6 +510,14 @@ export interface CodeProvider {
|
|
|
451
510
|
*/
|
|
452
511
|
export declare class Refused extends Error {
|
|
453
512
|
}
|
|
513
|
+
/**
|
|
514
|
+
* A proof that was read and is not there: the page, file, gist or DNS answer came back and
|
|
515
|
+
* the expected value was missing. Thrown by `verify` or `prove`. On a recheck it makes the
|
|
516
|
+
* proof unconfirmed at once. Any other error means the read itself failed, and the proof
|
|
517
|
+
* keeps counting until `freshnessMs` runs out.
|
|
518
|
+
*/
|
|
519
|
+
export declare class Absent extends Refused {
|
|
520
|
+
}
|
|
454
521
|
/**
|
|
455
522
|
* A piece of what the holder is told: a paragraph, a paragraph with links in it, or
|
|
456
523
|
* something they run or publish as is.
|
|
@@ -525,6 +592,10 @@ export * from './routes.js';
|
|
|
525
592
|
* telling anyone, so a confirmation is a heartbeat rather than a permanent fact.
|
|
526
593
|
*/
|
|
527
594
|
export declare const freshnessMs: number;
|
|
595
|
+
/** Whether a record has reached its deadline, including the exact expiry instant. */
|
|
596
|
+
export declare function expired(record: {
|
|
597
|
+
expiresAt: number;
|
|
598
|
+
}, now: number): boolean;
|
|
528
599
|
/**
|
|
529
600
|
* `freshness` bounds how stale a published proof may be before it stops counting. A
|
|
530
601
|
* backend that never rechecks must leave it unbounded, since an unread proof going stale
|
|
@@ -612,6 +683,11 @@ export declare function externalId(external: ExternalAccount): string | undefine
|
|
|
612
683
|
* between groups. Nothing for any other subject, whose id is written as it stands.
|
|
613
684
|
*/
|
|
614
685
|
export declare function externalIdGroups(external: ExternalAccount): string[] | undefined;
|
|
686
|
+
/**
|
|
687
|
+
* The text for an account's creation date, such as "Joined 2014-03-02", or undefined if
|
|
688
|
+
* the date is unknown. `day` formats the date, to the day.
|
|
689
|
+
*/
|
|
690
|
+
export declare function externalSince(external: ExternalAccount, day: (time: number) => string): string | undefined;
|
|
615
691
|
/**
|
|
616
692
|
* Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
|
|
617
693
|
* address is `mailto:`, which opens a message to it and shows a reader nothing.
|
package/dist/core/index.js
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export class Refused extends Error {
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* A proof that was read and is not there: the page, file, gist or DNS answer came back and
|
|
9
|
+
* the expected value was missing. Thrown by `verify` or `prove`. On a recheck it makes the
|
|
10
|
+
* proof unconfirmed at once. Any other error means the read itself failed, and the proof
|
|
11
|
+
* keeps counting until `freshnessMs` runs out.
|
|
12
|
+
*/
|
|
13
|
+
export class Absent extends Refused {
|
|
14
|
+
}
|
|
7
15
|
/** Only an artifact provider is holder-paced, and only it needs a url handed back. */
|
|
8
16
|
export function isArtifactProvider(provider) {
|
|
9
17
|
return 'verify' in provider;
|
|
@@ -28,6 +36,10 @@ export * from './routes.js';
|
|
|
28
36
|
* telling anyone, so a confirmation is a heartbeat rather than a permanent fact.
|
|
29
37
|
*/
|
|
30
38
|
export const freshnessMs = 7 * 86400000;
|
|
39
|
+
/** Whether a record has reached its deadline, including the exact expiry instant. */
|
|
40
|
+
export function expired(record, now) {
|
|
41
|
+
return record.expiresAt <= now;
|
|
42
|
+
}
|
|
31
43
|
/**
|
|
32
44
|
* `freshness` bounds how stale a published proof may be before it stops counting. A
|
|
33
45
|
* backend that never rechecks must leave it unbounded, since an unread proof going stale
|
|
@@ -39,7 +51,7 @@ export function status(connection, now, freshness = freshnessMs) {
|
|
|
39
51
|
// Frozen at its last proof, so neither its expiry nor its freshness is read again.
|
|
40
52
|
if (connection.retiredAt !== undefined)
|
|
41
53
|
return 'retired';
|
|
42
|
-
if (now
|
|
54
|
+
if (expired(connection, now))
|
|
43
55
|
return 'expired';
|
|
44
56
|
const main = connection.attestations?.external[0];
|
|
45
57
|
if (main && !fresh(main, now, freshness))
|
|
@@ -52,6 +64,9 @@ export function status(connection, now, freshness = freshnessMs) {
|
|
|
52
64
|
* reverses the moment it reads again.
|
|
53
65
|
*/
|
|
54
66
|
export function fresh(attestation, now, freshness = freshnessMs) {
|
|
67
|
+
// Read and found missing, which is not the same as not having been read lately.
|
|
68
|
+
if (attestation.absentAt !== undefined)
|
|
69
|
+
return false;
|
|
55
70
|
return (!attestation.artifactUrl ||
|
|
56
71
|
attestation.hosted === true ||
|
|
57
72
|
freshness === Infinity ||
|
|
@@ -75,7 +90,7 @@ export function statusLabel(evidence, now) {
|
|
|
75
90
|
// Before expiry: a retired record's approval has long run out, and that is not its state.
|
|
76
91
|
if (evidence.status === 'retired')
|
|
77
92
|
return 'Retired';
|
|
78
|
-
if (evidence.status === 'expired' || evidence
|
|
93
|
+
if (evidence.status === 'expired' || expired(evidence, now))
|
|
79
94
|
return 'Expired';
|
|
80
95
|
return evidence.status === 'unconfirmed' ? 'Unconfirmed' : 'Verified';
|
|
81
96
|
}
|
|
@@ -188,6 +203,15 @@ export function externalId(external) {
|
|
|
188
203
|
export function externalIdGroups(external) {
|
|
189
204
|
return external.kind === 'key' ? (external.id.match(/.{1,4}/g) ?? undefined) : undefined;
|
|
190
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* The text for an account's creation date, such as "Joined 2014-03-02", or undefined if
|
|
208
|
+
* the date is unknown. `day` formats the date, to the day.
|
|
209
|
+
*/
|
|
210
|
+
export function externalSince(external, day) {
|
|
211
|
+
return typeof external.createdAt === 'number' && Number.isFinite(external.createdAt)
|
|
212
|
+
? `Joined ${day(external.createdAt)}`
|
|
213
|
+
: undefined;
|
|
214
|
+
}
|
|
191
215
|
/**
|
|
192
216
|
* Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
|
|
193
217
|
* address is `mailto:`, which opens a message to it and shows a reader nothing.
|
package/dist/server/discord.js
CHANGED
|
@@ -62,6 +62,9 @@ export function discordProvider(options) {
|
|
|
62
62
|
id: user.id,
|
|
63
63
|
handle: user.username,
|
|
64
64
|
profileUrl: `https://discord.com/users/${user.id}`,
|
|
65
|
+
// A Discord id encodes its creation time: the bits above the low 22 are
|
|
66
|
+
// milliseconds since 2015-01-01 UTC.
|
|
67
|
+
createdAt: Number(BigInt(user.id) >> 22n) + 1420070400000,
|
|
65
68
|
};
|
|
66
69
|
},
|
|
67
70
|
};
|
package/dist/server/domain.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { MIMEType } from 'node:util';
|
|
2
|
-
import { Refused, } from '../core/index.js';
|
|
2
|
+
import { Absent, Refused, } from '../core/index.js';
|
|
3
3
|
import { discard, readBounded } from './body.js';
|
|
4
4
|
import { transport } from './public-fetch.js';
|
|
5
5
|
/**
|
|
@@ -47,18 +47,39 @@ export function dnsProvider(options = {}) {
|
|
|
47
47
|
const domain = looked(artifact);
|
|
48
48
|
const { values, dnssec } = await published(domain, options);
|
|
49
49
|
if (!values.includes(expect))
|
|
50
|
-
throw new
|
|
50
|
+
throw new Absent(`No TXT record at ${label}.${domain} has this value`);
|
|
51
51
|
return { account: account(domain), ...(dnssec ? { dnssec } : {}) };
|
|
52
52
|
};
|
|
53
53
|
return {
|
|
54
54
|
...shared(options),
|
|
55
55
|
method: 'dns',
|
|
56
|
-
instructions
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
instructions(expect, artifact) {
|
|
57
|
+
let domain = artifact ? named(artifact) : undefined;
|
|
58
|
+
if (!domain && artifact) {
|
|
59
|
+
try {
|
|
60
|
+
domain = looked(artifact);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* An invalid input is never a command. */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const record = domain ? `${label}.${domain}` : `${label}.YOUR-DOMAIN`;
|
|
67
|
+
return [
|
|
68
|
+
domain
|
|
69
|
+
? 'Add a TXT record with this name and value.'
|
|
70
|
+
: 'Add a TXT record with this name and value. Replace YOUR-DOMAIN with the exact domain you enter below.',
|
|
71
|
+
{ code: record },
|
|
72
|
+
'If your DNS host adds the domain automatically, enter only _verily for the root domain. For a subdomain such as www.example.com in the example.com zone, enter _verily.www.',
|
|
73
|
+
{ code: expect },
|
|
74
|
+
domain
|
|
75
|
+
? `Proves control of ${domain} only. Parent domains and subdomains must be verified separately.`
|
|
76
|
+
: 'Proves control of the exact domain you enter. Parent domains and subdomains must be verified separately.',
|
|
77
|
+
'DNS changes can take up to 72 hours to appear. Check the published record with:',
|
|
78
|
+
{ code: `dig TXT ${record} +short` },
|
|
79
|
+
...(domain ? [[{ text: 'Look up this TXT record', href: lookup(domain) }]] : []),
|
|
80
|
+
'Keep the TXT record in place after verification so Verily can check it again.',
|
|
81
|
+
];
|
|
82
|
+
},
|
|
62
83
|
resolve(artifact) {
|
|
63
84
|
const domain = named(artifact);
|
|
64
85
|
return domain ? lookup(domain) : artifact;
|
|
@@ -223,6 +244,9 @@ export function wellKnownProvider(options = {}) {
|
|
|
223
244
|
// The holder named this domain, so a redirect would take the check somewhere else.
|
|
224
245
|
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400))
|
|
225
246
|
throw new Refused('File redirects, and a redirect is not followed');
|
|
247
|
+
// The file is gone. Any other failure is a failed read.
|
|
248
|
+
if (response.status === 404 || response.status === 410)
|
|
249
|
+
throw new Absent('File unavailable');
|
|
226
250
|
if (!response.ok)
|
|
227
251
|
throw new Refused('File unavailable');
|
|
228
252
|
if (mediaType(response.headers.get('content-type')) !== 'text/plain')
|
|
@@ -232,8 +256,11 @@ export function wellKnownProvider(options = {}) {
|
|
|
232
256
|
// The last line of a file cut short may be the start of some longer address.
|
|
233
257
|
if (truncated)
|
|
234
258
|
lines.pop();
|
|
235
|
-
if (!lines.some((line) => line.trim() === expect))
|
|
236
|
-
|
|
259
|
+
if (!lines.some((line) => line.trim() === expect)) {
|
|
260
|
+
if (truncated)
|
|
261
|
+
throw new Refused('File is too large to read');
|
|
262
|
+
throw new Absent('File has no line with this value');
|
|
263
|
+
}
|
|
237
264
|
}
|
|
238
265
|
finally {
|
|
239
266
|
await discard(response);
|