feat(server): support multiple hosts in one deployment (#12950)

close CLOUD-233



#### PR Dependency Tree


* **PR #12950** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

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

* **New Features**
* Added support for configuring multiple server hosts across backend and
frontend settings.
* Enhanced deployment and Helm chart configuration to allow specifying
multiple ingress hosts.
* Updated admin and configuration interfaces to display and manage
multiple server hosts.

* **Improvements**
* Improved URL generation, OAuth, and worker service logic to
dynamically handle requests from multiple hosts.
  * Enhanced captcha verification to support multiple allowed hostnames.
* Updated frontend logic for platform-specific server base URLs and
allowed origins, including Apple app domains.
  * Expanded test coverage for multi-host scenarios.

* **Bug Fixes**
* Corrected backend logic to consistently use dynamic base URLs and
origins based on request host context.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fengmk2
2025-06-27 17:41:37 +08:00
committed by GitHub
parent 5c45c66ce8
commit dc55518c5b
21 changed files with 214 additions and 43 deletions

View File

@@ -540,6 +540,11 @@
"description": "Where the server get deployed(FQDN).\n@default \"localhost\"\n@environment `AFFINE_SERVER_HOST`", "description": "Where the server get deployed(FQDN).\n@default \"localhost\"\n@environment `AFFINE_SERVER_HOST`",
"default": "localhost" "default": "localhost"
}, },
"hosts": {
"type": "array",
"description": "Multiple hosts the server will accept requests from.\n@default []",
"default": []
},
"port": { "port": {
"type": "number", "type": "number",
"description": "Which port the server will listen on.\n@default 3010\n@environment `AFFINE_SERVER_PORT`", "description": "Which port the server will listen on.\n@default 3010\n@environment `AFFINE_SERVER_PORT`",

View File

@@ -126,7 +126,10 @@ const createHelmCommand = ({ isDryRun }) => {
? 'internal' ? 'internal'
: 'dev'; : 'dev';
const host = DEPLOY_HOST || CANARY_DEPLOY_HOST; const hosts = (DEPLOY_HOST || CANARY_DEPLOY_HOST)
.split(',')
.map(host => host.trim())
.filter(host => host);
const deployCommand = [ const deployCommand = [
`helm upgrade --install affine .github/helm/affine`, `helm upgrade --install affine .github/helm/affine`,
`--namespace ${namespace}`, `--namespace ${namespace}`,
@@ -135,7 +138,9 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-string global.app.buildType="${buildType}"`, `--set-string global.app.buildType="${buildType}"`,
`--set global.ingress.enabled=true`, `--set global.ingress.enabled=true`,
`--set-json global.ingress.annotations="{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${STATIC_IP_NAME}\\" }"`, `--set-json global.ingress.annotations="{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${STATIC_IP_NAME}\\" }"`,
`--set-string global.ingress.host="${host}"`, ...hosts.map(
(host, index) => `--set global.ingress.hosts[${index}]=${host}`
),
`--set-string global.version="${APP_VERSION}"`, `--set-string global.version="${APP_VERSION}"`,
...redisAndPostgres, ...redisAndPostgres,
...indexerOptions, ...indexerOptions,
@@ -143,14 +148,14 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-string web.image.tag="${imageTag}"`, `--set-string web.image.tag="${imageTag}"`,
`--set graphql.replicaCount=${replica.graphql}`, `--set graphql.replicaCount=${replica.graphql}`,
`--set-string graphql.image.tag="${imageTag}"`, `--set-string graphql.image.tag="${imageTag}"`,
`--set graphql.app.host=${host}`, `--set graphql.app.host=${hosts[0]}`,
`--set sync.replicaCount=${replica.sync}`, `--set sync.replicaCount=${replica.sync}`,
`--set-string sync.image.tag="${imageTag}"`, `--set-string sync.image.tag="${imageTag}"`,
`--set-string renderer.image.tag="${imageTag}"`, `--set-string renderer.image.tag="${imageTag}"`,
`--set renderer.app.host=${host}`, `--set renderer.app.host=${hosts[0]}`,
`--set renderer.replicaCount=${replica.renderer}`, `--set renderer.replicaCount=${replica.renderer}`,
`--set-string doc.image.tag="${imageTag}"`, `--set-string doc.image.tag="${imageTag}"`,
`--set doc.app.host=${host}`, `--set doc.app.host=${hosts[0]}`,
`--set doc.replicaCount=${replica.doc}`, `--set doc.replicaCount=${replica.doc}`,
...serviceAnnotations, ...serviceAnnotations,
...resources, ...resources,

View File

@@ -36,7 +36,8 @@ spec:
{{- end }} {{- end }}
{{- end }} {{- end }}
rules: rules:
- host: "{{ .Values.global.ingress.host }}" {{- range .Values.global.ingress.hosts }}
- host: {{ . | quote }}
http: http:
paths: paths:
- path: /socket.io - path: /socket.io
@@ -45,33 +46,34 @@ spec:
service: service:
name: affine-sync name: affine-sync
port: port:
number: {{ .Values.sync.service.port }} number: {{ $.Values.sync.service.port }}
- path: /graphql - path: /graphql
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: affine-graphql name: affine-graphql
port: port:
number: {{ .Values.graphql.service.port }} number: {{ $.Values.graphql.service.port }}
- path: /api - path: /api
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: affine-graphql name: affine-graphql
port: port:
number: {{ .Values.graphql.service.port }} number: {{ $.Values.graphql.service.port }}
- path: /workspace - path: /workspace
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: affine-renderer name: affine-renderer
port: port:
number: {{ .Values.renderer.service.port }} number: {{ $.Values.renderer.service.port }}
- path: / - path: /
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: affine-web name: affine-web
port: port:
number: {{ .Values.web.service.port }} number: {{ $.Values.web.service.port }}
{{- end }}
{{- end }} {{- end }}

View File

@@ -4,7 +4,13 @@ global:
ingress: ingress:
enabled: false enabled: false
className: '' className: ''
host: affine.pro # hosts for ingress rules
# e.g.
# hosts:
# - affine.pro
# - www.affine.pro
hosts:
- affine.pro
tls: [] tls: []
secret: secret:
secretName: 'server-private-key' secretName: 'server-private-key'

View File

@@ -15,6 +15,7 @@ const AFFINE_DOMAINS = [
'insider.affine.pro', // Beta/internal cloud domain 'insider.affine.pro', // Beta/internal cloud domain
'affine.fail', // Canary cloud domain 'affine.fail', // Canary cloud domain
'toeverything.app', // Safety measure for potential future use 'toeverything.app', // Safety measure for potential future use
'apple.getaffineapp.com', // Cloud domain for Apple app
]; ];
/** /**

View File

@@ -37,6 +37,10 @@ test.before(async t => {
}, },
}, },
}, },
server: {
hosts: ['localhost', 'test.affine.dev'],
https: true,
},
}), }),
AppModule, AppModule,
], ],
@@ -90,6 +94,38 @@ test("should be able to redirect to oauth provider's login page", async t => {
); );
}); });
test('should be able to redirect to oauth provider with multiple hosts', async t => {
const { app } = t.context;
const res = await app
.POST('/api/oauth/preflight')
.set('host', 'test.affine.dev')
.send({ provider: 'Google' })
.expect(HttpStatus.OK);
const { url } = res.body;
const redirect = new URL(url);
t.is(redirect.origin, 'https://accounts.google.com');
t.is(redirect.pathname, '/o/oauth2/v2/auth');
t.is(redirect.searchParams.get('client_id'), 'google-client-id');
t.is(
redirect.searchParams.get('redirect_uri'),
'https://test.affine.dev/oauth/callback'
);
t.is(redirect.searchParams.get('response_type'), 'code');
t.is(redirect.searchParams.get('prompt'), 'select_account');
t.truthy(redirect.searchParams.get('state'));
// state should be a json string
const state = JSON.parse(redirect.searchParams.get('state')!);
t.is(state.provider, 'Google');
t.regex(
state.state,
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
);
});
test('should be able to redirect to oauth provider with client_nonce', async t => { test('should be able to redirect to oauth provider with client_nonce', async t => {
const { app } = t.context; const { app } = t.context;

View File

@@ -8,6 +8,7 @@ import { ClsModule } from 'nestjs-cls';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { import {
getRequestFromHost,
getRequestIdFromHost, getRequestIdFromHost,
getRequestIdFromRequest, getRequestIdFromRequest,
ScannerModule, ScannerModule,
@@ -66,8 +67,9 @@ export const FunctionalityModules = [
// make every request has a unique id to tracing // make every request has a unique id to tracing
return getRequestIdFromRequest(req, 'http'); return getRequestIdFromRequest(req, 'http');
}, },
setup(cls, _req, res: Response) { setup(cls, req: Request, res: Response) {
res.setHeader('X-Request-Id', cls.getId()); res.setHeader('X-Request-Id', cls.getId());
cls.set(CLS_REQUEST_HOST, req.hostname);
}, },
}, },
// for websocket connection // for websocket connection
@@ -79,6 +81,10 @@ export const FunctionalityModules = [
// make every request has a unique id to tracing // make every request has a unique id to tracing
return getRequestIdFromHost(context); return getRequestIdFromHost(context);
}, },
setup(cls, context: ExecutionContext) {
const req = getRequestFromHost(context);
cls.set(CLS_REQUEST_HOST, req.hostname);
},
}, },
plugins: [ plugins: [
// https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter // https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter

View File

@@ -12,6 +12,7 @@ test.beforeEach(async t => {
server: { server: {
externalUrl: '', externalUrl: '',
host: 'app.affine.local', host: 'app.affine.local',
hosts: [],
port: 3010, port: 3010,
https: true, https: true,
path: '', path: '',
@@ -28,6 +29,7 @@ test('can factor base url correctly with specified external url', t => {
server: { server: {
externalUrl: 'https://external.domain.com', externalUrl: 'https://external.domain.com',
host: 'app.affine.local', host: 'app.affine.local',
hosts: [],
port: 3010, port: 3010,
https: true, https: true,
path: '/ignored', path: '/ignored',
@@ -42,6 +44,7 @@ test('can factor base url correctly with specified external url and path', t =>
server: { server: {
externalUrl: 'https://external.domain.com/anything', externalUrl: 'https://external.domain.com/anything',
host: 'app.affine.local', host: 'app.affine.local',
hosts: [],
port: 3010, port: 3010,
https: true, https: true,
path: '/ignored', path: '/ignored',
@@ -56,6 +59,7 @@ test('can factor base url correctly with specified external url with port', t =>
server: { server: {
externalUrl: 'https://external.domain.com:123', externalUrl: 'https://external.domain.com:123',
host: 'app.affine.local', host: 'app.affine.local',
hosts: [],
port: 3010, port: 3010,
https: true, https: true,
}, },
@@ -95,7 +99,7 @@ test('can safe redirect', t => {
function deny(to: string) { function deny(to: string) {
t.context.url.safeRedirect(res, to); t.context.url.safeRedirect(res, to);
t.true(spy.calledOnceWith(t.context.url.home)); t.true(spy.calledOnceWith(t.context.url.baseUrl));
spy.resetHistory(); spy.resetHistory();
} }
@@ -106,3 +110,38 @@ test('can safe redirect', t => {
].forEach(allow); ].forEach(allow);
['https://other.domain.com', 'a://invalid.uri'].forEach(deny); ['https://other.domain.com', 'a://invalid.uri'].forEach(deny);
}); });
test('can get request origin', t => {
t.is(t.context.url.requestOrigin, 'https://app.affine.local');
});
test('can get request base url', t => {
t.is(t.context.url.requestBaseUrl, 'https://app.affine.local');
});
test('can get request base url with multiple hosts', t => {
// mock cls
const cls = new Map<string, string>();
const url = new URLHelper(
{
server: {
externalUrl: '',
host: 'app.affine.local1',
hosts: ['app.affine.local1', 'app.affine.local2'],
port: 3010,
https: true,
path: '',
},
} as any,
cls as any
);
// no cls, use default origin
t.is(url.requestOrigin, 'https://app.affine.local1');
t.is(url.requestBaseUrl, 'https://app.affine.local1');
// set cls
cls.set(CLS_REQUEST_HOST, 'app.affine.local2');
t.is(url.requestOrigin, 'https://app.affine.local2');
t.is(url.requestBaseUrl, 'https://app.affine.local2');
});

View File

@@ -2,6 +2,7 @@ import { isIP } from 'node:net';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { Config } from '../config'; import { Config } from '../config';
import { OnEvent } from '../event'; import { OnEvent } from '../event';
@@ -11,10 +12,13 @@ export class URLHelper {
redirectAllowHosts!: string[]; redirectAllowHosts!: string[];
origin!: string; origin!: string;
allowedOrigins!: string[];
baseUrl!: string; baseUrl!: string;
home!: string;
constructor(private readonly config: Config) { constructor(
private readonly config: Config,
private readonly cls?: ClsService
) {
this.init(); this.init();
} }
@@ -34,19 +38,40 @@ export class URLHelper {
this.baseUrl = this.baseUrl =
externalUrl.origin + externalUrl.pathname.replace(/\/$/, ''); externalUrl.origin + externalUrl.pathname.replace(/\/$/, '');
} else { } else {
this.origin = [ this.origin = this.convertHostToOrigin(this.config.server.host);
this.config.server.https ? 'https' : 'http',
'://',
this.config.server.host,
this.config.server.host === 'localhost' || isIP(this.config.server.host)
? `:${this.config.server.port}`
: '',
].join('');
this.baseUrl = this.origin + this.config.server.path; this.baseUrl = this.origin + this.config.server.path;
} }
this.home = this.baseUrl;
this.redirectAllowHosts = [this.baseUrl]; this.redirectAllowHosts = [this.baseUrl];
this.allowedOrigins = [this.origin];
if (this.config.server.hosts.length > 0) {
for (const host of this.config.server.hosts) {
this.allowedOrigins.push(this.convertHostToOrigin(host));
}
}
}
get requestOrigin() {
if (this.config.server.hosts.length === 0) {
return this.origin;
}
// support multiple hosts
const requestHost = this.cls?.get<string | undefined>(CLS_REQUEST_HOST);
if (!requestHost || !this.config.server.hosts.includes(requestHost)) {
return this.origin;
}
return this.convertHostToOrigin(requestHost);
}
get requestBaseUrl() {
if (this.config.server.hosts.length === 0) {
return this.baseUrl;
}
return this.requestOrigin + this.config.server.path;
} }
stringify(query: Record<string, any>) { stringify(query: Record<string, any>) {
@@ -72,7 +97,7 @@ export class URLHelper {
} }
url(path: string, query: Record<string, any> = {}) { url(path: string, query: Record<string, any> = {}) {
const url = new URL(path, this.origin); const url = new URL(path, this.requestOrigin);
for (const key in query) { for (const key in query) {
url.searchParams.set(key, query[key]); url.searchParams.set(key, query[key]);
@@ -87,7 +112,7 @@ export class URLHelper {
safeRedirect(res: Response, to: string) { safeRedirect(res: Response, to: string) {
try { try {
const finalTo = new URL(decodeURIComponent(to), this.baseUrl); const finalTo = new URL(decodeURIComponent(to), this.requestBaseUrl);
for (const host of this.redirectAllowHosts) { for (const host of this.redirectAllowHosts) {
const hostURL = new URL(host); const hostURL = new URL(host);
@@ -103,7 +128,7 @@ export class URLHelper {
} }
// redirect to home if the url is invalid // redirect to home if the url is invalid
return res.redirect(this.home); return res.redirect(this.baseUrl);
} }
verify(url: string | URL) { verify(url: string | URL) {
@@ -118,4 +143,13 @@ export class URLHelper {
return false; return false;
} }
} }
private convertHostToOrigin(host: string) {
return [
this.config.server.https ? 'https' : 'http',
'://',
host,
host === 'localhost' || isIP(host) ? `:${this.config.server.port}` : '',
].join('');
}
} }

View File

@@ -12,6 +12,7 @@ declare global {
externalUrl?: string; externalUrl?: string;
https: boolean; https: boolean;
host: string; host: string;
hosts: ConfigItem<string[]>;
port: number; port: number;
path: string; path: string;
name?: string; name?: string;
@@ -52,6 +53,11 @@ Default to be \`[server.protocol]://[server.host][:server.port]\` if not specifi
default: 'localhost', default: 'localhost',
env: 'AFFINE_SERVER_HOST', env: 'AFFINE_SERVER_HOST',
}, },
hosts: {
desc: 'Multiple hosts the server will accept requests from.',
default: [],
shape: z.array(z.string()),
},
port: { port: {
desc: 'Which port the server will listen on.', desc: 'Which port the server will listen on.',
default: 3010, default: 3010,

View File

@@ -82,7 +82,7 @@ export class ServerConfigResolver {
? 'AFFiNE Beta Cloud' ? 'AFFiNE Beta Cloud'
: 'AFFiNE Cloud'), : 'AFFiNE Cloud'),
version: env.version, version: env.version,
baseUrl: this.url.home, baseUrl: this.url.requestBaseUrl,
type: env.DEPLOYMENT_TYPE, type: env.DEPLOYMENT_TYPE,
features: this.server.features, features: this.server.features,
}; };

View File

@@ -12,6 +12,8 @@ declare global {
var readEnv: <T>(key: string, defaultValue: T, availableValues?: T[]) => T; var readEnv: <T>(key: string, defaultValue: T, availableValues?: T[]) => T;
// oxlint-disable-next-line no-var // oxlint-disable-next-line no-var
var CUSTOM_CONFIG_PATH: string; var CUSTOM_CONFIG_PATH: string;
// oxlint-disable-next-line no-var
var CLS_REQUEST_HOST: 'CLS_REQUEST_HOST';
} }
} }
@@ -53,6 +55,7 @@ export type AppEnv = {
version: string; version: string;
}; };
globalThis.CLS_REQUEST_HOST = 'CLS_REQUEST_HOST';
globalThis.CUSTOM_CONFIG_PATH = join(homedir(), '.affine/config'); globalThis.CUSTOM_CONFIG_PATH = join(homedir(), '.affine/config');
globalThis.readEnv = function readEnv<T>( globalThis.readEnv = function readEnv<T>(
env: string, env: string,

View File

@@ -56,13 +56,26 @@ export class CaptchaService {
body: formData, body: formData,
method: 'POST', method: 'POST',
}); });
const outcome: any = await result.json(); const outcome = (await result.json()) as {
success: boolean;
hostname: string;
};
return ( if (!outcome.success) return false;
!!outcome.success &&
// skip hostname check in dev mode // skip hostname check in dev mode
(env.dev || outcome.hostname === this.config.server.host) if (env.dev) return true;
// check if the hostname is in the hosts
if (this.config.server.hosts.includes(outcome.hostname)) return true;
// check if the hostname is in the host
if (this.config.server.host === outcome.hostname) return true;
this.logger.warn(
`Captcha verification failed for hostname: ${outcome.hostname}`
); );
return false;
} }
private async verifyChallengeResponse(response: any, resource: string) { private async verifyChallengeResponse(response: any, resource: string) {

View File

@@ -142,7 +142,7 @@ export class OAuthController {
provider: rawState.provider, provider: rawState.provider,
}) })
); );
clientUrl.searchParams.set('server', this.url.origin); clientUrl.searchParams.set('server', this.url.requestOrigin);
return res.redirect( return res.redirect(
this.url.link('/open-app/url?', { this.url.link('/open-app/url?', {

View File

@@ -56,7 +56,7 @@ export class WorkerController {
this.logger.error('Invalid Origin', 'ERROR', { origin, referer }); this.logger.error('Invalid Origin', 'ERROR', { origin, referer });
throw new BadRequest('Invalid header'); throw new BadRequest('Invalid header');
} }
const url = new URL(req.url, this.url.baseUrl); const url = new URL(req.url, this.url.requestBaseUrl);
const imageURL = url.searchParams.get('url'); const imageURL = url.searchParams.get('url');
if (!imageURL) { if (!imageURL) {
throw new BadRequest('Missing "url" parameter'); throw new BadRequest('Missing "url" parameter');

View File

@@ -5,7 +5,7 @@ import { fixUrl, OriginRules } from './utils';
@Injectable() @Injectable()
export class WorkerService { export class WorkerService {
allowedOrigins: OriginRules = [this.url.origin]; allowedOrigins: OriginRules = [...this.url.allowedOrigins];
constructor( constructor(
private readonly config: Config, private readonly config: Config,
@@ -18,7 +18,7 @@ export class WorkerService {
...this.config.worker.allowedOrigin ...this.config.worker.allowedOrigin
.map(u => fixUrl(u)?.origin as string) .map(u => fixUrl(u)?.origin as string)
.filter(v => !!v), .filter(v => !!v),
this.url.origin, ...this.url.allowedOrigins,
]; ];
} }

View File

@@ -66,5 +66,5 @@ export async function run() {
logger.log(`AFFiNE Server is running in [${env.DEPLOYMENT_TYPE}] mode`); logger.log(`AFFiNE Server is running in [${env.DEPLOYMENT_TYPE}] mode`);
logger.log(`Listening on http://${listeningHost}:${config.server.port}`); logger.log(`Listening on http://${listeningHost}:${config.server.port}`);
logger.log(`And the public server should be recognized as ${url.home}`); logger.log(`And the public server should be recognized as ${url.baseUrl}`);
} }

View File

@@ -171,6 +171,10 @@
"desc": "Where the server get deployed(FQDN).", "desc": "Where the server get deployed(FQDN).",
"env": "AFFINE_SERVER_HOST" "env": "AFFINE_SERVER_HOST"
}, },
"hosts": {
"type": "Array",
"desc": "Multiple hosts the server will accept requests from."
},
"port": { "port": {
"type": "Number", "type": "Number",
"desc": "Which port the server will listen on.", "desc": "Which port the server will listen on.",

View File

@@ -48,7 +48,7 @@ export const KNOWN_CONFIG_GROUPS = [
{ {
name: 'Server', name: 'Server',
module: 'server', module: 'server',
fields: ['externalUrl', 'name'], fields: ['externalUrl', 'name', 'hosts'],
} as ConfigGroup<'server'>, } as ConfigGroup<'server'>,
{ {
name: 'Auth', name: 'Auth',

View File

@@ -63,7 +63,11 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
? [ ? [
{ {
id: 'affine-cloud', id: 'affine-cloud',
baseUrl: 'https://app.affine.pro', baseUrl: BUILD_CONFIG.isNative
? BUILD_CONFIG.isIOS
? 'https://apple.getaffineapp.com'
: 'https://app.affine.pro'
: location.origin,
config: { config: {
serverName: 'Affine Cloud', serverName: 'Affine Cloud',
features: [ features: [
@@ -91,7 +95,11 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
? [ ? [
{ {
id: 'affine-cloud', id: 'affine-cloud',
baseUrl: 'https://insider.affine.pro', baseUrl: BUILD_CONFIG.isNative
? BUILD_CONFIG.isIOS
? 'https://apple.getaffineapp.com'
: 'https://insider.affine.pro'
: location.origin,
config: { config: {
serverName: 'Affine Cloud', serverName: 'Affine Cloud',
features: [ features: [
@@ -147,7 +155,9 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
? [ ? [
{ {
id: 'affine-cloud', id: 'affine-cloud',
baseUrl: 'https://affine.fail', baseUrl: BUILD_CONFIG.isNative
? 'https://affine.fail'
: location.origin,
config: { config: {
serverName: 'Affine Cloud', serverName: 'Affine Cloud',
features: [ features: [

View File

@@ -8,6 +8,7 @@ function maybeAffineOrigin(origin: string, baseUrl: string) {
return ( return (
origin.startsWith('file://') || origin.startsWith('file://') ||
origin.endsWith('affine.pro') || // stable/beta origin.endsWith('affine.pro') || // stable/beta
origin.endsWith('apple.getaffineapp.com') || // stable/beta
origin.endsWith('affine.fail') || // canary origin.endsWith('affine.fail') || // canary
origin === baseUrl // localhost or self-hosted origin === baseUrl // localhost or self-hosted
); );