@spooky-sync/core 0.0.1-canary.263 → 0.0.1-canary.265

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -1,6 +1,141 @@
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 warning bar above the page. */
100
+ heightPx?: number;
101
+ /** Gutter left, right and below the page. `0` frames only from the top. */
102
+ insetPx?: number;
103
+ /** Radius of the framed page's corners. `0` squares them off. */
104
+ radiusPx?: number;
105
+ /** The backdrop behind the page. Any CSS `background` value. */
106
+ background?: string;
107
+ /** Colour of the warning text on the backdrop. */
108
+ text?: 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
+ * Wrap a run in `**` to emphasise it; the result is text, never markup. */
117
+ label?: (info: ImpersonationInfo) => string;
118
+ /** Keep the bar but leave the page's own layout completely alone. The CSS
119
+ * variable is still published, so the app can do its own offset. */
120
+ noPageShift?: boolean;
121
+ }
122
+ interface ImpersonationBannerConfig {
123
+ /**
124
+ * - `'default'` renders the built-in banner.
125
+ * - `'custom'` leaves the page alone so the app can render its own from
126
+ * `useImpersonation()` / `auth.subscribeImpersonation`. Acknowledge it
127
+ * with `client.acknowledgeImpersonationBanner()` while it is on screen;
128
+ * if nothing acknowledges within {@link CUSTOM_BANNER_GRACE_MS}, the
129
+ * built-in banner appears, on the assumption that the custom one failed
130
+ * to render rather than that nobody should be warned.
131
+ * - `'none'` renders nothing, ever. The app takes over responsibility for
132
+ * telling whoever is looking at the page that it is not their session.
133
+ */
134
+ mode?: 'default' | 'custom' | 'none';
135
+ /** Restyle the built-in banner (also used if `custom` falls back). */
136
+ theme?: ImpersonationBannerTheme;
137
+ }
138
+ //#endregion
4
139
  //#region src/events/index.d.ts
5
140
  /**
6
141
  * Utility type to define the payload structure of an event.
@@ -643,6 +778,17 @@ interface Sp00kyConfig<S extends SchemaStructure> {
643
778
  * flag is `true`. Defaults to `false`.
644
779
  */
645
780
  allowRawRemote?: boolean;
781
+ /**
782
+ * How the "you are impersonating" warning is shown while an admin acts as
783
+ * another user. The built-in one (hazard stripes at the top, the page
784
+ * shifted down onto them) needs no configuration; this is for restyling it,
785
+ * or replacing it with the app's own.
786
+ *
787
+ * See `ImpersonationBannerConfig`. Replacing it does not mean "no warning":
788
+ * if the app has not acknowledged its own banner shortly after impersonation
789
+ * starts, the built-in one appears anyway.
790
+ */
791
+ impersonationBanner?: ImpersonationBannerConfig;
646
792
  /**
647
793
  * Surface sustained sync failures as a "degraded" health status that the app
648
794
  * can observe via `subscribeToSyncHealth` (or the client-solid
@@ -1103,4 +1249,4 @@ interface DebounceOptions {
1103
1249
  delay?: number;
1104
1250
  }
1105
1251
  //#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 };
1252
+ 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.263",
3
+ "version": "0.0.1-canary.265",
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.263",
67
- "@spooky-sync/ssp-wasm": "0.0.1-canary.263",
66
+ "@spooky-sync/query-builder": "0.0.1-canary.265",
67
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.265",
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
  }