@spooky-sync/core 0.0.1-canary.304 → 0.0.1-canary.306

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.304";
2182
- const WASM_VERSION = "0.0.1-canary.304";
2181
+ const CORE_VERSION = "0.0.1-canary.306";
2182
+ const WASM_VERSION = "0.0.1-canary.306";
2183
2183
  const SURREAL_VERSION = "3.0.3";
2184
2184
  var DevToolsService = class DevToolsService {
2185
2185
  version = CORE_VERSION;
@@ -6935,19 +6935,25 @@ function* rebindQueries() {
6935
6935
  //#region src/boot/auth-flip.saga.ts
6936
6936
  /**
6937
6937
  * The signed-in principal changed (sign-in, sign-out, boot verification).
6938
- * Identity first (routing and `$auth` for the local SSP), then the bucket,
6939
- * then the salt if the principal really changed, then the verified user row.
6938
+ * Identity first (routing and `$auth` for the local SSP), then the salt if
6939
+ * the principal really changed, then the bucket, then the verified user row.
6940
+ *
6941
+ * The salt goes before the bucket because the switch re-keys every active
6942
+ * query (`rebindQueries`) from the salt it finds. A query's remote id hashes
6943
+ * the salt but not the principal, so re-keyed under the old salt it named the
6944
+ * view the previous principal still held, and the SSP refused it as another
6945
+ * identity's (409 `auth_mismatch`) until that view's TTL ran out. On whitepawn
6946
+ * every impersonation start, stop and token lapse refused a tab's queries for
6947
+ * the 10 minutes of that TTL, each refusal a failed sync round, so the rail
6948
+ * read "Can't reach the server" the whole time.
6940
6949
  */
6941
6950
  function* authFlip(env, userId) {
6942
6951
  const authId = yield fx.service("auth.sessionAuthId");
6943
6952
  const access = yield fx.service("auth.access");
6944
6953
  yield fx.state.update(setIdentity({ userId }));
6945
6954
  yield fx.service("ssp.setSessionAuth", authId, access);
6946
- const target = bucketIdForUser(userId);
6947
- yield fx.service("hint.write", target);
6948
- yield fx.state.update(setIdentity({ pendingBucket: target }));
6949
- yield* bucketSwitch(env, target, (yield fx.service("local.currentBucketId")) === target ? null : yield fx.service("local.beginSwitch"));
6950
- if (authId !== (yield fx.state.read((s) => s.saltUserId))) {
6955
+ const resalted = authId !== (yield fx.state.read((s) => s.saltUserId));
6956
+ if (resalted) {
6951
6957
  const salt = yield fx.id("salt");
6952
6958
  yield fx.state.update(setIdentity({
6953
6959
  sessionId: salt,
@@ -6955,6 +6961,15 @@ function* authFlip(env, userId) {
6955
6961
  }));
6956
6962
  yield fx.service("crdt.setSessionId", salt);
6957
6963
  }
6964
+ const target = bucketIdForUser(userId);
6965
+ yield fx.service("hint.write", target);
6966
+ yield fx.state.update(setIdentity({ pendingBucket: target }));
6967
+ const current = yield fx.service("local.currentBucketId");
6968
+ yield* bucketSwitch(env, target, current === target ? null : yield fx.service("local.beginSwitch"));
6969
+ if (resalted && current === target) {
6970
+ yield* rebindQueries();
6971
+ yield fx.dispatch({ type: "EnsureRegistered" });
6972
+ }
6958
6973
  yield* persistVerifiedUser(env);
6959
6974
  }
6960
6975
  /**
@@ -14588,7 +14603,7 @@ function createTabsCoordinator(config, s, host) {
14588
14603
  return new TabsCoordinator({
14589
14604
  tabId: s.tabId,
14590
14605
  fingerprint: computeTabsFingerprint({
14591
- coreVersion: "0.0.1-canary.304",
14606
+ coreVersion: "0.0.1-canary.306",
14592
14607
  schemaHash: hash53(config.schemaSurql),
14593
14608
  endpoint: config.database.endpoint ?? "",
14594
14609
  namespace: config.database.namespace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.304",
3
+ "version": "0.0.1-canary.306",
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.304",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.304",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.306",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.306",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
@@ -6,11 +6,13 @@ import { buildEntry, buildState } from '../testing/build';
6
6
  import * as R from '../state/reducers';
7
7
  import { defaultEnv } from '../query/env';
8
8
  import { authFlip, persistVerifiedUser } from './auth-flip.saga';
9
+ import { queryHashInput } from '../query/hash';
10
+ import { sha256Hex } from '../utils/sha256';
9
11
 
10
12
  const env = defaultEnv({ tables: [{ name: 'user', columns: { id: {}, email_verified: {} } }] } as any);
11
13
 
12
14
  describe('authFlip', () => {
13
- it('sets identity and $auth before the bucket work, writes the hint, switches, rotates the salt for a new principal, persists the user row', async () => {
15
+ it('sets identity and $auth, rotates the salt for a new principal before the bucket work, writes the hint, switches, persists the user row', async () => {
14
16
  const released: string[] = [];
15
17
  const svc = fakeServices({
16
18
  'auth.sessionAuthId': () => 'user:abc',
@@ -27,14 +29,65 @@ describe('authFlip', () => {
27
29
  handlers: { service: svc.handler, 'local.query': () => [], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
28
30
  });
29
31
  const names = svc.names();
30
- expect(names.slice(0, 5)).toEqual(['auth.sessionAuthId', 'auth.access', 'ssp.setSessionAuth', 'hint.write', 'local.currentBucketId']);
31
- expect(svc.calls[3]).toEqual(['hint.write', ['abc']]);
32
+ expect(names.slice(0, 6)).toEqual(['auth.sessionAuthId', 'auth.access', 'ssp.setSessionAuth', 'crdt.setSessionId', 'hint.write', 'local.currentBucketId']);
33
+ expect(svc.calls[3]).toEqual(['crdt.setSessionId', ['salt-1']]);
34
+ expect(svc.calls[4]).toEqual(['hint.write', ['abc']]);
32
35
  expect(names).toContain('local.switchStore');
33
- expect(names).toContain('crdt.setSessionId');
34
36
  expect(released).toEqual(['gate']);
35
37
  expect(out.state).toMatchObject({ userId: 'user:abc', saltUserId: 'user:abc', sessionId: 'salt-1', bucketId: 'abc' });
36
38
  expect(out.state.versions.get('user:abc')).toBe(1);
37
39
  });
40
+ it("re-keys active queries under the new principal's salt, so no remote id names the previous principal's view", async () => {
41
+ const svc = fakeServices({
42
+ 'auth.sessionAuthId': () => 'user:target',
43
+ 'auth.access': () => 'account',
44
+ 'local.currentBucketId': () => 'admin',
45
+ 'local.beginSwitch': () => () => undefined,
46
+ 'local.usesSurqlSchema': () => false,
47
+ 'auth.token': () => 'jwt',
48
+ 'auth.currentUser': () => null,
49
+ });
50
+ const def = { surql: 'SELECT * FROM notification', params: {} };
51
+ const before = await sha256Hex(queryHashInput(def, 'admin-salt'));
52
+ const state = R.putQuery(
53
+ buildEntry({ def: { hash: 'q', ...def, id: new RecordId('_00_query', before) }, lifecycle: { phase: 'live', remote: 'registered' } })
54
+ )({ ...buildState(), sessionId: 'admin-salt', saltUserId: 'user:admin', bucketId: 'admin' });
55
+ const out = await runPure(authFlip(env, 'user:target'), {
56
+ state,
57
+ handlers: {
58
+ service: svc.handler,
59
+ 'local.getById': () => null,
60
+ 'ssp.register': () => ({ localArray: [], timings: {} }),
61
+ 'local.query': () => [],
62
+ },
63
+ });
64
+ const id = String(out.state.queries.get('q')!.def.id.id);
65
+ expect(out.state.sessionId).toBe('salt-1');
66
+ expect(id).toBe(await sha256Hex(queryHashInput(def, 'salt-1')));
67
+ expect(id).not.toBe(before);
68
+ });
69
+ it('a new principal on the same bucket still re-keys its queries', async () => {
70
+ const svc = fakeServices({
71
+ 'auth.sessionAuthId': () => 'user:b',
72
+ 'auth.access': () => 'account',
73
+ 'local.currentBucketId': () => 'anon',
74
+ 'auth.currentUser': () => null,
75
+ });
76
+ const def = { surql: 'SELECT * FROM thing', params: {} };
77
+ const state = R.putQuery(buildEntry({ def: { hash: 'q', ...def }, lifecycle: { phase: 'live', remote: 'registered' } }))({
78
+ ...buildState(),
79
+ sessionId: 'old',
80
+ saltUserId: 'user:a',
81
+ bucketId: 'anon',
82
+ });
83
+ const out = await runPure(authFlip(env, null), {
84
+ state,
85
+ handlers: { service: svc.handler, 'local.getById': () => null, 'ssp.register': () => ({ localArray: [], timings: {} }) },
86
+ });
87
+ expect(svc.names()).not.toContain('local.switchStore');
88
+ expect(String(out.state.queries.get('q')!.def.id.id)).toBe(await sha256Hex(queryHashInput(def, 'salt-1')));
89
+ expect(out.dispatched.map((d) => d.type)).toContain('EnsureRegistered');
90
+ });
38
91
  it('same principal on the same bucket: no switch, no salt rotation', async () => {
39
92
  const svc = fakeServices({
40
93
  'auth.sessionAuthId': () => 'user:abc',
@@ -8,29 +8,44 @@ import { columnsFor } from '../query/env';
8
8
  import * as sql from '../query/sql';
9
9
  import { encodeRecordId } from '../utils/index';
10
10
  import { cleanRecord } from '../utils/parser';
11
- import { bucketSwitch } from './bucket-switch.saga';
11
+ import { bucketSwitch, rebindQueries } from './bucket-switch.saga';
12
12
 
13
13
  /**
14
14
  * The signed-in principal changed (sign-in, sign-out, boot verification).
15
- * Identity first (routing and `$auth` for the local SSP), then the bucket,
16
- * then the salt if the principal really changed, then the verified user row.
15
+ * Identity first (routing and `$auth` for the local SSP), then the salt if
16
+ * the principal really changed, then the bucket, then the verified user row.
17
+ *
18
+ * The salt goes before the bucket because the switch re-keys every active
19
+ * query (`rebindQueries`) from the salt it finds. A query's remote id hashes
20
+ * the salt but not the principal, so re-keyed under the old salt it named the
21
+ * view the previous principal still held, and the SSP refused it as another
22
+ * identity's (409 `auth_mismatch`) until that view's TTL ran out. On whitepawn
23
+ * every impersonation start, stop and token lapse refused a tab's queries for
24
+ * the 10 minutes of that TTL, each refusal a failed sync round, so the rail
25
+ * read "Can't reach the server" the whole time.
17
26
  */
18
27
  export function* authFlip(env: SagaEnv, userId: string | null): Saga<void> {
19
28
  const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
20
29
  const access = (yield fx.service('auth.access')) as string | null;
21
30
  yield fx.state.update(R.setIdentity({ userId }));
22
31
  yield fx.service('ssp.setSessionAuth', authId, access);
32
+ const saltUserId = (yield fx.state.read((s) => s.saltUserId)) as string | null;
33
+ const resalted = authId !== saltUserId;
34
+ if (resalted) {
35
+ const salt = (yield fx.id('salt')) as string;
36
+ yield fx.state.update(R.setIdentity({ sessionId: salt, saltUserId: authId }));
37
+ yield fx.service('crdt.setSessionId', salt);
38
+ }
23
39
  const target = bucketIdForUser(userId);
24
40
  yield fx.service('hint.write', target);
25
41
  yield fx.state.update(R.setIdentity({ pendingBucket: target }));
26
42
  const current = (yield fx.service('local.currentBucketId')) as string;
27
43
  const release = current === target ? null : ((yield fx.service('local.beginSwitch')) as () => void);
28
44
  yield* bucketSwitch(env, target, release);
29
- const saltUserId = (yield fx.state.read((s) => s.saltUserId)) as string | null;
30
- if (authId !== saltUserId) {
31
- const salt = (yield fx.id('salt')) as string;
32
- yield fx.state.update(R.setIdentity({ sessionId: salt, saltUserId: authId }));
33
- yield fx.service('crdt.setSessionId', salt);
45
+ // No switch means nothing re-keyed the queries under the new salt.
46
+ if (resalted && current === target) {
47
+ yield* rebindQueries();
48
+ yield fx.dispatch({ type: 'EnsureRegistered' });
34
49
  }
35
50
  yield* persistVerifiedUser(env);
36
51
  }