@spooky-sync/core 0.0.1-canary.262 → 0.0.1-canary.264

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.262";
1080
- const WASM_VERSION = "0.0.1-canary.262";
1079
+ const CORE_VERSION = "0.0.1-canary.264";
1080
+ const WASM_VERSION = "0.0.1-canary.264";
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.262",
13028
+ coreVersion: "0.0.1-canary.264",
13029
13029
  schemaHash: hash53(config.schemaSurql),
13030
13030
  endpoint: config.database.endpoint ?? "",
13031
13031
  namespace: config.database.namespace,
@@ -13296,23 +13296,43 @@ 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. */
13299
+ /** Default height of the warning layer the page is shifted down by. The
13300
+ * effective 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. */
13302
13303
  const BANNER_HEIGHT_PX = 44;
13303
- /** Radius of the page's new top corners. */
13304
+ /** Default radius of the page's new top corners. */
13304
13305
  const PAGE_RADIUS_PX = 14;
13306
+ /** Default hazard stripes: the warning layer, and the `<html>` backdrop the
13307
+ * page's rounded corners reveal. */
13308
+ const BANNER_STRIPES = "repeating-linear-gradient(45deg, #f2b600 0 14px, #1c1403 14px 28px)";
13305
13309
  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)";
13310
+ /** How long a `custom` banner has to acknowledge itself before the built-in
13311
+ * one is shown anyway. Long enough for a first paint, short enough that
13312
+ * nobody browses unwarned. */
13313
+ const CUSTOM_BANNER_GRACE_MS = 2500;
13314
+ function resolveTheme(theme = {}) {
13315
+ return {
13316
+ heightPx: theme.heightPx ?? BANNER_HEIGHT_PX,
13317
+ radiusPx: theme.radiusPx ?? PAGE_RADIUS_PX,
13318
+ background: theme.background ?? BANNER_STRIPES,
13319
+ pill: theme.pill ?? "rgba(14, 11, 2, .88)",
13320
+ pillText: theme.pillText ?? "#fde68a",
13321
+ accent: theme.accent ?? "#fde68a",
13322
+ accentText: theme.accentText ?? "#1c1403",
13323
+ stopLabel: theme.stopLabel ?? "Stop",
13324
+ noPageShift: theme.noPageShift ?? false,
13325
+ label: theme.label ?? ((info) => `Impersonating **${info.target}** as ${info.admin}`)
13326
+ };
13327
+ }
13309
13328
  /** Keeps only the area OUTSIDE a corner's curve, so the nub is striped and
13310
13329
  * everything inside the curve stays the app's own. `center` is the curve's
13311
13330
  * 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)`;
13331
+ function cornerMask(center, radius) {
13332
+ return `radial-gradient(circle ${radius}px at ${center}, transparent 0 ${radius}px, #000 ${radius}px)`;
13314
13333
  }
13315
- const STYLE = `
13334
+ function bannerCss(t) {
13335
+ return `
13316
13336
  :host {
13317
13337
  all: initial !important;
13318
13338
  display: block !important;
@@ -13324,7 +13344,7 @@ const STYLE = `
13324
13344
  }
13325
13345
  .layer {
13326
13346
  position: fixed; inset: 0 0 auto 0; z-index: 2147483646; pointer-events: none;
13327
- height: ${BANNER_HEIGHT_PX}px; background: ${STRIPES};
13347
+ height: ${t.heightPx}px; background: ${t.background};
13328
13348
  }
13329
13349
  /* The page's rounded top corners, drawn over whatever the app puts there.
13330
13350
  The body element's own border-radius rounds its background, but a fixed app
@@ -13332,25 +13352,25 @@ const STYLE = `
13332
13352
  square masked down to the nub OUTSIDE the corner's curve, so the app's own
13333
13353
  colour still shows inside it. */
13334
13354
  .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;
13355
+ position: fixed; left: 0; right: 0; top: ${t.heightPx}px;
13356
+ height: ${t.radiusPx}px; z-index: 2147483646; pointer-events: none;
13337
13357
  }
13338
13358
  .corner {
13339
- position: absolute; top: 0; width: ${PAGE_RADIUS_PX}px; height: ${PAGE_RADIUS_PX}px;
13340
- background: ${STRIPES};
13359
+ position: absolute; top: 0; width: ${t.radiusPx}px; height: ${t.radiusPx}px;
13360
+ background: ${t.background};
13341
13361
  }
13342
13362
  .corner.left {
13343
13363
  left: 0;
13344
- -webkit-mask: ${cornerMask("100% 100%")};
13345
- mask: ${cornerMask("100% 100%")};
13364
+ -webkit-mask: ${cornerMask("100% 100%", t.radiusPx)};
13365
+ mask: ${cornerMask("100% 100%", t.radiusPx)};
13346
13366
  }
13347
13367
  .corner.right {
13348
13368
  right: 0;
13349
- -webkit-mask: ${cornerMask("0% 100%")};
13350
- mask: ${cornerMask("0% 100%")};
13369
+ -webkit-mask: ${cornerMask("0% 100%", t.radiusPx)};
13370
+ mask: ${cornerMask("0% 100%", t.radiusPx)};
13351
13371
  }
13352
13372
  .bar {
13353
- position: fixed; inset: 0 0 auto 0; height: ${BANNER_HEIGHT_PX}px; z-index: 2147483647;
13373
+ position: fixed; inset: 0 0 auto 0; height: ${t.heightPx}px; z-index: 2147483647;
13354
13374
  display: flex; align-items: center; justify-content: center; gap: 10px;
13355
13375
  padding: 0 12px; box-sizing: border-box;
13356
13376
  font: 600 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
@@ -13358,25 +13378,25 @@ const STYLE = `
13358
13378
  .pill {
13359
13379
  display: flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%;
13360
13380
  padding: 6px 10px; border-radius: 999px;
13361
- background: rgba(14, 11, 2, .88); color: #fde68a;
13381
+ background: ${t.pill}; color: ${t.pillText};
13362
13382
  box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
13363
13383
  }
13364
13384
  .sign { flex: none; font-size: 14px; line-height: 1; }
13365
13385
  .text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
13366
- .text b { color: #fff; font-weight: 800; }
13367
13386
  button {
13368
13387
  flex: none; cursor: pointer; border: 0; border-radius: 999px;
13369
- background: #fde68a; color: #1c1403;
13388
+ background: ${t.accent}; color: ${t.accentText};
13370
13389
  font: 800 12px/1 system-ui, sans-serif; padding: 7px 12px;
13371
13390
  box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
13372
13391
  }
13373
- button:hover { background: #fff; }
13392
+ button:hover { filter: brightness(1.12); }
13374
13393
  button:disabled { opacity: .6; cursor: progress; }
13375
13394
  @media (max-width: 520px) {
13376
13395
  .bar { gap: 6px; padding: 0 8px; }
13377
13396
  .pill { padding: 5px 8px; }
13378
13397
  }
13379
13398
  `;
13399
+ }
13380
13400
  /**
13381
13401
  * The "you are impersonating" warning.
13382
13402
  *
@@ -13395,8 +13415,27 @@ var ImpersonationBanner = class ImpersonationBanner {
13395
13415
  text = null;
13396
13416
  /** `style` attributes as the page had them before the shift. */
