@spooky-sync/core 0.0.1-canary.315 → 0.0.1-canary.317

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.
@@ -43,8 +43,9 @@ export function* boot(env: SagaEnv, opts: BootOptions): Saga<void> {
43
43
  yield fx.dispatch({ type: 'PrimeCircuit' });
44
44
 
45
45
  const restoredUserId = (yield fx.service('auth.restoreSession')) as string | null;
46
- const salt = (yield fx.id('salt')) as string;
47
46
  const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
47
+ // A reload keeps the salt its tab had, so registrations land on warm views.
48
+ const salt = (((yield fx.service('salt.take', authId)) as string | null) ?? (yield fx.id('salt'))) as string;
48
49
  yield fx.state.update(R.setIdentity({ sessionId: salt, userId: restoredUserId, saltUserId: authId }));
49
50
  yield fx.service('crdt.setSessionId', salt);
50
51
  if (restoredUserId) yield fx.service('ssp.setSessionAuth', authId, (yield fx.service('auth.access')) as string | null);
@@ -115,9 +116,19 @@ export function* versionsPrimed(entries: Array<readonly [string, number]>): Saga
115
116
  yield fx.state.update(R.setVersions(entries));
116
117
  }
117
118
 
118
- /** Hand this tab's views back to the server when the page goes away for good. */
119
+ /**
120
+ * The page is going away for good: leave the salt for a reload of this tab,
121
+ * then hand this tab's views back to the server. The release is deferred
122
+ * server-side (`fn::query::leave`), so a reload that re-registers within the
123
+ * grace finds its views still warm.
124
+ */
119
125
  export function* pageHide(): Saga<void> {
120
- const ids = (yield fx.state.read((s) => [...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered').map((e) => e.def.id))) as unknown[];
126
+ const [salt, saltUserId, ids] = (yield fx.state.read((s) => [
127
+ s.sessionId,
128
+ s.saltUserId,
129
+ [...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered').map((e) => e.def.id),
130
+ ])) as [string | null, string | null, unknown[]];
131
+ if (salt) yield fx.service('salt.leave', salt, saltUserId);
121
132
  if (ids.length > 0) yield fx.service('remote.releaseViews', ids);
122
133
  }
123
134
 
@@ -0,0 +1,51 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { leaveReloadSalt, takeReloadSalt } from './services';
3
+
4
+ class MemoryStorage {
5
+ private items = new Map<string, string>();
6
+ getItem(key: string): string | null {
7
+ return this.items.get(key) ?? null;
8
+ }
9
+ setItem(key: string, value: string): void {
10
+ this.items.set(key, value);
11
+ }
12
+ removeItem(key: string): void {
13
+ this.items.delete(key);
14
+ }
15
+ }
16
+
17
+ describe('reload salt', () => {
18
+ const g = globalThis as { sessionStorage?: unknown };
19
+ beforeEach(() => {
20
+ g.sessionStorage = new MemoryStorage();
21
+ });
22
+ afterEach(() => {
23
+ delete g.sessionStorage;
24
+ });
25
+
26
+ it('a salt left on the way out is taken once, by the same principal', () => {
27
+ leaveReloadSalt('s1', 'user:a');
28
+ expect(takeReloadSalt('user:a')).toBe('s1');
29
+ // Taken, not read: a tab duplicated from this page finds nothing.
30
+ expect(takeReloadSalt('user:a')).toBeNull();
31
+ });
32
+
33
+ it('another principal, or none, mints a fresh salt', () => {
34
+ leaveReloadSalt('s1', 'user:a');
35
+ expect(takeReloadSalt('user:b')).toBeNull();
36
+ leaveReloadSalt('s2', null);
37
+ expect(takeReloadSalt(null)).toBe('s2');
38
+ leaveReloadSalt('s3', null);
39
+ expect(takeReloadSalt('user:a')).toBeNull();
40
+ });
41
+
42
+ it('garbage or missing storage is a fresh salt, never a throw', () => {
43
+ (g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', '{not json');
44
+ expect(takeReloadSalt(null)).toBeNull();
45
+ (g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', JSON.stringify({ salt: '', authId: null }));
46
+ expect(takeReloadSalt(null)).toBeNull();
47
+ delete g.sessionStorage;
48
+ expect(takeReloadSalt(null)).toBeNull();
49
+ expect(() => leaveReloadSalt('s1', null)).not.toThrow();
50
+ });
51
+ });
@@ -28,6 +28,7 @@ import type { RuntimeEvent } from '../kernel/events';
28
28
  import type { ServiceCalls } from '../kernel/effects';
29
29
  import { encodeRecordId } from '../utils/index';
30
30
  import { sha256Hex } from '../utils/sha256';
31
+ import { leaveViewsBeacon } from '../query/sql';
31
32
  import { hashOfEdge, rowOfEdge } from '../utils/edges';
32
33
  import { mintMutationId } from '../mutation/mutation-id';
33
34
  import { BucketHandle, bucketContentToBlob } from './bucket-handle';
@@ -50,6 +51,40 @@ export function writeBootBucketHint(bucketId: string): void {
50
51
  }
51
52
  }
52
53
 
54
+ const RELOAD_SALT_KEY = 'sp00ky:salt';
55
+
56
+ /**
57
+ * Take the salt this tab's previous page left on its way out, if it was minted
58
+ * for the same principal. A reload that keeps its salt re-registers every
59
+ * query onto the `_00_query` row it already had, which the SSP answers from
60
+ * the live view instead of building a new one.
61
+ *
62
+ * Taken, not read: the key is absent while a page lives, so a duplicated tab
63
+ * (which copies sessionStorage) mints its own salt rather than sharing this
64
+ * tab's remote views.
65
+ */
66
+ export function takeReloadSalt(authId: string | null): string | null {
67
+ try {
68
+ if (typeof sessionStorage === 'undefined') return null;
69
+ const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
70
+ if (raw === null) return null;
71
+ sessionStorage.removeItem(RELOAD_SALT_KEY);
72
+ const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown };
73
+ return typeof left.salt === 'string' && left.salt !== '' && (left.authId ?? null) === authId ? left.salt : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}. */
80
+ export function leaveReloadSalt(salt: string, authId: string | null): void {
81
+ try {
82
+ if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId }));
83
+ } catch {
84
+ /* private-mode storage errors: the next page mints a fresh salt */
85
+ }
86
+ }
87
+
53
88
  // Moved to a pure module so `@spooky-sync/core/pure` and the live feed share
54
89
  // it; re-exported here for existing imports.
55
90
  export { sha256Hex };
@@ -220,6 +255,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
220
255
  const services: ServiceCalls = {
221
256
  'hint.read': readBootBucketHint,
222
257
  'hint.write': writeBootBucketHint,
258
+ 'salt.take': takeReloadSalt,
259
+ 'salt.leave': leaveReloadSalt,
223
260
  'local.connect': (bucketId) => s.local.connect(bucketId),
224
261
  'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
225
262
  'local.beginSwitch': () => s.local.beginSwitch(),
@@ -260,11 +297,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
260
297
  'auth.consumeEndedImpersonation': () => s.auth.consumeEndedImpersonation(),
261
298
  'remote.connect': () => s.remote.connect(),
262
299
  'remote.releaseViews': (ids) => {
263
- const list = ids
264
- .map((id) => String(id))
265
- .filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id))
266
- .join(', ');
267
- if (list) s.remote.beaconSql(`FOR $id IN [${list}] { LET $_released = fn::query::unsubscribe($id); };`);
300
+ const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
301
+ if (sql) s.remote.beaconSql(sql);
268
302
  },
269
303
  'supervisor.start': () => s.connectionSupervisor.start(),
270
304
  'tabs.start': (bucketId) => {
@@ -70,6 +70,8 @@ export type EffectKind = Effect['kind'];
70
70
  export interface ServiceCalls {
71
71
  'hint.read': () => string | null;
72
72
  'hint.write': (bucketId: string) => void;
73
+ 'salt.take': (authId: string | null) => string | null;
74
+ 'salt.leave': (salt: string, authId: string | null) => void;
73
75
  'local.connect': (bucketId: string) => Promise<void>;
74
76
  'local.switchStore': (bucketId: string) => Promise<void>;
75
77
  'local.beginSwitch': () => () => void;
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createHmac } from 'node:crypto';
5
+ import { Surreal } from 'surrealdb';
6
+ import { AuthService } from './index';
7
+ import { ORIGIN_TOKEN_KEY } from './impersonation';
8
+
9
+ /**
10
+ * Impersonation start and stop against a real SurrealDB (the WASM engine) and
11
+ * the real SDK: record access for the admin, the `_00_impersonate` access with
12
+ * the same AUTHENTICATE revocation block the CLI generates, and the session
13
+ * functions minus the HTTP mint (the token is signed here, the way
14
+ * `ssp_protocol::impersonation::mint` signs it).
15
+ */
16
+
17
+ const KEY = 'test-impersonation-key';
18
+ const b64 = (v: string | Buffer) => Buffer.from(v).toString('base64url');
19
+
20
+ function mintImpersonation(session: string, target: string, admin: string, ttlSecs = 900): string {
21
+ const now = Math.floor(Date.now() / 1000);
22
+ const header = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
23
+ const claims = b64(
24
+ JSON.stringify({
25
+ iss: 'SurrealDB',
26
+ iat: now,
27
+ nbf: now,
28
+ exp: now + ttlSecs,
29
+ NS: 'n',
30
+ DB: 'd',
31
+ AC: '_00_impersonate',
32
+ ID: target,
33
+ spky_imp: session,
34
+ spky_admin: admin,
35
+ spky_as_access: 'account',
36
+ })
37
+ );
38
+ const sig = createHmac('sha256', KEY).update(`${header}.${claims}`).digest('base64url');
39
+ return `${header}.${claims}.${sig}`;
40
+ }
41
+
42
+ const SCHEMA = `
43
+ DEFINE TABLE user SCHEMALESS PERMISSIONS FOR select WHERE true FOR create, update, delete NONE;
44
+ DEFINE ACCESS account ON DATABASE TYPE RECORD
45
+ SIGNIN (SELECT * FROM user WHERE email = $email AND pass = $pass)
46
+ DURATION FOR TOKEN 365d, FOR SESSION 365d;
47
+ DEFINE TABLE _00_admin SCHEMALESS PERMISSIONS NONE;
48
+ DEFINE TABLE _00_impersonation SCHEMALESS PERMISSIONS FOR select, create, update WHERE true;
49
+ DEFINE ACCESS _00_impersonate ON DATABASE TYPE RECORD
50
+ WITH JWT ALGORITHM HS256 KEY '${KEY}'
51
+ AUTHENTICATE {
52
+ LET $s = (SELECT * FROM ONLY type::record($token.spky_imp));
53
+ IF $s = NONE OR $s.ended_at != NONE OR <string>$s.target != <string>$auth.id {
54
+ THROW 'Impersonation session is no longer valid.';
55
+ };
56
+ IF array::len((SELECT VALUE id FROM _00_admin WHERE user = $s.admin LIMIT 1)) = 0 {
57
+ THROW 'Impersonation session is no longer valid.';
58
+ };
59
+ RETURN $auth;
60
+ }
61
+ DURATION FOR TOKEN 15m, FOR SESSION 15m;
62
+ DEFINE FUNCTION fn::_00_impersonate::start($target: string, $reason: string) {
63
+ IF $access = '_00_impersonate' { THROW 'Cannot start impersonation from an impersonated session.'; };
64
+ UPSERT _00_impersonation:s1 SET target = type::record($target), admin = $auth.id, reason = $reason, ended_at = NONE;
65
+ RETURN { token: $test_imp_token, session: '_00_impersonation:s1' };
66
+ } PERMISSIONS FULL;
67
+ DEFINE FUNCTION fn::_00_impersonate::stop($session: string) {
68
+ UPDATE type::record($session) SET ended_at = time::now();
69
+ RETURN NONE;
70
+ } PERMISSIONS FULL;
71
+ DEFINE FUNCTION fn::_00_impersonate::renew() {
72
+ IF $access != '_00_impersonate' { THROW 'Not an impersonated session.'; };
73
+ RETURN { token: $test_imp_token_renewed };
74
+ } PERMISSIONS FULL;
75
+ DEFINE FUNCTION fn::_00_impersonate::current() {
76
+ IF $access != '_00_impersonate' { RETURN NONE; };
77
+ LET $s = (SELECT * FROM ONLY type::record($token.spky_imp));
78
+ RETURN IF $s = NONE OR $s.ended_at != NONE { NONE } ELSE { { session: <string>$s.id } };
79
+ } PERMISSIONS FULL;
80
+ CREATE user:alice SET email = 'alice@x', pass = 'pw';
81
+ CREATE user:bob SET email = 'bob@x', pass = 'pw';
82
+ CREATE _00_admin:alice SET user = user:alice;
83
+ `;
84
+
85
+ const silentLogger = (): any => {
86
+ const l: any = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), trace: vi.fn() };
87
+ l.child = () => l;
88
+ return l;
89
+ };
90
+
91
+ describe('impersonation on the SurrealDB-WASM engine', () => {
92
+ let db: Surreal;
93
+ let adminToken: string;
94
+
95
+ beforeAll(async () => {
96
+ const realFetch = globalThis.fetch;
97
+ vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
98
+ const url = input instanceof URL ? input : new URL(String(input));
99
+ if (url.protocol === 'file:') return new Response(await readFile(fileURLToPath(url)));
100
+ return realFetch(input as RequestInfo, init);
101
+ });
102
+ const { createWasmEngines } = await import('@surrealdb/wasm');
103
+ db = new Surreal({ engines: createWasmEngines() });
104
+ await db.connect('mem://');
105
+ await db.use({ namespace: 'n', database: 'd' });
106
+ await db.query(SCHEMA);
107
+ await db.query(`DEFINE PARAM $test_imp_token VALUE '${mintImpersonation('_00_impersonation:s1', 'user:bob', 'user:alice')}' PERMISSIONS FULL;`);
108
+ await db.query(`DEFINE PARAM $test_imp_token_renewed VALUE '${mintImpersonation('_00_impersonation:s1', 'user:bob', 'user:alice', 901)}' PERMISSIONS FULL;`);
109
+ const tokens = await db.signin({ access: 'account', variables: { email: 'alice@x', pass: 'pw' } });
110
+ adminToken = typeof tokens === 'string' ? tokens : (tokens as { access: string }).access;
111
+ }, 60_000);
112
+
113
+ afterAll(async () => {
114
+ await db?.close();
115
+ vi.unstubAllGlobals();
116
+ });
117
+
118
+ function makeAuth(opts: { failUserRead?: () => boolean; holdStop?: Promise<void> } = {}) {
119
+ const store = new Map<string, unknown>();
120
+ const persistence = {
121
+ get: async (k: string) => store.get(k) ?? null,
122
+ set: async (k: string, v: unknown) => void store.set(k, v),
123
+ remove: async (k: string) => void store.delete(k),
124
+ } as any;
125
+ const remote = {
126
+ query: async (sql: string, vars?: Record<string, unknown>) => {
127
+ if (sql.includes('$auth.id') && opts.failUserRead?.()) {
128
+ throw new Error('There was a problem with the database: the query was cancelled');
129
+ }
130
+ if (sql.includes('fn::_00_impersonate::stop') && opts.holdStop) await opts.holdStop;
131
+ return db.query(sql, vars);
132
+ },
133
+ setAuthToken: () => undefined,
134
+ getClient: () => db,
135
+ } as any;
136
+ return { auth: new AuthService({} as any, remote, persistence, silentLogger()), store };
137
+ }
138
+
139
+ it('stop returns to the admin', async () => {
140
+ const { auth, store } = makeAuth();
141
+ await auth.check(adminToken);
142
+ expect(String(auth.currentUser.id)).toBe('user:alice');
143
+
144
+ await auth.impersonate('user:bob', 'support');
145
+ expect(String(auth.currentUser.id)).toBe('user:bob');
146
+
147
+ await auth.stopImpersonating();
148
+ expect(auth.isAuthenticated).toBe(true);
149
+ expect(String(auth.currentUser.id)).toBe('user:alice');
150
+ expect(store.get('sp00ky_auth_token')).toBe(adminToken);
151
+ expect(store.has(ORIGIN_TOKEN_KEY)).toBe(false);
152
+ });
153
+
154
+ it('an explicit stop racing a server-ended stop still returns to the admin', async () => {
155
+ let release!: () => void;
156
+ const holdStop = new Promise<void>((r) => (release = r));
157
+ const { auth } = makeAuth({ holdStop });
158
+ await auth.check(adminToken);
159
+ await auth.impersonate('user:bob', 'support');
160
+
161
+ // The Stop button's stop is waiting on the server when the poll notices
162
+ // the end (or a renewal is refused). Run apart, the poll's stop returned
163
+ // to the admin and the button's, finding the admin token taken, signed
164
+ // the admin out. Now the second joins the first.
165
+ const explicit = auth.stopImpersonating();
166
+ const fromPoll = auth.stopImpersonating({ serverEnded: true });
167
+ release();
168
+ await Promise.all([explicit, fromPoll]);
169
+
170
+ expect(auth.isAuthenticated).toBe(true);
171
+ expect(String(auth.currentUser.id)).toBe('user:alice');
172
+ });
173
+
174
+ it('a failed read of the admin row on the way back keeps the admin signed in', async () => {
175
+ let failNext = false;
176
+ const { auth, store } = makeAuth({ failUserRead: () => (failNext ? ((failNext = false), true) : false) });
177
+ await auth.check(adminToken);
178
+ await auth.impersonate('user:bob', 'support');
179
+
180
+ failNext = true;
181
+ await auth.stopImpersonating();
182
+
183
+ expect(auth.isAuthenticated).toBe(true);
184
+ expect(String(auth.currentUser.id)).toBe('user:alice');
185
+ expect(store.get('sp00ky_auth_token')).toBe(adminToken);
186
+ });
187
+
188
+ it('stop after half an hour of renewals and polls returns to the admin', async () => {
189
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
190
+ try {
191
+ const { auth } = makeAuth();
192
+ await auth.check(adminToken);
193
+ await auth.impersonate('user:bob', 'support');
194
+ await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
195
+ expect(String(auth.currentUser.id)).toBe('user:bob');
196
+
197
+ await auth.stopImpersonating();
198
+
199
+ expect(auth.isAuthenticated).toBe(true);
200
+ expect(String(auth.currentUser.id)).toBe('user:alice');
201
+ } finally {
202
+ vi.useRealTimers();
203
+ }
204
+ });
205
+ });
@@ -260,6 +260,28 @@ describe('timers', () => {
260
260
  expect(auth.impersonation).toBeNull();
261
261
  });
