@spooky-sync/core 0.0.1-canary.263 → 0.0.1-canary.265
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/dist/index.d.ts +11 -87
- package/dist/index.js +239 -121
- package/dist/types.d.ts +147 -1
- package/package.json +3 -3
- package/src/client/sp00ky-client.ts +25 -4
- package/src/modules/auth/impersonation-banner.ts +310 -140
- package/src/modules/auth/impersonation.test.ts +73 -0
- package/src/types.ts +12 -0
package/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.
|
|
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.
|
|
67
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
-
//
|
|
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
|
}
|