@spooky-sync/core 0.0.1-canary.331 → 0.0.1-canary.333
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 +19 -4
- package/dist/index.js +220 -41
- package/dist/otel/index.d.ts +1 -1
- package/dist/pure.d.ts +16 -0
- package/dist/types.d.ts +62 -11
- package/package.json +3 -3
- package/src/client/router.ts +1 -1
- package/src/client/services.ts +4 -3
- package/src/kernel/events.ts +2 -2
- package/src/kernel/interpreter.ts +3 -3
- package/src/modules/auth/auth.session-recheck.test.ts +140 -0
- package/src/modules/auth/index.ts +68 -1
- package/src/modules/crdt/crdt.test.ts +52 -6
- package/src/modules/crdt/index.ts +59 -23
- package/src/query/anon-query-isolation.integration.test.ts +7 -3
- package/src/query/membership.saga.test.ts +73 -1
- package/src/query/membership.saga.ts +39 -5
- package/src/query/membership.test.ts +33 -0
- package/src/query/membership.ts +33 -0
- package/src/query/session-guard.integration.test.ts +302 -0
- package/src/sync/live.saga.test.ts +51 -0
- package/src/sync/live.saga.ts +17 -8
- package/src/types.ts +29 -10
- package/src/utils/edges.ts +19 -1
- package/src/utils/error-classification.test.ts +19 -1
- package/src/utils/error-classification.ts +12 -0
package/dist/types.d.ts
CHANGED
|
@@ -822,6 +822,12 @@ interface SignOutContext {
|
|
|
822
822
|
type SignOutHook = (ctx: SignOutContext) => Promise<void> | void;
|
|
823
823
|
/** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
|
|
824
824
|
declare const SIGN_OUT_HOOK_TIMEOUT_MS = 2000;
|
|
825
|
+
/**
|
|
826
|
+
* Floor between two session re-checks started by refused statements. A
|
|
827
|
+
* refused session fails every statement at once, and a check that keeps the
|
|
828
|
+
* session (the server was unreachable) must not be re-run by each of them.
|
|
829
|
+
*/
|
|
830
|
+
declare const REFUSAL_RECHECK_MIN_MS = 30000;
|
|
825
831
|
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
826
832
|
type MapColumnType<T extends ColumnSchema> = T['optional'] extends true ? TypeNameToTypeMap[T['type']] | undefined : TypeNameToTypeMap[T['type']];
|
|
827
833
|
type ExtractAccessParams<S extends SchemaStructure, Name extends keyof S['access'], Method extends 'signIn' | 'signup'> = S['access'] extends undefined ? never : S['access'][Name] extends AccessDefinition ? Prettify<{ [K in keyof S['access'][Name][Method]['params']]: MapColumnType<S['access'][Name][Method]['params'][K]> }> : never;
|
|
@@ -855,10 +861,37 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
855
861
|
private stopping;
|
|
856
862
|
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
857
863
|
private renewing;
|
|
864
|
+
/** The session re-check in flight (see `watchSession`), shared by every trigger. */
|
|
865
|
+
private reverifying;
|
|
866
|
+
/** When a refused statement last started a re-check. */
|
|
867
|
+
private lastRefusalCheckAt;
|
|
868
|
+
private watchingSession;
|
|
858
869
|
private impersonationTimers;
|
|
859
870
|
get eventSystem(): AuthEventSystem;
|
|
860
871
|
constructor(schema: S, remote: RemoteDatabaseService, persistenceClient: PersistenceClient, logger: Logger$1);
|
|
861
872
|
init(): Promise<void>;
|
|
873
|
+
/**
|
|
874
|
+
* Check the session again whenever the socket comes back, and whenever the
|
|
875
|
+
* server refuses a statement because of the session.
|
|
876
|
+
*
|
|
877
|
+
* The server judges a token only when a socket authenticates: the SDK
|
|
878
|
+
* replays `authenticate` on its own reconnects, `RemoteDatabaseService` on a
|
|
879
|
+
* socket it rebuilds. When the replay is refused (the app ended the session,
|
|
880
|
+
* say with a password reset) the SDK drops to an anonymous session, and the
|
|
881
|
+
* supervisor swallows the rebuild's error, so the socket reports `connected`
|
|
882
|
+
* while this service still announces the user. Every view registered from
|
|
883
|
+
* then on carried no identity: the page looked signed in and showed nothing
|
|
884
|
+
* of the user's. `check()` applies the token again and signs out when the
|
|
885
|
+
* server refuses it.
|
|
886
|
+
*
|
|
887
|
+
* A socket that never reconnects is ended by the app's session guard
|
|
888
|
+
* (`fn::sp00ky::session_live`) instead: its heartbeats stop finding rows,
|
|
889
|
+
* and the re-registration that follows is refused with "There was a problem
|
|
890
|
+
* with authentication", which starts a check here too.
|
|
891
|
+
*/
|
|
892
|
+
private watchSession;
|
|
893
|
+
/** One `check()` for every trigger that lands while it runs. */
|
|
894
|
+
private reverify;
|
|
862
895
|
getAccessDefinition<Name extends keyof S['access']>(name: Name): AccessDefinition | undefined;
|
|
863
896
|
/**
|
|
864
897
|
* Subscribe to auth state changes.
|
|
@@ -1594,14 +1627,20 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
1594
1627
|
*/
|
|
1595
1628
|
streamDebounceTime?: number;
|
|
1596
1629
|
/**
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1601
|
-
* Lower = snappier propagation and more rows; higher = fewer rows and more
|
|
1602
|
-
* lag for other collaborators. Defaults to 300ms.
|
|
1630
|
+
* How long in milliseconds the first edit to a collaborative (CRDT) field
|
|
1631
|
+
* after a quiet spell waits before it goes out, so a burst of keystrokes
|
|
1632
|
+
* travels as one edit-log row. Local state is kept on every change (so
|
|
1633
|
+
* reload and offline work). Defaults to 50ms.
|
|
1603
1634
|
*/
|
|
1604
1635
|
crdtDebounceMs?: number;
|
|
1636
|
+
/**
|
|
1637
|
+
* The shortest gap in milliseconds between two edit-log rows of one CRDT
|
|
1638
|
+
* field: while someone keeps typing, their edits go out on this cadence
|
|
1639
|
+
* instead of waiting for a pause. Lower = other collaborators see the text
|
|
1640
|
+
* sooner and more rows are written; higher = fewer rows and more lag.
|
|
1641
|
+
* Defaults to 200ms.
|
|
1642
|
+
*/
|
|
1643
|
+
crdtSendIntervalMs?: number;
|
|
1605
1644
|
/**
|
|
1606
1645
|
* Enable collaborative CRDT fields. When `true`, the `loro-crdt` engine is
|
|
1607
1646
|
* preloaded at client startup (fetched as a separate chunk on page load) so
|
|
@@ -1635,15 +1674,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
1635
1674
|
* back for it.
|
|
1636
1675
|
*
|
|
1637
1676
|
* The client holds exactly one live subscription, on its `_00_list_ref`
|
|
1638
|
-
* table
|
|
1639
|
-
*
|
|
1677
|
+
* table. Without this, the notification names the row and the client then
|
|
1678
|
+
* pulls it with `SELECT * FROM $ids`. The edge
|
|
1640
1679
|
* is a real graph edge, so `FETCH out` makes SurrealDB resolve the row into
|
|
1641
1680
|
* the notification and the body fetch disappears.
|
|
1642
1681
|
*
|
|
1643
1682
|
* The push stays advisory: the body is landed exactly the way a fetched body
|
|
1644
1683
|
* is (store write, circuit ingest, version recorded), so a notification the
|
|
1645
|
-
* server drops costs nothing but the old round trip.
|
|
1646
|
-
*
|
|
1684
|
+
* server drops costs nothing but the old round trip. A row joining a live
|
|
1685
|
+
* view is committed to membership from the notification either way (no
|
|
1686
|
+
* re-read); with this on, the body fetch is saved as well.
|
|
1647
1687
|
*
|
|
1648
1688
|
* Default `false`. The cost is wire bytes: one edit bumps the edge in every
|
|
1649
1689
|
* view holding the row, and under `FETCH` each of those notifications
|
|
@@ -1873,6 +1913,17 @@ interface InlineRow {
|
|
|
1873
1913
|
version: number;
|
|
1874
1914
|
record: Record<string, unknown>;
|
|
1875
1915
|
}
|
|
1916
|
+
/**
|
|
1917
|
+
* A row that joined a query's view, or moved to a newer version, as one LIVE
|
|
1918
|
+
* notification on `_00_list_ref` reports it: the edge's `out` and `version`.
|
|
1919
|
+
* The client commits it to the query's membership straight away instead of
|
|
1920
|
+
* re-reading the edges (see `sync/live.saga.ts`).
|
|
1921
|
+
*/
|
|
1922
|
+
interface EdgeDelta {
|
|
1923
|
+
hash: QueryHash;
|
|
1924
|
+
id: string;
|
|
1925
|
+
version: number;
|
|
1926
|
+
}
|
|
1876
1927
|
type RecordVersionArray = Array<[string, number]>;
|
|
1877
1928
|
/**
|
|
1878
1929
|
* Represents the difference between two record version sets.
|
|
@@ -2175,4 +2226,4 @@ interface DebounceOptions {
|
|
|
2175
2226
|
flushOnHide?: boolean;
|
|
2176
2227
|
}
|
|
2177
2228
|
//#endregion
|
|
2178
|
-
export {
|
|
2229
|
+
export { WebPushModule as $, RegistrationTimings as A, RemoteDatabaseService as At, SyncHealthConfig as B, ImpersonationTimers as Bt, QueryStatusCallback as C, SIGN_OUT_HOOK_TIMEOUT_MS as Ct, ReconnectConfig as D, AuthEventTypeMap as Dt, QueryUpdateCallback as E, AuthEventSystem as Et, Sp00kyQueryResultPromise as F, EventDefinition as Ft, PushDeviceKind as G, impersonationFromToken as Gt, TimingPhase as H, SESSION_POLL_MS as Ht, StorageHealth as I, EventSystem as It, WebPushError as J, WebPushConfig as K, renewDelayMs as Kt, StorageHealthStatus as L, IMPERSONATION_ACCESS as Lt, ServerViewMeta as M, Logger$1 as Mt, Sp00kyConfig as N, LocalStore as Nt, RecordVersionArray as O, AuthEventTypes as Ot, Sp00kyQueryResult as P, SealedQuery as Pt, WebPushMessageInput as Q, StoreType as R, ImpersonationInfo as Rt, QueryStatus as S, REFUSAL_RECHECK_MIN_MS as St, QueryTimings as T, SignOutHook as Tt, UpdateOptions as U, TokenClaims as Ut, SyncHealthStatus as V, ORIGIN_TOKEN_KEY as Vt, PUSH_ENDPOINT_KEY as W, decodeTokenClaims as Wt, WebPushInfo as X, WebPushErrorCode as Y, WebPushMessage as Z, QueryAuthorityCallback as _, WebPushClientMessage as _t, InlineRow as a, WebPushUnsupportedReason as at, QueryHash as b, AuthService as bt, MembershipOutcome as c, BridgeSignOutMessage as ct, MutationEventType as d, PushNotification as dt, WebPushSubscribeOptions as et, PersistenceClient as f, PushNotificationAction as ft, PreloadRefresh as g, PushUrgency as gt, PreloadOptions as h, PushPayloadKind as ht, EventSubscriptionOptions as i, WebPushUnsubscribeOptions as it, RunOptions as j, LogTap as jt, RecordVersionDiff as k, createAuthEventSystem as kt, MutationCallback as l, BridgeTokenMessage as lt, PinoTransmit as m, PushPayload as mt, DebounceOptions as n, WebPushSyncOptions as nt, Level$1 as o, WebPushUpdateOptions as ot, PhaseStat as p, PushOp as pt, WebPushDevice as q, sameImpersonation as qt, EdgeDelta as r, WebPushSyncStatus as rt, MATERIALIZATION_SAMPLE_WINDOW as s, detectWebPushSupport as st, ConnectionState as t, WebPushSupport as tt, MutationEvent as u, PUSH_PAYLOAD_VERSION as ut, QueryConfig as v, isPushPayload as vt, QueryTimeToLive as w, SignOutContext as wt, QueryState as x, ImpersonationCandidate as xt, QueryConfigRecord as y, ActiveImpersonation as yt, SyncHealth as z, ImpersonationTimerHost as zt };
|
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.333",
|
|
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.333",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.333",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.1.0",
|
|
82
82
|
"@surrealdb/wasm-native": "^3.3.4",
|
package/src/client/router.ts
CHANGED
|
@@ -77,7 +77,7 @@ export function route(env: SagaEnv, event: RuntimeEvent): RouteTarget {
|
|
|
77
77
|
case 'LiveStart':
|
|
78
78
|
return { saga: liveStart(env), lane: serial('live') };
|
|
79
79
|
case 'LiveChange':
|
|
80
|
-
return { saga: liveChange(env, event.hashes, event.rows), lane: serial('live-change') };
|
|
80
|
+
return { saga: liveChange(env, event.hashes, event.rows, event.deltas), lane: serial('live-change') };
|
|
81
81
|
case 'TabRole':
|
|
82
82
|
return { saga: setRole(env, event.role), lane: serial('tabs') };
|
|
83
83
|
case 'TabMessage':
|
package/src/client/services.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type { ServiceCalls } from '../kernel/effects';
|
|
|
30
30
|
import { encodeRecordId, mintOwnerSecret } from '../utils/index';
|
|
31
31
|
import { sha256Hex } from '../utils/sha256';
|
|
32
32
|
import { leaveViewsBeacon, listRefLive } from '../query/sql';
|
|
33
|
-
import { hashOfEdge, rowOfEdge } from '../utils/edges';
|
|
33
|
+
import { deltaOfEdge, hashOfEdge, rowOfEdge } from '../utils/edges';
|
|
34
34
|
import { mintMutationId } from '../mutation/mutation-id';
|
|
35
35
|
import { BucketHandle, bucketContentToBlob, bucketSettings } from './bucket-handle';
|
|
36
36
|
|
|
@@ -176,7 +176,7 @@ export function createServices<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
176
176
|
streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === 'sqlite', config.circuitCheckpointMs);
|
|
177
177
|
streamProcessor.configureProjection(config.circuitProjection ?? true);
|
|
178
178
|
const migrator = new LocalMigrator(local, logger);
|
|
179
|
-
const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ??
|
|
179
|
+
const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 50, config.crdtSendIntervalMs ?? 200);
|
|
180
180
|
const auth = new AuthService(config.schema, remote, persistence, logger);
|
|
181
181
|
const tabId = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
|
|
182
182
|
return {
|
|
@@ -395,7 +395,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
395
395
|
// that was actually deleted the join yields nothing anyway. Landing
|
|
396
396
|
// a body there would only ever write back what is going away.
|
|
397
397
|
const row = message.action === 'DELETE' ? null : rowOfEdge(message.value);
|
|
398
|
-
|
|
398
|
+
const delta = deltaOfEdge(message.action, message.value);
|
|
399
|
+
onChange([hash], row ? [row] : undefined, delta ? [delta] : undefined);
|
|
399
400
|
});
|
|
400
401
|
return String(uuid);
|
|
401
402
|
},
|
package/src/kernel/events.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConnectionState, InlineRow, QueryHash, QueryStatus, SyncHealth } from '../types';
|
|
1
|
+
import type { ConnectionState, EdgeDelta, InlineRow, QueryHash, QueryStatus, SyncHealth } from '../types';
|
|
2
2
|
import type { StreamUpdate } from '../services/stream-processor/index';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -36,7 +36,7 @@ export type RuntimeEvent =
|
|
|
36
36
|
| { type: 'ConnectionChanged'; state: ConnectionState }
|
|
37
37
|
| { type: 'StreamUpdate'; update: StreamUpdate }
|
|
38
38
|
| { type: 'LiveStart' }
|
|
39
|
-
| { type: 'LiveChange'; hashes: QueryHash[]; rows?: InlineRow[] }
|
|
39
|
+
| { type: 'LiveChange'; hashes: QueryHash[]; rows?: InlineRow[]; deltas?: EdgeDelta[] }
|
|
40
40
|
| { type: 'TabRole'; role: 'solo' | 'leader' | 'follower' }
|
|
41
41
|
| { type: 'TabMessage'; message: unknown };
|
|
42
42
|
|
|
@@ -2,7 +2,7 @@ import { Duration } from 'surrealdb';
|
|
|
2
2
|
import type { LocalStore } from '../services/database/cache-engine';
|
|
3
3
|
import type { IngestRecord, StreamUpdate } from '../services/stream-processor/index';
|
|
4
4
|
import type { ClientState } from '../state/client-state';
|
|
5
|
-
import type { InlineRow } from '../types';
|
|
5
|
+
import type { EdgeDelta, InlineRow } from '../types';
|
|
6
6
|
import { withTimeout } from '../utils/index';
|
|
7
7
|
import type { Effect, IdScope, RegisterResult, ServiceCalls, Settled, StatementResult } from './effects';
|
|
8
8
|
import type { OutEvent, RuntimeEvent } from './events';
|
|
@@ -17,7 +17,7 @@ export interface Adapters {
|
|
|
17
17
|
* changed query hashes, plus the changed rows themselves when the
|
|
18
18
|
* subscription was opened with the body joined on (`liveInlineBodies`).
|
|
19
19
|
*/
|
|
20
|
-
live(table: string, onChange: (hashes: string[], rows?: InlineRow[]) => void): Promise<string>;
|
|
20
|
+
live(table: string, onChange: (hashes: string[], rows?: InlineRow[], deltas?: EdgeDelta[]) => void): Promise<string>;
|
|
21
21
|
kill(uuid: string): Promise<void>;
|
|
22
22
|
};
|
|
23
23
|
ssp: {
|
|
@@ -81,7 +81,7 @@ export function createInterpreter(adapters: Adapters, host: InterpreterHost): In
|
|
|
81
81
|
: pending;
|
|
82
82
|
}
|
|
83
83
|
case 'remote.live':
|
|
84
|
-
return adapters.remote.live(effect.table, (hashes, rows) => host.dispatch({ type: 'LiveChange', hashes, rows }));
|
|
84
|
+
return adapters.remote.live(effect.table, (hashes, rows, deltas) => host.dispatch({ type: 'LiveChange', hashes, rows, deltas }));
|
|
85
85
|
case 'remote.kill':
|
|
86
86
|
return adapters.remote.kill(effect.uuid);
|
|
87
87
|
case 'ssp.register': {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { AuthService, REFUSAL_RECHECK_MIN_MS } from './index';
|
|
3
|
+
import {
|
|
4
|
+
createDatabaseEventSystem,
|
|
5
|
+
DatabaseEventTypes,
|
|
6
|
+
} from '../../services/database/events/index';
|
|
7
|
+
|
|
8
|
+
// The session is re-checked after every reconnect and after a statement the
|
|
9
|
+
// server refused because of the session: a token refused on a socket's own
|
|
10
|
+
// handshake used to leave the page on an anonymous `connected` socket while
|
|
11
|
+
// it kept announcing the user.
|
|
12
|
+
|
|
13
|
+
function jwt(claims: Record<string, unknown>): string {
|
|
14
|
+
return `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const silentLogger = () =>
|
|
18
|
+
({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: () => silentLogger() }) as any;
|
|
19
|
+
|
|
20
|
+
const REFUSED = 'There was a problem with authentication: this session has ended, sign in again';
|
|
21
|
+
|
|
22
|
+
function makeAuth() {
|
|
23
|
+
const token = jwt({ AC: 'account', ID: 'user:abc' });
|
|
24
|
+
const store = new Map<string, unknown>([['sp00ky_auth_token', token]]);
|
|
25
|
+
const persistence = {
|
|
26
|
+
get: vi.fn(async (k: string) => store.get(k) ?? null),
|
|
27
|
+
set: vi.fn(async (k: string, v: unknown) => void store.set(k, v)),
|
|
28
|
+
remove: vi.fn(async (k: string) => void store.delete(k)),
|
|
29
|
+
} as any;
|
|
30
|
+
const connected = new Set<() => void>();
|
|
31
|
+
const events = createDatabaseEventSystem();
|
|
32
|
+
let refuse = false;
|
|
33
|
+
const authenticate = vi.fn(async () => {
|
|
34
|
+
if (refuse) throw new Error(REFUSED);
|
|
35
|
+
});
|
|
36
|
+
const remote = {
|
|
37
|
+
query: vi.fn(async () => [{ id: 'user:abc' }]),
|
|
38
|
+
setAuthToken: vi.fn(),
|
|
39
|
+
whenConnected: vi.fn(async () => undefined),
|
|
40
|
+
subscribeConnection: (_event: 'connected', cb: () => void) => {
|
|
41
|
+
connected.add(cb);
|
|
42
|
+
return () => connected.delete(cb);
|
|
43
|
+
},
|
|
44
|
+
getEvents: () => events,
|
|
45
|
+
getClient: () => ({ authenticate, invalidate: vi.fn(async () => undefined) }),
|
|
46
|
+
} as any;
|
|
47
|
+
const auth = new AuthService({} as any, remote, persistence, silentLogger());
|
|
48
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
49
|
+
return {
|
|
50
|
+
auth,
|
|
51
|
+
authenticate,
|
|
52
|
+
store,
|
|
53
|
+
refuseFromNowOn: () => void (refuse = true),
|
|
54
|
+
reconnect: async () => {
|
|
55
|
+
for (const cb of connected) cb();
|
|
56
|
+
await settle();
|
|
57
|
+
},
|
|
58
|
+
statementFails: async (error: string) => {
|
|
59
|
+
events.emit(DatabaseEventTypes.RemoteQuery, {
|
|
60
|
+
query: 'RETURN fn::query::register($config)',
|
|
61
|
+
duration: 1,
|
|
62
|
+
success: false,
|
|
63
|
+
error,
|
|
64
|
+
timestamp: Date.now(),
|
|
65
|
+
});
|
|
66
|
+
await settle();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('AuthService session re-check', () => {
|
|
72
|
+
it('signs out when a reconnected socket can no longer use the token', async () => {
|
|
73
|
+
const t = makeAuth();
|
|
74
|
+
await t.auth.init();
|
|
75
|
+
expect(t.auth.isAuthenticated).toBe(true);
|
|
76
|
+
|
|
77
|
+
t.refuseFromNowOn();
|
|
78
|
+
await t.reconnect();
|
|
79
|
+
|
|
80
|
+
expect(t.authenticate).toHaveBeenCalledTimes(2);
|
|
81
|
+
expect(t.auth.isAuthenticated).toBe(false);
|
|
82
|
+
expect(t.store.get('sp00ky_auth_token')).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps a session the server still accepts after a reconnect', async () => {
|
|
86
|
+
const t = makeAuth();
|
|
87
|
+
await t.auth.init();
|
|
88
|
+
await t.reconnect();
|
|
89
|
+
|
|
90
|
+
expect(t.authenticate).toHaveBeenCalledTimes(2);
|
|
91
|
+
expect(t.auth.isAuthenticated).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('signs out when a statement on an open socket is refused for the session', async () => {
|
|
95
|
+
const t = makeAuth();
|
|
96
|
+
await t.auth.init();
|
|
97
|
+
|
|
98
|
+
t.refuseFromNowOn();
|
|
99
|
+
await t.statementFails(`An error occurred: ${REFUSED}`);
|
|
100
|
+
|
|
101
|
+
expect(t.auth.isAuthenticated).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('ignores statement errors that are not about the session', async () => {
|
|
105
|
+
const t = makeAuth();
|
|
106
|
+
await t.auth.init();
|
|
107
|
+
await t.statementFails('Found 3 for field `n`, but expected a string');
|
|
108
|
+
await t.statementFails('There was a problem with the underlying connection');
|
|
109
|
+
|
|
110
|
+
expect(t.authenticate).toHaveBeenCalledTimes(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('re-checks once per window however many statements are refused', async () => {
|
|
114
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
115
|
+
try {
|
|
116
|
+
const t = makeAuth();
|
|
117
|
+
await t.auth.init();
|
|
118
|
+
await t.statementFails(REFUSED);
|
|
119
|
+
await t.statementFails(REFUSED);
|
|
120
|
+
expect(t.authenticate).toHaveBeenCalledTimes(2);
|
|
121
|
+
|
|
122
|
+
vi.setSystemTime(Date.now() + REFUSAL_RECHECK_MIN_MS);
|
|
123
|
+
await t.statementFails(REFUSED);
|
|
124
|
+
expect(t.authenticate).toHaveBeenCalledTimes(3);
|
|
125
|
+
expect(t.auth.isAuthenticated).toBe(true);
|
|
126
|
+
} finally {
|
|
127
|
+
vi.useRealTimers();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does nothing for a signed-out page', async () => {
|
|
132
|
+
const t = makeAuth();
|
|
133
|
+
t.store.clear();
|
|
134
|
+
await t.auth.init();
|
|
135
|
+
await t.reconnect();
|
|
136
|
+
await t.statementFails(REFUSED);
|
|
137
|
+
|
|
138
|
+
expect(t.authenticate).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -9,7 +9,8 @@ import type { Logger } from '../../services/logger/index';
|
|
|
9
9
|
export * from './events/index';
|
|
10
10
|
import { AuthEventTypes, createAuthEventSystem } from './events/index';
|
|
11
11
|
import type { PersistenceClient } from '../../types';
|
|
12
|
-
import { classifySyncError } from '../../utils/error-classification';
|
|
12
|
+
import { classifySyncError, isRefusedSessionError } from '../../utils/error-classification';
|
|
13
|
+
import { DatabaseEventTypes } from '../../services/database/events/index';
|
|
13
14
|
import { encodeRecordId } from '../../utils/index';
|
|
14
15
|
import { RecordId } from 'surrealdb';
|
|
15
16
|
import {
|
|
@@ -53,6 +54,13 @@ export type SignOutHook = (ctx: SignOutContext) => Promise<void> | void;
|
|
|
53
54
|
/** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
|
|
54
55
|
export const SIGN_OUT_HOOK_TIMEOUT_MS = 2_000;
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Floor between two session re-checks started by refused statements. A
|
|
59
|
+
* refused session fails every statement at once, and a check that keeps the
|
|
60
|
+
* session (the server was unreachable) must not be re-run by each of them.
|
|
61
|
+
*/
|
|
62
|
+
export const REFUSAL_RECHECK_MIN_MS = 30_000;
|
|
63
|
+
|
|
56
64
|
// Helper to pretty print types
|
|
57
65
|
type Prettify<T> = {
|
|
58
66
|
[K in keyof T]: T[K];
|
|
@@ -111,6 +119,11 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
111
119
|
private stopping: Promise<void> | null = null;
|
|
112
120
|
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
113
121
|
private renewing: Promise<void> | null = null;
|
|
122
|
+
/** The session re-check in flight (see `watchSession`), shared by every trigger. */
|
|
123
|
+
private reverifying: Promise<void> | null = null;
|
|
124
|
+
/** When a refused statement last started a re-check. */
|
|
125
|
+
private lastRefusalCheckAt = Number.NEGATIVE_INFINITY;
|
|
126
|
+
private watchingSession = false;
|
|
114
127
|
private impersonationTimers = new ImpersonationTimers({
|
|
115
128
|
current: () => this.impersonation,
|
|
116
129
|
renew: () => this.renewImpersonation(),
|
|
@@ -141,6 +154,60 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
141
154
|
|
|
142
155
|
async init() {
|
|
143
156
|
await this.check();
|
|
157
|
+
this.watchSession();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check the session again whenever the socket comes back, and whenever the
|
|
162
|
+
* server refuses a statement because of the session.
|
|
163
|
+
*
|
|
164
|
+
* The server judges a token only when a socket authenticates: the SDK
|
|
165
|
+
* replays `authenticate` on its own reconnects, `RemoteDatabaseService` on a
|
|
166
|
+
* socket it rebuilds. When the replay is refused (the app ended the session,
|
|
167
|
+
* say with a password reset) the SDK drops to an anonymous session, and the
|
|
168
|
+
* supervisor swallows the rebuild's error, so the socket reports `connected`
|
|
169
|
+
* while this service still announces the user. Every view registered from
|
|
170
|
+
* then on carried no identity: the page looked signed in and showed nothing
|
|
171
|
+
* of the user's. `check()` applies the token again and signs out when the
|
|
172
|
+
* server refuses it.
|
|
173
|
+
*
|
|
174
|
+
* A socket that never reconnects is ended by the app's session guard
|
|
175
|
+
* (`fn::sp00ky::session_live`) instead: its heartbeats stop finding rows,
|
|
176
|
+
* and the re-registration that follows is refused with "There was a problem
|
|
177
|
+
* with authentication", which starts a check here too.
|
|
178
|
+
*/
|
|
179
|
+
private watchSession(): void {
|
|
180
|
+
if (this.watchingSession) return;
|
|
181
|
+
this.watchingSession = true;
|
|
182
|
+
this.remote.subscribeConnection('connected', () => this.reverify());
|
|
183
|
+
this.remote.getEvents().subscribe(DatabaseEventTypes.RemoteQuery, (event) => {
|
|
184
|
+
const { success, error } = event.payload;
|
|
185
|
+
if (success || !isRefusedSessionError(error ?? '')) return;
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
if (now - this.lastRefusalCheckAt < REFUSAL_RECHECK_MIN_MS) return;
|
|
188
|
+
this.lastRefusalCheckAt = now;
|
|
189
|
+
this.reverify();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One `check()` for every trigger that lands while it runs. */
|
|
194
|
+
private reverify(): void {
|
|
195
|
+
if (!this.isAuthenticated || this.reverifying) return;
|
|
196
|
+
this.reverifying = (async () => {
|
|
197
|
+
// A rebuilt socket reports `connected` before its namespace and token
|
|
198
|
+
// are applied: check against the finished handshake.
|
|
199
|
+
await this.remote.whenConnected();
|
|
200
|
+
if (this.isAuthenticated) await this.check();
|
|
201
|
+
})()
|
|
202
|
+
.catch((error) => {
|
|
203
|
+
this.logger.warn(
|
|
204
|
+
{ error, Category: 'sp00ky-client::AuthService::reverify' },
|
|
205
|
+
'Session re-check failed'
|
|
206
|
+
);
|
|
207
|
+
})
|
|
208
|
+
.finally(() => {
|
|
209
|
+
this.reverifying = null;
|
|
210
|
+
});
|
|
144
211
|
}
|
|
145
212
|
|
|
146
213
|
getAccessDefinition<Name extends keyof S['access']>(name: Name): AccessDefinition | undefined {
|
|
@@ -36,6 +36,8 @@ class World {
|
|
|
36
36
|
for (const go of this.held.splice(0)) go();
|
|
37
37
|
}
|
|
38
38
|
private watchers = new Set<{ table: string; where: Record<string, unknown>; cb: (rows: any[]) => void }>();
|
|
39
|
+
/** Every query a client registered, in order. */
|
|
40
|
+
watched: string[] = [];
|
|
39
41
|
|
|
40
42
|
client(user: string, store = new Map<string, any>(), logger: any = silent) {
|
|
41
43
|
const local = {
|
|
@@ -64,9 +66,10 @@ class World {
|
|
|
64
66
|
return [[]];
|
|
65
67
|
},
|
|
66
68
|
} as any;
|
|
67
|
-
const manager = new CrdtManager(schema, local, remote, logger
|
|
69
|
+
const manager = new CrdtManager(schema, local, remote, logger);
|
|
68
70
|
const ports: CrdtPorts = {
|
|
69
71
|
watch: async (table, where, cb) => {
|
|
72
|
+
this.watched.push(table);
|
|
70
73
|
const w = { table, where, cb };
|
|
71
74
|
this.watchers.add(w);
|
|
72
75
|
if (this.slow) {
|
|
@@ -158,17 +161,60 @@ describe('CRDT fields', () => {
|
|
|
158
161
|
expect(text(fb)).toBe(text(fa));
|
|
159
162
|
});
|
|
160
163
|
|
|
161
|
-
it('
|
|
164
|
+
it('while typing continues, edits go out on a steady cadence instead of waiting for a pause', async () => {
|
|
162
165
|
const world = new World();
|
|
163
166
|
const a = world.client('user:a');
|
|
164
167
|
const f = await a.manager.open('doc', 'doc:1', 'body');
|
|
165
|
-
for
|
|
168
|
+
// A key every 50 ms for a second: a trailing debounce would send nothing
|
|
169
|
+
// until the typing stopped. The first edit goes out after 50 ms, the rest
|
|
170
|
+
// every 200 ms.
|
|
171
|
+
for (let i = 0; i < 20; i++) {
|
|
166
172
|
f.getDoc().getText('body').insert(i, 'x');
|
|
167
173
|
f.getDoc().commit();
|
|
168
|
-
await vi.advanceTimersByTimeAsync(
|
|
174
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
175
|
+
if (i === 0) expect(world.log).toHaveLength(1);
|
|
169
176
|
}
|
|
170
|
-
expect(world.log.length).toBeGreaterThanOrEqual(
|
|
171
|
-
expect(world.log.length).
|
|
177
|
+
expect(world.log.length).toBeGreaterThanOrEqual(4);
|
|
178
|
+
expect(world.log.length).toBeLessThanOrEqual(6);
|
|
179
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
180
|
+
const copy = new LoroDoc();
|
|
181
|
+
for (const row of world.log) copy.import(row.data);
|
|
182
|
+
expect(copy.getText('body').toString()).toBe('x'.repeat(20));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('edits made while a send waits on the server go out right after it', async () => {
|
|
186
|
+
const world = new World();
|
|
187
|
+
const a = world.client('user:a');
|
|
188
|
+
const f = await a.manager.open('doc', 'doc:1', 'body');
|
|
189
|
+
world.slow = true;
|
|
190
|
+
// A fresh peer: the next send claims it first, and that claim hangs.
|
|
191
|
+
a.manager.setSessionId('');
|
|
192
|
+
f.getDoc().getText('body').insert(0, 'a');
|
|
193
|
+
f.getDoc().commit();
|
|
194
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
195
|
+
f.getDoc().getText('body').insert(1, 'b');
|
|
196
|
+
f.getDoc().commit();
|
|
197
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
198
|
+
world.slow = false;
|
|
199
|
+
world.release();
|
|
200
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
201
|
+
const copy = new LoroDoc();
|
|
202
|
+
for (const row of world.log) copy.import(row.data);
|
|
203
|
+
expect(copy.getText('body').toString()).toBe('ab');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('a first open reads the published document while its log query registers, not before', async () => {
|
|
207
|
+
const world = new World();
|
|
208
|
+
world.slow = true;
|
|
209
|
+
const a = world.client('user:a');
|
|
210
|
+
const opening = a.manager.open('doc', 'doc:1', 'body');
|
|
211
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
212
|
+
// The published-document read is still out, and the log query is already registered.
|
|
213
|
+
expect(world.watched).toEqual(['doc__crdt_log']);
|
|
214
|
+
world.slow = false;
|
|
215
|
+
world.release();
|
|
216
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
217
|
+
expect((await opening).isLoaded()).toBe(true);
|
|
172
218
|
});
|
|
173
219
|
|
|
174
220
|
it('send nothing until the peer is claimed, then everything at once', async () => {
|