fix(ios): ios local access limit for self-hosted workspace (#15338)

## Summary
- Route iOS nbstore worker token reads through the main-thread
MessagePort and skip Capacitor Auth on `/socket.io` polling so self-host
WebSocket/XHR sync no longer hangs on Connect timeout.
- Harden workspace `flavour:id` routing, DocSyncPeer abort/status
handling, and `resetSync` so local selfhost edits push and Mac browsers
can receive them.
- Soften root-doc readiness waits and session-exchange throttling to
keep mobile selfhost sign-in/sync stable under retries.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **New Features**
* Improved workspace switching across local and remote environments,
preserving workspace type and server context.
  * Added support for page navigation with query parameters.
* Added iOS local-network permission messaging for self-hosted
workspaces.
* **Bug Fixes**
* Improved document synchronization, reset handling, prioritized
document refreshes, and retry behavior.
* Prevented authentication headers and refresh attempts for socket
connection requests.
* Improved workspace reopening and routing when multiple workspace types
share an ID.
  * Fixed handling of unlimited data query limits.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
keepClamDown
2026-08-24 09:33:58 +08:00
committed by GitHub
parent b530198a3b
commit cd6593c659
15 changed files with 448 additions and 78 deletions
@@ -96,6 +96,30 @@ describe('shouldRefreshAccessToken', () => {
});
describe('auth request fetch', () => {
test.each([
['/socket.io', true],
['/socket.io/', true],
['/socket.io/?EIO=4&transport=polling', true],
['/socket.ioevil', false],
])('handles the socket auth boundary for %s', async (path, skipped) => {
const provider = {
getValidAccessToken: vi.fn(async () => 'access-token'),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
};
const rawFetch = vi.fn<typeof fetch>(async request => {
expect((request as Request).headers.has('Authorization')).toBe(!skipped);
return new Response(JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }), {
status: 401,
headers: { 'content-type': 'application/json' },
});
});
await createAuthFetch(provider, rawFetch)(`https://example.com${path}`);
expect(provider.getValidAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1);
expect(provider.refreshAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1);
});
test('injects the endpoint token', async () => {
const provider = {
getValidAccessToken: vi.fn(async () => 'access-token'),
@@ -164,6 +188,30 @@ describe('auth request fetch', () => {
});
describe('auth request XMLHttpRequest', () => {
test.each([
['/socket.io', true],
['/socket.io/', true],
['/socket.io/?EIO=4&transport=polling', true],
['/socket.ioevil', false],
])('handles the socket auth boundary for %s', async (path, skipped) => {
const xhrCalls = stubXMLHttpRequest();
const provider = {
getValidAccessToken: vi.fn(async () => 'access-token'),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
};
installAuthRequestProxy(provider);
const xhr = new XMLHttpRequest();
xhr.open('GET', `https://example.com${path}`);
xhr.send();
await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledOnce());
xhrCalls.respond(401, JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }));
await Promise.resolve();
expect(provider.getValidAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1);
expect(provider.refreshAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1);
});
test('does not send after abort while waiting for a token', async () => {
let resolveToken: (token: string | null) => void = () => {};
const token = new Promise<string | null>(resolve => {
@@ -19,6 +19,30 @@ function authEndpointForUrl(url: string | URL) {
}
}
function shouldSkipStoredAuthToken(url: string | URL) {
try {
const { pathname } = new URL(
url,
globalThis.location?.origin ?? 'http://localhost'
);
if (pathname === '/socket.io' || pathname.startsWith('/socket.io/')) {
return true;
}
return [
'/api/auth/captcha',
'/api/auth/magic-link',
'/api/auth/open-app/sign-in',
'/api/auth/preflight',
'/api/auth/session/exchange',
'/api/auth/sign-in',
'/api/oauth/callback',
'/api/oauth/preflight',
].includes(pathname);
} catch {
return false;
}
}
export function createAuthFetch(
provider: AuthRequestProvider,
rawFetch: typeof globalThis.fetch
@@ -26,7 +50,8 @@ export function createAuthFetch(
return async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const retry = request.clone();
const endpoint = authEndpointForUrl(request.url);
const skipStoredAuth = shouldSkipStoredAuthToken(request.url);
const endpoint = skipStoredAuth ? null : authEndpointForUrl(request.url);
const token = endpoint
? await provider.getValidAccessToken(endpoint)
: null;
@@ -70,6 +95,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) {
private requestBody?: Document | XMLHttpRequestBodyInit | null;
private replaying = false;
private hasReplayed = false;
private skipStoredAuth = false;
private sendVersion = 0;
constructor() {
@@ -87,6 +113,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) {
this.status !== 401 ||
this.replaying ||
this.hasReplayed ||
this.skipStoredAuth ||
!this.request?.async
) {
return;
@@ -123,6 +150,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) {
this.requestBody = undefined;
this.replaying = false;
this.hasReplayed = false;
this.skipStoredAuth = shouldSkipStoredAuthToken(url.toString());
xhrRequestUrls.set(this, url.toString());
return super.open(
method,
@@ -141,9 +169,11 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) {
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
this.requestBody = body;
const requestUrl = xhrRequestUrls.get(this);
const endpoint = authEndpointForUrl(
requestUrl ?? globalThis.location.href
);
const targetUrl = requestUrl ?? globalThis.location.href;
this.skipStoredAuth = shouldSkipStoredAuthToken(targetUrl);
const endpoint = this.skipStoredAuth
? null
: authEndpointForUrl(targetUrl);
const sendVersion = this.sendVersion;
const sendWithToken = (token: string | null) => {
@@ -168,7 +198,7 @@ export function installAuthRequestProxy(provider: AuthRequestProvider) {
private async replayWithFreshToken() {
const request = this.request;
if (!request) return this.failReplay();
if (!request || this.skipStoredAuth) return this.failReplay();
const endpoint = authEndpointForUrl(request.url);
if (!endpoint) return this.failReplay();
const sendVersion = this.sendVersion;