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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

# The account backup bucket and its 400-day expiry, the same way.
- name: Ensure the account backup bucket
run: node scripts/r2-lifecycle.mjs ww-account-backups
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

# Accounts schema (src/accounts/schema.ts) before the code that needs it.
# Already-applied migrations are skipped. The API token needs D1: Edit.
- name: Apply accounts database migrations
Expand Down
68 changes: 68 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,74 @@ curl -sX POST "$BASE/admin/accounts/status" -H "x-ww-admin-token: $TOKEN" \
of the account's inboxes. If `inboxesFailed` is above 0, send the same
request again.

### Handoff and backup

Moving the account to another phone and backing it up (witness-work ADR 0026;
"Handoff", "Backup", and "Recovery key" in its `docs/accounts-protocol.md`).
Shared helpers for the self-signed ops are in `src/accounts/signed.ts`.

- **Handoff** (Set Up Another Device): `POST /accounts/v1/handoff/open|claim|
status|send|receive|done|cancel`, code in `src/accounts/handoff.ts`. The old
phone's ops (`open`, `status`, `send`, and `cancel` with `p`/`s`) are signed
by the account key like `account/register` and need a registered account
(`unknown_account` otherwise; a suspended one may still hand off). The new
phone's (`claim`, `receive`, `done`, and `cancel` without `p`) are plain JSON
carrying `claimToken`. One `AccountHandoff` SQLite DO per `channelId`
(binding `ACCOUNT_HANDOFF`, migration `v7`, `src/accounts/handoffDO.ts`)
holds the opening account, `verifier = b64u(SHA-256(claimToken))`, the new
phone's temporary key, and the sealed hello (≤ 2,048 chars) and key
(≤ 1,024; over either is `too_large`). Status runs `open → claimed → sent →
done`, or `cancelled` from any of them. The first valid claim wins and an
identical repeat gets `ok`; five wrong tokens (on any new-phone op) burn the
channel to `cancelled`. `done` and `cancel` clear the blobs at once, and the
alarm deletes everything 10 minutes after `open`; expired and unknown
channels are `not_found`. Another account as the old phone is 403
`forbidden`. A wrong token is 401 `bad_signature`, and an op out of order is
`conflict`. Limits: `open` uses the Buddies register edge tier plus 20 per
account a rolling day (`BUDDY_REGISTRATION_QUOTA` under a hashed
`handoff-open` name; `admit` takes the limit), and every other op the read
tier.
- **Backup**: `POST /accounts/v1/backup/put|list|get|delete`, code in
`src/accounts/backup.ts`. All four are signed; `put` sends its envelope in
`x-ww-p`/`x-ww-s` (Sentry drops both) and the blob as the raw body. R2
bucket `ACCOUNT_BACKUPS` (`ww-account-backups` prod,
`ww-account-backups-dev` dev), one object per device slot at
`v1/<accountId>/<slotId>` with `customMetadata.uploadedAt` (epoch ms). `put`
refuses a declared `Content-Length` or signed `size` over 20 MiB (413
`too_large`) before reading the body, then reads at most `size` bytes and
checks length and SHA-256 (`bad_request`). 8 slots an account (429 `limit`)
and one `put` a slot a minute (429 `rate_limited` with `Retry-After`), both
read from an R2 prefix listing, so two racing puts can each pass. `put`
counts against the Buddies photo-upload edge tier, the rest the read tier.
`delete` without `slotId` deletes every slot. Suspended accounts can still
back up. Without the bucket or `ACCOUNTS_DB`, every op is 503 `disabled`.
- **`GET /h`** is the page a camera opens for a handoff QR code
(`/h#1<…>`): install or open WitnessWork on the new phone and scan from
inside the app. Localized like `/c` (`?lang=`, then `Accept-Language`;
strings in `src/accounts/handoffPageLocales.ts`), no script. The AASA
matches `/h#1…`; `assetlinks.json` already covers the whole host.
- **Privacy**: handoff and backup log only the op name. Never log or report
bodies, headers, keys, tokens, account ids, channel ids, or slot ids.

