Skip to content

Commit 732ef18

Browse files
feat(account-link): redirect-based connect handshake for self-hosted linking (#7494)
Links a self-hosted instance to a SaaS team over an ordinary redirect, and leaves the admin's browser holding a Stirling session at the same time. ## The problem A self-hosted server needs a device credential bound to a SaaS team, and the admin's Supabase JWT must never reach the instance backend. Three things ruled out the obvious approaches: - **A customer hostname can never be in Supabase's redirect allow-list**, so the sign-in cannot happen on the instance's own origin. That is why SSO and sign-up did not work for linking at all. - **A device credential identifies a server, not a person.** Every attended portal read (Usage, Billing, Documents, Infrastructure) goes through `getPortalSaasToken()` and needs a *user* session, so a credential-only link left all of them asking for a second sign-in. - **The previous design relayed a JWT** from the browser into the instance, which is the thing we wanted to avoid. That path is deleted here. ## The solution Redirect and nonce, modelled on desktop's `authService.loginWithSelfHostedOAuth`: mint a nonce, hand the browser off, accept only a callback carrying that nonce back. Desktop has the OS route the reply; self-hosted has no OS hop, so our own approval page performs it. That is the point — the human half happens on an origin we control. ``` instance SaaS admin's browser | POST connect/request | | | (name, callback, nonce, | | | claim-secret hash) | | |-------------------------->| | | <- requestId + authorizeUrl | | | GET /link?request=... | | |<-------------------------------| | | sign in (SSO works here), | | | see ACCOUNT + ORIGIN, approve | | |------------------------------->| | | 302 callback#nonce+session | | POST connect/claim | | | (requestId, claim secret)| | |-------------------------->| | | <- device credential | | ``` Four properties carry the safety, and each is stated in the code because each is easy to lose in a refactor: - **The redirect target is never caller-supplied.** Validated once at creation, then read back from the stored row, so nothing in the approval page's URL can steer the token elsewhere. - **Approval and minting are separate.** Approval records the team and hands out nothing usable; the credential is minted only on claim, authenticated by a secret that never entered a browser. - **A re-authentication cannot move a server between teams.** The team is pinned at creation from the credential only that instance holds, so an approver from another team gets `WRONG_TEAM` instead of a rebind. - **The approver has to confirm what they are binding.** The page shows the address and the signed-in account, with a way to switch, and a checkbox naming the address gates the approve button. The name the server reports is deliberately not shown: the requester picks it on an unauthenticated endpoint, and its honest value is the hostname already in the address. The session rides the URL fragment, so it stays out of access logs and `Referer`, and is stripped before anything awaits. The claim is row-locked, so one approval mints once. A request lives 30 minutes; a settled one is not offered again, since approving it fails server-side. Signing in mid-flow no longer loses the request. The id is kept on the SaaS origin and resumed after any sign-in, which is what makes creating an account work: the confirmation email opens a new tab, where the `next` parameter is gone. Reading it does not consume it — the request may be open in two tabs — and only a recorded decision retires it. The result lands as a modal over the portal the admin started from, and the portal re-reads its link status so the page behind agrees with the modal. Plaintext `http://` callbacks are accepted rather than refused, because many self-hosted instances legitimately run plain HTTP on a private network; the address carries a warning icon explaining the risk, derived server-side so a requester cannot suppress it. Hard-refusing `http://` to a public IP literal is a reasonable follow-up; a bare hostname can't be classified without a DNS lookup, so the warning stays the general mechanism. ## Configuration Four surfaces. Placeholders below, not values. **SaaS backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | The connect controller and service are `@ConditionalOnProperty` with no default, so without it the endpoints do not exist. | | `system.frontendUrl` | Only when the approval page is not on the API's own origin | Where the approver is sent. Must include the app's base path if it is served under one, or the redirect misses `/link`. | **SaaS frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Its own sign-in. Must be the project the SaaS backend validates tokens against. | | `RUN_SUBPATH` | Only if served under a subpath | Moves the approval page to `<base>/<subpath>/link`, so `system.frontendUrl` has to agree. | **Self-hosted backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | Defaults to `false`. | | `stirling.billing.account-link.saas-base-url` | Yes | Origin of the SaaS API it links to. Not the SaaS frontend. | | `system.frontendUrl` | Optional | Externally reachable base URL for the callback. Otherwise derived from the request's `Origin`, which is right for ordinary deployments and wrong behind a rewriting proxy. | **Self-hosted frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Accepts the session handed over in the callback fragment. | | `VITE_SAAS_API_URL` | For Usage and Billing | Attended reads go to the SaaS API with the admin's token. Absent, those surfaces stay on the mock. | | `VITE_INCLUDE_PORTAL` | Production builds | Dev builds include the portal automatically; without it there is no link UI and no callback route. | Two things worth stating because neither fails loudly: - **Both frontends must use the URL *and* key of the same Supabase project**, and the same one the SaaS backend validates against. A key from one project with a URL from another is accepted by the browser and rejected by Supabase, which surfaces much later as "session expired" on Usage rather than as an error at hand-over. - **The Supabase redirect allow-list must contain the SaaS app's `/auth/callback`**, since a confirmation email returns through it. Entries are matched exactly. - **`system.frontendUrl` is the existing setting for this**, not a new one, so each side reads its own value and there is nothing extra to configure. It also gates share links, so on a stack with storage and sharing already on, setting it here turns those on too. The self-hosted side deliberately does **not** configure where the approval page lives — SaaS answers that in the connect-request reply, being the only party that knows. Also here, because testing this needs two stacks side by side: `linked:staging` / `linked:dev` (which derive `system.frontendUrl` and `RUN_SUBPATH` themselves), the missing `frontend:staging:saas`, and a per-mode vite `cacheDir` — two dev servers in different modes otherwise re-optimise over one shared dep cache. ## How to test Automated and green: `task frontend:check:all` plus both backend modules. `ConnectRequestServiceTest` covers callback validation, the per-IP cap, single-use approval, claim outcomes, expiry, `WRONG_TEAM` and reauth confirming without minting; `ConnectServiceTest` covers callback-resolution precedence including a foreign-origin callback being discarded; `ConnectControllerTest` covers the authorize URL, including the forwarded-header path and only the first hop being trusted; `ConnectCallback.test.tsx` covers the fragment being stripped synchronously and malformed fragments refused; `LinkAccountModal.test.tsx` covers link and reauth hitting different endpoints. Manual walkthrough: 1. `task linked:staging` — added here; brings up a SaaS stack and a self-hosted instance pointed at it, on discovered ports, and prints the four addresses. 2. Open the link-account modal in the self-hosted portal and continue. Expect the SaaS approval page at `/link?request=<id>`. 3. Sign in as a team leader, or create an account and confirm the email. Either way you should come back to the approval page. 4. Tick the acknowledgement and approve. Expect the fragment gone from the address bar immediately, a result modal over the portal, the portal showing linked without a reload, and attended reads (Usage, Billing) working without a second sign-in. 5. Repeat, approving as a member of a different team. Expect a refusal, not a rebind. ## Outstanding - #7415 to be reworked against this design once this lands. - **No SaaS-side UI to disconnect a server.** `GET /account-link/instances` and `POST /account-link/instances/{id}/revoke` are already team-scoped and leader-gated, and the portal has a panel that uses them, but `portal-saas/components/settings/accountLinkSettings.tsx` exports `null` on the reasoning that "SaaS has no account-link concept". That held when linking was a self-hosted admin managing their own instance; here a leader approves a server they may not administer, and has no way to withdraw it. The seam to fill is that one file. Expected to land with the CTA work in #7415. --------- Co-authored-by: James Brunton <jbrunton96@gmail.com>
1 parent f7a2c62 commit 732ef18

78 files changed

Lines changed: 4512 additions & 841 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.taskfiles/backend.yml

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,15 @@ tasks:
4040
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
4141
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
4242
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
43+
# Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing
44+
# and cannot blank the committed default.
45+
ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}'
4346
env:
4447
SERVER_PORT: '{{.PORT}}'
4548
cmds:
46-
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
49+
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
4750
platforms: [windows]
48-
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
51+
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun'
4952
platforms: [linux, darwin]
5053

