@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
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.262";
|
|
1080
|
+
const WASM_VERSION = "0.0.1-canary.262";
|
|
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.262",
|
|
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,237 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
12900
13294
|
};
|
|
12901
13295
|
}
|
|
12902
13296
|
|
|
13297
|
+
//#endregion
|
|
13298
|
+
//#region src/modules/auth/impersonation-banner.ts
|
|
13299
|
+
/** Height of the warning layer the page is shifted down by. Also published as
|
|
13300
|
+
* the CSS variable `--sp00ky-impersonation-banner-height` on `<html>` (`0px`
|
|
13301
|
+
* when hidden), so an app can offset its own fixed header. */
|
|
13302
|
+
const BANNER_HEIGHT_PX = 44;
|
|
13303
|
+
/** Radius of the page's new top corners. */
|
|
13304
|
+
const PAGE_RADIUS_PX = 14;
|
|
13305
|
+
const HOST_TAG = "sp00ky-impersonation-banner";
|
|
13306
|
+
/** Hazard stripes: the warning layer, and the `<html>` backdrop the page's
|
|
13307
|
+
* rounded corners reveal. */
|
|
13308
|
+
const STRIPES = "repeating-linear-gradient(45deg, #f2b600 0 14px, #1c1403 14px 28px)";
|
|
13309
|
+
/** Keeps only the area OUTSIDE a corner's curve, so the nub is striped and
|
|
13310
|
+
* everything inside the curve stays the app's own. `center` is the curve's
|
|
13311
|
+
* centre inside the corner square. */
|
|
13312
|
+
function cornerMask(center) {
|
|
13313
|
+
return `radial-gradient(circle ${PAGE_RADIUS_PX}px at ${center}, transparent 0 ${PAGE_RADIUS_PX}px, #000 ${PAGE_RADIUS_PX}px)`;
|
|
13314
|
+
}
|
|
13315
|
+
const STYLE = `
|
|
13316
|
+
:host {
|
|
13317
|
+
all: initial !important;
|
|
13318
|
+
display: block !important;
|
|
13319
|
+
visibility: visible !important;
|
|
13320
|
+
opacity: 1 !important;
|
|
13321
|
+
position: static !important;
|
|
13322
|
+
transform: none !important;
|
|
13323
|
+
clip-path: none !important;
|
|
13324
|
+
}
|
|
13325
|
+
.layer {
|
|
13326
|
+
position: fixed; inset: 0 0 auto 0; z-index: 2147483646; pointer-events: none;
|
|
13327
|
+
height: ${BANNER_HEIGHT_PX}px; background: ${STRIPES};
|
|
13328
|
+
}
|
|
13329
|
+
/* The page's rounded top corners, drawn over whatever the app puts there.
|
|
13330
|
+
The body element's own border-radius rounds its background, but a fixed app
|
|
13331
|
+
header is not clipped by it, so each corner is also painted here: a striped
|
|
13332
|
+
square masked down to the nub OUTSIDE the corner's curve, so the app's own
|
|
13333
|
+
colour still shows inside it. */
|
|
13334
|
+
.corners {
|
|
13335
|
+
position: fixed; left: 0; right: 0; top: ${BANNER_HEIGHT_PX}px;
|
|
13336
|
+
height: ${PAGE_RADIUS_PX}px; z-index: 2147483646; pointer-events: none;
|
|
13337
|
+
}
|
|
13338
|
+
.corner {
|
|
13339
|
+
position: absolute; top: 0; width: ${PAGE_RADIUS_PX}px; height: ${PAGE_RADIUS_PX}px;
|
|
13340
|
+
background: ${STRIPES};
|
|
13341
|
+
}
|
|
13342
|
+
.corner.left {
|
|
13343
|
+
left: 0;
|
|
13344
|
+
-webkit-mask: ${cornerMask("100% 100%")};
|
|
13345
|
+
mask: ${cornerMask("100% 100%")};
|
|
13346
|
+
}
|
|
13347
|
+
.corner.right {
|
|
13348
|
+
right: 0;
|
|
13349
|
+
-webkit-mask: ${cornerMask("0% 100%")};
|
|
13350
|
+
mask: ${cornerMask("0% 100%")};
|
|
13351
|
+
}
|
|
13352
|
+
.bar {
|
|
13353
|
+
position: fixed; inset: 0 0 auto 0; height: ${BANNER_HEIGHT_PX}px; z-index: 2147483647;
|
|
13354
|
+
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
13355
|
+
padding: 0 12px; box-sizing: border-box;
|
|
13356
|
+
font: 600 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
13357
|
+
}
|
|
13358
|
+
.pill {
|
|
13359
|
+
display: flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%;
|
|
13360
|
+
padding: 6px 10px; border-radius: 999px;
|
|
13361
|
+
background: rgba(14, 11, 2, .88); color: #fde68a;
|
|
13362
|
+
box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
|
|
13363
|
+
}
|
|
13364
|
+
.sign { flex: none; font-size: 14px; line-height: 1; }
|
|
13365
|
+
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
13366
|
+
.text b { color: #fff; font-weight: 800; }
|
|
13367
|
+
button {
|
|
13368
|
+
flex: none; cursor: pointer; border: 0; border-radius: 999px;
|
|
13369
|
+
background: #fde68a; color: #1c1403;
|
|
13370
|
+
font: 800 12px/1 system-ui, sans-serif; padding: 7px 12px;
|
|
13371
|
+
box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
|
|
13372
|
+
}
|
|
13373
|
+
button:hover { background: #fff; }
|
|
13374
|
+
button:disabled { opacity: .6; cursor: progress; }
|
|
13375
|
+
@media (max-width: 520px) {
|
|
13376
|
+
.bar { gap: 6px; padding: 0 8px; }
|
|
13377
|
+
.pill { padding: 5px 8px; }
|
|
13378
|
+
}
|
|
13379
|
+
`;
|
|
13380
|
+
/**
|
|
13381
|
+
* The "you are impersonating" warning.
|
|
13382
|
+
*
|
|
13383
|
+
* Drawn as a layer the page sits on top of: hazard stripes fill the top of the
|
|
13384
|
+
* viewport, the page is pushed down by the bar's height and its new top
|
|
13385
|
+
* corners are rounded, so the stripes show through around them. The bar itself
|
|
13386
|
+
* lives in a closed shadow root, so app styles cannot hide or restyle it; the
|
|
13387
|
+
* page-shifting styles are set inline and `!important` on `<html>` / `<body>`
|
|
13388
|
+
* (nothing in a stylesheet outranks that), and the previous inline styles are
|
|
13389
|
+
* restored on unmount.
|
|
13390
|
+
*
|
|
13391
|
+
* Every piece of text goes through `textContent`.
|
|
13392
|
+
*/
|
|
13393
|
+
var ImpersonationBanner = class ImpersonationBanner {
|
|
13394
|
+
host = null;
|
|
13395
|
+
text = null;
|
|
13396
|
+
/** `style` attributes as the page had them before the shift. */
|
|
13397
|
+
saved = null;
|
|
13398
|
+
constructor(onStop) {
|
|
13399
|
+
this.onStop = onStop;
|
|
13400
|
+
}
|
|
13401
|
+
static supported() {
|
|
13402
|
+
return typeof document !== "undefined" && typeof document.createElement === "function";
|
|
13403
|
+
}
|
|
13404
|
+
update(info) {
|
|
13405
|
+
if (!ImpersonationBanner.supported()) return;
|
|
13406
|
+
if (!info) {
|
|
13407
|
+
this.unmount();
|
|
13408
|
+
return;
|
|
13409
|
+
}
|
|
13410
|
+
this.mount();
|
|
13411
|
+
const text = this.text;
|
|
13412
|
+
text.replaceChildren(document.createTextNode("Impersonating "), bold(info.target), document.createTextNode(` as ${info.admin}`));
|
|
13413
|
+
text.title = `Impersonating ${info.target} as ${info.admin}. Every write is audited.`;
|
|
13414
|
+
}
|
|
13415
|
+
unmount() {
|
|
13416
|
+
this.host?.remove();
|
|
13417
|
+
this.host = this.text = null;
|
|
13418
|
+
if (typeof document === "undefined") return;
|
|
13419
|
+
if (this.saved) {
|
|
13420
|
+
restoreStyle(document.documentElement, this.saved.html);
|
|
13421
|
+
restoreStyle(document.body, this.saved.body);
|
|
13422
|
+
this.saved = null;
|
|
13423
|
+
}
|
|
13424
|
+
document.documentElement?.style.setProperty("--sp00ky-impersonation-banner-height", "0px");
|
|
13425
|
+
}
|
|
13426
|
+
mount() {
|
|
13427
|
+
if (this.host?.isConnected) return;
|
|
13428
|
+
const host = document.createElement(HOST_TAG);
|
|
13429
|
+
const root = host.attachShadow({ mode: "closed" });
|
|
13430
|
+
const style = document.createElement("style");
|
|
13431
|
+
style.textContent = STYLE;
|
|
13432
|
+
const layer = document.createElement("div");
|
|
13433
|
+
layer.className = "layer";
|
|
13434
|
+
layer.setAttribute("aria-hidden", "true");
|
|
13435
|
+
const corners = document.createElement("div");
|
|
13436
|
+
corners.className = "corners";
|
|
13437
|
+
corners.setAttribute("aria-hidden", "true");
|
|
13438
|
+
for (const side of ["left", "right"]) {
|
|
13439
|
+
const corner = document.createElement("div");
|
|
13440
|
+
corner.className = `corner ${side}`;
|
|
13441
|
+
corners.append(corner);
|
|
13442
|
+
}
|
|
13443
|
+
const bar = document.createElement("div");
|
|
13444
|
+
bar.className = "bar";
|
|
13445
|
+
bar.setAttribute("role", "alert");
|
|
13446
|
+
const pill = document.createElement("div");
|
|
13447
|
+
pill.className = "pill";
|
|
13448
|
+
const sign = document.createElement("span");
|
|
13449
|
+
sign.className = "sign";
|
|
13450
|
+
sign.textContent = "⚠";
|
|
13451
|
+
const text = document.createElement("span");
|
|
13452
|
+
text.className = "text";
|
|
13453
|
+
const button = document.createElement("button");
|
|
13454
|
+
button.type = "button";
|
|
13455
|
+
button.textContent = "Stop";
|
|
13456
|
+
button.addEventListener("click", () => {
|
|
13457
|
+
button.disabled = true;
|
|
13458
|
+
this.onStop().finally(() => {
|
|
13459
|
+
button.disabled = false;
|
|
13460
|
+
});
|
|
13461
|
+
});
|
|
13462
|
+
pill.append(sign, text);
|
|
13463
|
+
bar.append(pill, button);
|
|
13464
|
+
root.append(style, layer, corners, bar);
|
|
13465
|
+
document.documentElement.appendChild(host);
|
|
13466
|
+
this.shiftPage();
|
|
13467
|
+
this.host = host;
|
|
13468
|
+
this.text = text;
|
|
13469
|
+
}
|
|
13470
|
+
/**
|
|
13471
|
+
* Push the page below the warning and round its new top edge.
|
|
13472
|
+
*
|
|
13473
|
+
* `<html>` carries the stripes as its background, because that is what the
|
|
13474
|
+
* page's rounded corners reveal. `<body>` therefore has to be painted
|
|
13475
|
+
* opaque: most apps colour it themselves, but a transparent one would let
|
|
13476
|
+
* the stripes through behind the whole page, so its resolved colour is
|
|
13477
|
+
* computed first and pinned.
|
|
13478
|
+
*/
|
|
13479
|
+
shiftPage() {
|
|
13480
|
+
const root = document.documentElement;
|
|
13481
|
+
const body = document.body;
|
|
13482
|
+
if (!root || !body) return;
|
|
13483
|
+
this.saved = {
|
|
13484
|
+
html: root.getAttribute("style"),
|
|
13485
|
+
body: body.getAttribute("style")
|
|
13486
|
+
};
|
|
13487
|
+
const surface = resolveSurface(root, body);
|
|
13488
|
+
root.style.setProperty("--sp00ky-impersonation-banner-height", `${BANNER_HEIGHT_PX}px`);
|
|
13489
|
+
root.style.setProperty("background-image", STRIPES, "important");
|
|
13490
|
+
root.style.setProperty("padding-top", `${BANNER_HEIGHT_PX}px`, "important");
|
|
13491
|
+
root.style.setProperty("box-sizing", "border-box", "important");
|
|
13492
|
+
body.style.setProperty("background-color", surface, "important");
|
|
13493
|
+
body.style.setProperty("border-radius", `${PAGE_RADIUS_PX}px ${PAGE_RADIUS_PX}px 0 0`, "important");
|
|
13494
|
+
body.style.setProperty("clip-path", `inset(0 round ${PAGE_RADIUS_PX}px ${PAGE_RADIUS_PX}px 0 0)`, "important");
|
|
13495
|
+
body.style.setProperty("box-shadow", "0 -6px 18px rgba(0, 0, 0, .35)", "important");
|
|
13496
|
+
body.style.setProperty("min-height", `calc(100vh - ${BANNER_HEIGHT_PX}px)`, "important");
|
|
13497
|
+
}
|
|
13498
|
+
};
|
|
13499
|
+
/** Put an element's inline styles back exactly as they were. */
|
|
13500
|
+
function restoreStyle(el, saved) {
|
|
13501
|
+
if (!el) return;
|
|
13502
|
+
if (saved === null) el.removeAttribute("style");
|
|
13503
|
+
else el.setAttribute("style", saved);
|
|
13504
|
+
}
|
|
13505
|
+
/**
|
|
13506
|
+
* The colour to paint `<body>` with while the stripes sit behind it: whatever
|
|
13507
|
+
* the page already paints (body first, then html), and failing that the canvas
|
|
13508
|
+
* colour for the viewer's colour scheme.
|
|
13509
|
+
*/
|
|
13510
|
+
function resolveSurface(root, body) {
|
|
13511
|
+
for (const el of [body, root]) {
|
|
13512
|
+
const color = getComputedStyle(el).backgroundColor;
|
|
13513
|
+
if (color && !isTransparent(color)) return color;
|
|
13514
|
+
}
|
|
13515
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches ? "#111111" : "#ffffff";
|
|
13516
|
+
}
|
|
13517
|
+
function isTransparent(color) {
|
|
13518
|
+
if (color === "transparent") return true;
|
|
13519
|
+
const parts = color.match(/[\d.]+/g);
|
|
13520
|
+
return !!parts && parts.length === 4 && Number(parts[3]) === 0;
|
|
13521
|
+
}
|
|
13522
|
+
function bold(value) {
|
|
13523
|
+
const b = document.createElement("b");
|
|
13524
|
+
b.textContent = value;
|
|
13525
|
+
return b;
|
|
13526
|
+
}
|
|
13527
|
+
|
|
12903
13528
|
//#endregion
|
|
12904
13529
|
//#region src/client/sp00ky-client.ts
|
|
12905
13530
|
const UNKNOWN_STORAGE_HEALTH = Object.freeze({
|
|
@@ -12919,6 +13544,8 @@ var Sp00kyClient = class {
|
|
|
12919
13544
|
runtime;
|
|
12920
13545
|
featureFlags;
|
|
12921
13546
|
appReleases;
|
|
13547
|
+
/** Teardown for subscriptions the client itself owns. */
|
|
13548
|
+
disposers = [];
|
|
12922
13549
|
devTools;
|
|
12923
13550
|
hub = null;
|
|
12924
13551
|
forwarder = null;
|
|
@@ -13029,6 +13656,8 @@ var Sp00kyClient = class {
|
|
|
13029
13656
|
type: "AuthFlip",
|
|
13030
13657
|
userId
|
|
13031
13658
|
}));
|
|
13659
|
+
const banner = new ImpersonationBanner(() => s.auth.stopImpersonating());
|
|
13660
|
+
this.disposers.push(s.auth.subscribeImpersonation((info) => banner.update(info)), () => banner.unmount());
|
|
13032
13661
|
this.runtime.on("tabs:broadcast", (e) => {
|
|
13033
13662
|
if (e.type !== "tabs:broadcast") return;
|
|
13034
13663
|
const msg = e.message;
|
|
@@ -13078,6 +13707,7 @@ var Sp00kyClient = class {
|
|
|
13078
13707
|
async close() {
|
|
13079
13708
|
const s = this.services;
|
|
13080
13709
|
s.connectionSupervisor.dispose();
|
|
13710
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
13081
13711
|
this.runtime.dispose();
|
|
13082
13712
|
await this.featureFlags.closeAll();
|
|
13083
13713
|
await this.appReleases.closeAll();
|
|
@@ -13313,4 +13943,4 @@ var Sp00kyClient = class {
|
|
|
13313
13943
|
};
|
|
13314
13944
|
|
|
13315
13945
|
//#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 };
|
|
13946
|
+
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 };
|