diff --git a/capacitor.config.ts b/capacitor.config.ts
index cc5b546d..6c21ff8f 100644
--- a/capacitor.config.ts
+++ b/capacitor.config.ts
@@ -40,14 +40,12 @@ const config: CapacitorConfig = {
allowMixedContent: false,
},
plugins: {
- // Patch window.fetch + XMLHttpRequest to route through Swift's
- // URLSession. The WKWebView's origin is `capacitor://localhost`,
- // which CORS-blocks calls to the build-time configured HTTPS API unless
- // the server explicitly allows that origin. Routing through the native layer sidesteps
- // CORS entirely — the request reaches the API the same way curl
- // would, no browser preflight involved.
+ // Do not patch window.fetch globally. LingxiLoop JSON requests explicitly
+ // use CapacitorHttp (src/api/transport.ts), while presigned File/Blob PUTs
+ // stay in the WebView fetch implementation and never cross the JS/native
+ // bridge as a duplicated binary payload.
CapacitorHttp: {
- enabled: true,
+ enabled: false,
},
SplashScreen: {
// Hide as soon as the WebView is ready — the launch storyboard
diff --git a/docker-compose.production.yml b/docker-compose.production.yml
index d63a1bd7..8d1850a4 100644
--- a/docker-compose.production.yml
+++ b/docker-compose.production.yml
@@ -139,6 +139,15 @@ services:
redis:
condition: service_healthy
+ # Deployment-only reconciler. When R2 is configured, deploy-production.sh
+ # runs this before the application cutover and the script reads the bucket
+ # policy back, failing the deployment if mobile WebView PUT origins are absent.
+ r2-cors:
+ <<: *lingxiloop-runtime
+ profiles: [tools]
+ restart: "no"
+ command: ["node", "server/scripts/r2-cors.mjs"]
+
lingxiloop:
<<: *lingxiloop-runtime
restart: unless-stopped
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
index 34d5ecac..73faabb8 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -25,5 +25,13 @@ OS, and verifies `/api/meta`, dependency health, authenticated channel access
and the release version. Rollback restores both pre-cutover backups and the
previous digest manifest. It never re-enables a retired runtime.
+When all four core `R2_*` secrets are configured, the production deployment
+also reconciles the bucket CORS policy before application cutover. The
+deployment image applies the policy and reads it back, requiring presigned
+`PUT` permission for the production web origin plus Electron, iOS
+(`capacitor://localhost`), and Android (`https://localhost`) renderer origins.
+Partial R2 configuration or a failed readback aborts the deployment. Operators
+can add comma-separated origins with `R2_CORS_EXTRA_ORIGINS` in `.env.secrets`.
+
Desktop artifacts contain only the renderer and Electron shell. Package
verification rejects server/runtime source and environment files.
diff --git a/docs/mobile-upload-device-smoke.md b/docs/mobile-upload-device-smoke.md
new file mode 100644
index 00000000..b28a3808
--- /dev/null
+++ b/docs/mobile-upload-device-smoke.md
@@ -0,0 +1,40 @@
+# Mobile presigned-upload acceptance
+
+This smoke test exercises the released mobile data path that unit tests cannot:
+
+```text
+authenticated app -> /uploads/capabilities -> /uploads/presign
+ -> WebView fetch(File) PUT -> R2
+```
+
+It must run against a deployment where `presignSupported` is true. The
+production deployment reconciles and reads back the target bucket CORS policy
+before cutover; a device run then proves the WebView origin and binary path.
+
+## Build the instrumented app
+
+The acceptance panel is opt-in and excluded from ordinary startup:
+
+```sh
+VITE_MOBILE_UPLOAD_SMOKE=1 npm run mobile:sync
+```
+
+Open the iOS or Android project, install it on a physical device, sign in, and
+select a representative 24 MiB file in the panel at the bottom of the app. This
+stays just below LingxiLoop's 25 MiB attachment ceiling while exercising the
+largest supported upload path. Keep Xcode Instruments (Allocations) or Android Studio Memory Profiler
+recording from before file selection until at least ten seconds after upload.
+
+Run once on each platform and attach the panel's `[mobile-upload-smoke] RESULT`
+object plus the profiler screenshot/export to the pull request. Acceptance
+requires:
+
+- `status: passed`, an R2 object key, and no CORS/preflight error;
+- the app remains responsive and is not terminated by the OS;
+- peak process memory does not show a second file-sized allocation attributable
+ to a JavaScript/native HTTP bridge copy;
+- a normal build without `VITE_MOBILE_UPLOAD_SMOKE=1` shows no smoke panel.
+
+Record device model, OS version, build commit, file size, elapsed time, baseline
+memory, peak memory, and post-upload memory. JavaScript heap fields may be
+`null` on iOS; the platform profiler is authoritative for peak process memory.
diff --git a/electron/authNonce.cjs b/electron/authNonce.cjs
new file mode 100644
index 00000000..9bb1143e
--- /dev/null
+++ b/electron/authNonce.cjs
@@ -0,0 +1,35 @@
+/* eslint-env node */
+
+function createAuthNonceGuard({ randomBytes, timingSafeEqual, now = Date.now, ttlMs }) {
+ let armedNonce = null
+ let armedExpiry = 0
+
+ return {
+ arm() {
+ armedNonce = randomBytes(16).toString('hex')
+ armedExpiry = now() + ttlMs
+ return armedNonce
+ },
+ consume(nonce) {
+ if (!armedNonce) return false
+ if (now() > armedExpiry) {
+ armedNonce = null
+ armedExpiry = 0
+ return false
+ }
+ if (typeof nonce !== 'string' || nonce.length !== armedNonce.length) return false
+ let matches = false
+ try {
+ matches = timingSafeEqual(Buffer.from(nonce), Buffer.from(armedNonce))
+ } catch {
+ return false
+ }
+ if (!matches) return false
+ armedNonce = null
+ armedExpiry = 0
+ return true
+ },
+ }
+}
+
+module.exports = { createAuthNonceGuard }
diff --git a/electron/main.cjs b/electron/main.cjs
index 9b3835ae..fbf55840 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -6,6 +6,7 @@ const http = require('node:http')
const crypto = require('node:crypto')
const { pathToFileURL } = require('node:url')
const autoUpdater = require('./autoUpdater.cjs')
+const { createAuthNonceGuard } = require('./authNonce.cjs')
const isDev = !app.isPackaged
const DEV_URL = process.env.ELECTRON_RENDERER_URL || 'http://localhost:5180'
@@ -410,33 +411,24 @@ const AUTH_DONE_HTML = `
// process finds no armed nonce and is (correctly) dropped — they just sign in
// again from the now-running app. That rare edge is the accepted cost of not
// persisting a bearer-handoff credential to disk.
-let armedAuthNonce = null
-let armedAuthExpiry = 0
const AUTH_NONCE_TTL_MS = 10 * 60 * 1000
+const authNonceGuard = createAuthNonceGuard({
+ randomBytes: crypto.randomBytes,
+ timingSafeEqual: crypto.timingSafeEqual,
+ ttlMs: AUTH_NONCE_TTL_MS,
+})
/** Arm for one sign-in and return a fresh nonce the renderer appends to the
* OAuth return URL. Supersedes any previous unused nonce. */
function armAuthHandoff() {
- armedAuthNonce = crypto.randomBytes(16).toString('hex')
- armedAuthExpiry = Date.now() + AUTH_NONCE_TTL_MS
- return armedAuthNonce
+ return authNonceGuard.arm()
}
/** Validate + single-use-consume an inbound nonce. Constant-time compare so a
- * mismatch leaks nothing; clears the armed nonce on any check so a token can
- * be accepted at most once. */
+ * mismatch leaks nothing; invalid callbacks preserve the current attempt,
+ * while a match or expiry clears it. */
function consumeAuthNonce(nonce) {
- const armed = armedAuthNonce
- const expiry = armedAuthExpiry
- armedAuthNonce = null
- armedAuthExpiry = 0
- if (!armed || Date.now() > expiry) return false
- if (typeof nonce !== 'string' || nonce.length !== armed.length) return false
- try {
- return crypto.timingSafeEqual(Buffer.from(nonce), Buffer.from(armed))
- } catch {
- return false
- }
+ return authNonceGuard.consume(nonce)
}
/** Pull token + companyId + nonce out of a `lingxiloop://auth#token=…` URL. The OS
diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh
index 1081b621..f378cd74 100755
--- a/scripts/deploy-production.sh
+++ b/scripts/deploy-production.sh
@@ -25,6 +25,25 @@ for secret in OPEN_NOTEBOOK_PASSWORD OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_
exit 2
fi
done
+
+r2_present=0
+r2_missing=""
+for secret in R2_ENDPOINT R2_BUCKET R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY; do
+ if grep -Eq "^${secret}=.+" .env.secrets; then
+ r2_present=$((r2_present + 1))
+ else
+ r2_missing="${r2_missing} ${secret}"
+ fi
+done
+if [ "$r2_present" -gt 0 ] && [ "$r2_present" -lt 4 ]; then
+ echo "Incomplete R2 production configuration; missing:${r2_missing}" >&2
+ exit 2
+fi
+if [ "$r2_present" -eq 4 ]; then
+ r2_configured=true
+else
+ r2_configured=false
+fi
if ! grep -Eq '^OPEN_NOTEBOOK_CHAT_MODEL=.+' .env.secrets &&
{ ! grep -Eq '^OPEN_NOTEBOOK_STRATEGY_MODEL=.+' .env.secrets ||
! grep -Eq '^OPEN_NOTEBOOK_ANSWER_MODEL=.+' .env.secrets ||
@@ -54,6 +73,15 @@ compose() {
docker compose --env-file "$active_env" --env-file .env.secrets -f "$compose_file" "$@"
}
+configure_r2_cors() {
+ if [ "$r2_configured" = "true" ]; then
+ echo "Applying and verifying R2 CORS policy"
+ compose --profile tools run --rm --no-deps r2-cors
+ else
+ echo "R2 is not configured; skipping bucket CORS reconciliation"
+ fi
+}
+
verify() {
expected_sha="$(sed -n 's/^LINGXILOOP_COMMIT_SHA=//p' "$active_env")"
expected_version="$(sed -n 's/^LINGXILOOP_VERSION=//p' "$active_env")"
@@ -85,6 +113,7 @@ rollback() {
}
if ! compose pull ||
+ ! configure_r2_cors ||
! compose --profile tools run --rm migrate ||
! compose up -d --remove-orphans ||
! verify ||
diff --git a/server/scripts/r2-cors-policy.mjs b/server/scripts/r2-cors-policy.mjs
new file mode 100644
index 00000000..85cdd31b
--- /dev/null
+++ b/server/scripts/r2-cors-policy.mjs
@@ -0,0 +1,49 @@
+export const DEFAULT_R2_CORS_ORIGINS = [
+ 'http://localhost:5173',
+ 'http://localhost:5180',
+ 'app://lingxiloop',
+ 'capacitor://localhost',
+ 'https://localhost',
+]
+
+export function uniqueOrigins(extraOrigins = []) {
+ return [...new Set([
+ ...DEFAULT_R2_CORS_ORIGINS,
+ ...extraOrigins.map((origin) => origin.trim().replace(/\/+$/, '')).filter(Boolean),
+ ])]
+}
+
+export function buildR2CorsRules(origins) {
+ return [
+ {
+ AllowedOrigins: origins,
+ AllowedMethods: ['PUT', 'GET', 'HEAD'],
+ AllowedHeaders: ['*'],
+ ExposeHeaders: ['ETag'],
+ MaxAgeSeconds: 3600,
+ },
+ ]
+}
+
+function ruleAllowsHeader(rule, header) {
+ const allowed = (rule.AllowedHeaders ?? []).map((value) => value.toLowerCase())
+ return allowed.includes('*') || allowed.includes(header.toLowerCase())
+}
+
+/** Return actionable errors when a read-back policy cannot serve presigned PUTs. */
+export function validateR2CorsRules(rules, requiredOrigins) {
+ const errors = []
+ for (const origin of requiredOrigins) {
+ const compatible = (rules ?? []).some((rule) =>
+ (rule.AllowedOrigins ?? []).includes(origin) &&
+ (rule.AllowedMethods ?? []).some((method) => method.toUpperCase() === 'PUT') &&
+ ruleAllowsHeader(rule, 'content-type'))
+ if (!compatible) errors.push(`missing presigned PUT permission for origin ${origin}`)
+ }
+ return errors
+}
+
+export function assertR2CorsRules(rules, requiredOrigins) {
+ const errors = validateR2CorsRules(rules, requiredOrigins)
+ if (errors.length > 0) throw new Error(`R2 CORS readback verification failed: ${errors.join('; ')}`)
+}
diff --git a/server/scripts/r2-cors.mjs b/server/scripts/r2-cors.mjs
index dfa6c3dd..5d6ab294 100644
--- a/server/scripts/r2-cors.mjs
+++ b/server/scripts/r2-cors.mjs
@@ -29,26 +29,36 @@
* Inspect the current policy without changing anything:
*
* node server/scripts/r2-cors.mjs --print
+ *
+ * Verify that the current policy supports every required origin:
+ *
+ * node server/scripts/r2-cors.mjs --check
*/
import 'dotenv/config'
import { S3Client, PutBucketCorsCommand, GetBucketCorsCommand } from '@aws-sdk/client-s3'
+import {
+ assertR2CorsRules,
+ buildR2CorsRules,
+ uniqueOrigins,
+} from './r2-cors-policy.mjs'
// Origins that must be allowed to PUT directly to R2. Keep these in sync
// with how each surface loads its renderer:
// - http://localhost:5173 → browser Vite dev (vite.config.ts `port`)
// - http://localhost:5180 → Electron dev renderer (electron/main.cjs DEV_URL)
// - app://lingxiloop → packaged Electron (main.cjs loadURL app://lingxiloop/...)
+// - capacitor://localhost → iOS Capacitor WebView
+// - https://localhost → Android Capacitor WebView
// Extra origins (prod web, alternate ports, …) come from CLI args.
-const DEFAULT_ORIGINS = [
- 'http://localhost:5173',
- 'http://localhost:5180',
- 'app://lingxiloop',
-]
-
const cliArgs = process.argv.slice(2)
const printOnly = cliArgs.includes('--print')
-const extraOrigins = cliArgs.filter((a) => !a.startsWith('--'))
-const origins = [...new Set([...DEFAULT_ORIGINS, ...extraOrigins])]
+const checkOnly = cliArgs.includes('--check')
+const environmentOrigins = [
+ process.env.LINGXILOOP_PUBLIC_ORIGIN ?? '',
+ ...(process.env.R2_CORS_EXTRA_ORIGINS ?? '').split(','),
+]
+const extraOrigins = [...environmentOrigins, ...cliArgs.filter((a) => !a.startsWith('--'))]
+const origins = uniqueOrigins(extraOrigins)
const { R2_ENDPOINT, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY } = process.env
const missing = [
@@ -70,42 +80,44 @@ const client = new S3Client({
credentials: { accessKeyId: R2_ACCESS_KEY_ID, secretAccessKey: R2_SECRET_ACCESS_KEY },
})
-async function printCurrent(label) {
+async function readCurrent(label) {
try {
const cur = await client.send(new GetBucketCorsCommand({ Bucket: R2_BUCKET }))
- console.log(`[r2-cors] ${label}:`, JSON.stringify(cur.CORSRules ?? [], null, 2))
+ const rules = cur.CORSRules ?? []
+ console.log(`[r2-cors] ${label}:`, JSON.stringify(rules, null, 2))
+ return rules
} catch (e) {
// R2 returns NoSuchCORSConfiguration when nothing is set yet.
- if (e?.name === 'NoSuchCORSConfiguration') console.log(`[r2-cors] ${label}: (none set)`)
+ if (e?.name === 'NoSuchCORSConfiguration') {
+ console.log(`[r2-cors] ${label}: (none set)`)
+ return []
+ }
else throw e
}
}
if (printOnly) {
- await printCurrent(`current CORS for ${R2_BUCKET}`)
+ await readCurrent(`current CORS for ${R2_BUCKET}`)
+ process.exit(0)
+}
+
+if (checkOnly) {
+ const rules = await readCurrent(`current CORS for ${R2_BUCKET}`)
+ assertR2CorsRules(rules, origins)
+ console.log(`[r2-cors] verified presigned PUT policy for ${origins.length} origins`)
process.exit(0)
}
await client.send(new PutBucketCorsCommand({
Bucket: R2_BUCKET,
CORSConfiguration: {
- CORSRules: [
- {
- AllowedOrigins: origins,
- // PUT is what the browser upload does. GET/HEAD are harmless and
- // cover any future cross-origin presigned read.
- AllowedMethods: ['PUT', 'GET', 'HEAD'],
- // "*" so the preflight's Access-Control-Request-Headers (Content-Type,
- // and anything we add later) always passes.
- AllowedHeaders: ['*'],
- ExposeHeaders: ['ETag'],
- MaxAgeSeconds: 3600,
- },
- ],
+ CORSRules: buildR2CorsRules(origins),
},
}))
console.log(`[r2-cors] ✅ applied to bucket "${R2_BUCKET}" for origins:`)
for (const o of origins) console.log(` - ${o}`)
-await printCurrent('readback')
+const readback = await readCurrent('readback')
+assertR2CorsRules(readback, origins)
+console.log(`[r2-cors] verified presigned PUT policy for ${origins.length} origins`)
console.log('[r2-cors] done — no server restart needed; just retry the upload.')
diff --git a/server/src/__tests__/r2-cors-deployment.test.ts b/server/src/__tests__/r2-cors-deployment.test.ts
new file mode 100644
index 00000000..25b09c35
--- /dev/null
+++ b/server/src/__tests__/r2-cors-deployment.test.ts
@@ -0,0 +1,47 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+// @ts-expect-error The production helper is plain ESM so Node can execute it directly.
+import * as r2CorsPolicy from '../../scripts/r2-cors-policy.mjs'
+
+const {
+ assertR2CorsRules,
+ buildR2CorsRules,
+ DEFAULT_R2_CORS_ORIGINS,
+ uniqueOrigins,
+ validateR2CorsRules,
+} = r2CorsPolicy
+
+test('R2 policy covers iOS and Android presigned PUT preflights', () => {
+ const origins = uniqueOrigins(['https://loop.example.com/'])
+ const rules = buildR2CorsRules(origins)
+
+ assert.ok(origins.includes('capacitor://localhost'))
+ assert.ok(origins.includes('https://localhost'))
+ assert.ok(origins.includes('https://loop.example.com'))
+ assert.deepEqual(validateR2CorsRules(rules, origins), [])
+ assert.doesNotThrow(() => assertR2CorsRules(rules, origins))
+})
+
+test('R2 readback validation rejects a policy without mobile PUT access', () => {
+ const webOnly = buildR2CorsRules(['https://loop.example.com'])
+ const errors = validateR2CorsRules(webOnly, DEFAULT_R2_CORS_ORIGINS)
+
+ assert.ok(errors.some((error: string) => error.includes('capacitor://localhost')))
+ assert.ok(errors.some((error: string) => error.includes('https://localhost')))
+ assert.throws(
+ () => assertR2CorsRules(webOnly, DEFAULT_R2_CORS_ORIGINS),
+ /R2 CORS readback verification failed/,
+ )
+})
+
+test('production deployment applies and verifies R2 CORS before cutover', () => {
+ const compose = readFileSync(new URL('../../../docker-compose.production.yml', import.meta.url), 'utf8')
+ const deploy = readFileSync(new URL('../../../scripts/deploy-production.sh', import.meta.url), 'utf8')
+ const cors = readFileSync(new URL('../../scripts/r2-cors.mjs', import.meta.url), 'utf8')
+
+ assert.match(compose, /\n {2}r2-cors:\n[\s\S]*command: \["node", "server\/scripts\/r2-cors\.mjs"\]/)
+ assert.match(deploy, /! configure_r2_cors \|\|\n\s+! compose --profile tools run --rm migrate/)
+ assert.match(deploy, /compose --profile tools run --rm --no-deps r2-cors/)
+ assert.match(cors, /assertR2CorsRules\(readback, origins\)/)
+})
diff --git a/src/App.tsx b/src/App.tsx
index 83bee936..c721eac3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,4 @@
-import { useEffect, useState } from 'react'
-import { AdminApp } from '@/admin/AdminApp'
+import { lazy, Suspense, useEffect, useState } from 'react'
import { consumeSuspendedFragment, SuspendedScreen } from '@/admin/SuspendedScreen'
import { consumeWaitlistFragment, WaitlistConfirmedScreen } from '@/admin/WaitlistConfirmedScreen'
import { api } from '@/api/client'
@@ -12,14 +11,10 @@ import {
InviteAcceptScreen,
} from '@/components/InviteAcceptScreen'
import { NotificationToasts } from '@/components/NotificationToasts'
-import { NotificationWindow } from '@/components/NotificationWindow'
import { UpdateBanner, UpdaterDialog } from '@/components/UpdaterDialog'
-import { DesktopApp } from '@/desktop/DesktopApp'
import { seedMockIm } from '@/dev/mockIm'
import { isMockImDevelopment } from '@/lib/devMode'
-import { isNotificationWindow } from '@/lib/runtime'
import { useIsMobile } from '@/lib/utils'
-import { MobileApp } from '@/mobile/MobileApp'
import { useApp } from '@/stores/app'
import { useAuth } from '@/stores/auth'
import { bootConversations, isMuted, useConversations } from '@/stores/conversations'
@@ -27,7 +22,14 @@ import { bootMessagesStream, useMessages } from '@/stores/messages'
import { bootParticipants } from '@/stores/participants'
import { usePrefs } from '@/stores/preferences'
import { bootWhispers, useWhispers } from '@/stores/whispers'
-import '@/admin/admin.css'
+
+const AdminApp = lazy(() => import('@/admin/AdminApp').then((module) => ({ default: module.AdminApp })))
+const DesktopApp = lazy(() => import('@/desktop/DesktopApp').then((module) => ({ default: module.DesktopApp })))
+const MobileApp = lazy(() => import('@/mobile/MobileApp').then((module) => ({ default: module.MobileApp })))
+
+function SurfaceFallback() {
+ return
Loading…
+}
/** True iff this browser tab is for the admin panel. An optional `admin.*`
* hostname or the `/admin` path prefix triggers it. We check both so dev can hit
@@ -95,7 +97,9 @@ function AuthedApp({ mockMode = false }: { mockMode?: boolean }) {
return (
<>
- {isMobile ? : }
+ }>
+ {isMobile ? : }
+
{/* In-app message toasts (window-blur / different-convo only) —
rendered at the AuthedApp level so they share auth context and
unmount cleanly on sign-out. */}
@@ -107,12 +111,6 @@ function AuthedApp({ mockMode = false }: { mockMode?: boolean }) {
}
export function App() {
- // The Electron notification BrowserWindow loads this same React bundle
- // with a `#notifications` hash. Bypass everything else (auth, stores,
- // routing) and just render the toast stack — it receives payloads over
- // IPC from the main window.
- if (isNotificationWindow) return
-
// Local Vite development opens straight into a deterministic IM workspace.
// `?api=1` remains available for explicitly testing the real auth/API stack.
if (isMockImDevelopment() && !isAdminContext()) {
@@ -178,7 +176,7 @@ export function App() {
return (
-
+ }>
)
diff --git a/src/admin/AdminApp.tsx b/src/admin/AdminApp.tsx
index 934a3e81..c785b53c 100644
--- a/src/admin/AdminApp.tsx
+++ b/src/admin/AdminApp.tsx
@@ -15,6 +15,7 @@
* the basePath is `/admin`.
*/
import { useEffect, useState } from 'react'
+import './admin.css'
import { CloudLogo } from '@/components/Avatar'
import { useAuth } from '@/stores/auth'
import { type AdminStats, adminApi } from './api'
diff --git a/src/admin/SuspendedScreen.tsx b/src/admin/SuspendedScreen.tsx
index 547a063b..9b47100b 100644
--- a/src/admin/SuspendedScreen.tsx
+++ b/src/admin/SuspendedScreen.tsx
@@ -14,6 +14,7 @@
* the next time they try to sign in.
*/
import { useState } from 'react'
+import './auth-state.css'
interface CarriedSuspension { email: string | null; reason: string | null }
@@ -56,7 +57,7 @@ export function SuspendedScreen({ email, reason }: { email: string | null; reaso
如果您认为这是一个错误,请回复我们发送给您的最新消息,或者
联系您的工作区所有者。
-