@spooky-sync/core 0.0.1-canary.35 → 0.0.1-canary.351

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 (269) hide show
  1. package/AGENTS.md +90 -0
  2. package/README.md +59 -16
  3. package/dist/index.d.ts +2135 -142
  4. package/dist/index.js +16570 -2575
  5. package/dist/live.d.ts +149 -0
  6. package/dist/live.js +739 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +401 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +392 -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 +477 -0
  18. package/dist/types.d.ts +1917 -38
  19. package/dist/worker-edges.js +498 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +48 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +28 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +173 -0
  27. package/skills/sp00ky-core/SKILL.md +37 -7
  28. package/skills/sp00ky-core/references/config.md +7 -1
  29. package/src/boot/auth-flip.saga.test.ts +174 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +203 -0
  32. package/src/boot/boot.saga.ts +146 -0
  33. package/src/boot/bucket-switch.saga.test.ts +191 -0
  34. package/src/boot/bucket-switch.saga.ts +151 -0
  35. package/src/boot/preload.saga.test.ts +96 -0
  36. package/src/boot/preload.saga.ts +45 -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 +195 -0
  40. package/src/client/bucket-handle.ts +352 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +102 -0
  43. package/src/client/runtime.test.ts +299 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +74 -0
  46. package/src/client/services.ts +437 -0
  47. package/src/client/sp00ky-client.test.ts +341 -0
  48. package/src/client/sp00ky-client.ts +625 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +171 -0
  56. package/src/kernel/events.ts +59 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +512 -0
  62. package/src/live/index.ts +1059 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +228 -0
  66. package/src/modules/auth/auth.session-recheck.test.ts +140 -0
  67. package/src/modules/auth/events/index.ts +9 -2
  68. package/src/modules/auth/impersonation-banner.ts +443 -0
  69. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  70. package/src/modules/auth/impersonation.test.ts +377 -0
  71. package/src/modules/auth/impersonation.ts +179 -0
  72. package/src/modules/auth/index.ts +543 -22
  73. package/src/modules/crdt/acceptance.ts +75 -0
  74. package/src/modules/crdt/bytes.ts +50 -0
  75. package/src/modules/crdt/crdt-field.ts +125 -0
  76. package/src/modules/crdt/crdt.test.ts +546 -0
  77. package/src/modules/crdt/hash.ts +103 -0
  78. package/src/modules/crdt/index.ts +821 -0
  79. package/src/modules/crdt/loro-loader.ts +25 -0
  80. package/src/modules/crdt/mirror.ts +37 -0
  81. package/src/modules/devtools/flags.ts +349 -0
  82. package/src/modules/devtools/index.ts +748 -95
  83. package/src/modules/devtools/mutations.test.ts +92 -0
  84. package/src/modules/devtools/mutations.ts +160 -0
  85. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  86. package/src/modules/devtools/state-shape.test.ts +263 -0
  87. package/src/modules/devtools/storage-info.test.ts +79 -0
  88. package/src/modules/devtools/storage-info.ts +168 -0
  89. package/src/modules/devtools/versions.test.ts +74 -0
  90. package/src/modules/devtools/versions.ts +110 -0
  91. package/src/modules/feature-flag/index.test.ts +298 -0
  92. package/src/modules/feature-flag/index.ts +316 -0
  93. package/src/modules/ref-tables.test.ts +93 -0
  94. package/src/modules/ref-tables.ts +91 -0
  95. package/src/modules/web-push/index.test.ts +824 -0
  96. package/src/modules/web-push/index.ts +1027 -0
  97. package/src/mutation/ingest-outage.integration.test.ts +400 -0
  98. package/src/mutation/jobs.test.ts +25 -0
  99. package/src/mutation/jobs.ts +41 -0
  100. package/src/mutation/mutation-id.test.ts +37 -0
  101. package/src/mutation/mutation-id.ts +35 -0
  102. package/src/mutation/pending-writes.integration.test.ts +175 -0
  103. package/src/mutation/push.saga.test.ts +361 -0
  104. package/src/mutation/push.saga.ts +245 -0
  105. package/src/mutation/rows.test.ts +278 -0
  106. package/src/mutation/rows.ts +499 -0
  107. package/src/mutation/tray.saga.test.ts +63 -0
  108. package/src/mutation/tray.saga.ts +48 -0
  109. package/src/mutation/write.saga.test.ts +316 -0
  110. package/src/mutation/write.saga.ts +268 -0
  111. package/src/otel/index.ts +10 -7
  112. package/src/pure.ts +66 -0
  113. package/src/push/types.ts +174 -0
  114. package/src/query/anon-query-isolation.integration.test.ts +531 -0
  115. package/src/query/env.test.ts +23 -0
  116. package/src/query/env.ts +65 -0
  117. package/src/query/fetch.saga.test.ts +199 -0
  118. package/src/query/fetch.saga.ts +120 -0
  119. package/src/query/hash.test.ts +14 -0
  120. package/src/query/hash.ts +18 -0
  121. package/src/query/id-set-plan.test.ts +132 -0
  122. package/src/query/lifecycle.saga.test.ts +274 -0
  123. package/src/query/lifecycle.saga.ts +186 -0
  124. package/src/query/list-ref-live.integration.test.ts +203 -0
  125. package/src/query/materialize.saga.test.ts +188 -0
  126. package/src/query/materialize.saga.ts +96 -0
  127. package/src/query/materialize.test.ts +58 -0
  128. package/src/query/materialize.ts +57 -0
  129. package/src/query/membership.saga.test.ts +482 -0
  130. package/src/query/membership.saga.ts +315 -0
  131. package/src/query/membership.test.ts +151 -0
  132. package/src/query/membership.ts +234 -0
  133. package/src/query/register.saga.test.ts +369 -0
  134. package/src/query/register.saga.ts +293 -0
  135. package/src/query/render-set.test.ts +47 -0
  136. package/src/query/render-set.ts +71 -0
  137. package/src/query/session-guard.integration.test.ts +304 -0
  138. package/src/query/sql.test.ts +118 -0
  139. package/src/query/sql.ts +174 -0
  140. package/src/query/window-query.test.ts +70 -0
  141. package/src/query/window-query.ts +208 -0
  142. package/src/services/blobs/blob-cache.test.ts +359 -0
  143. package/src/services/blobs/blob-cache.ts +603 -0
  144. package/src/services/blobs/blob-manifest.ts +227 -0
  145. package/src/services/blobs/blob-store.test.ts +77 -0
  146. package/src/services/blobs/blob-store.ts +359 -0
  147. package/src/services/blobs/blob.fixture.ts +90 -0
  148. package/src/services/blobs/index.ts +70 -0
  149. package/src/services/database/cache-engine.ts +209 -0
  150. package/src/services/database/connection-supervisor.test.ts +318 -0
  151. package/src/services/database/connection-supervisor.ts +422 -0
  152. package/src/services/database/database.query-timeout.test.ts +140 -0
  153. package/src/services/database/database.responses.test.ts +43 -0
  154. package/src/services/database/database.ts +156 -61
  155. package/src/services/database/engine-factory.ts +33 -0
  156. package/src/services/database/errors.ts +34 -0
  157. package/src/services/database/events/index.ts +2 -1
  158. package/src/services/database/index.ts +7 -0
  159. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  160. package/src/services/database/local-migrator.integration.test.ts +110 -0
  161. package/src/services/database/local-migrator.ts +61 -16
  162. package/src/services/database/local.test.ts +150 -0
  163. package/src/services/database/local.ts +508 -63
  164. package/src/services/database/plan-render.test.ts +159 -0
  165. package/src/services/database/plan-render.ts +108 -0
  166. package/src/services/database/relation-resolver.test.ts +484 -0
  167. package/src/services/database/relation-resolver.ts +0 -0
  168. package/src/services/database/remote.ts +285 -6
  169. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  170. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  171. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  172. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  173. package/src/services/database/sqlite-column-reset.ts +91 -0
  174. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  175. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  176. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  177. package/src/services/database/sqlite-lock-verify.ts +45 -0
  178. package/src/services/database/sqlite-open.test.ts +150 -0
  179. package/src/services/database/sqlite-open.ts +164 -0
  180. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  181. package/src/services/database/sqlite-plan-sql.ts +196 -0
  182. package/src/services/database/sqlite-projection.test.ts +121 -0
  183. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  184. package/src/services/database/sqlite-select.test.ts +261 -0
  185. package/src/services/database/sqlite-select.ts +131 -0
  186. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  187. package/src/services/database/sqlite-transport.ts +224 -0
  188. package/src/services/database/sqlite-worker.ts +437 -0
  189. package/src/services/database/surql-translate.ts +417 -0
  190. package/src/services/database/surreal-cache-engine.ts +163 -0
  191. package/src/services/logger/index.test.ts +132 -0
  192. package/src/services/logger/index.ts +189 -7
  193. package/src/services/persistence/localstorage.ts +2 -2
  194. package/src/services/persistence/resilient.ts +10 -3
  195. package/src/services/persistence/surrealdb.ts +4 -4
  196. package/src/services/stream-processor/index.ts +806 -67
  197. package/src/services/stream-processor/permissions.test.ts +47 -0
  198. package/src/services/stream-processor/permissions.ts +53 -0
  199. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  200. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  201. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  202. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  203. package/src/services/stream-processor/wasm-types.ts +58 -2
  204. package/src/services/tabs/broker-client.ts +284 -0
  205. package/src/services/tabs/broker.test.ts +381 -0
  206. package/src/services/tabs/coordinator.test.ts +386 -0
  207. package/src/services/tabs/coordinator.ts +646 -0
  208. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  209. package/src/services/tabs/leader-locks.ts +75 -0
  210. package/src/services/tabs/protocol.ts +262 -0
  211. package/src/services/tabs/support.ts +36 -0
  212. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  213. package/src/sp00ky.ts +5 -395
  214. package/src/state/client-state.ts +216 -0
  215. package/src/state/lifecycle.test.ts +184 -0
  216. package/src/state/lifecycle.ts +127 -0
  217. package/src/state/reducers.test.ts +319 -0
  218. package/src/state/reducers.ts +550 -0
  219. package/src/state/selectors.test.ts +188 -0
  220. package/src/state/selectors.ts +259 -0
  221. package/src/sw/index.test.ts +582 -0
  222. package/src/sw/index.ts +607 -0
  223. package/src/sw/store.ts +96 -0
  224. package/src/sync/connection.saga.test.ts +97 -0
  225. package/src/sync/connection.saga.ts +89 -0
  226. package/src/sync/live.saga.test.ts +229 -0
  227. package/src/sync/live.saga.ts +116 -0
  228. package/src/sync/policy.health.test.ts +56 -0
  229. package/src/sync/policy.test.ts +663 -0
  230. package/src/sync/policy.ts +515 -0
  231. package/src/sync/poll.saga.test.ts +51 -0
  232. package/src/sync/poll.saga.ts +36 -0
  233. package/src/sync/tabs.saga.test.ts +78 -0
  234. package/src/sync/tabs.saga.ts +96 -0
  235. package/src/testing/adapters.ts +137 -0
  236. package/src/testing/build.ts +63 -0
  237. package/src/testing/fake-services.ts +63 -0
  238. package/src/testing/run-pure.test.ts +92 -0
  239. package/src/testing/run-pure.ts +128 -0
  240. package/src/testing/services.ts +17 -0
  241. package/src/types.ts +661 -10
  242. package/src/utils/blurhash.ts +90 -0
  243. package/src/utils/edges.ts +57 -0
  244. package/src/utils/error-classification.test.ts +105 -0
  245. package/src/utils/error-classification.ts +64 -0
  246. package/src/utils/index.ts +89 -12
  247. package/src/utils/parser.test.ts +67 -102
  248. package/src/utils/parser.ts +64 -2
  249. package/src/utils/semver.test.ts +32 -0
  250. package/src/utils/semver.ts +30 -0
  251. package/src/utils/sha256.ts +15 -0
  252. package/src/utils/surql.ts +29 -18
  253. package/src/utils/utils.test.ts +3 -2
  254. package/tsdown.config.ts +117 -3
  255. package/vitest.config.ts +48 -0
  256. package/src/modules/cache/index.ts +0 -241
  257. package/src/modules/cache/types.ts +0 -19
  258. package/src/modules/data/data.test.ts +0 -58
  259. package/src/modules/data/index.ts +0 -879
  260. package/src/modules/sync/engine.ts +0 -173
  261. package/src/modules/sync/events/index.ts +0 -77
  262. package/src/modules/sync/index.ts +0 -3
  263. package/src/modules/sync/queue/index.ts +0 -2
  264. package/src/modules/sync/queue/queue-down.ts +0 -89
  265. package/src/modules/sync/queue/queue-up.ts +0 -223
  266. package/src/modules/sync/scheduler.ts +0 -84
  267. package/src/modules/sync/sync.ts +0 -421
  268. package/src/modules/sync/utils.test.ts +0 -311
  269. package/src/modules/sync/utils.ts +0 -167
