@abraca/dabra 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -0,0 +1,381 @@
1
+ import type {
2
+ UserProfile,
3
+ DocumentMeta,
4
+ UploadMeta,
5
+ UploadInfo,
6
+ PublicKeyInfo,
7
+ HealthStatus,
8
+ } from "./types.ts";
9
+
10
+ export interface AbracadabraClientConfig {
11
+ /** Server base URL (http or https). WebSocket URL is derived automatically. */
12
+ url: string;
13
+ /** Initial JWT token. If omitted and persistAuth is true, loads from storage. */
14
+ token?: string;
15
+ /** Persist JWT to localStorage for stay-logged-in. Default: true in browser. */
16
+ persistAuth?: boolean;
17
+ /** localStorage key for token persistence. Default: "abracadabra:auth". */
18
+ storageKey?: string;
19
+ /** Custom fetch implementation (useful for Node.js or testing). */
20
+ fetch?: typeof globalThis.fetch;
21
+ }
22
+
23
+ export class AbracadabraClient {
24
+ private _token: string | null;
25
+ private readonly baseUrl: string;
26
+ private readonly persistAuth: boolean;
27
+ private readonly storageKey: string;
28
+ private readonly _fetch: typeof globalThis.fetch;
29
+
30
+ constructor(config: AbracadabraClientConfig) {
31
+ this.baseUrl = config.url.replace(/\/+$/, "");
32
+ this.persistAuth = config.persistAuth ?? typeof localStorage !== "undefined";
33
+ this.storageKey = config.storageKey ?? "abracadabra:auth";
34
+ this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
35
+
36
+ // Load token: explicit > persisted > null
37
+ this._token = config.token ?? this.loadPersistedToken() ?? null;
38
+ }
39
+
40
+ // ── Token management ─────────────────────────────────────────────────────
41
+
42
+ get token(): string | null {
43
+ return this._token;
44
+ }
45
+
46
+ set token(value: string | null) {
47
+ this._token = value;
48
+ if (this.persistAuth) {
49
+ if (value) {
50
+ this.persistToken(value);
51
+ } else {
52
+ this.clearPersistedToken();
53
+ }
54
+ }
55
+ }
56
+
57
+ get isAuthenticated(): boolean {
58
+ return this._token !== null;
59
+ }
60
+
61
+ /** Derives ws:// or wss:// URL from the http(s) base URL. */
62
+ get wsUrl(): string {
63
+ return this.baseUrl
64
+ .replace(/^https:\/\//, "wss://")
65
+ .replace(/^http:\/\//, "ws://") + "/ws";
66
+ }
67
+
68
+ // ── Auth ─────────────────────────────────────────────────────────────────
69
+
70
+ /** Register a new user with password. */
71
+ async register(opts: {
72
+ username: string;
73
+ password: string;
74
+ email?: string;
75
+ displayName?: string;
76
+ }): Promise<UserProfile> {
77
+ return this.request<UserProfile>("POST", "/auth/register", {
78
+ body: opts,
79
+ auth: false,
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Register a new user with an Ed25519 public key (crypto auth).
85
+ * Username is optional — if omitted, a short identifier is derived from the key.
86
+ */
87
+ async registerWithKey(opts: {
88
+ publicKey: string;
89
+ username?: string;
90
+ deviceName?: string;
91
+ displayName?: string;
92
+ email?: string;
93
+ }): Promise<UserProfile> {
94
+ const username = opts.username ?? `user-${opts.publicKey.slice(0, 8)}`;
95
+ return this.request<UserProfile>("POST", "/auth/register", {
96
+ body: {
97
+ username,
98
+ identityPublicKey: opts.publicKey,
99
+ deviceName: opts.deviceName,
100
+ displayName: opts.displayName,
101
+ email: opts.email,
102
+ },
103
+ auth: false,
104
+ });
105
+ }
106
+
107
+ /** Login with username + password. Auto-persists returned token. */
108
+ async login(opts: { username: string; password: string }): Promise<string> {
109
+ const res = await this.request<{ token: string }>("POST", "/auth/login", {
110
+ body: opts,
111
+ auth: false,
112
+ });
113
+ this.token = res.token;
114
+ return res.token;
115
+ }
116
+
117
+ /** Request an Ed25519 crypto auth challenge for the given public key. */
118
+ async challenge(publicKey: string): Promise<{ challenge: string; expiresAt: number }> {
119
+ return this.request("POST", "/auth/challenge", {
120
+ body: { publicKey },
121
+ auth: false,
122
+ });
123
+ }
124
+
125
+ /** Verify an Ed25519 signature to complete crypto auth. Auto-persists token. */
126
+ async verify(opts: {
127
+ publicKey: string;
128
+ signature: string;
129
+ challenge: string;
130
+ }): Promise<string> {
131
+ const res = await this.request<{ token: string }>("POST", "/auth/verify", {
132
+ body: opts,
133
+ auth: false,
134
+ });
135
+ this.token = res.token;
136
+ return res.token;
137
+ }
138
+
139
+ /**
140
+ * Full crypto auth flow: challenge → sign → verify.
141
+ * Convenience method combining challenge() + external signing + verify().
142
+ */
143
+ async loginWithKey(
144
+ publicKey: string,
145
+ signChallenge: (challenge: string) => Promise<string>,
146
+ ): Promise<string> {
147
+ const { challenge } = await this.challenge(publicKey);
148
+ const signature = await signChallenge(challenge);
149
+ return this.verify({ publicKey, signature, challenge });
150
+ }
151
+
152
+ /** Add a new Ed25519 public key to the current user (multi-device). */
153
+ async addKey(opts: { publicKey: string; deviceName?: string }): Promise<void> {
154
+ await this.request("POST", "/auth/keys", { body: opts });
155
+ }
156
+
157
+ /** List all registered public keys for the current user. */
158
+ async listKeys(): Promise<PublicKeyInfo[]> {
159
+ const res = await this.request<{ keys: PublicKeyInfo[] }>("GET", "/auth/keys");
160
+ return res.keys;
161
+ }
162
+
163
+ /** Revoke a public key by its ID. */
164
+ async revokeKey(keyId: string): Promise<void> {
165
+ await this.request("DELETE", `/auth/keys/${encodeURIComponent(keyId)}`);
166
+ }
167
+
168
+ /** Clear token from memory and storage. */
169
+ logout(): void {
170
+ this.token = null;
171
+ }
172
+
173
+ // ── User ─────────────────────────────────────────────────────────────────
174
+
175
+ /** Get the current user's profile. */
176
+ async getMe(): Promise<UserProfile> {
177
+ return this.request<UserProfile>("GET", "/users/me");
178
+ }
179
+
180
+ /** Update the current user's display name. */
181
+ async updateMe(opts: { displayName?: string }): Promise<void> {
182
+ await this.request("PATCH", "/users/me", { body: opts });
183
+ }
184
+
185
+ // ── Documents ────────────────────────────────────────────────────────────
186
+
187
+ /** Create a new root document. Returns its metadata. */
188
+ async createDoc(opts?: { id?: string }): Promise<DocumentMeta> {
189
+ return this.request<DocumentMeta>("POST", "/docs", { body: opts ?? {} });
190
+ }
191
+
192
+ /** Get document metadata. */
193
+ async getDoc(docId: string): Promise<DocumentMeta> {
194
+ return this.request<DocumentMeta>("GET", `/docs/${encodeURIComponent(docId)}`);
195
+ }
196
+
197
+ /** Delete a document (requires Owner role). Cascades to children and uploads. */
198
+ async deleteDoc(docId: string): Promise<void> {
199
+ await this.request("DELETE", `/docs/${encodeURIComponent(docId)}`);
200
+ }
201
+
202
+ /** List immediate child documents. */
203
+ async listChildren(docId: string): Promise<string[]> {
204
+ const res = await this.request<{ children: string[] }>(
205
+ "GET",
206
+ `/docs/${encodeURIComponent(docId)}/children`,
207
+ );
208
+ return res.children;
209
+ }
210
+
211
+ /** Create a child document under a parent (requires write permission). */
212
+ async createChild(docId: string, opts?: { child_id?: string }): Promise<DocumentMeta> {
213
+ return this.request<DocumentMeta>(
214
+ "POST",
215
+ `/docs/${encodeURIComponent(docId)}/children`,
216
+ { body: opts ?? {} },
217
+ );
218
+ }
219
+
220
+ // ── Permissions ──────────────────────────────────────────────────────────
221
+
222
+ /** Grant or change a user's role on a document (requires Owner). */
223
+ async setPermission(
224
+ docId: string,
225
+ opts: { user_id: string; role: "owner" | "editor" | "viewer" | "observer" },
226
+ ): Promise<void> {
227
+ await this.request(
228
+ "POST",
229
+ `/docs/${encodeURIComponent(docId)}/permissions`,
230
+ { body: opts },
231
+ );
232
+ }
233
+
234
+ /** Revoke a user's permission on a document (requires Owner). */
235
+ async removePermission(docId: string, opts: { user_id: string }): Promise<void> {
236
+ await this.request(
237
+ "DELETE",
238
+ `/docs/${encodeURIComponent(docId)}/permissions`,
239
+ { body: opts },
240
+ );
241
+ }
242
+
243
+ // ── Uploads ──────────────────────────────────────────────────────────────
244
+
245
+ /** Upload a file to a document (requires write permission). */
246
+ async upload(
247
+ docId: string,
248
+ file: File | Blob,
249
+ filename?: string,
250
+ ): Promise<UploadMeta> {
251
+ const formData = new FormData();
252
+ formData.append("file", file, filename);
253
+
254
+ const headers: Record<string, string> = {};
255
+ if (this._token) {
256
+ headers["Authorization"] = `Bearer ${this._token}`;
257
+ }
258
+
259
+ const res = await this._fetch(
260
+ `${this.baseUrl}/docs/${encodeURIComponent(docId)}/uploads`,
261
+ { method: "POST", headers, body: formData },
262
+ );
263
+ if (!res.ok) {
264
+ throw await this.toError(res);
265
+ }
266
+ return res.json() as Promise<UploadMeta>;
267
+ }
268
+
269
+ /** List all uploads for a document. */
270
+ async listUploads(docId: string): Promise<UploadInfo[]> {
271
+ const res = await this.request<{ uploads: UploadInfo[] }>(
272
+ "GET",
273
+ `/docs/${encodeURIComponent(docId)}/uploads`,
274
+ );
275
+ return res.uploads;
276
+ }
277
+
278
+ /** Download an upload as a Blob. */
279
+ async getUpload(docId: string, uploadId: string): Promise<Blob> {
280
+ const headers: Record<string, string> = {};
281
+ if (this._token) {
282
+ headers["Authorization"] = `Bearer ${this._token}`;
283
+ }
284
+
285
+ const res = await this._fetch(
286
+ `${this.baseUrl}/docs/${encodeURIComponent(docId)}/uploads/${encodeURIComponent(uploadId)}`,
287
+ { method: "GET", headers },
288
+ );
289
+ if (!res.ok) {
290
+ throw await this.toError(res);
291
+ }
292
+ return res.blob();
293
+ }
294
+
295
+ /** Delete an upload (requires uploader or document Owner). */
296
+ async deleteUpload(docId: string, uploadId: string): Promise<void> {
297
+ await this.request(
298
+ "DELETE",
299
+ `/docs/${encodeURIComponent(docId)}/uploads/${encodeURIComponent(uploadId)}`,
300
+ );
301
+ }
302
+
303
+ // ── System ───────────────────────────────────────────────────────────────
304
+
305
+ /** Health check — no auth required. */
306
+ async health(): Promise<HealthStatus> {
307
+ return this.request<HealthStatus>("GET", "/health", { auth: false });
308
+ }
309
+
310
+ // ── Internals ────────────────────────────────────────────────────────────
311
+
312
+ private async request<T = void>(
313
+ method: string,
314
+ path: string,
315
+ opts?: { body?: unknown; auth?: boolean },
316
+ ): Promise<T> {
317
+ const auth = opts?.auth ?? true;
318
+ const headers: Record<string, string> = {};
319
+
320
+ if (auth && this._token) {
321
+ headers["Authorization"] = `Bearer ${this._token}`;
322
+ }
323
+
324
+ const init: RequestInit = { method, headers };
325
+
326
+ if (opts?.body !== undefined) {
327
+ headers["Content-Type"] = "application/json";
328
+ init.body = JSON.stringify(opts.body);
329
+ }
330
+
331
+ const res = await this._fetch(`${this.baseUrl}${path}`, init);
332
+
333
+ if (!res.ok) {
334
+ throw await this.toError(res);
335
+ }
336
+
337
+ // 204 No Content
338
+ if (res.status === 204) {
339
+ return undefined as T;
340
+ }
341
+
342
+ return res.json() as Promise<T>;
343
+ }
344
+
345
+ private async toError(res: Response): Promise<Error> {
346
+ let message: string;
347
+ try {
348
+ const body = await res.json() as { error?: string };
349
+ message = body.error ?? res.statusText;
350
+ } catch {
351
+ message = res.statusText;
352
+ }
353
+ const err = new Error(message);
354
+ (err as any).status = res.status;
355
+ return err;
356
+ }
357
+
358
+ private loadPersistedToken(): string | null {
359
+ try {
360
+ return localStorage.getItem(this.storageKey);
361
+ } catch {
362
+ return null;
363
+ }
364
+ }
365
+
366
+ private persistToken(token: string): void {
367
+ try {
368
+ localStorage.setItem(this.storageKey, token);
369
+ } catch {
370
+ // localStorage unavailable (SSR / Node.js)
371
+ }
372
+ }
373
+
374
+ private clearPersistedToken(): void {
375
+ try {
376
+ localStorage.removeItem(this.storageKey);
377
+ } catch {
378
+ // localStorage unavailable
379
+ }
380
+ }
381
+ }
@@ -12,9 +12,10 @@ import type {
12
12
  onSubdocLoadedParameters,
13
13
  } from "./types.ts";
14
14
  import { AuthenticationMessage } from "./OutgoingMessages/AuthenticationMessage.ts";
15
+ import type { AbracadabraClient } from "./AbracadabraClient.ts";
15
16
 
16
17
  export interface AbracadabraProviderConfiguration
17
- extends HocuspocusProviderConfiguration {
18
+ extends Omit<HocuspocusProviderConfiguration, "url" | "websocketProvider"> {
18
19
  /**
19
20
  * Subdocument loading strategy.
20
21
  * - "lazy" (default) – child providers are created only when explicitly requested.
@@ -45,6 +46,19 @@ export interface AbracadabraProviderConfiguration
45
46
  * Required when cryptoIdentity is set.
46
47
  */
47
48
  signChallenge?: (challenge: string) => Promise<string>;
49
+
50
+ /**
51
+ * AbracadabraClient instance for REST API access.
52
+ * When provided, the provider automatically derives the WebSocket URL
53
+ * and token from the client (unless explicitly overridden).
54
+ */
55
+ client?: AbracadabraClient;
56
+
57
+ /** WebSocket URL. Derived from client.wsUrl if client is provided. */
58
+ url?: string;
59
+
60
+ /** Shared WebSocket connection (use when multiplexing multiple root documents). */
61
+ websocketProvider?: HocuspocusProviderWebsocket;
48
62
  }
49
63
 
50
64
  /**
@@ -65,6 +79,7 @@ export interface AbracadabraProviderConfiguration
65
79
  export class AbracadabraProvider extends HocuspocusProvider {
66
80
  public effectiveRole: EffectiveRole = null;
67
81
 
82
+ private _client: AbracadabraClient | null;
68
83
  private offlineStore: OfflineStore | null;
69
84
  private childProviders = new Map<string, AbracadabraProvider>();
70
85
  private subdocLoading: "lazy" | "eager";
@@ -74,7 +89,21 @@ export class AbracadabraProvider extends HocuspocusProvider {
74
89
  private readonly boundHandleYSubdocsChange = this.handleYSubdocsChange.bind(this);
75
90
 
76
91
  constructor(configuration: AbracadabraProviderConfiguration) {
77
- super(configuration);
92
+ // Derive URL and token from client when not explicitly set.
93
+ const resolved = { ...configuration } as HocuspocusProviderConfiguration;
94
+ const client = configuration.client ?? null;
95
+
96
+ if (client) {
97
+ if (!resolved.url && !resolved.websocketProvider) {
98
+ (resolved as any).url = client.wsUrl;
99
+ }
100
+ if (resolved.token === undefined && !configuration.cryptoIdentity) {
101
+ resolved.token = () => client.token ?? "";
102
+ }
103
+ }
104
+
105
+ super(resolved);
106
+ this._client = client;
78
107
  this.abracadabraConfig = configuration;
79
108
  this.subdocLoading = configuration.subdocLoading ?? "lazy";
80
109
 
@@ -110,8 +139,12 @@ export class AbracadabraProvider extends HocuspocusProvider {
110
139
  }
111
140
 
112
141
  /**
113
- * Override sendToken to send an identity declaration instead of a JWT
114
- * when cryptoIdentity is configured.
142
+ * Override sendToken to send a pubkey-only identity declaration instead of a
143
+ * JWT when cryptoIdentity is configured.
144
+ *
145
+ * The public key is the sole identifier in the crypto auth handshake.
146
+ * Username is decoupled from auth; it lives on the server as an immutable
147
+ * internal field and is never sent in the challenge-response frames.
115
148
  */
116
149
  override async sendToken() {
117
150
  const { cryptoIdentity } = this.abracadabraConfig;
@@ -122,7 +155,6 @@ export class AbracadabraProvider extends HocuspocusProvider {
122
155
  : cryptoIdentity;
123
156
  const json = JSON.stringify({
124
157
  type: "identity",
125
- username: id.username,
126
158
  publicKey: id.publicKey,
127
159
  });
128
160
  this.send(AuthenticationMessage, {
@@ -153,9 +185,9 @@ export class AbracadabraProvider extends HocuspocusProvider {
153
185
  ? await cryptoIdentity()
154
186
  : cryptoIdentity;
155
187
  const signature = await signChallenge(challenge);
188
+ // Proof frame sends only publicKey — username is fully decoupled from auth.
156
189
  const proof = JSON.stringify({
157
190
  type: "proof",
158
- username: id.username,
159
191
  publicKey: id.publicKey,
160
192
  signature,
161
193
  challenge,
@@ -178,6 +210,11 @@ export class AbracadabraProvider extends HocuspocusProvider {
178
210
  return this.effectiveRole === "owner" || this.effectiveRole === "editor";
179
211
  }
180
212
 
213
+ /** The AbracadabraClient instance for REST API access, if configured. */
214
+ get client(): AbracadabraClient | null {
215
+ return this._client;
216
+ }
217
+
181
218
  // ── Stateless message interception ────────────────────────────────────────
182
219
 
183
220
  /**
@@ -293,6 +330,9 @@ export class AbracadabraProvider extends HocuspocusProvider {
293
330
  token: this.configuration.token,
294
331
  subdocLoading: this.subdocLoading,
295
332
  disableOfflineStore: this.abracadabraConfig.disableOfflineStore,
333
+ client: this._client ?? undefined,
334
+ cryptoIdentity: this.abracadabraConfig.cryptoIdentity,
335
+ signChallenge: this.abracadabraConfig.signChallenge,
296
336
  });
297
337
  this.childProviders.set(childId, childProvider);
298
338
 
@@ -18,8 +18,14 @@ import { sha256 } from "@noble/hashes/sha256";
18
18
  // ── Types ────────────────────────────────────────────────────────────────────
19
19
 
20
20
  interface StoredIdentity {
21
+ /**
22
+ * Internal label stored locally. NOT sent to the server during the
23
+ * challenge-response handshake — the public key is the sole auth identifier.
24
+ * This may be used as a hint when calling POST /auth/register (first device)
25
+ * or displayed to the user before they set a real display name.
26
+ */
21
27
  username: string;
22
- /** base64url-encoded Ed25519 public key (32 bytes) */
28
+ /** base64url-encoded Ed25519 public key (32 bytes). Primary auth identifier. */
23
29
  publicKey: string;
24
30
  /** AES-GCM ciphertext of the 32-byte private key */
25
31
  encryptedPrivateKey: ArrayBuffer;
@@ -269,7 +275,13 @@ export class CryptoIdentityKeystore {
269
275
  return stored?.publicKey ?? null;
270
276
  }
271
277
 
272
- /** Returns the stored username, or null if no identity exists. */
278
+ /**
279
+ * Returns the locally-stored internal username label, or null if no identity exists.
280
+ *
281
+ * This is NOT the auth identifier (the public key is). It can be used as a
282
+ * hint when calling POST /auth/register, or displayed before the user sets
283
+ * a real display name via PATCH /users/me.
284
+ */
273
285
  async getUsername(): Promise<string | null> {
274
286
  const db = await openDb();
275
287
  const stored = await dbGet(db);
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./HocuspocusProvider.ts";
2
2
  export * from "./HocuspocusProviderWebsocket.ts";
3
3
  export * from "./types.ts";
4
4
  export * from "./AbracadabraProvider.ts";
5
+ export * from "./AbracadabraClient.ts";
5
6
  export * from "./OfflineStore.ts";
6
7
  export { SubdocMessage } from "./OutgoingMessages/SubdocMessage.ts";
7
8
  export { CryptoIdentityKeystore } from "./CryptoIdentityKeystore.ts";
package/src/types.ts CHANGED
@@ -119,10 +119,15 @@ export type StatesArray = { clientId: number; [key: string | number]: any }[];
119
119
 
120
120
  export type EffectiveRole = "owner" | "editor" | "viewer" | null;
121
121
 
122
- /** Ed25519 identity for passwordless crypto auth (Model B multi-key). */
122
+ /**
123
+ * Ed25519 identity for passwordless crypto auth.
124
+ *
125
+ * The public key is the sole identifier sent to the server during the
126
+ * challenge-response handshake. Username is decoupled from auth and is
127
+ * managed separately as a mutable display name (see PATCH /users/me).
128
+ */
123
129
  export interface CryptoIdentity {
124
- username: string;
125
- /** base64url-encoded Ed25519 public key (32 bytes) */
130
+ /** base64url-encoded Ed25519 public key (32 bytes). Primary auth identifier. */
126
131
  publicKey: string;
127
132
  }
128
133
 
@@ -141,3 +146,43 @@ export type onSubdocLoadedParameters = {
141
146
  export interface AbracadabraOutgoingMessageArguments extends OutgoingMessageArguments {
142
147
  childDocumentName: string;
143
148
  }
149
+
150
+ // ── REST API response types ──────────────────────────────────────────────────
151
+
152
+ export interface UserProfile {
153
+ id: string;
154
+ username: string;
155
+ email: string | null;
156
+ displayName: string | null;
157
+ }
158
+
159
+ export interface DocumentMeta {
160
+ id: string;
161
+ parent_id: string | null;
162
+ }
163
+
164
+ export interface UploadMeta {
165
+ id: string;
166
+ doc_id: string;
167
+ filename: string;
168
+ }
169
+
170
+ export interface UploadInfo {
171
+ id: string;
172
+ filename: string;
173
+ mime_type: string;
174
+ size: number;
175
+ }
176
+
177
+ export interface PublicKeyInfo {
178
+ id: string;
179
+ publicKey: string;
180
+ deviceName: string | null;
181
+ revoked: boolean;
182
+ }
183
+
184
+ export interface HealthStatus {
185
+ status: string;
186
+ version: string;
187
+ active_documents: number;
188
+ }