@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.
Files changed (89) hide show
  1. package/README.md +67 -46
  2. package/dist/doors/doors.d.ts +1 -1
  3. package/dist/elements/auth-styles.d.ts +1 -1
  4. package/dist/elements/auth-styles.d.ts.map +1 -1
  5. package/dist/elements/auth-styles.js +13 -0
  6. package/dist/elements/auth-styles.js.map +1 -1
  7. package/dist/elements/weave-auth.d.ts +2 -2
  8. package/dist/elements/weave-auth.d.ts.map +1 -1
  9. package/dist/elements/weave-auth.js +251 -119
  10. package/dist/elements/weave-auth.js.map +1 -1
  11. package/dist/identity/account-vault.d.ts +10 -3
  12. package/dist/identity/account-vault.d.ts.map +1 -1
  13. package/dist/identity/account-vault.js +10 -3
  14. package/dist/identity/account-vault.js.map +1 -1
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/network/mailbox.d.ts +1 -1
  20. package/dist/network/mailbox.js +1 -1
  21. package/dist/network/peer-auth.d.ts +19 -0
  22. package/dist/network/peer-auth.d.ts.map +1 -1
  23. package/dist/network/peer-auth.js +22 -8
  24. package/dist/network/peer-auth.js.map +1 -1
  25. package/dist/node/actions.d.ts.map +1 -1
  26. package/dist/node/actions.js +9 -2
  27. package/dist/node/actions.js.map +1 -1
  28. package/dist/node/carrier.d.ts +16 -0
  29. package/dist/node/carrier.d.ts.map +1 -1
  30. package/dist/node/carrier.js +12 -0
  31. package/dist/node/carrier.js.map +1 -1
  32. package/dist/node/host.d.ts +2 -2
  33. package/dist/node/host.d.ts.map +1 -1
  34. package/dist/node/index.d.ts +3 -3
  35. package/dist/node/index.d.ts.map +1 -1
  36. package/dist/node/node.js +1 -1
  37. package/dist/node/node.js.map +1 -1
  38. package/dist/node/space-runtime.d.ts.map +1 -1
  39. package/dist/node/space-runtime.js +9 -1
  40. package/dist/node/space-runtime.js.map +1 -1
  41. package/dist/node/types.d.ts +6 -2
  42. package/dist/node/types.d.ts.map +1 -1
  43. package/dist/records/describe.d.ts +2 -0
  44. package/dist/records/describe.d.ts.map +1 -1
  45. package/dist/records/describe.js +5 -0
  46. package/dist/records/describe.js.map +1 -1
  47. package/dist/schema/collection-def.d.ts +16 -0
  48. package/dist/schema/collection-def.d.ts.map +1 -1
  49. package/dist/schema/collection-def.js +30 -0
  50. package/dist/schema/collection-def.js.map +1 -1
  51. package/dist/schemas/apps.d.ts +4 -1
  52. package/dist/schemas/apps.d.ts.map +1 -1
  53. package/dist/schemas/apps.js +8 -2
  54. package/dist/schemas/apps.js.map +1 -1
  55. package/dist/schemas/index.d.ts +1 -1
  56. package/dist/schemas/index.d.ts.map +1 -1
  57. package/dist/schemas/index.js +1 -1
  58. package/dist/schemas/index.js.map +1 -1
  59. package/dist/schemas/screens.d.ts +16 -4
  60. package/dist/schemas/screens.d.ts.map +1 -1
  61. package/dist/schemas/screens.js +49 -6
  62. package/dist/schemas/screens.js.map +1 -1
  63. package/dist/session/auth.d.ts +56 -10
  64. package/dist/session/auth.d.ts.map +1 -1
  65. package/dist/session/auth.js +247 -69
  66. package/dist/session/auth.js.map +1 -1
  67. package/dist/session/connect.d.ts +57 -3
  68. package/dist/session/connect.d.ts.map +1 -1
  69. package/dist/session/connect.js +49 -6
  70. package/dist/session/connect.js.map +1 -1
  71. package/dist/session/connection.d.ts +8 -1
  72. package/dist/session/connection.d.ts.map +1 -1
  73. package/dist/session/connection.js +6 -1
  74. package/dist/session/connection.js.map +1 -1
  75. package/dist/session/credentials.d.ts +22 -6
  76. package/dist/session/credentials.d.ts.map +1 -1
  77. package/dist/session/credentials.js +35 -6
  78. package/dist/session/credentials.js.map +1 -1
  79. package/dist/session/hosting.d.ts +1 -1
  80. package/dist/session/index.d.ts +5 -5
  81. package/dist/session/index.d.ts.map +1 -1
  82. package/dist/session/index.js +3 -3
  83. package/dist/session/index.js.map +1 -1
  84. package/dist/space/notify.d.ts +59 -1
  85. package/dist/space/notify.d.ts.map +1 -1
  86. package/dist/space/notify.js +91 -0
  87. package/dist/space/notify.js.map +1 -1
  88. package/dist/storage/blob/s3.js +1 -1
  89. package/package.json +52 -42
@@ -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
- * The credential is a generated 26-character code. It is the seed, written out,
12
- * which is what makes it work on a domain that has never heard of you — there is
13
- * nothing stored for it to unlock. A password manager holds it exactly as it
14
- * holds any generated password. The passkey and the short password are
15
- * conveniences layered on top, local to one site, so the code only has to be
16
- * fetched once per site.
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 account password. */
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
- /** Runs a way in, and goes to the app when it ends in a session. */
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
- update({ session, stage: 'ready' });
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
- // Nothing here yet and the storage question never answered: ask it first.
327
- const undecided = !pod && accounts.length === 0 && state.folderAvailable && get(BROWSER_CHOSEN) !== 'browser';
328
- update({ stage: undecided ? 'where' : afterStorage(accounts) });
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
- const wrap = entry?.vault.wraps.find((candidate) => candidate.kind === 'passphrase');
406
- if (!place || !entry || !wrap || wrap.kind !== 'passphrase') {
407
- throw protocolError('VAULT_UNLOCK_FAILED', 'No password is set for this account here.', 'Use your account password instead.');
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
- return begin(place, entry.summary, await unwrapSeedWithPassphrase(wrap, password));
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 account password, and you can set one up afterwards.');
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 account password still works, and a new passkey can be set up afterwards.');
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 password is the way in, and it needs none.
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
- update({ session, freshCode: seedToRecoveryCode(unlocked), accounts: await listAccounts(place) });
577
+ await arrive(session, 'new');
460
578
  });
461
579
  },
462
580
  codeSaved() {
463
- update({ freshCode: null, stage: 'ready' });
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
- await auth.signInWithCode(ticket.code);
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 as a new one starts:
521
- // openable by its password. Another site's passkey is not this one's to remove.
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
- accountPassword() {
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' });