@spooky-sync/core 0.0.1-canary.321 → 0.0.1-canary.323

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,7 +25,7 @@ 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')).toEqual({ salt: 's1', sessionIds: [] });
28
+ expect(takeReloadSalt('user:a')).toEqual({ salt: 's1', sessionIds: [], owner: null });
29
29
  // Taken, not read: a tab duplicated from this page finds nothing.
30
30
  expect(takeReloadSalt('user:a')).toBeNull();
31
31
  });
@@ -34,9 +34,23 @@ describe('reload salt', () => {
34
34
  // The reloaded page's views keep edges stamped by these sockets, and the
35
35
  // anonymous LIVE only hears sessions it knows.
36
36
  leaveReloadSalt('s1', null, ['sid-1', 'sid-2']);
37
- expect(takeReloadSalt(null)).toEqual({ salt: 's1', sessionIds: ['sid-1', 'sid-2'] });
37
+ expect(takeReloadSalt(null)).toEqual({ salt: 's1', sessionIds: ['sid-1', 'sid-2'], owner: null });
38
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'] });
39
+ expect(takeReloadSalt(null)).toEqual({ salt: 's2', sessionIds: ['ok'], owner: null });
40
+ });
41
+
42
+ it("the page's owner secret travels with the salt, so the reloaded page still owns its anonymous views", () => {
43
+ leaveReloadSalt('s1', null, [], 'secret-1');
44
+ expect(takeReloadSalt(null)).toEqual({ salt: 's1', sessionIds: [], owner: 'secret-1' });
45
+ // Gone with the salt: a duplicated tab mints its own.
46
+ expect(takeReloadSalt(null)).toBeNull();
47
+ // A different principal re-salts, and the secret is not handed over either.
48
+ leaveReloadSalt('s2', 'user:a', [], 'secret-2');
49
+ expect(takeReloadSalt(null)).toBeNull();
50
+ for (const owner of ['', 7, null]) {
51
+ (g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', JSON.stringify({ salt: 's3', authId: null, owner }));
52
+ expect(takeReloadSalt(null)?.owner).toBeNull();
53
+ }
40
54
  });
41
55
 
42
56
  it('another principal, or none, mints a fresh salt', () => {
@@ -21,12 +21,13 @@ import { TabsCoordinator, type CoordinatorHooks } from '../services/tabs/coordin
21
21
  import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
22
22
  import { computeTabsFingerprint, hash53 } from '../services/tabs/protocol';
23
23
  import type { SqliteCacheEngine } from '../services/database/sqlite-cache-engine';
24
+ import { columnFingerprints } from '../services/database/sqlite-column-reset';
24
25
  import type { BlobCache } from '../services/blobs/index';
25
26
  import { MemoryBlobStore, createBlobCache, resolveBlobBudget } from '../services/blobs/index';
26
27
  import type { Adapters } from '../kernel/interpreter';
27
28
  import type { RuntimeEvent } from '../kernel/events';
28
29
  import type { ServiceCalls } from '../kernel/effects';
29
- import { encodeRecordId } from '../utils/index';
30
+ import { encodeRecordId, mintOwnerSecret } from '../utils/index';
30
31
  import { sha256Hex } from '../utils/sha256';
31
32
  import { leaveViewsBeacon, listRefLive } from '../query/sql';
32
33
  import { hashOfEdge, rowOfEdge } from '../utils/edges';
@@ -59,20 +60,28 @@ const RELOAD_SALT_KEY = 'sp00ky:salt';
59
60
  * query onto the `_00_query` row it already had, which the SSP answers from
60
61
  * the live view instead of building a new one.
61
62
  *
63
+ * The page's owner secret travels with it: an anonymous row answers only to
64
+ * the secret it was registered with, so the next page needs the same one to
65
+ * rejoin those views. `null` from a page that predates it; the next page then
66
+ * mints one, and the rows it left (which hold no secret) take the new one.
67
+ *
62
68
  * Taken, not read: the key is absent while a page lives, so a duplicated tab
63
69
  * (which copies sessionStorage) mints its own salt rather than sharing this
64
70
  * tab's remote views.
65
71
  */
66
- export function takeReloadSalt(authId: string | null): { salt: string; sessionIds: string[] } | null {
72
+ export function takeReloadSalt(
73
+ authId: string | null
74
+ ): { salt: string; sessionIds: string[]; owner: string | null } | null {
67
75
  try {
68
76
  if (typeof sessionStorage === 'undefined') return null;
69
77
  const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
70
78
  if (raw === null) return null;
71
79
  sessionStorage.removeItem(RELOAD_SALT_KEY);
72
- const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown; sessionIds?: unknown };
80
+ const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown; sessionIds?: unknown; owner?: unknown };
73
81
  if (typeof left.salt !== 'string' || left.salt === '' || (left.authId ?? null) !== authId) return null;
74
82
  const sessionIds = Array.isArray(left.sessionIds) ? left.sessionIds.filter((sid): sid is string => typeof sid === 'string') : [];
75
- return { salt: left.salt, sessionIds };
83
+ const owner = typeof left.owner === 'string' && left.owner !== '' ? left.owner : null;
84
+ return { salt: left.salt, sessionIds, owner };
76
85
  } catch {
77
86
  return null;
78
87
  }
@@ -84,11 +93,17 @@ export function takeReloadSalt(authId: string | null): { salt: string; sessionId
84
93
  * The session ids of this page's sockets go with it. The views the next page
85
94
  * re-registers keep the edges this page's sockets stamped, and the anonymous
86
95
  * LIVE only hears edges stamped by a session it knows (see `listRefLive`).
96
+ * So does the page's owner secret, which those views answer to.
87
97
  */
88
- export function leaveReloadSalt(salt: string, authId: string | null, sessionIds: ReadonlyArray<string> = []): void {
98
+ export function leaveReloadSalt(
99
+ salt: string,
100
+ authId: string | null,
101
+ sessionIds: ReadonlyArray<string> = [],
102
+ owner: string | null = null
103
+ ): void {
89
104
  try {
90
105
  if (typeof sessionStorage !== 'undefined')
91
- sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId, sessionIds }));
106
+ sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId, sessionIds, owner }));
92
107
  } catch {
93
108
  /* private-mode storage errors: the next page mints a fresh salt */
94
109
  }
