@abraca/dabra 1.9.1 → 2.0.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 +12680 -9133
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +12697 -9200
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +1426 -118
- package/package.json +1 -1
- package/src/AbracadabraBaseProvider.ts +51 -2
- package/src/AbracadabraClient.ts +516 -66
- package/src/AbracadabraProvider.ts +22 -7
- package/src/AbracadabraWS.ts +1 -1
- package/src/ChatClient.ts +193 -113
- package/src/ContentManager.ts +80 -12
- package/src/CryptoIdentityKeystore.ts +3 -3
- package/src/DocConverters.ts +161 -6
- package/src/DocKeyManager.ts +60 -12
- package/src/DocTypes.ts +10 -0
- package/src/DocumentManager.ts +62 -85
- package/src/EncryptedChatClient.ts +173 -0
- package/src/EncryptedY.ts +2 -2
- package/src/IdentityDoc.ts +25 -0
- package/src/MnemonicKeyDerivation.ts +4 -4
- package/src/NotificationsClient.ts +120 -98
- package/src/OutgoingMessages/SubdocMessage.ts +2 -2
- package/src/RpcClient.ts +659 -0
- package/src/TreeManager.ts +61 -17
- package/src/TreeTimestamps.ts +28 -25
- package/src/index.ts +71 -1
- package/src/messageRecord.ts +121 -0
- package/src/types.ts +166 -16
- package/src/webrtc/AbracadabraWebRTC.ts +2 -2
- package/src/webrtc/DataChannelRouter.ts +2 -2
- package/src/webrtc/E2EEChannel.ts +3 -3
- package/src/webrtc/FileTransferChannel.ts +9 -2
package/src/AbracadabraClient.ts
CHANGED
|
@@ -7,22 +7,51 @@ import type {
|
|
|
7
7
|
PermissionEntry,
|
|
8
8
|
EffectivePermissionsResponse,
|
|
9
9
|
HealthStatus,
|
|
10
|
+
ReadyzStatus,
|
|
10
11
|
ServerInfo,
|
|
11
12
|
InviteRow,
|
|
12
|
-
SpaceMeta,
|
|
13
13
|
SnapshotMeta,
|
|
14
14
|
SnapshotData,
|
|
15
15
|
SnapshotCreateResult,
|
|
16
16
|
SnapshotRestoreResult,
|
|
17
17
|
SnapshotForkResult,
|
|
18
|
+
DocSearchHit,
|
|
19
|
+
AuditLogEntry,
|
|
20
|
+
AuditQueryOpts,
|
|
21
|
+
AuditVerifyResult,
|
|
18
22
|
} from "./types.ts";
|
|
23
|
+
import { Kind, SERVER_ROOT_ID } from "./types.ts";
|
|
19
24
|
import type { DocEncryptionInfo } from "./types.ts";
|
|
20
25
|
import type { DocumentCache } from "./DocumentCache.ts";
|
|
26
|
+
import { deriveDmDocId } from "./IdentityDoc.ts";
|
|
21
27
|
|
|
22
28
|
function fromBase64(b64: string): Uint8Array {
|
|
23
29
|
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Reason classifications surfaced to `onAuthFailed`. Consumers can decide
|
|
34
|
+
* whether to silently re-register the keypair (`user_not_found`) or
|
|
35
|
+
* surface a hard block (`account_revoked`, `forbidden`).
|
|
36
|
+
*/
|
|
37
|
+
export type AuthFailureReason =
|
|
38
|
+
| "user_not_found"
|
|
39
|
+
| "account_revoked"
|
|
40
|
+
| "forbidden"
|
|
41
|
+
| "unauthorized";
|
|
42
|
+
|
|
43
|
+
export interface AuthFailureContext {
|
|
44
|
+
/** HTTP status code from the failed request. */
|
|
45
|
+
status: number;
|
|
46
|
+
/** Server-provided error message verbatim. */
|
|
47
|
+
message: string;
|
|
48
|
+
/** Best-guess classification from the message text. */
|
|
49
|
+
reason: AuthFailureReason;
|
|
50
|
+
/** Method + path of the failed request, useful for debugging. */
|
|
51
|
+
method: string;
|
|
52
|
+
path: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
26
55
|
export interface AbracadabraClientConfig {
|
|
27
56
|
/** Server base URL (http or https). WebSocket URL is derived automatically. */
|
|
28
57
|
url: string;
|
|
@@ -41,6 +70,19 @@ export interface AbracadabraClientConfig {
|
|
|
41
70
|
* cache entries automatically.
|
|
42
71
|
*/
|
|
43
72
|
cache?: DocumentCache;
|
|
73
|
+
/**
|
|
74
|
+
* Called whenever a REST call returns 401/403 with a recoverable reason
|
|
75
|
+
* (typically "user not found" — server's DB was wiped or repointed).
|
|
76
|
+
* Consumers wire this to their reauth path (in cou-sh: `_reauthFn`)
|
|
77
|
+
* so silently-failing background fetches still trigger key re-registration
|
|
78
|
+
* without waiting for the next WS action to surface the problem.
|
|
79
|
+
*
|
|
80
|
+
* The callback runs OUT-OF-BAND of the failing request — the original
|
|
81
|
+
* Promise still rejects so callers handle their own error path.
|
|
82
|
+
* Long-running auth handlers should debounce or short-circuit duplicate
|
|
83
|
+
* concurrent invocations.
|
|
84
|
+
*/
|
|
85
|
+
onAuthFailed?: (ctx: AuthFailureContext) => void;
|
|
44
86
|
}
|
|
45
87
|
|
|
46
88
|
export class AbracadabraClient {
|
|
@@ -50,6 +92,7 @@ export class AbracadabraClient {
|
|
|
50
92
|
private readonly storageKey: string;
|
|
51
93
|
private readonly _fetch: typeof globalThis.fetch;
|
|
52
94
|
readonly cache: DocumentCache | null;
|
|
95
|
+
private readonly _onAuthFailed: ((ctx: AuthFailureContext) => void) | null;
|
|
53
96
|
|
|
54
97
|
constructor(config: AbracadabraClientConfig) {
|
|
55
98
|
this.baseUrl = config.url.replace(/\/+$/, "");
|
|
@@ -57,6 +100,7 @@ export class AbracadabraClient {
|
|
|
57
100
|
this.storageKey = config.storageKey ?? "abracadabra:auth";
|
|
58
101
|
this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
59
102
|
this.cache = config.cache ?? null;
|
|
103
|
+
this._onAuthFailed = config.onAuthFailed ?? null;
|
|
60
104
|
|
|
61
105
|
// Load token: explicit > persisted > null
|
|
62
106
|
this._token = config.token ?? this.loadPersistedToken() ?? null;
|
|
@@ -344,11 +388,133 @@ export class AbracadabraClient {
|
|
|
344
388
|
}));
|
|
345
389
|
}
|
|
346
390
|
|
|
347
|
-
/**
|
|
391
|
+
/**
|
|
392
|
+
* Clear token from memory and storage. Local-only; does NOT notify the
|
|
393
|
+
* server. Use {@link logoutServer} or {@link logoutAll} when you also
|
|
394
|
+
* want the JWT to land in the server's revocation cache.
|
|
395
|
+
*/
|
|
348
396
|
logout(): void {
|
|
349
397
|
this.token = null;
|
|
350
398
|
}
|
|
351
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Revoke the current JWT server-side and clear local state. Adds the
|
|
402
|
+
* token's `jti` to the server's revocation cache so subsequent requests
|
|
403
|
+
* with this token return 401, even if the JWT signature still verifies
|
|
404
|
+
* and `exp` hasn't passed. Safe to call when no token is set — degrades
|
|
405
|
+
* to a local clear.
|
|
406
|
+
*
|
|
407
|
+
* Network errors are swallowed (the local state is always cleared) so
|
|
408
|
+
* a sign-out flow can't get stuck on a flaky connection. The endpoint
|
|
409
|
+
* itself is idempotent.
|
|
410
|
+
*/
|
|
411
|
+
async logoutServer(): Promise<void> {
|
|
412
|
+
if (this._token) {
|
|
413
|
+
try {
|
|
414
|
+
await this.request("POST", "/auth/logout");
|
|
415
|
+
} catch {
|
|
416
|
+
// best-effort — local clear must always happen
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
this.token = null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Bump the user's `tokens_invalid_before` watermark, revoking every
|
|
424
|
+
* outstanding JWT for this user — every device, every browser, every
|
|
425
|
+
* pending background tab. The current token is required (this is the
|
|
426
|
+
* user's "I am who I say I am" assertion). After the call returns,
|
|
427
|
+
* future authenticated requests with any pre-existing token return 401.
|
|
428
|
+
*/
|
|
429
|
+
async logoutAll(): Promise<void> {
|
|
430
|
+
await this.request("POST", "/auth/logout-all");
|
|
431
|
+
this.token = null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Email verification ──────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Request an email verification message be sent to the current user's
|
|
438
|
+
* registered email address. Requires authentication. Server enforces a
|
|
439
|
+
* per-user rate limit (1/min, 10/day). Returns 404 if email
|
|
440
|
+
* verification is disabled in `[auth.email_verification]`.
|
|
441
|
+
*/
|
|
442
|
+
async requestEmailVerification(): Promise<void> {
|
|
443
|
+
await this.request("POST", "/auth/verify-email/request", { body: {} });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Confirm an email verification token (typically opened from a link in
|
|
448
|
+
* the verification email). On success the server sets
|
|
449
|
+
* `users.email_verified_at`. No auth required — the token itself is
|
|
450
|
+
* the proof.
|
|
451
|
+
*/
|
|
452
|
+
async confirmEmailVerification(token: string): Promise<void> {
|
|
453
|
+
await this.request("POST", "/auth/verify-email/confirm", {
|
|
454
|
+
body: { token },
|
|
455
|
+
auth: false,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Password lifecycle ──────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Initiate a password reset for a user identified by username or
|
|
463
|
+
* email. The server always returns 202 to avoid leaking whether the
|
|
464
|
+
* identifier exists; a real reset email is only sent if the lookup
|
|
465
|
+
* matched. Heavily rate-limited per-identifier and per-IP.
|
|
466
|
+
*/
|
|
467
|
+
async requestPasswordReset(opts: { identifier: string }): Promise<void> {
|
|
468
|
+
await this.request("POST", "/auth/password-reset/request", {
|
|
469
|
+
body: opts,
|
|
470
|
+
auth: false,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Complete a password reset using the token from the reset email.
|
|
476
|
+
* On success the user's password is updated and every existing JWT for
|
|
477
|
+
* the account is invalidated (the `tokens_invalid_before` watermark
|
|
478
|
+
* bumps). The caller is NOT auto-logged-in — call {@link login} after.
|
|
479
|
+
*/
|
|
480
|
+
async confirmPasswordReset(opts: { token: string; newPassword: string }): Promise<void> {
|
|
481
|
+
await this.request("POST", "/auth/password-reset/confirm", {
|
|
482
|
+
body: { token: opts.token, newPassword: opts.newPassword },
|
|
483
|
+
auth: false,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Change the current user's password. Requires the current password —
|
|
489
|
+
* a stolen JWT alone can't pivot to "I now own this account forever"
|
|
490
|
+
* because the lockout counter (shared with `/auth/login`) trips after
|
|
491
|
+
* a few wrong tries. The endpoint also bumps `tokens_invalid_before`,
|
|
492
|
+
* so other sessions are forced to re-auth.
|
|
493
|
+
*/
|
|
494
|
+
async changePassword(opts: { currentPassword: string; newPassword: string }): Promise<void> {
|
|
495
|
+
await this.request("POST", "/auth/password-change", {
|
|
496
|
+
body: { currentPassword: opts.currentPassword, newPassword: opts.newPassword },
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Add a password to an account that doesn't have one yet — for example,
|
|
502
|
+
* a key-based soft identity opting into a recovery credential. Requires
|
|
503
|
+
* the caller to be authenticated. The server checks
|
|
504
|
+
* `users.password_hash IS NULL` and returns 409 if a password is already
|
|
505
|
+
* set — use {@link changePassword} in that case.
|
|
506
|
+
*
|
|
507
|
+
* Setting a password does not bump `tokens_invalid_before`: it adds an
|
|
508
|
+
* orthogonal credential rather than rotating an existing one. Other
|
|
509
|
+
* devices keep their sessions; this just unlocks the password-login
|
|
510
|
+
* code path for future logins on new devices.
|
|
511
|
+
*/
|
|
512
|
+
async setPassword(newPassword: string): Promise<void> {
|
|
513
|
+
await this.request("POST", "/auth/set-password", {
|
|
514
|
+
body: { newPassword },
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
352
518
|
// ── User ─────────────────────────────────────────────────────────────────
|
|
353
519
|
|
|
354
520
|
/** Get the current user's profile. */
|
|
@@ -397,24 +563,81 @@ export class AbracadabraClient {
|
|
|
397
563
|
}
|
|
398
564
|
}
|
|
399
565
|
|
|
400
|
-
/**
|
|
401
|
-
|
|
566
|
+
/**
|
|
567
|
+
* Restore a soft-deleted document (and its descendants). Requires the
|
|
568
|
+
* same `manage` permission as {@link deleteDoc}, and works even when
|
|
569
|
+
* the doc currently has `deleted_at` set — the cascade resolver walks
|
|
570
|
+
* the ancestor chain regardless of soft-delete state. Returns the
|
|
571
|
+
* number of restored rows in the audit log; the SDK call returns
|
|
572
|
+
* `void` because the wire response is 204.
|
|
573
|
+
*/
|
|
574
|
+
async restoreDoc(docId: string): Promise<void> {
|
|
575
|
+
await this.request("POST", `/docs/${encodeURIComponent(docId)}/restore`);
|
|
402
576
|
if (this.cache) {
|
|
403
|
-
|
|
404
|
-
if (cached) return cached;
|
|
577
|
+
await this.cache.invalidateDoc(docId).catch(() => null);
|
|
405
578
|
}
|
|
406
|
-
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Full-text search over document labels via `GET /docs/search`. The
|
|
583
|
+
* server filters each candidate hit through the cascade resolver, so
|
|
584
|
+
* results only include docs the caller can read at viewer or above.
|
|
585
|
+
* Anonymous callers are permitted but only see public docs.
|
|
586
|
+
*
|
|
587
|
+
* `limit` is clamped to `[1, 50]` server-side; the default is 20.
|
|
588
|
+
* Hits arrive in best-first order. `snippet` is HTML with `<mark>`
|
|
589
|
+
* markers around matched tokens — sanitize before injecting.
|
|
590
|
+
*/
|
|
591
|
+
async searchDocs(query: string, opts?: { limit?: number }): Promise<DocSearchHit[]> {
|
|
592
|
+
const params = new URLSearchParams({ q: query });
|
|
593
|
+
if (opts?.limit != null) params.set("limit", String(opts.limit));
|
|
594
|
+
const res = await this.request<{ results: DocSearchHit[] }>(
|
|
407
595
|
"GET",
|
|
408
|
-
`/docs
|
|
596
|
+
`/docs/search?${params.toString()}`,
|
|
409
597
|
);
|
|
410
|
-
|
|
411
|
-
|
|
598
|
+
return res.results;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* List the direct children of a document, returning full metadata. Pass
|
|
603
|
+
* no argument to list the children of the server root — what the
|
|
604
|
+
* dashboard renders as the Spaces sidebar.
|
|
605
|
+
*
|
|
606
|
+
* The cache (when configured) stores the bare `id[]` topology used by
|
|
607
|
+
* recursive tree walks; callers that need it can read `meta.id` from
|
|
608
|
+
* the returned metas.
|
|
609
|
+
*/
|
|
610
|
+
async listChildren(parentId?: string): Promise<DocumentMeta[]> {
|
|
611
|
+
const path = parentId
|
|
612
|
+
? `/docs/${encodeURIComponent(parentId)}/children`
|
|
613
|
+
: "/docs?root=true";
|
|
614
|
+
const res = await this.request<{ documents: DocumentMeta[]; children?: string[] }>(
|
|
615
|
+
"GET",
|
|
616
|
+
path,
|
|
617
|
+
);
|
|
618
|
+
if (this.cache && parentId && res.children) {
|
|
619
|
+
await this.cache.setChildren(parentId, res.children).catch(() => null);
|
|
412
620
|
}
|
|
413
|
-
return res.
|
|
621
|
+
return res.documents;
|
|
414
622
|
}
|
|
415
623
|
|
|
416
|
-
/**
|
|
417
|
-
|
|
624
|
+
/**
|
|
625
|
+
* Create a child document under a parent (requires write permission).
|
|
626
|
+
*
|
|
627
|
+
* `kind` is the well-known tag (`Kind.Channel`, `Kind.Page`, etc.); the
|
|
628
|
+
* server stores it in `documents.kind` but does not enforce semantics.
|
|
629
|
+
* `description` is freeform metadata.
|
|
630
|
+
*/
|
|
631
|
+
async createChild(
|
|
632
|
+
docId: string,
|
|
633
|
+
opts?: {
|
|
634
|
+
child_id?: string;
|
|
635
|
+
doc_type?: string;
|
|
636
|
+
label?: string;
|
|
637
|
+
kind?: string;
|
|
638
|
+
description?: string;
|
|
639
|
+
},
|
|
640
|
+
): Promise<DocumentMeta> {
|
|
418
641
|
return this.request<DocumentMeta>(
|
|
419
642
|
"POST",
|
|
420
643
|
`/docs/${encodeURIComponent(docId)}/children`,
|
|
@@ -587,17 +810,6 @@ export class AbracadabraClient {
|
|
|
587
810
|
|
|
588
811
|
// ── Document Access & Discovery ──────────────────────────────────────────
|
|
589
812
|
|
|
590
|
-
/** List root documents (replaces listSpaces for new code). */
|
|
591
|
-
async listRootDocuments(): Promise<DocumentMeta[]> {
|
|
592
|
-
const res = await this.request<{ documents: DocumentMeta[] }>("GET", "/docs?root=true");
|
|
593
|
-
return res.documents;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
/** Get the hub document, or null if none is configured. */
|
|
597
|
-
async getHubDocument(): Promise<DocumentMeta | null> {
|
|
598
|
-
return this.requestOrNull<DocumentMeta>("GET", "/docs/hub");
|
|
599
|
-
}
|
|
600
|
-
|
|
601
813
|
/** Set the public_access level for a document. Pass null to inherit from parent. */
|
|
602
814
|
async setDocumentAccess(docId: string, publicAccess: string | null): Promise<void> {
|
|
603
815
|
await this.request("PATCH", `/docs/${encodeURIComponent(docId)}/access`, {
|
|
@@ -610,71 +822,153 @@ export class AbracadabraClient {
|
|
|
610
822
|
return this.request("GET", `/docs/${encodeURIComponent(docId)}/access`);
|
|
611
823
|
}
|
|
612
824
|
|
|
613
|
-
/** Update document metadata (label, description,
|
|
825
|
+
/** Update document metadata (label, description, kind). Requires manage permission. */
|
|
614
826
|
async updateDocumentMeta(
|
|
615
827
|
docId: string,
|
|
616
|
-
opts: { label?: string; description?: string | null;
|
|
828
|
+
opts: { label?: string | null; description?: string | null; kind?: string | null },
|
|
617
829
|
): Promise<void> {
|
|
618
830
|
await this.request("PATCH", `/docs/${encodeURIComponent(docId)}`, { body: opts });
|
|
619
831
|
}
|
|
620
832
|
|
|
621
|
-
// ── Spaces
|
|
833
|
+
// ── Spaces ───────────────────────────────────────────────────────────────
|
|
834
|
+
//
|
|
835
|
+
// "Spaces" are direct children of the server root with `kind: "space"`.
|
|
836
|
+
// They live in the regular `documents` table — there's no dedicated
|
|
837
|
+
// spaces resource on the server anymore. These helpers keep the familiar
|
|
838
|
+
// names so callers don't have to think about the underlying tree.
|
|
622
839
|
|
|
623
840
|
/**
|
|
624
|
-
* List
|
|
625
|
-
*
|
|
841
|
+
* List Spaces visible to the caller — top-level docs (children of the
|
|
842
|
+
* server root) tagged with `kind: "space"`. Authenticated users see
|
|
843
|
+
* spaces resolving to any role; anonymous users see public ones.
|
|
626
844
|
*/
|
|
627
|
-
async listSpaces(): Promise<
|
|
628
|
-
const
|
|
629
|
-
return
|
|
845
|
+
async listSpaces(): Promise<DocumentMeta[]> {
|
|
846
|
+
const docs = await this.listChildren();
|
|
847
|
+
return docs.filter((d) => d.kind === Kind.Space);
|
|
630
848
|
}
|
|
631
849
|
|
|
632
850
|
/**
|
|
633
|
-
*
|
|
634
|
-
*
|
|
851
|
+
* Create a new top-level Space. Equivalent to a `POST /docs` with
|
|
852
|
+
* `kind: "space"` plus the supplied metadata in one round trip.
|
|
853
|
+
*
|
|
854
|
+
* `visibility: "public"` sets `public_access = "observer"` (anonymous
|
|
855
|
+
* read, no awareness or writes). `"private"` (the default) leaves
|
|
856
|
+
* `public_access` unset so only explicit grants apply.
|
|
635
857
|
*/
|
|
636
|
-
async
|
|
637
|
-
|
|
858
|
+
async createSpace(opts: {
|
|
859
|
+
name: string;
|
|
860
|
+
description?: string;
|
|
861
|
+
visibility?: "public" | "private";
|
|
862
|
+
id?: string;
|
|
863
|
+
}): Promise<DocumentMeta> {
|
|
864
|
+
const public_access = opts.visibility === "public" ? "observer" : "none";
|
|
865
|
+
return this.request<DocumentMeta>("POST", "/docs", {
|
|
866
|
+
body: {
|
|
867
|
+
id: opts.id,
|
|
868
|
+
label: opts.name,
|
|
869
|
+
description: opts.description,
|
|
870
|
+
kind: Kind.Space,
|
|
871
|
+
public_access,
|
|
872
|
+
},
|
|
873
|
+
});
|
|
638
874
|
}
|
|
639
875
|
|
|
640
876
|
/**
|
|
641
|
-
*
|
|
642
|
-
*
|
|
877
|
+
* Update a Space's metadata. `visibility` flips `public_access` between
|
|
878
|
+
* `"observer"` (public) and `"none"` (private). To leave visibility
|
|
879
|
+
* untouched, omit it. Pass any property as `null` to clear it.
|
|
643
880
|
*/
|
|
644
|
-
async
|
|
645
|
-
|
|
881
|
+
async updateSpace(
|
|
882
|
+
docId: string,
|
|
883
|
+
opts: { name?: string | null; description?: string | null; visibility?: "public" | "private" },
|
|
884
|
+
): Promise<void> {
|
|
885
|
+
const meta: { label?: string | null; description?: string | null } = {};
|
|
886
|
+
if (opts.name !== undefined) meta.label = opts.name;
|
|
887
|
+
if (opts.description !== undefined) meta.description = opts.description;
|
|
888
|
+
if (Object.keys(meta).length > 0) {
|
|
889
|
+
await this.updateDocumentMeta(docId, meta);
|
|
890
|
+
}
|
|
891
|
+
if (opts.visibility !== undefined) {
|
|
892
|
+
await this.setDocumentAccess(docId, opts.visibility === "public" ? "observer" : "none");
|
|
893
|
+
}
|
|
646
894
|
}
|
|
647
895
|
|
|
648
|
-
/**
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
*/
|
|
652
|
-
async createSpace(opts: {
|
|
653
|
-
name: string;
|
|
654
|
-
description?: string;
|
|
655
|
-
visibility?: SpaceMeta["visibility"];
|
|
656
|
-
id?: string;
|
|
657
|
-
}): Promise<SpaceMeta> {
|
|
658
|
-
return this.request<SpaceMeta>("POST", "/spaces", { body: opts });
|
|
896
|
+
/** Delete a Space (and every doc nested under it). Requires manage permission. */
|
|
897
|
+
async deleteSpace(docId: string): Promise<void> {
|
|
898
|
+
await this.deleteDoc(docId);
|
|
659
899
|
}
|
|
660
900
|
|
|
661
901
|
/**
|
|
662
|
-
*
|
|
663
|
-
*
|
|
902
|
+
* Look up the DM doc between the calling user and `otherUserPk`, or
|
|
903
|
+
* create it if none exists. The doc id is deterministically derived from
|
|
904
|
+
* the sorted pubkey pair (see {@link deriveDmDocId}) so both sides
|
|
905
|
+
* compute the same target — race-tolerant by construction. The created
|
|
906
|
+
* doc has `kind = "dm"`, `public_access = "none"`, and explicit Editor
|
|
907
|
+
* permissions for both participants.
|
|
908
|
+
*
|
|
909
|
+
* The cascade resolver enforces that no other user can read the DM,
|
|
910
|
+
* because:
|
|
911
|
+
* - `public_access = "none"` blocks anonymous + falls through to the
|
|
912
|
+
* server-wide `[access].authenticated` floor for everyone else;
|
|
913
|
+
* - the explicit Editor rows only exist for the two participants, so
|
|
914
|
+
* non-participants get only the (capped) authenticated floor;
|
|
915
|
+
* - the doc lives at server root, so there's no ancestor that could
|
|
916
|
+
* leak via a higher cascade grant.
|
|
917
|
+
*
|
|
918
|
+
* Note: when `[access].authenticated >= "viewer"` is set server-wide,
|
|
919
|
+
* non-participants would still be able to *read* the DM via the
|
|
920
|
+
* authenticated floor. That's the documented "private docs need
|
|
921
|
+
* authenticated=none" caveat from REDESIGN.md §9 — operators wanting
|
|
922
|
+
* sealed DMs configure the server accordingly.
|
|
923
|
+
*
|
|
924
|
+
* @param otherUserPk Base64url-encoded Ed25519 public key of the other party.
|
|
925
|
+
* @returns The DM doc's id (existing or newly created).
|
|
664
926
|
*/
|
|
665
|
-
async
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
927
|
+
async findOrCreateDmDoc(otherUserPk: string): Promise<string> {
|
|
928
|
+
const me = await this.getMe();
|
|
929
|
+
if (!me.publicKey) {
|
|
930
|
+
throw new Error("findOrCreateDmDoc: caller has no public key");
|
|
931
|
+
}
|
|
932
|
+
const dmId = deriveDmDocId(me.publicKey, otherUserPk);
|
|
933
|
+
// Try to fetch the doc first — if it exists, we're done.
|
|
934
|
+
try {
|
|
935
|
+
const existing = await this.getDoc(dmId);
|
|
936
|
+
if (existing && existing.kind === Kind.Dm) {
|
|
937
|
+
return dmId;
|
|
938
|
+
}
|
|
939
|
+
} catch {
|
|
940
|
+
// 404 is the expected miss; any other error we let bubble up
|
|
941
|
+
// from createDoc below.
|
|
942
|
+
}
|
|
943
|
+
// Race-tolerant create: both clients hashing the same pubkey pair
|
|
944
|
+
// generate the same id, so whoever loses the create gets Conflict
|
|
945
|
+
// and the catch reuses the id.
|
|
946
|
+
try {
|
|
947
|
+
await this.request<DocumentMeta>("POST", "/docs", {
|
|
948
|
+
body: {
|
|
949
|
+
id: dmId,
|
|
950
|
+
kind: Kind.Dm,
|
|
951
|
+
public_access: "none",
|
|
952
|
+
},
|
|
953
|
+
});
|
|
954
|
+
} catch (err: any) {
|
|
955
|
+
// Conflict means the other side already created it; reuse.
|
|
956
|
+
const status = err?.status ?? err?.response?.status;
|
|
957
|
+
if (status !== 409) throw err;
|
|
958
|
+
}
|
|
959
|
+
// Idempotent permission grants (server upserts on (doc_id, user_id)).
|
|
960
|
+
await this.setPermission(dmId, { user_id: me.publicKey, role: "editor" });
|
|
961
|
+
await this.setPermission(dmId, { user_id: otherUserPk, role: "editor" });
|
|
962
|
+
return dmId;
|
|
670
963
|
}
|
|
671
964
|
|
|
672
965
|
/**
|
|
673
|
-
*
|
|
674
|
-
*
|
|
966
|
+
* The reserved server root document id. Convenience accessor for the
|
|
967
|
+
* client; identical to {@link SERVER_ROOT_ID}. Use this as the parent for
|
|
968
|
+
* top-level docs / Spaces.
|
|
675
969
|
*/
|
|
676
|
-
|
|
677
|
-
|
|
970
|
+
get rootDocId(): string {
|
|
971
|
+
return SERVER_ROOT_ID;
|
|
678
972
|
}
|
|
679
973
|
|
|
680
974
|
// ── Admin ───────────────────────────────────────────────────────────────
|
|
@@ -704,6 +998,111 @@ export class AbracadabraClient {
|
|
|
704
998
|
return this.request("POST", "/admin/storage/repair");
|
|
705
999
|
}
|
|
706
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* Clear the lockout state on a user account: zeroes the failed-login
|
|
1003
|
+
* counter and `locked_until`. Requires elevated role (Admin or
|
|
1004
|
+
* Service). The action is recorded in the audit log under
|
|
1005
|
+
* `admin.user_unlock`.
|
|
1006
|
+
*/
|
|
1007
|
+
async adminUnlockUser(userId: string): Promise<void> {
|
|
1008
|
+
await this.request("POST", `/admin/users/${encodeURIComponent(userId)}/unlock`);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Page through the audit log. Filters AND-combine; `limit` defaults to
|
|
1013
|
+
* 100 server-side. Requires elevated role.
|
|
1014
|
+
*/
|
|
1015
|
+
async adminAuditList(opts?: AuditQueryOpts): Promise<AuditLogEntry[]> {
|
|
1016
|
+
const params = new URLSearchParams();
|
|
1017
|
+
if (opts?.event_type) params.set("event_type", opts.event_type);
|
|
1018
|
+
if (opts?.actor_user_id) params.set("actor_user_id", opts.actor_user_id);
|
|
1019
|
+
if (opts?.target_type) params.set("target_type", opts.target_type);
|
|
1020
|
+
if (opts?.target_id) params.set("target_id", opts.target_id);
|
|
1021
|
+
if (opts?.since_ts != null) params.set("since_ts", String(opts.since_ts));
|
|
1022
|
+
if (opts?.until_ts != null) params.set("until_ts", String(opts.until_ts));
|
|
1023
|
+
if (opts?.limit != null) params.set("limit", String(opts.limit));
|
|
1024
|
+
if (opts?.offset != null) params.set("offset", String(opts.offset));
|
|
1025
|
+
const qs = params.toString();
|
|
1026
|
+
const res = await this.request<{ items: AuditLogEntry[] }>(
|
|
1027
|
+
"GET",
|
|
1028
|
+
`/admin/audit${qs ? `?${qs}` : ""}`,
|
|
1029
|
+
);
|
|
1030
|
+
return res.items;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Stream the audit log as NDJSON (one JSON object per line) for SIEM
|
|
1035
|
+
* ingestion. Filters mirror {@link adminAuditList} minus `limit`/`offset`.
|
|
1036
|
+
* The server pages internally so memory usage is bounded; this method
|
|
1037
|
+
* buffers the full response into a string and is therefore best for
|
|
1038
|
+
* moderate exports — large dumps should consume `/admin/audit/export`
|
|
1039
|
+
* directly with a streaming HTTP client.
|
|
1040
|
+
*/
|
|
1041
|
+
async adminAuditExport(opts?: Omit<AuditQueryOpts, "limit" | "offset">): Promise<string> {
|
|
1042
|
+
const params = new URLSearchParams();
|
|
1043
|
+
if (opts?.event_type) params.set("event_type", opts.event_type);
|
|
1044
|
+
if (opts?.actor_user_id) params.set("actor_user_id", opts.actor_user_id);
|
|
1045
|
+
if (opts?.target_type) params.set("target_type", opts.target_type);
|
|
1046
|
+
if (opts?.target_id) params.set("target_id", opts.target_id);
|
|
1047
|
+
if (opts?.since_ts != null) params.set("since_ts", String(opts.since_ts));
|
|
1048
|
+
if (opts?.until_ts != null) params.set("until_ts", String(opts.until_ts));
|
|
1049
|
+
const qs = params.toString();
|
|
1050
|
+
const headers: Record<string, string> = {};
|
|
1051
|
+
if (this._token) headers["Authorization"] = `Bearer ${this._token}`;
|
|
1052
|
+
const res = await this._fetch(
|
|
1053
|
+
`${this.baseUrl}/admin/audit/export${qs ? `?${qs}` : ""}`,
|
|
1054
|
+
{ method: "GET", headers },
|
|
1055
|
+
);
|
|
1056
|
+
if (!res.ok) throw await this.toError(res, "GET", "/admin/audit/export");
|
|
1057
|
+
return res.text();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Verify the integrity of the audit-log hash chain. Returns the result
|
|
1062
|
+
* unchanged: `status: "ok"` when the chain is intact, `status: "broken"`
|
|
1063
|
+
* with a `break` payload identifying the first divergent row when
|
|
1064
|
+
* tampering is detected. Wraps `GET /admin/audit/verify`. Requires
|
|
1065
|
+
* elevated role.
|
|
1066
|
+
*
|
|
1067
|
+
* Note: the server returns HTTP 409 on a broken chain — this method
|
|
1068
|
+
* special-cases the 409 status and returns the body as a successful
|
|
1069
|
+
* result rather than throwing, because "broken" is a valid answer
|
|
1070
|
+
* from the verifier, not an error.
|
|
1071
|
+
*/
|
|
1072
|
+
async adminAuditVerify(): Promise<AuditVerifyResult> {
|
|
1073
|
+
const headers: Record<string, string> = {};
|
|
1074
|
+
if (this._token) headers["Authorization"] = `Bearer ${this._token}`;
|
|
1075
|
+
const res = await this._fetch(`${this.baseUrl}/admin/audit/verify`, {
|
|
1076
|
+
method: "GET",
|
|
1077
|
+
headers,
|
|
1078
|
+
});
|
|
1079
|
+
if (res.status === 200 || res.status === 409) {
|
|
1080
|
+
return res.json() as Promise<AuditVerifyResult>;
|
|
1081
|
+
}
|
|
1082
|
+
throw await this.toError(res, "GET", "/admin/audit/verify");
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Download a tar archive of the schema-meaningful tables (users,
|
|
1087
|
+
* documents, permissions, invites, optionally audit log). Requires
|
|
1088
|
+
* elevated role. The server gzips the response when the client sends
|
|
1089
|
+
* `Accept-Encoding: gzip` — `fetch` handles that transparently, so
|
|
1090
|
+
* the returned Blob is the raw tar bytes.
|
|
1091
|
+
*/
|
|
1092
|
+
async adminBackupDump(opts?: { includeAudit?: boolean }): Promise<Blob> {
|
|
1093
|
+
const params = new URLSearchParams();
|
|
1094
|
+
if (opts?.includeAudit) params.set("include_audit", "true");
|
|
1095
|
+
const qs = params.toString();
|
|
1096
|
+
const headers: Record<string, string> = {};
|
|
1097
|
+
if (this._token) headers["Authorization"] = `Bearer ${this._token}`;
|
|
1098
|
+
const res = await this._fetch(
|
|
1099
|
+
`${this.baseUrl}/admin/backup/dump${qs ? `?${qs}` : ""}`,
|
|
1100
|
+
{ method: "GET", headers },
|
|
1101
|
+
);
|
|
1102
|
+
if (!res.ok) throw await this.toError(res, "GET", "/admin/backup/dump");
|
|
1103
|
+
return res.blob();
|
|
1104
|
+
}
|
|
1105
|
+
|
|
707
1106
|
// ── Snapshots ────────────────────────────────────────────────────────────
|
|
708
1107
|
|
|
709
1108
|
/** List snapshot metadata for a document. */
|
|
@@ -718,10 +1117,17 @@ export class AbracadabraClient {
|
|
|
718
1117
|
return res.snapshots;
|
|
719
1118
|
}
|
|
720
1119
|
|
|
721
|
-
/** Fetch a single snapshot including its base64-encoded data blob.
|
|
722
|
-
|
|
1120
|
+
/** Fetch a single snapshot including its base64-encoded data blob.
|
|
1121
|
+
* Pass `{ include: "files" }` to also receive the joined upload list
|
|
1122
|
+
* (each `fileBlock` / `coverUploadId` resolved against `uploads`). */
|
|
1123
|
+
async getSnapshot(
|
|
1124
|
+
docId: string,
|
|
1125
|
+
version: number,
|
|
1126
|
+
opts?: { include?: "files" | string },
|
|
1127
|
+
): Promise<SnapshotData> {
|
|
1128
|
+
const qs = opts?.include ? `?include=${encodeURIComponent(opts.include)}` : "";
|
|
723
1129
|
return this.request<SnapshotData>(
|
|
724
|
-
"GET", `/docs/${encodeURIComponent(docId)}/snapshots/${version}`,
|
|
1130
|
+
"GET", `/docs/${encodeURIComponent(docId)}/snapshots/${version}${qs}`,
|
|
725
1131
|
);
|
|
726
1132
|
}
|
|
727
1133
|
|
|
@@ -762,7 +1168,25 @@ export class AbracadabraClient {
|
|
|
762
1168
|
}
|
|
763
1169
|
|
|
764
1170
|
/**
|
|
765
|
-
*
|
|
1171
|
+
* Readiness probe — pings the database. Returns 200 with
|
|
1172
|
+
* `status: "ready"` only when the server can serve traffic, 503 with
|
|
1173
|
+
* `status: "unready"` otherwise (load balancers / Kubernetes probes
|
|
1174
|
+
* use the status code; the body is informational).
|
|
1175
|
+
*
|
|
1176
|
+
* The 503 case is special-cased here: instead of throwing, the method
|
|
1177
|
+
* returns the unready body so callers can react to the state without
|
|
1178
|
+
* try/catch boilerplate.
|
|
1179
|
+
*/
|
|
1180
|
+
async readyz(): Promise<ReadyzStatus> {
|
|
1181
|
+
const res = await this._fetch(`${this.baseUrl}/readyz`, { method: "GET" });
|
|
1182
|
+
if (res.status === 200 || res.status === 503) {
|
|
1183
|
+
return res.json() as Promise<ReadyzStatus>;
|
|
1184
|
+
}
|
|
1185
|
+
throw await this.toError(res, "GET", "/readyz");
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Fetch server metadata including `root_doc_id` and the `[access]` policy.
|
|
766
1190
|
* No auth required.
|
|
767
1191
|
*/
|
|
768
1192
|
async serverInfo(): Promise<ServerInfo> {
|
|
@@ -848,6 +1272,32 @@ export class AbracadabraClient {
|
|
|
848
1272
|
const prefix = method && path ? `${method} ${path}: ` : "";
|
|
849
1273
|
const err = new Error(`${prefix}${message} (${res.status})`);
|
|
850
1274
|
(err as any).status = res.status;
|
|
1275
|
+
// Surface 401/403 to the auth-failed handler so consumers can trigger
|
|
1276
|
+
// reauth without waiting for the next WS action. Fire-and-forget —
|
|
1277
|
+
// the original Promise still rejects so callers handle their own
|
|
1278
|
+
// error path. Wrap in setTimeout(0) so the callback runs out-of-band
|
|
1279
|
+
// of the current request's microtask queue.
|
|
1280
|
+
if ((res.status === 401 || res.status === 403) && this._onAuthFailed) {
|
|
1281
|
+
const lower = message.toLowerCase();
|
|
1282
|
+
let reason: AuthFailureReason = res.status === 401 ? "unauthorized" : "forbidden";
|
|
1283
|
+
if (/user not found|user_not_found|public key not registered|no such user/.test(lower)) {
|
|
1284
|
+
reason = "user_not_found";
|
|
1285
|
+
} else if (/account revoked|user account revoked/.test(lower)) {
|
|
1286
|
+
reason = "account_revoked";
|
|
1287
|
+
} else if (/forbidden|insufficient permissions/.test(lower)) {
|
|
1288
|
+
reason = "forbidden";
|
|
1289
|
+
}
|
|
1290
|
+
const ctx: AuthFailureContext = {
|
|
1291
|
+
status: res.status,
|
|
1292
|
+
message,
|
|
1293
|
+
reason,
|
|
1294
|
+
method: method ?? "",
|
|
1295
|
+
path: path ?? "",
|
|
1296
|
+
};
|
|
1297
|
+
try {
|
|
1298
|
+
setTimeout(() => { try { this._onAuthFailed!(ctx); } catch { /* swallow */ } }, 0);
|
|
1299
|
+
} catch { /* setTimeout unavailable in some sandboxes — drop the signal */ }
|
|
1300
|
+
}
|
|
851
1301
|
return err;
|
|
852
1302
|
}
|
|
853
1303
|
|