@spooky-sync/core 0.0.1-canary.332 → 0.0.1-canary.333

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as WebPushModule, A as RegistrationTimings, At as LogTap, B as SyncHealthConfig, Bt as ORIGIN_TOKEN_KEY, C as QueryStatusCallback, Ct as SignOutContext, D as ReconnectConfig, Dt as AuthEventTypes, E as QueryUpdateCallback, Et as AuthEventTypeMap, F as Sp00kyQueryResultPromise, Ft as EventSystem, G as PushDeviceKind, Gt as renewDelayMs, H as TimingPhase, Ht as TokenClaims, I as StorageHealth, It as IMPERSONATION_ACCESS, J as WebPushError, K as WebPushConfig, Kt as sameImpersonation, L as StorageHealthStatus, Lt as ImpersonationInfo, M as ServerViewMeta, Mt as LocalStore, N as Sp00kyConfig, Nt as SealedQuery, O as RecordVersionArray, Ot as createAuthEventSystem, P as Sp00kyQueryResult, Pt as EventDefinition, Q as WebPushMessageInput, R as StoreType, Rt as ImpersonationTimerHost, S as QueryStatus, St as SIGN_OUT_HOOK_TIMEOUT_MS, T as QueryTimings, Tt as AuthEventSystem, U as UpdateOptions, Ut as decodeTokenClaims, V as SyncHealthStatus, Vt as SESSION_POLL_MS, W as PUSH_ENDPOINT_KEY, Wt as impersonationFromToken, X as WebPushInfo, Y as WebPushErrorCode, Z as WebPushMessage, _ as QueryAuthorityCallback, _t as WebPushClientMessage, a as InlineRow, at as WebPushUnsupportedReason, b as QueryHash, bt as AuthService, c as MembershipOutcome, ct as BridgeSignOutMessage, d as MutationEventType, dt as PushNotification, et as WebPushSubscribeOptions, f as PersistenceClient, ft as PushNotificationAction, g as PreloadRefresh, gt as PushUrgency, h as PreloadOptions, ht as PushPayloadKind, i as EventSubscriptionOptions, it as WebPushUnsubscribeOptions, j as RunOptions, jt as Logger$1, k as RecordVersionDiff, kt as RemoteDatabaseService, l as MutationCallback, lt as BridgeTokenMessage, m as PinoTransmit, mt as PushPayload, n as DebounceOptions, nt as WebPushSyncOptions, o as Level, ot as WebPushUpdateOptions, p as PhaseStat, pt as PushOp, q as WebPushDevice, r as EdgeDelta, rt as WebPushSyncStatus, s as MATERIALIZATION_SAMPLE_WINDOW, st as detectWebPushSupport, t as ConnectionState, tt as WebPushSupport, u as MutationEvent, ut as PUSH_PAYLOAD_VERSION, v as QueryConfig, vt as isPushPayload, w as QueryTimeToLive, wt as SignOutHook, x as QueryState, xt as ImpersonationCandidate, y as QueryConfigRecord, yt as ActiveImpersonation, z as SyncHealth, zt as ImpersonationTimers } from "./types.js";
1
+ import { $ as WebPushModule, A as RegistrationTimings, At as RemoteDatabaseService, B as SyncHealthConfig, Bt as ImpersonationTimers, C as QueryStatusCallback, Ct as SIGN_OUT_HOOK_TIMEOUT_MS, D as ReconnectConfig, Dt as AuthEventTypeMap, E as QueryUpdateCallback, Et as AuthEventSystem, F as Sp00kyQueryResultPromise, Ft as EventDefinition, G as PushDeviceKind, Gt as impersonationFromToken, H as TimingPhase, Ht as SESSION_POLL_MS, I as StorageHealth, It as EventSystem, J as WebPushError, K as WebPushConfig, Kt as renewDelayMs, L as StorageHealthStatus, Lt as IMPERSONATION_ACCESS, M as ServerViewMeta, Mt as Logger$1, N as Sp00kyConfig, Nt as LocalStore, O as RecordVersionArray, Ot as AuthEventTypes, P as Sp00kyQueryResult, Pt as SealedQuery, Q as WebPushMessageInput, R as StoreType, Rt as ImpersonationInfo, S as QueryStatus, St as REFUSAL_RECHECK_MIN_MS, T as QueryTimings, Tt as SignOutHook, U as UpdateOptions, Ut as TokenClaims, V as SyncHealthStatus, Vt as ORIGIN_TOKEN_KEY, W as PUSH_ENDPOINT_KEY, Wt as decodeTokenClaims, X as WebPushInfo, Y as WebPushErrorCode, Z as WebPushMessage, _ as QueryAuthorityCallback, _t as WebPushClientMessage, a as InlineRow, at as WebPushUnsupportedReason, b as QueryHash, bt as AuthService, c as MembershipOutcome, ct as BridgeSignOutMessage, d as MutationEventType, dt as PushNotification, et as WebPushSubscribeOptions, f as PersistenceClient, ft as PushNotificationAction, g as PreloadRefresh, gt as PushUrgency, h as PreloadOptions, ht as PushPayloadKind, i as EventSubscriptionOptions, it as WebPushUnsubscribeOptions, j as RunOptions, jt as LogTap, k as RecordVersionDiff, kt as createAuthEventSystem, l as MutationCallback, lt as BridgeTokenMessage, m as PinoTransmit, mt as PushPayload, n as DebounceOptions, nt as WebPushSyncOptions, o as Level, ot as WebPushUpdateOptions, p as PhaseStat, pt as PushOp, q as WebPushDevice, qt as sameImpersonation, r as EdgeDelta, rt as WebPushSyncStatus, s as MATERIALIZATION_SAMPLE_WINDOW, st as detectWebPushSupport, t as ConnectionState, tt as WebPushSupport, u as MutationEvent, ut as PUSH_PAYLOAD_VERSION, v as QueryConfig, vt as isPushPayload, w as QueryTimeToLive, wt as SignOutContext, x as QueryState, xt as ImpersonationCandidate, y as QueryConfigRecord, yt as ActiveImpersonation, z as SyncHealth, zt as ImpersonationTimerHost } from "./types.js";
2
2
  import { BackendNames, BackendRoutes, BucketNames, FinalQuery, GetTable, QueryBuilder, QueryOptions, QueryPlan, RecordId, RoutePayload, SchemaStructure, TableModel, TableNames } from "@spooky-sync/query-builder";
3
3
  import * as surrealdb0 from "surrealdb";
4
4
  import { Duration, RecordId as RecordId$1, Surreal } from "surrealdb";
