@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 +1 -1
- package/src/AbracadabraClient.ts +381 -0
- package/src/AbracadabraProvider.ts +46 -6
- package/src/CryptoIdentityKeystore.ts +14 -2
- package/src/index.ts +1 -0
- package/src/types.ts +48 -3
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
+
}
|