@abraca/dabra 2.0.10 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ import EventEmitter from "./EventEmitter.ts";
2
+ import type { AbracadabraClient } from "./AbracadabraClient.ts";
3
+
4
+ /**
5
+ * On-disk shape of a device session. The server returns these values from
6
+ * `POST /auth/device-session`; persistence keeps them across reloads so a
7
+ * warm boot can mint a fresh JWT without re-prompting for the passkey.
8
+ */
9
+ export interface DeviceSessionRecord {
10
+ sessionId: string;
11
+ sessionToken: string;
12
+ /** Unix seconds — matches what the server returns. */
13
+ expiresAt: number;
14
+ }
15
+
16
+ export interface DeviceSessionStorage {
17
+ load(): DeviceSessionRecord | null;
18
+ save(record: DeviceSessionRecord): void;
19
+ clear(): void;
20
+ }
21
+
22
+ const DEFAULT_STORAGE_KEY = "abracadabra:device-session";
23
+
24
+ /** localStorage-backed storage; safe in SSR/Node (no-ops without localStorage). */
25
+ export class LocalStorageDeviceSessionStorage implements DeviceSessionStorage {
26
+ constructor(private readonly key: string = DEFAULT_STORAGE_KEY) {}
27
+
28
+ load(): DeviceSessionRecord | null {
29
+ try {
30
+ const raw = localStorage.getItem(this.key);
31
+ if (!raw) return null;
32
+ const parsed = JSON.parse(raw) as DeviceSessionRecord;
33
+ if (
34
+ typeof parsed?.sessionId === "string" &&
35
+ typeof parsed?.sessionToken === "string" &&
36
+ typeof parsed?.expiresAt === "number"
37
+ ) {
38
+ return parsed;
39
+ }
40
+ return null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ save(record: DeviceSessionRecord): void {
47
+ try {
48
+ localStorage.setItem(this.key, JSON.stringify(record));
49
+ } catch {
50
+ /* localStorage unavailable */
51
+ }
52
+ }
53
+
54
+ clear(): void {
55
+ try {
56
+ localStorage.removeItem(this.key);
57
+ } catch {
58
+ /* localStorage unavailable */
59
+ }
60
+ }
61
+ }
62
+
63
+ export interface TokenManagerOptions {
64
+ client: AbracadabraClient;
65
+ /**
66
+ * Where the device session record lives. Defaults to a localStorage
67
+ * adapter under "abracadabra:device-session".
68
+ */
69
+ storage?: DeviceSessionStorage;
70
+ /**
71
+ * Refresh the JWT this many milliseconds before its `exp`. Defaults to
72
+ * 5 minutes — well clear of clock skew without burning a refresh per
73
+ * minute. Refreshes are also dedup'd via in-flight promise.
74
+ */
75
+ refreshLeadMs?: number;
76
+ /**
77
+ * Hook `document.visibilitychange` and `window.online` to opportunistically
78
+ * refresh when the tab/network resumes. Default true. The proactive
79
+ * `setTimeout` doesn't fire reliably while the tab is hidden or the
80
+ * laptop is asleep, which is the actual breakage path being addressed —
81
+ * the resume hook is what makes the system self-heal after a long sleep.
82
+ */
83
+ installResumeHandlers?: boolean;
84
+ }
85
+
86
+ /**
87
+ * Manages the lifetime of the short-lived JWT against a long-lived device
88
+ * session token.
89
+ *
90
+ * Flow:
91
+ * - Bootstrap: if a stored device session exists, exchange it for a fresh
92
+ * JWT immediately (`bootstrap()`). Otherwise the consumer must run a
93
+ * full crypto-auth handshake and then call `attachSession(record)` once
94
+ * the server returns a device-session token.
95
+ * - Steady state: `getValidToken()` returns the JWT, refreshing if the
96
+ * JWT is within `refreshLeadMs` of expiry. Concurrent callers share one
97
+ * in-flight refresh.
98
+ * - Background: a proactive timer fires `refreshLeadMs` before `exp`.
99
+ * `visibilitychange` and `online` events trigger an opportunistic
100
+ * refresh in case the timer was suppressed (hidden tab, sleeping
101
+ * laptop). The combination is what restores the WS without a reload.
102
+ * - Failure: refresh errors with status 401/403 mean the device session
103
+ * itself is dead (revoked or 30 d expired). The record is cleared and
104
+ * `session-expired` fires so the consumer can prompt re-auth. Other
105
+ * errors (network) are transparent — the existing JWT is returned and
106
+ * the caller's normal retry loop covers it.
107
+ */
108
+ export class TokenManager extends EventEmitter {
109
+ private readonly client: AbracadabraClient;
110
+ private readonly storage: DeviceSessionStorage;
111
+ private readonly refreshLeadMs: number;
112
+ private session: DeviceSessionRecord | null;
113
+ private refreshing: Promise<string> | null = null;
114
+ private proactiveTimer: ReturnType<typeof setTimeout> | null = null;
115
+ private resumeHandlersInstalled = false;
116
+ private boundOnResume: (() => void) | null = null;
117
+ private disposed = false;
118
+
119
+ constructor(options: TokenManagerOptions) {
120
+ super();
121
+ this.client = options.client;
122
+ this.storage = options.storage ?? new LocalStorageDeviceSessionStorage();
123
+ this.refreshLeadMs = options.refreshLeadMs ?? 5 * 60 * 1000;
124
+ this.session = this.storage.load();
125
+ if (options.installResumeHandlers !== false) {
126
+ this.installResumeHandlers();
127
+ }
128
+ // Schedule a refresh against the JWT the client may already be
129
+ // holding (loaded from persistAuth localStorage) so warm boots
130
+ // without an explicit bootstrap() still benefit from proactive
131
+ // refresh on the existing token.
132
+ this.scheduleProactiveRefresh();
133
+ }
134
+
135
+ get hasSession(): boolean {
136
+ return this.session !== null;
137
+ }
138
+
139
+ get currentSession(): DeviceSessionRecord | null {
140
+ return this.session;
141
+ }
142
+
143
+ /**
144
+ * Persist a freshly-issued device session and exchange it immediately
145
+ * for a JWT. Call this once after a successful crypto-auth login when
146
+ * the server returns a device-session token.
147
+ */
148
+ async attachSession(record: DeviceSessionRecord): Promise<string> {
149
+ this.session = record;
150
+ this.storage.save(record);
151
+ const jwt = await this.runRefresh();
152
+ return jwt;
153
+ }
154
+
155
+ /**
156
+ * Drop the local device session (no server call). Use after a logout
157
+ * or when `session-expired` fires. To revoke server-side as well, call
158
+ * `client.revokeDeviceSession(sessionId)` first.
159
+ */
160
+ clearSession(): void {
161
+ this.session = null;
162
+ this.storage.clear();
163
+ if (this.proactiveTimer) {
164
+ clearTimeout(this.proactiveTimer);
165
+ this.proactiveTimer = null;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Return a valid JWT, refreshing if it is missing or within
171
+ * `refreshLeadMs` of expiry. Safe to call concurrently — one
172
+ * refresh is in flight at a time.
173
+ *
174
+ * Transient refresh errors (network) fall back to the cached JWT so
175
+ * the WS auth attempt can still proceed; the server will reject and
176
+ * the next reconnect tries again. Only 401/403 from the refresh
177
+ * endpoint surfaces as a thrown error here, since those mean the
178
+ * device session itself is dead.
179
+ */
180
+ async getValidToken(): Promise<string> {
181
+ if (this.refreshing) return this.refreshing;
182
+ const current = this.client.token;
183
+ if (current && this.tokenIsFresh(current)) return current;
184
+ if (!this.session) return current ?? "";
185
+ try {
186
+ return await this.runRefresh();
187
+ } catch (err) {
188
+ const status = (err as { status?: number })?.status;
189
+ if (status === 401 || status === 403) throw err;
190
+ // Transient — let the caller use the stale token; the server
191
+ // will reject and the WS reconnect loop will retry the refresh.
192
+ return current ?? "";
193
+ }
194
+ }
195
+
196
+ /**
197
+ * If a stored device session exists, exchange it for a fresh JWT now.
198
+ * Returns the JWT (or null if there is no session to bootstrap from).
199
+ * Throws on 401/403 — the stored session is invalid and the caller
200
+ * should fall through to a full crypto-auth flow.
201
+ */
202
+ async bootstrap(): Promise<string | null> {
203
+ if (!this.session) return null;
204
+ try {
205
+ return await this.runRefresh();
206
+ } catch (err) {
207
+ const status = (err as { status?: number })?.status;
208
+ if (status === 401 || status === 403) throw err;
209
+ // Transient — caller decides what to do (e.g. fall back to
210
+ // passkey login or proceed offline).
211
+ return null;
212
+ }
213
+ }
214
+
215
+ dispose(): void {
216
+ if (this.disposed) return;
217
+ this.disposed = true;
218
+ if (this.proactiveTimer) {
219
+ clearTimeout(this.proactiveTimer);
220
+ this.proactiveTimer = null;
221
+ }
222
+ if (this.boundOnResume) {
223
+ try {
224
+ if (typeof document !== "undefined") {
225
+ document.removeEventListener("visibilitychange", this.boundOnResume);
226
+ }
227
+ if (typeof window !== "undefined") {
228
+ window.removeEventListener("online", this.boundOnResume);
229
+ }
230
+ } catch { /* environment without listener APIs */ }
231
+ this.boundOnResume = null;
232
+ }
233
+ this.removeAllListeners();
234
+ }
235
+
236
+ // ── internals ───────────────────────────────────────────────────────────
237
+
238
+ private runRefresh(): Promise<string> {
239
+ if (this.refreshing) return this.refreshing;
240
+ if (!this.session) return Promise.reject(new Error("No device session"));
241
+ const sessionToken = this.session.sessionToken;
242
+ this.refreshing = (async () => {
243
+ try {
244
+ const jwt = await this.client.refreshWithDeviceSession(sessionToken);
245
+ this.scheduleProactiveRefresh();
246
+ this.emit("refresh", jwt);
247
+ return jwt;
248
+ } catch (err) {
249
+ const status = (err as { status?: number })?.status;
250
+ if (status === 401 || status === 403) {
251
+ const message = (err as Error)?.message ?? "device session rejected";
252
+ this.clearSession();
253
+ this.emit("session-expired", { status, message });
254
+ }
255
+ throw err;
256
+ } finally {
257
+ this.refreshing = null;
258
+ }
259
+ })();
260
+ return this.refreshing;
261
+ }
262
+
263
+ private tokenIsFresh(jwt: string): boolean {
264
+ const exp = this.parseExp(jwt);
265
+ if (exp == null) return false;
266
+ return exp * 1000 - Date.now() > this.refreshLeadMs;
267
+ }
268
+
269
+ private parseExp(jwt: string): number | null {
270
+ try {
271
+ const [, payload] = jwt.split(".");
272
+ if (!payload) return null;
273
+ const { exp } = JSON.parse(atob(payload));
274
+ return typeof exp === "number" ? exp : null;
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ private scheduleProactiveRefresh(): void {
281
+ if (this.proactiveTimer) {
282
+ clearTimeout(this.proactiveTimer);
283
+ this.proactiveTimer = null;
284
+ }
285
+ if (!this.session || this.disposed) return;
286
+ const jwt = this.client.token;
287
+ const exp = jwt ? this.parseExp(jwt) : null;
288
+ if (exp == null) {
289
+ // No JWT yet — defer; whoever calls getValidToken() / bootstrap()
290
+ // will reschedule once a JWT lands.
291
+ return;
292
+ }
293
+ // Floor at 30 s so a near-expired token doesn't trigger a busy
294
+ // refresh-then-immediately-refresh-again loop on clock skew.
295
+ const delay = Math.max(30_000, exp * 1000 - Date.now() - this.refreshLeadMs);
296
+ this.proactiveTimer = setTimeout(() => {
297
+ this.runRefresh().catch(() => {
298
+ // 401/403 already surfaced via session-expired event;
299
+ // transient errors are no-ops — the resume handler or the
300
+ // next getValidToken() call will retry.
301
+ });
302
+ }, delay);
303
+ }
304
+
305
+ private installResumeHandlers(): void {
306
+ if (this.resumeHandlersInstalled) return;
307
+ if (typeof document === "undefined" && typeof window === "undefined") return;
308
+ const onResume = () => {
309
+ if (this.disposed) return;
310
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
311
+ if (!this.session) return;
312
+ const jwt = this.client.token;
313
+ if (jwt && this.tokenIsFresh(jwt)) return;
314
+ this.runRefresh().catch(() => {
315
+ /* same swallow as above */
316
+ });
317
+ };
318
+ this.boundOnResume = onResume;
319
+ try {
320
+ if (typeof document !== "undefined") {
321
+ document.addEventListener("visibilitychange", onResume);
322
+ }
323
+ if (typeof window !== "undefined") {
324
+ window.addEventListener("online", onResume);
325
+ }
326
+ this.resumeHandlersInstalled = true;
327
+ } catch { /* environment without listener APIs */ }
328
+ }
329
+ }
@@ -15,6 +15,11 @@ import type {
15
15
  import { resolvePageType } from "./DocTypes.ts";
16
16
  import { toPlain, normalizeRootId } from "./DocUtils.ts";
17
17
  import type { DocumentManager } from "./DocumentManager.ts";
18
+ import {
19
+ projectTreeEntry,
20
+ type SchemaRegistryLike,
21
+ type TypedTreeEntry,
22
+ } from "./SchemaTypes.ts";
18
23
 
19
24
  export class TreeManager {
20
25
  constructor(private dm: DocumentManager) {}
@@ -120,6 +125,28 @@ export class TreeManager {
120
125
  });
121
126
  }
122
127
 
128
+ /**
129
+ * Schema-typed lookup. Returns a `TypedTreeEntry<TMap, N>` when the
130
+ * entry's `type` matches `expectedType`, else `null`. Pure projection
131
+ * over the existing untyped tree — no schema validation is performed
132
+ * here (the entry's data is whatever was last synced; meta correctness
133
+ * is the writer's responsibility, optionally enforced via
134
+ * `MetaManager.setSchema`).
135
+ *
136
+ * Rule 4 alignment: when the entry's type doesn't match, returns null
137
+ * rather than throwing — callers branch on the result.
138
+ */
139
+ getTyped<
140
+ TMap extends Record<string, unknown>,
141
+ N extends keyof TMap & string,
142
+ >(
143
+ _schema: SchemaRegistryLike<TMap>,
144
+ expectedType: N,
145
+ docId: string,
146
+ ): TypedTreeEntry<TMap, N> | null {
147
+ return projectTreeEntry<TMap, N>(this.get(docId), expectedType);
148
+ }
149
+
123
150
  /** Find a single entry by ID. */
124
151
  get(docId: string): TreeEntry | null {
125
152
  const treeMap = this.dm.getTreeMap();
package/src/index.ts CHANGED
@@ -3,6 +3,15 @@ export * from "./AbracadabraWS.ts";
3
3
  export * from "./types.ts";
4
4
  export * from "./AbracadabraProvider.ts";
5
5
  export * from "./AbracadabraClient.ts";
6
+ export {
7
+ TokenManager,
8
+ LocalStorageDeviceSessionStorage,
9
+ } from "./TokenManager.ts";
10
+ export type {
11
+ TokenManagerOptions,
12
+ DeviceSessionRecord,
13
+ DeviceSessionStorage,
14
+ } from "./TokenManager.ts";
6
15
  export * from "./OfflineStore.ts";
7
16
  export * from "./auth.ts";
8
17
  export * from "./CloseEvents.ts";
@@ -38,6 +47,16 @@ export type {
38
47
  export { DeviceRegistrationService } from "./DeviceRegistrationService.ts";
39
48
  export { ChatClient } from "./ChatClient.ts";
40
49
  export { RpcClient, RpcError, RPC_PREFIX } from "./RpcClient.ts";
50
+ export { QueryClient, QueryError, QUERY_PREFIX } from "./QueryClient.ts";
51
+ export type {
52
+ QueryKind,
53
+ QueryFrame,
54
+ QuerySpec,
55
+ QuerySubscriptionHandlers,
56
+ QuerySubscriptionHandle,
57
+ QueryTransport,
58
+ DocumentMetaWire,
59
+ } from "./QueryClient.ts";
41
60
  export type {
42
61
  RpcKind,
43
62
  RpcFrame,
@@ -111,8 +130,19 @@ export type { DocumentManagerConfig } from "./DocumentManager.ts";
111
130
  export { TreeManager } from "./TreeManager.ts";
112
131
  export { ContentManager } from "./ContentManager.ts";
113
132
  export type { DocumentContent } from "./ContentManager.ts";
114
- export { MetaManager } from "./MetaManager.ts";
115
- export type { DocumentMetaInfo } from "./MetaManager.ts";
133
+ export { MetaManager, MetaValidationError } from "./MetaManager.ts";
134
+ export type {
135
+ DocumentMetaInfo,
136
+ SchemaValidatorLike,
137
+ } from "./MetaManager.ts";
138
+ export { TypedDocTypeMismatchError } from "./SchemaTypes.ts";
139
+ export type {
140
+ SchemaRegistryLike,
141
+ SchemaDocTypeName,
142
+ SchemaMetaOf,
143
+ TypedTreeEntry,
144
+ TypedDocsClient,
145
+ } from "./SchemaTypes.ts";
116
146
  export * from "./DocTypes.ts";
117
147
  export { waitForSync, withTimeout, normalizeRootId, toPlain } from "./DocUtils.ts";
118
148
  export {