@spooky-sync/core 0.0.1-canary.315 → 0.0.1-canary.317
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 +8 -0
- package/dist/index.js +184 -19
- package/dist/live.js +1 -1
- package/dist/pure.d.ts +9 -1
- package/dist/pure.js +2 -2
- package/dist/types.d.ts +35 -0
- package/dist/worker-edges.js +17 -1
- package/package.json +3 -3
- package/src/boot/boot.saga.test.ts +22 -0
- package/src/boot/boot.saga.ts +14 -3
- package/src/client/services.test.ts +51 -0
- package/src/client/services.ts +39 -5
- package/src/kernel/effects.ts +2 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +22 -0
- package/src/modules/auth/index.ts +62 -2
- package/src/query/register.saga.test.ts +18 -0
- package/src/query/register.saga.ts +5 -2
- package/src/query/sql.test.ts +18 -0
- package/src/query/sql.ts +19 -0
- package/src/services/database/remote.ts +42 -0
- package/src/state/lifecycle.test.ts +26 -3
- package/src/state/lifecycle.ts +19 -6
- package/src/state/reducers.test.ts +1 -1
- package/src/state/selectors.test.ts +8 -0
- package/src/state/selectors.ts +8 -2
package/src/boot/boot.saga.ts
CHANGED
|
@@ -43,8 +43,9 @@ export function* boot(env: SagaEnv, opts: BootOptions): Saga<void> {
|
|
|
43
43
|
yield fx.dispatch({ type: 'PrimeCircuit' });
|
|
44
44
|
|
|
45
45
|
const restoredUserId = (yield fx.service('auth.restoreSession')) as string | null;
|
|
46
|
-
const salt = (yield fx.id('salt')) as string;
|
|
47
46
|
const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
|
|
47
|
+
// A reload keeps the salt its tab had, so registrations land on warm views.
|
|
48
|
+
const salt = (((yield fx.service('salt.take', authId)) as string | null) ?? (yield fx.id('salt'))) as string;
|
|
48
49
|
yield fx.state.update(R.setIdentity({ sessionId: salt, userId: restoredUserId, saltUserId: authId }));
|
|
49
50
|
yield fx.service('crdt.setSessionId', salt);
|
|
50
51
|
if (restoredUserId) yield fx.service('ssp.setSessionAuth', authId, (yield fx.service('auth.access')) as string | null);
|
|
@@ -115,9 +116,19 @@ export function* versionsPrimed(entries: Array<readonly [string, number]>): Saga
|
|
|
115
116
|
yield fx.state.update(R.setVersions(entries));
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* The page is going away for good: leave the salt for a reload of this tab,
|
|
121
|
+
* then hand this tab's views back to the server. The release is deferred
|
|
122
|
+
* server-side (`fn::query::leave`), so a reload that re-registers within the
|
|
123
|
+
* grace finds its views still warm.
|
|
124
|
+
*/
|
|
119
125
|
export function* pageHide(): Saga<void> {
|
|
120
|
-
const ids = (yield fx.state.read((s) => [
|
|
126
|
+
const [salt, saltUserId, ids] = (yield fx.state.read((s) => [
|
|
127
|
+
s.sessionId,
|
|
128
|
+
s.saltUserId,
|
|
129
|
+
[...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered').map((e) => e.def.id),
|
|
130
|
+
])) as [string | null, string | null, unknown[]];
|
|
131
|
+
if (salt) yield fx.service('salt.leave', salt, saltUserId);
|
|
121
132
|
if (ids.length > 0) yield fx.service('remote.releaseViews', ids);
|
|
122
133
|
}
|
|
123
134
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { leaveReloadSalt, takeReloadSalt } from './services';
|
|
3
|
+
|
|
4
|
+
class MemoryStorage {
|
|
5
|
+
private items = new Map<string, string>();
|
|
6
|
+
getItem(key: string): string | null {
|
|
7
|
+
return this.items.get(key) ?? null;
|
|
8
|
+
}
|
|
9
|
+
setItem(key: string, value: string): void {
|
|
10
|
+
this.items.set(key, value);
|
|
11
|
+
}
|
|
12
|
+
removeItem(key: string): void {
|
|
13
|
+
this.items.delete(key);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('reload salt', () => {
|
|
18
|
+
const g = globalThis as { sessionStorage?: unknown };
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
g.sessionStorage = new MemoryStorage();
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
delete g.sessionStorage;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('a salt left on the way out is taken once, by the same principal', () => {
|
|
27
|
+
leaveReloadSalt('s1', 'user:a');
|
|
28
|
+
expect(takeReloadSalt('user:a')).toBe('s1');
|
|
29
|
+
// Taken, not read: a tab duplicated from this page finds nothing.
|
|
30
|
+
expect(takeReloadSalt('user:a')).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('another principal, or none, mints a fresh salt', () => {
|
|
34
|
+
leaveReloadSalt('s1', 'user:a');
|
|
35
|
+
expect(takeReloadSalt('user:b')).toBeNull();
|
|
36
|
+
leaveReloadSalt('s2', null);
|
|
37
|
+
expect(takeReloadSalt(null)).toBe('s2');
|
|
38
|
+
leaveReloadSalt('s3', null);
|
|
39
|
+
expect(takeReloadSalt('user:a')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('garbage or missing storage is a fresh salt, never a throw', () => {
|
|
43
|
+
(g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', '{not json');
|
|
44
|
+
expect(takeReloadSalt(null)).toBeNull();
|
|
45
|
+
(g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', JSON.stringify({ salt: '', authId: null }));
|
|
46
|
+
expect(takeReloadSalt(null)).toBeNull();
|
|
47
|
+
delete g.sessionStorage;
|
|
48
|
+
expect(takeReloadSalt(null)).toBeNull();
|
|
49
|
+
expect(() => leaveReloadSalt('s1', null)).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/client/services.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type { RuntimeEvent } from '../kernel/events';
|
|
|
28
28
|
import type { ServiceCalls } from '../kernel/effects';
|
|
29
29
|
import { encodeRecordId } from '../utils/index';
|
|
30
30
|
import { sha256Hex } from '../utils/sha256';
|
|
31
|
+
import { leaveViewsBeacon } from '../query/sql';
|
|
31
32
|
import { hashOfEdge, rowOfEdge } from '../utils/edges';
|
|
32
33
|
import { mintMutationId } from '../mutation/mutation-id';
|
|
33
34
|
import { BucketHandle, bucketContentToBlob } from './bucket-handle';
|
|
@@ -50,6 +51,40 @@ export function writeBootBucketHint(bucketId: string): void {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
const RELOAD_SALT_KEY = 'sp00ky:salt';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Take the salt this tab's previous page left on its way out, if it was minted
|
|
58
|
+
* for the same principal. A reload that keeps its salt re-registers every
|
|
59
|
+
* query onto the `_00_query` row it already had, which the SSP answers from
|
|
60
|
+
* the live view instead of building a new one.
|
|
61
|
+
*
|
|
62
|
+
* Taken, not read: the key is absent while a page lives, so a duplicated tab
|
|
63
|
+
* (which copies sessionStorage) mints its own salt rather than sharing this
|
|
64
|
+
* tab's remote views.
|
|
65
|
+
*/
|
|
66
|
+
export function takeReloadSalt(authId: string | null): string | null {
|
|
67
|
+
try {
|
|
68
|
+
if (typeof sessionStorage === 'undefined') return null;
|
|
69
|
+
const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
|
|
70
|
+
if (raw === null) return null;
|
|
71
|
+
sessionStorage.removeItem(RELOAD_SALT_KEY);
|
|
72
|
+
const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown };
|
|
73
|
+
return typeof left.salt === 'string' && left.salt !== '' && (left.authId ?? null) === authId ? left.salt : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}. */
|
|
80
|
+
export function leaveReloadSalt(salt: string, authId: string | null): void {
|
|
81
|
+
try {
|
|
82
|
+
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId }));
|
|
83
|
+
} catch {
|
|
84
|
+
/* private-mode storage errors: the next page mints a fresh salt */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
// Moved to a pure module so `@spooky-sync/core/pure` and the live feed share
|
|
54
89
|
// it; re-exported here for existing imports.
|
|
55
90
|
export { sha256Hex };
|
|
@@ -220,6 +255,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
220
255
|
const services: ServiceCalls = {
|
|
221
256
|
'hint.read': readBootBucketHint,
|
|
222
257
|
'hint.write': writeBootBucketHint,
|
|
258
|
+
'salt.take': takeReloadSalt,
|
|
259
|
+
'salt.leave': leaveReloadSalt,
|
|
223
260
|
'local.connect': (bucketId) => s.local.connect(bucketId),
|
|
224
261
|
'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
|
|
225
262
|
'local.beginSwitch': () => s.local.beginSwitch(),
|
|
@@ -260,11 +297,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
260
297
|
'auth.consumeEndedImpersonation': () => s.auth.consumeEndedImpersonation(),
|
|
261
298
|
'remote.connect': () => s.remote.connect(),
|
|
262
299
|
'remote.releaseViews': (ids) => {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
.filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id))
|
|
266
|
-
.join(', ');
|
|
267
|
-
if (list) s.remote.beaconSql(`FOR $id IN [${list}] { LET $_released = fn::query::unsubscribe($id); };`);
|
|
300
|
+
const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
|
|
301
|
+
if (sql) s.remote.beaconSql(sql);
|
|
268
302
|
},
|
|
269
303
|
'supervisor.start': () => s.connectionSupervisor.start(),
|
|
270
304
|
'tabs.start': (bucketId) => {
|
package/src/kernel/effects.ts
CHANGED
|
@@ -70,6 +70,8 @@ export type EffectKind = Effect['kind'];
|
|
|
70
70
|
export interface ServiceCalls {
|
|
71
71
|
'hint.read': () => string | null;
|
|
72
72
|
'hint.write': (bucketId: string) => void;
|
|
73
|
+
'salt.take': (authId: string | null) => string | null;
|
|
74
|
+
'salt.leave': (salt: string, authId: string | null) => void;
|
|
73
75
|
'local.connect': (bucketId: string) => Promise<void>;
|
|
74
76
|
'local.switchStore': (bucketId: string) => Promise<void>;
|
|
75
77
|
'local.beginSwitch': () => () => void;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createHmac } from 'node:crypto';
|
|
5
|
+
import { Surreal } from 'surrealdb';
|
|
6
|
+
import { AuthService } from './index';
|
|
7
|
+
import { ORIGIN_TOKEN_KEY } from './impersonation';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Impersonation start and stop against a real SurrealDB (the WASM engine) and
|
|
11
|
+
* the real SDK: record access for the admin, the `_00_impersonate` access with
|
|
12
|
+
* the same AUTHENTICATE revocation block the CLI generates, and the session
|
|
13
|
+
* functions minus the HTTP mint (the token is signed here, the way
|
|
14
|
+
* `ssp_protocol::impersonation::mint` signs it).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const KEY = 'test-impersonation-key';
|
|
18
|
+
const b64 = (v: string | Buffer) => Buffer.from(v).toString('base64url');
|
|
19
|
+
|
|
20
|
+
function mintImpersonation(session: string, target: string, admin: string, ttlSecs = 900): string {
|
|
21
|
+
const now = Math.floor(Date.now() / 1000);
|
|
22
|
+
const header = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
23
|
+
const claims = b64(
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
iss: 'SurrealDB',
|
|
26
|
+
iat: now,
|
|
27
|
+
nbf: now,
|
|
28
|
+
exp: now + ttlSecs,
|
|
29
|
+
NS: 'n',
|
|
30
|
+
DB: 'd',
|
|
31
|
+
AC: '_00_impersonate',
|
|
32
|
+
ID: target,
|
|
33
|
+
spky_imp: session,
|
|
34
|
+
spky_admin: admin,
|
|
35
|
+
spky_as_access: 'account',
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
const sig = createHmac('sha256', KEY).update(`${header}.${claims}`).digest('base64url');
|
|
39
|
+
return `${header}.${claims}.${sig}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SCHEMA = `
|
|
43
|
+
DEFINE TABLE user SCHEMALESS PERMISSIONS FOR select WHERE true FOR create, update, delete NONE;
|
|
44
|
+
DEFINE ACCESS account ON DATABASE TYPE RECORD
|
|
45
|
+
SIGNIN (SELECT * FROM user WHERE email = $email AND pass = $pass)
|
|
46
|
+
DURATION FOR TOKEN 365d, FOR SESSION 365d;
|
|
47
|
+
DEFINE TABLE _00_admin SCHEMALESS PERMISSIONS NONE;
|
|
48
|
+
DEFINE TABLE _00_impersonation SCHEMALESS PERMISSIONS FOR select, create, update WHERE true;
|
|
49
|
+
DEFINE ACCESS _00_impersonate ON DATABASE TYPE RECORD
|
|
50
|
+
WITH JWT ALGORITHM HS256 KEY '${KEY}'
|
|
51
|
+
AUTHENTICATE {
|
|
52
|
+
LET $s = (SELECT * FROM ONLY type::record($token.spky_imp));
|
|
53
|
+
IF $s = NONE OR $s.ended_at != NONE OR <string>$s.target != <string>$auth.id {
|
|
54
|
+
THROW 'Impersonation session is no longer valid.';
|
|
55
|
+
};
|
|
56
|
+
IF array::len((SELECT VALUE id FROM _00_admin WHERE user = $s.admin LIMIT 1)) = 0 {
|
|
57
|
+
THROW 'Impersonation session is no longer valid.';
|
|
58
|
+
};
|
|
59
|
+
RETURN $auth;
|
|
60
|
+
}
|
|
61
|
+
DURATION FOR TOKEN 15m, FOR SESSION 15m;
|
|
62
|
+
DEFINE FUNCTION fn::_00_impersonate::start($target: string, $reason: string) {
|
|
63
|
+
IF $access = '_00_impersonate' { THROW 'Cannot start impersonation from an impersonated session.'; };
|
|
64
|
+
UPSERT _00_impersonation:s1 SET target = type::record($target), admin = $auth.id, reason = $reason, ended_at = NONE;
|
|
65
|
+
RETURN { token: $test_imp_token, session: '_00_impersonation:s1' };
|
|
66
|
+
} PERMISSIONS FULL;
|
|
67
|
+
DEFINE FUNCTION fn::_00_impersonate::stop($session: string) {
|
|
68
|
+
UPDATE type::record($session) SET ended_at = time::now();
|
|
69
|
+
RETURN NONE;
|
|
70
|
+
} PERMISSIONS FULL;
|
|
71
|
+
DEFINE FUNCTION fn::_00_impersonate::renew() {
|
|
72
|
+
IF $access != '_00_impersonate' { THROW 'Not an impersonated session.'; };
|
|
73
|
+
RETURN { token: $test_imp_token_renewed };
|
|
74
|
+
} PERMISSIONS FULL;
|
|
75
|
+
DEFINE FUNCTION fn::_00_impersonate::current() {
|
|
76
|
+
IF $access != '_00_impersonate' { RETURN NONE; };
|
|
77
|
+
LET $s = (SELECT * FROM ONLY type::record($token.spky_imp));
|
|
78
|
+
RETURN IF $s = NONE OR $s.ended_at != NONE { NONE } ELSE { { session: <string>$s.id } };
|
|
79
|
+
} PERMISSIONS FULL;
|
|
80
|
+
CREATE user:alice SET email = 'alice@x', pass = 'pw';
|
|
81
|
+
CREATE user:bob SET email = 'bob@x', pass = 'pw';
|
|
82
|
+
CREATE _00_admin:alice SET user = user:alice;
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
const silentLogger = (): any => {
|
|
86
|
+
const l: any = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), trace: vi.fn() };
|
|
87
|
+
l.child = () => l;
|
|
88
|
+
return l;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
describe('impersonation on the SurrealDB-WASM engine', () => {
|
|
92
|
+
let db: Surreal;
|
|
93
|
+
let adminToken: string;
|
|
94
|
+
|
|
95
|
+
beforeAll(async () => {
|
|
96
|
+
const realFetch = globalThis.fetch;
|
|
97
|
+
vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
|
|
98
|
+
const url = input instanceof URL ? input : new URL(String(input));
|
|
99
|
+
if (url.protocol === 'file:') return new Response(await readFile(fileURLToPath(url)));
|
|
100
|
+
return realFetch(input as RequestInfo, init);
|
|
101
|
+
});
|
|
102
|
+
const { createWasmEngines } = await import('@surrealdb/wasm');
|
|
103
|
+
db = new Surreal({ engines: createWasmEngines() });
|
|
104
|
+
await db.connect('mem://');
|
|
105
|
+
await db.use({ namespace: 'n', database: 'd' });
|
|
106
|
+
await db.query(SCHEMA);
|
|
107
|
+
await db.query(`DEFINE PARAM $test_imp_token VALUE '${mintImpersonation('_00_impersonation:s1', 'user:bob', 'user:alice')}' PERMISSIONS FULL;`);
|
|
108
|
+
await db.query(`DEFINE PARAM $test_imp_token_renewed VALUE '${mintImpersonation('_00_impersonation:s1', 'user:bob', 'user:alice', 901)}' PERMISSIONS FULL;`);
|
|
109
|
+
const tokens = await db.signin({ access: 'account', variables: { email: 'alice@x', pass: 'pw' } });
|
|
110
|
+
adminToken = typeof tokens === 'string' ? tokens : (tokens as { access: string }).access;
|
|
111
|
+
}, 60_000);
|
|
112
|
+
|
|
113
|
+
afterAll(async () => {
|
|
114
|
+
await db?.close();
|
|
115
|
+
vi.unstubAllGlobals();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function makeAuth(opts: { failUserRead?: () => boolean; holdStop?: Promise<void> } = {}) {
|
|
119
|
+
const store = new Map<string, unknown>();
|
|
120
|
+
const persistence = {
|
|
121
|
+
get: async (k: string) => store.get(k) ?? null,
|
|
122
|
+
set: async (k: string, v: unknown) => void store.set(k, v),
|
|
123
|
+
remove: async (k: string) => void store.delete(k),
|
|
124
|
+
} as any;
|
|
125
|
+
const remote = {
|
|
126
|
+
query: async (sql: string, vars?: Record<string, unknown>) => {
|
|
127
|
+
if (sql.includes('$auth.id') && opts.failUserRead?.()) {
|
|
128
|
+
throw new Error('There was a problem with the database: the query was cancelled');
|
|
129
|
+
}
|
|
130
|
+
if (sql.includes('fn::_00_impersonate::stop') && opts.holdStop) await opts.holdStop;
|
|
131
|
+
return db.query(sql, vars);
|
|
132
|
+
},
|
|
133
|
+
setAuthToken: () => undefined,
|
|
134
|
+
getClient: () => db,
|
|
135
|
+
} as any;
|
|
136
|
+
return { auth: new AuthService({} as any, remote, persistence, silentLogger()), store };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it('stop returns to the admin', async () => {
|
|
140
|
+
const { auth, store } = makeAuth();
|
|
141
|
+
await auth.check(adminToken);
|
|
142
|
+
expect(String(auth.currentUser.id)).toBe('user:alice');
|
|
143
|
+
|
|
144
|
+
await auth.impersonate('user:bob', 'support');
|
|
145
|
+
expect(String(auth.currentUser.id)).toBe('user:bob');
|
|
146
|
+
|
|
147
|
+
await auth.stopImpersonating();
|
|
148
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
149
|
+
expect(String(auth.currentUser.id)).toBe('user:alice');
|
|
150
|
+
expect(store.get('sp00ky_auth_token')).toBe(adminToken);
|
|
151
|
+
expect(store.has(ORIGIN_TOKEN_KEY)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('an explicit stop racing a server-ended stop still returns to the admin', async () => {
|
|
155
|
+
let release!: () => void;
|
|
156
|
+
const holdStop = new Promise<void>((r) => (release = r));
|
|
157
|
+
const { auth } = makeAuth({ holdStop });
|
|
158
|
+
await auth.check(adminToken);
|
|
159
|
+
await auth.impersonate('user:bob', 'support');
|
|
160
|
+
|
|
161
|
+
// The Stop button's stop is waiting on the server when the poll notices
|
|
162
|
+
// the end (or a renewal is refused). Run apart, the poll's stop returned
|
|
163
|
+
// to the admin and the button's, finding the admin token taken, signed
|
|
164
|
+
// the admin out. Now the second joins the first.
|
|
165
|
+
const explicit = auth.stopImpersonating();
|
|
166
|
+
const fromPoll = auth.stopImpersonating({ serverEnded: true });
|
|
167
|
+
release();
|
|
168
|
+
await Promise.all([explicit, fromPoll]);
|
|
169
|
+
|
|
170
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
171
|
+
expect(String(auth.currentUser.id)).toBe('user:alice');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('a failed read of the admin row on the way back keeps the admin signed in', async () => {
|
|
175
|
+
let failNext = false;
|
|
176
|
+
const { auth, store } = makeAuth({ failUserRead: () => (failNext ? ((failNext = false), true) : false) });
|
|
177
|
+
await auth.check(adminToken);
|
|
178
|
+
await auth.impersonate('user:bob', 'support');
|
|
179
|
+
|
|
180
|
+
failNext = true;
|
|
181
|
+
await auth.stopImpersonating();
|
|
182
|
+
|
|
183
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
184
|
+
expect(String(auth.currentUser.id)).toBe('user:alice');
|
|
185
|
+
expect(store.get('sp00ky_auth_token')).toBe(adminToken);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('stop after half an hour of renewals and polls returns to the admin', async () => {
|
|
189
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
|
|
190
|
+
try {
|
|
191
|
+
const { auth } = makeAuth();
|
|
192
|
+
await auth.check(adminToken);
|
|
193
|
+
await auth.impersonate('user:bob', 'support');
|
|
194
|
+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
|
|
195
|
+
expect(String(auth.currentUser.id)).toBe('user:bob');
|
|
196
|
+
|
|
197
|
+
await auth.stopImpersonating();
|
|
198
|
+
|
|
199
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
200
|
+
expect(String(auth.currentUser.id)).toBe('user:alice');
|
|
201
|
+
} finally {
|
|
202
|
+
vi.useRealTimers();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -260,6 +260,28 @@ describe('timers', () => {
|
|
|
260
260
|
expect(auth.impersonation).toBeNull();
|
|
261
261
|
});
|
|
262
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
|
+
|
|
263
285
|
it('keeps going through network failures on the poll', async () => {
|
|
264
286
|
let calls = 0;
|
|
265
287
|
const host = {
|
|
@@ -103,6 +103,13 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
103
103
|
/** Target of an impersonation that just ended, until the client purges its
|
|
104
104
|
* local bucket (see `consumeEndedImpersonation`). */
|
|
105
105
|
private endedImpersonationTarget: string | null = null;
|
|
106
|
+
/** The way back to the admin in flight. The Stop button, the session poll,
|
|
107
|
+
* a refused renewal and a rejected check can all end an impersonation, and
|
|
108
|
+
* they share this one: a second stop running beside the first found the
|
|
109
|
+
* admin token already taken and signed the admin out. */
|
|
110
|
+
private stopping: Promise<void> | null = null;
|
|
111
|
+
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
112
|
+
private renewing: Promise<void> | null = null;
|
|
106
113
|
private impersonationTimers = new ImpersonationTimers({
|
|
107
114
|
current: () => this.impersonation,
|
|
108
115
|
renew: () => this.renewImpersonation(),
|
|
@@ -272,9 +279,22 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
272
279
|
* a full sign-out rather than staying on the target.
|
|
273
280
|
*/
|
|
274
281
|
async stopImpersonating(opts: { serverEnded?: boolean } = {}): Promise<void> {
|
|
282
|
+
if (this.stopping) return this.stopping;
|
|
283
|
+
if (!this.impersonation) return;
|
|
284
|
+
this.stopping = this.leaveImpersonation(opts).finally(() => {
|
|
285
|
+
this.stopping = null;
|
|
286
|
+
});
|
|
287
|
+
return this.stopping;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async leaveImpersonation(opts: { serverEnded?: boolean }): Promise<void> {
|
|
291
|
+
this.impersonationTimers.clear();
|
|
292
|
+
// A renewal already sent finishes first, so it cannot put the target's
|
|
293
|
+
// token back on the socket after the admin's (it sees `stopping` and
|
|
294
|
+
// stands down).
|
|
295
|
+
await this.renewing?.catch(() => undefined);
|
|
275
296
|
const info = this.impersonation;
|
|
276
297
|
if (!info) return;
|
|
277
|
-
this.impersonationTimers.clear();
|
|
278
298
|
if (!opts.serverEnded) {
|
|
279
299
|
try {
|
|
280
300
|
await this.remote.query('RETURN fn::_00_impersonate::stop($session)', { session: info.session });
|
|
@@ -314,9 +334,22 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
314
334
|
|
|
315
335
|
/** Re-sign the impersonation token before it expires. */
|
|
316
336
|
async renewImpersonation(): Promise<void> {
|
|
317
|
-
if (!this.impersonation) return;
|
|
337
|
+
if (!this.impersonation || this.stopping) return;
|
|
338
|
+
const renewal = this.applyRenewal();
|
|
339
|
+
this.renewing = renewal;
|
|
340
|
+
try {
|
|
341
|
+
await renewal;
|
|
342
|
+
} finally {
|
|
343
|
+
if (this.renewing === renewal) this.renewing = null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private async applyRenewal(): Promise<void> {
|
|
318
348
|
const [renewed] = await this.remote.query<[{ token: string }]>('RETURN fn::_00_impersonate::renew()');
|
|
319
349
|
if (!renewed?.token) throw new Error('Impersonation renewal returned no token.');
|
|
350
|
+
// A stop began while the server was signing: the session is being left,
|
|
351
|
+
// so a fresh token for it must not go on the socket or into storage.
|
|
352
|
+
if (this.stopping || !this.impersonation) return;
|
|
320
353
|
this.remote.setAuthToken(renewed.token);
|
|
321
354
|
await this.remote.getClient().authenticate(renewed.token);
|
|
322
355
|
this.token = renewed.token;
|
|
@@ -399,6 +432,9 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
399
432
|
this.isLoading = true;
|
|
400
433
|
|
|
401
434
|
let token: string | undefined;
|
|
435
|
+
// Set once the server has accepted the token: from then on a failure is a
|
|
436
|
+
// failure to read the user row, not a verdict on the session.
|
|
437
|
+
let accepted = false;
|
|
402
438
|
try {
|
|
403
439
|
token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token')) || undefined;
|
|
404
440
|
|
|
@@ -418,6 +454,7 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
418
454
|
// `RemoteDatabaseService.setAuthToken`).
|
|
419
455
|
this.remote.setAuthToken(token);
|
|
420
456
|
await this.remote.getClient().authenticate(token);
|
|
457
|
+
accepted = true;
|
|
421
458
|
|
|
422
459
|
// Verify the session by fetching the full user record using $auth.id
|
|
423
460
|
const result = await this.remote.query('SELECT * FROM ONLY $auth.id');
|
|
@@ -471,6 +508,17 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
471
508
|
{ error, Category: 'sp00ky-client::AuthService::check' },
|
|
472
509
|
'Auth check unreachable; keeping the cached session and retrying later'
|
|
473
510
|
);
|
|
511
|
+
} else if (accepted && token) {
|
|
512
|
+
// The server took the token and only the read of the user row failed
|
|
513
|
+
// (a busy database, a statement error). Signing out here logged an
|
|
514
|
+
// admin out on the way back from an impersonation while the database
|
|
515
|
+
// was saturated. Keep the session on the id the token names; the row
|
|
516
|
+
// arrives with the next check or the app's own user query.
|
|
517
|
+
this.logger.warn(
|
|
518
|
+
{ error, Category: 'sp00ky-client::AuthService::check' },
|
|
519
|
+
'Auth check accepted the token but could not read the user; keeping the session'
|
|
520
|
+
);
|
|
521
|
+
await this.keepAcceptedSession(token);
|
|
474
522
|
} else {
|
|
475
523
|
this.logger.error(
|
|
476
524
|
{ error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
|
|
@@ -483,6 +531,18 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
483
531
|
}
|
|
484
532
|
}
|
|
485
533
|
|
|
534
|
+
/** Hold a session the server accepted without its user row: the id comes
|
|
535
|
+
* from the token, as on a warm boot. A token naming no user is refused. */
|
|
536
|
+
private async keepAcceptedSession(token: string): Promise<void> {
|
|
537
|
+
if (this.isAuthenticated && this.token === token && this.currentUser) return;
|
|
538
|
+
const { userId } = decodeTokenClaims(token);
|
|
539
|
+
if (!userId) {
|
|
540
|
+
await this.rejectToken(token);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
await this.setSession(token, { id: userId });
|
|
544
|
+
}
|
|
545
|
+
|
|
486
546
|
/**
|
|
487
547
|
* The server refused `token`. A refused impersonation token (stopped,
|
|
488
548
|
* expired, or revoked) returns to the admin session instead of signing the
|
|
@@ -156,6 +156,24 @@ describe('registerRemote', () => {
|
|
|
156
156
|
expect(out.dispatched).toEqual([{ type: 'FetchRows' }, { type: 'FetchRows' }, { type: 'SyncOutcome', ok: true }]);
|
|
157
157
|
expect(out.emitted).toContainEqual({ type: 'query:authority', hash: 'a', known: true });
|
|
158
158
|
});
|
|
159
|
+
it('a cold registration is foreground; a cached or live one is background', async () => {
|
|
160
|
+
for (const [phase, background] of [['cold', 0], ['cached', 1], ['live', 1]] as const) {
|
|
161
|
+
const seen: Array<[number, number]> = [];
|
|
162
|
+
const out = await runPure(registerRemote(env, 'a'), {
|
|
163
|
+
state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { phase } })]),
|
|
164
|
+
handlers: {
|
|
165
|
+
'remote.query': (_e: unknown, ctx) => {
|
|
166
|
+
const l = ctx.state.queries.get('a')!.lifecycle;
|
|
167
|
+
seen.push([l.fetchDepth, l.backgroundDepth]);
|
|
168
|
+
return happy();
|
|
169
|
+
},
|
|
170
|
+
'local.upsert': () => undefined,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
expect(seen).toEqual([[1, background]]);
|
|
174
|
+
expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
159
177
|
it('guards: missing entry, already registered / failed, registering without retry', async () => {
|
|
160
178
|
const none = await runPure(registerRemote(env, 'zzz'), { state: buildState([]) });
|
|
161
179
|
expect(none.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
|
|
@@ -151,8 +151,11 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
|
|
|
151
151
|
if (!entry) return;
|
|
152
152
|
const { remote } = entry.lifecycle;
|
|
153
153
|
if (remote === 'registered' || remote === 'failed' || (remote === 'registering' && !retry)) return;
|
|
154
|
+
// A query that already shows rows (cached or live) only re-verifies them
|
|
155
|
+
// here: the cycle holds `fetching` but is not traffic the user waits on.
|
|
156
|
+
const background = entry.lifecycle.phase !== 'cold';
|
|
154
157
|
yield fx.state.update(
|
|
155
|
-
R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin' }))
|
|
158
|
+
R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin', background }))
|
|
156
159
|
);
|
|
157
160
|
try {
|
|
158
161
|
const table = listRefTable(env, state);
|
|
@@ -221,6 +224,6 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
|
|
|
221
224
|
}
|
|
222
225
|
} finally {
|
|
223
226
|
const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
|
|
224
|
-
if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end' }));
|
|
227
|
+
if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
|
|
225
228
|
}
|
|
226
229
|
}
|
package/src/query/sql.test.ts
CHANGED
|
@@ -50,6 +50,24 @@ describe('query sql builders (golden against the previous builders)', () => {
|
|
|
50
50
|
expect(sql.heartbeatRowGone([{ id: a }])).toBe(false);
|
|
51
51
|
expect(sql.heartbeatRowGone(undefined)).toBe(false);
|
|
52
52
|
});
|
|
53
|
+
it('the pagehide beacon names the page sessions and inlines only shapes it minted', () => {
|
|
54
|
+
const a = new RecordId('_00_query', 'a'.repeat(64));
|
|
55
|
+
const b = new RecordId('_00_query', 'b'.repeat(64));
|
|
56
|
+
const s1 = 'de81a067-f4bc-4351-a2a3-1ecf06b6305a';
|
|
57
|
+
const s2 = '8A14ED5A-32B5-4B4E-973D-D2B88860932F';
|
|
58
|
+
expect(sql.leaveViewsBeacon([a, b], [s1, s2])).toBe(
|
|
59
|
+
`FOR $id IN [${a}, ${b}] { LET $_left = fn::query::leave($id, ['${s1}', '${s2}']); };`
|
|
60
|
+
);
|
|
61
|
+
const digits = new RecordId('_00_query', '1'.repeat(64));
|
|
62
|
+
expect(sql.leaveViewsBeacon([digits], [s1])).toBe(`FOR $id IN [${digits}] { LET $_left = fn::query::leave($id, ['${s1}']); };`);
|
|
63
|
+
// Anything else is dropped rather than interpolated.
|
|
64
|
+
expect(sql.leaveViewsBeacon([a, new RecordId('_00_query', "x']; DELETE user; --"), 'user:1'], [s1, "x'; DELETE user; --"])).toBe(
|
|
65
|
+
`FOR $id IN [${a}] { LET $_left = fn::query::leave($id, ['${s1}']); };`
|
|
66
|
+
);
|
|
67
|
+
// No session ids: the server could not tell this page's entries from a reload's, so send nothing.
|
|
68
|
+
expect(sql.leaveViewsBeacon([a], [])).toBeNull();
|
|
69
|
+
expect(sql.leaveViewsBeacon([], [s1])).toBeNull();
|
|
70
|
+
});
|
|
53
71
|
it('upsertBodiesTx MERGEs each body inside one transaction', () => {
|
|
54
72
|
const { query, vars } = sql.upsertBodiesTx([
|
|
55
73
|
{ id: 'thing:1', content: { a: 1 } },
|
package/src/query/sql.ts
CHANGED
|
@@ -76,6 +76,25 @@ export function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): { sql: str
|
|
|
76
76
|
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
77
77
|
export const heartbeatRowGone = (result: unknown): boolean => Array.isArray(result) && result.length === 0;
|
|
78
78
|
|
|
79
|
+
// The SDK escapes an all-digit key as `⟨…⟩`; both forms parse back to the same id.
|
|
80
|
+
const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
|
|
81
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The pagehide release: one bare statement for `beaconSql`, which has no params
|
|
85
|
+
* channel, so ids are inlined. Only shapes the client minted itself get in, a
|
|
86
|
+
* `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
|
|
87
|
+
* interpolated. `null` when there is nothing the server could act on: with no
|
|
88
|
+
* session ids it could not tell this page's entries from anyone else's.
|
|
89
|
+
*/
|
|
90
|
+
export function leaveViewsBeacon(ids: ReadonlyArray<unknown>, sessionIds: ReadonlyArray<string>): string | null {
|
|
91
|
+
const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
|
|
92
|
+
const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
|
|
93
|
+
if (list.length === 0 || sids.length === 0) return null;
|
|
94
|
+
const sidList = sids.map((sid) => `'${sid}'`).join(', ');
|
|
95
|
+
return `FOR $id IN [${list.join(', ')}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
|
|
96
|
+
}
|
|
97
|
+
|
|
79
98
|
export const bodySelect = (): string => 'SELECT * FROM $ids';
|
|
80
99
|
|
|
81
100
|
/** Local transaction that MERGEs many fetched bodies. */
|