5154
dev:bundled:
@@ -84,6 +87,8 @@ tasks:
8487
AIENGINE_URL: '{{.AIENGINE_URL}}'
8588
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
8689
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
90+
APP_BASE_URL: '{{.APP_BASE_URL}}'
91+
BASE_PATH: '{{.BASE_PATH}}'
8792

8893
staging:saas:
8994
desc: "Start SaaS backend against the shared v3 staging project"
@@ -95,10 +100,47 @@ tasks:
95100
AIENGINE_URL: '{{.AIENGINE_URL}}'
96101
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
97102
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
103+
APP_BASE_URL: '{{.APP_BASE_URL}}'
104+
BASE_PATH: '{{.BASE_PATH}}'
105+
106+
dev:linked:
107+
desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)"
108+
ignore_error: true
109+
vars:
110+
PORT: '{{.PORT | default "8080"}}'
111+
SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}'
112+
cmds:
113+
- 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"'
114+
# The two backends run different STIRLING_FLAVOURs, which are different Gradle
115+
# project graphs sharing one build/ tree. Waiting avoids overlapping builds; it
116+
# does not make the sharing safe, so avoid rebuilding one while the other runs.
117+
- cmd: |
118+
n=0
119+
while [ "$n" -lt 150 ]; do
120+
if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then
121+
echo ">> SaaS backend is up, starting self-hosted"
122+
break
123+
fi
124+
n=$((n + 1))
125+
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
126+
done
127+
if [ "$n" -ge 150 ]; then
128+
echo ">> SaaS backend never answered; starting anyway"
129+
fi
130+
- task: dev:proprietary
131+
vars:
132+
PORT: '{{.PORT}}'
133+
ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}'
98134

