From f8e4cfe136d251f49958d6e16322722d9a579907 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Fri, 23 Oct 2026 17:23:51 -0400
Subject: [PATCH 1/6] feat(device-pairing): pair phones and browsers with a QR
code
Settings -> Account gets "Connect a device", a paired-devices list, and a
remote access headers editor, so the mobile app and other browsers can sign
in without typing a password and pass a reverse proxy such as Cloudflare
Access.
- one-time pairing codes (3 min TTL) minted by a Better Auth plugin and
redeemed into a new session that records the device name
- POST /api/device-pairing/links builds the ocm://link app link with the
stored headers as h= parameters and a browser /pair link; plain-HTTP and
loopback origins are blocked or warned about
- remote access headers are stored encrypted, returned by name only, and
validated (8 headers, 512-char values, 1800 encoded characters, reserved
transport names blocked); a Cloudflare Access service token preset fills
CF-Access-Client-Id and CF-Access-Client-Secret
- login offers "Sign in with QR code" and /pair redeems a scanned code,
returning to the login redirect target
- revoking a device deletes its session, closes its live connections and
removes its push subscriptions; the current session cannot be revoked
---
backend/src/auth/device-pairing.ts | 170 +++++++
backend/src/auth/index.ts | 14 +
.../202610221800-session-device-name.ts | 20 +
...2610231200-push-subscription-session-id.ts | 14 +
backend/src/db/migrations/index.ts | 4 +
backend/src/db/push-subscriptions.ts | 32 ++
backend/src/db/secrets.ts | 4 +
backend/src/index.ts | 8 +
backend/src/routes/device-pairing.ts | 73 +++
backend/src/routes/notifications.ts | 11 +-
.../src/routes/opencode-server-terminals.ts | 7 +-
backend/src/routes/remote-access-headers.ts | 41 ++
backend/src/routes/repo-terminal-socket.ts | 7 +-
backend/src/routes/settings.ts | 3 +
backend/src/routes/sse.ts | 22 +-
backend/src/services/device-pairing-links.ts | 63 +++
backend/src/services/notification.ts | 35 +-
backend/src/services/preview/gateway.ts | 5 +-
backend/src/services/remote-access-headers.ts | 104 +++++
backend/src/services/session-connections.ts | 41 ++
backend/src/utils/websocket-bridge.ts | 19 +
backend/test/auth/device-pairing.test.ts | 424 ++++++++++++++++++
backend/test/auth/index.test.ts | 14 +-
.../test/auth/session-delete-hooks.test.ts | 174 +++++++
...-subscription-session-id-migration.test.ts | 73 +++
backend/test/helpers/auth-database.ts | 13 +
backend/test/helpers/log-assertions.ts | 24 +
backend/test/helpers/remote-access-headers.ts | 11 +
backend/test/helpers/websocket-server.ts | 2 +-
backend/test/routes/device-pairing.test.ts | 260 +++++++++++
backend/test/routes/notifications.test.ts | 35 ++
.../opencode-server-terminal-socket.test.ts | 56 +++
.../test/routes/remote-access-headers.test.ts | 220 +++++++++
.../test/routes/repo-terminal-socket.test.ts | 99 ++++
backend/test/routes/settings.test.ts | 9 +
backend/test/routes/sse.test.ts | 88 ++++
.../notification-subscriptions.test.ts | 123 +++++
backend/test/services/preview/gateway.test.ts | 19 +
.../test/services/session-connections.test.ts | 101 +++++
.../shared/device-pairing-schemas.test.ts | 230 ++++++++++
backend/test/shared/manager-app-link.test.ts | 111 +++++
backend/test/shared/trusted-origin.test.ts | 38 ++
backend/test/shared/url-hosts.test.ts | 50 +++
backend/test/shared/user-agent.test.ts | 50 +++
frontend/package.json | 1 +
frontend/src/App.tsx | 7 +-
frontend/src/api/devicePairing.ts | 80 ++++
.../components/settings/AccountSettings.tsx | 9 +
.../settings/ConnectDeviceDialog.test.tsx | 234 ++++++++++
.../settings/ConnectDeviceDialog.tsx | 216 +++++++++
.../settings/ConnectDeviceSettings.test.tsx | 82 ++++
.../settings/ConnectDeviceSettings.tsx | 56 +++
.../settings/OpenCodeServerDialog.test.tsx | 23 +
.../settings/OpenCodeServerDialog.tsx | 24 +-
.../settings/PairedDevicesSettings.test.tsx | 115 +++++
.../settings/PairedDevicesSettings.tsx | 113 +++++
.../settings/PairingQrCode.test.tsx | 22 +
.../src/components/settings/PairingQrCode.tsx | 51 +++
.../src/components/settings/QrCodeScanner.tsx | 38 +-
.../RemoteAccessHeadersSettings.test.tsx | 173 +++++++
.../settings/RemoteAccessHeadersSettings.tsx | 296 ++++++++++++
.../components/settings/SettingsDialog.tsx | 2 +-
frontend/src/contexts/AuthContext.test.tsx | 121 ++++-
frontend/src/contexts/AuthContext.tsx | 26 ++
frontend/src/hooks/useDevicePairing.ts | 81 ++++
frontend/src/lib/auth-client.ts | 4 +-
frontend/src/pages/Login.tsx | 49 +-
frontend/src/pages/Pair.tsx | 109 +++++
frontend/src/pages/__tests__/Login.test.tsx | 130 ++++++
frontend/src/pages/__tests__/Pair.test.tsx | 117 +++++
pnpm-lock.yaml | 3 +
shared/src/config/env.ts | 11 +
shared/src/schemas/device-pairing.ts | 204 +++++++++
shared/src/schemas/index.ts | 1 +
shared/src/schemas/opencode-servers.ts | 105 ++++-
shared/src/schemas/settings.ts | 7 +
shared/src/utils/index.ts | 1 +
shared/src/utils/url.ts | 44 ++
shared/src/utils/user-agent.ts | 32 ++
79 files changed, 5398 insertions(+), 110 deletions(-)
create mode 100644 backend/src/auth/device-pairing.ts
create mode 100644 backend/src/db/migrations/202610221800-session-device-name.ts
create mode 100644 backend/src/db/migrations/202610231200-push-subscription-session-id.ts
create mode 100644 backend/src/db/push-subscriptions.ts
create mode 100644 backend/src/routes/device-pairing.ts
create mode 100644 backend/src/routes/remote-access-headers.ts
create mode 100644 backend/src/services/device-pairing-links.ts
create mode 100644 backend/src/services/remote-access-headers.ts
create mode 100644 backend/src/services/session-connections.ts
create mode 100644 backend/test/auth/device-pairing.test.ts
create mode 100644 backend/test/auth/session-delete-hooks.test.ts
create mode 100644 backend/test/db/push-subscription-session-id-migration.test.ts
create mode 100644 backend/test/helpers/auth-database.ts
create mode 100644 backend/test/helpers/log-assertions.ts
create mode 100644 backend/test/helpers/remote-access-headers.ts
create mode 100644 backend/test/routes/device-pairing.test.ts
create mode 100644 backend/test/routes/remote-access-headers.test.ts
create mode 100644 backend/test/services/notification-subscriptions.test.ts
create mode 100644 backend/test/services/session-connections.test.ts
create mode 100644 backend/test/shared/device-pairing-schemas.test.ts
create mode 100644 backend/test/shared/manager-app-link.test.ts
create mode 100644 backend/test/shared/trusted-origin.test.ts
create mode 100644 backend/test/shared/url-hosts.test.ts
create mode 100644 backend/test/shared/user-agent.test.ts
create mode 100644 frontend/src/api/devicePairing.ts
create mode 100644 frontend/src/components/settings/ConnectDeviceDialog.test.tsx
create mode 100644 frontend/src/components/settings/ConnectDeviceDialog.tsx
create mode 100644 frontend/src/components/settings/ConnectDeviceSettings.test.tsx
create mode 100644 frontend/src/components/settings/ConnectDeviceSettings.tsx
create mode 100644 frontend/src/components/settings/PairedDevicesSettings.test.tsx
create mode 100644 frontend/src/components/settings/PairedDevicesSettings.tsx
create mode 100644 frontend/src/components/settings/PairingQrCode.test.tsx
create mode 100644 frontend/src/components/settings/PairingQrCode.tsx
create mode 100644 frontend/src/components/settings/RemoteAccessHeadersSettings.test.tsx
create mode 100644 frontend/src/components/settings/RemoteAccessHeadersSettings.tsx
create mode 100644 frontend/src/hooks/useDevicePairing.ts
create mode 100644 frontend/src/pages/Pair.tsx
create mode 100644 frontend/src/pages/__tests__/Login.test.tsx
create mode 100644 frontend/src/pages/__tests__/Pair.test.tsx
create mode 100644 shared/src/schemas/device-pairing.ts
create mode 100644 shared/src/utils/user-agent.ts
diff --git a/backend/src/auth/device-pairing.ts b/backend/src/auth/device-pairing.ts
new file mode 100644
index 000000000..54b1f6861
--- /dev/null
+++ b/backend/src/auth/device-pairing.ts
@@ -0,0 +1,170 @@
+import { createHash, randomBytes } from 'node:crypto'
+import type { BetterAuthPlugin } from 'better-auth'
+import { APIError, createAuthEndpoint, sessionMiddleware } from 'better-auth/api'
+import { setSessionCookie } from 'better-auth/cookies'
+import { z } from 'zod'
+import {
+ DEVICE_PAIRING_CODE_TTL_SECONDS,
+ DEVICE_PAIRING_ERROR_CODES,
+ PAIRING_CODE_INVALID_MESSAGE,
+ RedeemDevicePairingCodeRequestSchema,
+ type PairedDevice,
+} from '@opencode-manager/shared/schemas'
+import { logger } from '../utils/logger'
+
+const PAIRING_IDENTIFIER_PREFIX = 'device-pairing:'
+const REDEEM_PATH = '/device-pairing/redeem'
+const DEVICES_PATH = '/device-pairing/devices'
+const REVOKE_PATH = '/device-pairing/devices/revoke'
+
+type SessionRecord = {
+ id: string
+ createdAt: Date
+ updatedAt: Date
+ ipAddress?: string | null
+ userAgent?: string | null
+ deviceName?: string | null
+}
+
+function toPairedDevice(session: SessionRecord, currentSessionId: string): PairedDevice {
+ return {
+ id: session.id,
+ deviceName: session.deviceName ?? null,
+ userAgent: session.userAgent || null,
+ ipAddress: session.ipAddress || null,
+ createdAt: new Date(session.createdAt).toISOString(),
+ updatedAt: new Date(session.updatedAt).toISOString(),
+ isCurrent: session.id === currentSessionId,
+ }
+}
+
+function pairingIdentifier(code: string): string {
+ return `${PAIRING_IDENTIFIER_PREFIX}${createHash('sha256').update(code).digest('base64url')}`
+}
+
+function invalidPairingCodeError(): APIError {
+ return new APIError('BAD_REQUEST', {
+ message: PAIRING_CODE_INVALID_MESSAGE,
+ code: DEVICE_PAIRING_ERROR_CODES.PAIRING_CODE_INVALID,
+ })
+}
+
+export interface DevicePairingOptions {
+ onDeviceRevoked?: (sessionId: string) => void
+}
+
+/** Adds pairing code, paired device, and revoke endpoints to Better Auth. */
+export function devicePairing(options: DevicePairingOptions = {}) {
+ const { onDeviceRevoked } = options
+ return {
+ id: 'device-pairing',
+ schema: {
+ session: {
+ fields: {
+ deviceName: { type: 'string', required: false, input: false },
+ },
+ },
+ },
+ endpoints: {
+ createDevicePairingCode: createAuthEndpoint.serverOnly(
+ { method: 'POST', use: [sessionMiddleware], requireHeaders: true },
+ async (ctx) => {
+ const code = randomBytes(32).toString('base64url')
+ const expiresAt = new Date(Date.now() + DEVICE_PAIRING_CODE_TTL_SECONDS * 1000)
+
+ await ctx.context.adapter.deleteMany({
+ model: 'verification',
+ where: [
+ { field: 'identifier', value: PAIRING_IDENTIFIER_PREFIX, operator: 'starts_with' },
+ { field: 'expiresAt', value: new Date(), operator: 'lt' },
+ ],
+ })
+ await ctx.context.internalAdapter.createVerificationValue({
+ identifier: pairingIdentifier(code),
+ value: ctx.context.session.user.id,
+ expiresAt,
+ })
+
+ return ctx.json({ code, expiresAt })
+ },
+ ),
+ redeemDevicePairingCode: createAuthEndpoint(
+ REDEEM_PATH,
+ {
+ method: 'POST',
+ body: RedeemDevicePairingCodeRequestSchema,
+ metadata: { noStore: true },
+ },
+ async (ctx) => {
+ const { code, deviceName } = ctx.body
+ const record = await ctx.context.internalAdapter.consumeVerificationValue(pairingIdentifier(code))
+ if (!record) throw invalidPairingCodeError()
+
+ const user = await ctx.context.internalAdapter.findUserById(record.value)
+ if (!user) throw invalidPairingCodeError()
+
+ const session = await ctx.context.internalAdapter.createSession(
+ user.id,
+ false,
+ deviceName ? { deviceName } : undefined,
+ )
+ await setSessionCookie(ctx, { session, user })
+
+ return ctx.json({ user: { id: user.id, name: user.name, email: user.email } })
+ },
+ ),
+ listPairedDevices: createAuthEndpoint(
+ DEVICES_PATH,
+ { method: 'GET', use: [sessionMiddleware], requireHeaders: true },
+ async (ctx) => {
+ const currentSessionId = ctx.context.session.session.id
+ const sessions = await ctx.context.internalAdapter.listSessions(ctx.context.session.user.id, {
+ onlyActiveSessions: true,
+ })
+ const devices = sessions
+ .map((session) => toPairedDevice(session, currentSessionId))
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
+
+ return ctx.json({ devices })
+ },
+ ),
+ revokePairedDevice: createAuthEndpoint(
+ REVOKE_PATH,
+ {
+ method: 'POST',
+ body: z.object({ id: z.string().min(1) }),
+ use: [sessionMiddleware],
+ requireHeaders: true,
+ },
+ async (ctx) => {
+ const sessions = await ctx.context.internalAdapter.listSessions(ctx.context.session.user.id, {
+ onlyActiveSessions: true,
+ })
+ const target = sessions.find((session) => session.id === ctx.body.id)
+ if (!target) {
+ throw new APIError('NOT_FOUND', {
+ message: 'This device is no longer paired',
+ code: DEVICE_PAIRING_ERROR_CODES.DEVICE_NOT_FOUND,
+ })
+ }
+ if (target.id === ctx.context.session.session.id) {
+ throw new APIError('BAD_REQUEST', {
+ message: 'The current device cannot be revoked',
+ code: DEVICE_PAIRING_ERROR_CODES.CANNOT_REVOKE_CURRENT_SESSION,
+ })
+ }
+
+ await ctx.context.internalAdapter.deleteSession(target.token)
+ if (onDeviceRevoked) {
+ try {
+ onDeviceRevoked(target.id)
+ } catch (error) {
+ logger.error(`Failed to delete push subscriptions for revoked session ${target.id}`, error)
+ }
+ }
+ return ctx.json({ status: true })
+ },
+ ),
+ },
+ } satisfies BetterAuthPlugin
+}
diff --git a/backend/src/auth/index.ts b/backend/src/auth/index.ts
index 1732f74bb..006f5cc82 100644
--- a/backend/src/auth/index.ts
+++ b/backend/src/auth/index.ts
@@ -5,6 +5,9 @@ import { Database } from 'bun:sqlite'
import { ENV, getTrustedOrigins } from '@opencode-manager/shared/config/env'
import { AUTH_COOKIE_PREFIX } from './cookies'
import { AUTH_SOCKET_IP_HEADER } from './client-ip'
+import { devicePairing } from './device-pairing'
+import { closeSessionConnections } from '../services/session-connections'
+import { deletePushSubscriptionsForSession } from '../db/push-subscriptions'
export type AuthInstance = ReturnType
@@ -53,6 +56,13 @@ export function createAuth(db: Database) {
},
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
databaseHooks: {
+ session: {
+ delete: {
+ after: async (session) => {
+ closeSessionConnections(session.id)
+ },
+ },
+ },
user: {
create: {
before: async (user, context) => {
@@ -73,6 +83,9 @@ export function createAuth(db: Database) {
userVerification: 'required',
},
}),
+ devicePairing({
+ onDeviceRevoked: (sessionId) => deletePushSubscriptionsForSession(db, sessionId),
+ }),
],
session: {
expiresIn: 60 * 60 * 24 * 7,
@@ -110,6 +123,7 @@ export type Session = {
updatedAt: Date
ipAddress?: string | null
userAgent?: string | null
+ deviceName?: string | null
}
user: {
id: string
diff --git a/backend/src/db/migrations/202610221800-session-device-name.ts b/backend/src/db/migrations/202610221800-session-device-name.ts
new file mode 100644
index 000000000..d00bc5dc0
--- /dev/null
+++ b/backend/src/db/migrations/202610221800-session-device-name.ts
@@ -0,0 +1,20 @@
+import type { Migration } from '../migration-runner'
+
+interface ColumnInfo {
+ name: string
+}
+
+const migration: Migration = {
+ id: '202610221800-session-device-name',
+ up(db) {
+ const tableInfo = db.prepare('PRAGMA table_info("session")').all() as ColumnInfo[]
+ if (!tableInfo.some((column) => column.name === 'deviceName')) {
+ db.run('ALTER TABLE "session" ADD COLUMN deviceName TEXT')
+ }
+ },
+ down(db) {
+ void db
+ },
+}
+
+export default migration
diff --git a/backend/src/db/migrations/202610231200-push-subscription-session-id.ts b/backend/src/db/migrations/202610231200-push-subscription-session-id.ts
new file mode 100644
index 000000000..bea2fdfa9
--- /dev/null
+++ b/backend/src/db/migrations/202610231200-push-subscription-session-id.ts
@@ -0,0 +1,14 @@
+import type { Migration } from '../migration-runner'
+import { ensurePushSubscriptionsTable } from '../../db/push-subscriptions'
+
+const migration: Migration = {
+ id: '202610231200-push-subscription-session-id',
+ up(db) {
+ ensurePushSubscriptionsTable(db)
+ },
+ down(db) {
+ void db
+ },
+}
+
+export default migration
diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts
index 56feebfbf..813f8ea29 100644
--- a/backend/src/db/migrations/index.ts
+++ b/backend/src/db/migrations/index.ts
@@ -39,6 +39,8 @@ import migration202610181310 from './202610181310-multi-run-targets'
import migration202610181320 from './202610181320-remote-schedule-targets'
import migration202610181400 from './202610181400-secrets'
import { secretsGlobalScopeIndexMigration } from './202610181410-secrets-global-scope-index'
+import migration202610221800 from './202610221800-session-device-name'
+import migration202610231200 from './202610231200-push-subscription-session-id'
export const allMigrations: Migration[] = [
migration001,
@@ -81,4 +83,6 @@ export const allMigrations: Migration[] = [
migration202610181320,
migration202610181400,
secretsGlobalScopeIndexMigration,
+ migration202610221800,
+ migration202610231200,
]
diff --git a/backend/src/db/push-subscriptions.ts b/backend/src/db/push-subscriptions.ts
new file mode 100644
index 000000000..7471ded34
--- /dev/null
+++ b/backend/src/db/push-subscriptions.ts
@@ -0,0 +1,32 @@
+import type { Database } from 'bun:sqlite'
+
+/** Creates the push subscriptions schema and adds the columns older databases lack. */
+export function ensurePushSubscriptionsTable(db: Database): void {
+ db.run(`
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ endpoint TEXT NOT NULL UNIQUE,
+ p256dh TEXT NOT NULL,
+ auth TEXT NOT NULL,
+ device_name TEXT,
+ session_id TEXT,
+ created_at INTEGER NOT NULL,
+ last_used_at INTEGER
+ )
+ `)
+
+ const columns = db.prepare('PRAGMA table_info(push_subscriptions)').all() as Array<{ name: string }>
+ if (!columns.some((column) => column.name === 'session_id')) {
+ db.run('ALTER TABLE push_subscriptions ADD COLUMN session_id TEXT')
+ }
+
+ db.run('CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_id)')
+ db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_push_sub_endpoint ON push_subscriptions(endpoint)')
+ db.run('CREATE INDEX IF NOT EXISTS idx_push_sub_session ON push_subscriptions(session_id)')
+}
+
+/** Deletes the push subscriptions a revoked Better Auth session registered. */
+export function deletePushSubscriptionsForSession(db: Database, sessionId: string): number {
+ return db.prepare('DELETE FROM push_subscriptions WHERE session_id = ?').run(sessionId).changes
+}
diff --git a/backend/src/db/secrets.ts b/backend/src/db/secrets.ts
index 50c539389..c1562a64b 100644
--- a/backend/src/db/secrets.ts
+++ b/backend/src/db/secrets.ts
@@ -25,3 +25,7 @@ export function writeEncryptedSecret(db: Database, key: string, value: string):
export function deleteSecret(db: Database, key: string): void {
db.prepare('DELETE FROM app_secrets WHERE key = ?').run(key)
}
+
+export function hasSecret(db: Database, key: string): boolean {
+ return Boolean(db.prepare('SELECT 1 FROM app_secrets WHERE key = ?').get(key))
+}
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 189800ccc..9921facac 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -10,6 +10,7 @@ import { createRepoTerminalSocketRoutes } from './routes/repo-terminal-socket'
import { createIPCServer, type IPCServer } from './ipc/ipcServer'
import { GitAuthService } from './services/git-auth'
import { createSettingsRoutes } from './routes/settings'
+import { createDevicePairingRoutes } from './routes/device-pairing'
import { createHealthRoutes } from './routes/health'
import { createTTSRoutes, cleanupExpiredCache } from './routes/tts';
import { createSTTRoutes } from './routes/stt'
@@ -62,6 +63,7 @@ import { sseAggregator } from './services/sse-aggregator'
import { RemoteServerEventHub } from './services/remote-server-events'
import { ensureDirectoryExists, writeFileContent, fileExists } from './services/file-operations'
import { SettingsService } from './services/settings'
+import { RemoteAccessHeadersService } from './services/remote-access-headers'
import { SessionPermissionModeService } from './services/session-permission-modes'
import { SessionSandboxService } from './services/session-sandbox'
import { SessionAncestry } from './services/session-ancestry'
@@ -400,6 +402,12 @@ protectedApi.use('/*', requireAuth)
protectedApi.route('/repos', createRepoTerminalSocketRoutes(db, gitAuthService, openCodeClient, terminalService, upgradeWebSocket))
protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeClient, terminalService, projectConfigService, repoWorkspaces, secretsService))
protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeClient, openCodeSupervisor))
+protectedApi.route('/device-pairing', createDevicePairingRoutes({
+ mintPairingCode: (headers) => auth.api.createDevicePairingCode({ headers }),
+ remoteAccessHeaders: new RemoteAccessHeadersService(db),
+ readAllowPrivateNetworkHttp: () =>
+ settingsService.getSettings('default').preferences.devicePairing?.allowPrivateNetworkHttp === true,
+}))
protectedApi.route('/files', createFileRoutes())
protectedApi.route('/filesystem', createFilesystemRoutes())
protectedApi.route('/providers', createProvidersRoutes(openCodeClient))
diff --git a/backend/src/routes/device-pairing.ts b/backend/src/routes/device-pairing.ts
new file mode 100644
index 000000000..e31284925
--- /dev/null
+++ b/backend/src/routes/device-pairing.ts
@@ -0,0 +1,73 @@
+import { Hono } from 'hono'
+import { ENV, getTrustedOrigins, isTrustedOrigin } from '@opencode-manager/shared/config/env'
+import { CreateDevicePairingLinksRequestSchema } from '@opencode-manager/shared/schemas'
+import { buildDevicePairingLinks, resolveAppLinkPolicy } from '../services/device-pairing-links'
+import type { RemoteAccessHeadersService } from '../services/remote-access-headers'
+import { logger } from '../utils/logger'
+import { parseJsonBody } from '../utils/route-helpers'
+
+const MISSING_ORIGIN_MESSAGE =
+ 'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.'
+const LINKS_FAILED_MESSAGE = 'Failed to create device pairing links'
+
+function untrustedOriginMessage(origin: string | undefined): string {
+ if (!origin) return MISSING_ORIGIN_MESSAGE
+ return `This address (${origin}) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.`
+}
+
+interface DevicePairingRouteDeps {
+ mintPairingCode(headers: Headers): Promise<{ code: string; expiresAt: Date | string }>
+ remoteAccessHeaders: RemoteAccessHeadersService
+ readAllowPrivateNetworkHttp(): boolean
+ resolveTrustedOrigins?: () => string[]
+ isDevelopment?: () => boolean
+}
+
+/** Creates the routes that mint device pairing links for the Manager origin. */
+export function createDevicePairingRoutes(deps: DevicePairingRouteDeps) {
+ const app = new Hono()
+ const resolveTrustedOrigins = deps.resolveTrustedOrigins ?? getTrustedOrigins
+ const isDevelopment = deps.isDevelopment ?? (() => ENV.SERVER.NODE_ENV === 'development')
+
+ app.use('/links', async (c, next) => {
+ await next()
+ c.header('Cache-Control', 'no-store')
+ })
+
+ app.post('/links', async (c) => {
+ const body = await parseJsonBody(c, CreateDevicePairingLinksRequestSchema, { allowEmpty: true })
+ if (!body.ok) return body.response
+
+ const origin = c.req.header('origin')
+ if (!origin || !isTrustedOrigin(origin, { requireOrigin: true }, resolveTrustedOrigins())) {
+ return c.json({ error: untrustedOriginMessage(origin) }, 400)
+ }
+
+ try {
+ const originUrl = new URL(origin)
+ const policy = resolveAppLinkPolicy(originUrl, {
+ allowPrivateNetworkHttp: deps.readAllowPrivateNetworkHttp(),
+ isDevelopment: isDevelopment(),
+ })
+ const stored = deps.remoteAccessHeaders.read()
+ const minted = await deps.mintPairingCode(c.req.raw.headers)
+
+ return c.json(
+ buildDevicePairingLinks({
+ origin: originUrl,
+ name: body.data.name ?? originUrl.hostname,
+ code: minted.code,
+ expiresAt: new Date(minted.expiresAt),
+ headers: stored.headers,
+ headersUnreadable: stored.unreadable,
+ policy,
+ }),
+ )
+ } catch (error) {
+ logger.error(LINKS_FAILED_MESSAGE, error instanceof Error ? error.message : 'unknown')
+ return c.json({ error: LINKS_FAILED_MESSAGE }, 500)
+ }
+ })
+
+ return app
+}
diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts
index ad44ae788..08852a95c 100644
--- a/backend/src/routes/notifications.ts
+++ b/backend/src/routes/notifications.ts
@@ -2,11 +2,16 @@ import { Hono } from "hono";
import { z } from "zod";
import { PushSubscriptionRequestSchema } from "@opencode-manager/shared/schemas";
import type { NotificationService } from "../services/notification";
+import type { Session } from "../auth";
+
+interface NotificationRouteVariables {
+ session?: Session['session'];
+}
export function createNotificationRoutes(
notificationService: NotificationService
) {
- const app = new Hono();
+ const app = new Hono<{ Variables: NotificationRouteVariables }>();
app.get("/vapid-public-key", (c) => {
const publicKey = notificationService.getVapidPublicKey();
@@ -21,6 +26,7 @@ export function createNotificationRoutes(
app.post("/subscribe", async (c) => {
const userId = c.req.query('userId') || 'default'
+ const sessionId = c.get('session')?.id
const body = await c.req.json();
const parsed = PushSubscriptionRequestSchema.safeParse(body);
@@ -35,7 +41,8 @@ export function createNotificationRoutes(
endpoint,
keys.p256dh,
keys.auth,
- deviceName
+ deviceName,
+ sessionId
);
return c.json({ subscription });
diff --git a/backend/src/routes/opencode-server-terminals.ts b/backend/src/routes/opencode-server-terminals.ts
index fa0a1d9af..b25ca8394 100644
--- a/backend/src/routes/opencode-server-terminals.ts
+++ b/backend/src/routes/opencode-server-terminals.ts
@@ -12,6 +12,7 @@ import { TerminalNotFoundError, type TerminalService } from '../services/termina
import { parseTerminalCursor } from '../utils/terminal-cursor'
import { createBridgedSocketEvents, isAllowedUpgradeOrigin } from '../utils/websocket-bridge'
import { handleOpenCodeError, handleServiceError, parseJsonBody } from '../utils/route-helpers'
+import type { Session } from '../auth'
export type ServerTerminalServiceResolver = (serverId: string) => TerminalService
@@ -19,6 +20,7 @@ interface ServerTerminalSocketVariables {
terminalService: TerminalService
terminalDirectory: string
terminalCursor: number | undefined
+ session?: Session['session']
}
function isAbsoluteDirectory(directory: string): boolean {
@@ -181,7 +183,10 @@ export function createOpenCodeServerTerminalSocketRoutes(
const cursor = c.get('terminalCursor')
const ptyID = c.req.param('ptyID')
- return createBridgedSocketEvents((peer) => terminalService.connect(directory, ptyID, cursor, peer))
+ return createBridgedSocketEvents(
+ (peer) => terminalService.connect(directory, ptyID, cursor, peer),
+ { sessionId: c.get('session')?.id },
+ )
}),
)
diff --git a/backend/src/routes/remote-access-headers.ts b/backend/src/routes/remote-access-headers.ts
new file mode 100644
index 000000000..d1f0532f2
--- /dev/null
+++ b/backend/src/routes/remote-access-headers.ts
@@ -0,0 +1,41 @@
+import { Hono } from 'hono'
+import { ReplaceRemoteAccessHeadersRequestSchema } from '@opencode-manager/shared/schemas'
+import { handleServiceError, parseJsonBody } from '../utils/route-helpers'
+import { RemoteAccessHeadersError, type RemoteAccessHeadersService } from '../services/remote-access-headers'
+
+const READ_FAILED_MESSAGE = 'Failed to read remote access headers'
+const WRITE_FAILED_MESSAGE = 'Failed to update remote access headers'
+
+export function createRemoteAccessHeaderRoutes(service: RemoteAccessHeadersService) {
+ const app = new Hono()
+
+ app.get('/', (c) => {
+ try {
+ return c.json(service.getSummary())
+ } catch (error) {
+ return handleServiceError(c, error, READ_FAILED_MESSAGE, RemoteAccessHeadersError)
+ }
+ })
+
+ app.put('/', async (c) => {
+ const body = await parseJsonBody(c, ReplaceRemoteAccessHeadersRequestSchema)
+ if (!body.ok) return body.response
+
+ try {
+ return c.json(service.replace(body.data))
+ } catch (error) {
+ return handleServiceError(c, error, WRITE_FAILED_MESSAGE, RemoteAccessHeadersError)
+ }
+ })
+
+ app.delete('/', (c) => {
+ try {
+ service.clear()
+ return c.json(service.getSummary())
+ } catch (error) {
+ return handleServiceError(c, error, WRITE_FAILED_MESSAGE, RemoteAccessHeadersError)
+ }
+ })
+
+ return app
+}
diff --git a/backend/src/routes/repo-terminal-socket.ts b/backend/src/routes/repo-terminal-socket.ts
index 486feec3a..6e7b458cb 100644
--- a/backend/src/routes/repo-terminal-socket.ts
+++ b/backend/src/routes/repo-terminal-socket.ts
@@ -9,10 +9,12 @@ import type { TerminalService } from '../services/terminal'
import { createBridgedSocketEvents, isAllowedUpgradeOrigin } from '../utils/websocket-bridge'
import { parseTerminalCursor } from '../utils/terminal-cursor'
import { resolveRepoRequestDirectory } from './repo-directory'
+import type { Session } from '../auth'
interface RepoTerminalSocketVariables {
terminalDirectory: string
terminalCursor: number | undefined
+ session?: Session['session']
}
export function createRepoTerminalSocketRoutes(
@@ -54,7 +56,10 @@ export function createRepoTerminalSocketRoutes(
const cursor = c.get('terminalCursor')
const ptyID = c.req.param('ptyID') ?? ''
- return createBridgedSocketEvents((peer) => terminalService.connect(directory, ptyID, cursor, peer))
+ return createBridgedSocketEvents(
+ (peer) => terminalService.connect(directory, ptyID, cursor, peer),
+ { sessionId: c.get('session')?.id },
+ )
}),
)
diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts
index 3ba3af2cd..2b0e833fa 100644
--- a/backend/src/routes/settings.ts
+++ b/backend/src/routes/settings.ts
@@ -7,7 +7,9 @@ import { SettingsService } from '../services/settings'
import { writeFileContent, readFileContent, fileExists } from '../services/file-operations'
import { archiveBrokenOpenCodeConfigFile, deleteOpenCodeConfigFile } from '../services/opencode-config-file'
import { reloadOpenCodeOrMarkRestartPending, restoreLastKnownGoodOpenCodeConfig } from '../services/opencode-config-apply'
+import { RemoteAccessHeadersService } from '../services/remote-access-headers'
import { createOpenCodeConfigRoutes } from './opencode-config'
+import { createRemoteAccessHeaderRoutes } from './remote-access-headers'
import type { OpenCodeClient } from '../services/opencode/client'
import { getAgentsMdPath } from '@opencode-manager/shared/config/env'
import {
@@ -476,6 +478,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
// OpenCode Config routes
app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient))
+ app.route('/remote-access-headers', createRemoteAccessHeaderRoutes(new RemoteAccessHeadersService(db)))
app.post('/opencode-restart', async (c) => {
try {
diff --git a/backend/src/routes/sse.ts b/backend/src/routes/sse.ts
index 2d4882ce2..093890ecc 100644
--- a/backend/src/routes/sse.ts
+++ b/backend/src/routes/sse.ts
@@ -5,11 +5,17 @@ import { LOCAL_OPENCODE_SERVER_ID, SSESubscribeSchema, SSEVisibilitySchema } fro
import { logger } from '../utils/logger'
import { DEFAULTS } from '@opencode-manager/shared/config'
import { createQueuedSSEWriter } from './sse-writer'
+import { registerSessionConnection } from '../services/session-connections'
+import type { Session } from '../auth'
const { HEARTBEAT_INTERVAL_MS } = DEFAULTS.SSE
+interface SSERouteVariables {
+ session?: Session['session']
+}
+
export function createSSERoutes() {
- const app = new Hono()
+ const app = new Hono<{ Variables: SSERouteVariables }>()
app.get('/stream', async (c) => {
const directoriesParam = c.req.query('directories')
@@ -48,15 +54,27 @@ export function createSSERoutes() {
directories
)
+ let endStream: () => void = () => {}
+ const streamEnded = new Promise((resolve) => {
+ endStream = resolve
+ })
+
+ const sessionId = c.get('session')?.id
+ const unregisterSession = sessionId === undefined
+ ? () => {}
+ : registerSessionConnection(sessionId, () => writer.abort())
+
writer.onAbort(() => {
+ unregisterSession()
queuedWriter.close()
clearInterval(heartbeatInterval)
cleanup()
+ endStream()
})
queuedWriter.writeSSE('connected', JSON.stringify({ clientId, directories, ...sseAggregator.getConnectionStatus() }))
- await new Promise(() => {})
+ await streamEnded
})
})
diff --git a/backend/src/services/device-pairing-links.ts b/backend/src/services/device-pairing-links.ts
new file mode 100644
index 000000000..893aa2ff8
--- /dev/null
+++ b/backend/src/services/device-pairing-links.ts
@@ -0,0 +1,63 @@
+import {
+ buildManagerAppLink,
+ buildManagerBrowserPairingLink,
+ type AppLinkBlockedReason,
+ type DevicePairingLinkWarning,
+ type DevicePairingLinksResponse,
+ type RemoteAccessHeader,
+} from '@opencode-manager/shared/schemas'
+import { isLoopbackHost, isPrivateNetworkHost } from '@opencode-manager/shared/utils'
+
+type AppLinkPolicy = {
+ blockedReason: AppLinkBlockedReason | null
+ warnings: DevicePairingLinkWarning[]
+}
+
+/** Decides whether an origin can carry an app link and which warnings it raises. */
+export function resolveAppLinkPolicy(
+ origin: URL,
+ options: { allowPrivateNetworkHttp: boolean; isDevelopment: boolean },
+): AppLinkPolicy {
+ const warnings: DevicePairingLinkWarning[] = []
+ const isPlainHttp = origin.protocol === 'http:'
+
+ if (isPlainHttp) warnings.push('plain-http')
+
+ if (isLoopbackHost(origin.hostname)) {
+ warnings.push('loopback')
+ return { blockedReason: options.isDevelopment ? null : 'loopback', warnings }
+ }
+
+ if (isPlainHttp) {
+ const isAllowed = options.allowPrivateNetworkHttp && isPrivateNetworkHost(origin.hostname)
+ return { blockedReason: isAllowed ? null : 'insecure-origin', warnings }
+ }
+
+ return { blockedReason: null, warnings }
+}
+
+/** Builds the pairing link response, dropping the app link when its origin is blocked. */
+export function buildDevicePairingLinks(input: {
+ origin: URL
+ name: string
+ code: string
+ expiresAt: Date
+ headers: RemoteAccessHeader[]
+ headersUnreadable: boolean
+ policy: AppLinkPolicy
+}): DevicePairingLinksResponse {
+ const { origin, name, code, headers, policy } = input
+ const baseUrl = origin.origin
+
+ return {
+ appLink:
+ policy.blockedReason === null
+ ? buildManagerAppLink({ kind: 'manager', name, baseUrl, code, headers })
+ : null,
+ appLinkBlockedReason: policy.blockedReason,
+ browserLink: buildManagerBrowserPairingLink(baseUrl, code),
+ expiresAt: input.expiresAt.toISOString(),
+ headerNames: headers.map((header) => header.name),
+ warnings: input.headersUnreadable ? [...policy.warnings, 'headers-unreadable'] : policy.warnings,
+ }
+}
diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts
index 1ae8b2a52..1596cfb2a 100644
--- a/backend/src/services/notification.ts
+++ b/backend/src/services/notification.ts
@@ -24,6 +24,7 @@ import { SettingsService } from "./settings";
import { sseAggregator, type SSEEvent } from "./sse-aggregator";
import { getRepoName } from "../db/queries";
import { getScheduleRunBySessionId } from "../db/schedules";
+import { ensurePushSubscriptionsTable } from "../db/push-subscriptions";
import type { Repo } from "../types/repo";
import { buildServerSessionPath, buildSessionPath } from "@opencode-manager/shared/utils";
import { sessionIDFromEvent } from "@opencode-manager/shared/opencode";
@@ -183,28 +184,7 @@ export class NotificationService {
private serverRegistry: NotificationServerRegistry | null = null
) {
this.settingsService = new SettingsService(db);
- this.initializePushSubscriptionsTable();
- }
-
- private initializePushSubscriptionsTable(): void {
- this.db.run(`
- CREATE TABLE IF NOT EXISTS push_subscriptions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id TEXT NOT NULL,
- endpoint TEXT NOT NULL UNIQUE,
- p256dh TEXT NOT NULL,
- auth TEXT NOT NULL,
- device_name TEXT,
- created_at INTEGER NOT NULL,
- last_used_at INTEGER
- )
- `);
- this.db.run(
- "CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_id)"
- );
- this.db.run(
- "CREATE UNIQUE INDEX IF NOT EXISTS idx_push_sub_endpoint ON push_subscriptions(endpoint)"
- );
+ ensurePushSubscriptionsTable(db);
}
configureVapid(config: VapidConfig): void {
@@ -229,22 +209,24 @@ export class NotificationService {
endpoint: string,
p256dh: string,
auth: string,
- deviceName?: string
+ deviceName?: string,
+ sessionId?: string | null
): PushSubscriptionRecord {
const now = Date.now();
this.db
.prepare(
- `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device_name, created_at, last_used_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device_name, session_id, created_at, last_used_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
p256dh = excluded.p256dh,
auth = excluded.auth,
device_name = excluded.device_name,
+ session_id = excluded.session_id,
last_used_at = excluded.last_used_at`
)
- .run(userId, endpoint, p256dh, auth, deviceName ?? null, now, now);
+ .run(userId, endpoint, p256dh, auth, deviceName ?? null, sessionId ?? null, now, now);
const row = this.db
.prepare("SELECT * FROM push_subscriptions WHERE endpoint = ?")
@@ -255,6 +237,7 @@ export class NotificationService {
p256dh: string;
auth: string;
device_name: string | null;
+ session_id: string | null;
created_at: number;
last_used_at: number | null;
};
diff --git a/backend/src/services/preview/gateway.ts b/backend/src/services/preview/gateway.ts
index 6115dd73d..a96e2b15b 100644
--- a/backend/src/services/preview/gateway.ts
+++ b/backend/src/services/preview/gateway.ts
@@ -3,6 +3,7 @@ import { Hono } from 'hono'
import type { Context, MiddlewareHandler } from 'hono'
import { createNodeWebSocket, type NodeWebSocket } from '@hono/node-ws'
import type { PreviewPort } from '@opencode-manager/shared/schemas'
+import { isLoopbackHost } from '@opencode-manager/shared/utils'
import { AUTH_COOKIE_PREFIX } from '../../auth/cookies'
import { buildProxyResponseHeaders, filterProxyHeaders } from '../../utils/proxy-headers'
import { bridgeWebSocket, createBridgedSocketEvents } from '../../utils/websocket-bridge'
@@ -135,9 +136,7 @@ function rewriteLocalLocation(location: string, port: number): string | null {
} catch {
return null
}
- const isLocalHost =
- parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]' || parsed.hostname === '::1'
- if (!isLocalHost) return null
+ if (!isLoopbackHost(parsed.hostname)) return null
const locationPort = parsed.port
? Number.parseInt(parsed.port, 10)
: parsed.protocol === 'https:'
diff --git a/backend/src/services/remote-access-headers.ts b/backend/src/services/remote-access-headers.ts
new file mode 100644
index 000000000..cf6559cf5
--- /dev/null
+++ b/backend/src/services/remote-access-headers.ts
@@ -0,0 +1,104 @@
+import type { Database } from 'bun:sqlite'
+import {
+ RemoteAccessHeaderListSchema,
+ type RemoteAccessHeader,
+ type RemoteAccessHeadersResponse,
+ type RemoteAccessHeaderSummary,
+ type ReplaceRemoteAccessHeadersRequest,
+} from '@opencode-manager/shared/schemas'
+import { deleteSecret, hasSecret, readEncryptedSecret, writeEncryptedSecret } from '../db/secrets'
+import { logger } from '../utils/logger'
+import { ServiceError } from '../utils/service-error'
+
+export const REMOTE_ACCESS_HEADERS_SECRET_KEY = 'remote_access_headers'
+
+export class RemoteAccessHeadersError extends ServiceError {
+ constructor(message: string, status: number) {
+ super(message, status)
+ this.name = 'RemoteAccessHeadersError'
+ }
+}
+
+export interface StoredRemoteAccessHeaders {
+ headers: RemoteAccessHeader[]
+ unreadable: boolean
+}
+
+function parseStoredHeaders(value: string): RemoteAccessHeader[] | null {
+ let parsedValue: unknown
+ try {
+ parsedValue = JSON.parse(value)
+ } catch {
+ return null
+ }
+
+ const parsed = RemoteAccessHeaderListSchema.safeParse(parsedValue)
+ return parsed.success ? parsed.data : null
+}
+
+function summarize(headers: RemoteAccessHeader[]): RemoteAccessHeaderSummary[] {
+ return headers.map((header) => ({ name: header.name, hasValue: true }))
+}
+
+export class RemoteAccessHeadersService {
+ constructor(private db: Database) {}
+
+ read(): StoredRemoteAccessHeaders {
+ const stored = readEncryptedSecret(this.db, REMOTE_ACCESS_HEADERS_SECRET_KEY)
+ if (stored === null) {
+ return { headers: [], unreadable: hasSecret(this.db, REMOTE_ACCESS_HEADERS_SECRET_KEY) }
+ }
+
+ const headers = parseStoredHeaders(stored)
+ if (headers !== null) {
+ return { headers, unreadable: false }
+ }
+
+ logger.warn('Stored remote access headers could not be read')
+ return { headers: [], unreadable: true }
+ }
+
+ getSummary(): RemoteAccessHeadersResponse {
+ const { headers, unreadable } = this.read()
+ return { headers: summarize(headers), unreadable }
+ }
+
+ replace(request: ReplaceRemoteAccessHeadersRequest): RemoteAccessHeadersResponse {
+ const stored = this.read().headers
+
+ const merged = request.headers.map((entry) => {
+ const value = entry.value?.trim()
+ if (value) {
+ return { name: entry.name, value }
+ }
+
+ const existing = stored.find((header) => header.name.toLowerCase() === entry.name.toLowerCase())
+ if (!existing) {
+ throw new RemoteAccessHeadersError(`A value is required for ${entry.name}`, 400)
+ }
+ return { name: entry.name, value: existing.value }
+ })
+
+ const validated = RemoteAccessHeaderListSchema.safeParse(merged)
+ if (!validated.success) {
+ throw new RemoteAccessHeadersError(
+ validated.error.issues.map((issue) => issue.message).join('; '),
+ 400,
+ )
+ }
+
+ const headers = validated.data
+ if (headers.length === 0) {
+ deleteSecret(this.db, REMOTE_ACCESS_HEADERS_SECRET_KEY)
+ } else {
+ writeEncryptedSecret(this.db, REMOTE_ACCESS_HEADERS_SECRET_KEY, JSON.stringify(headers))
+ }
+
+ logger.info(`Updated remote access headers (${headers.length} headers)`)
+ return { headers: summarize(headers), unreadable: false }
+ }
+
+ clear(): void {
+ deleteSecret(this.db, REMOTE_ACCESS_HEADERS_SECRET_KEY)
+ }
+}
diff --git a/backend/src/services/session-connections.ts b/backend/src/services/session-connections.ts
new file mode 100644
index 000000000..128d8f9f6
--- /dev/null
+++ b/backend/src/services/session-connections.ts
@@ -0,0 +1,41 @@
+import { logger } from '../utils/logger'
+
+export type SessionConnectionCloser = () => void
+
+export const SESSION_CONNECTION_CLOSE_CODE = 1008
+
+export const SESSION_CONNECTION_CLOSE_REASON = 'Session ended'
+
+const connectionsBySession = new Map>()
+
+export function registerSessionConnection(sessionId: string, close: SessionConnectionCloser): () => void {
+ let closers = connectionsBySession.get(sessionId)
+ if (!closers) {
+ closers = new Set()
+ connectionsBySession.set(sessionId, closers)
+ }
+ closers.add(close)
+
+ return () => {
+ const current = connectionsBySession.get(sessionId)
+ if (!current) return
+ current.delete(close)
+ if (current.size === 0) {
+ connectionsBySession.delete(sessionId)
+ }
+ }
+}
+
+export function closeSessionConnections(sessionId: string): void {
+ const closers = connectionsBySession.get(sessionId)
+ if (!closers) return
+ connectionsBySession.delete(sessionId)
+
+ for (const close of closers) {
+ try {
+ close()
+ } catch (error) {
+ logger.error('Failed to close a session connection', { sessionId, error })
+ }
+ }
+}
diff --git a/backend/src/utils/websocket-bridge.ts b/backend/src/utils/websocket-bridge.ts
index 21945c623..9e74aef2c 100644
--- a/backend/src/utils/websocket-bridge.ts
+++ b/backend/src/utils/websocket-bridge.ts
@@ -1,5 +1,10 @@
import type { WSContext, WSEvents } from 'hono/ws'
import { getErrorMessage } from './error-utils'
+import {
+ registerSessionConnection,
+ SESSION_CONNECTION_CLOSE_CODE,
+ SESSION_CONNECTION_CLOSE_REASON,
+} from '../services/session-connections'
export interface WebSocketPeer {
send(data: string | Uint8Array): void
@@ -27,6 +32,10 @@ export interface ConnectUpstreamOptions {
timeoutMs?: number
}
+export interface BridgedSocketEventsOptions {
+ sessionId?: string
+}
+
export const MAX_PEER_BUFFERED_BYTES = 4 * 1024 * 1024
export const MAX_UPSTREAM_BUFFERED_BYTES = 4 * 1024 * 1024
@@ -320,13 +329,21 @@ export function connectUpstreamWebSocket(
export function createBridgedSocketEvents(
openBridge: (peer: WebSocketPeer) => WebSocketBridge | Promise,
+ options: BridgedSocketEventsOptions = {},
): WSEvents {
let bridge: WebSocketBridge | undefined
let closed = false
let connectPromise: Promise = Promise.resolve()
+ let unregisterSession: (() => void) | undefined
return {
onOpen(_event, ws) {
+ if (options.sessionId !== undefined) {
+ const sessionId = options.sessionId
+ unregisterSession = registerSessionConnection(sessionId, () => {
+ ws.close(SESSION_CONNECTION_CLOSE_CODE, SESSION_CONNECTION_CLOSE_REASON)
+ })
+ }
connectPromise = (async () => {
const connected = await openBridge(toWebSocketPeer(ws))
if (closed) {
@@ -346,6 +363,8 @@ export function createBridgedSocketEvents(
},
onClose() {
closed = true
+ unregisterSession?.()
+ unregisterSession = undefined
bridge?.close()
},
}
diff --git a/backend/test/auth/device-pairing.test.ts b/backend/test/auth/device-pairing.test.ts
new file mode 100644
index 000000000..d13cc7bc7
--- /dev/null
+++ b/backend/test/auth/device-pairing.test.ts
@@ -0,0 +1,424 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { createHash } from 'node:crypto'
+import type { Database } from 'bun:sqlite'
+import {
+ DEVICE_PAIRING_CODE_TTL_SECONDS,
+ DEVICE_PAIRING_ERROR_CODES,
+ PAIRING_CODE_INVALID_MESSAGE,
+ PairedDeviceSchema,
+} from '@opencode-manager/shared/schemas'
+import { createAuth, type AuthInstance } from '../../src/auth'
+import { devicePairing } from '../../src/auth/device-pairing'
+import { AUTH_SOCKET_IP_HEADER } from '../../src/auth/client-ip'
+import { createAuthDatabase } from '../helpers/auth-database'
+
+const { ENV } = vi.hoisted(() => ({
+ ENV: {
+ SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' },
+ AUTH: {
+ SECRET: 'test-secret-for-encryption-key-32c',
+ TRUSTED_ORIGINS: 'http://localhost:5173,http://localhost:5003',
+ SECURE_COOKIES: false,
+ ADMIN_EMAIL: undefined as string | undefined,
+ ADMIN_PASSWORD: undefined as string | undefined,
+ ADMIN_PASSWORD_RESET: false,
+ GITHUB_CLIENT_ID: undefined as string | undefined,
+ GITHUB_CLIENT_SECRET: undefined as string | undefined,
+ GOOGLE_CLIENT_ID: undefined as string | undefined,
+ GOOGLE_CLIENT_SECRET: undefined as string | undefined,
+ DISCORD_CLIENT_ID: undefined as string | undefined,
+ DISCORD_CLIENT_SECRET: undefined as string | undefined,
+ PASSKEY_RP_ID: 'localhost',
+ PASSKEY_RP_NAME: 'OpenCode Manager',
+ PASSKEY_ORIGIN: 'http://localhost:5003',
+ },
+ LOGGING: { DEBUG: false, LOG_LEVEL: 'info' },
+ },
+}))
+
+vi.mock('@opencode-manager/shared/config/env', () => ({
+ ENV,
+ getTrustedOrigins: () => ENV.AUTH.TRUSTED_ORIGINS
+ .split(',')
+ .map((origin: string) => origin.trim())
+ .filter((origin: string) => origin.length > 0),
+}))
+
+vi.mock('../../src/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}))
+
+const ORIGIN = 'http://localhost:5173'
+const SIGN_UP_URL = `${ORIGIN}/api/auth/sign-up/email`
+const REDEEM_URL = `${ORIGIN}/api/auth/device-pairing/redeem`
+const UNROUTABLE_CREATE_URL = `${ORIGIN}/api/auth/device-pairing/create`
+const DEVICES_URL = `${ORIGIN}/api/auth/device-pairing/devices`
+const REVOKE_URL = `${ORIGIN}/api/auth/device-pairing/devices/revoke`
+const PAIRED_DEVICE_KEYS = Object.keys(PairedDeviceSchema.shape).sort()
+
+type PairedDeviceBody = {
+ id: string
+ deviceName: string | null
+ userAgent: string | null
+ ipAddress: string | null
+ createdAt: string
+ updatedAt: string
+ isCurrent: boolean
+}
+
+function cookieHeaders(response: Response): Headers {
+ const cookies = response.headers.getSetCookie().map((cookie) => cookie.split(';')[0])
+ return new Headers({ cookie: cookies.join('; ') })
+}
+
+function redeemRequest(code: string, deviceName?: string, ipAddress = '198.51.100.7'): Request {
+ return new Request(REDEEM_URL, {
+ method: 'POST',
+ headers: {
+ origin: ORIGIN,
+ 'content-type': 'application/json',
+ 'user-agent': 'TestPhone/1.0',
+ [AUTH_SOCKET_IP_HEADER]: ipAddress,
+ },
+ body: JSON.stringify(deviceName === undefined ? { code } : { code, deviceName }),
+ })
+}
+
+function deviceRequest(url: string, method: 'GET' | 'POST', headers?: Headers, body?: unknown): Request {
+ const requestHeaders: Record = {
+ origin: ORIGIN,
+ 'content-type': 'application/json',
+ [AUTH_SOCKET_IP_HEADER]: '198.51.100.31',
+ }
+ const cookie = headers?.get('cookie')
+ if (cookie) requestHeaders.cookie = cookie
+
+ return new Request(url, {
+ method,
+ headers: requestHeaders,
+ body: body === undefined ? undefined : JSON.stringify(body),
+ })
+}
+
+describe('devicePairing', () => {
+ let db: Database
+ let auth: AuthInstance
+
+ beforeEach(() => {
+ db = createAuthDatabase()
+ auth = createAuth(db)
+ })
+
+ afterEach(() => {
+ db.close()
+ })
+
+ async function signUp(email = 'creator@example.com', name = 'Creator'): Promise {
+ const response = await auth.handler(new Request(SIGN_UP_URL, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', origin: ORIGIN },
+ body: JSON.stringify({ email, password: 'test-password', name }),
+ }))
+ expect(response.status).toBe(200)
+ return cookieHeaders(response)
+ }
+
+ async function listDevices(headers: Headers): Promise<{ status: number; devices: PairedDeviceBody[] }> {
+ const response = await auth.handler(deviceRequest(DEVICES_URL, 'GET', headers))
+ const body = await response.json() as { devices?: PairedDeviceBody[] }
+ return { status: response.status, devices: body.devices ?? [] }
+ }
+
+ async function pairPhone(creatorHeaders: Headers, deviceName: string): Promise {
+ const { code } = await auth.api.createDevicePairingCode({ headers: creatorHeaders })
+ const response = await auth.handler(redeemRequest(code, deviceName, '198.51.100.55'))
+ expect(response.status).toBe(200)
+ return cookieHeaders(response)
+ }
+
+ it('mints a code stored only as a hash with the three-minute lifetime', async () => {
+ const headers = await signUp()
+ const mintedAt = Date.now()
+
+ const { code, expiresAt } = await auth.api.createDevicePairingCode({ headers })
+
+ expect(code).toMatch(/^[A-Za-z0-9_-]{43}$/)
+ const lifetimeMs = new Date(expiresAt).getTime() - mintedAt
+ expect(lifetimeMs).toBeGreaterThan((DEVICE_PAIRING_CODE_TTL_SECONDS - 5) * 1000)
+ expect(lifetimeMs).toBeLessThanOrEqual((DEVICE_PAIRING_CODE_TTL_SECONDS + 5) * 1000)
+
+ const rows = db.prepare('SELECT identifier, value, expiresAt FROM verification').all() as Array<{
+ identifier: string
+ value: string
+ expiresAt: number
+ }>
+ expect(rows).toHaveLength(1)
+ expect(rows[0]!.identifier).toBe(`device-pairing:${createHash('sha256').update(code).digest('base64url')}`)
+ expect(JSON.stringify(rows)).not.toContain(code)
+ })
+
+ it('does not expose code minting over HTTP', async () => {
+ const headers = await signUp()
+
+ const response = await auth.handler(new Request(UNROUTABLE_CREATE_URL, {
+ method: 'POST',
+ headers: { origin: ORIGIN, 'content-type': 'application/json', cookie: headers.get('cookie') ?? '' },
+ body: '{}',
+ }))
+
+ expect(response.status).toBe(404)
+ })
+
+ it('redeems a code into a new session for the same user', async () => {
+ const creatorHeaders = await signUp()
+ const creatorSession = await auth.api.getSession({ headers: creatorHeaders })
+ expect(creatorSession).not.toBeNull()
+
+ const { code } = await auth.api.createDevicePairingCode({ headers: creatorHeaders })
+ const response = await auth.handler(redeemRequest(code, 'Chris iPhone'))
+
+ expect(response.status).toBe(200)
+ const body = await response.json() as { user: { id: string; name: string; email: string } }
+ expect(body.user.id).toBe(creatorSession!.user.id)
+ expect(body.user.email).toBe('creator@example.com')
+ expect(body.user).not.toHaveProperty('token')
+
+ const cookies = response.headers.getSetCookie()
+ expect(cookies.length).toBeGreaterThan(0)
+ const phoneSession = await auth.api.getSession({ headers: cookieHeaders(response) })
+ expect(phoneSession).not.toBeNull()
+ expect(phoneSession!.user.id).toBe(creatorSession!.user.id)
+ expect(phoneSession!.session.id).not.toBe(creatorSession!.session.id)
+ expect(phoneSession!.session.deviceName).toBe('Chris iPhone')
+ expect(phoneSession!.session.userAgent).toBe('TestPhone/1.0')
+
+ const creatorAfter = await auth.api.getSession({ headers: creatorHeaders })
+ expect(creatorAfter?.session.id).toBe(creatorSession!.session.id)
+ expect(db.prepare('SELECT COUNT(*) AS count FROM "session"').get()).toEqual({ count: 2 })
+ })
+
+ it('consumes a code once', async () => {
+ const headers = await signUp()
+ const { code } = await auth.api.createDevicePairingCode({ headers })
+
+ const first = await auth.handler(redeemRequest(code, 'First Phone'))
+ expect(first.status).toBe(200)
+
+ const second = await auth.handler(redeemRequest(code, 'Second Phone'))
+ expect(second.status).toBe(400)
+ const body = await second.json() as { code?: string; message?: string }
+ expect(body.code).toBe(DEVICE_PAIRING_ERROR_CODES.PAIRING_CODE_INVALID)
+ expect(body.message).toBe(PAIRING_CODE_INVALID_MESSAGE)
+ })
+
+ it('rejects an expired code', async () => {
+ const headers = await signUp()
+ const { code } = await auth.api.createDevicePairingCode({ headers })
+ db.prepare('UPDATE verification SET expiresAt = ?').run(Date.now() - 1000)
+
+ const response = await auth.handler(redeemRequest(code, 'Late Phone'))
+
+ expect(response.status).toBe(400)
+ const body = await response.json() as { code?: string }
+ expect(body.code).toBe(DEVICE_PAIRING_ERROR_CODES.PAIRING_CODE_INVALID)
+ })
+
+ it('allows exactly one of two concurrent redeems', async () => {
+ const headers = await signUp()
+ const { code } = await auth.api.createDevicePairingCode({ headers })
+
+ const responses = await Promise.all([
+ auth.handler(redeemRequest(code, 'Phone A', '198.51.100.20')),
+ auth.handler(redeemRequest(code, 'Phone B', '198.51.100.20')),
+ ])
+
+ expect(responses.map((response) => response.status).sort()).toEqual([200, 400])
+ expect(db.prepare('SELECT COUNT(*) AS count FROM "session"').get()).toEqual({ count: 2 })
+ })
+
+ it('creates a session without a device name when none is given', async () => {
+ const headers = await signUp()
+ const { code } = await auth.api.createDevicePairingCode({ headers })
+
+ const response = await auth.handler(redeemRequest(code))
+ expect(response.status).toBe(200)
+
+ const session = await auth.api.getSession({ headers: cookieHeaders(response) })
+ expect(session).not.toBeNull()
+ expect(session!.session.deviceName ?? null).toBeNull()
+ })
+
+ it('lists active sessions as paired devices without session tokens', async () => {
+ const creatorHeaders = await signUp()
+ await pairPhone(creatorHeaders, 'Chris iPhone')
+
+ const { status, devices } = await listDevices(creatorHeaders)
+
+ expect(status).toBe(200)
+ expect(devices).toHaveLength(2)
+ for (const device of devices) {
+ expect(Object.keys(device).sort()).toEqual(PAIRED_DEVICE_KEYS)
+ }
+
+ const updatedAt = devices.map((device) => device.updatedAt)
+ expect(updatedAt).toEqual([...updatedAt].sort().reverse())
+
+ const creatorSession = await auth.api.getSession({ headers: creatorHeaders })
+ const current = devices.find((device) => device.id === creatorSession!.session.id)
+ expect(current?.isCurrent).toBe(true)
+
+ const phone = devices.find((device) => device.id !== creatorSession!.session.id)
+ expect(phone).toMatchObject({
+ deviceName: 'Chris iPhone',
+ userAgent: 'TestPhone/1.0',
+ ipAddress: '198.51.100.55',
+ isCurrent: false,
+ })
+
+ const tokens = db.prepare('SELECT token FROM "session"').all() as Array<{ token: string }>
+ expect(tokens).toHaveLength(2)
+ const serialized = JSON.stringify(devices)
+ for (const { token } of tokens) expect(serialized).not.toContain(token)
+ })
+
+ it('revokes one paired device without signing out the current one', async () => {
+ const creatorHeaders = await signUp()
+ const phoneHeaders = await pairPhone(creatorHeaders, 'Chris iPhone')
+ const creatorSession = await auth.api.getSession({ headers: creatorHeaders })
+
+ const { devices } = await listDevices(creatorHeaders)
+ const phone = devices.find((device) => device.id !== creatorSession!.session.id)
+ expect(phone).toBeDefined()
+
+ const response = await auth.handler(deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: phone!.id }))
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({ status: true })
+ expect(await auth.api.getSession({ headers: phoneHeaders })).toBeNull()
+ const creatorAfter = await auth.api.getSession({ headers: creatorHeaders })
+ expect(creatorAfter?.session.id).toBe(creatorSession!.session.id)
+ })
+
+ it('removes only the revoked device push subscriptions', async () => {
+ const creatorHeaders = await signUp()
+ const phoneHeaders = await pairPhone(creatorHeaders, 'Chris iPhone')
+ const creatorSession = await auth.api.getSession({ headers: creatorHeaders })
+ const phoneSession = await auth.api.getSession({ headers: phoneHeaders })
+ expect(phoneSession).not.toBeNull()
+
+ const insertSubscription = db.prepare(
+ `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device_name, session_id, created_at, last_used_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ insertSubscription.run('default', 'https://push.example.com/phone', 'p', 'a', 'Chris iPhone', phoneSession!.session.id, 1, 1)
+ insertSubscription.run('default', 'https://push.example.com/desktop', 'p', 'a', 'Desktop', creatorSession!.session.id, 1, 1)
+ insertSubscription.run('default', 'https://push.example.com/legacy', 'p', 'a', 'Legacy', null, 1, 1)
+
+ const response = await auth.handler(
+ deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: phoneSession!.session.id }),
+ )
+
+ expect(response.status).toBe(200)
+ const endpoints = (db.prepare('SELECT endpoint FROM push_subscriptions ORDER BY endpoint').all() as Array<{ endpoint: string }>)
+ .map((row) => row.endpoint)
+ expect(endpoints).toEqual([
+ 'https://push.example.com/desktop',
+ 'https://push.example.com/legacy',
+ ])
+ })
+
+ it('refuses to revoke the current session', async () => {
+ const creatorHeaders = await signUp()
+ const creatorSession = await auth.api.getSession({ headers: creatorHeaders })
+
+ const response = await auth.handler(
+ deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: creatorSession!.session.id }),
+ )
+
+ expect(response.status).toBe(400)
+ const body = await response.json() as { code?: string }
+ expect(body.code).toBe(DEVICE_PAIRING_ERROR_CODES.CANNOT_REVOKE_CURRENT_SESSION)
+ expect(await auth.api.getSession({ headers: creatorHeaders })).not.toBeNull()
+ })
+
+ it('reports unknown and foreign session ids as missing', async () => {
+ const creatorHeaders = await signUp()
+ const otherHeaders = await signUp('other@example.com', 'Other')
+ const otherSession = await auth.api.getSession({ headers: otherHeaders })
+
+ const unknown = await auth.handler(
+ deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: 'unknown-session' }),
+ )
+ expect(unknown.status).toBe(404)
+ expect((await unknown.json() as { code?: string }).code).toBe(DEVICE_PAIRING_ERROR_CODES.DEVICE_NOT_FOUND)
+
+ const foreign = await auth.handler(
+ deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: otherSession!.session.id }),
+ )
+ expect(foreign.status).toBe(404)
+ expect((await foreign.json() as { code?: string }).code).toBe(DEVICE_PAIRING_ERROR_CODES.DEVICE_NOT_FOUND)
+
+ expect(await auth.api.getSession({ headers: otherHeaders })).not.toBeNull()
+ })
+
+ it('requires a session for the device endpoints', async () => {
+ const list = await auth.handler(deviceRequest(DEVICES_URL, 'GET'))
+ expect(list.status).toBe(401)
+
+ const revoke = await auth.handler(deviceRequest(REVOKE_URL, 'POST', undefined, { id: 'anything' }))
+ expect(revoke.status).toBe(401)
+ })
+
+ it('lists only unexpired sessions as paired devices', async () => {
+ const creatorHeaders = await signUp()
+ const phoneHeaders = await pairPhone(creatorHeaders, 'Chris iPhone')
+ const phoneSession = await auth.api.getSession({ headers: phoneHeaders })
+ expect(phoneSession).not.toBeNull()
+
+ db.prepare('UPDATE "session" SET expiresAt = ? WHERE id = ?')
+ .run(new Date(Date.now() - 1000).toISOString(), phoneSession!.session.id)
+
+ const { devices } = await listDevices(creatorHeaders)
+ expect(devices.map((device) => device.id)).not.toContain(phoneSession!.session.id)
+
+ const revoke = await auth.handler(
+ deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: phoneSession!.session.id }),
+ )
+ expect(revoke.status).toBe(404)
+ expect((await revoke.json() as { code?: string }).code).toBe(DEVICE_PAIRING_ERROR_CODES.DEVICE_NOT_FOUND)
+ })
+
+ it('deletes only expired device pairing codes when minting', async () => {
+ const headers = await signUp()
+ const now = Date.now()
+ const insertVerification = db.prepare(
+ 'INSERT INTO verification (id, identifier, value, expiresAt, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)',
+ )
+ const expiredAt = new Date(now - 1000).toISOString()
+ insertVerification.run('expired-pairing', 'device-pairing:expired', 'user-1', expiredAt, expiredAt, expiredAt)
+ insertVerification.run(
+ 'active-pairing',
+ 'device-pairing:active',
+ 'user-1',
+ new Date(now + 60_000).toISOString(),
+ expiredAt,
+ expiredAt,
+ )
+ insertVerification.run('expired-other', 'email-verification:expired', 'user-1', expiredAt, expiredAt, expiredAt)
+
+ const { code } = await auth.api.createDevicePairingCode({ headers })
+
+ const identifiers = (db.prepare('SELECT identifier FROM verification').all() as Array<{ identifier: string }>)
+ .map((row) => row.identifier)
+ .sort()
+ expect(identifiers).toEqual([
+ 'device-pairing:active',
+ `device-pairing:${createHash('sha256').update(code).digest('base64url')}`,
+ 'email-verification:expired',
+ ].sort())
+ })
+
+ it('does not override rate limiting for the redeem endpoint', () => {
+ expect(devicePairing()).not.toHaveProperty('rateLimit')
+ })
+})
diff --git a/backend/test/auth/index.test.ts b/backend/test/auth/index.test.ts
index 70af8bf33..65dd20c7e 100644
--- a/backend/test/auth/index.test.ts
+++ b/backend/test/auth/index.test.ts
@@ -1,10 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createAuth } from '../../src/auth'
-import { DatabaseSync } from 'node:sqlite'
-import type { Database } from 'bun:sqlite'
-import { migrate } from '../../src/db/migration-runner'
-import { allMigrations } from '../../src/db/migrations'
import { syncAdminFromEnv } from '../../src/routes/auth'
+import { createAuthDatabase } from '../helpers/auth-database'
const { ENV } = vi.hoisted(() => ({
ENV: {
@@ -56,15 +53,6 @@ function resetEnv(): void {
ENV.AUTH.DISCORD_CLIENT_SECRET = undefined
}
-function createAuthDatabase(): Database {
- const db = new DatabaseSync(':memory:')
- const compatible = Object.assign(db, {
- run: (sql: string, ...params: (string | number | null)[]) => db.prepare(sql).run(...params),
- }) as unknown as Database
- migrate(compatible, allMigrations)
- return compatible
-}
-
describe('createAuth', () => {
beforeEach(() => {
resetEnv()
diff --git a/backend/test/auth/session-delete-hooks.test.ts b/backend/test/auth/session-delete-hooks.test.ts
new file mode 100644
index 000000000..687dfbb68
--- /dev/null
+++ b/backend/test/auth/session-delete-hooks.test.ts
@@ -0,0 +1,174 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import type { Database } from 'bun:sqlite'
+import { createAuth, type AuthInstance } from '../../src/auth'
+import { closeSessionConnections, registerSessionConnection } from '../../src/services/session-connections'
+import { createAuthDatabase } from '../helpers/auth-database'
+
+const { ENV } = vi.hoisted(() => ({
+ ENV: {
+ SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' },
+ AUTH: {
+ SECRET: 'test-secret-for-encryption-key-32c',
+ TRUSTED_ORIGINS: 'http://localhost:5173,http://localhost:5003',
+ SECURE_COOKIES: false,
+ ADMIN_EMAIL: undefined as string | undefined,
+ ADMIN_PASSWORD: undefined as string | undefined,
+ ADMIN_PASSWORD_RESET: false,
+ GITHUB_CLIENT_ID: undefined as string | undefined,
+ GITHUB_CLIENT_SECRET: undefined as string | undefined,
+ GOOGLE_CLIENT_ID: undefined as string | undefined,
+ GOOGLE_CLIENT_SECRET: undefined as string | undefined,
+ DISCORD_CLIENT_ID: undefined as string | undefined,
+ DISCORD_CLIENT_SECRET: undefined as string | undefined,
+ PASSKEY_RP_ID: 'localhost',
+ PASSKEY_RP_NAME: 'OpenCode Manager',
+ PASSKEY_ORIGIN: 'http://localhost:5003',
+ },
+ LOGGING: { DEBUG: false, LOG_LEVEL: 'info' },
+ },
+}))
+
+vi.mock('@opencode-manager/shared/config/env', () => ({
+ ENV,
+ getTrustedOrigins: () => ENV.AUTH.TRUSTED_ORIGINS
+ .split(',')
+ .map((origin: string) => origin.trim())
+ .filter((origin: string) => origin.length > 0),
+}))
+
+vi.mock('../../src/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}))
+
+const ORIGIN = 'http://localhost:5173'
+const SIGN_UP_URL = `${ORIGIN}/api/auth/sign-up/email`
+const SIGN_OUT_URL = `${ORIGIN}/api/auth/sign-out`
+const REDEEM_URL = `${ORIGIN}/api/auth/device-pairing/redeem`
+const DEVICES_URL = `${ORIGIN}/api/auth/device-pairing/devices`
+const REVOKE_URL = `${ORIGIN}/api/auth/device-pairing/devices/revoke`
+
+function cookieHeaders(response: Response): Headers {
+ const cookies = response.headers.getSetCookie().map((cookie) => cookie.split(';')[0])
+ return new Headers({ cookie: cookies.join('; ') })
+}
+
+function cookieHeaderFrom(headers: Headers, url: string, method: 'GET' | 'POST', body?: unknown): Request {
+ const requestHeaders: Record = {
+ origin: ORIGIN,
+ 'content-type': 'application/json',
+ }
+ const cookie = headers.get('cookie')
+ if (cookie) requestHeaders.cookie = cookie
+
+ return new Request(url, {
+ method,
+ headers: requestHeaders,
+ body: body === undefined ? undefined : JSON.stringify(body),
+ })
+}
+
+describe('session delete hooks', () => {
+ let db: Database
+ let auth: AuthInstance
+
+ beforeEach(() => {
+ db = createAuthDatabase()
+ auth = createAuth(db)
+ })
+
+ afterEach(() => {
+ db.close()
+ })
+
+ async function signUp(email = 'creator@example.com'): Promise {
+ const response = await auth.handler(new Request(SIGN_UP_URL, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', origin: ORIGIN },
+ body: JSON.stringify({ email, password: 'test-password', name: 'Creator' }),
+ }))
+ expect(response.status).toBe(200)
+ return cookieHeaders(response)
+ }
+
+ async function pairPhone(creatorHeaders: Headers, deviceName: string): Promise {
+ const { code } = await auth.api.createDevicePairingCode({ headers: creatorHeaders })
+ const response = await auth.handler(new Request(REDEEM_URL, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', origin: ORIGIN, 'user-agent': 'TestPhone/1.0' },
+ body: JSON.stringify({ code, deviceName }),
+ }))
+ expect(response.status).toBe(200)
+ return cookieHeaders(response)
+ }
+
+ async function sessionId(headers: Headers): Promise {
+ const session = await auth.api.getSession({ headers })
+ expect(session).not.toBeNull()
+ return session!.session.id
+ }
+
+ it('closes the connections of a device revoked through the pairing plugin', async () => {
+ const creatorHeaders = await signUp()
+ const phoneHeaders = await pairPhone(creatorHeaders, 'Chris iPhone')
+ const phoneSessionId = await sessionId(phoneHeaders)
+ const creatorSessionId = await sessionId(creatorHeaders)
+
+ const phoneClose = vi.fn()
+ const creatorClose = vi.fn()
+ registerSessionConnection(phoneSessionId, phoneClose)
+ registerSessionConnection(creatorSessionId, creatorClose)
+
+ const devicesResponse = await auth.handler(cookieHeaderFrom(creatorHeaders, DEVICES_URL, 'GET'))
+ const { devices } = await devicesResponse.json() as { devices: Array<{ id: string }> }
+ expect(devices.map((device) => device.id)).toContain(phoneSessionId)
+
+ const revokeResponse = await auth.handler(
+ cookieHeaderFrom(creatorHeaders, REVOKE_URL, 'POST', { id: phoneSessionId }),
+ )
+
+ expect(revokeResponse.status).toBe(200)
+ await vi.waitFor(() => expect(phoneClose).toHaveBeenCalledTimes(1))
+ expect(creatorClose).not.toHaveBeenCalled()
+ })
+
+ it('closes the connections of the session that signs out', async () => {
+ const headers = await signUp()
+ const sessionId_ = await sessionId(headers)
+ const close = vi.fn()
+ registerSessionConnection(sessionId_, close)
+
+ const response = await auth.handler(cookieHeaderFrom(headers, SIGN_OUT_URL, 'POST'))
+
+ expect(response.status).toBe(200)
+ await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1))
+ })
+
+ it('closes the connections of a session revoked through the auth api', async () => {
+ const headers = await signUp()
+ const session = await auth.api.getSession({ headers })
+ expect(session).not.toBeNull()
+ const close = vi.fn()
+ registerSessionConnection(session!.session.id, close)
+
+ const response = await auth.api.revokeSession({
+ headers,
+ body: { token: session!.session.token },
+ })
+
+ expect(response).toEqual({ status: true })
+ await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1))
+ })
+
+ it('does not leave closed sessions registered', async () => {
+ const headers = await signUp()
+ const sessionId_ = await sessionId(headers)
+ const close = vi.fn()
+ registerSessionConnection(sessionId_, close)
+
+ await auth.handler(cookieHeaderFrom(headers, SIGN_OUT_URL, 'POST'))
+ await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1))
+
+ closeSessionConnections(sessionId_)
+ expect(close).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/backend/test/db/push-subscription-session-id-migration.test.ts b/backend/test/db/push-subscription-session-id-migration.test.ts
new file mode 100644
index 000000000..8d3392aeb
--- /dev/null
+++ b/backend/test/db/push-subscription-session-id-migration.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect } from 'vitest'
+import { Database } from 'bun:sqlite'
+import { migrate } from '../../src/db/migration-runner'
+import { allMigrations } from '../../src/db/migrations'
+
+const MIGRATION_ID = '202610231200-push-subscription-session-id'
+
+const LEGACY_TABLE = `
+ CREATE TABLE push_subscriptions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ endpoint TEXT NOT NULL UNIQUE,
+ p256dh TEXT NOT NULL,
+ auth TEXT NOT NULL,
+ device_name TEXT,
+ created_at INTEGER NOT NULL,
+ last_used_at INTEGER
+ )
+`
+
+function columnNames(db: Database): string[] {
+ return (db.prepare('PRAGMA table_info(push_subscriptions)').all() as Array<{ name: string }>)
+ .map((column) => column.name)
+}
+
+function indexNames(db: Database): string[] {
+ return (db.prepare('PRAGMA index_list(push_subscriptions)').all() as Array<{ name: string }>)
+ .map((index) => index.name)
+}
+
+describe('push subscription session id migration', () => {
+ it('creates the push subscriptions table with its session id column', () => {
+ const db = new Database(':memory:')
+ migrate(db, allMigrations)
+
+ expect(columnNames(db)).toContain('session_id')
+ expect(indexNames(db)).toContain('idx_push_sub_session')
+ expect(indexNames(db)).toContain('idx_push_sub_endpoint')
+ db.close()
+ })
+
+ it('adds the session id column to an existing table without losing subscriptions', () => {
+ const db = new Database(':memory:')
+ db.run(LEGACY_TABLE)
+ db.prepare(
+ `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device_name, created_at, last_used_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ ).run('user-1', 'https://push.example.com/legacy', 'p256dh-key', 'auth-key', 'Old Phone', 1, 1)
+
+ migrate(db, allMigrations)
+
+ expect(columnNames(db)).toContain('session_id')
+ expect(indexNames(db)).toContain('idx_push_sub_session')
+ expect(db.prepare('SELECT endpoint, session_id FROM push_subscriptions').all()).toEqual([
+ { endpoint: 'https://push.example.com/legacy', session_id: null },
+ ])
+ expect(db.prepare('SELECT COUNT(*) AS count FROM applied_migrations WHERE id = ?').get(MIGRATION_ID))
+ .toEqual({ count: 1 })
+ db.close()
+ })
+
+ it('can run again on a database that always had the column', () => {
+ const db = new Database(':memory:')
+ migrate(db, allMigrations)
+ db.prepare('DELETE FROM applied_migrations WHERE id = ?').run(MIGRATION_ID)
+
+ migrate(db, allMigrations)
+
+ expect(columnNames(db).filter((name) => name === 'session_id')).toHaveLength(1)
+ expect(indexNames(db).filter((name) => name === 'idx_push_sub_session')).toHaveLength(1)
+ db.close()
+ })
+})
diff --git a/backend/test/helpers/auth-database.ts b/backend/test/helpers/auth-database.ts
new file mode 100644
index 000000000..f68e3fc60
--- /dev/null
+++ b/backend/test/helpers/auth-database.ts
@@ -0,0 +1,13 @@
+import { DatabaseSync } from 'node:sqlite'
+import type { Database } from 'bun:sqlite'
+import { migrate } from '../../src/db/migration-runner'
+import { allMigrations } from '../../src/db/migrations'
+
+export function createAuthDatabase(): Database {
+ const db = new DatabaseSync(':memory:')
+ const compatible = Object.assign(db, {
+ run: (sql: string, ...params: (string | number | null)[]) => db.prepare(sql).run(...params),
+ }) as unknown as Database
+ migrate(compatible, allMigrations)
+ return compatible
+}
diff --git a/backend/test/helpers/log-assertions.ts b/backend/test/helpers/log-assertions.ts
new file mode 100644
index 000000000..d478ae6e3
--- /dev/null
+++ b/backend/test/helpers/log-assertions.ts
@@ -0,0 +1,24 @@
+import { expect, type Mock } from 'vitest'
+
+export interface LoggerMocks {
+ info: Mock
+ warn: Mock
+ error: Mock
+ debug: Mock
+}
+
+export function loggedArguments(loggerMocks: LoggerMocks): unknown[][] {
+ return [...loggerMocks.info.mock.calls, ...loggerMocks.warn.mock.calls, ...loggerMocks.error.mock.calls]
+}
+
+export function expectNoSecretsLogged(loggerMocks: LoggerMocks, secrets: readonly string[]): void {
+ const calls = loggedArguments(loggerMocks)
+ expect(calls.length).toBeGreaterThan(0)
+
+ for (const args of calls) {
+ const serialized = JSON.stringify(args)
+ for (const value of secrets) {
+ expect(serialized).not.toContain(value)
+ }
+ }
+}
diff --git a/backend/test/helpers/remote-access-headers.ts b/backend/test/helpers/remote-access-headers.ts
new file mode 100644
index 000000000..86bdf1b34
--- /dev/null
+++ b/backend/test/helpers/remote-access-headers.ts
@@ -0,0 +1,11 @@
+import type { RemoteAccessHeader } from '@opencode-manager/shared/schemas'
+
+export const CLOUDFLARE_CLIENT_ID = 'client-id-9f4c2b7e1a5d'
+export const CLOUDFLARE_CLIENT_SECRET = 'client-secret-4d8e2f7b1c90'
+
+export const CLOUDFLARE_HEADERS: RemoteAccessHeader[] = [
+ { name: 'CF-Access-Client-Id', value: CLOUDFLARE_CLIENT_ID },
+ { name: 'CF-Access-Client-Secret', value: CLOUDFLARE_CLIENT_SECRET },
+]
+
+export const CLOUDFLARE_SECRET_VALUES = [CLOUDFLARE_CLIENT_ID, CLOUDFLARE_CLIENT_SECRET]
diff --git a/backend/test/helpers/websocket-server.ts b/backend/test/helpers/websocket-server.ts
index 73122517c..ec441e64f 100644
--- a/backend/test/helpers/websocket-server.ts
+++ b/backend/test/helpers/websocket-server.ts
@@ -6,7 +6,7 @@ import { serve } from '@hono/node-server'
import { createNodeWebSocket } from '@hono/node-ws'
export interface StartWebSocketServerOptions {
- app: Hono
+ app: Hono
register?: (app: Hono, upgradeWebSocket: UpgradeWebSocket) => void
injectWebSocket?: (server: Server) => void
}
diff --git a/backend/test/routes/device-pairing.test.ts b/backend/test/routes/device-pairing.test.ts
new file mode 100644
index 000000000..dc40b9f01
--- /dev/null
+++ b/backend/test/routes/device-pairing.test.ts
@@ -0,0 +1,260 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Database } from 'bun:sqlite'
+import { Hono } from 'hono'
+import {
+ DevicePairingLinksResponseSchema,
+ buildManagerBrowserPairingLink,
+ parseManagerAppLink,
+ type DevicePairingLinksResponse,
+} from '@opencode-manager/shared/schemas'
+
+const loggerMocks = vi.hoisted(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+}))
+
+vi.mock('../../src/utils/logger', () => ({ logger: loggerMocks }))
+
+import { createTestDb } from '../helpers/assistant-workspace'
+import { expectNoSecretsLogged } from '../helpers/log-assertions'
+import { CLOUDFLARE_HEADERS, CLOUDFLARE_SECRET_VALUES } from '../helpers/remote-access-headers'
+import { writeEncryptedSecret } from '../../src/db/secrets'
+import {
+ RemoteAccessHeadersService,
+ REMOTE_ACCESS_HEADERS_SECRET_KEY,
+} from '../../src/services/remote-access-headers'
+import { createDevicePairingRoutes } from '../../src/routes/device-pairing'
+
+const CODE = 'pairing-code-0123456789abcdef'
+const EXPIRES_AT = new Date('2026-10-22T12:00:00.000Z')
+
+const TRUSTED_ORIGINS = [
+ 'https://m.example.com',
+ 'http://192.168.1.20:5003',
+ 'http://203.0.113.5:5003',
+ 'http://localhost:5173',
+]
+
+const MISSING_ORIGIN_ERROR =
+ 'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.'
+const untrustedOriginError = (origin: string) =>
+ `This address (${origin}) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.`
+const LINKS_FAILED_ERROR = 'Failed to create device pairing links'
+
+const SENSITIVE_VALUES = [CODE, ...CLOUDFLARE_SECRET_VALUES]
+
+const COOKIE_HEADER = 'session=test-session'
+
+const createMintPairingCode = () =>
+ vi.fn<(headers: Headers) => Promise<{ code: string; expiresAt: Date }>>(async () => ({
+ code: CODE,
+ expiresAt: EXPIRES_AT,
+ }))
+
+async function readLinksBody(response: Response): Promise {
+ return DevicePairingLinksResponseSchema.parse(await response.json())
+}
+
+describe('device pairing link routes', () => {
+ let db: Database
+ let remoteAccessHeaders: RemoteAccessHeadersService
+ let mintPairingCode: ReturnType
+ let allowPrivateNetwork: boolean
+ let development: boolean
+ let app: Hono
+
+ beforeEach(() => {
+ allowPrivateNetwork = false
+ development = false
+ db = createTestDb()
+ remoteAccessHeaders = new RemoteAccessHeadersService(db)
+ remoteAccessHeaders.replace({ headers: CLOUDFLARE_HEADERS })
+ mintPairingCode = createMintPairingCode()
+ app = new Hono()
+ app.route(
+ '/device-pairing',
+ createDevicePairingRoutes({
+ mintPairingCode,
+ remoteAccessHeaders,
+ readAllowPrivateNetworkHttp: () => allowPrivateNetwork,
+ resolveTrustedOrigins: () => TRUSTED_ORIGINS,
+ isDevelopment: () => development,
+ }),
+ )
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const postLinks = (origin?: string, body?: unknown) => {
+ const headers: Record = {
+ 'content-type': 'application/json',
+ cookie: COOKIE_HEADER,
+ }
+ if (origin) headers.origin = origin
+
+ return app.request('/device-pairing/links', {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body ?? {}),
+ })
+ }
+
+ it('rejects a request without an origin', async () => {
+ const res = await postLinks()
+
+ expect(res.status).toBe(400)
+ expect(res.headers.get('Cache-Control')).toBe('no-store')
+ await expect(res.json()).resolves.toEqual({ error: MISSING_ORIGIN_ERROR })
+ expect(mintPairingCode).not.toHaveBeenCalled()
+ })
+
+ it('rejects an untrusted origin', async () => {
+ const res = await postLinks('https://evil.example')
+
+ expect(res.status).toBe(400)
+ expect(res.headers.get('Cache-Control')).toBe('no-store')
+ const body = await res.json() as { error: string }
+ expect(body.error).toBe(untrustedOriginError('https://evil.example'))
+ expect(body.error).toContain('AUTH_TRUSTED_ORIGINS')
+ expect(mintPairingCode).not.toHaveBeenCalled()
+ })
+
+ it('rejects the null origin', async () => {
+ const res = await postLinks('null')
+
+ expect(res.status).toBe(400)
+ await expect(res.json()).resolves.toEqual({ error: untrustedOriginError('null') })
+ expect(mintPairingCode).not.toHaveBeenCalled()
+ })
+
+ it('builds links for a trusted https origin', async () => {
+ const res = await postLinks('https://m.example.com')
+
+ expect(res.status).toBe(200)
+ expect(res.headers.get('Cache-Control')).toBe('no-store')
+ const body = await readLinksBody(res)
+
+ expect(parseManagerAppLink(body.appLink ?? '')).toEqual({
+ kind: 'manager',
+ name: 'm.example.com',
+ baseUrl: 'https://m.example.com',
+ code: CODE,
+ headers: CLOUDFLARE_HEADERS,
+ })
+ expect(body.browserLink).toBe(buildManagerBrowserPairingLink('https://m.example.com', CODE))
+ expect(body.headerNames).toEqual(['CF-Access-Client-Id', 'CF-Access-Client-Secret'])
+ expect(body.warnings).toEqual([])
+ expect(body.appLinkBlockedReason).toBeNull()
+ expect(body.expiresAt).toBe(EXPIRES_AT.toISOString())
+
+ expect(mintPairingCode).toHaveBeenCalledTimes(1)
+ const forwardedHeaders = mintPairingCode.mock.calls[0]?.[0]
+ expect(forwardedHeaders).toBeInstanceOf(Headers)
+ expect(forwardedHeaders?.get('cookie')).toBe(COOKIE_HEADER)
+ })
+
+ it('uses the supplied device name in the app link', async () => {
+ const res = await postLinks('https://m.example.com', { name: 'Office' })
+
+ expect(res.status).toBe(200)
+ const body = await readLinksBody(res)
+
+ expect(parseManagerAppLink(body.appLink ?? '')?.name).toBe('Office')
+ })
+
+ it('blocks a private network http origin when the toggle is off', async () => {
+ const res = await postLinks('http://192.168.1.20:5003')
+
+ expect(res.status).toBe(200)
+ const body = await readLinksBody(res)
+
+ expect(body.appLink).toBeNull()
+ expect(body.appLinkBlockedReason).toBe('insecure-origin')
+ expect(body.warnings).toEqual(['plain-http'])
+ expect(body.browserLink).toBe(buildManagerBrowserPairingLink('http://192.168.1.20:5003', CODE))
+ })
+
+ it('allows a private network http origin when the toggle is on', async () => {
+ allowPrivateNetwork = true
+
+ const res = await postLinks('http://192.168.1.20:5003')
+
+ expect(res.status).toBe(200)
+ const body = await readLinksBody(res)
+
+ expect(parseManagerAppLink(body.appLink ?? '')?.baseUrl).toBe('http://192.168.1.20:5003')
+ expect(body.warnings).toEqual(['plain-http'])
+ })
+
+ it('blocks a public http origin even when the toggle is on', async () => {
+ allowPrivateNetwork = true
+
+ const res = await postLinks('http://203.0.113.5:5003')
+
+ expect(res.status).toBe(200)
+ const body = await readLinksBody(res)
+
+ expect(body.appLink).toBeNull()
+ expect(body.appLinkBlockedReason).toBe('insecure-origin')
+ })
+
+ it('blocks a loopback origin outside development', async () => {
+ allowPrivateNetwork = true
+ development = false
+
+ const res = await postLinks('http://localhost:5173')
+
+ expect(res.status).toBe(200)
+ const body = await readLinksBody(res)
+
+ expect(body.appLink).toBeNull()
+ expect(body.appLinkBlockedReason).toBe('loopback')
+ expect(body.warnings).toEqual(['plain-http', 'loopback'])
+ })
+
+ it('allows a loopback origin in development', async () => {
+ allowPrivateNetwork = true
+ development = true
+
+ const res = await postLinks('http://localhost:5173')
+
+ expect(res.status).toBe(200)
+ const body = await readLinksBody(res)
+
+ expect(parseManagerAppLink(body.appLink ?? '')?.baseUrl).toBe('http://localhost:5173')
+ })
+
+ it('reports unreadable stored headers', async () => {
+ writeEncryptedSecret(
+ db,
+ REMOTE_ACCESS_HEADERS_SECRET_KEY,
+ JSON.stringify([{ name: 'Cookie', value: 'stolen' }]),
+ )
+
+ const res = await postLinks('https://m.example.com')
+
+ expect(res.status).toBe(200)
+ const body = await readLinksBody(res)
+
+ expect(body.headerNames).toEqual([])
+ expect(body.warnings).toContain('headers-unreadable')
+ expect(parseManagerAppLink(body.appLink ?? '')?.headers).toEqual([])
+ })
+
+ it('fails without logging pairing secrets', async () => {
+ await postLinks('https://m.example.com')
+ mintPairingCode.mockRejectedValueOnce(new Error('mint failed'))
+
+ const res = await postLinks('https://m.example.com')
+
+ expect(res.status).toBe(500)
+ expect(res.headers.get('Cache-Control')).toBe('no-store')
+ await expect(res.json()).resolves.toEqual({ error: LINKS_FAILED_ERROR })
+
+ expectNoSecretsLogged(loggerMocks, SENSITIVE_VALUES)
+ })
+})
diff --git a/backend/test/routes/notifications.test.ts b/backend/test/routes/notifications.test.ts
index 69b0ed653..2d0205a3e 100644
--- a/backend/test/routes/notifications.test.ts
+++ b/backend/test/routes/notifications.test.ts
@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { Hono } from 'hono'
import { createNotificationRoutes } from '../../src/routes/notifications'
import type { NotificationService } from '../../src/services/notification'
+import type { Session } from '../../src/auth'
interface NotificationServiceStub {
getVapidPublicKey: ReturnType
@@ -82,6 +84,7 @@ describe('Notification Routes', () => {
'p256dh-key',
'auth-key',
'Phone',
+ undefined,
)
})
@@ -106,6 +109,38 @@ describe('Notification Routes', () => {
'p256dh-key',
'auth-key',
undefined,
+ undefined,
+ )
+ })
+
+ it('saves the session of the device that subscribed', async () => {
+ service.saveSubscription.mockReturnValue({ id: 3 })
+
+ const sessionApp = new Hono<{ Variables: { session?: Session['session'] } }>()
+ sessionApp.use('*', async (c, next) => {
+ c.set('session', { id: 'ses_phone' } as Session['session'])
+ await next()
+ })
+ sessionApp.route('/', createNotificationRoutes(service as unknown as NotificationService))
+
+ const res = await sessionApp.fetch(new Request('http://localhost/subscribe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ endpoint: 'https://push.example.com/abc',
+ keys: { p256dh: 'p256dh-key', auth: 'auth-key' },
+ deviceName: 'Phone',
+ }),
+ }))
+
+ expect(res.status).toBe(200)
+ expect(service.saveSubscription).toHaveBeenCalledWith(
+ 'default',
+ 'https://push.example.com/abc',
+ 'p256dh-key',
+ 'auth-key',
+ 'Phone',
+ 'ses_phone',
)
})
diff --git a/backend/test/routes/opencode-server-terminal-socket.test.ts b/backend/test/routes/opencode-server-terminal-socket.test.ts
index bc386095b..f2acfb7b9 100644
--- a/backend/test/routes/opencode-server-terminal-socket.test.ts
+++ b/backend/test/routes/opencode-server-terminal-socket.test.ts
@@ -10,6 +10,12 @@ import { TerminalService } from '../../src/services/terminal'
import { OpenCodeServerNotFoundError } from '../../src/services/opencode-server-registry'
import type { OpenCodeClient } from '../../src/services/opencode/client'
import type { CredentialProvider } from '../../src/services/credential-provider'
+import type { Session } from '../../src/auth'
+import {
+ closeSessionConnections,
+ SESSION_CONNECTION_CLOSE_CODE,
+ SESSION_CONNECTION_CLOSE_REASON,
+} from '../../src/services/session-connections'
import { createStubRepoEnvService } from '../helpers/stub-repo-env-service'
import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server'
import { rawWebSocketHandshake } from '../helpers/raw-websocket-handshake'
@@ -38,7 +44,9 @@ const credentialProvider = { getGhCliEnv: vi.fn(() => ({})) } as unknown as Cred
let upstream: WebSocketTestServer
let manager: WebSocketTestServer
+let sessionServer: WebSocketTestServer
let managerPort = 0
+let sessionPort = 0
let capturedUrl: URL | undefined
const upstreamApp = new Hono()
@@ -75,6 +83,25 @@ managerApp.route(
createOpenCodeServerTerminalSocketRoutes(resolveTerminalService, managerWs.upgradeWebSocket, [TRUSTED_ORIGIN]),
)
+const SESSION_ID = 'session-server-terminal'
+const sessionApp = new Hono<{ Variables: { session: Session['session'] } }>()
+const sessionWs = createNodeWebSocket({ app: sessionApp })
+sessionApp.use('/*', async (c, next) => {
+ c.set('session', {
+ id: SESSION_ID,
+ userId: 'user-1',
+ token: 'token-server-terminal',
+ expiresAt: new Date(Date.now() + 60_000),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ await next()
+})
+sessionApp.route(
+ '/',
+ createOpenCodeServerTerminalSocketRoutes(resolveTerminalService, sessionWs.upgradeWebSocket, [TRUSTED_ORIGIN]),
+)
+
function clientUrl(ptyID: string, directory = DIRECTORY, cursor?: number): string {
const url = new URL(`ws://127.0.0.1:${managerPort}/${SERVER_ID}/terminals/${ptyID}/connect`)
url.searchParams.set('directory', directory)
@@ -105,13 +132,17 @@ beforeAll(async () => {
upstream = await startWebSocketServer({ app: upstreamApp, injectWebSocket: upstreamWs.injectWebSocket })
manager = await startWebSocketServer({ app: managerApp, injectWebSocket: managerWs.injectWebSocket })
managerPort = manager.port
+ sessionServer = await startWebSocketServer({ app: sessionApp, injectWebSocket: sessionWs.injectWebSocket })
+ sessionPort = sessionServer.port
})
afterAll(async () => {
upstreamWs.wss.close()
managerWs.wss.close()
+ sessionWs.wss.close()
await upstream.close()
await manager.close()
+ await sessionServer.close()
})
describe('OpenCode Server Terminal Socket Route', () => {
@@ -177,3 +208,28 @@ describe('OpenCode Server Terminal Socket Route', () => {
expect(outcome).toEqual({ upgraded: false, status: 400 })
})
})
+
+describe('OpenCode Server Terminal Socket session binding', () => {
+ it('closes the socket when its session is deleted', async () => {
+ const socket = new WebSocket(`ws://127.0.0.1:${sessionPort}/${SERVER_ID}/terminals/pty-1/connect?directory=${encodeURIComponent(DIRECTORY)}`)
+ socket.binaryType = 'arraybuffer'
+ const messages: Array = []
+ socket.addEventListener('message', (event) => {
+ messages.push(event.data as string | ArrayBuffer)
+ })
+
+ await openEvent(socket)
+ socket.send('echo session')
+ await waitFor(() => messages.includes('echo session'))
+
+ const closeEvent = new Promise((resolve) => {
+ socket.addEventListener('close', (event) => resolve(event), { once: true })
+ })
+
+ closeSessionConnections(SESSION_ID)
+
+ const event = await closeEvent
+ expect(event.code).toBe(SESSION_CONNECTION_CLOSE_CODE)
+ expect(event.reason).toBe(SESSION_CONNECTION_CLOSE_REASON)
+ })
+})
diff --git a/backend/test/routes/remote-access-headers.test.ts b/backend/test/routes/remote-access-headers.test.ts
new file mode 100644
index 000000000..87c4af332
--- /dev/null
+++ b/backend/test/routes/remote-access-headers.test.ts
@@ -0,0 +1,220 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Database } from 'bun:sqlite'
+import { Hono } from 'hono'
+import { ENV } from '@opencode-manager/shared/config/env'
+
+const loggerMocks = vi.hoisted(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+}))
+
+vi.mock('../../src/utils/logger', () => ({ logger: loggerMocks }))
+
+import { createTestDb } from '../helpers/assistant-workspace'
+import { expectNoSecretsLogged } from '../helpers/log-assertions'
+import {
+ CLOUDFLARE_CLIENT_ID,
+ CLOUDFLARE_CLIENT_SECRET,
+ CLOUDFLARE_HEADERS,
+ CLOUDFLARE_SECRET_VALUES,
+} from '../helpers/remote-access-headers'
+import { decryptSecret } from '../../src/utils/crypto'
+import { RemoteAccessHeadersService, REMOTE_ACCESS_HEADERS_SECRET_KEY } from '../../src/services/remote-access-headers'
+import { createRemoteAccessHeaderRoutes } from '../../src/routes/remote-access-headers'
+
+const REPLACEMENT_SECRET_VALUE = 'replacement-secret-77aa13c5'
+
+const SECRET_VALUES = [...CLOUDFLARE_SECRET_VALUES, REPLACEMENT_SECRET_VALUE]
+
+const mutableAuth = ENV.AUTH as unknown as { SECRET: string }
+const originalSecret = ENV.AUTH.SECRET
+
+function buildHeaders(count: number) {
+ return Array.from({ length: count }, (_, index) => ({ name: `X-Remote-Header-${index}`, value: `value-${index}` }))
+}
+
+describe('remote access header routes', () => {
+ let db: Database
+ let service: RemoteAccessHeadersService
+ let app: Hono
+
+ beforeEach(() => {
+ db = createTestDb()
+ service = new RemoteAccessHeadersService(db)
+ app = new Hono()
+ app.route('/remote-access-headers', createRemoteAccessHeaderRoutes(service))
+ })
+
+ afterEach(() => {
+ mutableAuth.SECRET = originalSecret
+ vi.clearAllMocks()
+ })
+
+ const readSummary = () => app.request('/remote-access-headers')
+
+ const putHeaders = (headers: Array<{ name: string; value?: string }>) =>
+ app.request('/remote-access-headers', {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ headers }),
+ })
+
+ it('reads an empty configuration', async () => {
+ const res = await readSummary()
+
+ expect(res.status).toBe(200)
+ await expect(res.json()).resolves.toEqual({ headers: [], unreadable: false })
+ })
+
+ it('stores headers without exposing their values', async () => {
+ const res = await putHeaders(CLOUDFLARE_HEADERS)
+
+ expect(res.status).toBe(200)
+ const body = await res.text()
+ expect(JSON.parse(body)).toEqual({
+ headers: [
+ { name: 'CF-Access-Client-Id', hasValue: true },
+ { name: 'CF-Access-Client-Secret', hasValue: true },
+ ],
+ unreadable: false,
+ })
+ expect(body).not.toContain(CLOUDFLARE_CLIENT_ID)
+ expect(body).not.toContain(CLOUDFLARE_CLIENT_SECRET)
+
+ const readBody = await (await readSummary()).text()
+ expect(readBody).not.toContain(CLOUDFLARE_CLIENT_ID)
+ expect(readBody).not.toContain(CLOUDFLARE_CLIENT_SECRET)
+ })
+
+ it('stores header values encrypted at rest', async () => {
+ await putHeaders(CLOUDFLARE_HEADERS)
+
+ const row = db
+ .prepare('SELECT value FROM app_secrets WHERE key = ?')
+ .get(REMOTE_ACCESS_HEADERS_SECRET_KEY) as { value: string } | undefined
+
+ expect(row).toBeDefined()
+ const encrypted = row?.value ?? ''
+ expect(encrypted).not.toContain(CLOUDFLARE_CLIENT_ID)
+ expect(encrypted).not.toContain(CLOUDFLARE_CLIENT_SECRET)
+ expect(JSON.parse(decryptSecret(encrypted))).toEqual(CLOUDFLARE_HEADERS)
+ })
+
+ it('keeps a stored value when the replacement value is blank', async () => {
+ await putHeaders(CLOUDFLARE_HEADERS)
+
+ const res = await putHeaders([{ name: 'CF-Access-Client-Secret', value: '' }])
+
+ expect(res.status).toBe(200)
+ expect(service.read()).toEqual({
+ headers: [{ name: 'CF-Access-Client-Secret', value: CLOUDFLARE_CLIENT_SECRET }],
+ unreadable: false,
+ })
+ })
+
+ it('uses a supplied value over the stored one', async () => {
+ await putHeaders(CLOUDFLARE_HEADERS)
+
+ const res = await putHeaders([{ name: 'CF-Access-Client-Secret', value: REPLACEMENT_SECRET_VALUE }])
+
+ expect(res.status).toBe(200)
+ expect(service.read().headers).toEqual([
+ { name: 'CF-Access-Client-Secret', value: REPLACEMENT_SECRET_VALUE },
+ ])
+ })
+
+ it('rejects a blank value for a header that has no stored value', async () => {
+ const res = await putHeaders([{ name: 'CF-Access-Client-Id', value: '' }])
+
+ expect(res.status).toBe(400)
+ await expect(res.json()).resolves.toMatchObject({ error: 'A value is required for CF-Access-Client-Id' })
+ })
+
+ it('rejects a reserved header name', async () => {
+ const res = await putHeaders([{ name: 'Cookie', value: 'session=1' }])
+
+ expect(res.status).toBe(400)
+ expect(service.read().headers).toEqual([])
+ })
+
+ it('rejects more than eight headers', async () => {
+ const res = await putHeaders(buildHeaders(9))
+
+ expect(res.status).toBe(400)
+ expect(service.read().headers).toEqual([])
+ })
+
+ it('rejects duplicate header names after merging', async () => {
+ const res = await putHeaders([
+ { name: 'CF-Access-Client-Id', value: 'first' },
+ { name: 'cf-access-client-id', value: 'second' },
+ ])
+
+ expect(res.status).toBe(400)
+ expect(service.read().headers).toEqual([])
+ })
+
+ it('rejects a value with a line break after merging', async () => {
+ const res = await putHeaders([{ name: 'X-Remote-Header', value: 'line\r\nbreak' }])
+
+ expect(res.status).toBe(400)
+ expect(service.read().headers).toEqual([])
+ })
+
+ it('clears stored headers with an empty replacement', async () => {
+ await putHeaders(CLOUDFLARE_HEADERS)
+
+ const res = await putHeaders([])
+
+ expect(res.status).toBe(200)
+ await expect(res.json()).resolves.toEqual({ headers: [], unreadable: false })
+ await expect((await readSummary()).json()).resolves.toEqual({ headers: [], unreadable: false })
+ })
+
+ it('clears stored headers on delete', async () => {
+ await putHeaders(CLOUDFLARE_HEADERS)
+
+ const res = await app.request('/remote-access-headers', { method: 'DELETE' })
+
+ expect(res.status).toBe(200)
+ await expect(res.json()).resolves.toEqual({ headers: [], unreadable: false })
+ await expect((await readSummary()).json()).resolves.toEqual({ headers: [], unreadable: false })
+ })
+
+ it('reports headers that cannot be decrypted as unreadable and unset', async () => {
+ await putHeaders(CLOUDFLARE_HEADERS)
+ mutableAuth.SECRET = 'a-different-auth-secret'
+
+ await expect((await readSummary()).json()).resolves.toEqual({ headers: [], unreadable: true })
+ expect(service.read()).toEqual({ headers: [], unreadable: true })
+ })
+
+ it('recovers the stored headers when the original secret returns', async () => {
+ await putHeaders(CLOUDFLARE_HEADERS)
+ mutableAuth.SECRET = 'a-different-auth-secret'
+ await readSummary()
+ mutableAuth.SECRET = originalSecret
+
+ expect(service.read().headers).toEqual(CLOUDFLARE_HEADERS)
+ })
+
+ describe('logging', () => {
+ afterEach(() => {
+ expectNoSecretsLogged(loggerMocks, SECRET_VALUES)
+ })
+
+ it('logs a write without values', async () => {
+ await putHeaders(CLOUDFLARE_HEADERS)
+ await putHeaders([{ name: 'CF-Access-Client-Secret', value: REPLACEMENT_SECRET_VALUE }])
+ await app.request('/remote-access-headers', { method: 'DELETE' })
+ })
+
+ it('logs an unreadable store without values', async () => {
+ await putHeaders(CLOUDFLARE_HEADERS)
+ mutableAuth.SECRET = 'a-different-auth-secret'
+ await readSummary()
+ })
+ })
+})
diff --git a/backend/test/routes/repo-terminal-socket.test.ts b/backend/test/routes/repo-terminal-socket.test.ts
index efebec97b..35ad2068f 100644
--- a/backend/test/routes/repo-terminal-socket.test.ts
+++ b/backend/test/routes/repo-terminal-socket.test.ts
@@ -9,6 +9,13 @@ import { TerminalService } from '../../src/services/terminal'
import type { GitAuthService } from '../../src/services/git-auth'
import type { OpenCodeClient } from '../../src/services/opencode/client'
import type { CredentialProvider } from '../../src/services/credential-provider'
+import type { Session } from '../../src/auth'
+import {
+ closeSessionConnections,
+ registerSessionConnection,
+ SESSION_CONNECTION_CLOSE_CODE,
+ SESSION_CONNECTION_CLOSE_REASON,
+} from '../../src/services/session-connections'
import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server'
import { rawWebSocketHandshake } from '../helpers/raw-websocket-handshake'
import { createStubRepoEnvService } from '../helpers/stub-repo-env-service'
@@ -51,6 +58,7 @@ const credentialProvider = { getGhCliEnv: vi.fn(() => ({})) } as unknown as Cred
let upstream: WebSocketTestServer
let manager: WebSocketTestServer
let authenticated: WebSocketTestServer
+let sessionServer: WebSocketTestServer
let managerPort = 0
let upstreamBase = ''
let capturedUrl: URL | undefined
@@ -123,12 +131,45 @@ authenticatedApp.route(
let authenticatedPort = 0
+const SESSION_ID = 'session-terminal'
+const sessionApp = new Hono<{ Variables: { session: Session['session'] } }>()
+const sessionWs = createNodeWebSocket({ app: sessionApp })
+sessionApp.use('/*', async (c, next) => {
+ c.set('session', {
+ id: SESSION_ID,
+ userId: 'user-1',
+ token: 'token-terminal',
+ expiresAt: new Date(Date.now() + 60_000),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ await next()
+})
+sessionApp.route(
+ '/',
+ createRepoTerminalSocketRoutes(
+ database,
+ gitAuthService,
+ openCodeClient,
+ terminalService,
+ sessionWs.upgradeWebSocket,
+ ),
+)
+
+let sessionPort = 0
+
function clientUrl(ptyID: string, directory = '/tmp/repo'): string {
const url = new URL(`ws://127.0.0.1:${managerPort}/1/terminals/${ptyID}/connect`)
url.searchParams.set('directory', directory)
return url.toString()
}
+function sessionClientUrl(ptyID: string, directory = '/tmp/repo'): string {
+ const url = new URL(`ws://127.0.0.1:${sessionPort}/1/terminals/${ptyID}/connect`)
+ url.searchParams.set('directory', directory)
+ return url.toString()
+}
+
function openEvent(socket: WebSocket): Promise {
return new Promise((resolve, reject) => {
if (socket.readyState === WebSocket.OPEN) {
@@ -166,6 +207,9 @@ beforeAll(async () => {
authenticated = await startWebSocketServer({ app: authenticatedApp, injectWebSocket: authenticatedWs.injectWebSocket })
authenticatedPort = authenticated.port
+ sessionServer = await startWebSocketServer({ app: sessionApp, injectWebSocket: sessionWs.injectWebSocket })
+ sessionPort = sessionServer.port
+
vi.mocked(resolveRepoOrAssistant).mockImplementation((_db, id) => (id === 1 ? readyRepo : null))
vi.mocked(resolveRepoWorkingDirectory).mockImplementation(async (_repo, directory) => {
if (directory === undefined || directory === '/tmp/repo') return '/tmp/repo'
@@ -178,9 +222,11 @@ afterAll(async () => {
upstreamWs.wss.close()
managerWs.wss.close()
authenticatedWs.wss.close()
+ sessionWs.wss.close()
await upstream.close()
await manager.close()
await authenticated.close()
+ await sessionServer.close()
})
describe('Repo Terminal Socket Route', () => {
@@ -232,6 +278,59 @@ describe('Repo Terminal Socket Route', () => {
})
})
+describe('Repo Terminal Socket session binding', () => {
+ it('closes the socket when its session is deleted', async () => {
+ const socket = new WebSocket(sessionClientUrl('pty-1'))
+ socket.binaryType = 'arraybuffer'
+ const messages: Array = []
+ socket.addEventListener('message', (event) => {
+ messages.push(event.data as string | ArrayBuffer)
+ })
+
+ await openEvent(socket)
+ await waitFor(() => messages.length === 1)
+
+ const closeEvent = new Promise((resolve) => {
+ socket.addEventListener('close', (event) => resolve(event), { once: true })
+ })
+ const otherSessionClose = vi.fn()
+ registerSessionConnection('session-terminal-other', otherSessionClose)
+
+ closeSessionConnections(SESSION_ID)
+
+ const event = await closeEvent
+ expect(event.code).toBe(SESSION_CONNECTION_CLOSE_CODE)
+ expect(event.reason).toBe(SESSION_CONNECTION_CLOSE_REASON)
+ expect(otherSessionClose).not.toHaveBeenCalled()
+ closeSessionConnections('session-terminal-other')
+ })
+
+ it('unregisters the socket so a repeated session close does not fire again', async () => {
+ const socket = new WebSocket(sessionClientUrl('pty-1'))
+ socket.binaryType = 'arraybuffer'
+ const messages: Array = []
+ socket.addEventListener('message', (event) => {
+ messages.push(event.data as string | ArrayBuffer)
+ })
+
+ await openEvent(socket)
+ await waitFor(() => messages.length === 1)
+
+ const closeEvent = new Promise((resolve) => {
+ socket.addEventListener('close', (event) => resolve(event), { once: true })
+ })
+ const survivor = vi.fn()
+ registerSessionConnection(SESSION_ID, survivor)
+
+ closeSessionConnections(SESSION_ID)
+ await closeEvent
+
+ expect(survivor).toHaveBeenCalledTimes(1)
+ closeSessionConnections(SESSION_ID)
+ expect(survivor).toHaveBeenCalledTimes(1)
+ })
+})
+
describe('Repo Terminal Socket Origin validation', () => {
const path = `/1/terminals/pty-1/connect?directory=${encodeURIComponent('/tmp/repo')}`
diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts
index 66f87fc6a..26db680c7 100644
--- a/backend/test/routes/settings.test.ts
+++ b/backend/test/routes/settings.test.ts
@@ -2728,4 +2728,13 @@ describe('Settings Routes - versions, directory files, skills, MCP and maintenan
expect(await res.json()).toEqual({ error: 'Failed to discover models' })
})
})
+
+ describe('Remote access header routes', () => {
+ it('serves the remote access header summary under settings', async () => {
+ const res = await app.request(new Request('http://localhost/remote-access-headers'))
+
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ headers: [], unreadable: false })
+ })
+ })
})
diff --git a/backend/test/routes/sse.test.ts b/backend/test/routes/sse.test.ts
index 95ed9f488..ab962400a 100644
--- a/backend/test/routes/sse.test.ts
+++ b/backend/test/routes/sse.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { Hono } from 'hono'
const sseMocks = vi.hoisted(() => ({
addClient: vi.fn(),
@@ -20,14 +21,49 @@ vi.mock('../../src/utils/logger', () => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
+ debug: vi.fn(),
},
}))
import { createSSERoutes } from '../../src/routes/sse'
+import { createAuthMiddleware } from '../../src/auth/middleware'
+import { closeSessionConnections } from '../../src/services/session-connections'
+import type { AuthInstance, Session } from '../../src/auth'
import { encodeSSEFrame } from '../../src/utils/sse-frame'
const decoder = new TextDecoder()
+function createSession(sessionId: string): Session {
+ return {
+ session: {
+ id: sessionId,
+ userId: 'user-1',
+ token: `token-${sessionId}`,
+ expiresAt: new Date(Date.now() + 60_000),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ user: {
+ id: 'user-1',
+ name: 'Test User',
+ email: 'test@example.com',
+ emailVerified: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ role: 'user',
+ },
+ }
+}
+
+function createAuthenticatedApp(sessionId: string) {
+ const app = new Hono<{ Variables: { session: Session['session']; user: Session['user'] } }>()
+ app.use('/*', createAuthMiddleware({
+ api: { getSession: async () => createSession(sessionId) },
+ } as unknown as AuthInstance))
+ app.route('/', createSSERoutes())
+ return app
+}
+
async function readChunk(reader: { read(): Promise<{ value?: Uint8Array }> }): Promise {
const { value } = await reader.read()
return decoder.decode(value)
@@ -317,4 +353,56 @@ describe('SSE Routes', () => {
})
})
})
+
+ describe('GET /stream session binding', () => {
+ it('ends the stream once its session is closed', async () => {
+ const cleanup = vi.fn()
+ sseMocks.addClient.mockReturnValue(cleanup)
+ const sessionId = 'session-revoked'
+ const authenticatedApp = createAuthenticatedApp(sessionId)
+
+ const res = await authenticatedApp.fetch(new Request('http://localhost/stream'))
+ const reader = res.body!.getReader()
+ try {
+ expect(await readChunk(reader)).toContain('event: connected')
+
+ closeSessionConnections(sessionId)
+
+ await vi.waitFor(() => expect(cleanup).toHaveBeenCalledTimes(1))
+ expect(await reader.read()).toEqual({ done: true, value: undefined })
+
+ closeSessionConnections(sessionId)
+ expect(cleanup).toHaveBeenCalledTimes(1)
+ } finally {
+ await reader.cancel()
+ }
+ })
+
+ it('keeps streaming while another session is closed', async () => {
+ const cleanup = vi.fn()
+ sseMocks.addClient.mockReturnValue(cleanup)
+ const sessionId = 'session-active'
+ const authenticatedApp = createAuthenticatedApp(sessionId)
+
+ const res = await authenticatedApp.fetch(new Request('http://localhost/stream'))
+ const reader = res.body!.getReader()
+ try {
+ expect(await readChunk(reader)).toContain('event: connected')
+ const [, writeEvent] = sseMocks.addClient.mock.calls[0] as [
+ string,
+ (event: string, data: string) => void,
+ ]
+
+ closeSessionConnections('session-other')
+
+ writeEvent('message', '{"still":"open"}')
+ expect(await readChunk(reader)).toBe('event: message\ndata: {"still":"open"}\n\n')
+ expect(cleanup).not.toHaveBeenCalled()
+ } finally {
+ await reader.cancel()
+ }
+
+ expect(cleanup).toHaveBeenCalledTimes(1)
+ })
+ })
})
diff --git a/backend/test/services/notification-subscriptions.test.ts b/backend/test/services/notification-subscriptions.test.ts
new file mode 100644
index 000000000..28fe208af
--- /dev/null
+++ b/backend/test/services/notification-subscriptions.test.ts
@@ -0,0 +1,123 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { Database } from 'bun:sqlite'
+import { NotificationService } from '../../src/services/notification'
+import {
+ deletePushSubscriptionsForSession,
+ ensurePushSubscriptionsTable,
+} from '../../src/db/push-subscriptions'
+
+const USER_ID = 'user-1'
+
+const LEGACY_TABLE = `
+ CREATE TABLE push_subscriptions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ endpoint TEXT NOT NULL UNIQUE,
+ p256dh TEXT NOT NULL,
+ auth TEXT NOT NULL,
+ device_name TEXT,
+ created_at INTEGER NOT NULL,
+ last_used_at INTEGER
+ )
+`
+
+function columnNames(db: Database, table: string): string[] {
+ return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>)
+ .map((column) => column.name)
+}
+
+function indexNames(db: Database, table: string): string[] {
+ return (db.prepare(`PRAGMA index_list(${table})`).all() as Array<{ name: string }>)
+ .map((index) => index.name)
+}
+
+function sessionIdOf(db: Database, endpoint: string): string | null {
+ const row = db
+ .prepare('SELECT session_id FROM push_subscriptions WHERE endpoint = ?')
+ .get(endpoint) as { session_id: string | null } | undefined
+ expect(row).toBeDefined()
+ return row!.session_id
+}
+
+function endpointsOf(db: Database): string[] {
+ return (db.prepare('SELECT endpoint FROM push_subscriptions ORDER BY endpoint').all() as Array<{ endpoint: string }>)
+ .map((row) => row.endpoint)
+}
+
+describe('push subscription sessions', () => {
+ let db: Database
+ let service: NotificationService
+
+ beforeEach(() => {
+ db = new Database(':memory:')
+ service = new NotificationService(db)
+ })
+
+ afterEach(() => {
+ db.close()
+ })
+
+ it('stores the session that registered the subscription', () => {
+ service.saveSubscription(USER_ID, 'https://push.example.com/phone', 'p256dh-key', 'auth-key', 'Phone', 'ses_phone')
+
+ expect(sessionIdOf(db, 'https://push.example.com/phone')).toBe('ses_phone')
+ })
+
+ it('stores no session for a subscription registered without one', () => {
+ service.saveSubscription(USER_ID, 'https://push.example.com/anonymous', 'p256dh-key', 'auth-key', 'Browser')
+
+ expect(sessionIdOf(db, 'https://push.example.com/anonymous')).toBeNull()
+ })
+
+ it('replaces the session id when the same endpoint subscribes again', () => {
+ const endpoint = 'https://push.example.com/phone'
+ service.saveSubscription(USER_ID, endpoint, 'p256dh-key', 'auth-key', 'Phone', 'ses_old')
+ service.saveSubscription(USER_ID, endpoint, 'p256dh-key-2', 'auth-key-2', 'Phone', 'ses_new')
+
+ expect(sessionIdOf(db, endpoint)).toBe('ses_new')
+ expect(service.getSubscriptions(USER_ID)).toHaveLength(1)
+ })
+
+ it('adds the session id column to a database created before it existed', () => {
+ const legacyDb = new Database(':memory:')
+ legacyDb.run(LEGACY_TABLE)
+ legacyDb
+ .prepare(
+ `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device_name, created_at, last_used_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ )
+ .run(USER_ID, 'https://push.example.com/legacy', 'p256dh-key', 'auth-key', 'Old Phone', 1, 1)
+
+ new NotificationService(legacyDb)
+
+ expect(columnNames(legacyDb, 'push_subscriptions')).toContain('session_id')
+ expect(indexNames(legacyDb, 'push_subscriptions')).toContain('idx_push_sub_session')
+ expect(sessionIdOf(legacyDb, 'https://push.example.com/legacy')).toBeNull()
+ legacyDb.close()
+ })
+
+ it('creates the session id column and index on a fresh database', () => {
+ expect(columnNames(db, 'push_subscriptions')).toContain('session_id')
+ expect(indexNames(db, 'push_subscriptions')).toContain('idx_push_sub_session')
+ })
+
+ it('leaves the schema unchanged when the table is ensured twice', () => {
+ ensurePushSubscriptionsTable(db)
+ ensurePushSubscriptionsTable(db)
+
+ expect(columnNames(db, 'push_subscriptions').filter((name) => name === 'session_id')).toHaveLength(1)
+ expect(indexNames(db, 'push_subscriptions').filter((name) => name === 'idx_push_sub_session')).toHaveLength(1)
+ })
+
+ it('deletes only the subscriptions of the revoked session', () => {
+ service.saveSubscription(USER_ID, 'https://push.example.com/phone', 'p256dh-key', 'auth-key', 'Phone', 'ses_phone')
+ service.saveSubscription(USER_ID, 'https://push.example.com/desktop', 'p256dh-key', 'auth-key', 'Desktop', 'ses_desktop')
+ service.saveSubscription(USER_ID, 'https://push.example.com/legacy', 'p256dh-key', 'auth-key', 'Legacy')
+
+ expect(deletePushSubscriptionsForSession(db, 'ses_phone')).toBe(1)
+ expect(endpointsOf(db)).toEqual([
+ 'https://push.example.com/desktop',
+ 'https://push.example.com/legacy',
+ ])
+ })
+})
diff --git a/backend/test/services/preview/gateway.test.ts b/backend/test/services/preview/gateway.test.ts
index 3c4ce0675..479daaaa8 100644
--- a/backend/test/services/preview/gateway.test.ts
+++ b/backend/test/services/preview/gateway.test.ts
@@ -42,6 +42,8 @@ upstreamApp.get('/manager-cookies', () => {
return new Response('ok', { status: 200, headers })
})
+upstreamApp.get('/redirect', (c) => c.redirect(c.req.query('to') ?? '/', 302))
+
upstreamApp.all('/*', async (c) => {
if (c.req.path === '/r') {
return c.redirect(`http://localhost:${upstreamPort}/done`, 302)
@@ -311,6 +313,23 @@ describe('Preview Gateway', () => {
expect(res.headers.get('location')).toBe('/done')
})
+ it('rewrites loopback locations beyond 127.0.0.1 and IPv6 loopback locations to their path', async () => {
+ const store = new PreviewSessionStore()
+ const app = createPreviewGatewayApp(store).app
+ const cookie = await openSession(app, store, upstreamPort)
+
+ for (const host of ['127.0.0.2', '[::1]']) {
+ const target = `http://${host}:${upstreamPort}/wide?x=1`
+ const res = await app.request(
+ `http://localhost/redirect?to=${encodeURIComponent(target)}`,
+ { headers: { cookie }, redirect: 'manual' },
+ )
+
+ expect(res.status).toBe(302)
+ expect(res.headers.get('location')).toBe('/wide?x=1')
+ }
+ })
+
it('returns 502 when the target port is unreachable', async () => {
const closedPort = await reserveClosedPort()
const store = new PreviewSessionStore()
diff --git a/backend/test/services/session-connections.test.ts b/backend/test/services/session-connections.test.ts
new file mode 100644
index 000000000..54e04129a
--- /dev/null
+++ b/backend/test/services/session-connections.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('../../src/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}))
+
+import {
+ closeSessionConnections,
+ registerSessionConnection,
+} from '../../src/services/session-connections'
+import { logger } from '../../src/utils/logger'
+
+describe('session connections registry', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ closeSessionConnections('session-a')
+ closeSessionConnections('session-b')
+ })
+
+ it('closes every connection registered for a session', () => {
+ const first = vi.fn()
+ const second = vi.fn()
+ registerSessionConnection('session-a', first)
+ registerSessionConnection('session-a', second)
+
+ closeSessionConnections('session-a')
+
+ expect(first).toHaveBeenCalledTimes(1)
+ expect(second).toHaveBeenCalledTimes(1)
+ })
+
+ it('clears the session so later closes do not re-invoke connections', () => {
+ const close = vi.fn()
+ registerSessionConnection('session-a', close)
+
+ closeSessionConnections('session-a')
+ closeSessionConnections('session-a')
+
+ expect(close).toHaveBeenCalledTimes(1)
+ })
+
+ it('leaves other sessions connected', () => {
+ const closed = vi.fn()
+ const kept = vi.fn()
+ registerSessionConnection('session-a', closed)
+ registerSessionConnection('session-b', kept)
+
+ closeSessionConnections('session-a')
+
+ expect(closed).toHaveBeenCalledTimes(1)
+ expect(kept).not.toHaveBeenCalled()
+
+ closeSessionConnections('session-b')
+ expect(kept).toHaveBeenCalledTimes(1)
+ })
+
+ it('unregisters a single connection without closing it', () => {
+ const first = vi.fn()
+ const second = vi.fn()
+ const unregisterFirst = registerSessionConnection('session-a', first)
+ registerSessionConnection('session-a', second)
+
+ unregisterFirst()
+ closeSessionConnections('session-a')
+
+ expect(first).not.toHaveBeenCalled()
+ expect(second).toHaveBeenCalledTimes(1)
+ })
+
+ it('treats a repeated unregister as a no-op', () => {
+ const close = vi.fn()
+ const unregister = registerSessionConnection('session-a', close)
+
+ unregister()
+ unregister()
+ registerSessionConnection('session-a', close)
+ closeSessionConnections('session-a')
+
+ expect(close).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps closing the remaining connections when one throws', () => {
+ const failing = vi.fn(() => {
+ throw new Error('socket already closed')
+ })
+ const healthy = vi.fn()
+ registerSessionConnection('session-a', failing)
+ registerSessionConnection('session-a', healthy)
+
+ expect(() => closeSessionConnections('session-a')).not.toThrow()
+
+ expect(failing).toHaveBeenCalledTimes(1)
+ expect(healthy).toHaveBeenCalledTimes(1)
+ expect(logger.error).toHaveBeenCalledTimes(1)
+ })
+
+ it('ignores sessions without registered connections', () => {
+ expect(() => closeSessionConnections('unknown-session')).not.toThrow()
+ expect(logger.error).not.toHaveBeenCalled()
+ })
+})
diff --git a/backend/test/shared/device-pairing-schemas.test.ts b/backend/test/shared/device-pairing-schemas.test.ts
new file mode 100644
index 000000000..5cade570c
--- /dev/null
+++ b/backend/test/shared/device-pairing-schemas.test.ts
@@ -0,0 +1,230 @@
+import { describe, expect, it } from 'vitest'
+import {
+ REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH,
+ REMOTE_ACCESS_HEADER_VALUE_MAX_LENGTH,
+ RedeemDevicePairingCodeResponseSchema,
+ RemoteAccessHeaderDraftValueSchema,
+ RemoteAccessHeaderListSchema,
+ ReplaceRemoteAccessHeadersRequestSchema,
+ buildManagerBrowserPairingLink,
+ parseManagerBrowserPairingLink,
+ readPairingCodeFromHash,
+} from '@opencode-manager/shared/schemas'
+
+const CODE = 'a'.repeat(43)
+
+function header(name: string, value = 'value') {
+ return { name, value }
+}
+
+function encodedLength(headers: Array<{ name: string; value: string }>): number {
+ const params = new URLSearchParams()
+ for (const entry of headers) params.append('h', `${entry.name}:${entry.value}`)
+ return params.toString().length
+}
+
+function headersFillingQrLimit(extraCharacters = 0): Array<{ name: string; value: string }> {
+ const headers = Array.from({ length: 8 }, (_, index) => header(`X-H-${index}`, ''))
+ let remaining = REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH - encodedLength(headers) + extraCharacters
+ return headers.map((entry, index) => {
+ const length = Math.min(
+ Math.ceil(remaining / (headers.length - index)),
+ REMOTE_ACCESS_HEADER_VALUE_MAX_LENGTH,
+ )
+ remaining -= length
+ return { name: entry.name, value: 'a'.repeat(length) }
+ })
+}
+
+describe('RemoteAccessHeaderListSchema', () => {
+ it('accepts Cloudflare Access headers and values containing colons', () => {
+ const result = RemoteAccessHeaderListSchema.safeParse([
+ header('CF-Access-Client-Id', 'abc.access'),
+ header('X-Token', 'a:b:c'),
+ ])
+
+ expect(result.success).toBe(true)
+ })
+
+ it.each([
+ ['a carriage return', 'a\rb'],
+ ['a line feed', 'a\nb'],
+ ['a null byte', 'a\0b'],
+ ])('rejects a value containing %s', (_label, value) => {
+ expect(RemoteAccessHeaderListSchema.safeParse([header('X-Token', value)]).success).toBe(false)
+ })
+
+ it.each(['X Token', 'X:Token', 'X Token:'])('rejects the invalid header name %s', (name) => {
+ expect(RemoteAccessHeaderListSchema.safeParse([header(name)]).success).toBe(false)
+ })
+
+ it.each([
+ 'Cookie',
+ 'set-cookie',
+ 'HOST',
+ 'Origin',
+ 'Content-Length',
+ 'Content-Type',
+ 'Connection',
+ 'Transfer-Encoding',
+ 'Upgrade',
+ 'TE',
+ 'Keep-Alive',
+ 'Proxy-Authorization',
+ 'X-Forwarded-For',
+ 'X-Forwarded-Host',
+ 'X-Forwarded-Proto',
+ 'Forwarded',
+ 'X-Real-IP',
+ 'CF-Connecting-IP',
+ ])('rejects the blocked header name %s in any case', (name) => {
+ expect(RemoteAccessHeaderListSchema.safeParse([header(name)]).success).toBe(false)
+ })
+
+ it('rejects more than eight headers', () => {
+ const headers = Array.from({ length: 9 }, (_, index) => header(`X-Header-${index}`))
+ expect(RemoteAccessHeaderListSchema.safeParse(headers).success).toBe(false)
+ })
+
+ it('accepts a typical Cloudflare Access header pair', () => {
+ const headers = [
+ header('CF-Access-Client-Id', `${'a'.repeat(64)}.access`),
+ header('CF-Access-Client-Secret', 'b'.repeat(64)),
+ ]
+
+ expect(RemoteAccessHeaderListSchema.safeParse(headers).success).toBe(true)
+ expect(encodedLength(headers)).toBeLessThan(REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH)
+ })
+
+ it('accepts headers that encode to exactly the QR limit', () => {
+ const headers = headersFillingQrLimit()
+
+ expect(encodedLength(headers)).toBe(REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH)
+ expect(RemoteAccessHeaderListSchema.safeParse(headers).success).toBe(true)
+ })
+
+ it('rejects headers that encode to one character over the QR limit', () => {
+ const headers = headersFillingQrLimit(1)
+
+ expect(encodedLength(headers)).toBe(REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH + 1)
+ expect(RemoteAccessHeaderListSchema.safeParse(headers).success).toBe(false)
+ })
+
+ it('rejects eight 512-character values because they no longer fit in the QR code', () => {
+ const headers = Array.from({ length: 8 }, (_, index) => header(`X-Header-${index}`, 'a'.repeat(512)))
+
+ expect(RemoteAccessHeaderListSchema.safeParse(headers).success).toBe(false)
+ })
+
+ it('reports the encoded limit when the list is too long for a QR code', () => {
+ const result = RemoteAccessHeaderListSchema.safeParse(headersFillingQrLimit(1))
+
+ expect(result.error?.issues.map((issue) => issue.message)).toContain(
+ `Remote access headers are too long to fit in a QR code (limit ${REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH} encoded characters)`,
+ )
+ })
+
+ it('rejects a 513-character value', () => {
+ expect(RemoteAccessHeaderListSchema.safeParse([header('X-Token', 'a'.repeat(513))]).success).toBe(false)
+ })
+
+ it('rejects duplicate names that differ only in case', () => {
+ expect(
+ RemoteAccessHeaderListSchema.safeParse([header('X-Token', 'one'), header('x-token', 'two')]).success,
+ ).toBe(false)
+ })
+})
+
+describe('RemoteAccessHeaderDraftValueSchema', () => {
+ it('accepts a blank value, meaning the stored value is kept', () => {
+ expect(RemoteAccessHeaderDraftValueSchema.safeParse(' ')).toEqual({ success: true, data: '' })
+ })
+
+ it('trims a provided value', () => {
+ const result = RemoteAccessHeaderDraftValueSchema.safeParse(' secret ')
+
+ expect(result.success && result.data).toBe('secret')
+ })
+
+ it.each([
+ ['a carriage return', 'a\rb'],
+ ['a line feed', 'a\nb'],
+ ['a null byte', 'a\0b'],
+ ])('rejects a value containing %s', (_label, value) => {
+ expect(RemoteAccessHeaderDraftValueSchema.safeParse(value).success).toBe(false)
+ })
+
+ it('accepts a draft entry without a value in the replace request', () => {
+ const result = ReplaceRemoteAccessHeadersRequestSchema.safeParse({
+ headers: [{ name: 'CF-Access-Client-Id' }, { name: 'CF-Access-Client-Secret', value: 'secret' }],
+ })
+
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects a draft entry whose value contains a line break', () => {
+ const result = ReplaceRemoteAccessHeadersRequestSchema.safeParse({
+ headers: [{ name: 'CF-Access-Client-Secret', value: 'sec\r\nret' }],
+ })
+
+ expect(result.success).toBe(false)
+ })
+})
+
+describe('RedeemDevicePairingCodeResponseSchema', () => {
+ it('parses the user returned by the redeem endpoint', () => {
+ const response = { user: { id: 'user_1', name: 'Chris', email: 'chris@example.com' } }
+
+ expect(RedeemDevicePairingCodeResponseSchema.parse(response)).toEqual(response)
+ })
+
+ it('rejects a response without a user email', () => {
+ expect(RedeemDevicePairingCodeResponseSchema.safeParse({ user: { id: 'user_1', name: 'Chris' } }).success).toBe(
+ false,
+ )
+ })
+})
+
+describe('buildManagerBrowserPairingLink', () => {
+ it('builds a browser pairing link with the code in the fragment', () => {
+ expect(buildManagerBrowserPairingLink('https://m.example.com', CODE)).toBe(
+ `https://m.example.com/pair#c=${CODE}`,
+ )
+ })
+
+ it('trims trailing slashes and whitespace from the origin', () => {
+ expect(buildManagerBrowserPairingLink(' https://m.example.com/ ', CODE)).toBe(
+ `https://m.example.com/pair#c=${CODE}`,
+ )
+ })
+})
+
+describe('parseManagerBrowserPairingLink', () => {
+ it('parses a browser pairing link built by the shared builder', () => {
+ expect(parseManagerBrowserPairingLink(buildManagerBrowserPairingLink('https://m.example.com', CODE))).toEqual({
+ origin: 'https://m.example.com',
+ code: CODE,
+ })
+ })
+
+ it.each([
+ ['a wrong path', `https://m.example.com/other#c=${CODE}`],
+ ['a missing code', 'https://m.example.com/pair'],
+ ['an empty code', 'https://m.example.com/pair#c='],
+ ['a non-http scheme', `ocm://link?v=2&kind=manager#c=${CODE}`],
+ ['garbage text', 'not a pairing link'],
+ ])('returns null for %s', (_label, text) => {
+ expect(parseManagerBrowserPairingLink(text)).toBeNull()
+ })
+})
+
+describe('readPairingCodeFromHash', () => {
+ it('reads a valid code from the fragment', () => {
+ expect(readPairingCodeFromHash(`#c=${CODE}`)).toBe(CODE)
+ })
+
+ it('returns null for a missing or invalid code', () => {
+ expect(readPairingCodeFromHash('')).toBeNull()
+ expect(readPairingCodeFromHash('#c=short')).toBeNull()
+ })
+})
diff --git a/backend/test/shared/manager-app-link.test.ts b/backend/test/shared/manager-app-link.test.ts
new file mode 100644
index 000000000..5adbb3893
--- /dev/null
+++ b/backend/test/shared/manager-app-link.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from 'vitest'
+import {
+ OPENCODE_SERVER_LINK_VERSION,
+ buildManagerAppLink,
+ buildOpenCodeServerLink,
+ parseManagerAppLink,
+ parseOpenCodeServerLink,
+ type ManagerAppLink,
+} from '@opencode-manager/shared/schemas'
+
+const CODE = 'a'.repeat(43)
+
+const MANAGER_LINK: ManagerAppLink = {
+ kind: 'manager',
+ name: 'Home',
+ baseUrl: 'https://m.example.com',
+ code: CODE,
+ headers: [
+ { name: 'CF-Access-Client-Id', value: 'id.access' },
+ { name: 'X-Secret', value: 'a:b=c&d' },
+ ],
+}
+
+const OPENCODE_LINK = {
+ kind: 'opencode' as const,
+ name: 'build-host',
+ baseUrl: 'http://192.168.1.20:4096',
+ pairing: { pairUrl: 'http://127.0.0.1:4096/redeem', code: CODE },
+}
+
+function managerLink(extra: Record): string {
+ const params = new URLSearchParams({
+ v: OPENCODE_SERVER_LINK_VERSION,
+ kind: 'manager',
+ name: 'Home',
+ url: 'https://m.example.com',
+ code: CODE,
+ })
+ for (const [key, value] of Object.entries(extra)) {
+ params.delete(key)
+ for (const item of Array.isArray(value) ? value : [value]) params.append(key, item)
+ }
+ return `ocm://link?${params.toString()}`
+}
+
+describe('buildManagerAppLink', () => {
+ it('builds an ocm link carrying the version and the manager kind', () => {
+ const link = buildManagerAppLink(MANAGER_LINK)
+ const params = new URL(link).searchParams
+
+ expect(link.startsWith('ocm://link?')).toBe(true)
+ expect(params.get('v')).toBe(OPENCODE_SERVER_LINK_VERSION)
+ expect(params.get('kind')).toBe('manager')
+ })
+
+ it('keeps the exact wire format of a manager link', () => {
+ expect(buildManagerAppLink(MANAGER_LINK)).toBe(
+ `ocm://link?v=2&kind=manager&name=Home&url=https%3A%2F%2Fm.example.com&code=${CODE}` +
+ '&h=CF-Access-Client-Id%3Aid.access&h=X-Secret%3Aa%3Ab%3Dc%26d',
+ )
+ })
+
+ it('appends one h parameter per header in order', () => {
+ const params = new URL(buildManagerAppLink(MANAGER_LINK)).searchParams
+
+ expect(params.getAll('h')).toEqual(['CF-Access-Client-Id:id.access', 'X-Secret:a:b=c&d'])
+ })
+})
+
+describe('buildOpenCodeServerLink', () => {
+ it('keeps the exact wire format of an opencode link', () => {
+ expect(buildOpenCodeServerLink(OPENCODE_LINK)).toBe(
+ `ocm://link?v=2&kind=opencode&name=build-host&url=http%3A%2F%2F192.168.1.20%3A4096` +
+ `&pair=http%3A%2F%2F127.0.0.1%3A4096%2Fredeem&code=${CODE}`,
+ )
+ })
+})
+
+describe('parseManagerAppLink', () => {
+ it('round-trips a manager link with headers in order', () => {
+ expect(parseManagerAppLink(buildManagerAppLink(MANAGER_LINK))).toEqual(MANAGER_LINK)
+ })
+
+ it('round-trips a manager link without headers', () => {
+ const link = { ...MANAGER_LINK, headers: [] }
+
+ expect(parseManagerAppLink(buildManagerAppLink(link))).toEqual(link)
+ })
+
+ it.each([
+ ['an opencode link', buildOpenCodeServerLink(OPENCODE_LINK)],
+ ['a wrong version', managerLink({ v: '3' })],
+ ['a header without a colon', managerLink({ h: 'X-Secret' })],
+ ['a blocked header name', managerLink({ h: 'Cookie:session' })],
+ ['a forwarding header name', managerLink({ h: 'X-Forwarded-For:1.2.3.4' })],
+ ['more than eight headers', managerLink({ h: Array.from({ length: 9 }, (_, index) => `X-Header-${index}:value`) })],
+ ['garbage text', 'not a link'],
+ ])('returns null for %s', (_label, text) => {
+ expect(parseManagerAppLink(text)).toBeNull()
+ })
+})
+
+describe('manager and opencode link kinds', () => {
+ it('does not parse a manager link as an opencode link', () => {
+ expect(parseOpenCodeServerLink(buildManagerAppLink(MANAGER_LINK))).toBeNull()
+ })
+
+ it('keeps the opencode link round trip unchanged', () => {
+ expect(parseOpenCodeServerLink(buildOpenCodeServerLink(OPENCODE_LINK))).toEqual(OPENCODE_LINK)
+ })
+})
diff --git a/backend/test/shared/trusted-origin.test.ts b/backend/test/shared/trusted-origin.test.ts
new file mode 100644
index 000000000..baccab583
--- /dev/null
+++ b/backend/test/shared/trusted-origin.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest'
+import { getTrustedOrigins, isTrustedOrigin } from '@opencode-manager/shared/config/env'
+
+const TRUSTED = ['https://m.example.com', 'http://localhost:5173'] as const
+
+describe('isTrustedOrigin', () => {
+ it('accepts a listed origin', () => {
+ expect(isTrustedOrigin('https://m.example.com', {}, TRUSTED)).toBe(true)
+ expect(isTrustedOrigin('https://m.example.com', { requireOrigin: true }, TRUSTED)).toBe(true)
+ })
+
+ it('rejects an unlisted origin', () => {
+ expect(isTrustedOrigin('https://evil.example', {}, TRUSTED)).toBe(false)
+ expect(isTrustedOrigin('https://evil.example', { requireOrigin: true }, TRUSTED)).toBe(false)
+ })
+
+ it('treats a missing origin as trusted unless an origin is required', () => {
+ expect(isTrustedOrigin(undefined, {}, TRUSTED)).toBe(true)
+ expect(isTrustedOrigin(undefined, { requireOrigin: true }, TRUSTED)).toBe(false)
+ })
+
+ it('treats an empty origin as missing', () => {
+ expect(isTrustedOrigin('', {}, TRUSTED)).toBe(true)
+ expect(isTrustedOrigin('', { requireOrigin: true }, TRUSTED)).toBe(false)
+ })
+
+ it('treats the null origin as untrusted', () => {
+ expect(isTrustedOrigin('null', {}, TRUSTED)).toBe(false)
+ expect(isTrustedOrigin('null', { requireOrigin: true }, [...TRUSTED, 'null'])).toBe(false)
+ })
+
+ it('reads the configured origins by default', () => {
+ const configured = getTrustedOrigins()
+ expect(configured.length).toBeGreaterThan(0)
+ expect(isTrustedOrigin(configured[0])).toBe(true)
+ expect(isTrustedOrigin('https://not-configured.example')).toBe(false)
+ })
+})
diff --git a/backend/test/shared/url-hosts.test.ts b/backend/test/shared/url-hosts.test.ts
new file mode 100644
index 000000000..bc69ca7e8
--- /dev/null
+++ b/backend/test/shared/url-hosts.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import { isLoopbackHost, isPrivateNetworkHost } from '@opencode-manager/shared/utils'
+
+describe('isLoopbackHost', () => {
+ it.each(['localhost', 'LOCALHOST', '127.0.0.1', '127.4.5.6', '::1', '[::1]'])(
+ 'treats %s as loopback',
+ (host) => {
+ expect(isLoopbackHost(host)).toBe(true)
+ },
+ )
+
+ it.each(['192.168.1.2', 'example.com', '100.64.0.1', '10.0.0.1'])(
+ 'treats %s as not loopback',
+ (host) => {
+ expect(isLoopbackHost(host)).toBe(false)
+ },
+ )
+})
+
+describe('isPrivateNetworkHost', () => {
+ it.each([
+ '10.0.0.1',
+ '172.16.0.1',
+ '172.31.255.255',
+ '192.168.1.20',
+ '169.254.10.1',
+ '100.64.0.1',
+ '100.127.255.255',
+ 'fd7a:115c:a1e0::1',
+ '[fd7a:115c:a1e0::1]',
+ 'fc00::1',
+ ])('treats %s as private', (host) => {
+ expect(isPrivateNetworkHost(host)).toBe(true)
+ })
+
+ it.each([
+ '172.32.0.1',
+ '100.128.0.1',
+ '8.8.8.8',
+ '127.0.0.1',
+ '::1',
+ 'fe80::1',
+ 'example.ts.net',
+ 'host.local',
+ '256.1.1.1',
+ '10.0.0',
+ ])('treats %s as public', (host) => {
+ expect(isPrivateNetworkHost(host)).toBe(false)
+ })
+})
diff --git a/backend/test/shared/user-agent.test.ts b/backend/test/shared/user-agent.test.ts
new file mode 100644
index 000000000..94b85e276
--- /dev/null
+++ b/backend/test/shared/user-agent.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import { describeUserAgent } from '@opencode-manager/shared/utils'
+
+const IPHONE_SAFARI =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1'
+const IPHONE_CHROME =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.88 Mobile/15E148 Safari/604.1'
+const ANDROID_CHROME =
+ 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36'
+const MACOS_FIREFOX =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0'
+const WINDOWS_EDGE =
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.80'
+
+describe('describeUserAgent', () => {
+ it('describes iPhone Safari', () => {
+ expect(describeUserAgent(IPHONE_SAFARI)).toBe('Safari on iPhone')
+ })
+
+ it('describes iPhone Chrome', () => {
+ expect(describeUserAgent(IPHONE_CHROME)).toBe('Chrome on iPhone')
+ })
+
+ it('describes Android Chrome without reporting Linux', () => {
+ expect(describeUserAgent(ANDROID_CHROME)).toBe('Chrome on Android')
+ })
+
+ it('describes macOS Firefox', () => {
+ expect(describeUserAgent(MACOS_FIREFOX)).toBe('Firefox on macOS')
+ })
+
+ it('describes Windows Edge before Chrome', () => {
+ expect(describeUserAgent(WINDOWS_EDGE)).toBe('Edge on Windows')
+ })
+
+ it('falls back to Unknown device for empty input', () => {
+ expect(describeUserAgent(null)).toBe('Unknown device')
+ expect(describeUserAgent('')).toBe('Unknown device')
+ })
+
+ it('returns an unrecognized user agent verbatim', () => {
+ expect(describeUserAgent('curl/8.4.0')).toBe('curl/8.4.0')
+ })
+
+ it('truncates a long unrecognized user agent to 80 characters', () => {
+ const result = describeUserAgent('x'.repeat(120))
+ expect(result).toBe('x'.repeat(80))
+ expect(result.length).toBe(80)
+ })
+})
diff --git a/frontend/package.json b/frontend/package.json
index 33d670686..c81dd3aa0 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -55,6 +55,7 @@
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
+ "uqr": "^0.1.3",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ddb4e713c..e203f12e2 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2,7 +2,7 @@
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
import { createBrowserRouter, RouterProvider, Outlet, useNavigate, useLocation, useParams, Navigate } from 'react-router-dom'
import { useEffect, useRef, useCallback, type ReactNode } from 'react'
-import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas'
+import { DEVICE_PAIRING_PATH, LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas'
import { Toaster } from 'sonner'
import { Repos } from './pages/Repos'
import { RepoDetail } from './pages/RepoDetail'
@@ -12,6 +12,7 @@ import { SessionDetail } from './pages/SessionDetail'
import { Schedules } from './pages/Schedules'
import { GlobalSchedules } from './pages/GlobalSchedules'
import { Login } from './pages/Login'
+import { Pair } from './pages/Pair'
import { Register } from './pages/Register'
import { Setup } from './pages/Setup'
import { AssistantRedirect } from './pages/AssistantRedirect'
@@ -290,6 +291,10 @@ const router = createBrowserRouter([
element: ,
loader: loginLoader,
},
+ {
+ path: DEVICE_PAIRING_PATH,
+ element: ,
+ },
{
path: '/register',
element: ,
diff --git a/frontend/src/api/devicePairing.ts b/frontend/src/api/devicePairing.ts
new file mode 100644
index 000000000..77543f270
--- /dev/null
+++ b/frontend/src/api/devicePairing.ts
@@ -0,0 +1,80 @@
+import type {
+ CreateDevicePairingLinksRequest,
+ DevicePairingLinksResponse,
+ PairedDevice,
+ PairedDevicesResponse,
+ RedeemDevicePairingCodeRequest,
+ RedeemDevicePairingCodeResponse,
+ RemoteAccessHeadersResponse,
+ ReplaceRemoteAccessHeadersRequest,
+} from '@opencode-manager/shared/schemas'
+import { API_BASE_URL } from '@/config'
+import { getAuthBaseUrl } from '@/lib/auth-client'
+import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper'
+
+const REMOTE_ACCESS_HEADERS_URL = `${API_BASE_URL}/api/settings/remote-access-headers`
+
+/** Redeems a browser pairing code and starts a session for the paired user. */
+export async function redeemDevicePairingCode(
+ body: RedeemDevicePairingCodeRequest,
+): Promise {
+ return fetchWrapper(
+ `${getAuthBaseUrl()}/device-pairing/redeem`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ },
+ )
+}
+
+/** Reads the names of the stored remote-access headers without their values. */
+export async function getRemoteAccessHeaders(): Promise {
+ return fetchWrapper(REMOTE_ACCESS_HEADERS_URL)
+}
+
+/** Replaces the stored remote-access headers, keeping stored values for blank entries. */
+export async function replaceRemoteAccessHeaders(
+ body: ReplaceRemoteAccessHeadersRequest,
+): Promise {
+ return fetchWrapper(REMOTE_ACCESS_HEADERS_URL, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+}
+
+/** Removes every stored remote-access header. */
+export async function clearRemoteAccessHeaders(): Promise {
+ return fetchWrapper(REMOTE_ACCESS_HEADERS_URL, {
+ method: 'DELETE',
+ })
+}
+
+/** Mints a pairing code and builds the app and browser links for this origin. */
+export async function createDevicePairingLinks(
+ body: CreateDevicePairingLinksRequest = {},
+): Promise {
+ return fetchWrapper(`${API_BASE_URL}/api/device-pairing/links`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+}
+
+/** Lists the devices signed in to this account. */
+export async function listPairedDevices(): Promise {
+ const response = await fetchWrapper(
+ `${getAuthBaseUrl()}/device-pairing/devices`,
+ )
+ return response.devices
+}
+
+/** Signs one paired device out. */
+export async function revokePairedDevice(id: string): Promise {
+ return fetchWrapperVoid(`${getAuthBaseUrl()}/device-pairing/devices/revoke`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id }),
+ })
+}
diff --git a/frontend/src/components/settings/AccountSettings.tsx b/frontend/src/components/settings/AccountSettings.tsx
index 4c8b5db8d..516707a75 100644
--- a/frontend/src/components/settings/AccountSettings.tsx
+++ b/frontend/src/components/settings/AccountSettings.tsx
@@ -10,6 +10,9 @@ import { DeleteDialog } from '@/components/ui/delete-dialog'
import { Loader2, User, KeyRound, LogOut, Plus, Trash2, AlertCircle, CheckCircle, Lock } from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { passkey, changePassword } from '@/lib/auth-client'
+import { ConnectDeviceSettings } from './ConnectDeviceSettings'
+import { PairedDevicesSettings } from './PairedDevicesSettings'
+import { RemoteAccessHeadersSettings } from './RemoteAccessHeadersSettings'
interface Passkey {
id: string
@@ -343,6 +346,12 @@ export function AccountSettings() {
+
+
diff --git a/frontend/src/components/settings/ConnectDeviceDialog.test.tsx b/frontend/src/components/settings/ConnectDeviceDialog.test.tsx
new file mode 100644
index 000000000..a25745610
--- /dev/null
+++ b/frontend/src/components/settings/ConnectDeviceDialog.test.tsx
@@ -0,0 +1,234 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import type { DevicePairingLinksResponse } from '@opencode-manager/shared/schemas'
+import { ConnectDeviceDialog } from './ConnectDeviceDialog'
+
+const mocks = vi.hoisted(() => ({
+ createDevicePairingLinks: vi.fn(),
+}))
+
+vi.mock('@/api/devicePairing', () => ({
+ createDevicePairingLinks: mocks.createDevicePairingLinks,
+}))
+
+const BASE_LINKS: DevicePairingLinksResponse = {
+ appLink: 'ocm://link?v=2&kind=manager&name=studio&url=https%3A%2F%2Fstudio.example.com&code=abcdefghijklmnop',
+ appLinkBlockedReason: null,
+ browserLink: 'https://studio.example.com/pair#c=abcdefghijklmnop',
+ expiresAt: new Date(Date.now() + 179_000).toISOString(),
+ headerNames: ['CF-Access-Client-Id', 'CF-Access-Client-Secret'],
+ warnings: ['plain-http'],
+}
+
+function renderDialog() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+
+ return render(
+
+
+ ,
+ )
+}
+
+describe('ConnectDeviceDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createDevicePairingLinks.mockResolvedValue(BASE_LINKS)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('requests pairing links once when it opens', async () => {
+ renderDialog()
+
+ await screen.findByAltText('Mobile app pairing QR code')
+
+ expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(1)
+ })
+
+ it('hides the mobile app QR code until it is revealed', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ const qrCode = await screen.findByAltText('Mobile app pairing QR code')
+ expect(qrCode).toHaveClass('blur-md')
+
+ await user.click(screen.getByRole('button', { name: 'Reveal QR code' }))
+
+ expect(qrCode).not.toHaveClass('blur-md')
+ })
+
+ it('warns about screen sharing and names the included headers', async () => {
+ renderDialog()
+
+ expect(
+ await screen.findByText(
+ "This QR code contains your remote access headers. Don't show it while screen sharing or recording.",
+ ),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText('Includes headers: CF-Access-Client-Id, CF-Access-Client-Secret'),
+ ).toBeInTheDocument()
+ })
+
+ it('shows the browser sign-in QR code on the browser tab', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await screen.findByAltText('Mobile app pairing QR code')
+ await user.click(screen.getByRole('tab', { name: 'Browser / PWA' }))
+
+ expect(await screen.findByAltText('Browser sign-in QR code')).toBeInTheDocument()
+ })
+
+ it('explains a loopback block without showing the mobile app QR code', async () => {
+ mocks.createDevicePairingLinks.mockResolvedValue({
+ ...BASE_LINKS,
+ appLink: null,
+ appLinkBlockedReason: 'loopback',
+ warnings: ['plain-http', 'loopback'],
+ })
+ renderDialog()
+
+ expect(
+ await screen.findByText(
+ 'This page is open on localhost, which your phone cannot reach. Open the Manager at its public or LAN address to show the app QR code.',
+ ),
+ ).toBeInTheDocument()
+ expect(screen.queryByAltText('Mobile app pairing QR code')).not.toBeInTheDocument()
+ expect(
+ screen.getByText('This address uses plain HTTP, so the sign-in code travels unencrypted.'),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText('This address is localhost; other devices cannot reach it.'),
+ ).toBeInTheDocument()
+ })
+
+ it('explains how to unblock a plain-HTTP app link', async () => {
+ mocks.createDevicePairingLinks.mockResolvedValue({
+ ...BASE_LINKS,
+ appLink: null,
+ appLinkBlockedReason: 'insecure-origin',
+ warnings: ['plain-http'],
+ })
+ renderDialog()
+
+ expect(
+ await screen.findByText(
+ "The mobile app QR code needs HTTPS. Use an HTTPS address, or turn on 'Allow plain-HTTP app links on private networks' if this Manager uses a private IP address.",
+ ),
+ ).toBeInTheDocument()
+ })
+
+ it('warns when the stored remote access headers are unreadable', async () => {
+ mocks.createDevicePairingLinks.mockResolvedValue({
+ ...BASE_LINKS,
+ headerNames: [],
+ warnings: ['plain-http', 'headers-unreadable'],
+ })
+ renderDialog()
+
+ expect(
+ await screen.findByText(
+ 'Stored remote access headers could not be decrypted and are not included.',
+ ),
+ ).toBeInTheDocument()
+ })
+
+ it('counts down and requests a new link after it expires', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
+
+ renderDialog()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0)
+ })
+
+ expect(screen.getByText('Expires in 3:00')).toBeInTheDocument()
+ expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(1)
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(180_000)
+ })
+
+ expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(2)
+ })
+
+ it('times expiry on the client clock when the client clock is ahead of the server', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
+ mocks.createDevicePairingLinks.mockImplementation(async () => ({
+ ...BASE_LINKS,
+ expiresAt: new Date(Date.now() - 600_000).toISOString(),
+ }))
+
+ renderDialog()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0)
+ })
+
+ expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(1)
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5_000)
+ })
+
+ expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(1)
+ expect(screen.getByText('Expires in 2:55')).toBeInTheDocument()
+ })
+
+ it('stops the countdown while the links are loading and after a failure', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
+
+ let rejectRequest: ((error: Error) => void) | undefined
+ mocks.createDevicePairingLinks.mockImplementation(
+ () =>
+ new Promise
((_resolve, reject) => {
+ rejectRequest = reject
+ }),
+ )
+
+ renderDialog()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5_000)
+ })
+
+ expect(screen.getByText('Creating a pairing code…')).toBeInTheDocument()
+ expect(screen.queryByText(/^Expires in/)).not.toBeInTheDocument()
+
+ await act(async () => {
+ rejectRequest?.(new Error('Failed to create pairing links'))
+ await vi.advanceTimersByTimeAsync(65_000)
+ })
+
+ expect(screen.getByText('Failed to create pairing links')).toBeInTheDocument()
+ expect(screen.queryByText(/^Expires in/)).not.toBeInTheDocument()
+ expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(1)
+ })
+
+ it('shows a failed request with a retry button', async () => {
+ const user = userEvent.setup()
+ mocks.createDevicePairingLinks.mockRejectedValueOnce(new Error('Failed to create pairing links'))
+ renderDialog()
+
+ expect(await screen.findByText('Failed to create pairing links')).toBeInTheDocument()
+
+ mocks.createDevicePairingLinks.mockResolvedValueOnce(BASE_LINKS)
+ await user.click(screen.getByRole('button', { name: 'Try again' }))
+
+ expect(await screen.findByAltText('Mobile app pairing QR code')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/settings/ConnectDeviceDialog.tsx b/frontend/src/components/settings/ConnectDeviceDialog.tsx
new file mode 100644
index 000000000..cd4ed7686
--- /dev/null
+++ b/frontend/src/components/settings/ConnectDeviceDialog.tsx
@@ -0,0 +1,216 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { AlertCircle, Loader2, QrCode, RefreshCw } from 'lucide-react'
+import {
+ DEVICE_PAIRING_CODE_TTL_SECONDS,
+ type DevicePairingLinkWarning,
+ type DevicePairingLinksResponse,
+} from '@opencode-manager/shared/schemas'
+import { useCreateDevicePairingLinks, useInvalidatePairedDevices } from '@/hooks/useDevicePairing'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { PairingQrCode } from './PairingQrCode'
+
+const WARNING_MESSAGES: Record = {
+ 'plain-http': 'This address uses plain HTTP, so the sign-in code travels unencrypted.',
+ loopback: 'This address is localhost; other devices cannot reach it.',
+ 'headers-unreadable': 'Stored remote access headers could not be decrypted and are not included.',
+}
+
+const LOOPBACK_EXPLANATION =
+ 'This page is open on localhost, which your phone cannot reach. Open the Manager at its public or LAN address to show the app QR code.'
+
+const INSECURE_ORIGIN_EXPLANATION =
+ "The mobile app QR code needs HTTPS. Use an HTTPS address, or turn on 'Allow plain-HTTP app links on private networks' if this Manager uses a private IP address."
+
+const SCREEN_SHARING_WARNING =
+ "This QR code contains your remote access headers. Don't show it while screen sharing or recording."
+
+const BROWSER_HINT =
+ "Scan with your phone camera to sign in the browser or installed app. In an iPhone home-screen app, use 'Sign in with QR code' on its sign-in screen."
+
+const LINKS_FAILED_MESSAGE = 'Failed to create pairing links'
+
+interface ConnectDeviceDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+function formatCountdown(localExpiresAt: number, now: number): string {
+ const remainingSeconds = Math.max(0, Math.ceil((localExpiresAt - now) / 1000))
+ const minutes = Math.floor(remainingSeconds / 60)
+ return `${minutes}:${String(remainingSeconds % 60).padStart(2, '0')}`
+}
+
+export function ConnectDeviceDialog({ open, onOpenChange }: ConnectDeviceDialogProps) {
+ const invalidatePairedDevices = useInvalidatePairedDevices()
+ const { mutate, isPending } = useCreateDevicePairingLinks()
+ const [links, setLinks] = useState(null)
+ const [localExpiresAt, setLocalExpiresAt] = useState(null)
+ const [error, setError] = useState(null)
+ const [revealed, setRevealed] = useState(false)
+ const [now, setNow] = useState(() => Date.now())
+ const wasOpenRef = useRef(false)
+
+ const requestLinks = useCallback(() => {
+ setError(null)
+ mutate(
+ {},
+ {
+ onSuccess: (response) => {
+ const receivedAt = Date.now()
+ setLinks(response)
+ setLocalExpiresAt(receivedAt + DEVICE_PAIRING_CODE_TTL_SECONDS * 1000)
+ setNow(receivedAt)
+ },
+ onError: (requestError) => {
+ setLinks(null)
+ setLocalExpiresAt(null)
+ setError(
+ requestError instanceof Error && requestError.message
+ ? requestError.message
+ : LINKS_FAILED_MESSAGE,
+ )
+ },
+ },
+ )
+ }, [mutate])
+
+ useEffect(() => {
+ if (open && !wasOpenRef.current) {
+ wasOpenRef.current = true
+ setLinks(null)
+ setLocalExpiresAt(null)
+ setRevealed(false)
+ requestLinks()
+ return
+ }
+
+ if (!open && wasOpenRef.current) {
+ wasOpenRef.current = false
+ setLinks(null)
+ setLocalExpiresAt(null)
+ setError(null)
+ setRevealed(false)
+ void invalidatePairedDevices()
+ }
+ }, [invalidatePairedDevices, open, requestLinks])
+
+ useEffect(() => {
+ if (!open || !links) return undefined
+ const interval = setInterval(() => setNow(Date.now()), 1000)
+ return () => clearInterval(interval)
+ }, [links, open])
+
+ useEffect(() => {
+ if (!open || !links || localExpiresAt === null || isPending) return
+ if (now < localExpiresAt) return
+ requestLinks()
+ }, [isPending, links, localExpiresAt, now, open, requestLinks])
+
+ return (
+
+
+
+
+
+ Connect a device
+
+
+ Scan a code with the mobile app or your phone camera to sign in.
+
+
+
+ {error ? (
+
+
+
+ {error}
+
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+ Try again
+
+
+ ) : !links ? (
+
+
+
Creating a pairing code…
+
+ ) : (
+
+ {localExpiresAt !== null && (
+
+ Expires in {formatCountdown(localExpiresAt, now)}
+
+ )}
+
+
+
+
+ Mobile app
+
+
+ Browser / PWA
+
+
+
+
+ {links.appLink ? (
+ <>
+
+ setRevealed((current) => !current)}>
+ {revealed ? 'Hide QR code' : 'Reveal QR code'}
+
+ {SCREEN_SHARING_WARNING}
+
+ {links.headerNames.length > 0
+ ? `Includes headers: ${links.headerNames.join(', ')}`
+ : 'No remote access headers are included.'}
+
+ >
+ ) : (
+
+ {links.appLinkBlockedReason === 'loopback'
+ ? LOOPBACK_EXPLANATION
+ : INSECURE_ORIGIN_EXPLANATION}
+
+ )}
+
+
+
+
+ {BROWSER_HINT}
+
+
+
+ {links.warnings.map((warning) => (
+
+ {WARNING_MESSAGES[warning]}
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/settings/ConnectDeviceSettings.test.tsx b/frontend/src/components/settings/ConnectDeviceSettings.test.tsx
new file mode 100644
index 000000000..6690d2f49
--- /dev/null
+++ b/frontend/src/components/settings/ConnectDeviceSettings.test.tsx
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { DEFAULT_USER_PREFERENCES } from '@opencode-manager/shared/schemas'
+import { createUseSettingsMock } from '@/test/test-utils'
+import { ConnectDeviceSettings } from './ConnectDeviceSettings'
+
+const mocks = vi.hoisted(() => ({
+ useSettings: vi.fn(),
+ updateSettings: vi.fn(),
+ createDevicePairingLinks: vi.fn(),
+}))
+
+vi.mock('@/hooks/useSettings', () => ({
+ useSettings: mocks.useSettings,
+}))
+
+vi.mock('@/api/devicePairing', () => ({
+ createDevicePairingLinks: mocks.createDevicePairingLinks,
+}))
+
+function renderSettings() {
+ mocks.useSettings.mockReturnValue(
+ createUseSettingsMock({
+ preferences: {
+ ...DEFAULT_USER_PREFERENCES,
+ devicePairing: { allowPrivateNetworkHttp: false },
+ },
+ updateSettings: mocks.updateSettings,
+ }),
+ )
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+
+ return render(
+
+
+ ,
+ )
+}
+
+describe('ConnectDeviceSettings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createDevicePairingLinks.mockResolvedValue({
+ appLink: 'ocm://link?v=2&kind=manager&name=studio&url=https%3A%2F%2Fstudio.example.com&code=abcdefghijklmnop',
+ appLinkBlockedReason: null,
+ browserLink: 'https://studio.example.com/pair#c=abcdefghijklmnop',
+ expiresAt: new Date(Date.now() + 179_000).toISOString(),
+ headerNames: [],
+ warnings: [],
+ })
+ })
+
+ it('persists the private-network plain-HTTP preference', async () => {
+ const user = userEvent.setup()
+ renderSettings()
+
+ await user.click(
+ screen.getByRole('switch', { name: 'Allow plain-HTTP app links on private networks' }),
+ )
+
+ expect(mocks.updateSettings).toHaveBeenCalledWith({
+ devicePairing: { allowPrivateNetworkHttp: true },
+ })
+ })
+
+ it('opens the pairing dialog from the QR code button', async () => {
+ const user = userEvent.setup()
+ renderSettings()
+
+ await user.click(screen.getByRole('button', { name: 'Show QR code' }))
+
+ expect(await screen.findByAltText('Mobile app pairing QR code')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/settings/ConnectDeviceSettings.tsx b/frontend/src/components/settings/ConnectDeviceSettings.tsx
new file mode 100644
index 000000000..80624a7d5
--- /dev/null
+++ b/frontend/src/components/settings/ConnectDeviceSettings.tsx
@@ -0,0 +1,56 @@
+import { useState } from 'react'
+import { QrCode } from 'lucide-react'
+import { useSettings } from '@/hooks/useSettings'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { ConnectDeviceDialog } from './ConnectDeviceDialog'
+
+const ALLOW_PRIVATE_NETWORK_HTTP_LABEL = 'Allow plain-HTTP app links on private networks'
+
+export function ConnectDeviceSettings() {
+ const { preferences, updateSettings } = useSettings()
+ const [dialogOpen, setDialogOpen] = useState(false)
+ const allowPrivateNetworkHttp = preferences?.devicePairing?.allowPrivateNetworkHttp ?? false
+
+ return (
+
+
+
+
+ Connect a device
+
+
+ Pair the mobile app or sign another browser in with a QR code.
+
+
+
+ setDialogOpen(true)}>
+ Show QR code
+
+
+
+
+
+ {ALLOW_PRIVATE_NETWORK_HTTP_LABEL}
+
+
+ Lets the mobile app QR code use a LAN address without HTTPS.
+
+
+
+ updateSettings({ devicePairing: { allowPrivateNetworkHttp: checked } })
+ }
+ />
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/settings/OpenCodeServerDialog.test.tsx b/frontend/src/components/settings/OpenCodeServerDialog.test.tsx
index 24aae1a81..b71071dd6 100644
--- a/frontend/src/components/settings/OpenCodeServerDialog.test.tsx
+++ b/frontend/src/components/settings/OpenCodeServerDialog.test.tsx
@@ -168,6 +168,29 @@ describe('OpenCodeServerDialog', () => {
expect(screen.queryByText(originChangeWarning)).not.toBeInTheDocument()
})
+ it('warns about unencrypted credentials only for public HTTP origins', async () => {
+ const user = userEvent.setup()
+ const transportWarning = 'This URL is not HTTPS, so credentials travel unencrypted'
+ renderDialog()
+
+ const urlInput = screen.getByLabelText('URL')
+
+ await user.type(urlInput, 'http://203.0.113.5:4096')
+ expect(screen.getByText(new RegExp(transportWarning))).toBeInTheDocument()
+
+ await user.clear(urlInput)
+ await user.type(urlInput, 'http://192.168.1.20:4096')
+ expect(screen.queryByText(new RegExp(transportWarning))).not.toBeInTheDocument()
+
+ await user.clear(urlInput)
+ await user.type(urlInput, 'http://100.101.102.103:4096')
+ expect(screen.queryByText(new RegExp(transportWarning))).not.toBeInTheDocument()
+
+ await user.clear(urlInput)
+ await user.type(urlInput, 'https://studio.example.com')
+ expect(screen.queryByText(new RegExp(transportWarning))).not.toBeInTheDocument()
+ })
+
it('does not warn about an origin change when no password is stored', async () => {
const user = userEvent.setup()
renderDialog({ server: { ...savedServer, hasPassword: false } })
diff --git a/frontend/src/components/settings/OpenCodeServerDialog.tsx b/frontend/src/components/settings/OpenCodeServerDialog.tsx
index 8c22a20ff..428629861 100644
--- a/frontend/src/components/settings/OpenCodeServerDialog.tsx
+++ b/frontend/src/components/settings/OpenCodeServerDialog.tsx
@@ -12,7 +12,7 @@ import {
type OpenCodeServerLink,
type OpenCodeServerTestResult,
} from '@opencode-manager/shared/schemas'
-import { haveSameUrlOrigin } from '@opencode-manager/shared/utils'
+import { haveSameUrlOrigin, isLoopbackHost, isPrivateNetworkHost } from '@opencode-manager/shared/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -84,26 +84,14 @@ const serverFormSchema = z.object({
type ServerFormValues = z.infer
-function isPrivateHost(hostname: string): boolean {
- const host = hostname.toLowerCase()
- if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') return true
- const octets = host.split('.').map((part) => Number(part))
- if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
- return false
- }
- const [first, second] = octets
- return (
- first === 10 ||
- (first === 172 && second >= 16 && second <= 31) ||
- (first === 192 && second === 168) ||
- (first === 169 && second === 254)
- )
-}
-
function sendsCredentialsInClear(baseUrl: string): boolean {
try {
const url = new URL(baseUrl)
- return url.protocol === 'http:' && !isPrivateHost(url.hostname)
+ return (
+ url.protocol === 'http:' &&
+ !isLoopbackHost(url.hostname) &&
+ !isPrivateNetworkHost(url.hostname)
+ )
} catch {
return false
}
diff --git a/frontend/src/components/settings/PairedDevicesSettings.test.tsx b/frontend/src/components/settings/PairedDevicesSettings.test.tsx
new file mode 100644
index 000000000..53f603ad6
--- /dev/null
+++ b/frontend/src/components/settings/PairedDevicesSettings.test.tsx
@@ -0,0 +1,115 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import type { PairedDevice } from '@opencode-manager/shared/schemas'
+import { PairedDevicesSettings } from './PairedDevicesSettings'
+
+const mocks = vi.hoisted(() => ({
+ listPairedDevices: vi.fn(),
+ revokePairedDevice: vi.fn(),
+}))
+
+vi.mock('@/api/devicePairing', () => ({
+ listPairedDevices: mocks.listPairedDevices,
+ revokePairedDevice: mocks.revokePairedDevice,
+}))
+
+const IPHONE_SAFARI_UA =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
+
+const CURRENT_DEVICE: PairedDevice = {
+ id: 'session-current',
+ deviceName: 'Chris MacBook',
+ userAgent:
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
+ ipAddress: '192.168.1.10',
+ createdAt: '2026-10-01T10:00:00.000Z',
+ updatedAt: '2026-10-20T10:00:00.000Z',
+ isCurrent: true,
+}
+
+const IPHONE_DEVICE: PairedDevice = {
+ id: 'session-iphone',
+ deviceName: null,
+ userAgent: IPHONE_SAFARI_UA,
+ ipAddress: '192.168.1.44',
+ createdAt: '2026-10-02T10:00:00.000Z',
+ updatedAt: '2026-10-21T10:00:00.000Z',
+ isCurrent: false,
+}
+
+function renderSettings() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+
+ return render(
+
+
+ ,
+ )
+}
+
+describe('PairedDevicesSettings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.listPairedDevices.mockResolvedValue([CURRENT_DEVICE])
+ mocks.revokePairedDevice.mockResolvedValue(undefined)
+ })
+
+ it('shows the device name when one is set', async () => {
+ renderSettings()
+
+ expect(await screen.findByText('Chris MacBook')).toBeInTheDocument()
+ })
+
+ it('shows when the device signed in', async () => {
+ mocks.listPairedDevices.mockResolvedValue([{ ...CURRENT_DEVICE, ipAddress: null }])
+ renderSettings()
+
+ expect(await screen.findByText(/^Signed in /)).toBeInTheDocument()
+ })
+
+ it('falls back to a user-agent label when the device has no name', async () => {
+ mocks.listPairedDevices.mockResolvedValue([IPHONE_DEVICE])
+ renderSettings()
+
+ expect(await screen.findByText('Safari on iPhone')).toBeInTheDocument()
+ })
+
+ it('marks the current device and hides its revoke button', async () => {
+ mocks.listPairedDevices.mockResolvedValue([CURRENT_DEVICE])
+ renderSettings()
+
+ expect(await screen.findByText('This device')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /^Revoke / })).not.toBeInTheDocument()
+ })
+
+ it('revokes a paired device after confirmation and refetches the list', async () => {
+ mocks.listPairedDevices.mockResolvedValue([CURRENT_DEVICE, IPHONE_DEVICE])
+ const user = userEvent.setup()
+ renderSettings()
+
+ await user.click(await screen.findByRole('button', { name: 'Revoke Safari on iPhone' }))
+ await user.click(await screen.findByRole('button', { name: 'Delete' }))
+
+ await waitFor(() => expect(mocks.revokePairedDevice).toHaveBeenCalledWith('session-iphone'))
+ await waitFor(() => expect(mocks.listPairedDevices).toHaveBeenCalledTimes(2))
+ })
+
+ it('shows the error message when the revoke fails', async () => {
+ mocks.listPairedDevices.mockResolvedValue([CURRENT_DEVICE, IPHONE_DEVICE])
+ mocks.revokePairedDevice.mockRejectedValueOnce(new Error('This device is no longer paired'))
+ const user = userEvent.setup()
+ renderSettings()
+
+ await user.click(await screen.findByRole('button', { name: 'Revoke Safari on iPhone' }))
+ await user.click(await screen.findByRole('button', { name: 'Delete' }))
+
+ expect(await screen.findByText('This device is no longer paired')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/settings/PairedDevicesSettings.tsx b/frontend/src/components/settings/PairedDevicesSettings.tsx
new file mode 100644
index 000000000..f8ee18950
--- /dev/null
+++ b/frontend/src/components/settings/PairedDevicesSettings.tsx
@@ -0,0 +1,113 @@
+import { useState } from 'react'
+import { formatDistanceToNow } from 'date-fns'
+import { describeUserAgent } from '@opencode-manager/shared/utils'
+import type { PairedDevice } from '@opencode-manager/shared/schemas'
+import { usePairedDevices, useRevokePairedDevice } from '@/hooks/useDevicePairing'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { DeleteDialog } from '@/components/ui/delete-dialog'
+import { AlertCircle, Loader2, MonitorSmartphone, Trash2 } from 'lucide-react'
+
+function deviceLabel(device: PairedDevice): string {
+ return device.deviceName?.trim() || describeUserAgent(device.userAgent)
+}
+
+export function PairedDevicesSettings() {
+ const { data: devices, isLoading } = usePairedDevices()
+ const revoke = useRevokePairedDevice()
+ const [deviceToRevoke, setDeviceToRevoke] = useState(null)
+
+ const error = revoke.error instanceof Error
+ ? revoke.error.message
+ : revoke.error
+ ? 'Failed to revoke device'
+ : null
+
+ const handleConfirmRevoke = () => {
+ if (!deviceToRevoke) return
+ revoke.mutate(deviceToRevoke.id)
+ setDeviceToRevoke(null)
+ }
+
+ return (
+
+
+
+
+ Paired devices
+
+
+ Every signed-in browser and app. Revoking one signs out only that device.
+
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {isLoading ? (
+
+
+
+ ) : !devices || devices.length === 0 ? (
+ No paired devices.
+ ) : (
+
+ {devices.map((device) => {
+ const label = deviceLabel(device)
+
+ return (
+
+
+
+
{label}
+ {device.isCurrent && (
+
+ This device
+
+ )}
+
+
+ {device.ipAddress ? `${device.ipAddress} · ` : null}
+ Signed in {formatDistanceToNow(new Date(device.createdAt), { addSuffix: true })}
+
+
+ {!device.isCurrent && (
+
setDeviceToRevoke(device)}
+ disabled={revoke.isPending}
+ >
+
+
+ )}
+
+ )
+ })}
+
+ )}
+
+
+ !open && setDeviceToRevoke(null)}
+ onConfirm={handleConfirmRevoke}
+ onCancel={() => setDeviceToRevoke(null)}
+ title="Revoke device"
+ description={deviceToRevoke ? `${deviceLabel(deviceToRevoke)} will be signed out.` : ''}
+ isDeleting={revoke.isPending}
+ />
+
+ )
+}
diff --git a/frontend/src/components/settings/PairingQrCode.test.tsx b/frontend/src/components/settings/PairingQrCode.test.tsx
new file mode 100644
index 000000000..d8839e976
--- /dev/null
+++ b/frontend/src/components/settings/PairingQrCode.test.tsx
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { PairingQrCode } from './PairingQrCode'
+
+describe('PairingQrCode', () => {
+ it('renders the QR code once uqr has loaded', async () => {
+ render( )
+
+ const image = await screen.findByAltText('Pairing QR code')
+ expect(image).toHaveAttribute('src', expect.stringContaining('data:image/svg+xml'))
+ })
+
+ it('reports values too large to encode', async () => {
+ render( )
+
+ expect(
+ await screen.findByText(
+ 'This QR code holds too much data. Remove some remote access headers.',
+ ),
+ ).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/settings/PairingQrCode.tsx b/frontend/src/components/settings/PairingQrCode.tsx
new file mode 100644
index 000000000..f4cf6f23e
--- /dev/null
+++ b/frontend/src/components/settings/PairingQrCode.tsx
@@ -0,0 +1,51 @@
+import { useEffect, useMemo, useState } from 'react'
+import { cn } from '@/lib/utils'
+
+type RenderSvg = (typeof import('uqr'))['renderSVG']
+
+interface PairingQrCodeProps {
+ value: string
+ label: string
+ blurred?: boolean
+}
+
+export function PairingQrCode({ value, label, blurred = false }: PairingQrCodeProps) {
+ const [renderSvg, setRenderSvg] = useState(null)
+
+ useEffect(() => {
+ let active = true
+ void import('uqr').then((module) => {
+ if (active) setRenderSvg(() => module.renderSVG)
+ })
+ return () => {
+ active = false
+ }
+ }, [])
+
+ const source = useMemo(() => {
+ if (!renderSvg) return null
+ try {
+ return `data:image/svg+xml;utf8,${encodeURIComponent(renderSvg(value, { ecc: 'M', border: 2 }))}`
+ } catch {
+ return null
+ }
+ }, [renderSvg, value])
+
+ if (!renderSvg) return null
+
+ if (!source) {
+ return (
+
+ This QR code holds too much data. Remove some remote access headers.
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/settings/QrCodeScanner.tsx b/frontend/src/components/settings/QrCodeScanner.tsx
index 8e52a4676..fee64855b 100644
--- a/frontend/src/components/settings/QrCodeScanner.tsx
+++ b/frontend/src/components/settings/QrCodeScanner.tsx
@@ -19,21 +19,24 @@ interface QrCodeScannerProps {
open: boolean
onOpenChange: (open: boolean) => void
onResult: (text: string) => void
+ title?: string
+ description?: string
+ fallbackHint?: string
}
-function describeCameraError(error: unknown): string {
+function describeCameraError(error: unknown, fallbackHint: string): string {
if (error instanceof DOMException) {
if (error.name === 'NotAllowedError' || error.name === 'SecurityError') {
- return 'Camera permission was denied. Allow camera access for this site or paste the pairing link instead.'
+ return `Camera permission was denied. Allow camera access for this site. ${fallbackHint}`
}
if (error.name === 'NotFoundError' || error.name === 'OverconstrainedError') {
- return 'No camera was found on this device. Paste the pairing link instead.'
+ return `No camera was found on this device. ${fallbackHint}`
}
if (error.name === 'NotReadableError') {
- return 'The camera is already in use by another application. Paste the pairing link instead.'
+ return `The camera is already in use by another application. ${fallbackHint}`
}
}
- return 'The camera could not be started. Paste the pairing link instead.'
+ return `The camera could not be started. ${fallbackHint}`
}
function fitScanDimensions(width: number, height: number): { width: number; height: number } {
@@ -45,7 +48,14 @@ function fitScanDimensions(width: number, height: number): { width: number; heig
return { width: Math.round(width * scale), height: Math.round(height * scale) }
}
-export function QrCodeScanner({ open, onOpenChange, onResult }: QrCodeScannerProps) {
+export function QrCodeScanner({
+ open,
+ onOpenChange,
+ onResult,
+ title = 'Scan pairing QR code',
+ description = 'Point the camera at the QR code shown by the OpenCode server you want to link.',
+ fallbackHint = 'Paste the pairing link instead.',
+}: QrCodeScannerProps) {
const videoRef = useRef(null)
const decoderRef = useRef(null)
const onResultRef = useRef(onResult)
@@ -126,12 +136,12 @@ export function QrCodeScanner({ open, onOpenChange, onResult }: QrCodeScannerPro
const startCamera = async () => {
if (!navigator.mediaDevices?.getUserMedia) {
- setError('Camera access is not available in this browser. Paste the pairing link instead.')
+ setError(`Camera access is not available in this browser. ${fallbackHint}`)
setIsStarting(false)
return
}
if (!window.isSecureContext) {
- setError('Camera access requires a secure (HTTPS) connection. Paste the pairing link instead.')
+ setError(`Camera access requires a secure (HTTPS) connection. ${fallbackHint}`)
setIsStarting(false)
return
}
@@ -139,7 +149,7 @@ export function QrCodeScanner({ open, onOpenChange, onResult }: QrCodeScannerPro
await loadDecoder()
} catch {
if (cancelled) return
- setError('The QR decoder could not be loaded. Paste the pairing link instead.')
+ setError(`The QR decoder could not be loaded. ${fallbackHint}`)
setIsStarting(false)
return
}
@@ -161,7 +171,7 @@ export function QrCodeScanner({ open, onOpenChange, onResult }: QrCodeScannerPro
frameId = requestAnimationFrame(scanFrame)
} catch (cameraError) {
if (cancelled) return
- setError(describeCameraError(cameraError))
+ setError(describeCameraError(cameraError, fallbackHint))
setIsStarting(false)
}
}
@@ -172,16 +182,14 @@ export function QrCodeScanner({ open, onOpenChange, onResult }: QrCodeScannerPro
cancelled = true
stopCamera()
}
- }, [open, onOpenChange])
+ }, [open, onOpenChange, fallbackHint])
return (
- Scan pairing QR code
-
- Point the camera at the QR code shown by the OpenCode server you want to link.
-
+ {title}
+ {description}
diff --git a/frontend/src/components/settings/RemoteAccessHeadersSettings.test.tsx b/frontend/src/components/settings/RemoteAccessHeadersSettings.test.tsx
new file mode 100644
index 000000000..4f47f40ec
--- /dev/null
+++ b/frontend/src/components/settings/RemoteAccessHeadersSettings.test.tsx
@@ -0,0 +1,173 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import type { ReplaceRemoteAccessHeadersRequest } from '@opencode-manager/shared/schemas'
+import { RemoteAccessHeadersSettings } from './RemoteAccessHeadersSettings'
+
+const mocks = vi.hoisted(() => ({
+ getRemoteAccessHeaders: vi.fn(),
+ replaceRemoteAccessHeaders: vi.fn(),
+ clearRemoteAccessHeaders: vi.fn(),
+}))
+
+vi.mock('@/api/devicePairing', () => ({
+ getRemoteAccessHeaders: mocks.getRemoteAccessHeaders,
+ replaceRemoteAccessHeaders: mocks.replaceRemoteAccessHeaders,
+ clearRemoteAccessHeaders: mocks.clearRemoteAccessHeaders,
+}))
+
+const STORED_VALUE_PLACEHOLDER = 'Stored – leave blank to keep'
+
+function renderSettings() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+
+ return render(
+
+
+ ,
+ )
+}
+
+describe('RemoteAccessHeadersSettings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getRemoteAccessHeaders.mockResolvedValue({ headers: [], unreadable: false })
+ mocks.replaceRemoteAccessHeaders.mockImplementation(
+ async (body: ReplaceRemoteAccessHeadersRequest) => ({
+ headers: body.headers.map((header) => ({ name: header.name, hasValue: true })),
+ unreadable: false,
+ }),
+ )
+ mocks.clearRemoteAccessHeaders.mockResolvedValue({ headers: [], unreadable: false })
+ })
+
+ it('adds the Cloudflare service token rows and saves typed values only once', async () => {
+ const user = userEvent.setup()
+ renderSettings()
+
+ await user.click(
+ await screen.findByRole('button', { name: /Cloudflare Access service token/i }),
+ )
+
+ const nameInputs = screen.getAllByPlaceholderText('Header name')
+ expect(nameInputs.map((input) => (input as HTMLInputElement).value)).toEqual([
+ 'CF-Access-Client-Id',
+ 'CF-Access-Client-Secret',
+ ])
+
+ const valueInputs = screen.getAllByPlaceholderText('Value')
+ await user.type(valueInputs[0] as HTMLInputElement, 'id')
+ await user.type(valueInputs[1] as HTMLInputElement, 'secret')
+
+ await user.click(screen.getByRole('button', { name: /^Save$/i }))
+
+ await waitFor(() => {
+ expect(mocks.replaceRemoteAccessHeaders).toHaveBeenCalledWith({
+ headers: [
+ { name: 'CF-Access-Client-Id', value: 'id' },
+ { name: 'CF-Access-Client-Secret', value: 'secret' },
+ ],
+ })
+ })
+
+ await waitFor(() => {
+ expect(screen.queryByDisplayValue('secret')).not.toBeInTheDocument()
+ })
+ expect(await screen.findAllByPlaceholderText(STORED_VALUE_PLACEHOLDER)).toHaveLength(2)
+ })
+
+ it('keeps a stored value when its blank field is left untouched', async () => {
+ mocks.getRemoteAccessHeaders.mockResolvedValue({
+ headers: [{ name: 'CF-Access-Client-Secret', hasValue: true }],
+ unreadable: false,
+ })
+ const user = userEvent.setup()
+ renderSettings()
+
+ const valueInput = await screen.findByPlaceholderText(STORED_VALUE_PLACEHOLDER)
+ expect(valueInput).toHaveValue('')
+
+ await user.click(screen.getByRole('button', { name: /^Save$/i }))
+
+ await waitFor(() => {
+ expect(mocks.replaceRemoteAccessHeaders).toHaveBeenCalledWith({
+ headers: [{ name: 'CF-Access-Client-Secret', value: undefined }],
+ })
+ })
+ })
+
+ it('shows the schema error for a reserved header name and blocks saving', async () => {
+ const user = userEvent.setup()
+ renderSettings()
+
+ await user.click(await screen.findByRole('button', { name: /Add header/i }))
+ await user.type(screen.getByPlaceholderText('Header name'), 'Cookie')
+ await user.type(screen.getByPlaceholderText('Value'), 'session')
+
+ expect(await screen.findByText('This header name is reserved')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: /^Save$/i }))
+
+ expect(mocks.replaceRemoteAccessHeaders).not.toHaveBeenCalled()
+ })
+
+ it('requires a value after renaming a stored row to a name the server does not have', async () => {
+ mocks.getRemoteAccessHeaders.mockResolvedValue({
+ headers: [{ name: 'CF-Access-Client-Secret', hasValue: true }],
+ unreadable: false,
+ })
+ const user = userEvent.setup()
+ renderSettings()
+
+ expect(await screen.findByPlaceholderText(STORED_VALUE_PLACEHOLDER)).toBeInTheDocument()
+
+ const nameInput = screen.getByPlaceholderText('Header name')
+ await user.clear(nameInput)
+ await user.type(nameInput, 'X-Custom-Secret')
+
+ expect(screen.getByPlaceholderText('Value')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /^Save$/i })).toBeDisabled()
+
+ await user.type(screen.getByPlaceholderText('Value'), 'new-secret')
+ await user.click(screen.getByRole('button', { name: /^Save$/i }))
+
+ await waitFor(() => {
+ expect(mocks.replaceRemoteAccessHeaders).toHaveBeenCalledWith({
+ headers: [{ name: 'X-Custom-Secret', value: 'new-secret' }],
+ })
+ })
+ })
+
+ it('shows the decrypt alert when the stored headers are unreadable', async () => {
+ mocks.getRemoteAccessHeaders.mockResolvedValue({ headers: [], unreadable: true })
+ renderSettings()
+
+ expect(
+ await screen.findByText(
+ 'Stored headers could not be decrypted (AUTH_SECRET may have changed). Enter them again.',
+ ),
+ ).toBeInTheDocument()
+ })
+
+ it('clears all headers after confirmation', async () => {
+ mocks.getRemoteAccessHeaders.mockResolvedValue({
+ headers: [{ name: 'CF-Access-Client-Secret', hasValue: true }],
+ unreadable: false,
+ })
+ const user = userEvent.setup()
+ renderSettings()
+
+ await user.click(await screen.findByRole('button', { name: /Clear all/i }))
+ await user.click(await screen.findByRole('button', { name: /^Delete$/i }))
+
+ await waitFor(() => {
+ expect(mocks.clearRemoteAccessHeaders).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/frontend/src/components/settings/RemoteAccessHeadersSettings.tsx b/frontend/src/components/settings/RemoteAccessHeadersSettings.tsx
new file mode 100644
index 000000000..bd865b770
--- /dev/null
+++ b/frontend/src/components/settings/RemoteAccessHeadersSettings.tsx
@@ -0,0 +1,296 @@
+import { useEffect, useRef, useState } from 'react'
+import {
+ CLOUDFLARE_ACCESS_HEADER_NAMES,
+ REMOTE_ACCESS_HEADER_MAX_COUNT,
+ RemoteAccessHeaderNameSchema,
+ RemoteAccessHeaderValueSchema,
+ type RemoteAccessHeaderSummary,
+} from '@opencode-manager/shared/schemas'
+import { useRemoteAccessHeaders } from '@/hooks/useDevicePairing'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { DeleteDialog } from '@/components/ui/delete-dialog'
+import { AlertCircle, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
+
+const STORED_VALUE_PLACEHOLDER = 'Stored – leave blank to keep'
+
+interface HeaderRow {
+ id: number
+ name: string
+ value: string
+}
+
+let nextRowIdValue = 0
+
+function nextRowId(): number {
+ nextRowIdValue += 1
+ return nextRowIdValue
+}
+
+function createRows(summaries: RemoteAccessHeaderSummary[]): HeaderRow[] {
+ return summaries.map((header) => ({
+ id: nextRowId(),
+ name: header.name,
+ value: '',
+ }))
+}
+
+function normalizeName(name: string): string {
+ return name.trim().toLowerCase()
+}
+
+function hasStoredValue(storedNames: Set
, name: string): boolean {
+ return storedNames.has(normalizeName(name))
+}
+
+function rowNameError(row: HeaderRow): string | null {
+ const parsed = RemoteAccessHeaderNameSchema.safeParse(row.name)
+ return parsed.success ? null : parsed.error.issues[0]?.message ?? 'Invalid header name'
+}
+
+function rowValueError(row: HeaderRow, stored: boolean): string | null {
+ if (stored && row.value.trim() === '') return null
+ const parsed = RemoteAccessHeaderValueSchema.safeParse(row.value)
+ return parsed.success ? null : parsed.error.issues[0]?.message ?? 'Invalid header value'
+}
+
+function isRowValid(row: HeaderRow, stored: boolean): boolean {
+ return RemoteAccessHeaderNameSchema.safeParse(row.name).success && rowValueError(row, stored) === null
+}
+
+export function RemoteAccessHeadersSettings() {
+ const { headers, unreadable, isLoading, replace, clear, isSaving } = useRemoteAccessHeaders()
+ const [rows, setRows] = useState(() => (isLoading ? [] : createRows(headers)))
+ const [error, setError] = useState(null)
+ const [confirmClear, setConfirmClear] = useState(false)
+ const initialized = useRef(!isLoading)
+
+ useEffect(() => {
+ if (initialized.current || isLoading) return
+ initialized.current = true
+ setRows(createRows(headers))
+ }, [headers, isLoading])
+
+ const storedNames = new Set(headers.map((header) => normalizeName(header.name)))
+ const rowsAreValid = rows.every((row) => isRowValid(row, hasStoredValue(storedNames, row.name)))
+ const atMaxCount = rows.length >= REMOTE_ACCESS_HEADER_MAX_COUNT
+
+ const handleAdd = () => {
+ setRows((current) => [...current, { id: nextRowId(), name: '', value: '' }])
+ }
+
+ const handleCloudflarePreset = () => {
+ setRows((current) => {
+ const existingNames = new Set(current.map((row) => normalizeName(row.name)))
+ const missingNames = CLOUDFLARE_ACCESS_HEADER_NAMES.filter(
+ (name) => !existingNames.has(normalizeName(name)),
+ )
+ const availableSlots = REMOTE_ACCESS_HEADER_MAX_COUNT - current.length
+ return [
+ ...current,
+ ...missingNames.slice(0, Math.max(availableSlots, 0)).map((name) => ({
+ id: nextRowId(),
+ name,
+ value: '',
+ })),
+ ]
+ })
+ }
+
+ const handleRemove = (index: number) => {
+ setRows((current) => current.filter((_, rowIndex) => rowIndex !== index))
+ }
+
+ const handleNameChange = (index: number, name: string) => {
+ setRows((current) =>
+ current.map((row, rowIndex) => (rowIndex === index ? { ...row, name } : row)),
+ )
+ }
+
+ const handleValueChange = (index: number, value: string) => {
+ setRows((current) =>
+ current.map((row, rowIndex) => (rowIndex === index ? { ...row, value } : row)),
+ )
+ }
+
+ const handleSave = async () => {
+ setError(null)
+ if (!rowsAreValid) return
+
+ try {
+ const response = await replace.mutateAsync({
+ headers: rows.map((row) => ({
+ name: row.name.trim(),
+ value: row.value.trim() === '' ? undefined : row.value.trim(),
+ })),
+ })
+ setRows(createRows(response.headers))
+ } catch (saveError) {
+ setError(saveError instanceof Error ? saveError.message : 'Failed to save remote access headers')
+ }
+ }
+
+ const handleClear = async () => {
+ setError(null)
+ setConfirmClear(false)
+
+ try {
+ const response = await clear.mutateAsync()
+ setRows(createRows(response.headers))
+ } catch (clearError) {
+ setError(clearError instanceof Error ? clearError.message : 'Failed to clear remote access headers')
+ }
+ }
+
+ return (
+
+
+
+
+ Remote access headers
+
+
+ Sent by the mobile app on every request, so it can pass a reverse proxy such as Cloudflare Access.
+
+
+
+ {unreadable && (
+
+
+
+ Stored headers could not be decrypted (AUTH_SECRET may have changed). Enter them again.
+
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+ {rows.length === 0 ? (
+
+ No remote access headers are stored.
+
+ ) : (
+
+ {rows.map((row, index) => {
+ const stored = hasStoredValue(storedNames, row.name)
+ const nameError = row.name.trim() === '' ? null : rowNameError(row)
+ const valueError = row.name.trim() === '' ? null : rowValueError(row, stored)
+
+ return (
+
+
+
+
handleNameChange(index, event.target.value)}
+ placeholder="Header name"
+ aria-label={`Header ${index + 1} name`}
+ className="h-9 font-mono md:text-sm"
+ disabled={isSaving}
+ />
+ {nameError &&
{nameError}
}
+
+
+
handleValueChange(index, event.target.value)}
+ placeholder={stored ? STORED_VALUE_PLACEHOLDER : 'Value'}
+ aria-label={`Header ${index + 1} value`}
+ className="h-9 font-mono md:text-sm"
+ disabled={isSaving}
+ />
+ {valueError &&
{valueError}
}
+
+
handleRemove(index)}
+ aria-label={`Remove header ${index + 1}`}
+ className="h-9 w-9 shrink-0"
+ disabled={isSaving}
+ >
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+ Add header
+
+
+ Cloudflare Access service token
+
+
+ {replace.isPending && }
+ Save
+
+
setConfirmClear(true)}
+ disabled={isSaving || rows.length === 0}
+ >
+ Clear all
+
+
+ >
+ )}
+
+
+ Cloudflare Access: create a service token in Zero Trust, add a Service Auth policy for it
+ on the Manager's Access application, then paste its Client ID and Client Secret here.
+
+
+
+ !open && setConfirmClear(false)}
+ onConfirm={handleClear}
+ onCancel={() => setConfirmClear(false)}
+ title="Clear Remote Access Headers"
+ description="The mobile app will stop sending these headers on every request."
+ isDeleting={clear.isPending}
+ />
+
+ )
+}
diff --git a/frontend/src/components/settings/SettingsDialog.tsx b/frontend/src/components/settings/SettingsDialog.tsx
index 5f9c5f0cc..c7110eee9 100644
--- a/frontend/src/components/settings/SettingsDialog.tsx
+++ b/frontend/src/components/settings/SettingsDialog.tsx
@@ -129,7 +129,7 @@ export function SettingsDialog() {
}, [isOpen, isDesktop, selectedTab, pushSectionHistory])
const menuItems: Array<{ id: SettingsContentTab; icon: LucideIcon; label: string; description: string }> = [
- { id: 'account', icon: User, label: 'Account', description: 'Profile, passkeys, and sign out' },
+ { id: 'account', icon: User, label: 'Account', description: 'Profile, passkeys, devices, and sign out' },
{ id: 'general', icon: Settings2, label: 'General Settings', description: 'App preferences and behavior' },
{ id: 'notifications', icon: Bell, label: 'Notifications', description: 'Push notification preferences' },
{ id: 'voice', icon: Volume2, label: 'Voice', description: 'Text-to-speech and speech-to-text settings' },
diff --git a/frontend/src/contexts/AuthContext.test.tsx b/frontend/src/contexts/AuthContext.test.tsx
index d25fc2766..cb9f86756 100644
--- a/frontend/src/contexts/AuthContext.test.tsx
+++ b/frontend/src/contexts/AuthContext.test.tsx
@@ -1,7 +1,12 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { act, render, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useState } from 'react'
+import { act, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { describeUserAgent } from '@opencode-manager/shared/utils'
+import { PAIRING_CODE_INVALID_MESSAGE } from '@opencode-manager/shared/schemas'
+import { FetchError } from '@/api/fetchWrapper'
import { LocationCatcher } from '@/test/test-utils'
import { AuthProvider } from './AuthContext'
import { QueryCachePersistenceProvider } from './QueryCachePersistenceContext'
@@ -17,6 +22,7 @@ const mocks = vi.hoisted(() => ({
signInPasskey: vi.fn(),
signUpEmail: vi.fn(),
signOut: vi.fn(),
+ redeemDevicePairingCode: vi.fn(),
}))
vi.mock('@/lib/auth-client', () => ({
@@ -24,12 +30,17 @@ vi.mock('@/lib/auth-client', () => ({
signIn: { email: mocks.signInEmail, social: mocks.signInSocial },
signUp: { email: mocks.signUpEmail },
signOut: mocks.signOut,
- authClient: { signIn: { passkey: mocks.signInPasskey } },
+ authClient: { signIn: { passkey: mocks.signInPasskey }, passkey: { addPasskey: vi.fn() } },
+}))
+
+vi.mock('@/api/devicePairing', () => ({
+ redeemDevicePairingCode: mocks.redeemDevicePairingCode,
}))
type AuthValue = ReturnType
const STORED_USER = { id: 'user-1', email: 'user@example.com', name: 'Ada' }
+const pairingCode = 'a'.repeat(43)
function renderAuth(initialEntry: string, options: { persistence?: boolean } = {}) {
const capturedPathname: { current: string } = { current: '' }
@@ -71,6 +82,48 @@ function AuthProbe({ captured }: { captured: { current: AuthValue | null } }) {
return null
}
+function PairingSignIn() {
+ const { signInWithPairingCode } = useAuth()
+ const [error, setError] = useState(null)
+
+ const submit = async () => {
+ const result = await signInWithPairingCode(pairingCode)
+ setError(result.error ?? null)
+ }
+
+ return (
+ <>
+
+ Pair
+
+ {error && {error}
}
+ >
+ )
+}
+
+function renderPairing(initialEntry: string) {
+ return render(
+
+
+
+ } />
+ repos marker } />
+ home marker } />
+
+
+ ,
+ )
+}
+
+async function pair() {
+ const user = userEvent.setup()
+ await user.click(screen.getByRole('button', { name: 'Pair' }))
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
describe('AuthProvider post-login navigation', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -141,6 +194,68 @@ describe('AuthProvider post-login navigation', () => {
})
})
+describe('AuthProvider signInWithPairingCode', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ mocks.useSession.mockReturnValue({ data: null, error: null, isPending: false, refetch: mocks.refetch })
+ mocks.refetch.mockResolvedValue(undefined)
+ mocks.redeemDevicePairingCode.mockResolvedValue({ user: { id: 'u1', name: 'Ada', email: 'ada@example.com' } })
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ enabledProviders: ['credentials'], registrationEnabled: true, isFirstUser: false, adminConfigured: true }),
+ }))
+ })
+
+ it('redeems the code with the described device and returns to the redirect target', async () => {
+ renderPairing('/login?redirect=%2Frepos%2F1')
+
+ await pair()
+
+ expect(await screen.findByText('repos marker')).toBeInTheDocument()
+ expect(mocks.redeemDevicePairingCode).toHaveBeenCalledWith({
+ code: pairingCode,
+ deviceName: describeUserAgent(navigator.userAgent),
+ })
+ expect(mocks.refetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('lands home without a redirect target', async () => {
+ renderPairing('/login')
+
+ await pair()
+
+ expect(await screen.findByText('home marker')).toBeInTheDocument()
+ })
+
+ it('shows the server message when the code expired or was already used', async () => {
+ mocks.redeemDevicePairingCode.mockRejectedValue(
+ new FetchError(PAIRING_CODE_INVALID_MESSAGE, 400, 'PAIRING_CODE_INVALID'),
+ )
+ renderPairing('/login?redirect=%2Frepos%2F1')
+
+ await pair()
+
+ expect(await screen.findByText(PAIRING_CODE_INVALID_MESSAGE)).toBeInTheDocument()
+ expect(screen.queryByText('repos marker')).not.toBeInTheDocument()
+ expect(mocks.refetch).not.toHaveBeenCalled()
+ })
+
+ it('shows the rate-limit message for a 429', async () => {
+ mocks.redeemDevicePairingCode.mockRejectedValue(
+ new FetchError('Too many attempts', 429, 'RATE_LIMITED'),
+ )
+ renderPairing('/login')
+
+ await pair()
+
+ expect(
+ await screen.findByText('Too many attempts. Wait a minute and try again.'),
+ ).toBeInTheDocument()
+ expect(mocks.refetch).not.toHaveBeenCalled()
+ })
+})
+
describe('AuthProvider session restore', () => {
beforeEach(() => {
vi.clearAllMocks()
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx
index 0d9df3c64..41f85b1fe 100644
--- a/frontend/src/contexts/AuthContext.tsx
+++ b/frontend/src/contexts/AuthContext.tsx
@@ -1,6 +1,9 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useEffect, useMemo, useState, useCallback, type ReactNode } from 'react'
+import { describeUserAgent } from '@opencode-manager/shared/utils'
import { useSession, signIn, signUp, signOut, authClient } from '@/lib/auth-client'
+import { redeemDevicePairingCode } from '@/api/devicePairing'
+import { FetchError } from '@/api/fetchWrapper'
import { useNavigate, useLocation } from 'react-router-dom'
import { getLoginRedirectTarget } from '@/lib/auth-loaders'
import {
@@ -11,6 +14,9 @@ import {
type StoredAuthUser,
} from '@/lib/authSession'
+const RATE_LIMITED_MESSAGE = 'Too many attempts. Wait a minute and try again.'
+const SIGN_IN_FAILED_MESSAGE = 'Sign-in failed'
+
interface AuthConfig {
enabledProviders: string[]
registrationEnabled: boolean
@@ -26,6 +32,7 @@ interface AuthContextValue {
signInWithEmail: (email: string, password: string) => Promise<{ error?: string }>
signInWithProvider: (provider: 'github' | 'google' | 'discord') => Promise<{ error?: string }>
signInWithPasskey: () => Promise<{ error?: string }>
+ signInWithPairingCode: (code: string) => Promise<{ error?: string }>
signUpWithEmail: (email: string, password: string, name: string) => Promise<{ error?: string }>
addPasskey: (name?: string) => Promise<{ error?: string }>
logout: () => Promise
@@ -112,6 +119,23 @@ export function AuthProvider({ children }: AuthProviderProps) {
}
}, [refetch, navigate, redirectTarget])
+ const signInWithPairingCode = useCallback(async (code: string) => {
+ try {
+ await redeemDevicePairingCode({
+ code,
+ deviceName: describeUserAgent(navigator.userAgent),
+ })
+ await refetch()
+ } catch (err) {
+ if (err instanceof FetchError && err.statusCode === 429) {
+ return { error: RATE_LIMITED_MESSAGE }
+ }
+ return { error: err instanceof Error && err.message ? err.message : SIGN_IN_FAILED_MESSAGE }
+ }
+ navigate(redirectTarget, { replace: true })
+ return {}
+ }, [refetch, navigate, redirectTarget])
+
const signUpWithEmail = useCallback(async (email: string, password: string, name: string) => {
const result = await signUp.email({ email, password, name })
if (result.error) {
@@ -175,6 +199,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
signInWithEmail,
signInWithProvider,
signInWithPasskey,
+ signInWithPairingCode,
signUpWithEmail,
addPasskey,
logout,
@@ -186,6 +211,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
signInWithEmail,
signInWithProvider,
signInWithPasskey,
+ signInWithPairingCode,
signUpWithEmail,
addPasskey,
logout,
diff --git a/frontend/src/hooks/useDevicePairing.ts b/frontend/src/hooks/useDevicePairing.ts
new file mode 100644
index 000000000..a3d01ed93
--- /dev/null
+++ b/frontend/src/hooks/useDevicePairing.ts
@@ -0,0 +1,81 @@
+import { useCallback } from 'react'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import type {
+ CreateDevicePairingLinksRequest,
+ RemoteAccessHeadersResponse,
+ ReplaceRemoteAccessHeadersRequest,
+} from '@opencode-manager/shared/schemas'
+import {
+ clearRemoteAccessHeaders,
+ createDevicePairingLinks,
+ getRemoteAccessHeaders,
+ listPairedDevices,
+ replaceRemoteAccessHeaders,
+ revokePairedDevice,
+} from '@/api/devicePairing'
+
+const REMOTE_ACCESS_HEADERS_QUERY_KEY = ['remote-access-headers']
+const PAIRED_DEVICES_QUERY_KEY = ['paired-devices']
+
+export function useRemoteAccessHeaders() {
+ const queryClient = useQueryClient()
+
+ const query = useQuery({
+ queryKey: REMOTE_ACCESS_HEADERS_QUERY_KEY,
+ queryFn: getRemoteAccessHeaders,
+ })
+
+ const setHeaders = (response: RemoteAccessHeadersResponse) => {
+ queryClient.setQueryData(REMOTE_ACCESS_HEADERS_QUERY_KEY, response)
+ }
+
+ const replace = useMutation({
+ mutationFn: (body: ReplaceRemoteAccessHeadersRequest) => replaceRemoteAccessHeaders(body),
+ onSuccess: setHeaders,
+ })
+
+ const clear = useMutation({
+ mutationFn: clearRemoteAccessHeaders,
+ onSuccess: setHeaders,
+ })
+
+ return {
+ headers: query.data?.headers ?? [],
+ unreadable: query.data?.unreadable ?? false,
+ isLoading: query.isLoading,
+ replace,
+ clear,
+ isSaving: replace.isPending || clear.isPending,
+ }
+}
+
+export function useCreateDevicePairingLinks() {
+ return useMutation({
+ mutationFn: (body: CreateDevicePairingLinksRequest = {}) => createDevicePairingLinks(body),
+ })
+}
+
+export function usePairedDevices() {
+ return useQuery({
+ queryKey: PAIRED_DEVICES_QUERY_KEY,
+ queryFn: listPairedDevices,
+ })
+}
+
+export function useInvalidatePairedDevices() {
+ const queryClient = useQueryClient()
+
+ return useCallback(
+ () => queryClient.invalidateQueries({ queryKey: PAIRED_DEVICES_QUERY_KEY }),
+ [queryClient],
+ )
+}
+
+export function useRevokePairedDevice() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (id: string) => revokePairedDevice(id),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: PAIRED_DEVICES_QUERY_KEY }),
+ })
+}
diff --git a/frontend/src/lib/auth-client.ts b/frontend/src/lib/auth-client.ts
index e1a5050d9..03bdc307e 100644
--- a/frontend/src/lib/auth-client.ts
+++ b/frontend/src/lib/auth-client.ts
@@ -2,7 +2,7 @@ import { createAuthClient } from 'better-auth/react'
import { passkeyClient } from '@better-auth/passkey/client'
import { API_BASE_URL } from '@/config'
-const getBaseUrl = () => {
+export const getAuthBaseUrl = (): string => {
if (typeof window !== 'undefined') {
return `${window.location.origin}/api/auth`
}
@@ -10,7 +10,7 @@ const getBaseUrl = () => {
}
export const authClient = createAuthClient({
- baseURL: getBaseUrl(),
+ baseURL: getAuthBaseUrl(),
plugins: [passkeyClient()],
})
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index 32066716f..6913b419c 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -3,13 +3,15 @@ import { useLoaderData } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
+import { parseManagerBrowserPairingLink } from '@opencode-manager/shared/schemas'
import { useAuth } from '@/hooks/useAuth'
import { useTheme } from '@/hooks/useTheme'
+import { QrCodeScanner } from '@/components/settings/QrCodeScanner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
-import { Loader2, Github, KeyRound, Mail, AlertCircle } from 'lucide-react'
+import { Loader2, Github, KeyRound, Mail, AlertCircle, ScanLine } from 'lucide-react'
import type { AuthConfig } from '@/lib/auth-loaders'
const loginSchema = z.object({
@@ -20,13 +22,15 @@ const loginSchema = z.object({
type LoginFormData = z.infer
export function Login() {
- const { signInWithEmail, signInWithProvider, signInWithPasskey } = useAuth()
+ const { signInWithEmail, signInWithProvider, signInWithPasskey, signInWithPairingCode } = useAuth()
const { config } = useLoaderData() as { config: AuthConfig }
const theme = useTheme()
const [error, setError] = useState(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false)
const [oauthLoading, setOauthLoading] = useState(null)
+ const [isScannerOpen, setIsScannerOpen] = useState(false)
+ const [isRedeeming, setIsRedeeming] = useState(false)
const {
register,
@@ -75,6 +79,28 @@ export function Login() {
}
}
+ const handleScan = async (text: string) => {
+ setError(null)
+ const link = parseManagerBrowserPairingLink(text)
+ if (!link) {
+ setError('That QR code is not a Manager sign-in code.')
+ return
+ }
+ if (link.origin !== window.location.origin) {
+ setError(`This QR code is for ${link.origin}. Open that address to sign in.`)
+ return
+ }
+ setIsRedeeming(true)
+ try {
+ const result = await signInWithPairingCode(link.code)
+ if (result.error) {
+ setError(result.error)
+ }
+ } finally {
+ setIsRedeeming(false)
+ }
+ }
+
const hasOAuth = config.enabledProviders.some(p => ['github', 'google', 'discord'].includes(p))
const hasPasskey = config.enabledProviders.includes('passkey')
const hasCredentials = config.enabledProviders.includes('credentials')
@@ -98,6 +124,16 @@ export function Login() {
)}
+ setIsScannerOpen(true)}
+ disabled={isRedeeming}
+ >
+
+ Sign in with QR code
+
+
{hasPasskey && (
-
+
)
diff --git a/frontend/src/pages/Pair.tsx b/frontend/src/pages/Pair.tsx
new file mode 100644
index 000000000..f810f040e
--- /dev/null
+++ b/frontend/src/pages/Pair.tsx
@@ -0,0 +1,109 @@
+import { useEffect, useRef, useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { AlertCircle, Loader2 } from 'lucide-react'
+import { showToast } from '@/lib/toast'
+import { readPairingCodeFromHash } from '@opencode-manager/shared/schemas'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import { useAuth } from '@/hooks/useAuth'
+import { useTheme } from '@/hooks/useTheme'
+import { getSession } from '@/lib/auth-client'
+
+type PairState = 'redeeming' | 'missing' | 'error'
+
+/** Signs the browser in from a `/pair#c=` link. */
+export function Pair() {
+ const navigate = useNavigate()
+ const theme = useTheme()
+ const { signInWithPairingCode } = useAuth()
+ const [code] = useState(() => readPairingCodeFromHash(window.location.hash))
+ const [state, setState] = useState('redeeming')
+ const [errorMessage, setErrorMessage] = useState(null)
+ const startedRef = useRef(false)
+
+ useEffect(() => {
+ if (!window.location.hash) return
+ window.history.replaceState(
+ window.history.state,
+ '',
+ window.location.pathname + window.location.search,
+ )
+ }, [])
+
+ useEffect(() => {
+ if (startedRef.current) return
+ startedRef.current = true
+
+ const run = async () => {
+ if (!code) {
+ setState('missing')
+ return
+ }
+
+ const session = await getSession()
+ if (session?.data?.user) {
+ showToast.info('Already signed in')
+ navigate('/', { replace: true })
+ return
+ }
+
+ const result = await signInWithPairingCode(code)
+ if (result.error) {
+ setErrorMessage(result.error)
+ setState('error')
+ }
+ }
+
+ void run().catch((error: unknown) => {
+ setErrorMessage(error instanceof Error && error.message ? error.message : 'Sign-in failed')
+ setState('error')
+ })
+ }, [code, navigate, signInWithPairingCode])
+
+ return (
+
+
+
+
+
+
+
+ {state === 'redeeming' && (
+
+ )}
+
+ {state === 'missing' && (
+ <>
+
+
+ No pairing code was found
+
+
+ Sign in
+
+ >
+ )}
+
+ {state === 'error' && (
+ <>
+
+
+ {errorMessage}
+
+
+ Sign in
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/__tests__/Login.test.tsx b/frontend/src/pages/__tests__/Login.test.tsx
new file mode 100644
index 000000000..617a8190f
--- /dev/null
+++ b/frontend/src/pages/__tests__/Login.test.tsx
@@ -0,0 +1,130 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import {
+ buildManagerBrowserPairingLink,
+ PAIRING_CODE_INVALID_MESSAGE,
+} from '@opencode-manager/shared/schemas'
+import { Login } from '../Login'
+
+const mocks = vi.hoisted(() => ({
+ signInWithEmail: vi.fn(),
+ signInWithProvider: vi.fn(),
+ signInWithPasskey: vi.fn(),
+ signInWithPairingCode: vi.fn(),
+ scanText: { value: '' },
+}))
+
+vi.mock('react-router-dom', () => ({
+ useLoaderData: () => ({
+ config: {
+ enabledProviders: ['credentials', 'passkey'],
+ registrationEnabled: true,
+ isFirstUser: false,
+ adminConfigured: false,
+ },
+ }),
+}))
+
+vi.mock('@/hooks/useAuth', () => ({
+ useAuth: () => ({
+ signInWithEmail: mocks.signInWithEmail,
+ signInWithProvider: mocks.signInWithProvider,
+ signInWithPasskey: mocks.signInWithPasskey,
+ signInWithPairingCode: mocks.signInWithPairingCode,
+ }),
+}))
+
+vi.mock('@/hooks/useTheme', () => ({
+ useTheme: () => 'light',
+}))
+
+vi.mock('@/components/settings/QrCodeScanner', () => ({
+ QrCodeScanner: ({ open, onResult }: { open: boolean; onResult: (text: string) => void }) =>
+ open ? (
+ onResult(mocks.scanText.value)}>
+ Deliver scanned text
+
+ ) : null,
+}))
+
+const code = 'a'.repeat(43)
+
+function renderLogin() {
+ return render( )
+}
+
+async function scan(text: string) {
+ const user = userEvent.setup()
+ mocks.scanText.value = text
+ await user.click(screen.getByRole('button', { name: 'Sign in with QR code' }))
+ await user.click(await screen.findByRole('button', { name: 'Deliver scanned text' }))
+}
+
+describe('Login', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.scanText.value = ''
+ mocks.signInWithPairingCode.mockResolvedValue({})
+ })
+
+ it('starts a QR scan from the sign-in button', async () => {
+ const user = userEvent.setup()
+ renderLogin()
+
+ const button = screen.getByRole('button', { name: 'Sign in with QR code' })
+ expect(button).toBeEnabled()
+ expect(screen.queryByRole('button', { name: 'Deliver scanned text' })).not.toBeInTheDocument()
+
+ await user.click(button)
+
+ expect(await screen.findByRole('button', { name: 'Deliver scanned text' })).toBeInTheDocument()
+ })
+
+ it('redeems the code from a manager sign-in QR code', async () => {
+ renderLogin()
+
+ await scan(buildManagerBrowserPairingLink(window.location.origin, code))
+
+ await waitFor(() => {
+ expect(mocks.signInWithPairingCode).toHaveBeenCalledTimes(1)
+ })
+ expect(mocks.signInWithPairingCode).toHaveBeenCalledWith(code)
+ })
+
+ it('rejects a QR code for another origin', async () => {
+ renderLogin()
+
+ await scan(buildManagerBrowserPairingLink('https://other.example.com', code))
+
+ expect(
+ await screen.findByText(
+ 'This QR code is for https://other.example.com. Open that address to sign in.',
+ ),
+ ).toBeInTheDocument()
+ expect(mocks.signInWithPairingCode).not.toHaveBeenCalled()
+ })
+
+ it.each(['ocm://link?v=2&kind=manager&name=Desk', 'not a pairing link'])(
+ 'rejects text that is not a manager sign-in code: %s',
+ async (text) => {
+ renderLogin()
+
+ await scan(text)
+
+ expect(
+ await screen.findByText('That QR code is not a Manager sign-in code.'),
+ ).toBeInTheDocument()
+ expect(mocks.signInWithPairingCode).not.toHaveBeenCalled()
+ },
+ )
+
+ it('shows the redeem error in the page alert', async () => {
+ mocks.signInWithPairingCode.mockResolvedValue({ error: PAIRING_CODE_INVALID_MESSAGE })
+ renderLogin()
+
+ await scan(buildManagerBrowserPairingLink(window.location.origin, code))
+
+ expect(await screen.findByText(PAIRING_CODE_INVALID_MESSAGE)).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/__tests__/Pair.test.tsx b/frontend/src/pages/__tests__/Pair.test.tsx
new file mode 100644
index 000000000..352dc58b4
--- /dev/null
+++ b/frontend/src/pages/__tests__/Pair.test.tsx
@@ -0,0 +1,117 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { StrictMode } from 'react'
+import { render, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { PAIRING_CODE_INVALID_MESSAGE } from '@opencode-manager/shared/schemas'
+import { Pair } from '../Pair'
+
+const mocks = vi.hoisted(() => ({
+ signInWithPairingCode: vi.fn(),
+ getSession: vi.fn(),
+}))
+
+vi.mock('@/lib/auth-client', () => ({
+ getSession: mocks.getSession,
+}))
+
+vi.mock('@/hooks/useAuth', async () => {
+ const { useNavigate } = await import('react-router-dom')
+ return {
+ useAuth: () => {
+ const navigate = useNavigate()
+ return {
+ signInWithPairingCode: async (code: string) => {
+ const result = await mocks.signInWithPairingCode(code)
+ if (!result.error) navigate('/', { replace: true })
+ return result
+ },
+ }
+ },
+ }
+})
+
+const code = 'a'.repeat(43)
+
+function renderPair() {
+ return render(
+
+
+
+ } />
+ home marker} />
+
+
+ ,
+ )
+}
+
+describe('Pair', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ window.history.replaceState(null, '', '/pair')
+ mocks.getSession.mockResolvedValue({ data: null })
+ mocks.signInWithPairingCode.mockResolvedValue({})
+ })
+
+ it('redeems once with the hash cleared first and lands home', async () => {
+ window.history.replaceState(null, '', '/pair#c=' + code)
+ mocks.signInWithPairingCode.mockImplementation(async () => {
+ expect(window.location.hash).toBe('')
+ return {}
+ })
+
+ renderPair()
+
+ await screen.findByText('home marker')
+ expect(window.location.hash).toBe('')
+ expect(mocks.signInWithPairingCode).toHaveBeenCalledTimes(1)
+ expect(mocks.signInWithPairingCode).toHaveBeenCalledWith(code)
+ expect(mocks.getSession).toHaveBeenCalled()
+ })
+
+ it('keeps an existing session without redeeming', async () => {
+ mocks.getSession.mockResolvedValue({ data: { user: { id: 'u1' } } })
+ window.history.replaceState(null, '', '/pair#c=' + code)
+
+ renderPair()
+
+ await screen.findByText('home marker')
+ expect(mocks.signInWithPairingCode).not.toHaveBeenCalled()
+ expect(window.location.hash).toBe('')
+ })
+
+ it('shows the message and a sign-in link for an expired or used code', async () => {
+ window.history.replaceState(null, '', '/pair#c=' + code)
+ mocks.signInWithPairingCode.mockResolvedValue({ error: PAIRING_CODE_INVALID_MESSAGE })
+
+ renderPair()
+
+ expect(await screen.findByText(PAIRING_CODE_INVALID_MESSAGE)).toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/login')
+ })
+ })
+
+ it('shows the rate-limit message for a 429', async () => {
+ window.history.replaceState(null, '', '/pair#c=' + code)
+ mocks.signInWithPairingCode.mockResolvedValue({
+ error: 'Too many attempts. Wait a minute and try again.',
+ })
+
+ renderPair()
+
+ expect(
+ await screen.findByText('Too many attempts. Wait a minute and try again.'),
+ ).toBeInTheDocument()
+ })
+
+ it('reports a missing code', async () => {
+ renderPair()
+
+ expect(await screen.findByText('No pairing code was found')).toBeInTheDocument()
+ expect(mocks.signInWithPairingCode).not.toHaveBeenCalled()
+ await waitFor(() => {
+ expect(screen.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/login')
+ })
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 358210671..4937d0039 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -234,6 +234,9 @@ importers:
tailwind-merge:
specifier: ^3.3.1
version: 3.4.0
+ uqr:
+ specifier: ^0.1.3
+ version: 0.1.3
zod:
specifier: ^4.1.12
version: 4.3.2
diff --git a/shared/src/config/env.ts b/shared/src/config/env.ts
index b8f647635..be8d9dcdf 100644
--- a/shared/src/config/env.ts
+++ b/shared/src/config/env.ts
@@ -176,6 +176,17 @@ export const getTrustedOrigins = (): string[] =>
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0)
+/** Reports whether a request Origin is one of the configured trusted origins. */
+export const isTrustedOrigin = (
+ origin: string | undefined,
+ options: { requireOrigin?: boolean } = {},
+ trustedOrigins: readonly string[] = getTrustedOrigins(),
+): boolean => {
+ if (!origin) return !(options.requireOrigin ?? false)
+ if (origin === 'null') return false
+ return trustedOrigins.includes(origin)
+}
+
export const getApiUrl = (port: number = ENV.SERVER.PORT): string => {
const host = ENV.SERVER.HOST
diff --git a/shared/src/schemas/device-pairing.ts b/shared/src/schemas/device-pairing.ts
new file mode 100644
index 000000000..a67676afa
--- /dev/null
+++ b/shared/src/schemas/device-pairing.ts
@@ -0,0 +1,204 @@
+import { z } from "zod";
+import { trimUrlTrailingSlashes } from "../utils/url";
+
+export const REMOTE_ACCESS_HEADER_MAX_COUNT = 8;
+export const REMOTE_ACCESS_HEADER_VALUE_MAX_LENGTH = 512;
+export const REMOTE_ACCESS_HEADER_NAME_MAX_LENGTH = 128;
+export const REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH = 1800;
+export const CLOUDFLARE_ACCESS_HEADER_NAMES = ["CF-Access-Client-Id", "CF-Access-Client-Secret"] as const;
+export const DEVICE_PAIRING_PATH = "/pair";
+export const DEVICE_PAIRING_CODE_TTL_SECONDS = 180;
+
+export const DEVICE_PAIRING_ERROR_CODES = {
+ PAIRING_CODE_INVALID: "PAIRING_CODE_INVALID",
+ DEVICE_NOT_FOUND: "DEVICE_NOT_FOUND",
+ CANNOT_REVOKE_CURRENT_SESSION: "CANNOT_REVOKE_CURRENT_SESSION",
+} as const;
+
+export const PAIRING_CODE_INVALID_MESSAGE =
+ "This pairing code has expired or was already used. Show a new QR code and scan again.";
+
+const HTTP_HEADER_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
+const BLOCKED_REMOTE_ACCESS_HEADER_NAMES = new Set([
+ "cookie",
+ "set-cookie",
+ "host",
+ "origin",
+ "content-length",
+ "content-type",
+ "connection",
+ "transfer-encoding",
+ "upgrade",
+ "te",
+ "keep-alive",
+ "x-forwarded-for",
+ "x-forwarded-host",
+ "x-forwarded-proto",
+ "forwarded",
+ "x-real-ip",
+ "cf-connecting-ip",
+]);
+
+/** Reports whether a header name is reserved by the transport or uses the proxy- prefix. */
+export function isBlockedRemoteAccessHeaderName(name: string): boolean {
+ const normalized = name.trim().toLowerCase();
+ return BLOCKED_REMOTE_ACCESS_HEADER_NAMES.has(normalized) || normalized.startsWith("proxy-");
+}
+
+export const RemoteAccessHeaderNameSchema = z
+ .string()
+ .trim()
+ .min(1)
+ .max(REMOTE_ACCESS_HEADER_NAME_MAX_LENGTH)
+ .regex(HTTP_HEADER_NAME_TOKEN, "Header names must be valid HTTP tokens")
+ .refine((name) => !isBlockedRemoteAccessHeaderName(name), "This header name is reserved");
+
+const remoteAccessHeaderValueRules = z
+ .string()
+ .refine((value) => !/[\r\n\0]/.test(value), "Header values must not contain line breaks")
+ .trim()
+ .max(REMOTE_ACCESS_HEADER_VALUE_MAX_LENGTH);
+
+export const RemoteAccessHeaderValueSchema = remoteAccessHeaderValueRules.min(1);
+
+export const RemoteAccessHeaderDraftValueSchema = remoteAccessHeaderValueRules;
+
+export const RemoteAccessHeaderSchema = z.object({
+ name: RemoteAccessHeaderNameSchema,
+ value: RemoteAccessHeaderValueSchema,
+});
+
+export const RemoteAccessHeaderListSchema = z
+ .array(RemoteAccessHeaderSchema)
+ .max(REMOTE_ACCESS_HEADER_MAX_COUNT)
+ .refine(
+ (headers) => new Set(headers.map((header) => header.name.toLowerCase())).size === headers.length,
+ "Header names must be unique",
+ )
+ .refine(
+ (headers) => encodedRemoteAccessHeadersLength(headers) <= REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH,
+ `Remote access headers are too long to fit in a QR code (limit ${REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH} encoded characters)`,
+ );
+
+/** Serializes a remote-access header into the `Name:Value` form carried by the link `h` parameter. */
+export function formatRemoteAccessHeaderParam(header: RemoteAccessHeader): string {
+ return `${header.name}:${header.value}`;
+}
+
+function encodedRemoteAccessHeadersLength(headers: RemoteAccessHeader[]): number {
+ const params = new URLSearchParams();
+ for (const header of headers) params.append("h", formatRemoteAccessHeaderParam(header));
+ return params.toString().length;
+}
+
+export const RemoteAccessHeaderSummarySchema = z.object({
+ name: z.string(),
+ hasValue: z.boolean(),
+});
+
+export const RemoteAccessHeadersResponseSchema = z.object({
+ headers: z.array(RemoteAccessHeaderSummarySchema),
+ unreadable: z.boolean(),
+});
+
+export const ReplaceRemoteAccessHeadersRequestSchema = z.object({
+ headers: z
+ .array(
+ z.object({
+ name: RemoteAccessHeaderNameSchema,
+ value: RemoteAccessHeaderDraftValueSchema.optional(),
+ }),
+ )
+ .max(REMOTE_ACCESS_HEADER_MAX_COUNT),
+});
+
+export const DevicePairingCodeSchema = z.string().min(16).max(256);
+export const DeviceNameSchema = z.string().trim().min(1).max(80);
+
+export const RedeemDevicePairingCodeRequestSchema = z.object({
+ code: DevicePairingCodeSchema,
+ deviceName: DeviceNameSchema.optional(),
+});
+
+export const RedeemDevicePairingCodeResponseSchema = z.object({
+ user: z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ }),
+});
+
+export const AppLinkBlockedReasonSchema = z.enum(["insecure-origin", "loopback"]);
+export const DevicePairingLinkWarningSchema = z.enum(["plain-http", "loopback", "headers-unreadable"]);
+
+export const CreateDevicePairingLinksRequestSchema = z.object({
+ name: z.string().trim().min(1).max(100).optional(),
+});
+
+export const DevicePairingLinksResponseSchema = z.object({
+ appLink: z.string().nullable(),
+ appLinkBlockedReason: AppLinkBlockedReasonSchema.nullable(),
+ browserLink: z.string(),
+ expiresAt: z.string(),
+ headerNames: z.array(z.string()),
+ warnings: z.array(DevicePairingLinkWarningSchema),
+});
+
+export const PairedDeviceSchema = z.object({
+ id: z.string(),
+ deviceName: z.string().nullable(),
+ userAgent: z.string().nullable(),
+ ipAddress: z.string().nullable(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ isCurrent: z.boolean(),
+});
+
+export const PairedDevicesResponseSchema = z.object({
+ devices: z.array(PairedDeviceSchema),
+});
+
+export type RemoteAccessHeader = z.infer;
+export type RemoteAccessHeaderDraftValue = z.infer;
+export type RemoteAccessHeaderList = z.infer;
+export type RemoteAccessHeaderSummary = z.infer;
+export type RemoteAccessHeadersResponse = z.infer;
+export type ReplaceRemoteAccessHeadersRequest = z.infer;
+export type DevicePairingCode = z.infer;
+export type DeviceName = z.infer;
+export type RedeemDevicePairingCodeRequest = z.infer;
+export type RedeemDevicePairingCodeResponse = z.infer;
+export type AppLinkBlockedReason = z.infer;
+export type DevicePairingLinkWarning = z.infer;
+export type CreateDevicePairingLinksRequest = z.infer;
+export type DevicePairingLinksResponse = z.infer;
+export type PairedDevice = z.infer;
+export type PairedDevicesResponse = z.infer;
+export type DevicePairingErrorCode =
+ (typeof DEVICE_PAIRING_ERROR_CODES)[keyof typeof DEVICE_PAIRING_ERROR_CODES];
+
+/** Builds the browser pairing link whose code stays in the URL fragment. */
+export function buildManagerBrowserPairingLink(origin: string, code: string): string {
+ return `${trimUrlTrailingSlashes(origin)}${DEVICE_PAIRING_PATH}#c=${encodeURIComponent(code)}`;
+}
+
+/** Reads a pairing code from a URL fragment, or null when the fragment holds no valid code. */
+export function readPairingCodeFromHash(hash: string): string | null {
+ const code = new URLSearchParams(hash.replace(/^#/, "")).get("c");
+ const parsed = DevicePairingCodeSchema.safeParse(code);
+ return parsed.success ? parsed.data : null;
+}
+
+/** Parses a browser pairing link into its origin and code, or null when the text is not one. */
+export function parseManagerBrowserPairingLink(text: string): { origin: string; code: string } | null {
+ let url: URL;
+ try {
+ url = new URL(text.trim());
+ } catch {
+ return null;
+ }
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
+ if (url.pathname !== DEVICE_PAIRING_PATH) return null;
+ const code = readPairingCodeFromHash(url.hash);
+ return code === null ? null : { origin: url.origin, code };
+}
diff --git a/shared/src/schemas/index.ts b/shared/src/schemas/index.ts
index 29ae7ad7d..b295a360b 100644
--- a/shared/src/schemas/index.ts
+++ b/shared/src/schemas/index.ts
@@ -25,3 +25,4 @@ export * from './preview'
export * from './change-walkthroughs'
export * from './opencode-servers'
export * from './secrets'
+export * from './device-pairing'
diff --git a/shared/src/schemas/opencode-servers.ts b/shared/src/schemas/opencode-servers.ts
index 5072e0dd7..1d2033609 100644
--- a/shared/src/schemas/opencode-servers.ts
+++ b/shared/src/schemas/opencode-servers.ts
@@ -1,4 +1,9 @@
import { z } from "zod";
+import {
+ DevicePairingCodeSchema,
+ RemoteAccessHeaderListSchema,
+ formatRemoteAccessHeaderParam,
+} from "./device-pairing";
export const LOCAL_OPENCODE_SERVER_ID = "local";
export const OPENCODE_SERVER_USERNAME = "opencode";
@@ -77,6 +82,14 @@ export const OpenCodeServerLinkSchema = z.object({
pairing: OpenCodeServerPairingSchema,
});
+export const ManagerAppLinkSchema = z.object({
+ kind: z.literal("manager"),
+ name: z.string().trim().min(1).max(100),
+ baseUrl: HttpUrlSchema,
+ code: DevicePairingCodeSchema,
+ headers: RemoteAccessHeaderListSchema,
+});
+
export type OpenCodeServer = z.infer;
export type OpenCodeServerPairing = z.infer;
export type CreateOpenCodeServerRequest = z.input;
@@ -85,33 +98,42 @@ export type TestOpenCodeServerRequest = z.input;
export type OpenCodeServerStatus = z.infer;
export type OpenCodeServerLink = z.infer;
+export type ManagerAppLink = z.infer;
/** Builds the `ocm://link` URL encoded into a pairing QR code. */
export function buildOpenCodeServerLink(link: OpenCodeServerLink): string {
- const params = new URLSearchParams({
- v: OPENCODE_SERVER_LINK_VERSION,
- kind: link.kind,
- name: link.name,
- url: link.baseUrl,
- pair: link.pairing.pairUrl,
- code: link.pairing.code,
- });
- return `${OPENCODE_SERVER_LINK_PROTOCOL}//link?${params.toString()}`;
+ const params = buildLinkParams(link);
+ params.set("pair", link.pairing.pairUrl);
+ params.set("code", link.pairing.code);
+ return buildLinkUrl(params);
}
/** Parses an `ocm://link` URL from a QR code or pasted text; returns null when it is not a valid link. */
export function parseOpenCodeServerLink(text: string): OpenCodeServerLink | null {
- const url = parseOpenCodeServerLinkUrl(text);
- if (!url) return null;
- if (url.searchParams.get("v") !== OPENCODE_SERVER_LINK_VERSION) return null;
-
- const parsed = OpenCodeServerLinkSchema.safeParse({
- kind: url.searchParams.get("kind"),
- name: url.searchParams.get("name"),
- baseUrl: url.searchParams.get("url"),
+ return readLink(OpenCodeServerLinkSchema, text, (url) => ({
pairing: { pairUrl: url.searchParams.get("pair"), code: url.searchParams.get("code") },
+ }));
+}
+
+/** Builds the `ocm://link` URL for a manager app pairing QR code, including remote-access headers. */
+export function buildManagerAppLink(link: ManagerAppLink): string {
+ const params = buildLinkParams(link);
+ params.set("code", link.code);
+ for (const header of link.headers) params.append("h", formatRemoteAccessHeaderParam(header));
+ return buildLinkUrl(params);
+}
+
+/** Parses an `ocm://link` manager app link; returns null when it is not a valid manager link. */
+export function parseManagerAppLink(text: string): ManagerAppLink | null {
+ return readLink(ManagerAppLinkSchema, text, (url) => {
+ const headers: Array<{ name: string; value: string }> = [];
+ for (const raw of url.searchParams.getAll("h")) {
+ const separator = raw.indexOf(":");
+ if (separator < 0) return null;
+ headers.push({ name: raw.slice(0, separator), value: raw.slice(separator + 1) });
+ }
+ return { headers };
});
- return parsed.success ? parsed.data : null;
}
/** Reads the `v` parameter of an `ocm://link` URL, or null when the text is not an `ocm://link` URL. */
@@ -130,6 +152,53 @@ function parseOpenCodeServerLinkUrl(text: string): URL | null {
return url;
}
+type LinkQueryFields = {
+ kind: string | null;
+ name: string | null;
+ baseUrl: string | null;
+ code: string | null;
+};
+
+function buildLinkParams(link: { kind: string; name: string; baseUrl: string }): URLSearchParams {
+ return new URLSearchParams({
+ v: OPENCODE_SERVER_LINK_VERSION,
+ kind: link.kind,
+ name: link.name,
+ url: link.baseUrl,
+ });
+}
+
+function buildLinkUrl(params: URLSearchParams): string {
+ return `${OPENCODE_SERVER_LINK_PROTOCOL}//link?${params.toString()}`;
+}
+
+function readLinkQueryFields(text: string): { url: URL; fields: LinkQueryFields } | null {
+ const url = parseOpenCodeServerLinkUrl(text);
+ if (!url || url.searchParams.get("v") !== OPENCODE_SERVER_LINK_VERSION) return null;
+ return {
+ url,
+ fields: {
+ kind: url.searchParams.get("kind"),
+ name: url.searchParams.get("name"),
+ baseUrl: url.searchParams.get("url"),
+ code: url.searchParams.get("code"),
+ },
+ };
+}
+
+function readLink(
+ schema: z.ZodType,
+ text: string,
+ readDetails: (url: URL) => Record | null,
+): T | null {
+ const link = readLinkQueryFields(text);
+ if (!link) return null;
+ const details = readDetails(link.url);
+ if (!details) return null;
+ const parsed = schema.safeParse({ ...link.fields, ...details });
+ return parsed.success ? parsed.data : null;
+}
+
const REMOTE_DIRECTORY_KEY_PATTERN = /^(srv_[A-Za-z0-9_-]+)\|(.+)$/s;
/** Builds the event-stream subscription key for a directory; local directories stay unchanged. */
diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts
index e1ea34960..f135b3768 100644
--- a/shared/src/schemas/settings.ts
+++ b/shared/src/schemas/settings.ts
@@ -205,6 +205,12 @@ export const DEFAULT_SESSION_DEFAULTS = {
goalMaxContinuations: 20,
} satisfies SessionDefaults;
+export const DevicePairingPreferencesSchema = z.object({
+ allowPrivateNetworkHttp: z.boolean().optional(),
+});
+
+export type DevicePairingPreferences = z.infer;
+
export const UserPreferencesSchema = z.object({
theme: z.enum(["dark", "light", "system"]),
colorTheme: ColorThemeIdSchema.optional(),
@@ -236,6 +242,7 @@ export const UserPreferencesSchema = z.object({
serverEnvVars: z.array(ServerEnvVarSchema).optional(),
sandbox: SandboxPreferencesSchema.optional(),
sessionDefaults: SessionDefaultsSchema.optional(),
+ devicePairing: DevicePairingPreferencesSchema.optional(),
disabledDefaultServerEnvVars: z.array(z.string()).optional(),
});
diff --git a/shared/src/utils/index.ts b/shared/src/utils/index.ts
index f52fff3c9..4871d5da8 100644
--- a/shared/src/utils/index.ts
+++ b/shared/src/utils/index.ts
@@ -7,3 +7,4 @@ export * from './semver'
export * from './terminal-title'
export * from './unified-diff'
export * from './url'
+export * from './user-agent'
diff --git a/shared/src/utils/url.ts b/shared/src/utils/url.ts
index 0c798b43c..e250dfc59 100644
--- a/shared/src/utils/url.ts
+++ b/shared/src/utils/url.ts
@@ -9,6 +9,50 @@ export function haveSameUrlOrigin(first: string, second: string): boolean {
return origin !== null && origin === readUrlOrigin(second)
}
+/** Reports whether a hostname refers to the local machine. */
+export function isLoopbackHost(hostname: string): boolean {
+ const host = normalizeHostname(hostname)
+ if (host === 'localhost' || host === '::1') return true
+ const octets = parseIpv4Octets(host)
+ return octets !== null && octets[0] === 127
+}
+
+/** Reports whether a hostname is an RFC 1918, link-local, or CGNAT address. */
+export function isPrivateNetworkHost(hostname: string): boolean {
+ const host = normalizeHostname(hostname)
+ const octets = parseIpv4Octets(host)
+ if (octets !== null) {
+ const [first, second] = octets
+ return (
+ first === 10 ||
+ (first === 172 && second >= 16 && second <= 31) ||
+ (first === 192 && second === 168) ||
+ (first === 169 && second === 254) ||
+ (first === 100 && second >= 64 && second <= 127)
+ )
+ }
+ if (!host.includes(':')) return false
+ const firstHextet = Number.parseInt(host.split(':')[0] ?? '', 16)
+ return firstHextet >= 0xfc00 && firstHextet <= 0xfdff
+}
+
+function normalizeHostname(hostname: string): string {
+ return hostname.trim().toLowerCase().replace(/^\[/, '').replace(/\]$/, '')
+}
+
+function parseIpv4Octets(host: string): [number, number, number, number] | null {
+ const parts = host.split('.')
+ if (parts.length !== 4) return null
+ const octets: [number, number, number, number] = [0, 0, 0, 0]
+ for (const [index, part] of parts.entries()) {
+ if (!/^\d{1,3}$/.test(part)) return null
+ const value = Number(part)
+ if (value > 255) return null
+ octets[index] = value
+ }
+ return octets
+}
+
function readUrlOrigin(value: string): string | null {
try {
return new URL(value).origin
diff --git a/shared/src/utils/user-agent.ts b/shared/src/utils/user-agent.ts
new file mode 100644
index 000000000..ea294377d
--- /dev/null
+++ b/shared/src/utils/user-agent.ts
@@ -0,0 +1,32 @@
+const UNKNOWN_DEVICE = 'Unknown device'
+const RAW_USER_AGENT_MAX_LENGTH = 80
+
+/** Describes a user agent as "browser on platform", falling back to the raw string. */
+export function describeUserAgent(userAgent: string | null | undefined): string {
+ const value = userAgent?.trim()
+ if (!value) return UNKNOWN_DEVICE
+
+ const platform = detectPlatform(value)
+ const browser = detectBrowser(value)
+ if (browser && platform) return `${browser} on ${platform}`
+ return browser ?? platform ?? value.slice(0, RAW_USER_AGENT_MAX_LENGTH)
+}
+
+function detectPlatform(userAgent: string): string | null {
+ if (userAgent.includes('iPhone')) return 'iPhone'
+ if (userAgent.includes('iPad')) return 'iPad'
+ if (userAgent.includes('Android')) return 'Android'
+ if (userAgent.includes('Macintosh')) return 'macOS'
+ if (userAgent.includes('Windows')) return 'Windows'
+ if (userAgent.includes('Linux')) return 'Linux'
+ return null
+}
+
+function detectBrowser(userAgent: string): string | null {
+ if (userAgent.includes('Edg/')) return 'Edge'
+ if (userAgent.includes('OPR/')) return 'Opera'
+ if (userAgent.includes('Firefox/') || userAgent.includes('FxiOS/')) return 'Firefox'
+ if (userAgent.includes('CriOS/') || userAgent.includes('Chrome/')) return 'Chrome'
+ if (userAgent.includes('Safari/') && userAgent.includes('Version/')) return 'Safari'
+ return null
+}
From 106e8f51fc0cf220398cd728a9a3d7ddf08dec41 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Fri, 23 Oct 2026 18:00:40 -0400
Subject: [PATCH 2/6] fix(frontend): show a message when the QR renderer fails
to load
A rejected dynamic import left the pairing dialog blank forever. The
renderer now loads through async/await with a catch and renders an error
instead of nothing when the chunk cannot be fetched.
---
.../settings/PairingQrCode.test.tsx | 22 ++++++++++++++++++-
.../src/components/settings/PairingQrCode.tsx | 21 +++++++++++++++---
2 files changed, 39 insertions(+), 4 deletions(-)
diff --git a/frontend/src/components/settings/PairingQrCode.test.tsx b/frontend/src/components/settings/PairingQrCode.test.tsx
index d8839e976..0eca25430 100644
--- a/frontend/src/components/settings/PairingQrCode.test.tsx
+++ b/frontend/src/components/settings/PairingQrCode.test.tsx
@@ -1,8 +1,28 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { PairingQrCode } from './PairingQrCode'
describe('PairingQrCode', () => {
+ afterEach(() => {
+ vi.doUnmock('uqr')
+ vi.resetModules()
+ })
+
+ it('reports a renderer that fails to load', async () => {
+ vi.resetModules()
+ vi.doMock('uqr', () => {
+ throw new Error('chunk failed')
+ })
+ const { PairingQrCode: FreshPairingQrCode } = await import('./PairingQrCode')
+
+ render( )
+
+ expect(
+ await screen.findByText('The QR code could not be loaded. Reload the page and try again.'),
+ ).toBeInTheDocument()
+ expect(screen.queryByAltText('Pairing QR code')).not.toBeInTheDocument()
+ })
+
it('renders the QR code once uqr has loaded', async () => {
render( )
diff --git a/frontend/src/components/settings/PairingQrCode.tsx b/frontend/src/components/settings/PairingQrCode.tsx
index f4cf6f23e..d7ce3b7cd 100644
--- a/frontend/src/components/settings/PairingQrCode.tsx
+++ b/frontend/src/components/settings/PairingQrCode.tsx
@@ -11,12 +11,19 @@ interface PairingQrCodeProps {
export function PairingQrCode({ value, label, blurred = false }: PairingQrCodeProps) {
const [renderSvg, setRenderSvg] = useState(null)
+ const [loadFailed, setLoadFailed] = useState(false)
useEffect(() => {
let active = true
- void import('uqr').then((module) => {
- if (active) setRenderSvg(() => module.renderSVG)
- })
+ const loadRenderer = async () => {
+ try {
+ const module = await import('uqr')
+ if (active) setRenderSvg(() => module.renderSVG)
+ } catch {
+ if (active) setLoadFailed(true)
+ }
+ }
+ void loadRenderer()
return () => {
active = false
}
@@ -31,6 +38,14 @@ export function PairingQrCode({ value, label, blurred = false }: PairingQrCodePr
}
}, [renderSvg, value])
+ if (loadFailed) {
+ return (
+
+ The QR code could not be loaded. Reload the page and try again.
+
+ )
+ }
+
if (!renderSvg) return null
if (!source) {
From b9d8d1812446ea83e3cb3670d9f87503a91092be Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Fri, 23 Oct 2026 18:29:51 -0400
Subject: [PATCH 3/6] fix(device-pairing): refuse to mint pairing codes from an
address that cannot pair
A loopback origin in production or plain HTTP on a public address still
minted a code and showed a browser QR no other device could use.
- one availability rule decides untrusted, loopback and public plain-HTTP
origins; POST /api/device-pairing/links checks it before minting
- GET /api/device-pairing/availability reports the rule for the page origin
without minting
- Connect a device disables "Show QR code" and explains the fix when the
address cannot pair
- private-network plain HTTP keeps the browser QR; the app QR still needs
the toggle
---
backend/src/routes/device-pairing.ts | 50 +++--
backend/src/services/device-pairing-links.ts | 86 +++++++--
backend/test/routes/device-pairing.test.ts | 172 ++++++++++++++++--
.../services/device-pairing-links.test.ts | 113 ++++++++++++
.../shared/device-pairing-schemas.test.ts | 36 ++++
frontend/src/api/devicePairing.ts | 11 ++
.../settings/ConnectDeviceDialog.test.tsx | 13 +-
.../settings/ConnectDeviceDialog.tsx | 9 +-
.../settings/ConnectDeviceSettings.test.tsx | 51 +++++-
.../settings/ConnectDeviceSettings.tsx | 22 ++-
frontend/src/hooks/useDevicePairing.ts | 11 ++
shared/src/schemas/device-pairing.ts | 15 +-
12 files changed, 527 insertions(+), 62 deletions(-)
create mode 100644 backend/test/services/device-pairing-links.test.ts
diff --git a/backend/src/routes/device-pairing.ts b/backend/src/routes/device-pairing.ts
index e31284925..b4dcea0ff 100644
--- a/backend/src/routes/device-pairing.ts
+++ b/backend/src/routes/device-pairing.ts
@@ -1,18 +1,26 @@
import { Hono } from 'hono'
-import { ENV, getTrustedOrigins, isTrustedOrigin } from '@opencode-manager/shared/config/env'
+import type { MiddlewareHandler } from 'hono'
+import { ENV, getTrustedOrigins } from '@opencode-manager/shared/config/env'
import { CreateDevicePairingLinksRequestSchema } from '@opencode-manager/shared/schemas'
-import { buildDevicePairingLinks, resolveAppLinkPolicy } from '../services/device-pairing-links'
+import {
+ buildDevicePairingLinks,
+ normalizeDevicePairingOrigin,
+ resolveAppLinkPolicy,
+ resolveDevicePairingAvailability,
+} from '../services/device-pairing-links'
import type { RemoteAccessHeadersService } from '../services/remote-access-headers'
import { logger } from '../utils/logger'
import { parseJsonBody } from '../utils/route-helpers'
-const MISSING_ORIGIN_MESSAGE =
- 'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.'
const LINKS_FAILED_MESSAGE = 'Failed to create device pairing links'
+const MISSING_ORIGIN_QUERY_MESSAGE =
+ 'Add an origin query parameter, for example ?origin=https://manager.example.com.'
+const INVALID_ORIGIN_QUERY_MESSAGE =
+ 'The origin query parameter must be an absolute http or https URL.'
-function untrustedOriginMessage(origin: string | undefined): string {
- if (!origin) return MISSING_ORIGIN_MESSAGE
- return `This address (${origin}) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.`
+const noStore: MiddlewareHandler = async (c, next) => {
+ await next()
+ c.header('Cache-Control', 'no-store')
}
interface DevicePairingRouteDeps {
@@ -29,9 +37,22 @@ export function createDevicePairingRoutes(deps: DevicePairingRouteDeps) {
const resolveTrustedOrigins = deps.resolveTrustedOrigins ?? getTrustedOrigins
const isDevelopment = deps.isDevelopment ?? (() => ENV.SERVER.NODE_ENV === 'development')
- app.use('/links', async (c, next) => {
- await next()
- c.header('Cache-Control', 'no-store')
+ app.use('/links', noStore)
+ app.use('/availability', noStore)
+
+ app.get('/availability', (c) => {
+ const requestedOrigin = c.req.query('origin')
+ if (!requestedOrigin) return c.json({ error: MISSING_ORIGIN_QUERY_MESSAGE }, 400)
+
+ const origin = normalizeDevicePairingOrigin(requestedOrigin)
+ if (!origin) return c.json({ error: INVALID_ORIGIN_QUERY_MESSAGE }, 400)
+
+ return c.json(
+ resolveDevicePairingAvailability(origin, {
+ trustedOrigins: resolveTrustedOrigins(),
+ isDevelopment: isDevelopment(),
+ }),
+ )
})
app.post('/links', async (c) => {
@@ -39,15 +60,18 @@ export function createDevicePairingRoutes(deps: DevicePairingRouteDeps) {
if (!body.ok) return body.response
const origin = c.req.header('origin')
- if (!origin || !isTrustedOrigin(origin, { requireOrigin: true }, resolveTrustedOrigins())) {
- return c.json({ error: untrustedOriginMessage(origin) }, 400)
+ const availability = resolveDevicePairingAvailability(origin, {
+ trustedOrigins: resolveTrustedOrigins(),
+ isDevelopment: isDevelopment(),
+ })
+ if (!origin || !availability.available) {
+ return c.json({ error: availability.message, code: availability.reason }, 400)
}
try {
const originUrl = new URL(origin)
const policy = resolveAppLinkPolicy(originUrl, {
allowPrivateNetworkHttp: deps.readAllowPrivateNetworkHttp(),
- isDevelopment: isDevelopment(),
})
const stored = deps.remoteAccessHeaders.read()
const minted = await deps.mintPairingCode(c.req.raw.headers)
diff --git a/backend/src/services/device-pairing-links.ts b/backend/src/services/device-pairing-links.ts
index 893aa2ff8..712fa9f46 100644
--- a/backend/src/services/device-pairing-links.ts
+++ b/backend/src/services/device-pairing-links.ts
@@ -1,39 +1,101 @@
+import { isTrustedOrigin } from '@opencode-manager/shared/config/env'
import {
buildManagerAppLink,
buildManagerBrowserPairingLink,
type AppLinkBlockedReason,
+ type DevicePairingAvailability,
type DevicePairingLinkWarning,
type DevicePairingLinksResponse,
+ type DevicePairingUnavailableReason,
type RemoteAccessHeader,
} from '@opencode-manager/shared/schemas'
import { isLoopbackHost, isPrivateNetworkHost } from '@opencode-manager/shared/utils'
+export const MISSING_ORIGIN_MESSAGE =
+ 'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.'
+export const LOOPBACK_ORIGIN_MESSAGE =
+ 'This page is open on localhost, which other devices cannot reach. Open the Manager at its public or LAN address to pair a device.'
+export const INSECURE_ORIGIN_MESSAGE =
+ 'Pairing needs HTTPS on a public address. Serve the Manager over HTTPS, or open it at a private network address.'
+
type AppLinkPolicy = {
blockedReason: AppLinkBlockedReason | null
warnings: DevicePairingLinkWarning[]
}
+type DevicePairingAvailabilityOptions = {
+ trustedOrigins: string[]
+ isDevelopment: boolean
+}
+
+function untrustedOriginMessage(origin: string): string {
+ return `This address (${origin}) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.`
+}
+
+function unavailable(
+ reason: DevicePairingUnavailableReason,
+ message: string,
+): DevicePairingAvailability {
+ return { available: false, reason, message }
+}
+
+function parseHttpOrigin(value: string): URL | null {
+ let url: URL
+ try {
+ url = new URL(value)
+ } catch {
+ return null
+ }
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
+ return url
+}
+
+/** Normalises an address to its origin, or null when it is not an absolute http(s) URL. */
+export function normalizeDevicePairingOrigin(value: string): string | null {
+ return parseHttpOrigin(value)?.origin ?? null
+}
+
+/** Reports whether an origin can mint a pairing code, and why it cannot when blocked. */
+export function resolveDevicePairingAvailability(
+ origin: string | undefined,
+ options: DevicePairingAvailabilityOptions,
+): DevicePairingAvailability {
+ if (!origin) return unavailable('untrusted-origin', MISSING_ORIGIN_MESSAGE)
+ if (!isTrustedOrigin(origin, { requireOrigin: true }, options.trustedOrigins)) {
+ return unavailable('untrusted-origin', untrustedOriginMessage(origin))
+ }
+
+ const originUrl = parseHttpOrigin(origin)
+ if (!originUrl) return unavailable('untrusted-origin', untrustedOriginMessage(origin))
+
+ const isLoopback = isLoopbackHost(originUrl.hostname)
+ if (isLoopback && !options.isDevelopment) {
+ return unavailable('loopback', LOOPBACK_ORIGIN_MESSAGE)
+ }
+
+ if (originUrl.protocol === 'http:' && !isLoopback && !isPrivateNetworkHost(originUrl.hostname)) {
+ return unavailable('insecure-origin', INSECURE_ORIGIN_MESSAGE)
+ }
+
+ return { available: true, reason: null, message: null }
+}
+
/** Decides whether an origin can carry an app link and which warnings it raises. */
export function resolveAppLinkPolicy(
origin: URL,
- options: { allowPrivateNetworkHttp: boolean; isDevelopment: boolean },
+ options: { allowPrivateNetworkHttp: boolean },
): AppLinkPolicy {
const warnings: DevicePairingLinkWarning[] = []
const isPlainHttp = origin.protocol === 'http:'
+ const isLoopback = isLoopbackHost(origin.hostname)
if (isPlainHttp) warnings.push('plain-http')
+ if (isLoopback) warnings.push('loopback')
+ if (!isPlainHttp) return { blockedReason: null, warnings }
- if (isLoopbackHost(origin.hostname)) {
- warnings.push('loopback')
- return { blockedReason: options.isDevelopment ? null : 'loopback', warnings }
- }
-
- if (isPlainHttp) {
- const isAllowed = options.allowPrivateNetworkHttp && isPrivateNetworkHost(origin.hostname)
- return { blockedReason: isAllowed ? null : 'insecure-origin', warnings }
- }
-
- return { blockedReason: null, warnings }
+ const isAllowed =
+ isLoopback || (options.allowPrivateNetworkHttp && isPrivateNetworkHost(origin.hostname))
+ return { blockedReason: isAllowed ? null : 'insecure-origin', warnings }
}
/** Builds the pairing link response, dropping the app link when its origin is blocked. */
diff --git a/backend/test/routes/device-pairing.test.ts b/backend/test/routes/device-pairing.test.ts
index dc40b9f01..a583b6415 100644
--- a/backend/test/routes/device-pairing.test.ts
+++ b/backend/test/routes/device-pairing.test.ts
@@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Database } from 'bun:sqlite'
import { Hono } from 'hono'
import {
+ DevicePairingAvailabilitySchema,
DevicePairingLinksResponseSchema,
buildManagerBrowserPairingLink,
parseManagerAppLink,
+ type DevicePairingAvailability,
type DevicePairingLinksResponse,
} from '@opencode-manager/shared/schemas'
@@ -41,6 +43,10 @@ const MISSING_ORIGIN_ERROR =
'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.'
const untrustedOriginError = (origin: string) =>
`This address (${origin}) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.`
+const LOOPBACK_ORIGIN_ERROR =
+ 'This page is open on localhost, which other devices cannot reach. Open the Manager at its public or LAN address to pair a device.'
+const INSECURE_ORIGIN_ERROR =
+ 'Pairing needs HTTPS on a public address. Serve the Manager over HTTPS, or open it at a private network address.'
const LINKS_FAILED_ERROR = 'Failed to create device pairing links'
const SENSITIVE_VALUES = [CODE, ...CLOUDFLARE_SECRET_VALUES]
@@ -57,6 +63,10 @@ async function readLinksBody(response: Response): Promise {
+ return DevicePairingAvailabilitySchema.parse(await response.json())
+}
+
describe('device pairing link routes', () => {
let db: Database
let remoteAccessHeaders: RemoteAccessHeadersService
@@ -103,12 +113,24 @@ describe('device pairing link routes', () => {
})
}
+ const getAvailability = (origin?: string) => {
+ const path =
+ origin === undefined
+ ? '/device-pairing/availability'
+ : `/device-pairing/availability?origin=${encodeURIComponent(origin)}`
+
+ return app.request(path, { headers: { cookie: COOKIE_HEADER } })
+ }
+
it('rejects a request without an origin', async () => {
const res = await postLinks()
expect(res.status).toBe(400)
expect(res.headers.get('Cache-Control')).toBe('no-store')
- await expect(res.json()).resolves.toEqual({ error: MISSING_ORIGIN_ERROR })
+ await expect(res.json()).resolves.toEqual({
+ error: MISSING_ORIGIN_ERROR,
+ code: 'untrusted-origin',
+ })
expect(mintPairingCode).not.toHaveBeenCalled()
})
@@ -117,9 +139,10 @@ describe('device pairing link routes', () => {
expect(res.status).toBe(400)
expect(res.headers.get('Cache-Control')).toBe('no-store')
- const body = await res.json() as { error: string }
+ const body = await res.json() as { error: string; code: string }
expect(body.error).toBe(untrustedOriginError('https://evil.example'))
expect(body.error).toContain('AUTH_TRUSTED_ORIGINS')
+ expect(body.code).toBe('untrusted-origin')
expect(mintPairingCode).not.toHaveBeenCalled()
})
@@ -127,7 +150,10 @@ describe('device pairing link routes', () => {
const res = await postLinks('null')
expect(res.status).toBe(400)
- await expect(res.json()).resolves.toEqual({ error: untrustedOriginError('null') })
+ await expect(res.json()).resolves.toEqual({
+ error: untrustedOriginError('null'),
+ code: 'untrusted-origin',
+ })
expect(mintPairingCode).not.toHaveBeenCalled()
})
@@ -195,11 +221,13 @@ describe('device pairing link routes', () => {
const res = await postLinks('http://203.0.113.5:5003')
- expect(res.status).toBe(200)
- const body = await readLinksBody(res)
-
- expect(body.appLink).toBeNull()
- expect(body.appLinkBlockedReason).toBe('insecure-origin')
+ expect(res.status).toBe(400)
+ expect(res.headers.get('Cache-Control')).toBe('no-store')
+ await expect(res.json()).resolves.toEqual({
+ error: INSECURE_ORIGIN_ERROR,
+ code: 'insecure-origin',
+ })
+ expect(mintPairingCode).not.toHaveBeenCalled()
})
it('blocks a loopback origin outside development', async () => {
@@ -208,12 +236,12 @@ describe('device pairing link routes', () => {
const res = await postLinks('http://localhost:5173')
- expect(res.status).toBe(200)
- const body = await readLinksBody(res)
-
- expect(body.appLink).toBeNull()
- expect(body.appLinkBlockedReason).toBe('loopback')
- expect(body.warnings).toEqual(['plain-http', 'loopback'])
+ expect(res.status).toBe(400)
+ await expect(res.json()).resolves.toEqual({
+ error: LOOPBACK_ORIGIN_ERROR,
+ code: 'loopback',
+ })
+ expect(mintPairingCode).not.toHaveBeenCalled()
})
it('allows a loopback origin in development', async () => {
@@ -226,6 +254,7 @@ describe('device pairing link routes', () => {
const body = await readLinksBody(res)
expect(parseManagerAppLink(body.appLink ?? '')?.baseUrl).toBe('http://localhost:5173')
+ expect(body.warnings).toEqual(['plain-http', 'loopback'])
})
it('reports unreadable stored headers', async () => {
@@ -257,4 +286,119 @@ describe('device pairing link routes', () => {
expectNoSecretsLogged(loggerMocks, SENSITIVE_VALUES)
})
+
+ describe('GET availability', () => {
+ it('reports a trusted https origin as available without minting', async () => {
+ const res = await getAvailability('https://m.example.com')
+
+ expect(res.status).toBe(200)
+ expect(res.headers.get('Cache-Control')).toBe('no-store')
+ await expect(readAvailabilityBody(res)).resolves.toEqual({
+ available: true,
+ reason: null,
+ message: null,
+ })
+ expect(mintPairingCode).not.toHaveBeenCalled()
+ })
+
+ it('reports an untrusted origin', async () => {
+ const res = await getAvailability('https://evil.example')
+
+ expect(res.status).toBe(200)
+ await expect(readAvailabilityBody(res)).resolves.toEqual({
+ available: false,
+ reason: 'untrusted-origin',
+ message: untrustedOriginError('https://evil.example'),
+ })
+ expect(mintPairingCode).not.toHaveBeenCalled()
+ })
+
+ it('reports a loopback origin outside development', async () => {
+ development = false
+
+ const res = await getAvailability('http://localhost:5173')
+
+ expect(res.status).toBe(200)
+ await expect(readAvailabilityBody(res)).resolves.toEqual({
+ available: false,
+ reason: 'loopback',
+ message: LOOPBACK_ORIGIN_ERROR,
+ })
+ expect(mintPairingCode).not.toHaveBeenCalled()
+ })
+
+ it('reports a loopback origin in development as available', async () => {
+ development = true
+
+ const res = await getAvailability('http://localhost:5173')
+
+ expect(res.status).toBe(200)
+ await expect(readAvailabilityBody(res)).resolves.toEqual({
+ available: true,
+ reason: null,
+ message: null,
+ })
+ expect(mintPairingCode).not.toHaveBeenCalled()
+ })
+
+ it('reports a public http origin as insecure', async () => {
+ allowPrivateNetwork = true
+
+ const res = await getAvailability('http://203.0.113.5:5003')
+
+ expect(res.status).toBe(200)
+ await expect(readAvailabilityBody(res)).resolves.toEqual({
+ available: false,
+ reason: 'insecure-origin',
+ message: INSECURE_ORIGIN_ERROR,
+ })
+ expect(mintPairingCode).not.toHaveBeenCalled()
+ })
+
+ it('keeps a private network http origin available so the browser link can be shown', async () => {
+ const res = await getAvailability('http://192.168.1.20:5003')
+
+ expect(res.status).toBe(200)
+ await expect(readAvailabilityBody(res)).resolves.toEqual({
+ available: true,
+ reason: null,
+ message: null,
+ })
+ expect(mintPairingCode).not.toHaveBeenCalled()
+ })
+
+ it('normalises the supplied address to its origin', async () => {
+ const res = await getAvailability('https://m.example.com/settings?tab=pairing')
+
+ expect(res.status).toBe(200)
+ await expect(readAvailabilityBody(res)).resolves.toEqual({
+ available: true,
+ reason: null,
+ message: null,
+ })
+ })
+
+ it('rejects a missing origin query parameter', async () => {
+ const res = await getAvailability()
+
+ expect(res.status).toBe(400)
+ expect(res.headers.get('Cache-Control')).toBe('no-store')
+ const body = await res.json() as { error: string }
+ expect(body.error).toContain('origin query parameter')
+ expect(mintPairingCode).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['garbage text', 'not a url'],
+ ['a non-http scheme', 'ocm://link?v=2&kind=manager'],
+ ['a scheme-relative address', '//m.example.com'],
+ ])('rejects %s in the origin query parameter', async (_label, origin) => {
+ const res = await getAvailability(origin)
+
+ expect(res.status).toBe(400)
+ const body = await res.json() as { error: string }
+ expect(body.error).toContain('absolute http or https URL')
+ expect(mintPairingCode).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/backend/test/services/device-pairing-links.test.ts b/backend/test/services/device-pairing-links.test.ts
new file mode 100644
index 000000000..1e05160e2
--- /dev/null
+++ b/backend/test/services/device-pairing-links.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from 'vitest'
+import { resolveAppLinkPolicy, resolveDevicePairingAvailability } from '../../src/services/device-pairing-links'
+
+const TRUSTED_ORIGINS = [
+ 'https://m.example.com',
+ 'http://192.168.1.20:5003',
+ 'http://203.0.113.5:5003',
+ 'http://localhost:5173',
+]
+
+const production = { trustedOrigins: TRUSTED_ORIGINS, isDevelopment: false }
+const development = { trustedOrigins: TRUSTED_ORIGINS, isDevelopment: true }
+
+describe('resolveDevicePairingAvailability', () => {
+ it('allows a trusted https origin', () => {
+ expect(resolveDevicePairingAvailability('https://m.example.com', production)).toEqual({
+ available: true,
+ reason: null,
+ message: null,
+ })
+ })
+
+ it('rejects a missing origin', () => {
+ expect(resolveDevicePairingAvailability(undefined, production)).toEqual({
+ available: false,
+ reason: 'untrusted-origin',
+ message:
+ 'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.',
+ })
+ })
+
+ it('rejects the null origin', () => {
+ expect(resolveDevicePairingAvailability('null', production)).toEqual({
+ available: false,
+ reason: 'untrusted-origin',
+ message:
+ 'This address (null) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.',
+ })
+ })
+
+ it('rejects a loopback origin outside development', () => {
+ expect(resolveDevicePairingAvailability('http://localhost:5173', production)).toEqual({
+ available: false,
+ reason: 'loopback',
+ message:
+ 'This page is open on localhost, which other devices cannot reach. Open the Manager at its public or LAN address to pair a device.',
+ })
+ })
+
+ it('allows a loopback origin in development', () => {
+ expect(resolveDevicePairingAvailability('http://localhost:5173', development).available).toBe(true)
+ })
+
+ it('rejects public plain http', () => {
+ expect(resolveDevicePairingAvailability('http://203.0.113.5:5003', production)).toEqual({
+ available: false,
+ reason: 'insecure-origin',
+ message:
+ 'Pairing needs HTTPS on a public address. Serve the Manager over HTTPS, or open it at a private network address.',
+ })
+ })
+
+ it('allows private network plain http', () => {
+ expect(resolveDevicePairingAvailability('http://192.168.1.20:5003', production).available).toBe(true)
+ })
+
+ it('rejects a trusted origin that is not an http url', () => {
+ const options = { trustedOrigins: ['ocm://link'], isDevelopment: false }
+
+ expect(resolveDevicePairingAvailability('ocm://link', options)).toEqual({
+ available: false,
+ reason: 'untrusted-origin',
+ message:
+ 'This address (ocm://link) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.',
+ })
+ })
+})
+
+describe('resolveAppLinkPolicy', () => {
+ it('allows an https origin without warnings', () => {
+ expect(resolveAppLinkPolicy(new URL('https://m.example.com'), { allowPrivateNetworkHttp: false })).toEqual({
+ blockedReason: null,
+ warnings: [],
+ })
+ })
+
+ it('blocks a private network http app link without the toggle', () => {
+ expect(
+ resolveAppLinkPolicy(new URL('http://192.168.1.20:5003'), { allowPrivateNetworkHttp: false }),
+ ).toEqual({
+ blockedReason: 'insecure-origin',
+ warnings: ['plain-http'],
+ })
+ })
+
+ it('allows a private network http app link with the toggle', () => {
+ expect(
+ resolveAppLinkPolicy(new URL('http://192.168.1.20:5003'), { allowPrivateNetworkHttp: true }),
+ ).toEqual({
+ blockedReason: null,
+ warnings: ['plain-http'],
+ })
+ })
+
+ it('warns about loopback without blocking the app link itself', () => {
+ expect(
+ resolveAppLinkPolicy(new URL('http://localhost:5173'), { allowPrivateNetworkHttp: true }),
+ ).toEqual({
+ blockedReason: null,
+ warnings: ['plain-http', 'loopback'],
+ })
+ })
+})
diff --git a/backend/test/shared/device-pairing-schemas.test.ts b/backend/test/shared/device-pairing-schemas.test.ts
index 5cade570c..686ec6e3e 100644
--- a/backend/test/shared/device-pairing-schemas.test.ts
+++ b/backend/test/shared/device-pairing-schemas.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
+ AppLinkBlockedReasonSchema,
+ DevicePairingAvailabilitySchema,
REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH,
REMOTE_ACCESS_HEADER_VALUE_MAX_LENGTH,
RedeemDevicePairingCodeResponseSchema,
@@ -171,6 +173,40 @@ describe('RemoteAccessHeaderDraftValueSchema', () => {
})
})
+describe('DevicePairingAvailabilitySchema', () => {
+ it('accepts an available origin', () => {
+ expect(
+ DevicePairingAvailabilitySchema.parse({ available: true, reason: null, message: null }),
+ ).toEqual({ available: true, reason: null, message: null })
+ })
+
+ it.each(['untrusted-origin', 'loopback', 'insecure-origin'])(
+ 'accepts the unavailable reason %s',
+ (reason) => {
+ expect(
+ DevicePairingAvailabilitySchema.parse({ available: false, reason, message: 'blocked' }),
+ ).toEqual({ available: false, reason, message: 'blocked' })
+ },
+ )
+
+ it('rejects an unknown reason', () => {
+ expect(
+ DevicePairingAvailabilitySchema.safeParse({ available: false, reason: 'no-origin', message: 'x' })
+ .success,
+ ).toBe(false)
+ })
+})
+
+describe('AppLinkBlockedReasonSchema', () => {
+ it('accepts an insecure origin', () => {
+ expect(AppLinkBlockedReasonSchema.parse('insecure-origin')).toBe('insecure-origin')
+ })
+
+ it('no longer accepts loopback, which now blocks pairing entirely', () => {
+ expect(AppLinkBlockedReasonSchema.safeParse('loopback').success).toBe(false)
+ })
+})
+
describe('RedeemDevicePairingCodeResponseSchema', () => {
it('parses the user returned by the redeem endpoint', () => {
const response = { user: { id: 'user_1', name: 'Chris', email: 'chris@example.com' } }
diff --git a/frontend/src/api/devicePairing.ts b/frontend/src/api/devicePairing.ts
index 77543f270..c6405bbe2 100644
--- a/frontend/src/api/devicePairing.ts
+++ b/frontend/src/api/devicePairing.ts
@@ -1,5 +1,6 @@
import type {
CreateDevicePairingLinksRequest,
+ DevicePairingAvailability,
DevicePairingLinksResponse,
PairedDevice,
PairedDevicesResponse,
@@ -62,6 +63,16 @@ export async function createDevicePairingLinks(
})
}
+/** Reads whether the Manager can mint pairing codes for an address without minting one. */
+export async function getDevicePairingAvailability(
+ origin: string,
+): Promise {
+ return fetchWrapper(
+ `${API_BASE_URL}/api/device-pairing/availability`,
+ { params: { origin } },
+ )
+}
+
/** Lists the devices signed in to this account. */
export async function listPairedDevices(): Promise {
const response = await fetchWrapper(
diff --git a/frontend/src/components/settings/ConnectDeviceDialog.test.tsx b/frontend/src/components/settings/ConnectDeviceDialog.test.tsx
index a25745610..a0944810a 100644
--- a/frontend/src/components/settings/ConnectDeviceDialog.test.tsx
+++ b/frontend/src/components/settings/ConnectDeviceDialog.test.tsx
@@ -90,27 +90,20 @@ describe('ConnectDeviceDialog', () => {
expect(await screen.findByAltText('Browser sign-in QR code')).toBeInTheDocument()
})
- it('explains a loopback block without showing the mobile app QR code', async () => {
+ it('warns about a localhost address while still showing the mobile app QR code', async () => {
mocks.createDevicePairingLinks.mockResolvedValue({
...BASE_LINKS,
- appLink: null,
- appLinkBlockedReason: 'loopback',
warnings: ['plain-http', 'loopback'],
})
renderDialog()
+ expect(await screen.findByAltText('Mobile app pairing QR code')).toBeInTheDocument()
expect(
- await screen.findByText(
- 'This page is open on localhost, which your phone cannot reach. Open the Manager at its public or LAN address to show the app QR code.',
- ),
+ screen.getByText('This address is localhost; other devices cannot reach it.'),
).toBeInTheDocument()
- expect(screen.queryByAltText('Mobile app pairing QR code')).not.toBeInTheDocument()
expect(
screen.getByText('This address uses plain HTTP, so the sign-in code travels unencrypted.'),
).toBeInTheDocument()
- expect(
- screen.getByText('This address is localhost; other devices cannot reach it.'),
- ).toBeInTheDocument()
})
it('explains how to unblock a plain-HTTP app link', async () => {
diff --git a/frontend/src/components/settings/ConnectDeviceDialog.tsx b/frontend/src/components/settings/ConnectDeviceDialog.tsx
index cd4ed7686..63871b33c 100644
--- a/frontend/src/components/settings/ConnectDeviceDialog.tsx
+++ b/frontend/src/components/settings/ConnectDeviceDialog.tsx
@@ -24,9 +24,6 @@ const WARNING_MESSAGES: Record = {
'headers-unreadable': 'Stored remote access headers could not be decrypted and are not included.',
}
-const LOOPBACK_EXPLANATION =
- 'This page is open on localhost, which your phone cannot reach. Open the Manager at its public or LAN address to show the app QR code.'
-
const INSECURE_ORIGIN_EXPLANATION =
"The mobile app QR code needs HTTPS. Use an HTTPS address, or turn on 'Allow plain-HTTP app links on private networks' if this Manager uses a private IP address."
@@ -187,11 +184,7 @@ export function ConnectDeviceDialog({ open, onOpenChange }: ConnectDeviceDialogP
>
) : (
-
- {links.appLinkBlockedReason === 'loopback'
- ? LOOPBACK_EXPLANATION
- : INSECURE_ORIGIN_EXPLANATION}
-
+ {INSECURE_ORIGIN_EXPLANATION}
)}
diff --git a/frontend/src/components/settings/ConnectDeviceSettings.test.tsx b/frontend/src/components/settings/ConnectDeviceSettings.test.tsx
index 6690d2f49..92c9f52fe 100644
--- a/frontend/src/components/settings/ConnectDeviceSettings.test.tsx
+++ b/frontend/src/components/settings/ConnectDeviceSettings.test.tsx
@@ -1,15 +1,19 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { DEFAULT_USER_PREFERENCES } from '@opencode-manager/shared/schemas'
import { createUseSettingsMock } from '@/test/test-utils'
import { ConnectDeviceSettings } from './ConnectDeviceSettings'
+const UNAVAILABLE_MESSAGE =
+ 'This page is open on localhost, which other devices cannot reach. Open the Manager at its public or LAN address to pair a device.'
+
const mocks = vi.hoisted(() => ({
useSettings: vi.fn(),
updateSettings: vi.fn(),
createDevicePairingLinks: vi.fn(),
+ getDevicePairingAvailability: vi.fn(),
}))
vi.mock('@/hooks/useSettings', () => ({
@@ -18,6 +22,7 @@ vi.mock('@/hooks/useSettings', () => ({
vi.mock('@/api/devicePairing', () => ({
createDevicePairingLinks: mocks.createDevicePairingLinks,
+ getDevicePairingAvailability: mocks.getDevicePairingAvailability,
}))
function renderSettings() {
@@ -56,6 +61,11 @@ describe('ConnectDeviceSettings', () => {
headerNames: [],
warnings: [],
})
+ mocks.getDevicePairingAvailability.mockResolvedValue({
+ available: true,
+ reason: null,
+ message: null,
+ })
})
it('persists the private-network plain-HTTP preference', async () => {
@@ -75,8 +85,45 @@ describe('ConnectDeviceSettings', () => {
const user = userEvent.setup()
renderSettings()
- await user.click(screen.getByRole('button', { name: 'Show QR code' }))
+ const button = screen.getByRole('button', { name: 'Show QR code' })
+ await waitFor(() => expect(button).toBeEnabled())
+ await user.click(button)
expect(await screen.findByAltText('Mobile app pairing QR code')).toBeInTheDocument()
})
+
+ it('disables the QR code button while availability loads', async () => {
+ mocks.getDevicePairingAvailability.mockReturnValue(new Promise(() => undefined))
+ renderSettings()
+
+ expect(screen.getByRole('button', { name: 'Show QR code' })).toBeDisabled()
+ })
+
+ it('disables the QR code button and shows the reason when pairing is unavailable', async () => {
+ const user = userEvent.setup()
+ mocks.getDevicePairingAvailability.mockResolvedValue({
+ available: false,
+ reason: 'loopback',
+ message: UNAVAILABLE_MESSAGE,
+ })
+ renderSettings()
+
+ const button = screen.getByRole('button', { name: 'Show QR code' })
+ await screen.findByText(UNAVAILABLE_MESSAGE)
+ expect(button).toBeDisabled()
+
+ await user.click(button)
+
+ expect(screen.queryByAltText('Mobile app pairing QR code')).not.toBeInTheDocument()
+ expect(mocks.createDevicePairingLinks).not.toHaveBeenCalled()
+ })
+
+ it('keeps the QR code button enabled when the availability check fails', async () => {
+ mocks.getDevicePairingAvailability.mockRejectedValue(new Error('availability failed'))
+ renderSettings()
+
+ const button = screen.getByRole('button', { name: 'Show QR code' })
+ await waitFor(() => expect(button).toBeEnabled())
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
})
diff --git a/frontend/src/components/settings/ConnectDeviceSettings.tsx b/frontend/src/components/settings/ConnectDeviceSettings.tsx
index 80624a7d5..58e8b195e 100644
--- a/frontend/src/components/settings/ConnectDeviceSettings.tsx
+++ b/frontend/src/components/settings/ConnectDeviceSettings.tsx
@@ -1,6 +1,8 @@
import { useState } from 'react'
-import { QrCode } from 'lucide-react'
+import { AlertCircle, QrCode } from 'lucide-react'
+import { useDevicePairingAvailability } from '@/hooks/useDevicePairing'
import { useSettings } from '@/hooks/useSettings'
+import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
@@ -12,7 +14,11 @@ const ALLOW_PRIVATE_NETWORK_HTTP_LABEL = 'Allow plain-HTTP app links on private
export function ConnectDeviceSettings() {
const { preferences, updateSettings } = useSettings()
const [dialogOpen, setDialogOpen] = useState(false)
+ const availability = useDevicePairingAvailability()
const allowPrivateNetworkHttp = preferences?.devicePairing?.allowPrivateNetworkHttp ?? false
+ const isPairingAvailable = availability.data?.available !== false
+ const unavailableMessage =
+ availability.data?.available === false ? availability.data.message : null
return (
@@ -26,10 +32,22 @@ export function ConnectDeviceSettings() {
- setDialogOpen(true)}>
+ setDialogOpen(true)}
+ disabled={availability.isLoading || !isPairingAvailable}
+ >
Show QR code
+ {unavailableMessage && (
+
+
+ {unavailableMessage}
+
+ )}
+
diff --git a/frontend/src/hooks/useDevicePairing.ts b/frontend/src/hooks/useDevicePairing.ts
index a3d01ed93..e4c30d038 100644
--- a/frontend/src/hooks/useDevicePairing.ts
+++ b/frontend/src/hooks/useDevicePairing.ts
@@ -8,6 +8,7 @@ import type {
import {
clearRemoteAccessHeaders,
createDevicePairingLinks,
+ getDevicePairingAvailability,
getRemoteAccessHeaders,
listPairedDevices,
replaceRemoteAccessHeaders,
@@ -16,6 +17,7 @@ import {
const REMOTE_ACCESS_HEADERS_QUERY_KEY = ['remote-access-headers']
const PAIRED_DEVICES_QUERY_KEY = ['paired-devices']
+const DEVICE_PAIRING_AVAILABILITY_QUERY_KEY = ['device-pairing-availability']
export function useRemoteAccessHeaders() {
const queryClient = useQueryClient()
@@ -55,6 +57,15 @@ export function useCreateDevicePairingLinks() {
})
}
+export function useDevicePairingAvailability() {
+ const origin = window.location.origin
+
+ return useQuery({
+ queryKey: [...DEVICE_PAIRING_AVAILABILITY_QUERY_KEY, origin],
+ queryFn: () => getDevicePairingAvailability(origin),
+ })
+}
+
export function usePairedDevices() {
return useQuery({
queryKey: PAIRED_DEVICES_QUERY_KEY,
diff --git a/shared/src/schemas/device-pairing.ts b/shared/src/schemas/device-pairing.ts
index a67676afa..c4942a7f4 100644
--- a/shared/src/schemas/device-pairing.ts
+++ b/shared/src/schemas/device-pairing.ts
@@ -128,8 +128,19 @@ export const RedeemDevicePairingCodeResponseSchema = z.object({
}),
});
-export const AppLinkBlockedReasonSchema = z.enum(["insecure-origin", "loopback"]);
+export const AppLinkBlockedReasonSchema = z.enum(["insecure-origin"]);
export const DevicePairingLinkWarningSchema = z.enum(["plain-http", "loopback", "headers-unreadable"]);
+export const DevicePairingUnavailableReasonSchema = z.enum([
+ "untrusted-origin",
+ "loopback",
+ "insecure-origin",
+]);
+
+export const DevicePairingAvailabilitySchema = z.object({
+ available: z.boolean(),
+ reason: DevicePairingUnavailableReasonSchema.nullable(),
+ message: z.string().nullable(),
+});
export const CreateDevicePairingLinksRequestSchema = z.object({
name: z.string().trim().min(1).max(100).optional(),
@@ -170,6 +181,8 @@ export type RedeemDevicePairingCodeRequest = z.infer;
export type AppLinkBlockedReason = z.infer;
export type DevicePairingLinkWarning = z.infer;
+export type DevicePairingUnavailableReason = z.infer;
+export type DevicePairingAvailability = z.infer;
export type CreateDevicePairingLinksRequest = z.infer;
export type DevicePairingLinksResponse = z.infer;
export type PairedDevice = z.infer;
From cbe4f7ed9e789dd44ab68db8b51af2b388c478ce Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Fri, 23 Oct 2026 18:33:46 -0400
Subject: [PATCH 4/6] refactor(auth): reset the admin and count users through
the Better Auth adapter
The admin password reset and the first-user check read and wrote the
Better Auth tables with raw SQL, so session delete hooks never ran on the
reset path.
- the admin is found with findUserByEmail and its credential account is
updated by id with the configured password hasher
- sessions are removed with deleteUserSessions, which runs the session
delete hooks
- a missing credential account logs a warning instead of updating nothing
- /api/auth-info/config counts users through the adapter
---
backend/src/index.ts | 4 +-
backend/src/routes/auth.ts | 37 ++--
backend/test/auth/index.test.ts | 2 +-
backend/test/routes/auth.test.ts | 282 ++++++++++++++++++-------------
4 files changed, 190 insertions(+), 135 deletions(-)
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 9921facac..74916fb8b 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -244,7 +244,7 @@ try {
await gitAuthService.initialize(ipcServer, db)
logger.info(`Git IPC server running at ${ipcServer.ipcHandlePath}`)
- await syncAdminFromEnv(auth, db)
+ await syncAdminFromEnv(auth)
opencodeServerManager.setDatabase(db)
detectSandboxCapability()
@@ -389,7 +389,7 @@ const previewSessionStore = new PreviewSessionStore()
const previewAvailability = createPreviewAvailability(false)
app.route('/api/auth', createAuthRoutes(auth))
-app.route('/api/auth-info', createAuthInfoRoutes(auth, db))
+app.route('/api/auth-info', createAuthInfoRoutes(auth))
app.route('/api/health', createHealthRoutes(db, openCodeSupervisor))
app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth))
diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts
index 1f73ede93..52982d4c3 100644
--- a/backend/src/routes/auth.ts
+++ b/backend/src/routes/auth.ts
@@ -1,10 +1,8 @@
import { Hono } from 'hono'
import { isAdminConfigured, type AuthInstance } from '../auth'
import { applyClientIpHeaders, resolveSocketIp } from '../auth/client-ip'
-import { Database } from 'bun:sqlite'
import { ENV } from '@opencode-manager/shared/config/env'
import { logger } from '../utils/logger'
-import { hashPassword } from 'better-auth/crypto'
export function createAuthRoutes(auth: AuthInstance): Hono {
const app = new Hono()
@@ -17,26 +15,29 @@ export function createAuthRoutes(auth: AuthInstance): Hono {
return app
}
-export async function syncAdminFromEnv(auth: AuthInstance, db: Database): Promise {
+export async function syncAdminFromEnv(auth: AuthInstance): Promise {
if (!isAdminConfigured()) return
const adminEmail = ENV.AUTH.ADMIN_EMAIL!
const adminPassword = ENV.AUTH.ADMIN_PASSWORD!
- const existingUser = db.prepare('SELECT id, email FROM "user" WHERE lower(email) = lower(?)').get(adminEmail) as { id: string; email: string } | undefined
+ const ctx = await auth.$context
+ const existingAdmin = await ctx.internalAdapter.findUserByEmail(adminEmail)
- if (existingUser) {
+ if (existingAdmin) {
if (ENV.AUTH.ADMIN_PASSWORD_RESET) {
- const hashedPassword = await hashPassword(adminPassword)
- const resetAdminCredentials = db.transaction(() => {
- db.prepare('UPDATE "account" SET password = ? WHERE "userId" = ? AND "providerId" = ?').run(
- hashedPassword,
- existingUser.id,
- 'credential'
- )
- db.prepare('DELETE FROM "session" WHERE "userId" = ?').run(existingUser.id)
- })
- resetAdminCredentials()
+ const credentialAccount = (await ctx.internalAdapter.findAccounts(existingAdmin.user.id))
+ .find((account) => account.providerId === 'credential')
+
+ if (credentialAccount) {
+ await ctx.internalAdapter.updateAccount(credentialAccount.id, {
+ password: await ctx.password.hash(adminPassword),
+ })
+ } else {
+ logger.warn(`No credential account found for ${adminEmail}; skipped the password reset`)
+ }
+
+ await ctx.internalAdapter.deleteUserSessions(existingAdmin.user.id)
logger.info(`Admin password reset from environment for ${adminEmail}`)
logger.warn('Remove ADMIN_PASSWORD_RESET=true from environment after password reset')
}
@@ -57,7 +58,7 @@ export async function syncAdminFromEnv(auth: AuthInstance, db: Database): Promis
}
}
-export function createAuthInfoRoutes(auth: AuthInstance, db: Database) {
+export function createAuthInfoRoutes(auth: AuthInstance) {
const app = new Hono()
app.get('/config', async (c) => {
@@ -75,13 +76,13 @@ export function createAuthInfoRoutes(auth: AuthInstance, db: Database) {
enabledProviders.push('passkey')
- const hasUsers = db.prepare('SELECT COUNT(*) as count FROM "user"').get() as { count: number }
+ const userCount = await (await auth.$context).adapter.count({ model: 'user' })
const adminConfigured = isAdminConfigured()
return c.json({
enabledProviders,
registrationEnabled: !adminConfigured,
- isFirstUser: hasUsers.count === 0,
+ isFirstUser: userCount === 0,
adminConfigured,
})
})
diff --git a/backend/test/auth/index.test.ts b/backend/test/auth/index.test.ts
index 65dd20c7e..e09a03c0c 100644
--- a/backend/test/auth/index.test.ts
+++ b/backend/test/auth/index.test.ts
@@ -78,7 +78,7 @@ describe('createAuth', () => {
body: { email: 'other@example.com', password: 'test-password', name: 'Other' },
})).rejects.toThrow('Registration is disabled')
- await syncAdminFromEnv(auth, db)
+ await syncAdminFromEnv(auth)
const response = await auth.handler(new Request('http://localhost:5173/api/auth/sign-in/email', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: 'http://localhost:5173' },
diff --git a/backend/test/routes/auth.test.ts b/backend/test/routes/auth.test.ts
index 00ddc9c3a..0f4a42b98 100644
--- a/backend/test/routes/auth.test.ts
+++ b/backend/test/routes/auth.test.ts
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import { createTestDb } from '../helpers/assistant-workspace'
-import { createAuthRoutes, createAuthInfoRoutes, syncAdminFromEnv } from '../../src/routes/auth'
-import type { AuthInstance, Session } from '../../src/auth'
+import { createAuthInfoRoutes, createAuthRoutes, syncAdminFromEnv } from '../../src/routes/auth'
+import { createAuth, type AuthInstance, type Session } from '../../src/auth'
+import { registerSessionConnection } from '../../src/services/session-connections'
+import { createAuthDatabase } from '../helpers/auth-database'
const { ENV } = vi.hoisted(() => ({
ENV: {
@@ -27,18 +28,18 @@ const { ENV } = vi.hoisted(() => ({
},
}))
-vi.mock('@opencode-manager/shared/config/env', () => ({ ENV }))
+vi.mock('@opencode-manager/shared/config/env', () => ({
+ ENV,
+ getTrustedOrigins: () => ENV.AUTH.TRUSTED_ORIGINS
+ .split(',')
+ .map((origin: string) => origin.trim())
+ .filter((origin: string) => origin.length > 0),
+}))
vi.mock('../../src/utils/logger', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}))
-const { hashPasswordMock } = vi.hoisted(() => ({
- hashPasswordMock: vi.fn(),
-}))
-
-vi.mock('better-auth/crypto', () => ({ hashPassword: hashPasswordMock }))
-
import { logger } from '../../src/utils/logger'
const mockLoggerInfo = vi.mocked(logger.info)
@@ -46,6 +47,9 @@ const mockLoggerWarn = vi.mocked(logger.warn)
const mockLoggerError = vi.mocked(logger.error)
const mockLoggerDebug = vi.mocked(logger.debug)
+const ORIGIN = 'http://localhost:5173'
+const SIGN_IN_URL = `${ORIGIN}/api/auth/sign-in/email`
+
function resetEnv(): void {
ENV.AUTH.TRUSTED_ORIGINS = 'http://localhost:5173,http://localhost:5003'
ENV.AUTH.SECURE_COOKIES = false
@@ -60,27 +64,42 @@ function resetEnv(): void {
ENV.AUTH.DISCORD_CLIENT_SECRET = undefined
}
-function insertUser(db: ReturnType, id: string, email: string): void {
- db.prepare(
- 'INSERT INTO "user" (id, name, email, emailVerified, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?, ?)'
- ).run(id, 'Test User', email, 0, Date.now(), Date.now(), 'user')
+function cookieHeaders(response: Response): Headers {
+ return new Headers({
+ cookie: response.headers.getSetCookie().map((cookie) => cookie.split(';')[0]).join('; '),
+ })
+}
+
+function signInRequest(auth: AuthInstance, email: string, password: string): Promise {
+ return auth.handler(new Request(SIGN_IN_URL, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', origin: ORIGIN },
+ body: JSON.stringify({ email, password }),
+ }))
}
-function insertCredentialAccount(db: ReturnType, id: string, userId: string, password: string): void {
- db.prepare(
- 'INSERT INTO "account" (id, accountId, providerId, userId, password, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)'
- ).run(id, userId, 'credential', userId, password, Date.now(), Date.now())
+async function createUserSession(auth: AuthInstance, email: string, password: string): Promise {
+ const response = await signInRequest(auth, email, password)
+ expect(response.status).toBe(200)
+ return cookieHeaders(response)
}
-function insertSession(db: ReturnType, id: string, userId: string, token: string): void {
- db.prepare(
- 'INSERT INTO "session" (id, expiresAt, token, createdAt, updatedAt, userId) VALUES (?, ?, ?, ?, ?, ?)'
- ).run(id, Date.now() + 86_400_000, token, Date.now(), Date.now(), userId)
+async function countRows(auth: AuthInstance, model: 'user' | 'session', userId?: string): Promise {
+ const ctx = await auth.$context
+ return ctx.adapter.count({
+ model,
+ where: userId ? [{ field: 'userId', value: userId }] : undefined,
+ })
}
-function listSessionIds(db: ReturnType, userId: string): string[] {
- const rows = db.prepare('SELECT id FROM "session" WHERE "userId" = ? ORDER BY id').all(userId) as { id: string }[]
- return rows.map((row) => row.id)
+async function findPasswordHash(auth: AuthInstance, userId: string): Promise {
+ const ctx = await auth.$context
+ const account = await ctx.adapter.findOne<{ password: string }>({
+ model: 'account',
+ where: [{ field: 'userId', value: userId }],
+ })
+ expect(account?.password).toBeTypeOf('string')
+ return account!.password
}
function createSession(): Session {
@@ -153,159 +172,182 @@ describe('createAuthRoutes', () => {
})
describe('syncAdminFromEnv', () => {
- let db: ReturnType
- let signUpEmail: ReturnType
+ let db: ReturnType
+ let auth: AuthInstance
beforeEach(() => {
resetEnv()
- hashPasswordMock.mockImplementation(async (password: string) => `hashed:${password}`)
- db = createTestDb()
+ db = createAuthDatabase()
+ auth = createAuth(db)
vi.clearAllMocks()
- signUpEmail = vi.fn(async () => ({}))
})
- afterEach(() => {
+ afterEach(async () => {
+ await (await auth.$context).checkSchema?.()
db.close()
})
it('does nothing when the admin credentials are not configured', async () => {
- const auth = { api: { signUpEmail } } as unknown as AuthInstance
-
- await syncAdminFromEnv(auth, db)
+ await syncAdminFromEnv(auth)
- expect(signUpEmail).not.toHaveBeenCalled()
+ expect(await countRows(auth, 'user')).toBe(0)
expect(mockLoggerInfo).not.toHaveBeenCalled()
})
it('does nothing when only one admin credential is configured', async () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
- const auth = { api: { signUpEmail } } as unknown as AuthInstance
- await syncAdminFromEnv(auth, db)
+ await syncAdminFromEnv(auth)
- expect(signUpEmail).not.toHaveBeenCalled()
+ expect(await countRows(auth, 'user')).toBe(0)
})
it('returns without resetting when an existing user has no password reset flag', async () => {
+ await auth.api.signUpEmail({ body: { email: 'admin@example.com', password: 'old-password', name: 'Admin' } })
+ const cookies = await createUserSession(auth, 'admin@example.com', 'old-password')
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
- ENV.AUTH.ADMIN_PASSWORD = 'admin-password'
- insertUser(db, 'user-1', 'admin@example.com')
- insertCredentialAccount(db, 'account-1', 'user-1', 'old-hash')
- insertSession(db, 'session-1', 'user-1', 'token-1')
- const auth = { api: { signUpEmail } } as unknown as AuthInstance
-
- await syncAdminFromEnv(auth, db)
-
- expect(signUpEmail).not.toHaveBeenCalled()
- expect(hashPasswordMock).not.toHaveBeenCalled()
- const account = db.prepare('SELECT password FROM "account" WHERE "userId" = ?').get('user-1') as { password: string }
- expect(account.password).toBe('old-hash')
- expect(listSessionIds(db, 'user-1')).toEqual(['session-1'])
- })
+ ENV.AUTH.ADMIN_PASSWORD = 'new-password'
+
+ await syncAdminFromEnv(auth)
+
+ expect((await signInRequest(auth, 'admin@example.com', 'old-password')).status).toBe(200)
+ expect((await signInRequest(auth, 'admin@example.com', 'new-password')).status).toBe(401)
+ expect(await auth.api.getSession({ headers: cookies })).not.toBeNull()
+ }, 30000)
it('finds the existing admin user when ADMIN_EMAIL differs only by case', async () => {
+ await auth.api.signUpEmail({ body: { email: 'admin@example.com', password: 'old-password', name: 'Admin' } })
ENV.AUTH.ADMIN_EMAIL = 'Admin@Example.com'
- ENV.AUTH.ADMIN_PASSWORD = 'admin-password'
- insertUser(db, 'user-1', 'admin@example.com')
- const auth = { api: { signUpEmail } } as unknown as AuthInstance
+ ENV.AUTH.ADMIN_PASSWORD = 'new-password'
+ ENV.AUTH.ADMIN_PASSWORD_RESET = true
- await syncAdminFromEnv(auth, db)
+ await syncAdminFromEnv(auth)
- expect(signUpEmail).not.toHaveBeenCalled()
- expect(db.prepare('SELECT COUNT(*) AS count FROM "user"').get()).toEqual({ count: 1 })
- })
+ expect(await countRows(auth, 'user')).toBe(1)
+ expect((await signInRequest(auth, 'Admin@Example.com', 'new-password')).status).toBe(200)
+ expect((await signInRequest(auth, 'admin@example.com', 'old-password')).status).toBe(401)
+ }, 30000)
it('resets the credential account password for an existing user', async () => {
+ await auth.api.signUpEmail({ body: { email: 'admin@example.com', password: 'old-password', name: 'Admin' } })
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
ENV.AUTH.ADMIN_PASSWORD_RESET = true
- insertUser(db, 'user-1', 'admin@example.com')
- insertCredentialAccount(db, 'account-1', 'user-1', 'old-hash')
- const auth = { api: { signUpEmail } } as unknown as AuthInstance
- await syncAdminFromEnv(auth, db)
+ await syncAdminFromEnv(auth)
- expect(hashPasswordMock).toHaveBeenCalledWith('new-password')
- expect(signUpEmail).not.toHaveBeenCalled()
- const account = db.prepare('SELECT password FROM "account" WHERE "userId" = ?').get('user-1') as { password: string }
- expect(account.password).toBe('hashed:new-password')
+ expect((await signInRequest(auth, 'admin@example.com', 'new-password')).status).toBe(200)
+ expect((await signInRequest(auth, 'admin@example.com', 'old-password')).status).toBe(401)
expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('Admin password reset'))
expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('ADMIN_PASSWORD_RESET'))
- })
+ }, 30000)
+
+ it('revokes every session of the admin whose password was reset', async () => {
+ await auth.api.signUpEmail({ body: { email: 'admin@example.com', password: 'admin-password', name: 'Admin' } })
+ await auth.api.signUpEmail({ body: { email: 'other@example.com', password: 'other-password', name: 'Other' } })
+ const firstAdminCookies = await createUserSession(auth, 'admin@example.com', 'admin-password')
+ await createUserSession(auth, 'admin@example.com', 'admin-password')
+ const otherCookies = await createUserSession(auth, 'other@example.com', 'other-password')
+ const adminId = (await auth.api.getSession({ headers: firstAdminCookies }))!.user.id
+ const otherId = (await auth.api.getSession({ headers: otherCookies }))!.user.id
+ const otherSessionsBefore = await countRows(auth, 'session', otherId)
+ ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
+ ENV.AUTH.ADMIN_PASSWORD = 'new-password'
+ ENV.AUTH.ADMIN_PASSWORD_RESET = true
- it('revokes the existing sessions of the admin whose password was reset', async () => {
+ await syncAdminFromEnv(auth)
+
+ expect(await countRows(auth, 'session', adminId)).toBe(0)
+ expect(await countRows(auth, 'session', otherId)).toBe(otherSessionsBefore)
+ }, 30000)
+
+ it('runs the session delete hook for every revoked admin session', async () => {
+ await auth.api.signUpEmail({ body: { email: 'admin@example.com', password: 'admin-password', name: 'Admin' } })
+ const cookies = await createUserSession(auth, 'admin@example.com', 'admin-password')
+ const sessionId = (await auth.api.getSession({ headers: cookies }))!.session.id
+ const close = vi.fn()
+ registerSessionConnection(sessionId, close)
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
ENV.AUTH.ADMIN_PASSWORD_RESET = true
- insertUser(db, 'user-1', 'admin@example.com')
- insertUser(db, 'user-2', 'other@example.com')
- insertCredentialAccount(db, 'account-1', 'user-1', 'old-hash')
- insertSession(db, 'session-1', 'user-1', 'token-1')
- insertSession(db, 'session-2', 'user-1', 'token-2')
- insertSession(db, 'session-3', 'user-2', 'token-3')
- const auth = { api: { signUpEmail } } as unknown as AuthInstance
-
- await syncAdminFromEnv(auth, db)
-
- expect(listSessionIds(db, 'user-1')).toEqual([])
- expect(listSessionIds(db, 'user-2')).toEqual(['session-3'])
- })
- it('rolls back the password reset when revoking the sessions fails', async () => {
+ await syncAdminFromEnv(auth)
+
+ expect(close).toHaveBeenCalledTimes(1)
+ }, 30000)
+
+ it('warns and still revokes the sessions when the admin has no credential account', async () => {
+ await auth.api.signUpEmail({ body: { email: 'admin@example.com', password: 'admin-password', name: 'Admin' } })
+ const cookies = await createUserSession(auth, 'admin@example.com', 'admin-password')
+ const adminId = (await auth.api.getSession({ headers: cookies }))!.user.id
+ const ctx = await auth.$context
+ await ctx.adapter.deleteMany({ model: 'account', where: [{ field: 'userId', value: adminId }] })
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
ENV.AUTH.ADMIN_PASSWORD_RESET = true
- insertUser(db, 'user-1', 'admin@example.com')
- insertCredentialAccount(db, 'account-1', 'user-1', 'old-hash')
+
+ await syncAdminFromEnv(auth)
+
+ expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('No credential account found'))
+ expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('Admin password reset'))
+ expect(await countRows(auth, 'session', adminId)).toBe(0)
+ }, 30000)
+
+ it('keeps the new password when revoking the sessions fails', async () => {
+ await auth.api.signUpEmail({ body: { email: 'admin@example.com', password: 'old-password', name: 'Admin' } })
+ const cookies = await createUserSession(auth, 'admin@example.com', 'old-password')
+ const adminId = (await auth.api.getSession({ headers: cookies }))!.user.id
db.exec('DROP TABLE "session"')
- const auth = { api: { signUpEmail } } as unknown as AuthInstance
+ ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
+ ENV.AUTH.ADMIN_PASSWORD = 'new-password'
+ ENV.AUTH.ADMIN_PASSWORD_RESET = true
- await expect(syncAdminFromEnv(auth, db)).rejects.toThrow()
+ await expect(syncAdminFromEnv(auth)).rejects.toThrow()
- const account = db.prepare('SELECT password FROM "account" WHERE "userId" = ?').get('user-1') as { password: string }
- expect(account.password).toBe('old-hash')
- })
+ const ctx = await auth.$context
+ expect(await ctx.password.verify({
+ password: 'new-password',
+ hash: await findPasswordHash(auth, adminId),
+ })).toBe(true)
+ }, 30000)
it('creates a new admin user when none exists', async () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'admin-password'
- const auth = { api: { signUpEmail } } as unknown as AuthInstance
- await syncAdminFromEnv(auth, db)
+ await syncAdminFromEnv(auth)
- expect(signUpEmail).toHaveBeenCalledWith({
- body: { email: 'admin@example.com', password: 'admin-password', name: 'Admin' },
- })
+ expect(await countRows(auth, 'user')).toBe(1)
+ expect((await signInRequest(auth, 'admin@example.com', 'admin-password')).status).toBe(200)
expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('Admin user created'))
- })
+ }, 30000)
it('logs and swallows sign-up failures', async () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
- ENV.AUTH.ADMIN_PASSWORD = 'admin-password'
- const failure = new Error('sign up failed')
- signUpEmail.mockRejectedValueOnce(failure)
- const auth = { api: { signUpEmail } } as unknown as AuthInstance
+ ENV.AUTH.ADMIN_PASSWORD = 'short'
- await expect(syncAdminFromEnv(auth, db)).resolves.toBeUndefined()
+ await expect(syncAdminFromEnv(auth)).resolves.toBeUndefined()
- expect(mockLoggerError).toHaveBeenCalledWith('Failed to create admin user from environment:', failure)
- })
+ expect(await countRows(auth, 'user')).toBe(0)
+ expect(mockLoggerError).toHaveBeenCalledWith('Failed to create admin user from environment:', expect.anything())
+ }, 30000)
})
-describe('createAuthInfoRoutes', () => {
- let db: ReturnType
- let getSession: ReturnType
+describe('createAuthInfoRoutes /config', () => {
+ let db: ReturnType
+ let auth: AuthInstance
let app: ReturnType
beforeEach(() => {
resetEnv()
+ db = createAuthDatabase()
+ auth = createAuth(db)
+ app = createAuthInfoRoutes(auth)
vi.clearAllMocks()
- db = createTestDb()
- getSession = vi.fn()
- app = createAuthInfoRoutes({ api: { getSession } } as unknown as AuthInstance, db)
})
- afterEach(() => {
+ afterEach(async () => {
+ await (await auth.$context).checkSchema?.()
db.close()
})
@@ -358,17 +400,25 @@ describe('createAuthInfoRoutes', () => {
})
it('reports that it is not the first user once a user exists', async () => {
- insertUser(db, 'user-1', 'user@example.com')
+ await auth.api.signUpEmail({ body: { email: 'user@example.com', password: 'user-password', name: 'User' } })
const res = await app.fetch(new Request('http://localhost/config'))
const json = await res.json() as { isFirstUser: boolean }
expect(json.isFirstUser).toBe(false)
+ }, 30000)
+})
+
+describe('createAuthInfoRoutes /me', () => {
+ beforeEach(() => {
+ resetEnv()
+ vi.clearAllMocks()
})
it('returns the session and user from GET /me', async () => {
const session = createSession()
- getSession.mockResolvedValueOnce(session)
+ const getSession = vi.fn(async () => session)
+ const app = createAuthInfoRoutes({ api: { getSession } } as unknown as AuthInstance)
const res = await app.fetch(new Request('http://localhost/me'))
const json = await res.json() as { user: unknown; session: { id: string; expiresAt: string } }
@@ -386,7 +436,8 @@ describe('createAuthInfoRoutes', () => {
})
it('returns nulls from GET /me when no session is found', async () => {
- getSession.mockResolvedValueOnce(null)
+ const getSession = vi.fn(async () => null)
+ const app = createAuthInfoRoutes({ api: { getSession } } as unknown as AuthInstance)
const res = await app.fetch(new Request('http://localhost/me'))
@@ -394,7 +445,10 @@ describe('createAuthInfoRoutes', () => {
})
it('returns nulls from GET /me when the session lookup throws', async () => {
- getSession.mockRejectedValueOnce(new Error('database unavailable'))
+ const getSession = vi.fn(async () => {
+ throw new Error('database unavailable')
+ })
+ const app = createAuthInfoRoutes({ api: { getSession } } as unknown as AuthInstance)
const res = await app.fetch(new Request('http://localhost/me'))
From 6e398c4d400aad7807082d846083ae29658ba08c Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Fri, 23 Oct 2026 23:47:43 -0400
Subject: [PATCH 5/6] fix(device-pairing): clean up push subscriptions on admin
reset and share URL helpers
The admin password reset finds the admin case-insensitively and deletes the push subscriptions of the sessions it ends. The app re-sends its push subscription once per signed-in session so revoke reaches re-signed-in devices, and the upsert keeps the stored device name. Plain-HTTP and URL parsing checks are shared helpers.
---
backend/src/db/push-subscriptions.ts | 11 +-
backend/src/index.ts | 2 +-
backend/src/routes/auth.ts | 15 ++-
backend/src/routes/device-pairing.ts | 19 ++-
backend/src/services/device-pairing-links.ts | 41 +++---
backend/src/services/notification.ts | 2 +-
backend/test/auth/index.test.ts | 2 +-
backend/test/helpers/log-assertions.ts | 2 +-
backend/test/routes/auth.test.ts | 75 +++++++++--
backend/test/routes/notifications.test.ts | 44 +++++-
.../services/device-pairing-links.test.ts | 1 +
.../notification-subscriptions.test.ts | 10 ++
backend/test/shared/url-hosts.test.ts | 53 +++++++-
.../src/App.push-subscription-sync.test.tsx | 117 ++++++++++++++++
frontend/src/App.tsx | 9 +-
.../settings/OpenCodeServerDialog.tsx | 14 +-
frontend/src/hooks/useNotifications.ts | 22 ++-
.../usePushSubscriptionSessionSync.test.tsx | 127 ++++++++++++++++++
.../hooks/usePushSubscriptionSessionSync.ts | 21 +++
shared/src/schemas/device-pairing.ts | 11 +-
shared/src/utils/url.ts | 27 +++-
21 files changed, 540 insertions(+), 85 deletions(-)
create mode 100644 frontend/src/App.push-subscription-sync.test.tsx
create mode 100644 frontend/src/hooks/usePushSubscriptionSessionSync.test.tsx
create mode 100644 frontend/src/hooks/usePushSubscriptionSessionSync.ts
diff --git a/backend/src/db/push-subscriptions.ts b/backend/src/db/push-subscriptions.ts
index 7471ded34..e1134a219 100644
--- a/backend/src/db/push-subscriptions.ts
+++ b/backend/src/db/push-subscriptions.ts
@@ -26,7 +26,16 @@ export function ensurePushSubscriptionsTable(db: Database): void {
db.run('CREATE INDEX IF NOT EXISTS idx_push_sub_session ON push_subscriptions(session_id)')
}
+/** Deletes the push subscriptions registered by any of the given Better Auth sessions. */
+export function deletePushSubscriptionsForSessions(db: Database, sessionIds: string[]): number {
+ if (sessionIds.length === 0) return 0
+ const placeholders = sessionIds.map(() => '?').join(', ')
+ return db
+ .prepare(`DELETE FROM push_subscriptions WHERE session_id IN (${placeholders})`)
+ .run(...sessionIds).changes
+}
+
/** Deletes the push subscriptions a revoked Better Auth session registered. */
export function deletePushSubscriptionsForSession(db: Database, sessionId: string): number {
- return db.prepare('DELETE FROM push_subscriptions WHERE session_id = ?').run(sessionId).changes
+ return deletePushSubscriptionsForSessions(db, [sessionId])
}
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 74916fb8b..b81c6e602 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -244,7 +244,7 @@ try {
await gitAuthService.initialize(ipcServer, db)
logger.info(`Git IPC server running at ${ipcServer.ipcHandlePath}`)
- await syncAdminFromEnv(auth)
+ await syncAdminFromEnv(auth, db)
opencodeServerManager.setDatabase(db)
detectSandboxCapability()
diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts
index 52982d4c3..cd8e1bf31 100644
--- a/backend/src/routes/auth.ts
+++ b/backend/src/routes/auth.ts
@@ -1,8 +1,10 @@
import { Hono } from 'hono'
+import type { Database } from 'bun:sqlite'
import { isAdminConfigured, type AuthInstance } from '../auth'
import { applyClientIpHeaders, resolveSocketIp } from '../auth/client-ip'
import { ENV } from '@opencode-manager/shared/config/env'
import { logger } from '../utils/logger'
+import { deletePushSubscriptionsForSessions } from '../db/push-subscriptions'
export function createAuthRoutes(auth: AuthInstance): Hono {
const app = new Hono()
@@ -15,18 +17,21 @@ export function createAuthRoutes(auth: AuthInstance): Hono {
return app
}
-export async function syncAdminFromEnv(auth: AuthInstance): Promise {
+export async function syncAdminFromEnv(auth: AuthInstance, db: Database): Promise {
if (!isAdminConfigured()) return
const adminEmail = ENV.AUTH.ADMIN_EMAIL!
const adminPassword = ENV.AUTH.ADMIN_PASSWORD!
const ctx = await auth.$context
- const existingAdmin = await ctx.internalAdapter.findUserByEmail(adminEmail)
+ const existingAdmin = await ctx.adapter.findOne<{ id: string }>({
+ model: 'user',
+ where: [{ field: 'email', value: adminEmail, mode: 'insensitive' }],
+ })
if (existingAdmin) {
if (ENV.AUTH.ADMIN_PASSWORD_RESET) {
- const credentialAccount = (await ctx.internalAdapter.findAccounts(existingAdmin.user.id))
+ const credentialAccount = (await ctx.internalAdapter.findAccounts(existingAdmin.id))
.find((account) => account.providerId === 'credential')
if (credentialAccount) {
@@ -37,7 +42,9 @@ export async function syncAdminFromEnv(auth: AuthInstance): Promise {
logger.warn(`No credential account found for ${adminEmail}; skipped the password reset`)
}
- await ctx.internalAdapter.deleteUserSessions(existingAdmin.user.id)
+ const sessions = await ctx.internalAdapter.listSessions(existingAdmin.id)
+ await ctx.internalAdapter.deleteUserSessions(existingAdmin.id)
+ deletePushSubscriptionsForSessions(db, sessions.map((session) => session.id))
logger.info(`Admin password reset from environment for ${adminEmail}`)
logger.warn('Remove ADMIN_PASSWORD_RESET=true from environment after password reset')
}
diff --git a/backend/src/routes/device-pairing.ts b/backend/src/routes/device-pairing.ts
index b4dcea0ff..8896597cd 100644
--- a/backend/src/routes/device-pairing.ts
+++ b/backend/src/routes/device-pairing.ts
@@ -47,29 +47,28 @@ export function createDevicePairingRoutes(deps: DevicePairingRouteDeps) {
const origin = normalizeDevicePairingOrigin(requestedOrigin)
if (!origin) return c.json({ error: INVALID_ORIGIN_QUERY_MESSAGE }, 400)
- return c.json(
- resolveDevicePairingAvailability(origin, {
- trustedOrigins: resolveTrustedOrigins(),
- isDevelopment: isDevelopment(),
- }),
- )
+ const { available, reason, message } = resolveDevicePairingAvailability(origin, {
+ trustedOrigins: resolveTrustedOrigins(),
+ isDevelopment: isDevelopment(),
+ })
+
+ return c.json({ available, reason, message })
})
app.post('/links', async (c) => {
const body = await parseJsonBody(c, CreateDevicePairingLinksRequestSchema, { allowEmpty: true })
if (!body.ok) return body.response
- const origin = c.req.header('origin')
- const availability = resolveDevicePairingAvailability(origin, {
+ const availability = resolveDevicePairingAvailability(c.req.header('origin'), {
trustedOrigins: resolveTrustedOrigins(),
isDevelopment: isDevelopment(),
})
- if (!origin || !availability.available) {
+ if (!availability.available) {
return c.json({ error: availability.message, code: availability.reason }, 400)
}
try {
- const originUrl = new URL(origin)
+ const originUrl = availability.originUrl
const policy = resolveAppLinkPolicy(originUrl, {
allowPrivateNetworkHttp: deps.readAllowPrivateNetworkHttp(),
})
diff --git a/backend/src/services/device-pairing-links.ts b/backend/src/services/device-pairing-links.ts
index 712fa9f46..7b4b330e8 100644
--- a/backend/src/services/device-pairing-links.ts
+++ b/backend/src/services/device-pairing-links.ts
@@ -3,19 +3,23 @@ import {
buildManagerAppLink,
buildManagerBrowserPairingLink,
type AppLinkBlockedReason,
- type DevicePairingAvailability,
type DevicePairingLinkWarning,
type DevicePairingLinksResponse,
type DevicePairingUnavailableReason,
type RemoteAccessHeader,
} from '@opencode-manager/shared/schemas'
-import { isLoopbackHost, isPrivateNetworkHost } from '@opencode-manager/shared/utils'
+import {
+ isLoopbackHost,
+ isPrivateNetworkHost,
+ isPublicPlainHttpUrl,
+ parseHttpUrl,
+} from '@opencode-manager/shared/utils'
-export const MISSING_ORIGIN_MESSAGE =
+const MISSING_ORIGIN_MESSAGE =
'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.'
-export const LOOPBACK_ORIGIN_MESSAGE =
+const LOOPBACK_ORIGIN_MESSAGE =
'This page is open on localhost, which other devices cannot reach. Open the Manager at its public or LAN address to pair a device.'
-export const INSECURE_ORIGIN_MESSAGE =
+const INSECURE_ORIGIN_MESSAGE =
'Pairing needs HTTPS on a public address. Serve the Manager over HTTPS, or open it at a private network address.'
type AppLinkPolicy = {
@@ -23,6 +27,10 @@ type AppLinkPolicy = {
warnings: DevicePairingLinkWarning[]
}
+type DevicePairingAvailabilityResult =
+ | { available: true; reason: null; message: null; originUrl: URL }
+ | { available: false; reason: DevicePairingUnavailableReason; message: string }
+
type DevicePairingAvailabilityOptions = {
trustedOrigins: string[]
isDevelopment: boolean
@@ -35,37 +43,26 @@ function untrustedOriginMessage(origin: string): string {
function unavailable(
reason: DevicePairingUnavailableReason,
message: string,
-): DevicePairingAvailability {
+): DevicePairingAvailabilityResult {
return { available: false, reason, message }
}
-function parseHttpOrigin(value: string): URL | null {
- let url: URL
- try {
- url = new URL(value)
- } catch {
- return null
- }
- if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
- return url
-}
-
/** Normalises an address to its origin, or null when it is not an absolute http(s) URL. */
export function normalizeDevicePairingOrigin(value: string): string | null {
- return parseHttpOrigin(value)?.origin ?? null
+ return parseHttpUrl(value)?.origin ?? null
}
/** Reports whether an origin can mint a pairing code, and why it cannot when blocked. */
export function resolveDevicePairingAvailability(
origin: string | undefined,
options: DevicePairingAvailabilityOptions,
-): DevicePairingAvailability {
+): DevicePairingAvailabilityResult {
if (!origin) return unavailable('untrusted-origin', MISSING_ORIGIN_MESSAGE)
if (!isTrustedOrigin(origin, { requireOrigin: true }, options.trustedOrigins)) {
return unavailable('untrusted-origin', untrustedOriginMessage(origin))
}
- const originUrl = parseHttpOrigin(origin)
+ const originUrl = parseHttpUrl(origin)
if (!originUrl) return unavailable('untrusted-origin', untrustedOriginMessage(origin))
const isLoopback = isLoopbackHost(originUrl.hostname)
@@ -73,11 +70,11 @@ export function resolveDevicePairingAvailability(
return unavailable('loopback', LOOPBACK_ORIGIN_MESSAGE)
}
- if (originUrl.protocol === 'http:' && !isLoopback && !isPrivateNetworkHost(originUrl.hostname)) {
+ if (isPublicPlainHttpUrl(originUrl)) {
return unavailable('insecure-origin', INSECURE_ORIGIN_MESSAGE)
}
- return { available: true, reason: null, message: null }
+ return { available: true, reason: null, message: null, originUrl }
}
/** Decides whether an origin can carry an app link and which warnings it raises. */
diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts
index 1596cfb2a..19c45febf 100644
--- a/backend/src/services/notification.ts
+++ b/backend/src/services/notification.ts
@@ -222,7 +222,7 @@ export class NotificationService {
user_id = excluded.user_id,
p256dh = excluded.p256dh,
auth = excluded.auth,
- device_name = excluded.device_name,
+ device_name = COALESCE(excluded.device_name, push_subscriptions.device_name),
session_id = excluded.session_id,
last_used_at = excluded.last_used_at`
)
diff --git a/backend/test/auth/index.test.ts b/backend/test/auth/index.test.ts
index e09a03c0c..65dd20c7e 100644
--- a/backend/test/auth/index.test.ts
+++ b/backend/test/auth/index.test.ts
@@ -78,7 +78,7 @@ describe('createAuth', () => {
body: { email: 'other@example.com', password: 'test-password', name: 'Other' },
})).rejects.toThrow('Registration is disabled')
- await syncAdminFromEnv(auth)
+ await syncAdminFromEnv(auth, db)
const response = await auth.handler(new Request('http://localhost:5173/api/auth/sign-in/email', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: 'http://localhost:5173' },
diff --git a/backend/test/helpers/log-assertions.ts b/backend/test/helpers/log-assertions.ts
index d478ae6e3..ea8e36760 100644
--- a/backend/test/helpers/log-assertions.ts
+++ b/backend/test/helpers/log-assertions.ts
@@ -7,7 +7,7 @@ export interface LoggerMocks {
debug: Mock
}
-export function loggedArguments(loggerMocks: LoggerMocks): unknown[][] {
+function loggedArguments(loggerMocks: LoggerMocks): unknown[][] {
return [...loggerMocks.info.mock.calls, ...loggerMocks.warn.mock.calls, ...loggerMocks.error.mock.calls]
}
diff --git a/backend/test/routes/auth.test.ts b/backend/test/routes/auth.test.ts
index 0f4a42b98..e1c7c7b65 100644
--- a/backend/test/routes/auth.test.ts
+++ b/backend/test/routes/auth.test.ts
@@ -102,6 +102,18 @@ async function findPasswordHash(auth: AuthInstance, userId: string): Promise, endpoint: string, sessionId: string | null): void {
+ db.prepare(
+ `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, session_id, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ ).run('default', endpoint, 'p256dh-key', 'auth-key', sessionId, Date.now())
+}
+
+function pushSubscriptionEndpoints(db: ReturnType): string[] {
+ return (db.prepare('SELECT endpoint FROM push_subscriptions ORDER BY endpoint').all() as Array<{ endpoint: string }>)
+ .map((row) => row.endpoint)
+}
+
function createSession(): Session {
return {
session: {
@@ -188,7 +200,7 @@ describe('syncAdminFromEnv', () => {
})
it('does nothing when the admin credentials are not configured', async () => {
- await syncAdminFromEnv(auth)
+ await syncAdminFromEnv(auth, db)
expect(await countRows(auth, 'user')).toBe(0)
expect(mockLoggerInfo).not.toHaveBeenCalled()
@@ -197,7 +209,7 @@ describe('syncAdminFromEnv', () => {
it('does nothing when only one admin credential is configured', async () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
- await syncAdminFromEnv(auth)
+ await syncAdminFromEnv(auth, db)
expect(await countRows(auth, 'user')).toBe(0)
})
@@ -208,7 +220,7 @@ describe('syncAdminFromEnv', () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
- await syncAdminFromEnv(auth)
+ await syncAdminFromEnv(auth, db)
expect((await signInRequest(auth, 'admin@example.com', 'old-password')).status).toBe(200)
expect((await signInRequest(auth, 'admin@example.com', 'new-password')).status).toBe(401)
@@ -221,20 +233,39 @@ describe('syncAdminFromEnv', () => {
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
ENV.AUTH.ADMIN_PASSWORD_RESET = true
- await syncAdminFromEnv(auth)
+ await syncAdminFromEnv(auth, db)
expect(await countRows(auth, 'user')).toBe(1)
expect((await signInRequest(auth, 'Admin@Example.com', 'new-password')).status).toBe(200)
expect((await signInRequest(auth, 'admin@example.com', 'old-password')).status).toBe(401)
}, 30000)
+ it('finds the existing admin user when the stored email differs only by case', async () => {
+ await auth.api.signUpEmail({ body: { email: 'admin@example.com', password: 'old-password', name: 'Admin' } })
+ const cookies = await createUserSession(auth, 'admin@example.com', 'old-password')
+ const adminId = (await auth.api.getSession({ headers: cookies }))!.user.id
+ db.prepare('UPDATE "user" SET email = ? WHERE id = ?').run('Admin@Example.com', adminId)
+ ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
+ ENV.AUTH.ADMIN_PASSWORD = 'new-password'
+ ENV.AUTH.ADMIN_PASSWORD_RESET = true
+
+ await syncAdminFromEnv(auth, db)
+
+ const ctx = await auth.$context
+ expect(await countRows(auth, 'user')).toBe(1)
+ expect(await ctx.password.verify({
+ password: 'new-password',
+ hash: await findPasswordHash(auth, adminId),
+ })).toBe(true)
+ }, 30000)
+
it('resets the credential account password for an existing user', async () => {
await auth.api.signUpEmail({ body: { email: 'admin@example.com', password: 'old-password', name: 'Admin' } })
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
ENV.AUTH.ADMIN_PASSWORD_RESET = true
- await syncAdminFromEnv(auth)
+ await syncAdminFromEnv(auth, db)
expect((await signInRequest(auth, 'admin@example.com', 'new-password')).status).toBe(200)
expect((await signInRequest(auth, 'admin@example.com', 'old-password')).status).toBe(401)
@@ -255,12 +286,34 @@ describe('syncAdminFromEnv', () => {
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
ENV.AUTH.ADMIN_PASSWORD_RESET = true
- await syncAdminFromEnv(auth)
+ await syncAdminFromEnv(auth, db)
expect(await countRows(auth, 'session', adminId)).toBe(0)
expect(await countRows(auth, 'session', otherId)).toBe(otherSessionsBefore)
}, 30000)
+ it('deletes the push subscriptions of the revoked admin sessions and keeps the others', async () => {
+ await auth.api.signUpEmail({ body: { email: 'admin@example.com', password: 'admin-password', name: 'Admin' } })
+ await auth.api.signUpEmail({ body: { email: 'other@example.com', password: 'other-password', name: 'Other' } })
+ const adminCookies = await createUserSession(auth, 'admin@example.com', 'admin-password')
+ const otherCookies = await createUserSession(auth, 'other@example.com', 'other-password')
+ const adminSessionId = (await auth.api.getSession({ headers: adminCookies }))!.session.id
+ const otherSessionId = (await auth.api.getSession({ headers: otherCookies }))!.session.id
+ insertPushSubscription(db, 'https://push.example.com/phone', adminSessionId)
+ insertPushSubscription(db, 'https://push.example.com/tablet', otherSessionId)
+ insertPushSubscription(db, 'https://push.example.com/legacy', null)
+ ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
+ ENV.AUTH.ADMIN_PASSWORD = 'new-password'
+ ENV.AUTH.ADMIN_PASSWORD_RESET = true
+
+ await syncAdminFromEnv(auth, db)
+
+ expect(pushSubscriptionEndpoints(db)).toEqual([
+ 'https://push.example.com/legacy',
+ 'https://push.example.com/tablet',
+ ])
+ }, 30000)
+
it('runs the session delete hook for every revoked admin session', async () => {
await auth.api.signUpEmail({ body: { email: 'admin@example.com', password: 'admin-password', name: 'Admin' } })
const cookies = await createUserSession(auth, 'admin@example.com', 'admin-password')
@@ -271,7 +324,7 @@ describe('syncAdminFromEnv', () => {
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
ENV.AUTH.ADMIN_PASSWORD_RESET = true
- await syncAdminFromEnv(auth)
+ await syncAdminFromEnv(auth, db)
expect(close).toHaveBeenCalledTimes(1)
}, 30000)
@@ -286,7 +339,7 @@ describe('syncAdminFromEnv', () => {
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
ENV.AUTH.ADMIN_PASSWORD_RESET = true
- await syncAdminFromEnv(auth)
+ await syncAdminFromEnv(auth, db)
expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('No credential account found'))
expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('Admin password reset'))
@@ -302,7 +355,7 @@ describe('syncAdminFromEnv', () => {
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
ENV.AUTH.ADMIN_PASSWORD_RESET = true
- await expect(syncAdminFromEnv(auth)).rejects.toThrow()
+ await expect(syncAdminFromEnv(auth, db)).rejects.toThrow()
const ctx = await auth.$context
expect(await ctx.password.verify({
@@ -315,7 +368,7 @@ describe('syncAdminFromEnv', () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'admin-password'
- await syncAdminFromEnv(auth)
+ await syncAdminFromEnv(auth, db)
expect(await countRows(auth, 'user')).toBe(1)
expect((await signInRequest(auth, 'admin@example.com', 'admin-password')).status).toBe(200)
@@ -326,7 +379,7 @@ describe('syncAdminFromEnv', () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'short'
- await expect(syncAdminFromEnv(auth)).resolves.toBeUndefined()
+ await expect(syncAdminFromEnv(auth, db)).resolves.toBeUndefined()
expect(await countRows(auth, 'user')).toBe(0)
expect(mockLoggerError).toHaveBeenCalledWith('Failed to create admin user from environment:', expect.anything())
diff --git a/backend/test/routes/notifications.test.ts b/backend/test/routes/notifications.test.ts
index 2d0205a3e..4ac87f84f 100644
--- a/backend/test/routes/notifications.test.ts
+++ b/backend/test/routes/notifications.test.ts
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Hono } from 'hono'
+import { Database } from 'bun:sqlite'
import { createNotificationRoutes } from '../../src/routes/notifications'
-import type { NotificationService } from '../../src/services/notification'
+import { NotificationService } from '../../src/services/notification'
import type { Session } from '../../src/auth'
interface NotificationServiceStub {
@@ -144,6 +145,47 @@ describe('Notification Routes', () => {
)
})
+ it('updates the stored session id and keeps the device name when the same endpoint subscribes again without one', async () => {
+ const db = new Database(':memory:')
+ const service = new NotificationService(db)
+ const endpoint = 'https://push.example.com/phone'
+ const subscriptionBody = (deviceName?: string) => JSON.stringify({
+ endpoint,
+ keys: { p256dh: 'p256dh-key', auth: 'auth-key' },
+ ...(deviceName ? { deviceName } : {}),
+ })
+
+ try {
+ const subscribes: Array<[sessionId: string, deviceName: string | undefined]> = [
+ ['ses_old', 'Phone'],
+ ['ses_new', undefined],
+ ]
+
+ for (const [sessionId, deviceName] of subscribes) {
+ const sessionApp = new Hono<{ Variables: { session?: Session['session'] } }>()
+ sessionApp.use('*', async (c, next) => {
+ c.set('session', { id: sessionId } as Session['session'])
+ await next()
+ })
+ sessionApp.route('/', createNotificationRoutes(service))
+
+ const res = await sessionApp.fetch(new Request('http://localhost/subscribe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: subscriptionBody(deviceName),
+ }))
+ expect(res.status).toBe(200)
+ }
+
+ const rows = db
+ .prepare('SELECT session_id, device_name FROM push_subscriptions WHERE endpoint = ?')
+ .all(endpoint)
+ expect(rows).toEqual([{ session_id: 'ses_new', device_name: 'Phone' }])
+ } finally {
+ db.close()
+ }
+ })
+
it('returns 400 for invalid subscription data', async () => {
const res = await app.fetch(new Request('http://localhost/subscribe', {
method: 'POST',
diff --git a/backend/test/services/device-pairing-links.test.ts b/backend/test/services/device-pairing-links.test.ts
index 1e05160e2..3197e2600 100644
--- a/backend/test/services/device-pairing-links.test.ts
+++ b/backend/test/services/device-pairing-links.test.ts
@@ -17,6 +17,7 @@ describe('resolveDevicePairingAvailability', () => {
available: true,
reason: null,
message: null,
+ originUrl: new URL('https://m.example.com'),
})
})
diff --git a/backend/test/services/notification-subscriptions.test.ts b/backend/test/services/notification-subscriptions.test.ts
index 28fe208af..5006968ca 100644
--- a/backend/test/services/notification-subscriptions.test.ts
+++ b/backend/test/services/notification-subscriptions.test.ts
@@ -78,6 +78,16 @@ describe('push subscription sessions', () => {
expect(service.getSubscriptions(USER_ID)).toHaveLength(1)
})
+ it('keeps the stored device name when the same endpoint subscribes again without one', () => {
+ const endpoint = 'https://push.example.com/phone'
+ service.saveSubscription(USER_ID, endpoint, 'p256dh-key', 'auth-key', 'Phone', 'ses_old')
+ const resubscribed = service.saveSubscription(USER_ID, endpoint, 'p256dh-key', 'auth-key', undefined, 'ses_new')
+
+ expect(resubscribed.deviceName).toBe('Phone')
+ expect(sessionIdOf(db, endpoint)).toBe('ses_new')
+ expect(service.getSubscriptions(USER_ID)).toHaveLength(1)
+ })
+
it('adds the session id column to a database created before it existed', () => {
const legacyDb = new Database(':memory:')
legacyDb.run(LEGACY_TABLE)
diff --git a/backend/test/shared/url-hosts.test.ts b/backend/test/shared/url-hosts.test.ts
index bc69ca7e8..e658ee870 100644
--- a/backend/test/shared/url-hosts.test.ts
+++ b/backend/test/shared/url-hosts.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
-import { isLoopbackHost, isPrivateNetworkHost } from '@opencode-manager/shared/utils'
+import {
+ isLoopbackHost,
+ isPrivateNetworkHost,
+ isPublicPlainHttpUrl,
+ parseHttpUrl,
+} from '@opencode-manager/shared/utils'
describe('isLoopbackHost', () => {
it.each(['localhost', 'LOCALHOST', '127.0.0.1', '127.4.5.6', '::1', '[::1]'])(
@@ -48,3 +53,49 @@ describe('isPrivateNetworkHost', () => {
expect(isPrivateNetworkHost(host)).toBe(false)
})
})
+
+describe('isPublicPlainHttpUrl', () => {
+ it.each([
+ 'http://example.com',
+ 'http://example.com.',
+ 'http://203.0.113.5:5003',
+ 'http://8.8.8.8:5003/path',
+ ])('treats %s as public plain http', (url) => {
+ expect(isPublicPlainHttpUrl(new URL(url))).toBe(true)
+ })
+
+ it.each([
+ 'https://example.com',
+ 'https://203.0.113.5:5003',
+ 'http://localhost:5173',
+ 'http://127.0.0.1:5003',
+ 'http://[::1]:5003',
+ 'http://192.168.1.20:5003',
+ 'http://172.16.0.1:5003',
+ 'http://100.64.0.1:5003',
+ 'http://[fd00::1]:5003',
+ ])('treats %s as not public plain http', (url) => {
+ expect(isPublicPlainHttpUrl(new URL(url))).toBe(false)
+ })
+})
+
+describe('parseHttpUrl', () => {
+ it('parses an absolute http url', () => {
+ expect(parseHttpUrl('http://m.example.com:5003/pair?x=1')?.origin).toBe(
+ 'http://m.example.com:5003',
+ )
+ })
+
+ it('parses an absolute https url with surrounding whitespace', () => {
+ expect(parseHttpUrl(' https://m.example.com/settings?tab=pairing ')?.origin).toBe(
+ 'https://m.example.com',
+ )
+ })
+
+ it.each(['', 'not a url', '//m.example.com', 'ocm://link?v=2&kind=manager', 'ftp://example.com'])(
+ 'returns null for %s',
+ (value) => {
+ expect(parseHttpUrl(value)).toBeNull()
+ },
+ )
+})
diff --git a/frontend/src/App.push-subscription-sync.test.tsx b/frontend/src/App.push-subscription-sync.test.tsx
new file mode 100644
index 000000000..69314526c
--- /dev/null
+++ b/frontend/src/App.push-subscription-sync.test.tsx
@@ -0,0 +1,117 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter } from 'react-router-dom'
+import type { ReactNode } from 'react'
+
+vi.mock('react-router-dom', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, createBrowserRouter: vi.fn(() => ({ routes: [] })) }
+})
+
+import { AppShell } from './App'
+
+const mocks = vi.hoisted(() => ({
+ usePushSubscriptionSessionSync: vi.fn(),
+}))
+
+vi.mock('@/hooks/usePushSubscriptionSessionSync', () => ({
+ usePushSubscriptionSessionSync: mocks.usePushSubscriptionSessionSync,
+}))
+
+vi.mock('@/hooks/useMobile', () => ({
+ useSwipeBack: () => ({ bind: () => () => {} }),
+ useRightEdgeSwipe: () => ({ bind: () => () => {} }),
+}))
+
+vi.mock('@/hooks/useMobileTabBar', () => ({
+ useMobileTabBar: () => ({ openSheet: null, open: vi.fn() }),
+}))
+
+vi.mock('@/contexts/SwipeNavigationContext', () => ({
+ useSwipeNavigation: () => null,
+}))
+
+vi.mock('@/contexts/EventContext', () => ({
+ EventProvider: ({ children }: { children: ReactNode }) => <>{children}>,
+ usePermissions: () => ({
+ current: null,
+ pendingCount: 0,
+ respond: vi.fn(),
+ showDialog: false,
+ setShowDialog: vi.fn(),
+ navigateToCurrent: vi.fn(),
+ viewedSessionId: null,
+ currentDirectory: null,
+ currentIsFromDifferentSession: false,
+ }),
+ useEventContext: () => ({
+ getRepoIdForSession: () => null,
+ sshHostKey: { request: null, respond: vi.fn() },
+ }),
+}))
+
+vi.mock('@/contexts/RepoWorktreesContext', () => ({
+ RepoWorktreesProvider: ({ children }: { children: ReactNode }) => <>{children}>,
+}))
+
+vi.mock('@/contexts/KeyboardShortcutsContext', () => ({
+ KeyboardShortcutsProvider: ({ children }: { children: ReactNode }) => <>{children}>,
+}))
+
+vi.mock('@/contexts/OpenCodeServerContext', () => ({
+ OpenCodeServerProvider: ({ children }: { children: ReactNode }) => <>{children}>,
+ isLocalOpenCodeServer: () => true,
+}))
+
+vi.mock('@/lib/serviceWorker', () => ({
+ onNotificationClick: () => () => {},
+}))
+
+vi.mock('@/hooks/useAuth', () => ({ useAuth: () => ({ isAuthenticated: true }) }))
+vi.mock('@/hooks/useGlobalShortcutActions', () => ({ useGlobalShortcutActions: vi.fn() }))
+vi.mock('@/hooks/useSidebarServerSelection', () => ({ useSidebarServerRouteSync: vi.fn() }))
+vi.mock('@/hooks/useOpenCodeFailureToast', () => ({ useOpenCodeFailureToast: vi.fn() }))
+vi.mock('@/hooks/useOpenCode', () => ({
+ sessionQueryOptions: () => ({ queryKey: ['session'], queryFn: async () => null, enabled: false }),
+}))
+vi.mock('@/hooks/useOpenCodeServerActions', () => ({
+ useOpenCodeServerActions: () => ({
+ restartServerMutation: { isPending: false },
+ confirmOpen: false,
+ setConfirmOpen: vi.fn(),
+ activeSessionCount: 0,
+ requestRestart: vi.fn(),
+ confirmRestart: vi.fn(),
+ }),
+}))
+
+vi.mock('@/components/navigation/DesktopSidebar', () => ({ DesktopSidebar: () => null }))
+vi.mock('@/components/navigation/MobileTabBar', () => ({ MobileTabBar: () => null }))
+vi.mock('@/components/navigation/MobileSheetHost', () => ({ MobileSheetHost: () => null }))
+vi.mock('@/components/settings/SettingsDialog', () => ({ SettingsDialog: () => null }))
+vi.mock('@/components/settings/RestartServerDialog', () => ({ RestartServerDialog: () => null }))
+vi.mock('@/components/session/PermissionRequestDialog', () => ({ PermissionRequestDialog: () => null }))
+vi.mock('@/components/ssh/SSHHostKeyDialog', () => ({ SSHHostKeyDialog: () => null }))
+vi.mock('@/components/VersionNotifier', () => ({ VersionNotifier: () => null }))
+vi.mock('@/components/PwaUpdatePrompt', () => ({ PwaUpdatePrompt: () => null }))
+
+describe('AppShell', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('mounts the push subscription session sync in the authenticated shell', () => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+ render(
+
+
+
+
+ ,
+ )
+
+ expect(mocks.usePushSubscriptionSessionSync).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e203f12e2..4d8704a49 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -46,6 +46,7 @@ import { useOpenCodeFailureToast } from '@/hooks/useOpenCodeFailureToast'
import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions'
import { useGlobalShortcutActions } from '@/hooks/useGlobalShortcutActions'
import { useSidebarServerRouteSync } from '@/hooks/useSidebarServerSelection'
+import { usePushSubscriptionSessionSync } from '@/hooks/usePushSubscriptionSessionSync'
import { sessionQueryOptions } from '@/hooks/useOpenCode'
import { RestartServerDialog } from '@/components/settings/RestartServerDialog'
@@ -160,6 +161,11 @@ function SidebarServerRouteSync() {
return null
}
+function PushSubscriptionSessionSync() {
+ usePushSubscriptionSessionSync()
+ return null
+}
+
function ServerScope({ children }: { children: ReactNode }) {
const { serverId } = useParams<{ serverId: string }>()
@@ -185,7 +191,7 @@ function RootLayout() {
)
}
-function AppShell() {
+export function AppShell() {
const navigate = useNavigate()
const location = useLocation()
const rootRef = useRef(null)
@@ -259,6 +265,7 @@ function AppShell() {
+
diff --git a/frontend/src/components/settings/OpenCodeServerDialog.tsx b/frontend/src/components/settings/OpenCodeServerDialog.tsx
index 428629861..c3e0d84e6 100644
--- a/frontend/src/components/settings/OpenCodeServerDialog.tsx
+++ b/frontend/src/components/settings/OpenCodeServerDialog.tsx
@@ -12,7 +12,7 @@ import {
type OpenCodeServerLink,
type OpenCodeServerTestResult,
} from '@opencode-manager/shared/schemas'
-import { haveSameUrlOrigin, isLoopbackHost, isPrivateNetworkHost } from '@opencode-manager/shared/utils'
+import { haveSameUrlOrigin, isPublicPlainHttpUrl, parseHttpUrl } from '@opencode-manager/shared/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -85,16 +85,8 @@ const serverFormSchema = z.object({
type ServerFormValues = z.infer
function sendsCredentialsInClear(baseUrl: string): boolean {
- try {
- const url = new URL(baseUrl)
- return (
- url.protocol === 'http:' &&
- !isLoopbackHost(url.hostname) &&
- !isPrivateNetworkHost(url.hostname)
- )
- } catch {
- return false
- }
+ const url = parseHttpUrl(baseUrl)
+ return url !== null && isPublicPlainHttpUrl(url)
}
function buildDefaultValues(server?: OpenCodeServer | null): ServerFormValues {
diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts
index 9a4cc8173..29aee14cd 100644
--- a/frontend/src/hooks/useNotifications.ts
+++ b/frontend/src/hooks/useNotifications.ts
@@ -7,6 +7,7 @@ import {
urlBase64ToUint8Array,
} from "@/lib/serviceWorker";
import type { NotificationPreferences } from "@/api/types/settings";
+import type { PushSubscriptionRecord } from "@opencode-manager/shared/types";
import { DEFAULT_NOTIFICATION_PREFERENCES } from "@opencode-manager/shared/schemas";
type PermissionState = NotificationPermission | "unsupported";
@@ -16,6 +17,18 @@ function getPermissionState(): PermissionState {
return Notification.permission;
}
+export async function sendRegisteredSubscription(
+ deviceName?: string
+): Promise<{ subscription: PushSubscriptionRecord } | null> {
+ const registration = await getServiceWorkerRegistration();
+ if (!registration) return null;
+
+ const existingSubscription = await registration.pushManager.getSubscription();
+ if (!existingSubscription) return null;
+
+ return notificationsApi.subscribe(existingSubscription.toJSON(), deviceName);
+}
+
export function useNotifications() {
const queryClient = useQueryClient();
const { preferences, updateSettings } = useSettings();
@@ -44,16 +57,13 @@ export function useNotifications() {
const subscribeMutation = useMutation({
mutationFn: async (deviceName?: string) => {
+ const registered = await sendRegisteredSubscription(deviceName);
+ if (registered) return registered;
+
const reg = await getServiceWorkerRegistration();
if (!reg) throw new Error("Service worker not registered");
if (!vapidData?.publicKey) throw new Error("VAPID key not available");
- const existingSub = await reg.pushManager.getSubscription();
- if (existingSub) {
- const json = existingSub.toJSON();
- return notificationsApi.subscribe(json, deviceName);
- }
-
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidData.publicKey).buffer as ArrayBuffer,
diff --git a/frontend/src/hooks/usePushSubscriptionSessionSync.test.tsx b/frontend/src/hooks/usePushSubscriptionSessionSync.test.tsx
new file mode 100644
index 000000000..490bb26f1
--- /dev/null
+++ b/frontend/src/hooks/usePushSubscriptionSessionSync.test.tsx
@@ -0,0 +1,127 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { renderHook, waitFor } from '@testing-library/react'
+import { usePushSubscriptionSessionSync } from './usePushSubscriptionSessionSync'
+
+const authState = vi.hoisted(() => ({ sessionId: null as string | null }))
+const serviceWorker = vi.hoisted(() => ({ getServiceWorkerRegistration: vi.fn() }))
+const notificationsApi = vi.hoisted(() => ({ subscribe: vi.fn() }))
+
+vi.mock('@/lib/auth-client', () => ({
+ useSession: () => ({
+ data: authState.sessionId ? { session: { id: authState.sessionId }, user: { id: 'user-1' } } : null,
+ }),
+}))
+
+vi.mock('@/lib/serviceWorker', () => ({
+ getServiceWorkerRegistration: serviceWorker.getServiceWorkerRegistration,
+ urlBase64ToUint8Array: vi.fn(() => new Uint8Array([1, 2, 3])),
+}))
+
+vi.mock('@/api/notifications', () => ({ notificationsApi }))
+
+const subscriptionJson = {
+ endpoint: 'https://push.example.com/phone',
+ keys: { p256dh: 'p256dh-key', auth: 'auth-key' },
+}
+
+function grantNotificationPermission(): void {
+ Object.defineProperty(window, 'Notification', {
+ configurable: true,
+ value: { permission: 'granted' },
+ })
+}
+
+describe('usePushSubscriptionSessionSync', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ authState.sessionId = 'ses_first'
+ grantNotificationPermission()
+ serviceWorker.getServiceWorkerRegistration.mockResolvedValue({
+ pushManager: {
+ getSubscription: vi.fn().mockResolvedValue({ toJSON: () => subscriptionJson }),
+ },
+ })
+ notificationsApi.subscribe.mockResolvedValue({ subscription: { id: 1 } })
+ })
+
+ afterEach(() => {
+ Reflect.deleteProperty(window, 'Notification')
+ })
+
+ it('re-sends the registered subscription once a session is signed in', async () => {
+ renderHook(() => usePushSubscriptionSessionSync())
+
+ await waitFor(() => {
+ expect(notificationsApi.subscribe).toHaveBeenCalledTimes(1)
+ })
+ expect(notificationsApi.subscribe).toHaveBeenCalledWith(subscriptionJson, undefined)
+ })
+
+ it('re-sends the registered subscription again after the session changes', async () => {
+ const { rerender } = renderHook(() => usePushSubscriptionSessionSync())
+
+ await waitFor(() => {
+ expect(notificationsApi.subscribe).toHaveBeenCalledTimes(1)
+ })
+
+ authState.sessionId = 'ses_second'
+ rerender()
+
+ await waitFor(() => {
+ expect(notificationsApi.subscribe).toHaveBeenCalledTimes(2)
+ })
+ expect(notificationsApi.subscribe).toHaveBeenLastCalledWith(subscriptionJson, undefined)
+ })
+
+ it('sends the subscription only once for the same session', async () => {
+ const { rerender } = renderHook(() => usePushSubscriptionSessionSync())
+
+ await waitFor(() => {
+ expect(notificationsApi.subscribe).toHaveBeenCalledTimes(1)
+ })
+
+ rerender()
+
+ await waitFor(() => {
+ expect(notificationsApi.subscribe).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('does not send while notification permission is not granted', async () => {
+ Object.defineProperty(window, 'Notification', {
+ configurable: true,
+ value: { permission: 'default' },
+ })
+
+ renderHook(() => usePushSubscriptionSessionSync())
+
+ await waitFor(() => {
+ expect(serviceWorker.getServiceWorkerRegistration).not.toHaveBeenCalled()
+ })
+ expect(notificationsApi.subscribe).not.toHaveBeenCalled()
+ })
+
+ it('does not send when the browser has no push subscription', async () => {
+ serviceWorker.getServiceWorkerRegistration.mockResolvedValue({
+ pushManager: { getSubscription: vi.fn().mockResolvedValue(null) },
+ })
+
+ renderHook(() => usePushSubscriptionSessionSync())
+
+ await waitFor(() => {
+ expect(serviceWorker.getServiceWorkerRegistration).toHaveBeenCalled()
+ })
+ expect(notificationsApi.subscribe).not.toHaveBeenCalled()
+ })
+
+ it('does not send before a session is signed in', async () => {
+ authState.sessionId = null
+
+ renderHook(() => usePushSubscriptionSessionSync())
+
+ await waitFor(() => {
+ expect(serviceWorker.getServiceWorkerRegistration).not.toHaveBeenCalled()
+ })
+ expect(notificationsApi.subscribe).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/hooks/usePushSubscriptionSessionSync.ts b/frontend/src/hooks/usePushSubscriptionSessionSync.ts
new file mode 100644
index 000000000..7d141faaf
--- /dev/null
+++ b/frontend/src/hooks/usePushSubscriptionSessionSync.ts
@@ -0,0 +1,21 @@
+import { useEffect, useRef } from "react";
+import { useSession } from "@/lib/auth-client";
+import { sendRegisteredSubscription } from "@/hooks/useNotifications";
+
+function hasNotificationPermission(): boolean {
+ return "Notification" in window && Notification.permission === "granted";
+}
+
+export function usePushSubscriptionSessionSync(): void {
+ const { data: authSession } = useSession();
+ const sessionId = authSession?.session?.id ?? null;
+ const syncedSessionIdRef = useRef(null);
+
+ useEffect(() => {
+ if (!sessionId || !hasNotificationPermission()) return;
+ if (syncedSessionIdRef.current === sessionId) return;
+
+ syncedSessionIdRef.current = sessionId;
+ void sendRegisteredSubscription().catch(() => {});
+ }, [sessionId]);
+}
diff --git a/shared/src/schemas/device-pairing.ts b/shared/src/schemas/device-pairing.ts
index c4942a7f4..cae781724 100644
--- a/shared/src/schemas/device-pairing.ts
+++ b/shared/src/schemas/device-pairing.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { trimUrlTrailingSlashes } from "../utils/url";
+import { parseHttpUrl, trimUrlTrailingSlashes } from "../utils/url";
export const REMOTE_ACCESS_HEADER_MAX_COUNT = 8;
export const REMOTE_ACCESS_HEADER_VALUE_MAX_LENGTH = 512;
@@ -204,13 +204,8 @@ export function readPairingCodeFromHash(hash: string): string | null {
/** Parses a browser pairing link into its origin and code, or null when the text is not one. */
export function parseManagerBrowserPairingLink(text: string): { origin: string; code: string } | null {
- let url: URL;
- try {
- url = new URL(text.trim());
- } catch {
- return null;
- }
- if (url.protocol !== "https:" && url.protocol !== "http:") return null;
+ const url = parseHttpUrl(text.trim());
+ if (!url) return null;
if (url.pathname !== DEVICE_PAIRING_PATH) return null;
const code = readPairingCodeFromHash(url.hash);
return code === null ? null : { origin: url.origin, code };
diff --git a/shared/src/utils/url.ts b/shared/src/utils/url.ts
index e250dfc59..b4052b3ac 100644
--- a/shared/src/utils/url.ts
+++ b/shared/src/utils/url.ts
@@ -3,6 +3,18 @@ export function trimUrlTrailingSlashes(url: string): string {
return url.trim().replace(/\/+$/, '')
}
+/** Parses a value into a URL, or returns null when it is not an absolute http(s) URL. */
+export function parseHttpUrl(value: string): URL | null {
+ let url: URL
+ try {
+ url = new URL(value)
+ } catch {
+ return null
+ }
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
+ return url
+}
+
/** Reports whether two URLs share the same origin; false when either URL is invalid. */
export function haveSameUrlOrigin(first: string, second: string): boolean {
const origin = readUrlOrigin(first)
@@ -36,6 +48,15 @@ export function isPrivateNetworkHost(hostname: string): boolean {
return firstHextet >= 0xfc00 && firstHextet <= 0xfdff
}
+/** Reports whether a URL uses plain HTTP on a host other than loopback or a private network. */
+export function isPublicPlainHttpUrl(url: URL): boolean {
+ return (
+ url.protocol === 'http:' &&
+ !isLoopbackHost(url.hostname) &&
+ !isPrivateNetworkHost(url.hostname)
+ )
+}
+
function normalizeHostname(hostname: string): string {
return hostname.trim().toLowerCase().replace(/^\[/, '').replace(/\]$/, '')
}
@@ -54,9 +75,5 @@ function parseIpv4Octets(host: string): [number, number, number, number] | null
}
function readUrlOrigin(value: string): string | null {
- try {
- return new URL(value).origin
- } catch {
- return null
- }
+ return parseHttpUrl(value)?.origin ?? null
}
From a769d3fd34f081e5ce3770cdfa308851e54ccf8f Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sat, 24 Oct 2026 10:37:43 -0400
Subject: [PATCH 6/6] fix(notifications): reject push registrations from
revoked sessions
POST /subscribe re-checks the registering auth session inside the same synchronous transaction as the upsert, so a request accepted before a logout cannot restore a revoked session_id over a newer registration. A stale request gets 401, which the session sync already tolerates.
---
backend/src/db/auth-sessions.ts | 13 +++
backend/src/routes/notifications.ts | 4 +
backend/src/services/notification.ts | 20 ++++
backend/test/helpers/auth-sessions.ts | 36 ++++++++
backend/test/routes/notifications.test.ts | 91 +++++++++++++++----
.../notification-subscriptions.test.ts | 33 ++++++-
6 files changed, 178 insertions(+), 19 deletions(-)
create mode 100644 backend/src/db/auth-sessions.ts
create mode 100644 backend/test/helpers/auth-sessions.ts
diff --git a/backend/src/db/auth-sessions.ts b/backend/src/db/auth-sessions.ts
new file mode 100644
index 000000000..2c6f15bfc
--- /dev/null
+++ b/backend/src/db/auth-sessions.ts
@@ -0,0 +1,13 @@
+import type { Database } from 'bun:sqlite'
+
+/** Reports whether a Better Auth session row still exists and has not expired. */
+export function isAuthSessionActive(db: Database, sessionId: string, now: number = Date.now()): boolean {
+ const row = db
+ .prepare('SELECT expiresAt FROM "session" WHERE id = ?')
+ .get(sessionId) as { expiresAt: string | number } | null
+
+ if (!row) return false
+
+ const expiresAt = typeof row.expiresAt === 'number' ? row.expiresAt : Date.parse(row.expiresAt)
+ return Number.isFinite(expiresAt) && expiresAt > now
+}
diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts
index 08852a95c..137241b6f 100644
--- a/backend/src/routes/notifications.ts
+++ b/backend/src/routes/notifications.ts
@@ -45,6 +45,10 @@ export function createNotificationRoutes(
sessionId
);
+ if (!subscription) {
+ return c.json({ error: "Session is no longer valid" }, 401);
+ }
+
return c.json({ subscription });
});
diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts
index 19c45febf..526942d6a 100644
--- a/backend/src/services/notification.ts
+++ b/backend/src/services/notification.ts
@@ -25,6 +25,7 @@ import { sseAggregator, type SSEEvent } from "./sse-aggregator";
import { getRepoName } from "../db/queries";
import { getScheduleRunBySessionId } from "../db/schedules";
import { ensurePushSubscriptionsTable } from "../db/push-subscriptions";
+import { isAuthSessionActive } from "../db/auth-sessions";
import type { Repo } from "../types/repo";
import { buildServerSessionPath, buildSessionPath } from "@opencode-manager/shared/utils";
import { sessionIDFromEvent } from "@opencode-manager/shared/opencode";
@@ -204,6 +205,11 @@ export class NotificationService {
return this.vapidConfig !== null;
}
+ /**
+ * Stores a device's push subscription, returning null when the session that registered it was
+ * revoked or expired before the write. The session check and the upsert share one synchronous
+ * transaction so a concurrent logout cannot land between them.
+ */
saveSubscription(
userId: string,
endpoint: string,
@@ -211,6 +217,20 @@ export class NotificationService {
auth: string,
deviceName?: string,
sessionId?: string | null
+ ): PushSubscriptionRecord | null {
+ return this.db.transaction(() => {
+ if (sessionId && !isAuthSessionActive(this.db, sessionId)) return null;
+ return this.writeSubscription(userId, endpoint, p256dh, auth, deviceName, sessionId);
+ })();
+ }
+
+ private writeSubscription(
+ userId: string,
+ endpoint: string,
+ p256dh: string,
+ auth: string,
+ deviceName?: string,
+ sessionId?: string | null
): PushSubscriptionRecord {
const now = Date.now();
diff --git a/backend/test/helpers/auth-sessions.ts b/backend/test/helpers/auth-sessions.ts
new file mode 100644
index 000000000..a714726a8
--- /dev/null
+++ b/backend/test/helpers/auth-sessions.ts
@@ -0,0 +1,36 @@
+import type { Database } from 'bun:sqlite'
+
+/** Creates the Better Auth session table for test databases that skip the migrations. */
+export function ensureAuthSessionTable(db: Database): void {
+ db.run(`
+ CREATE TABLE IF NOT EXISTS "session" (
+ id TEXT PRIMARY KEY NOT NULL,
+ expiresAt INTEGER NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ createdAt INTEGER NOT NULL,
+ updatedAt INTEGER NOT NULL,
+ ipAddress TEXT,
+ userAgent TEXT,
+ userId TEXT NOT NULL
+ )
+ `)
+}
+
+/** Inserts a Better Auth session row that expires at the given time. */
+export function insertAuthSession(
+ db: Database,
+ id: string,
+ options: { expiresAt?: number; userId?: string } = {},
+): void {
+ const timestamp = new Date().toISOString()
+ db.prepare(
+ 'INSERT INTO "session" (id, expiresAt, token, createdAt, updatedAt, userId) VALUES (?, ?, ?, ?, ?, ?)',
+ ).run(
+ id,
+ new Date(options.expiresAt ?? Date.now() + 60_000).toISOString(),
+ `token-${id}`,
+ timestamp,
+ timestamp,
+ options.userId ?? 'default',
+ )
+}
diff --git a/backend/test/routes/notifications.test.ts b/backend/test/routes/notifications.test.ts
index 4ac87f84f..ffdb7d071 100644
--- a/backend/test/routes/notifications.test.ts
+++ b/backend/test/routes/notifications.test.ts
@@ -4,6 +4,7 @@ import { Database } from 'bun:sqlite'
import { createNotificationRoutes } from '../../src/routes/notifications'
import { NotificationService } from '../../src/services/notification'
import type { Session } from '../../src/auth'
+import { ensureAuthSessionTable, insertAuthSession } from '../helpers/auth-sessions'
interface NotificationServiceStub {
getVapidPublicKey: ReturnType
@@ -15,6 +16,24 @@ interface NotificationServiceStub {
sendTestNotification: ReturnType
}
+function sessionScopedApp(service: NotificationService, sessionId: string) {
+ const sessionApp = new Hono<{ Variables: { session?: Session['session'] } }>()
+ sessionApp.use('*', async (c, next) => {
+ c.set('session', { id: sessionId } as Session['session'])
+ await next()
+ })
+ sessionApp.route('/', createNotificationRoutes(service))
+ return sessionApp
+}
+
+function subscriptionBody(endpoint: string, deviceName?: string): string {
+ return JSON.stringify({
+ endpoint,
+ keys: { p256dh: 'p256dh-key', auth: 'auth-key' },
+ ...(deviceName ? { deviceName } : {}),
+ })
+}
+
describe('Notification Routes', () => {
let service: NotificationServiceStub
let app: ReturnType
@@ -117,12 +136,7 @@ describe('Notification Routes', () => {
it('saves the session of the device that subscribed', async () => {
service.saveSubscription.mockReturnValue({ id: 3 })
- const sessionApp = new Hono<{ Variables: { session?: Session['session'] } }>()
- sessionApp.use('*', async (c, next) => {
- c.set('session', { id: 'ses_phone' } as Session['session'])
- await next()
- })
- sessionApp.route('/', createNotificationRoutes(service as unknown as NotificationService))
+ const sessionApp = sessionScopedApp(service as unknown as NotificationService, 'ses_phone')
const res = await sessionApp.fetch(new Request('http://localhost/subscribe', {
method: 'POST',
@@ -145,15 +159,27 @@ describe('Notification Routes', () => {
)
})
+ it('returns 401 when the registering session is no longer valid', async () => {
+ service.saveSubscription.mockReturnValue(null)
+
+ const res = await app.fetch(new Request('http://localhost/subscribe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ endpoint: 'https://push.example.com/abc',
+ keys: { p256dh: 'p256dh-key', auth: 'auth-key' },
+ }),
+ }))
+
+ expect(res.status).toBe(401)
+ await expect(res.json()).resolves.toEqual({ error: 'Session is no longer valid' })
+ })
+
it('updates the stored session id and keeps the device name when the same endpoint subscribes again without one', async () => {
const db = new Database(':memory:')
+ ensureAuthSessionTable(db)
const service = new NotificationService(db)
const endpoint = 'https://push.example.com/phone'
- const subscriptionBody = (deviceName?: string) => JSON.stringify({
- endpoint,
- keys: { p256dh: 'p256dh-key', auth: 'auth-key' },
- ...(deviceName ? { deviceName } : {}),
- })
try {
const subscribes: Array<[sessionId: string, deviceName: string | undefined]> = [
@@ -162,17 +188,13 @@ describe('Notification Routes', () => {
]
for (const [sessionId, deviceName] of subscribes) {
- const sessionApp = new Hono<{ Variables: { session?: Session['session'] } }>()
- sessionApp.use('*', async (c, next) => {
- c.set('session', { id: sessionId } as Session['session'])
- await next()
- })
- sessionApp.route('/', createNotificationRoutes(service))
+ insertAuthSession(db, sessionId)
+ const sessionApp = sessionScopedApp(service, sessionId)
const res = await sessionApp.fetch(new Request('http://localhost/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: subscriptionBody(deviceName),
+ body: subscriptionBody(endpoint, deviceName),
}))
expect(res.status).toBe(200)
}
@@ -186,6 +208,39 @@ describe('Notification Routes', () => {
}
})
+ it('keeps the newer session registration when a revoked session saves afterwards', async () => {
+ const db = new Database(':memory:')
+ ensureAuthSessionTable(db)
+ const service = new NotificationService(db)
+ const endpoint = 'https://push.example.com/phone'
+
+ try {
+ insertAuthSession(db, 'ses_new')
+ const newSessionApp = sessionScopedApp(service, 'ses_new')
+ const newSessionRes = await newSessionApp.fetch(new Request('http://localhost/subscribe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: subscriptionBody(endpoint),
+ }))
+ expect(newSessionRes.status).toBe(200)
+
+ const staleSessionApp = sessionScopedApp(service, 'ses_old')
+ const staleSessionRes = await staleSessionApp.fetch(new Request('http://localhost/subscribe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: subscriptionBody(endpoint),
+ }))
+
+ expect(staleSessionRes.status).toBe(401)
+ const rows = db
+ .prepare('SELECT session_id FROM push_subscriptions WHERE endpoint = ?')
+ .all(endpoint)
+ expect(rows).toEqual([{ session_id: 'ses_new' }])
+ } finally {
+ db.close()
+ }
+ })
+
it('returns 400 for invalid subscription data', async () => {
const res = await app.fetch(new Request('http://localhost/subscribe', {
method: 'POST',
diff --git a/backend/test/services/notification-subscriptions.test.ts b/backend/test/services/notification-subscriptions.test.ts
index 5006968ca..e7ccbf4c5 100644
--- a/backend/test/services/notification-subscriptions.test.ts
+++ b/backend/test/services/notification-subscriptions.test.ts
@@ -5,6 +5,7 @@ import {
deletePushSubscriptionsForSession,
ensurePushSubscriptionsTable,
} from '../../src/db/push-subscriptions'
+import { ensureAuthSessionTable, insertAuthSession } from '../helpers/auth-sessions'
const USER_ID = 'user-1'
@@ -50,6 +51,7 @@ describe('push subscription sessions', () => {
beforeEach(() => {
db = new Database(':memory:')
+ ensureAuthSessionTable(db)
service = new NotificationService(db)
})
@@ -58,6 +60,8 @@ describe('push subscription sessions', () => {
})
it('stores the session that registered the subscription', () => {
+ insertAuthSession(db, 'ses_phone')
+
service.saveSubscription(USER_ID, 'https://push.example.com/phone', 'p256dh-key', 'auth-key', 'Phone', 'ses_phone')
expect(sessionIdOf(db, 'https://push.example.com/phone')).toBe('ses_phone')
@@ -71,6 +75,8 @@ describe('push subscription sessions', () => {
it('replaces the session id when the same endpoint subscribes again', () => {
const endpoint = 'https://push.example.com/phone'
+ insertAuthSession(db, 'ses_old')
+ insertAuthSession(db, 'ses_new')
service.saveSubscription(USER_ID, endpoint, 'p256dh-key', 'auth-key', 'Phone', 'ses_old')
service.saveSubscription(USER_ID, endpoint, 'p256dh-key-2', 'auth-key-2', 'Phone', 'ses_new')
@@ -80,10 +86,12 @@ describe('push subscription sessions', () => {
it('keeps the stored device name when the same endpoint subscribes again without one', () => {
const endpoint = 'https://push.example.com/phone'
+ insertAuthSession(db, 'ses_old')
+ insertAuthSession(db, 'ses_new')
service.saveSubscription(USER_ID, endpoint, 'p256dh-key', 'auth-key', 'Phone', 'ses_old')
const resubscribed = service.saveSubscription(USER_ID, endpoint, 'p256dh-key', 'auth-key', undefined, 'ses_new')
- expect(resubscribed.deviceName).toBe('Phone')
+ expect(resubscribed?.deviceName).toBe('Phone')
expect(sessionIdOf(db, endpoint)).toBe('ses_new')
expect(service.getSubscriptions(USER_ID)).toHaveLength(1)
})
@@ -120,6 +128,8 @@ describe('push subscription sessions', () => {
})
it('deletes only the subscriptions of the revoked session', () => {
+ insertAuthSession(db, 'ses_phone')
+ insertAuthSession(db, 'ses_desktop')
service.saveSubscription(USER_ID, 'https://push.example.com/phone', 'p256dh-key', 'auth-key', 'Phone', 'ses_phone')
service.saveSubscription(USER_ID, 'https://push.example.com/desktop', 'p256dh-key', 'auth-key', 'Desktop', 'ses_desktop')
service.saveSubscription(USER_ID, 'https://push.example.com/legacy', 'p256dh-key', 'auth-key', 'Legacy')
@@ -130,4 +140,25 @@ describe('push subscription sessions', () => {
'https://push.example.com/legacy',
])
})
+
+ it('keeps the newer session registration when a revoked session saves afterwards', () => {
+ const endpoint = 'https://push.example.com/phone'
+ insertAuthSession(db, 'ses_new')
+ service.saveSubscription(USER_ID, endpoint, 'p256dh-key', 'auth-key', 'Phone', 'ses_new')
+
+ const staleSave = service.saveSubscription(USER_ID, endpoint, 'p256dh-key', 'auth-key', 'Phone', 'ses_old')
+
+ expect(staleSave).toBeNull()
+ expect(sessionIdOf(db, endpoint)).toBe('ses_new')
+ expect(service.getSubscriptions(USER_ID)).toHaveLength(1)
+ })
+
+ it('does not store a subscription whose auth session has expired', () => {
+ insertAuthSession(db, 'ses_expired', { expiresAt: Date.now() - 1_000 })
+
+ const saved = service.saveSubscription(USER_ID, 'https://push.example.com/phone', 'p256dh-key', 'auth-key', 'Phone', 'ses_expired')
+
+ expect(saved).toBeNull()
+ expect(endpointsOf(db)).toEqual([])
+ })
})