@spooky-sync/core 0.0.1-canary.31 → 0.0.1-canary.310

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