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

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
@@ -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.317";
2182
- const WASM_VERSION = "0.0.1-canary.317";
2181
+ const CORE_VERSION = "0.0.1-canary.319";
2182
+ const WASM_VERSION = "0.0.1-canary.319";
2183
2183
  const SURREAL_VERSION = "3.0.3";
2184
2184
  var DevToolsService = class DevToolsService {
2185
2185
  version = CORE_VERSION;
@@ -11574,7 +11574,7 @@ var AuthService = class {
11574
11574
  * callback is called immediately with current value and whenever validation status changes.
11575
11575
  */
11576
11576
  subscribe(cb) {
11577
- cb(this.currentUser?.id || null);
11577
+ cb(this.announcedUserId());
11578
11578
  const id = this.events.subscribe(AuthEventTypes.AuthStateChanged, (event) => {
11579
11579
  cb(event.payload);
11580
11580
  });
@@ -11614,8 +11614,24 @@ var AuthService = class {
11614
11614
  clearTimeout(timer);
11615
11615
  }
11616
11616
  }
11617
+ /**
11618
+ * The signed-in user as subscribers see it: the raw `table:id` string, the
11619
+ * way the token's `ID` claim spells it. `currentUser.id` is that string while
11620
+ * the session is restored from the token and the server's `RecordId` once
11621
+ * `check()` has answered, and the two must announce as the same user: every
11622
+ * subscriber that compares ids (feature flags, app release) took the switch
11623
+ * for a new principal and reset, so flag-gated UI blinked off and back on a
11624
+ * few seconds into every reload. A `RecordId`'s own `toString()` will not do
11625
+ * either, as it escapes ids like `user:⟨5abc⟩` that the claim spells raw.
11626
+ */
11627
+ announcedUserId() {
11628
+ const id = this.currentUser?.id;
11629
+ if (!id) return null;
11630
+ if (typeof id === "string") return id;
11631
+ return id instanceof RecordId$1 ? encodeRecordId(id) : String(id);
11632
+ }
11617
11633
  notifyListeners() {
11618
- const userId = this.currentUser?.id || null;
11634
+ const userId = this.announcedUserId();
11619
11635
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
11620
11636
  const impersonation = this.impersonation;
11621
11637
  if (!sameImpersonation(impersonation, this.announcedImpersonation)) {
@@ -14766,7 +14782,7 @@ function createTabsCoordinator(config, s, host) {
14766
14782
  return new TabsCoordinator({
14767
14783
  tabId: s.tabId,
14768
14784
  fingerprint: computeTabsFingerprint({
14769
- coreVersion: "0.0.1-canary.317",
14785
+ coreVersion: "0.0.1-canary.319",
14770
14786
  schemaHash: hash53(config.schemaSurql),
14771
14787
  endpoint: config.database.endpoint ?? "",
14772
14788
  namespace: config.database.namespace,
package/dist/types.d.ts CHANGED
@@ -842,6 +842,17 @@ declare class AuthService<S extends SchemaStructure> {
842
842
  */
843
843
  onBeforeSignOut(hook: SignOutHook): () => void;
844
844
  private runSignOutHooks;
845
+ /**
846
+ * The signed-in user as subscribers see it: the raw `table:id` string, the
847
+ * way the token's `ID` claim spells it. `currentUser.id` is that string while
848
+ * the session is restored from the token and the server's `RecordId` once
849
+ * `check()` has answered, and the two must announce as the same user: every
850
+ * subscriber that compares ids (feature flags, app release) took the switch
851
+ * for a new principal and reset, so flag-gated UI blinked off and back on a
852
+ * few seconds into every reload. A `RecordId`'s own `toString()` will not do
853
+ * either, as it escapes ids like `user:⟨5abc⟩` that the claim spells raw.
854
+ */
855
+ private announcedUserId;
845
856
  private notifyListeners;
846
857
  /**
847
858
  * The impersonation the current session is, or null. Derived from the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.317",
3
+ "version": "0.0.1-canary.319",
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.317",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.317",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.319",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.319",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
2
3
  import { AuthService } from './index';
3
4
 
4
5
  // A SurrealDB record-access JWT carries the access method as `AC` and the
@@ -139,3 +140,30 @@ describe('auth token reaches the transport', () => {
139
140
  expect(remote.setAuthToken).toHaveBeenLastCalledWith(null);
140
141
  });
141
142
  });
143
+
144
+ describe('the announced user id', () => {
145
+ // The cached token names the user as a raw string; once check() answers,
146
+ // currentUser holds the server's RecordId. Subscribers compare ids, so the
147
+ // two must announce identically or every reload reads as a sign-in switch.
148
+ it.each([['abc'], ['5onl3w8g9jhnymk3d10p']])(
149
+ 'is the same raw string before and after the server check (user:%s)',
150
+ async (key) => {
151
+ const claim = `user:${key}`;
152
+ const { auth } = makeAuth({
153
+ token: jwt({ AC: 'account', ID: claim }),
154
+ query: vi.fn(async () => [{ id: new RecordId('user', key), email: 'x@y' }]),
155
+ });
156
+ const seen: (string | null)[] = [];
157
+ auth.subscribe((uid) => seen.push(uid));
158
+
159
+ await auth.restoreSessionFromToken();
160
+ await auth.check();
161
+
162
+ expect(auth.currentUser.id).toBeInstanceOf(RecordId);
163
+ // A RecordId's toString escapes a key that starts with a digit.
164
+ expect(seen.filter((v) => v !== null)).toEqual([claim, claim]);
165
+ expect(seen.every((v) => v === null || typeof v === 'string')).toBe(true);
166
+ }
167
+ );
168
+ });
169
+
@@ -11,6 +11,7 @@ import { AuthEventTypes, createAuthEventSystem } from './events/index';
11
11
  import type { PersistenceClient } from '../../types';
12
12
  import { classifySyncError } from '../../utils/error-classification';
13
13
  import { encodeRecordId } from '../../utils/index';
14
+ import { RecordId } from 'surrealdb';
14
15
  import {
15
16
  decodeTokenClaims,
16
17
  impersonationFromToken,
@@ -152,7 +153,7 @@ export class AuthService<S extends SchemaStructure> {
152
153
  */
153
154
  subscribe(cb: (userId: string | null) => void): () => void {
154
155
  // Immediate callback
155
- cb(this.currentUser?.id || null);
156
+ cb(this.announcedUserId());
156
157
 
157
158
  const id = this.events.subscribe(AuthEventTypes.AuthStateChanged, (event) => {
158
159
  cb(event.payload);
@@ -197,8 +198,25 @@ export class AuthService<S extends SchemaStructure> {
197
198
  }
198
199
  }
199
200
 
201
+ /**
202
+ * The signed-in user as subscribers see it: the raw `table:id` string, the
203
+ * way the token's `ID` claim spells it. `currentUser.id` is that string while
204
+ * the session is restored from the token and the server's `RecordId` once
205
+ * `check()` has answered, and the two must announce as the same user: every
206
+ * subscriber that compares ids (feature flags, app release) took the switch
207
+ * for a new principal and reset, so flag-gated UI blinked off and back on a
208
+ * few seconds into every reload. A `RecordId`'s own `toString()` will not do
209
+ * either, as it escapes ids like `user:⟨5abc⟩` that the claim spells raw.
210
+ */
211
+ private announcedUserId(): string | null {
212
+ const id = this.currentUser?.id;
213
+ if (!id) return null;
214
+ if (typeof id === 'string') return id;
215
+ return id instanceof RecordId ? encodeRecordId(id as RecordId<string>) : String(id);
216
+ }
217
+
200
218
  private notifyListeners() {
201
- const userId = this.currentUser?.id || null;
219
+ const userId = this.announcedUserId();
202
220
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
203
221
  const impersonation = this.impersonation;
204
222
  if (!sameImpersonation(impersonation, this.announcedImpersonation)) {
@@ -1,5 +1,7 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
2
3
  import { FeatureFlagModule } from './index';
4
+ import { AuthService } from '../auth/index';
3
5
 
4
6
  // vitest runs in the `node` environment here, so there is no localStorage.
5
7
  // The module guards every access with `globalThis.localStorage?.`, which keeps
@@ -249,3 +251,48 @@ describe('FeatureFlagModule local overrides', () => {
249
251
  expect(() => new FeatureFlagModule(makeDeps().deps)).not.toThrow();
250
252
  });
251
253
  });
254
+
255
+ describe('FeatureFlagModule across a reload', () => {
256
+ // A reload restores the session from the cached token (the id as a string),
257
+ // then the server check lands the user row (the id as a RecordId) a few
258
+ // seconds later. Same user: an assigned flag must stay on throughout, not
259
+ // blink off while the module re-registers its query.
260
+ it('keeps flags through the server verification of the same user', async () => {
261
+ const b64 = Buffer.from(JSON.stringify({ AC: 'account', ID: 'user:5abc' })).toString('base64url');
262
+ const token = `h.${b64}.s`;
263
+ const store = new Map<string, unknown>([['sp00ky_auth_token', token]]);
264
+ const persistence = {
265
+ get: async (k: string) => store.get(k) ?? null,
266
+ set: async (k: string, v: unknown) => void store.set(k, v),
267
+ remove: async (k: string) => void store.delete(k),
268
+ } as any;
269
+ const remote = {
270
+ query: async () => [{ id: new RecordId('user', '5abc') }],
271
+ setAuthToken: () => undefined,
272
+ getClient: () => ({ authenticate: async () => undefined, invalidate: async () => undefined }),
273
+ } as any;
274
+ const silent: any = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
275
+ silent.child = () => silent;
276
+ const auth = new AuthService({} as any, remote, persistence, silent);
277
+
278
+ const env = makeDeps();
279
+ const flags = new FeatureFlagModule({ ...env.deps, auth } as any);
280
+ flags.init();
281
+ await auth.restoreSessionFromToken();
282
+ const clubs = flags.feature('clubs');
283
+ const seen: (string | undefined)[] = [];
284
+ clubs.subscribe((s) => seen.push(s.variant));
285
+ await tick();
286
+ env.push([{ key: 'clubs', variant: 'on' }]);
287
+ expect(clubs.variant()).toBe('on');
288
+ const registrations = env.calls.length;
289
+
290
+ await auth.check();
291
+ await tick();
292
+
293
+ expect(clubs.variant()).toBe('on');
294
+ expect(seen.slice(seen.indexOf('on'))).not.toContain(undefined);
295
+ expect(env.calls.length).toBe(registrations);
296
+ });
297
+ });
298
+