@abraca/dabra 2.20.0 → 2.22.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/dist/index.d.ts CHANGED
@@ -158,9 +158,12 @@ declare class OfflineStore {
158
158
  private db;
159
159
  /**
160
160
  * @param docId The document UUID.
161
- * @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
162
- * When provided the IndexedDB database is namespaced
163
- * per-server, preventing cross-server data contamination.
161
+ * @param serverOrigin Host of the server, including a non-default port
162
+ * (e.g. "abra.cou.sh", "localhost:3001"). When provided
163
+ * the IndexedDB database is namespaced per-server,
164
+ * preventing cross-server data contamination — the port
165
+ * matters because two same-host servers sharing the
166
+ * default root_doc_id would otherwise collide.
164
167
  */
165
168
  constructor(docId: string, serverOrigin?: string);
166
169
  private dbPromise;
@@ -354,7 +357,14 @@ declare class AbracadabraClient {
354
357
  listKeys(): Promise<PublicKeyInfo[]>;
355
358
  /** Rename a registered device key. */
356
359
  renameKey(keyId: string, deviceName: string): Promise<void>;
357
- /** Revoke a public key by its ID. */
360
+ /**
361
+ * Revoke a public key by its ID. The server treats this as a panic
362
+ * action: every outstanding JWT for the account is invalidated (a stolen
363
+ * device's token must die immediately, and tokens carry no per-key
364
+ * claim), and a fresh token for THIS session is returned and adopted
365
+ * automatically — so the device performing the revocation stays
366
+ * authenticated while every other device silently re-auths.
367
+ */
358
368
  revokeKey(keyId: string): Promise<void>;
359
369
  /** Create a single-use device invite code for pairing a new device to this account. */
360
370
  createDeviceInvite(opts?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "2.20.0",
3
+ "version": "2.22.0",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -41,7 +41,7 @@
41
41
  "yjs": "^13.6.8"
42
42
  },
43
43
  "devDependencies": {
44
- "@abraca/schema": "2.20.0"
44
+ "@abraca/schema": "2.22.0"
45
45
  },
46
46
  "scripts": {
47
47
  "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
@@ -717,9 +717,18 @@ export class AbracadabraBaseProvider extends EventEmitter {
717
717
 
718
718
  this.configuration.websocketProvider.on("rateLimited", this.forwardRateLimited);
719
719
 
720
- this.configuration.websocketProvider.attach(this);
721
-
720
+ // Mark attached BEFORE registering with the socket: when the shared
721
+ // socket is already connected (the loadChild case), wsp.attach()
722
+ // synchronously invokes onOpen() → sendToken(), and send() drops frames
723
+ // while _isAttached is false. With a crypto identity OBJECT sendToken()
724
+ // hits no await before send(), so the AUTH frame was silently swallowed
725
+ // and the doc never authenticated on the connection — the server then
726
+ // (correctly) ignored every subsequent frame for it: child providers
727
+ // never synced and their writes were dropped. (JWT auth only survived
728
+ // by accident: `await getToken()` defers send() past attach().)
722
729
  this._isAttached = true;
730
+
731
+ this.configuration.websocketProvider.attach(this);
723
732
  }
724
733
 
725
734
  permissionDeniedHandler(reason: string) {
@@ -31,6 +31,21 @@ function fromBase64(b64: string): Uint8Array {
31
31
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
32
32
  }
33
33
 
34
+ /**
35
+ * Decode a base64url-encoded string (e.g. a JWT header/payload segment) to
36
+ * UTF-8 text. `atob` only accepts standard base64, so we translate the
37
+ * url-safe alphabet (`-`/`_`) and re-pad before decoding — otherwise a
38
+ * perfectly valid token whose payload happens to contain `-` or `_` throws
39
+ * and gets misclassified as invalid/expired.
40
+ */
41
+ function decodeBase64UrlToString(b64url: string): string {
42
+ let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
43
+ const pad = b64.length % 4;
44
+ if (pad) b64 += "=".repeat(4 - pad);
45
+ const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
46
+ return new TextDecoder().decode(bytes);
47
+ }
48
+
34
49
  /**
35
50
  * Reason classifications surfaced to `onAuthFailed`. Consumers can decide
36
51
  * whether to silently re-register the keypair (`user_not_found`) or
@@ -142,7 +157,10 @@ export class AbracadabraClient {
142
157
  if (!this._token) return false;
143
158
  try {
144
159
  const [, payload] = this._token.split(".");
145
- const { exp } = JSON.parse(atob(payload));
160
+ // JWT payloads are base64url — `atob` only handles standard base64,
161
+ // so a payload containing `-` or `_` would throw and a valid token
162
+ // would be misread as expired, forcing a spurious re-auth.
163
+ const { exp } = JSON.parse(decodeBase64UrlToString(payload));
146
164
  return typeof exp === "number" && exp * 1000 > Date.now();
147
165
  } catch {
148
166
  return false;
@@ -262,9 +280,23 @@ export class AbracadabraClient {
262
280
  await this.request("PATCH", `/auth/keys/${encodeURIComponent(keyId)}`, { body: { deviceName } });
263
281
  }
264
282
 
265
- /** Revoke a public key by its ID. */
283
+ /**
284
+ * Revoke a public key by its ID. The server treats this as a panic
285
+ * action: every outstanding JWT for the account is invalidated (a stolen
286
+ * device's token must die immediately, and tokens carry no per-key
287
+ * claim), and a fresh token for THIS session is returned and adopted
288
+ * automatically — so the device performing the revocation stays
289
+ * authenticated while every other device silently re-auths.
290
+ */
266
291
  async revokeKey(keyId: string): Promise<void> {
267
- await this.request("DELETE", `/auth/keys/${encodeURIComponent(keyId)}`);
292
+ const res = await this.request<{ token?: string } | null>(
293
+ "DELETE",
294
+ `/auth/keys/${encodeURIComponent(keyId)}`,
295
+ );
296
+ // Older servers respond 204 with no body — keep working against them.
297
+ if (res && typeof res === "object" && typeof res.token === "string") {
298
+ this.token = res.token;
299
+ }
268
300
  }
269
301
 
270
302
  // ── Device Invites ───────────────────────────────────────────────────────
@@ -244,7 +244,13 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
244
244
  config.url ??
245
245
  (config.websocketProvider as AbracadabraWS | undefined)?.url ??
246
246
  client?.wsUrl;
247
- if (url) return new URL(url).hostname;
247
+ // `host` (NOT `hostname`): the port is part of a server's identity.
248
+ // Two servers on the same host but different ports would otherwise
249
+ // share one IDB namespace — and since default-config servers also
250
+ // share the same root_doc_id, one server's cached doc state silently
251
+ // hydrated into the other's Y.Doc (cross-server contamination that
252
+ // can then sync back upstream).
253
+ if (url) return new URL(url).host;
248
254
  } catch {
249
255
  // Malformed URL — fall back to no scoping
250
256
  }
@@ -165,10 +165,12 @@ export class BackgroundSyncManager extends EventEmitter {
165
165
  maxRetries: opts?.maxRetries ?? 2,
166
166
  };
167
167
 
168
- // Derive server origin from client URL for IDB namespacing
168
+ // Derive server origin from client URL for IDB namespacing.
169
+ // `host` (not `hostname`): port-scoped so same-host servers on
170
+ // different ports don't share a namespace.
169
171
  let serverOrigin = "default";
170
172
  try {
171
- serverOrigin = new URL((client as any).baseUrl ?? "").hostname;
173
+ serverOrigin = new URL((client as any).baseUrl ?? "").host;
172
174
  } catch {}
173
175
 
174
176
  this.persistence = new BackgroundSyncPersistence(serverOrigin);
@@ -377,10 +379,10 @@ export class BackgroundSyncManager extends EventEmitter {
377
379
  docIds.add(docId);
378
380
  }
379
381
 
380
- // Derive server origin the same way the provider does
382
+ // Derive server origin the same way the provider does (`host`, port-scoped)
381
383
  let serverOrigin: string | undefined;
382
384
  try {
383
- serverOrigin = new URL((this.client as any).baseUrl ?? "").hostname;
385
+ serverOrigin = new URL((this.client as any).baseUrl ?? "").host;
384
386
  } catch {}
385
387
 
386
388
  // Clear each document's offline store contents
@@ -14,7 +14,12 @@ import type { CryptoIdentityKeystore } from "./CryptoIdentityKeystore.ts";
14
14
  const HKDF_INFO = new TextEncoder().encode("abracadabra-dockey-v1");
15
15
 
16
16
  function fromBase64(b64: string): Uint8Array {
17
- return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
17
+ // Accept both standard base64 and base64url, padded or not — server
18
+ // payloads mix the two alphabets depending on the field.
19
+ let normalized = b64.replace(/-/g, "+").replace(/_/g, "/");
20
+ const pad = normalized.length % 4;
21
+ if (pad) normalized += "=".repeat(4 - pad);
22
+ return Uint8Array.from(atob(normalized), (c) => c.charCodeAt(0));
18
23
  }
19
24
 
20
25
  export class DocKeyManager {
@@ -84,7 +89,7 @@ export class DocKeyManager {
84
89
  const myKeys = await client.listUserKeys(me.id);
85
90
  const primaryKey = myKeys[0];
86
91
  if (primaryKey?.x25519Key) {
87
- const x25519Pub = fromBase64(primaryKey.x25519Key.replace(/-/g, "+").replace(/_/g, "/"));
92
+ const x25519Pub = fromBase64(primaryKey.x25519Key);
88
93
  const rewrapped = await this.wrapKeyForRecipient(docKey, x25519Pub, docId);
89
94
  const b64 = (() => {
90
95
  let s = "";
@@ -69,9 +69,12 @@ export class OfflineStore {
69
69
 
70
70
  /**
71
71
  * @param docId The document UUID.
72
- * @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
73
- * When provided the IndexedDB database is namespaced
74
- * per-server, preventing cross-server data contamination.
72
+ * @param serverOrigin Host of the server, including a non-default port
73
+ * (e.g. "abra.cou.sh", "localhost:3001"). When provided
74
+ * the IndexedDB database is namespaced per-server,
75
+ * preventing cross-server data contamination — the port
76
+ * matters because two same-host servers sharing the
77
+ * default root_doc_id would otherwise collide.
75
78
  */
76
79
  constructor(docId: string, serverOrigin?: string) {
77
80
  this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;
@@ -270,7 +270,15 @@ export class TokenManager extends EventEmitter {
270
270
  try {
271
271
  const [, payload] = jwt.split(".");
272
272
  if (!payload) return null;
273
- const { exp } = JSON.parse(atob(payload));
273
+ // base64url-safe: `atob` alone throws on `-`/`_`, which would make
274
+ // the proactive-refresh timer treat a valid token as unparseable.
275
+ let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
276
+ const pad = b64.length % 4;
277
+ if (pad) b64 += "=".repeat(4 - pad);
278
+ const json = new TextDecoder().decode(
279
+ Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)),
280
+ );
281
+ const { exp } = JSON.parse(json);
274
282
  return typeof exp === "number" ? exp : null;
275
283
  } catch {
276
284
  return null;