@bkazemi/verily 0.1.0 → 0.2.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 +22 -14
- package/dist/browser/connect-dialog.d.ts +8 -0
- package/dist/browser/connect-dialog.js +11 -6
- package/dist/core/index.d.ts +47 -1
- package/dist/server/copy.d.ts +6 -0
- package/dist/server/copy.js +20 -0
- package/dist/server/discord.js +3 -1
- package/dist/server/github.js +4 -2
- package/dist/server/index.js +65 -19
- package/dist/server/link.js +24 -4
- package/dist/server/service.d.ts +50 -3
- package/dist/server/service.js +206 -9
- package/dist/server/youtube.js +3 -1
- package/dist/verily.js +10 -6
- 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.2.0/dist/verily.js`. The script defines a global `Verily`.
|
|
149
149
|
|
|
150
150
|
Then place the badge wherever the account appears:
|
|
151
151
|
|
|
@@ -207,18 +207,18 @@ import {
|
|
|
207
207
|
} from '@bkazemi/verily';
|
|
208
208
|
```
|
|
209
209
|
|
|
210
|
-
| Provider | The user proves it by
|
|
211
|
-
| ---------------------- |
|
|
212
|
-
| `githubProvider()` | signing in with GitHub.
|
|
213
|
-
| `discordProvider()` | signing in with Discord.
|
|
214
|
-
| `youtubeProvider()` | signing in with Google and choosing their YouTube channel.
|
|
215
|
-
| `githubGistProvider()` | publishing a public gist containing a line Verily gives them.
|
|
216
|
-
| `linkProvider()` | adding a `rel="me"` link to their profile on a page they control.
|
|
217
|
-
| `githubLinkProvider()` | putting their profile URL in the website field of their GitHub profile.
|
|
218
|
-
| `pgpProvider()` | signing a line Verily gives them with their OpenPGP key, then pasting it and the key.
|
|
219
|
-
| `emailProvider()` | entering a code Verily mails to their address.
|
|
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, then entering their GitHub username. | The local account needs a `profileUrl`. |
|
|
218
|
+
| `pgpProvider()` | signing a line Verily gives them with their OpenPGP key, then pasting it and the key. | None. |
|
|
219
|
+
| `emailProvider()` | entering a code Verily mails to their address. | A function that sends one message. |
|
|
220
220
|
|
|
221
|
-
All methods except the sign-ins and the mailed code let the user publish the proof in their own time and come back. Gists and link-backs can be taken down later, so Verily re-reads them on a schedule. A PGP signature is kept by Verily and published at `<baseUrl>/connections/<id>/proof`.
|
|
221
|
+
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
222
|
|
|
223
223
|
**Several at once.** List more than one, and `/verify` offers each method as its own button:
|
|
224
224
|
|
|
@@ -228,8 +228,16 @@ providers: [githubProvider({ clientId, clientSecret }), githubLinkProvider()],
|
|
|
228
228
|
|
|
229
229
|
Proving the same account a second way adds that proof to the existing record instead of creating another.
|
|
230
230
|
|
|
231
|
+
**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.
|
|
232
|
+
|
|
233
|
+
**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.
|
|
234
|
+
|
|
231
235
|
**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
236
|
|
|
237
|
+
**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.
|
|
238
|
+
|
|
239
|
+
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.
|
|
240
|
+
|
|
233
241
|
**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
242
|
|
|
235
243
|
**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 +264,7 @@ emailProvider({
|
|
|
256
264
|
|
|
257
265
|
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
266
|
|
|
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
|
|
267
|
+
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
268
|
|
|
261
269
|
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
270
|
|
|
@@ -280,7 +288,7 @@ A static site only needs the script and the badge markup; the backend runs somew
|
|
|
280
288
|
4. Add the script and the badge to your site:
|
|
281
289
|
|
|
282
290
|
```html
|
|
283
|
-
<script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.
|
|
291
|
+
<script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.2.0/dist/verily.js" defer></script>
|
|
284
292
|
<verily-badge
|
|
285
293
|
backend-url="https://your-backend.example/api/verily"
|
|
286
294
|
connection-id="CONNECTION_ID"
|
|
@@ -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. */
|
|
@@ -331,9 +331,14 @@ provider) {
|
|
|
331
331
|
function publish(flow) {
|
|
332
332
|
const input = flow.artifact === 'document'
|
|
333
333
|
? node('textarea')
|
|
334
|
-
: Object.assign(node('input'), {
|
|
334
|
+
: Object.assign(node('input'), {
|
|
335
|
+
type: flow.input ?? 'url',
|
|
336
|
+
autocapitalize: 'none',
|
|
337
|
+
spellcheck: false,
|
|
338
|
+
});
|
|
335
339
|
input.name = 'artifact';
|
|
336
340
|
input.required = true;
|
|
341
|
+
input.value = flow.suggested ?? '';
|
|
337
342
|
if (input instanceof HTMLTextAreaElement)
|
|
338
343
|
input.rows = 10;
|
|
339
344
|
const label = node('label', flow.field ?? 'Your proof');
|
|
@@ -352,7 +357,7 @@ provider) {
|
|
|
352
357
|
? node('p', part)
|
|
353
358
|
: Array.isArray(part)
|
|
354
359
|
? paragraph(part)
|
|
355
|
-
: codeBlock(part.code)), label, row(back, check), ...(flow.note ? [node('p', flow.note, 'muted')] : []));
|
|
360
|
+
: codeBlock(part.code)), ...(flow.refused ? [node('p', flow.refused, 'error')] : []), label, row(back, check), ...(flow.note ? [node('p', flow.note, 'muted')] : []));
|
|
356
361
|
}
|
|
357
362
|
/**
|
|
358
363
|
* A mailed code, in two steps on the one flow: the holder names an address, then enters
|
|
@@ -445,6 +450,8 @@ provider) {
|
|
|
445
450
|
}
|
|
446
451
|
parts.push(choice);
|
|
447
452
|
}
|
|
453
|
+
if (flow.standingNote)
|
|
454
|
+
parts.push(node('p', flow.standingNote));
|
|
448
455
|
if (methods)
|
|
449
456
|
parts.push(node('p', `${methods.siteName} receives the result. Verified via ${methods.verifierName}.`, 'muted'));
|
|
450
457
|
const confirm = button(flow.joined ? 'Add to connection' : 'Confirm connection', true);
|
|
@@ -479,12 +486,10 @@ provider) {
|
|
|
479
486
|
cancel.onclick = () => void decide(cancel, true);
|
|
480
487
|
content.replaceChildren(...parts, row(cancel, confirm));
|
|
481
488
|
}
|
|
489
|
+
/** The result is on the page behind the dialog, so there is nothing left to say here. */
|
|
482
490
|
function done() {
|
|
483
491
|
settle();
|
|
484
|
-
|
|
485
|
-
finish.onclick = () => dialog.close();
|
|
486
|
-
content.replaceChildren(node('p', 'The connection is recorded.', 'state'), row(finish));
|
|
487
|
-
finish.focus();
|
|
492
|
+
dialog.close();
|
|
488
493
|
}
|
|
489
494
|
close.addEventListener('click', () => dialog.close());
|
|
490
495
|
dialog.addEventListener('click', (event) => {
|
package/dist/core/index.d.ts
CHANGED
|
@@ -127,8 +127,30 @@ export interface Flow {
|
|
|
127
127
|
* unguessable and per-flow, so an artifact made for one flow cannot complete another.
|
|
128
128
|
*/
|
|
129
129
|
expect?: string;
|
|
130
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* What the holder handed back: an address to read, or the proof itself. On a flow still
|
|
132
|
+
* waiting it is what they last handed back and had refused, kept so they can put it right.
|
|
133
|
+
*/
|
|
131
134
|
artifact?: string;
|
|
135
|
+
/** How many times what the holder handed back has been refused. */
|
|
136
|
+
tries?: number;
|
|
137
|
+
/**
|
|
138
|
+
* Whether the flow was started by a request shown to come from the holder's own page,
|
|
139
|
+
* and not by an address anyone could have sent their browser to. Only such a flow may
|
|
140
|
+
* be recorded without an approval, which is otherwise the one step a link cannot press.
|
|
141
|
+
*/
|
|
142
|
+
attended?: boolean;
|
|
143
|
+
/**
|
|
144
|
+
* What the holder is offered to hand back, where a record of theirs already names the
|
|
145
|
+
* account. Only an offer: what is verified is whatever they then submit.
|
|
146
|
+
*/
|
|
147
|
+
suggested?: string;
|
|
148
|
+
/**
|
|
149
|
+
* Proofs found already standing when another method named the account, such as a link
|
|
150
|
+
* back on the profile of an account that just signed in. Recorded with the method that
|
|
151
|
+
* found them once the holder approves.
|
|
152
|
+
*/
|
|
153
|
+
standing?: Attestation[];
|
|
132
154
|
/**
|
|
133
155
|
* A message on its way to the address the holder named, carrying a link to press and a
|
|
134
156
|
* code to type. The account is only a claim until one of them comes back, which is why
|
|
@@ -224,6 +246,11 @@ export interface RedirectProvider {
|
|
|
224
246
|
state: string;
|
|
225
247
|
challenge: string;
|
|
226
248
|
redirectUri: string;
|
|
249
|
+
/**
|
|
250
|
+
* The account the flow must show, on a flow the holder of its record started. There is
|
|
251
|
+
* no account to choose then, so the provider need not be made to offer the choice.
|
|
252
|
+
*/
|
|
253
|
+
account?: ExternalAccount;
|
|
227
254
|
}): string;
|
|
228
255
|
authenticate(input: {
|
|
229
256
|
code: string;
|
|
@@ -261,6 +288,25 @@ export interface ArtifactProvider {
|
|
|
261
288
|
* character, and prose that reflows is prose a holder cannot safely copy.
|
|
262
289
|
*/
|
|
263
290
|
instructions(expect: string): Instruction[];
|
|
291
|
+
/**
|
|
292
|
+
* What the holder is asked to hand back, where the method takes something a holder knows
|
|
293
|
+
* by a better name than "an address". `input` says how to ask for it: `text` where it
|
|
294
|
+
* need not be an address at all, such as a username.
|
|
295
|
+
*/
|
|
296
|
+
field?: string;
|
|
297
|
+
input?: 'url' | 'text';
|
|
298
|
+
/**
|
|
299
|
+
* Turns what the holder typed into the address to read, for a method that takes something
|
|
300
|
+
* shorter. What it returns is what is verified and kept as the proof's location, so it
|
|
301
|
+
* must be the whole address. Anything it does not recognise is returned as it came.
|
|
302
|
+
*/
|
|
303
|
+
resolve?(artifact: string): string;
|
|
304
|
+
/**
|
|
305
|
+
* What to hand back for an account the subject is already linked to, where the account
|
|
306
|
+
* alone says so: a profile that carries the link is at the account's own address. The
|
|
307
|
+
* holder is offered it instead of being asked, and may still hand back something else.
|
|
308
|
+
*/
|
|
309
|
+
known?(account: ExternalAccount): string | undefined;
|
|
264
310
|
/**
|
|
265
311
|
* Reads what the holder handed back and returns whose it is. Must confirm it contains
|
|
266
312
|
* `expect`. A `location` provider must also refuse any address outside itself, since
|
package/dist/server/copy.d.ts
CHANGED
|
@@ -6,3 +6,9 @@
|
|
|
6
6
|
* Served as a file because these pages allow no inline scripts.
|
|
7
7
|
*/
|
|
8
8
|
export declare const copyScript = "for (const block of document.querySelectorAll('pre')) {\n const code = block.querySelector('code');\n\n if (!code) continue;\n\n const button = document.createElement('button');\n\n button.type = 'button';\n button.className = 'copy';\n button.textContent = 'Copy';\n button.setAttribute('aria-live', 'polite');\n\n button.addEventListener('click', async () => {\n try {\n await navigator.clipboard.writeText(code.textContent);\n button.textContent = 'Copied';\n } catch {\n // No clipboard permission, or an insecure origin. Select it instead, which leaves\n // the holder one keystroke away rather than telling them it worked when it did not.\n // The label stays short so the button never grows over the line it sits on.\n const range = document.createRange();\n const selection = getSelection();\n\n range.selectNodeContents(code);\n selection.removeAllRanges();\n selection.addRange(range);\n button.textContent = 'Selected';\n }\n\n setTimeout(() => (button.textContent = 'Copy'), 2000);\n });\n\n block.prepend(button);\n}\n";
|
|
9
|
+
/**
|
|
10
|
+
* Moves a page that is waiting on a mailed link along once the link has been pressed,
|
|
11
|
+
* wherever it was pressed. Nothing is redrawn until then, so a code half typed is left
|
|
12
|
+
* alone. Without this the page still has its own link to press.
|
|
13
|
+
*/
|
|
14
|
+
export declare const waitScript = "const timer = setInterval(async () => {\n try {\n const response = await fetch(location.pathname + '/phase', { credentials: 'same-origin' });\n\n // Still exchanging means the link was pressed and is being recorded: the page that\n // loaded now would show neither the step before nor the one after.\n if (response.ok && !['pending', 'exchanging'].includes((await response.json()).phase)) {\n clearInterval(timer);\n location.reload();\n }\n } catch {\n // A read that fails says nothing about the flow, which the next one may.\n }\n}, 2000);\n";
|
package/dist/server/copy.js
CHANGED
|
@@ -40,3 +40,23 @@ export const copyScript = `for (const block of document.querySelectorAll('pre'))
|
|
|
40
40
|
block.prepend(button);
|
|
41
41
|
}
|
|
42
42
|
`;
|
|
43
|
+
/**
|
|
44
|
+
* Moves a page that is waiting on a mailed link along once the link has been pressed,
|
|
45
|
+
* wherever it was pressed. Nothing is redrawn until then, so a code half typed is left
|
|
46
|
+
* alone. Without this the page still has its own link to press.
|
|
47
|
+
*/
|
|
48
|
+
export const waitScript = `const timer = setInterval(async () => {
|
|
49
|
+
try {
|
|
50
|
+
const response = await fetch(location.pathname + '/phase', { credentials: 'same-origin' });
|
|
51
|
+
|
|
52
|
+
// Still exchanging means the link was pressed and is being recorded: the page that
|
|
53
|
+
// loaded now would show neither the step before nor the one after.
|
|
54
|
+
if (response.ok && !['pending', 'exchanging'].includes((await response.json()).phase)) {
|
|
55
|
+
clearInterval(timer);
|
|
56
|
+
location.reload();
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// A read that fails says nothing about the flow, which the next one may.
|
|
60
|
+
}
|
|
61
|
+
}, 2000);
|
|
62
|
+
`;
|
package/dist/server/discord.js
CHANGED
|
@@ -10,7 +10,9 @@ export function discordProvider(options) {
|
|
|
10
10
|
authorizationUrl({ state, challenge, redirectUri }) {
|
|
11
11
|
const url = new URL('https://discord.com/oauth2/authorize');
|
|
12
12
|
// Discord has no account chooser. Forcing the consent screen at least shows the holder
|
|
13
|
-
// which account they are signed in as before it is linked.
|
|
13
|
+
// which account they are signed in as before it is linked. It is forced on a flow on
|
|
14
|
+
// a record too: the holder may have withdrawn the grant since, and asking silently
|
|
15
|
+
// for one that is gone fails with no way for them to give it again.
|
|
14
16
|
url.search = new URLSearchParams({
|
|
15
17
|
response_type: 'code',
|
|
16
18
|
client_id: options.clientId,
|
package/dist/server/github.js
CHANGED
|
@@ -7,8 +7,10 @@ export function githubProvider(options) {
|
|
|
7
7
|
id: 'github',
|
|
8
8
|
name: 'GitHub',
|
|
9
9
|
method: 'oauth',
|
|
10
|
-
authorizationUrl({ state, challenge, redirectUri }) {
|
|
10
|
+
authorizationUrl({ state, challenge, redirectUri, account }) {
|
|
11
11
|
const url = new URL('https://github.com/login/oauth/authorize');
|
|
12
|
+
// A new link is to whichever account the holder picks. A flow on a record already
|
|
13
|
+
// names its account, so GitHub is asked for that one and not made to offer a choice.
|
|
12
14
|
url.search = new URLSearchParams({
|
|
13
15
|
client_id: options.clientId,
|
|
14
16
|
redirect_uri: redirectUri,
|
|
@@ -16,7 +18,7 @@ export function githubProvider(options) {
|
|
|
16
18
|
code_challenge: challenge,
|
|
17
19
|
code_challenge_method: 'S256',
|
|
18
20
|
scope: '',
|
|
19
|
-
prompt: 'select_account',
|
|
21
|
+
...(account ? { login: account.handle } : { prompt: 'select_account' }),
|
|
20
22
|
}).toString();
|
|
21
23
|
return url.href;
|
|
22
24
|
},
|
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { attestationLabel, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
|
|
2
|
-
import { codeAttempts, VerilyService, Unavailable } from './service.js';
|
|
3
|
-
import { copyScript } from './copy.js';
|
|
2
|
+
import { artifactAttempts, codeAttempts, VerilyService, Unavailable, } from './service.js';
|
|
3
|
+
import { copyScript, waitScript } from './copy.js';
|
|
4
4
|
import { escape } from './escape.js';
|
|
5
5
|
import { logo } from '../logo.js';
|
|
6
6
|
import { stylesheet, styleVersion } from './style.js';
|
|
@@ -26,7 +26,7 @@ const headers = {
|
|
|
26
26
|
'Referrer-Policy': 'same-origin',
|
|
27
27
|
'X-Robots-Tag': 'noindex, nofollow',
|
|
28
28
|
'X-Content-Type-Options': 'nosniff',
|
|
29
|
-
'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
|
29
|
+
'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
|
30
30
|
};
|
|
31
31
|
const html = (body, status = 200) => new Response(body, {
|
|
32
32
|
status,
|
|
@@ -105,12 +105,33 @@ function methodAction(provider) {
|
|
|
105
105
|
}
|
|
106
106
|
/** What the holder hands back for an artifact method, named for what it is. */
|
|
107
107
|
function artifactField(provider) {
|
|
108
|
+
if (provider.field)
|
|
109
|
+
return provider.field;
|
|
108
110
|
if (provider.artifact === 'document')
|
|
109
111
|
return 'Your proof';
|
|
110
112
|
return provider.method === 'backlink'
|
|
111
113
|
? 'Address of the page carrying your link'
|
|
112
114
|
: 'Address of your published proof';
|
|
113
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Said before the holder approves, where the method they used found another proof already
|
|
118
|
+
* standing: it goes on the record with this one, and they did not ask for it by name.
|
|
119
|
+
*/
|
|
120
|
+
function standingNote(flow, site) {
|
|
121
|
+
return flow.standing?.some((a) => a.method === 'backlink')
|
|
122
|
+
? `This account already links back to ${site}. Confirming records that too.`
|
|
123
|
+
: undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Said above a proof that was handed back and refused, while the flow still waits for it:
|
|
127
|
+
* why, where the provider said why in words meant for the holder, and how many tries remain.
|
|
128
|
+
*/
|
|
129
|
+
function refusedNote(flow) {
|
|
130
|
+
if (!flow.tries)
|
|
131
|
+
return undefined;
|
|
132
|
+
const left = artifactAttempts - flow.tries;
|
|
133
|
+
return `${flow.reason ? `That did not check out: ${flow.reason}.` : 'That could not be checked.'} ${left === 1 ? 'One try is' : `${left} tries are`} left.`;
|
|
134
|
+
}
|
|
114
135
|
/** Said beside every artifact method: what the holder publishes is public by design. */
|
|
115
136
|
function artifactNote(provider) {
|
|
116
137
|
return provider.method === 'backlink'
|
|
@@ -424,16 +445,23 @@ export function createVerily(options) {
|
|
|
424
445
|
instructions: provider.instructions(flow.expect),
|
|
425
446
|
artifact: provider.artifact,
|
|
426
447
|
field: artifactField(provider),
|
|
448
|
+
input: provider.input ?? 'url',
|
|
449
|
+
...((flow.artifact ?? flow.suggested)
|
|
450
|
+
? { suggested: flow.artifact ?? flow.suggested }
|
|
451
|
+
: {}),
|
|
452
|
+
...(flow.tries ? { refused: refusedNote(flow) } : {}),
|
|
427
453
|
note: artifactNote(provider),
|
|
428
454
|
});
|
|
429
455
|
if (flow.phase === 'pending' && isCodeProvider(provider))
|
|
430
456
|
Object.assign(view, { code: codeStep(provider, flow), note: codeNote });
|
|
431
457
|
if (flow.phase === 'approval') {
|
|
432
458
|
const joined = await service.joining(flow);
|
|
459
|
+
const found = standingNote(flow, service.siteOf(flow.local));
|
|
433
460
|
Object.assign(view, {
|
|
434
461
|
local: subject(flow.local),
|
|
435
462
|
external: flow.external,
|
|
436
463
|
...(joined ? { joined: { visibility: joined.visibility } } : {}),
|
|
464
|
+
...(found ? { standingNote: found } : {}),
|
|
437
465
|
visibilities: visibilities(flow.local),
|
|
438
466
|
...(signs ? { signedNote } : {}),
|
|
439
467
|
});
|
|
@@ -451,6 +479,14 @@ export function createVerily(options) {
|
|
|
451
479
|
throw new Unavailable();
|
|
452
480
|
return flow;
|
|
453
481
|
}
|
|
482
|
+
/**
|
|
483
|
+
* What the tab a confirmation was pressed in says. It is the end of that tab's part: the
|
|
484
|
+
* page that asked is waiting on the flow and carries on from there, so this one offers
|
|
485
|
+
* nowhere to go.
|
|
486
|
+
*/
|
|
487
|
+
const confirmed = (flow) => html(page(prefix, flow.phase === 'failed' ? 'Address not confirmed' : 'Address confirmed', flow.phase === 'failed'
|
|
488
|
+
? `<p>${escape(flow.reason ?? 'This address could not be confirmed')}.</p>`
|
|
489
|
+
: '<p>You can close this tab now.</p>'));
|
|
454
490
|
async function handle(request) {
|
|
455
491
|
const url = new URL(request.url);
|
|
456
492
|
if (url.origin !== base.origin ||
|
|
@@ -481,6 +517,14 @@ export function createVerily(options) {
|
|
|
481
517
|
return new Response(copyScript, {
|
|
482
518
|
headers: { ...headers, 'Content-Type': 'text/javascript' },
|
|
483
519
|
});
|
|
520
|
+
if (path === '/wait.js')
|
|
521
|
+
return new Response(waitScript, {
|
|
522
|
+
headers: { ...headers, 'Content-Type': 'text/javascript' },
|
|
523
|
+
});
|
|
524
|
+
const phase = path.match(/^\/flows\/([^/]+)\/phase$/);
|
|
525
|
+
// All a waiting page needs to know, and only the browser that started the flow.
|
|
526
|
+
if (phase)
|
|
527
|
+
return json({ phase: (await service.flow(phase[1], binding(request))).phase });
|
|
484
528
|
if (path === '/result.js')
|
|
485
529
|
return new Response(`const e=document.getElementById('verily-result');if(window.opener&&e){window.opener.postMessage({type:'verily-result',outcome:e.dataset.outcome,connectionId:e.dataset.id},location.origin);window.close()}`, { headers: { ...headers, 'Content-Type': 'text/javascript' } });
|
|
486
530
|
if (path === '/verify' ||
|
|
@@ -564,10 +608,11 @@ export function createVerily(options) {
|
|
|
564
608
|
if ((await local(request)).id !== flow.local?.id)
|
|
565
609
|
throw new Unavailable();
|
|
566
610
|
return html(page(prefix, `Verify with ${provider.name}`, `${instructions(provider.instructions(flow.expect))}
|
|
611
|
+
${flow.tries ? `<p>${escape(refusedNote(flow))}</p>` : ''}
|
|
567
612
|
<form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/submit">
|
|
568
613
|
<label>${escape(artifactField(provider))} ${provider.artifact === 'document'
|
|
569
|
-
?
|
|
570
|
-
:
|
|
614
|
+
? `<textarea name="artifact" rows="14" cols="72" required>${escape(flow.artifact ?? '')}</textarea>`
|
|
615
|
+
: `<input name="artifact" type="${provider.input ?? 'url'}" value="${escape(flow.artifact ?? flow.suggested ?? '')}" autocapitalize="none" spellcheck="false" required>`}</label>
|
|
571
616
|
<button>Check my proof</button></form>
|
|
572
617
|
<p class="fine">${escape(artifactNote(provider))}</p>
|
|
573
618
|
<script src="${escape(prefix)}/copy.js" defer></script>`));
|
|
@@ -580,7 +625,7 @@ export function createVerily(options) {
|
|
|
580
625
|
const step = codeStep(provider, flow);
|
|
581
626
|
return html(page(prefix, `Verify with ${provider.name}`, `${step.sentTo === undefined
|
|
582
627
|
? ''
|
|
583
|
-
: `<p>A message was sent to ${escape(step.sentTo)}. Press the button in it,
|
|
628
|
+
: `<p>A message was sent to ${escape(step.sentTo)}. Press the button in it, and this page carries on.</p><noscript><p><a href="${escape(prefix)}/flows/${escape(flow.id)}">I pressed the button</a></p></noscript><p>Or enter the code from the message here.</p>${step.wrong
|
|
584
629
|
? `<p>That code did not match. ${step.triesLeft === 1 ? 'One try is' : `${step.triesLeft} tries are`} left.</p>`
|
|
585
630
|
: ''}`}
|
|
586
631
|
<form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/submit">
|
|
@@ -588,7 +633,7 @@ export function createVerily(options) {
|
|
|
588
633
|
? `<input name="artifact" type="${step.input}" autocomplete="${step.input}" required>`
|
|
589
634
|
: '<input name="artifact" autocomplete="one-time-code" autocapitalize="characters" spellcheck="false" required>'}</label>
|
|
590
635
|
<button>${step.sentTo === undefined ? `Send the ${escape(provider.name.toLowerCase())}` : 'Check my code'}</button></form>
|
|
591
|
-
${step.sentTo === undefined ? `<p class="fine">${escape(codeNote)}</p>` :
|
|
636
|
+
${step.sentTo === undefined ? `<p class="fine">${escape(codeNote)}</p>` : `<script src="${escape(prefix)}/wait.js" defer></script>`}`));
|
|
592
637
|
}
|
|
593
638
|
if (flow.phase !== 'approval')
|
|
594
639
|
return await ended(flow);
|
|
@@ -599,6 +644,7 @@ export function createVerily(options) {
|
|
|
599
644
|
// visibility was chosen when it was made and is not the holder's to rechoose here.
|
|
600
645
|
const joined = await service.joining(flow);
|
|
601
646
|
const kept = ['revoke', 'share-revoke', 'renew'].includes(flow.kind) || joined;
|
|
647
|
+
const found = standingNote(flow, service.siteOf(flow.local));
|
|
602
648
|
return html(page(prefix, ['revoke', 'share-revoke'].includes(flow.kind)
|
|
603
649
|
? 'Remove connection'
|
|
604
650
|
: flow.kind === 'renew'
|
|
@@ -609,6 +655,7 @@ export function createVerily(options) {
|
|
|
609
655
|
${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
|
|
610
656
|
${card(provider.name, externalName(flow.external), externalLink(flow.external), flow.external.kind === 'mailbox' ? undefined : flow.external.id)}
|
|
611
657
|
${joined ? `<p>This account is already linked here. Confirming adds this method to that connection, beneath the one it was first shown by, and it stays ${escape(joined.visibility)}.</p>` : ''}
|
|
658
|
+
${found ? `<p>${escape(found)}</p>` : ''}
|
|
612
659
|
<p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
|
|
613
660
|
<form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/approve">
|
|
614
661
|
${kept ? '<input type="hidden" name="visibility" value="unlisted">' : visibilityChoice(flow.local, visibilities(flow.local), signs)}
|
|
@@ -617,11 +664,16 @@ export function createVerily(options) {
|
|
|
617
664
|
}
|
|
618
665
|
const confirmation = path.match(/^\/confirm\/([^/]+)$/);
|
|
619
666
|
// Where the button in a message leads, in whatever browser the holder reads mail
|
|
620
|
-
// in.
|
|
621
|
-
//
|
|
667
|
+
// in. In the browser that started the flow, arriving is the confirmation: its
|
|
668
|
+
// cookie shows the one who asked is the one who opened the message. Anywhere else
|
|
669
|
+
// arriving confirms nothing: a scanner that opens every link in a message carries
|
|
670
|
+
// no cookie, gets this page and stops, and a holder on another device is shown
|
|
671
|
+
// what they are confirming first.
|
|
622
672
|
if (confirmation) {
|
|
623
673
|
const token = url.searchParams.get('token') ?? '';
|
|
624
674
|
const asked = await service.confirming(confirmation[1], token);
|
|
675
|
+
if (await service.flow(confirmation[1], binding(request)).catch(() => undefined))
|
|
676
|
+
return confirmed(await service.confirm(confirmation[1], token));
|
|
625
677
|
const next = {
|
|
626
678
|
connect: `link it to this ${subjectNoun(asked.local?.kind) ?? 'site'}`,
|
|
627
679
|
renew: 'renew its link',
|
|
@@ -720,22 +772,16 @@ export function createVerily(options) {
|
|
|
720
772
|
const flow = await service.start(user, data.connectionId || undefined, kind, await choose(user, data.connectionId || undefined, {
|
|
721
773
|
provider: data.provider || undefined,
|
|
722
774
|
method: data.method || undefined,
|
|
723
|
-
}), await context(request, kind)
|
|
775
|
+
}), await context(request, kind),
|
|
776
|
+
// Posted from this origin, which the check on every POST has already held it to.
|
|
777
|
+
true);
|
|
724
778
|
if (asJson && kind === 'connect')
|
|
725
779
|
return json(await flowView(await service.flow(flow.flowId, flow.binding), flow.authorizationUrl), flowCookie(flow.binding));
|
|
726
780
|
return redirect(entry(flow), flowCookie(flow.binding));
|
|
727
781
|
}
|
|
728
782
|
const confirmation = path.match(/^\/confirm\/([^/]+)$/);
|
|
729
783
|
if (confirmation) {
|
|
730
|
-
|
|
731
|
-
// The flow is still the browser's that started it. This one may be that browser,
|
|
732
|
-
// and then it can carry on from here; any other is sent back to where it began.
|
|
733
|
-
const here = await service.flow(flow.id, binding(request)).catch(() => undefined);
|
|
734
|
-
return html(page(prefix, flow.phase === 'failed' ? 'Address not confirmed' : 'Address confirmed', flow.phase === 'failed'
|
|
735
|
-
? `<p>${escape(flow.reason ?? 'This address could not be confirmed')}.</p>`
|
|
736
|
-
: `<p>Go back to the page where you started. It carries on from here.</p>${here
|
|
737
|
-
? `<p><a href="${escape(prefix)}/flows/${escape(flow.id)}">Or carry on in this window</a></p>`
|
|
738
|
-
: '<p>You can close this window.</p>'}`));
|
|
784
|
+
return confirmed(await service.confirm(confirmation[1], data.token ?? ''));
|
|
739
785
|
}
|
|
740
786
|
const submission = path.match(/^\/flows\/([^/]+)\/submit$/);
|
|
741
787
|
if (submission) {
|
package/dist/server/link.js
CHANGED
|
@@ -86,6 +86,9 @@ export function linkProvider(options) {
|
|
|
86
86
|
}
|
|
87
87
|
return account(page, profile);
|
|
88
88
|
},
|
|
89
|
+
// A page that carries the link is at the address it is named by, so a record of one
|
|
90
|
+
// already says where to look.
|
|
91
|
+
known: (account) => account.profileUrl,
|
|
89
92
|
};
|
|
90
93
|
}
|
|
91
94
|
/**
|
|
@@ -216,18 +219,32 @@ export function githubLinkProvider(options = {}) {
|
|
|
216
219
|
id: 'github',
|
|
217
220
|
name: 'GitHub',
|
|
218
221
|
hosts: ['github.com'],
|
|
219
|
-
|
|
220
|
-
profile: /^\/([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})$/,
|
|
222
|
+
profile: new RegExp(`^/(${username})$`),
|
|
221
223
|
});
|
|
224
|
+
// A profile is at its username, so the username is all the holder needs to give. A
|
|
225
|
+
// leading @ is how one is written, and is not part of it.
|
|
226
|
+
const named = new RegExp(`^@?(${username})$`);
|
|
222
227
|
return {
|
|
223
228
|
...provider,
|
|
224
229
|
// GitHub writes the rel="me" itself, so the holder only fills in one field.
|
|
225
230
|
instructions: (expect) => [
|
|
226
|
-
'Put this address in the website field of your GitHub profile, then
|
|
231
|
+
'Put this address in the website field of your GitHub profile, then enter your GitHub username below.',
|
|
227
232
|
{ code: expect },
|
|
228
233
|
],
|
|
234
|
+
field: 'Your GitHub username',
|
|
235
|
+
input: 'text',
|
|
236
|
+
resolve(artifact) {
|
|
237
|
+
const name = named.exec(artifact.trim())?.[1];
|
|
238
|
+
return name ? `https://github.com/${name}` : artifact;
|
|
239
|
+
},
|
|
240
|
+
// Only an account has a username. The handle of anything else is not one.
|
|
241
|
+
known: (account) => (account.kind ?? 'account') === 'account' && named.test(account.handle)
|
|
242
|
+
? account.handle.replace(/^@/, '')
|
|
243
|
+
: undefined,
|
|
229
244
|
};
|
|
230
245
|
}
|
|
246
|
+
/** GitHub's own rule: alphanumerics and single hyphens, never leading or trailing, 39 max. */
|
|
247
|
+
const username = '[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}';
|
|
231
248
|
/**
|
|
232
249
|
* Whether a link points at the subject. Every key in the subject's query must carry the
|
|
233
250
|
* same values in the same order on the link, since a site whose profiles differ only by a
|
|
@@ -244,7 +261,10 @@ function points(href, base, subject) {
|
|
|
244
261
|
}
|
|
245
262
|
// A subject with a fragment is one of the views a hash-routed page serves, so the
|
|
246
263
|
// fragment names it and must match. A fragment on the link alone is a spot on the page.
|
|
247
|
-
|
|
264
|
+
// A site reached over http is the same site as over https, and a profile field given a
|
|
265
|
+
// bare domain is often written as the former. Any other scheme is some other resource.
|
|
266
|
+
const scheme = target.protocol === 'http:' && subject.protocol === 'https:' ? 'https:' : target.protocol;
|
|
267
|
+
if (scheme !== subject.protocol ||
|
|
248
268
|
target.host.toLowerCase() !== subject.host.toLowerCase() ||
|
|
249
269
|
trim(target.pathname) !== trim(subject.pathname) ||
|
|
250
270
|
(subject.hash !== '' && target.hash !== subject.hash))
|
package/dist/server/service.d.ts
CHANGED
|
@@ -84,7 +84,13 @@ export declare class VerilyService {
|
|
|
84
84
|
start(local?: LocalAccount, connectionId?: string, kind?: Flow['kind'], choice?: {
|
|
85
85
|
provider?: string;
|
|
86
86
|
method?: string;
|
|
87
|
-
}, context?: Record<string, string
|
|
87
|
+
}, context?: Record<string, string>,
|
|
88
|
+
/**
|
|
89
|
+
* Whether the caller has shown the holder asked for this themselves, as a form posted
|
|
90
|
+
* from their own page shows and following a link does not. Without it the flow reads
|
|
91
|
+
* nothing at its start and ends on an approval, however little there is to approve.
|
|
92
|
+
*/
|
|
93
|
+
attended?: boolean): Promise<{
|
|
88
94
|
flowId: string;
|
|
89
95
|
binding: string;
|
|
90
96
|
expect: string;
|
|
@@ -103,6 +109,19 @@ export declare class VerilyService {
|
|
|
103
109
|
instructions?: undefined;
|
|
104
110
|
authorizationUrl?: undefined;
|
|
105
111
|
}>;
|
|
112
|
+
/**
|
|
113
|
+
* What a new flow can offer the holder to hand back: the one account this subject has
|
|
114
|
+
* already shown some other way, which is the record the flow would join. With several,
|
|
115
|
+
* which one they mean is theirs to say.
|
|
116
|
+
*/
|
|
117
|
+
private known;
|
|
118
|
+
/**
|
|
119
|
+
* Reads the proof at the account a record already names, before the holder is asked for
|
|
120
|
+
* anything. Found, the flow goes straight to approval, as if they had handed it back.
|
|
121
|
+
* Not found is no failure: they have not been told what to publish yet, so the flow
|
|
122
|
+
* stays where it was and tells them.
|
|
123
|
+
*/
|
|
124
|
+
private offered;
|
|
106
125
|
/**
|
|
107
126
|
* Which method a flow runs. A flow on an existing record stays in that record's
|
|
108
127
|
* namespace, and without a choice it uses the method the record was first shown by.
|
|
@@ -119,6 +138,13 @@ export declare class VerilyService {
|
|
|
119
138
|
* code that arrived there.
|
|
120
139
|
*/
|
|
121
140
|
submit(id: string, binding: string, artifact: string): Promise<string>;
|
|
141
|
+
/**
|
|
142
|
+
* Takes a refused proof as something the holder can put right, and leaves the flow
|
|
143
|
+
* waiting for it. What they must publish is minted per flow, so a flow ended here would
|
|
144
|
+
* have them publish or sign all over again for a mistyped address. The tries are counted
|
|
145
|
+
* all the same: each one is a fetch somewhere the holder chose.
|
|
146
|
+
*/
|
|
147
|
+
private refused;
|
|
122
148
|
/**
|
|
123
149
|
* One step of a mailed code. The first answer is an address: a code is minted, sent
|
|
124
150
|
* there, and only its hash kept. The second is the code, which the holder could only
|
|
@@ -168,8 +194,18 @@ export declare class VerilyService {
|
|
|
168
194
|
*/
|
|
169
195
|
private established;
|
|
170
196
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
197
|
+
* The proofs already standing for the account a flow just showed, by the other methods
|
|
198
|
+
* configured for its provider that can tell where to look from the account alone. One
|
|
199
|
+
* that is not there, or cannot be read, is simply not found: the holder asked for none
|
|
200
|
+
* of them, so none of them can fail the flow.
|
|
201
|
+
*/
|
|
202
|
+
private standing;
|
|
203
|
+
/** Adds the proofs a flow found standing beneath the method the record was first shown by. */
|
|
204
|
+
private recordStanding;
|
|
205
|
+
/**
|
|
206
|
+
* A sign-in or a mailed code that fails leaves the flow dead: there is nothing in it to
|
|
207
|
+
* put right, and starting again costs the holder nothing. Only a `Refused` reason is
|
|
208
|
+
* kept: any other error may describe this backend rather than the proof.
|
|
173
209
|
*/
|
|
174
210
|
private failed;
|
|
175
211
|
approve(id: string, binding: string, local: LocalAccount | undefined, visibility: Visibility, cancel?: boolean,
|
|
@@ -179,6 +215,15 @@ export declare class VerilyService {
|
|
|
179
215
|
* this approval makes a record or joins one, so no change in between gets past it.
|
|
180
216
|
*/
|
|
181
217
|
allowed?: Visibility[]): Promise<string | undefined>;
|
|
218
|
+
/**
|
|
219
|
+
* Whether approving a flow would decide anything. A renewal, or another way of showing
|
|
220
|
+
* an account the subject is already linked to, changes neither who is linked nor who
|
|
221
|
+
* can read it: the holder asked for exactly this and the account has been checked
|
|
222
|
+
* against the record, so it is recorded without asking them to say so again.
|
|
223
|
+
*/
|
|
224
|
+
private settled;
|
|
225
|
+
/** Records what an approved flow does, and ends it. The caller has checked who approves. */
|
|
226
|
+
private conclude;
|
|
182
227
|
/**
|
|
183
228
|
* Ends a flow and records how, inside the transaction that ends it: the connection as it
|
|
184
229
|
* stands now, since whatever later reports this result must not read a record that has
|
|
@@ -309,3 +354,5 @@ export declare class VerilyService {
|
|
|
309
354
|
}
|
|
310
355
|
/** How many wrong codes a flow takes before it is dead. */
|
|
311
356
|
export declare const codeAttempts = 5;
|
|
357
|
+
/** How many times a published proof may be refused before its flow is dead. */
|
|
358
|
+
export declare const artifactAttempts = 5;
|
package/dist/server/service.js
CHANGED
|
@@ -135,7 +135,13 @@ export class VerilyService {
|
|
|
135
135
|
? this.providers[0]
|
|
136
136
|
: this.resolve(flow.provider, flow.method);
|
|
137
137
|
}
|
|
138
|
-
async start(local, connectionId, kind = 'connect', choice = {}, context
|
|
138
|
+
async start(local, connectionId, kind = 'connect', choice = {}, context,
|
|
139
|
+
/**
|
|
140
|
+
* Whether the caller has shown the holder asked for this themselves, as a form posted
|
|
141
|
+
* from their own page shows and following a link does not. Without it the flow reads
|
|
142
|
+
* nothing at its start and ends on an approval, however little there is to approve.
|
|
143
|
+
*/
|
|
144
|
+
attended = false) {
|
|
139
145
|
if (kind === 'connect' && !local)
|
|
140
146
|
throw new Unavailable();
|
|
141
147
|
if (context !== undefined &&
|
|
@@ -157,10 +163,15 @@ export class VerilyService {
|
|
|
157
163
|
connectionId,
|
|
158
164
|
phase: 'pending',
|
|
159
165
|
expiresAt: this.now() + (this.options.flowTtlMs ?? 600000),
|
|
166
|
+
...(attended ? { attended } : {}),
|
|
160
167
|
};
|
|
161
168
|
// Removal from the external side answers to nobody local, so nothing local rides on it.
|
|
162
169
|
if (context && !['revoke', 'share-revoke'].includes(kind))
|
|
163
170
|
flow.context = { ...context };
|
|
171
|
+
// The account a sign-in is asked for by name. Only where the local holder of the record
|
|
172
|
+
// started the flow: removal from the external side is started by anyone, and is told
|
|
173
|
+
// nothing about the account it would have to show.
|
|
174
|
+
let account;
|
|
164
175
|
const provider = await this.transaction(async (tx) => {
|
|
165
176
|
// Whose link this is. A flow against an existing connection may carry no local
|
|
166
177
|
// subject of its own, and the record is the authority on it in any case.
|
|
@@ -174,6 +185,8 @@ export class VerilyService {
|
|
|
174
185
|
(['visibility', 'renew'].includes(kind) && connection.local.id !== local?.id))
|
|
175
186
|
throw new Unavailable();
|
|
176
187
|
subject = connection.local;
|
|
188
|
+
if (['renew', 'visibility'].includes(kind))
|
|
189
|
+
account = connection.external;
|
|
177
190
|
}
|
|
178
191
|
const provider = this.chosen(kind, choice, connection);
|
|
179
192
|
flow.provider = provider.id;
|
|
@@ -189,10 +202,23 @@ export class VerilyService {
|
|
|
189
202
|
flow.expect = artifact.expect
|
|
190
203
|
? artifact.expect(subject)
|
|
191
204
|
: `Verily proof for ${this.siteOf(subject)}: ${secret()}`;
|
|
205
|
+
// These kinds are all started by the local holder, whose own record this is. Removal
|
|
206
|
+
// from the external side is started by anyone, and is told nothing.
|
|
207
|
+
if (artifact?.known && ['connect', 'renew', 'visibility'].includes(kind)) {
|
|
208
|
+
const suggested = connection
|
|
209
|
+
? artifact.known(connection.external)
|
|
210
|
+
: await this.known(tx, subject, artifact);
|
|
211
|
+
if (suggested)
|
|
212
|
+
flow.suggested = suggested;
|
|
213
|
+
}
|
|
192
214
|
await tx.put('flows', flow.id, flow);
|
|
193
215
|
return provider;
|
|
194
216
|
});
|
|
195
217
|
const artifact = isArtifactProvider(provider) ? provider : undefined;
|
|
218
|
+
// Where a record already says whose account this is, there is nothing to ask the
|
|
219
|
+
// holder: the proof is either there to be read or it is not.
|
|
220
|
+
if (artifact && flow.suggested && attended)
|
|
221
|
+
await this.offered(flow.id, binding, artifact, flow.expect, flow.suggested);
|
|
196
222
|
// A redirect provider hands the holder to its own site; an artifact provider tells
|
|
197
223
|
// them what to publish and waits for them to say where they put it; a code provider
|
|
198
224
|
// has nothing to say until the holder names the address to send to.
|
|
@@ -211,10 +237,53 @@ export class VerilyService {
|
|
|
211
237
|
state,
|
|
212
238
|
challenge: hash(verifier),
|
|
213
239
|
redirectUri: `${this.baseUrl}/callback`,
|
|
240
|
+
...(account ? { account } : {}),
|
|
214
241
|
}),
|
|
215
242
|
}
|
|
216
243
|
: { flowId: flow.id, binding };
|
|
217
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* What a new flow can offer the holder to hand back: the one account this subject has
|
|
247
|
+
* already shown some other way, which is the record the flow would join. With several,
|
|
248
|
+
* which one they mean is theirs to say.
|
|
249
|
+
*/
|
|
250
|
+
async known(tx, local, provider) {
|
|
251
|
+
const offers = (await tx.list('connections'))
|
|
252
|
+
.filter((c) => c.revokedAt === undefined &&
|
|
253
|
+
c.local.id === local.id &&
|
|
254
|
+
c.provider === provider.id &&
|
|
255
|
+
(c.attestations?.external[0].method ?? 'oauth') !== provider.method)
|
|
256
|
+
.map((c) => provider.known(c.external))
|
|
257
|
+
.filter((offer) => offer !== undefined);
|
|
258
|
+
return new Set(offers.map((offer) => offer.toLowerCase())).size === 1 ? offers[0] : undefined;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Reads the proof at the account a record already names, before the holder is asked for
|
|
262
|
+
* anything. Found, the flow goes straight to approval, as if they had handed it back.
|
|
263
|
+
* Not found is no failure: they have not been told what to publish yet, so the flow
|
|
264
|
+
* stays where it was and tells them.
|
|
265
|
+
*/
|
|
266
|
+
async offered(id, binding, provider, expect, suggested) {
|
|
267
|
+
try {
|
|
268
|
+
const handed = provider.resolve?.(suggested) ?? suggested;
|
|
269
|
+
const external = await this.deadline(provider.verify({ artifact: handed, expect }));
|
|
270
|
+
await this.transaction(async (tx) => {
|
|
271
|
+
const flow = await this.bound(tx, id, binding);
|
|
272
|
+
flow.phase = 'exchanging';
|
|
273
|
+
await tx.put('flows', id, flow);
|
|
274
|
+
});
|
|
275
|
+
await this.established(id, binding, external, handed);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
await this.transaction(async (tx) => {
|
|
279
|
+
const flow = await tx.get('flows', id);
|
|
280
|
+
if (flow?.phase === 'exchanging') {
|
|
281
|
+
flow.phase = 'pending';
|
|
282
|
+
await tx.put('flows', id, flow);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
218
287
|
/**
|
|
219
288
|
* Which method a flow runs. A flow on an existing record stays in that record's
|
|
220
289
|
* namespace, and without a choice it uses the method the record was first shown by.
|
|
@@ -263,14 +332,40 @@ export class VerilyService {
|
|
|
263
332
|
return { provider, expect: flow.expect };
|
|
264
333
|
});
|
|
265
334
|
try {
|
|
266
|
-
|
|
267
|
-
|
|
335
|
+
// Kept as the address that was read, never as the shorthand that named it: it is
|
|
336
|
+
// where a reader is sent, and what is read again later.
|
|
337
|
+
const handed = provider.resolve?.(artifact) ?? artifact;
|
|
338
|
+
const external = await provider.verify({ artifact: handed, expect });
|
|
339
|
+
await this.established(id, binding, external, handed);
|
|
268
340
|
}
|
|
269
341
|
catch (error) {
|
|
270
|
-
await this.
|
|
342
|
+
await this.refused(id, error, artifact);
|
|
271
343
|
}
|
|
272
344
|
return id;
|
|
273
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Takes a refused proof as something the holder can put right, and leaves the flow
|
|
348
|
+
* waiting for it. What they must publish is minted per flow, so a flow ended here would
|
|
349
|
+
* have them publish or sign all over again for a mistyped address. The tries are counted
|
|
350
|
+
* all the same: each one is a fetch somewhere the holder chose.
|
|
351
|
+
*/
|
|
352
|
+
async refused(id, error, artifact) {
|
|
353
|
+
await this.transaction(async (tx) => {
|
|
354
|
+
const flow = await tx.get('flows', id);
|
|
355
|
+
if (flow?.phase !== 'exchanging')
|
|
356
|
+
return;
|
|
357
|
+
flow.tries = (flow.tries ?? 0) + 1;
|
|
358
|
+
// Only a `Refused` reason is kept: any other error may describe this backend.
|
|
359
|
+
flow.reason = error instanceof Refused ? error.message : undefined;
|
|
360
|
+
if (flow.tries >= artifactAttempts)
|
|
361
|
+
await this.ended(tx, flow, 'failed');
|
|
362
|
+
else {
|
|
363
|
+
flow.phase = 'pending';
|
|
364
|
+
flow.artifact = artifact;
|
|
365
|
+
}
|
|
366
|
+
await tx.put('flows', id, flow);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
274
369
|
/**
|
|
275
370
|
* One step of a mailed code. The first answer is an address: a code is minted, sent
|
|
276
371
|
* there, and only its hash kept. The second is the code, which the holder could only
|
|
@@ -484,7 +579,9 @@ export class VerilyService {
|
|
|
484
579
|
// A mailbox has no page to point at, so its own address stands in for one.
|
|
485
580
|
new URL(external.profileUrl).protocol !== (external.kind === 'mailbox' ? 'mailto:' : 'https:'))
|
|
486
581
|
throw new Unavailable();
|
|
487
|
-
|
|
582
|
+
// The flow as it stands once this account is accepted for it, or a refusal. Asked
|
|
583
|
+
// twice, because what it is checked against can change while proofs are being read.
|
|
584
|
+
const accepted = async (tx) => {
|
|
488
585
|
const flow = binding === undefined ? await tx.get('flows', id) : await this.bound(tx, id, binding);
|
|
489
586
|
if (flow?.phase !== 'exchanging')
|
|
490
587
|
throw new Unavailable();
|
|
@@ -499,15 +596,91 @@ export class VerilyService {
|
|
|
499
596
|
flow.local = connection.local;
|
|
500
597
|
}
|
|
501
598
|
flow.external = external;
|
|
599
|
+
return flow;
|
|
600
|
+
};
|
|
601
|
+
// The account is named now, so a proof that needs nothing but its name can be read
|
|
602
|
+
// without the holder choosing that method or handing anything back. Read while the
|
|
603
|
+
// flow is still exchanging: approval is shown once, and must show all it will record.
|
|
604
|
+
const shown = await this.transaction(accepted);
|
|
605
|
+
const standing = ['connect', 'renew'].includes(shown.kind) ? await this.standing(shown) : [];
|
|
606
|
+
await this.transaction(async (tx) => {
|
|
607
|
+
const flow = await accepted(tx);
|
|
502
608
|
flow.artifact = artifact;
|
|
609
|
+
flow.reason = undefined;
|
|
503
610
|
flow.authenticatedAt = this.now();
|
|
611
|
+
if (standing.length)
|
|
612
|
+
flow.standing = standing;
|
|
504
613
|
flow.phase = 'approval';
|
|
505
|
-
|
|
614
|
+
// Nothing for the holder to decide, so nothing to ask them. The visibility passed is
|
|
615
|
+
// never read: both of these keep the record's own.
|
|
616
|
+
if (await this.settled(tx, flow))
|
|
617
|
+
await this.conclude(tx, flow, 'unlisted');
|
|
618
|
+
else
|
|
619
|
+
await tx.put('flows', id, flow);
|
|
506
620
|
});
|
|
507
621
|
}
|
|
508
622
|
/**
|
|
509
|
-
*
|
|
510
|
-
*
|
|
623
|
+
* The proofs already standing for the account a flow just showed, by the other methods
|
|
624
|
+
* configured for its provider that can tell where to look from the account alone. One
|
|
625
|
+
* that is not there, or cannot be read, is simply not found: the holder asked for none
|
|
626
|
+
* of them, so none of them can fail the flow.
|
|
627
|
+
*/
|
|
628
|
+
async standing(flow) {
|
|
629
|
+
const shownBy = this.providerOf(flow);
|
|
630
|
+
const found = [];
|
|
631
|
+
for (const provider of this.providers) {
|
|
632
|
+
if (!isArtifactProvider(provider) ||
|
|
633
|
+
provider.id !== shownBy.id ||
|
|
634
|
+
provider.method === providerMethod(shownBy) ||
|
|
635
|
+
provider.artifact !== 'location' ||
|
|
636
|
+
!provider.expect ||
|
|
637
|
+
!provider.known)
|
|
638
|
+
continue;
|
|
639
|
+
try {
|
|
640
|
+
const expect = provider.expect(flow.local);
|
|
641
|
+
const known = provider.known(flow.external);
|
|
642
|
+
if (!known)
|
|
643
|
+
continue;
|
|
644
|
+
const handed = provider.resolve?.(known) ?? known;
|
|
645
|
+
const external = await this.deadline(provider.verify({ artifact: handed, expect }));
|
|
646
|
+
if (!sameAccount(flow.external, external))
|
|
647
|
+
continue;
|
|
648
|
+
found.push({
|
|
649
|
+
by: 'provider',
|
|
650
|
+
method: provider.method,
|
|
651
|
+
confirmedAt: this.now(),
|
|
652
|
+
artifactUrl: handed,
|
|
653
|
+
expect,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return found;
|
|
661
|
+
}
|
|
662
|
+
/** Adds the proofs a flow found standing beneath the method the record was first shown by. */
|
|
663
|
+
recordStanding(connection, flow) {
|
|
664
|
+
if (!flow.standing?.length || !connection.attestations)
|
|
665
|
+
return;
|
|
666
|
+
const [main, ...rest] = connection.attestations.external;
|
|
667
|
+
for (const attestation of flow.standing) {
|
|
668
|
+
// The main method is reread against the id it named, so it is only ever replaced
|
|
669
|
+
// by a flow that ran it.
|
|
670
|
+
if (attestation.method === main.method)
|
|
671
|
+
continue;
|
|
672
|
+
const at = rest.findIndex((a) => a.method === attestation.method);
|
|
673
|
+
if (at < 0)
|
|
674
|
+
rest.push(attestation);
|
|
675
|
+
else
|
|
676
|
+
rest[at] = attestation;
|
|
677
|
+
}
|
|
678
|
+
connection.attestations = { ...connection.attestations, external: [main, ...rest] };
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* A sign-in or a mailed code that fails leaves the flow dead: there is nothing in it to
|
|
682
|
+
* put right, and starting again costs the holder nothing. Only a `Refused` reason is
|
|
683
|
+
* kept: any other error may describe this backend rather than the proof.
|
|
511
684
|
*/
|
|
512
685
|
async failed(id, error) {
|
|
513
686
|
await this.transaction(async (tx) => {
|
|
@@ -542,6 +715,26 @@ export class VerilyService {
|
|
|
542
715
|
await tx.put('flows', id, flow);
|
|
543
716
|
return undefined;
|
|
544
717
|
}
|
|
718
|
+
return this.conclude(tx, flow, visibility, allowed);
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Whether approving a flow would decide anything. A renewal, or another way of showing
|
|
723
|
+
* an account the subject is already linked to, changes neither who is linked nor who
|
|
724
|
+
* can read it: the holder asked for exactly this and the account has been checked
|
|
725
|
+
* against the record, so it is recorded without asking them to say so again.
|
|
726
|
+
*/
|
|
727
|
+
async settled(tx, flow) {
|
|
728
|
+
// A flow anyone could have sent the holder's browser into is theirs only once they
|
|
729
|
+
// approve it, and the approval is a form that only their own page can post.
|
|
730
|
+
if (!flow.attended)
|
|
731
|
+
return false;
|
|
732
|
+
return flow.kind === 'renew' || (flow.kind === 'connect' && !!(await this.joins(tx, flow)));
|
|
733
|
+
}
|
|
734
|
+
/** Records what an approved flow does, and ends it. The caller has checked who approves. */
|
|
735
|
+
async conclude(tx, flow, visibility, allowed) {
|
|
736
|
+
const id = flow.id;
|
|
737
|
+
{
|
|
545
738
|
let connection;
|
|
546
739
|
const provider = this.providerOf(flow);
|
|
547
740
|
const joined = flow.kind === 'connect' ? await this.joins(tx, flow) : undefined;
|
|
@@ -603,13 +796,15 @@ export class VerilyService {
|
|
|
603
796
|
connection.visibilityApprovedAt = this.now();
|
|
604
797
|
}
|
|
605
798
|
}
|
|
799
|
+
if (['connect', 'renew'].includes(flow.kind))
|
|
800
|
+
this.recordStanding(connection, flow);
|
|
606
801
|
await tx.put('connections', connection.id, connection);
|
|
607
802
|
await this.audit(tx, connection.id, flow.kind, ['revoke', 'share-revoke'].includes(flow.kind) ? 'external' : 'local', id, connection.visibility);
|
|
608
803
|
flow.resultId = connection.id;
|
|
609
804
|
await this.ended(tx, flow, 'complete', connection);
|
|
610
805
|
await tx.put('flows', id, flow);
|
|
611
806
|
return connection.id;
|
|
612
|
-
}
|
|
807
|
+
}
|
|
613
808
|
}
|
|
614
809
|
/**
|
|
615
810
|
* Ends a flow and records how, inside the transaction that ends it: the connection as it
|
|
@@ -1149,6 +1344,8 @@ function profile(url) {
|
|
|
1149
1344
|
const dayMs = 86400000;
|
|
1150
1345
|
/** How many wrong codes a flow takes before it is dead. */
|
|
1151
1346
|
export const codeAttempts = 5;
|
|
1347
|
+
/** How many times a published proof may be refused before its flow is dead. */
|
|
1348
|
+
export const artifactAttempts = 5;
|
|
1152
1349
|
/** Crockford's base 32: no I, L, O or U, so no letter is mistaken for a digit when typed. */
|
|
1153
1350
|
const codeAlphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
1154
1351
|
/**
|
package/dist/server/youtube.js
CHANGED
|
@@ -15,7 +15,9 @@ export function youtubeProvider(options) {
|
|
|
15
15
|
authorizationUrl({ state, challenge, redirectUri }) {
|
|
16
16
|
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
17
17
|
// select_account lets a holder with several Google accounts, or a channel under a
|
|
18
|
-
// brand account, pick the one they mean rather than whichever is signed in.
|
|
18
|
+
// brand account, pick the one they mean rather than whichever is signed in. A flow on
|
|
19
|
+
// a record asks too: a channel is not a Google account, so nothing here can name the
|
|
20
|
+
// one to use, and the wrong one signed in silently would be refused with no way round.
|
|
19
21
|
url.search = new URLSearchParams({
|
|
20
22
|
response_type: 'code',
|
|
21
23
|
client_id: options.clientId,
|
package/dist/verily.js
CHANGED
|
@@ -10396,7 +10396,7 @@ ${claims(evidence)}`;
|
|
|
10396
10396
|
}
|
|
10397
10397
|
|
|
10398
10398
|
// src/version.ts
|
|
10399
|
-
var version = "v0.
|
|
10399
|
+
var version = "v0.2.0";
|
|
10400
10400
|
|
|
10401
10401
|
// src/browser/evidence-dialog.ts
|
|
10402
10402
|
var openDialogs = /* @__PURE__ */ new WeakMap();
|
|
@@ -11107,9 +11107,14 @@ ${claims(evidence)}`;
|
|
|
11107
11107
|
}, 1e3);
|
|
11108
11108
|
}
|
|
11109
11109
|
function publish(flow) {
|
|
11110
|
-
const input = flow.artifact === "document" ? node("textarea") : Object.assign(node("input"), {
|
|
11110
|
+
const input = flow.artifact === "document" ? node("textarea") : Object.assign(node("input"), {
|
|
11111
|
+
type: flow.input ?? "url",
|
|
11112
|
+
autocapitalize: "none",
|
|
11113
|
+
spellcheck: false
|
|
11114
|
+
});
|
|
11111
11115
|
input.name = "artifact";
|
|
11112
11116
|
input.required = true;
|
|
11117
|
+
input.value = flow.suggested ?? "";
|
|
11113
11118
|
if (input instanceof HTMLTextAreaElement) input.rows = 10;
|
|
11114
11119
|
const label = node("label", flow.field ?? "Your proof");
|
|
11115
11120
|
label.append(input);
|
|
@@ -11127,6 +11132,7 @@ ${claims(evidence)}`;
|
|
|
11127
11132
|
...flow.instructions.map(
|
|
11128
11133
|
(part) => typeof part === "string" ? node("p", part) : Array.isArray(part) ? paragraph(part) : codeBlock(part.code)
|
|
11129
11134
|
),
|
|
11135
|
+
...flow.refused ? [node("p", flow.refused, "error")] : [],
|
|
11130
11136
|
label,
|
|
11131
11137
|
row(back2, check2),
|
|
11132
11138
|
...flow.note ? [node("p", flow.note, "muted")] : []
|
|
@@ -11234,6 +11240,7 @@ ${claims(evidence)}`;
|
|
|
11234
11240
|
}
|
|
11235
11241
|
parts.push(choice);
|
|
11236
11242
|
}
|
|
11243
|
+
if (flow.standingNote) parts.push(node("p", flow.standingNote));
|
|
11237
11244
|
if (methods)
|
|
11238
11245
|
parts.push(
|
|
11239
11246
|
node(
|
|
@@ -11274,10 +11281,7 @@ ${claims(evidence)}`;
|
|
|
11274
11281
|
}
|
|
11275
11282
|
function done() {
|
|
11276
11283
|
settle();
|
|
11277
|
-
|
|
11278
|
-
finish.onclick = () => dialog.close();
|
|
11279
|
-
content.replaceChildren(node("p", "The connection is recorded.", "state"), row(finish));
|
|
11280
|
-
finish.focus();
|
|
11284
|
+
dialog.close();
|
|
11281
11285
|
}
|
|
11282
11286
|
close.addEventListener("click", () => dialog.close());
|
|
11283
11287
|
dialog.addEventListener("click", (event) => {
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED