@spooky-sync/core 0.0.1-canary.260 → 0.0.1-canary.262
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/AGENTS.md +1 -1
- package/dist/index.d.ts +159 -2
- package/dist/index.js +657 -27
- package/dist/types.d.ts +6 -0
- package/package.json +3 -3
- package/src/boot/bucket-switch.saga.test.ts +30 -1
- package/src/boot/bucket-switch.saga.ts +17 -1
- package/src/client/services.ts +4 -0
- package/src/client/sp00ky-client.ts +8 -0
- package/src/kernel/effects.ts +2 -0
- package/src/modules/auth/events/index.ts +7 -1
- package/src/modules/auth/impersonation-banner.ts +273 -0
- package/src/modules/auth/impersonation.test.ts +282 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +240 -36
- package/src/modules/devtools/index.ts +71 -1
- package/src/services/database/cache-engine.ts +6 -0
- package/src/services/database/local.ts +7 -0
- package/src/services/database/sqlite-cache-engine.ts +17 -0
- package/src/testing/fake-services.ts +1 -1
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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 {
|
|
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',
|