@@ -268,9 +283,9 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
268
283
  const left = takeReloadSalt(authId);
269
284
  if (!left) return null;
270
285
  s.remote.adoptSessionIds(left.sessionIds);
271
- return left.salt;
286
+ return { salt: left.salt, owner: left.owner };
272
287
  },
273
- 'salt.leave': (salt, authId) => leaveReloadSalt(salt, authId, s.remote.sessionIds()),
288
+ 'salt.leave': (salt, authId, owner) => leaveReloadSalt(salt, authId, s.remote.sessionIds(), owner),
274
289
  'local.connect': (bucketId) => s.local.connect(bucketId),
275
290
  'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
276
291
  'local.beginSwitch': () => s.local.beginSwitch(),
@@ -294,6 +309,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
294
309
  tables: [...config.schema.tables.map((t) => t.name), '_00_user_feature', '_00_app_release'],
295
310
  schemaHash: String(hash53(config.schemaSurql)),
296
311
  pendingIds: new Set(pendingIds),
312
+ columnFingerprints: columnFingerprints(config.schema.tables),
297
313
  onVersions: (_table, entries) => host.dispatch({ type: 'VersionsPrimed', entries }),
298
314
  }),
299
315
  'ssp.reset': () => s.streamProcessor.reset(),
@@ -411,7 +427,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
411
427
  },
412
428
  },
413
429
  clock: { now: () => Date.now() },
414
- ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt },
430
+ ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt, owner: mintOwnerSecret },
415
431
  hash: sha256Hex,
416
432
  services,
417
433
  };
@@ -53,7 +53,7 @@ export type Effect =
53
53
  | { kind: 'state.update'; fn: (s: ClientState) => ClientState }
54
54
  | { kind: 'state.wait'; until: (s: ClientState) => boolean }
55
55
  | { kind: 'now' }
56
- | { kind: 'id'; scope: 'mutation' | 'salt' }
56
+ | { kind: 'id'; scope: IdScope }
57
57
  | { kind: 'hash'; input: string }
58
58
  | { kind: 'emit'; event: OutEvent }
59
59
  | { kind: 'dispatch'; event: RuntimeEvent }
@@ -62,6 +62,9 @@ export type Effect =
62
62
 
63
63
  export type EffectKind = Effect['kind'];
