@spooky-sync/core 0.0.1-canary.260 → 0.0.1-canary.261

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,179 @@
1
+ /**
2
+ * Admin impersonation, client side.
3
+ *
4
+ * The server does the enforcing (see `impersonation_remote.surql` in the CLI):
5
+ * an admin calls `fn::_00_impersonate::start`, receives a token for the
6
+ * `_00_impersonate` access method, and authenticates with it, so `$auth.id` is
7
+ * the target user. Everything here is state and timers around that:
8
+ *
9
+ * - the admin's own token is kept under {@link ORIGIN_TOKEN_KEY} so Stop (and a
10
+ * reload in between) can return to it;
11
+ * - the impersonation state is derived from the token's claims, never stored
12
+ * separately, so it cannot disagree with the session actually in use;
13
+ * - the token is re-signed before it expires, and the session is polled so a
14
+ * stop or revocation made elsewhere reaches an open WebSocket quickly.
15
+ */
16
+
17
+ /** Access method impersonation tokens are issued for. */
18
+ export const IMPERSONATION_ACCESS = '_00_impersonate';
19
+
20
+ /** Persistence key holding the admin's own token while impersonating. */
21
+ export const ORIGIN_TOKEN_KEY = 'sp00ky_auth_token_origin';
22
+
23
+ /** How often an active impersonation re-checks its session on the server. */
24
+ export const SESSION_POLL_MS = 60_000;
25
+
26
+ export interface ImpersonationInfo {
27
+ /** `_00_impersonation:<id>` the session is bound to. */
28
+ session: string;
29
+ /** Record id being impersonated. */
30
+ target: string;
31
+ /** Record id of the admin. */
32
+ admin: string;
33
+ /** When the current token expires (it is renewed before then). */
34
+ tokenExpiresAt: Date | null;
35
+ }
36
+
37
+ export interface TokenClaims {
38
+ access: string | null;
39
+ userId: string | null;
40
+ exp: number | null;
41
+ impersonation: { session: string; admin: string } | null;
42
+ }
43
+
44
+ /**
45
+ * Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
46
+ * server still enforces the token on every request; this is only so the client
47
+ * can act on what it already holds before a round trip completes.
48
+ *
49
+ * Returns nulls on any malformed input.
50
+ */
51
+ export function decodeTokenClaims(token: string): TokenClaims {
52
+ const none: TokenClaims = { access: null, userId: null, exp: null, impersonation: null };
53
+ try {
54
+ const payload = token.split('.')[1];
55
+ if (!payload) return none;
56
+ let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
57
+ b64 += '='.repeat((4 - (b64.length % 4)) % 4);
58
+ const json =
59
+ typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
60
+ const claims = JSON.parse(json) as Record<string, unknown>;
61
+ const ac = claims.AC ?? claims.ac;
62
+ const id = claims.ID ?? claims.id;
63
+ const access = typeof ac === 'string' ? ac : null;
64
+ const session = claims.spky_imp;
65
+ const admin = claims.spky_admin;
66
+ return {
67
+ access,
68
+ userId: typeof id === 'string' ? id : null,
69
+ exp: typeof claims.exp === 'number' ? claims.exp : null,
70
+ impersonation:
71
+ access === IMPERSONATION_ACCESS && typeof session === 'string' && typeof admin === 'string'
72
+ ? { session, admin }
73
+ : null,
74
+ };
75
+ } catch {
76
+ return none;
77
+ }
78
+ }
79
+
80
+ /** The impersonation a token represents, or null for a regular session. */
81
+ export function impersonationFromToken(token: string | null): ImpersonationInfo | null {
82
+ if (!token) return null;
83
+ const claims = decodeTokenClaims(token);
84
+ if (!claims.impersonation || !claims.userId) return null;
85
+ return {
86
+ session: claims.impersonation.session,
87
+ admin: claims.impersonation.admin,
88
+ target: claims.userId,
89
+ tokenExpiresAt: claims.exp === null ? null : new Date(claims.exp * 1000),
90
+ };
91
+ }
92
+
93
+ /** Same impersonation, for change detection (renewals only move the expiry). */
94
+ export function sameImpersonation(a: ImpersonationInfo | null, b: ImpersonationInfo | null): boolean {
95
+ if (a === null || b === null) return a === b;
96
+ return a.session === b.session && a.target === b.target && a.admin === b.admin;
97
+ }
98
+
99
+ /**
100
+ * When to renew a token expiring at `expSecs`: at 80% of its remaining life,
101
+ * never sooner than 5s from now, and immediately once it is inside that.
102
+ */
103
+ export function renewDelayMs(expSecs: number, nowMs: number): number {
104
+ const remaining = expSecs * 1000 - nowMs;
105
+ if (remaining <= 5_000) return 0;
106
+ return Math.max(5_000, Math.floor(remaining * 0.8));
107
+ }
108
+
109
+ export interface ImpersonationTimerHost {
110
+ /** Current impersonation, or null. */
111
+ current(): ImpersonationInfo | null;
112
+ /** Re-sign the token. Rejects when the session has ended. */
113
+ renew(): Promise<void>;
114
+ /** Whether the server still considers the session live. */
115
+ stillActive(): Promise<boolean>;
116
+ /** Leave the impersonation because the server ended it. */
117
+ endedRemotely(reason: string): Promise<void>;
118
+ /** Whether a failure was a reachability problem (retry) rather than an answer. */
119
+ isNetworkError(error: unknown): boolean;
120
+ }
121
+
122
+ /**
123
+ * The two timers an active impersonation needs. `sync()` is called whenever
124
+ * the auth state changes and arms or clears them to match.
125
+ */
126
+ export class ImpersonationTimers {
127
+ private renewTimer: ReturnType<typeof setTimeout> | null = null;
128
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
129
+ private armedFor: string | null = null;
130
+
131
+ constructor(private host: ImpersonationTimerHost) {}
132
+
133
+ sync(): void {
134
+ const info = this.host.current();
135
+ if (!info) {
136
+ this.clear();
137
+ return;
138
+ }
139
+ const key = `${info.session}@${info.tokenExpiresAt?.getTime() ?? 'none'}`;
140
+ if (key === this.armedFor) return;
141
+ this.clear();
142
+ this.armedFor = key;
143
+ if (info.tokenExpiresAt) {
144
+ const delay = renewDelayMs(info.tokenExpiresAt.getTime() / 1000, Date.now());
145
+ this.renewTimer = setTimeout(() => void this.renew(), delay);
146
+ }
147
+ this.pollTimer = setInterval(() => void this.poll(), SESSION_POLL_MS);
148
+ }
149
+
150
+ clear(): void {
151
+ if (this.renewTimer) clearTimeout(this.renewTimer);
152
+ if (this.pollTimer) clearInterval(this.pollTimer);
153
+ this.renewTimer = null;
154
+ this.pollTimer = null;
155
+ this.armedFor = null;
156
+ }
157
+
158
+ private async renew(): Promise<void> {
159
+ this.renewTimer = null;
160
+ try {
161
+ await this.host.renew();
162
+ } catch (error) {
163
+ if (this.host.isNetworkError(error)) {
164
+ // Try again shortly; the token may still have some life left.
165
+ this.renewTimer = setTimeout(() => void this.renew(), 10_000);
166
+ return;
167
+ }
168
+ await this.host.endedRemotely('renew refused');
169
+ }
170
+ }
171
+
172
+ private async poll(): Promise<void> {
173
+ try {
174
+ if (!(await this.host.stillActive())) await this.host.endedRemotely('session ended');
175
+ } catch (error) {
176
+ if (!this.host.isNetworkError(error)) await this.host.endedRemotely('session check refused');
177
+ }
178
+ }
179
+ }
@@ -10,6 +10,32 @@ export * from './events/index';
10
10
  import { AuthEventTypes, createAuthEventSystem } from './events/index';
