@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.
- package/AGENTS.md +1 -1
- package/dist/index.d.ts +159 -2
- package/dist/index.js +522 -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 +112 -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
package/dist/index.js
CHANGED
|
@@ -760,9 +760,12 @@ function createEventSystem(eventTypes) {
|
|
|
760
760
|
|
|
761
761
|
//#endregion
|
|
762
762
|
//#region src/modules/auth/events/index.ts
|
|
763
|
-
const AuthEventTypes = {
|
|
763
|
+
const AuthEventTypes = {
|
|
764
|
+
AuthStateChanged: "AUTH_STATE_CHANGED",
|
|
765
|
+
ImpersonationChanged: "IMPERSONATION_CHANGED"
|
|
766
|
+
};
|
|
764
767
|
function createAuthEventSystem() {
|
|
765
|
-
return createEventSystem([AuthEventTypes.AuthStateChanged]);
|
|
768
|
+
return createEventSystem([AuthEventTypes.AuthStateChanged, AuthEventTypes.ImpersonationChanged]);
|
|
766
769
|
}
|
|
767
770
|
|
|
768
771
|
//#endregion
|
|
@@ -1073,8 +1076,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
1073
1076
|
|
|
1074
1077
|
//#endregion
|
|
1075
1078
|
//#region src/modules/devtools/index.ts
|
|
1076
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
1077
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
1079
|
+
const CORE_VERSION = "0.0.1-canary.261";
|
|
1080
|
+
const WASM_VERSION = "0.0.1-canary.261";
|
|
1078
1081
|
const SURREAL_VERSION = "3.0.3";
|
|
1079
1082
|
var DevToolsService = class DevToolsService {
|
|
1080
1083
|
eventsHistory = [];
|
|
@@ -1307,7 +1310,8 @@ var DevToolsService = class DevToolsService {
|
|
|
1307
1310
|
activeQueries: Object.fromEntries(this.getActiveQueries()),
|
|
1308
1311
|
auth: {
|
|
1309
1312
|
authenticated: this.authService.isAuthenticated,
|
|
1310
|
-
userId: this.authService.currentUser?.id
|
|
1313
|
+
userId: this.authService.currentUser?.id,
|
|
1314
|
+
impersonation: this.authService.impersonation
|
|
1311
1315
|
},
|
|
1312
1316
|
version: this.version,
|
|
1313
1317
|
versions: {
|
|
@@ -1332,6 +1336,56 @@ var DevToolsService = class DevToolsService {
|
|
|
1332
1336
|
});
|
|
1333
1337
|
}
|
|
1334
1338
|
/**
|
|
1339
|
+
* The Access tab's impersonation controls. Errors come back as
|
|
1340
|
+
* `{ success: false, error }` with the server's own message, so the panel
|
|
1341
|
+
* can show why (not an admin, feature disabled, target is an admin).
|
|
1342
|
+
*/
|
|
1343
|
+
async impersonationOp(op, args) {
|
|
1344
|
+
const enabled = this.schema.policy?.impersonation === true;
|
|
1345
|
+
try {
|
|
1346
|
+
switch (op) {
|
|
1347
|
+
case "status": {
|
|
1348
|
+
const current = this.authService.impersonation;
|
|
1349
|
+
const isAdmin = (await this.flagsAdmin.getFlags().catch(() => null))?.isAdmin === true;
|
|
1350
|
+
const active = enabled && isAdmin && !current ? await this.authService.listActiveImpersonations().catch(() => []) : [];
|
|
1351
|
+
return this.serializeForDevTools({
|
|
1352
|
+
success: true,
|
|
1353
|
+
enabled,
|
|
1354
|
+
isAdmin,
|
|
1355
|
+
current,
|
|
1356
|
+
active
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
case "listUsers": return this.serializeForDevTools({
|
|
1360
|
+
success: true,
|
|
1361
|
+
users: await this.authService.searchImpersonationTargets(String(args.search ?? ""))
|
|
1362
|
+
});
|
|
1363
|
+
case "start": {
|
|
1364
|
+
const current = await this.authService.impersonate(String(args.target ?? ""), String(args.reason ?? ""));
|
|
1365
|
+
return this.serializeForDevTools({
|
|
1366
|
+
success: true,
|
|
1367
|
+
current
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
case "stop":
|
|
1371
|
+
await this.authService.stopImpersonating();
|
|
1372
|
+
return {
|
|
1373
|
+
success: true,
|
|
1374
|
+
current: null
|
|
1375
|
+
};
|
|
1376
|
+
default: return {
|
|
1377
|
+
success: false,
|
|
1378
|
+
error: `Unknown impersonation op: ${String(op)}`
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
} catch (e) {
|
|
1382
|
+
return {
|
|
1383
|
+
success: false,
|
|
1384
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1335
1389
|
* Full storage diagnostics for the DevTools Storage tab. Every section is
|
|
1336
1390
|
* gathered independently and failures land in that section's `error` field,
|
|
1337
1391
|
* so one broken source (a mid-switch worker, a browser without OPFS) never
|
|
@@ -1477,6 +1531,7 @@ var DevToolsService = class DevToolsService {
|
|
|
1477
1531
|
});
|
|
1478
1532
|
},
|
|
1479
1533
|
getFlags: () => this.flagsAdmin.getFlags(),
|
|
1534
|
+
impersonationOp: (op, args) => this.impersonationOp(op, args ?? {}),
|
|
1480
1535
|
setFlagEnabled: (key, enabled) => this.flagsAdmin.setFlagEnabled(key, enabled),
|
|
1481
1536
|
setFlagUserVariant: (key, variant, remove, userId) => this.flagsAdmin.setFlagUserVariant(key, variant, remove, userId),
|
|
1482
1537
|
setLocalFlagOverride: (key, variant, payload) => this.flagsAdmin.setLocalFlagOverride(key, variant, payload),
|
|
@@ -5492,6 +5547,7 @@ function* bucketSwitch(env, target, release = null, opts = { clearBlobsOnSignOut
|
|
|
5492
5547
|
} finally {
|
|
5493
5548
|
gate();
|
|
5494
5549
|
}
|
|
5550
|
+
yield* dropImpersonatedBucket(current);
|
|
5495
5551
|
const token = yield fx.service("auth.token");
|
|
5496
5552
|
if (token) try {
|
|
5497
5553
|
yield fx.service("persistence.set", "sp00ky_auth_token", token);
|
|
@@ -5510,6 +5566,28 @@ function* bucketSwitch(env, target, release = null, opts = { clearBlobsOnSignOut
|
|
|
5510
5566
|
yield fx.dispatch({ type: "PollTick" });
|
|
5511
5567
|
yield fx.dispatch({ type: "Drain" });
|
|
5512
5568
|
}
|
|
5569
|
+
/**
|
|
5570
|
+
* An impersonation just ended and this switch left its bucket: delete that
|
|
5571
|
+
* user's local data from the admin's device. Best-effort; a failure only
|
|
5572
|
+
* leaves rows the server already let this admin read.
|
|
5573
|
+
*/
|
|
5574
|
+
function* dropImpersonatedBucket(left) {
|
|
5575
|
+
const ended = yield fx.service("auth.consumeEndedImpersonation");
|
|
5576
|
+
if (!ended || left === ANON_USER_ID || bucketIdForUser(ended) !== left) return;
|
|
5577
|
+
try {
|
|
5578
|
+
yield fx.service("local.dropBucket", left);
|
|
5579
|
+
} catch (error) {
|
|
5580
|
+
yield fx.emit({
|
|
5581
|
+
type: "log",
|
|
5582
|
+
level: "warn",
|
|
5583
|
+
message: "could not delete the impersonated bucket",
|
|
5584
|
+
data: {
|
|
5585
|
+
bucket: left,
|
|
5586
|
+
error
|
|
5587
|
+
}
|
|
5588
|
+
});
|
|
5589
|
+
}
|
|
5590
|
+
}
|
|
5513
5591
|
/** Re-seed every active query from the new store and rebuild its SSP view. */
|
|
5514
5592
|
function* rebindQueries() {
|
|
5515
5593
|
const [entries, sessionId] = yield fx.state.read((s) => [[...s.queries.values()], s.sessionId]);
|
|
@@ -6437,6 +6515,12 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
6437
6515
|
Category: "sp00ky-client::LocalDatabaseService::switchStore"
|
|
6438
6516
|
}, "Switched local store bucket");
|
|
6439
6517
|
}
|
|
6518
|
+
/** See `LocalStore.dropBucket`. A memory store keeps nothing to delete. */
|
|
6519
|
+
async dropBucket(bucketId) {
|
|
6520
|
+
if (bucketId === this.bucketId) throw new Error("Cannot drop the open bucket.");
|
|
6521
|
+
if ((this.getConfig().store ?? "memory") === "memory") return;
|
|
6522
|
+
await dropLocalIndexedDbStores(this.logger, bucketStoreName(bucketId));
|
|
6523
|
+
}
|
|
6440
6524
|
/**
|
|
6441
6525
|
* Open `storeUrl` on `client` with tiered recovery:
|
|
6442
6526
|
* tier 1 retries the same store (transient idb-handle races — preserves the
|
|
@@ -8079,6 +8163,22 @@ var SqliteCacheEngine = class {
|
|
|
8079
8163
|
getClient() {
|
|
8080
8164
|
throw new Error("SqliteCacheEngine has no SurrealDB client (getClient is unavailable).");
|
|
8081
8165
|
}
|
|
8166
|
+
/**
|
|
8167
|
+
* See `LocalStore.dropBucket`. Each bucket is its own OPFS SAH pool, whose
|
|
8168
|
+
* directory sqlite-wasm names `.<pool name>` (`sp00ky-<bucket>`, see
|
|
8169
|
+
* sqlite-open.ts). Fails harmlessly while a worker still holds its handles.
|
|
8170
|
+
*/
|
|
8171
|
+
async dropBucket(bucketId) {
|
|
8172
|
+
if (bucketId === this.bucketId) throw new Error("Cannot drop the open bucket.");
|
|
8173
|
+
const storage = typeof navigator !== "undefined" ? navigator.storage : void 0;
|
|
8174
|
+
if (!storage?.getDirectory) return;
|
|
8175
|
+
const root = await storage.getDirectory();
|
|
8176
|
+
try {
|
|
8177
|
+
await root.removeEntry(`.sp00ky-${bucketId}`, { recursive: true });
|
|
8178
|
+
} catch (error) {
|
|
8179
|
+
if (error?.name !== "NotFoundError") throw error;
|
|
8180
|
+
}
|
|
8181
|
+
}
|
|
8082
8182
|
/** LocalStore alias; SQLite has no in-flight gate, so this maps to a rebuild. */
|
|
8083
8183
|
switchStore(bucketId) {
|
|
8084
8184
|
return this.switchBucket(bucketId);
|
|
@@ -9688,43 +9788,150 @@ function selectPredicateFromBody(body) {
|
|
|
9688
9788
|
}
|
|
9689
9789
|
|
|
9690
9790
|
//#endregion
|
|
9691
|
-
//#region src/modules/auth/
|
|
9791
|
+
//#region src/modules/auth/impersonation.ts
|
|
9792
|
+
/**
|
|
9793
|
+
* Admin impersonation, client side.
|
|
9794
|
+
*
|
|
9795
|
+
* The server does the enforcing (see `impersonation_remote.surql` in the CLI):
|
|
9796
|
+
* an admin calls `fn::_00_impersonate::start`, receives a token for the
|
|
9797
|
+
* `_00_impersonate` access method, and authenticates with it, so `$auth.id` is
|
|
9798
|
+
* the target user. Everything here is state and timers around that:
|
|
9799
|
+
*
|
|
9800
|
+
* - the admin's own token is kept under {@link ORIGIN_TOKEN_KEY} so Stop (and a
|
|
9801
|
+
* reload in between) can return to it;
|
|
9802
|
+
* - the impersonation state is derived from the token's claims, never stored
|
|
9803
|
+
* separately, so it cannot disagree with the session actually in use;
|
|
9804
|
+
* - the token is re-signed before it expires, and the session is polled so a
|
|
9805
|
+
* stop or revocation made elsewhere reaches an open WebSocket quickly.
|
|
9806
|
+
*/
|
|
9807
|
+
/** Access method impersonation tokens are issued for. */
|
|
9808
|
+
const IMPERSONATION_ACCESS = "_00_impersonate";
|
|
9809
|
+
/** Persistence key holding the admin's own token while impersonating. */
|
|
9810
|
+
const ORIGIN_TOKEN_KEY = "sp00ky_auth_token_origin";
|
|
9811
|
+
/** How often an active impersonation re-checks its session on the server. */
|
|
9812
|
+
const SESSION_POLL_MS = 6e4;
|
|
9692
9813
|
/**
|
|
9693
9814
|
* Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
|
|
9694
9815
|
* server still enforces the token on every request; this is only so the client
|
|
9695
9816
|
* can act on what it already holds before a round trip completes.
|
|
9696
9817
|
*
|
|
9697
|
-
* `AC` is the access-method name — the in-browser SSP needs it to resolve
|
|
9698
|
-
* `$access` in table permission predicates (mirrors the session's `$access`
|
|
9699
|
-
* that the server's `fn::query::register` reads). `ID` is the `$auth.id` record
|
|
9700
|
-
* id, which is what lets a warm boot restore a session locally.
|
|
9701
|
-
*
|
|
9702
9818
|
* Returns nulls on any malformed input.
|
|
9703
9819
|
*/
|
|
9704
9820
|
function decodeTokenClaims(token) {
|
|
9821
|
+
const none = {
|
|
9822
|
+
access: null,
|
|
9823
|
+
userId: null,
|
|
9824
|
+
exp: null,
|
|
9825
|
+
impersonation: null
|
|
9826
|
+
};
|
|
9705
9827
|
try {
|
|
9706
9828
|
const payload = token.split(".")[1];
|
|
9707
|
-
if (!payload) return
|
|
9708
|
-
access: null,
|
|
9709
|
-
userId: null
|
|
9710
|
-
};
|
|
9829
|
+
if (!payload) return none;
|
|
9711
9830
|
let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
9712
9831
|
b64 += "=".repeat((4 - b64.length % 4) % 4);
|
|
9713
9832
|
const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
9714
9833
|
const claims = JSON.parse(json);
|
|
9715
9834
|
const ac = claims.AC ?? claims.ac;
|
|
9716
9835
|
const id = claims.ID ?? claims.id;
|
|
9836
|
+
const access = typeof ac === "string" ? ac : null;
|
|
9837
|
+
const session = claims.spky_imp;
|
|
9838
|
+
const admin = claims.spky_admin;
|
|
9717
9839
|
return {
|
|
9718
|
-
access
|
|
9719
|
-
userId: typeof id === "string" ? id : null
|
|
9840
|
+
access,
|
|
9841
|
+
userId: typeof id === "string" ? id : null,
|
|
9842
|
+
exp: typeof claims.exp === "number" ? claims.exp : null,
|
|
9843
|
+
impersonation: access === IMPERSONATION_ACCESS && typeof session === "string" && typeof admin === "string" ? {
|
|
9844
|
+
session,
|
|
9845
|
+
admin
|
|
9846
|
+
} : null
|
|
9720
9847
|
};
|
|
9721
9848
|
} catch {
|
|
9722
|
-
return
|
|
9723
|
-
access: null,
|
|
9724
|
-
userId: null
|
|
9725
|
-
};
|
|
9849
|
+
return none;
|
|
9726
9850
|
}
|
|
9727
9851
|
}
|
|
9852
|
+
/** The impersonation a token represents, or null for a regular session. */
|
|
9853
|
+
function impersonationFromToken(token) {
|
|
9854
|
+
if (!token) return null;
|
|
9855
|
+
const claims = decodeTokenClaims(token);
|
|
9856
|
+
if (!claims.impersonation || !claims.userId) return null;
|
|
9857
|
+
return {
|
|
9858
|
+
session: claims.impersonation.session,
|
|
9859
|
+
admin: claims.impersonation.admin,
|
|
9860
|
+
target: claims.userId,
|
|
9861
|
+
tokenExpiresAt: claims.exp === null ? null : /* @__PURE__ */ new Date(claims.exp * 1e3)
|
|
9862
|
+
};
|
|
9863
|
+
}
|
|
9864
|
+
/** Same impersonation, for change detection (renewals only move the expiry). */
|
|
9865
|
+
function sameImpersonation(a, b) {
|
|
9866
|
+
if (a === null || b === null) return a === b;
|
|
9867
|
+
return a.session === b.session && a.target === b.target && a.admin === b.admin;
|
|
9868
|
+
}
|
|
9869
|
+
/**
|
|
9870
|
+
* When to renew a token expiring at `expSecs`: at 80% of its remaining life,
|
|
9871
|
+
* never sooner than 5s from now, and immediately once it is inside that.
|
|
9872
|
+
*/
|
|
9873
|
+
function renewDelayMs(expSecs, nowMs) {
|
|
9874
|
+
const remaining = expSecs * 1e3 - nowMs;
|
|
9875
|
+
if (remaining <= 5e3) return 0;
|
|
9876
|
+
return Math.max(5e3, Math.floor(remaining * .8));
|
|
9877
|
+
}
|
|
9878
|
+
/**
|
|
9879
|
+
* The two timers an active impersonation needs. `sync()` is called whenever
|
|
9880
|
+
* the auth state changes and arms or clears them to match.
|
|
9881
|
+
*/
|
|
9882
|
+
var ImpersonationTimers = class {
|
|
9883
|
+
renewTimer = null;
|
|
9884
|
+
pollTimer = null;
|
|
9885
|
+
armedFor = null;
|
|
9886
|
+
constructor(host) {
|
|
9887
|
+
this.host = host;
|
|
9888
|
+
}
|
|
9889
|
+
sync() {
|
|
9890
|
+
const info = this.host.current();
|
|
9891
|
+
if (!info) {
|
|
9892
|
+
this.clear();
|
|
9893
|
+
return;
|
|
9894
|
+
}
|
|
9895
|
+
const key = `${info.session}@${info.tokenExpiresAt?.getTime() ?? "none"}`;
|
|
9896
|
+
if (key === this.armedFor) return;
|
|
9897
|
+
this.clear();
|
|
9898
|
+
this.armedFor = key;
|
|
9899
|
+
if (info.tokenExpiresAt) {
|
|
9900
|
+
const delay = renewDelayMs(info.tokenExpiresAt.getTime() / 1e3, Date.now());
|
|
9901
|
+
this.renewTimer = setTimeout(() => void this.renew(), delay);
|
|
9902
|
+
}
|
|
9903
|
+
this.pollTimer = setInterval(() => void this.poll(), SESSION_POLL_MS);
|
|
9904
|
+
}
|
|
9905
|
+
clear() {
|
|
9906
|
+
if (this.renewTimer) clearTimeout(this.renewTimer);
|
|
9907
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
9908
|
+
this.renewTimer = null;
|
|
9909
|
+
this.pollTimer = null;
|
|
9910
|
+
this.armedFor = null;
|
|
9911
|
+
}
|
|
9912
|
+
async renew() {
|
|
9913
|
+
this.renewTimer = null;
|
|
9914
|
+
try {
|
|
9915
|
+
await this.host.renew();
|
|
9916
|
+
} catch (error) {
|
|
9917
|
+
if (this.host.isNetworkError(error)) {
|
|
9918
|
+
this.renewTimer = setTimeout(() => void this.renew(), 1e4);
|
|
9919
|
+
return;
|
|
9920
|
+
}
|
|
9921
|
+
await this.host.endedRemotely("renew refused");
|
|
9922
|
+
}
|
|
9923
|
+
}
|
|
9924
|
+
async poll() {
|
|
9925
|
+
try {
|
|
9926
|
+
if (!await this.host.stillActive()) await this.host.endedRemotely("session ended");
|
|
9927
|
+
} catch (error) {
|
|
9928
|
+
if (!this.host.isNetworkError(error)) await this.host.endedRemotely("session check refused");
|
|
9929
|
+
}
|
|
9930
|
+
}
|
|
9931
|
+
};
|
|
9932
|
+
|
|
9933
|
+
//#endregion
|
|
9934
|
+
//#region src/modules/auth/index.ts
|
|
9728
9935
|
function decodeAccessFromToken(token) {
|
|
9729
9936
|
return decodeTokenClaims(token).access;
|
|
9730
9937
|
}
|
|
@@ -9741,6 +9948,27 @@ var AuthService = class {
|
|
|
9741
9948
|
access = null;
|
|
9742
9949
|
isLoading = true;
|
|
9743
9950
|
events = createAuthEventSystem();
|
|
9951
|
+
/** The impersonation last announced, for change detection. */
|
|
9952
|
+
announcedImpersonation = null;
|
|
9953
|
+
/** Target of an impersonation that just ended, until the client purges its
|
|
9954
|
+
* local bucket (see `consumeEndedImpersonation`). */
|
|
9955
|
+
endedImpersonationTarget = null;
|
|
9956
|
+
impersonationTimers = new ImpersonationTimers({
|
|
9957
|
+
current: () => this.impersonation,
|
|
9958
|
+
renew: () => this.renewImpersonation(),
|
|
9959
|
+
stillActive: async () => {
|
|
9960
|
+
const [row] = await this.remote.query("RETURN fn::_00_impersonate::current()");
|
|
9961
|
+
return row !== null && row !== void 0;
|
|
9962
|
+
},
|
|
9963
|
+
endedRemotely: async (reason) => {
|
|
9964
|
+
this.logger.warn({
|
|
9965
|
+
reason,
|
|
9966
|
+
Category: "sp00ky-client::AuthService::impersonation"
|
|
9967
|
+
}, "Impersonation ended on the server; returning to the admin session");
|
|
9968
|
+
await this.stopImpersonating({ serverEnded: true });
|
|
9969
|
+
},
|
|
9970
|
+
isNetworkError: (error) => classifySyncError(error) === "network"
|
|
9971
|
+
});
|
|
9744
9972
|
get eventSystem() {
|
|
9745
9973
|
return this.events;
|
|
9746
9974
|
}
|
|
@@ -9772,6 +10000,141 @@ var AuthService = class {
|
|
|
9772
10000
|
notifyListeners() {
|
|
9773
10001
|
const userId = this.currentUser?.id || null;
|
|
9774
10002
|
this.events.emit(AuthEventTypes.AuthStateChanged, userId);
|
|
10003
|
+
const impersonation = this.impersonation;
|
|
10004
|
+
if (!sameImpersonation(impersonation, this.announcedImpersonation)) {
|
|
10005
|
+
this.announcedImpersonation = impersonation;
|
|
10006
|
+
this.events.emit(AuthEventTypes.ImpersonationChanged, impersonation);
|
|
10007
|
+
}
|
|
10008
|
+
this.impersonationTimers.sync();
|
|
10009
|
+
}
|
|
10010
|
+
/**
|
|
10011
|
+
* The impersonation the current session is, or null. Derived from the
|
|
10012
|
+
* token in use, so it always describes the identity the server sees.
|
|
10013
|
+
*/
|
|
10014
|
+
get impersonation() {
|
|
10015
|
+
return this.isAuthenticated ? impersonationFromToken(this.token) : null;
|
|
10016
|
+
}
|
|
10017
|
+
/** Subscribe to impersonation changes; called immediately with the current value. */
|
|
10018
|
+
subscribeImpersonation(cb) {
|
|
10019
|
+
cb(this.impersonation);
|
|
10020
|
+
const id = this.events.subscribe(AuthEventTypes.ImpersonationChanged, (event) => {
|
|
10021
|
+
cb(event.payload);
|
|
10022
|
+
});
|
|
10023
|
+
return () => {
|
|
10024
|
+
this.events.unsubscribe(id);
|
|
10025
|
+
};
|
|
10026
|
+
}
|
|
10027
|
+
/**
|
|
10028
|
+
* Act as `targetId` (a record id such as `user:abc`). Admins only, and only
|
|
10029
|
+
* when the project enabled `impersonation` in sp00ky.yml: the server refuses
|
|
10030
|
+
* everything else. `reason` is recorded in the audit log.
|
|
10031
|
+
*
|
|
10032
|
+
* The admin's session is kept aside and restored by
|
|
10033
|
+
* {@link stopImpersonating}. Local data switches to the target's bucket
|
|
10034
|
+
* exactly as on a sign-in, and that bucket is removed on stop.
|
|
10035
|
+
*/
|
|
10036
|
+
async impersonate(targetId, reason) {
|
|
10037
|
+
if (this.impersonation) throw new Error("Already impersonating; stop first.");
|
|
10038
|
+
if (!this.isAuthenticated || !this.token) throw new Error("Sign in as an admin first.");
|
|
10039
|
+
const originToken = this.token;
|
|
10040
|
+
const [started] = await this.remote.query("RETURN fn::_00_impersonate::start($target, $reason)", {
|
|
10041
|
+
target: targetId,
|
|
10042
|
+
reason
|
|
10043
|
+
});
|
|
10044
|
+
if (!started?.token) throw new Error("The server did not return an impersonation token.");
|
|
10045
|
+
await this.persistenceClient.set(ORIGIN_TOKEN_KEY, originToken);
|
|
10046
|
+
try {
|
|
10047
|
+
await this.remote.getClient().authenticate(started.token);
|
|
10048
|
+
} catch (error) {
|
|
10049
|
+
await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
|
|
10050
|
+
this.remote.setAuthToken(originToken);
|
|
10051
|
+
await this.remote.getClient().authenticate(originToken).catch(() => void 0);
|
|
10052
|
+
throw error;
|
|
10053
|
+
}
|
|
10054
|
+
await this.check(started.token);
|
|
10055
|
+
const info = impersonationFromToken(this.isAuthenticated ? this.token : null);
|
|
10056
|
+
if (!info) {
|
|
10057
|
+
await this.stopImpersonating();
|
|
10058
|
+
throw new Error("Impersonation could not be established.");
|
|
10059
|
+
}
|
|
10060
|
+
this.logger.info({
|
|
10061
|
+
target: info.target,
|
|
10062
|
+
session: info.session,
|
|
10063
|
+
Category: "sp00ky-client::AuthService::impersonate"
|
|
10064
|
+
}, "Impersonation started");
|
|
10065
|
+
return info;
|
|
10066
|
+
}
|
|
10067
|
+
/**
|
|
10068
|
+
* Return to the admin session. Always leaves the impersonated identity,
|
|
10069
|
+
* even when the server cannot be reached: the admin token is restored
|
|
10070
|
+
* locally first and verified afterwards, and a rejected admin token ends in
|
|
10071
|
+
* a full sign-out rather than staying on the target.
|
|
10072
|
+
*/
|
|
10073
|
+
async stopImpersonating(opts = {}) {
|
|
10074
|
+
const info = this.impersonation;
|
|
10075
|
+
if (!info) return;
|
|
10076
|
+
this.impersonationTimers.clear();
|
|
10077
|
+
if (!opts.serverEnded) try {
|
|
10078
|
+
await this.remote.query("RETURN fn::_00_impersonate::stop($session)", { session: info.session });
|
|
10079
|
+
} catch (error) {
|
|
10080
|
+
this.logger.warn({
|
|
10081
|
+
error,
|
|
10082
|
+
Category: "sp00ky-client::AuthService::stopImpersonating"
|
|
10083
|
+
}, "Could not end the impersonation session on the server");
|
|
10084
|
+
}
|
|
10085
|
+
this.endedImpersonationTarget = info.target;
|
|
10086
|
+
const origin = await this.persistenceClient.get(ORIGIN_TOKEN_KEY);
|
|
10087
|
+
await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
|
|
10088
|
+
if (!origin || impersonationFromToken(origin)) {
|
|
10089
|
+
await this.signOut();
|
|
10090
|
+
return;
|
|
10091
|
+
}
|
|
10092
|
+
const { access, userId } = decodeTokenClaims(origin);
|
|
10093
|
+
if (!userId) {
|
|
10094
|
+
await this.signOut();
|
|
10095
|
+
return;
|
|
10096
|
+
}
|
|
10097
|
+
this.token = origin;
|
|
10098
|
+
this.remote.setAuthToken(origin);
|
|
10099
|
+
this.currentUser = { id: userId };
|
|
10100
|
+
this.access = access ?? this.defaultAccessName();
|
|
10101
|
+
await this.persistenceClient.set("sp00ky_auth_token", origin);
|
|
10102
|
+
this.notifyListeners();
|
|
10103
|
+
await this.check(origin);
|
|
10104
|
+
this.logger.info({
|
|
10105
|
+
target: info.target,
|
|
10106
|
+
Category: "sp00ky-client::AuthService::stopImpersonating"
|
|
10107
|
+
}, "Impersonation stopped");
|
|
10108
|
+
}
|
|
10109
|
+
/** Re-sign the impersonation token before it expires. */
|
|
10110
|
+
async renewImpersonation() {
|
|
10111
|
+
if (!this.impersonation) return;
|
|
10112
|
+
const [renewed] = await this.remote.query("RETURN fn::_00_impersonate::renew()");
|
|
10113
|
+
if (!renewed?.token) throw new Error("Impersonation renewal returned no token.");
|
|
10114
|
+
this.remote.setAuthToken(renewed.token);
|
|
10115
|
+
await this.remote.getClient().authenticate(renewed.token);
|
|
10116
|
+
this.token = renewed.token;
|
|
10117
|
+
await this.persistenceClient.set("sp00ky_auth_token", renewed.token);
|
|
10118
|
+
this.notifyListeners();
|
|
10119
|
+
}
|
|
10120
|
+
/** Users an admin may pick from. Admin-only on the server. */
|
|
10121
|
+
async searchImpersonationTargets(search) {
|
|
10122
|
+
const [rows] = await this.remote.query("RETURN fn::_00_impersonate::list_users($search)", { search });
|
|
10123
|
+
return Array.isArray(rows) ? rows : [];
|
|
10124
|
+
}
|
|
10125
|
+
/** Open impersonation sessions. Admin-only on the server. */
|
|
10126
|
+
async listActiveImpersonations() {
|
|
10127
|
+
const [rows] = await this.remote.query("RETURN fn::_00_impersonate::active()");
|
|
10128
|
+
return Array.isArray(rows) ? rows : [];
|
|
10129
|
+
}
|
|
10130
|
+
/**
|
|
10131
|
+
* The target of an impersonation that ended, once. The bucket switch calls
|
|
10132
|
+
* this after leaving a bucket so it can delete that user's local data.
|
|
10133
|
+
*/
|
|
10134
|
+
consumeEndedImpersonation() {
|
|
10135
|
+
const target = this.endedImpersonationTarget;
|
|
10136
|
+
this.endedImpersonationTarget = null;
|
|
10137
|
+
return target;
|
|
9775
10138
|
}
|
|
9776
10139
|
/**
|
|
9777
10140
|
* Restore a session from the locally cached JWT, with NO network.
|
|
@@ -9812,8 +10175,9 @@ var AuthService = class {
|
|
|
9812
10175
|
*/
|
|
9813
10176
|
async check(accessToken) {
|
|
9814
10177
|
this.isLoading = true;
|
|
10178
|
+
let token;
|
|
9815
10179
|
try {
|
|
9816
|
-
|
|
10180
|
+
token = accessToken || await this.persistenceClient.get("sp00ky_auth_token") || void 0;
|
|
9817
10181
|
if (!token) {
|
|
9818
10182
|
this.logger.debug({ Category: "sp00ky-client::AuthService::check" }, "No token found in storage or arguments");
|
|
9819
10183
|
this.isLoading = false;
|
|
@@ -9845,7 +10209,7 @@ var AuthService = class {
|
|
|
9845
10209
|
await this.setSession(token, manualUser);
|
|
9846
10210
|
} else {
|
|
9847
10211
|
this.logger.warn({ Category: "sp00ky-client::AuthService::check" }, "Token valid but user not found via fallback");
|
|
9848
|
-
await this.
|
|
10212
|
+
await this.rejectToken(token);
|
|
9849
10213
|
}
|
|
9850
10214
|
}
|
|
9851
10215
|
} catch (error) {
|
|
@@ -9859,16 +10223,42 @@ var AuthService = class {
|
|
|
9859
10223
|
stack: error.stack,
|
|
9860
10224
|
Category: "sp00ky-client::AuthService::check"
|
|
9861
10225
|
}, "Auth check failed");
|
|
9862
|
-
await this.
|
|
10226
|
+
await this.rejectToken(token);
|
|
9863
10227
|
}
|
|
9864
10228
|
} finally {
|
|
9865
10229
|
this.isLoading = false;
|
|
9866
10230
|
}
|
|
9867
10231
|
}
|
|
9868
10232
|
/**
|
|
9869
|
-
*
|
|
10233
|
+
* The server refused `token`. A refused impersonation token (stopped,
|
|
10234
|
+
* expired, or revoked) returns to the admin session instead of signing the
|
|
10235
|
+
* admin out; anything else signs out.
|
|
10236
|
+
*/
|
|
10237
|
+
async rejectToken(token) {
|
|
10238
|
+
if (token && impersonationFromToken(token)) {
|
|
10239
|
+
if (!this.impersonation || this.token !== token) {
|
|
10240
|
+
this.token = token;
|
|
10241
|
+
this.isAuthenticated = true;
|
|
10242
|
+
}
|
|
10243
|
+
await this.stopImpersonating({ serverEnded: true });
|
|
10244
|
+
return;
|
|
10245
|
+
}
|
|
10246
|
+
await this.signOut();
|
|
10247
|
+
}
|
|
10248
|
+
/**
|
|
10249
|
+
* Sign out and clear session. While impersonating this ends the
|
|
10250
|
+
* impersonation and signs the admin out too.
|
|
9870
10251
|
*/
|
|
9871
10252
|
async signOut() {
|
|
10253
|
+
const impersonating = this.impersonation;
|
|
10254
|
+
this.impersonationTimers.clear();
|
|
10255
|
+
if (impersonating) {
|
|
10256
|
+
this.endedImpersonationTarget = impersonating.target;
|
|
10257
|
+
try {
|
|
10258
|
+
await this.remote.query("RETURN fn::_00_impersonate::stop($session)", { session: impersonating.session });
|
|
10259
|
+
} catch (_e) {}
|
|
10260
|
+
}
|
|
10261
|
+
await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
|
|
9872
10262
|
this.token = null;
|
|
9873
10263
|
this.remote.setAuthToken(null);
|
|
9874
10264
|
this.currentUser = null;
|
|
@@ -12635,7 +13025,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
12635
13025
|
return new TabsCoordinator({
|
|
12636
13026
|
tabId: s.tabId,
|
|
12637
13027
|
fingerprint: computeTabsFingerprint({
|
|
12638
|
-
coreVersion: "0.0.1-canary.
|
|
13028
|
+
coreVersion: "0.0.1-canary.261",
|
|
12639
13029
|
schemaHash: hash53(config.schemaSurql),
|
|
12640
13030
|
endpoint: config.database.endpoint ?? "",
|
|
12641
13031
|
namespace: config.database.namespace,
|
|
@@ -12774,6 +13164,9 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
12774
13164
|
"local.beginSwitch": () => s.local.beginSwitch(),
|
|
12775
13165
|
"local.currentBucketId": () => s.local.currentBucketId,
|
|
12776
13166
|
"local.usesSurqlSchema": () => s.local.usesSurqlSchema,
|
|
13167
|
+
"local.dropBucket": async (bucketId) => {
|
|
13168
|
+
await s.local.dropBucket?.(bucketId);
|
|
13169
|
+
},
|
|
12777
13170
|
"migrator.provision": () => s.migrator.provision(config.schemaSurql),
|
|
12778
13171
|
"blobs.start": async (bucketId) => {
|
|
12779
13172
|
s.blobs.setMaxBytes(await resolveBlobBudget(config.blobCache?.maxBytes));
|
|
@@ -12809,6 +13202,7 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
12809
13202
|
"auth.access": () => s.auth.access,
|
|
12810
13203
|
"auth.token": () => s.auth.token,
|
|
12811
13204
|
"auth.currentUser": () => s.auth.currentUser ?? null,
|
|
13205
|
+
"auth.consumeEndedImpersonation": () => s.auth.consumeEndedImpersonation(),
|
|
12812
13206
|
"remote.connect": () => s.remote.connect(),
|
|
12813
13207
|
"remote.releaseViews": (ids) => {
|
|
12814
13208
|
const list = ids.map((id) => String(id)).filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id)).join(", ");
|
|
@@ -12900,6 +13294,102 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
12900
13294
|
};
|
|
12901
13295
|
}
|
|
12902
13296
|
|
|
13297
|
+
//#endregion
|
|
13298
|
+
//#region src/modules/auth/impersonation-banner.ts
|
|
13299
|
+
/** Height the banner occupies. Also published as the CSS variable
|
|
13300
|
+
* `--sp00ky-impersonation-banner-height` on `<html>` (`0px` when hidden), so
|
|
13301
|
+
* an app can offset fixed headers. */
|
|
13302
|
+
const BANNER_HEIGHT_PX = 36;
|
|
13303
|
+
const HOST_TAG = "sp00ky-impersonation-banner";
|
|
13304
|
+
const STYLE = `
|
|
13305
|
+
:host {
|
|
13306
|
+
all: initial !important;
|
|
13307
|
+
display: block !important;
|
|
13308
|
+
visibility: visible !important;
|
|
13309
|
+
opacity: 1 !important;
|
|
13310
|
+
position: static !important;
|
|
13311
|
+
transform: none !important;
|
|
13312
|
+
clip-path: none !important;
|
|
13313
|
+
}
|
|
13314
|
+
.bar {
|
|
13315
|
+
position: fixed; inset: 0 0 auto 0; height: ${BANNER_HEIGHT_PX}px; z-index: 2147483647;
|
|
13316
|
+
display: flex; align-items: center; justify-content: center; gap: 12px; padding: 0 12px;
|
|
13317
|
+
box-sizing: border-box; background: #b91c1c; color: #fff;
|
|
13318
|
+
font: 600 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
13319
|
+
box-shadow: 0 1px 4px rgba(0,0,0,.35);
|
|
13320
|
+
}
|
|
13321
|
+
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
13322
|
+
.text b { font-weight: 800; }
|
|
13323
|
+
button {
|
|
13324
|
+
flex: none; cursor: pointer; border: 1px solid rgba(255,255,255,.8); border-radius: 6px;
|
|
13325
|
+
background: #fff; color: #b91c1c; font: 700 12px/1 system-ui, sans-serif; padding: 6px 12px;
|
|
13326
|
+
}
|
|
13327
|
+
button:disabled { opacity: .6; cursor: progress; }
|
|
13328
|
+
`;
|
|
13329
|
+
/**
|
|
13330
|
+
* The "you are impersonating" bar. Mounted by the client whenever the session
|
|
13331
|
+
* is an impersonation, in its own closed shadow root so app styles cannot hide
|
|
13332
|
+
* or restyle it. Every piece of text goes through `textContent`.
|
|
13333
|
+
*/
|
|
13334
|
+
var ImpersonationBanner = class ImpersonationBanner {
|
|
13335
|
+
host = null;
|
|
13336
|
+
text = null;
|
|
13337
|
+
constructor(onStop) {
|
|
13338
|
+
this.onStop = onStop;
|
|
13339
|
+
}
|
|
13340
|
+
static supported() {
|
|
13341
|
+
return typeof document !== "undefined" && typeof document.createElement === "function";
|
|
13342
|
+
}
|
|
13343
|
+
update(info) {
|
|
13344
|
+
if (!ImpersonationBanner.supported()) return;
|
|
13345
|
+
if (!info) {
|
|
13346
|
+
this.unmount();
|
|
13347
|
+
return;
|
|
13348
|
+
}
|
|
13349
|
+
this.mount();
|
|
13350
|
+
const text = this.text;
|
|
13351
|
+
text.replaceChildren(document.createTextNode("Impersonating "), bold(info.target), document.createTextNode(` as ${info.admin}`));
|
|
13352
|
+
text.title = `Impersonating ${info.target} as ${info.admin}. Every write is audited.`;
|
|
13353
|
+
}
|
|
13354
|
+
unmount() {
|
|
13355
|
+
this.host?.remove();
|
|
13356
|
+
this.host = this.text = null;
|
|
13357
|
+
if (typeof document !== "undefined") document.documentElement?.style.setProperty("--sp00ky-impersonation-banner-height", "0px");
|
|
13358
|
+
}
|
|
13359
|
+
mount() {
|
|
13360
|
+
if (this.host?.isConnected) return;
|
|
13361
|
+
const host = document.createElement(HOST_TAG);
|
|
13362
|
+
const root = host.attachShadow({ mode: "closed" });
|
|
13363
|
+
const style = document.createElement("style");
|
|
13364
|
+
style.textContent = STYLE;
|
|
13365
|
+
const bar = document.createElement("div");
|
|
13366
|
+
bar.className = "bar";
|
|
13367
|
+
bar.setAttribute("role", "alert");
|
|
13368
|
+
const text = document.createElement("span");
|
|
13369
|
+
text.className = "text";
|
|
13370
|
+
const button = document.createElement("button");
|
|
13371
|
+
button.type = "button";
|
|
13372
|
+
button.textContent = "Stop impersonating";
|
|
13373
|
+
button.addEventListener("click", () => {
|
|
13374
|
+
button.disabled = true;
|
|
13375
|
+
this.onStop().finally(() => {
|
|
13376
|
+
button.disabled = false;
|
|
13377
|
+
});
|
|
13378
|
+
});
|
|
13379
|
+
bar.append(text, button);
|
|
13380
|
+
root.append(style, bar);
|
|
13381
|
+
(document.body ?? document.documentElement).appendChild(host);
|
|
13382
|
+
document.documentElement.style.setProperty("--sp00ky-impersonation-banner-height", `${BANNER_HEIGHT_PX}px`);
|
|
13383
|
+
this.host = host;
|
|
13384
|
+
this.text = text;
|
|
13385
|
+
}
|
|
13386
|
+
};
|
|
13387
|
+
function bold(value) {
|
|
13388
|
+
const b = document.createElement("b");
|
|
13389
|
+
b.textContent = value;
|
|
13390
|
+
return b;
|
|
13391
|
+
}
|
|
13392
|
+
|
|
12903
13393
|
//#endregion
|
|
12904
13394
|
//#region src/client/sp00ky-client.ts
|
|
12905
13395
|
const UNKNOWN_STORAGE_HEALTH = Object.freeze({
|
|
@@ -12919,6 +13409,8 @@ var Sp00kyClient = class {
|
|
|
12919
13409
|
runtime;
|
|
12920
13410
|
featureFlags;
|
|
12921
13411
|
appReleases;
|
|
13412
|
+
/** Teardown for subscriptions the client itself owns. */
|
|
13413
|
+
disposers = [];
|
|
12922
13414
|
devTools;
|
|
12923
13415
|
hub = null;
|
|
12924
13416
|
forwarder = null;
|
|
@@ -13029,6 +13521,8 @@ var Sp00kyClient = class {
|
|
|
13029
13521
|
type: "AuthFlip",
|
|
13030
13522
|
userId
|
|
13031
13523
|
}));
|
|
13524
|
+
const banner = new ImpersonationBanner(() => s.auth.stopImpersonating());
|
|
13525
|
+
this.disposers.push(s.auth.subscribeImpersonation((info) => banner.update(info)), () => banner.unmount());
|
|
13032
13526
|
this.runtime.on("tabs:broadcast", (e) => {
|
|
13033
13527
|
if (e.type !== "tabs:broadcast") return;
|
|
13034
13528
|
const msg = e.message;
|
|
@@ -13078,6 +13572,7 @@ var Sp00kyClient = class {
|
|
|
13078
13572
|
async close() {
|
|
13079
13573
|
const s = this.services;
|
|
13080
13574
|
s.connectionSupervisor.dispose();
|
|
13575
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
13081
13576
|
this.runtime.dispose();
|
|
13082
13577
|
await this.featureFlags.closeAll();
|
|
13083
13578
|
await this.appReleases.closeAll();
|
|
@@ -13313,4 +13808,4 @@ var Sp00kyClient = class {
|
|
|
13313
13808
|
};
|
|
13314
13809
|
|
|
13315
13810
|
//#endregion
|
|
13316
|
-
export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, FeatureFlagHandle, FeatureFlagModule, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, isBlurhashValid, isImagePath, semverGt, textToHtml };
|
|
13811
|
+
export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, FeatureFlagHandle, FeatureFlagModule, IMPERSONATION_ACCESS, ImpersonationTimers, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, ORIGIN_TOKEN_KEY, SESSION_POLL_MS, Sp00kyClient, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, renewDelayMs, sameImpersonation, semverGt, textToHtml };
|