@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/abracadabra-provider.cjs +45 -13
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +45 -13
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +14 -4
- package/package.json +2 -2
- package/src/AbracadabraBaseProvider.ts +11 -2
- package/src/AbracadabraClient.ts +35 -3
- package/src/AbracadabraProvider.ts +7 -1
- package/src/BackgroundSyncManager.ts +6 -4
- package/src/DocKeyManager.ts +7 -2
- package/src/OfflineStore.ts +6 -3
- package/src/TokenManager.ts +9 -1
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
|
|
162
|
-
*
|
|
163
|
-
* per-server,
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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) {
|
package/src/AbracadabraClient.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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 ?? "").
|
|
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 ?? "").
|
|
385
|
+
serverOrigin = new URL((this.client as any).baseUrl ?? "").host;
|
|
384
386
|
} catch {}
|
|
385
387
|
|
|
386
388
|
// Clear each document's offline store contents
|
package/src/DocKeyManager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
92
|
+
const x25519Pub = fromBase64(primaryKey.x25519Key);
|
|
88
93
|
const rewrapped = await this.wrapKeyForRecipient(docKey, x25519Pub, docId);
|
|
89
94
|
const b64 = (() => {
|
|
90
95
|
let s = "";
|
package/src/OfflineStore.ts
CHANGED
|
@@ -69,9 +69,12 @@ export class OfflineStore {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* @param docId The document UUID.
|
|
72
|
-
* @param serverOrigin
|
|
73
|
-
*
|
|
74
|
-
* per-server,
|
|
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;
|
package/src/TokenManager.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|