@spooky-sync/core 0.0.1-canary.260 → 0.0.1-canary.262
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 +159 -2
- package/dist/index.js +657 -27
- package/dist/types.d.ts +6 -0
- package/package.json +3 -3
- package/src/boot/bucket-switch.saga.test.ts +30 -1
- package/src/boot/bucket-switch.saga.ts +17 -1
- package/src/client/services.ts +4 -0
- package/src/client/sp00ky-client.ts +8 -0
- package/src/kernel/effects.ts +2 -0
- package/src/modules/auth/events/index.ts +7 -1
- package/src/modules/auth/impersonation-banner.ts +273 -0
- package/src/modules/auth/impersonation.test.ts +282 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +240 -36
- package/src/modules/devtools/index.ts +71 -1
- package/src/services/database/cache-engine.ts +6 -0
- package/src/services/database/local.ts +7 -0
- package/src/services/database/sqlite-cache-engine.ts +17 -0
- package/src/testing/fake-services.ts +1 -1
package/dist/types.d.ts
CHANGED
|
@@ -299,6 +299,12 @@ interface LocalStore extends LocalCacheEngine {
|
|
|
299
299
|
* client facade, so adding this needs no change on their side.
|
|
300
300
|
*/
|
|
301
301
|
readonly storageHealth?: StorageHealth;
|
|
302
|
+
/**
|
|
303
|
+
* Delete a bucket's persisted store. Never the open one. OPTIONAL and
|
|
304
|
+
* best-effort: used to remove an impersonated user's data from the admin's
|
|
305
|
+
* device once the impersonation ends.
|
|
306
|
+
*/
|
|
307
|
+
dropBucket?(bucketId: string): Promise<void>;
|
|
302
308
|
/** Fires immediately with the current snapshot, then on every change.
|
|
303
309
|
* Returns an unsubscribe function. */
|
|
304
310
|
subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
|
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.262",
|
|
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.262",
|
|
67
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.262",
|
|
68
68
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
69
69
|
"@surrealdb/wasm": "^3.0.3",
|
|
70
70
|
"blurhash": "^2.0.5",
|
|
@@ -5,7 +5,7 @@ import { fakeServices } from '../testing/services';
|
|
|
5
5
|
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
6
6
|
import * as R from '../state/reducers';
|
|
7
7
|
import { defaultEnv } from '../query/env';
|
|
8
|
-
import { bucketSwitch, rebindQueries } from './bucket-switch.saga';
|
|
8
|
+
import { bucketSwitch, dropImpersonatedBucket, rebindQueries } from './bucket-switch.saga';
|
|
9
9
|
|
|
10
10
|
const env = defaultEnv({ tables: [] } as any);
|
|
11
11
|
const base = (over: Partial<ReturnType<typeof buildState>> = {}) => ({ ...buildState(), pendingBucket: 'u2', bucketId: 'u1', ...over });
|
|
@@ -57,6 +57,7 @@ describe('bucketSwitch', () => {
|
|
|
57
57
|
'auth.sessionAuthId',
|
|
58
58
|
'auth.access',
|
|
59
59
|
'ssp.setSessionAuth',
|
|
60
|
+
'auth.consumeEndedImpersonation',
|
|
60
61
|
'auth.token',
|
|
61
62
|
'persistence.set',
|
|
62
63
|
]);
|
|
@@ -128,6 +129,34 @@ describe('bucketSwitch', () => {
|
|
|
128
129
|
});
|
|
129
130
|
});
|
|
130
131
|
|
|
132
|
+
describe('dropImpersonatedBucket', () => {
|
|
133
|
+
it('drops the bucket it just left when that was the impersonated user', async () => {
|
|
134
|
+
const svc = fakeServices({ 'auth.consumeEndedImpersonation': () => 'user:bob' });
|
|
135
|
+
await runPure(dropImpersonatedBucket('bob'), { state: buildState(), handlers: { service: svc.handler } });
|
|
136
|
+
expect(svc.calls).toEqual([
|
|
137
|
+
['auth.consumeEndedImpersonation', []],
|
|
138
|
+
['local.dropBucket', ['bob']],
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
it('leaves every other bucket alone', async () => {
|
|
142
|
+
for (const [ended, left] of [[null, 'bob'], ['user:bob', 'alice'], ['user:bob', 'anon']] as const) {
|
|
143
|
+
const svc = fakeServices({ 'auth.consumeEndedImpersonation': () => ended });
|
|
144
|
+
await runPure(dropImpersonatedBucket(left), { state: buildState(), handlers: { service: svc.handler } });
|
|
145
|
+
expect(svc.names()).toEqual(['auth.consumeEndedImpersonation']);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
it('logs a failed delete instead of failing the switch', async () => {
|
|
149
|
+
const svc = fakeServices({
|
|
150
|
+
'auth.consumeEndedImpersonation': () => 'user:bob',
|
|
151
|
+
'local.dropBucket': async () => {
|
|
152
|
+
throw new Error('handles still open');
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const out = await runPure(dropImpersonatedBucket('bob'), { state: buildState(), handlers: { service: svc.handler } });
|
|
156
|
+
expect(out.emitted.filter((e) => e.type === 'log')).toHaveLength(1);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
131
160
|
describe('rebindQueries', () => {
|
|
132
161
|
it('re-seeds from the new store, rebuilds the local view (tolerating failures), emits authority flips', async () => {
|
|
133
162
|
const s = R.compose(
|
|
@@ -5,7 +5,7 @@ import { fx } from '../kernel/effects';
|
|
|
5
5
|
import type { ClientState, QueryEntry } from '../state/client-state';
|
|
6
6
|
import { seedLifecycle, isAuthoritative } from '../state/lifecycle';
|
|
7
7
|
import * as R from '../state/reducers';
|
|
8
|
-
import { ANON_USER_ID } from '../modules/ref-tables';
|
|
8
|
+
import { ANON_USER_ID, bucketIdForUser } from '../modules/ref-tables';
|
|
9
9
|
import type { SagaEnv } from '../query/env';
|
|
10
10
|
import { queryHashInput } from '../query/hash';
|
|
11
11
|
import { isResolvedBefore, parseViewRow } from '../query/membership';
|
|
@@ -62,6 +62,7 @@ export function* bucketSwitch(env: SagaEnv, target: string, release: (() => void
|
|
|
62
62
|
} finally {
|
|
63
63
|
gate();
|
|
64
64
|
}
|
|
65
|
+
yield* dropImpersonatedBucket(current);
|
|
65
66
|
const token = (yield fx.service('auth.token')) as string | null;
|
|
66
67
|
if (token) {
|
|
67
68
|
try {
|
|
@@ -82,6 +83,21 @@ export function* bucketSwitch(env: SagaEnv, target: string, release: (() => void
|
|
|
82
83
|
yield fx.dispatch({ type: 'Drain' });
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* An impersonation just ended and this switch left its bucket: delete that
|
|
88
|
+
* user's local data from the admin's device. Best-effort; a failure only
|
|
89
|
+
* leaves rows the server already let this admin read.
|
|
90
|
+
*/
|
|
91
|
+
export function* dropImpersonatedBucket(left: string): Saga<void> {
|
|
92
|
+
const ended = (yield fx.service('auth.consumeEndedImpersonation')) as string | null;
|
|
93
|
+
if (!ended || left === ANON_USER_ID || bucketIdForUser(ended) !== left) return;
|
|
94
|
+
try {
|
|
95
|
+
yield fx.service('local.dropBucket', left);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'could not delete the impersonated bucket', data: { bucket: left, error } });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
85
101
|
/** Re-seed every active query from the new store and rebuild its SSP view. */
|
|
86
102
|
export function* rebindQueries(): Saga<void> {
|
|
87
103
|
const [entries, sessionId] = (yield fx.state.read((s) => [[...s.queries.values()], s.sessionId])) as [QueryEntry[], string | null];
|
package/src/client/services.ts
CHANGED
|
@@ -249,6 +249,9 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
249
249
|
'local.beginSwitch': () => s.local.beginSwitch(),
|
|
250
250
|
'local.currentBucketId': () => s.local.currentBucketId,
|
|
251
251
|
'local.usesSurqlSchema': () => s.local.usesSurqlSchema,
|
|
252
|
+
'local.dropBucket': async (bucketId) => {
|
|
253
|
+
await s.local.dropBucket?.(bucketId);
|
|
254
|
+
},
|
|
252
255
|
'migrator.provision': () => s.migrator.provision(config.schemaSurql),
|
|
253
256
|
'blobs.start': async (bucketId) => {
|
|
254
257
|
s.blobs.setMaxBytes(await resolveBlobBudget(config.blobCache?.maxBytes));
|
|
@@ -278,6 +281,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
278
281
|
'auth.access': () => s.auth.access,
|
|
279
282
|
'auth.token': () => s.auth.token,
|
|
280
283
|
'auth.currentUser': () => (s.auth.currentUser as Record<string, unknown> | null) ?? null,
|
|
284
|
+
'auth.consumeEndedImpersonation': () => s.auth.consumeEndedImpersonation(),
|
|
281
285
|
'remote.connect': () => s.remote.connect(),
|
|
282
286
|
'remote.releaseViews': (ids) => {
|
|
283
287
|
const list = ids
|
|
@@ -54,6 +54,7 @@ import { DEGRADE_AFTER_FAILURES, OUTBOX_BATCH_SIZE, PUSH_TIMEOUT_MS } from '../k
|
|
|
54
54
|
import { Runtime } from './runtime';
|
|
55
55
|
import { createAdapters, createServices, createTabsCoordinator, type ServiceHost, type Services } from './services';
|
|
56
56
|
import { BucketHandle } from './bucket-handle';
|
|
57
|
+
import { ImpersonationBanner } from '../modules/auth/impersonation-banner';
|
|
57
58
|
import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
|
|
58
59
|
|
|
59
60
|
const UNKNOWN_STORAGE_HEALTH: StorageHealth = Object.freeze({ status: 'unknown', fallback: false });
|
|
@@ -78,6 +79,8 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
78
79
|
private readonly runtime: Runtime;
|
|
79
80
|
private readonly featureFlags: FeatureFlagModule<S>;
|
|
80
81
|
private readonly appReleases: AppReleaseModule<S>;
|
|
82
|
+
/** Teardown for subscriptions the client itself owns. */
|
|
83
|
+
private readonly disposers: Array<() => void> = [];
|
|
81
84
|
private readonly devTools: DevToolsService;
|
|
82
85
|
private hub: LeaderSyncHub | null = null;
|
|
83
86
|
private forwarder: SyncForwarder | null = null;
|
|
@@ -174,6 +177,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
174
177
|
s.streamProcessor.addReceiver(receiver);
|
|
175
178
|
s.connectionSupervisor.subscribe((state) => void this.runtime.dispatch({ type: 'ConnectionChanged', state }));
|
|
176
179
|
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());
|
|
177
184
|
this.runtime.on('tabs:broadcast', (e) => {
|
|
178
185
|
if (e.type !== 'tabs:broadcast') return;
|
|
179
186
|
const msg = e.message as { type: string; records?: unknown[]; mutationId?: string };
|
|
@@ -224,6 +231,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
224
231
|
async close(): Promise<void> {
|
|
225
232
|
const s = this.services;
|
|
226
233
|
s.connectionSupervisor.dispose();
|
|
234
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
227
235
|
this.runtime.dispose();
|
|
228
236
|
await this.featureFlags.closeAll();
|
|
229
237
|
await this.appReleases.closeAll();
|
package/src/kernel/effects.ts
CHANGED
|
@@ -75,6 +75,7 @@ export interface ServiceCalls {
|
|
|
75
75
|
'local.beginSwitch': () => () => void;
|
|
76
76
|
'local.currentBucketId': () => string;
|
|
77
77
|
'local.usesSurqlSchema': () => boolean;
|
|
78
|
+
'local.dropBucket': (bucketId: string) => Promise<void>;
|
|
78
79
|
'migrator.provision': () => Promise<void>;
|
|
79
80
|
'blobs.start': (bucketId: string) => Promise<void>;
|
|
80
81
|
'blobs.setNamespace': (bucketId: string) => Promise<void>;
|
|
@@ -91,6 +92,7 @@ export interface ServiceCalls {
|
|
|
91
92
|
'auth.access': () => string | null;
|
|
92
93
|
'auth.token': () => string | null;
|
|
93
94
|
'auth.currentUser': () => Record<string, unknown> | null;
|
|
95
|
+
'auth.consumeEndedImpersonation': () => string | null;
|
|
94
96
|
'remote.connect': () => Promise<void>;
|
|
95
97
|
'remote.releaseViews': (ids: unknown[]) => void;
|
|
96
98
|
'supervisor.start': () => void;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { EventDefinition, EventSystem } from '../../../events/index';
|
|
2
2
|
import { createEventSystem } from '../../../events/index';
|
|
3
|
+
import type { ImpersonationInfo } from '../impersonation';
|
|
3
4
|
|
|
4
5
|
export const AuthEventTypes = {
|
|
5
6
|
AuthStateChanged: 'AUTH_STATE_CHANGED',
|
|
7
|
+
ImpersonationChanged: 'IMPERSONATION_CHANGED',
|
|
6
8
|
} as const;
|
|
7
9
|
|
|
8
10
|
export type AuthEventTypeMap = {
|
|
@@ -10,10 +12,14 @@ export type AuthEventTypeMap = {
|
|
|
10
12
|
typeof AuthEventTypes.AuthStateChanged,
|
|
11
13
|
string | null
|
|
12
14
|
>;
|
|
15
|
+
[AuthEventTypes.ImpersonationChanged]: EventDefinition<
|
|
16
|
+
typeof AuthEventTypes.ImpersonationChanged,
|
|
17
|
+
ImpersonationInfo | null
|
|
18
|
+
>;
|
|
13
19
|
};
|
|
14
20
|
|
|
15
21
|
export type AuthEventSystem = EventSystem<AuthEventTypeMap>;
|
|
16
22
|
|
|
17
23
|
export function createAuthEventSystem(): AuthEventSystem {
|
|
18
|
-
return createEventSystem([AuthEventTypes.AuthStateChanged]);
|
|
24
|
+
return createEventSystem([AuthEventTypes.AuthStateChanged, AuthEventTypes.ImpersonationChanged]);
|
|
19
25
|
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type { ImpersonationInfo } from './impersonation';
|
|
2
|
+
|
|
3
|
+
/** Height of the warning layer the page is shifted down by. Also published as
|
|
4
|
+
* the CSS variable `--sp00ky-impersonation-banner-height` on `<html>` (`0px`
|
|
5
|
+
* when hidden), so an app can offset its own fixed header. */
|
|
6
|
+
export const BANNER_HEIGHT_PX = 44;
|
|
7
|
+
|
|
8
|
+
/** Radius of the page's new top corners. */
|
|
9
|
+
const PAGE_RADIUS_PX = 14;
|
|
10
|
+
|
|
11
|
+
const HOST_TAG = 'sp00ky-impersonation-banner';
|
|
12
|
+
|
|
13
|
+
/** Hazard stripes: the warning layer, and the `<html>` backdrop the page's
|
|
14
|
+
* rounded corners reveal. */
|
|
15
|
+
const STRIPES = 'repeating-linear-gradient(45deg, #f2b600 0 14px, #1c1403 14px 28px)';
|
|
16
|
+
|
|
17
|
+
/** Keeps only the area OUTSIDE a corner's curve, so the nub is striped and
|
|
18
|
+
* everything inside the curve stays the app's own. `center` is the curve's
|
|
19
|
+
* centre inside the corner square. */
|
|
20
|
+
function cornerMask(center: string): string {
|
|
21
|
+
return `radial-gradient(circle ${PAGE_RADIUS_PX}px at ${center}, transparent 0 ${PAGE_RADIUS_PX}px, #000 ${PAGE_RADIUS_PX}px)`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The shadow root keeps app CSS away from the bar, but the host element itself
|
|
25
|
+
// is in the page and matches the page's selectors (`div { display: none }`).
|
|
26
|
+
// An `!important` rule on `:host` wins over the page's own `!important`
|
|
27
|
+
// (inner tree context), which keeps the host rendered whatever the app says.
|
|
28
|
+
const STYLE = `
|
|
29
|
+
:host {
|
|
30
|
+
all: initial !important;
|
|
31
|
+
display: block !important;
|
|
32
|
+
visibility: visible !important;
|
|
33
|
+
opacity: 1 !important;
|
|
34
|
+
position: static !important;
|
|
35
|
+
transform: none !important;
|
|
36
|
+
clip-path: none !important;
|
|
37
|
+
}
|
|
38
|
+
.layer {
|
|
39
|
+
position: fixed; inset: 0 0 auto 0; z-index: 2147483646; pointer-events: none;
|
|
40
|
+
height: ${BANNER_HEIGHT_PX}px; background: ${STRIPES};
|
|
41
|
+
}
|
|
42
|
+
/* The page's rounded top corners, drawn over whatever the app puts there.
|
|
43
|
+
The body element's own border-radius rounds its background, but a fixed app
|
|
44
|
+
header is not clipped by it, so each corner is also painted here: a striped
|
|
45
|
+
square masked down to the nub OUTSIDE the corner's curve, so the app's own
|
|
46
|
+
colour still shows inside it. */
|
|
47
|
+
.corners {
|
|
48
|
+
position: fixed; left: 0; right: 0; top: ${BANNER_HEIGHT_PX}px;
|
|
49
|
+
height: ${PAGE_RADIUS_PX}px; z-index: 2147483646; pointer-events: none;
|
|
50
|
+
}
|
|
51
|
+
.corner {
|
|
52
|
+
position: absolute; top: 0; width: ${PAGE_RADIUS_PX}px; height: ${PAGE_RADIUS_PX}px;
|
|
53
|
+
background: ${STRIPES};
|
|
54
|
+
}
|
|
55
|
+
.corner.left {
|
|
56
|
+
left: 0;
|
|
57
|
+
-webkit-mask: ${cornerMask('100% 100%')};
|
|
58
|
+
mask: ${cornerMask('100% 100%')};
|
|
59
|
+
}
|
|
60
|
+
.corner.right {
|
|
61
|
+
right: 0;
|
|
62
|
+
-webkit-mask: ${cornerMask('0% 100%')};
|
|
63
|
+
mask: ${cornerMask('0% 100%')};
|
|
64
|
+
}
|
|
65
|
+
.bar {
|
|
66
|
+
position: fixed; inset: 0 0 auto 0; height: ${BANNER_HEIGHT_PX}px; z-index: 2147483647;
|
|
67
|
+
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
68
|
+
padding: 0 12px; box-sizing: border-box;
|
|
69
|
+
font: 600 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
70
|
+
}
|
|
71
|
+
.pill {
|
|
72
|
+
display: flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%;
|
|
73
|
+
padding: 6px 10px; border-radius: 999px;
|
|
74
|
+
background: rgba(14, 11, 2, .88); color: #fde68a;
|
|
75
|
+
box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
|
|
76
|
+
}
|
|
77
|
+
.sign { flex: none; font-size: 14px; line-height: 1; }
|
|
78
|
+
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
79
|
+
.text b { color: #fff; font-weight: 800; }
|
|
80
|
+
button {
|
|
81
|
+
flex: none; cursor: pointer; border: 0; border-radius: 999px;
|
|
82
|
+
background: #fde68a; color: #1c1403;
|
|
83
|
+
font: 800 12px/1 system-ui, sans-serif; padding: 7px 12px;
|
|
84
|
+
box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
|
|
85
|
+
}
|
|
86
|
+
button:hover { background: #fff; }
|
|
87
|
+
button:disabled { opacity: .6; cursor: progress; }
|
|
88
|
+
@media (max-width: 520px) {
|
|
89
|
+
.bar { gap: 6px; padding: 0 8px; }
|
|
90
|
+
.pill { padding: 5px 8px; }
|
|
91
|
+
}
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The "you are impersonating" warning.
|
|
96
|
+
*
|
|
97
|
+
* Drawn as a layer the page sits on top of: hazard stripes fill the top of the
|
|
98
|
+
* viewport, the page is pushed down by the bar's height and its new top
|
|
99
|
+
* corners are rounded, so the stripes show through around them. The bar itself
|
|
100
|
+
* lives in a closed shadow root, so app styles cannot hide or restyle it; the
|
|
101
|
+
* page-shifting styles are set inline and `!important` on `<html>` / `<body>`
|
|
102
|
+
* (nothing in a stylesheet outranks that), and the previous inline styles are
|
|
103
|
+
* restored on unmount.
|
|
104
|
+
*
|
|
105
|
+
* Every piece of text goes through `textContent`.
|
|
106
|
+
*/
|
|
107
|
+
export class ImpersonationBanner {
|
|
108
|
+
private host: HTMLElement | null = null;
|
|
109
|
+
private text: HTMLElement | null = null;
|
|
110
|
+
/** `style` attributes as the page had them before the shift. */
|
|
111
|
+
private saved: { html: string | null; body: string | null } | null = null;
|
|
112
|
+
|
|
113
|
+
constructor(private onStop: () => Promise<void>) {}
|
|
114
|
+
|
|
115
|
+
static supported(): boolean {
|
|
116
|
+
return typeof document !== 'undefined' && typeof document.createElement === 'function';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
update(info: ImpersonationInfo | null): void {
|
|
120
|
+
if (!ImpersonationBanner.supported()) return;
|
|
121
|
+
if (!info) {
|
|
122
|
+
this.unmount();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.mount();
|
|
126
|
+
const text = this.text!;
|
|
127
|
+
text.replaceChildren(
|
|
128
|
+
document.createTextNode('Impersonating '),
|
|
129
|
+
bold(info.target),
|
|
130
|
+
document.createTextNode(` as ${info.admin}`)
|
|
131
|
+
);
|
|
132
|
+
text.title = `Impersonating ${info.target} as ${info.admin}. Every write is audited.`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
unmount(): void {
|
|
136
|
+
this.host?.remove();
|
|
137
|
+
this.host = this.text = null;
|
|
138
|
+
if (typeof document === 'undefined') return;
|
|
139
|
+
if (this.saved) {
|
|
140
|
+
restoreStyle(document.documentElement, this.saved.html);
|
|
141
|
+
restoreStyle(document.body, this.saved.body);
|
|
142
|
+
this.saved = null;
|
|
143
|
+
}
|
|
144
|
+
document.documentElement?.style.setProperty('--sp00ky-impersonation-banner-height', '0px');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private mount(): void {
|
|
148
|
+
if (this.host?.isConnected) return;
|
|
149
|
+
const host = document.createElement(HOST_TAG);
|
|
150
|
+
const root = host.attachShadow({ mode: 'closed' });
|
|
151
|
+
const style = document.createElement('style');
|
|
152
|
+
style.textContent = STYLE;
|
|
153
|
+
|
|
154
|
+
// The striped ground the bar's text sits on: fixed, so it stays put while
|
|
155
|
+
// the page scrolls underneath. Never takes a click.
|
|
156
|
+
const layer = document.createElement('div');
|
|
157
|
+
layer.className = 'layer';
|
|
158
|
+
layer.setAttribute('aria-hidden', 'true');
|
|
159
|
+
const corners = document.createElement('div');
|
|
160
|
+
corners.className = 'corners';
|
|
161
|
+
corners.setAttribute('aria-hidden', 'true');
|
|
162
|
+
for (const side of ['left', 'right']) {
|
|
163
|
+
const corner = document.createElement('div');
|
|
164
|
+
corner.className = `corner ${side}`;
|
|
165
|
+
corners.append(corner);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const bar = document.createElement('div');
|
|
169
|
+
bar.className = 'bar';
|
|
170
|
+
bar.setAttribute('role', 'alert');
|
|
171
|
+
const pill = document.createElement('div');
|
|
172
|
+
pill.className = 'pill';
|
|
173
|
+
const sign = document.createElement('span');
|
|
174
|
+
sign.className = 'sign';
|
|
175
|
+
sign.textContent = '⚠';
|
|
176
|
+
const text = document.createElement('span');
|
|
177
|
+
text.className = 'text';
|
|
178
|
+
const button = document.createElement('button');
|
|
179
|
+
button.type = 'button';
|
|
180
|
+
button.textContent = 'Stop';
|
|
181
|
+
button.addEventListener('click', () => {
|
|
182
|
+
button.disabled = true;
|
|
183
|
+
this.onStop().finally(() => {
|
|
184
|
+
button.disabled = false;
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
pill.append(sign, text);
|
|
188
|
+
bar.append(pill, button);
|
|
189
|
+
root.append(style, layer, corners, bar);
|
|
190
|
+
// Hosted on `<html>`, not `<body>`: the page shift clips `<body>` to the
|
|
191
|
+
// rounded top, and a bar inside it would be clipped away with everything
|
|
192
|
+
// else.
|
|
193
|
+
document.documentElement.appendChild(host);
|
|
194
|
+
|
|
195
|
+
this.shiftPage();
|
|
196
|
+
this.host = host;
|
|
197
|
+
this.text = text;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Push the page below the warning and round its new top edge.
|
|
202
|
+
*
|
|
203
|
+
* `<html>` carries the stripes as its background, because that is what the
|
|
204
|
+
* page's rounded corners reveal. `<body>` therefore has to be painted
|
|
205
|
+
* opaque: most apps colour it themselves, but a transparent one would let
|
|
206
|
+
* the stripes through behind the whole page, so its resolved colour is
|
|
207
|
+
* computed first and pinned.
|
|
208
|
+
*/
|
|
209
|
+
private shiftPage(): void {
|
|
210
|
+
const root = document.documentElement;
|
|
211
|
+
const body = document.body;
|
|
212
|
+
if (!root || !body) return;
|
|
213
|
+
this.saved = { html: root.getAttribute('style'), body: body.getAttribute('style') };
|
|
214
|
+
|
|
215
|
+
const surface = resolveSurface(root, body);
|
|
216
|
+
root.style.setProperty('--sp00ky-impersonation-banner-height', `${BANNER_HEIGHT_PX}px`);
|
|
217
|
+
root.style.setProperty('background-image', STRIPES, 'important');
|
|
218
|
+
root.style.setProperty('padding-top', `${BANNER_HEIGHT_PX}px`, 'important');
|
|
219
|
+
root.style.setProperty('box-sizing', 'border-box', 'important');
|
|
220
|
+
|
|
221
|
+
body.style.setProperty('background-color', surface, 'important');
|
|
222
|
+
body.style.setProperty(
|
|
223
|
+
'border-radius',
|
|
224
|
+
`${PAGE_RADIUS_PX}px ${PAGE_RADIUS_PX}px 0 0`,
|
|
225
|
+
'important'
|
|
226
|
+
);
|
|
227
|
+
// Clips the app's own content — a fixed header included — to the rounded
|
|
228
|
+
// top, which is what makes the page read as a sheet over the stripes.
|
|
229
|
+
body.style.setProperty(
|
|
230
|
+
'clip-path',
|
|
231
|
+
`inset(0 round ${PAGE_RADIUS_PX}px ${PAGE_RADIUS_PX}px 0 0)`,
|
|
232
|
+
'important'
|
|
233
|
+
);
|
|
234
|
+
body.style.setProperty('box-shadow', '0 -6px 18px rgba(0, 0, 0, .35)', 'important');
|
|
235
|
+
// An app sized to `100vh` would otherwise overflow by the bar's height and
|
|
236
|
+
// put a scrollbar on a page that had none.
|
|
237
|
+
body.style.setProperty('min-height', `calc(100vh - ${BANNER_HEIGHT_PX}px)`, 'important');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Put an element's inline styles back exactly as they were. */
|
|
242
|
+
function restoreStyle(el: HTMLElement | null, saved: string | null): void {
|
|
243
|
+
if (!el) return;
|
|
244
|
+
if (saved === null) el.removeAttribute('style');
|
|
245
|
+
else el.setAttribute('style', saved);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The colour to paint `<body>` with while the stripes sit behind it: whatever
|
|
250
|
+
* the page already paints (body first, then html), and failing that the canvas
|
|
251
|
+
* colour for the viewer's colour scheme.
|
|
252
|
+
*/
|
|
253
|
+
function resolveSurface(root: HTMLElement, body: HTMLElement): string {
|
|
254
|
+
for (const el of [body, root]) {
|
|
255
|
+
const color = getComputedStyle(el).backgroundColor;
|
|
256
|
+
if (color && !isTransparent(color)) return color;
|
|
257
|
+
}
|
|
258
|
+
const dark =
|
|
259
|
+
typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
|
|
260
|
+
return dark ? '#111111' : '#ffffff';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isTransparent(color: string): boolean {
|
|
264
|
+
if (color === 'transparent') return true;
|
|
265
|
+
const parts = color.match(/[\d.]+/g);
|
|
266
|
+
return !!parts && parts.length === 4 && Number(parts[3]) === 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function bold(value: string): HTMLElement {
|
|
270
|
+
const b = document.createElement('b');
|
|
271
|
+
b.textContent = value;
|
|
272
|
+
return b;
|
|
273
|
+
}
|