@spooky-sync/core 0.0.1-canary.263 → 0.0.1-canary.265
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 +11 -87
- package/dist/index.js +239 -121
- package/dist/types.d.ts +147 -1
- package/package.json +3 -3
- package/src/client/sp00ky-client.ts +25 -4
- package/src/modules/auth/impersonation-banner.ts +310 -140
- package/src/modules/auth/impersonation.test.ts +73 -0
- package/src/types.ts +12 -0
package/AGENTS.md
CHANGED
|
@@ -33,7 +33,7 @@ re-materialized from state, never patched in place.
|
|
|
33
33
|
|
|
34
34
|
## Key exports (`src/index.ts`)
|
|
35
35
|
|
|
36
|
-
- `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, id)`, `query(table, opts?)`, `preload(query, { signal? })`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `auth.signOut()`. Admin impersonation (`impersonation.enabled` in sp00ky.yml, admins only, server-enforced): `auth.impersonate(targetId, reason)`, `auth.stopImpersonating()`, `auth.impersonation`, `auth.subscribeImpersonation(cb)`, `auth.searchImpersonationTargets(search)`; while active the client injects a warning bar with a Stop button (
|
|
36
|
+
- `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, id)`, `query(table, opts?)`, `preload(query, { signal? })`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `auth.signOut()`. Admin impersonation (`impersonation.enabled` in sp00ky.yml, admins only, server-enforced): `auth.impersonate(targetId, reason)`, `auth.stopImpersonating()`, `auth.impersonation`, `auth.subscribeImpersonation(cb)`, `auth.searchImpersonationTargets(search)`; while active the client injects a warning bar with a Stop button and shifts the page down onto a hazard-striped layer. `impersonationBanner` in the client config restyles it (`theme`), replaces it (`mode: 'custom'` + `client.acknowledgeImpersonationBanner()`, else it falls back after ~2.5s) or turns it off (`mode: 'none'`). See `/docs/client/impersonation`. Traffic indicators: `pendingMutationCount` / `subscribeToPendingMutations(cb)` (outbox depth), `fetchingQueryCount` / `subscribeToFetchActivity(cb)`. Failed-writes tray: `failedMutationCount`, `subscribeToFailedMutations(cb)`, `listFailedMutations()`, `retryFailedMutation(id)`, `discardFailedMutation(id)`.
|
|
37
37
|
- `FailedMutation` — a row of the `_00_failed_mutations` tray (`mutationType`, `recordId`, `data`, `beforeRecord`, `error`, `revert`).
|
|
38
38
|
- `BucketHandle` — file storage handle (`put`, `get`, `delete`, `exists`), plus the cache-aware `read`, `url`, `pin`/`unpin`, `evict`, `prefetch`. `get` is always remote; `read`/`url` go through the blob cache.
|
|
39
39
|
- `services/blobs/` — durable cache for bucket bytes: OPFS holds the files, `_00_blob` holds a manifest that reconcile rebuilds from disk (so a wiped local store costs metadata, not the offline cache). Nothing expires on a timer; eviction is LRU under a byte budget only, skipping pinned and on-screen entries. Config: `blobCache` in `types.ts`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as RunOptions, B as SyncHealthStatus, C as QueryTimeToLive, D as RecordVersionArray, E as ReconnectConfig, F as StorageHealth, G as DatabaseEventTypes, H as UpdateOptions, I as StorageHealthStatus, J as EventSystem, K as SealedQuery, L as StoreType, M as Sp00kyConfig, N as Sp00kyQueryResult, O as RecordVersionDiff, P as Sp00kyQueryResultPromise, R as SyncHealth, S as QueryStatusCallback, T as QueryUpdateCallback, U as LocalStore, V as TimingPhase, W as DatabaseEventSystem, _ as QueryConfig, a as Level, b as QueryState, c as MutationCallback, d as PersistenceClient, f as PhaseStat, g as QueryAuthorityCallback, h as PreloadRefresh, i as InlineRow, j as ServerViewMeta, k as RegistrationTimings, l as MutationEvent, m as PreloadOptions, n as DebounceOptions, o as MATERIALIZATION_SAMPLE_WINDOW, p as PinoTransmit, q as EventDefinition, r as EventSubscriptionOptions, s as MembershipOutcome, t as ConnectionState, u as MutationEventType, v as QueryConfigRecord, w as QueryTimings, x as QueryStatus, y as QueryHash, z as SyncHealthConfig } from "./types.js";
|
|
1
|
+
import { $ as ORIGIN_TOKEN_KEY, A as RunOptions, B as SyncHealthStatus, C as QueryTimeToLive, D as RecordVersionArray, E as ReconnectConfig, F as StorageHealth, G as DatabaseEventTypes, H as UpdateOptions, I as StorageHealthStatus, J as EventSystem, K as SealedQuery, L as StoreType, M as Sp00kyConfig, N as Sp00kyQueryResult, O as RecordVersionDiff, P as Sp00kyQueryResultPromise, Q as ImpersonationTimers, R as SyncHealth, S as QueryStatusCallback, T as QueryUpdateCallback, U as LocalStore, V as TimingPhase, W as DatabaseEventSystem, X as ImpersonationInfo, Y as IMPERSONATION_ACCESS, Z as ImpersonationTimerHost, _ as QueryConfig, a as Level, at as sameImpersonation, b as QueryState, c as MutationCallback, d as PersistenceClient, et as SESSION_POLL_MS, f as PhaseStat, g as QueryAuthorityCallback, h as PreloadRefresh, i as InlineRow, it as renewDelayMs, j as ServerViewMeta, k as RegistrationTimings, l as MutationEvent, m as PreloadOptions, n as DebounceOptions, nt as decodeTokenClaims, o as MATERIALIZATION_SAMPLE_WINDOW, p as PinoTransmit, q as EventDefinition, r as EventSubscriptionOptions, rt as impersonationFromToken, s as MembershipOutcome, t as ConnectionState, tt as TokenClaims, u as MutationEventType, v as QueryConfigRecord, w as QueryTimings, x as QueryStatus, y as QueryHash, z as SyncHealthConfig } from "./types.js";
|
|
2
2
|
import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, FinalQuery, GetTable, QueryBuilder, QueryOptions, QueryPlan, RecordId, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
|
|
3
3
|
import * as surrealdb0 from "surrealdb";
|
|
4
4
|
import { Duration, RecordId as RecordId$1, Surreal, SurrealEvents, SurrealTransaction } from "surrealdb";
|
|
@@ -674,92 +674,6 @@ declare class StreamProcessorService {
|
|
|
674
674
|
private normalizeValue;
|
|
675
675
|
}
|
|
676
676
|
//#endregion
|
|
677
|
-
//#region src/modules/auth/impersonation.d.ts
|
|
678
|
-
/**
|
|
679
|
-
* Admin impersonation, client side.
|
|
680
|
-
*
|
|
681
|
-
* The server does the enforcing (see `impersonation_remote.surql` in the CLI):
|
|
682
|
-
* an admin calls `fn::_00_impersonate::start`, receives a token for the
|
|
683
|
-
* `_00_impersonate` access method, and authenticates with it, so `$auth.id` is
|
|
684
|
-
* the target user. Everything here is state and timers around that:
|
|
685
|
-
*
|
|
686
|
-
* - the admin's own token is kept under {@link ORIGIN_TOKEN_KEY} so Stop (and a
|
|
687
|
-
* reload in between) can return to it;
|
|
688
|
-
* - the impersonation state is derived from the token's claims, never stored
|
|
689
|
-
* separately, so it cannot disagree with the session actually in use;
|
|
690
|
-
* - the token is re-signed before it expires, and the session is polled so a
|
|
691
|
-
* stop or revocation made elsewhere reaches an open WebSocket quickly.
|
|
692
|
-
*/
|
|
693
|
-
/** Access method impersonation tokens are issued for. */
|
|
694
|
-
declare const IMPERSONATION_ACCESS = "_00_impersonate";
|
|
695
|
-
/** Persistence key holding the admin's own token while impersonating. */
|
|
696
|
-
declare const ORIGIN_TOKEN_KEY = "sp00ky_auth_token_origin";
|
|
697
|
-
/** How often an active impersonation re-checks its session on the server. */
|
|
698
|
-
declare const SESSION_POLL_MS = 60000;
|
|
699
|
-
interface ImpersonationInfo {
|
|
700
|
-
/** `_00_impersonation:<id>` the session is bound to. */
|
|
701
|
-
session: string;
|
|
702
|
-
/** Record id being impersonated. */
|
|
703
|
-
target: string;
|
|
704
|
-
/** Record id of the admin. */
|
|
705
|
-
admin: string;
|
|
706
|
-
/** When the current token expires (it is renewed before then). */
|
|
707
|
-
tokenExpiresAt: Date | null;
|
|
708
|
-
}
|
|
709
|
-
interface TokenClaims {
|
|
710
|
-
access: string | null;
|
|
711
|
-
userId: string | null;
|
|
712
|
-
exp: number | null;
|
|
713
|
-
impersonation: {
|
|
714
|
-
session: string;
|
|
715
|
-
admin: string;
|
|
716
|
-
} | null;
|
|
717
|
-
}
|
|
718
|
-
/**
|
|
719
|
-
* Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
|
|
720
|
-
* server still enforces the token on every request; this is only so the client
|
|
721
|
-
* can act on what it already holds before a round trip completes.
|
|
722
|
-
*
|
|
723
|
-
* Returns nulls on any malformed input.
|
|
724
|
-
*/
|
|
725
|
-
declare function decodeTokenClaims(token: string): TokenClaims;
|
|
726
|
-
/** The impersonation a token represents, or null for a regular session. */
|
|
727
|
-
declare function impersonationFromToken(token: string | null): ImpersonationInfo | null;
|
|
728
|
-
/** Same impersonation, for change detection (renewals only move the expiry). */
|
|
729
|
-
declare function sameImpersonation(a: ImpersonationInfo | null, b: ImpersonationInfo | null): boolean;
|
|
730
|
-
/**
|
|
731
|
-
* When to renew a token expiring at `expSecs`: at 80% of its remaining life,
|
|
732
|
-
* never sooner than 5s from now, and immediately once it is inside that.
|
|
733
|
-
*/
|
|
734
|
-
declare function renewDelayMs(expSecs: number, nowMs: number): number;
|
|
735
|
-
interface ImpersonationTimerHost {
|
|
736
|
-
/** Current impersonation, or null. */
|
|
737
|
-
current(): ImpersonationInfo | null;
|
|
738
|
-
/** Re-sign the token. Rejects when the session has ended. */
|
|
739
|
-
renew(): Promise<void>;
|
|
740
|
-
/** Whether the server still considers the session live. */
|
|
741
|
-
stillActive(): Promise<boolean>;
|
|
742
|
-
/** Leave the impersonation because the server ended it. */
|
|
743
|
-
endedRemotely(reason: string): Promise<void>;
|
|
744
|
-
/** Whether a failure was a reachability problem (retry) rather than an answer. */
|
|
745
|
-
isNetworkError(error: unknown): boolean;
|
|
746
|
-
}
|
|
747
|
-
/**
|
|
748
|
-
* The two timers an active impersonation needs. `sync()` is called whenever
|
|
749
|
-
* the auth state changes and arms or clears them to match.
|
|
750
|
-
*/
|
|
751
|
-
declare class ImpersonationTimers {
|
|
752
|
-
private host;
|
|
753
|
-
private renewTimer;
|
|
754
|
-
private pollTimer;
|
|
755
|
-
private armedFor;
|
|
756
|
-
constructor(host: ImpersonationTimerHost);
|
|
757
|
-
sync(): void;
|
|
758
|
-
clear(): void;
|
|
759
|
-
private renew;
|
|
760
|
-
private poll;
|
|
761
|
-
}
|
|
762
|
-
//#endregion
|
|
763
677
|
//#region src/modules/auth/events/index.d.ts
|
|
764
678
|
declare const AuthEventTypes: {
|
|
765
679
|
readonly AuthStateChanged: "AUTH_STATE_CHANGED";
|
|
@@ -2407,6 +2321,8 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
2407
2321
|
private readonly appReleases;
|
|
2408
2322
|
/** Teardown for subscriptions the client itself owns. */
|
|
2409
2323
|
private readonly disposers;
|
|
2324
|
+
/** Assigned in `wireAdapters`, which the constructor always runs. */
|
|
2325
|
+
private impersonationBanner;
|
|
2410
2326
|
private readonly devTools;
|
|
2411
2327
|
private hub;
|
|
2412
2328
|
private forwarder;
|
|
@@ -2476,6 +2392,14 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
2476
2392
|
deauthenticate(): Promise<void>;
|
|
2477
2393
|
useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T>;
|
|
2478
2394
|
remoteQuery<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T>;
|
|
2395
|
+
/**
|
|
2396
|
+
* Tell the client that the app's own impersonation banner is on screen.
|
|
2397
|
+
* Only meaningful with `impersonationBanner: { mode: 'custom' }`, where it
|
|
2398
|
+
* cancels the fallback that would otherwise show the built-in banner. Call
|
|
2399
|
+
* it while the custom banner is rendered (the Solid `useImpersonation`
|
|
2400
|
+
* hook does it for you with `rendersBanner: true`).
|
|
2401
|
+
*/
|
|
2402
|
+
acknowledgeImpersonationBanner(): void;
|
|
2479
2403
|
feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle;
|
|
2480
2404
|
setFeatureOverride(key: string, variant: string | null, payload?: unknown): void;
|
|
2481
2405
|
clearFeatureOverrides(): void;
|
package/dist/index.js
CHANGED
|
@@ -1076,8 +1076,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
1076
1076
|
|
|
1077
1077
|
//#endregion
|
|
1078
1078
|
//#region src/modules/devtools/index.ts
|
|
1079
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
1080
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
1079
|
+
const CORE_VERSION = "0.0.1-canary.265";
|
|
1080
|
+
const WASM_VERSION = "0.0.1-canary.265";
|
|
1081
1081
|
const SURREAL_VERSION = "3.0.3";
|
|
1082
1082
|
var DevToolsService = class DevToolsService {
|
|
1083
1083
|
eventsHistory = [];
|
|
@@ -13025,7 +13025,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
13025
13025
|
return new TabsCoordinator({
|
|
13026
13026
|
tabId: s.tabId,
|
|
13027
13027
|
fingerprint: computeTabsFingerprint({
|
|
13028
|
-
coreVersion: "0.0.1-canary.
|
|
13028
|
+
coreVersion: "0.0.1-canary.265",
|
|
13029
13029
|
schemaHash: hash53(config.schemaSurql),
|
|
13030
13030
|
endpoint: config.database.endpoint ?? "",
|
|
13031
13031
|
namespace: config.database.namespace,
|
|
@@ -13296,23 +13296,60 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
13296
13296
|
|
|
13297
13297
|
//#endregion
|
|
13298
13298
|
//#region src/modules/auth/impersonation-banner.ts
|
|
13299
|
-
/**
|
|
13300
|
-
*
|
|
13301
|
-
* when hidden), so
|
|
13302
|
-
|
|
13303
|
-
|
|
13299
|
+
/** Default height of the warning bar above the framed page. The effective
|
|
13300
|
+
* height is published as the CSS variable
|
|
13301
|
+
* `--sp00ky-impersonation-banner-height` on `<html>` (`0px` when hidden), so
|
|
13302
|
+
* an app can offset its own fixed header. */
|
|
13303
|
+
const BANNER_HEIGHT_PX = 40;
|
|
13304
|
+
/** Default gutter left, right and below the framed page. */
|
|
13305
|
+
const FRAME_INSET_PX = 10;
|
|
13306
|
+
/** Default radius of the framed page's corners. */
|
|
13304
13307
|
const PAGE_RADIUS_PX = 14;
|
|
13308
|
+
/** The default backdrop: a warm amber-to-orange wash with a faint diagonal
|
|
13309
|
+
* hazard rake over it. Layered background-image, topmost layer first. */
|
|
13310
|
+
const BANNER_BACKGROUND = "repeating-linear-gradient(45deg, rgba(69, 26, 3, .07) 0 9px, rgba(69, 26, 3, 0) 9px 18px), linear-gradient(135deg, #fbbf24 0%, #fb923c 52%, #f59e0b 100%)";
|
|
13305
13311
|
const HOST_TAG = "sp00ky-impersonation-banner";
|
|
13306
|
-
/**
|
|
13307
|
-
*
|
|
13308
|
-
|
|
13309
|
-
|
|
13310
|
-
|
|
13311
|
-
|
|
13312
|
-
|
|
13313
|
-
|
|
13314
|
-
|
|
13315
|
-
|
|
13312
|
+
/** How long a `custom` banner has to acknowledge itself before the built-in
|
|
13313
|
+
* one is shown anyway. Long enough for a first paint, short enough that
|
|
13314
|
+
* nobody browses unwarned. */
|
|
13315
|
+
const CUSTOM_BANNER_GRACE_MS = 2500;
|
|
13316
|
+
function resolveTheme(theme = {}) {
|
|
13317
|
+
return {
|
|
13318
|
+
heightPx: theme.heightPx ?? BANNER_HEIGHT_PX,
|
|
13319
|
+
insetPx: theme.insetPx ?? FRAME_INSET_PX,
|
|
13320
|
+
radiusPx: theme.radiusPx ?? PAGE_RADIUS_PX,
|
|
13321
|
+
background: theme.background ?? BANNER_BACKGROUND,
|
|
13322
|
+
text: theme.text ?? "#45230a",
|
|
13323
|
+
accent: theme.accent ?? "#45230a",
|
|
13324
|
+
accentText: theme.accentText ?? "#fff7ed",
|
|
13325
|
+
stopLabel: theme.stopLabel ?? "Stop",
|
|
13326
|
+
noPageShift: theme.noPageShift ?? false,
|
|
13327
|
+
label: theme.label ?? ((info) => `Impersonating **${info.target}** as ${info.admin}`)
|
|
13328
|
+
};
|
|
13329
|
+
}
|
|
13330
|
+
/**
|
|
13331
|
+
* How much the page has to shrink to fit inside the frame, and how wide its
|
|
13332
|
+
* layout box must be to fill the frame at that scale.
|
|
13333
|
+
*
|
|
13334
|
+
* Pure so the arithmetic is unit tested: the frame takes `heightPx` off the
|
|
13335
|
+
* top and `insetPx` off the other three sides, and the page is scaled
|
|
13336
|
+
* uniformly (one factor for both axes, so nothing is distorted) by the
|
|
13337
|
+
* vertical ratio. `width` is the CSS width the page's box needs, since a
|
|
13338
|
+
* zoomed box occupies `width * scale` of its parent.
|
|
13339
|
+
*/
|
|
13340
|
+
function frameMetrics(viewport, theme) {
|
|
13341
|
+
const { width: w, height: h } = viewport;
|
|
13342
|
+
const contentHeight = Math.max(1, h - theme.heightPx - theme.insetPx);
|
|
13343
|
+
const contentWidth = Math.max(1, w - theme.insetPx * 2);
|
|
13344
|
+
const scale = h > 0 ? contentHeight / h : 1;
|
|
13345
|
+
return {
|
|
13346
|
+
scale,
|
|
13347
|
+
width: contentWidth / scale,
|
|
13348
|
+
contentHeight
|
|
13349
|
+
};
|
|
13350
|
+
}
|
|
13351
|
+
function bannerCss(t) {
|
|
13352
|
+
return `
|
|
13316
13353
|
:host {
|
|
13317
13354
|
all: initial !important;
|
|
13318
13355
|
display: block !important;
|
|
@@ -13322,100 +13359,136 @@ const STYLE = `
|
|
|
13322
13359
|
transform: none !important;
|
|
13323
13360
|
clip-path: none !important;
|
|
13324
13361
|
}
|
|
13325
|
-
.
|
|
13326
|
-
|
|
13327
|
-
|
|
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
|
-
}
|
|
13362
|
+
/* The bar. The backdrop itself is painted on the root element, which is
|
|
13363
|
+
BEHIND the page's sheet; an overlay here would cover the page. The bar
|
|
13364
|
+
repeats that background with a fixed attachment, so its gradient lines up
|
|
13365
|
+
with the root's to the pixel. */
|
|
13352
13366
|
.bar {
|
|
13353
|
-
position: fixed; inset: 0 0 auto 0; height: ${
|
|
13367
|
+
position: fixed; inset: 0 0 auto 0; height: ${t.heightPx}px; z-index: 2147483647;
|
|
13354
13368
|
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
13355
|
-
padding: 0
|
|
13356
|
-
|
|
13369
|
+
padding: 0 ${Math.max(t.insetPx, 10)}px; box-sizing: border-box;
|
|
13370
|
+
background-image: ${t.background};
|
|
13371
|
+
background-attachment: fixed;
|
|
13372
|
+
color: ${t.text};
|
|
13373
|
+
font: 600 12.5px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
13374
|
+
letter-spacing: .01em;
|
|
13375
|
+
}
|
|
13376
|
+
.sign {
|
|
13377
|
+
flex: none; display: grid; place-items: center;
|
|
13378
|
+
width: 17px; height: 17px; border-radius: 50%;
|
|
13379
|
+
background: ${t.text}; color: #fff7ed;
|
|
13380
|
+
font-size: 11px; font-weight: 800;
|
|
13357
13381
|
}
|
|
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
13382
|
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
13366
|
-
.text b {
|
|
13383
|
+
.text b { font-weight: 800; }
|
|
13367
13384
|
button {
|
|
13368
13385
|
flex: none; cursor: pointer; border: 0; border-radius: 999px;
|
|
13369
|
-
background:
|
|
13370
|
-
font:
|
|
13371
|
-
box-shadow: 0 1px
|
|
13386
|
+
background: ${t.accent}; color: ${t.accentText};
|
|
13387
|
+
font: 700 11.5px/1 system-ui, sans-serif; padding: 6px 11px;
|
|
13388
|
+
box-shadow: 0 1px 2px rgba(69, 26, 3, .35);
|
|
13372
13389
|
}
|
|
13373
|
-
button:hover {
|
|
13390
|
+
button:hover { filter: brightness(1.15); }
|
|
13374
13391
|
button:disabled { opacity: .6; cursor: progress; }
|
|
13375
13392
|
@media (max-width: 520px) {
|
|
13376
|
-
.bar { gap:
|
|
13377
|
-
.
|
|
13393
|
+
.bar { gap: 7px; padding: 0 8px; font-size: 11.5px; }
|
|
13394
|
+
.sign { display: none; }
|
|
13378
13395
|
}
|
|
13379
13396
|
`;
|
|
13397
|
+
}
|
|
13380
13398
|
/**
|
|
13381
13399
|
* The "you are impersonating" warning.
|
|
13382
13400
|
*
|
|
13383
|
-
*
|
|
13384
|
-
*
|
|
13385
|
-
*
|
|
13386
|
-
*
|
|
13387
|
-
*
|
|
13388
|
-
*
|
|
13389
|
-
*
|
|
13401
|
+
* The page becomes a sheet on a warm striped backdrop: a bar across the top
|
|
13402
|
+
* names the session, and the page itself is scaled down to sit inside the
|
|
13403
|
+
* remaining frame with rounded corners. Scaled, not pushed: growing the
|
|
13404
|
+
* document moved the bottom of an app-shell layout off-screen, while a
|
|
13405
|
+
* uniform scale keeps the aspect ratio and every pixel reachable, and
|
|
13406
|
+
* `100vh`/`100dvh` layouts still resolve to exactly the frame.
|
|
13407
|
+
*
|
|
13408
|
+
* The bar lives in a closed shadow root, so app styles cannot hide or
|
|
13409
|
+
* restyle it; the framing is written as `!important` inline styles on
|
|
13410
|
+
* `<html>` / `<body>` (nothing in a stylesheet outranks that) and the
|
|
13411
|
+
* previous inline styles are restored on unmount.
|
|
13390
13412
|
*
|
|
13391
13413
|
* Every piece of text goes through `textContent`.
|
|
13392
13414
|
*/
|
|
13393
13415
|
var ImpersonationBanner = class ImpersonationBanner {
|
|
13394
13416
|
host = null;
|
|
13395
13417
|
text = null;
|
|
13396
|
-
/** `style` attributes as the page had them before the
|
|
13418
|
+
/** `style` attributes as the page had them before the frame. */
|
|
13397
13419
|
saved = null;
|
|
13398
|
-
|
|
13420
|
+
theme;
|
|
13421
|
+
mode;
|
|
13422
|
+
/** Grace timer for `custom` mode. */
|
|
13423
|
+
fallbackTimer = null;
|
|
13424
|
+
/** The app said its own banner is on screen for this impersonation. */
|
|
13425
|
+
acknowledged = false;
|
|
13426
|
+
warnedAboutNone = false;
|
|
13427
|
+
onResize = null;
|
|
13428
|
+
constructor(onStop, config = {}) {
|
|
13399
13429
|
this.onStop = onStop;
|
|
13430
|
+
this.mode = config.mode ?? "default";
|
|
13431
|
+
this.theme = resolveTheme(config.theme);
|
|
13400
13432
|
}
|
|
13401
13433
|
static supported() {
|
|
13402
13434
|
return typeof document !== "undefined" && typeof document.createElement === "function";
|
|
13403
13435
|
}
|
|
13436
|
+
/**
|
|
13437
|
+
* The app's own banner is rendered. Cancels the `custom`-mode fallback, and
|
|
13438
|
+
* removes the built-in banner if the fallback already fired (a slow first
|
|
13439
|
+
* paint, say). No-op in the other modes.
|
|
13440
|
+
*/
|
|
13441
|
+
acknowledge() {
|
|
13442
|
+
this.acknowledged = true;
|
|
13443
|
+
this.clearFallback();
|
|
13444
|
+
if (this.mode === "custom") this.teardown();
|
|
13445
|
+
}
|
|
13404
13446
|
update(info) {
|
|
13405
13447
|
if (!ImpersonationBanner.supported()) return;
|
|
13406
13448
|
if (!info) {
|
|
13449
|
+
this.acknowledged = false;
|
|
13407
13450
|
this.unmount();
|
|
13408
13451
|
return;
|
|
13409
13452
|
}
|
|
13453
|
+
if (this.mode === "none") {
|
|
13454
|
+
if (!this.warnedAboutNone) {
|
|
13455
|
+
this.warnedAboutNone = true;
|
|
13456
|
+
console.warn("[sp00ky] impersonation is active and impersonationBanner.mode is \"none\": nothing marks this page as another user's session.");
|
|
13457
|
+
}
|
|
13458
|
+
return;
|
|
13459
|
+
}
|
|
13460
|
+
if (this.mode === "custom" && !this.acknowledged) {
|
|
13461
|
+
if (!this.fallbackTimer && !this.host) this.fallbackTimer = setTimeout(() => {
|
|
13462
|
+
this.fallbackTimer = null;
|
|
13463
|
+
if (!this.acknowledged) this.render(info);
|
|
13464
|
+
}, CUSTOM_BANNER_GRACE_MS);
|
|
13465
|
+
return;
|
|
13466
|
+
}
|
|
13467
|
+
if (this.mode === "custom") return;
|
|
13468
|
+
this.render(info);
|
|
13469
|
+
}
|
|
13470
|
+
unmount() {
|
|
13471
|
+
this.clearFallback();
|
|
13472
|
+
this.teardown();
|
|
13473
|
+
}
|
|
13474
|
+
/** Mount (if needed) and write the current identities into the bar. */
|
|
13475
|
+
render(info) {
|
|
13410
13476
|
this.mount();
|
|
13477
|
+
const label = this.theme.label(info);
|
|
13411
13478
|
const text = this.text;
|
|
13412
|
-
text.
|
|
13413
|
-
text.
|
|
13479
|
+
text.textContent = "";
|
|
13480
|
+
text.append(...labelNodes(label));
|
|
13481
|
+
text.title = `${label.replace(/\*\*/g, "")}. Every write is audited.`;
|
|
13414
13482
|
}
|
|
13415
|
-
|
|
13483
|
+
/** Remove the bar and put the page back exactly as it was. */
|
|
13484
|
+
teardown() {
|
|
13416
13485
|
this.host?.remove();
|
|
13417
13486
|
this.host = this.text = null;
|
|
13418
13487
|
if (typeof document === "undefined") return;
|
|
13488
|
+
if (this.onResize) {
|
|
13489
|
+
window.removeEventListener("resize", this.onResize);
|
|
13490
|
+
this.onResize = null;
|
|
13491
|
+
}
|
|
13419
13492
|
if (this.saved) {
|
|
13420
13493
|
restoreStyle(document.documentElement, this.saved.html);
|
|
13421
13494
|
restoreStyle(document.body, this.saved.body);
|
|
@@ -13423,89 +13496,106 @@ var ImpersonationBanner = class ImpersonationBanner {
|
|
|
13423
13496
|
}
|
|
13424
13497
|
document.documentElement?.style.setProperty("--sp00ky-impersonation-banner-height", "0px");
|
|
13425
13498
|
}
|
|
13499
|
+
clearFallback() {
|
|
13500
|
+
if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
|
|
13501
|
+
this.fallbackTimer = null;
|
|
13502
|
+
}
|
|
13426
13503
|
mount() {
|
|
13427
13504
|
if (this.host?.isConnected) return;
|
|
13428
13505
|
const host = document.createElement(HOST_TAG);
|
|
13429
13506
|
const root = host.attachShadow({ mode: "closed" });
|
|
13430
13507
|
const style = document.createElement("style");
|
|
13431
|
-
style.textContent =
|
|
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
|
-
}
|
|
13508
|
+
style.textContent = bannerCss(this.theme);
|
|
13443
13509
|
const bar = document.createElement("div");
|
|
13444
13510
|
bar.className = "bar";
|
|
13445
13511
|
bar.setAttribute("role", "alert");
|
|
13446
|
-
const pill = document.createElement("div");
|
|
13447
|
-
pill.className = "pill";
|
|
13448
13512
|
const sign = document.createElement("span");
|
|
13449
13513
|
sign.className = "sign";
|
|
13450
|
-
sign.textContent = "
|
|
13514
|
+
sign.textContent = "!";
|
|
13515
|
+
sign.setAttribute("aria-hidden", "true");
|
|
13451
13516
|
const text = document.createElement("span");
|
|
13452
13517
|
text.className = "text";
|
|
13453
13518
|
const button = document.createElement("button");
|
|
13454
13519
|
button.type = "button";
|
|
13455
|
-
button.textContent =
|
|
13520
|
+
button.textContent = this.theme.stopLabel;
|
|
13456
13521
|
button.addEventListener("click", () => {
|
|
13457
13522
|
button.disabled = true;
|
|
13458
13523
|
this.onStop().finally(() => {
|
|
13459
13524
|
button.disabled = false;
|
|
13460
13525
|
});
|
|
13461
13526
|
});
|
|
13462
|
-
|
|
13463
|
-
|
|
13464
|
-
root.append(style, layer, corners, bar);
|
|
13527
|
+
bar.append(sign, text, button);
|
|
13528
|
+
root.append(style, bar);
|
|
13465
13529
|
document.documentElement.appendChild(host);
|
|
13466
|
-
this.
|
|
13530
|
+
this.frame();
|
|
13467
13531
|
this.host = host;
|
|
13468
13532
|
this.text = text;
|
|
13469
13533
|
}
|
|
13470
13534
|
/**
|
|
13471
|
-
*
|
|
13535
|
+
* Turn `<body>` into the framed sheet.
|
|
13472
13536
|
*
|
|
13473
|
-
*
|
|
13474
|
-
*
|
|
13475
|
-
*
|
|
13476
|
-
* the
|
|
13477
|
-
*
|
|
13537
|
+
* `zoom`, not `transform`: it scales at layout level, so `100vh` children
|
|
13538
|
+
* resolve to the frame instead of overflowing it, scrollbars stay correct,
|
|
13539
|
+
* and `position: fixed` keeps meaning the viewport (a transform would make
|
|
13540
|
+
* the body a containing block and every fixed header would scroll away).
|
|
13541
|
+
* The body's CSS width is widened by `1 / scale` so the scaled box still
|
|
13542
|
+
* fills the frame.
|
|
13478
13543
|
*/
|
|
13479
|
-
|
|
13544
|
+
frame() {
|
|
13480
13545
|
const root = document.documentElement;
|
|
13481
13546
|
const body = document.body;
|
|
13482
13547
|
if (!root || !body) return;
|
|
13548
|
+
const { heightPx, insetPx, radiusPx, noPageShift } = this.theme;
|
|
13549
|
+
root.style.setProperty("--sp00ky-impersonation-banner-height", `${heightPx}px`);
|
|
13550
|
+
if (noPageShift) return;
|
|
13483
13551
|
this.saved = {
|
|
13484
13552
|
html: root.getAttribute("style"),
|
|
13485
13553
|
body: body.getAttribute("style")
|
|
13486
13554
|
};
|
|
13487
13555
|
const surface = resolveSurface(root, body);
|
|
13488
|
-
root.style.setProperty("
|
|
13489
|
-
root.style.setProperty("
|
|
13490
|
-
|
|
13491
|
-
|
|
13492
|
-
|
|
13493
|
-
|
|
13494
|
-
|
|
13495
|
-
|
|
13496
|
-
|
|
13556
|
+
root.style.setProperty("background", this.theme.background, "important");
|
|
13557
|
+
root.style.setProperty("overflow", "hidden", "important");
|
|
13558
|
+
const apply = () => {
|
|
13559
|
+
const { scale, width } = frameMetrics({
|
|
13560
|
+
width: window.innerWidth,
|
|
13561
|
+
height: window.innerHeight
|
|
13562
|
+
}, {
|
|
13563
|
+
heightPx,
|
|
13564
|
+
insetPx
|
|
13565
|
+
});
|
|
13566
|
+
body.style.setProperty("position", "fixed", "important");
|
|
13567
|
+
body.style.setProperty("top", `${heightPx}px`, "important");
|
|
13568
|
+
body.style.setProperty("left", `${insetPx}px`, "important");
|
|
13569
|
+
body.style.setProperty("margin", "0", "important");
|
|
13570
|
+
body.style.setProperty("zoom", String(scale), "important");
|
|
13571
|
+
body.style.setProperty("width", `${width}px`, "important");
|
|
13572
|
+
body.style.setProperty("height", "100vh", "important");
|
|
13573
|
+
body.style.setProperty("overflow", "auto", "important");
|
|
13574
|
+
body.style.setProperty("background-color", surface, "important");
|
|
13575
|
+
body.style.setProperty("box-shadow", "0 6px 24px rgba(69, 26, 3, .28)", "important");
|
|
13576
|
+
if (radiusPx > 0) {
|
|
13577
|
+
const radius = `${radiusPx / scale}px`;
|
|
13578
|
+
body.style.setProperty("border-radius", radius, "important");
|
|
13579
|
+
body.style.setProperty("clip-path", `inset(0 round ${radius})`, "important");
|
|
13580
|
+
}
|
|
13581
|
+
};
|
|
13582
|
+
apply();
|
|
13583
|
+
this.onResize = apply;
|
|
13584
|
+
window.addEventListener("resize", apply);
|
|
13497
13585
|
}
|
|
13498
13586
|
};
|
|
13499
|
-
/** Put an element's inline styles back exactly as they were.
|
|
13587
|
+
/** Put an element's inline styles back exactly as they were. An element that
|
|
13588
|
+
* had no `style` attribute (or an empty one) ends up without one again. */
|
|
13500
13589
|
function restoreStyle(el, saved) {
|
|
13501
13590
|
if (!el) return;
|
|
13502
|
-
if (saved
|
|
13503
|
-
else el.
|
|
13591
|
+
if (saved) el.setAttribute("style", saved);
|
|
13592
|
+
else el.removeAttribute("style");
|
|
13504
13593
|
}
|
|
13505
13594
|
/**
|
|
13506
|
-
* The colour to paint
|
|
13507
|
-
*
|
|
13508
|
-
* colour
|
|
13595
|
+
* The colour to paint the sheet with: whatever the page already paints (body
|
|
13596
|
+
* first, then html), and failing that the canvas colour for the viewer's
|
|
13597
|
+
* colour scheme. A transparent sheet would show the backdrop through the
|
|
13598
|
+
* whole page.
|
|
13509
13599
|
*/
|
|
13510
13600
|
function resolveSurface(root, body) {
|
|
13511
13601
|
for (const el of [body, root]) {
|
|
@@ -13519,10 +13609,25 @@ function isTransparent(color) {
|
|
|
13519
13609
|
const parts = color.match(/[\d.]+/g);
|
|
13520
13610
|
return !!parts && parts.length === 4 && Number(parts[3]) === 0;
|
|
13521
13611
|
}
|
|
13522
|
-
|
|
13523
|
-
|
|
13524
|
-
|
|
13525
|
-
|
|
13612
|
+
/**
|
|
13613
|
+
* The label as DOM nodes, emphasising the runs between `**` markers. The
|
|
13614
|
+
* label is a plain string (an app's own function can return anything), so it
|
|
13615
|
+
* never reaches the DOM as markup.
|
|
13616
|
+
*/
|
|
13617
|
+
function labelNodes(label) {
|
|
13618
|
+
return labelSegments(label).map(({ text, bold }) => {
|
|
13619
|
+
if (!bold) return document.createTextNode(text);
|
|
13620
|
+
const b = document.createElement("b");
|
|
13621
|
+
b.textContent = text;
|
|
13622
|
+
return b;
|
|
13623
|
+
});
|
|
13624
|
+
}
|
|
13625
|
+
/** The label split into plain and emphasised runs. Pure, so it is unit tested. */
|
|
13626
|
+
function labelSegments(label) {
|
|
13627
|
+
return label.split(/\*\*(.+?)\*\*/g).map((text, i) => ({
|
|
13628
|
+
text,
|
|
13629
|
+
bold: i % 2 === 1
|
|
13630
|
+
})).filter((part) => part.text !== "");
|
|
13526
13631
|
}
|
|
13527
13632
|
|
|
13528
13633
|
//#endregion
|
|
@@ -13546,6 +13651,8 @@ var Sp00kyClient = class {
|
|
|
13546
13651
|
appReleases;
|
|
13547
13652
|
/** Teardown for subscriptions the client itself owns. */
|
|
13548
13653
|
disposers = [];
|
|
13654
|
+
/** Assigned in `wireAdapters`, which the constructor always runs. */
|
|
13655
|
+
impersonationBanner;
|
|
13549
13656
|
devTools;
|
|
13550
13657
|
hub = null;
|
|
13551
13658
|
forwarder = null;
|
|
@@ -13656,7 +13763,8 @@ var Sp00kyClient = class {
|
|
|
13656
13763
|
type: "AuthFlip",
|
|
13657
13764
|
userId
|
|
13658
13765
|
}));
|
|
13659
|
-
|
|
13766
|
+
this.impersonationBanner = new ImpersonationBanner(() => s.auth.stopImpersonating(), this.config.impersonationBanner);
|
|
13767
|
+
const banner = this.impersonationBanner;
|
|
13660
13768
|
this.disposers.push(s.auth.subscribeImpersonation((info) => banner.update(info)), () => banner.unmount());
|
|
13661
13769
|
this.runtime.on("tabs:broadcast", (e) => {
|
|
13662
13770
|
if (e.type !== "tabs:broadcast") return;
|
|
@@ -13910,6 +14018,16 @@ var Sp00kyClient = class {
|
|
|
13910
14018
|
this.assertRawRemoteAllowed("remoteQuery");
|
|
13911
14019
|
return this.services.remote.query(sql, vars);
|
|
13912
14020
|
}
|
|
14021
|
+
/**
|
|
14022
|
+
* Tell the client that the app's own impersonation banner is on screen.
|
|
14023
|
+
* Only meaningful with `impersonationBanner: { mode: 'custom' }`, where it
|
|
14024
|
+
* cancels the fallback that would otherwise show the built-in banner. Call
|
|
14025
|
+
* it while the custom banner is rendered (the Solid `useImpersonation`
|
|
14026
|
+
* hook does it for you with `rendersBanner: true`).
|
|
14027
|
+
*/
|
|
14028
|
+
acknowledgeImpersonationBanner() {
|
|
14029
|
+
this.impersonationBanner.acknowledge();
|
|
14030
|
+
}
|
|
13913
14031
|
feature(key, options) {
|
|
13914
14032
|
return this.featureFlags.feature(key, options);
|
|
13915
14033
|
}
|