@spooky-sync/core 0.0.1-canary.26 → 0.0.1-canary.261

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.
Files changed (231) hide show
  1. package/AGENTS.md +78 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2355 -81
  4. package/dist/index.js +13247 -2595
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +331 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +793 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +187 -0
  23. package/src/boot/bucket-switch.saga.ts +131 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +96 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +386 -0
  35. package/src/client/sp00ky-client.test.ts +264 -0
  36. package/src/client/sp00ky-client.ts +507 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +61 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +161 -0
  44. package/src/kernel/events.ts +56 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +148 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +9 -2
  53. package/src/modules/auth/impersonation-banner.ts +112 -0
  54. package/src/modules/auth/impersonation.test.ts +282 -0
  55. package/src/modules/auth/impersonation.ts +179 -0
  56. package/src/modules/auth/index.ts +344 -27
  57. package/src/modules/crdt/crdt-field.ts +294 -0
  58. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  59. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  60. package/src/modules/crdt/index.ts +463 -0
  61. package/src/modules/crdt/loro-loader.ts +25 -0
  62. package/src/modules/devtools/flags.ts +349 -0
  63. package/src/modules/devtools/index.ts +529 -47
  64. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  65. package/src/modules/devtools/state-shape.test.ts +149 -0
  66. package/src/modules/devtools/storage-info.test.ts +79 -0
  67. package/src/modules/devtools/storage-info.ts +168 -0
  68. package/src/modules/devtools/versions.test.ts +74 -0
  69. package/src/modules/devtools/versions.ts +110 -0
  70. package/src/modules/feature-flag/index.test.ts +251 -0
  71. package/src/modules/feature-flag/index.ts +316 -0
  72. package/src/modules/ref-tables.test.ts +91 -0
  73. package/src/modules/ref-tables.ts +88 -0
  74. package/src/mutation/jobs.test.ts +25 -0
  75. package/src/mutation/jobs.ts +41 -0
  76. package/src/mutation/mutation-id.test.ts +37 -0
  77. package/src/mutation/mutation-id.ts +35 -0
  78. package/src/mutation/push.saga.test.ts +285 -0
  79. package/src/mutation/push.saga.ts +215 -0
  80. package/src/mutation/rows.test.ts +201 -0
  81. package/src/mutation/rows.ts +377 -0
  82. package/src/mutation/tray.saga.test.ts +63 -0
  83. package/src/mutation/tray.saga.ts +48 -0
  84. package/src/mutation/write.saga.test.ts +163 -0
  85. package/src/mutation/write.saga.ts +166 -0
  86. package/src/otel/index.ts +13 -10
  87. package/src/query/env.test.ts +23 -0
  88. package/src/query/env.ts +56 -0
  89. package/src/query/fetch.saga.test.ts +156 -0
  90. package/src/query/fetch.saga.ts +115 -0
  91. package/src/query/hash.test.ts +14 -0
  92. package/src/query/hash.ts +18 -0
  93. package/src/query/id-set-plan.test.ts +132 -0
  94. package/src/query/lifecycle.saga.test.ts +122 -0
  95. package/src/query/lifecycle.saga.ts +101 -0
  96. package/src/query/materialize.saga.test.ts +82 -0
  97. package/src/query/materialize.saga.ts +75 -0
  98. package/src/query/materialize.test.ts +50 -0
  99. package/src/query/materialize.ts +44 -0
  100. package/src/query/membership.saga.test.ts +317 -0
  101. package/src/query/membership.saga.ts +262 -0
  102. package/src/query/membership.test.ts +118 -0
  103. package/src/query/membership.ts +171 -0
  104. package/src/query/register.saga.test.ts +293 -0
  105. package/src/query/register.saga.ts +226 -0
  106. package/src/query/render-set.test.ts +36 -0
  107. package/src/query/render-set.ts +60 -0
  108. package/src/query/sql.test.ts +62 -0
  109. package/src/query/sql.ts +79 -0
  110. package/src/query/window-query.test.ts +70 -0
  111. package/src/query/window-query.ts +195 -0
  112. package/src/services/blobs/blob-cache.test.ts +359 -0
  113. package/src/services/blobs/blob-cache.ts +603 -0
  114. package/src/services/blobs/blob-manifest.ts +227 -0
  115. package/src/services/blobs/blob-store.test.ts +77 -0
  116. package/src/services/blobs/blob-store.ts +359 -0
  117. package/src/services/blobs/blob.fixture.ts +90 -0
  118. package/src/services/blobs/index.ts +70 -0
  119. package/src/services/database/cache-engine.ts +199 -0
  120. package/src/services/database/connection-supervisor.test.ts +318 -0
  121. package/src/services/database/connection-supervisor.ts +422 -0
  122. package/src/services/database/database.query-timeout.test.ts +140 -0
  123. package/src/services/database/database.responses.test.ts +43 -0
  124. package/src/services/database/database.ts +145 -62
  125. package/src/services/database/engine-factory.ts +33 -0
  126. package/src/services/database/errors.ts +34 -0
  127. package/src/services/database/events/index.ts +2 -1
  128. package/src/services/database/index.ts +7 -0
  129. package/src/services/database/local-migrator.ts +30 -27
  130. package/src/services/database/local.test.ts +64 -0
  131. package/src/services/database/local.ts +491 -67
  132. package/src/services/database/plan-render.test.ts +159 -0
  133. package/src/services/database/plan-render.ts +108 -0
  134. package/src/services/database/relation-resolver.test.ts +467 -0
  135. package/src/services/database/relation-resolver.ts +0 -0
  136. package/src/services/database/remote.ts +222 -15
  137. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  138. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  139. package/src/services/database/sqlite-cache-engine.ts +1366 -0
  140. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  141. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  142. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  143. package/src/services/database/sqlite-lock-verify.ts +45 -0
  144. package/src/services/database/sqlite-open.test.ts +150 -0
  145. package/src/services/database/sqlite-open.ts +164 -0
  146. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  147. package/src/services/database/sqlite-plan-sql.ts +153 -0
  148. package/src/services/database/sqlite-projection.test.ts +121 -0
  149. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  150. package/src/services/database/sqlite-select.test.ts +246 -0
  151. package/src/services/database/sqlite-select.ts +131 -0
  152. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  153. package/src/services/database/sqlite-transport.ts +224 -0
  154. package/src/services/database/sqlite-worker.ts +437 -0
  155. package/src/services/database/surql-translate.ts +416 -0
  156. package/src/services/database/surreal-cache-engine.ts +161 -0
  157. package/src/services/logger/index.ts +3 -2
  158. package/src/services/persistence/localstorage.ts +2 -2
  159. package/src/services/persistence/resilient.ts +11 -4
  160. package/src/services/persistence/surrealdb.ts +10 -10
  161. package/src/services/stream-processor/index.ts +796 -84
  162. package/src/services/stream-processor/permissions.test.ts +47 -0
  163. package/src/services/stream-processor/permissions.ts +53 -0
  164. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  165. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  166. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  167. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  168. package/src/services/stream-processor/wasm-types.ts +59 -3
  169. package/src/services/tabs/broker-client.ts +283 -0
  170. package/src/services/tabs/broker.test.ts +327 -0
  171. package/src/services/tabs/coordinator.test.ts +365 -0
  172. package/src/services/tabs/coordinator.ts +633 -0
  173. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  174. package/src/services/tabs/leader-locks.ts +75 -0
  175. package/src/services/tabs/protocol.ts +262 -0
  176. package/src/services/tabs/support.ts +36 -0
  177. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  178. package/src/sp00ky.ts +5 -0
  179. package/src/state/client-state.ts +191 -0
  180. package/src/state/lifecycle.test.ts +118 -0
  181. package/src/state/lifecycle.ts +86 -0
  182. package/src/state/reducers.test.ts +280 -0
  183. package/src/state/reducers.ts +488 -0
  184. package/src/state/selectors.test.ts +149 -0
  185. package/src/state/selectors.ts +184 -0
  186. package/src/sync/connection.saga.test.ts +86 -0
  187. package/src/sync/connection.saga.ts +85 -0
  188. package/src/sync/live.saga.test.ts +178 -0
  189. package/src/sync/live.saga.ts +107 -0
  190. package/src/sync/policy.health.test.ts +56 -0
  191. package/src/sync/policy.test.ts +659 -0
  192. package/src/sync/policy.ts +502 -0
  193. package/src/sync/poll.saga.test.ts +51 -0
  194. package/src/sync/poll.saga.ts +36 -0
  195. package/src/sync/tabs.saga.test.ts +74 -0
  196. package/src/sync/tabs.saga.ts +93 -0
  197. package/src/testing/adapters.ts +133 -0
  198. package/src/testing/build.ts +63 -0
  199. package/src/testing/fake-services.ts +34 -0
  200. package/src/testing/run-pure.test.ts +92 -0
  201. package/src/testing/run-pure.ts +128 -0
  202. package/src/testing/services.ts +17 -0
  203. package/src/types.ts +591 -14
  204. package/src/utils/blurhash.ts +90 -0
  205. package/src/utils/error-classification.test.ts +54 -0
  206. package/src/utils/error-classification.ts +25 -0
  207. package/src/utils/index.ts +79 -13
  208. package/src/utils/parser.test.ts +49 -120
  209. package/src/utils/parser.ts +32 -2
  210. package/src/utils/semver.test.ts +32 -0
  211. package/src/utils/semver.ts +30 -0
  212. package/src/utils/surql.ts +30 -18
  213. package/src/utils/utils.test.ts +2 -1
  214. package/src/utils/withRetry.test.ts +1 -1
  215. package/tsdown.config.ts +86 -1
  216. package/vitest.config.ts +48 -0
  217. package/src/modules/cache/index.ts +0 -241
  218. package/src/modules/cache/types.ts +0 -19
  219. package/src/modules/data/data.test.ts +0 -58
  220. package/src/modules/data/index.ts +0 -879
  221. package/src/modules/sync/engine.ts +0 -173
  222. package/src/modules/sync/events/index.ts +0 -77
  223. package/src/modules/sync/index.ts +0 -3
  224. package/src/modules/sync/queue/index.ts +0 -2
  225. package/src/modules/sync/queue/queue-down.ts +0 -89
  226. package/src/modules/sync/queue/queue-up.ts +0 -223
  227. package/src/modules/sync/scheduler.ts +0 -84
  228. package/src/modules/sync/sync.ts +0 -421
  229. package/src/modules/sync/utils.test.ts +0 -311
  230. package/src/modules/sync/utils.ts +0 -167
  231. package/src/spooky.ts +0 -395