64
64
 
65
+ /** `mutation`: an outbox row id. `salt`: a query-key salt. `owner`: a page's anonymous-view owner secret. */
66
+ export type IdScope = 'mutation' | 'salt' | 'owner';
67
+
65
68
  /**
66
69
  * Calls into the legacy services that are adapters, not state: auth, tabs,
67
70
  * blobs, migrator, crdt, persistence, the SSP lifecycle, the remote socket.
@@ -70,8 +73,9 @@ export type EffectKind = Effect['kind'];
70
73
  export interface ServiceCalls {
71
74
  'hint.read': () => string | null;
72
75
  'hint.write': (bucketId: string) => void;
73
- 'salt.take': (authId: string | null) => string | null;
74
- 'salt.leave': (salt: string, authId: string | null) => void;
76
+ /** The salt (and owner secret) this tab's previous page left, if minted for `authId`. */
77
+ 'salt.take': (authId: string | null) => { salt: string; owner: string | null } | null;
78
+ 'salt.leave': (salt: string, authId: string | null, owner: string | null) => void;
75
79
  'local.connect': (bucketId: string) => Promise<void>;
76
80
  'local.switchStore': (bucketId: string) => Promise<void>;
77
81
  'local.beginSwitch': () => () => void;
@@ -154,7 +158,7 @@ export const fx = {
154
158
  wait: (until: (s: ClientState) => boolean): Effect => ({ kind: 'state.wait', until }),
155
159
  },
156
160
  now: (): Effect => ({ kind: 'now' }),
157
- id: (scope: 'mutation' | 'salt'): Effect => ({ kind: 'id', scope }),
161
+ id: (scope: IdScope): Effect => ({ kind: 'id', scope }),
158
162
  hash: (input: string): Effect => ({ kind: 'hash', input }),
159
163
  emit: (event: OutEvent): Effect => ({ kind: 'emit', event }),
160
164
  dispatch: (event: RuntimeEvent): Effect => ({ kind: 'dispatch', event }),
@@ -4,7 +4,7 @@ import type { IngestRecord, StreamUpdate } from '../services/stream-processor/in
4
4
  import type { ClientState } from '../state/client-state';
5
5
  import type { InlineRow } from '../types';
6
6
  import { withTimeout } from '../utils/index';
7
- import type { Effect, RegisterResult, ServiceCalls, Settled, StatementResult } from './effects';
7
+ import type { Effect, IdScope, RegisterResult, ServiceCalls, Settled, StatementResult } from './effects';
8
8
  import type { OutEvent, RuntimeEvent } from './events';
9
9
 
10
10
  /** The five adapters plus the pure ports, as the interpreter sees them. */
@@ -36,7 +36,7 @@ export interface Adapters {
36
36
  };
37
37
  timers: { set(key: string, ms: number, fire: () => void): void; clear(key: string): void };
38
38
  clock: { now(): number };
39
- ids: { mutation(): string; salt(): string };
39
+ ids: Record<IdScope, () => string>;
40
40
  hash(input: string): Promise<string>;
41
41
  services: ServiceCalls;
42
42
  }
@@ -127,7 +127,7 @@ export function createInterpreter(adapters: Adapters, host: InterpreterHost): In
127
127
  case 'now':
128
128
  return adapters.clock.now();
129
129
  case 'id':
130
- return effect.scope === 'mutation' ? adapters.ids.mutation() : adapters.ids.salt();
130
+ return adapters.ids[effect.scope]();
131
131
  case 'hash':
132
132
  return adapters.hash(effect.input);
133
133
  case 'emit':
