@weaveprotocol/core 0.2.1 → 0.2.3
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 +67 -46
- package/dist/doors/doors.d.ts +1 -1
- package/dist/elements/auth-styles.d.ts +1 -1
- package/dist/elements/auth-styles.d.ts.map +1 -1
- package/dist/elements/auth-styles.js +13 -0
- package/dist/elements/auth-styles.js.map +1 -1
- package/dist/elements/weave-auth.d.ts +2 -2
- package/dist/elements/weave-auth.d.ts.map +1 -1
- package/dist/elements/weave-auth.js +251 -119
- package/dist/elements/weave-auth.js.map +1 -1
- package/dist/identity/account-vault.d.ts +10 -3
- package/dist/identity/account-vault.d.ts.map +1 -1
- package/dist/identity/account-vault.js +10 -3
- package/dist/identity/account-vault.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/network/mailbox.d.ts +1 -1
- package/dist/network/mailbox.js +1 -1
- package/dist/network/peer-auth.d.ts +19 -0
- package/dist/network/peer-auth.d.ts.map +1 -1
- package/dist/network/peer-auth.js +22 -8
- package/dist/network/peer-auth.js.map +1 -1
- package/dist/node/actions.d.ts.map +1 -1
- package/dist/node/actions.js +9 -2
- package/dist/node/actions.js.map +1 -1
- package/dist/node/carrier.d.ts +16 -0
- package/dist/node/carrier.d.ts.map +1 -1
- package/dist/node/carrier.js +12 -0
- package/dist/node/carrier.js.map +1 -1
- package/dist/node/host.d.ts +2 -2
- package/dist/node/host.d.ts.map +1 -1
- package/dist/node/index.d.ts +3 -3
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/node.js +1 -1
- package/dist/node/node.js.map +1 -1
- package/dist/node/space-runtime.d.ts.map +1 -1
- package/dist/node/space-runtime.js +9 -1
- package/dist/node/space-runtime.js.map +1 -1
- package/dist/node/types.d.ts +6 -2
- package/dist/node/types.d.ts.map +1 -1
- package/dist/records/describe.d.ts +2 -0
- package/dist/records/describe.d.ts.map +1 -1
- package/dist/records/describe.js +5 -0
- package/dist/records/describe.js.map +1 -1
- package/dist/schema/collection-def.d.ts +16 -0
- package/dist/schema/collection-def.d.ts.map +1 -1
- package/dist/schema/collection-def.js +30 -0
- package/dist/schema/collection-def.js.map +1 -1
- package/dist/schemas/apps.d.ts +4 -1
- package/dist/schemas/apps.d.ts.map +1 -1
- package/dist/schemas/apps.js +8 -2
- package/dist/schemas/apps.js.map +1 -1
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/screens.d.ts +16 -4
- package/dist/schemas/screens.d.ts.map +1 -1
- package/dist/schemas/screens.js +49 -6
- package/dist/schemas/screens.js.map +1 -1
- package/dist/session/auth.d.ts +56 -10
- package/dist/session/auth.d.ts.map +1 -1
- package/dist/session/auth.js +247 -69
- package/dist/session/auth.js.map +1 -1
- package/dist/session/connect.d.ts +57 -3
- package/dist/session/connect.d.ts.map +1 -1
- package/dist/session/connect.js +49 -6
- package/dist/session/connect.js.map +1 -1
- package/dist/session/connection.d.ts +8 -1
- package/dist/session/connection.d.ts.map +1 -1
- package/dist/session/connection.js +6 -1
- package/dist/session/connection.js.map +1 -1
- package/dist/session/credentials.d.ts +22 -6
- package/dist/session/credentials.d.ts.map +1 -1
- package/dist/session/credentials.js +35 -6
- package/dist/session/credentials.js.map +1 -1
- package/dist/session/hosting.d.ts +1 -1
- package/dist/session/index.d.ts +5 -5
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +3 -3
- package/dist/session/index.js.map +1 -1
- package/dist/space/notify.d.ts +59 -1
- package/dist/space/notify.d.ts.map +1 -1
- package/dist/space/notify.js +91 -0
- package/dist/space/notify.js.map +1 -1
- package/dist/storage/blob/s3.js +1 -1
- package/package.json +52 -42
package/dist/session/auth.js
CHANGED
|
@@ -8,12 +8,20 @@
|
|
|
8
8
|
* React hook follows it, and an app that wants its own screens can draw it
|
|
9
9
|
* itself. None of them see the seed: it stays inside this object.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* Three things, each with one job:
|
|
12
|
+
*
|
|
13
|
+
* - **The recovery code** is the account: the seed, written out as 26
|
|
14
|
+
* characters. It needs nothing stored to work, so it restores the account on
|
|
15
|
+
* a device or home that has never seen it. Shown once when the account is
|
|
16
|
+
* made, and kept somewhere safe rather than typed every day.
|
|
17
|
+
* - **A passkey or a password** is the everyday way in. Each wraps the seed in
|
|
18
|
+
* the account's vault, so it works wherever the vault is — this browser, or
|
|
19
|
+
* a pod — and nowhere else. Setting one up is part of making an account.
|
|
20
|
+
* - **Pairing** hands the account to a new device from one already signed in.
|
|
21
|
+
*
|
|
22
|
+
* So a new account goes: name → recovery code → passkey or password → (a pod,
|
|
23
|
+
* if this browser can open one) → in. Where the data lives is asked only of
|
|
24
|
+
* people who already have something somewhere, or offered at the end.
|
|
17
25
|
*
|
|
18
26
|
* The identity key never signs a record. Signing in starts a node, which makes
|
|
19
27
|
* a throwaway session key and asks the identity for one note saying that key
|
|
@@ -24,7 +32,7 @@ import { createLocalRootSigner } from '../identity/root-signer.js';
|
|
|
24
32
|
import { AGENT_FACT } from '../identity/agent-note.js';
|
|
25
33
|
import { accountDataPath, newAccountId, createBrowserAccountStore } from '../identity/account-store.js';
|
|
26
34
|
import { createVault } from '../identity/folder-account.js';
|
|
27
|
-
import { withWrap, wrapSeedWithDeviceKey, unwrapSeedWithDeviceKey, unwrapSeedWithPassphrase, deviceWrapsFor, deriveVaultKey, deriveVaultKeyBytes, } from '../identity/account-vault.js';
|
|
35
|
+
import { withWrap, wrapSeedWithDeviceKey, unwrapSeedWithDeviceKey, wrapSeedWithPassphrase, unwrapSeedWithPassphrase, deviceWrapsFor, CLI_PASSPHRASE_LABEL, deriveVaultKey, deriveVaultKeyBytes, } from '../identity/account-vault.js';
|
|
28
36
|
import { deriveContactKeyBytes, deriveMemberKeyBytes } from '../identity/contact-key.js';
|
|
29
37
|
import { createDeviceKey, getDeviceKey, deleteDeviceKey } from '../identity/device-key.js';
|
|
30
38
|
import { registerPasskey, authenticatePasskey, hasPlatformAuthenticator, renamePasskey } from '../identity/webauthn.js';
|
|
@@ -37,7 +45,10 @@ import { isProtocolError, protocolError } from '../utils/errors.js';
|
|
|
37
45
|
import { deleteBrowserData, forgetPod, inspectPod, listAccounts, pickPod, recallPod, rememberPod, storesFor, } from './places.js';
|
|
38
46
|
import { createStaySignedIn } from './stay-signed-in.js';
|
|
39
47
|
import { grantCapabilities, MAX_GRANT_DAYS } from './connect.js';
|
|
48
|
+
import { fromProposal, proposalSpaces, sameSubscription } from '../space/notify.js';
|
|
40
49
|
import { clearPairingTicket, collectFromDesktop, offerToPhone as offerPairing, readPairingTicket, } from './pairing.js';
|
|
50
|
+
/** The shortest password `setPassword` takes. It guards a copy of the seed in a folder that may be copied. */
|
|
51
|
+
export const MIN_PASSWORD_LENGTH = 10;
|
|
41
52
|
/** Turns a thrown value into something worth showing, ignoring a dismissed prompt. */
|
|
42
53
|
function describe(error) {
|
|
43
54
|
if (error instanceof DOMException && (error.name === 'NotAllowedError' || error.name === 'AbortError')) {
|
|
@@ -49,6 +60,39 @@ function describe(error) {
|
|
|
49
60
|
return { message: error instanceof Error ? error.message : 'Something went wrong' };
|
|
50
61
|
}
|
|
51
62
|
const afterStorage = (accounts) => (accounts.length > 0 ? 'signIn' : 'welcome');
|
|
63
|
+
/**
|
|
64
|
+
* The subscriptions an app's proposals become, for the ones the person kept.
|
|
65
|
+
* Checked whole before any is written: a proposal naming a space the app
|
|
66
|
+
* can't reach refuses them all, and so does a carrier naming a topic value —
|
|
67
|
+
* it can't read, so the tag it got back would tell it which records hold a
|
|
68
|
+
* value it picked.
|
|
69
|
+
*/
|
|
70
|
+
function subscriptionsFrom(proposals, kept, context) {
|
|
71
|
+
const made = [];
|
|
72
|
+
for (const [index, proposal] of proposals.entries()) {
|
|
73
|
+
if (kept && !kept.includes(index))
|
|
74
|
+
continue;
|
|
75
|
+
if (context.carrier && proposal.topic && !('me' in proposal.topic))
|
|
76
|
+
throw new Error('An extension can’t read your spaces, so it may only suggest “mentions me”, not a value of its choosing.');
|
|
77
|
+
const spaces = proposalSpaces(proposal, context.reach);
|
|
78
|
+
if (!spaces)
|
|
79
|
+
throw new Error(`“${proposal.label}” looks at a space it was not given.`);
|
|
80
|
+
made.push(fromProposal(proposal, { app: context.app, spaces, account: context.account }));
|
|
81
|
+
}
|
|
82
|
+
return made;
|
|
83
|
+
}
|
|
84
|
+
/** Adds each, unless the same origin already has the same one */
|
|
85
|
+
async function addSubscriptions(node, origin, subscriptions) {
|
|
86
|
+
if (subscriptions.length === 0)
|
|
87
|
+
return [];
|
|
88
|
+
const existing = (await node.notifications.list()).filter((sub) => sub.app?.origin === origin);
|
|
89
|
+
const added = [];
|
|
90
|
+
for (const when of subscriptions) {
|
|
91
|
+
const made = existing.find((sub) => sameSubscription(sub, when)) ?? (await node.notifications.add(when));
|
|
92
|
+
added.push({ id: made.id, label: made.label });
|
|
93
|
+
}
|
|
94
|
+
return added;
|
|
95
|
+
}
|
|
52
96
|
/**
|
|
53
97
|
* Creates the sign-in flow for this page.
|
|
54
98
|
* @param config Where to connect once signed in, and what to call the app
|
|
@@ -62,7 +106,6 @@ export function createWeaveAuth(config = {}) {
|
|
|
62
106
|
const browserAccounts = config.browser?.accounts ?? createBrowserAccountStore;
|
|
63
107
|
const browserStores = config.browser?.stores ?? ((account) => storesFor(account));
|
|
64
108
|
const LAST_ACCOUNT = `${prefix}.last-account`;
|
|
65
|
-
const BROWSER_CHOSEN = `${prefix}.storage-choice`;
|
|
66
109
|
const get = (key) => {
|
|
67
110
|
try {
|
|
68
111
|
return storage?.getItem(key) ?? null;
|
|
@@ -87,6 +130,7 @@ export function createWeaveAuth(config = {}) {
|
|
|
87
130
|
entry: null,
|
|
88
131
|
session: null,
|
|
89
132
|
freshCode: null,
|
|
133
|
+
setup: null,
|
|
90
134
|
busy: false,
|
|
91
135
|
error: null,
|
|
92
136
|
folderAvailable: isFolderStorageAvailable(),
|
|
@@ -101,7 +145,7 @@ export function createWeaveAuth(config = {}) {
|
|
|
101
145
|
for (const listener of listeners)
|
|
102
146
|
listener(state);
|
|
103
147
|
};
|
|
104
|
-
/** The seed behind the open session. Never leaves this closure except as the
|
|
148
|
+
/** The seed behind the open session. Never leaves this closure except as the recovery code. */
|
|
105
149
|
let seed = null;
|
|
106
150
|
let vaultKey = null;
|
|
107
151
|
let stopFollowingName = null;
|
|
@@ -221,11 +265,84 @@ export function createWeaveAuth(config = {}) {
|
|
|
221
265
|
update({ busy: false });
|
|
222
266
|
}
|
|
223
267
|
}
|
|
224
|
-
/**
|
|
268
|
+
/** Whether an account can be opened here without its recovery code. */
|
|
269
|
+
const hasEverydayWay = (entry) => entry !== null && (entry.shortcuts.length > 0 || entry.hasPassword);
|
|
270
|
+
/**
|
|
271
|
+
* Lands a new session: in, or on through the setup screens.
|
|
272
|
+
*
|
|
273
|
+
* With no reason to set up, or a passkey or password already here, that is
|
|
274
|
+
* the app. Otherwise the recovery code is shown first — a new account's for
|
|
275
|
+
* the first time, a restored one's as a reminder to keep it apart from the
|
|
276
|
+
* password about to be set, which a password manager may file in its place —
|
|
277
|
+
* then a way in is chosen. A paired device skips the code: it was never
|
|
278
|
+
* shown one, and the device it came from keeps it.
|
|
279
|
+
*/
|
|
280
|
+
async function arrive(session, setup) {
|
|
281
|
+
const place = state.place;
|
|
282
|
+
const entry = place ? await openEntry(place, session.account.id) : null;
|
|
283
|
+
const landed = {
|
|
284
|
+
session,
|
|
285
|
+
entry,
|
|
286
|
+
selectedId: session.account.id,
|
|
287
|
+
accounts: place ? await listAccounts(place) : state.accounts,
|
|
288
|
+
};
|
|
289
|
+
if (setup === null || hasEverydayWay(entry)) {
|
|
290
|
+
update({ ...landed, stage: 'ready', setup: null, freshCode: null });
|
|
291
|
+
}
|
|
292
|
+
else if (setup === 'paired') {
|
|
293
|
+
update({ ...landed, stage: 'unlock', setup });
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
update({ ...landed, stage: 'recovery', setup, freshCode: seed ? seedToRecoveryCode(seed) : null });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** After a way in is set up: offer a pod to a new account that could use one, or go in. */
|
|
300
|
+
function afterUnlock() {
|
|
301
|
+
const offerPod = state.setup === 'new' && state.folderAvailable && state.place?.kind === 'browser';
|
|
302
|
+
update(offerPod ? { stage: 'pod' } : { stage: 'ready', setup: null });
|
|
303
|
+
}
|
|
304
|
+
/** Runs a way in. One that took the recovery code may lead on to setting up an everyday one. */
|
|
225
305
|
const enter = (open) => run(async () => {
|
|
226
|
-
const session = await open();
|
|
227
|
-
|
|
306
|
+
const { session, byCode } = await open();
|
|
307
|
+
await arrive(session, byCode ? 'restored' : null);
|
|
228
308
|
}).then(() => undefined);
|
|
309
|
+
/**
|
|
310
|
+
* Opens an account from its recovery code, filing it here if this place has
|
|
311
|
+
* not seen it.
|
|
312
|
+
* @param code The code, as typed or filled
|
|
313
|
+
* @param expected The account the person meant, when they picked one
|
|
314
|
+
*/
|
|
315
|
+
async function openWithCode(code, expected) {
|
|
316
|
+
const place = state.place;
|
|
317
|
+
if (!place)
|
|
318
|
+
throw new Error('No place to sign in to yet.');
|
|
319
|
+
if (!isValidRecoveryCode(code)) {
|
|
320
|
+
throw protocolError('VAULT_UNLOCK_FAILED', 'That is not a recovery code.', 'A recovery code is 26 letters and digits, usually in groups of four. A password only opens the account where it was ' +
|
|
321
|
+
'set up — on a new device, use the recovery code, or add this device from one that is signed in.');
|
|
322
|
+
}
|
|
323
|
+
const unlocked = recoveryCodeToSeed(code);
|
|
324
|
+
const identity = await createIdentityManager().fromSeed(unlocked);
|
|
325
|
+
if (expected && identity.did !== expected.did) {
|
|
326
|
+
throw protocolError('VAULT_UNLOCK_FAILED', `That recovery code opens a different account, not ${expected.name}.`, `Check it is the code you kept for ${expected.name}, or restore the account it belongs to instead.`);
|
|
327
|
+
}
|
|
328
|
+
const known = (await listAccounts(place)).find((account) => account.did === identity.did);
|
|
329
|
+
if (known)
|
|
330
|
+
return begin(place, known, unlocked);
|
|
331
|
+
// New to this place — a new device, or a new home. File it, so there is
|
|
332
|
+
// somewhere to keep the passkey or password set up next.
|
|
333
|
+
const id = newAccountId();
|
|
334
|
+
const summary = {
|
|
335
|
+
id,
|
|
336
|
+
name: expected?.name ?? 'My account',
|
|
337
|
+
did: identity.did,
|
|
338
|
+
createdAt: new Date().toISOString(),
|
|
339
|
+
dataPath: accountDataPath(id),
|
|
340
|
+
};
|
|
341
|
+
await place.store.write(summary, createVault({ did: identity.did, label: summary.name, wraps: [] }));
|
|
342
|
+
return begin(place, summary, unlocked);
|
|
343
|
+
}
|
|
344
|
+
/** The account the person picked, if any */
|
|
345
|
+
const selectedAccount = () => state.accounts.find((account) => account.id === state.selectedId) ?? null;
|
|
229
346
|
// ─── Passkeys ──────────────────────────────────────────────────────
|
|
230
347
|
/**
|
|
231
348
|
* Runs a passkey ceremony as a gate.
|
|
@@ -283,12 +400,14 @@ export function createWeaveAuth(config = {}) {
|
|
|
283
400
|
update({ session: Object.freeze({ ...session, account: renamed }), accounts: await listAccounts(place) });
|
|
284
401
|
return renamed;
|
|
285
402
|
}
|
|
286
|
-
/** Changes to the open account's shortcuts, then re-reads them. */
|
|
403
|
+
/** Changes to the open account's shortcuts, then re-reads them. During setup, moves the flow on. */
|
|
287
404
|
const changeShortcut = (apply) => run(async () => {
|
|
288
405
|
if (!seed)
|
|
289
406
|
throw new Error('Not signed in.');
|
|
290
407
|
await apply(seed);
|
|
291
408
|
await refreshEntry();
|
|
409
|
+
if (state.stage === 'unlock' && hasEverydayWay(state.entry))
|
|
410
|
+
afterUnlock();
|
|
292
411
|
});
|
|
293
412
|
/** Connected apps are remembered per account, on this device */
|
|
294
413
|
function writeConnections(connections) {
|
|
@@ -323,9 +442,10 @@ export function createWeaveAuth(config = {}) {
|
|
|
323
442
|
update({ stage: 'pair' });
|
|
324
443
|
return;
|
|
325
444
|
}
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
445
|
+
// Where the data lives is not asked up front: someone new has
|
|
446
|
+
// nothing anywhere, and someone who does finds their pod under
|
|
447
|
+
// "I already have an account".
|
|
448
|
+
update({ stage: afterStorage(accounts) });
|
|
329
449
|
}
|
|
330
450
|
catch (error) {
|
|
331
451
|
update({ error: describe(error), stage: 'welcome' });
|
|
@@ -334,7 +454,7 @@ export function createWeaveAuth(config = {}) {
|
|
|
334
454
|
return started;
|
|
335
455
|
},
|
|
336
456
|
async choosePod() {
|
|
337
|
-
await run(async () => {
|
|
457
|
+
const picked = await run(async () => {
|
|
338
458
|
const pod = await pickPod();
|
|
339
459
|
const { session, place } = state;
|
|
340
460
|
// Signed in: look before leaping. The picker needed the click, but
|
|
@@ -349,9 +469,17 @@ export function createWeaveAuth(config = {}) {
|
|
|
349
469
|
await rememberPod(pod);
|
|
350
470
|
update({ stage: afterStorage(await refresh(pod)) });
|
|
351
471
|
});
|
|
472
|
+
// Setting up a new account there is nothing to decide: no pod has it
|
|
473
|
+
// yet, so it moves, and the browser copy it leaves is only a shell.
|
|
474
|
+
if (picked && state.stage === 'pod' && state.podChoice) {
|
|
475
|
+
await auth.confirmPod('combine');
|
|
476
|
+
if (state.podChoice)
|
|
477
|
+
return;
|
|
478
|
+
await auth.forgetBrowserCopy();
|
|
479
|
+
update({ stage: 'ready', setup: null });
|
|
480
|
+
}
|
|
352
481
|
},
|
|
353
482
|
async useBrowser() {
|
|
354
|
-
set(BROWSER_CHOSEN, 'browser');
|
|
355
483
|
await run(async () => {
|
|
356
484
|
if (state.place?.kind === 'folder')
|
|
357
485
|
await forgetPod();
|
|
@@ -359,54 +487,33 @@ export function createWeaveAuth(config = {}) {
|
|
|
359
487
|
update({ stage: afterStorage(await refresh(place)) });
|
|
360
488
|
});
|
|
361
489
|
},
|
|
362
|
-
changeStorage() {
|
|
363
|
-
update({ error: null, stage: 'where' });
|
|
364
|
-
},
|
|
365
490
|
async select(accountId) {
|
|
366
491
|
update({ selectedId: accountId, error: null });
|
|
367
492
|
if (state.place)
|
|
368
493
|
update({ entry: await openEntry(state.place, accountId) });
|
|
369
494
|
},
|
|
370
495
|
signInWithCode(code) {
|
|
371
|
-
return enter(async () => {
|
|
372
|
-
const place = state.place;
|
|
373
|
-
if (!place)
|
|
374
|
-
throw new Error('No place to sign in to yet.');
|
|
375
|
-
if (!isValidRecoveryCode(code)) {
|
|
376
|
-
throw protocolError('VAULT_UNLOCK_FAILED', 'That does not look like an account password.', 'It is 26 letters and digits, usually shown in groups of four.');
|
|
377
|
-
}
|
|
378
|
-
const unlocked = recoveryCodeToSeed(code);
|
|
379
|
-
const identity = await createIdentityManager().fromSeed(unlocked);
|
|
380
|
-
const expected = state.accounts.find((account) => account.id === state.selectedId);
|
|
381
|
-
if (expected && identity.did !== expected.did) {
|
|
382
|
-
throw protocolError('VAULT_UNLOCK_FAILED', `That password opens a different account, not ${expected.name}.`, 'With more than one account saved for this site, a password manager fills whichever it saw last ' +
|
|
383
|
-
`unless you pick. Choose the entry named ${expected.name}, or open the account it did fill.`);
|
|
384
|
-
}
|
|
385
|
-
const known = (await listAccounts(place)).find((account) => account.did === identity.did);
|
|
386
|
-
if (known)
|
|
387
|
-
return begin(place, known, unlocked);
|
|
388
|
-
// New to this place — the second-site case. File it, so next time there
|
|
389
|
-
// is something to add a passkey to.
|
|
390
|
-
const id = newAccountId();
|
|
391
|
-
const summary = {
|
|
392
|
-
id,
|
|
393
|
-
name: expected?.name ?? 'My account',
|
|
394
|
-
did: identity.did,
|
|
395
|
-
createdAt: new Date().toISOString(),
|
|
396
|
-
dataPath: accountDataPath(id),
|
|
397
|
-
};
|
|
398
|
-
await place.store.write(summary, createVault({ did: identity.did, label: summary.name, wraps: [] }));
|
|
399
|
-
return begin(place, summary, unlocked);
|
|
400
|
-
});
|
|
496
|
+
return enter(async () => ({ session: await openWithCode(code, selectedAccount()), byCode: true }));
|
|
401
497
|
},
|
|
402
498
|
signInWithPassword(password) {
|
|
403
499
|
return enter(async () => {
|
|
404
500
|
const { place, entry } = state;
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
501
|
+
// The account's password, and the CLI's passphrase if a pod has one: either opens it.
|
|
502
|
+
const wraps = (entry?.vault.wraps ?? []).filter((candidate) => candidate.kind === 'passphrase');
|
|
503
|
+
// Before passwords, the recovery code was this site's login, and a
|
|
504
|
+
// password manager still fills it here. It opens the account either way.
|
|
505
|
+
const code = isValidRecoveryCode(password) ? password : null;
|
|
506
|
+
let failed = null;
|
|
507
|
+
for (const wrap of place && entry ? wraps : []) {
|
|
508
|
+
const unlocked = await unwrapSeedWithPassphrase(wrap, password).catch((error) => ((failed = error), null));
|
|
509
|
+
if (unlocked)
|
|
510
|
+
return { session: await begin(place, entry.summary, unlocked), byCode: false };
|
|
408
511
|
}
|
|
409
|
-
|
|
512
|
+
if (failed && !code)
|
|
513
|
+
throw failed;
|
|
514
|
+
if (code)
|
|
515
|
+
return { session: await openWithCode(code, entry?.summary ?? selectedAccount()), byCode: true };
|
|
516
|
+
throw protocolError('VAULT_UNLOCK_FAILED', 'No password is set for this account here.', 'Use your recovery code, and you can set one up afterwards.');
|
|
410
517
|
});
|
|
411
518
|
},
|
|
412
519
|
signInWithPasskey() {
|
|
@@ -414,16 +521,16 @@ export function createWeaveAuth(config = {}) {
|
|
|
414
521
|
const { place, entry } = state;
|
|
415
522
|
const target = entry?.shortcuts[entry.shortcuts.length - 1];
|
|
416
523
|
if (!place || !entry || !target) {
|
|
417
|
-
throw protocolError('VAULT_UNLOCK_FAILED', 'No passkey on this device can open that account.', 'Use your
|
|
524
|
+
throw protocolError('VAULT_UNLOCK_FAILED', 'No passkey on this device can open that account.', 'Use your recovery code, and you can set one up afterwards.');
|
|
418
525
|
}
|
|
419
526
|
// The gate first, so the key is never reached for without someone present.
|
|
420
527
|
if (target.credentialId)
|
|
421
528
|
await passkeyGate('get', { credentialId: target.credentialId });
|
|
422
529
|
const key = await getDeviceKey(target.deviceKeyId);
|
|
423
530
|
if (!key) {
|
|
424
|
-
throw protocolError('VAULT_UNLOCK_FAILED', 'The key for that passkey is not in this browser any more.', 'Clearing site data removes it. Your
|
|
531
|
+
throw protocolError('VAULT_UNLOCK_FAILED', 'The key for that passkey is not in this browser any more.', 'Clearing site data removes it. Your recovery code still works, and a new passkey can be set up afterwards.');
|
|
425
532
|
}
|
|
426
|
-
return begin(place, entry.summary, await unwrapSeedWithDeviceKey(target, key));
|
|
533
|
+
return { session: await begin(place, entry.summary, await unwrapSeedWithDeviceKey(target, key)), byCode: false };
|
|
427
534
|
});
|
|
428
535
|
},
|
|
429
536
|
startCreating() {
|
|
@@ -432,13 +539,20 @@ export function createWeaveAuth(config = {}) {
|
|
|
432
539
|
showSignIn() {
|
|
433
540
|
update({ error: null, stage: 'signIn' });
|
|
434
541
|
},
|
|
542
|
+
showWelcome() {
|
|
543
|
+
update({ error: null, stage: 'welcome' });
|
|
544
|
+
},
|
|
545
|
+
showExisting() {
|
|
546
|
+
update({ error: null, stage: 'existing' });
|
|
547
|
+
},
|
|
548
|
+
showRestore() {
|
|
549
|
+
update({ error: null, stage: 'restore' });
|
|
550
|
+
},
|
|
435
551
|
async createAccount(name) {
|
|
436
552
|
await run(async () => {
|
|
437
553
|
const place = state.place;
|
|
438
554
|
if (!place)
|
|
439
555
|
throw new Error('No place to keep the account yet.');
|
|
440
|
-
// The seed is random, and the password shown afterwards is that seed
|
|
441
|
-
// written out — not a backup of it.
|
|
442
556
|
const unlocked = generateSeed();
|
|
443
557
|
const identity = await createIdentityManager().fromSeed(unlocked);
|
|
444
558
|
const id = newAccountId();
|
|
@@ -449,18 +563,29 @@ export function createWeaveAuth(config = {}) {
|
|
|
449
563
|
createdAt: new Date().toISOString(),
|
|
450
564
|
dataPath: accountDataPath(id),
|
|
451
565
|
};
|
|
452
|
-
// No wraps yet: the
|
|
566
|
+
// No wraps yet: the passkey or password comes after the recovery code.
|
|
453
567
|
await place.store.write(summary, createVault({ did: identity.did, label: summary.name, wraps: [] }));
|
|
568
|
+
// Kept only in this browser, the account is only as safe as the
|
|
569
|
+
// browser's willingness to keep it. Asking costs nothing.
|
|
570
|
+
if (place.kind === 'browser')
|
|
571
|
+
await globalThis.navigator?.storage?.persist?.().catch(() => false);
|
|
454
572
|
const session = await begin(place, summary, unlocked);
|
|
455
573
|
// The name travels with the account, so the next site it is opened on
|
|
456
574
|
// shows it. Only at creation and on rename — never on a plain start,
|
|
457
575
|
// where a device that has not synced yet would publish a stale name.
|
|
458
576
|
await session.node.account.setName(summary.name).catch(() => { });
|
|
459
|
-
|
|
577
|
+
await arrive(session, 'new');
|
|
460
578
|
});
|
|
461
579
|
},
|
|
462
580
|
codeSaved() {
|
|
463
|
-
|
|
581
|
+
if (!state.session)
|
|
582
|
+
return;
|
|
583
|
+
update({ freshCode: null, stage: 'unlock' });
|
|
584
|
+
},
|
|
585
|
+
finishSetup() {
|
|
586
|
+
if (!state.session)
|
|
587
|
+
return;
|
|
588
|
+
update({ stage: 'ready', setup: null, error: null });
|
|
464
589
|
},
|
|
465
590
|
async acceptPairing() {
|
|
466
591
|
const ticket = state.pairing;
|
|
@@ -468,9 +593,10 @@ export function createWeaveAuth(config = {}) {
|
|
|
468
593
|
return;
|
|
469
594
|
if (!state.place)
|
|
470
595
|
update({ place: await browserPlace() });
|
|
471
|
-
|
|
596
|
+
// The link says which account; whichever was last used here is beside the point.
|
|
597
|
+
const opened = await run(async () => arrive(await openWithCode(ticket.code, null), 'paired'));
|
|
472
598
|
const session = state.session;
|
|
473
|
-
if (!session)
|
|
599
|
+
if (!opened || !session)
|
|
474
600
|
return;
|
|
475
601
|
clearPairingTicket();
|
|
476
602
|
await collectFromDesktop(session.node, ticket, (stage) => update({ pairingStage: stage }));
|
|
@@ -510,6 +636,17 @@ export function createWeaveAuth(config = {}) {
|
|
|
510
636
|
for (const stale of previous)
|
|
511
637
|
await deleteDeviceKey(stale.deviceKeyId);
|
|
512
638
|
}),
|
|
639
|
+
setPassword: (password) => changeShortcut(async (unlocked) => {
|
|
640
|
+
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
641
|
+
throw new Error(`Use at least ${MIN_PASSWORD_LENGTH} characters. Your password manager can make one for you.`);
|
|
642
|
+
}
|
|
643
|
+
const wrap = await wrapSeedWithPassphrase(unlocked, password, 'Password');
|
|
644
|
+
// One password: the new one replaces the last. The CLI's passphrase is its own, and stays.
|
|
645
|
+
await updateVault(async (vault) => ({
|
|
646
|
+
...vault,
|
|
647
|
+
wraps: [...vault.wraps.filter((kept) => kept.kind !== 'passphrase' || kept.label === CLI_PASSPHRASE_LABEL), wrap],
|
|
648
|
+
}));
|
|
649
|
+
}),
|
|
513
650
|
removeShortcut: (kind) => changeShortcut(async () => {
|
|
514
651
|
const session = state.session;
|
|
515
652
|
if (kind === 'passkey') {
|
|
@@ -517,8 +654,8 @@ export function createWeaveAuth(config = {}) {
|
|
|
517
654
|
for (const wrap of vault ? deviceWrapsFor(vault, rpId) : [])
|
|
518
655
|
await deleteDeviceKey(wrap.deviceKeyId);
|
|
519
656
|
}
|
|
520
|
-
// Removing every shortcut leaves the account
|
|
521
|
-
//
|
|
657
|
+
// Removing every shortcut leaves the account openable by its recovery
|
|
658
|
+
// code alone. Another site's passkey is not this one's to remove.
|
|
522
659
|
await updateVault(async (vault) => ({
|
|
523
660
|
...vault,
|
|
524
661
|
wraps: vault.wraps.filter((wrap) => kind === 'passkey' ? !(wrap.kind === 'device' && wrap.rpId === rpId) : wrap.kind !== 'passphrase'),
|
|
@@ -600,9 +737,12 @@ export function createWeaveAuth(config = {}) {
|
|
|
600
737
|
dismissMoved() {
|
|
601
738
|
update({ moved: null });
|
|
602
739
|
},
|
|
603
|
-
|
|
740
|
+
recoveryCode() {
|
|
604
741
|
return seed ? seedToRecoveryCode(seed) : null;
|
|
605
742
|
},
|
|
743
|
+
accountPassword() {
|
|
744
|
+
return auth.recoveryCode();
|
|
745
|
+
},
|
|
606
746
|
offerToPhone(onStage) {
|
|
607
747
|
const session = state.session;
|
|
608
748
|
if (!session || !seed)
|
|
@@ -630,12 +770,19 @@ export function createWeaveAuth(config = {}) {
|
|
|
630
770
|
if (agent && request.contacts)
|
|
631
771
|
throw new Error('An agent is not given your contacts.');
|
|
632
772
|
const whole = request.scope === 'account';
|
|
773
|
+
const app = { origin: choice.origin, ...(request.name ? { name: request.name.slice(0, 80) } : {}) };
|
|
774
|
+
// A proposal looking at a space the app isn't given is refused before anything is made. (It can't name one made for it.)
|
|
775
|
+
if (!agent && !whole)
|
|
776
|
+
subscriptionsFrom(request.notify ?? [], choice.notify, { app, reach: choice.spaceIds, carrier: false, account: session.did });
|
|
633
777
|
const created = [];
|
|
634
778
|
for (const params of request.create ?? [])
|
|
635
779
|
created.push(await node.spaces.create(params));
|
|
636
780
|
// With the whole account the app derives the contacts space itself; otherwise it is one more space it is given.
|
|
637
781
|
const contactsSpace = request.contacts && !whole ? await node.contacts.space() : null;
|
|
638
782
|
const ids = [...new Set([...choice.spaceIds, ...created.map((space) => space.id), ...(contactsSpace ? [contactsSpace] : [])])];
|
|
783
|
+
// Subscriptions the app proposed and the person kept.
|
|
784
|
+
const looked = whole ? 'all' : ids.filter((id) => id !== contactsSpace);
|
|
785
|
+
const proposed = agent || (looked !== 'all' && looked.length === 0) ? [] : subscriptionsFrom(request.notify ?? [], choice.notify, { app, reach: looked, carrier: false, account: session.did });
|
|
639
786
|
const spaces = [];
|
|
640
787
|
for (const id of ids) {
|
|
641
788
|
const space = await node.spaces.get(id);
|
|
@@ -672,6 +819,8 @@ export function createWeaveAuth(config = {}) {
|
|
|
672
819
|
// Connecting again replaces the old note. An agent is its own key: connecting one replaces only that one.
|
|
673
820
|
const replaced = (known) => (agent ? known.audience === request.audience : known.origin === choice.origin && !known.agent);
|
|
674
821
|
writeConnections([connection, ...auth.connections().filter((known) => !replaced(known))]);
|
|
822
|
+
// Written by the home for the app. Connecting again adds nothing twice.
|
|
823
|
+
const notify = await addSubscriptions(node, choice.origin, proposed);
|
|
675
824
|
return {
|
|
676
825
|
v: 1,
|
|
677
826
|
did: session.did,
|
|
@@ -687,8 +836,30 @@ export function createWeaveAuth(config = {}) {
|
|
|
687
836
|
...(config.network?.relays?.length ? { relays: [...config.network.relays] } : {}),
|
|
688
837
|
expiresAt,
|
|
689
838
|
...(agent ? { agent: true } : {}),
|
|
839
|
+
...(notify.length ? { notify } : {}),
|
|
690
840
|
};
|
|
691
841
|
},
|
|
842
|
+
async propose({ origin, request, notify: kept }) {
|
|
843
|
+
const session = state.session;
|
|
844
|
+
if (!session)
|
|
845
|
+
throw new Error('Sign in first.');
|
|
846
|
+
const { node } = session;
|
|
847
|
+
const connection = auth.connections().find((known) => known.origin === origin && !known.agent);
|
|
848
|
+
if (!connection)
|
|
849
|
+
throw new Error('Only an app or extension connected to your account here may suggest what to notify you about. Connect it first.');
|
|
850
|
+
const carrier = connection.access === 'carry';
|
|
851
|
+
// What it may reach: every space for a carrier or a whole-account app, else the spaces it was given, less the contacts space.
|
|
852
|
+
let reach = 'all';
|
|
853
|
+
if (!carrier && connection.scope === 'spaces') {
|
|
854
|
+
const contactsSpace = connection.spaces.length ? await node.contacts.space().catch(() => null) : null;
|
|
855
|
+
reach = connection.spaces.map((space) => space.id).filter((id) => id !== contactsSpace);
|
|
856
|
+
if (reach.length === 0)
|
|
857
|
+
throw new Error('It was given no spaces to notify you about.');
|
|
858
|
+
}
|
|
859
|
+
const app = { origin, ...(connection.name ? { name: connection.name } : request.name ? { name: request.name.slice(0, 80) } : {}) };
|
|
860
|
+
const when = subscriptionsFrom(request.notify, kept, { app, reach, carrier, account: session.did });
|
|
861
|
+
return { v: 1, kind: 'proposed', notify: await addSubscriptions(node, origin, when) };
|
|
862
|
+
},
|
|
692
863
|
async grantCarry({ origin, request }) {
|
|
693
864
|
const session = state.session;
|
|
694
865
|
if (!session || !seed)
|
|
@@ -773,6 +944,13 @@ export function createWeaveAuth(config = {}) {
|
|
|
773
944
|
if (connection.scope === 'account')
|
|
774
945
|
await node.account.revoke(connection.token).catch(() => { });
|
|
775
946
|
}
|
|
947
|
+
// The app's own subscriptions go with it, and a carrier's: what they look for is theirs to know.
|
|
948
|
+
if (!connection.agent && node) {
|
|
949
|
+
for (const sub of await node.notifications.list()) {
|
|
950
|
+
if (sub.app?.origin === connection.origin)
|
|
951
|
+
await node.notifications.remove(sub.id).catch(() => { });
|
|
952
|
+
}
|
|
953
|
+
}
|
|
776
954
|
}
|
|
777
955
|
writeConnections(auth.connections().filter((known) => !goes(known)));
|
|
778
956
|
},
|
|
@@ -784,7 +962,7 @@ export function createWeaveAuth(config = {}) {
|
|
|
784
962
|
async signOut() {
|
|
785
963
|
await stay.forget();
|
|
786
964
|
const leaving = state.session?.node;
|
|
787
|
-
update({ stage: 'starting', session: null, entry: null, freshCode: null, podChoice: null, moved: null });
|
|
965
|
+
update({ stage: 'starting', session: null, entry: null, freshCode: null, setup: null, podChoice: null, moved: null });
|
|
788
966
|
await stopNode(leaving).catch(() => { });
|
|
789
967
|
const place = state.place;
|
|
790
968
|
update({ stage: place ? afterStorage(await refresh(place)) : 'welcome' });
|