@synonymdev/pubky 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -219,6 +219,18 @@ like a bearer token. Delegated browser grant sessions should use
219
219
  > applications should use grant auth plus `browserSessionStore` or
220
220
  > `exportLocalSecret()`.
221
221
 
222
+ **Multiple browser tabs**
223
+
224
+ `browserSessionStore.restore(id)` shares one bearer per grant across tabs on the
225
+ same origin, including after reloads. It uses the existing single-session protocol.
226
+ Browser persistence requires IndexedDB and Web Locks in a secure context.
227
+
228
+ Signout revokes the grant for every tab. Separate applications should obtain
229
+ their own grants: restoring the same exported grant outside browser coordination
230
+ replaces its current bearer.
231
+ See [grant session lifecycle](../../../../docs/grant-session-lifecycle.md)
232
+ for compatibility and expiry behavior.
233
+
222
234
  **Approve a pubkyauth request URL**
223
235
 
224
236
  ```js
@@ -281,19 +293,31 @@ legacy `callback` parameter is accepted as an `xSuccess` fallback.
281
293
 
282
294
  #### Resume an auth flow after page refresh
283
295
 
284
- Grant auth flows are resumable by saving the pending flow state before refresh and restoring it afterwards:
296
+ Save pending grant auth state before a refresh, then restore it afterwards.
297
+ The browser example below uses a delegated signing key stored in IndexedDB.
298
+ It requires a secure context with WebCrypto Ed25519 support and IndexedDB.
299
+
300
+ For signed approvals, saved flow state includes a temporary HPKE private key
301
+ that decrypts the signer's response. The authorization URL carries the matching
302
+ public key in `epk`; a domain-separated hash of that key identifies the relay
303
+ channel. Keep the saved state confidential and delete it when the flow
304
+ completes or is abandoned.
285
305
 
286
306
  ```js