11
11
  import type { PersistenceClient } from '../../types';
12
12
  import { classifySyncError } from '../../utils/error-classification';
13
+ import {
14
+ decodeTokenClaims,
15
+ impersonationFromToken,
16
+ sameImpersonation,
17
+ ImpersonationTimers,
18
+ ORIGIN_TOKEN_KEY,
19
+ } from './impersonation';
20
+ import type { ImpersonationInfo } from './impersonation';
21
+ export * from './impersonation';
22
+
23
+ /** What `fn::_00_impersonate::list_users` returns per row. */
24
+ export interface ImpersonationCandidate {
25
+ id: string;
26
+ is_admin: boolean;
27
+ [field: string]: unknown;
28
+ }
29
+
30
+ /** An open session, as `fn::_00_impersonate::active` lists it. */
31
+ export interface ActiveImpersonation {
32
+ session: string;
33
+ target: string;
34
+ admin: string;
35
+ reason: string;
36
+ started_at: unknown;
37
+ expires_at: unknown;
38
+ }
13
39
 
14
40
  // Helper to pretty print types
15
41
  type Prettify<T> = {
@@ -36,38 +62,6 @@ type ExtractAccessParams<
36
62
  }>
37
63
  : never;
38
64
 
39
- /**
40
- * Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
41
- * server still enforces the token on every request; this is only so the client
42
- * can act on what it already holds before a round trip completes.
43
- *
44
- * `AC` is the access-method name — the in-browser SSP needs it to resolve
45
- * `$access` in table permission predicates (mirrors the session's `$access`
46
- * that the server's `fn::query::register` reads). `ID` is the `$auth.id` record
47
- * id, which is what lets a warm boot restore a session locally.
48
- *
49
- * Returns nulls on any malformed input.
50
- */
51
- function decodeTokenClaims(token: string): { access: string | null; userId: string | null } {
52
- try {
53
- const payload = token.split('.')[1];
54
- if (!payload) return { access: null, userId: null };
55
- let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
56
- b64 += '='.repeat((4 - (b64.length % 4)) % 4);
57
- const json =
58
- typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
59
- const claims = JSON.parse(json) as Record<string, unknown>;
60
- const ac = claims.AC ?? claims.ac;
61
- const id = claims.ID ?? claims.id;
62
- return {
63
- access: typeof ac === 'string' ? ac : null,
64
- userId: typeof id === 'string' ? id : null,
65
- };
66
- } catch {
67
- return { access: null, userId: null };
68
- }
69
- }
70
-
71
65
  function decodeAccessFromToken(token: string): string | null {
72
66
  return decodeTokenClaims(token).access;
73
67
  }
