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