@spooky-sync/core 0.0.1-canary.30 → 0.0.1-canary.301
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 +81 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2026 -137
- package/dist/index.js +14939 -2432
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +329 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +358 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +1765 -38
- package/dist/worker-edges.js +423 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -0
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +158 -0
- package/src/boot/boot.saga.ts +131 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +370 -0
- package/src/client/sp00ky-client.test.ts +303 -0
- package/src/client/sp00ky-client.ts +586 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +386 -17
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +217 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +159 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +63 -0
- package/src/query/sql.ts +93 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +199 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +146 -61
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +59 -16
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +487 -63
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +213 -6
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1372 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +139 -0
- package/src/services/database/sqlite-plan-sql.ts +179 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +779 -67
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +58 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +638 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +200 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +167 -0
- package/src/state/selectors.ts +216 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +48 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +624 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +75 -9
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +2 -1
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import type { ImpersonationInfo } from './impersonation';
|
|
2
|
+
|
|
3
|
+
/** Default height of the warning bar above the framed page. The effective
|
|
4
|
+
* 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. */
|
|
7
|
+
export const BANNER_HEIGHT_PX = 40;
|
|
8
|
+
|
|
9
|
+
/** Default gutter left, right and below the framed page. */
|
|
10
|
+
export const FRAME_INSET_PX = 10;
|
|
11
|
+
|
|
12
|
+
/** Default radius of the framed page's corners. */
|
|
13
|
+
export const PAGE_RADIUS_PX = 14;
|
|
14
|
+
|
|
15
|
+
/** The default backdrop: a warm amber-to-orange wash with a faint diagonal
|
|
16
|
+
* hazard rake over it. Layered background-image, topmost layer first. */
|
|
17
|
+
export const BANNER_BACKGROUND =
|
|
18
|
+
'repeating-linear-gradient(45deg, rgba(69, 26, 3, .07) 0 9px, rgba(69, 26, 3, 0) 9px 18px),' +
|
|
19
|
+
' linear-gradient(135deg, #fbbf24 0%, #fb923c 52%, #f59e0b 100%)';
|
|
20
|
+
|
|
21
|
+
const HOST_TAG = 'sp00ky-impersonation-banner';
|
|
22
|
+
|
|
23
|
+
/** How long a `custom` banner has to acknowledge itself before the built-in
|
|
24
|
+
* one is shown anyway. Long enough for a first paint, short enough that
|
|
25
|
+
* nobody browses unwarned. */
|
|
26
|
+
export const CUSTOM_BANNER_GRACE_MS = 2500;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Restyling of the built-in banner. Every field is optional; anything unset
|
|
30
|
+
* keeps the default. Colours are plain CSS values, so a project can pass its
|
|
31
|
+
* own tokens (`var(--brand-warning)`) as long as they resolve at the document
|
|
32
|
+
* level.
|
|
33
|
+
*/
|
|
34
|
+
export interface ImpersonationBannerTheme {
|
|
35
|
+
/** Height of the warning bar above the page. */
|
|
36
|
+
heightPx?: number;
|
|
37
|
+
/** Gutter left, right and below the page. `0` frames only from the top. */
|
|
38
|
+
insetPx?: number;
|
|
39
|
+
/** Radius of the framed page's corners. `0` squares them off. */
|
|
40
|
+
radiusPx?: number;
|
|
41
|
+
/** The backdrop behind the page. Any CSS `background` value. */
|
|
42
|
+
background?: string;
|
|
43
|
+
/** Colour of the warning text on the backdrop. */
|
|
44
|
+
text?: string;
|
|
45
|
+
/** Background of the Stop button. */
|
|
46
|
+
accent?: string;
|
|
47
|
+
/** Text colour of the Stop button. */
|
|
48
|
+
accentText?: string;
|
|
49
|
+
/** Label for the Stop button. Defaults to `Stop`. */
|
|
50
|
+
stopLabel?: string;
|
|
51
|
+
/** The warning sentence. Defaults to `Impersonating <target> as <admin>`.
|
|
52
|
+
* Wrap a run in `**` to emphasise it; the result is text, never markup. */
|
|
53
|
+
label?: (info: ImpersonationInfo) => string;
|
|
54
|
+
/** Keep the bar but leave the page's own layout completely alone. The CSS
|
|
55
|
+
* variable is still published, so the app can do its own offset. */
|
|
56
|
+
noPageShift?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ImpersonationBannerConfig {
|
|
60
|
+
/**
|
|
61
|
+
* - `'default'` renders the built-in banner.
|
|
62
|
+
* - `'custom'` leaves the page alone so the app can render its own from
|
|
63
|
+
* `useImpersonation()` / `auth.subscribeImpersonation`. Acknowledge it
|
|
64
|
+
* with `client.acknowledgeImpersonationBanner()` while it is on screen;
|
|
65
|
+
* if nothing acknowledges within {@link CUSTOM_BANNER_GRACE_MS}, the
|
|
66
|
+
* built-in banner appears, on the assumption that the custom one failed
|
|
67
|
+
* to render rather than that nobody should be warned.
|
|
68
|
+
* - `'none'` renders nothing, ever. The app takes over responsibility for
|
|
69
|
+
* telling whoever is looking at the page that it is not their session.
|
|
70
|
+
*/
|
|
71
|
+
mode?: 'default' | 'custom' | 'none';
|
|
72
|
+
/** Restyle the built-in banner (also used if `custom` falls back). */
|
|
73
|
+
theme?: ImpersonationBannerTheme;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ResolvedTheme
|
|
77
|
+
extends Required<Omit<ImpersonationBannerTheme, 'label' | 'stopLabel'>> {
|
|
78
|
+
stopLabel: string;
|
|
79
|
+
label: (info: ImpersonationInfo) => string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveTheme(theme: ImpersonationBannerTheme = {}): ResolvedTheme {
|
|
83
|
+
return {
|
|
84
|
+
heightPx: theme.heightPx ?? BANNER_HEIGHT_PX,
|
|
85
|
+
insetPx: theme.insetPx ?? FRAME_INSET_PX,
|
|
86
|
+
radiusPx: theme.radiusPx ?? PAGE_RADIUS_PX,
|
|
87
|
+
background: theme.background ?? BANNER_BACKGROUND,
|
|
88
|
+
text: theme.text ?? '#45230a',
|
|
89
|
+
accent: theme.accent ?? '#45230a',
|
|
90
|
+
accentText: theme.accentText ?? '#fff7ed',
|
|
91
|
+
stopLabel: theme.stopLabel ?? 'Stop',
|
|
92
|
+
noPageShift: theme.noPageShift ?? false,
|
|
93
|
+
label: theme.label ?? ((info) => `Impersonating **${info.target}** as ${info.admin}`),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How much the page has to shrink to fit inside the frame, and how wide its
|
|
99
|
+
* layout box must be to fill the frame at that scale.
|
|
100
|
+
*
|
|
101
|
+
* Pure so the arithmetic is unit tested: the frame takes `heightPx` off the
|
|
102
|
+
* top and `insetPx` off the other three sides, and the page is scaled
|
|
103
|
+
* uniformly (one factor for both axes, so nothing is distorted) by the
|
|
104
|
+
* vertical ratio. `width` is the CSS width the page's box needs, since a
|
|
105
|
+
* zoomed box occupies `width * scale` of its parent.
|
|
106
|
+
*/
|
|
107
|
+
export function frameMetrics(
|
|
108
|
+
viewport: { width: number; height: number },
|
|
109
|
+
theme: { heightPx: number; insetPx: number }
|
|
110
|
+
): { scale: number; width: number; contentHeight: number } {
|
|
111
|
+
const { width: w, height: h } = viewport;
|
|
112
|
+
const contentHeight = Math.max(1, h - theme.heightPx - theme.insetPx);
|
|
113
|
+
const contentWidth = Math.max(1, w - theme.insetPx * 2);
|
|
114
|
+
// Uniform: the vertical fit decides, so the aspect ratio is untouched and
|
|
115
|
+
// the page keeps every pixel of its height reachable.
|
|
116
|
+
const scale = h > 0 ? contentHeight / h : 1;
|
|
117
|
+
return { scale, width: contentWidth / scale, contentHeight };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The shadow root keeps app CSS away from the bar, but the host element itself
|
|
121
|
+
// is in the page and matches the page's selectors (`div { display: none }`).
|
|
122
|
+
// An `!important` rule on `:host` wins over the page's own `!important`
|
|
123
|
+
// (inner tree context), which keeps the host rendered whatever the app says.
|
|
124
|
+
export function bannerCss(t: ResolvedTheme): string {
|
|
125
|
+
return `
|
|
126
|
+
:host {
|
|
127
|
+
all: initial !important;
|
|
128
|
+
display: block !important;
|
|
129
|
+
visibility: visible !important;
|
|
130
|
+
opacity: 1 !important;
|
|
131
|
+
position: static !important;
|
|
132
|
+
transform: none !important;
|
|
133
|
+
clip-path: none !important;
|
|
134
|
+
}
|
|
135
|
+
/* The bar. The backdrop itself is painted on the root element, which is
|
|
136
|
+
BEHIND the page's sheet; an overlay here would cover the page. The bar
|
|
137
|
+
repeats that background with a fixed attachment, so its gradient lines up
|
|
138
|
+
with the root's to the pixel. */
|
|
139
|
+
.bar {
|
|
140
|
+
position: fixed; inset: 0 0 auto 0; height: ${t.heightPx}px; z-index: 2147483647;
|
|
141
|
+
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
142
|
+
padding: 0 ${Math.max(t.insetPx, 10)}px; box-sizing: border-box;
|
|
143
|
+
background-image: ${t.background};
|
|
144
|
+
background-attachment: fixed;
|
|
145
|
+
color: ${t.text};
|
|
146
|
+
font: 600 12.5px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
147
|
+
letter-spacing: .01em;
|
|
148
|
+
}
|
|
149
|
+
.sign {
|
|
150
|
+
flex: none; display: grid; place-items: center;
|
|
151
|
+
width: 17px; height: 17px; border-radius: 50%;
|
|
152
|
+
background: ${t.text}; color: #fff7ed;
|
|
153
|
+
font-size: 11px; font-weight: 800;
|
|
154
|
+
}
|
|
155
|
+
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
156
|
+
.text b { font-weight: 800; }
|
|
157
|
+
button {
|
|
158
|
+
flex: none; cursor: pointer; border: 0; border-radius: 999px;
|
|
159
|
+
background: ${t.accent}; color: ${t.accentText};
|
|
160
|
+
font: 700 11.5px/1 system-ui, sans-serif; padding: 6px 11px;
|
|
161
|
+
box-shadow: 0 1px 2px rgba(69, 26, 3, .35);
|
|
162
|
+
}
|
|
163
|
+
button:hover { filter: brightness(1.15); }
|
|
164
|
+
button:disabled { opacity: .6; cursor: progress; }
|
|
165
|
+
@media (max-width: 520px) {
|
|
166
|
+
.bar { gap: 7px; padding: 0 8px; font-size: 11.5px; }
|
|
167
|
+
.sign { display: none; }
|
|
168
|
+
}
|
|
169
|
+
`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The "you are impersonating" warning.
|
|
174
|
+
*
|
|
175
|
+
* The page becomes a sheet on a warm striped backdrop: a bar across the top
|
|
176
|
+
* names the session, and the page itself is scaled down to sit inside the
|
|
177
|
+
* remaining frame with rounded corners. Scaled, not pushed: growing the
|
|
178
|
+
* document moved the bottom of an app-shell layout off-screen, while a
|
|
179
|
+
* uniform scale keeps the aspect ratio and every pixel reachable, and
|
|
180
|
+
* `100vh`/`100dvh` layouts still resolve to exactly the frame.
|
|
181
|
+
*
|
|
182
|
+
* The bar lives in a closed shadow root, so app styles cannot hide or
|
|
183
|
+
* restyle it; the framing is written as `!important` inline styles on
|
|
184
|
+
* `<html>` / `<body>` (nothing in a stylesheet outranks that) and the
|
|
185
|
+
* previous inline styles are restored on unmount.
|
|
186
|
+
*
|
|
187
|
+
* Every piece of text goes through `textContent`.
|
|
188
|
+
*/
|
|
189
|
+
export class ImpersonationBanner {
|
|
190
|
+
private host: HTMLElement | null = null;
|
|
191
|
+
private text: HTMLElement | null = null;
|
|
192
|
+
/** `style` attributes as the page had them before the frame. */
|
|
193
|
+
private saved: { html: string | null; body: string | null } | null = null;
|
|
194
|
+
private theme: ResolvedTheme;
|
|
195
|
+
private mode: 'default' | 'custom' | 'none';
|
|
196
|
+
/** Grace timer for `custom` mode. */
|
|
197
|
+
private fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
198
|
+
/** The app said its own banner is on screen for this impersonation. */
|
|
199
|
+
private acknowledged = false;
|
|
200
|
+
private warnedAboutNone = false;
|
|
201
|
+
private onResize: (() => void) | null = null;
|
|
202
|
+
|
|
203
|
+
constructor(
|
|
204
|
+
private onStop: () => Promise<void>,
|
|
205
|
+
config: ImpersonationBannerConfig = {}
|
|
206
|
+
) {
|
|
207
|
+
this.mode = config.mode ?? 'default';
|
|
208
|
+
this.theme = resolveTheme(config.theme);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
static supported(): boolean {
|
|
212
|
+
return typeof document !== 'undefined' && typeof document.createElement === 'function';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The app's own banner is rendered. Cancels the `custom`-mode fallback, and
|
|
217
|
+
* removes the built-in banner if the fallback already fired (a slow first
|
|
218
|
+
* paint, say). No-op in the other modes.
|
|
219
|
+
*/
|
|
220
|
+
acknowledge(): void {
|
|
221
|
+
this.acknowledged = true;
|
|
222
|
+
this.clearFallback();
|
|
223
|
+
if (this.mode === 'custom') this.teardown();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
update(info: ImpersonationInfo | null): void {
|
|
227
|
+
if (!ImpersonationBanner.supported()) return;
|
|
228
|
+
if (!info) {
|
|
229
|
+
this.acknowledged = false;
|
|
230
|
+
this.unmount();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (this.mode === 'none') {
|
|
234
|
+
// The app owns the warning entirely. Said once, loudly, because the
|
|
235
|
+
// page now looks like an ordinary session of someone else's account.
|
|
236
|
+
if (!this.warnedAboutNone) {
|
|
237
|
+
this.warnedAboutNone = true;
|
|
238
|
+
// oxlint-disable-next-line no-console
|
|
239
|
+
console.warn(
|
|
240
|
+
'[sp00ky] impersonation is active and impersonationBanner.mode is "none": ' +
|
|
241
|
+
"nothing marks this page as another user's session."
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (this.mode === 'custom' && !this.acknowledged) {
|
|
247
|
+
// Give the app its grace period, then warn anyway.
|
|
248
|
+
if (!this.fallbackTimer && !this.host) {
|
|
249
|
+
this.fallbackTimer = setTimeout(() => {
|
|
250
|
+
this.fallbackTimer = null;
|
|
251
|
+
if (!this.acknowledged) this.render(info);
|
|
252
|
+
}, CUSTOM_BANNER_GRACE_MS);
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (this.mode === 'custom') return;
|
|
257
|
+
this.render(info);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
unmount(): void {
|
|
261
|
+
this.clearFallback();
|
|
262
|
+
this.teardown();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Mount (if needed) and write the current identities into the bar. */
|
|
266
|
+
private render(info: ImpersonationInfo): void {
|
|
267
|
+
this.mount();
|
|
268
|
+
const label = this.theme.label(info);
|
|
269
|
+
const text = this.text!;
|
|
270
|
+
text.textContent = '';
|
|
271
|
+
text.append(...labelNodes(label));
|
|
272
|
+
text.title = `${label.replace(/\*\*/g, '')}. Every write is audited.`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Remove the bar and put the page back exactly as it was. */
|
|
276
|
+
private teardown(): void {
|
|
277
|
+
this.host?.remove();
|
|
278
|
+
this.host = this.text = null;
|
|
279
|
+
if (typeof document === 'undefined') return;
|
|
280
|
+
if (this.onResize) {
|
|
281
|
+
window.removeEventListener('resize', this.onResize);
|
|
282
|
+
this.onResize = null;
|
|
283
|
+
}
|
|
284
|
+
if (this.saved) {
|
|
285
|
+
restoreStyle(document.documentElement, this.saved.html);
|
|
286
|
+
restoreStyle(document.body, this.saved.body);
|
|
287
|
+
this.saved = null;
|
|
288
|
+
}
|
|
289
|
+
document.documentElement?.style.setProperty('--sp00ky-impersonation-banner-height', '0px');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private clearFallback(): void {
|
|
293
|
+
if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
|
|
294
|
+
this.fallbackTimer = null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private mount(): void {
|
|
298
|
+
if (this.host?.isConnected) return;
|
|
299
|
+
const host = document.createElement(HOST_TAG);
|
|
300
|
+
const root = host.attachShadow({ mode: 'closed' });
|
|
301
|
+
const style = document.createElement('style');
|
|
302
|
+
style.textContent = bannerCss(this.theme);
|
|
303
|
+
|
|
304
|
+
const bar = document.createElement('div');
|
|
305
|
+
bar.className = 'bar';
|
|
306
|
+
bar.setAttribute('role', 'alert');
|
|
307
|
+
const sign = document.createElement('span');
|
|
308
|
+
sign.className = 'sign';
|
|
309
|
+
sign.textContent = '!';
|
|
310
|
+
sign.setAttribute('aria-hidden', 'true');
|
|
311
|
+
const text = document.createElement('span');
|
|
312
|
+
text.className = 'text';
|
|
313
|
+
const button = document.createElement('button');
|
|
314
|
+
button.type = 'button';
|
|
315
|
+
button.textContent = this.theme.stopLabel;
|
|
316
|
+
button.addEventListener('click', () => {
|
|
317
|
+
button.disabled = true;
|
|
318
|
+
this.onStop().finally(() => {
|
|
319
|
+
button.disabled = false;
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
bar.append(sign, text, button);
|
|
323
|
+
root.append(style, bar);
|
|
324
|
+
// Hosted on `<html>`, not `<body>`: the body is the framed sheet, and a
|
|
325
|
+
// bar inside it would be scaled and clipped along with the page.
|
|
326
|
+
document.documentElement.appendChild(host);
|
|
327
|
+
|
|
328
|
+
this.frame();
|
|
329
|
+
this.host = host;
|
|
330
|
+
this.text = text;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Turn `<body>` into the framed sheet.
|
|
335
|
+
*
|
|
336
|
+
* `zoom`, not `transform`: it scales at layout level, so `100vh` children
|
|
337
|
+
* resolve to the frame instead of overflowing it, scrollbars stay correct,
|
|
338
|
+
* and `position: fixed` keeps meaning the viewport (a transform would make
|
|
339
|
+
* the body a containing block and every fixed header would scroll away).
|
|
340
|
+
* The body's CSS width is widened by `1 / scale` so the scaled box still
|
|
341
|
+
* fills the frame.
|
|
342
|
+
*/
|
|
343
|
+
private frame(): void {
|
|
344
|
+
const root = document.documentElement;
|
|
345
|
+
const body = document.body;
|
|
346
|
+
if (!root || !body) return;
|
|
347
|
+
const { heightPx, insetPx, radiusPx, noPageShift } = this.theme;
|
|
348
|
+
// Published even when the framing is off, so an app doing its own offset
|
|
349
|
+
// has one number to read either way.
|
|
350
|
+
root.style.setProperty('--sp00ky-impersonation-banner-height', `${heightPx}px`);
|
|
351
|
+
if (noPageShift) return;
|
|
352
|
+
|
|
353
|
+
this.saved = { html: root.getAttribute('style'), body: body.getAttribute('style') };
|
|
354
|
+
const surface = resolveSurface(root, body);
|
|
355
|
+
// The root paints the backdrop: it is the one box that is guaranteed to
|
|
356
|
+
// be behind the page's sheet. `overflow: hidden` keeps the document
|
|
357
|
+
// itself from scrolling — the sheet is fixed and scrolls internally.
|
|
358
|
+
root.style.setProperty('background', this.theme.background, 'important');
|
|
359
|
+
root.style.setProperty('overflow', 'hidden', 'important');
|
|
360
|
+
|
|
361
|
+
const apply = () => {
|
|
362
|
+
const { scale, width } = frameMetrics(
|
|
363
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
364
|
+
{ heightPx, insetPx }
|
|
365
|
+
);
|
|
366
|
+
body.style.setProperty('position', 'fixed', 'important');
|
|
367
|
+
body.style.setProperty('top', `${heightPx}px`, 'important');
|
|
368
|
+
body.style.setProperty('left', `${insetPx}px`, 'important');
|
|
369
|
+
body.style.setProperty('margin', '0', 'important');
|
|
370
|
+
body.style.setProperty('zoom', String(scale), 'important');
|
|
371
|
+
body.style.setProperty('width', `${width}px`, 'important');
|
|
372
|
+
body.style.setProperty('height', '100vh', 'important');
|
|
373
|
+
body.style.setProperty('overflow', 'auto', 'important');
|
|
374
|
+
body.style.setProperty('background-color', surface, 'important');
|
|
375
|
+
body.style.setProperty('box-shadow', '0 6px 24px rgba(69, 26, 3, .28)', 'important');
|
|
376
|
+
if (radiusPx > 0) {
|
|
377
|
+
const radius = `${radiusPx / scale}px`;
|
|
378
|
+
body.style.setProperty('border-radius', radius, 'important');
|
|
379
|
+
// Clips the page's own fixed layers (a loading overlay, a modal
|
|
380
|
+
// backdrop) to the sheet, so nothing paints over the frame.
|
|
381
|
+
body.style.setProperty('clip-path', `inset(0 round ${radius})`, 'important');
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
apply();
|
|
385
|
+
this.onResize = apply;
|
|
386
|
+
window.addEventListener('resize', apply);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Put an element's inline styles back exactly as they were. An element that
|
|
391
|
+
* had no `style` attribute (or an empty one) ends up without one again. */
|
|
392
|
+
function restoreStyle(el: HTMLElement | null, saved: string | null): void {
|
|
393
|
+
if (!el) return;
|
|
394
|
+
if (saved) el.setAttribute('style', saved);
|
|
395
|
+
else el.removeAttribute('style');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The colour to paint the sheet with: whatever the page already paints (body
|
|
400
|
+
* first, then html), and failing that the canvas colour for the viewer's
|
|
401
|
+
* colour scheme. A transparent sheet would show the backdrop through the
|
|
402
|
+
* whole page.
|
|
403
|
+
*/
|
|
404
|
+
function resolveSurface(root: HTMLElement, body: HTMLElement): string {
|
|
405
|
+
for (const el of [body, root]) {
|
|
406
|
+
const color = getComputedStyle(el).backgroundColor;
|
|
407
|
+
if (color && !isTransparent(color)) return color;
|
|
408
|
+
}
|
|
409
|
+
const dark =
|
|
410
|
+
typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
|
|
411
|
+
return dark ? '#111111' : '#ffffff';
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function isTransparent(color: string): boolean {
|
|
415
|
+
if (color === 'transparent') return true;
|
|
416
|
+
const parts = color.match(/[\d.]+/g);
|
|
417
|
+
return !!parts && parts.length === 4 && Number(parts[3]) === 0;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The label as DOM nodes, emphasising the runs between `**` markers. The
|
|
422
|
+
* label is a plain string (an app's own function can return anything), so it
|
|
423
|
+
* never reaches the DOM as markup.
|
|
424
|
+
*/
|
|
425
|
+
function labelNodes(label: string): Node[] {
|
|
426
|
+
return labelSegments(label).map(({ text, bold }) => {
|
|
427
|
+
if (!bold) return document.createTextNode(text);
|
|
428
|
+
const b = document.createElement('b');
|
|
429
|
+
b.textContent = text;
|
|
430
|
+
return b;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The label split into plain and emphasised runs. Pure, so it is unit tested. */
|
|
435
|
+
function labelSegments(label: string): Array<{ text: string; bold: boolean }> {
|
|
436
|
+
return label
|
|
437
|
+
.split(/\*\*(.+?)\*\*/g)
|
|
438
|
+
.map((text, i) => ({ text, bold: i % 2 === 1 }))
|
|
439
|
+
.filter((part) => part.text !== '');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Exported for unit tests only. */
|
|
443
|
+
export const __test = { labelSegments, resolveTheme };
|