@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.
@@ -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
- /** Clear token from memory and storage. */
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
- /** List immediate child documents. */
401
- async listChildren(docId: string): Promise<string[]> {
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
- const cached = await this.cache.getChildren(docId);
404
- if (cached) return cached;
579
+ await this.cache.invalidateDoc(docId).catch(() => null);
405
580
  }
406
- const res = await this.request<{ children: string[] }>(
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/${encodeURIComponent(docId)}/children`,
598
+ `/docs/search?${params.toString()}`,
409
599
  );
410
- if (this.cache) {
411
- await this.cache.setChildren(docId, res.children).catch(() => null);
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.children;
623
+ return res.documents;
414
624
  }
415
625
 
416
- /** Create a child document under a parent (requires write permission). */
417
- async createChild(docId: string, opts?: { child_id?: string; doc_type?: string; label?: string }): Promise<DocumentMeta> {
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, is_hub). Requires manage permission. */
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; is_hub?: boolean },
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 (deprecated — use document-based methods) ─────────────────────
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 spaces visible to the caller. No auth required for public spaces.
625
- * @deprecated Use {@link listRootDocuments} instead.
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<SpaceMeta[]> {
628
- const res = await this.request<{ spaces: SpaceMeta[] }>("GET", "/spaces", { auth: false });
629
- return res.spaces;
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
- * Get a single space by ID.
634
- * @deprecated Use {@link getDoc} instead.
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 getSpace(spaceId: string): Promise<SpaceMeta> {
637
- return this.request<SpaceMeta>("GET", `/spaces/${encodeURIComponent(spaceId)}`, { auth: false });
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
- * Get the hub space, or null if none is configured.
642
- * @deprecated Use {@link getHubDocument} instead.
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 getHubSpace(): Promise<SpaceMeta | null> {
645
- return this.requestOrNull<SpaceMeta>("GET", "/spaces/hub", { auth: false });
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
- * Create a new space (auth required).
650
- * @deprecated Use {@link createDoc} + {@link updateDocumentMeta} instead.
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
- * Update an existing space (Owner or admin required).
663
- * @deprecated Use {@link updateDocumentMeta} + {@link setDocumentAccess} instead.
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 updateSpace(
666
- spaceId: string,
667
- opts: Partial<Pick<SpaceMeta, "name" | "description" | "visibility" | "is_hub">>,
668
- ): Promise<SpaceMeta> {
669
- return this.request<SpaceMeta>("PATCH", `/spaces/${encodeURIComponent(spaceId)}`, { body: opts });
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
- * Delete a space and its root document (Owner or admin required).
674
- * @deprecated Use {@link deleteDoc} instead.
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
- async deleteSpace(spaceId: string): Promise<void> {
677
- await this.request("DELETE", `/spaces/${encodeURIComponent(spaceId)}`);
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
- async getSnapshot(docId: string, version: number): Promise<SnapshotData> {
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
- * Fetch server metadata including the optional `index_doc_id` entry point.
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