@spooky-sync/core 0.0.1-canary.321 → 0.0.1-canary.323
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/dist/index.d.ts +21 -7
- package/dist/index.js +158 -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/types.d.ts +10 -0
- 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 +25 -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/services/database/cache-engine.ts +10 -0
- package/src/services/database/sqlite-cache-engine.ts +26 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -0
- package/src/services/stream-processor/index.ts +24 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +36 -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
|
@@ -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
|
@@ -21,12 +21,13 @@ import { TabsCoordinator, type CoordinatorHooks } from '../services/tabs/coordin
|
|
|
21
21
|
import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
|
|
22
22
|
import { computeTabsFingerprint, hash53 } from '../services/tabs/protocol';
|
|
23
23
|
import type { SqliteCacheEngine } from '../services/database/sqlite-cache-engine';
|
|
24
|
+
import { columnFingerprints } from '../services/database/sqlite-column-reset';
|
|
24
25
|
import type { BlobCache } from '../services/blobs/index';
|
|
25
26
|
import { MemoryBlobStore, createBlobCache, resolveBlobBudget } from '../services/blobs/index';
|
|
26
27
|
import type { Adapters } from '../kernel/interpreter';
|
|
27
28
|
import type { RuntimeEvent } from '../kernel/events';
|
|
28
29
|
import type { ServiceCalls } from '../kernel/effects';
|
|
29
|
-
import { encodeRecordId } from '../utils/index';
|
|
30
|
+
import { encodeRecordId, mintOwnerSecret } from '../utils/index';
|
|
30
31
|
import { sha256Hex } from '../utils/sha256';
|
|
31
32
|
import { leaveViewsBeacon, listRefLive } from '../query/sql';
|
|
32
33
|
import { hashOfEdge, rowOfEdge } from '../utils/edges';
|
|
@@ -59,20 +60,28 @@ const RELOAD_SALT_KEY = 'sp00ky:salt';
|
|
|
59
60
|
* query onto the `_00_query` row it already had, which the SSP answers from
|
|
60
61
|
* the live view instead of building a new one.
|
|
61
62
|
*
|
|
63
|
+
* The page's owner secret travels with it: an anonymous row answers only to
|
|
64
|
+
* the secret it was registered with, so the next page needs the same one to
|
|
65
|
+
* rejoin those views. `null` from a page that predates it; the next page then
|
|
66
|
+
* mints one, and the rows it left (which hold no secret) take the new one.
|
|
67
|
+
*
|
|
62
68
|
* Taken, not read: the key is absent while a page lives, so a duplicated tab
|
|
63
69
|
* (which copies sessionStorage) mints its own salt rather than sharing this
|
|
64
70
|
* tab's remote views.
|
|
65
71
|
*/
|
|
66
|
-
export function takeReloadSalt(
|
|
72
|
+
export function takeReloadSalt(
|
|
73
|
+
authId: string | null
|
|
74
|
+
): { salt: string; sessionIds: string[]; owner: string | null } | null {
|
|
67
75
|
try {
|
|
68
76
|
if (typeof sessionStorage === 'undefined') return null;
|
|
69
77
|
const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
|
|
70
78
|
if (raw === null) return null;
|
|
71
79
|
sessionStorage.removeItem(RELOAD_SALT_KEY);
|
|
72
|
-
const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown; sessionIds?: unknown };
|
|
80
|
+
const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown; sessionIds?: unknown; owner?: unknown };
|
|
73
81
|
if (typeof left.salt !== 'string' || left.salt === '' || (left.authId ?? null) !== authId) return null;
|
|
74
82
|
const sessionIds = Array.isArray(left.sessionIds) ? left.sessionIds.filter((sid): sid is string => typeof sid === 'string') : [];
|
|
75
|
-
|
|
83
|
+
const owner = typeof left.owner === 'string' && left.owner !== '' ? left.owner : null;
|
|
84
|
+
return { salt: left.salt, sessionIds, owner };
|
|
76
85
|
} catch {
|
|
77
86
|
return null;
|
|
78
87
|
}
|
|
@@ -84,11 +93,17 @@ export function takeReloadSalt(authId: string | null): { salt: string; sessionId
|
|
|
84
93
|
* The session ids of this page's sockets go with it. The views the next page
|
|
85
94
|
* re-registers keep the edges this page's sockets stamped, and the anonymous
|
|
86
95
|
* LIVE only hears edges stamped by a session it knows (see `listRefLive`).
|
|
96
|
+
* So does the page's owner secret, which those views answer to.
|
|
87
97
|
*/
|
|
88
|
-
export function leaveReloadSalt(
|
|
98
|
+
export function leaveReloadSalt(
|
|
99
|
+
salt: string,
|
|
100
|
+
authId: string | null,
|
|
101
|
+
sessionIds: ReadonlyArray<string> = [],
|
|
102
|
+
owner: string | null = null
|
|
103
|
+
): void {
|
|
89
104
|
try {
|
|
90
105
|
if (typeof sessionStorage !== 'undefined')
|
|
91
|
-
sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId, sessionIds }));
|
|
106
|
+
sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId, sessionIds, owner }));
|
|
92
107
|
} catch {
|
|
93
108
|
/* private-mode storage errors: the next page mints a fresh salt */
|
|
94
109
|
}
|
|
@@ -268,9 +283,9 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
268
283
|
const left = takeReloadSalt(authId);
|
|
269
284
|
if (!left) return null;
|
|
270
285
|
s.remote.adoptSessionIds(left.sessionIds);
|
|
271
|
-
return left.salt;
|
|
286
|
+
return { salt: left.salt, owner: left.owner };
|
|
272
287
|
},
|
|
273
|
-
'salt.leave': (salt, authId) => leaveReloadSalt(salt, authId, s.remote.sessionIds()),
|
|
288
|
+
'salt.leave': (salt, authId, owner) => leaveReloadSalt(salt, authId, s.remote.sessionIds(), owner),
|
|
274
289
|
'local.connect': (bucketId) => s.local.connect(bucketId),
|
|
275
290
|
'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
|
|
276
291
|
'local.beginSwitch': () => s.local.beginSwitch(),
|
|
@@ -294,6 +309,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
294
309
|
tables: [...config.schema.tables.map((t) => t.name), '_00_user_feature', '_00_app_release'],
|
|
295
310
|
schemaHash: String(hash53(config.schemaSurql)),
|
|
296
311
|
pendingIds: new Set(pendingIds),
|
|
312
|
+
columnFingerprints: columnFingerprints(config.schema.tables),
|
|
297
313
|
onVersions: (_table, entries) => host.dispatch({ type: 'VersionsPrimed', entries }),
|
|
298
314
|
}),
|
|
299
315
|
'ssp.reset': () => s.streamProcessor.reset(),
|
|
@@ -411,7 +427,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
411
427
|
},
|
|
412
428
|
},
|
|
413
429
|
clock: { now: () => Date.now() },
|
|
414
|
-
ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt },
|
|
430
|
+
ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt, owner: mintOwnerSecret },
|
|
415
431
|
hash: sha256Hex,
|
|
416
432
|
services,
|
|
417
433
|
};
|
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. */
|
|
@@ -151,6 +151,16 @@ export interface LocalStore extends LocalCacheEngine {
|
|
|
151
151
|
* without it boots the circuit empty, as before.
|
|
152
152
|
*/
|
|
153
153
|
scanVersions?(tables: string[]): Promise<Record<string, [string, number][]>>;
|
|
154
|
+
/**
|
|
155
|
+
* Reset the cached `_00_rv` of every table whose column set (`fingerprints`,
|
|
156
|
+
* see `columnFingerprints`) changed since the store last recorded it, except
|
|
157
|
+
* the rows in `keep`, and record the new sets. Returns the tables reset.
|
|
158
|
+
* For engines that keep bodies across schema changes: a body landed under an
|
|
159
|
+
* older schema lacks the columns it did not know, at the server's version,
|
|
160
|
+
* and would never be fetched again. OPTIONAL: an engine that rebuilds its
|
|
161
|
+
* store on a schema change (SurrealDB) does not need it.
|
|
162
|
+
*/
|
|
163
|
+
resetChangedTables?(fingerprints: Record<string, string>, keep: Set<string>): Promise<string[]>;
|
|
154
164
|
/**
|
|
155
165
|
* Circuit snapshot storage, keyed. Lives in the same durable store as the
|
|
156
166
|
* rows (OPFS SQLite), so it is per-bucket by construction, atomic, and
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type SqliteTransport,
|
|
31
31
|
} from './sqlite-transport';
|
|
32
32
|
import { PROMOTION_OPEN_OPTIONS } from './sqlite-open';
|
|
33
|
+
import { COLUMNS_ROW_ID, planColumnReset } from './sqlite-column-reset';
|
|
33
34
|
import type { EngineTx, Id, LocalStore, OrderBy, RelationFetch, Row } from './cache-engine';
|
|
34
35
|
import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-info';
|
|
35
36
|
|
|
@@ -898,6 +899,31 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
898
899
|
return out;
|
|
899
900
|
}
|
|
900
901
|
|
|
902
|
+
/**
|
|
903
|
+
* Reset the cached `_00_rv` of every table whose column set changed since
|
|
904
|
+
* the last boot, so its rows are fetched again whole. See
|
|
905
|
+
* `sqlite-column-reset.ts`. Returns the tables reset.
|
|
906
|
+
*/
|
|
907
|
+
async resetChangedTables(fingerprints: Record<string, string>, keep: Set<string>): Promise<string[]> {
|
|
908
|
+
const { rows } = await this.call<{ rows: { data: unknown }[] }>('exec', {
|
|
909
|
+
sql: `SELECT data FROM "_00_schema" WHERE id = ?`,
|
|
910
|
+
bind: [COLUMNS_ROW_ID],
|
|
911
|
+
});
|
|
912
|
+
let stored: Record<string, string> | null = null;
|
|
913
|
+
try {
|
|
914
|
+
const raw = rows?.[0]?.data;
|
|
915
|
+
if (typeof raw === 'string') stored = JSON.parse(raw) as Record<string, string>;
|
|
916
|
+
} catch {
|
|
917
|
+
stored = null;
|
|
918
|
+
}
|
|
919
|
+
const { rows: tables } = await this.call<{ rows: { name: string }[] }>('exec', {
|
|
920
|
+
sql: `SELECT name FROM sqlite_master WHERE type = 'table'`,
|
|
921
|
+
});
|
|
922
|
+
const plan = planColumnReset(stored, fingerprints, new Set((tables ?? []).map((t) => t.name)), keep);
|
|
923
|
+
if (plan.statements.length > 0) await this.call('batch', plan.statements);
|
|
924
|
+
return plan.changed;
|
|
925
|
+
}
|
|
926
|
+
|
|
901
927
|
async getSnapshot(key: string): Promise<StoredSnapshot | null> {
|
|
902
928
|
const { rows } = await this.call<{ rows: { id: string; data: unknown }[] }>('exec', {
|
|
903
929
|
sql: `SELECT id, data FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,
|