fix(core): selfhosted auth handling (#15295)

fix #15284
fix #15266
fix #15268
fix #15267

#### PR Dependency Tree


* **PR #15295** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
DarkSky
2026-07-21 11:26:15 +08:00
committed by GitHub
parent bb55d6fd21
commit b1abd8db54
17 changed files with 169 additions and 65 deletions
@@ -5,8 +5,7 @@ query getCurrentUser {
email
emailVerified
avatarUrl
token {
sessionToken
}
hasPassword
features
}
}
+2 -4
View File
@@ -1967,12 +1967,10 @@ export const getCurrentUserQuery = {
email
emailVerified
avatarUrl
token {
sessionToken
}
hasPassword
features
}
}`,
deprecations: ["'token' is deprecated: use auth session exchange instead"],
};
export const getDocCreatedByUpdatedByListQuery = {
+2 -1
View File
@@ -6519,7 +6519,8 @@ export type GetCurrentUserQuery = {
email: string;
emailVerified: boolean;
avatarUrl: string | null;
token: { __typename?: 'tokenType'; sessionToken: string | null };
hasPassword: boolean | null;
features: Array<FeatureType>;
} | null;
};
@@ -186,17 +186,27 @@ export abstract class AutoReconnectConnection<
return;
}
if (signal?.aborted) {
reject(signal.reason);
return;
}
const off = this.onStatusChanged(status => {
if (status === 'connected') {
resolve();
off();
cleanup();
}
});
signal?.addEventListener('abort', reason => {
reject(reason);
const onAbort = () => {
reject(signal?.reason);
cleanup();
};
const cleanup = () => {
off();
});
signal?.removeEventListener('abort', onAbort);
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
@@ -41,8 +41,9 @@ class FakeSocket {
}
}
const { resetSharedConnection } = vi.hoisted(() => ({
const { resetSharedConnection, waitForConnected } = vi.hoisted(() => ({
resetSharedConnection: vi.fn(),
waitForConnected: vi.fn(async () => {}),
}));
const socket = new FakeSocket();
@@ -56,7 +57,7 @@ vi.mock('../../impls/cloud/socket', () => ({
connect() {}
async waitForConnected() {}
waitForConnected = waitForConnected;
disconnect() {
socket.disconnected = true;
@@ -74,6 +75,8 @@ beforeEach(() => {
socket.connected = true;
socket.disconnected = false;
resetSharedConnection.mockClear();
waitForConnected.mockReset();
waitForConnected.mockResolvedValue(undefined);
});
test('getRealtimeInputKey is deterministic for realtime subscription inputs', () => {
@@ -157,7 +160,7 @@ test('non-bootstrap request still requires authenticated context', async () => {
);
});
test('request rejects when aborted', async () => {
test('request rejects when connection times out or is aborted', async () => {
const manager = new RealtimeManager();
manager.setContext({
endpoint: 'http://server',
@@ -175,6 +178,14 @@ test('request rejects when aborted', async () => {
controller.abort();
await expect(request).rejects.toThrow('Realtime request aborted');
waitForConnected.mockImplementationOnce(() => new Promise(() => {}));
await expect(
manager.request('notification.count.get', {}, { timeoutMs: 1 })
).rejects.toMatchObject({
name: 'RealtimeRequestTimeout',
message: 'Realtime request timed out: notification.count.get',
});
});
test('subscribe routes events by topic and stable input key', async () => {
+26 -18
View File
@@ -79,8 +79,8 @@ export class RealtimeManager {
input: RealtimeRequestInputOf<Op>,
options?: { timeoutMs?: number; signal?: AbortSignal }
): Promise<RealtimeRequestOutputOf<Op>> {
const socket = await this.connect(op === 'user.profile.get');
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT;
const connectAbort = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let abortHandler: (() => void) | undefined;
const abort = () => {
@@ -104,28 +104,36 @@ export class RealtimeManager {
options?.signal?.addEventListener('abort', abortHandler, { once: true });
});
const ack = await Promise.race([
socket.emitWithAck('realtime:request', {
op,
input,
clientVersion: BUILD_CONFIG.appVersion,
}),
timeout,
aborted,
]).finally(() => {
try {
const socket = await Promise.race([
this.connect(op === 'user.profile.get', connectAbort.signal),
timeout,
aborted,
]);
const ack = await Promise.race([
socket.emitWithAck('realtime:request', {
op,
input,
clientVersion: BUILD_CONFIG.appVersion,
}),
timeout,
aborted,
]);
if ('error' in ack) {
throw rejectAck(ack.error);
}
return ack.data as unknown as RealtimeRequestOutputOf<Op>;
} finally {
connectAbort.abort();
if (timeoutId) {
clearTimeout(timeoutId);
}
if (abortHandler) {
options?.signal?.removeEventListener('abort', abortHandler);
}
});
if ('error' in ack) {
throw rejectAck(ack.error);
}
return ack.data as unknown as RealtimeRequestOutputOf<Op>;
}
subscribe<Topic extends RealtimeTopicName>(
@@ -224,7 +232,7 @@ export class RealtimeManager {
};
}
private async connect(allowUnauthenticated = false) {
private async connect(allowUnauthenticated = false, signal?: AbortSignal) {
if (
!this.context?.endpoint ||
(!this.context.authenticated && !allowUnauthenticated)
@@ -245,7 +253,7 @@ export class RealtimeManager {
this.socketConnection.connect();
}
await this.socketConnection.waitForConnected();
await this.socketConnection.waitForConnected(signal);
this.socketConnection.inner.socket.off('realtime:event', this.handleEvent);
this.socketConnection.inner.socket.on('realtime:event', this.handleEvent);
this.socketConnection.inner.socket.off('connect', this.handleReconnect);