@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/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 = { AuthStateChanged: "AUTH_STATE_CHANGED" };
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.260";
1077
- const WASM_VERSION = "0.0.1-canary.260";
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/index.ts
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: typeof ac === "string" ? ac : null,
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
- const token = accessToken || await this.persistenceClient.get("sp00ky_auth_token");
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.signOut();
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.signOut();
10226
+ await this.rejectToken(token);
9863
10227
  }
9864
10228
  } finally {
9865
10229
  this.isLoading = false;
9866
10230
  }
9867
10231
  }
9868
10232
  /**
9869
- * Sign out and clear session
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.260",
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 };