@@ -2212,4 +2212,4 @@ declare class LocalOpTimeoutError extends Error {
2212
2212
  * select on a throttled tab is seconds, not tens of seconds. */
2213
2213
  declare const DEFAULT_LOCAL_OP_TIMEOUT_MS = 30000;
2214
2214
  //#endregion
2215
- export { ActiveImpersonation, AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, type BlurhashEncodeOptions, type BlurhashSetting, type BridgeSignOutMessage, type BridgeTokenMessage, BucketHandle, BucketPutOptions, BucketPutResult, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, DebounceOptions, EdgeDelta, EventSubscriptionOptions, type FailedMutationRow as FailedMutation, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, IMPERSONATION_ACCESS, ImpersonationCandidate, ImpersonationInfo, ImpersonationTimerHost, ImpersonationTimers, InlineRow, Level, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, MembershipOutcome, MutationCallback, MutationEvent, MutationEventType, ORIGIN_TOKEN_KEY, PUSH_ENDPOINT_KEY, PUSH_PAYLOAD_VERSION, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, type PushDeviceKind, type PushNotification, type PushNotificationAction, type PushOp, type PushPayload, type PushPayloadKind, type PushUrgency, QueryAuthorityCallback, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, SESSION_POLL_MS, SIGN_OUT_HOOK_TIMEOUT_MS, ServerViewMeta, SignOutContext, SignOutHook, Sp00kyClient, Sp00kyClientDeps, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, TokenClaims, UpdateOptions, type WebPushClientMessage, type WebPushConfig, type WebPushDevice, WebPushError, type WebPushErrorCode, type WebPushInfo, type WebPushMessage, type WebPushMessageInput, WebPushModule, type WebPushSubscribeOptions, type WebPushSupport, type WebPushSyncOptions, type WebPushSyncStatus, type WebPushUnsubscribeOptions, type WebPushUnsupportedReason, type WebPushUpdateOptions, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, detectWebPushSupport, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, isPushPayload, renewDelayMs, sameImpersonation, semverGt, textToHtml };
2215
+ export { ActiveImpersonation, AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, type BlurhashEncodeOptions, type BlurhashSetting, type BridgeSignOutMessage, type BridgeTokenMessage, BucketHandle, BucketPutOptions, BucketPutResult, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, DebounceOptions, EdgeDelta, EventSubscriptionOptions, type FailedMutationRow as FailedMutation, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, IMPERSONATION_ACCESS, ImpersonationCandidate, ImpersonationInfo, ImpersonationTimerHost, ImpersonationTimers, InlineRow, Level, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, MembershipOutcome, MutationCallback, MutationEvent, MutationEventType, ORIGIN_TOKEN_KEY, PUSH_ENDPOINT_KEY, PUSH_PAYLOAD_VERSION, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, type PushDeviceKind, type PushNotification, type PushNotificationAction, type PushOp, type PushPayload, type PushPayloadKind, type PushUrgency, QueryAuthorityCallback, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, REFUSAL_RECHECK_MIN_MS, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, SESSION_POLL_MS, SIGN_OUT_HOOK_TIMEOUT_MS, ServerViewMeta, SignOutContext, SignOutHook, Sp00kyClient, Sp00kyClientDeps, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, TokenClaims, UpdateOptions, type WebPushClientMessage, type WebPushConfig, type WebPushDevice, WebPushError, type WebPushErrorCode, type WebPushInfo, type WebPushMessage, type WebPushMessageInput, WebPushModule, type WebPushSubscribeOptions, type WebPushSupport, type WebPushSyncOptions, type WebPushSyncStatus, type WebPushUnsubscribeOptions, type WebPushUnsupportedReason, type WebPushUpdateOptions, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, detectWebPushSupport, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, isPushPayload, renewDelayMs, sameImpersonation, semverGt, textToHtml };
package/dist/index.js CHANGED
@@ -551,6 +551,16 @@ function classifySyncError(error) {
551
551
  for (const pattern of NETWORK_ERROR_PATTERNS) if (message.includes(pattern)) return "network";
552
552
  return "application";
553
553
  }
554
+ /**
555
+ * Whether the server refused the SESSION rather than the statement: its own
556
+ * wording for a token it will not accept ("There was a problem with
557
+ * authentication"), which is also what an app's AUTHENTICATE clause and
558
+ * sp00ky's session guard throw, and a session past its `DURATION FOR SESSION`.
559
+ */
560
+ function isRefusedSessionError(error) {
561
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
562
+ return message.includes("problem with authentication") || message.includes("session has expired");
563
+ }
554
564
 
555
565
  //#endregion
556
566
  //#region src/utils/index.ts
@@ -2188,8 +2198,8 @@ function trimHistory(history, cap) {
2188
2198
 
2189
2199
  //#endregion
2190
2200
  //#region src/modules/devtools/index.ts
2191
- const CORE_VERSION = "0.0.1-canary.332";
2192
- const WASM_VERSION = "0.0.1-canary.332";
2201
+ const CORE_VERSION = "0.0.1-canary.333";
2202
+ const WASM_VERSION = "0.0.1-canary.333";
2193
2203
  const SURREAL_VERSION = "3.1.0";
2194
2204
  var DevToolsService = class DevToolsService {
2195
2205
  version = CORE_VERSION;
@@ -11901,6 +11911,12 @@ var ImpersonationTimers = class {
11901
11911
  //#region src/modules/auth/index.ts
11902
11912
  /** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
11903
11913
  const SIGN_OUT_HOOK_TIMEOUT_MS = 2e3;
11914
+ /**
11915
+ * Floor between two session re-checks started by refused statements. A
11916
+ * refused session fails every statement at once, and a check that keeps the
11917
+ * session (the server was unreachable) must not be re-run by each of them.
11918
+ */
11919
+ const REFUSAL_RECHECK_MIN_MS = 3e4;
11904
11920
  function decodeAccessFromToken(token) {
11905
11921
  return decodeTokenClaims(token).access;
11906
11922
  }
@@ -11930,6 +11946,11 @@ var AuthService = class {
11930
11946
  stopping = null;
11931
11947
  /** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
11932
11948
  renewing = null;
11949
+ /** The session re-check in flight (see `watchSession`), shared by every trigger. */
11950
+ reverifying = null;
11951
+ /** When a refused statement last started a re-check. */
11952
+ lastRefusalCheckAt = Number.NEGATIVE_INFINITY;
11953
+ watchingSession = false;
11933
11954
  impersonationTimers = new ImpersonationTimers({
11934
11955
  current: () => this.impersonation,
11935
11956
  renew: () => this.renewImpersonation(),
@@ -11957,6 +11978,54 @@ var AuthService = class {
11957
11978
  }
11958
11979
  async init() {
11959
11980
  await this.check();
11981
+ this.watchSession();
11982
+ }
11983
+ /**
11984
+ * Check the session again whenever the socket comes back, and whenever the
11985
+ * server refuses a statement because of the session.
11986
+ *
11987
+ * The server judges a token only when a socket authenticates: the SDK
11988
+ * replays `authenticate` on its own reconnects, `RemoteDatabaseService` on a
11989
+ * socket it rebuilds. When the replay is refused (the app ended the session,
11990
+ * say with a password reset) the SDK drops to an anonymous session, and the
11991
+ * supervisor swallows the rebuild's error, so the socket reports `connected`
11992
+ * while this service still announces the user. Every view registered from
11993
+ * then on carried no identity: the page looked signed in and showed nothing
11994
+ * of the user's. `check()` applies the token again and signs out when the
11995
+ * server refuses it.
11996
+ *
11997
+ * A socket that never reconnects is ended by the app's session guard
11998
+ * (`fn::sp00ky::session_live`) instead: its heartbeats stop finding rows,
11999
+ * and the re-registration that follows is refused with "There was a problem
12000
+ * with authentication", which starts a check here too.
12001
+ */
12002
+ watchSession() {
12003
+ if (this.watchingSession) return;
12004
+ this.watchingSession = true;
12005
+ this.remote.subscribeConnection("connected", () => this.reverify());
12006
+ this.remote.getEvents().subscribe(DatabaseEventTypes.RemoteQuery, (event) => {
12007
+ const { success, error } = event.payload;
12008
+ if (success || !isRefusedSessionError(error ?? "")) return;
12009
+ const now = Date.now();
12010
+ if (now - this.lastRefusalCheckAt < REFUSAL_RECHECK_MIN_MS) return;
12011
+ this.lastRefusalCheckAt = now;
12012
+ this.reverify();
12013
+ });
12014
+ }
12015
+ /** One `check()` for every trigger that lands while it runs. */
12016
+ reverify() {
12017
+ if (!this.isAuthenticated || this.reverifying) return;
12018
+ this.reverifying = (async () => {
12019
+ await this.remote.whenConnected();
12020
+ if (this.isAuthenticated) await this.check();
12021
+ })().catch((error) => {
12022
+ this.logger.warn({
12023
+ error,
12024
+ Category: "sp00ky-client::AuthService::reverify"
12025
+ }, "Session re-check failed");
12026
+ }).finally(() => {
12027
+ this.reverifying = null;
12028
+ });
11960
12029
  }
11961
12030
  getAccessDefinition(name) {
11962
12031
  return this.schema.access?.[name];
@@ -15676,7 +15745,7 @@ function createTabsCoordinator(config, s, host) {
15676
15745
  return new TabsCoordinator({
15677
15746
  tabId: s.tabId,
15678
15747
  fingerprint: computeTabsFingerprint({
15679
- coreVersion: "0.0.1-canary.332",
15748
+ coreVersion: "0.0.1-canary.333",
15680
15749
  schemaHash: hash53(config.schemaSurql),
15681
15750
  endpoint: config.database.endpoint ?? "",
15682
15751
  namespace: config.database.namespace,
@@ -16796,4 +16865,4 @@ var Sp00kyClient = class {
16796
16865
  };
16797
16866
 
16798
16867
  //#endregion
16799
- export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, FeatureFlagHandle, FeatureFlagModule, IMPERSONATION_ACCESS, ImpersonationTimers, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, ORIGIN_TOKEN_KEY, PUSH_ENDPOINT_KEY, PUSH_PAYLOAD_VERSION, SESSION_POLL_MS, SIGN_OUT_HOOK_TIMEOUT_MS, Sp00kyClient, WebPushError, WebPushModule, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, detectWebPushSupport, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, isPushPayload, renewDelayMs, sameImpersonation, semverGt, textToHtml };
16868
+ export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, FeatureFlagHandle, FeatureFlagModule, IMPERSONATION_ACCESS, ImpersonationTimers, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, ORIGIN_TOKEN_KEY, PUSH_ENDPOINT_KEY, PUSH_PAYLOAD_VERSION, REFUSAL_RECHECK_MIN_MS, SESSION_POLL_MS, SIGN_OUT_HOOK_TIMEOUT_MS, Sp00kyClient, WebPushError, WebPushModule, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, detectWebPushSupport, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, isPushPayload, renewDelayMs, sameImpersonation, semverGt, textToHtml };
package/dist/types.d.ts CHANGED
@@ -822,6 +822,12 @@ interface SignOutContext {
822
822
  type SignOutHook = (ctx: SignOutContext) => Promise<void> | void;
823
823
  /** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
824
824
  declare const SIGN_OUT_HOOK_TIMEOUT_MS = 2000;
825
+ /**
826
+ * Floor between two session re-checks started by refused statements. A
827
+ * refused session fails every statement at once, and a check that keeps the
828
+ * session (the server was unreachable) must not be re-run by each of them.
829
+ */
830
+ declare const REFUSAL_RECHECK_MIN_MS = 30000;
825
831
  type Prettify<T> = { [K in keyof T]: T[K] } & {};
826
832
  type MapColumnType<T extends ColumnSchema> = T['optional'] extends true ? TypeNameToTypeMap[T['type']] | undefined : TypeNameToTypeMap[T['type']];
827
833
  type ExtractAccessParams<S extends SchemaStructure, Name extends keyof S['access'], Method extends 'signIn' | 'signup'> = S['access'] extends undefined ? never : S['access'][Name] extends AccessDefinition ? Prettify<{ [K in keyof S['access'][Name][Method]['params']]: MapColumnType<S['access'][Name][Method]['params'][K]> }> : never;
@@ -855,10 +861,37 @@ declare class AuthService<S extends SchemaStructure> {
855
861
  private stopping;
856
862
  /** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
857
863
  private renewing;
864
+ /** The session re-check in flight (see `watchSession`), shared by every trigger. */
865
+ private reverifying;
866
+ /** When a refused statement last started a re-check. */
867
+ private lastRefusalCheckAt;
868
+ private watchingSession;
858
869
  private impersonationTimers;
859
870
  get eventSystem(): AuthEventSystem;
860
871
  constructor(schema: S, remote: RemoteDatabaseService, persistenceClient: PersistenceClient, logger: Logger$1);
861
872
  init(): Promise<void>;
873
+ /**
874
+ * Check the session again whenever the socket comes back, and whenever the
875
+ * server refuses a statement because of the session.
876
+ *
877
+ * The server judges a token only when a socket authenticates: the SDK
878
+ * replays `authenticate` on its own reconnects, `RemoteDatabaseService` on a
879
+ * socket it rebuilds. When the replay is refused (the app ended the session,
880
+ * say with a password reset) the SDK drops to an anonymous session, and the
881
+ * supervisor swallows the rebuild's error, so the socket reports `connected`
882
+ * while this service still announces the user. Every view registered from
883
+ * then on carried no identity: the page looked signed in and showed nothing
884
+ * of the user's. `check()` applies the token again and signs out when the
885
+ * server refuses it.
886
+ *
887
+ * A socket that never reconnects is ended by the app's session guard
888
+ * (`fn::sp00ky::session_live`) instead: its heartbeats stop finding rows,
889
+ * and the re-registration that follows is refused with "There was a problem
890
+ * with authentication", which starts a check here too.
891
+ */
892
+ private watchSession;
893
+ /** One `check()` for every trigger that lands while it runs. */
894
+ private reverify;
862
895
  getAccessDefinition<Name extends keyof S['access']>(name: Name): AccessDefinition | undefined;
863
896
  /**
864
897
  * Subscribe to auth state changes.
@@ -2193,4 +2226,4 @@ interface DebounceOptions {
2193
2226
  flushOnHide?: boolean;
2194
2227
  }
2195
2228
  //#endregion
2196
- export { WebPushModule as $, RegistrationTimings as A, LogTap as At, SyncHealthConfig as B, ORIGIN_TOKEN_KEY as Bt, QueryStatusCallback as C, SignOutContext as Ct, ReconnectConfig as D, AuthEventTypes as Dt, QueryUpdateCallback as E, AuthEventTypeMap as Et, Sp00kyQueryResultPromise as F, EventSystem as Ft, PushDeviceKind as G, renewDelayMs as Gt, TimingPhase as H, TokenClaims as Ht, StorageHealth as I, IMPERSONATION_ACCESS as It, WebPushError as J, WebPushConfig as K, sameImpersonation as Kt, StorageHealthStatus as L, ImpersonationInfo as Lt, ServerViewMeta as M, LocalStore as Mt, Sp00kyConfig as N, SealedQuery as Nt, RecordVersionArray as O, createAuthEventSystem as Ot, Sp00kyQueryResult as P, EventDefinition as Pt, WebPushMessageInput as Q, StoreType as R, ImpersonationTimerHost as Rt, QueryStatus as S, SIGN_OUT_HOOK_TIMEOUT_MS as St, QueryTimings as T, AuthEventSystem as Tt, UpdateOptions as U, decodeTokenClaims as Ut, SyncHealthStatus as V, SESSION_POLL_MS as Vt, PUSH_ENDPOINT_KEY as W, impersonationFromToken as Wt, WebPushInfo as X, WebPushErrorCode as Y, WebPushMessage as Z, QueryAuthorityCallback as _, WebPushClientMessage as _t, InlineRow as a, WebPushUnsupportedReason as at, QueryHash as b, AuthService as bt, MembershipOutcome as c, BridgeSignOutMessage as ct, MutationEventType as d, PushNotification as dt, WebPushSubscribeOptions as et, PersistenceClient as f, PushNotificationAction as ft, PreloadRefresh as g, PushUrgency as gt, PreloadOptions as h, PushPayloadKind as ht, EventSubscriptionOptions as i, WebPushUnsubscribeOptions as it, RunOptions as j, Logger$1 as jt, RecordVersionDiff as k, RemoteDatabaseService as kt, MutationCallback as l, BridgeTokenMessage as lt, PinoTransmit as m, PushPayload as mt, DebounceOptions as n, WebPushSyncOptions as nt, Level$1 as o, WebPushUpdateOptions as ot, PhaseStat as p, PushOp as pt, WebPushDevice as q, EdgeDelta as r, WebPushSyncStatus as rt, MATERIALIZATION_SAMPLE_WINDOW as s, detectWebPushSupport as st, ConnectionState as t, WebPushSupport as tt, MutationEvent as u, PUSH_PAYLOAD_VERSION as ut, QueryConfig as v, isPushPayload as vt, QueryTimeToLive as w, SignOutHook as wt, QueryState as x, ImpersonationCandidate as xt, QueryConfigRecord as y, ActiveImpersonation as yt, SyncHealth as z, ImpersonationTimers as zt };
2229
+ export { WebPushModule as $, RegistrationTimings as A, RemoteDatabaseService as At, SyncHealthConfig as B, ImpersonationTimers as Bt, QueryStatusCallback as C, SIGN_OUT_HOOK_TIMEOUT_MS as Ct, ReconnectConfig as D, AuthEventTypeMap as Dt, QueryUpdateCallback as E, AuthEventSystem as Et, Sp00kyQueryResultPromise as F, EventDefinition as Ft, PushDeviceKind as G, impersonationFromToken as Gt, TimingPhase as H, SESSION_POLL_MS as Ht, StorageHealth as I, EventSystem as It, WebPushError as J, WebPushConfig as K, renewDelayMs as Kt, StorageHealthStatus as L, IMPERSONATION_ACCESS as Lt, ServerViewMeta as M, Logger$1 as Mt, Sp00kyConfig as N, LocalStore as Nt, RecordVersionArray as O, AuthEventTypes as Ot, Sp00kyQueryResult as P, SealedQuery as Pt, WebPushMessageInput as Q, StoreType as R, ImpersonationInfo as Rt, QueryStatus as S, REFUSAL_RECHECK_MIN_MS as St, QueryTimings as T, SignOutHook as Tt, UpdateOptions as U, TokenClaims as Ut, SyncHealthStatus as V, ORIGIN_TOKEN_KEY as Vt, PUSH_ENDPOINT_KEY as W, decodeTokenClaims as Wt, WebPushInfo as X, WebPushErrorCode as Y, WebPushMessage as Z, QueryAuthorityCallback as _, WebPushClientMessage as _t, InlineRow as a, WebPushUnsupportedReason as at, QueryHash as b, AuthService as bt, MembershipOutcome as c, BridgeSignOutMessage as ct, MutationEventType as d, PushNotification as dt, WebPushSubscribeOptions as et, PersistenceClient as f, PushNotificationAction as ft, PreloadRefresh as g, PushUrgency as gt, PreloadOptions as h, PushPayloadKind as ht, EventSubscriptionOptions as i, WebPushUnsubscribeOptions as it, RunOptions as j, LogTap as jt, RecordVersionDiff as k, createAuthEventSystem as kt, MutationCallback as l, BridgeTokenMessage as lt, PinoTransmit as m, PushPayload as mt, DebounceOptions as n, WebPushSyncOptions as nt, Level$1 as o, WebPushUpdateOptions as ot, PhaseStat as p, PushOp as pt, WebPushDevice as q, sameImpersonation as qt, EdgeDelta as r, WebPushSyncStatus as rt, MATERIALIZATION_SAMPLE_WINDOW as s, detectWebPushSupport as st, ConnectionState as t, WebPushSupport as tt, MutationEvent as u, PUSH_PAYLOAD_VERSION as ut, QueryConfig as v, isPushPayload as vt, QueryTimeToLive as w, SignOutContext as wt, QueryState as x, ImpersonationCandidate as xt, QueryConfigRecord as y, ActiveImpersonation as yt, SyncHealth as z, ImpersonationTimerHost as zt };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.332",
3
+ "version": "0.0.1-canary.333",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,8 +75,8 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.332",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.332",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.333",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.333",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.1.0",
82
82
  "@surrealdb/wasm-native": "^3.3.4",
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { AuthService, REFUSAL_RECHECK_MIN_MS } from './index';
3
+ import {
4
+ createDatabaseEventSystem,
5
+ DatabaseEventTypes,
6
+ } from '../../services/database/events/index';
7
+
8
+ // The session is re-checked after every reconnect and after a statement the
9
+ // server refused because of the session: a token refused on a socket's own
10
+ // handshake used to leave the page on an anonymous `connected` socket while
11
+ // it kept announcing the user.
12
+
13
+ function jwt(claims: Record<string, unknown>): string {
14
+ return `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`;
15
+ }
16
+
17
+ const silentLogger = () =>
18
+ ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: () => silentLogger() }) as any;
19
+
20
+ const REFUSED = 'There was a problem with authentication: this session has ended, sign in again';
21
+
22
+ function makeAuth() {
23
+ const token = jwt({ AC: 'account', ID: 'user:abc' });
24
+ const store = new Map<string, unknown>([['sp00ky_auth_token', token]]);
25
+ const persistence = {
26
+ get: vi.fn(async (k: string) => store.get(k) ?? null),
27
+ set: vi.fn(async (k: string, v: unknown) => void store.set(k, v)),
28
+ remove: vi.fn(async (k: string) => void store.delete(k)),
29
+ } as any;
30
+ const connected = new Set<() => void>();
31
+ const events = createDatabaseEventSystem();
32
+ let refuse = false;
33
+ const authenticate = vi.fn(async () => {
34
+ if (refuse) throw new Error(REFUSED);
35
+ });
36
+ const remote = {
37
+ query: vi.fn(async () => [{ id: 'user:abc' }]),
38
+ setAuthToken: vi.fn(),
39
+ whenConnected: vi.fn(async () => undefined),
40
+ subscribeConnection: (_event: 'connected', cb: () => void) => {
41
+ connected.add(cb);
42
+ return () => connected.delete(cb);
43
+ },
44
+ getEvents: () => events,
45
+ getClient: () => ({ authenticate, invalidate: vi.fn(async () => undefined) }),
46
+ } as any;
47
+ const auth = new AuthService({} as any, remote, persistence, silentLogger());
48
+ const settle = () => new Promise((r) => setTimeout(r, 0));
49
+ return {
50
+ auth,
51
+ authenticate,
52
+ store,
53
+ refuseFromNowOn: () => void (refuse = true),
54
+ reconnect: async () => {
55
+ for (const cb of connected) cb();
56
+ await settle();
57
+ },
58
+ statementFails: async (error: string) => {
59
+ events.emit(DatabaseEventTypes.RemoteQuery, {
60
+ query: 'RETURN fn::query::register($config)',
61
+ duration: 1,
62
+ success: false,
63
+ error,
64
+ timestamp: Date.now(),
65
+ });
66
+ await settle();
67
+ },
68
+ };
69
+ }
70
+
71
+ describe('AuthService session re-check', () => {
72
+ it('signs out when a reconnected socket can no longer use the token', async () => {
73
+ const t = makeAuth();
74
+ await t.auth.init();
75
+ expect(t.auth.isAuthenticated).toBe(true);
76
+
77
+ t.refuseFromNowOn();
78
+ await t.reconnect();
79
+
80
+ expect(t.authenticate).toHaveBeenCalledTimes(2);
81
+ expect(t.auth.isAuthenticated).toBe(false);
82
+ expect(t.store.get('sp00ky_auth_token')).toBeUndefined();
83
+ });
84
+
85
+ it('keeps a session the server still accepts after a reconnect', async () => {
86
+ const t = makeAuth();
87
+ await t.auth.init();
88
+ await t.reconnect();
89
+
90
+ expect(t.authenticate).toHaveBeenCalledTimes(2);
91
+ expect(t.auth.isAuthenticated).toBe(true);
92
+ });
93
+
94
+ it('signs out when a statement on an open socket is refused for the session', async () => {
95
+ const t = makeAuth();
96
+ await t.auth.init();
97
+
98
+ t.refuseFromNowOn();
99
+ await t.statementFails(`An error occurred: ${REFUSED}`);
100
+
101
+ expect(t.auth.isAuthenticated).toBe(false);
102
+ });
103
+
104
+ it('ignores statement errors that are not about the session', async () => {
105
+ const t = makeAuth();
106
+ await t.auth.init();
107
+ await t.statementFails('Found 3 for field `n`, but expected a string');
108
+ await t.statementFails('There was a problem with the underlying connection');
109
+
110
+ expect(t.authenticate).toHaveBeenCalledTimes(1);
111
+ });
112
+
113
+ it('re-checks once per window however many statements are refused', async () => {
114
+ vi.useFakeTimers({ toFake: ['Date'] });
115
+ try {
116
+ const t = makeAuth();
117
+ await t.auth.init();
118
+ await t.statementFails(REFUSED);
119
+ await t.statementFails(REFUSED);
120
+ expect(t.authenticate).toHaveBeenCalledTimes(2);
121
+
122
+ vi.setSystemTime(Date.now() + REFUSAL_RECHECK_MIN_MS);
123
+ await t.statementFails(REFUSED);
124
+ expect(t.authenticate).toHaveBeenCalledTimes(3);
125
+ expect(t.auth.isAuthenticated).toBe(true);
126
+ } finally {
127
+ vi.useRealTimers();
128
+ }
129
+ });
130
+
131
+ it('does nothing for a signed-out page', async () => {
132
+ const t = makeAuth();
133
+ t.store.clear();
134
+ await t.auth.init();
135
+ await t.reconnect();
136
+ await t.statementFails(REFUSED);
137
+
138
+ expect(t.authenticate).not.toHaveBeenCalled();
139
+ });
140
+ });
@@ -9,7 +9,8 @@ import type { Logger } from '../../services/logger/index';
9
9
  export * from './events/index';
10
10
  import { AuthEventTypes, createAuthEventSystem } from './events/index';
11
11
  import type { PersistenceClient } from '../../types';
12
- import { classifySyncError } from '../../utils/error-classification';
12
+ import { classifySyncError, isRefusedSessionError } from '../../utils/error-classification';
13
+ import { DatabaseEventTypes } from '../../services/database/events/index';
13
14
  import { encodeRecordId } from '../../utils/index';
14
15
  import { RecordId } from 'surrealdb';
15
16
  import {
@@ -53,6 +54,13 @@ 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. */
54
55
  export const SIGN_OUT_HOOK_TIMEOUT_MS = 2_000;
55
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;
63
+
56
64
  // Helper to pretty print types
57
65
  type Prettify<T> = {
58
66
  [K in keyof T]: T[K];
@@ -111,6 +119,11 @@ export class AuthService<S extends SchemaStructure> {
111
119
  private stopping: Promise<void> | null = null;
112
120
  /** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
113
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;
114
127
  private impersonationTimers = new ImpersonationTimers({
115
128
  current: () => this.impersonation,
116
129
  renew: () => this.renewImpersonation(),
@@ -141,6 +154,60 @@ export class AuthService<S extends SchemaStructure> {
141
154
 
142
155
  async init() {
143
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
+ });
144
211
  }
145
212
 
146
213
  getAccessDefinition<Name extends keyof S['access']>(name: Name): AccessDefinition | undefined {
@@ -58,14 +58,18 @@ function ownerGuard(): string {
58
58
 
59
59
  const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
60
60
 
61
- /** Just the `_00_query` block: the rest of the file needs tables and modules this test does not. */
61
+ /**
62
+ * Just the session guard (its rule's user branch asks it) and the `_00_query`
63
+ * block: the rest of the file needs tables and modules this test does not.
64
+ */
62
65
  function queryTableSchema(): string {
63
66
  const meta = cliSource('meta_tables_remote.surql');
67
+ const guard = meta.indexOf('-- SPOOKY SESSION GUARD');
64
68
  const start = meta.indexOf('DEFINE TABLE OVERWRITE _00_query SCHEMALESS');
65
69
  const end = meta.indexOf('-- SPOOKY LIST REF');
66
- if (start < 0 || end < start)
70
+ if (guard < 0 || start < guard || end < start)
67
71
  throw new Error('_00_query block not found in meta_tables_remote.surql');
68
- return meta.slice(start, end);
72
+ return meta.slice(guard, meta.indexOf('-- SPOOKY INCANTATION')) + meta.slice(start, end);
69
73
  }
70
74
 
71
75
  const ACCESS = `
@@ -0,0 +1,302 @@
1
+ import { Socket } from 'node:net';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
6
+ import { RecordId, Surreal } from 'surrealdb';
7
+ import { liveEndpoint } from '../live/index';
8
+ import { isRefusedSessionError } from '../utils/error-classification';
9
+ import { heartbeatBatch, heartbeatRowGone, queryRowCountSelect } from './sql';
10
+
11
+ /**
12
+ * The app's session guard (`sessionGuard` in sp00ky.yml, the SESSION GUARD
13
+ * block of `meta_tables_remote.surql`) ends sync for a WebSocket that is still
14
+ * open. SurrealDB checks a token only when a socket authenticates, so before
15
+ * the guard a socket that authenticated before a password reset kept reading
16
+ * its user's edges, heartbeating its views and registering new ones until it
17
+ * happened to reconnect.
18
+ *
19
+ * Runs the shipped guard, `_00_query` and `_00_list_ref` blocks, the
20
+ * `fn::query::*` functions and the per-user edge-table rule ssp-node defines,
21
+ * against a real SurrealDB, because the guarantee is the engine's evaluation
22
+ * of those rules, LIVE notifications included. Boot one first, e.g.:
23
+ * docker run -d --name spooky-surreal-it -p 18011:8000 \
24
+ * surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
25
+ *
26
+ * Override the endpoint with SURREAL_IT_ENDPOINT. Skips if unreachable.
27
+ */
28
+ const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
29
+ const NS = 'it';
30
+
31
+ /** The guard WhitePawn ships: a password reset stamps `session_epoch`. */
32
+ const GUARD = '$auth.session_epoch = NONE OR ($token.iat ?? 0) >= $auth.session_epoch';
33
+
34
+ const repoFile = (path: string) =>
35
+ readFileSync(fileURLToPath(new URL(`../../../../${path}`, import.meta.url)), 'utf8');
36
+ const cliSource = (file: string) => repoFile(`apps/cli/src/${file}`);
37
+
38
+ function slice(src: string, from: string, to: string): string {
39
+ const start = src.indexOf(from);
40
+ const end = src.indexOf(to, start);
41
+ if (start < 0 || end < start) throw new Error(`block ${from} .. ${to} not found`);
42
+ return src.slice(start, end);
43
+ }
44
+
45
+ /**
46
+ * The SESSION GUARD block, with `expr` put in the way `spky migrate` does
47
+ * (`schema_builder::apply_session_guard`, whose marker is read from the Rust
48
+ * source so the two cannot drift).
49
+ */
50
+ function guardSchema(expr?: string): string {
51
+ const block = slice(
52
+ cliSource('meta_tables_remote.surql'),
53
+ '-- SPOOKY SESSION GUARD',
54
+ '-- SPOOKY INCANTATION'
55
+ );
56
+ const m = /pub const SESSION_GUARD_DEFAULT: &str = "([^"]+)";/.exec(
57
+ repoFile('apps/cli/src/schema_builder.rs')
58
+ );
59
+ if (!m || !block.includes(m[1])) throw new Error('session guard marker not found');
60
+ return expr ? block.replace(m[1], `RETURN (${expr}) == true;`) : block;
61
+ }
62
+
63
+ /** Just the tables this test needs: the rest of the file needs modules. */
64
+ const queryTableSchema = () =>
65
+ slice(
66
+ cliSource('meta_tables_remote.surql'),
67
+ 'DEFINE TABLE OVERWRITE _00_query SCHEMALESS',
68
+ '-- SPOOKY LIST REF'
69
+ );
70
+ const listRefSchema = () =>
71
+ slice(
72
+ cliSource('meta_tables_remote.surql'),
73
+ 'DEFINE TABLE OVERWRITE _00_list_ref SCHEMALESS',
74
+ '-- SPOOKY STREAM PROCESSOR STATE'
75
+ );
76
+
77
+ /** The rule ssp-node puts on `_00_list_ref_user_<uid>` (`user_list_ref_ddl` in tables.rs). */
78
+ function userListRefTable(uid: string): string {
79
+ const m = /PERMISSIONS FOR select WHERE (\$auth\.id = user:\{uid\}[^,]*),/.exec(
80
+ repoFile('packages/ssp-node/src/tables.rs')
81
+ );
82
+ if (!m) throw new Error('per-user list_ref rule not found in ssp-node/src/tables.rs');
83
+ return `DEFINE TABLE OVERWRITE _00_list_ref_user_${uid} SCHEMALESS
84
+ PERMISSIONS FOR select WHERE ${m[1].replace('{uid}', uid)}, FOR create, update, delete WHERE false;`;
85
+ }
86
+
87
+ /** An app schema: a hidden `session_epoch` and an app table that asks the guard too. */
88
+ const APP = `
89
+ DEFINE TABLE OVERWRITE user SCHEMAFULL PERMISSIONS FOR select WHERE id = $auth.id;
90
+ DEFINE FIELD OVERWRITE name ON TABLE user TYPE string;
91
+ DEFINE FIELD OVERWRITE session_epoch ON TABLE user TYPE option<int>
92
+ PERMISSIONS FOR select WHERE false, FOR create, update WHERE false;
93
+ DEFINE ACCESS OVERWRITE account ON DATABASE TYPE RECORD
94
+ SIGNUP (CREATE ONLY type::record('user', $name) SET name = $name)
95
+ SIGNIN (SELECT * FROM user WHERE name = $name)
96
+ DURATION FOR TOKEN 1h, FOR SESSION 1h;
97
+ DEFINE TABLE OVERWRITE game SCHEMALESS
98
+ PERMISSIONS FOR select WHERE owner = $auth.id AND fn::sp00ky::session_live();
99
+ CREATE game:1 SET owner = user:alice;
100
+ `;
101
+
102
+ /** What the SSP writes, as root, when a session registers a view (`node.rs`, cold path). */
103
+ const SSP_REGISTER = `
104
+ UPSERT $id SET clientId = $sid, auth_id = 'user:alice', surql = 'SELECT * FROM game',
105
+ params = { auth: { id: 'user:alice' }, access: 'account' }, ttl = 1h, lastActiveAt = time::now(),
106
+ rowCount = 1, state = 'ready',
107
+ subscribers = array::append(
108
+ array::filter(subscribers ?? [], |$s| <string>$s.id != $sid AND <datetime>$s.seenAt + 1h > time::now()),
109
+ { id: $sid, seenAt: time::now() })`;
110
+
111
+ const viewId = () =>
112
+ new RecordId('_00_query', createHash('sha256').update(randomUUID()).digest('hex'));
113
+
114
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
115
+
116
+ describe('session guard ends sync for an open socket (real SurrealDB)', () => {
117
+ let root: Surreal;
118
+ let database = '';
119
+ let reachable = false;
120
+ const open: Surreal[] = [];
121
+
122
+ beforeAll(async () => {
123
+ const url = new URL(endpoint);
124
+ reachable = await new Promise<boolean>((resolve) => {
125
+ const socket = new Socket();
126
+ const done = (ok: boolean) => {
127
+ socket.destroy();
128
+ resolve(ok);
129
+ };
130
+ socket.setTimeout(1_000, () => done(false));
131
+ socket.once('error', () => done(false));
132
+ socket.connect(Number(url.port || 80), url.hostname, () => done(true));
133
+ });
134
+ });
135
+
136
+ /** Root, a fresh database, the app and sp00ky's rules with `guard` in place. */
137
+ async function boot(guard?: string): Promise<void> {
138
+ database = `session_guard_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
139
+ root = new Surreal();
140
+ await root.connect(endpoint, { reconnect: false });
141
+ await root.signin({ username: 'root', password: 'root' });
142
+ await root.use({ namespace: NS, database });
143
+ await root.query(guardSchema(guard));
144
+ await root.query(APP);
145
+ await root.query(queryTableSchema());
146
+ await root.query(listRefSchema());
147
+ await root.query(userListRefTable('alice'));
148
+ await root.query(cliSource('functions_remote.surql'));
149
+ // The guard throws before the post; a live session's post fails on this
150
+ // closed port, which is not a refused session.
151
+ await root.query(
152
+ cliSource('functions_remote_singlenode.surql')
153
+ .replaceAll('{{ENDPOINT}}', 'http://127.0.0.1:9')
154
+ .replaceAll('{{SECRET}}', 'it-secret')
155
+ );
156
+ }
157
+
158
+ afterEach(async () => {
159
+ if (!reachable || !root) return;
160
+ for (const db of open.splice(0)) await db.close().catch(() => undefined);
161
+ await root.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
162
+ await root.close();
163
+ });
164
+
165
+ /** Alice on a socket of her own, and that socket's `session::id()`. */
166
+ async function alice(how: 'signup' | 'signin'): Promise<[Surreal, string]> {
167
+ const db = new Surreal();
168
+ open.push(db);
169
+ await db.connect(endpoint, { reconnect: false });
170
+ await db.use({ namespace: NS, database });
171
+ await db[how]({ namespace: NS, database, access: 'account', variables: { name: 'alice' } });
172
+ const [sid] = (await db.query('RETURN <string>session::id()')) as [string];
173
+ return [db, sid];
174
+ }
175
+
176
+ /** What a password reset does: every token issued before now stops counting. */
177
+ async function resetPassword(stale: Surreal): Promise<void> {
178
+ const [iat] = (await stale.query('RETURN $token.iat')) as [number];
179
+ const epoch = iat + 1;
180
+ await root.query('UPDATE user:alice SET session_epoch = $epoch', { epoch });
181
+ // `iat` has second precision: the next sign-in must land at or past the epoch.
182
+ while (Date.now() / 1000 < epoch) await sleep(50);
183
+ }
184
+
185
+ /** LIVE on the per-user edge table; returns the notifications seen so far. */
186
+ async function watchEdges(db: Surreal): Promise<unknown[]> {
187
+ const seen: unknown[] = [];
188
+ const [id] = (await db.query('LIVE SELECT * FROM _00_list_ref_user_alice')) as [unknown];
189
+ const stream = await db.liveOf(id as never);
190
+ stream.subscribe((message: { action: string; value: unknown }) => {
191
+ if (message.action !== 'KILLED') seen.push(message.value);
192
+ });
193
+ return seen;
194
+ }
195
+
196
+ const edge = (n: number) =>
197
+ root.query(
198
+ `CREATE type::record('_00_list_ref_user_alice', $n) SET in = _00_query:q, out = game:1, version = $n`,
199
+ { n }
200
+ );
201
+
202
+ /** The client's register read-back, as `registerSelect` sends it. */
203
+ const readBack = async (db: Surreal, id: RecordId<string>) =>
204
+ ((await db.query(queryRowCountSelect(), { in: id })) as [unknown])[0] ?? null;
205
+
206
+ const heartbeatFound = async (db: Surreal, id: RecordId<string>) => {
207
+ const beat = heartbeatBatch([id]);
208
+ const [result] = (await db.query(beat.sql, beat.vars)) as [unknown];
209
+ return !heartbeatRowGone(result);
210
+ };
211
+
212
+ const registerError = async (db: Surreal): Promise<unknown> =>
213
+ db
214
+ .query('RETURN fn::query::register($config)', {
215
+ config: { id: viewId(), surql: 'SELECT * FROM game', params: {}, ttl: '1h' },
216
+ })
217
+ .then(
218
+ () => null,
219
+ (error: unknown) => error
220
+ );
221
+
222
+ it('a socket from before the reset stops receiving edges, a fresh sign-in keeps them', async (ctx) => {
223
+ if (!reachable) ctx.skip();
224
+ await boot(GUARD);
225
+ const [stale] = await alice('signup');
226
+ const staleSeen = await watchEdges(stale);
227
+ await edge(1);
228
+ await expect.poll(() => staleSeen.length).toBe(1);
229
+
230
+ await resetPassword(stale);
231
+ const [fresh] = await alice('signin');
232
+ const freshSeen = await watchEdges(fresh);
233
+ await edge(2);
234
+ await edge(3);
235
+
236
+ await expect.poll(() => freshSeen.length).toBe(2);
237
+ await sleep(200);
238
+ expect(staleSeen.length).toBe(1);
239
+ });
240
+
241
+ it('a socket from before the reset reads none of its user\'s edges, views or rows', async (ctx) => {
242
+ if (!reachable) ctx.skip();
243
+ await boot(GUARD);
244
+ const [stale, staleSid] = await alice('signup');
245
+ await edge(1);
246
+ await root.query(
247
+ `CREATE _00_list_ref:1 SET in = _00_query:q, out = game:1, clientId = 'x', auth_id = 'user:alice', version = 1`
248
+ );
249
+ await resetPassword(stale);
250
+ const [fresh, freshSid] = await alice('signin');
251
+ const q = viewId();
252
+ await root.query(SSP_REGISTER, { id: q, sid: staleSid });
253
+ await root.query(SSP_REGISTER, { id: q, sid: freshSid });
254
+
255
+ for (const table of ['_00_list_ref_user_alice', '_00_list_ref', 'game']) {
256
+ expect(await stale.query(`SELECT VALUE id FROM ${table}`), table).toEqual([[]]);
257
+ expect(((await fresh.query(`SELECT VALUE id FROM ${table}`)) as [unknown[]])[0], table).toHaveLength(1);
258
+ }
259
+ expect(await readBack(stale, q)).toBeNull();
260
+ expect(await heartbeatFound(stale, q)).toBe(false);
261
+ expect(await readBack(fresh, q)).toEqual({ rowCount: 1, state: 'ready' });
262
+ expect(await heartbeatFound(fresh, q)).toBe(true);
263
+ });
264
+
265
+ it('fn::query::register refuses the stale socket as a refused session, and only that one', async (ctx) => {
266
+ if (!reachable) ctx.skip();
267
+ await boot(GUARD);
268
+ const [stale] = await alice('signup');
269
+ expect(isRefusedSessionError(await registerError(stale))).toBe(false);
270
+
271
+ await resetPassword(stale);
272
+ const [fresh] = await alice('signin');
273
+
274
+ const refused = await registerError(stale);
275
+ expect(isRefusedSessionError(refused), String(refused)).toBe(true);
276
+ expect(isRefusedSessionError(await registerError(fresh))).toBe(false);
277
+ });
278
+
279
+ it('without a sessionGuard every session stays live, as before the guard', async (ctx) => {
280
+ if (!reachable) ctx.skip();
281
+ await boot();
282
+ const [stale, staleSid] = await alice('signup');
283
+ await edge(1);
284
+ const q = viewId();
285
+ await root.query(SSP_REGISTER, { id: q, sid: staleSid });
286
+ await resetPassword(stale);
287
+
288
+ expect(((await stale.query('SELECT VALUE id FROM _00_list_ref_user_alice')) as [unknown[]])[0]).toHaveLength(1);
289
+ expect(await heartbeatFound(stale, q)).toBe(true);
290
+ expect(isRefusedSessionError(await registerError(stale))).toBe(false);
291
+ });
292
+
293
+ it('a database without the probe row lets every registration through', async (ctx) => {
294
+ if (!reachable) ctx.skip();
295
+ await boot(GUARD);
296
+ const [stale] = await alice('signup');
297
+ await resetPassword(stale);
298
+ await root.query('DELETE _00_session_guard:probe');
299
+
300
+ expect(isRefusedSessionError(await registerError(stale))).toBe(false);
301
+ });
302
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { classifySyncError } from './error-classification';
2
+ import { classifySyncError, isRefusedSessionError } from './error-classification';
3
3
 
4
4
  describe('classifySyncError', () => {
5
5
  it('classifies surreal ConnectionUnavailableError as network', () => {
@@ -52,3 +52,21 @@ describe('classifySyncError', () => {
52
52
  expect(classifySyncError('boom')).toBe('application');
53
53
  });
54
54
  });
55
+
56
+ describe('isRefusedSessionError', () => {
57
+ it.each([
58
+ 'There was a problem with authentication',
59
+ 'An error occurred: There was a problem with authentication: this session has ended, sign in again',
60
+ 'The session has expired',
61
+ ])('takes %j as a refused session', (message) => {
62
+ expect(isRefusedSessionError(new Error(message))).toBe(true);
63
+ });
64
+
65
+ it.each([
66
+ 'There was a problem with the database: Invalid token',
67
+ 'There was a problem with the underlying connection',
68
+ 'Permission denied',
69
+ ])('does not take %j as one', (message) => {
70
+ expect(isRefusedSessionError(new Error(message))).toBe(false);
71
+ });
72
+ });
@@ -51,3 +51,15 @@ export function classifySyncError(error: unknown): 'network' | 'application' {
51
51
 
52
52
  return 'application';
53
53
  }
54
+
55
+ /**
56
+ * Whether the server refused the SESSION rather than the statement: its own
57
+ * wording for a token it will not accept ("There was a problem with
58
+ * authentication"), which is also what an app's AUTHENTICATE clause and
59
+ * sp00ky's session guard throw, and a session past its `DURATION FOR SESSION`.
60
+ */
61
+ export function isRefusedSessionError(error: unknown): boolean {
62
+ const message =
63
+ error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
64
+ return message.includes('problem with authentication') || message.includes('session has expired');
65
+ }