Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions capacitor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
40 changes: 40 additions & 0 deletions docs/mobile-upload-device-smoke.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions electron/authNonce.cjs
Original file line number Diff line number Diff line change
@@ -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 }
28 changes: 10 additions & 18 deletions electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -410,33 +411,24 @@ const AUTH_DONE_HTML = `<!doctype 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
Expand Down
29 changes: 29 additions & 0 deletions scripts/deploy-production.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -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")"
Expand Down Expand Up @@ -85,6 +113,7 @@ rollback() {
}

if ! compose pull ||
! configure_r2_cors ||
! compose --profile tools run --rm migrate ||
! compose up -d --remove-orphans ||
! verify ||
Expand Down
49 changes: 49 additions & 0 deletions server/scripts/r2-cors-policy.mjs
Original file line number Diff line number Diff line change
@@ -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('; ')}`)
}
64 changes: 38 additions & 26 deletions server/scripts/r2-cors.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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.')
Loading
Loading