The buckets need to exist **before the first deploy that carries the
`ACCOUNT_BACKUPS` binding**. Every deploy (`pnpm run deploy`, `deploy:dev`,
and the tag deploy) runs `scripts/r2-lifecycle.mjs` for both buckets first,
which creates a missing bucket and sets its rules: `v1/` objects deleted 400
days after their last upload (each `put` rewrites the object, restarting its
age), plus R2's default multipart-abort rule. By hand:

```bash
node scripts/r2-lifecycle.mjs ww-account-backups-dev # dev: create + 400-day rule
node scripts/r2-lifecycle.mjs ww-account-backups # prod
node scripts/r2-lifecycle.mjs ww-account-backups --check # read-only
# The same with wrangler alone:
wrangler r2 bucket create ww-account-backups
wrangler r2 bucket lifecycle add ww-account-backups account-backups-expiry v1/ --expire-days 400
```

The `v7` migration (`AccountHandoff`) applies on the next deploy of each
worker.

## App Store ratings (paywall social proof)

`GET /app-store/ratings` returns `{averageRating, ratingCount, countryCount,
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"main": "src/index.ts",
"scripts": {
"dev": "wrangler dev",
"deploy": "node scripts/r2-lifecycle.mjs ww-buddy-blobs && wrangler deploy --outdir dist --var SENTRY_RELEASE:$(sentry-cli releases propose-version) && pnpm run sentry:sourcemaps",
"deploy:dev": "node scripts/r2-lifecycle.mjs ww-buddy-blobs-dev && wrangler deploy --env dev --outdir dist --var SENTRY_RELEASE:$(sentry-cli releases propose-version) && pnpm run sentry:sourcemaps",
"deploy": "node scripts/r2-lifecycle.mjs ww-buddy-blobs && node scripts/r2-lifecycle.mjs ww-account-backups && wrangler deploy --outdir dist --var SENTRY_RELEASE:$(sentry-cli releases propose-version) && pnpm run sentry:sourcemaps",
"deploy:dev": "node scripts/r2-lifecycle.mjs ww-buddy-blobs-dev && node scripts/r2-lifecycle.mjs ww-account-backups-dev && wrangler deploy --env dev --outdir dist --var SENTRY_RELEASE:$(sentry-cli releases propose-version) && pnpm run sentry:sourcemaps",
"sentry:sourcemaps": "sentry-cli releases new $(sentry-cli releases propose-version) && sentry-cli sourcemaps upload --release=$(sentry-cli releases propose-version) --strip-prefix 'dist/..' dist",
"test": "vitest run",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
Expand Down
98 changes: 64 additions & 34 deletions scripts/r2-lifecycle.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
#!/usr/bin/env node
/**
* Makes sure a Buddies photo bucket exists and carries its lifecycle rules,
* so a deploy never depends on someone having run them by hand.
* Makes sure an R2 bucket exists and carries its lifecycle rules, so a deploy
* never depends on someone having run them by hand.
*
* node scripts/r2-lifecycle.mjs ww-buddy-blobs # create if missing, set the rules
* node scripts/r2-lifecycle.mjs ww-buddy-blobs --check # read-only; exit 1 if anything is off
* node scripts/r2-lifecycle.mjs ww-account-backups # the same for account backups
*
* Buddies photo buckets (`ww-buddy-blobs*`): the backstop deletes any object a
* day past the longest a photo can live (`BUDDIES_LIMITS.maxBlobLifetimeMs`,
* 90 days). R2 counts an object's age from its last write, and every upload
* rewrites the object, so the rule never touches a live photo; it only catches
* objects the relay lost track of.
*
* Account backup buckets (`ww-account-backups*`): a slot not uploaded for 400
* days is deleted (witness-work ADR 0026). Every `backup/put` rewrites the
* slot's object, which restarts its age.
*
* The backstop deletes any object a day past the longest a photo can live
* (`BUDDIES_LIMITS.maxBlobLifetimeMs`, 90 days). R2 counts an object's age from
* its last write, and every upload rewrites the object, so the rule never
* touches a live photo; it only catches objects the relay lost track of.
* `lifecycle set` replaces every rule on the bucket, so R2's default
* multipart-abort rule is restated here too.
*
Expand All @@ -30,26 +37,44 @@ export const BACKSTOP_DAYS = 90 + 1
export const BACKSTOP_RULE_ID = 'buddy-blobs-backstop'
export const BLOB_PREFIX = 'v1/'

export const lifecycleRules = () => ({
rules: [
{
id: BACKSTOP_RULE_ID,
enabled: true,
conditions: { prefix: BLOB_PREFIX },
deleteObjectsTransition: {
condition: { type: 'Age', maxAge: BACKSTOP_DAYS * DAY_SECONDS },
/** Account backup slots: deleted 400 days after their last upload. */
export const BACKUP_EXPIRY_DAYS = 400
export const BACKUP_RULE_ID = 'account-backups-expiry'

/** Each bucket family's expiry rule, by bucket name prefix. */
export const BUCKET_RULES = [
{ bucketPrefix: 'ww-buddy-blobs', id: BACKSTOP_RULE_ID, days: BACKSTOP_DAYS },
{ bucketPrefix: 'ww-account-backups', id: BACKUP_RULE_ID, days: BACKUP_EXPIRY_DAYS },
]

/** The expiry rule for `bucket`, or null for a bucket this script doesn't know. */
export const ruleFor = (bucket = 'ww-buddy-blobs') =>
BUCKET_RULES.find((rule) => bucket.startsWith(rule.bucketPrefix)) ?? null

export const lifecycleRules = (bucket = 'ww-buddy-blobs') => {
const rule = ruleFor(bucket)
if (!rule) throw new Error(`no lifecycle rule for bucket ${bucket}`)
return {
rules: [
{
id: rule.id,
enabled: true,
conditions: { prefix: BLOB_PREFIX },
deleteObjectsTransition: {
condition: { type: 'Age', maxAge: rule.days * DAY_SECONDS },
},
},
},
{
id: 'Default Multipart Abort Rule',
enabled: true,
conditions: { prefix: '' },
abortMultipartUploadsTransition: {
condition: { type: 'Age', maxAge: 7 * DAY_SECONDS },
{
id: 'Default Multipart Abort Rule',
enabled: true,
conditions: { prefix: '' },
abortMultipartUploadsTransition: {
condition: { type: 'Age', maxAge: 7 * DAY_SECONDS },
},
},
},
],
})
],
}
}

const root = join(dirname(fileURLToPath(import.meta.url)), '..')

Expand Down Expand Up @@ -86,16 +111,16 @@ const bucketExists = (bucket) => {
return parseBlocks(listed.output).some((fields) => fields.name === bucket)
}

/** Whether the bucket's rules include the backstop at the right age. */
const backstopSet = (bucket) => {
/** Whether the bucket's rules include its expiry rule at the right age. */
const backstopSet = (bucket, expected) => {
const listed = wrangler(['r2', 'bucket', 'lifecycle', 'list', bucket])
if (!listed.ok) throw new Error(`wrangler r2 bucket lifecycle list failed:\n${listed.output}`)
return parseBlocks(listed.output).some(
(rule) =>
rule.name === BACKSTOP_RULE_ID &&
rule.name === expected.id &&
rule.enabled === 'Yes' &&
rule.prefix === BLOB_PREFIX &&
rule.action?.includes(`Expire objects after ${BACKSTOP_DAYS} days`)
rule.action?.includes(`Expire objects after ${expected.days} days`)
)
}

Expand All @@ -106,6 +131,11 @@ const main = () => {
console.error('usage: node scripts/r2-lifecycle.mjs <bucket> [--check]')
process.exit(2)
}
const expected = ruleFor(bucket)
if (!expected) {
console.error(`r2-lifecycle: no rule for ${bucket} (known: ${BUCKET_RULES.map((rule) => `${rule.bucketPrefix}*`).join(', ')})`)
process.exit(2)
}

if (!bucketExists(bucket)) {
if (check) {
Expand All @@ -118,25 +148,25 @@ const main = () => {
}

if (check) {
if (!backstopSet(bucket)) {
console.error(`r2-lifecycle: ${bucket} lacks ${BACKSTOP_RULE_ID} (${BLOB_PREFIX}, ${BACKSTOP_DAYS} days)`)
if (!backstopSet(bucket, expected)) {
console.error(`r2-lifecycle: ${bucket} lacks ${expected.id} (${BLOB_PREFIX}, ${expected.days} days)`)
process.exit(1)
}
console.log(`r2-lifecycle: ${bucket} ok (${BACKSTOP_RULE_ID}, ${BACKSTOP_DAYS} days)`)
console.log(`r2-lifecycle: ${bucket} ok (${expected.id}, ${expected.days} days)`)
return
}

const dir = mkdtempSync(join(tmpdir(), 'r2-lifecycle-'))
try {
const file = join(dir, 'lifecycle.json')
writeFileSync(file, JSON.stringify(lifecycleRules(), null, 2))
writeFileSync(file, JSON.stringify(lifecycleRules(bucket), null, 2))
const set = wrangler(['r2', 'bucket', 'lifecycle', 'set', bucket, '--file', file, '--force'])
if (!set.ok) throw new Error(`setting ${bucket}'s lifecycle failed:\n${set.output}`)
} finally {
rmSync(dir, { recursive: true, force: true })
}
if (!backstopSet(bucket)) throw new Error(`${bucket}'s lifecycle rules did not take`)
console.log(`r2-lifecycle: ${bucket} ok (${BACKSTOP_RULE_ID}, ${BACKSTOP_DAYS} days)`)
if (!backstopSet(bucket, expected)) throw new Error(`${bucket}'s lifecycle rules did not take`)
console.log(`r2-lifecycle: ${bucket} ok (${expected.id}, ${expected.days} days)`)
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
Expand Down
23 changes: 23 additions & 0 deletions scripts/r2-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { expect, it } from 'vitest'
import { BUDDIES_LIMITS, buddyBlobKey } from '../src/buddies/contracts'
import { backupObjectKey } from '../src/accounts/contracts'
import {
BACKSTOP_DAYS,
BACKSTOP_RULE_ID,
BACKUP_EXPIRY_DAYS,
BACKUP_RULE_ID,
BLOB_PREFIX,
lifecycleRules,
parseBlocks,
ruleFor,
} from './r2-lifecycle.mjs'

