@spooky-sync/core 0.0.1-canary.321 → 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/AGENTS.md CHANGED
@@ -42,7 +42,7 @@ re-materialized from state, never patched in place.
42
42
  - Types: `Sp00kyConfig` (client-solid wraps it as `SyncedDbConfig`), `QueryTimeToLive`, `PersistenceClient`, `StoreType`, `UpdateOptions`, `RunOptions`, `PreloadOptions`.
43
43
  - Subpath: `@spooky-sync/core/otel` — `createOtelTransmit(endpoint)` for piping pino logs to OpenTelemetry.
44
44
  - `client.webPush` (`WebPushModule`, `db.webPush` in the Solid bindings) - Web Push on the page: `support()`, `info()`, `subscribe(opts)` (call from a click), `unsubscribe(opts)`, `sync()`, `isSubscribed()`, `update(opts)`, `devices()`, `notify(msg)`, `cancel(id)`, `test()`, `bridge()`, `onMessage(cb)`. Server calls are `fn::push::*`; typed `WebPushError`; config `webPush` in `types.ts`. Sign-out runs through `AuthService.onBeforeSignOut`.
45
- - Subpaths for service workers, workers and Node (no wasm, no pino, no DOM; `scripts/check-worker-entries.mjs` enforces it in `build`): `@spooky-sync/core/pure` (protocol builders), `@spooky-sync/core/live` (`createLiveFeed`, the live subscriber over the pure-JS SDK), `@spooky-sync/core/sw` (`installPushHandlers`, `getBridgedToken`). Built by the second tsdown config; chunks are prefixed `worker-`. See `docs/web-push-design.md` at the repo root.
45
+ - Subpaths for service workers, workers and Node (no wasm, no pino, no DOM; `scripts/check-worker-entries.mjs` enforces it in `build`): `@spooky-sync/core/pure` (protocol builders), `@spooky-sync/core/live` (`createLiveFeed`, the live subscriber over the pure-JS SDK; a feed that shares views with another through the same `sessionId` must also pass the same `ownerSecret`, or a signed-out feed's second registration is refused 409 `owner_mismatch`), `@spooky-sync/core/sw` (`installPushHandlers`, `getBridgedToken`). Built by the second tsdown config; chunks are prefixed `worker-`. See `docs/web-push-design.md` at the repo root.
46
46
 
47
47
  ## Common gotchas
48
48
 
package/dist/index.d.ts CHANGED
@@ -1123,7 +1123,7 @@ type Effect = {
1123
1123
  kind: 'now';
1124
1124
  } | {
1125
1125
  kind: 'id';
1126
- scope: 'mutation' | 'salt';
1126
+ scope: IdScope;
1127
1127
  } | {
1128
1128
  kind: 'hash';
1129
1129
  input: string;
@@ -1141,6 +1141,8 @@ type Effect = {
1141
1141
  name: ServiceName;
1142
1142
  args: unknown[];
1143
1143
  };
1144
+ /** `mutation`: an outbox row id. `salt`: a query-key salt. `owner`: a page's anonymous-view owner secret. */
1145
+ type IdScope = 'mutation' | 'salt' | 'owner';
1144
1146
  /**
1145
1147
  * Calls into the legacy services that are adapters, not state: auth, tabs,
1146
1148
  * blobs, migrator, crdt, persistence, the SSP lifecycle, the remote socket.
@@ -1149,8 +1151,12 @@ type Effect = {
1149
1151
  interface ServiceCalls {
1150
1152
  'hint.read': () => string | null;
1151
1153
  'hint.write': (bucketId: string) => void;
1152
- 'salt.take': (authId: string | null) => string | null;
1153
- 'salt.leave': (salt: string, authId: string | null) => void;
1154
+ /** The salt (and owner secret) this tab's previous page left, if minted for `authId`. */
1155
+ 'salt.take': (authId: string | null) => {
1156
+ salt: string;
1157
+ owner: string | null;
1158
+ } | null;
1159
+ 'salt.leave': (salt: string, authId: string | null, owner: string | null) => void;
1154
1160
  'local.connect': (bucketId: string) => Promise<void>;
1155
1161
  'local.switchStore': (bucketId: string) => Promise<void>;
1156
1162
  'local.beginSwitch': () => () => void;
@@ -1309,6 +1315,13 @@ interface ClientState {
1309
1315
  readonly userId: string | null;
1310
1316
  /** The principal the current session salt was minted for. */
1311
1317
  readonly saltUserId: string | null;
1318
+ /**
1319
+ * This page's secret, sent with every registration (`$config.owner`). An
1320
+ * anonymous view answers only to the secret it was registered with, so it
1321
+ * lives as long as the page's views: a reconnect keeps it, and a reload
1322
+ * that keeps the salt keeps it too.
1323
+ */
1324
+ readonly ownerSecret: string | null;
1312
1325
  /** Latest bucket-switch target; an older switch that wakes up to a newer target skips. */
1313
1326
  readonly pendingBucket: string | null;
1314
1327
  readonly tabId: string;
@@ -1428,10 +1441,7 @@ interface Adapters {
1428
1441
  clock: {
1429
1442
  now(): number;
1430
1443
  };
1431
- ids: {
1432
- mutation(): string;
1433
- salt(): string;
1434
- };
1444
+ ids: Record<IdScope, () => string>;
1435
1445
  hash(input: string): Promise<string>;
1436
1446
  services: ServiceCalls;
1437
1447
  }
package/dist/index.js CHANGED
@@ -569,6 +569,16 @@ function generateId() {
569
569
  return Uuid.v4().toString().replace(/-/g, "");
570
570
  }
571
571
  /**
572
+ * 32 random bytes, hex: the secret a page proves it owns its anonymous views
573
+ * with (`$config.owner` on `fn::query::register`). `getRandomValues` rather
574
+ * than `randomUUID`, which a page served over plain http does not have.
575
+ */
576
+ function mintOwnerSecret() {
577
+ const bytes = new Uint8Array(32);
578
+ globalThis.crypto.getRandomValues(bytes);
579
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
580
+ }
581
+ /**
572
582
  * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
573
583
  * releases expose it under `milliseconds` (current) or the older private
574
584
  * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
@@ -2178,8 +2188,8 @@ function trimHistory(history, cap) {
2178
2188
 
2179
2189
  //#endregion
2180
2190
  //#region src/modules/devtools/index.ts
2181
- const CORE_VERSION = "0.0.1-canary.321";
2182
- const WASM_VERSION = "0.0.1-canary.321";
2191
+ const CORE_VERSION = "0.0.1-canary.322";
2192
+ const WASM_VERSION = "0.0.1-canary.322";
2183
2193
  const SURREAL_VERSION = "3.0.3";
2184
2194
  var DevToolsService = class DevToolsService {
2185
2195
  version = CORE_VERSION;
@@ -3989,6 +3999,7 @@ function emptyState(init) {
3989
3999
  sessionId: null,
3990
4000
  userId: null,
3991
4001
  saltUserId: null,
4002
+ ownerSecret: null,
3992
4003
  pendingBucket: null,
3993
4004
  tabId: init.tabId,
3994
4005
  tabRole: "solo",
@@ -4825,7 +4836,8 @@ function* registerRemote(env, hash, retry = false) {
4825
4836
  id: entry.def.id,
4826
4837
  surql: entry.def.surql,
4827
4838
  params: { ...entry.def.params },
4828
- ttl: entry.def.ttl
4839
+ ttl: entry.def.ttl,
4840
+ ...state.ownerSecret ? { owner: state.ownerSecret } : {}
4829
4841
  }), env.remoteTimeoutMs);
4830
4842
  if (!(yield fx.state.read((s) => s.queries.has(hash)))) return;
4831
4843
  const register = stmt(results, 0);
@@ -5873,11 +5885,14 @@ function* boot(env, opts) {
5873
5885
  yield fx.dispatch({ type: "PrimeCircuit" });
5874
5886
  const restoredUserId = yield fx.service("auth.restoreSession");
5875
5887
  const authId = yield fx.service("auth.sessionAuthId");
5876
- const salt = (yield fx.service("salt.take", authId)) ?? (yield fx.id("salt"));
5888
+ const left = yield fx.service("salt.take", authId);
5889
+ const salt = left?.salt ?? (yield fx.id("salt"));
5890
+ const ownerSecret = left?.owner ?? (yield fx.id("owner"));
5877
5891
  yield fx.state.update(setIdentity({
5878
5892
  sessionId: salt,
5879
5893
  userId: restoredUserId,
5880
- saltUserId: authId
5894
+ saltUserId: authId,
5895
+ ownerSecret
5881
5896
  }));
5882
5897
  yield fx.service("crdt.setSessionId", salt);
5883
5898
  if (restoredUserId) yield fx.service("ssp.setSessionAuth", authId, yield fx.service("auth.access"));
@@ -5972,12 +5987,13 @@ function* versionsPrimed(entries) {
5972
5987
  * grace finds its views still warm.
5973
5988
  */
5974
5989
  function* pageHide() {
5975
- const [salt, saltUserId, ids] = yield fx.state.read((s) => [
5990
+ const [salt, saltUserId, ownerSecret, ids] = yield fx.state.read((s) => [
5976
5991
  s.sessionId,
5977
5992
  s.saltUserId,
5993
+ s.ownerSecret,
5978
5994
  [...s.queries.values()].filter((e) => e.lifecycle.remote === "registered").map((e) => e.def.id)
5979
5995
  ]);
5980
- if (salt) yield fx.service("salt.leave", salt, saltUserId);
5996
+ if (salt) yield fx.service("salt.leave", salt, saltUserId, ownerSecret);
5981
5997
  if (ids.length > 0) yield fx.service("remote.releaseViews", ids);
5982
5998
  }
5983
5999
  function* warmBlobs() {
@@ -6084,7 +6100,7 @@ function createInterpreter(adapters, host) {
6084
6100
  }
6085
6101
  case "state.wait": return host.waitFor(effect.until, ctx?.signal);
6086
6102
  case "now": return adapters.clock.now();
6087
- case "id": return effect.scope === "mutation" ? adapters.ids.mutation() : adapters.ids.salt();
6103
+ case "id": return adapters.ids[effect.scope]();
6088
6104
  case "hash": return adapters.hash(effect.input);
6089
6105
  case "emit": return host.emit(effect.event);
6090
6106
  case "dispatch": return host.dispatch(effect.event);
@@ -14742,6 +14758,11 @@ const RELOAD_SALT_KEY = "sp00ky:salt";
14742
14758
  * query onto the `_00_query` row it already had, which the SSP answers from
14743
14759
  * the live view instead of building a new one.
14744
14760
  *
14761
+ * The page's owner secret travels with it: an anonymous row answers only to
14762
+ * the secret it was registered with, so the next page needs the same one to
14763
+ * rejoin those views. `null` from a page that predates it; the next page then
14764
+ * mints one, and the rows it left (which hold no secret) take the new one.
14765
+ *
14745
14766
  * Taken, not read: the key is absent while a page lives, so a duplicated tab
14746
14767
  * (which copies sessionStorage) mints its own salt rather than sharing this
14747
14768
  * tab's remote views.
@@ -14755,9 +14776,11 @@ function takeReloadSalt(authId) {
14755
14776
  const left = JSON.parse(raw);
14756
14777
  if (typeof left.salt !== "string" || left.salt === "" || (left.authId ?? null) !== authId) return null;
14757
14778
  const sessionIds = Array.isArray(left.sessionIds) ? left.sessionIds.filter((sid) => typeof sid === "string") : [];
14779
+ const owner = typeof left.owner === "string" && left.owner !== "" ? left.owner : null;
14758
14780
  return {
14759
14781
  salt: left.salt,
14760
- sessionIds
14782
+ sessionIds,
14783
+ owner
14761
14784
  };
14762
14785
  } catch {
14763
14786
  return null;
@@ -14769,13 +14792,15 @@ function takeReloadSalt(authId) {
14769
14792
  * The session ids of this page's sockets go with it. The views the next page
14770
14793
  * re-registers keep the edges this page's sockets stamped, and the anonymous
14771
14794
  * LIVE only hears edges stamped by a session it knows (see `listRefLive`).
14795
+ * So does the page's owner secret, which those views answer to.
14772
14796
  */
14773
- function leaveReloadSalt(salt, authId, sessionIds = []) {
14797
+ function leaveReloadSalt(salt, authId, sessionIds = [], owner = null) {
14774
14798
  try {
14775
14799
  if (typeof sessionStorage !== "undefined") sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({
14776
14800
  salt,
14777
14801
  authId,
14778
- sessionIds
14802
+ sessionIds,
14803
+ owner
14779
14804
  }));
14780
14805
  } catch {}
14781
14806
  }
@@ -14840,7 +14865,7 @@ function createTabsCoordinator(config, s, host) {
14840
14865
  return new TabsCoordinator({
14841
14866
  tabId: s.tabId,
14842
14867
  fingerprint: computeTabsFingerprint({
14843
- coreVersion: "0.0.1-canary.321",
14868
+ coreVersion: "0.0.1-canary.322",
14844
14869
  schemaHash: hash53(config.schemaSurql),
14845
14870
  endpoint: config.database.endpoint ?? "",
14846
14871
  namespace: config.database.namespace,
@@ -14948,9 +14973,12 @@ function createAdapters(config, s, host, lateModules) {
14948
14973
  const left = takeReloadSalt(authId);
14949
14974
  if (!left) return null;
14950
14975
  s.remote.adoptSessionIds(left.sessionIds);
14951
- return left.salt;
14976
+ return {
14977
+ salt: left.salt,
14978
+ owner: left.owner
14979
+ };
14952
14980
  },
14953
- "salt.leave": (salt, authId) => leaveReloadSalt(salt, authId, s.remote.sessionIds()),
14981
+ "salt.leave": (salt, authId, owner) => leaveReloadSalt(salt, authId, s.remote.sessionIds(), owner),
14954
14982
  "local.connect": (bucketId) => s.local.connect(bucketId),
14955
14983
  "local.switchStore": (bucketId) => s.local.switchStore(bucketId),
14956
14984
  "local.beginSwitch": () => s.local.beginSwitch(),
@@ -15087,7 +15115,8 @@ function createAdapters(config, s, host, lateModules) {
15087
15115
  clock: { now: () => Date.now() },
15088
15116
  ids: {
15089
15117
  mutation: () => mintMutationId(s.tabId),
15090
- salt: mintSalt
15118
+ salt: mintSalt,
15119
+ owner: mintOwnerSecret
15091
15120
  },
15092
15121
  hash: sha256Hex,
15093
15122
  services
package/dist/live.d.ts CHANGED
@@ -43,6 +43,14 @@ interface LiveFeedOptions {
43
43
  ttl?: string;
44
44
  /** Salt of the `_00_query` keys. Default: a fresh random id per feed. */
45
45
  sessionId?: string;
46
+ /**
47
+ * The secret the feed's registrations prove they own their views with
48
+ * (`$config.owner`): a signed-out feed's view answers only to the secret it
49
+ * was registered with. Default: a fresh one per feed. Pass the same one
50
+ * wherever you pass the same `sessionId`, or the second registrant of a
51
+ * shared anonymous view is refused.
52
+ */
53
+ ownerSecret?: string;
46
54
  /**
47
55
  * The generated schema. Used to type query params (record ids, datetimes)
48
56
  * exactly like the full client does before registering. Taken from a query
package/dist/live.js CHANGED
@@ -1,4 +1,4 @@
1
- import { D as registerSelect, F as ANON_USER_ID, H as queryHashInput, I as DEFAULT_REF_MODE, O as registerVars, R as listRefTableFor, V as sha256Hex, _ as heartbeatBatch, a as metaFromRow, c as parseDuration, d as parseQueryParams, h as bodySelect, k as singleSnapshotSelect, l as parseRecordIdString, n as rowOfEdge, s as encodeRecordId, t as hashOfEdge, u as withTimeout, v as heartbeatRowGone, x as listRefLive } from "./worker-edges.js";
1
+ import { A as singleSnapshotSelect, H as sha256Hex, I as ANON_USER_ID, L as DEFAULT_REF_MODE, O as registerSelect, S as listRefLive, U as queryHashInput, a as metaFromRow, c as mintOwnerSecret, d as withTimeout, f as parseQueryParams, g as bodySelect, k as registerVars, l as parseDuration, n as rowOfEdge, s as encodeRecordId, t as hashOfEdge, u as parseRecordIdString, v as heartbeatBatch, y as heartbeatRowGone, z as listRefTableFor } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims } from "./worker-impersonation.js";
3
3
  import { RecordId, Surreal } from "surrealdb";
4
4
 
@@ -15,7 +15,8 @@ import { RecordId, Surreal } from "surrealdb";
15
15
  * - `fn::query::register($config)` plus the membership read-back in one
16
16
  * request (`registerSelect` / `registerVars`, the same builders the full
17
17
  * client's `registerRemote` uses; `$config = { id: _00_query:<key>, surql,
18
- * params, ttl }`, key = sha256(JSON.stringify({ surql, params, sessionId })));
18
+ * params, ttl, owner }`, key = sha256(JSON.stringify({ surql, params,
19
+ * sessionId })), owner = the feed's `ownerSecret`);
19
20
  * - bodies with `SELECT * FROM $ids`;
20
21
  * - one `LIVE SELECT * FROM <the user's _00_list_ref table> FETCH out` per
21
22
  * feed (on the shared anon table, filtered to the feed's own edges; see
@@ -186,6 +187,7 @@ var Feed = class {
186
187
  liveStarting = null;
187
188
  subs = /* @__PURE__ */ new Map();
188
189
  sessionId;
190
+ ownerSecret;
189
191
  /** `session::id()` of every socket this feed has had, oldest first. */
190
192
  sockets = [];
191
193
  ttl;
@@ -202,6 +204,7 @@ var Feed = class {
202
204
  constructor(options) {
203
205
  this.options = options;
204
206
  this.sessionId = options.sessionId ?? randomId();
207
+ this.ownerSecret = options.ownerSecret ?? mintOwnerSecret();
205
208
  this.ttl = options.ttl ?? "10m";
206
209
  this.timeoutMs = options.timeoutMs ?? 15e3;
207
210
  }
@@ -539,7 +542,10 @@ var Feed = class {
539
542
  }
540
543
  /** Register (or re-register) a view and read its membership back, in one request. */
541
544
  async register(sub) {
542
- let snap = snapshotAt(await this.q(registerSelect(this.table), registerVars(sub.payload)), 1);
545
+ let snap = snapshotAt(await this.q(registerSelect(this.table), registerVars({
546
+ ...sub.payload,
547
+ owner: this.ownerSecret
548
+ })), 1);
543
549
  for (let i = 0; snap && snap.state === "materializing" && i < MATERIALIZE_RETRIES; i++) {
544
550
  await sleep(MATERIALIZE_RETRY_MS);
545
551
  snap = await this.readSnapshot(sub);
package/dist/pure.d.ts CHANGED
@@ -234,6 +234,14 @@ interface RegisterPayload {
234
234
  surql: string;
235
235
  params: Record<string, unknown>;
236
236
  ttl: string;
237
+ /**
238
+ * The registering page's secret. `fn::query::register` forwards only its
239
+ * hash, and the SSP lets a registration onto an existing anonymous view
240
+ * only with the hash that view was registered with: every signed-out
241
+ * visitor is `anon`, and anyone can read an anonymous view's id. Kept out
242
+ * of `params`, which name the computation the SSP shares between views.
243
+ */
244
+ owner?: string;
237
245
  }
238
246
  /** Register + read back edges/meta/children in ONE request. */
239
247
  declare const registerSelect: (table: string) => string;
package/dist/pure.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as subqueryListRefSelect, B as surql, C as queryRowCountBatchSelect, D as registerSelect, E as readViewRows, F as ANON_USER_ID, H as queryHashInput, I as DEFAULT_REF_MODE, L as bucketIdForUser, M as viewRecordId, N as viewRow, O as registerVars, P as ANON_LIST_REF_TABLE, R as listRefTableFor, S as listRefSelect, T as readLegacyViewRows, U as viewKeyInput, V as sha256Hex, _ as heartbeatBatch, a as metaFromRow, b as listRefBatchSelect, c as parseDuration, d as parseQueryParams, f as LEGACY_VIEW_TABLE, g as countViewRows, h as bodySelect, i as dedupeRecordVersions, j as upsertBodiesTx, k as singleSnapshotSelect, l as parseRecordIdString, m as batchSnapshotSelect, n as rowOfEdge, o as snapshotFromSingle, p as VIEW_TABLE, r as FAILED, s as encodeRecordId, t as hashOfEdge, v as heartbeatRowGone, w as queryRowCountSelect, x as listRefLive, y as leaveViewsBeacon, z as sanitizeUserId } from "./worker-edges.js";
1
+ import { A as singleSnapshotSelect, B as sanitizeUserId, C as listRefSelect, D as readViewRows, E as readLegacyViewRows, F as ANON_LIST_REF_TABLE, H as sha256Hex, I as ANON_USER_ID, L as DEFAULT_REF_MODE, M as upsertBodiesTx, N as viewRecordId, O as registerSelect, P as viewRow, R as bucketIdForUser, S as listRefLive, T as queryRowCountSelect, U as queryHashInput, V as surql, W as viewKeyInput, _ as countViewRows, a as metaFromRow, b as leaveViewsBeacon, f as parseQueryParams, g as bodySelect, h as batchSnapshotSelect, i as dedupeRecordVersions, j as subqueryListRefSelect, k as registerVars, l as parseDuration, m as VIEW_TABLE, n as rowOfEdge, o as snapshotFromSingle, p as LEGACY_VIEW_TABLE, r as FAILED, s as encodeRecordId, t as hashOfEdge, u as parseRecordIdString, v as heartbeatBatch, w as queryRowCountBatchSelect, x as listRefBatchSelect, y as heartbeatRowGone, z as listRefTableFor } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims, t as IMPERSONATION_ACCESS } from "./worker-impersonation.js";
3
3
  import { a as sameBytes, i as isPushPayload, n as base64UrlToBytes, r as bytesToBase64Url, t as PUSH_PAYLOAD_VERSION } from "./worker-types.js";
4
4
 
@@ -319,6 +319,16 @@ const parseRecordIdString = (id) => {
319
319
  return new RecordId(table, idParts.join(":"));
320
320
  };
321
321
  /**
322
+ * 32 random bytes, hex: the secret a page proves it owns its anonymous views
323
+ * with (`$config.owner` on `fn::query::register`). `getRandomValues` rather
324
+ * than `randomUUID`, which a page served over plain http does not have.
325
+ */
326
+ function mintOwnerSecret() {
327
+ const bytes = new Uint8Array(32);
328
+ globalThis.crypto.getRandomValues(bytes);
329
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
330
+ }
331
+ /**
322
332
  * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
323
333
  * releases expose it under `milliseconds` (current) or the older private
324
334
  * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
@@ -467,4 +477,4 @@ function rowOfEdge(value) {
467
477
  }
468
478
 
469
479
  //#endregion
470
- export { subqueryListRefSelect as A, surql as B, queryRowCountBatchSelect as C, registerSelect as D, readViewRows as E, ANON_USER_ID as F, queryHashInput as H, DEFAULT_REF_MODE as I, bucketIdForUser as L, viewRecordId as M, viewRow as N, registerVars as O, ANON_LIST_REF_TABLE as P, listRefTableFor as R, listRefSelect as S, readLegacyViewRows as T, viewKeyInput as U, sha256Hex as V, heartbeatBatch as _, metaFromRow as a, listRefBatchSelect as b, parseDuration as c, parseQueryParams as d, LEGACY_VIEW_TABLE as f, countViewRows as g, bodySelect as h, dedupeRecordVersions as i, upsertBodiesTx as j, singleSnapshotSelect as k, parseRecordIdString as l, batchSnapshotSelect as m, rowOfEdge as n, snapshotFromSingle as o, VIEW_TABLE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, withTimeout as u, heartbeatRowGone as v, queryRowCountSelect as w, listRefLive as x, leaveViewsBeacon as y, sanitizeUserId as z };
480
+ export { singleSnapshotSelect as A, sanitizeUserId as B, listRefSelect as C, readViewRows as D, readLegacyViewRows as E, ANON_LIST_REF_TABLE as F, sha256Hex as H, ANON_USER_ID as I, DEFAULT_REF_MODE as L, upsertBodiesTx as M, viewRecordId as N, registerSelect as O, viewRow as P, bucketIdForUser as R, listRefLive as S, queryRowCountSelect as T, queryHashInput as U, surql as V, viewKeyInput as W, countViewRows as _, metaFromRow as a, leaveViewsBeacon as b, mintOwnerSecret as c, withTimeout as d, parseQueryParams as f, bodySelect as g, batchSnapshotSelect as h, dedupeRecordVersions as i, subqueryListRefSelect as j, registerVars as k, parseDuration as l, VIEW_TABLE as m, rowOfEdge as n, snapshotFromSingle as o, LEGACY_VIEW_TABLE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, parseRecordIdString as u, heartbeatBatch as v, queryRowCountBatchSelect as w, listRefBatchSelect as x, heartbeatRowGone as y, listRefTableFor as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.321",
3
+ "version": "0.0.1-canary.322",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,8 +75,8 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.321",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.321",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.322",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.322",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
@@ -43,24 +43,42 @@ describe('boot', () => {
43
43
  'releases.init',
44
44
  ]);
45
45
  expect(svc.calls[1]).toEqual(['local.connect', ['u1']]);
46
- expect(out.state).toMatchObject({ bucketId: 'u1', userId: 'user:u1', saltUserId: 'user:u1', sessionId: 'salt-1', localReady: true });
46
+ expect(out.state).toMatchObject({
47
+ bucketId: 'u1',
48
+ userId: 'user:u1',
49
+ saltUserId: 'user:u1',
50
+ sessionId: 'salt-1',
51
+ ownerSecret: 'owner-2',
52
+ localReady: true,
53
+ });
47
54
  expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'AdoptPendingWrites', 'LifecycleTick', 'StartRemote']);
48
55
  // The view index is loaded before anything can paint; the first GC waits out the boot.
49
56
  expect(out.state.views).toEqual(new Map([['k', ['t:1']]]));
50
57
  expect(out.timers.get('gc')).toEqual({ ms: 30_000, event: { type: 'GcTick' } });
51
58
  expect(svc.names()).not.toContain('remote.connect');
52
59
  });
53
- it('a reload keeps the salt its tab left for the same principal', async () => {
60
+ it('a reload keeps the salt its tab left for the same principal, and the owner secret its views answer to', async () => {
54
61
  const svc = fakeServices({
55
62
  'hint.read': () => 'u1',
56
63
  'auth.restoreSession': async () => 'user:u1',
57
64
  'auth.sessionAuthId': () => 'user:u1',
58
- 'salt.take': () => 'kept-salt',
65
+ 'salt.take': () => ({ salt: 'kept-salt', owner: 'kept-owner' }),
59
66
  });
60
67
  const out = await runPure(boot(env, { sharedTabs: false }), { handlers: { service: svc.handler, 'local.query': () => [] } });
61
68
  expect(svc.calls).toContainEqual(['salt.take', ['user:u1']]);
62
69
  expect(svc.calls).toContainEqual(['crdt.setSessionId', ['kept-salt']]);
63
- expect(out.state).toMatchObject({ sessionId: 'kept-salt', saltUserId: 'user:u1' });
70
+ expect(out.state).toMatchObject({ sessionId: 'kept-salt', saltUserId: 'user:u1', ownerSecret: 'kept-owner' });
71
+ expect(out.log.filter((e) => e.kind === 'id')).toEqual([]);
72
+ });
73
+ it('a salt left by a page that kept no owner secret gets a fresh one', async () => {
74
+ const svc = fakeServices({
75
+ 'hint.read': () => null,
76
+ 'auth.restoreSession': async () => null,
77
+ 'auth.sessionAuthId': () => null,
78
+ 'salt.take': () => ({ salt: 'kept-salt', owner: null }),
79
+ });
80
+ const out = await runPure(boot(env, { sharedTabs: false }), { handlers: { service: svc.handler, 'local.query': () => [] } });
81
+ expect(out.state).toMatchObject({ sessionId: 'kept-salt', ownerSecret: 'owner-1' });
64
82
  });
65
83
  it('shared tabs: takes the role from the coordinator, or falls back to solo; schemaless engines skip provisioning; anonymous skips session auth', async () => {
66
84
  const leader = fakeServices({ 'hint.read': () => null, 'tabs.start': async () => 'leader', 'local.usesSurqlSchema': () => false, 'auth.restoreSession': async () => null, 'auth.sessionAuthId': () => null });
@@ -146,12 +164,17 @@ describe('startRemote / primeCircuit / versionsPrimed / pageHide / warmBlobs', (
146
164
  expect(f.state.primed).toBe(true);
147
165
  expect(f.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
148
166
  });
149
- it('pageHide leaves the salt for a reload before releasing views', async () => {
167
+ it('pageHide leaves the salt and owner secret for a reload before releasing views', async () => {
150
168
  const svc = fakeServices();
151
- const s = { ...buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } })]), sessionId: 'salt-1', saltUserId: 'user:u1' };
169
+ const s = {
170
+ ...buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } })]),
171
+ sessionId: 'salt-1',
172
+ saltUserId: 'user:u1',
173
+ ownerSecret: 'owner-1',
174
+ };
152
175
  await runPure(pageHide(), { state: s, handlers: { service: svc.handler } });
153
176
  expect(svc.calls).toEqual([
154
- ['salt.leave', ['salt-1', 'user:u1']],
177
+ ['salt.leave', ['salt-1', 'user:u1', 'owner-1']],
155
178
  ['remote.releaseViews', [[new RecordId('_00_query', 'a')]]],
156
179
  ]);
157
180
  });
@@ -44,9 +44,12 @@ export function* boot(env: SagaEnv, opts: BootOptions): Saga<void> {
44
44
 
45
45
  const restoredUserId = (yield fx.service('auth.restoreSession')) as string | null;
46
46
  const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
47
- // A reload keeps the salt its tab had, so registrations land on warm views.
48
- const salt = (((yield fx.service('salt.take', authId)) as string | null) ?? (yield fx.id('salt'))) as string;
49
- yield fx.state.update(R.setIdentity({ sessionId: salt, userId: restoredUserId, saltUserId: authId }));
47
+ // A reload keeps the salt its tab had, so registrations land on warm views,
48
+ // and the owner secret those views answer to.
49
+ const left = (yield fx.service('salt.take', authId)) as { salt: string; owner: string | null } | null;
50
+ const salt = (left?.salt ?? (yield fx.id('salt'))) as string;
51
+ const ownerSecret = (left?.owner ?? (yield fx.id('owner'))) as string;
52
+ yield fx.state.update(R.setIdentity({ sessionId: salt, userId: restoredUserId, saltUserId: authId, ownerSecret }));
50
53
  yield fx.service('crdt.setSessionId', salt);
51
54
  if (restoredUserId) yield fx.service('ssp.setSessionAuth', authId, (yield fx.service('auth.access')) as string | null);
52
55
 
@@ -123,12 +126,13 @@ export function* versionsPrimed(entries: Array<readonly [string, number]>): Saga
123
126
  * grace finds its views still warm.
124
127
  */
125
128
  export function* pageHide(): Saga<void> {
126
- const [salt, saltUserId, ids] = (yield fx.state.read((s) => [
129
+ const [salt, saltUserId, ownerSecret, ids] = (yield fx.state.read((s) => [
127
130
  s.sessionId,
128
131
  s.saltUserId,
132
+ s.ownerSecret,
129
133
  [...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered').map((e) => e.def.id),
130
- ])) as [string | null, string | null, unknown[]];
131
- if (salt) yield fx.service('salt.leave', salt, saltUserId);
134
+ ])) as [string | null, string | null, string | null, unknown[]];
135
+ if (salt) yield fx.service('salt.leave', salt, saltUserId, ownerSecret);
132
136
  if (ids.length > 0) yield fx.service('remote.releaseViews', ids);
133
137
  }
134
138
 
@@ -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', () => {
@@ -26,7 +26,7 @@ import { MemoryBlobStore, createBlobCache, resolveBlobBudget } from '../services
26
26
  import type { Adapters } from '../kernel/interpreter';
27
27
  import type { RuntimeEvent } from '../kernel/events';
28
28
  import type { ServiceCalls } from '../kernel/effects';
29
- import { encodeRecordId } from '../utils/index';
29
+ import { encodeRecordId, mintOwnerSecret } from '../utils/index';
30
30
  import { sha256Hex } from '../utils/sha256';
31
31
  import { leaveViewsBeacon, listRefLive } from '../query/sql';
32
32
  import { hashOfEdge, rowOfEdge } from '../utils/edges';
@@ -59,20 +59,28 @@ const RELOAD_SALT_KEY = 'sp00ky:salt';
59
59
  * query onto the `_00_query` row it already had, which the SSP answers from
60
60
  * the live view instead of building a new one.
61
61
  *
62
+ * The page's owner secret travels with it: an anonymous row answers only to
63
+ * the secret it was registered with, so the next page needs the same one to
64
+ * rejoin those views. `null` from a page that predates it; the next page then
65
+ * mints one, and the rows it left (which hold no secret) take the new one.
66
+ *
62
67
  * Taken, not read: the key is absent while a page lives, so a duplicated tab
63
68
  * (which copies sessionStorage) mints its own salt rather than sharing this
64
69
  * tab's remote views.
65
70
  */
66
- export function takeReloadSalt(authId: string | null): { salt: string; sessionIds: string[] } | null {
71
+ export function takeReloadSalt(
72
+ authId: string | null
73
+ ): { salt: string; sessionIds: string[]; owner: string | null } | null {
67
74
  try {
68
75
  if (typeof sessionStorage === 'undefined') return null;
69
76
  const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
70
77
  if (raw === null) return null;
71
78
  sessionStorage.removeItem(RELOAD_SALT_KEY);
72
- const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown; sessionIds?: unknown };
79
+ const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown; sessionIds?: unknown; owner?: unknown };
73
80
  if (typeof left.salt !== 'string' || left.salt === '' || (left.authId ?? null) !== authId) return null;
74
81
  const sessionIds = Array.isArray(left.sessionIds) ? left.sessionIds.filter((sid): sid is string => typeof sid === 'string') : [];
75
- return { salt: left.salt, sessionIds };
82
+ const owner = typeof left.owner === 'string' && left.owner !== '' ? left.owner : null;
83
+ return { salt: left.salt, sessionIds, owner };
76
84
  } catch {
77
85
  return null;
78
86
  }
@@ -84,11 +92,17 @@ export function takeReloadSalt(authId: string | null): { salt: string; sessionId
84
92
  * The session ids of this page's sockets go with it. The views the next page
85
93
  * re-registers keep the edges this page's sockets stamped, and the anonymous
86
94
  * LIVE only hears edges stamped by a session it knows (see `listRefLive`).
95
+ * So does the page's owner secret, which those views answer to.
87
96
  */
88
- export function leaveReloadSalt(salt: string, authId: string | null, sessionIds: ReadonlyArray<string> = []): void {
97
+ export function leaveReloadSalt(
98
+ salt: string,
99
+ authId: string | null,
100
+ sessionIds: ReadonlyArray<string> = [],
101
+ owner: string | null = null
102
+ ): void {
89
103
  try {
90
104
  if (typeof sessionStorage !== 'undefined')
91
- sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId, sessionIds }));
105
+ sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId, sessionIds, owner }));
92
106
  } catch {
93
107
  /* private-mode storage errors: the next page mints a fresh salt */
94
108
  }
@@ -268,9 +282,9 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
268
282
  const left = takeReloadSalt(authId);
269
283
  if (!left) return null;
270
284
  s.remote.adoptSessionIds(left.sessionIds);
271
- return left.salt;
285
+ return { salt: left.salt, owner: left.owner };
272
286
  },
273
- 'salt.leave': (salt, authId) => leaveReloadSalt(salt, authId, s.remote.sessionIds()),
287
+ 'salt.leave': (salt, authId, owner) => leaveReloadSalt(salt, authId, s.remote.sessionIds(), owner),
274
288
  'local.connect': (bucketId) => s.local.connect(bucketId),
275
289
  'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
276
290
  'local.beginSwitch': () => s.local.beginSwitch(),
@@ -411,7 +425,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
411
425
  },
412
426
  },
413
427
  clock: { now: () => Date.now() },
414
- ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt },
428
+ ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt, owner: mintOwnerSecret },
415
429
  hash: sha256Hex,
416
430
  services,
417
431
  };
@@ -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. */
@@ -114,6 +114,13 @@ export interface ClientState {
114
114
  readonly userId: string | null;
115
115
  /** The principal the current session salt was minted for. */
116
116
  readonly saltUserId: string | null;
117
+ /**
118
+ * This page's secret, sent with every registration (`$config.owner`). An
119
+ * anonymous view answers only to the secret it was registered with, so it
120
+ * lives as long as the page's views: a reconnect keeps it, and a reload
121
+ * that keeps the salt keeps it too.
122
+ */
123
+ readonly ownerSecret: string | null;
117
124
  /** Latest bucket-switch target; an older switch that wakes up to a newer target skips. */
118
125
  readonly pendingBucket: string | null;
119
126
  readonly tabId: string;
@@ -167,6 +174,7 @@ export function emptyState(init: { tabId: string }): ClientState {
167
174
  sessionId: null,
168
175
  userId: null,
169
176
  saltUserId: null,
177
+ ownerSecret: null,
170
178
  pendingBucket: null,
171
179
  tabId: init.tabId,
172
180
  tabRole: 'solo',
@@ -444,7 +444,10 @@ export const setFailedCount =
444
444
  export const setIdentity =
445
445
  (
446
446
  patch: Partial<
447
- Pick<ClientState, 'sessionId' | 'userId' | 'saltUserId' | 'pendingBucket' | 'bucketId' | 'tabRole' | 'localReady' | 'primed'>
447
+ Pick<
448
+ ClientState,
449
+ 'sessionId' | 'userId' | 'saltUserId' | 'ownerSecret' | 'pendingBucket' | 'bucketId' | 'tabRole' | 'localReady' | 'primed'
450
+ >
448
451
  >
449
452
  ): Reducer =>
450
453
  (s) => ({ ...s, ...patch });
@@ -78,7 +78,11 @@ export function fakeAdapters(over: {
78
78
  clear: (key) => void timers.pending.delete(key),
79
79
  },
80
80
  clock: { now: () => now },
81
- ids: { mutation: () => `_00_pending_mutations:${String(++ids).padStart(13, '0')}_0001_tab`, salt: () => `salt-${++ids}` },
81
+ ids: {
82
+ mutation: () => `_00_pending_mutations:${String(++ids).padStart(13, '0')}_0001_tab`,
83
+ salt: () => `salt-${++ids}`,
84
+ owner: () => `owner-${++ids}`,
85
+ },
82
86
  hash: sha256Hex,
83
87
  services,
84
88
  };
@@ -51,6 +51,17 @@ export function generateId(): string {
51
51
  return Uuid.v4().toString().replace(/-/g, '');
52
52
  }
53
53
 
54
+ /**
55
+ * 32 random bytes, hex: the secret a page proves it owns its anonymous views
56
+ * with (`$config.owner` on `fn::query::register`). `getRandomValues` rather
57
+ * than `randomUUID`, which a page served over plain http does not have.
58
+ */
59
+ export function mintOwnerSecret(): string {
60
+ const bytes = new Uint8Array(32);
61
+ globalThis.crypto.getRandomValues(bytes);
62
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
63
+ }
64
+
54
65
  export function generateNewTableId<S extends SchemaStructure, T extends TableNames<S>>(
55
66
  tableName: T
56
67
  ): RecordId {