@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.321
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 +2034 -137
- package/dist/index.js +15236 -2467
- package/dist/live.d.ts +141 -0
- package/dist/live.js +733 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +365 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +379 -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 +1821 -38
- package/dist/worker-edges.js +470 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -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 +135 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +180 -0
- package/src/boot/boot.saga.ts +142 -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.test.ts +60 -0
- package/src/client/services.ts +418 -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 +163 -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 +501 -0
- package/src/live/index.ts +1039 -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 +169 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +466 -19
- 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 +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -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 +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +337 -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/list-ref-live.integration.test.ts +203 -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 +311 -0
- package/src/query/register.saga.ts +229 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +112 -0
- package/src/query/sql.ts +149 -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 +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +273 -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 +145 -0
- package/src/services/database/sqlite-plan-sql.ts +182 -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 +261 -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 +783 -68
- 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 +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +175 -0
- package/src/state/selectors.ts +222 -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 +97 -0
- package/src/sync/connection.saga.ts +89 -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 +663 -0
- package/src/sync/policy.ts +515 -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 +78 -12
- 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 +3 -2
- 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,377 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AuthService } from './index';
|
|
3
|
+
import {
|
|
4
|
+
decodeTokenClaims,
|
|
5
|
+
impersonationFromToken,
|
|
6
|
+
ImpersonationTimers,
|
|
7
|
+
ORIGIN_TOKEN_KEY,
|
|
8
|
+
renewDelayMs,
|
|
9
|
+
SESSION_POLL_MS,
|
|
10
|
+
} from './impersonation';
|
|
11
|
+
|
|
12
|
+
function jwt(claims: Record<string, unknown>): string {
|
|
13
|
+
const b64 = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
14
|
+
return `header.${b64}.signature`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ADMIN = jwt({ AC: 'account', ID: 'user:alice', exp: 9_999_999_999 });
|
|
18
|
+
const impToken = (n = 1, extra: Record<string, unknown> = {}) =>
|
|
19
|
+
jwt({
|
|
20
|
+
AC: '_00_impersonate',
|
|
21
|
+
ID: 'user:bob',
|
|
22
|
+
exp: Math.floor(Date.now() / 1000) + 900,
|
|
23
|
+
spky_imp: '_00_impersonation:s1',
|
|
24
|
+
spky_admin: 'user:alice',
|
|
25
|
+
n,
|
|
26
|
+
...extra,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const silentLogger = () =>
|
|
30
|
+
({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: () => silentLogger() }) as any;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A fake server: tracks which token the socket is authenticated with, and
|
|
34
|
+
* answers the handful of statements the auth flow sends. `ended` simulates a
|
|
35
|
+
* stop or revocation made elsewhere.
|
|
36
|
+
*/
|
|
37
|
+
function makeServer(opts: { refuse?: Set<string>; unreachable?: boolean } = {}) {
|
|
38
|
+
const state = { socketToken: null as string | null, ended: false, stops: [] as unknown[], renews: 0 };
|
|
39
|
+
const idFor = (token: string | null) => (token ? decodeTokenClaims(token).userId : null);
|
|
40
|
+
const query = vi.fn(async (sql: string, vars?: Record<string, unknown>) => {
|
|
41
|
+
if (opts.unreachable && sql.includes('fn::_00_impersonate::stop')) {
|
|
42
|
+
throw new Error('There was a problem with the underlying connection');
|
|
43
|
+
}
|
|
44
|
+
if (sql.includes('fn::_00_impersonate::start')) return [{ token: impToken(), session: '_00_impersonation:s1' }];
|
|
45
|
+
if (sql.includes('fn::_00_impersonate::renew')) {
|
|
46
|
+
if (state.ended) throw new Error('Impersonation session has ended.');
|
|
47
|
+
state.renews++;
|
|
48
|
+
return [{ token: impToken(state.renews + 1) }];
|
|
49
|
+
}
|
|
50
|
+
if (sql.includes('fn::_00_impersonate::current')) return [state.ended ? null : { session: '_00_impersonation:s1' }];
|
|
51
|
+
if (sql.includes('fn::_00_impersonate::stop')) {
|
|
52
|
+
state.stops.push(vars?.session);
|
|
53
|
+
state.ended = true;
|
|
54
|
+
return [null];
|
|
55
|
+
}
|
|
56
|
+
if (sql.includes('fn::_00_impersonate::list_users')) return [[{ id: 'user:bob', is_admin: false }]];
|
|
57
|
+
if (sql.includes('$auth.id')) return [[{ id: idFor(state.socketToken) }]];
|
|
58
|
+
throw new Error(`unexpected statement ${sql}`);
|
|
59
|
+
});
|
|
60
|
+
const authenticate = vi.fn(async (token: string) => {
|
|
61
|
+
if (opts.refuse?.has(token) || (state.ended && decodeTokenClaims(token).impersonation)) {
|
|
62
|
+
throw new Error('Impersonation session is no longer valid.');
|
|
63
|
+
}
|
|
64
|
+
state.socketToken = token;
|
|
65
|
+
});
|
|
66
|
+
return { state, query, authenticate };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeAuth(server: ReturnType<typeof makeServer>, stored: Record<string, string> = {}) {
|
|
70
|
+
const store = new Map<string, unknown>(Object.entries(stored));
|
|
71
|
+
const persistence = {
|
|
72
|
+
get: vi.fn(async (k: string) => store.get(k) ?? null),
|
|
73
|
+
set: vi.fn(async (k: string, v: unknown) => void store.set(k, v)),
|
|
74
|
+
remove: vi.fn(async (k: string) => void store.delete(k)),
|
|
75
|
+
} as any;
|
|
76
|
+
const remote = {
|
|
77
|
+
query: server.query,
|
|
78
|
+
setAuthToken: vi.fn(),
|
|
79
|
+
getClient: () => ({ authenticate: server.authenticate, invalidate: vi.fn(async () => undefined) }),
|
|
80
|
+
} as any;
|
|
81
|
+
const auth = new AuthService({} as any, remote, persistence, silentLogger());
|
|
82
|
+
return { auth, store, remote };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function signedInAdmin(server = makeServer()) {
|
|
86
|
+
const ctx = makeAuth(server);
|
|
87
|
+
await ctx.auth.check(ADMIN);
|
|
88
|
+
return { ...ctx, server };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
|
|
93
|
+
});
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
vi.useRealTimers();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('token claims', () => {
|
|
99
|
+
it('recognises only impersonation tokens', () => {
|
|
100
|
+
expect(impersonationFromToken(ADMIN)).toBeNull();
|
|
101
|
+
expect(impersonationFromToken(null)).toBeNull();
|
|
102
|
+
expect(impersonationFromToken('garbage')).toBeNull();
|
|
103
|
+
// A regular token cannot pose as one by carrying the claim.
|
|
104
|
+
expect(impersonationFromToken(jwt({ AC: 'account', ID: 'user:x', spky_imp: 's', spky_admin: 'a' }))).toBeNull();
|
|
105
|
+
const info = impersonationFromToken(impToken());
|
|
106
|
+
expect(info).toMatchObject({ session: '_00_impersonation:s1', target: 'user:bob', admin: 'user:alice' });
|
|
107
|
+
expect(info?.tokenExpiresAt).toBeInstanceOf(Date);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('renews at 80% of the remaining life, never later than expiry', () => {
|
|
111
|
+
expect(renewDelayMs(1_000, 0)).toBe(800_000);
|
|
112
|
+
expect(renewDelayMs(10, 0)).toBe(8_000);
|
|
113
|
+
expect(renewDelayMs(4, 0)).toBe(0);
|
|
114
|
+
expect(renewDelayMs(1, 5_000)).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('impersonate / stopImpersonating', () => {
|
|
119
|
+
it('switches the session to the target and keeps the admin token aside', async () => {
|
|
120
|
+
const { auth, store, server } = await signedInAdmin();
|
|
121
|
+
const changes: (string | null)[] = [];
|
|
122
|
+
auth.subscribeImpersonation((i) => changes.push(i?.target ?? null));
|
|
123
|
+
|
|
124
|
+
const info = await auth.impersonate('user:bob', 'support ticket');
|
|
125
|
+
|
|
126
|
+
expect(info.target).toBe('user:bob');
|
|
127
|
+
expect(auth.currentUser.id).toBe('user:bob');
|
|
128
|
+
expect(auth.access).toBe('_00_impersonate');
|
|
129
|
+
expect(store.get(ORIGIN_TOKEN_KEY)).toBe(ADMIN);
|
|
130
|
+
expect(server.state.socketToken).toBe(auth.token);
|
|
131
|
+
expect(changes).toEqual([null, 'user:bob']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('refuses to nest', async () => {
|
|
135
|
+
const { auth } = await signedInAdmin();
|
|
136
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
137
|
+
await expect(auth.impersonate('user:carol', 'again')).rejects.toThrow('Already impersonating');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('stays the admin when the server refuses the issued token', async () => {
|
|
141
|
+
const server = makeServer();
|
|
142
|
+
const refused = impToken();
|
|
143
|
+
server.query.mockImplementationOnce(async () => [{ token: refused }]);
|
|
144
|
+
server.authenticate.mockImplementation(async (token: string) => {
|
|
145
|
+
if (token === refused) throw new Error('Impersonation session is no longer valid.');
|
|
146
|
+
server.state.socketToken = token;
|
|
147
|
+
});
|
|
148
|
+
const { auth, store } = await signedInAdmin(server);
|
|
149
|
+
|
|
150
|
+
await expect(auth.impersonate('user:carol', 'support ticket')).rejects.toThrow('no longer valid');
|
|
151
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
152
|
+
expect(auth.impersonation).toBeNull();
|
|
153
|
+
expect(store.has(ORIGIN_TOKEN_KEY)).toBe(false);
|
|
154
|
+
expect(server.state.socketToken).toBe(ADMIN);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('stop ends the server session and returns to the admin', async () => {
|
|
158
|
+
const { auth, store, server } = await signedInAdmin();
|
|
159
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
160
|
+
|
|
161
|
+
await auth.stopImpersonating();
|
|
162
|
+
|
|
163
|
+
expect(server.state.stops).toEqual(['_00_impersonation:s1']);
|
|
164
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
165
|
+
expect(auth.token).toBe(ADMIN);
|
|
166
|
+
expect(auth.impersonation).toBeNull();
|
|
167
|
+
expect(store.get('sp00ky_auth_token')).toBe(ADMIN);
|
|
168
|
+
expect(store.has(ORIGIN_TOKEN_KEY)).toBe(false);
|
|
169
|
+
expect(auth.consumeEndedImpersonation()).toBe('user:bob');
|
|
170
|
+
expect(auth.consumeEndedImpersonation()).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('stop still leaves the target when the server is unreachable', async () => {
|
|
174
|
+
const { auth } = await signedInAdmin(makeServer({ unreachable: true }));
|
|
175
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
176
|
+
|
|
177
|
+
await auth.stopImpersonating();
|
|
178
|
+
|
|
179
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
180
|
+
expect(auth.impersonation).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('signs out entirely when the admin token is refused on the way back', async () => {
|
|
184
|
+
const server = makeServer({ refuse: new Set([ADMIN]) });
|
|
185
|
+
const { auth, store } = makeAuth(server, {
|
|
186
|
+
sp00ky_auth_token: impToken(),
|
|
187
|
+
[ORIGIN_TOKEN_KEY]: ADMIN,
|
|
188
|
+
});
|
|
189
|
+
await auth.check();
|
|
190
|
+
expect(auth.impersonation?.target).toBe('user:bob');
|
|
191
|
+
|
|
192
|
+
await auth.stopImpersonating();
|
|
193
|
+
|
|
194
|
+
expect(auth.isAuthenticated).toBe(false);
|
|
195
|
+
expect(auth.currentUser).toBeNull();
|
|
196
|
+
expect(store.has('sp00ky_auth_token')).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('a revoked impersonation token at boot returns to the admin instead of signing out', async () => {
|
|
200
|
+
const server = makeServer();
|
|
201
|
+
server.state.ended = true;
|
|
202
|
+
const { auth } = makeAuth(server, { sp00ky_auth_token: impToken(), [ORIGIN_TOKEN_KEY]: ADMIN });
|
|
203
|
+
|
|
204
|
+
await auth.check();
|
|
205
|
+
|
|
206
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
207
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('an impersonation token with no admin token behind it signs out', async () => {
|
|
211
|
+
const server = makeServer();
|
|
212
|
+
server.state.ended = true;
|
|
213
|
+
const { auth } = makeAuth(server, { sp00ky_auth_token: impToken() });
|
|
214
|
+
|
|
215
|
+
await auth.check();
|
|
216
|
+
|
|
217
|
+
expect(auth.isAuthenticated).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('signOut while impersonating ends the session and forgets the admin token', async () => {
|
|
221
|
+
const { auth, store, server } = await signedInAdmin();
|
|
222
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
223
|
+
|
|
224
|
+
await auth.signOut();
|
|
225
|
+
|
|
226
|
+
expect(server.state.stops).toEqual(['_00_impersonation:s1']);
|
|
227
|
+
expect(auth.isAuthenticated).toBe(false);
|
|
228
|
+
expect(store.has(ORIGIN_TOKEN_KEY)).toBe(false);
|
|
229
|
+
expect(store.has('sp00ky_auth_token')).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('search goes through the admin-only function', async () => {
|
|
233
|
+
const { auth } = await signedInAdmin();
|
|
234
|
+
expect(await auth.searchImpersonationTargets('bo')).toEqual([{ id: 'user:bob', is_admin: false }]);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('timers', () => {
|
|
239
|
+
it('renews the token before it expires', async () => {
|
|
240
|
+
const { auth, server } = await signedInAdmin();
|
|
241
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
242
|
+
const first = auth.token;
|
|
243
|
+
|
|
244
|
+
await vi.advanceTimersByTimeAsync(800 * 1000);
|
|
245
|
+
|
|
246
|
+
expect(server.state.renews).toBe(1);
|
|
247
|
+
expect(auth.token).not.toBe(first);
|
|
248
|
+
expect(server.state.socketToken).toBe(auth.token);
|
|
249
|
+
expect(auth.impersonation?.target).toBe('user:bob');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('a session ended elsewhere drops the client back to the admin within a poll', async () => {
|
|
253
|
+
const { auth, server } = await signedInAdmin();
|
|
254
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
255
|
+
server.state.ended = true;
|
|
256
|
+
|
|
257
|
+
await vi.advanceTimersByTimeAsync(SESSION_POLL_MS);
|
|
258
|
+
|
|
259
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
260
|
+
expect(auth.impersonation).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('a renewal in flight when stop begins does not put the target back on the socket', async () => {
|
|
264
|
+
const { auth, server, store } = await signedInAdmin();
|
|
265
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
266
|
+
let releaseRenew!: () => void;
|
|
267
|
+
const held = new Promise<void>((r) => (releaseRenew = r));
|
|
268
|
+
const answer = server.query.getMockImplementation()!;
|
|
269
|
+
server.query.mockImplementation(async (sql: string, vars?: Record<string, unknown>) => {
|
|
270
|
+
if (sql.includes('fn::_00_impersonate::renew')) await held;
|
|
271
|
+
return answer(sql, vars);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const renewal = auth.renewImpersonation();
|
|
275
|
+
const stop = auth.stopImpersonating();
|
|
276
|
+
releaseRenew();
|
|
277
|
+
await Promise.all([renewal, stop]);
|
|
278
|
+
|
|
279
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
280
|
+
expect(auth.token).toBe(ADMIN);
|
|
281
|
+
expect(server.state.socketToken).toBe(ADMIN);
|
|
282
|
+
expect(store.get('sp00ky_auth_token')).toBe(ADMIN);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('keeps going through network failures on the poll', async () => {
|
|
286
|
+
let calls = 0;
|
|
287
|
+
const host = {
|
|
288
|
+
current: () => ({ session: 's', target: 't', admin: 'a', tokenExpiresAt: null }),
|
|
289
|
+
renew: vi.fn(),
|
|
290
|
+
stillActive: vi.fn(async () => {
|
|
291
|
+
calls++;
|
|
292
|
+
throw new Error('There was a problem with the underlying connection');
|
|
293
|
+
}),
|
|
294
|
+
endedRemotely: vi.fn(),
|
|
295
|
+
isNetworkError: () => true,
|
|
296
|
+
};
|
|
297
|
+
const timers = new ImpersonationTimers(host);
|
|
298
|
+
timers.sync();
|
|
299
|
+
await vi.advanceTimersByTimeAsync(SESSION_POLL_MS * 2);
|
|
300
|
+
expect(calls).toBe(2);
|
|
301
|
+
expect(host.endedRemotely).not.toHaveBeenCalled();
|
|
302
|
+
timers.clear();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('banner configuration', () => {
|
|
307
|
+
it('builds CSS from the theme, defaults included', async () => {
|
|
308
|
+
const { bannerCss, BANNER_BACKGROUND } = await import('./impersonation-banner');
|
|
309
|
+
// `bannerCss` takes a resolved theme; the client resolves it, so this
|
|
310
|
+
// pins the rendered output for a fully specified one.
|
|
311
|
+
const css = bannerCss({
|
|
312
|
+
heightPx: 60,
|
|
313
|
+
insetPx: 16,
|
|
314
|
+
radiusPx: 0,
|
|
315
|
+
background: 'var(--brand-warning)',
|
|
316
|
+
text: '#222',
|
|
317
|
+
accent: '#f00',
|
|
318
|
+
accentText: '#fff',
|
|
319
|
+
stopLabel: 'Leave',
|
|
320
|
+
noPageShift: true,
|
|
321
|
+
label: () => 'x',
|
|
322
|
+
});
|
|
323
|
+
expect(css).toContain('height: 60px');
|
|
324
|
+
expect(css).toContain('background-image: var(--brand-warning)');
|
|
325
|
+
expect(css).toContain('background: #f00; color: #fff');
|
|
326
|
+
expect(css).not.toContain(BANNER_BACKGROUND);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('scales the page to the frame without distorting it', async () => {
|
|
330
|
+
const { frameMetrics } = await import('./impersonation-banner');
|
|
331
|
+
const theme = { heightPx: 40, insetPx: 10 };
|
|
332
|
+
|
|
333
|
+
const desktop = frameMetrics({ width: 1440, height: 900 }, theme);
|
|
334
|
+
// 900 - 40 - 10 = 850 of 900: one factor for both axes, so the aspect
|
|
335
|
+
// ratio is untouched and nothing is cropped.
|
|
336
|
+
expect(desktop.scale).toBeCloseTo(850 / 900);
|
|
337
|
+
expect(desktop.contentHeight).toBe(850);
|
|
338
|
+
// A zoomed box occupies width * scale, so the layout box is widened to
|
|
339
|
+
// still fill the frame exactly.
|
|
340
|
+
expect(desktop.width * desktop.scale).toBeCloseTo(1440 - 20);
|
|
341
|
+
|
|
342
|
+
const phone = frameMetrics({ width: 390, height: 844 }, theme);
|
|
343
|
+
expect(phone.contentHeight).toBe(844 - 50);
|
|
344
|
+
expect(phone.width * phone.scale).toBeCloseTo(370);
|
|
345
|
+
|
|
346
|
+
// Degenerate viewports (a hidden tab reporting 0) must not divide by zero
|
|
347
|
+
// or hand back a negative box.
|
|
348
|
+
const zero = frameMetrics({ width: 0, height: 0 }, theme);
|
|
349
|
+
expect(zero.scale).toBe(1);
|
|
350
|
+
expect(zero.contentHeight).toBeGreaterThan(0);
|
|
351
|
+
expect(zero.width).toBeGreaterThan(0);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('emphasises the label between ** markers instead of parsing markup', async () => {
|
|
355
|
+
const { __test } = await import('./impersonation-banner');
|
|
356
|
+
// A label is a plain string an app supplies, so markup in it must stay text.
|
|
357
|
+
expect(__test.labelSegments('Impersonating **user:bob** as user:alice')).toEqual([
|
|
358
|
+
{ text: 'Impersonating ', bold: false },
|
|
359
|
+
{ text: 'user:bob', bold: true },
|
|
360
|
+
{ text: ' as user:alice', bold: false },
|
|
361
|
+
]);
|
|
362
|
+
expect(__test.labelSegments('<img src=x onerror=alert(1)>')).toEqual([
|
|
363
|
+
{ text: '<img src=x onerror=alert(1)>', bold: false },
|
|
364
|
+
]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('resolves a partial theme over the defaults', async () => {
|
|
368
|
+
const { __test, BANNER_HEIGHT_PX } = await import('./impersonation-banner');
|
|
369
|
+
const theme = __test.resolveTheme({ insetPx: 0, stopLabel: 'Exit' });
|
|
370
|
+
expect(theme.insetPx).toBe(0);
|
|
371
|
+
expect(theme.stopLabel).toBe('Exit');
|
|
372
|
+
expect(theme.heightPx).toBe(BANNER_HEIGHT_PX);
|
|
373
|
+
expect(theme.label({ target: 'user:b', admin: 'user:a', session: 's', tokenExpiresAt: null })).toBe(
|
|
374
|
+
'Impersonating **user:b** as user:a'
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin impersonation, client side.
|
|
3
|
+
*
|
|
4
|
+
* The server does the enforcing (see `impersonation_remote.surql` in the CLI):
|
|
5
|
+
* an admin calls `fn::_00_impersonate::start`, receives a token for the
|
|
6
|
+
* `_00_impersonate` access method, and authenticates with it, so `$auth.id` is
|
|
7
|
+
* the target user. Everything here is state and timers around that:
|
|
8
|
+
*
|
|
9
|
+
* - the admin's own token is kept under {@link ORIGIN_TOKEN_KEY} so Stop (and a
|
|
10
|
+
* reload in between) can return to it;
|
|
11
|
+
* - the impersonation state is derived from the token's claims, never stored
|
|
12
|
+
* separately, so it cannot disagree with the session actually in use;
|
|
13
|
+
* - the token is re-signed before it expires, and the session is polled so a
|
|
14
|
+
* stop or revocation made elsewhere reaches an open WebSocket quickly.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Access method impersonation tokens are issued for. */
|
|
18
|
+
export const IMPERSONATION_ACCESS = '_00_impersonate';
|
|
19
|
+
|
|
20
|
+
/** Persistence key holding the admin's own token while impersonating. */
|
|
21
|
+
export const ORIGIN_TOKEN_KEY = 'sp00ky_auth_token_origin';
|
|
22
|
+
|
|
23
|
+
/** How often an active impersonation re-checks its session on the server. */
|
|
24
|
+
export const SESSION_POLL_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
export interface ImpersonationInfo {
|
|
27
|
+
/** `_00_impersonation:<id>` the session is bound to. */
|
|
28
|
+
session: string;
|
|
29
|
+
/** Record id being impersonated. */
|
|
30
|
+
target: string;
|
|
31
|
+
/** Record id of the admin. */
|
|
32
|
+
admin: string;
|
|
33
|
+
/** When the current token expires (it is renewed before then). */
|
|
34
|
+
tokenExpiresAt: Date | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TokenClaims {
|
|
38
|
+
access: string | null;
|
|
39
|
+
userId: string | null;
|
|
40
|
+
exp: number | null;
|
|
41
|
+
impersonation: { session: string; admin: string } | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
|
|
46
|
+
* server still enforces the token on every request; this is only so the client
|
|
47
|
+
* can act on what it already holds before a round trip completes.
|
|
48
|
+
*
|
|
49
|
+
* Returns nulls on any malformed input.
|
|
50
|
+
*/
|
|
51
|
+
export function decodeTokenClaims(token: string): TokenClaims {
|
|
52
|
+
const none: TokenClaims = { access: null, userId: null, exp: null, impersonation: null };
|
|
53
|
+
try {
|
|
54
|
+
const payload = token.split('.')[1];
|
|
55
|
+
if (!payload) return none;
|
|
56
|
+
let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
|
57
|
+
b64 += '='.repeat((4 - (b64.length % 4)) % 4);
|
|
58
|
+
const json =
|
|
59
|
+
typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
|
|
60
|
+
const claims = JSON.parse(json) as Record<string, unknown>;
|
|
61
|
+
const ac = claims.AC ?? claims.ac;
|
|
62
|
+
const id = claims.ID ?? claims.id;
|
|
63
|
+
const access = typeof ac === 'string' ? ac : null;
|
|
64
|
+
const session = claims.spky_imp;
|
|
65
|
+
const admin = claims.spky_admin;
|
|
66
|
+
return {
|
|
67
|
+
access,
|
|
68
|
+
userId: typeof id === 'string' ? id : null,
|
|
69
|
+
exp: typeof claims.exp === 'number' ? claims.exp : null,
|
|
70
|
+
impersonation:
|
|
71
|
+
access === IMPERSONATION_ACCESS && typeof session === 'string' && typeof admin === 'string'
|
|
72
|
+
? { session, admin }
|
|
73
|
+
: null,
|
|
74
|
+
};
|
|
75
|
+
} catch {
|
|
76
|
+
return none;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The impersonation a token represents, or null for a regular session. */
|
|
81
|
+
export function impersonationFromToken(token: string | null): ImpersonationInfo | null {
|
|
82
|
+
if (!token) return null;
|
|
83
|
+
const claims = decodeTokenClaims(token);
|
|
84
|
+
if (!claims.impersonation || !claims.userId) return null;
|
|
85
|
+
return {
|
|
86
|
+
session: claims.impersonation.session,
|
|
87
|
+
admin: claims.impersonation.admin,
|
|
88
|
+
target: claims.userId,
|
|
89
|
+
tokenExpiresAt: claims.exp === null ? null : new Date(claims.exp * 1000),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Same impersonation, for change detection (renewals only move the expiry). */
|
|
94
|
+
export function sameImpersonation(a: ImpersonationInfo | null, b: ImpersonationInfo | null): boolean {
|
|
95
|
+
if (a === null || b === null) return a === b;
|
|
96
|
+
return a.session === b.session && a.target === b.target && a.admin === b.admin;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* When to renew a token expiring at `expSecs`: at 80% of its remaining life,
|
|
101
|
+
* never sooner than 5s from now, and immediately once it is inside that.
|
|
102
|
+
*/
|
|
103
|
+
export function renewDelayMs(expSecs: number, nowMs: number): number {
|
|
104
|
+
const remaining = expSecs * 1000 - nowMs;
|
|
105
|
+
if (remaining <= 5_000) return 0;
|
|
106
|
+
return Math.max(5_000, Math.floor(remaining * 0.8));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ImpersonationTimerHost {
|
|
110
|
+
/** Current impersonation, or null. */
|
|
111
|
+
current(): ImpersonationInfo | null;
|
|
112
|
+
/** Re-sign the token. Rejects when the session has ended. */
|
|
113
|
+
renew(): Promise<void>;
|
|
114
|
+
/** Whether the server still considers the session live. */
|
|
115
|
+
stillActive(): Promise<boolean>;
|
|
116
|
+
/** Leave the impersonation because the server ended it. */
|
|
117
|
+
endedRemotely(reason: string): Promise<void>;
|
|
118
|
+
/** Whether a failure was a reachability problem (retry) rather than an answer. */
|
|
119
|
+
isNetworkError(error: unknown): boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The two timers an active impersonation needs. `sync()` is called whenever
|
|
124
|
+
* the auth state changes and arms or clears them to match.
|
|
125
|
+
*/
|
|
126
|
+
export class ImpersonationTimers {
|
|
127
|
+
private renewTimer: ReturnType<typeof setTimeout> | null = null;
|
|
128
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
129
|
+
private armedFor: string | null = null;
|
|
130
|
+
|
|
131
|
+
constructor(private host: ImpersonationTimerHost) {}
|
|
132
|
+
|
|
133
|
+
sync(): void {
|
|
134
|
+
const info = this.host.current();
|
|
135
|
+
if (!info) {
|
|
136
|
+
this.clear();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const key = `${info.session}@${info.tokenExpiresAt?.getTime() ?? 'none'}`;
|
|
140
|
+
if (key === this.armedFor) return;
|
|
141
|
+
this.clear();
|
|
142
|
+
this.armedFor = key;
|
|
143
|
+
if (info.tokenExpiresAt) {
|
|
144
|
+
const delay = renewDelayMs(info.tokenExpiresAt.getTime() / 1000, Date.now());
|
|
145
|
+
this.renewTimer = setTimeout(() => void this.renew(), delay);
|
|
146
|
+
}
|
|
147
|
+
this.pollTimer = setInterval(() => void this.poll(), SESSION_POLL_MS);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
clear(): void {
|
|
151
|
+
if (this.renewTimer) clearTimeout(this.renewTimer);
|
|
152
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
153
|
+
this.renewTimer = null;
|
|
154
|
+
this.pollTimer = null;
|
|
155
|
+
this.armedFor = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async renew(): Promise<void> {
|
|
159
|
+
this.renewTimer = null;
|
|
160
|
+
try {
|
|
161
|
+
await this.host.renew();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (this.host.isNetworkError(error)) {
|
|
164
|
+
// Try again shortly; the token may still have some life left.
|
|
165
|
+
this.renewTimer = setTimeout(() => void this.renew(), 10_000);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
await this.host.endedRemotely('renew refused');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async poll(): Promise<void> {
|
|
173
|
+
try {
|
|
174
|
+
if (!(await this.host.stillActive())) await this.host.endedRemotely('session ended');
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (!this.host.isNetworkError(error)) await this.host.endedRemotely('session check refused');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|