@spooky-sync/core 0.0.1-canary.320 → 0.0.1-canary.322

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/src/live/index.ts CHANGED
@@ -10,10 +10,12 @@
10
10
  * - `fn::query::register($config)` plus the membership read-back in one
11
11
  * request (`registerSelect` / `registerVars`, the same builders the full
12
12
  * client's `registerRemote` uses; `$config = { id: _00_query:<key>, surql,
13
- * params, ttl }`, key = sha256(JSON.stringify({ surql, params, sessionId })));
13
+ * params, ttl, owner }`, key = sha256(JSON.stringify({ surql, params,
14
+ * sessionId })), owner = the feed's `ownerSecret`);
14
15
  * - bodies with `SELECT * FROM $ids`;
15
16
  * - 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
17
+ * feed (on the shared anon table, filtered to the feed's own edges; see
18
+ * `listRefLive`), so a changed row usually arrives inline with its edge; an edge whose
17
19
  * `out` is a bare id (a server that ignores FETCH) costs a body read, one
18
20
  * whose `out` is null (unreadable or deleted row) a membership re-read;
19
21
  * - `fn::query::heartbeat` at half the ttl, re-registering a reclaimed view;
@@ -30,6 +32,7 @@ import {
30
32
  bodySelect,
31
33
  heartbeatBatch,
32
34
  heartbeatRowGone,
35
+ listRefLive,
33
36
  registerSelect,
34
37
  registerVars,
35
38
  singleSnapshotSelect,
@@ -46,7 +49,13 @@ import { decodeTokenClaims } from '../modules/auth/impersonation';
46
49
  import { hashOfEdge, rowOfEdge } from '../utils/edges';
47
50
  import { sha256Hex } from '../utils/sha256';
48
51
  import { parseQueryParams } from '../utils/parser';
49
- import { encodeRecordId, parseDuration, parseRecordIdString, withTimeout } from '../utils/index';
52
+ import {
53
+ encodeRecordId,
54
+ mintOwnerSecret,
55
+ parseDuration,
56
+ parseRecordIdString,
57
+ withTimeout,
58
+ } from '../utils/index';
50
59
 
51
60
  export type LiveToken = string | null | undefined;
52
61
  /** A token, or a function returning one (called on every connect). */
@@ -90,6 +99,14 @@ export interface LiveFeedOptions {
90
99
  ttl?: string;
91
100
  /** Salt of the `_00_query` keys. Default: a fresh random id per feed. */
92
101
  sessionId?: string;
102
+ /**
103
+ * The secret the feed's registrations prove they own their views with
104
+ * (`$config.owner`): a signed-out feed's view answers only to the secret it
105
+ * was registered with. Default: a fresh one per feed. Pass the same one
106
+ * wherever you pass the same `sessionId`, or the second registrant of a
107
+ * shared anonymous view is refused.
108
+ */
109
+ ownerSecret?: string;
93
110
  /**
94
111
  * The generated schema. Used to type query params (record ids, datetimes)
95
112
  * exactly like the full client does before registering. Taken from a query
@@ -201,6 +218,7 @@ const FLUSH_MS = 10;
201
218
  const FETCH_BATCH_MS = 15;
202
219
  const REREAD_MS = 50;
203
220
  const RELEASE_TIMEOUT_MS = 2_000;
221
+ const MAX_SOCKETS = 16;
204
222
 
205
223
  interface Edge {
206
224
  rid: RecordId;
@@ -381,6 +399,9 @@ class Feed implements LiveFeed {
381
399
  private liveStarting: Promise<boolean> | null = null;
382
400
  private readonly subs = new Map<string, SubState>();
383
401
  private readonly sessionId: string;
402
+ private readonly ownerSecret: string;
403
+ /** `session::id()` of every socket this feed has had, oldest first. */
404
+ private sockets: string[] = [];
384
405
  private readonly ttl: string;
385
406
  private readonly timeoutMs: number;
386
407
  private isClosed = false;
@@ -398,6 +419,7 @@ class Feed implements LiveFeed {
398
419
 
399
420
  constructor(private readonly options: LiveFeedOptions) {
400
421
  this.sessionId = options.sessionId ?? randomId();
422
+ this.ownerSecret = options.ownerSecret ?? mintOwnerSecret();
401
423
  this.ttl = options.ttl ?? '10m';
402
424
  this.timeoutMs = options.timeoutMs ?? 15_000;
403
425
  }
@@ -498,10 +520,27 @@ class Feed implements LiveFeed {
498
520
  );
499
521
  }
500
522
  this.db = db;
523
+ void this.noteSession();
524
+ }
525
+
526
+ /**
527
+ * Remember the `session::id()` of the socket now open. Views are registered
528
+ * once and not again after a reconnect, so their edges keep naming the socket
529
+ * that registered them; the anonymous LIVE has to keep hearing those.
530
+ */
531
+ private async noteSession(): Promise<void> {
532
+ try {
533
+ const [sid] = await this.q('RETURN <string>session::id()');
534
+ if (typeof sid !== 'string' || this.sockets.includes(sid)) return;
535
+ this.sockets = [...this.sockets, sid].slice(-MAX_SOCKETS);
536
+ } catch {
537
+ // The socket now open is matched by `session::id()` itself.
538
+ }
501
539
  }
502
540
 
503
541
  /** After a reconnect: LIVE again, then re-read every view (events in the gap are lost). */
504
542
  private async recover(): Promise<void> {
543
+ void this.noteSession();
505
544
  try {
506
545
  await this.ensureLive();
507
546
  for (const sub of this.subs.values()) if (sub.loaded) this.scheduleReread(sub);
@@ -557,14 +596,18 @@ class Feed implements LiveFeed {
557
596
  if (!this.liveStarting) {
558
597
  this.liveStarting = (async () => {
559
598
  try {
599
+ const live = (inlineBodies: boolean) =>
600
+ listRefLive(this.table!, { inlineBodies, sessionIds: this.sockets });
560
601
  let id: unknown;
561
602
  try {
562
- [id] = await this.q(`LIVE SELECT * FROM ${this.table} FETCH out`);
603
+ const { sql, vars } = live(true);
604
+ [id] = await this.q(sql, vars);
563
605
  } catch (error) {
564
606
  // A server that refuses FETCH on LIVE still has the doorbell:
565
607
  // bare-id edges fall back to a body read in `onLive`.
566
608
  this.report(error);
567
- [id] = await this.q(`LIVE SELECT * FROM ${this.table}`);
609
+ const { sql, vars } = live(false);
610
+ [id] = await this.q(sql, vars);
568
611
  }
569
612
  const stream = await this.db!.liveOf(id as never);
570
613
  const off = stream.subscribe((message) => this.onLive(message));
@@ -778,7 +821,10 @@ class Feed implements LiveFeed {
778
821
 
779
822
  /** Register (or re-register) a view and read its membership back, in one request. */
780
823
  private async register(sub: SubState): Promise<Snapshot> {
781
- const results = await this.q(registerSelect(this.table!), registerVars(sub.payload));
824
+ const results = await this.q(
825
+ registerSelect(this.table!),
826
+ registerVars({ ...sub.payload, owner: this.ownerSecret })
827
+ );
782
828
  let snap = snapshotAt(results, 1);
783
829
  for (let i = 0; snap && snap.state === 'materializing' && i < MATERIALIZE_RETRIES; i++) {
784
830
  await sleep(MATERIALIZE_RETRY_MS);
@@ -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,496 @@
1
+ import { createServer, type Server } from 'node:http';
2
+ import { Socket, type AddressInfo } from 'node:net';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
7
+ import { RecordId, Surreal } from 'surrealdb';
8
+ import { liveEndpoint } from '../live/index';
9
+ import {
10
+ heartbeatBatch,
11
+ heartbeatRowGone,
12
+ leaveViewsBeacon,
13
+ queryRowCountBatchSelect,
14
+ queryRowCountSelect,
15
+ } from './sql';
16
+
17
+ /**
18
+ * Every signed-out visitor shares the one `anon` identity, so `_00_query`
19
+ * tells their views apart by session: an anonymous row answers only to the
20
+ * sessions in its `subscribers`. A second visitor must not be able to list,
21
+ * read, rewrite, plant or delete the first one's view, while the first keeps
22
+ * its heartbeat and its registration read-back, also after a reconnect.
23
+ *
24
+ * Runs the shipped `_00_query` block and `fn::query::*` functions from
25
+ * `apps/cli/src` against a real SurrealDB, because the guarantee is the
26
+ * engine's evaluation of the table and field permissions. Boot one first, e.g.:
27
+ * docker run -d --name spooky-surreal-it -p 18011:8000 \
28
+ * surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
29
+ *
30
+ * Override the endpoint with SURREAL_IT_ENDPOINT. Skips if unreachable.
31
+ *
32
+ * The registration tests also run the shipped `fn::query::register`, whose
33
+ * `http::post` must reach a stand-in SSP this test serves. SurrealDB reaches it
34
+ * at SURREAL_IT_CALLBACK_HOST (default `host.docker.internal`, the host as seen
35
+ * from Docker Desktop); those tests skip when it cannot.
36
+ */
37
+ const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
38
+ const callbackHost = process.env.SURREAL_IT_CALLBACK_HOST ?? 'host.docker.internal';
39
+ const NS = 'it';
40
+
41
+ const cliSource = (file: string) =>
42
+ readFileSync(fileURLToPath(new URL(`../../../../apps/cli/src/${file}`, import.meta.url)), 'utf8');
43
+
44
+ /**
45
+ * The SET clause ssp-node puts first in every registration write, read out of
46
+ * `packages/ssp-node/src/node.rs` so this test runs the shipped text.
47
+ */
48
+ function ownerGuard(): string {
49
+ const src = readFileSync(
50
+ fileURLToPath(new URL('../../../ssp-node/src/node.rs', import.meta.url)),
51
+ 'utf8'
52
+ );
53
+ const m = /pub const OWNER_GUARD: &str = "((?:[^"\\]|\\.)*)";/s.exec(src);
54
+ if (!m) throw new Error('OWNER_GUARD not found in ssp-node/src/node.rs');
55
+ // A Rust `\` at a line end drops the newline and the next line's indent.
56
+ return m[1].replace(/\\\n\s*/g, '');
57
+ }
58
+
59
+ const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
60
+
61
+ /** Just the `_00_query` block: the rest of the file needs tables and modules this test does not. */
62
+ function queryTableSchema(): string {
63
+ const meta = cliSource('meta_tables_remote.surql');
64
+ const start = meta.indexOf('DEFINE TABLE OVERWRITE _00_query SCHEMALESS');
65
+ const end = meta.indexOf('-- SPOOKY LIST REF');
66
+ if (start < 0 || end < start)
67
+ throw new Error('_00_query block not found in meta_tables_remote.surql');
68
+ return meta.slice(start, end);
69
+ }
70
+
71
+ const ACCESS = `
72
+ DEFINE TABLE OVERWRITE user SCHEMALESS PERMISSIONS FOR select WHERE id = $auth.id;
73
+ DEFINE ACCESS OVERWRITE account ON DATABASE TYPE RECORD
74
+ SIGNUP (CREATE user SET name = $name)
75
+ SIGNIN (SELECT * FROM user WHERE name = $name)
76
+ DURATION FOR TOKEN 1h, FOR SESSION 1h;
77
+ `;
78
+
79
+ /** What the SSP writes, as root, when a session registers a view (`node.rs`, cold path). */
80
+ const SSP_REGISTER = `
81
+ UPSERT $id SET clientId = $sid, auth_id = $owner, surql = 'SELECT * FROM item',
82
+ params = { auth: { id: $authParam }, access: '' }, ttl = 1h, lastActiveAt = time::now(),
83
+ rowCount = 3, state = 'ready',
84
+ subscribers = array::append(
85
+ array::filter(subscribers ?? [], |$s| <string>$s.id != $sid AND <datetime>$s.seenAt + 1h > time::now()),
86
+ { id: $sid, seenAt: time::now() })`;
87
+
88
+ /**
89
+ * What the SSP writes, as root, when a session joins an anonymous view that
90
+ * already exists (`join_registered_view`), with the shipped ownership guard.
91
+ */
92
+ const SSP_JOIN = () => `
93
+ UPSERT type::record($id) SET ${ownerGuard()}, clientId = $sid, auth_id = auth_id ?? 'anon',
94
+ surql = surql ?? 'SELECT * FROM item', params = params ?? { auth: { id: '' }, access: '' },
95
+ ttl = ttl ?? 1h, lastActiveAt = time::now(), rowCount = rowCount ?? 3, state = state ?? 'ready',
96
+ subscribers = array::append(
97
+ array::filter(subscribers ?? [], |$s| <string>$s.id != $sid AND <datetime>$s.seenAt + 1h > time::now()),
98
+ { id: $sid, seenAt: time::now() })`;
99
+
100
+ /** A `_00_query:<sha256>` id, the shape the client mints (and the pagehide beacon accepts). */
101
+ const viewId = () =>
102
+ new RecordId('_00_query', createHash('sha256').update(randomUUID()).digest('hex'));
103
+
104
+ describe('anonymous _00_query isolation (real SurrealDB)', () => {
105
+ let root: Surreal;
106
+ let database = '';
107
+ let reachable = false;
108
+ const open: Surreal[] = [];
109
+
110
+ // The stand-in SSP: records what `fn::query::register` posts and answers it
111
+ // as ssp-node's warm join does (409 `owner_mismatch` when the guard throws).
112
+ const posted: Array<Record<string, unknown>> = [];
113
+ let ssp: Server | null = null;
114
+ let sspUrl = '';
115
+ let sspReachable: boolean | null = null;
116
+
117
+ // A plain TCP probe: the SDK never rejects a connect to a refused port.
118
+ beforeAll(async () => {
119
+ const url = new URL(endpoint);
120
+ reachable = await new Promise<boolean>((resolve) => {
121
+ const socket = new Socket();
122
+ const done = (ok: boolean) => {
123
+ socket.destroy();
124
+ resolve(ok);
125
+ };
126
+ socket.setTimeout(1_000, () => done(false));
127
+ socket.once('error', () => done(false));
128
+ socket.connect(Number(url.port || 80), url.hostname, () => done(true));
129
+ });
130
+ ssp = createServer(async (req, res) => {
131
+ let text = '';
132
+ for await (const chunk of req) text += chunk;
133
+ const body = JSON.parse(text) as Record<string, unknown>;
134
+ posted.push(body);
135
+ if (body.probe) return void res.writeHead(200).end('null');
136
+ try {
137
+ await root.query(SSP_JOIN(), {
138
+ id: body.id,
139
+ sid: body.clientId,
140
+ ownerHash: body.ownerHash ?? '',
141
+ });
142
+ res.writeHead(200, { 'content-type': 'application/json' }).end('null');
143
+ } catch (error) {
144
+ const refused = String(error).includes('owner_mismatch');
145
+ res
146
+ .writeHead(refused ? 409 : 500, { 'content-type': 'application/json' })
147
+ .end(
148
+ JSON.stringify({
149
+ code: refused ? 'owner_mismatch' : 'db_error',
150
+ message: String(error),
151
+ })
152
+ );
153
+ }
154
+ });
155
+ await new Promise<void>((resolve) => ssp!.listen(0, '0.0.0.0', resolve));
156
+ sspUrl = `http://${callbackHost}:${(ssp.address() as AddressInfo).port}`;
157
+ });
158
+
159
+ afterAll(async () => {
160
+ await new Promise((resolve) => ssp?.close(resolve));
161
+ });
162
+
163
+ /** Load the shipped `fn::query::register` against the stand-in; false when SurrealDB cannot reach it. */
164
+ async function registerFunctions(): Promise<boolean> {
165
+ if (sspReachable === null) {
166
+ sspReachable = await root
167
+ .query('RETURN http::post($url, { probe: true })', { url: `${sspUrl}/probe` })
168
+ .then(
169
+ () => true,
170
+ () => false
171
+ );
172
+ }
173
+ if (!sspReachable) return false;
174
+ await root.query(
175
+ cliSource('functions_remote_singlenode.surql')
176
+ .replaceAll('{{ENDPOINT}}', sspUrl)
177
+ .replaceAll('{{SECRET}}', 'it-secret')
178
+ );
179
+ return true;
180
+ }
181
+
182
+ beforeEach(async () => {
183
+ if (!reachable) return;
184
+ database = `anon_query_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
185
+ root = new Surreal();
186
+ await root.connect(endpoint, { reconnect: false });
187
+ await root.signin({ username: 'root', password: 'root' });
188
+ await root.use({ namespace: NS, database });
189
+ await root.query(ACCESS);
190
+ await root.query(queryTableSchema());
191
+ await root.query(cliSource('functions_remote.surql'));
192
+ });
193
+
194
+ afterEach(async () => {
195
+ if (!reachable) return;
196
+ for (const db of open.splice(0)) await db.close().catch(() => undefined);
197
+ await root.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
198
+ await root.close();
199
+ });
200
+
201
+ /** A signed-out socket and its `session::id()`. */
202
+ async function guest(): Promise<[Surreal, string]> {
203
+ const db = new Surreal();
204
+ open.push(db);
205
+ await db.connect(endpoint, { reconnect: false });
206
+ await db.use({ namespace: NS, database });
207
+ const [sid] = (await db.query('RETURN <string>session::id()')) as [string];
208
+ return [db, sid];
209
+ }
210
+
211
+ /** A signed-in socket, its `session::id()`, `$auth.id` and access token. */
212
+ async function member(name: string): Promise<[Surreal, string, string, string]> {
213
+ const db = new Surreal();
214
+ open.push(db);
215
+ await db.connect(endpoint, { reconnect: false });
216
+ await db.use({ namespace: NS, database });
217
+ const tokens = (await db.signup({
218
+ namespace: NS,
219
+ database,
220
+ access: 'account',
221
+ variables: { name },
222
+ })) as string | { access: string };
223
+ const [sid, authId] = (await db.query(
224
+ 'RETURN <string>session::id(); RETURN <string>$auth.id'
225
+ )) as [string, string];
226
+ return [db, sid, authId, typeof tokens === 'string' ? tokens : tokens.access];
227
+ }
228
+
229
+ /** `beaconSql`'s request: a fresh HTTP session, Bearer token only when signed in. */
230
+ async function beacon(sql: string, token?: string): Promise<void> {
231
+ const url = new URL(endpoint);
232
+ url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
233
+ url.pathname = url.pathname.replace(/\/rpc\/?$/, '') + '/sql';
234
+ const headers: Record<string, string> = {
235
+ Accept: 'application/json',
236
+ 'Content-Type': 'text/plain',
237
+ 'surreal-ns': NS,
238
+ 'surreal-db': database,
239
+ };
240
+ if (token) headers.Authorization = `Bearer ${token}`;
241
+ const res = await fetch(url, { method: 'POST', headers, body: sql });
242
+ expect(res.ok).toBe(true);
243
+ }
244
+
245
+ const register = (id: RecordId<string>, sid: string, owner = 'anon') =>
246
+ root.query(SSP_REGISTER, { id, sid, owner, authParam: owner === 'anon' ? '' : owner });
247
+ const row = async (id: RecordId<string>) =>
248
+ ((await root.query('SELECT * FROM ONLY $id', { id })) as [Record<string, any> | undefined])[0];
249
+ const count = async () =>
250
+ ((await root.query('SELECT VALUE id FROM _00_query')) as [unknown[]])[0].length;
251
+
252
+ /** The client's register read-back, as `registerSelect` sends it after `fn::query::register`. */
253
+ const readBack = async (db: Surreal, id: RecordId<string>) =>
254
+ ((await db.query(queryRowCountSelect(), { in: id })) as [unknown])[0] ?? null;
255
+ /** The client's lifecycle tick: did the heartbeat find the row? */
256
+ const heartbeatFound = async (db: Surreal, id: RecordId<string>) => {
257
+ const beat = heartbeatBatch([id]);
258
+ const [result] = (await db.query(beat.sql, beat.vars)) as [unknown];
259
+ return !heartbeatRowGone(result);
260
+ };
261
+
262
+ it("a second guest cannot list, read, rewrite, release or delete the first guest's view", async (ctx) => {
263
+ if (!reachable) ctx.skip();
264
+ const [a, sidA] = await guest();
265
+ const [b, sidB] = await guest();
266
+ const qa = viewId();
267
+ await register(qa, sidA);
268
+ const before = await row(qa);
269
+
270
+ expect(await b.query('SELECT id, surql FROM _00_query')).toEqual([[]]);
271
+ expect(await readBack(b, qa)).toBeNull();
272
+ // The membership batch read. 3.0.x answers a hidden row with an empty
273
+ // object, which the client skips for want of an `id`.
274
+ const [batch] = (await b.query(queryRowCountBatchSelect(), { ins: [qa] })) as [
275
+ Array<{ id?: unknown }>,
276
+ ];
277
+ expect(batch.filter((r) => r?.id)).toEqual([]);
278
+
279
+ expect(
280
+ await b.query(`UPDATE $id SET surql = 'SELECT * FROM secret', ttl = 1000w`, { id: qa })
281
+ ).toEqual([[]]);
282
+ expect(await heartbeatFound(b, qa)).toBe(false);
283
+ expect(await b.query('RETURN fn::query::unsubscribe($id)', { id: qa })).toEqual([
284
+ { released: false, remaining: 0 },
285
+ ]);
286
+ // Session ids are not secret (they ride on `_00_list_ref_anon`), so naming
287
+ // the owner's must not help, over the socket or over the beacon's HTTP path.
288
+ await b.query('RETURN fn::query::leave($id, [$sid])', { id: qa, sid: sidA });
289
+ await beacon(leaveViewsBeacon([qa], [sidA])!);
290
+ await b.query('DELETE $id', { id: qa });
291
+
292
+ expect(await row(qa)).toEqual(before);
293
+ expect(await count()).toBe(1);
294
+ // ...and the owner never noticed.
295
+ expect(await readBack(a, qa)).toEqual({ rowCount: 3, state: 'ready' });
296
+ expect(sidB).not.toBe(sidA);
297
+ });
298
+
299
+ it('no caller can plant a row, so nothing the SSP re-registers at boot comes from a client', async (ctx) => {
300
+ if (!reachable) ctx.skip();
301
+ const [g, sidG] = await guest();
302
+ const [u, , authU] = await member('alice');
303
+ const planted = (sid: string, owner: string) =>
304
+ `SET auth_id = '${owner}', surql = 'SELECT * FROM item', params = {}, ttl = 1h,
305
+ subscribers = [{ id: '${sid}', seenAt: time::now() }]`;
306
+
307
+ await g.query(`CREATE _00_query:g1 ${planted(sidG, 'anon')}`);
308
+ await g.query(`UPSERT _00_query:g2 ${planted(sidG, 'anon')}`);
309
+ await g.query(
310
+ `INSERT INTO _00_query { id: 'g3', auth_id: 'anon', ttl: 1h, subscribers: [{ id: '${sidG}' }] }`
311
+ );
312
+ await u.query(`CREATE _00_query:u1 ${planted('x', authU)}`);
313
+ await u.query(`UPSERT _00_query:u2 ${planted('x', authU)}`);
314
+ expect(await count()).toBe(0);
315
+ });
316
+
317
+ it('the owner cannot rewrite what the SSP trusts on its own row either', async (ctx) => {
318
+ if (!reachable) ctx.skip();
319
+ const [g, sidG] = await guest();
320
+ const [u, sidU, authU] = await member('alice');
321
+ const qg = viewId();
322
+ const qu = viewId();
323
+ await register(qg, sidG);
324
+ await register(qu, sidU, authU);
325
+ // One field per statement: a write the table rule rejects as a whole would
326
+ // hide whether the field itself is locked. `params.auth.id` is the identity
327
+ // the SSP injects into permission predicates when it rebuilds the view at
328
+ // boot, `surql` skips the allowlist there, and turning a user's row into an
329
+ // anonymous one would publish its edges to the world-readable anon table.
330
+ const tampers = (owner: string): Record<string, string> => ({
331
+ surql: `surql = 'SELECT * FROM secret'`,
332
+ params: `params = { auth: { id: 'user:victim' }, access: 'account' }`,
333
+ ttl: 'ttl = 1000w',
334
+ auth_id: `auth_id = '${owner === 'anon' ? 'user:victim' : 'anon'}'`,
335
+ clientId: `clientId = 'forged'`,
336
+ rowCount: 'rowCount = 0',
337
+ state: `state = 'materializing'`,
338
+ lastActiveAt: 'lastActiveAt = time::now() + 1000w',
339
+ });
340
+
341
+ for (const [db, id, owner] of [
342
+ [g, qg, 'anon'],
343
+ [u, qu, authU],
344
+ ] as const) {
345
+ for (const [field, set] of Object.entries(tampers(owner))) {
346
+ const before = await row(id);
347
+ await db.query(`UPDATE $id SET ${set}`, { id });
348
+ expect((await row(id))?.[field], `${owner} ${field}`).toEqual(before?.[field]);
349
+ }
350
+ }
351
+ });
352
+
353
+ it('the owning guest keeps its heartbeat and read-back, and after a reconnect gets them back by registering again', async (ctx) => {
354
+ if (!reachable) ctx.skip();
355
+ const [a, sidA] = await guest();
356
+ const [b] = await guest();
357
+ const qa = viewId();
358
+ await register(qa, sidA);
359
+
360
+ expect(await readBack(a, qa)).toEqual({ rowCount: 3, state: 'ready' });
361
+ const [batch] = (await a.query(queryRowCountBatchSelect(), { ins: [qa] })) as [
362
+ Array<{ id?: unknown; rowCount?: number }>,
363
+ ];
364
+ expect(batch.map((r) => r.rowCount)).toEqual([3]);
365
+ const stamped = (await row(qa))?.lastActiveAt;
366
+ expect(await heartbeatFound(a, qa)).toBe(true);
367
+ expect((await row(qa))?.lastActiveAt).not.toEqual(stamped);
368
+
369
+ // A reconnect is a new session. Until it registers again its heartbeat
370
+ // finds nothing, which the client reads as a reclaimed view and
371
+ // re-registers; the SSP's registration then adds the new session.
372
+ await a.close();
373
+ const [a2, sidA2] = await guest();
374
+ expect(await heartbeatFound(a2, qa)).toBe(false);
375
+ expect(await readBack(a2, qa)).toBeNull();
376
+ await register(qa, sidA2);
377
+ expect(await heartbeatFound(a2, qa)).toBe(true);
378
+ expect(await readBack(a2, qa)).toEqual({ rowCount: 3, state: 'ready' });
379
+ expect((await row(qa))?.subscribers.map((s: { id: string }) => s.id)).toEqual([sidA, sidA2]);
380
+
381
+ // The bystander is still out.
382
+ expect(await heartbeatFound(b, qa)).toBe(false);
383
+ expect(await readBack(b, qa)).toBeNull();
384
+ });
385
+
386
+ it("a guest who read another guest's view id cannot join it through fn::query::register; the owner's reconnect can", async (ctx) => {
387
+ if (!reachable) ctx.skip();
388
+ if (!(await registerFunctions())) ctx.skip();
389
+ const [a, sidA] = await guest();
390
+ const [b] = await guest();
391
+ const qa = viewId();
392
+ const register = (db: Surreal, owner?: string) =>
393
+ db.query('fn::query::register($config)', {
394
+ config: {
395
+ id: qa,
396
+ surql: 'SELECT * FROM item',
397
+ params: {},
398
+ ttl: '1h',
399
+ ...(owner ? { owner } : {}),
400
+ },
401
+ });
402
+
403
+ await register(a, 'secret-a');
404
+ const sent = posted.at(-1)!;
405
+ expect(sent).toMatchObject({ clientId: sidA, ownerHash: sha256('secret-a') });
406
+ // Only the hash leaves the database, and never inside `params` (the SSP's
407
+ // merge key is built from them).
408
+ expect(JSON.stringify(sent)).not.toContain('secret-a');
409
+ expect(String(sent.params)).not.toContain(sha256('secret-a'));
410
+ const before = await row(qa);
411
+ expect(before?.ownerHash).toBe(sha256('secret-a'));
412
+
413
+ // Anonymous view ids are world-readable on `_00_list_ref_anon`. With its
414
+ // own secret or with none, the stranger's registration is refused, so it
415
+ // never lands in `subscribers` and its DELETE still matches nothing.
416
+ await expect(register(b, 'secret-b')).rejects.toThrow();
417
+ await expect(register(b)).rejects.toThrow();
418
+ await b.query('DELETE $id', { id: qa });
419
+ expect(await row(qa)).toEqual(before);
420
+ expect(await readBack(b, qa)).toBeNull();
421
+
422
+ // The owner's reconnect: a new socket, the same page secret.
423
+ await a.close();
424
+ const [a2, sidA2] = await guest();
425
+ expect(await heartbeatFound(a2, qa)).toBe(false);
426
+ await register(a2, 'secret-a');
427
+ expect((await row(qa))?.subscribers.map((s: { id: string }) => s.id)).toEqual([sidA, sidA2]);
428
+ expect(await heartbeatFound(a2, qa)).toBe(true);
429
+ expect(await readBack(a2, qa)).toEqual({ rowCount: 3, state: 'ready' });
430
+ });
431
+
432
+ it('no caller can read or rewrite the owner hash, not even the owner', async (ctx) => {
433
+ if (!reachable) ctx.skip();
434
+ const [g, sidG] = await guest();
435
+ const qg = viewId();
436
+ await register(qg, sidG);
437
+ await root.query(`UPDATE $id SET ownerHash = 'stored'`, { id: qg });
438
+
439
+ const [own] = (await g.query('SELECT * FROM ONLY $id', { id: qg })) as [
440
+ Record<string, unknown>,
441
+ ];
442
+ expect(own.subscribers).toBeDefined();
443
+ expect(own).not.toHaveProperty('ownerHash');
444
+ for (const set of [`ownerHash = 'mine'`, 'ownerHash = NONE']) {
445
+ await g.query(`UPDATE $id SET ${set}`, { id: qg });
446
+ expect((await row(qg))?.ownerHash).toBe('stored');
447
+ }
448
+ // The heartbeat still writes what it needs to around the frozen field.
449
+ expect(await heartbeatFound(g, qg)).toBe(true);
450
+ });
451
+
452
+ it('the owning guest can still release its view', async (ctx) => {
453
+ if (!reachable) ctx.skip();
454
+ const [a, sidA] = await guest();
455
+ const qa = viewId();
456
+ await register(qa, sidA);
457
+ expect(await a.query('RETURN fn::query::unsubscribe($id)', { id: qa })).toEqual([
458
+ { released: true, remaining: 0 },
459
+ ]);
460
+ expect(await row(qa)).toBeUndefined();
461
+ });
462
+
463
+ it("a signed-in user's views are untouched: other tabs share them, the pagehide leave still pulls the expiry in", async (ctx) => {
464
+ if (!reachable) ctx.skip();
465
+ const [u, sidU, authU, token] = await member('alice');
466
+ const [bob] = await member('bob');
467
+ const [g] = await guest();
468
+ const qu = viewId();
469
+ await register(qu, sidU, authU);
470
+
471
+ // A second tab of the same user is not a subscriber yet and still gets in.
472
+ const tab2 = new Surreal();
473
+ open.push(tab2);
474
+ await tab2.connect(endpoint, { reconnect: false });
475
+ await tab2.use({ namespace: NS, database });
476
+ await tab2.authenticate(token);
477
+ expect(await heartbeatFound(tab2, qu)).toBe(true);
478
+ expect(await readBack(tab2, qu)).toEqual({ rowCount: 3, state: 'ready' });
479
+
480
+ expect(await heartbeatFound(bob, qu)).toBe(false);
481
+ expect(await readBack(g, qu)).toBeNull();
482
+
483
+ // The beacon names both tabs' sessions over a fresh HTTP session.
484
+ const [sid2] = (await tab2.query('RETURN <string>session::id()')) as [string];
485
+ await beacon(leaveViewsBeacon([qu], [sidU, sid2])!, token);
486
+ const [left] = (await root.query(
487
+ 'SELECT VALUE duration::secs((lastActiveAt + ttl) - time::now()) FROM ONLY $id',
488
+ {
489
+ id: qu,
490
+ }
491
+ )) as [number];
492
+ expect(left).toBeGreaterThan(0);
493
+ expect(left).toBeLessThanOrEqual(60);
494
+ expect((await row(qu))?.subscribers).toEqual([]);
495
+ });
496
+ });