@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 +2 -2
- package/dist/index.js +73 -4
- package/dist/types.d.ts +34 -1
- package/package.json +3 -3
- package/src/modules/auth/auth.session-recheck.test.ts +140 -0
- package/src/modules/auth/index.ts +68 -1
- package/src/query/anon-query-isolation.integration.test.ts +7 -3
- package/src/query/session-guard.integration.test.ts +302 -0
- package/src/utils/error-classification.test.ts +19 -1
- package/src/utils/error-classification.ts +12 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as WebPushModule, A as RegistrationTimings, At as
|
|
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.
|
|
2192
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
+
}
|