13397
13417
  saved = null;
13398
- constructor(onStop) {
13418
+ theme;
13419
+ mode;
13420
+ /** Grace timer for `custom` mode. */
13421
+ fallbackTimer = null;
13422
+ /** The app said its own banner is on screen for this impersonation. */
13423
+ acknowledged = false;
13424
+ warnedAboutNone = false;
13425
+ constructor(onStop, config = {}) {
13399
13426
  this.onStop = onStop;
13427
+ this.mode = config.mode ?? "default";
13428
+ this.theme = resolveTheme(config.theme);
13429
+ }
13430
+ /**
13431
+ * The app's own banner is rendered. Cancels the `custom`-mode fallback, and
13432
+ * removes the built-in banner if the fallback already fired (a slow first
13433
+ * paint, say). No-op in the other modes.
13434
+ */
13435
+ acknowledge() {
13436
+ this.acknowledged = true;
13437
+ this.clearFallback();
13438
+ if (this.mode === "custom") this.teardown();
13400
13439
  }
13401
13440
  static supported() {
13402
13441
  return typeof document !== "undefined" && typeof document.createElement === "function";
@@ -13404,15 +13443,41 @@ var ImpersonationBanner = class ImpersonationBanner {
13404
13443
  update(info) {
13405
13444
  if (!ImpersonationBanner.supported()) return;
13406
13445
  if (!info) {
13446
+ this.acknowledged = false;
13407
13447
  this.unmount();
13408
13448
  return;
13409
13449
  }
13450
+ if (this.mode === "none") {
13451
+ if (!this.warnedAboutNone) {
13452
+ this.warnedAboutNone = true;
13453
+ console.warn("[sp00ky] impersonation is active and impersonationBanner.mode is \"none\": nothing marks this page as another user's session.");
13454
+ }
13455
+ return;
13456
+ }
13457
+ if (this.mode === "custom" && !this.acknowledged) {
13458
+ if (!this.fallbackTimer && !this.host) this.fallbackTimer = setTimeout(() => {
13459
+ this.fallbackTimer = null;
13460
+ if (!this.acknowledged) this.render(info);
13461
+ }, CUSTOM_BANNER_GRACE_MS);
13462
+ return;
13463
+ }
13464
+ if (this.mode === "custom") return;
13465
+ this.render(info);
13466
+ }
13467
+ unmount() {
13468
+ this.clearFallback();
13469
+ this.teardown();
13470
+ }
13471
+ /** Mount (if needed) and write the current identities into the bar. */
13472
+ render(info) {
13410
13473
  this.mount();
13411
13474
  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.`;
13475
+ text.textContent = "";
13476
+ text.append(...labelNodes(this.theme.label(info)));
13477
+ text.title = `${this.theme.label(info)}. Every write is audited.`;
13414
13478
  }
13415
- unmount() {
13479
+ /** Remove the bar and undo the page shift. */
13480
+ teardown() {
13416
13481
  this.host?.remove();
13417
13482
  this.host = this.text = null;
13418
13483
  if (typeof document === "undefined") return;
@@ -13423,12 +13488,16 @@ var ImpersonationBanner = class ImpersonationBanner {
13423
13488
  }
13424
13489
  document.documentElement?.style.setProperty("--sp00ky-impersonation-banner-height", "0px");
13425
13490
  }
13491
+ clearFallback() {
13492
+ if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
13493
+ this.fallbackTimer = null;
13494
+ }
13426
13495
  mount() {
13427
13496
  if (this.host?.isConnected) return;
13428
13497
  const host = document.createElement(HOST_TAG);
13429
13498
  const root = host.attachShadow({ mode: "closed" });
13430
13499
  const style = document.createElement("style");
13431
- style.textContent = STYLE;
13500
+ style.textContent = bannerCss(this.theme);
13432
13501
  const layer = document.createElement("div");
13433
13502
  layer.className = "layer";
13434
13503
  layer.setAttribute("aria-hidden", "true");
@@ -13440,6 +13509,7 @@ var ImpersonationBanner = class ImpersonationBanner {
13440
13509
  corner.className = `corner ${side}`;
13441
13510
  corners.append(corner);
13442
13511
  }
13512
+ if (this.theme.noPageShift || this.theme.radiusPx <= 0) corners.remove();
13443
13513
  const bar = document.createElement("div");
13444
13514
  bar.className = "bar";
13445
13515
  bar.setAttribute("role", "alert");
@@ -13452,7 +13522,7 @@ var ImpersonationBanner = class ImpersonationBanner {
13452
13522
  text.className = "text";
13453
13523
  const button = document.createElement("button");
13454
13524
  button.type = "button";
13455
- button.textContent = "Stop";
13525
+ button.textContent = this.theme.stopLabel;
13456
13526
  button.addEventListener("click", () => {
13457
13527
  button.disabled = true;
13458
13528
  this.onStop().finally(() => {
@@ -13480,20 +13550,24 @@ var ImpersonationBanner = class ImpersonationBanner {
13480
13550
  const root = document.documentElement;
13481
13551
  const body = document.body;
13482
13552
  if (!root || !body) return;
13553
+ const { heightPx, radiusPx, background, noPageShift } = this.theme;
13554
+ root.style.setProperty("--sp00ky-impersonation-banner-height", `${heightPx}px`);
13555
+ if (noPageShift) return;
13483
13556
  this.saved = {
13484
13557
  html: root.getAttribute("style"),
13485
13558
  body: body.getAttribute("style")
13486
13559
  };
13487
13560
  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");
13561
+ root.style.setProperty("background-image", background, "important");
13562
+ root.style.setProperty("padding-top", `${heightPx}px`, "important");
13491
13563
  root.style.setProperty("box-sizing", "border-box", "important");
13492
13564
  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");
13565
+ if (radiusPx > 0) {
13566
+ body.style.setProperty("border-radius", `${radiusPx}px ${radiusPx}px 0 0`, "important");
13567
+ body.style.setProperty("clip-path", `inset(0 round ${radiusPx}px ${radiusPx}px 0 0)`, "important");
13568
+ }
13495
13569
  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");
13570
+ body.style.setProperty("min-height", `calc(100vh - ${heightPx}px)`, "important");
13497
13571
  }
13498
13572
  };
13499
13573
  /** Put an element's inline styles back exactly as they were. */
@@ -13519,10 +13593,26 @@ function isTransparent(color) {
13519
13593
  const parts = color.match(/[\d.]+/g);
13520
13594
  return !!parts && parts.length === 4 && Number(parts[3]) === 0;
13521
13595
  }
13522
- function bold(value) {
13523
- const b = document.createElement("b");
13524
- b.textContent = value;
13525
- return b;
13596
+ /**
13597
+ * The label, with every `<b>…</b>`-free segment as a text node and the record
13598
+ * ids emphasised. The label is a plain string (an app's own function can
13599
+ * return anything), so it never reaches the DOM as markup: `**bold**` marks
13600
+ * emphasis instead.
13601
+ */
13602
+ function labelNodes(label) {
13603
+ return labelSegments(label).map(({ text, bold }) => {
13604
+ if (!bold) return document.createTextNode(text);
13605
+ const b = document.createElement("b");
13606
+ b.textContent = text;
13607
+ return b;
13608
+ });
13609
+ }
13610
+ /** The label split into plain and emphasised runs. Pure, so it is unit tested. */
13611
+ function labelSegments(label) {
13612
+ return label.split(/\*\*(.+?)\*\*/g).map((text, i) => ({
13613
+ text,
13614
+ bold: i % 2 === 1
13615
+ })).filter((part) => part.text !== "");
13526
13616
  }
13527
13617
 
13528
13618
  //#endregion
@@ -13546,6 +13636,8 @@ var Sp00kyClient = class {
13546
13636
  appReleases;
13547
13637
  /** Teardown for subscriptions the client itself owns. */
13548
13638
  disposers = [];
13639
+ /** Assigned in `wireAdapters`, which the constructor always runs. */
13640
+ impersonationBanner;
13549
13641
  devTools;
13550
13642
  hub = null;
13551
13643
  forwarder = null;
@@ -13656,7 +13748,8 @@ var Sp00kyClient = class {
13656
13748
  type: "AuthFlip",
13657
13749
  userId
13658
13750
  }));
13659
- const banner = new ImpersonationBanner(() => s.auth.stopImpersonating());
13751
+ this.impersonationBanner = new ImpersonationBanner(() => s.auth.stopImpersonating(), this.config.impersonationBanner);
13752
+ const banner = this.impersonationBanner;
13660
13753
  this.disposers.push(s.auth.subscribeImpersonation((info) => banner.update(info)), () => banner.unmount());
13661
13754
  this.runtime.on("tabs:broadcast", (e) => {
13662
13755
  if (e.type !== "tabs:broadcast") return;
@@ -13910,6 +14003,16 @@ var Sp00kyClient = class {
13910
14003
  this.assertRawRemoteAllowed("remoteQuery");
13911
14004
  return this.services.remote.query(sql, vars);
13912
14005
  }
14006
+ /**
14007
+ * Tell the client that the app's own impersonation banner is on screen.
14008
+ * Only meaningful with `impersonationBanner: { mode: 'custom' }`, where it
14009
+ * cancels the fallback that would otherwise show the built-in banner. Call
14010
+ * it while the custom banner is rendered (the Solid `useImpersonation`
14011
+ * hook does it for you with `rendersBanner: true`).
14012
+ */
14013
+ acknowledgeImpersonationBanner() {
14014
+ this.impersonationBanner.acknowledge();
14015
+ }
13913
14016
  feature(key, options) {
13914
14017
  return this.featureFlags.feature(key, options);
13915
14018
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,140 @@
1
1
  import { QueryPlan, QueryPlan as QueryPlan$1, RecordId, SchemaStructure, WhereNode } from "@spooky-sync/query-builder";
2
2
  import { Level as Level$1, LoggerOptions } from "pino";
3
3
 
4
+ //#region src/modules/auth/impersonation.d.ts
5
+
6
+ /**
7
+ * Admin impersonation, client side.
8
+ *
9
+ * The server does the enforcing (see `impersonation_remote.surql` in the CLI):
10
+ * an admin calls `fn::_00_impersonate::start`, receives a token for the
11
+ * `_00_impersonate` access method, and authenticates with it, so `$auth.id` is
12
+ * the target user. Everything here is state and timers around that:
13
+ *
14
+ * - the admin's own token is kept under {@link ORIGIN_TOKEN_KEY} so Stop (and a
15
+ * reload in between) can return to it;
16
+ * - the impersonation state is derived from the token's claims, never stored
17
+ * separately, so it cannot disagree with the session actually in use;
18
+ * - the token is re-signed before it expires, and the session is polled so a
19
+ * stop or revocation made elsewhere reaches an open WebSocket quickly.
20
+ */
21
+ /** Access method impersonation tokens are issued for. */
22
+ declare const IMPERSONATION_ACCESS = "_00_impersonate";
23
+ /** Persistence key holding the admin's own token while impersonating. */
24
+ declare const ORIGIN_TOKEN_KEY = "sp00ky_auth_token_origin";
25
+ /** How often an active impersonation re-checks its session on the server. */
26
+ declare const SESSION_POLL_MS = 60000;
27
+ interface ImpersonationInfo {
28
+ /** `_00_impersonation:<id>` the session is bound to. */
29
+ session: string;
30
+ /** Record id being impersonated. */
31
+ target: string;
32
+ /** Record id of the admin. */
33
+ admin: string;
34
+ /** When the current token expires (it is renewed before then). */
35
+ tokenExpiresAt: Date | null;
36
+ }
37
+ interface TokenClaims {
38
+ access: string | null;
39
+ userId: string | null;
40
+ exp: number | null;
41
+ impersonation: {
42
+ session: string;
43
+ admin: string;
44
+ } | null;
45
+ }
46
+ /**
47
+ * Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
48
+ * server still enforces the token on every request; this is only so the client
49
+ * can act on what it already holds before a round trip completes.
50
+ *
51
+ * Returns nulls on any malformed input.
52
+ */
53
+ declare function decodeTokenClaims(token: string): TokenClaims;
54
+ /** The impersonation a token represents, or null for a regular session. */
55
+ declare function impersonationFromToken(token: string | null): ImpersonationInfo | null;
56
+ /** Same impersonation, for change detection (renewals only move the expiry). */
57
+ declare function sameImpersonation(a: ImpersonationInfo | null, b: ImpersonationInfo | null): boolean;
58
+ /**
59
+ * When to renew a token expiring at `expSecs`: at 80% of its remaining life,
60
+ * never sooner than 5s from now, and immediately once it is inside that.
61
+ */
62
+ declare function renewDelayMs(expSecs: number, nowMs: number): number;
63
+ interface ImpersonationTimerHost {
64
+ /** Current impersonation, or null. */
65
+ current(): ImpersonationInfo | null;
66
+ /** Re-sign the token. Rejects when the session has ended. */
67
+ renew(): Promise<void>;
68
+ /** Whether the server still considers the session live. */
69
+ stillActive(): Promise<boolean>;
70
+ /** Leave the impersonation because the server ended it. */
71
+ endedRemotely(reason: string): Promise<void>;
72
+ /** Whether a failure was a reachability problem (retry) rather than an answer. */
73
+ isNetworkError(error: unknown): boolean;
74
+ }
75
+ /**
76
+ * The two timers an active impersonation needs. `sync()` is called whenever
77
+ * the auth state changes and arms or clears them to match.
78
+ */
79
+ declare class ImpersonationTimers {
80
+ private host;
81
+ private renewTimer;
82
+ private pollTimer;
83
+ private armedFor;
84
+ constructor(host: ImpersonationTimerHost);
85
+ sync(): void;
86
+ clear(): void;
87
+ private renew;
88
+ private poll;
89
+ }
90
+ //#endregion
91
+ //#region src/modules/auth/impersonation-banner.d.ts
92
+ /**
93
+ * Restyling of the built-in banner. Every field is optional; anything unset
94
+ * keeps the default. Colours are plain CSS values, so a project can pass its
95
+ * own tokens (`var(--brand-warning)`) as long as they resolve at the document
96
+ * level.
97
+ */
98
+ interface ImpersonationBannerTheme {
99
+ /** Height of the bar, and therefore how far the page is pushed down. */
100
+ heightPx?: number;
101
+ /** Radius of the page's new top corners. `0` squares them off. */
102
+ radiusPx?: number;
103
+ /** Background of the warning layer. Any CSS `background` value. */
104
+ background?: string;
105
+ /** Background of the pill the text sits in. */
106
+ pill?: string;
107
+ /** Text colour inside the pill. */
108
+ pillText?: string;
109
+ /** Background of the Stop button. */
110
+ accent?: string;
111
+ /** Text colour of the Stop button. */
112
+ accentText?: string;
113
+ /** Label for the Stop button. Defaults to `Stop`. */
114
+ stopLabel?: string;
115
+ /** The warning sentence. Defaults to `Impersonating <target> as <admin>`. */
116
+ label?: (info: ImpersonationInfo) => string;
117
+ /** Drop the page shift and the rounded corners, leaving only the bar.
118
+ * The CSS variable is still published, so the app can do its own offset. */
119
+ noPageShift?: boolean;
120
+ }
121
+ interface ImpersonationBannerConfig {
122
+ /**
123
+ * - `'default'` renders the built-in banner.
124
+ * - `'custom'` leaves the page alone so the app can render its own from
125
+ * `useImpersonation()` / `auth.subscribeImpersonation`. Acknowledge it
126
+ * with `client.acknowledgeImpersonationBanner()` while it is on screen;
127
+ * if nothing acknowledges within {@link CUSTOM_BANNER_GRACE_MS}, the
128
+ * built-in banner appears, on the assumption that the custom one failed
129
+ * to render rather than that nobody should be warned.
130
+ * - `'none'` renders nothing, ever. The app takes over responsibility for
131
+ * telling whoever is looking at the page that it is not their session.
132
+ */
133
+ mode?: 'default' | 'custom' | 'none';
134
+ /** Restyle the built-in banner (also used if `custom` falls back). */
135
+ theme?: ImpersonationBannerTheme;
136
+ }
137
+ //#endregion
4
138
  //#region src/events/index.d.ts
5
139
  /**
6
140
  * Utility type to define the payload structure of an event.
@@ -643,6 +777,17 @@ interface Sp00kyConfig<S extends SchemaStructure> {
643
777
  * flag is `true`. Defaults to `false`.
644
778
  */
645
779
  allowRawRemote?: boolean;
780
+ /**
781
+ * How the "you are impersonating" warning is shown while an admin acts as
782
+ * another user. The built-in one (hazard stripes at the top, the page
783
+ * shifted down onto them) needs no configuration; this is for restyling it,
784
+ * or replacing it with the app's own.
785
+ *
786
+ * See `ImpersonationBannerConfig`. Replacing it does not mean "no warning":
787
+ * if the app has not acknowledged its own banner shortly after impersonation
788
+ * starts, the built-in one appears anyway.
789
+ */
790
+ impersonationBanner?: ImpersonationBannerConfig;
646
791
  /**
647
792
  * Surface sustained sync failures as a "degraded" health status that the app
648
793
  * can observe via `subscribeToSyncHealth` (or the client-solid
@@ -1103,4 +1248,4 @@ interface DebounceOptions {
1103
1248
  delay?: number;
1104
1249
  }
1105
1250
  //#endregion
1106
- export { RunOptions as A, SyncHealthStatus as B, QueryTimeToLive as C, RecordVersionArray as D, ReconnectConfig as E, StorageHealth as F, DatabaseEventTypes as G, UpdateOptions as H, StorageHealthStatus as I, EventSystem as J, SealedQuery as K, StoreType as L, Sp00kyConfig as M, Sp00kyQueryResult as N, RecordVersionDiff as O, Sp00kyQueryResultPromise as P, SyncHealth as R, QueryStatusCallback as S, QueryUpdateCallback as T, LocalStore as U, TimingPhase as V, DatabaseEventSystem as W, QueryConfig as _, Level$1 as a, QueryState as b, MutationCallback as c, PersistenceClient as d, PhaseStat as f, QueryAuthorityCallback as g, PreloadRefresh as h, InlineRow as i, ServerViewMeta as j, RegistrationTimings as k, MutationEvent as l, PreloadOptions as m, DebounceOptions as n, MATERIALIZATION_SAMPLE_WINDOW as o, PinoTransmit as p, EventDefinition as q, EventSubscriptionOptions as r, MembershipOutcome as s, ConnectionState as t, MutationEventType as u, QueryConfigRecord as v, QueryTimings as w, QueryStatus as x, QueryHash as y, SyncHealthConfig as z };
1251
+ export { ORIGIN_TOKEN_KEY as $, RunOptions as A, SyncHealthStatus as B, QueryTimeToLive as C, RecordVersionArray as D, ReconnectConfig as E, StorageHealth as F, DatabaseEventTypes as G, UpdateOptions as H, StorageHealthStatus as I, EventSystem as J, SealedQuery as K, StoreType as L, Sp00kyConfig as M, Sp00kyQueryResult as N, RecordVersionDiff as O, Sp00kyQueryResultPromise as P, ImpersonationTimers as Q, SyncHealth as R, QueryStatusCallback as S, QueryUpdateCallback as T, LocalStore as U, TimingPhase as V, DatabaseEventSystem as W, ImpersonationInfo as X, IMPERSONATION_ACCESS as Y, ImpersonationTimerHost as Z, QueryConfig as _, Level$1 as a, sameImpersonation as at, QueryState as b, MutationCallback as c, PersistenceClient as d, SESSION_POLL_MS as et, PhaseStat as f, QueryAuthorityCallback as g, PreloadRefresh as h, InlineRow as i, renewDelayMs as it, ServerViewMeta as j, RegistrationTimings as k, MutationEvent as l, PreloadOptions as m, DebounceOptions as n, decodeTokenClaims as nt, MATERIALIZATION_SAMPLE_WINDOW as o, PinoTransmit as p, EventDefinition as q, EventSubscriptionOptions as r, impersonationFromToken as rt, MembershipOutcome as s, ConnectionState as t, TokenClaims as tt, MutationEventType as u, QueryConfigRecord as v, QueryTimings as w, QueryStatus as x, QueryHash as y, SyncHealthConfig as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.262",
3
+ "version": "0.0.1-canary.264",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -63,8 +63,8 @@
63
63
  }
64
64
  },
65
65
  "dependencies": {
66
- "@spooky-sync/query-builder": "0.0.1-canary.262",
67
- "@spooky-sync/ssp-wasm": "0.0.1-canary.262",
66
+ "@spooky-sync/query-builder": "0.0.1-canary.264",
67
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.264",
68
68
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
69
69
  "@surrealdb/wasm": "^3.0.3",
70
70
  "blurhash": "^2.0.5",
@@ -81,6 +81,8 @@ export class Sp00kyClient<S extends SchemaStructure> {
81
81
  private readonly appReleases: AppReleaseModule<S>;
82
82
  /** Teardown for subscriptions the client itself owns. */
83
83
  private readonly disposers: Array<() => void> = [];
84
+ /** Assigned in `wireAdapters`, which the constructor always runs. */
85
+ private impersonationBanner!: ImpersonationBanner;
84
86
  private readonly devTools: DevToolsService;
85
87
  private hub: LeaderSyncHub | null = null;
86
88
  private forwarder: SyncForwarder | null = null;
@@ -177,10 +179,18 @@ export class Sp00kyClient<S extends SchemaStructure> {
177
179
  s.streamProcessor.addReceiver(receiver);
178
180
  s.connectionSupervisor.subscribe((state) => void this.runtime.dispatch({ type: 'ConnectionChanged', state }));
179
181
  s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }));
180
- // The impersonation banner is not optional: whoever is looking at the page
181
- // must be able to tell, and leave, whatever the app renders.
182
- const banner = new ImpersonationBanner(() => s.auth.stopImpersonating());
183
- this.disposers.push(s.auth.subscribeImpersonation((info) => banner.update(info)), () => banner.unmount());
182
+ // Whoever is looking at the page should be able to tell, and leave. The
183
+ // app can restyle this, replace it (`mode: 'custom'`, with a fallback if
184
+ // its own never renders) or take the job over entirely (`mode: 'none'`).
185
+ this.impersonationBanner = new ImpersonationBanner(
186
+ () => s.auth.stopImpersonating(),
187
+ this.config.impersonationBanner
188
+ );
189
+ const banner = this.impersonationBanner;
190
+ this.disposers.push(
191
+ s.auth.subscribeImpersonation((info) => banner.update(info)),
192
+ () => banner.unmount()
193
+ );
184
194
  this.runtime.on('tabs:broadcast', (e) => {
185
195
  if (e.type !== 'tabs:broadcast') return;
186
196
  const msg = e.message as { type: string; records?: unknown[]; mutationId?: string };
@@ -468,6 +478,17 @@ export class Sp00kyClient<S extends SchemaStructure> {
468
478
  return this.services.remote.query<T>(sql, vars);
469
479
  }
470
480
 
481
+ /**
482
+ * Tell the client that the app's own impersonation banner is on screen.
483
+ * Only meaningful with `impersonationBanner: { mode: 'custom' }`, where it
484
+ * cancels the fallback that would otherwise show the built-in banner. Call
485
+ * it while the custom banner is rendered (the Solid `useImpersonation`
486
+ * hook does it for you with `rendersBanner: true`).
487
+ */
488
+ acknowledgeImpersonationBanner(): void {
489
+ this.impersonationBanner.acknowledge();
490
+ }
491
+
471
492
  feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle {
472
493
  return this.featureFlags.feature(key, options);
473
494
  }
@@ -1,31 +1,106 @@
1
1
  import type { ImpersonationInfo } from './impersonation';
2
2
 
3
- /** Height of the warning layer the page is shifted down by. Also published as
4
- * the CSS variable `--sp00ky-impersonation-banner-height` on `<html>` (`0px`
5
- * when hidden), so an app can offset its own fixed header. */
3
+ /** Default height of the warning layer the page is shifted down by. The
4
+ * effective height is published as the CSS variable
5
+ * `--sp00ky-impersonation-banner-height` on `<html>` (`0px` when hidden), so
6
+ * an app can offset its own fixed header. */
6
7
  export const BANNER_HEIGHT_PX = 44;
7
8
 
8
- /** Radius of the page's new top corners. */
9
- const PAGE_RADIUS_PX = 14;
9
+ /** Default radius of the page's new top corners. */
10
+ export const PAGE_RADIUS_PX = 14;
11
+
12
+ /** Default hazard stripes: the warning layer, and the `<html>` backdrop the
13
+ * page's rounded corners reveal. */
14
+ export const BANNER_STRIPES =
15
+ 'repeating-linear-gradient(45deg, #f2b600 0 14px, #1c1403 14px 28px)';
10
16
 
11
17
  const HOST_TAG = 'sp00ky-impersonation-banner';
12
18
 
13
- /** Hazard stripes: the warning layer, and the `<html>` backdrop the page's
14
- * rounded corners reveal. */
15
- const STRIPES = 'repeating-linear-gradient(45deg, #f2b600 0 14px, #1c1403 14px 28px)';
19
+ /** How long a `custom` banner has to acknowledge itself before the built-in
20
+ * one is shown anyway. Long enough for a first paint, short enough that
21
+ * nobody browses unwarned. */
22
+ export const CUSTOM_BANNER_GRACE_MS = 2500;
23
+
24
+ /**
25
+ * Restyling of the built-in banner. Every field is optional; anything unset
26
+ * keeps the default. Colours are plain CSS values, so a project can pass its
27
+ * own tokens (`var(--brand-warning)`) as long as they resolve at the document
28
+ * level.
29
+ */
30
+ export interface ImpersonationBannerTheme {
31
+ /** Height of the bar, and therefore how far the page is pushed down. */
32
+ heightPx?: number;
33
+ /** Radius of the page's new top corners. `0` squares them off. */
34
+ radiusPx?: number;
35
+ /** Background of the warning layer. Any CSS `background` value. */
36
+ background?: string;
37
+ /** Background of the pill the text sits in. */
38
+ pill?: string;
39
+ /** Text colour inside the pill. */
40
+ pillText?: string;
41
+ /** Background of the Stop button. */
42
+ accent?: string;
43
+ /** Text colour of the Stop button. */
44
+ accentText?: string;
45
+ /** Label for the Stop button. Defaults to `Stop`. */
46
+ stopLabel?: string;
47
+ /** The warning sentence. Defaults to `Impersonating <target> as <admin>`. */
48
+ label?: (info: ImpersonationInfo) => string;
49
+ /** Drop the page shift and the rounded corners, leaving only the bar.
50
+ * The CSS variable is still published, so the app can do its own offset. */
51
+ noPageShift?: boolean;
52
+ }
53
+
54
+ export interface ImpersonationBannerConfig {
55
+ /**
56
+ * - `'default'` renders the built-in banner.
57
+ * - `'custom'` leaves the page alone so the app can render its own from
58
+ * `useImpersonation()` / `auth.subscribeImpersonation`. Acknowledge it
59
+ * with `client.acknowledgeImpersonationBanner()` while it is on screen;
60
+ * if nothing acknowledges within {@link CUSTOM_BANNER_GRACE_MS}, the
61
+ * built-in banner appears, on the assumption that the custom one failed
62
+ * to render rather than that nobody should be warned.
63
+ * - `'none'` renders nothing, ever. The app takes over responsibility for
64
+ * telling whoever is looking at the page that it is not their session.
65
+ */
66
+ mode?: 'default' | 'custom' | 'none';
67
+ /** Restyle the built-in banner (also used if `custom` falls back). */
68
+ theme?: ImpersonationBannerTheme;
69
+ }
70
+
71
+ interface ResolvedTheme extends Required<Omit<ImpersonationBannerTheme, 'label' | 'stopLabel'>> {
72
+ stopLabel: string;
73
+ label: (info: ImpersonationInfo) => string;
74
+ }
75
+
76
+ function resolveTheme(theme: ImpersonationBannerTheme = {}): ResolvedTheme {
77
+ return {
78
+ heightPx: theme.heightPx ?? BANNER_HEIGHT_PX,
79
+ radiusPx: theme.radiusPx ?? PAGE_RADIUS_PX,
80
+ background: theme.background ?? BANNER_STRIPES,
81
+ pill: theme.pill ?? 'rgba(14, 11, 2, .88)',
82
+ pillText: theme.pillText ?? '#fde68a',
83
+ accent: theme.accent ?? '#fde68a',
84
+ accentText: theme.accentText ?? '#1c1403',
85
+ stopLabel: theme.stopLabel ?? 'Stop',
86
+ noPageShift: theme.noPageShift ?? false,
87
+ label: theme.label ?? ((info) => `Impersonating **${info.target}** as ${info.admin}`),
88
+ };
89
+ }
16
90
 
17
91
  /** Keeps only the area OUTSIDE a corner's curve, so the nub is striped and
18
92
  * everything inside the curve stays the app's own. `center` is the curve's
19
93
  * centre inside the corner square. */
20
- function cornerMask(center: string): string {
21
- return `radial-gradient(circle ${PAGE_RADIUS_PX}px at ${center}, transparent 0 ${PAGE_RADIUS_PX}px, #000 ${PAGE_RADIUS_PX}px)`;
94
+ function cornerMask(center: string, radius: number): string {
95
+ return `radial-gradient(circle ${radius}px at ${center}, transparent 0 ${radius}px, #000 ${radius}px)`;
22
96
  }
23
97
 
24
98
  // The shadow root keeps app CSS away from the bar, but the host element itself
25
99
  // is in the page and matches the page's selectors (`div { display: none }`).
26
100
  // An `!important` rule on `:host` wins over the page's own `!important`
27
101
  // (inner tree context), which keeps the host rendered whatever the app says.
28
- const STYLE = `
102
+ export function bannerCss(t: ResolvedTheme): string {
103
+ return `
29
104
  :host {
30
105
  all: initial !important;
31
106
  display: block !important;
@@ -37,7 +112,7 @@ const STYLE = `
37
112
  }
38
113
  .layer {
39
114
  position: fixed; inset: 0 0 auto 0; z-index: 2147483646; pointer-events: none;
40
- height: ${BANNER_HEIGHT_PX}px; background: ${STRIPES};
115
+ height: ${t.heightPx}px; background: ${t.background};
41
116
  }
42
117
  /* The page's rounded top corners, drawn over whatever the app puts there.
43
118
  The body element's own border-radius rounds its background, but a fixed app
@@ -45,25 +120,25 @@ const STYLE = `
45
120
  square masked down to the nub OUTSIDE the corner's curve, so the app's own
46
121
  colour still shows inside it. */
47
122
  .corners {
48
- position: fixed; left: 0; right: 0; top: ${BANNER_HEIGHT_PX}px;
49
- height: ${PAGE_RADIUS_PX}px; z-index: 2147483646; pointer-events: none;
123
+ position: fixed; left: 0; right: 0; top: ${t.heightPx}px;
124
+ height: ${t.radiusPx}px; z-index: 2147483646; pointer-events: none;
50
125
  }
51
126
  .corner {
52
- position: absolute; top: 0; width: ${PAGE_RADIUS_PX}px; height: ${PAGE_RADIUS_PX}px;
53
- background: ${STRIPES};
127
+ position: absolute; top: 0; width: ${t.radiusPx}px; height: ${t.radiusPx}px;
128
+ background: ${t.background};
54
129
  }
55
130
  .corner.left {
56
131
  left: 0;
57
- -webkit-mask: ${cornerMask('100% 100%')};
58
- mask: ${cornerMask('100% 100%')};
132
+ -webkit-mask: ${cornerMask('100% 100%', t.radiusPx)};
133
+ mask: ${cornerMask('100% 100%', t.radiusPx)};
59
134
  }
60
135
  .corner.right {
61
136
  right: 0;
62
- -webkit-mask: ${cornerMask('0% 100%')};
63
- mask: ${cornerMask('0% 100%')};
137
+ -webkit-mask: ${cornerMask('0% 100%', t.radiusPx)};
138
+ mask: ${cornerMask('0% 100%', t.radiusPx)};
64
139
  }
65
140
  .bar {
66
- position: fixed; inset: 0 0 auto 0; height: ${BANNER_HEIGHT_PX}px; z-index: 2147483647;
141
+ position: fixed; inset: 0 0 auto 0; height: ${t.heightPx}px; z-index: 2147483647;
67
142
  display: flex; align-items: center; justify-content: center; gap: 10px;
68
143
  padding: 0 12px; box-sizing: border-box;
69
144
  font: 600 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
@@ -71,25 +146,25 @@ const STYLE = `
71
146
  .pill {
72
147
  display: flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%;
73
148
  padding: 6px 10px; border-radius: 999px;
74
- background: rgba(14, 11, 2, .88); color: #fde68a;
149
+ background: ${t.pill}; color: ${t.pillText};
75
150
  box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
76
151
  }
77
152
  .sign { flex: none; font-size: 14px; line-height: 1; }
78
153
  .text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
79
- .text b { color: #fff; font-weight: 800; }
80
154
  button {
81
155
  flex: none; cursor: pointer; border: 0; border-radius: 999px;
82
- background: #fde68a; color: #1c1403;
156
+ background: ${t.accent}; color: ${t.accentText};
83
157
  font: 800 12px/1 system-ui, sans-serif; padding: 7px 12px;
84
158
  box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
85
159
  }
86
- button:hover { background: #fff; }
160
+ button:hover { filter: brightness(1.12); }
87
161
  button:disabled { opacity: .6; cursor: progress; }
88
162
  @media (max-width: 520px) {
89
163
  .bar { gap: 6px; padding: 0 8px; }
90
164
  .pill { padding: 5px 8px; }
91
165
  }
92
166
  `;
167
+ }
93
168
 
94
169
  /**
95
170
  * The "you are impersonating" warning.
@@ -109,8 +184,32 @@ export class ImpersonationBanner {
109
184
  private text: HTMLElement | null = null;
110
185
  /** `style` attributes as the page had them before the shift. */
111
186
  private saved: { html: string | null; body: string | null } | null = null;
187
+ private theme: ResolvedTheme;
188
+ private mode: 'default' | 'custom' | 'none';
189
+ /** Grace timer for `custom` mode. */
190
+ private fallbackTimer: ReturnType<typeof setTimeout> | null = null;
191
+ /** The app said its own banner is on screen for this impersonation. */
192
+ private acknowledged = false;
193
+ private warnedAboutNone = false;
194
+
195
+ constructor(
196
+ private onStop: () => Promise<void>,
197
+ config: ImpersonationBannerConfig = {}
198
+ ) {
199
+ this.mode = config.mode ?? 'default';
200
+ this.theme = resolveTheme(config.theme);
201
+ }
112
202
 
113
- constructor(private onStop: () => Promise<void>) {}
203
+ /**
204
+ * The app's own banner is rendered. Cancels the `custom`-mode fallback, and
205
+ * removes the built-in banner if the fallback already fired (a slow first
206
+ * paint, say). No-op in the other modes.
207
+ */
208
+ acknowledge(): void {
209
+ this.acknowledged = true;
210
+ this.clearFallback();
211
+ if (this.mode === 'custom') this.teardown();
212
+ }
114
213
 
115
214
  static supported(): boolean {
116
215
  return typeof document !== 'undefined' && typeof document.createElement === 'function';
@@ -119,20 +218,53 @@ export class ImpersonationBanner {
119
218
  update(info: ImpersonationInfo | null): void {
120
219
  if (!ImpersonationBanner.supported()) return;
121
220
  if (!info) {
221
+ this.acknowledged = false;
122
222
  this.unmount();
123
223
  return;
124
224
  }
225
+ if (this.mode === 'none') {
226
+ // The app owns the warning entirely. Said once, loudly, because the
227
+ // page now looks like an ordinary session of someone else's account.
228
+ if (!this.warnedAboutNone) {
229
+ this.warnedAboutNone = true;
230
+ // oxlint-disable-next-line no-console
231
+ console.warn(
232
+ '[sp00ky] impersonation is active and impersonationBanner.mode is "none": ' +
233
+ 'nothing marks this page as another user\'s session.'
234
+ );
235
+ }
236
+ return;
237
+ }
238
+ if (this.mode === 'custom' && !this.acknowledged) {
239
+ // Give the app its grace period, then warn anyway.
240
+ if (!this.fallbackTimer && !this.host) {
241
+ this.fallbackTimer = setTimeout(() => {
242
+ this.fallbackTimer = null;
243
+ if (!this.acknowledged) this.render(info);
244
+ }, CUSTOM_BANNER_GRACE_MS);
245
+ }
246
+ return;
247
+ }
248
+ if (this.mode === 'custom') return;
249
+ this.render(info);
250
+ }
251
+
252
+ unmount(): void {
253
+ this.clearFallback();
254
+ this.teardown();
255
+ }
256
+
257
+ /** Mount (if needed) and write the current identities into the bar. */
258
+ private render(info: ImpersonationInfo): void {
125
259
  this.mount();
126
260
  const text = this.text!;
127
- text.replaceChildren(
128
- document.createTextNode('Impersonating '),
129
- bold(info.target),
130
- document.createTextNode(` as ${info.admin}`)
131
- );
132
- text.title = `Impersonating ${info.target} as ${info.admin}. Every write is audited.`;
261
+ text.textContent = '';
262
+ text.append(...labelNodes(this.theme.label(info)));
263
+ text.title = `${this.theme.label(info)}. Every write is audited.`;
133
264
  }
134
265
 
135
- unmount(): void {
266
+ /** Remove the bar and undo the page shift. */
267
+ private teardown(): void {
136
268
  this.host?.remove();
137
269
  this.host = this.text = null;
138
270
  if (typeof document === 'undefined') return;
@@ -144,12 +276,17 @@ export class ImpersonationBanner {
144
276
  document.documentElement?.style.setProperty('--sp00ky-impersonation-banner-height', '0px');
145
277
  }
146
278
 
279
+ private clearFallback(): void {
280
+ if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
281
+ this.fallbackTimer = null;
282
+ }
283
+
147
284
  private mount(): void {
148
285
  if (this.host?.isConnected) return;
149
286
  const host = document.createElement(HOST_TAG);
150
287
  const root = host.attachShadow({ mode: 'closed' });
151
288
  const style = document.createElement('style');
152
- style.textContent = STYLE;
289
+ style.textContent = bannerCss(this.theme);
153
290
 
154
291
  // The striped ground the bar's text sits on: fixed, so it stays put while
155
292
  // the page scrolls underneath. Never takes a click.
@@ -164,6 +301,7 @@ export class ImpersonationBanner {
164
301
  corner.className = `corner ${side}`;
165
302
  corners.append(corner);
166
303
  }
304
+ if (this.theme.noPageShift || this.theme.radiusPx <= 0) corners.remove();
167
305
 
168
306
  const bar = document.createElement('div');
169
307
  bar.className = 'bar';
@@ -177,7 +315,7 @@ export class ImpersonationBanner {
177
315
  text.className = 'text';
178
316
  const button = document.createElement('button');
179
317
  button.type = 'button';
180
- button.textContent = 'Stop';
318
+ button.textContent = this.theme.stopLabel;
181
319
  button.addEventListener('click', () => {
182
320
  button.disabled = true;
183
321
  this.onStop().finally(() => {
@@ -210,31 +348,33 @@ export class ImpersonationBanner {
210
348
  const root = document.documentElement;
211
349
  const body = document.body;
212
350
  if (!root || !body) return;
213
- this.saved = { html: root.getAttribute('style'), body: body.getAttribute('style') };
351
+ const { heightPx, radiusPx, background, noPageShift } = this.theme;
352
+ // Published even when the shift is off, so an app doing its own offset
353
+ // has one number to read either way.
354
+ root.style.setProperty('--sp00ky-impersonation-banner-height', `${heightPx}px`);
355
+ if (noPageShift) return;
214
356
 
357
+ this.saved = { html: root.getAttribute('style'), body: body.getAttribute('style') };
215
358
  const surface = resolveSurface(root, body);
216
- root.style.setProperty('--sp00ky-impersonation-banner-height', `${BANNER_HEIGHT_PX}px`);
217
- root.style.setProperty('background-image', STRIPES, 'important');
218
- root.style.setProperty('padding-top', `${BANNER_HEIGHT_PX}px`, 'important');
359
+ root.style.setProperty('background-image', background, 'important');
360
+ root.style.setProperty('padding-top', `${heightPx}px`, 'important');
219
361
  root.style.setProperty('box-sizing', 'border-box', 'important');
220
362
 
221
363
  body.style.setProperty('background-color', surface, 'important');
222
- body.style.setProperty(
223
- 'border-radius',
224
- `${PAGE_RADIUS_PX}px ${PAGE_RADIUS_PX}px 0 0`,
225
- 'important'
226
- );
227
- // Clips the app's own content — a fixed header included — to the rounded
228
- // top, which is what makes the page read as a sheet over the stripes.
229
- body.style.setProperty(
230
- 'clip-path',
231
- `inset(0 round ${PAGE_RADIUS_PX}px ${PAGE_RADIUS_PX}px 0 0)`,
232
- 'important'
233
- );
364
+ if (radiusPx > 0) {
365
+ body.style.setProperty('border-radius', `${radiusPx}px ${radiusPx}px 0 0`, 'important');
366
+ // Clips the app's own content — a fixed header included — to the rounded
367
+ // top, which is what makes the page read as a sheet over the stripes.
368
+ body.style.setProperty(
369
+ 'clip-path',
370
+ `inset(0 round ${radiusPx}px ${radiusPx}px 0 0)`,
371
+ 'important'
372
+ );
373
+ }
234
374
  body.style.setProperty('box-shadow', '0 -6px 18px rgba(0, 0, 0, .35)', 'important');
235
375
  // An app sized to `100vh` would otherwise overflow by the bar's height and
236
376
  // put a scrollbar on a page that had none.
237
- body.style.setProperty('min-height', `calc(100vh - ${BANNER_HEIGHT_PX}px)`, 'important');
377
+ body.style.setProperty('min-height', `calc(100vh - ${heightPx}px)`, 'important');
238
378
  }
239
379
  }
240
380
 
@@ -266,8 +406,28 @@ function isTransparent(color: string): boolean {
266
406
  return !!parts && parts.length === 4 && Number(parts[3]) === 0;
267
407
  }
268
408
 
269
- function bold(value: string): HTMLElement {
270
- const b = document.createElement('b');
271
- b.textContent = value;
272
- return b;
409
+ /**
410
+ * The label, with every `<b>…</b>`-free segment as a text node and the record
411
+ * ids emphasised. The label is a plain string (an app's own function can
412
+ * return anything), so it never reaches the DOM as markup: `**bold**` marks
413
+ * emphasis instead.
414
+ */
415
+ function labelNodes(label: string): Node[] {
416
+ return labelSegments(label).map(({ text, bold }) => {
417
+ if (!bold) return document.createTextNode(text);
418
+ const b = document.createElement('b');
419
+ b.textContent = text;
420
+ return b;
421
+ });
422
+ }
423
+
424
+ /** The label split into plain and emphasised runs. Pure, so it is unit tested. */
425
+ function labelSegments(label: string): Array<{ text: string; bold: boolean }> {
426
+ return label
427
+ .split(/\*\*(.+?)\*\*/g)
428
+ .map((text, i) => ({ text, bold: i % 2 === 1 }))
429
+ .filter((part) => part.text !== '');
273
430
  }
431
+
432
+ /** Exported for unit tests only. */
433
+ export const __test = { labelSegments, resolveTheme };
@@ -280,3 +280,39 @@ describe('timers', () => {
280
280
  timers.clear();
281
281
  });
282
282
  });
283
+
284
+ describe('banner configuration', () => {
285
+ it('builds CSS from the theme, defaults included', async () => {
286
+ const { bannerCss, BANNER_STRIPES } = await import('./impersonation-banner');
287
+ // `bannerCss` takes a resolved theme; the client resolves it, so this
288
+ // pins the rendered output for a fully specified one.
289
+ const css = bannerCss({
290
+ heightPx: 60,
291
+ radiusPx: 0,
292
+ background: 'var(--brand-warning)',
293
+ pill: '#000',
294
+ pillText: '#fff',
295
+ accent: '#f00',
296
+ accentText: '#fff',
297
+ stopLabel: 'Leave',
298
+ noPageShift: true,
299
+ label: () => 'x',
300
+ });
301
+ expect(css).toContain('height: 60px; background: var(--brand-warning)');
302
+ expect(css).toContain('background: #000; color: #fff');
303
+ expect(css).not.toContain(BANNER_STRIPES);
304
+ });
305
+
306
+ it('emphasises the label between ** markers instead of parsing markup', async () => {
307
+ const { __test } = await import('./impersonation-banner');
308
+ // A label is a plain string an app supplies, so markup in it must stay text.
309
+ expect(__test.labelSegments('Impersonating **user:bob** as user:alice')).toEqual([
310
+ { text: 'Impersonating ', bold: false },
311
+ { text: 'user:bob', bold: true },
312
+ { text: ' as user:alice', bold: false },
313
+ ]);
314
+ expect(__test.labelSegments('<img src=x onerror=alert(1)>')).toEqual([
315
+ { text: '<img src=x onerror=alert(1)>', bold: false },
316
+ ]);
317
+ });
318
+ });
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ImpersonationBannerConfig } from './modules/auth/impersonation-banner';
1
2
  import type { RecordId, SchemaStructure, QueryPlan } from '@spooky-sync/query-builder';
2
3
  import type { Level, LoggerOptions } from 'pino';
3
4
  import type { PushEventOptions } from './events/index';
@@ -331,6 +332,17 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
331
332
  * flag is `true`. Defaults to `false`.
332
333
  */
333
334
  allowRawRemote?: boolean;
335
+ /**
336
+ * How the "you are impersonating" warning is shown while an admin acts as
337
+ * another user. The built-in one (hazard stripes at the top, the page
338
+ * shifted down onto them) needs no configuration; this is for restyling it,
339
+ * or replacing it with the app's own.
340
+ *
341
+ * See `ImpersonationBannerConfig`. Replacing it does not mean "no warning":
342
+ * if the app has not acknowledged its own banner shortly after impersonation
343
+ * starts, the built-in one appears anyway.
344
+ */
345
+ impersonationBanner?: ImpersonationBannerConfig;
334
346
  /**
335
347
  * Surface sustained sync failures as a "degraded" health status that the app
336
348
  * can observe via `subscribeToSyncHealth` (or the client-solid