@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.
@@ -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')).toBe('s1');
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
  });
@@ -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
- return typeof left.salt === 'string' && left.salt !== '' && (left.authId ?? null) === authId ? left.salt : null;
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
- /** 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
+ /**
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') sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId }));
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': takeReloadSalt,
259
- 'salt.leave': leaveReloadSalt,
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 join = config.liveInlineBodies ? ' FETCH out' : '';
358
- const [uuid] = await s.remote.query<[Uuid]>(`LIVE SELECT * FROM ${table}${join}`);
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;
@@ -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, so a changed row usually arrives inline with its edge; an edge whose
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
- [id] = await this.q(`LIVE SELECT * FROM ${this.table} FETCH out`);
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
- [id] = await this.q(`LIVE SELECT * FROM ${this.table}`);
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 '_00_list_ref_anon';
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
+ });