@spooky-sync/core 0.0.1-canary.280 → 0.0.1-canary.281

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.
@@ -10,6 +10,7 @@ export * from './events/index';
10
10
  import { AuthEventTypes, createAuthEventSystem } from './events/index';
11
11
  import type { PersistenceClient } from '../../types';
12
12
  import { classifySyncError } from '../../utils/error-classification';
13
+ import { encodeRecordId } from '../../utils/index';
13
14
  import {
14
15
  decodeTokenClaims,
15
16
  impersonationFromToken,
@@ -37,6 +38,20 @@ export interface ActiveImpersonation {
37
38
  expires_at: unknown;
38
39
  }
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;
54
+
40
55
  // Helper to pretty print types
41
56
  type Prettify<T> = {
42
57
  [K in keyof T]: T[K];
@@ -81,6 +96,7 @@ export class AuthService<S extends SchemaStructure> {
81
96
  public isLoading: boolean = true;
82
97
 
83
98
  private events = createAuthEventSystem();
99
+ private signOutHooks = new Set<SignOutHook>();
84
100
 
85
101
  /** The impersonation last announced, for change detection. */
86
102
  private announcedImpersonation: ImpersonationInfo | null = null;
@@ -140,6 +156,40 @@ export class AuthService<S extends SchemaStructure> {
140
156
  };
141
157
  }
142
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
+
143
193
  private notifyListeners() {
144
194
  const userId = this.currentUser?.id || null;
145
195
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
@@ -457,6 +507,8 @@ export class AuthService<S extends SchemaStructure> {
457
507
  * impersonation and signs the admin out too.
458
508
  */
459
509
  async signOut() {
510
+ // Before anything is dropped: hooks still act as the leaving user.
511
+ await this.runSignOutHooks();
460
512
  const impersonating = this.impersonation;
461
513
  this.impersonationTimers.clear();
462
514
  if (impersonating) {