@@ -1,17 +1,41 @@
1
- import { RemoteDatabaseService } from '../../services/database/remote';
2
- import { LocalDatabaseService } from '../../services/database/local';
3
- import { DataModule } from '../data/index';
4
- import {
1
+ import type { RemoteDatabaseService } from '../../services/database/remote';
2
+ import type {
5
3
  SchemaStructure,
6
4
  AccessDefinition,
7
5
  ColumnSchema,
8
6
  TypeNameToTypeMap,
9
7
  } from '@spooky-sync/query-builder';
10
- import { Logger } from '../../services/logger/index';
11
- import { encodeRecordId } from '../../utils/index';
8
+ import type { Logger } from '../../services/logger/index';
12
9
  export * from './events/index';
13
10
  import { AuthEventTypes, createAuthEventSystem } from './events/index';
14
- import { PersistenceClient } from '../../types';
11
+ import type { PersistenceClient } from '../../types';
12
+ import { classifySyncError } from '../../utils/error-classification';
13
+ import {
14
+ decodeTokenClaims,
15
+ impersonationFromToken,
16
+ sameImpersonation,
17
+ ImpersonationTimers,
18
+ ORIGIN_TOKEN_KEY,
19
+ } from './impersonation';
20
+ import type { ImpersonationInfo } from './impersonation';
21
+ export * from './impersonation';
22
+
23
+ /** What `fn::_00_impersonate::list_users` returns per row. */
24
+ export interface ImpersonationCandidate {
25
+ id: string;
26
+ is_admin: boolean;
27
+ [field: string]: unknown;
28
+ }
29
+
30
+ /** An open session, as `fn::_00_impersonate::active` lists it. */
31
+ export interface ActiveImpersonation {
32
+ session: string;
33
+ target: string;
34
+ admin: string;
35
+ reason: string;
36
+ started_at: unknown;
37
+ expires_at: unknown;
38
+ }
15
39
 
16
40
  // Helper to pretty print types
17
41
  type Prettify<T> = {
@@ -38,15 +62,48 @@ type ExtractAccessParams<
38
62
  }>
39
63
  : never;
40
64
 
65
+ function decodeAccessFromToken(token: string): string | null {
66
+ return decodeTokenClaims(token).access;
67
+ }
68
+
41
69
  export class AuthService<S extends SchemaStructure> {
42
70
  // State
43
71
  public token: string | null = null;
44
72
  public currentUser: any | null = null;
45
73
  public isAuthenticated: boolean = false;
74
+ /**
75
+ * The record-access method name for the current session (e.g. `"account"`),
76
+ * derived from the token's `AC` claim. Consumed by the in-browser SSP's
77
+ * permission injection so `$access`-gated table predicates resolve locally,
78
+ * mirroring the server's `$access`. Null when logged out.
79
+ */
80
+ public access: string | null = null;
46
81
  public isLoading: boolean = true;
47
82
 
48
83
  private events = createAuthEventSystem();
49
84
 
85
+ /** The impersonation last announced, for change detection. */
86
+ private announcedImpersonation: ImpersonationInfo | null = null;
87
+ /** Target of an impersonation that just ended, until the client purges its
88
+ * local bucket (see `consumeEndedImpersonation`). */
89
+ private endedImpersonationTarget: string | null = null;
90
+ private impersonationTimers = new ImpersonationTimers({
91
+ current: () => this.impersonation,
92
+ renew: () => this.renewImpersonation(),
93
+ stillActive: async () => {
94
+ const [row] = await this.remote.query<[unknown]>('RETURN fn::_00_impersonate::current()');
95
+ return row !== null && row !== undefined;
96
+ },
97
+ endedRemotely: async (reason) => {
98
+ this.logger.warn(
99
+ { reason, Category: 'sp00ky-client::AuthService::impersonation' },
100
+ 'Impersonation ended on the server; returning to the admin session'
101
+ );
102
+ await this.stopImpersonating({ serverEnded: true });
103
+ },
104
+ isNetworkError: (error) => classifySyncError(error) === 'network',
105
+ });
106
+
50
107
  public get eventSystem() {
51
108
  return this.events;
52
109
  }
@@ -86,6 +143,203 @@ export class AuthService<S extends SchemaStructure> {
86
143
  private notifyListeners() {
87
144
  const userId = this.currentUser?.id || null;
88
145
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
146
+ const impersonation = this.impersonation;
147
+ if (!sameImpersonation(impersonation, this.announcedImpersonation)) {
148
+ this.announcedImpersonation = impersonation;
149
+ this.events.emit(AuthEventTypes.ImpersonationChanged, impersonation);
150
+ }
151
+ this.impersonationTimers.sync();
152
+ }
153
+
154
+ /**
155
+ * The impersonation the current session is, or null. Derived from the
156
+ * token in use, so it always describes the identity the server sees.
157
+ */
158
+ get impersonation(): ImpersonationInfo | null {
159
+ return this.isAuthenticated ? impersonationFromToken(this.token) : null;
160
+ }
161
+
162
+ /** Subscribe to impersonation changes; called immediately with the current value. */
163
+ subscribeImpersonation(cb: (info: ImpersonationInfo | null) => void): () => void {
164
+ cb(this.impersonation);
165
+ const id = this.events.subscribe(AuthEventTypes.ImpersonationChanged, (event) => {
166
+ cb(event.payload);
167
+ });
168
+ return () => {
169
+ this.events.unsubscribe(id);
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Act as `targetId` (a record id such as `user:abc`). Admins only, and only
175
+ * when the project enabled `impersonation` in sp00ky.yml: the server refuses
176
+ * everything else. `reason` is recorded in the audit log.
177
+ *
178
+ * The admin's session is kept aside and restored by
179
+ * {@link stopImpersonating}. Local data switches to the target's bucket
180
+ * exactly as on a sign-in, and that bucket is removed on stop.
181
+ */
182
+ async impersonate(targetId: string, reason: string): Promise<ImpersonationInfo> {
183
+ if (this.impersonation) throw new Error('Already impersonating; stop first.');
184
+ if (!this.isAuthenticated || !this.token) throw new Error('Sign in as an admin first.');
185
+ const originToken = this.token;
186
+ const [started] = await this.remote.query<[{ token: string }]>(
187
+ 'RETURN fn::_00_impersonate::start($target, $reason)',
188
+ { target: targetId, reason }
189
+ );
190
+ if (!started?.token) throw new Error('The server did not return an impersonation token.');
191
+
192
+ await this.persistenceClient.set(ORIGIN_TOKEN_KEY, originToken);
193
+ try {
194
+ await this.remote.getClient().authenticate(started.token);
195
+ } catch (error) {
196
+ // The server refused the session it just issued (for example the
197
+ // target is an admin, or no longer exists). Nothing changed locally
198
+ // yet; put the admin's token back on the transport.
199
+ await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
200
+ this.remote.setAuthToken(originToken);
201
+ await this.remote.getClient().authenticate(originToken).catch(() => undefined);
202
+ throw error;
203
+ }
204
+ await this.check(started.token);
205
+ // A method call, not the getter: TS narrowed the getter to null above.
206
+ const info = impersonationFromToken(this.isAuthenticated ? this.token : null);
207
+ if (!info) {
208
+ await this.stopImpersonating();
209
+ throw new Error('Impersonation could not be established.');
210
+ }
211
+ this.logger.info(
212
+ { target: info.target, session: info.session, Category: 'sp00ky-client::AuthService::impersonate' },
213
+ 'Impersonation started'
214
+ );
215
+ return info;
216
+ }
217
+
218
+ /**
219
+ * Return to the admin session. Always leaves the impersonated identity,
220
+ * even when the server cannot be reached: the admin token is restored
221
+ * locally first and verified afterwards, and a rejected admin token ends in
222
+ * a full sign-out rather than staying on the target.
223
+ */
224
+ async stopImpersonating(opts: { serverEnded?: boolean } = {}): Promise<void> {
225
+ const info = this.impersonation;
226
+ if (!info) return;
227
+ this.impersonationTimers.clear();
228
+ if (!opts.serverEnded) {
229
+ try {
230
+ await this.remote.query('RETURN fn::_00_impersonate::stop($session)', { session: info.session });
231
+ } catch (error) {
232
+ // The token expires on its own; the local switch below is what matters.
233
+ this.logger.warn(
234
+ { error, Category: 'sp00ky-client::AuthService::stopImpersonating' },
235
+ 'Could not end the impersonation session on the server'
236
+ );
237
+ }
238
+ }
239
+ this.endedImpersonationTarget = info.target;
240
+ const origin = await this.persistenceClient.get<string>(ORIGIN_TOKEN_KEY);
241
+ await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
242
+ if (!origin || impersonationFromToken(origin)) {
243
+ await this.signOut();
244
+ return;
245
+ }
246
+ const { access, userId } = decodeTokenClaims(origin);
247
+ if (!userId) {
248
+ await this.signOut();
249
+ return;
250
+ }
251
+ // Optimistic switch back, as a warm boot does, then verify.
252
+ this.token = origin;
253
+ this.remote.setAuthToken(origin);
254
+ this.currentUser = { id: userId };
255
+ this.access = access ?? this.defaultAccessName();
256
+ await this.persistenceClient.set('sp00ky_auth_token', origin);
257
+ this.notifyListeners();
258
+ await this.check(origin);
259
+ this.logger.info(
260
+ { target: info.target, Category: 'sp00ky-client::AuthService::stopImpersonating' },
261
+ 'Impersonation stopped'
262
+ );
263
+ }
264
+
265
+ /** Re-sign the impersonation token before it expires. */
266
+ async renewImpersonation(): Promise<void> {
267
+ if (!this.impersonation) return;
268
+ const [renewed] = await this.remote.query<[{ token: string }]>('RETURN fn::_00_impersonate::renew()');
269
+ if (!renewed?.token) throw new Error('Impersonation renewal returned no token.');
270
+ this.remote.setAuthToken(renewed.token);
271
+ await this.remote.getClient().authenticate(renewed.token);
272
+ this.token = renewed.token;
273
+ await this.persistenceClient.set('sp00ky_auth_token', renewed.token);
274
+ this.notifyListeners();
275
+ }
276
+
277
+ /** Users an admin may pick from. Admin-only on the server. */
278
+ async searchImpersonationTargets(search: string): Promise<ImpersonationCandidate[]> {
279
+ const [rows] = await this.remote.query<[ImpersonationCandidate[]]>(
280
+ 'RETURN fn::_00_impersonate::list_users($search)',
281
+ { search }
282
+ );
283
+ return Array.isArray(rows) ? rows : [];
284
+ }
285
+
286
+ /** Open impersonation sessions. Admin-only on the server. */
287
+ async listActiveImpersonations(): Promise<ActiveImpersonation[]> {
288
+ const [rows] = await this.remote.query<[ActiveImpersonation[]]>('RETURN fn::_00_impersonate::active()');
289
+ return Array.isArray(rows) ? rows : [];
290
+ }
291
+
292
+ /**
293
+ * The target of an impersonation that ended, once. The bucket switch calls
294
+ * this after leaving a bucket so it can delete that user's local data.
295
+ */
296
+ consumeEndedImpersonation(): string | null {
297
+ const target = this.endedImpersonationTarget;
298
+ this.endedImpersonationTarget = null;
299
+ return target;
300
+ }
301
+
302
+ /**
303
+ * Restore a session from the locally cached JWT, with NO network.
304
+ *
305
+ * This is what makes a warm boot paint instantly and what makes an offline
306
+ * boot possible at all: the token is in local storage, and it already carries
307
+ * both the access method and the `$auth.id` record id. Everything the client
308
+ * needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
309
+ * permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
310
+ * available before a socket exists.
311
+ *
312
+ * The session is OPTIMISTIC: the token is unverified here. `check()` runs
313
+ * afterwards in the background and downgrades to a real sign-out if the
314
+ * server rejects it. Nothing is trusted that the server has not also seen -
315
+ * the local store only ever holds rows the server previously sent.
316
+ *
317
+ * Returns the restored user id, or null when there is no usable token.
318
+ */
319
+ async restoreSessionFromToken(): Promise<string | null> {
320
+ const token = await this.persistenceClient.get<string>('sp00ky_auth_token');
321
+ if (!token) return null;
322
+ const { access, userId } = decodeTokenClaims(token);
323
+ if (!userId) return null;
324
+
325
+ this.token = token;
326
+ // Hand it to the transport too, so a socket rebuilt from scratch later
327
+ // (the supervisor's revive loop) comes back authenticated. Without this the
328
+ // page kept reporting this user while its session was anonymous, and every
329
+ // view registered afterwards was stamped with an empty identity.
330
+ this.remote.setAuthToken(token);
331
+ // Only the id: the full row is not in the token. It lands from the local
332
+ // cache when the app's own `user` query paints, and is replaced wholesale
333
+ // by `check()` once the server answers.
334
+ this.currentUser = { id: userId };
335
+ this.isAuthenticated = true;
336
+ this.access = access ?? this.defaultAccessName();
337
+ this.notifyListeners();
338
+ this.logger.debug(
339
+ { userId, Category: 'sp00ky-client::AuthService::restoreSessionFromToken' },
340
+ 'Session restored optimistically from cached token'
341
+ );
342
+ return userId;
89
343
  }
90
344
 
91
345
  /**
@@ -94,12 +348,13 @@ export class AuthService<S extends SchemaStructure> {
94
348
  async check(accessToken?: string) {
95
349
  this.isLoading = true;
96
350
 
351
+ let token: string | undefined;
97
352
  try {
98
- const token = accessToken || (await this.persistenceClient.get<string>('spooky_auth_token'));
353
+ token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token')) || undefined;
99
354
 
100
355
  if (!token) {
101
356
  this.logger.debug(
102
- { Category: 'spooky-client::AuthService::check' },
357
+ { Category: 'sp00ky-client::AuthService::check' },
103
358
  'No token found in storage or arguments'
104
359
  );
105
360
  this.isLoading = false;
@@ -108,7 +363,10 @@ export class AuthService<S extends SchemaStructure> {
108
363
  return;
109
364
  }
110
365
 
111
- // Authenticate with the token
366
+ // Authenticate with the token, and record it for future connects so a
367
+ // socket rebuilt from scratch reproduces this identity (see
368
+ // `RemoteDatabaseService.setAuthToken`).
369
+ this.remote.setAuthToken(token);
112
370
  await this.remote.getClient().authenticate(token);
113
371
 
114
372
  // Verify the session by fetching the full user record using $auth.id
@@ -119,13 +377,13 @@ export class AuthService<S extends SchemaStructure> {
119
377
 
120
378
  if (user && user.id) {
121
379
  this.logger.info(
122
- { user, Category: 'spooky-client::AuthService::check' },
380
+ { user, Category: 'sp00ky-client::AuthService::check' },
123
381
  'Auth check complete (via $auth.id)'
124
382
  );
125
383
  await this.setSession(token, user);
126
384
  } else {
127
385
  this.logger.warn(
128
- { Category: 'spooky-client::AuthService::check' },
386
+ { Category: 'sp00ky-client::AuthService::check' },
129
387
  '$auth.id empty, attempting manual user fetch'
130
388
  );
131
389
 
@@ -140,42 +398,89 @@ export class AuthService<S extends SchemaStructure> {
140
398
 
141
399
  if (manualUser && manualUser.id) {
142
400
  this.logger.info(
143
- { user: manualUser, Category: 'spooky-client::AuthService::check' },
401
+ { user: manualUser, Category: 'sp00ky-client::AuthService::check' },
144
402
  'Auth check complete (via manual fetch)'
145
403
  );
146
404
  await this.setSession(token, manualUser);
147
405
  } else {
148
406
  this.logger.warn(
149
- { Category: 'spooky-client::AuthService::check' },
407
+ { Category: 'sp00ky-client::AuthService::check' },
150
408
  'Token valid but user not found via fallback'
151
409
  );
152
- await this.signOut();
410
+ await this.rejectToken(token);
153
411
  }
154
412
  }
155
413
  } catch (error) {
156
- this.logger.error(
157
- { error, stack: (error as Error).stack, Category: 'spooky-client::AuthService::check' },
158
- 'Auth check failed'
159
- );
160
- await this.signOut();
414
+ // A REACHABILITY failure is not a rejected token. This catch used to call
415
+ // signOut() unconditionally, which deletes `sp00ky_auth_token` - so a
416
+ // blip on boot silently logged the user out, and an offline boot could
417
+ // never stay signed in. Only an application error (the server answered,
418
+ // and the answer was "no") ends the session.
419
+ if (classifySyncError(error) === 'network') {
420
+ this.logger.warn(
421
+ { error, Category: 'sp00ky-client::AuthService::check' },
422
+ 'Auth check unreachable; keeping the cached session and retrying later'
423
+ );
424
+ } else {
425
+ this.logger.error(
426
+ { error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
427
+ 'Auth check failed'
428
+ );
429
+ await this.rejectToken(token);
430
+ }
161
431
  } finally {
162
432
  this.isLoading = false;
163
433
  }
164
434
  }
165
435
 
166
436
  /**
167
- * Sign out and clear session
437
+ * The server refused `token`. A refused impersonation token (stopped,
438
+ * expired, or revoked) returns to the admin session instead of signing the
439
+ * admin out; anything else signs out.
440
+ */
441
+ private async rejectToken(token: string | undefined): Promise<void> {
442
+ if (token && impersonationFromToken(token)) {
443
+ if (!this.impersonation || this.token !== token) {
444
+ // A boot or explicit check with an impersonation token that is not
445
+ // the session in memory yet: adopt it so stop has something to leave.
446
+ this.token = token;
447
+ this.isAuthenticated = true;
448
+ }
449
+ await this.stopImpersonating({ serverEnded: true });
450
+ return;
451
+ }
452
+ await this.signOut();
453
+ }
454
+
455
+ /**
456
+ * Sign out and clear session. While impersonating this ends the
457
+ * impersonation and signs the admin out too.
168
458
  */
169
459
  async signOut() {
460
+ const impersonating = this.impersonation;
461
+ this.impersonationTimers.clear();
462
+ if (impersonating) {
463
+ this.endedImpersonationTarget = impersonating.target;
464
+ try {
465
+ await this.remote.query('RETURN fn::_00_impersonate::stop($session)', {
466
+ session: impersonating.session,
467
+ });
468
+ } catch (_e) {
469
+ // The token expires on its own.
470
+ }
471
+ }
472
+ await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
170
473
  this.token = null;
474
+ this.remote.setAuthToken(null);
171
475
  this.currentUser = null;
172
476
  this.isAuthenticated = false;
477
+ this.access = null;
173
478
 
174
- await this.persistenceClient.remove('spooky_auth_token');
479
+ await this.persistenceClient.remove('sp00ky_auth_token');
175
480
 
176
481
  try {
177
482
  await this.remote.getClient().invalidate();
178
- } catch (e) {
483
+ } catch (_e) {
179
484
  // Ignore invalidation errors
180
485
  }
181
486
 
@@ -184,12 +489,24 @@ export class AuthService<S extends SchemaStructure> {
184
489
 
185
490
  private async setSession(token: string, user: any) {
186
491
  this.token = token;
492
+ this.remote.setAuthToken(token);
187
493
  this.currentUser = user;
188
494
  this.isAuthenticated = true;
189
- await this.persistenceClient.set('spooky_auth_token', token);
495
+ // Resolve the access-method name (e.g. "account") for in-browser SSP
496
+ // permission injection. Prefer the token's `AC` claim; fall back to the
497
+ // schema's sole record-access method if the claim is absent.
498
+ this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
499
+ await this.persistenceClient.set('sp00ky_auth_token', token);
190
500
  this.notifyListeners();
191
501
  }
192
502
 
503
+ /** Fallback when the token carries no `AC` claim: if the schema defines
504
+ * exactly one record-access method, assume the session used it. */
505
+ private defaultAccessName(): string | null {
506
+ const names = Object.keys(this.schema.access ?? {});
507
+ return names.length === 1 ? names[0] : null;
508
+ }
509
+
193
510
  async signUp<Name extends keyof S['access'] & string>(
194
511
  accessName: Name,
195
512
  params: ExtractAccessParams<S, Name, 'signup'>
@@ -212,7 +529,7 @@ export class AuthService<S extends SchemaStructure> {
212
529
  }
213
530
 
214
531
  this.logger.info(
215
- { accessName, runtimeParams, Category: 'spooky-client::AuthService::signUp' },
532
+ { accessName, runtimeParams, Category: 'sp00ky-client::AuthService::signUp' },
216
533
  'Attempting signup'
217
534
  );
218
535
 
@@ -222,7 +539,7 @@ export class AuthService<S extends SchemaStructure> {
222
539
  });
223
540
 
224
541
  this.logger.info(
225
- { Category: 'spooky-client::AuthService::signUp' },
542
+ { Category: 'sp00ky-client::AuthService::signUp' },
226
543
  'Signup successful, token received'
227
544
  );
228
545
 
@@ -253,7 +570,7 @@ export class AuthService<S extends SchemaStructure> {
253
570
  }
254
571
 
255
572
  this.logger.info(
256
- { accessName, Category: 'spooky-client::AuthService::signIn' },
573
+ { accessName, Category: 'sp00ky-client::AuthService::signIn' },
257
574
  'Attempting signin'
258
575
  );
259
576