@@ -88,6 +82,28 @@ export class AuthService<S extends SchemaStructure> {
88
82
 
89
83
  private events = createAuthEventSystem();
90
84
 
85
+ /** The impersonation last announced, for change detection. */
86
+ private announcedImpersonation: ImpersonationInfo | null = null;
87
+ /** Target of an impersonation that just ended, until the client purges its
88
+ * local bucket (see `consumeEndedImpersonation`). */
89
+ private endedImpersonationTarget: string | null = null;
90
+ private impersonationTimers = new ImpersonationTimers({
91
+ current: () => this.impersonation,
92
+ renew: () => this.renewImpersonation(),
93
+ stillActive: async () => {
94
+ const [row] = await this.remote.query<[unknown]>('RETURN fn::_00_impersonate::current()');
95
+ return row !== null && row !== undefined;
96
+ },
97
+ endedRemotely: async (reason) => {
98
+ this.logger.warn(
99
+ { reason, Category: 'sp00ky-client::AuthService::impersonation' },
100
+ 'Impersonation ended on the server; returning to the admin session'
101
+ );
102
+ await this.stopImpersonating({ serverEnded: true });
103
+ },
104
+ isNetworkError: (error) => classifySyncError(error) === 'network',
105
+ });
106
+
91
107
  public get eventSystem() {
92
108
  return this.events;
93
109
  }
@@ -127,6 +143,160 @@ export class AuthService<S extends SchemaStructure> {
127
143
  private notifyListeners() {
128
144
  const userId = this.currentUser?.id || null;
129
145
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
146
+ const impersonation = this.impersonation;
147
+ if (!sameImpersonation(impersonation, this.announcedImpersonation)) {
148
+ this.announcedImpersonation = impersonation;
149
+ this.events.emit(AuthEventTypes.ImpersonationChanged, impersonation);
150
+ }
151
+ this.impersonationTimers.sync();
152
+ }
153
+
154
+ /**
155
+ * The impersonation the current session is, or null. Derived from the
156
+ * token in use, so it always describes the identity the server sees.
157
+ */
158
+ get impersonation(): ImpersonationInfo | null {
159
+ return this.isAuthenticated ? impersonationFromToken(this.token) : null;
160
+ }
161
+
162
+ /** Subscribe to impersonation changes; called immediately with the current value. */
163
+ subscribeImpersonation(cb: (info: ImpersonationInfo | null) => void): () => void {
164
+ cb(this.impersonation);
165
+ const id = this.events.subscribe(AuthEventTypes.ImpersonationChanged, (event) => {
166
+ cb(event.payload);
167
+ });
168
+ return () => {
169
+ this.events.unsubscribe(id);
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Act as `targetId` (a record id such as `user:abc`). Admins only, and only
175
+ * when the project enabled `impersonation` in sp00ky.yml: the server refuses
176
+ * everything else. `reason` is recorded in the audit log.
177
+ *
178
+ * The admin's session is kept aside and restored by
179
+ * {@link stopImpersonating}. Local data switches to the target's bucket
180
+ * exactly as on a sign-in, and that bucket is removed on stop.
181
+ */
182
+ async impersonate(targetId: string, reason: string): Promise<ImpersonationInfo> {
183
+ if (this.impersonation) throw new Error('Already impersonating; stop first.');
184
+ if (!this.isAuthenticated || !this.token) throw new Error('Sign in as an admin first.');
185
+ const originToken = this.token;
186
+ const [started] = await this.remote.query<[{ token: string }]>(
187
+ 'RETURN fn::_00_impersonate::start($target, $reason)',
188
+ { target: targetId, reason }
189
+ );
190
+ if (!started?.token) throw new Error('The server did not return an impersonation token.');
191
+
192
+ await this.persistenceClient.set(ORIGIN_TOKEN_KEY, originToken);
193
+ try {
194
+ await this.remote.getClient().authenticate(started.token);
195
+ } catch (error) {
196
+ // The server refused the session it just issued (for example the
197
+ // target is an admin, or no longer exists). Nothing changed locally
198
+ // yet; put the admin's token back on the transport.
199
+ await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
200
+ this.remote.setAuthToken(originToken);
201
+ await this.remote.getClient().authenticate(originToken).catch(() => undefined);
202
+ throw error;
203
+ }
204
+ await this.check(started.token);
205
+ // A method call, not the getter: TS narrowed the getter to null above.
206
+ const info = impersonationFromToken(this.isAuthenticated ? this.token : null);
207
+ if (!info) {
208
+ await this.stopImpersonating();
209
+ throw new Error('Impersonation could not be established.');
210
+ }
211
+ this.logger.info(
212
+ { target: info.target, session: info.session, Category: 'sp00ky-client::AuthService::impersonate' },
213
+ 'Impersonation started'
214
+ );
215
+ return info;
216
+ }
217
+
218
+ /**
219
+ * Return to the admin session. Always leaves the impersonated identity,
220
+ * even when the server cannot be reached: the admin token is restored
221
+ * locally first and verified afterwards, and a rejected admin token ends in
222
+ * a full sign-out rather than staying on the target.
223
+ */
224
+ async stopImpersonating(opts: { serverEnded?: boolean } = {}): Promise<void> {
225
+ const info = this.impersonation;
226
+ if (!info) return;
227
+ this.impersonationTimers.clear();
228
+ if (!opts.serverEnded) {
229
+ try {
230
+ await this.remote.query('RETURN fn::_00_impersonate::stop($session)', { session: info.session });
231
+ } catch (error) {
232
+ // The token expires on its own; the local switch below is what matters.
233
+ this.logger.warn(
234
+ { error, Category: 'sp00ky-client::AuthService::stopImpersonating' },
235
+ 'Could not end the impersonation session on the server'
236
+ );
237
+ }
238
+ }
239
+ this.endedImpersonationTarget = info.target;
240
+ const origin = await this.persistenceClient.get<string>(ORIGIN_TOKEN_KEY);
241
+ await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
242
+ if (!origin || impersonationFromToken(origin)) {
243
+ await this.signOut();
244
+ return;
245
+ }
246
+ const { access, userId } = decodeTokenClaims(origin);
247
+ if (!userId) {
248
+ await this.signOut();
249
+ return;
250
+ }
251
+ // Optimistic switch back, as a warm boot does, then verify.
252
+ this.token = origin;
253
+ this.remote.setAuthToken(origin);
254
+ this.currentUser = { id: userId };
255
+ this.access = access ?? this.defaultAccessName();
256
+ await this.persistenceClient.set('sp00ky_auth_token', origin);
257
+ this.notifyListeners();
258
+ await this.check(origin);
259
+ this.logger.info(
260
+ { target: info.target, Category: 'sp00ky-client::AuthService::stopImpersonating' },
261
+ 'Impersonation stopped'
262
+ );
263
+ }
264
+
265
+ /** Re-sign the impersonation token before it expires. */
266
+ async renewImpersonation(): Promise<void> {
267
+ if (!this.impersonation) return;
268
+ const [renewed] = await this.remote.query<[{ token: string }]>('RETURN fn::_00_impersonate::renew()');
269
+ if (!renewed?.token) throw new Error('Impersonation renewal returned no token.');
270
+ this.remote.setAuthToken(renewed.token);
271
+ await this.remote.getClient().authenticate(renewed.token);
272
+ this.token = renewed.token;
273
+ await this.persistenceClient.set('sp00ky_auth_token', renewed.token);
274
+ this.notifyListeners();
275
+ }
276
+
277
+ /** Users an admin may pick from. Admin-only on the server. */
278
+ async searchImpersonationTargets(search: string): Promise<ImpersonationCandidate[]> {
279
+ const [rows] = await this.remote.query<[ImpersonationCandidate[]]>(
280
+ 'RETURN fn::_00_impersonate::list_users($search)',
281
+ { search }
282
+ );
283
+ return Array.isArray(rows) ? rows : [];
284
+ }
285
+
286
+ /** Open impersonation sessions. Admin-only on the server. */
287
+ async listActiveImpersonations(): Promise<ActiveImpersonation[]> {
288
+ const [rows] = await this.remote.query<[ActiveImpersonation[]]>('RETURN fn::_00_impersonate::active()');
289
+ return Array.isArray(rows) ? rows : [];
290
+ }
291
+
292
+ /**
293
+ * The target of an impersonation that ended, once. The bucket switch calls
294
+ * this after leaving a bucket so it can delete that user's local data.
295
+ */
296
+ consumeEndedImpersonation(): string | null {
297
+ const target = this.endedImpersonationTarget;
298
+ this.endedImpersonationTarget = null;
299
+ return target;
130
300
  }
131
301
 
132
302
  /**
@@ -178,8 +348,9 @@ export class AuthService<S extends SchemaStructure> {
178
348
  async check(accessToken?: string) {
179
349
  this.isLoading = true;
180
350
 
351
+ let token: string | undefined;
181
352
  try {
182
- const token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token'));
353
+ token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token')) || undefined;
183
354
 
184
355
  if (!token) {
185
356
  this.logger.debug(
@@ -236,7 +407,7 @@ export class AuthService<S extends SchemaStructure> {
236
407
  { Category: 'sp00ky-client::AuthService::check' },
237
408
  'Token valid but user not found via fallback'
238
409
  );
239
- await this.signOut();
410
+ await this.rejectToken(token);
240
411
  }
241
412
  }
242
413
  } catch (error) {
@@ -255,7 +426,7 @@ export class AuthService<S extends SchemaStructure> {
255
426
  { error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
256
427
  'Auth check failed'
257
428
  );
258
- await this.signOut();
429
+ await this.rejectToken(token);
259
430
  }
260
431
  } finally {
261
432
  this.isLoading = false;
@@ -263,9 +434,42 @@ export class AuthService<S extends SchemaStructure> {
263
434
  }
264
435
 
265
436
  /**
266
- * Sign out and clear session
437
+ * The server refused `token`. A refused impersonation token (stopped,
438
+ * expired, or revoked) returns to the admin session instead of signing the
439
+ * admin out; anything else signs out.
440
+ */
441
+ private async rejectToken(token: string | undefined): Promise<void> {
442
+ if (token && impersonationFromToken(token)) {
443
+ if (!this.impersonation || this.token !== token) {
444
+ // A boot or explicit check with an impersonation token that is not
445
+ // the session in memory yet: adopt it so stop has something to leave.
446
+ this.token = token;
447
+ this.isAuthenticated = true;
448
+ }
449
+ await this.stopImpersonating({ serverEnded: true });
450
+ return;
451
+ }
452
+ await this.signOut();
453
+ }
454
+
455
+ /**
456
+ * Sign out and clear session. While impersonating this ends the
457
+ * impersonation and signs the admin out too.
267
458
  */
268
459
  async signOut() {
460
+ const impersonating = this.impersonation;
461
+ this.impersonationTimers.clear();
462
+ if (impersonating) {
463
+ this.endedImpersonationTarget = impersonating.target;
464
+ try {
465
+ await this.remote.query('RETURN fn::_00_impersonate::stop($session)', {
466
+ session: impersonating.session,
467
+ });
468
+ } catch (_e) {
469
+ // The token expires on its own.
470
+ }
471
+ }
472
+ await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
269
473
  this.token = null;
270
474
  this.remote.setAuthToken(null);
271
475
  this.currentUser = null;
@@ -14,7 +14,12 @@ export interface DevToolsEvent {
14
14
  }
15
15
 
16
16
  import type { QueryState, QueryTimings } from '../../types';
17
- import type { AuthService } from '../auth/index';
17
+ import type {
18
+ AuthService,
19
+ ActiveImpersonation,
20
+ ImpersonationCandidate,
21
+ ImpersonationInfo,
22
+ } from '../auth/index';
18
23
  import { AuthEventTypes } from '../auth/events/index';
19
24
  import {
20
25
  type BackendInfo,
@@ -44,6 +49,19 @@ export interface DevToolsQuerySource {
44
49
  phaseTimings(q: QueryState): QueryTimings;
45
50
  }
46
51
 
52
+ export type ImpersonationOp = 'status' | 'listUsers' | 'start' | 'stop';
53
+
54
+ export interface ImpersonationOpResult {
55
+ success: boolean;
56
+ error?: string;
57
+ /** Whether the generated schema says the project enabled impersonation. */
58
+ enabled?: boolean;
59
+ isAdmin?: boolean;
60
+ current?: ImpersonationInfo | null;
61
+ active?: ActiveImpersonation[];
62
+ users?: ImpersonationCandidate[];
63
+ }
64
+
47
65
  export class DevToolsService implements StreamUpdateReceiver {
48
66
  private eventsHistory: DevToolsEvent[] = [];
49
67
  private eventIdCounter = 0;
@@ -400,6 +418,7 @@ export class DevToolsService implements StreamUpdateReceiver {
400
418
  auth: {
401
419
  authenticated: this.authService.isAuthenticated,
402
420
  userId: this.authService.currentUser?.id,
421
+ impersonation: this.authService.impersonation,
403
422
  },
404
423
  version: this.version,
405
424
  versions: {
@@ -431,6 +450,51 @@ export class DevToolsService implements StreamUpdateReceiver {
431
450
  });
432
451
  }
433
452
 
453
+ /**
454
+ * The Access tab's impersonation controls. Errors come back as
455
+ * `{ success: false, error }` with the server's own message, so the panel
456
+ * can show why (not an admin, feature disabled, target is an admin).
457
+ */
458
+ public async impersonationOp(
459
+ op: ImpersonationOp,
460
+ args: Record<string, unknown>
461
+ ): Promise<ImpersonationOpResult> {
462
+ const enabled = this.schema.policy?.impersonation === true;
463
+ try {
464
+ switch (op) {
465
+ case 'status': {
466
+ const current = this.authService.impersonation;
467
+ const snapshot = await this.flagsAdmin.getFlags().catch(() => null);
468
+ const isAdmin = snapshot?.isAdmin === true;
469
+ const active =
470
+ enabled && isAdmin && !current
471
+ ? await this.authService.listActiveImpersonations().catch(() => [])
472
+ : [];
473
+ return this.serializeForDevTools({ success: true, enabled, isAdmin, current, active });
474
+ }
475
+ case 'listUsers':
476
+ return this.serializeForDevTools({
477
+ success: true,
478
+ users: await this.authService.searchImpersonationTargets(String(args.search ?? '')),
479
+ });
480
+ case 'start': {
481
+ const current = await this.authService.impersonate(
482
+ String(args.target ?? ''),
483
+ String(args.reason ?? '')
484
+ );
485
+ return this.serializeForDevTools({ success: true, current });
486
+ }
487
+ case 'stop':
488
+ await this.authService.stopImpersonating();
489
+ return { success: true, current: null };
490
+ default:
491
+ return { success: false, error: `Unknown impersonation op: ${String(op)}` };
492
+ }
493
+ } catch (e) {
494
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
495
+ }
496
+ }
497
+
434
498
  /**
435
499
  * Full storage diagnostics for the DevTools Storage tab. Every section is
436
500
  * gathered independently and failures land in that section's `error` field,
@@ -631,6 +695,12 @@ export class DevToolsService implements StreamUpdateReceiver {
631
695
  // are denied outright. The override methods are purely local and
632
696
  // work signed out.
633
697
  getFlags: () => this.flagsAdmin.getFlags(),
698
+ // ---- Impersonation (Access tab) --------------------------------
699
+ // Every op is authorized by SurrealDB (`fn::_00_impersonate::*` are
700
+ // admin-only and absent unless the project enabled the feature);
701
+ // nothing here grants anything.
702
+ impersonationOp: (op: ImpersonationOp, args?: Record<string, unknown>) =>
703
+ this.impersonationOp(op, args ?? {}),
634
704
  setFlagEnabled: (key: string, enabled: boolean) =>
635
705
  this.flagsAdmin.setFlagEnabled(key, enabled),
636
706
  setFlagUserVariant: (key: string, variant: string, remove: boolean, userId?: string) =>
@@ -135,6 +135,12 @@ export interface LocalStore extends LocalCacheEngine {
135
135
  * client facade, so adding this needs no change on their side.
136
136
  */
137
137
  readonly storageHealth?: StorageHealth;
138
+ /**
139
+ * Delete a bucket's persisted store. Never the open one. OPTIONAL and
140
+ * best-effort: used to remove an impersonated user's data from the admin's
141
+ * device once the impersonation ends.
142
+ */
143
+ dropBucket?(bucketId: string): Promise<void>;
138
144
  /** Fires immediately with the current snapshot, then on every change.
139
145
  * Returns an unsubscribe function. */
140
146
  subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
@@ -255,6 +255,13 @@ export class LocalDatabaseService extends AbstractDatabaseService {
255
255
  );
256
256
  }
257
257
 
258
+ /** See `LocalStore.dropBucket`. A memory store keeps nothing to delete. */
259
+ async dropBucket(bucketId: string): Promise<void> {
260
+ if (bucketId === this.bucketId) throw new Error('Cannot drop the open bucket.');
261
+ if ((this.getConfig().store ?? 'memory') === 'memory') return;
262
+ await dropLocalIndexedDbStores(this.logger, bucketStoreName(bucketId));
263
+ }
264
+
258
265
  /**
259
266
  * Open `storeUrl` on `client` with tiered recovery:
260
267
  * tier 1 retries the same store (transient idb-handle races — preserves the
@@ -236,6 +236,23 @@ export class SqliteCacheEngine implements LocalStore {
236
236
  throw new Error('SqliteCacheEngine has no SurrealDB client (getClient is unavailable).');
237
237
  }
238
238
 
239
+ /**
240
+ * See `LocalStore.dropBucket`. Each bucket is its own OPFS SAH pool, whose
241
+ * directory sqlite-wasm names `.<pool name>` (`sp00ky-<bucket>`, see
242
+ * sqlite-open.ts). Fails harmlessly while a worker still holds its handles.
243
+ */
244
+ async dropBucket(bucketId: string): Promise<void> {
245
+ if (bucketId === this.bucketId) throw new Error('Cannot drop the open bucket.');
246
+ const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
247
+ if (!storage?.getDirectory) return;
248
+ const root = await storage.getDirectory();
249
+ try {
250
+ await root.removeEntry(`.sp00ky-${bucketId}`, { recursive: true });
251
+ } catch (error) {
252
+ if ((error as DOMException)?.name !== 'NotFoundError') throw error;
253
+ }
254
+ }
255
+
239
256
  /** LocalStore alias; SQLite has no in-flight gate, so this maps to a rebuild. */
240
257
  switchStore(bucketId: string): Promise<void> {
241
258
  return this.switchBucket(bucketId);
@@ -24,7 +24,7 @@ export function fakeServiceBundle<S extends SchemaStructure>(over: Partial<Servi
24
24
  streamProcessor: { addReceiver: (r: unknown) => void receivers.push(r), checkpoint: async () => undefined, dispose: noop } as any,
25
25
  migrator: {} as any,
26
26
  crdt: { closeAll: noop, dispose: noop, open: async () => 'field', close: noop } as any,
27
- auth: { subscribe: (cb: (u: string | null) => void) => (authListeners.push(cb), noop), currentUser: null, isAuthenticated: false, eventSystem: { subscribe: noop } } as any,
27
+ auth: { subscribe: (cb: (u: string | null) => void) => (authListeners.push(cb), noop), subscribeImpersonation: () => noop, currentUser: null, isAuthenticated: false, impersonation: null, eventSystem: { subscribe: noop } } as any,
28
28
  tabs: null,
29
29
  tabsUnsupportedReason: 'test',
30
30
  tabId: 'tab-test',