@@ -1,17 +1,65 @@
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, isRefusedSessionError } from '../../utils/error-classification';
13
+ import { DatabaseEventTypes } from '../../services/database/events/index';
14
+ import { encodeRecordId } from '../../utils/index';
15
+ import { RecordId } from 'surrealdb';
16
+ import {
17
+ decodeTokenClaims,
18
+ impersonationFromToken,
19
+ sameImpersonation,
20
+ ImpersonationTimers,
21
+ ORIGIN_TOKEN_KEY,
22
+ } from './impersonation';
23
+ import type { ImpersonationInfo } from './impersonation';
24
+ export * from './impersonation';
25
+
26
+ /** What `fn::_00_impersonate::list_users` returns per row. */
27
+ export interface ImpersonationCandidate {
28
+ id: string;
29
+ is_admin: boolean;
30
+ [field: string]: unknown;
31
+ }
32
+
33
+ /** An open session, as `fn::_00_impersonate::active` lists it. */
34
+ export interface ActiveImpersonation {
35
+ session: string;
36
+ target: string;
37
+ admin: string;
38
+ reason: string;
39
+ started_at: unknown;
40
+ expires_at: unknown;
41
+ }
42
+
43
+ /** What a sign-out hook sees: the session that is about to end. */
44
+ export interface SignOutContext {
45
+ /** `table:id` of the signed-in record, or null. */
46
+ userId: string | null;
47
+ token: string | null;
48
+ /** The session is an admin impersonation (its token is the target's). */
49
+ impersonating: boolean;
50
+ }
51
+
52
+ export type SignOutHook = (ctx: SignOutContext) => Promise<void> | void;
53
+
54
+ /** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
55
+ export const SIGN_OUT_HOOK_TIMEOUT_MS = 2_000;
56
+
57
+ /**
58
+ * Floor between two session re-checks started by refused statements. A
59
+ * refused session fails every statement at once, and a check that keeps the
60
+ * session (the server was unreachable) must not be re-run by each of them.
61
+ */
62
+ export const REFUSAL_RECHECK_MIN_MS = 30_000;
15
63
 