99135
_run:saas:
100136
internal: true
101-
dotenv: ['app/.env.saas.local', 'app/.env.saas']
137+
# The frontend files are here only for RUN_SUBPATH, which the authorize URL needs.
138+
# Last, because dotenv is set-if-absent: app/* still decides everything else.
139+
dotenv:
140+
- 'app/.env.saas.local'
141+
- 'app/.env.saas'
142+
- 'frontend/editor/.env.saas.local'
143+
- 'frontend/editor/.env.saas'
102144
ignore_error: true
103145
vars:
104146
PORT: '{{.PORT | default "8080"}}'
@@ -111,12 +153,29 @@ tasks:
111153
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
112154
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
113155
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
156+
# Empty is the same as unset: the property defaults to empty and is blank-checked.
157+
APP_BASE_URL: '{{.APP_BASE_URL | default ""}}'
158+
# Relocates configs/pipeline/logs, for a second backend in the same directory.
159+
# Empty is the same as unset: the reader blank-checks it.
160+
BASE_PATH: '{{.BASE_PATH | default ""}}'
114161
env:
115162
SERVER_PORT: '{{.PORT}}'
116163
STIRLING_FLAVOR: saas
164+
STIRLING_BASE_PATH: '{{.BASE_PATH}}'
117165
AIENGINE_URL: '{{.AIENGINE_URL}}'
118166
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
119167
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
168+
# Appends RUN_SUBPATH: the approval page is at <base>/link, so a subpath build
169+
# serves it at <base>/app/link. An explicit value still wins.
170+
SYSTEM_FRONTENDURL:
171+
sh: |
172+
if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then
173+
echo "${SYSTEM_FRONTENDURL}"
174+
elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then
175+
echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}"
176+
else
177+
echo "{{.APP_BASE_URL}}"
178+
fi
120179
cmds:
121180
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
122181
# against SAAS_DB_* (production).

.taskfiles/frontend.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,17 @@ tasks:
121121
sh: |
122122
case "${SAAS_ENV:-dev}" in
123123
staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
124-
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
124+
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
125125
esac
126126
echo "https://${ref}.supabase.co"
127127
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
128128
sh: |
129129
case "${SAAS_ENV:-dev}" in
130130
staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
131-
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
131+
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
132132
esac
133133
cmds:
134-
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
134+
- 'echo ">> frontend {{.SAAS_ENV}}: Supabase $VITE_SUPABASE_URL, backend $BACKEND_URL"'
135135
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
136136

137137
dev:
@@ -173,6 +173,16 @@ tasks:
173173
OPEN: '{{.OPEN}}'
174174
SAAS_ENV: '{{.SAAS_ENV}}'
175175

176+
staging:saas:
177+
desc: "Start frontend dev server against the shared v3 staging project"
178+
cmds:
179+
- task: dev:saas
180+
vars:
181+
SAAS_ENV: staging
182+
PORT: '{{.PORT}}'
183+
BACKEND_URL: '{{.BACKEND_URL}}'
184+
OPEN: '{{.OPEN}}'
185+
176186
dev:desktop:
177187
desc: "Start frontend dev server in desktop mode"
178188
deps:

Taskfile.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,92 @@ tasks:
121121
cmds:
122122
- task: dev:_all
123123

