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