16
64
  // Helper to pretty print types
17
65
  type Prettify<T> = {
@@ -38,14 +86,60 @@ type ExtractAccessParams<
38
86
  }>
39
87
  : never;
40
88
 
89
+ function decodeAccessFromToken(token: string): string | null {
90
+ return decodeTokenClaims(token).access;
91
+ }
92
+
41
93
  export class AuthService<S extends SchemaStructure> {
42
94
  // State
43
95
  public token: string | null = null;
44
96
  public currentUser: any | null = null;
45
97
  public isAuthenticated: boolean = false;
98
+ /**
99
+ * The record-access method name for the current session (e.g. `"account"`),
100
+ * derived from the token's `AC` claim. Consumed by the in-browser SSP's
101
+ * permission injection so `$access`-gated table predicates resolve locally,
102
+ * mirroring the server's `$access`. Null when logged out.
103
+ */
104
+ public access: string | null = null;
46
105
  public isLoading: boolean = true;
47
106
 
48
107
  private events = createAuthEventSystem();
108
+ private signOutHooks = new Set<SignOutHook>();
109
+
110
+ /** The impersonation last announced, for change detection. */
111
+ private announcedImpersonation: ImpersonationInfo | null = null;
112
+ /** Target of an impersonation that just ended, until the client purges its
113
+ * local bucket (see `consumeEndedImpersonation`). */
114
+ private endedImpersonationTarget: string | null = null;
115
+ /** The way back to the admin in flight. The Stop button, the session poll,
116
+ * a refused renewal and a rejected check can all end an impersonation, and
117
+ * they share this one: a second stop running beside the first found the
118
+ * admin token already taken and signed the admin out. */
119
+ private stopping: Promise<void> | null = null;
120
+ /** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
121
+ private renewing: Promise<void> | null = null;
122
+ /** The session re-check in flight (see `watchSession`), shared by every trigger. */
123
+ private reverifying: Promise<void> | null = null;
124
+ /** When a refused statement last started a re-check. */
125
+ private lastRefusalCheckAt = Number.NEGATIVE_INFINITY;
126
+ private watchingSession = false;
127
+ private impersonationTimers = new ImpersonationTimers({
128
+ current: () => this.impersonation,
129
+ renew: () => this.renewImpersonation(),
130
+ stillActive: async () => {
131
+ const [row] = await this.remote.query<[unknown]>('RETURN fn::_00_impersonate::current()');
132
+ return row !== null && row !== undefined;
133
+ },
134
+ endedRemotely: async (reason) => {
135
+ this.logger.warn(
136
+ { reason, Category: 'sp00ky-client::AuthService::impersonation' },
137
+ 'Impersonation ended on the server; returning to the admin session'
138
+ );
139
+ await this.stopImpersonating({ serverEnded: true });
140
+ },
141
+ isNetworkError: (error) => classifySyncError(error) === 'network',
142
+ });
49
143
 
50
144
  public get eventSystem() {
51
145
  return this.events;
@@ -60,6 +154,60 @@ export class AuthService<S extends SchemaStructure> {
60
154
 
61
155
  async init() {
62
156
  await this.check();
157
+ this.watchSession();
158
+ }
159
+
160
+ /**
161
+ * Check the session again whenever the socket comes back, and whenever the
162
+ * server refuses a statement because of the session.
163
+ *
164
+ * The server judges a token only when a socket authenticates: the SDK
165
+ * replays `authenticate` on its own reconnects, `RemoteDatabaseService` on a
166
+ * socket it rebuilds. When the replay is refused (the app ended the session,
167
+ * say with a password reset) the SDK drops to an anonymous session, and the
168
+ * supervisor swallows the rebuild's error, so the socket reports `connected`
169
+ * while this service still announces the user. Every view registered from
170
+ * then on carried no identity: the page looked signed in and showed nothing
171
+ * of the user's. `check()` applies the token again and signs out when the
172
+ * server refuses it.
173
+ *
174
+ * A socket that never reconnects is ended by the app's session guard
175
+ * (`fn::sp00ky::session_live`) instead: its heartbeats stop finding rows,
176
+ * and the re-registration that follows is refused with "There was a problem
177
+ * with authentication", which starts a check here too.
178
+ */
179
+ private watchSession(): void {
180
+ if (this.watchingSession) return;
181
+ this.watchingSession = true;
182
+ this.remote.subscribeConnection('connected', () => this.reverify());
183
+ this.remote.getEvents().subscribe(DatabaseEventTypes.RemoteQuery, (event) => {
184
+ const { success, error } = event.payload;
185
+ if (success || !isRefusedSessionError(error ?? '')) return;
186
+ const now = Date.now();
187
+ if (now - this.lastRefusalCheckAt < REFUSAL_RECHECK_MIN_MS) return;
188
+ this.lastRefusalCheckAt = now;
189
+ this.reverify();
190
+ });
191
+ }
192
+
193
+ /** One `check()` for every trigger that lands while it runs. */
194
+ private reverify(): void {
195
+ if (!this.isAuthenticated || this.reverifying) return;
196
+ this.reverifying = (async () => {
197
+ // A rebuilt socket reports `connected` before its namespace and token
198
+ // are applied: check against the finished handshake.
199
+ await this.remote.whenConnected();
200
+ if (this.isAuthenticated) await this.check();
201
+ })()
202
+ .catch((error) => {
203
+ this.logger.warn(
204
+ { error, Category: 'sp00ky-client::AuthService::reverify' },
205
+ 'Session re-check failed'
206
+ );
207
+ })
208
+ .finally(() => {
209
+ this.reverifying = null;
210
+ });
63
211
  }
64
212
 
65
213
  getAccessDefinition<Name extends keyof S['access']>(name: Name): AccessDefinition | undefined {
@@ -69,10 +217,10 @@ export class AuthService<S extends SchemaStructure> {
69
217
  /**
70
218
  * Subscribe to auth state changes.
71
219
  * callback is called immediately with current value and whenever validation status changes.
220
+ * `immediate: false` skips that first call: only changes are reported.
72
221
  */
73
- subscribe(cb: (userId: string | null) => void): () => void {
74
- // Immediate callback
75
- cb(this.currentUser?.id || null);
222
+ subscribe(cb: (userId: string | null) => void, opts: { immediate?: boolean } = {}): () => void {
223
+ if (opts.immediate !== false) cb(this.announcedUserId());
76
224
 
77
225
  const id = this.events.subscribe(AuthEventTypes.AuthStateChanged, (event) => {
78
226
  cb(event.payload);
@@ -83,9 +231,283 @@ export class AuthService<S extends SchemaStructure> {
83
231
  };
84
232
  }
85
233
 
234
+ /**
235
+ * Run `hook` at the start of every sign-out, while the token and the socket
236
+ * still belong to the leaving user (Web Push removes this device's
237
+ * subscription here). Hooks are best-effort: they run in parallel, errors
238
+ * are swallowed, and the sign-out goes on after
239
+ * {@link SIGN_OUT_HOOK_TIMEOUT_MS} whatever they are doing. Returns a disposer.
240
+ */
241
+ onBeforeSignOut(hook: SignOutHook): () => void {
242
+ this.signOutHooks.add(hook);
243
+ return () => {
244
+ this.signOutHooks.delete(hook);
245
+ };
246
+ }
247
+
248
+ private async runSignOutHooks(): Promise<void> {
249
+ if (this.signOutHooks.size === 0 || !this.isAuthenticated) return;
250
+ const id = this.currentUser?.id;
251
+ const ctx: SignOutContext = {
252
+ userId: id ? (typeof id === 'string' ? id : encodeRecordId(id)) : null,
253
+ token: this.token,
254
+ impersonating: this.impersonation !== null,
255
+ };
256
+ let timer: ReturnType<typeof setTimeout> | undefined;
257
+ const deadline = new Promise<void>((resolve) => {
258
+ timer = setTimeout(resolve, SIGN_OUT_HOOK_TIMEOUT_MS);
259
+ });
260
+ const hooks = Promise.allSettled([...this.signOutHooks].map(async (hook) => hook(ctx)));
261
+ try {
262
+ await Promise.race([hooks, deadline]);
263
+ } finally {
264
+ clearTimeout(timer);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * The signed-in user as subscribers see it: the raw `table:id` string, the
270
+ * way the token's `ID` claim spells it. `currentUser.id` is that string while
271
+ * the session is restored from the token and the server's `RecordId` once
272
+ * `check()` has answered, and the two must announce as the same user: every
273
+ * subscriber that compares ids (feature flags, app release) took the switch
274
+ * for a new principal and reset, so flag-gated UI blinked off and back on a
275
+ * few seconds into every reload. A `RecordId`'s own `toString()` will not do
276
+ * either, as it escapes ids like `user:⟨5abc⟩` that the claim spells raw.
277
+ */
278
+ private announcedUserId(): string | null {
279
+ const id = this.currentUser?.id;
280
+ if (!id) return null;
281
+ if (typeof id === 'string') return id;
282
+ return id instanceof RecordId ? encodeRecordId(id as RecordId<string>) : String(id);
283
+ }
284
+
86
285
  private notifyListeners() {
87
- const userId = this.currentUser?.id || null;
286
+ const userId = this.announcedUserId();
88
287
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
288
+ const impersonation = this.impersonation;
289
+ if (!sameImpersonation(impersonation, this.announcedImpersonation)) {
290
+ this.announcedImpersonation = impersonation;
291
+ this.events.emit(AuthEventTypes.ImpersonationChanged, impersonation);
292
+ }
293
+ this.impersonationTimers.sync();
294
+ }
295
+
296
+ /**
297
+ * The impersonation the current session is, or null. Derived from the
298
+ * token in use, so it always describes the identity the server sees.
299
+ */
300
+ get impersonation(): ImpersonationInfo | null {
301
+ return this.isAuthenticated ? impersonationFromToken(this.token) : null;
302
+ }
303
+
304
+ /** Subscribe to impersonation changes; called immediately with the current value. */
305
+ subscribeImpersonation(cb: (info: ImpersonationInfo | null) => void): () => void {
306
+ cb(this.impersonation);
307
+ const id = this.events.subscribe(AuthEventTypes.ImpersonationChanged, (event) => {
308
+ cb(event.payload);
309
+ });
310
+ return () => {
311
+ this.events.unsubscribe(id);
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Act as `targetId` (a record id such as `user:abc`). Admins only, and only
317
+ * when the project enabled `impersonation` in sp00ky.yml: the server refuses
318
+ * everything else. `reason` is recorded in the audit log.
319
+ *
320
+ * The admin's session is kept aside and restored by
321
+ * {@link stopImpersonating}. Local data switches to the target's bucket
322
+ * exactly as on a sign-in, and that bucket is removed on stop.
323
+ */
324
+ async impersonate(targetId: string, reason: string): Promise<ImpersonationInfo> {
325
+ if (this.impersonation) throw new Error('Already impersonating; stop first.');
326
+ if (!this.isAuthenticated || !this.token) throw new Error('Sign in as an admin first.');
327
+ const originToken = this.token;
328
+ const [started] = await this.remote.query<[{ token: string }]>(
329
+ 'RETURN fn::_00_impersonate::start($target, $reason)',
330
+ { target: targetId, reason }
331
+ );
332
+ if (!started?.token) throw new Error('The server did not return an impersonation token.');
333
+
334
+ await this.persistenceClient.set(ORIGIN_TOKEN_KEY, originToken);
335
+ try {
336
+ await this.remote.getClient().authenticate(started.token);
337
+ } catch (error) {
338
+ // The server refused the session it just issued (for example the
339
+ // target is an admin, or no longer exists). Nothing changed locally
340
+ // yet; put the admin's token back on the transport.
341
+ await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
342
+ this.remote.setAuthToken(originToken);
343
+ await this.remote.getClient().authenticate(originToken).catch(() => undefined);
344
+ throw error;
345
+ }
346
+ await this.check(started.token);
347
+ // A method call, not the getter: TS narrowed the getter to null above.
348
+ const info = impersonationFromToken(this.isAuthenticated ? this.token : null);
349
+ if (!info) {
350
+ await this.stopImpersonating();
351
+ throw new Error('Impersonation could not be established.');
352
+ }
353
+ this.logger.info(
354
+ { target: info.target, session: info.session, Category: 'sp00ky-client::AuthService::impersonate' },
355
+ 'Impersonation started'
356
+ );
357
+ return info;
358
+ }
359
+
360
+ /**
361
+ * Return to the admin session. Always leaves the impersonated identity,
362
+ * even when the server cannot be reached: the admin token is restored
363
+ * locally first and verified afterwards, and a rejected admin token ends in
364
+ * a full sign-out rather than staying on the target.
365
+ */
366
+ async stopImpersonating(opts: { serverEnded?: boolean } = {}): Promise<void> {
367
+ if (this.stopping) return this.stopping;
368
+ if (!this.impersonation) return;
369
+ this.stopping = this.leaveImpersonation(opts).finally(() => {
370
+ this.stopping = null;
371
+ });
372
+ return this.stopping;
373
+ }
374
+
375
+ private async leaveImpersonation(opts: { serverEnded?: boolean }): Promise<void> {
376
+ this.impersonationTimers.clear();
377
+ // A renewal already sent finishes first, so it cannot put the target's
378
+ // token back on the socket after the admin's (it sees `stopping` and
379
+ // stands down).
380
+ await this.renewing?.catch(() => undefined);
381
+ const info = this.impersonation;
382
+ if (!info) return;
383
+ if (!opts.serverEnded) {
384
+ try {
385
+ await this.remote.query('RETURN fn::_00_impersonate::stop($session)', { session: info.session });
386
+ } catch (error) {
387
+ // The token expires on its own; the local switch below is what matters.
388
+ this.logger.warn(
389
+ { error, Category: 'sp00ky-client::AuthService::stopImpersonating' },
390
+ 'Could not end the impersonation session on the server'
391
+ );
392
+ }
393
+ }
394
+ this.endedImpersonationTarget = info.target;
395
+ const origin = await this.persistenceClient.get<string>(ORIGIN_TOKEN_KEY);
396
+ await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
397
+ if (!origin || impersonationFromToken(origin)) {
398
+ await this.signOut();
399
+ return;
400
+ }
401
+ const { access, userId } = decodeTokenClaims(origin);
402
+ if (!userId) {
403
+ await this.signOut();
404
+ return;
405
+ }
406
+ // Optimistic switch back, as a warm boot does, then verify.
407
+ this.token = origin;
408
+ this.remote.setAuthToken(origin);
409
+ this.currentUser = { id: userId };
410
+ this.access = access ?? this.defaultAccessName();
411
+ await this.persistenceClient.set('sp00ky_auth_token', origin);
412
+ this.notifyListeners();
413
+ await this.check(origin);
414
+ this.logger.info(
415
+ { target: info.target, Category: 'sp00ky-client::AuthService::stopImpersonating' },
416
+ 'Impersonation stopped'
417
+ );
418
+ }
419
+
420
+ /** Re-sign the impersonation token before it expires. */
421
+ async renewImpersonation(): Promise<void> {
422
+ if (!this.impersonation || this.stopping) return;
423
+ const renewal = this.applyRenewal();
424
+ this.renewing = renewal;
425
+ try {
426
+ await renewal;
427
+ } finally {
428
+ if (this.renewing === renewal) this.renewing = null;
429
+ }
430
+ }
431
+
432
+ private async applyRenewal(): Promise<void> {
433
+ const [renewed] = await this.remote.query<[{ token: string }]>('RETURN fn::_00_impersonate::renew()');
434
+ if (!renewed?.token) throw new Error('Impersonation renewal returned no token.');
435
+ // A stop began while the server was signing: the session is being left,
436
+ // so a fresh token for it must not go on the socket or into storage.
437
+ if (this.stopping || !this.impersonation) return;
438
+ this.remote.setAuthToken(renewed.token);
439
+ await this.remote.getClient().authenticate(renewed.token);
440
+ this.token = renewed.token;
441
+ await this.persistenceClient.set('sp00ky_auth_token', renewed.token);
442
+ this.notifyListeners();
443
+ }
444
+
445
+ /** Users an admin may pick from. Admin-only on the server. */
446
+ async searchImpersonationTargets(search: string): Promise<ImpersonationCandidate[]> {
447
+ const [rows] = await this.remote.query<[ImpersonationCandidate[]]>(
448
+ 'RETURN fn::_00_impersonate::list_users($search)',
449
+ { search }
450
+ );
451
+ return Array.isArray(rows) ? rows : [];
452
+ }
453
+
454
+ /** Open impersonation sessions. Admin-only on the server. */
455
+ async listActiveImpersonations(): Promise<ActiveImpersonation[]> {
456
+ const [rows] = await this.remote.query<[ActiveImpersonation[]]>('RETURN fn::_00_impersonate::active()');
457
+ return Array.isArray(rows) ? rows : [];
458
+ }
459
+
460
+ /**
461
+ * The target of an impersonation that ended, once. The bucket switch calls
462
+ * this after leaving a bucket so it can delete that user's local data.
463
+ */
464
+ consumeEndedImpersonation(): string | null {
465
+ const target = this.endedImpersonationTarget;
466
+ this.endedImpersonationTarget = null;
467
+ return target;
468
+ }
469
+
470
+ /**
471
+ * Restore a session from the locally cached JWT, with NO network.
472
+ *
473
+ * This is what makes a warm boot paint instantly and what makes an offline
474
+ * boot possible at all: the token is in local storage, and it already carries
475
+ * both the access method and the `$auth.id` record id. Everything the client
476
+ * needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
477
+ * permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
478
+ * available before a socket exists.
479
+ *
480
+ * The session is OPTIMISTIC: the token is unverified here. `check()` runs
481
+ * afterwards in the background and downgrades to a real sign-out if the
482
+ * server rejects it. Nothing is trusted that the server has not also seen -
483
+ * the local store only ever holds rows the server previously sent.
484
+ *
485
+ * Returns the restored user id, or null when there is no usable token.
486
+ */
487
+ async restoreSessionFromToken(): Promise<string | null> {
488
+ const token = await this.persistenceClient.get<string>('sp00ky_auth_token');
489
+ if (!token) return null;
490
+ const { access, userId } = decodeTokenClaims(token);
491
+ if (!userId) return null;
492
+
493
+ this.token = token;
494
+ // Hand it to the transport too, so a socket rebuilt from scratch later
495
+ // (the supervisor's revive loop) comes back authenticated. Without this the
496
+ // page kept reporting this user while its session was anonymous, and every
497
+ // view registered afterwards was stamped with an empty identity.
498
+ this.remote.setAuthToken(token);
499
+ // Only the id: the full row is not in the token. It lands from the local
500
+ // cache when the app's own `user` query paints, and is replaced wholesale
501
+ // by `check()` once the server answers.
502
+ this.currentUser = { id: userId };
503
+ this.isAuthenticated = true;
504
+ this.access = access ?? this.defaultAccessName();
505
+ this.notifyListeners();
506
+ this.logger.debug(
507
+ { userId, Category: 'sp00ky-client::AuthService::restoreSessionFromToken' },
508
+ 'Session restored optimistically from cached token'
509
+ );
510
+ return userId;
89
511
  }
90
512
 
91
513
  /**
@@ -94,8 +516,12 @@ export class AuthService<S extends SchemaStructure> {
94
516
  async check(accessToken?: string) {
95
517
  this.isLoading = true;
96
518
 
519
+ let token: string | undefined;
520
+ // Set once the server has accepted the token: from then on a failure is a
521
+ // failure to read the user row, not a verdict on the session.
522
+ let accepted = false;
97
523
  try {
98
- const token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token'));
524
+ token = accessToken || (await this.persistenceClient.get<string>('sp00ky_auth_token')) || undefined;
99
525
 
100
526
  if (!token) {
101
527
  this.logger.debug(
@@ -108,8 +534,12 @@ export class AuthService<S extends SchemaStructure> {
108
534
  return;
109
535
  }
110
536
 
111
- // Authenticate with the token
537
+ // Authenticate with the token, and record it for future connects so a
538
+ // socket rebuilt from scratch reproduces this identity (see
539
+ // `RemoteDatabaseService.setAuthToken`).
540
+ this.remote.setAuthToken(token);
112
541
  await this.remote.getClient().authenticate(token);
542
+ accepted = true;
113
543
 
114
544
  // Verify the session by fetching the full user record using $auth.id
115
545
  const result = await this.remote.query('SELECT * FROM ONLY $auth.id');
@@ -149,33 +579,105 @@ export class AuthService<S extends SchemaStructure> {
149
579
  { Category: 'sp00ky-client::AuthService::check' },
150
580
  'Token valid but user not found via fallback'
151
581
  );
152
- await this.signOut();
582
+ await this.rejectToken(token);
153
583
  }
154
584
  }
155
585
  } 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();
586
+ // A REACHABILITY failure is not a rejected token. This catch used to call
587
+ // signOut() unconditionally, which deletes `sp00ky_auth_token` - so a
588
+ // blip on boot silently logged the user out, and an offline boot could
589
+ // never stay signed in. Only an application error (the server answered,
590
+ // and the answer was "no") ends the session.
591
+ if (classifySyncError(error) === 'network') {
592
+ this.logger.warn(
593
+ { error, Category: 'sp00ky-client::AuthService::check' },
594
+ 'Auth check unreachable; keeping the cached session and retrying later'
595
+ );
596
+ } else if (accepted && token) {
597
+ // The server took the token and only the read of the user row failed
598
+ // (a busy database, a statement error). Signing out here logged an
599
+ // admin out on the way back from an impersonation while the database
600
+ // was saturated. Keep the session on the id the token names; the row
601
+ // arrives with the next check or the app's own user query.
602
+ this.logger.warn(
603
+ { error, Category: 'sp00ky-client::AuthService::check' },
604
+ 'Auth check accepted the token but could not read the user; keeping the session'
605
+ );
606
+ await this.keepAcceptedSession(token);
607
+ } else {
608
+ this.logger.error(
609
+ { error, stack: (error as Error).stack, Category: 'sp00ky-client::AuthService::check' },
610
+ 'Auth check failed'
611
+ );
612
+ await this.rejectToken(token);
613
+ }
161
614
  } finally {
162
615
  this.isLoading = false;
163
616
  }
164
617
  }
165
618
 
619
+ /** Hold a session the server accepted without its user row: the id comes
620
+ * from the token, as on a warm boot. A token naming no user is refused. */
621
+ private async keepAcceptedSession(token: string): Promise<void> {
622
+ if (this.isAuthenticated && this.token === token && this.currentUser) return;
623
+ const { userId } = decodeTokenClaims(token);
624
+ if (!userId) {
625
+ await this.rejectToken(token);
626
+ return;
627
+ }
628
+ await this.setSession(token, { id: userId });
629
+ }
630
+
631
+ /**
632
+ * The server refused `token`. A refused impersonation token (stopped,
633
+ * expired, or revoked) returns to the admin session instead of signing the
634
+ * admin out; anything else signs out.
635
+ */
636
+ private async rejectToken(token: string | undefined): Promise<void> {
637
+ if (token && impersonationFromToken(token)) {
638
+ if (!this.impersonation || this.token !== token) {
639
+ // A boot or explicit check with an impersonation token that is not
640
+ // the session in memory yet: adopt it so stop has something to leave.
641
+ this.token = token;
642
+ this.isAuthenticated = true;
643
+ }
644
+ await this.stopImpersonating({ serverEnded: true });
645
+ return;
646
+ }
647
+ await this.signOut();
648
+ }
649
+
166
650
  /**
167
- * Sign out and clear session
651
+ * Sign out and clear session. While impersonating this ends the
652
+ * impersonation and signs the admin out too.
168
653
  */
169
654
  async signOut() {
655
+ // Before anything is dropped: hooks still act as the leaving user.
656
+ await this.runSignOutHooks();
657
+ const impersonating = this.impersonation;
658
+ this.impersonationTimers.clear();
659
+ if (impersonating) {
660
+ this.endedImpersonationTarget = impersonating.target;
661
+ try {
662
+ await this.remote.query('RETURN fn::_00_impersonate::stop($session)', {
663
+ session: impersonating.session,
664
+ });
665
+ } catch (_e) {
666
+ // The token expires on its own.
667
+ }
668
+ }
669
+ await this.persistenceClient.remove(ORIGIN_TOKEN_KEY);
170
670
  this.token = null;
671
+ this.remote.setAuthToken(null);
171
672
  this.currentUser = null;
172
673
  this.isAuthenticated = false;
674
+ this.access = null;
173
675
 
174
676
  await this.persistenceClient.remove('sp00ky_auth_token');
175
677
 
176
678
  try {
177
679
  await this.remote.getClient().invalidate();
178
- } catch (e) {
680
+ } catch (_e) {
179
681
  // Ignore invalidation errors
180
682
  }
181
683
 
@@ -184,12 +686,24 @@ export class AuthService<S extends SchemaStructure> {
184
686
 
185
687
  private async setSession(token: string, user: any) {
186
688
  this.token = token;
689
+ this.remote.setAuthToken(token);
187
690
  this.currentUser = user;
188
691
  this.isAuthenticated = true;
692
+ // Resolve the access-method name (e.g. "account") for in-browser SSP
693
+ // permission injection. Prefer the token's `AC` claim; fall back to the
694
+ // schema's sole record-access method if the claim is absent.
695
+ this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
189
696
  await this.persistenceClient.set('sp00ky_auth_token', token);
190
697
  this.notifyListeners();
191
698
  }
192
699
 
700
+ /** Fallback when the token carries no `AC` claim: if the schema defines
701
+ * exactly one record-access method, assume the session used it. */
702
+ private defaultAccessName(): string | null {
703
+ const names = Object.keys(this.schema.access ?? {});
704
+ return names.length === 1 ? names[0] : null;
705
+ }
706
+
193
707
  async signUp<Name extends keyof S['access'] & string>(
194
708
  accessName: Name,
195
709
  params: ExtractAccessParams<S, Name, 'signup'>
@@ -211,11 +725,17 @@ export class AuthService<S extends SchemaStructure> {
211
725
  );
212
726
  }
213
727
 
728
+ // Parameter names only: the values hold the password (and whatever else
729
+ // the app signs up with), and logs may leave the device.
214
730
  this.logger.info(
215
- { accessName, runtimeParams, Category: 'sp00ky-client::AuthService::signUp' },
731
+ { accessName, params: Object.keys(runtimeParams), Category: 'sp00ky-client::AuthService::signUp' },
216
732
  'Attempting signup'
217
733
  );
218
734
 
735
+ // Straight to the SDK client, so wait out a connect in flight: on a socket
736
+ // without its namespace yet the call fails with "There is no namespace
737
+ // and/or database selected" (a sign-up right after the page loaded).
738
+ await this.remote.whenConnected();
219
739
  const { access } = await this.remote.getClient().signup({
220
740
  access: accessName,
221
741
  variables: runtimeParams,
@@ -257,6 +777,7 @@ export class AuthService<S extends SchemaStructure> {
257
777
  'Attempting signin'
258
778
  );
259
779
 
780
+ await this.remote.whenConnected(); // see signUp
260
781
  const { access } = await this.remote.getClient().signin({
261
782
  access: accessName,
262
783
  variables: runtimeParams,