s3nd

Move to a new device

A code on the old phone, typed into the new one. The whole database follows.

The flagship case. The user has a new phone, the app on the old one holds everything, and there is no account to sign into. They read a code off one screen and type it into the other.

The server

// lib/store.ts
import { createBucket } from 's3nd'

export const store = createBucket({
  bucket: process.env.S3_BUCKET,
  prefix: 'transfers',
  maxSize: 4 * 1024 * 1024,
})

export const SCHEMA_VERSION = 3
// app/api/sync/route.ts
import { store, SCHEMA_VERSION } from '@/lib/store'

export async function POST(request: Request) {
  const state = await request.json()
  const code = store.codes.create()

  const result = await store.putSnapshot(code, state, {
    app: 'notes',
    version: SCHEMA_VERSION,
    device: request.headers.get('user-agent') ?? undefined,
    expiresIn: 60 * 60,
    ifAbsent: true,
  })

  return Response.json({ code, expiresAt: result.expiresAt })
}
// app/api/sync/[code]/route.ts
import { isS3ndError } from 's3nd'

import { store, SCHEMA_VERSION } from '@/lib/store'

export async function GET(_request: Request, { params }: { params: Promise<{ code: string }> }) {
  const { code } = await params

  let normalized: string

  try {
    normalized = store.codes.normalize(code)
  } catch (error) {
    if (isS3ndError(error) && error.code === 'INVALID_SYNC_CODE') {
      return Response.json({ error: 'That does not look like a code' }, { status: 400 })
    }

    throw error
  }

  const snapshot = await store.getSnapshot(normalized, { maxVersion: SCHEMA_VERSION })

  if (!snapshot) {
    return Response.json({ error: 'Unknown or expired code' }, { status: 404 })
  }

  return Response.json({
    data: snapshot.data,
    createdAt: snapshot.createdAt,
    device: snapshot.device,
    version: snapshot.version,
  })
}

Returning createdAt and device is what turns a scary prompt into an informed one. The receiving device can say "Restore from Pixel 8, saved 4 minutes ago?" instead of "Restore?".

Rate-limit the lookup

The code is the only thing standing between a stranger and that snapshot. Without a limit on the GET route, an attacker gets unlimited guesses; with one, forty bits stops being brute-forceable in any useful time. Use whatever your platform gives you — a KV counter keyed by IP is enough:

const attempts = await kv.incr(`sync-attempts:${ip}`)
if (attempts === 1) await kv.expire(`sync-attempts:${ip}`, 60)
if (attempts > 10) return new Response('Too many attempts', { status: 429 })

Burn the code after use

The transfer is over once the new device has the data. Nothing should still answer to that code:

// app/api/sync/[code]/route.ts
export async function DELETE(_request: Request, { params }: { params: Promise<{ code: string }> }) {
  const { code } = await params

  await store.delete(store.codes.normalize(code))

  return new Response(null, { status: 204 })
}

Have the client call it after a successful import. The expiresIn is the backstop for when it does not — a closed tab, a failed import, a user who changed their mind.

The client

export async function sendToOtherDevice(): Promise<string> {
  const state = await exportDatabase()

  const response = await fetch('/api/sync', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(state),
  })

  if (!response.ok) throw new Error('Could not prepare the transfer')

  return (await response.json()).code
}

export async function restoreFromCode(typed: string) {
  const response = await fetch(`/api/sync/${encodeURIComponent(typed.trim())}`)

  if (!response.ok) throw new Error((await response.json()).error)

  const { data, createdAt, device } = await response.json()

  await importDatabase(data) // replaces this device's state

  await fetch(`/api/sync/${encodeURIComponent(typed.trim())}`, { method: 'DELETE' })

  return { createdAt: new Date(createdAt), device }
}

Confirm before importing. A restore replaces what is on the receiving device. Show what is about to arrive, and let the user say no — especially since the most common mistake is running the restore on the phone that already had the data.

A complete version, with a real IndexedDB store behind exportDatabase and importDatabase, is in examples/indexeddb-sync.

Show the code well

The code is read by a human off one screen and typed into another, so present it that way: large, monospaced, grouped in fours, with a copy button and the expiry visible.

<output className="code">{code.match(/.{1,4}/g)?.join(' ')}</output>
<p>Expires in an hour. Type it into the app on your other device.</p>

A QR code encoding the deep link removes the typing entirely on phones — and the typed code stays as the fallback for when the camera is not an option.

On this page