@@ -187,7 +187,12 @@ describe('createLiveFeed', () => {
187
187
  const input = (client as any).registerInput('notification', q.innerQuery, '10m');
188
188
  const env = defaultEnv(schema);
189
189
  const local = await runPure(registerLocal(env, input), {
190
- state: { ...emptyState({ tabId: 't' }), sessionId: 'sess', userId: 'user:alice' },
190
+ state: {
191
+ ...emptyState({ tabId: 't' }),
192
+ sessionId: 'sess',
193
+ userId: 'user:alice',
194
+ ownerSecret: 'owner-k',
195
+ },
191
196
  handlers: {
192
197
  'local.getById': () => null,
193
198
  'ssp.register': () => ({
@@ -208,7 +213,7 @@ describe('createLiveFeed', () => {
208
213
  });
209
214
 
210
215
  const fake = new FakeSurreal();
211
- const feed = feedWith(fake);
216
+ const feed = feedWith(fake, { ownerSecret: 'owner-k' });
212
217
  const sub = feed.subscribe(q, { onSet: () => {} });
213
218
  await sub.ready;
214
219
  const reg = fake.calls.find((c) => c.sql.startsWith('fn::query::register'))!;
@@ -409,7 +414,13 @@ describe('createLiveFeed', () => {
409
414
  fake.heartbeatGone = true;
410
415
  await vi.advanceTimersByTimeAsync(10_000);
411
416
  expect(fake.calls.some((c) => c.sql.startsWith('fn::query::heartbeat($id0)'))).toBe(true);
412
- expect(fake.calls.filter((c) => c.sql.startsWith('fn::query::register'))).toHaveLength(2);
417
+ const registers = fake.calls.filter((c) => c.sql.startsWith('fn::query::register'));
418
+ expect(registers).toHaveLength(2);
419
+ // The re-registration proves it owns the view with the secret it was
420
+ // registered with (a fresh one per feed, at least 256 bits).
421
+ const owners = registers.map((c) => (c.vars!.config as { owner?: string }).owner);
422
+ expect(owners[0]).toMatch(/^[0-9a-f]{64}$/);
423
+ expect(owners[1]).toBe(owners[0]);
413
424
  await feed.close();
414
425
  } finally {
415
426
  vi.useRealTimers();
package/src/live/index.ts CHANGED
@@ -10,7 +10,8 @@
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
17
  * feed (on the shared anon table, filtered to the feed's own edges; see
@@ -48,7 +49,13 @@ import { decodeTokenClaims } from '../modules/auth/impersonation';
48
49
  import { hashOfEdge, rowOfEdge } from '../utils/edges';
49
50
  import { sha256Hex } from '../utils/sha256';
50
51
  import { parseQueryParams } from '../utils/parser';
51
- import { encodeRecordId, parseDuration, parseRecordIdString, withTimeout } from '../utils/index';
52
+ import {
53
+ encodeRecordId,
54
+ mintOwnerSecret,
55
+ parseDuration,
56
+ parseRecordIdString,
57
+ withTimeout,
58
+ } from '../utils/index';
52
59
 
53
60
  export type LiveToken = string | null | undefined;
54
61
  /** A token, or a function returning one (called on every connect). */
@@ -92,6 +99,14 @@ export interface LiveFeedOptions {
92
99
  ttl?: string;
93
100
  /** Salt of the `_00_query` keys. Default: a fresh random id per feed. */
94
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;
95
110
  /**
96
111
  * The generated schema. Used to type query params (record ids, datetimes)
97
112
  * exactly like the full client does before registering. Taken from a query
@@ -384,6 +399,7 @@ class Feed implements LiveFeed {
384
399
  private liveStarting: Promise<boolean> | null = null;
385
400
  private readonly subs = new Map<string, SubState>();
386
401
  private readonly sessionId: string;
402
+ private readonly ownerSecret: string;
387
403
  /** `session::id()` of every socket this feed has had, oldest first. */
388
404
  private sockets: string[] = [];
389
405
  private readonly ttl: string;
@@ -403,6 +419,7 @@ class Feed implements LiveFeed {
403
419
 
404
420
  constructor(private readonly options: LiveFeedOptions) {
405
421
  this.sessionId = options.sessionId ?? randomId();
422
+ this.ownerSecret = options.ownerSecret ?? mintOwnerSecret();
406
423
  this.ttl = options.ttl ?? '10m';
407
424
  this.timeoutMs = options.timeoutMs ?? 15_000;
408
425
  }
@@ -804,7 +821,10 @@ class Feed implements LiveFeed {
804
821
 
805
822
  /** Register (or re-register) a view and read its membership back, in one request. */
806
823
  private async register(sub: SubState): Promise<Snapshot> {
807
- 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
+ );
808
828
  let snap = snapshotAt(results, 1);
809
829
  for (let i = 0; snap && snap.state === 'materializing' && i < MATERIALIZE_RETRIES; i++) {
810
830
  await sleep(MATERIALIZE_RETRY_MS);
@@ -1,8 +1,9 @@
1
- import { Socket } from 'node:net';
1
+ import { createServer, type Server } from 'node:http';
2
+ import { Socket, type AddressInfo } from 'node:net';
2
3
  import { createHash, randomUUID } from 'node:crypto';
3
4
  import { readFileSync } from 'node:fs';
4
5
  import { fileURLToPath } from 'node:url';
5
- import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
6
7
  import { RecordId, Surreal } from 'surrealdb';
7
8
  import { liveEndpoint } from '../live/index';
8
9
  import {
@@ -27,13 +28,36 @@ import {
27
28
  * surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
28
29
  *
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.
30
36
  */
31
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';
32
39
  const NS = 'it';
33
40
 
34
41
  const cliSource = (file: string) =>
35
42
  readFileSync(fileURLToPath(new URL(`../../../../apps/cli/src/${file}`, import.meta.url)), 'utf8');
36
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
+
37
61
  /** Just the `_00_query` block: the rest of the file needs tables and modules this test does not. */
38
62
  function queryTableSchema(): string {
39
63
  const meta = cliSource('meta_tables_remote.surql');
@@ -61,6 +85,18 @@ UPSERT $id SET clientId = $sid, auth_id = $owner, surql = 'SELECT * FROM item',
61
85
  array::filter(subscribers ?? [], |$s| <string>$s.id != $sid AND <datetime>$s.seenAt + 1h > time::now()),
62
86
  { id: $sid, seenAt: time::now() })`;
63
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
+
64
100
  /** A `_00_query:<sha256>` id, the shape the client mints (and the pagehide beacon accepts). */
65
101
  const viewId = () =>
66
102
  new RecordId('_00_query', createHash('sha256').update(randomUUID()).digest('hex'));
@@ -71,6 +107,13 @@ describe('anonymous _00_query isolation (real SurrealDB)', () => {
71
107
  let reachable = false;
72
108
  const open: Surreal[] = [];
73
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
+
74
117
  // A plain TCP probe: the SDK never rejects a connect to a refused port.
75
118
  beforeAll(async () => {
76
119
  const url = new URL(endpoint);
@@ -84,8 +127,58 @@ describe('anonymous _00_query isolation (real SurrealDB)', () => {
84
127
  socket.once('error', () => done(false));
85
128
  socket.connect(Number(url.port || 80), url.hostname, () => done(true));
86
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}`;
87
157
  });
88
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
+
89
182
  beforeEach(async () => {
90
183
  if (!reachable) return;
91
184
  database = `anon_query_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -290,6 +383,72 @@ describe('anonymous _00_query isolation (real SurrealDB)', () => {
290
383
  expect(await readBack(b, qa)).toBeNull();
291
384
  });
292
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
+
293
452
  it('the owning guest can still release its view', async (ctx) => {
294
453
  if (!reachable) ctx.skip();
295
454
  const [a, sidA] = await guest();
@@ -161,7 +161,13 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
161
161
  const table = listRefTable(env, state);
162
162
  const results = (yield fx.remote.query(
163
163
  sql.registerSelect(table),
164
- sql.registerVars({ id: entry.def.id, surql: entry.def.surql, params: { ...entry.def.params }, ttl: entry.def.ttl }),
164
+ sql.registerVars({
165
+ id: entry.def.id,
166
+ surql: entry.def.surql,
167
+ params: { ...entry.def.params },
168
+ ttl: entry.def.ttl,
169
+ ...(state.ownerSecret ? { owner: state.ownerSecret } : {}),
170
+ }),
165
171
  env.remoteTimeoutMs
166
172
  )) as StatementResult[];
167
173
  const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
@@ -39,6 +39,12 @@ describe('query sql builders (golden against the previous builders)', () => {
39
39
  config: { id, surql: 's', params: { a: 1 }, ttl: '10m' },
40
40
  in: id,
41
41
  });
42
+ // The owner secret rides beside params, never inside them: params name
43
+ // the computation the SSP shares between views.
44
+ expect(sql.registerVars({ id, surql: 's', params: { a: 1 }, ttl: '10m', owner: 'k' })).toEqual({
45
+ config: { id, surql: 's', params: { a: 1 }, ttl: '10m', owner: 'k' },
46
+ in: id,
47
+ });
42
48
  });
43
49
  it('the LIVE on a per-user table is unfiltered; FETCH out only when bodies ride inline', () => {
44
50
  expect(sql.listRefLive('_00_list_ref_user_x', { inlineBodies: true, sessionIds: ['s1'] })).toEqual({
package/src/query/sql.ts CHANGED
@@ -93,6 +93,14 @@ export interface RegisterPayload {
93
93
  surql: string;
94
94
  params: Record<string, unknown>;
95
95
  ttl: string;
96
+ /**
97
+ * The registering page's secret. `fn::query::register` forwards only its
98
+ * hash, and the SSP lets a registration onto an existing anonymous view
99
+ * only with the hash that view was registered with: every signed-out
100
+ * visitor is `anon`, and anyone can read an anonymous view's id. Kept out
101
+ * of `params`, which name the computation the SSP shares between views.
102
+ */
103
+ owner?: string;
96
104
  }
97
105
 
98
106
  /** Register + read back edges/meta/children in ONE request. */
@@ -151,6 +151,16 @@ export interface LocalStore extends LocalCacheEngine {
151
151
  * without it boots the circuit empty, as before.
152
152
  */
153
153
  scanVersions?(tables: string[]): Promise<Record<string, [string, number][]>>;
154
+ /**
155
+ * Reset the cached `_00_rv` of every table whose column set (`fingerprints`,
156
+ * see `columnFingerprints`) changed since the store last recorded it, except
157
+ * the rows in `keep`, and record the new sets. Returns the tables reset.
158
+ * For engines that keep bodies across schema changes: a body landed under an
159
+ * older schema lacks the columns it did not know, at the server's version,
160
+ * and would never be fetched again. OPTIONAL: an engine that rebuilds its
161
+ * store on a schema change (SurrealDB) does not need it.
162
+ */
163
+ resetChangedTables?(fingerprints: Record<string, string>, keep: Set<string>): Promise<string[]>;
154
164
  /**
155
165
  * Circuit snapshot storage, keyed. Lives in the same durable store as the
156
166
  * rows (OPFS SQLite), so it is per-bucket by construction, atomic, and
@@ -30,6 +30,7 @@ import {
30
30
  type SqliteTransport,
31
31
  } from './sqlite-transport';
32
32
  import { PROMOTION_OPEN_OPTIONS } from './sqlite-open';
33
+ import { COLUMNS_ROW_ID, planColumnReset } from './sqlite-column-reset';
33
34
  import type { EngineTx, Id, LocalStore, OrderBy, RelationFetch, Row } from './cache-engine';
34
35
  import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-info';
35
36
 
@@ -898,6 +899,31 @@ export class SqliteCacheEngine implements LocalStore {
898
899
  return out;
899
900
  }
900
901
 
902
+ /**
903
+ * Reset the cached `_00_rv` of every table whose column set changed since
904
+ * the last boot, so its rows are fetched again whole. See
905
+ * `sqlite-column-reset.ts`. Returns the tables reset.
906
+ */
907
+ async resetChangedTables(fingerprints: Record<string, string>, keep: Set<string>): Promise<string[]> {
908
+ const { rows } = await this.call<{ rows: { data: unknown }[] }>('exec', {
909
+ sql: `SELECT data FROM "_00_schema" WHERE id = ?`,
910
+ bind: [COLUMNS_ROW_ID],
911
+ });
912
+ let stored: Record<string, string> | null = null;
913
+ try {
914
+ const raw = rows?.[0]?.data;
915
+ if (typeof raw === 'string') stored = JSON.parse(raw) as Record<string, string>;
916
+ } catch {
917
+ stored = null;
918
+ }
919
+ const { rows: tables } = await this.call<{ rows: { name: string }[] }>('exec', {
920
+ sql: `SELECT name FROM sqlite_master WHERE type = 'table'`,
921
+ });
922
+ const plan = planColumnReset(stored, fingerprints, new Set((tables ?? []).map((t) => t.name)), keep);
923
+ if (plan.statements.length > 0) await this.call('batch', plan.statements);
924
+ return plan.changed;
925
+ }
926
+
901
927
  async getSnapshot(key: string): Promise<StoredSnapshot | null> {
902
928
  const { rows } = await this.call<{ rows: { id: string; data: unknown }[] }>('exec', {
903
929
  sql: `SELECT id, data FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,