const DAY_MS = 24 * 60 * 60 * 1000
Expand All @@ -26,6 +30,25 @@ it('deletes objects a day past the longest a blob can live', () => {
expect(buddyBlobKey('inbox', 'blob').startsWith(BLOB_PREFIX)).toBe(true)
})

it('deletes account backup slots 400 days after their last upload', () => {
expect(BACKUP_EXPIRY_DAYS).toBe(400)
for (const bucket of ['ww-account-backups', 'ww-account-backups-dev']) {
const [expiry, multipart] = lifecycleRules(bucket).rules
expect(expiry).toEqual({
id: BACKUP_RULE_ID,
enabled: true,
conditions: { prefix: BLOB_PREFIX },
deleteObjectsTransition: {
condition: { type: 'Age', maxAge: 400 * 24 * 60 * 60 },
},
})
expect(multipart.id).toBe('Default Multipart Abort Rule')
}
expect(lifecycleRules('ww-buddy-blobs-dev').rules[0].id).toBe(BACKSTOP_RULE_ID)
expect(ruleFor('some-other-bucket')).toBeNull()
expect(backupObjectKey('account', 'slot').startsWith(BLOB_PREFIX)).toBe(true)
})

it('reads wrangler’s labelled blocks', () => {
const output = [
"Listing lifecycle rules for bucket 'ww-buddy-blobs'...",
Expand Down
Loading
Loading