@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 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 (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)`.
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.263";
1080
- const WASM_VERSION = "0.0.1-canary.263";
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.263",
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
- /** 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. */
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
- /** 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 = `
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
- .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
- }
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: ${BANNER_HEIGHT_PX}px; z-index: 2147483647;
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 12px; box-sizing: border-box;
13356
- font: 600 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
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 { color: #fff; font-weight: 800; }
13383
+ .text b { font-weight: 800; }
13367
13384
  button {
13368
13385
  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);
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 { background: #fff; }
13390
+ button:hover { filter: brightness(1.15); }
13374
13391
  button:disabled { opacity: .6; cursor: progress; }
13375
13392
  @media (max-width: 520px) {
13376
- .bar { gap: 6px; padding: 0 8px; }
13377
- .pill { padding: 5px 8px; }
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
- * 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.
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 shift. */
13418
+ /** `style` attributes as the page had them before the frame. */
13397
13419
  saved = null;
13398
- constructor(onStop) {
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.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.`;
13479
+ text.textContent = "";
13480
+ text.append(...labelNodes(label));
13481
+ text.title = `${label.replace(/\*\*/g, "")}. Every write is audited.`;
13414
13482
  }
13415
- unmount() {
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 = 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
- }
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 = "Stop";
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
- pill.append(sign, text);
13463
- bar.append(pill, button);
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.shiftPage();
13530
+ this.frame();
13467
13531
  this.host = host;
13468
13532
  this.text = text;
13469
13533
  }
13470
13534
  /**
13471
- * Push the page below the warning and round its new top edge.
13535
+ * Turn `<body>` into the framed sheet.
13472
13536
  *
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.
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
- shiftPage() {
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("--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");
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 === null) el.removeAttribute("style");
13503
- else el.setAttribute("style", saved);
13591
+ if (saved) el.setAttribute("style", saved);
13592
+ else el.removeAttribute("style");
13504
13593
  }
13505
13594
  /**
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.
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
- function bold(value) {
13523
- const b = document.createElement("b");
13524
- b.textContent = value;
13525
- return b;
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
- const banner = new ImpersonationBanner(() => s.auth.stopImpersonating());
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
  }