262
262
 
263
+ it('a renewal in flight when stop begins does not put the target back on the socket', async () => {
264
+ const { auth, server, store } = await signedInAdmin();
265
+ await auth.impersonate('user:bob', 'support ticket');
266
+ let releaseRenew!: () => void;
267
+ const held = new Promise<void>((r) => (releaseRenew = r));
268
+ const answer = server.query.getMockImplementation()!;
269
+ server.query.mockImplementation(async (sql: string, vars?: Record<string, unknown>) => {
270
+ if (sql.includes('fn::_00_impersonate::renew')) await held;
271
+ return answer(sql, vars);
272
+ });
273
+
274
+ const renewal = auth.renewImpersonation();
275
+ const stop = auth.stopImpersonating();
276
+ releaseRenew();
277
+ await Promise.all([renewal, stop]);
278
+
279
+ expect(auth.currentUser.id).toBe('user:alice');
280
+ expect(auth.token).toBe(ADMIN);
281
+ expect(server.state.socketToken).toBe(ADMIN);
282
+ expect(store.get('sp00ky_auth_token')).toBe(ADMIN);
283
+ });
284
+
263
285
  it('keeps going through network failures on the poll', async () => {
264
286
  let calls = 0;
265
287
  const host = {
@@ -103,6 +103,13 @@ export class AuthService<S extends SchemaStructure> {
103
103
  /** Target of an impersonation that just ended, until the client purges its
104
104
  * local bucket (see `consumeEndedImpersonation`). */
105
105
  private endedImpersonationTarget: string | null = null;
106
+ /** The way back to the admin in flight. The Stop button, the session poll,
107
+ * a refused renewal and a rejected check can all end an impersonation, and
108
+ * they share this one: a second stop running beside the first found the
109
+ * admin token already taken and signed the admin out. */
110
+ private stopping: Promise<void> | null = null;
111
+ /** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
112
+ private renewing: Promise<void> | null = null;
106
113
  private impersonationTimers = new ImpersonationTimers({
107
114
  current: () => this.impersonation,
108
115
  renew: () => this.renewImpersonation(),
@@ -272,9 +279,22 @@ export class AuthService<S extends SchemaStructure> {
272
279
  * a full sign-out rather than staying on the target.
273
280
  */
274
281
  async stopImpersonating(opts: { serverEnded?: boolean } = {}): Promise<void> {
282
+ if (this.stopping) return this.stopping;
283
+ if (!this.impersonation) return;
284
+ this.stopping = this.leaveImpersonation(opts).finally(() => {
285
+ this.stopping = null;
286
+ });
287
+ return this.stopping;
288
+ }
289
+
290
+ private async leaveImpersonation(opts: { serverEnded?: boolean }): Promise<void> {
291
+ this.impersonationTimers.clear();
292
+ // A renewal already sent finishes first, so it cannot put the target's
293
+ // token back on the socket after the admin's (it sees `stopping` and
294
+ // stands down).
295
+ await this.renewing?.catch(() => undefined);
275
296
  const info = this.impersonation;
276
297
  if (!info) return;
277
- this.impersonationTimers.clear();
278
298
  if (!opts.serverEnded) {
279
299
  try {
280
300
  await this.remote.query('RETURN fn::_00_impersonate::stop($session)', { session: info.session });
@@ -314,9 +334,22 @@ export class AuthService<S extends SchemaStructure> {
314
334
 
315
335
  /** Re-sign the impersonation token before it expires. */
316
336
  async renewImpersonation(): Promise<void> {
317
- if (!this.impersonation) return;
337
+ if (!this.impersonation || this.stopping) return;
338
+ const renewal = this.applyRenewal();
339
+ this.renewing = renewal;
340
+ try {
341
+ await renewal;
342
+ } finally {
343
+ if (this.renewing === renewal) this.renewing = null;
344
+ }
345
+ }
346
+
347
+ private async applyRenewal(): Promise<void> {
318
348
  const [renewed] = await this.remote.query<[{ token: string }]>('RETURN fn::_00_impersonate::renew()');
319
349
  if (!renewed?.token) throw new Error('Impersonation renewal returned no token.');
350
+ // A stop began while the server was signing: the session is being left,
351
+ // so a fresh token for it must not go on the socket or into storage.
352
+ if (this.stopping || !this.impersonation) return;
320
353
  this.remote.setAuthToken(renewed.token);
321
354
  await this.remote.getClient().authenticate(renewed.token);
322
355
  this.token = renewed.token;
@@ -399,6 +432,9 @@ export class AuthService<S extends SchemaStructure> {
399
432
  this.isLoading = true;
400
433
 
401
434
  let token: string | undefined;
435
+ // Set once the server has accepted the token: from then on a failure is a
436
+ // failure to read the user row, not a verdict on the session.
437
+ let accepted = false;
402
438
  try {
403
439
  token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token')) || undefined;
404
440
 
@@ -418,6 +454,7 @@ export class AuthService<S extends SchemaStructure> {
418
454
  // `RemoteDatabaseService.setAuthToken`).
419
455
  this.remote.setAuthToken(token);
420
456
  await this.remote.getClient().authenticate(token);
457
+ accepted = true;
421
458
 
422
459
  // Verify the session by fetching the full user record using $auth.id
423
460
  const result = await this.remote.query('SELECT * FROM ONLY $auth.id');
@@ -471,6 +508,17 @@ export class AuthService<S extends SchemaStructure> {
471
508
  { error, Category: 'sp00ky-client::AuthService::check' },
472
509
  'Auth check unreachable; keeping the cached session and retrying later'
473
510
  );
511
+ } else if (accepted && token) {
512
+ // The server took the token and only the read of the user row failed
513
+ // (a busy database, a statement error). Signing out here logged an
514
+ // admin out on the way back from an impersonation while the database
515
+ // was saturated. Keep the session on the id the token names; the row
516
+ // arrives with the next check or the app's own user query.
517
+ this.logger.warn(
518
+ { error, Category: 'sp00ky-client::AuthService::check' },
519
+ 'Auth check accepted the token but could not read the user; keeping the session'
520
+ );
521
+ await this.keepAcceptedSession(token);
474
522
  } else {
475
523
  this.logger.error(
476
524
  { error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
@@ -483,6 +531,18 @@ export class AuthService<S extends SchemaStructure> {
483
531
  }
484
532
  }
485
533
 
534
+ /** Hold a session the server accepted without its user row: the id comes
535
+ * from the token, as on a warm boot. A token naming no user is refused. */
536
+ private async keepAcceptedSession(token: string): Promise<void> {
537
+ if (this.isAuthenticated && this.token === token && this.currentUser) return;
538
+ const { userId } = decodeTokenClaims(token);
539
+ if (!userId) {
540
+ await this.rejectToken(token);
541
+ return;
542
+ }
543
+ await this.setSession(token, { id: userId });
544
+ }
545
+
486
546
  /**
487
547
  * The server refused `token`. A refused impersonation token (stopped,
488
548
  * expired, or revoked) returns to the admin session instead of signing the
@@ -156,6 +156,24 @@ describe('registerRemote', () => {
156
156
  expect(out.dispatched).toEqual([{ type: 'FetchRows' }, { type: 'FetchRows' }, { type: 'SyncOutcome', ok: true }]);
157
157
  expect(out.emitted).toContainEqual({ type: 'query:authority', hash: 'a', known: true });
158
158
  });
159
+ it('a cold registration is foreground; a cached or live one is background', async () => {
160
+ for (const [phase, background] of [['cold', 0], ['cached', 1], ['live', 1]] as const) {
161
+ const seen: Array<[number, number]> = [];
162
+ const out = await runPure(registerRemote(env, 'a'), {
163
+ state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { phase } })]),
164
+ handlers: {
165
+ 'remote.query': (_e: unknown, ctx) => {
166
+ const l = ctx.state.queries.get('a')!.lifecycle;
167
+ seen.push([l.fetchDepth, l.backgroundDepth]);
168
+ return happy();
169
+ },
170
+ 'local.upsert': () => undefined,
171
+ },
172
+ });
173
+ expect(seen).toEqual([[1, background]]);
174
+ expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
175
+ }
176
+ });
159
177
  it('guards: missing entry, already registered / failed, registering without retry', async () => {
160
178
  const none = await runPure(registerRemote(env, 'zzz'), { state: buildState([]) });
161
179
  expect(none.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
@@ -151,8 +151,11 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
151
151
  if (!entry) return;
152
152
  const { remote } = entry.lifecycle;
153
153
  if (remote === 'registered' || remote === 'failed' || (remote === 'registering' && !retry)) return;
154
+ // A query that already shows rows (cached or live) only re-verifies them
155
+ // here: the cycle holds `fetching` but is not traffic the user waits on.
156
+ const background = entry.lifecycle.phase !== 'cold';
154
157
  yield fx.state.update(
155
- R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin' }))
158
+ R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin', background }))
156
159
  );
157
160
  try {
158
161
  const table = listRefTable(env, state);
@@ -221,6 +224,6 @@ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): S
221
224
  }
222
225
  } finally {
223
226
  const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
224
- if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end' }));
227
+ if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
225
228
  }
226
229
  }
@@ -50,6 +50,24 @@ describe('query sql builders (golden against the previous builders)', () => {
50
50
  expect(sql.heartbeatRowGone([{ id: a }])).toBe(false);
51
51
  expect(sql.heartbeatRowGone(undefined)).toBe(false);
52
52
  });
53
+ it('the pagehide beacon names the page sessions and inlines only shapes it minted', () => {
54
+ const a = new RecordId('_00_query', 'a'.repeat(64));
55
+ const b = new RecordId('_00_query', 'b'.repeat(64));
56
+ const s1 = 'de81a067-f4bc-4351-a2a3-1ecf06b6305a';
57
+ const s2 = '8A14ED5A-32B5-4B4E-973D-D2B88860932F';
58
+ expect(sql.leaveViewsBeacon([a, b], [s1, s2])).toBe(
59
+ `FOR $id IN [${a}, ${b}] { LET $_left = fn::query::leave($id, ['${s1}', '${s2}']); };`
60
+ );
61
+ const digits = new RecordId('_00_query', '1'.repeat(64));
62
+ expect(sql.leaveViewsBeacon([digits], [s1])).toBe(`FOR $id IN [${digits}] { LET $_left = fn::query::leave($id, ['${s1}']); };`);
63
+ // Anything else is dropped rather than interpolated.
64
+ expect(sql.leaveViewsBeacon([a, new RecordId('_00_query', "x']; DELETE user; --"), 'user:1'], [s1, "x'; DELETE user; --"])).toBe(
65
+ `FOR $id IN [${a}] { LET $_left = fn::query::leave($id, ['${s1}']); };`
66
+ );
67
+ // No session ids: the server could not tell this page's entries from a reload's, so send nothing.
68
+ expect(sql.leaveViewsBeacon([a], [])).toBeNull();
69
+ expect(sql.leaveViewsBeacon([], [s1])).toBeNull();
70
+ });
53
71
  it('upsertBodiesTx MERGEs each body inside one transaction', () => {
54
72
  const { query, vars } = sql.upsertBodiesTx([
55
73
  { id: 'thing:1', content: { a: 1 } },
package/src/query/sql.ts CHANGED
@@ -76,6 +76,25 @@ export function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): { sql: str
76
76
  /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
77
77
  export const heartbeatRowGone = (result: unknown): boolean => Array.isArray(result) && result.length === 0;
78
78
 
79
+ // The SDK escapes an all-digit key as `⟨…⟩`; both forms parse back to the same id.
80
+ const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
81
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
82
+
83
+ /**
84
+ * The pagehide release: one bare statement for `beaconSql`, which has no params
85
+ * channel, so ids are inlined. Only shapes the client minted itself get in, a
86
+ * `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
87
+ * interpolated. `null` when there is nothing the server could act on: with no
88
+ * session ids it could not tell this page's entries from anyone else's.
89
+ */
90
+ export function leaveViewsBeacon(ids: ReadonlyArray<unknown>, sessionIds: ReadonlyArray<string>): string | null {
91
+ const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
92
+ const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
93
+ if (list.length === 0 || sids.length === 0) return null;
94
+ const sidList = sids.map((sid) => `'${sid}'`).join(', ');
95
+ return `FOR $id IN [${list.join(', ')}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
96
+ }
97
+
79
98
  export const bodySelect = (): string => 'SELECT * FROM $ids';
80
99
 
81
100
  /** Local transaction that MERGEs many fetched bodies. */