@spooky-sync/core 0.0.1-canary.320 → 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/dist/index.js +97 -31
- package/dist/live.d.ts +1 -1
- package/dist/live.js +28 -4
- package/dist/pure.d.ts +30 -2
- package/dist/pure.js +2 -2
- package/dist/types.d.ts +10 -0
- package/dist/worker-edges.js +98 -67
- package/dist/worker-ref-tables.d.ts +3 -1
- package/package.json +3 -3
- package/src/client/services.test.ts +11 -2
- package/src/client/services.ts +25 -11
- package/src/live/index.test.ts +24 -1
- package/src/live/index.ts +29 -3
- package/src/modules/ref-tables.test.ts +3 -1
- package/src/modules/ref-tables.ts +4 -1
- package/src/pure.ts +1 -0
- package/src/query/anon-query-isolation.integration.test.ts +337 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/membership.saga.test.ts +5 -5
- package/src/query/sql.test.ts +31 -0
- package/src/query/sql.ts +40 -3
- package/src/services/database/remote.ts +20 -2
- package/src/sync/connection.saga.test.ts +13 -2
- package/src/sync/connection.saga.ts +17 -13
- package/src/sync/policy.test.ts +13 -9
- package/src/sync/policy.ts +16 -3
|
@@ -25,16 +25,25 @@ describe('reload salt', () => {
|
|
|
25
25
|
|
|
26
26
|
it('a salt left on the way out is taken once, by the same principal', () => {
|
|
27
27
|
leaveReloadSalt('s1', 'user:a');
|
|
28
|
-
expect(takeReloadSalt('user:a')).
|
|
28
|
+
expect(takeReloadSalt('user:a')).toEqual({ salt: 's1', sessionIds: [] });
|
|
29
29
|
// Taken, not read: a tab duplicated from this page finds nothing.
|
|
30
30
|
expect(takeReloadSalt('user:a')).toBeNull();
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
it("the page's socket session ids travel with the salt", () => {
|
|
34
|
+
// The reloaded page's views keep edges stamped by these sockets, and the
|
|
35
|
+
// anonymous LIVE only hears sessions it knows.
|
|
36
|
+
leaveReloadSalt('s1', null, ['sid-1', 'sid-2']);
|
|
37
|
+
expect(takeReloadSalt(null)).toEqual({ salt: 's1', sessionIds: ['sid-1', 'sid-2'] });
|
|
38
|
+
(g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', JSON.stringify({ salt: 's2', authId: null, sessionIds: ['ok', 7, null] }));
|
|
39
|
+
expect(takeReloadSalt(null)).toEqual({ salt: 's2', sessionIds: ['ok'] });
|
|
40
|
+
});
|
|
41
|
+
|
|
33
42
|
it('another principal, or none, mints a fresh salt', () => {
|
|
34
43
|
leaveReloadSalt('s1', 'user:a');
|
|
35
44
|
expect(takeReloadSalt('user:b')).toBeNull();
|
|
36
45
|
leaveReloadSalt('s2', null);
|
|
37
|
-
expect(takeReloadSalt(null)).toBe('s2');
|
|
46
|
+
expect(takeReloadSalt(null)?.salt).toBe('s2');
|
|
38
47
|
leaveReloadSalt('s3', null);
|
|
39
48
|
expect(takeReloadSalt('user:a')).toBeNull();
|
|
40
49
|
});
|
package/src/client/services.ts
CHANGED
|
@@ -28,7 +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
|
+
import { leaveViewsBeacon, listRefLive } from '../query/sql';
|
|
32
32
|
import { hashOfEdge, rowOfEdge } from '../utils/edges';
|
|
33
33
|
import { mintMutationId } from '../mutation/mutation-id';
|
|
34
34
|
import { BucketHandle, bucketContentToBlob } from './bucket-handle';
|
|
@@ -63,23 +63,32 @@ const RELOAD_SALT_KEY = 'sp00ky:salt';
|
|
|
63
63
|
* (which copies sessionStorage) mints its own salt rather than sharing this
|
|
64
64
|
* tab's remote views.
|
|
65
65
|
*/
|
|
66
|
-
export function takeReloadSalt(authId: string | null): string | null {
|
|
66
|
+
export function takeReloadSalt(authId: string | null): { salt: string; sessionIds: string[] } | null {
|
|
67
67
|
try {
|
|
68
68
|
if (typeof sessionStorage === 'undefined') return null;
|
|
69
69
|
const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
|
|
70
70
|
if (raw === null) return null;
|
|
71
71
|
sessionStorage.removeItem(RELOAD_SALT_KEY);
|
|
72
|
-
const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown };
|
|
73
|
-
|
|
72
|
+
const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown; sessionIds?: unknown };
|
|
73
|
+
if (typeof left.salt !== 'string' || left.salt === '' || (left.authId ?? null) !== authId) return null;
|
|
74
|
+
const sessionIds = Array.isArray(left.sessionIds) ? left.sessionIds.filter((sid): sid is string => typeof sid === 'string') : [];
|
|
75
|
+
return { salt: left.salt, sessionIds };
|
|
74
76
|
} catch {
|
|
75
77
|
return null;
|
|
76
78
|
}
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}.
|
|
83
|
+
*
|
|
84
|
+
* The session ids of this page's sockets go with it. The views the next page
|
|
85
|
+
* re-registers keep the edges this page's sockets stamped, and the anonymous
|
|
86
|
+
* LIVE only hears edges stamped by a session it knows (see `listRefLive`).
|
|
87
|
+
*/
|
|
88
|
+
export function leaveReloadSalt(salt: string, authId: string | null, sessionIds: ReadonlyArray<string> = []): void {
|
|
81
89
|
try {
|
|
82
|
-
if (typeof sessionStorage !== 'undefined')
|
|
90
|
+
if (typeof sessionStorage !== 'undefined')
|
|
91
|
+
sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId, sessionIds }));
|
|
83
92
|
} catch {
|
|
84
93
|
/* private-mode storage errors: the next page mints a fresh salt */
|
|
85
94
|
}
|
|
@@ -255,8 +264,13 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
255
264
|
const services: ServiceCalls = {
|
|
256
265
|
'hint.read': readBootBucketHint,
|
|
257
266
|
'hint.write': writeBootBucketHint,
|
|
258
|
-
'salt.take':
|
|
259
|
-
|
|
267
|
+
'salt.take': (authId) => {
|
|
268
|
+
const left = takeReloadSalt(authId);
|
|
269
|
+
if (!left) return null;
|
|
270
|
+
s.remote.adoptSessionIds(left.sessionIds);
|
|
271
|
+
return left.salt;
|
|
272
|
+
},
|
|
273
|
+
'salt.leave': (salt, authId) => leaveReloadSalt(salt, authId, s.remote.sessionIds()),
|
|
260
274
|
'local.connect': (bucketId) => s.local.connect(bucketId),
|
|
261
275
|
'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
|
|
262
276
|
'local.beginSwitch': () => s.local.beginSwitch(),
|
|
@@ -354,8 +368,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
354
368
|
// trip. The clause is only ever added, never relied on: a server that
|
|
355
369
|
// ignores it, or a row the session cannot read, simply yields no
|
|
356
370
|
// inline row and the fetch path takes over.
|
|
357
|
-
const
|
|
358
|
-
const [uuid] = await s.remote.query<[Uuid]>(
|
|
371
|
+
const stmt = listRefLive(table, { inlineBodies: config.liveInlineBodies === true, sessionIds: s.remote.sessionIds() });
|
|
372
|
+
const [uuid] = await s.remote.query<[Uuid]>(stmt.sql, stmt.vars);
|
|
359
373
|
const live = await s.remote.getClient().liveOf(uuid);
|
|
360
374
|
live.subscribe((message) => {
|
|
361
375
|
if (message.action === 'KILLED') return;
|
package/src/live/index.test.ts
CHANGED
|
@@ -215,7 +215,7 @@ describe('createLiveFeed', () => {
|
|
|
215
215
|
expect(sent).not.toBeNull();
|
|
216
216
|
expect(reg.sql).toBe(sent!.sql);
|
|
217
217
|
expect(reg.vars).toEqual(sent!.vars);
|
|
218
|
-
expect(reg.sql).toContain('FROM _00_list_ref_user_alice');
|
|
218
|
+
expect(reg.sql).toContain('FROM $in->_00_list_ref_user_alice');
|
|
219
219
|
expect((reg.vars!.config as any).params.user).toBeInstanceOf(RecordId);
|
|
220
220
|
expect(sub.key).toBe(local.result);
|
|
221
221
|
expect(await queryKey({ surql: input.surql, params: input.params }, 'sess')).toBe(local.result);
|
|
@@ -375,6 +375,29 @@ describe('createLiveFeed', () => {
|
|
|
375
375
|
await feed.close();
|
|
376
376
|
});
|
|
377
377
|
|
|
378
|
+
it("an anonymous feed's LIVE hears only its own sockets, earlier ones included after a reconnect", async () => {
|
|
379
|
+
const fake = new FakeSurreal();
|
|
380
|
+
let sid = 0;
|
|
381
|
+
const answer = fake.answer.bind(fake);
|
|
382
|
+
fake.answer = (sql, vars) =>
|
|
383
|
+
sql === 'RETURN <string>session::id()' ? [`sid-${++sid}`] : answer(sql, vars);
|
|
384
|
+
const feed = feedWith(fake, { token: undefined });
|
|
385
|
+
const sub = feed.subscribe({ surql: 'SELECT * FROM notification;' }, { onSet: () => {} });
|
|
386
|
+
await sub.ready;
|
|
387
|
+
fake.fire('disconnected');
|
|
388
|
+
fake.fire('connected');
|
|
389
|
+
await tick(60);
|
|
390
|
+
const lives = fake.calls.filter((c) => c.sql.startsWith('LIVE SELECT'));
|
|
391
|
+
expect(lives.map((c) => c.sql)).toEqual([
|
|
392
|
+
'LIVE SELECT * FROM _00_list_ref_anon WHERE clientId = <string>session::id() OR clientId IN $sids FETCH out',
|
|
393
|
+
'LIVE SELECT * FROM _00_list_ref_anon WHERE clientId = <string>session::id() OR clientId IN $sids FETCH out',
|
|
394
|
+
]);
|
|
395
|
+
// The view was registered on the first socket and is not re-registered,
|
|
396
|
+
// so its edges keep naming `sid-1`: the second LIVE must still hear them.
|
|
397
|
+
expect(lives[1].vars).toEqual({ sids: ['sid-1'] });
|
|
398
|
+
await feed.close();
|
|
399
|
+
});
|
|
400
|
+
|
|
378
401
|
it('heartbeats and re-registers a view the server reclaimed', async () => {
|
|
379
402
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
380
403
|
try {
|
package/src/live/index.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* params, ttl }`, key = sha256(JSON.stringify({ surql, params, sessionId })));
|
|
14
14
|
* - bodies with `SELECT * FROM $ids`;
|
|
15
15
|
* - one `LIVE SELECT * FROM <the user's _00_list_ref table> FETCH out` per
|
|
16
|
-
* feed
|
|
16
|
+
* feed (on the shared anon table, filtered to the feed's own edges; see
|
|
17
|
+
* `listRefLive`), so a changed row usually arrives inline with its edge; an edge whose
|
|
17
18
|
* `out` is a bare id (a server that ignores FETCH) costs a body read, one
|
|
18
19
|
* whose `out` is null (unreadable or deleted row) a membership re-read;
|
|
19
20
|
* - `fn::query::heartbeat` at half the ttl, re-registering a reclaimed view;
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
bodySelect,
|
|
31
32
|
heartbeatBatch,
|
|
32
33
|
heartbeatRowGone,
|
|
34
|
+
listRefLive,
|
|
33
35
|
registerSelect,
|
|
34
36
|
registerVars,
|
|
35
37
|
singleSnapshotSelect,
|
|
@@ -201,6 +203,7 @@ const FLUSH_MS = 10;
|
|
|
201
203
|
const FETCH_BATCH_MS = 15;
|
|
202
204
|
const REREAD_MS = 50;
|
|
203
205
|
const RELEASE_TIMEOUT_MS = 2_000;
|
|
206
|
+
const MAX_SOCKETS = 16;
|
|
204
207
|
|
|
205
208
|
interface Edge {
|
|
206
209
|
rid: RecordId;
|
|
@@ -381,6 +384,8 @@ class Feed implements LiveFeed {
|
|
|
381
384
|
private liveStarting: Promise<boolean> | null = null;
|
|
382
385
|
private readonly subs = new Map<string, SubState>();
|
|
383
386
|
private readonly sessionId: string;
|
|
387
|
+
/** `session::id()` of every socket this feed has had, oldest first. */
|
|
388
|
+
private sockets: string[] = [];
|
|
384
389
|
private readonly ttl: string;
|
|
385
390
|
private readonly timeoutMs: number;
|
|
386
391
|
private isClosed = false;
|
|
@@ -498,10 +503,27 @@ class Feed implements LiveFeed {
|
|
|
498
503
|
);
|
|
499
504
|
}
|
|
500
505
|
this.db = db;
|
|
506
|
+
void this.noteSession();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Remember the `session::id()` of the socket now open. Views are registered
|
|
511
|
+
* once and not again after a reconnect, so their edges keep naming the socket
|
|
512
|
+
* that registered them; the anonymous LIVE has to keep hearing those.
|
|
513
|
+
*/
|
|
514
|
+
private async noteSession(): Promise<void> {
|
|
515
|
+
try {
|
|
516
|
+
const [sid] = await this.q('RETURN <string>session::id()');
|
|
517
|
+
if (typeof sid !== 'string' || this.sockets.includes(sid)) return;
|
|
518
|
+
this.sockets = [...this.sockets, sid].slice(-MAX_SOCKETS);
|
|
519
|
+
} catch {
|
|
520
|
+
// The socket now open is matched by `session::id()` itself.
|
|
521
|
+
}
|
|
501
522
|
}
|
|
502
523
|
|
|
503
524
|
/** After a reconnect: LIVE again, then re-read every view (events in the gap are lost). */
|
|
504
525
|
private async recover(): Promise<void> {
|
|
526
|
+
void this.noteSession();
|
|
505
527
|
try {
|
|
506
528
|
await this.ensureLive();
|
|
507
529
|
for (const sub of this.subs.values()) if (sub.loaded) this.scheduleReread(sub);
|
|
@@ -557,14 +579,18 @@ class Feed implements LiveFeed {
|
|
|
557
579
|
if (!this.liveStarting) {
|
|
558
580
|
this.liveStarting = (async () => {
|
|
559
581
|
try {
|
|
582
|
+
const live = (inlineBodies: boolean) =>
|
|
583
|
+
listRefLive(this.table!, { inlineBodies, sessionIds: this.sockets });
|
|
560
584
|
let id: unknown;
|
|
561
585
|
try {
|
|
562
|
-
|
|
586
|
+
const { sql, vars } = live(true);
|
|
587
|
+
[id] = await this.q(sql, vars);
|
|
563
588
|
} catch (error) {
|
|
564
589
|
// A server that refuses FETCH on LIVE still has the doorbell:
|
|
565
590
|
// bare-id edges fall back to a body read in `onLive`.
|
|
566
591
|
this.report(error);
|
|
567
|
-
|
|
592
|
+
const { sql, vars } = live(false);
|
|
593
|
+
[id] = await this.q(sql, vars);
|
|
568
594
|
}
|
|
569
595
|
const stream = await this.db!.liveOf(id as never);
|
|
570
596
|
const off = stream.subscribe((message) => this.onLive(message));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { RecordId } from 'surrealdb';
|
|
3
|
-
import { ANON_USER_ID, bucketIdForUser, listRefTableFor, sanitizeUserId } from './ref-tables';
|
|
3
|
+
import { ANON_LIST_REF_TABLE, ANON_USER_ID, bucketIdForUser, listRefTableFor, sanitizeUserId } from './ref-tables';
|
|
4
4
|
|
|
5
5
|
describe('listRefTableFor', () => {
|
|
6
6
|
it('returns global table in single mode regardless of user', () => {
|
|
@@ -36,6 +36,8 @@ describe('listRefTableFor', () => {
|
|
|
36
36
|
it('routes the anon sentinel to the shared anon table in both modes', () => {
|
|
37
37
|
expect(listRefTableFor('dedicated', ANON_USER_ID)).toBe('_00_list_ref_anon');
|
|
38
38
|
expect(listRefTableFor('single', ANON_USER_ID)).toBe('_00_list_ref_anon');
|
|
39
|
+
// The LIVE builder keys its per-session filter off this constant.
|
|
40
|
+
expect(ANON_LIST_REF_TABLE).toBe('_00_list_ref_anon');
|
|
39
41
|
// A real user whose id sanitizes to "anon" still carries the user: prefix,
|
|
40
42
|
// so it never collides with the bare sentinel.
|
|
41
43
|
expect(listRefTableFor('dedicated', 'user:anon')).toBe(
|
|
@@ -22,6 +22,9 @@ export type RefMode = 'single' | 'dedicated';
|
|
|
22
22
|
*/
|
|
23
23
|
export const ANON_USER_ID = 'anon';
|
|
24
24
|
|
|
25
|
+
/** The one `_00_list_ref` table every signed-out session shares. */
|
|
26
|
+
export const ANON_LIST_REF_TABLE = '_00_list_ref_anon';
|
|
27
|
+
|
|
25
28
|
/**
|
|
26
29
|
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
27
30
|
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
@@ -81,7 +84,7 @@ export function listRefTableFor(mode: RefMode, userId: unknown): string {
|
|
|
81
84
|
// Anonymous clients (flag-enabled) share one dedicated table in both modes —
|
|
82
85
|
// checked before the mode split so it never lands on the per-user or the
|
|
83
86
|
// auth-gated global table. Matches `ssp_protocol::list_ref_table_for`.
|
|
84
|
-
if (userId === ANON_USER_ID) return
|
|
87
|
+
if (userId === ANON_USER_ID) return ANON_LIST_REF_TABLE;
|
|
85
88
|
if (mode === 'single') return '_00_list_ref';
|
|
86
89
|
const uid = sanitizeUserId(userId);
|
|
87
90
|
return uid ? `_00_list_ref_user_${uid}` : '_00_list_ref';
|
package/src/pure.ts
CHANGED
|
@@ -16,6 +16,7 @@ export * from './query/sql';
|
|
|
16
16
|
export { queryHashInput, viewKeyInput, type QueryKeyInput } from './query/hash';
|
|
17
17
|
// `_00_list_ref` table naming and user-id sanitizing (mirrors ssp-protocol).
|
|
18
18
|
export {
|
|
19
|
+
ANON_LIST_REF_TABLE,
|
|
19
20
|
ANON_USER_ID,
|
|
20
21
|
DEFAULT_REF_MODE,
|
|
21
22
|
bucketIdForUser,
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { Socket } from 'node:net';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { RecordId, Surreal } from 'surrealdb';
|
|
7
|
+
import { liveEndpoint } from '../live/index';
|
|
8
|
+
import {
|
|
9
|
+
heartbeatBatch,
|
|
10
|
+
heartbeatRowGone,
|
|
11
|
+
leaveViewsBeacon,
|
|
12
|
+
queryRowCountBatchSelect,
|
|
13
|
+
queryRowCountSelect,
|
|
14
|
+
} from './sql';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every signed-out visitor shares the one `anon` identity, so `_00_query`
|
|
18
|
+
* tells their views apart by session: an anonymous row answers only to the
|
|
19
|
+
* sessions in its `subscribers`. A second visitor must not be able to list,
|
|
20
|
+
* read, rewrite, plant or delete the first one's view, while the first keeps
|
|
21
|
+
* its heartbeat and its registration read-back, also after a reconnect.
|
|
22
|
+
*
|
|
23
|
+
* Runs the shipped `_00_query` block and `fn::query::*` functions from
|
|
24
|
+
* `apps/cli/src` against a real SurrealDB, because the guarantee is the
|
|
25
|
+
* engine's evaluation of the table and field permissions. Boot one first, e.g.:
|
|
26
|
+
* docker run -d --name spooky-surreal-it -p 18011:8000 \
|
|
27
|
+
* surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
|
|
28
|
+
*
|
|
29
|
+
* Override the endpoint with SURREAL_IT_ENDPOINT. Skips if unreachable.
|
|
30
|
+
*/
|
|
31
|
+
const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
|
|
32
|
+
const NS = 'it';
|
|
33
|
+
|
|
34
|
+
const cliSource = (file: string) =>
|
|
35
|
+
readFileSync(fileURLToPath(new URL(`../../../../apps/cli/src/${file}`, import.meta.url)), 'utf8');
|
|
36
|
+
|
|
37
|
+
/** Just the `_00_query` block: the rest of the file needs tables and modules this test does not. */
|
|
38
|
+
function queryTableSchema(): string {
|
|
39
|
+
const meta = cliSource('meta_tables_remote.surql');
|
|
40
|
+
const start = meta.indexOf('DEFINE TABLE OVERWRITE _00_query SCHEMALESS');
|
|
41
|
+
const end = meta.indexOf('-- SPOOKY LIST REF');
|
|
42
|
+
if (start < 0 || end < start)
|
|
43
|
+
throw new Error('_00_query block not found in meta_tables_remote.surql');
|
|
44
|
+
return meta.slice(start, end);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ACCESS = `
|
|
48
|
+
DEFINE TABLE OVERWRITE user SCHEMALESS PERMISSIONS FOR select WHERE id = $auth.id;
|
|
49
|
+
DEFINE ACCESS OVERWRITE account ON DATABASE TYPE RECORD
|
|
50
|
+
SIGNUP (CREATE user SET name = $name)
|
|
51
|
+
SIGNIN (SELECT * FROM user WHERE name = $name)
|
|
52
|
+
DURATION FOR TOKEN 1h, FOR SESSION 1h;
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
/** What the SSP writes, as root, when a session registers a view (`node.rs`, cold path). */
|
|
56
|
+
const SSP_REGISTER = `
|
|
57
|
+
UPSERT $id SET clientId = $sid, auth_id = $owner, surql = 'SELECT * FROM item',
|
|
58
|
+
params = { auth: { id: $authParam }, access: '' }, ttl = 1h, lastActiveAt = time::now(),
|
|
59
|
+
rowCount = 3, state = 'ready',
|
|
60
|
+
subscribers = array::append(
|
|
61
|
+
array::filter(subscribers ?? [], |$s| <string>$s.id != $sid AND <datetime>$s.seenAt + 1h > time::now()),
|
|
62
|
+
{ id: $sid, seenAt: time::now() })`;
|
|
63
|
+
|
|
64
|
+
/** A `_00_query:<sha256>` id, the shape the client mints (and the pagehide beacon accepts). */
|
|
65
|
+
const viewId = () =>
|
|
66
|
+
new RecordId('_00_query', createHash('sha256').update(randomUUID()).digest('hex'));
|
|
67
|
+
|
|
68
|
+
describe('anonymous _00_query isolation (real SurrealDB)', () => {
|
|
69
|
+
let root: Surreal;
|
|
70
|
+
let database = '';
|
|
71
|
+
let reachable = false;
|
|
72
|
+
const open: Surreal[] = [];
|
|
73
|
+
|
|
74
|
+
// A plain TCP probe: the SDK never rejects a connect to a refused port.
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
const url = new URL(endpoint);
|
|
77
|
+
reachable = await new Promise<boolean>((resolve) => {
|
|
78
|
+
const socket = new Socket();
|
|
79
|
+
const done = (ok: boolean) => {
|
|
80
|
+
socket.destroy();
|
|
81
|
+
resolve(ok);
|
|
82
|
+
};
|
|
83
|
+
socket.setTimeout(1_000, () => done(false));
|
|
84
|
+
socket.once('error', () => done(false));
|
|
85
|
+
socket.connect(Number(url.port || 80), url.hostname, () => done(true));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
if (!reachable) return;
|
|
91
|
+
database = `anon_query_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
92
|
+
root = new Surreal();
|
|
93
|
+
await root.connect(endpoint, { reconnect: false });
|
|
94
|
+
await root.signin({ username: 'root', password: 'root' });
|
|
95
|
+
await root.use({ namespace: NS, database });
|
|
96
|
+
await root.query(ACCESS);
|
|
97
|
+
await root.query(queryTableSchema());
|
|
98
|
+
await root.query(cliSource('functions_remote.surql'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(async () => {
|
|
102
|
+
if (!reachable) return;
|
|
103
|
+
for (const db of open.splice(0)) await db.close().catch(() => undefined);
|
|
104
|
+
await root.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
|
|
105
|
+
await root.close();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/** A signed-out socket and its `session::id()`. */
|
|
109
|
+
async function guest(): Promise<[Surreal, string]> {
|
|
110
|
+
const db = new Surreal();
|
|
111
|
+
open.push(db);
|
|
112
|
+
await db.connect(endpoint, { reconnect: false });
|
|
113
|
+
await db.use({ namespace: NS, database });
|
|
114
|
+
const [sid] = (await db.query('RETURN <string>session::id()')) as [string];
|
|
115
|
+
return [db, sid];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A signed-in socket, its `session::id()`, `$auth.id` and access token. */
|
|
119
|
+
async function member(name: string): Promise<[Surreal, string, string, string]> {
|
|
120
|
+
const db = new Surreal();
|
|
121
|
+
open.push(db);
|
|
122
|
+
await db.connect(endpoint, { reconnect: false });
|
|
123
|
+
await db.use({ namespace: NS, database });
|
|
124
|
+
const tokens = (await db.signup({
|
|
125
|
+
namespace: NS,
|
|
126
|
+
database,
|
|
127
|
+
access: 'account',
|
|
128
|
+
variables: { name },
|
|
129
|
+
})) as string | { access: string };
|
|
130
|
+
const [sid, authId] = (await db.query(
|
|
131
|
+
'RETURN <string>session::id(); RETURN <string>$auth.id'
|
|
132
|
+
)) as [string, string];
|
|
133
|
+
return [db, sid, authId, typeof tokens === 'string' ? tokens : tokens.access];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `beaconSql`'s request: a fresh HTTP session, Bearer token only when signed in. */
|
|
137
|
+
async function beacon(sql: string, token?: string): Promise<void> {
|
|
138
|
+
const url = new URL(endpoint);
|
|
139
|
+
url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
|
|
140
|
+
url.pathname = url.pathname.replace(/\/rpc\/?$/, '') + '/sql';
|
|
141
|
+
const headers: Record<string, string> = {
|
|
142
|
+
Accept: 'application/json',
|
|
143
|
+
'Content-Type': 'text/plain',
|
|
144
|
+
'surreal-ns': NS,
|
|
145
|
+
'surreal-db': database,
|
|
146
|
+
};
|
|
147
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
148
|
+
const res = await fetch(url, { method: 'POST', headers, body: sql });
|
|
149
|
+
expect(res.ok).toBe(true);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const register = (id: RecordId<string>, sid: string, owner = 'anon') =>
|
|
153
|
+
root.query(SSP_REGISTER, { id, sid, owner, authParam: owner === 'anon' ? '' : owner });
|
|
154
|
+
const row = async (id: RecordId<string>) =>
|
|
155
|
+
((await root.query('SELECT * FROM ONLY $id', { id })) as [Record<string, any> | undefined])[0];
|
|
156
|
+
const count = async () =>
|
|
157
|
+
((await root.query('SELECT VALUE id FROM _00_query')) as [unknown[]])[0].length;
|
|
158
|
+
|
|
159
|
+
/** The client's register read-back, as `registerSelect` sends it after `fn::query::register`. */
|
|
160
|
+
const readBack = async (db: Surreal, id: RecordId<string>) =>
|
|
161
|
+
((await db.query(queryRowCountSelect(), { in: id })) as [unknown])[0] ?? null;
|
|
162
|
+
/** The client's lifecycle tick: did the heartbeat find the row? */
|
|
163
|
+
const heartbeatFound = async (db: Surreal, id: RecordId<string>) => {
|
|
164
|
+
const beat = heartbeatBatch([id]);
|
|
165
|
+
const [result] = (await db.query(beat.sql, beat.vars)) as [unknown];
|
|
166
|
+
return !heartbeatRowGone(result);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
it("a second guest cannot list, read, rewrite, release or delete the first guest's view", async (ctx) => {
|
|
170
|
+
if (!reachable) ctx.skip();
|
|
171
|
+
const [a, sidA] = await guest();
|
|
172
|
+
const [b, sidB] = await guest();
|
|
173
|
+
const qa = viewId();
|
|
174
|
+
await register(qa, sidA);
|
|
175
|
+
const before = await row(qa);
|
|
176
|
+
|
|
177
|
+
expect(await b.query('SELECT id, surql FROM _00_query')).toEqual([[]]);
|
|
178
|
+
expect(await readBack(b, qa)).toBeNull();
|
|
179
|
+
// The membership batch read. 3.0.x answers a hidden row with an empty
|
|
180
|
+
// object, which the client skips for want of an `id`.
|
|
181
|
+
const [batch] = (await b.query(queryRowCountBatchSelect(), { ins: [qa] })) as [
|
|
182
|
+
Array<{ id?: unknown }>,
|
|
183
|
+
];
|
|
184
|
+
expect(batch.filter((r) => r?.id)).toEqual([]);
|
|
185
|
+
|
|
186
|
+
expect(
|
|
187
|
+
await b.query(`UPDATE $id SET surql = 'SELECT * FROM secret', ttl = 1000w`, { id: qa })
|
|
188
|
+
).toEqual([[]]);
|
|
189
|
+
expect(await heartbeatFound(b, qa)).toBe(false);
|
|
190
|
+
expect(await b.query('RETURN fn::query::unsubscribe($id)', { id: qa })).toEqual([
|
|
191
|
+
{ released: false, remaining: 0 },
|
|
192
|
+
]);
|
|
193
|
+
// Session ids are not secret (they ride on `_00_list_ref_anon`), so naming
|
|
194
|
+
// the owner's must not help, over the socket or over the beacon's HTTP path.
|
|
195
|
+
await b.query('RETURN fn::query::leave($id, [$sid])', { id: qa, sid: sidA });
|
|
196
|
+
await beacon(leaveViewsBeacon([qa], [sidA])!);
|
|
197
|
+
await b.query('DELETE $id', { id: qa });
|
|
198
|
+
|
|
199
|
+
expect(await row(qa)).toEqual(before);
|
|
200
|
+
expect(await count()).toBe(1);
|
|
201
|
+
// ...and the owner never noticed.
|
|
202
|
+
expect(await readBack(a, qa)).toEqual({ rowCount: 3, state: 'ready' });
|
|
203
|
+
expect(sidB).not.toBe(sidA);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('no caller can plant a row, so nothing the SSP re-registers at boot comes from a client', async (ctx) => {
|
|
207
|
+
if (!reachable) ctx.skip();
|
|
208
|
+
const [g, sidG] = await guest();
|
|
209
|
+
const [u, , authU] = await member('alice');
|
|
210
|
+
const planted = (sid: string, owner: string) =>
|
|
211
|
+
`SET auth_id = '${owner}', surql = 'SELECT * FROM item', params = {}, ttl = 1h,
|
|
212
|
+
subscribers = [{ id: '${sid}', seenAt: time::now() }]`;
|
|
213
|
+
|
|
214
|
+
await g.query(`CREATE _00_query:g1 ${planted(sidG, 'anon')}`);
|
|
215
|
+
await g.query(`UPSERT _00_query:g2 ${planted(sidG, 'anon')}`);
|
|
216
|
+
await g.query(
|
|
217
|
+
`INSERT INTO _00_query { id: 'g3', auth_id: 'anon', ttl: 1h, subscribers: [{ id: '${sidG}' }] }`
|
|
218
|
+
);
|
|
219
|
+
await u.query(`CREATE _00_query:u1 ${planted('x', authU)}`);
|
|
220
|
+
await u.query(`UPSERT _00_query:u2 ${planted('x', authU)}`);
|
|
221
|
+
expect(await count()).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('the owner cannot rewrite what the SSP trusts on its own row either', async (ctx) => {
|
|
225
|
+
if (!reachable) ctx.skip();
|
|
226
|
+
const [g, sidG] = await guest();
|
|
227
|
+
const [u, sidU, authU] = await member('alice');
|
|
228
|
+
const qg = viewId();
|
|
229
|
+
const qu = viewId();
|
|
230
|
+
await register(qg, sidG);
|
|
231
|
+
await register(qu, sidU, authU);
|
|
232
|
+
// One field per statement: a write the table rule rejects as a whole would
|
|
233
|
+
// hide whether the field itself is locked. `params.auth.id` is the identity
|
|
234
|
+
// the SSP injects into permission predicates when it rebuilds the view at
|
|
235
|
+
// boot, `surql` skips the allowlist there, and turning a user's row into an
|
|
236
|
+
// anonymous one would publish its edges to the world-readable anon table.
|
|
237
|
+
const tampers = (owner: string): Record<string, string> => ({
|
|
238
|
+
surql: `surql = 'SELECT * FROM secret'`,
|
|
239
|
+
params: `params = { auth: { id: 'user:victim' }, access: 'account' }`,
|
|
240
|
+
ttl: 'ttl = 1000w',
|
|
241
|
+
auth_id: `auth_id = '${owner === 'anon' ? 'user:victim' : 'anon'}'`,
|
|
242
|
+
clientId: `clientId = 'forged'`,
|
|
243
|
+
rowCount: 'rowCount = 0',
|
|
244
|
+
state: `state = 'materializing'`,
|
|
245
|
+
lastActiveAt: 'lastActiveAt = time::now() + 1000w',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
for (const [db, id, owner] of [
|
|
249
|
+
[g, qg, 'anon'],
|
|
250
|
+
[u, qu, authU],
|
|
251
|
+
] as const) {
|
|
252
|
+
for (const [field, set] of Object.entries(tampers(owner))) {
|
|
253
|
+
const before = await row(id);
|
|
254
|
+
await db.query(`UPDATE $id SET ${set}`, { id });
|
|
255
|
+
expect((await row(id))?.[field], `${owner} ${field}`).toEqual(before?.[field]);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('the owning guest keeps its heartbeat and read-back, and after a reconnect gets them back by registering again', async (ctx) => {
|
|
261
|
+
if (!reachable) ctx.skip();
|
|
262
|
+
const [a, sidA] = await guest();
|
|
263
|
+
const [b] = await guest();
|
|
264
|
+
const qa = viewId();
|
|
265
|
+
await register(qa, sidA);
|
|
266
|
+
|
|
267
|
+
expect(await readBack(a, qa)).toEqual({ rowCount: 3, state: 'ready' });
|
|
268
|
+
const [batch] = (await a.query(queryRowCountBatchSelect(), { ins: [qa] })) as [
|
|
269
|
+
Array<{ id?: unknown; rowCount?: number }>,
|
|
270
|
+
];
|
|
271
|
+
expect(batch.map((r) => r.rowCount)).toEqual([3]);
|
|
272
|
+
const stamped = (await row(qa))?.lastActiveAt;
|
|
273
|
+
expect(await heartbeatFound(a, qa)).toBe(true);
|
|
274
|
+
expect((await row(qa))?.lastActiveAt).not.toEqual(stamped);
|
|
275
|
+
|
|
276
|
+
// A reconnect is a new session. Until it registers again its heartbeat
|
|
277
|
+
// finds nothing, which the client reads as a reclaimed view and
|
|
278
|
+
// re-registers; the SSP's registration then adds the new session.
|
|
279
|
+
await a.close();
|
|
280
|
+
const [a2, sidA2] = await guest();
|
|
281
|
+
expect(await heartbeatFound(a2, qa)).toBe(false);
|
|
282
|
+
expect(await readBack(a2, qa)).toBeNull();
|
|
283
|
+
await register(qa, sidA2);
|
|
284
|
+
expect(await heartbeatFound(a2, qa)).toBe(true);
|
|
285
|
+
expect(await readBack(a2, qa)).toEqual({ rowCount: 3, state: 'ready' });
|
|
286
|
+
expect((await row(qa))?.subscribers.map((s: { id: string }) => s.id)).toEqual([sidA, sidA2]);
|
|
287
|
+
|
|
288
|
+
// The bystander is still out.
|
|
289
|
+
expect(await heartbeatFound(b, qa)).toBe(false);
|
|
290
|
+
expect(await readBack(b, qa)).toBeNull();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('the owning guest can still release its view', async (ctx) => {
|
|
294
|
+
if (!reachable) ctx.skip();
|
|
295
|
+
const [a, sidA] = await guest();
|
|
296
|
+
const qa = viewId();
|
|
297
|
+
await register(qa, sidA);
|
|
298
|
+
expect(await a.query('RETURN fn::query::unsubscribe($id)', { id: qa })).toEqual([
|
|
299
|
+
{ released: true, remaining: 0 },
|
|
300
|
+
]);
|
|
301
|
+
expect(await row(qa)).toBeUndefined();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("a signed-in user's views are untouched: other tabs share them, the pagehide leave still pulls the expiry in", async (ctx) => {
|
|
305
|
+
if (!reachable) ctx.skip();
|
|
306
|
+
const [u, sidU, authU, token] = await member('alice');
|
|
307
|
+
const [bob] = await member('bob');
|
|
308
|
+
const [g] = await guest();
|
|
309
|
+
const qu = viewId();
|
|
310
|
+
await register(qu, sidU, authU);
|
|
311
|
+
|
|
312
|
+
// A second tab of the same user is not a subscriber yet and still gets in.
|
|
313
|
+
const tab2 = new Surreal();
|
|
314
|
+
open.push(tab2);
|
|
315
|
+
await tab2.connect(endpoint, { reconnect: false });
|
|
316
|
+
await tab2.use({ namespace: NS, database });
|
|
317
|
+
await tab2.authenticate(token);
|
|
318
|
+
expect(await heartbeatFound(tab2, qu)).toBe(true);
|
|
319
|
+
expect(await readBack(tab2, qu)).toEqual({ rowCount: 3, state: 'ready' });
|
|
320
|
+
|
|
321
|
+
expect(await heartbeatFound(bob, qu)).toBe(false);
|
|
322
|
+
expect(await readBack(g, qu)).toBeNull();
|
|
323
|
+
|
|
324
|
+
// The beacon names both tabs' sessions over a fresh HTTP session.
|
|
325
|
+
const [sid2] = (await tab2.query('RETURN <string>session::id()')) as [string];
|
|
326
|
+
await beacon(leaveViewsBeacon([qu], [sidU, sid2])!, token);
|
|
327
|
+
const [left] = (await root.query(
|
|
328
|
+
'SELECT VALUE duration::secs((lastActiveAt + ttl) - time::now()) FROM ONLY $id',
|
|
329
|
+
{
|
|
330
|
+
id: qu,
|
|
331
|
+
}
|
|
332
|
+
)) as [number];
|
|
333
|
+
expect(left).toBeGreaterThan(0);
|
|
334
|
+
expect(left).toBeLessThanOrEqual(60);
|
|
335
|
+
expect((await row(qu))?.subscribers).toEqual([]);
|
|
336
|
+
});
|
|
337
|
+
});
|