287
- const flow = pubky.startGrantAuthFlow(caps, AuthFlowKind.signin(), {
307
+ import { GrantAuthFlow } from "@synonymdev/pubky";
308
+
309
+ // Before refreshing:
310
+ const flow = await GrantAuthFlow.startDelegated(caps, AuthFlowKind.signin(), {
288
311
  clientId: "my-cool-app.example",
289
312
  relay,
290
313
  });
291
- sessionStorage.setItem("pubky-grant-auth", flow.save());
314
+ sessionStorage.setItem("pubky-grant-auth", flow.saveDelegated());
292
315
 
316
+ // After reloading:
293
317
  const saved = sessionStorage.getItem("pubky-grant-auth");
294
318
  if (saved) {
295
319
  try {
296
- const resumed = pubky.resumeGrantAuthFlow(saved);
320
+ const resumed = await pubky.resumeDelegatedGrantAuthFlow(saved);
297
321
  const session = await resumed.awaitApproval();
298
322
  } finally {
299
323
  sessionStorage.removeItem("pubky-grant-auth");
@@ -301,7 +325,96 @@ if (saved) {
301
325
  }
302
326
  ```
303
327
 
304
- Legacy cookie auth flows can be resumed with `pubky.resumeCookieAuthFlow(authorizationUrl)`. Store pending auth state in `sessionStorage`, not `localStorage`, and delete it once the flow completes or is abandoned.
328
+ For local signing keys, use `GrantAuthFlow.start()`, `flow.saveLocal()`, and
329
+ `pubky.resumeGrantAuthFlow(saved)`.
330
+
331
+ Legacy cookie auth flows can be resumed with
332
+ `pubky.resumeCookieAuthFlow(authorizationUrl)`. Store pending auth state in
333
+ `sessionStorage`, not `localStorage`, and delete it once the flow completes or
334
+ is abandoned.
335
+
336
+ #### Request encryption keys
337
+
338
+ Apps can also ask the signer for encryption keys scoped to storage paths. Set
339
+ `approvalFormat: "signedApprovalV1"` and add the `e` action to each scope that
340
+ needs keys. Storage actions `r` and `w` don't deliver keys.
341
+
342
+ Keys stay usable after the grant expires or is revoked, and older homeservers
343
+ reject grants containing `e`. Read the
344
+ [scoped encryption keys guide](../../../../docs/scoped-encryption-keys.md)
345
+ before you use them.
346
+
347
+ ```js
348
+ const flow = await pubky.startGrantAuthFlow("/pub/chat/:rwe", AuthFlowKind.signin(), {
349
+ clientId: "chat.example",
350
+ approvalFormat: "signedApprovalV1",
351
+ });
352
+ renderQr(flow.authorizationUrl);
353
+ const session = await flow.awaitApproval();
354
+
355
+ const keys = session.grant.encryptionKeys; // undefined for bare grants
356
+ let key;
357
+ try {
358
+ // The signer may narrow scopes or decline `e`.
359
+ if (!keys.scopes.includes("/pub/chat/")) {
360
+ throw new Error("The signer didn't approve chat keys.");
361
+ }
362
+ key = await keys.deriveCryptoKeyForPath("/pub/chat/message");
363
+ } finally {
364
+ keys.free(); // The CryptoKey stays usable.
365
+ }
366
+
367
+ const iv = crypto.getRandomValues(new Uint8Array(12)); // Fresh IV every time.
368
+ const ciphertext = await crypto.subtle.encrypt(
369
+ { name: "AES-GCM", iv }, key, new TextEncoder().encode("Hello"),
370
+ );
371
+ ```
372
+
373
+ `EncryptionKeys` has these members:
374
+
375
+ | API | Returns |
376
+ | --- | --- |
377
+ | `keys.scopes` | Approved scope paths as `string[]` |
378
+ | `keys.deriveForPath(path)` | Raw key bytes as `Uint8Array`. Clear them after use. |
379
+ | `await keys.deriveCryptoKeyForPath(path)` | Non-extractable AES-GCM-256 `CryptoKey`. Requires WebCrypto in a secure context. |
380
+
381
+ Both derivation methods take a canonical file path and reject directories and
382
+ paths outside the approved scopes. Each read of `session.grant.encryptionKeys`
383
+ returns a separate copy; call `free()` when you're done with it. Signed
384
+ approvals without `e` scopes return an object with empty scopes.
385
+
386
+ `exportLocalSecret()` and `browserSessionStore.save()` keep the keys. The
387
+ browser store saves them as plaintext in IndexedDB, readable by any script on
388
+ the same origin. You can recover keys offline, even after the grant expires or
389
+ is revoked. Recovery creates no session:
390
+
391
+ ```js
392
+ import { EncryptionKeys } from "@synonymdev/pubky";
393
+
394
+ const recovered = EncryptionKeys.fromLocalSecret(savedToken);
395
+ if (recovered) {
396
+ try {
397
+ if (recovered.publicKey !== expectedUser.z32() ||
398
+ recovered.grantId !== expectedGrantId) {
399
+ throw new Error("This backup belongs to a different account or grant");
400
+ }
401
+ const key = await recovered.keys.deriveCryptoKeyForPath("/pub/chat/message");
402
+ // Use key with WebCrypto for locally saved ciphertext.
403
+ } finally {
404
+ recovered.keys.free();
405
+ }
406
+ }
407
+ ```
408
+
409
+ `fromLocalSecret()` returns `{ publicKey, grantId, keys }`, or `undefined` for
410
+ bare-grant tokens. `publicKey` is the authenticated account's z-base-32 key.
411
+ Compare it with the account you expect; also check `grantId` when restoring a
412
+ specific grant. Callers that previously received keys directly must now read
413
+ `recovered.keys` after checking the identity.
414
+
415
+ For a browser record, use
416
+ `await pubky.browserSessionStore.restoreEncryptionKeys(storedId)`. It checks
417
+ the account and grant against the record ID and returns the keys directly.
305
418
 
306
419
  #### Validate and normalize capabilities
307
420
 
@@ -406,8 +519,23 @@ await s.list("/pub/example.com/", null, false, 100, false);
406
519
 
407
520
  // Delete
408
521
  await s.delete("/pub/example.com/data.json");
522
+
523
+ // Locked writes (WebDAV LOCK/UNLOCK)
524
+ const lock = await s.lock("/pub/example.com/state.json", 30); // seconds
525
+ lock.path; // -> "/pub/example.com/state.json"
526
+ lock.token; // -> "opaquelocktoken:<uuid>"
527
+ lock.timeoutSeconds; // -> what the homeserver granted (it caps the lifetime)
528
+ await s.putTextLocked(lock, JSON.stringify({ count: 1 })); // only the holder can write; no putJsonLocked
529
+ await s.putBytesLocked(lock, new Uint8Array([1, 2, 3]));
530
+ await s.deleteLocked(lock);
531
+ await s.refreshLock(lock, 30); // restart the lifetime
532
+ await s.unlock(lock);
409
533
  ```
410
534
 
535
+ While a lock lives, a plain `putJson`/`putText`/`putBytes`/`delete` on that path rejects with `423 Locked`, and so does a second `lock()`. Once the lock is gone, the old value is refused with `412` (write, delete, refresh) and `409` on `unlock()`; take a new lock and read the file again. A homeserver without lock support answers `405`.
536
+
537
+ `timeoutSeconds` is rounded down to whole seconds, with a minimum of 1. If `lock()` rejects without a status code (a lost connection, say), the homeserver may still have granted the lock; nothing can release it, so the path answers `423` until it times out.
538
+
411
539
  `get()` exposes the underlying `Response`, which is handy for streaming bodies or inspecting headers before consuming content.
412
540
 
413
541
  Path rules:
@@ -445,6 +573,15 @@ await signer.pkdns.publishHomeserverForce(/* optional override homeserver*/);
445
573
  await signer.pkdns.getHomeserver();
446
574
  ```
447
575
 
576
+ ### Custom proofs
577
+
578
+ Use `session.grant.createCustomPop(data)` to sign JSON and return both the proof
579
+ and its root-signed grant. Use `verifyCustomGrantPop(credentials)` to verify the bundle
580
+ offline.
581
+
582
+ See [Sign and verify custom data](https://github.com/pubky/pubky-homeserver/blob/main/docs/CUSTOM_POP.md) for Rust and
583
+ JavaScript examples and the verification contract.
584
+
448
585
  ## Logging
449
586
 
450
587
  The SDK ships with a WASM logger that bridges Rust `log` output into the browser or Node console. Call `setLogLevel` **once at application start**, before constructing `Pubky` or other SDK actors, to choose how verbose the logs should be.