@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 +1 -1
- package/dist/index.d.ts +17 -7
- package/dist/index.js +44 -15
- package/dist/live.d.ts +8 -0
- package/dist/live.js +9 -3
- package/dist/pure.d.ts +8 -0
- package/dist/pure.js +1 -1
- package/dist/worker-edges.js +11 -1
- package/package.json +3 -3
- package/src/boot/boot.saga.test.ts +30 -7
- package/src/boot/boot.saga.ts +10 -6
- package/src/client/services.test.ts +17 -3
- package/src/client/services.ts +23 -9
- package/src/kernel/effects.ts +8 -4
- package/src/kernel/interpreter.ts +3 -3
- package/src/live/index.test.ts +14 -3
- package/src/live/index.ts +23 -3
- package/src/query/anon-query-isolation.integration.test.ts +161 -2
- package/src/query/register.saga.ts +7 -1
- package/src/query/sql.test.ts +6 -0
- package/src/query/sql.ts +8 -0
- package/src/state/client-state.ts +8 -0
- package/src/state/reducers.ts +4 -1
- package/src/testing/adapters.ts +5 -1
- package/src/utils/index.ts +11 -0
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:
|
|
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
|
-
|
|
1153
|
-
'salt.
|
|
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.
|
|
2182
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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 {
|
|
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,
|
|
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(
|
|
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
|
|
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
|
|
package/dist/worker-edges.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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({
|
|
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 = {
|
|
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
|
});
|
package/src/boot/boot.saga.ts
CHANGED
|
@@ -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
|
-
|
|
49
|
-
yield fx.
|
|
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', () => {
|
package/src/client/services.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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
|
};
|
package/src/kernel/effects.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
74
|
-
'salt.
|
|
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:
|
|
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:
|
|
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
|
|
130
|
+
return adapters.ids[effect.scope]();
|
|
131
131
|
case 'hash':
|
|
132
132
|
return adapters.hash(effect.input);
|
|
133
133
|
case 'emit':
|
package/src/live/index.test.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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(
|
|
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 {
|
|
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({
|
|
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;
|
package/src/query/sql.test.ts
CHANGED
|
@@ -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',
|
package/src/state/reducers.ts
CHANGED
|
@@ -444,7 +444,10 @@ export const setFailedCount =
|
|
|
444
444
|
export const setIdentity =
|
|
445
445
|
(
|
|
446
446
|
patch: Partial<
|
|
447
|
-
Pick<
|
|
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 });
|
package/src/testing/adapters.ts
CHANGED
|
@@ -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: {
|
|
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
|
};
|
package/src/utils/index.ts
CHANGED
|
@@ -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 {
|