124+
# No engine: linking never calls it.
125+
linked:staging:
126+
desc: "SaaS on the shared v3 project + a self-hosted instance linked to it"
127+
cmds:
128+
- task: linked:_all
129+
vars: { SAAS_ENV: staging }
130+
131+
linked:dev:
132+
desc: "SaaS on the current PR's preview branch + a self-hosted instance linked to it"
133+
cmds:
134+
- task: linked:_all
135+
vars: { SAAS_ENV: dev }
136+
137+
linked:_all:
138+
internal: true
139+
vars:
140+
SAAS_ENV: '{{.SAAS_ENV | default "staging"}}'
141+
PORTS:
142+
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8081 5174 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8081 5174 8080 5173{{end}}'
143+
SAAS_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
144+
SAAS_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
145+
APP_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 2}}'
146+
APP_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 3}}'
147+
deps:
148+
# APP_BASE_URL is the SaaS *frontend*: the approval page is served by vite, not
149+
# by the API. BASE_PATH moves this backend's configs/pipeline aside so it does not
150+
# race the self-hosted one, which keeps ./configs and its existing database.
151+
- task: 'backend:{{.SAAS_ENV}}:saas'
152+
vars:
153+
PORT: '{{.SAAS_BACKEND_PORT}}'
154+
APP_BASE_URL: 'http://localhost:{{.SAAS_FRONTEND_PORT}}'
155+
BASE_PATH: 'tmp/linked-saas'
156+
- task: frontend:dev:saas
157+
vars:
158+
PORT: '{{.SAAS_FRONTEND_PORT}}'
159+
BACKEND_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
160+
SAAS_ENV: '{{.SAAS_ENV}}'
161+
- task: backend:dev:linked
162+
vars:
163+
PORT: '{{.APP_BACKEND_PORT}}'
164+
SAAS_BASE_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
165+
- task: frontend:dev:proprietary
166+
vars:
167+
PORT: '{{.APP_FRONTEND_PORT}}'
168+
BACKEND_URL: 'http://localhost:{{.APP_BACKEND_PORT}}'
169+
OPEN: "true"
170+
- task: linked:_ready
171+
vars:
172+
SAAS_BACKEND_PORT: '{{.SAAS_BACKEND_PORT}}'
173+
SAAS_FRONTEND_PORT: '{{.SAAS_FRONTEND_PORT}}'
174+
APP_BACKEND_PORT: '{{.APP_BACKEND_PORT}}'
175+
APP_FRONTEND_PORT: '{{.APP_FRONTEND_PORT}}'
176+
177+
# Waits for all four to answer, then prints where they landed.
178+
linked:_ready:
179+
internal: true
180+
cmds:
181+
- cmd: |
182+
n=0
183+
ok=0
184+
while [ "$n" -lt 150 ]; do
185+
ok=1
186+
for u in "http://localhost:{{.SAAS_BACKEND_PORT}}" \
187+
"http://localhost:{{.SAAS_FRONTEND_PORT}}" \
188+
"http://localhost:{{.APP_BACKEND_PORT}}" \
189+
"http://localhost:{{.APP_FRONTEND_PORT}}"; do
190+
# Not -o /dev/null: Windows curl.exe treats it as a real path and exits 23.
191+
curl -s -m 2 "$u" >/dev/null 2>&1 || ok=0
192+
done
193+
if [ "$ok" = 1 ]; then break; fi
194+
n=$((n + 1))
195+
# `sleep` is a binary, not a builtin, and Windows has none.
196+
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
197+
done
198+
echo ""
199+
if [ "$ok" = 1 ]; then
200+
echo ">> all four answering"
201+
else
202+
echo ">> still waiting on one or more after 5 minutes; addresses below anyway"
203+
fi
204+
echo ">> self-hosted UI http://localhost:{{.APP_FRONTEND_PORT}}/processor"
205+
echo ">> self-hosted api http://localhost:{{.APP_BACKEND_PORT}}"
206+
echo ">> saas UI http://localhost:{{.SAAS_FRONTEND_PORT}}"
207+
echo ">> saas api http://localhost:{{.SAAS_BACKEND_PORT}}"
208+
echo ""
209+
124210
dev:_all:
125211
internal: true
126212
vars:

app/core/src/main/resources/settings.yml.template

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ system:
186186
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
187187
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
188188
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
189-
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
189+
frontendUrl: "" # Base URL of the web app, as a browser reaches it (e.g. 'https://app.example.com', or 'https://example.com/app' if served under a base path). Optional - if not set, will use backendUrl. Used for any link handed to a browser: invite emails, share links, mobile QR codes, and the account-link handshake.
190190
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
191191
enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured.
192192
mobileScannerSettings:

0 commit comments

Comments
 (0)