@spooky-sync/core 0.0.1-canary.314 → 0.0.1-canary.316

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/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as renderOrderSql, c as serializeMergeRow, d as stableKey, i as projectedDataSql, l as serializeRow, o as renderWhereSql, r as project, s as reviveRow, t as PROMOTION_OPEN_OPTIONS, u as resolveRelations } from "./sqlite-open.js";
1
+ import { a as renderOrderSql, c as serializeMergeRow, d as resolveRelations, f as stableKey, i as projectedDataSql, l as serializeRow, o as renderWhereSql, r as project, s as reviveRow, t as PROMOTION_OPEN_OPTIONS, u as recordIdKey } from "./sqlite-open.js";
2
2
  import { QueryBuilder, RecordId, baseFieldOfParam, cyrb53 } from "@spooky-sync/query-builder";
3
3
  import { DateTime, Duration, RecordId as RecordId$1, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
4
4
  import pino from "pino";
@@ -2178,8 +2178,8 @@ function trimHistory(history, cap) {
2178
2178
 
2179
2179
  //#endregion
2180
2180
  //#region src/modules/devtools/index.ts
2181
- const CORE_VERSION = "0.0.1-canary.314";
2182
- const WASM_VERSION = "0.0.1-canary.314";
2181
+ const CORE_VERSION = "0.0.1-canary.316";
2182
+ const WASM_VERSION = "0.0.1-canary.316";
2183
2183
  const SURREAL_VERSION = "3.0.3";
2184
2184
  var DevToolsService = class DevToolsService {
2185
2185
  version = CORE_VERSION;
@@ -11212,7 +11212,7 @@ var StreamProcessorService = class {
11212
11212
  const hasToString = typeof value.toString === "function";
11213
11213
  const isNotPlainObject = value.constructor !== Object;
11214
11214
  if (hasTable && hasId && hasToString && isNotPlainObject) {
11215
- const result = value.toString();
11215
+ const result = recordIdKey(value) ?? value.toString();
11216
11216
  this.logger.trace({
11217
11217
  result,
11218
11218
  Category: "sp00ky-client::StreamProcessorService::normalizeValue"
@@ -11445,6 +11445,13 @@ var AuthService = class {
11445
11445
  /** Target of an impersonation that just ended, until the client purges its
11446
11446
  * local bucket (see `consumeEndedImpersonation`). */
11447
11447
  endedImpersonationTarget = null;
11448
+ /** The way back to the admin in flight. The Stop button, the session poll,
11449
+ * a refused renewal and a rejected check can all end an impersonation, and
11450
+ * they share this one: a second stop running beside the first found the
11451
+ * admin token already taken and signed the admin out. */
11452
+ stopping = null;
11453
+ /** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
11454
+ renewing = null;
11448
11455
  impersonationTimers = new ImpersonationTimers({
11449
11456
  current: () => this.impersonation,
11450
11457
  renew: () => this.renewImpersonation(),
@@ -11595,9 +11602,18 @@ var AuthService = class {
11595
11602
  * a full sign-out rather than staying on the target.
11596
11603
  */
11597
11604
  async stopImpersonating(opts = {}) {
11605
+ if (this.stopping) return this.stopping;
11606
+ if (!this.impersonation) return;
11607
+ this.stopping = this.leaveImpersonation(opts).finally(() => {
11608
+ this.stopping = null;
11609
+ });
11610
+ return this.stopping;
11611
+ }
11612
+ async leaveImpersonation(opts) {
11613
+ this.impersonationTimers.clear();
11614
+ await this.renewing?.catch(() => void 0);
11598
11615
  const info = this.impersonation;
11599
11616
  if (!info) return;
11600
- this.impersonationTimers.clear();
11601
11617
  if (!opts.serverEnded) try {
11602
11618
  await this.remote.query("RETURN fn::_00_impersonate::stop($session)", { session: info.session });
11603
11619
  } catch (error) {
@@ -11632,9 +11648,19 @@ var AuthService = class {
11632
11648
  }
11633
11649
  /** Re-sign the impersonation token before it expires. */
11634
11650
  async renewImpersonation() {
11635
- if (!this.impersonation) return;
11651
+ if (!this.impersonation || this.stopping) return;
11652
+ const renewal = this.applyRenewal();
11653
+ this.renewing = renewal;
11654
+ try {
11655
+ await renewal;
11656
+ } finally {
11657
+ if (this.renewing === renewal) this.renewing = null;
11658
+ }
11659
+ }
11660
+ async applyRenewal() {
11636
11661
  const [renewed] = await this.remote.query("RETURN fn::_00_impersonate::renew()");
11637
11662
  if (!renewed?.token) throw new Error("Impersonation renewal returned no token.");
11663
+ if (this.stopping || !this.impersonation) return;
11638
11664
  this.remote.setAuthToken(renewed.token);
11639
11665
  await this.remote.getClient().authenticate(renewed.token);
11640
11666
  this.token = renewed.token;
@@ -11700,6 +11726,7 @@ var AuthService = class {
11700
11726
  async check(accessToken) {
11701
11727
  this.isLoading = true;
11702
11728
  let token;
11729
+ let accepted = false;
11703
11730
  try {
11704
11731
  token = accessToken || await this.persistenceClient.get("sp00ky_auth_token") || void 0;
11705
11732
  if (!token) {
@@ -11711,6 +11738,7 @@ var AuthService = class {
11711
11738
  }
11712
11739
  this.remote.setAuthToken(token);
11713
11740
  await this.remote.getClient().authenticate(token);
11741
+ accepted = true;
11714
11742
  const result = await this.remote.query("SELECT * FROM ONLY $auth.id");
11715
11743
  const items = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result;
11716
11744
  const user = Array.isArray(items) ? items[0] : items;
@@ -11741,7 +11769,13 @@ var AuthService = class {
11741
11769
  error,
11742
11770
  Category: "sp00ky-client::AuthService::check"
11743
11771
  }, "Auth check unreachable; keeping the cached session and retrying later");
11744
- else {
11772
+ else if (accepted && token) {
11773
+ this.logger.warn({
11774
+ error,
11775
+ Category: "sp00ky-client::AuthService::check"
11776
+ }, "Auth check accepted the token but could not read the user; keeping the session");
11777
+ await this.keepAcceptedSession(token);
11778
+ } else {
11745
11779
  this.logger.error({
11746
11780
  error,
11747
11781
  stack: error.stack,
@@ -11753,6 +11787,17 @@ var AuthService = class {
11753
11787
  this.isLoading = false;
11754
11788
  }
11755
11789
  }
11790
+ /** Hold a session the server accepted without its user row: the id comes
11791
+ * from the token, as on a warm boot. A token naming no user is refused. */
11792
+ async keepAcceptedSession(token) {
11793
+ if (this.isAuthenticated && this.token === token && this.currentUser) return;
11794
+ const { userId } = decodeTokenClaims(token);
11795
+ if (!userId) {
11796
+ await this.rejectToken(token);
11797
+ return;
11798
+ }
11799
+ await this.setSession(token, { id: userId });
11800
+ }
11756
11801
  /**
11757
11802
  * The server refused `token`. A refused impersonation token (stopped,
11758
11803
  * expired, or revoked) returns to the admin session instead of signing the
@@ -14603,7 +14648,7 @@ function createTabsCoordinator(config, s, host) {
14603
14648
  return new TabsCoordinator({
14604
14649
  tabId: s.tabId,
14605
14650
  fingerprint: computeTabsFingerprint({
14606
- coreVersion: "0.0.1-canary.314",
14651
+ coreVersion: "0.0.1-canary.316",
14607
14652
  schemaHash: hash53(config.schemaSurql),
14608
14653
  endpoint: config.database.endpoint ?? "",
14609
14654
  namespace: config.database.namespace,
@@ -88,13 +88,32 @@ async function resolveOne(parents, relation, fetcher, depth, path) {
88
88
  function looksLikeRecordId(value) {
89
89
  if (value == null) return false;
90
90
  if (typeof value === "string") return /^[A-Za-z_][A-Za-z0-9_]*:.+/.test(value);
91
- if (typeof value === "object") {
92
- const rid = value;
93
- return rid.tb !== void 0 && rid.id !== void 0;
94
- }
91
+ if (typeof value === "object") return recordIdKey(value) !== null;
95
92
  return false;
96
93
  }
97
94
  /**
95
+ * The raw `table:id` key of a RecordId, duck-typed: the SDK 1.x shape
96
+ * `{ tb, id }` or the SDK 2.x one, whose `table` is a `Table` (both behind
97
+ * getters). `null` for anything else.
98
+ *
99
+ * Never the SDK's `toString()`: that is SurrealQL, escaped. SDK 2.1.0 escapes
100
+ * every key that starts with a digit (`game:⟨476g…⟩`), while the store and
101
+ * server membership spell the same id `game:476g…`, so a key built from
102
+ * `toString()` silently missed about a quarter of random ids. A complex
103
+ * (array/object) key has no raw spelling and returns `null`.
104
+ */
105
+ function recordIdKey(value) {
106
+ const rid = value;
107
+ if (rid.id === void 0) return null;
108
+ if (rid.tb !== void 0) return `${String(rid.tb)}:${stableKey(rid.id)}`;
109
+ const proto = Object.getPrototypeOf(value);
110
+ if (proto === Object.prototype || proto === null) return null;
111
+ const name = rid.table?.name;
112
+ if (typeof rid.table !== "object" || typeof name !== "string") return null;
113
+ if (typeof rid.id === "string" || typeof rid.id === "number" || typeof rid.id === "bigint") return `${name}:${String(rid.id)}`;
114
+ return null;
115
+ }
116
+ /**
98
117
  * Stable grouping key for a correlation value. A `RecordId` (duck-typed to
99
118
  * avoid a hard surrealdb dependency here) and its `table:id` string form must
100
119
  * collapse to the same key so a parent FK stored either way matches the child.
@@ -104,8 +123,9 @@ function stableKey(value) {
104
123
  if (typeof value === "string") return value;
105
124
  if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
106
125
  if (typeof value === "object") {
126
+ const key = recordIdKey(value);
127
+ if (key !== null) return key;
107
128
  const rid = value;
108
- if (rid.tb !== void 0 && rid.id !== void 0) return `${String(rid.tb)}:${stableKey(rid.id)}`;
109
129
  if (typeof rid.toString === "function") {
110
130
  const s = rid.toString();
111
131
  if (s !== "[object Object]") return s;
@@ -173,11 +193,12 @@ function scalar(value) {
173
193
  return value;
174
194
  }
175
195
  function serializeRow(row) {
176
- return JSON.stringify(row, (_k, v) => {
196
+ return JSON.stringify(row, function(k, v) {
177
197
  if (v instanceof Uint8Array) return { __u8: toBase64(v) };
178
- if (v && typeof v === "object") {
179
- const rid = v;
180
- if (rid.tb !== void 0 && rid.id !== void 0) return stableKey(v);
198
+ const raw = this[k];
199
+ if (raw && typeof raw === "object") {
200
+ const key = recordIdKey(raw);
201
+ if (key !== null) return key;
181
202
  }
182
203
  return v;
183
204
  });
@@ -355,4 +376,4 @@ async function openDb(sqlite3, dbName, useOpfs, opts = {}) {
355
376
  }
356
377
 
357
378
  //#endregion
358
- export { renderOrderSql as a, serializeMergeRow as c, stableKey as d, projectedDataSql as i, serializeRow as l, openDb as n, renderWhereSql as o, project as r, reviveRow as s, PROMOTION_OPEN_OPTIONS as t, resolveRelations as u };
379
+ export { renderOrderSql as a, serializeMergeRow as c, resolveRelations as d, stableKey as f, projectedDataSql as i, serializeRow as l, openDb as n, renderWhereSql as o, project as r, reviveRow as s, PROMOTION_OPEN_OPTIONS as t, recordIdKey as u };
@@ -1,4 +1,4 @@
1
- import { a as renderOrderSql, d as stableKey, i as projectedDataSql, n as openDb, o as renderWhereSql, r as project, s as reviveRow, u as resolveRelations } from "./sqlite-open.js";
1
+ import { a as renderOrderSql, d as resolveRelations, f as stableKey, i as projectedDataSql, n as openDb, o as renderWhereSql, r as project, s as reviveRow } from "./sqlite-open.js";
2
2
  import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
3
3
 
4
4
  //#region src/services/database/sqlite-lock-verify.ts
package/dist/types.d.ts CHANGED
@@ -793,6 +793,13 @@ declare class AuthService<S extends SchemaStructure> {
793
793
  /** Target of an impersonation that just ended, until the client purges its
794
794
  * local bucket (see `consumeEndedImpersonation`). */
795
795
  private endedImpersonationTarget;
796
+ /** The way back to the admin in flight. The Stop button, the session poll,
797
+ * a refused renewal and a rejected check can all end an impersonation, and
798
+ * they share this one: a second stop running beside the first found the
799
+ * admin token already taken and signed the admin out. */
800
+ private stopping;
801
+ /** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
802
+ private renewing;
796
803
  private impersonationTimers;
797
804
  get eventSystem(): AuthEventSystem;
798
805
  constructor(schema: S, remote: RemoteDatabaseService, persistenceClient: PersistenceClient, logger: Logger$1);
@@ -839,8 +846,10 @@ declare class AuthService<S extends SchemaStructure> {
839
846
  stopImpersonating(opts?: {
840
847
  serverEnded?: boolean;
841
848
  }): Promise<void>;
849
+ private leaveImpersonation;
842
850
  /** Re-sign the impersonation token before it expires. */
843
851
  renewImpersonation(): Promise<void>;
852
+ private applyRenewal;
844
853
  /** Users an admin may pick from. Admin-only on the server. */
845
854
  searchImpersonationTargets(search: string): Promise<ImpersonationCandidate[]>;
846
855
  /** Open impersonation sessions. Admin-only on the server. */
@@ -872,6 +881,9 @@ declare class AuthService<S extends SchemaStructure> {
872
881
  * Check for existing session and validate
873
882
  */
874
883
  check(accessToken?: string): Promise<void>;
884
+ /** Hold a session the server accepted without its user row: the id comes
885
+ * from the token, as on a warm boot. A token naming no user is refused. */
886
+ private keepAcceptedSession;
875
887
  /**
876
888
  * The server refused `token`. A refused impersonation token (stopped,
877
889
  * expired, or revoked) returns to the admin session instead of signing the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.314",
3
+ "version": "0.0.1-canary.316",
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.314",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.314",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.316",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.316",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
@@ -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
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
2
3
  import type { RelationPlan, WhereNode } from '@spooky-sync/query-builder';
3
4
  import { looksLikeRecordId, resolveRelations, sortRows, stableKey } from './relation-resolver';
4
5
  import {
@@ -306,6 +307,22 @@ describe('stableKey', () => {
306
307
  expect(stableKey({ tb: 'user', id: '1' })).toBe('user:1');
307
308
  expect(stableKey('user:1')).toBe('user:1');
308
309
  });
310
+
311
+ it('spells an SDK RecordId raw, never as escaped SurrealQL', () => {
312
+ // SDK 2.1.0 `toString()`s these as `game:⟨476g…⟩`, `t:⟨123⟩`, `t:⟨a-b⟩`.
313
+ expect(stableKey(new RecordId('game', '476gsdlvro7cme8nwied'))).toBe('game:476gsdlvro7cme8nwied');
314
+ expect(stableKey(new RecordId('t', '123'))).toBe('t:123');
315
+ expect(stableKey(new RecordId('t', 'a-b'))).toBe('t:a-b');
316
+ expect(stableKey(new RecordId('t', 7))).toBe('t:7');
317
+ expect(looksLikeRecordId(new RecordId('game', '476gsdlvro7cme8nwied'))).toBe(true);
318
+ });
319
+
320
+ it('leaves a complex key to toString and a plain row with table/id columns alone', () => {
321
+ const complex = new RecordId('t', [1, 'x']);
322
+ expect(stableKey(complex)).toBe(complex.toString());
323
+ const row = { table: { name: 'x' }, id: 'y' };
324
+ expect(stableKey(row)).toBe(JSON.stringify(row));
325
+ });
309
326
  });
310
327
 
311
328
  /**
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
2
3
  import type { WhereNode } from '@spooky-sync/query-builder';
3
4
  import {
4
5
  comparisonSql,
@@ -96,6 +97,11 @@ describe('serializeRow / reviveRow round-trip', () => {
96
97
  const json = serializeRow({ id: 't:1', author: { tb: 'user', id: 'u1' }, n: 2 });
97
98
  expect(reviveRow(json)).toEqual({ id: 't:1', author: 'user:u1', n: 2 });
98
99
  });
100
+
101
+ it('stores an SDK RecordId link raw, not as its escaped toJSON', () => {
102
+ const json = serializeRow({ id: 'game:1', white: new RecordId('player_name', '3abc'), tags: [new RecordId('t', '9z')] });
103
+ expect(reviveRow(json)).toEqual({ id: 'game:1', white: 'player_name:3abc', tags: ['t:9z'] });
104
+ });
99
105
  });
100
106
 
101
107
  describe('project', () => {
@@ -1,5 +1,5 @@
1
1
  import type { WhereComparison, WhereNode } from '@spooky-sync/query-builder';
2
- import { stableKey } from './relation-resolver';
2
+ import { recordIdKey, stableKey } from './relation-resolver';
3
3
  import type { OrderBy, Row } from './cache-engine';
4
4
 
5
5
  /**
@@ -60,11 +60,14 @@ export function scalar(value: unknown): unknown {
60
60
  }
61
61
 
62
62
  export function serializeRow(row: Row): string {
63
- return JSON.stringify(row, (_k, v) => {
63
+ return JSON.stringify(row, function (this: Record<string, unknown>, k, v) {
64
64
  if (v instanceof Uint8Array) return { __u8: toBase64(v) };
65
- if (v && typeof v === 'object') {
66
- const rid = v as { tb?: unknown; id?: unknown };
67
- if (rid.tb !== undefined && rid.id !== undefined) return stableKey(v);
65
+ // `v` has already been through `toJSON()`, and a RecordId's is escaped
66
+ // SurrealQL (`game:⟨476g…⟩`), so key the original value instead.
67
+ const raw = this[k];
68
+ if (raw && typeof raw === 'object') {
69
+ const key = recordIdKey(raw);
70
+ if (key !== null) return key;
68
71
  }
69
72
  return v;
70
73
  });
@@ -45,6 +45,10 @@ const FIXTURE: Record<string, Row[]> = {
45
45
  { id: 'comment:2', thread: 'thread:B', body: 'second' },
46
46
  { id: 'comment:3', thread: 'thread:A', body: 'other thread' },
47
47
  ],
48
+ game: [
49
+ { id: 'game:476gsdlvro7cme8nwied', white: 'player_name:a' },
50
+ { id: 'game:stgyqp7fm3sc4tmyc8fm', white: 'player_name:b' },
51
+ ],
48
52
  };
49
53
 
50
54
  /** The `.one()` detail-view plan shape: id baked as a literal AND slaved to
@@ -187,12 +191,23 @@ describe('SqliteCacheEngine.select via worker (one hop)', () => {
187
191
  const rid = new RecordId('comment', '2');
188
192
  const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: true });
189
193
  await engine.connect('anon');
190
- await engine.select({ table: 'comment', ids: [rid, 'comment:1'] }, {});
194
+ const rows = await engine.select({ table: 'comment', ids: [rid, 'comment:1'] }, {});
191
195
  const sel = calls.find((c) => c.type === 'select')!;
192
- // stableKey output (surrealdb may escape the id part, e.g. comment:⟨2⟩) —
193
- // identical to what the legacy `selectByIds` binds, so stored rows match.
194
- expect(sel.payload.plan.ids).toEqual([stableKey(rid), 'comment:1']);
195
- expect(sel.payload.plan.ids.every((i: unknown) => typeof i === 'string')).toBe(true);
196
+ // The raw spelling the rows are stored under, never the SDK's escaped
197
+ // `comment:⟨2⟩`: that one matched no stored row.
198
+ expect(sel.payload.plan.ids).toEqual(['comment:2', 'comment:1']);
199
+ expect(rows.map((r) => r.id)).toEqual(['comment:2', 'comment:1']);
200
+ });
201
+
202
+ it('finds a window row whose id starts with a digit (SDK 2.1.0 escapes it)', async () => {
203
+ // `materializeEffect` parses every window id into a RecordId, and SDK
204
+ // 2.1.0's `toString()` spells this one `game:⟨476g…⟩`. Keyed that way the
205
+ // row went missing, the window came back short and read as the list end.
206
+ const { engine } = makeEngine({ workerSelect: true, answerSelect: true });
207
+ await engine.connect('anon');
208
+ const ids = [new RecordId('game', '476gsdlvro7cme8nwied'), new RecordId('game', 'stgyqp7fm3sc4tmyc8fm')];
209
+ const rows = await engine.select({ table: 'game', ids }, {});
210
+ expect(rows.map((r) => r.id)).toEqual(['game:476gsdlvro7cme8nwied', 'game:stgyqp7fm3sc4tmyc8fm']);
196
211
  });
197
212
 
198
213
  it('normalizes RecordId values baked inside the plan\'s where tree', async () => {
@@ -7,6 +7,7 @@ import type { WasmProcessor, WasmStreamUpdate } from './wasm-types';
7
7
  import type { Duration } from 'surrealdb';
8
8
  import type { QueryTimeToLive, RecordVersionArray } from '../../types';
9
9
  import { encodeRecordId } from '../../utils/index';
10
+ import { recordIdKey } from '../database/relation-resolver';
10
11
 
11
12
  // Simple interface for query plan registration (replaces Incantation class)
12
13
  interface QueryPlanConfig {
@@ -1044,7 +1045,9 @@ export class StreamProcessorService {
1044
1045
  const isNotPlainObject = value.constructor !== Object;
1045
1046
 
1046
1047
  if (hasTable && hasId && hasToString && isNotPlainObject) {
1047
- const result = value.toString();
1048
+ // The raw `table:id`, as the store spells it. `toString()` is escaped
1049
+ // SurrealQL (SDK 2.1.0: `game:⟨476g…⟩`); only a complex key uses it.
1050
+ const result = recordIdKey(value) ?? value.toString();
1048
1051
  this.logger.trace(
1049
1052
  { result, Category: 'sp00ky-client::StreamProcessorService::normalizeValue' },
1050
1053
  'RecordId detected'