@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 +1 -1
- package/dist/index.d.ts +11 -87
- package/dist/index.js +147 -44
- package/dist/types.d.ts +146 -1
- package/package.json +3 -3
- package/src/client/sp00ky-client.ts +25 -4
- package/src/modules/auth/impersonation-banner.ts +216 -56
- package/src/modules/auth/impersonation.test.ts +36 -0
- package/src/types.ts +12 -0
package/AGENTS.md
CHANGED
|
@@ -33,7 +33,7 @@ re-materialized from state, never patched in place.
|
|
|
33
33
|
|
|
34
34
|
## Key exports (`src/index.ts`)
|
|
35
35
|
|
|
36
|
-
- `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, id)`, `query(table, opts?)`, `preload(query, { signal? })`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `auth.signOut()`. Admin impersonation (`impersonation.enabled` in sp00ky.yml, admins only, server-enforced): `auth.impersonate(targetId, reason)`, `auth.stopImpersonating()`, `auth.impersonation`, `auth.subscribeImpersonation(cb)`, `auth.searchImpersonationTargets(search)`; while active the client injects a warning bar with a Stop button (
|
|
36
|
+
- `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, id)`, `query(table, opts?)`, `preload(query, { signal? })`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `auth.signOut()`. Admin impersonation (`impersonation.enabled` in sp00ky.yml, admins only, server-enforced): `auth.impersonate(targetId, reason)`, `auth.stopImpersonating()`, `auth.impersonation`, `auth.subscribeImpersonation(cb)`, `auth.searchImpersonationTargets(search)`; while active the client injects a warning bar with a Stop button and shifts the page down onto a hazard-striped layer. `impersonationBanner` in the client config restyles it (`theme`), replaces it (`mode: 'custom'` + `client.acknowledgeImpersonationBanner()`, else it falls back after ~2.5s) or turns it off (`mode: 'none'`). See `/docs/client/impersonation`. Traffic indicators: `pendingMutationCount` / `subscribeToPendingMutations(cb)` (outbox depth), `fetchingQueryCount` / `subscribeToFetchActivity(cb)`. Failed-writes tray: `failedMutationCount`, `subscribeToFailedMutations(cb)`, `listFailedMutations()`, `retryFailedMutation(id)`, `discardFailedMutation(id)`.
|
|
37
37
|
- `FailedMutation` — a row of the `_00_failed_mutations` tray (`mutationType`, `recordId`, `data`, `beforeRecord`, `error`, `revert`).
|
|
38
38
|
- `BucketHandle` — file storage handle (`put`, `get`, `delete`, `exists`), plus the cache-aware `read`, `url`, `pin`/`unpin`, `evict`, `prefetch`. `get` is always remote; `read`/`url` go through the blob cache.
|
|
39
39
|
- `services/blobs/` — durable cache for bucket bytes: OPFS holds the files, `_00_blob` holds a manifest that reconcile rebuilds from disk (so a wiped local store costs metadata, not the offline cache). Nothing expires on a timer; eviction is LRU under a byte budget only, skipping pinned and on-screen entries. Config: `blobCache` in `types.ts`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as RunOptions, B as SyncHealthStatus, C as QueryTimeToLive, D as RecordVersionArray, E as ReconnectConfig, F as StorageHealth, G as DatabaseEventTypes, H as UpdateOptions, I as StorageHealthStatus, J as EventSystem, K as SealedQuery, L as StoreType, M as Sp00kyConfig, N as Sp00kyQueryResult, O as RecordVersionDiff, P as Sp00kyQueryResultPromise, R as SyncHealth, S as QueryStatusCallback, T as QueryUpdateCallback, U as LocalStore, V as TimingPhase, W as DatabaseEventSystem, _ as QueryConfig, a as Level, b as QueryState, c as MutationCallback, d as PersistenceClient, f as PhaseStat, g as QueryAuthorityCallback, h as PreloadRefresh, i as InlineRow, j as ServerViewMeta, k as RegistrationTimings, l as MutationEvent, m as PreloadOptions, n as DebounceOptions, o as MATERIALIZATION_SAMPLE_WINDOW, p as PinoTransmit, q as EventDefinition, r as EventSubscriptionOptions, s as MembershipOutcome, t as ConnectionState, u as MutationEventType, v as QueryConfigRecord, w as QueryTimings, x as QueryStatus, y as QueryHash, z as SyncHealthConfig } from "./types.js";
|
|
1
|
+
import { $ as ORIGIN_TOKEN_KEY, A as RunOptions, B as SyncHealthStatus, C as QueryTimeToLive, D as RecordVersionArray, E as ReconnectConfig, F as StorageHealth, G as DatabaseEventTypes, H as UpdateOptions, I as StorageHealthStatus, J as EventSystem, K as SealedQuery, L as StoreType, M as Sp00kyConfig, N as Sp00kyQueryResult, O as RecordVersionDiff, P as Sp00kyQueryResultPromise, Q as ImpersonationTimers, R as SyncHealth, S as QueryStatusCallback, T as QueryUpdateCallback, U as LocalStore, V as TimingPhase, W as DatabaseEventSystem, X as ImpersonationInfo, Y as IMPERSONATION_ACCESS, Z as ImpersonationTimerHost, _ as QueryConfig, a as Level, at as sameImpersonation, b as QueryState, c as MutationCallback, d as PersistenceClient, et as SESSION_POLL_MS, f as PhaseStat, g as QueryAuthorityCallback, h as PreloadRefresh, i as InlineRow, it as renewDelayMs, j as ServerViewMeta, k as RegistrationTimings, l as MutationEvent, m as PreloadOptions, n as DebounceOptions, nt as decodeTokenClaims, o as MATERIALIZATION_SAMPLE_WINDOW, p as PinoTransmit, q as EventDefinition, r as EventSubscriptionOptions, rt as impersonationFromToken, s as MembershipOutcome, t as ConnectionState, tt as TokenClaims, u as MutationEventType, v as QueryConfigRecord, w as QueryTimings, x as QueryStatus, y as QueryHash, z as SyncHealthConfig } from "./types.js";
|
|
2
2
|
import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, FinalQuery, GetTable, QueryBuilder, QueryOptions, QueryPlan, RecordId, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
|
|
3
3
|
import * as surrealdb0 from "surrealdb";
|
|
4
4
|
import { Duration, RecordId as RecordId$1, Surreal, SurrealEvents, SurrealTransaction } from "surrealdb";
|
|
@@ -674,92 +674,6 @@ declare class StreamProcessorService {
|
|
|
674
674
|
private normalizeValue;
|
|
675
675
|
}
|
|
676
676
|
//#endregion
|
|
677
|
-
//#region src/modules/auth/impersonation.d.ts
|
|
678
|
-
/**
|
|
679
|
-
* Admin impersonation, client side.
|
|
680
|
-
*
|
|
681
|
-
* The server does the enforcing (see `impersonation_remote.surql` in the CLI):
|
|
682
|
-
* an admin calls `fn::_00_impersonate::start`, receives a token for the
|
|
683
|
-
* `_00_impersonate` access method, and authenticates with it, so `$auth.id` is
|
|
684
|
-
* the target user. Everything here is state and timers around that:
|
|
685
|
-
*
|
|
686
|
-
* - the admin's own token is kept under {@link ORIGIN_TOKEN_KEY} so Stop (and a
|
|
687
|
-
* reload in between) can return to it;
|
|
688
|
-
* - the impersonation state is derived from the token's claims, never stored
|
|
689
|
-
* separately, so it cannot disagree with the session actually in use;
|
|
690
|
-
* - the token is re-signed before it expires, and the session is polled so a
|
|
691
|
-
* stop or revocation made elsewhere reaches an open WebSocket quickly.
|
|
692
|
-
*/
|
|
693
|
-
/** Access method impersonation tokens are issued for. */
|
|
694
|
-
declare const IMPERSONATION_ACCESS = "_00_impersonate";
|
|
695
|
-
/** Persistence key holding the admin's own token while impersonating. */
|
|
696
|
-
declare const ORIGIN_TOKEN_KEY = "sp00ky_auth_token_origin";
|
|
697
|
-
/** How often an active impersonation re-checks its session on the server. */
|
|
698
|
-
declare const SESSION_POLL_MS = 60000;
|
|
699
|
-
interface ImpersonationInfo {
|
|
700
|
-
/** `_00_impersonation:<id>` the session is bound to. */
|
|
701
|
-
session: string;
|
|
702
|
-
/** Record id being impersonated. */
|
|
703
|
-
target: string;
|
|
704
|
-
/** Record id of the admin. */
|
|
705
|
-
admin: string;
|
|
706
|
-
/** When the current token expires (it is renewed before then). */
|
|
707
|
-
tokenExpiresAt: Date | null;
|
|
708
|
-
}
|
|
709
|
-
interface TokenClaims {
|
|
710
|
-
access: string | null;
|
|
711
|
-
userId: string | null;
|
|
712
|
-
exp: number | null;
|
|
713
|
-
impersonation: {
|
|
714
|
-
session: string;
|
|
715
|
-
admin: string;
|
|
716
|
-
} | null;
|
|
717
|
-
}
|
|
718
|
-
/**
|
|
719
|
-
* Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
|
|
720
|
-
* server still enforces the token on every request; this is only so the client
|
|
721
|
-
* can act on what it already holds before a round trip completes.
|
|
722
|
-
*
|
|
723
|
-
* Returns nulls on any malformed input.
|
|
724
|
-
*/
|
|
725
|
-
declare function decodeTokenClaims(token: string): TokenClaims;
|
|
726
|
-
/** The impersonation a token represents, or null for a regular session. */
|
|
727
|
-
declare function impersonationFromToken(token: string | null): ImpersonationInfo | null;
|
|
728
|
-
/** Same impersonation, for change detection (renewals only move the expiry). */
|
|
729
|
-
declare function sameImpersonation(a: ImpersonationInfo | null, b: ImpersonationInfo | null): boolean;
|
|
730
|
-
/**
|
|
731
|
-
* When to renew a token expiring at `expSecs`: at 80% of its remaining life,
|
|
732
|
-
* never sooner than 5s from now, and immediately once it is inside that.
|
|
733
|
-
*/
|
|
734
|
-
declare function renewDelayMs(expSecs: number, nowMs: number): number;
|
|
735
|
-
interface ImpersonationTimerHost {
|
|
736
|
-
/** Current impersonation, or null. */
|
|
737
|
-
current(): ImpersonationInfo | null;
|
|
738
|
-
/** Re-sign the token. Rejects when the session has ended. */
|
|
739
|
-
renew(): Promise<void>;
|
|
740
|
-
/** Whether the server still considers the session live. */
|
|
741
|
-
stillActive(): Promise<boolean>;
|
|
742
|
-
/** Leave the impersonation because the server ended it. */
|
|
743
|
-
endedRemotely(reason: string): Promise<void>;
|
|
744
|
-
/** Whether a failure was a reachability problem (retry) rather than an answer. */
|
|
745
|
-
isNetworkError(error: unknown): boolean;
|
|
746
|
-
}
|
|
747
|
-
/**
|
|
748
|
-
* The two timers an active impersonation needs. `sync()` is called whenever
|
|
749
|
-
* the auth state changes and arms or clears them to match.
|
|
750
|
-
*/
|
|
751
|
-
declare class ImpersonationTimers {
|
|
752
|
-
private host;
|
|
753
|
-
private renewTimer;
|
|
754
|
-
private pollTimer;
|
|
755
|
-
private armedFor;
|
|
756
|
-
constructor(host: ImpersonationTimerHost);
|
|
757
|
-
sync(): void;
|
|
758
|
-
clear(): void;
|
|
759
|
-
private renew;
|
|
760
|
-
private poll;
|
|
761
|
-
}
|
|
762
|
-
//#endregion
|
|
763
677
|
//#region src/modules/auth/events/index.d.ts
|
|
764
678
|
declare const AuthEventTypes: {
|
|
765
679
|
readonly AuthStateChanged: "AUTH_STATE_CHANGED";
|
|
@@ -2407,6 +2321,8 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
2407
2321
|
private readonly appReleases;
|
|
2408
2322
|
/** Teardown for subscriptions the client itself owns. */
|
|
2409
2323
|
private readonly disposers;
|
|
2324
|
+
/** Assigned in `wireAdapters`, which the constructor always runs. */
|
|
2325
|
+
private impersonationBanner;
|
|
2410
2326
|
private readonly devTools;
|
|
2411
2327
|
private hub;
|
|
2412
2328
|
private forwarder;
|
|
@@ -2476,6 +2392,14 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
2476
2392
|
deauthenticate(): Promise<void>;
|
|
2477
2393
|
useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T>;
|
|
2478
2394
|
remoteQuery<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T>;
|
|
2395
|
+
/**
|
|
2396
|
+
* Tell the client that the app's own impersonation banner is on screen.
|
|
2397
|
+
* Only meaningful with `impersonationBanner: { mode: 'custom' }`, where it
|
|
2398
|
+
* cancels the fallback that would otherwise show the built-in banner. Call
|
|
2399
|
+
* it while the custom banner is rendered (the Solid `useImpersonation`
|
|
2400
|
+
* hook does it for you with `rendersBanner: true`).
|
|
2401
|
+
*/
|
|
2402
|
+
acknowledgeImpersonationBanner(): void;
|
|
2479
2403
|
feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle;
|
|
2480
2404
|
setFeatureOverride(key: string, variant: string | null, payload?: unknown): void;
|
|
2481
2405
|
clearFeatureOverrides(): void;
|
package/dist/index.js
CHANGED
|
@@ -1076,8 +1076,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
1076
1076
|
|
|
1077
1077
|
//#endregion
|
|
1078
1078
|
//#region src/modules/devtools/index.ts
|
|
1079
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
1080
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
1079
|
+
const CORE_VERSION = "0.0.1-canary.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.
|
|
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
|
-
/**
|
|
13300
|
-
*
|
|
13301
|
-
* when hidden), so
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
13307
|
-
*
|
|
13308
|
-
|
|
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 ${
|
|
13331
|
+
function cornerMask(center, radius) {
|
|
13332
|
+
return `radial-gradient(circle ${radius}px at ${center}, transparent 0 ${radius}px, #000 ${radius}px)`;
|
|
13314
13333
|
}
|
|
13315
|
-
|
|
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: ${
|
|
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: ${
|
|
13336
|
-
height: ${
|
|
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: ${
|
|
13340
|
-
background: ${
|
|
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: ${
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
13413
|
-
text.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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("
|
|
13489
|
-
root.style.setProperty("
|
|
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
|
-
|
|
13494
|
-
|
|
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 - ${
|
|
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
|
-
|
|
13523
|
-
|
|
13524
|
-
|
|
13525
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
67
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
this.
|
|
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
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
* when hidden), so
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
14
|
-
*
|
|
15
|
-
|
|
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 ${
|
|
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
|
-
|
|
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: ${
|
|
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: ${
|
|
49
|
-
height: ${
|
|
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: ${
|
|
53
|
-
background: ${
|
|
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: ${
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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('
|
|
217
|
-
root.style.setProperty('
|
|
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
|
-
|
|
223
|
-
'border-radius',
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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 - ${
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|