From 6cfc3454c16e42566317f5b540647ae41ea5b7f9 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Thu, 29 Oct 2026 15:59:40 -0500 Subject: [PATCH] feat(push): add the push relay that sends the phone app its boxes' notifications Apple delivers a notification only from a server holding the app's push key, which a box can't have. The relay, for push.berthd.app, seals a phone's device token into a key only it can open, takes a box's notice with that key, and makes the notification itself from a few checked fields. It keeps nothing, and logs no token, key or text. Built with esbuild into Vercel's Build Output API, checked in CI. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 40 +++++ AGENTS.md | 1 + push/.gitignore | 2 + push/README.md | 58 ++++++++ push/build.mjs | 46 ++++++ push/package.json | 19 +++ push/pnpm-lock.yaml | 310 +++++++++++++++++++++++++++++++++++++++ push/pnpm-workspace.yaml | 2 + push/src/apns.ts | 134 +++++++++++++++++ push/src/node.ts | 39 +++++ push/src/ratelimit.ts | 34 +++++ push/src/relay.ts | 106 +++++++++++++ push/src/schema.ts | 69 +++++++++ push/src/seal.ts | 70 +++++++++ push/test/relay.test.ts | 244 ++++++++++++++++++++++++++++++ push/tsconfig.json | 17 +++ push/vercel.json | 6 + 17 files changed, 1197 insertions(+) create mode 100644 push/.gitignore create mode 100644 push/README.md create mode 100644 push/build.mjs create mode 100644 push/package.json create mode 100644 push/pnpm-lock.yaml create mode 100644 push/pnpm-workspace.yaml create mode 100644 push/src/apns.ts create mode 100644 push/src/node.ts create mode 100644 push/src/ratelimit.ts create mode 100644 push/src/relay.ts create mode 100644 push/src/schema.ts create mode 100644 push/src/seal.ts create mode 100644 push/test/relay.test.ts create mode 100644 push/tsconfig.json create mode 100644 push/vercel.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28e1b1495..6947e9645 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -291,3 +291,43 @@ jobs: - run: pnpm install --frozen-lockfile # The pages are the repository's docs/ folder; a broken page fails here. - run: pnpm build + + push: + # Only what was asked for: see "on" above. + if: github.event_name != 'pull_request' || (github.event.action != 'labeled' || github.event.label.name == 'run-ci') && contains(github.event.pull_request.labels.*.name, 'run-ci') + runs-on: ${{ (vars.SELF_HOSTED == 'true' && github.event_name == 'push' && github.actor == github.repository_owner) && fromJSON('["self-hosted","omarchy"]') || 'ubuntu-latest' }} + defaults: + run: + working-directory: push + steps: + # omarchy runs only the owner's pushes; its job-started hook + # (ci/runner/job-started.sh) refuses anything else too. + - name: Only the owner's pushes run on a self-hosted runner + if: runner.environment == 'self-hosted' + working-directory: ${{ runner.temp }} + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + OWNER: ${{ github.repository_owner }} + EVENT: ${{ github.event_name }} + run: | + if [ "$ACTOR" != "$OWNER" ] || [ "$TRIGGERING_ACTOR" != "$OWNER" ] || [ "$EVENT" != push ]; then + echo "::error::$EVENT by $ACTOR may not run on a self-hosted runner" + exit 1 + fi + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 11 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + # A self-hosted runner keeps its pnpm store between jobs. + cache: ${{ runner.environment == 'github-hosted' && 'pnpm' || '' }} + cache-dependency-path: push/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + # The push relay (push/README.md): its checks, and the function Vercel + # deploys. + - run: pnpm typecheck + - run: pnpm test + - run: pnpm build diff --git a/AGENTS.md b/AGENTS.md index e50f6efac..865e23a3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ Shipyard (formerly Berth) runs coding agents on your own machines. This repo has - `cmd/berthd`, `internal/box`: the box daemon. It runs agents, terminals and services in its own tmux server. - `cmd/berth`, `internal/agent`: the laptop CLI and agent (UI API on 1378, proxy on 1377, phone on 1379). - `app/`: the Tauri 2 + React desktop app (coss-ui, 23 themes). `app/e2e` holds the Playwright suite on mock fixtures. +- `push/`: the push relay at `push.berthd.app` (Vercel), which sends the phone app the notifications its boxes ask for. `cd push && pnpm typecheck && pnpm test && pnpm build`. - `docs/`: the docs as MDX, built by `docs-site/` (Fumadocs). `site/` is the landing page and `install.sh`. **Names:** "Shipyard" is the brand in everything people read. `berth`, `berthd`, `~/.berth`, `BERTH_*`, `berth://`, `dev.berth.app`, `berth.prefs` and the `berth-*` skill ids are identifiers. Don't rename them; a later change will, with migrations. diff --git a/push/.gitignore b/push/.gitignore new file mode 100644 index 000000000..7980d25f7 --- /dev/null +++ b/push/.gitignore @@ -0,0 +1,2 @@ +.vercel/ +node_modules/ diff --git a/push/README.md b/push/README.md new file mode 100644 index 000000000..b76cddf70 --- /dev/null +++ b/push/README.md @@ -0,0 +1,58 @@ +# Shipyard's push relay + +Sends a Shipyard phone app the notifications its boxes ask for, at +`push.berthd.app`. Apple delivers a notification only from a server holding +the app's push key, which a box can't have, so boxes ask this relay. How it +fits together is in [the phone guide](../docs/guides/phone.mdx); the box's side is +`internal/box/phonev2.go`. + +It keeps nothing. A phone app registers its device token and gets back a +**key**, the token sealed (AES-256-GCM) so only the relay can open it. The +app gives the key to each box it pairs with, and a box sends it with every +notice. A notice is a few short fields (`src/schema.ts`), and the relay makes +the notification from them itself, so a box, or anyone holding a phone's +key, can put nothing else in it. + +| Route | | +| --- | --- | +| `POST /v1/register` | `{ platform: "ios", token, env: "production" \| "sandbox", bundle }` → `{ key }` | +| `POST /v1/send` | `Authorization: Bearer `, a notice → `{ sent: true }`; 401 for a key that isn't this relay's, 410 when the phone no longer takes notifications (the box forgets it), 429, 502 for Apple's trouble | +| `GET /v1/health` | `{ ok: true }` | + +Logs say what happened and to which key (the first characters of its hash), +never a body, a token or a key. + +## Working on it + +```sh +pnpm install --frozen-lockfile +pnpm typecheck && pnpm test && pnpm build +``` + +`pnpm build` bundles `src/node.ts` with esbuild into Vercel's Build Output +API layout (`.vercel/output`): one Node function that every route goes to. + +## Deploying + +A Vercel project with this folder as its root directory, and the domain +`push.berthd.app`. Its environment variables, all marked sensitive: + +| Variable | | +| --- | --- | +| `APNS_KEY_P8` | The APNs auth key's PEM (`AuthKey_XXXXXXXXXX.p8`), from Apple Developer → Keys. `\n` in it is read as a newline | +| `APNS_KEY_ID` | That key's id | +| `APNS_TEAM_ID` | The Apple Developer team's id | +| `APNS_TOPIC` | The app's bundle id, `dev.berth.phone` | +| `SEAL_KEYS` | `{"": ""}`: the keys phone keys are sealed with | +| `SEAL_KID` | Which of them seals new keys | + +Make a sealing key with `openssl rand -base64 32`. To replace one, add the +new key to `SEAL_KEYS` beside the old, point `SEAL_KID` at it, and keep the +old one: phones' keys sealed with it still open. Remove it only once every +phone has registered again (the app does whenever its device token changes, +or a box answers 401). + +The relay limits each key to 30 notices a minute and 300 a day, and each +address to 20 registrations an hour, in each instance's memory. For a limit +that holds across instances, add Vercel Firewall rate limits on the project: +`/v1/register` at 20 an hour per IP, and `/v1/send` at 120 a minute per IP. diff --git a/push/build.mjs b/push/build.mjs new file mode 100644 index 000000000..f8434dd17 --- /dev/null +++ b/push/build.mjs @@ -0,0 +1,46 @@ +// Builds the relay as one Vercel function, in the Build Output API's layout +// (.vercel/output), so the deploy is exactly what this bundles: every route +// goes to it. +// +// node build.mjs +import { build } from "esbuild"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const out = join(import.meta.dirname, ".vercel", "output"); +const fn = join(out, "functions", "relay.func"); +await rm(out, { recursive: true, force: true }); +await mkdir(fn, { recursive: true }); + +await build({ + entryPoints: [join(import.meta.dirname, "src", "node.ts")], + outfile: join(fn, "index.mjs"), + bundle: true, + platform: "node", + target: "node22", + format: "esm", + minify: false, + sourcemap: false, + legalComments: "none", +}); + +await writeFile(join(fn, ".vc-config.json"), JSON.stringify({ runtime: "nodejs22.x", handler: "index.mjs", launcherType: "Nodejs", maxDuration: 30 }, null, 2)); +await writeFile( + join(out, "config.json"), + JSON.stringify( + { + version: 3, + routes: [ + { + src: "/(.*)", + headers: { "strict-transport-security": "max-age=63072000; includeSubDomains; preload", "x-robots-tag": "noindex" }, + continue: true, + }, + { src: "/(.*)", dest: "/relay" }, + ], + }, + null, + 2, + ), +); +console.log(`built ${fn}`); diff --git a/push/package.json b/push/package.json new file mode 100644 index 000000000..2c30e378b --- /dev/null +++ b/push/package.json @@ -0,0 +1,19 @@ +{ + "name": "berth-push-relay", + "private": true, + "description": "Shipyard's push relay: sends a phone app the notifications its boxes ask for. Deployed to push.berthd.app.", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "node build.mjs", + "typecheck": "tsc --noEmit", + "test": "node --experimental-strip-types --test 'test/**/*.test.ts'" + }, + "devDependencies": { + "@types/node": "^26.6.6", + "esbuild": "^0.25.12", + "typescript": "~6.0.3" + } +} diff --git a/push/pnpm-lock.yaml b/push/pnpm-lock.yaml new file mode 100644 index 000000000..f8451f7a6 --- /dev/null +++ b/push/pnpm-lock.yaml @@ -0,0 +1,310 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^26.6.6 + version: 26.6.6 + esbuild: + specifier: ^0.25.12 + version: 0.25.12 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/node@26.6.6': + resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@types/node@26.6.6': + dependencies: + undici-types: 8.9.0 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + typescript@6.0.3: {} + + undici-types@8.9.0: {} diff --git a/push/pnpm-workspace.yaml b/push/pnpm-workspace.yaml new file mode 100644 index 000000000..5ed0b5af0 --- /dev/null +++ b/push/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/push/src/apns.ts b/push/src/apns.ts new file mode 100644 index 000000000..ead02a34f --- /dev/null +++ b/push/src/apns.ts @@ -0,0 +1,134 @@ +import { createHash, createPrivateKey, sign, type KeyObject } from "node:crypto"; +import { connect, constants, type ClientHttp2Session } from "node:http2"; + +import type { Notice } from "./schema.ts"; + +// Apple's push service, with token-based auth: a JWT signed with the +// relay's .p8 key, good for an hour (Apple refuses one older, and one +// renewed more often than every 20 minutes), sent over HTTP/2. + +export interface ApnsConfig { + // The .p8 key's PEM, its id, and the team it belongs to. + key: string; + keyId: string; + teamId: string; + // The app's bundle id, which is APNs' topic. + topic: string; +} + +export type Outcome = + | { ok: true } + // The phone no longer takes notifications from this app: the box forgets it. + | { ok: false; gone: true; reason: string } + | { ok: false; gone: false; status: number; reason: string }; + +// One request to APNs, as Transport sends it; replaced in tests. +export interface ApnsRequest { + host: string; + path: string; + headers: Record; + body: string; +} + +export type Transport = (req: ApnsRequest) => Promise<{ status: number; body: string }>; + +export const HOSTS = { production: "https://api.push.apple.com", sandbox: "https://api.sandbox.push.apple.com" } as const; + +export class Apns { + private signing?: KeyObject; + private jwt?: { token: string; at: number }; + private config: ApnsConfig; + private transport: Transport; + private now: () => number; + + constructor(config: ApnsConfig, transport: Transport = http2Transport(), now: () => number = Date.now) { + this.config = config; + this.transport = transport; + this.now = now; + } + + // providerToken is the JWT, made afresh every 50 minutes. + providerToken(fresh = false): string { + const at = Math.floor(this.now() / 1000); + if (!fresh && this.jwt && at - this.jwt.at < 50 * 60) return this.jwt.token; + this.signing ??= createPrivateKey(this.config.key); + const head = Buffer.from(JSON.stringify({ alg: "ES256", kid: this.config.keyId })).toString("base64url"); + const claims = Buffer.from(JSON.stringify({ iss: this.config.teamId, iat: at })).toString("base64url"); + const sig = sign("sha256", Buffer.from(`${head}.${claims}`), { key: this.signing, dsaEncoding: "ieee-p1363" }).toString("base64url"); + this.jwt = { token: `${head}.${claims}.${sig}`, at }; + return this.jwt.token; + } + + async send(device: { token: string; env: "production" | "sandbox" }, n: Notice): Promise { + const req = (fresh: boolean): ApnsRequest => ({ + host: HOSTS[device.env], + path: `/3/device/${device.token}`, + headers: { + authorization: `bearer ${this.providerToken(fresh)}`, + "apns-topic": this.config.topic, + "apns-push-type": "alert", + // Someone waiting on you is now; a finished turn can wait for the + // phone's own good time. + "apns-priority": n.state === "waiting" ? "10" : "5", + "apns-collapse-id": createHash("sha256").update(n.collapse).digest("hex").slice(0, 32), + // Worth delivering for a day, not after. + "apns-expiration": String(Math.floor(this.now() / 1000) + 24 * 3600), + "content-type": "application/json", + }, + body: JSON.stringify(payload(n)), + }); + let res = await this.transport(req(false)); + if (res.status === 403 && reasonOf(res.body) === "ExpiredProviderToken") res = await this.transport(req(true)); + if (res.status === 200) return { ok: true }; + const reason = reasonOf(res.body); + if (res.status === 410 || reason === "BadDeviceToken" || reason === "Unregistered" || reason === "DeviceTokenNotForTopic") return { ok: false, gone: true, reason }; + return { ok: false, gone: false, status: res.status, reason }; + } +} + +// payload is the notification itself, made here from a notice's few +// fields: a box cannot add any of its own. +export function payload(n: Notice) { + return { + aps: { alert: { title: n.title, body: n.body }, sound: "default", "thread-id": n.kind === "run" ? `${n.box}/run/${n.run}` : `${n.box}/${n.session ?? ""}` }, + berth: { v: 1, box: n.box, kind: n.kind, ...(n.session ? { session: n.session } : {}), ...(n.run ? { run: n.run } : {}) }, + }; +} + +const reasonOf = (body: string): string => { + try { + return String((JSON.parse(body) as { reason?: string }).reason ?? ""); + } catch { + return ""; + } +}; + +// http2Transport keeps one connection per APNs host, as Apple asks, and +// makes a new one when it closes. +export function http2Transport(): Transport { + const sessions = new Map(); + const session = (host: string) => { + const s = sessions.get(host); + if (s && !s.closed && !s.destroyed) return s; + const fresh = connect(host); + fresh.on("error", () => sessions.delete(host)); + fresh.on("close", () => sessions.delete(host)); + // Not kept alive for the connection alone: a function may freeze. + fresh.unref(); + sessions.set(host, fresh); + return fresh; + }; + return (r) => + new Promise((resolve, reject) => { + const stream = session(r.host).request({ [constants.HTTP2_HEADER_METHOD]: "POST", [constants.HTTP2_HEADER_PATH]: r.path, ...r.headers }); + stream.setTimeout(10_000, () => stream.close(constants.NGHTTP2_CANCEL)); + let status = 0; + let body = ""; + stream.on("response", (h) => (status = Number(h[constants.HTTP2_HEADER_STATUS]))); + stream.setEncoding("utf8"); + stream.on("data", (d: string) => (body += d)); + stream.on("end", () => resolve({ status, body })); + stream.on("error", reject); + stream.end(r.body); + }); +} diff --git a/push/src/node.ts b/push/src/node.ts new file mode 100644 index 000000000..f16a1b780 --- /dev/null +++ b/push/src/node.ts @@ -0,0 +1,39 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +import { fromEnv, MAX_BODY, relay } from "./relay.ts"; + +// The Vercel function: Node's request turned into a web Request for the +// relay, and its Response written back. build.mjs bundles this file. + +let handle: ReturnType | undefined; + +export default async function (req: IncomingMessage, res: ServerResponse) { + try { + handle ??= relay(fromEnv(process.env)); + } catch (err) { + console.error(`relay misconfigured: ${(err as Error).message}`); + res.writeHead(503, { "content-type": "application/json" }).end(JSON.stringify({ error: "the relay isn't set up" })); + return; + } + // Read a little past the limit, so the relay can tell a body is too big. + const chunks: Buffer[] = []; + let size = 0; + for await (const c of req) { + size += (c as Buffer).length; + if (size > MAX_BODY + 1) break; + chunks.push(c as Buffer); + } + const headers = new Headers(); + for (const [k, v] of Object.entries(req.headers)) if (typeof v === "string") headers.set(k, v); + const method = req.method ?? "GET"; + const request = new Request(`https://${req.headers.host ?? "push.berthd.app"}${req.url ?? "/"}`, { + method, + headers, + body: method === "GET" || method === "HEAD" ? undefined : Buffer.concat(chunks), + }); + // Vercel sets x-forwarded-for to the caller's address, first. + const ip = String(req.headers["x-forwarded-for"] ?? "").split(",")[0].trim(); + const response = await handle(request, ip); + res.writeHead(response.status, Object.fromEntries(response.headers)); + res.end(Buffer.from(await response.arrayBuffer())); +} diff --git a/push/src/ratelimit.ts b/push/src/ratelimit.ts new file mode 100644 index 000000000..5d5b051a3 --- /dev/null +++ b/push/src/ratelimit.ts @@ -0,0 +1,34 @@ +// Limits per phone key and per address, kept in the function's memory. +// That is a best effort: Vercel runs several instances, each with its own +// count, so the firewall's rules (README.md) are the hard limit per +// address, and these keep one busy key or address from using an instance +// up. + +export interface Window { + limit: number; + ms: number; +} + +export class Limiter { + private hits = new Map(); + private windows: Window[]; + private now: () => number; + + constructor(windows: Window[], now: () => number = Date.now) { + this.windows = windows; + this.now = now; + } + + // take counts one for id, and says whether it is within every window. + take(id: string): boolean { + const now = this.now(); + const longest = Math.max(...this.windows.map((w) => w.ms)); + const kept = (this.hits.get(id) ?? []).filter((t) => now - t < longest); + const ok = this.windows.every((w) => kept.filter((t) => now - t < w.ms).length < w.limit); + if (ok) kept.push(now); + this.hits.set(id, kept); + // Forget ids that went quiet, so the map stays small. + if (this.hits.size > 10_000) for (const [k, v] of this.hits) if (!v.length || now - v[v.length - 1] >= longest) this.hits.delete(k); + return ok; + } +} diff --git a/push/src/relay.ts b/push/src/relay.ts new file mode 100644 index 000000000..a9e511d66 --- /dev/null +++ b/push/src/relay.ts @@ -0,0 +1,106 @@ +import { createHash } from "node:crypto"; + +import { Apns, type Outcome } from "./apns.ts"; +import { Limiter } from "./ratelimit.ts"; +import { checkNotice, checkRegistration, type Notice } from "./schema.ts"; +import { open, seal, sealKeys, type SealKeys } from "./seal.ts"; + +// The relay's routes, on web Requests and Responses: +// +// POST /v1/register {platform, token, env, bundle} → {key} +// POST /v1/send Authorization: Bearer , a notice → {sent} +// GET /v1/health → {ok} +// +// It keeps nothing: a key carries its device token, sealed. Logs say what +// happened and to which key (a hash's first characters), never a body, a +// token or a key. + +export interface Deps { + keys: SealKeys; + kid: string; + topic: string; + send: (device: { token: string; env: "production" | "sandbox" }, n: Notice) => Promise; + log: (line: string) => void; + now: () => number; +} + +// MAX_BODY: a notice is a few hundred bytes. +export const MAX_BODY = 2048; + +export function fromEnv(env: NodeJS.ProcessEnv): Deps { + const missing = ["APNS_KEY_P8", "APNS_KEY_ID", "APNS_TEAM_ID", "APNS_TOPIC", "SEAL_KEYS", "SEAL_KID"].filter((k) => !env[k]); + if (missing.length) throw new Error(`the relay needs ${missing.join(", ")}`); + const apns = new Apns({ key: env.APNS_KEY_P8!.replace(/\\n/g, "\n"), keyId: env.APNS_KEY_ID!, teamId: env.APNS_TEAM_ID!, topic: env.APNS_TOPIC! }); + const keys = sealKeys(env.SEAL_KEYS); + if (!keys[env.SEAL_KID!]) throw new Error("SEAL_KID names no key in SEAL_KEYS"); + return { keys, kid: env.SEAL_KID!, topic: env.APNS_TOPIC!, send: (d, n) => apns.send(d, n), log: (l) => console.log(l), now: Date.now }; +} + +const json = (status: number, body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff", ...headers }, + }); + +const tag = (key: string) => createHash("sha256").update(key).digest("hex").slice(0, 10); + +export function relay(deps: Deps): (req: Request, ip?: string) => Promise { + // Per address: registering is rare (an install, a new token). Per key: a + // box's notices about one phone. + const registers = new Limiter([{ limit: 20, ms: 3600_000 }], deps.now); + const sends = new Limiter([{ limit: 30, ms: 60_000 }, { limit: 300, ms: 86_400_000 }], deps.now); + + async function body(req: Request): Promise { + const len = Number(req.headers.get("content-length") ?? "0"); + if (len > MAX_BODY) return json(413, { error: "too large" }); + const raw = await req.text(); + if (raw.length > MAX_BODY) return json(413, { error: "too large" }); + try { + return JSON.parse(raw); + } catch { + return json(400, { error: "the body must be JSON" }); + } + } + + return async (req, ip = "") => { + const path = new URL(req.url).pathname; + if (path === "/v1/health" && req.method === "GET") return json(200, { ok: true }); + if (path !== "/v1/register" && path !== "/v1/send") return json(404, { error: "not found" }); + if (req.method !== "POST") return json(405, { error: "method not allowed" }, { allow: "POST" }); + + if (path === "/v1/register") { + if (!registers.take(ip)) return json(429, { error: "too many registrations; try again later" }); + const b = await body(req); + if (b instanceof Response) return b; + const r = checkRegistration(b, deps.topic); + if (!r.ok) return json(400, { error: r.error }); + const key = seal(deps.keys, deps.kid, { token: r.value.token, env: r.value.env, bundle: r.value.bundle, iat: Math.floor(deps.now() / 1000) }); + deps.log(`register ${r.value.env} key=${tag(key)}`); + return json(200, { key }); + } + + const key = (req.headers.get("authorization") ?? "").replace(/^Bearer /, ""); + const device = key ? open(deps.keys, key) : undefined; + if (!device || device.bundle !== deps.topic) return json(401, { error: "that isn't a key from this relay" }); + if (!sends.take(tag(key))) return json(429, { error: "too many notices for this phone; try again later" }); + const b = await body(req); + if (b instanceof Response) return b; + const n = checkNotice(b); + if (!n.ok) return json(400, { error: n.error }); + let out: Outcome; + try { + out = await deps.send(device, n.value); + } catch (err) { + deps.log(`send key=${tag(key)} failed: ${(err as Error).message}`); + return json(502, { error: "Apple's push service didn't answer" }); + } + if (out.ok) { + deps.log(`send key=${tag(key)} ${n.value.state} ok`); + return json(200, { sent: true }); + } + deps.log(`send key=${tag(key)} ${n.value.state} ${out.gone ? "gone" : out.status} ${out.reason}`); + if (out.gone) return json(410, { error: "this phone no longer takes notifications" }); + if (out.status === 429) return json(429, { error: "Apple is limiting notices to this phone" }); + return json(502, { error: `Apple's push service said ${out.reason || out.status}` }); + }; +} diff --git a/push/src/schema.ts b/push/src/schema.ts new file mode 100644 index 000000000..05e3b61b2 --- /dev/null +++ b/push/src/schema.ts @@ -0,0 +1,69 @@ +// What the relay accepts, exactly: anything else is refused, so a box (or +// whoever has a phone's key) can't put more in a notification than a box +// would. + +// A notice from a box (internal/box/phonev2.go relayNotice). +export interface Notice { + v: 1; + kind: "session" | "run"; + state: "waiting" | "finished"; + title: string; + body: string; + box: string; + session?: string; + run?: string; + collapse: string; +} + +// A phone app asking for a key. +export interface Registration { + platform: "ios"; + token: string; + env: "production" | "sandbox"; + bundle: string; +} + +export type Checked = { ok: true; value: T } | { ok: false; error: string }; + +const NAME = /^[\p{L}\p{N}._\-/:@]+$/u; +// Printable text on one line: no control characters, no bidi overrides. +const TEXT = /^[^\p{Cc}\p{Cf}\u2028\u2029]*$/u; + +const isObject = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); +const only = (o: Record, keys: string[]) => Object.keys(o).find((k) => !keys.includes(k)); + +function text(v: unknown, max: number, pattern: RegExp): string | undefined { + if (typeof v !== "string" || !v || [...v].length > max || !pattern.test(v)) return undefined; + return v; +} + +export function checkNotice(v: unknown): Checked { + if (!isObject(v)) return { ok: false, error: "a notice is a JSON object" }; + const extra = only(v, ["v", "kind", "state", "title", "body", "box", "session", "run", "collapse"]); + if (extra) return { ok: false, error: `unknown field ${JSON.stringify(extra.slice(0, 40))}` }; + if (v.v !== 1) return { ok: false, error: "v must be 1" }; + if (v.kind !== "session" && v.kind !== "run") return { ok: false, error: "kind must be session or run" }; + if (v.state !== "waiting" && v.state !== "finished") return { ok: false, error: "state must be waiting or finished" }; + const title = text(v.title, 80, TEXT); + const body = text(v.body, 160, TEXT); + const box = text(v.box, 120, NAME); + const collapse = text(v.collapse, 200, TEXT); + if (!title || !body || !box || !collapse) return { ok: false, error: "title (80), body (160), box (120) and collapse (200) are short text" }; + const session = v.session === undefined ? undefined : text(v.session, 120, NAME); + const run = v.run === undefined ? undefined : text(v.run, 120, NAME); + if ((v.session !== undefined && !session) || (v.run !== undefined && !run)) return { ok: false, error: "session and run are names" }; + if (v.kind === "run" && !run) return { ok: false, error: "a run's notice names the run" }; + return { ok: true, value: { v: 1, kind: v.kind, state: v.state, title, body, box, session, run, collapse } }; +} + +export function checkRegistration(v: unknown, topic: string): Checked { + if (!isObject(v)) return { ok: false, error: "a registration is a JSON object" }; + const extra = only(v, ["platform", "token", "env", "bundle"]); + if (extra) return { ok: false, error: `unknown field ${JSON.stringify(extra.slice(0, 40))}` }; + if (v.platform === "android") return { ok: false, error: "Android isn't supported yet" }; + if (v.platform !== "ios") return { ok: false, error: "platform must be ios" }; + if (typeof v.token !== "string" || !/^[0-9a-fA-F]{64,200}$/.test(v.token)) return { ok: false, error: "token must be an APNs device token, as hex" }; + if (v.env !== "production" && v.env !== "sandbox") return { ok: false, error: "env must be production or sandbox" }; + if (v.bundle !== topic) return { ok: false, error: "this relay doesn't send for that app" }; + return { ok: true, value: { platform: "ios", token: v.token.toLowerCase(), env: v.env, bundle: v.bundle } }; +} diff --git a/push/src/seal.ts b/push/src/seal.ts new file mode 100644 index 000000000..322d26e9f --- /dev/null +++ b/push/src/seal.ts @@ -0,0 +1,70 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +// A phone app's key: its device token sealed so that only the relay can +// open it. The app hands the key to each box it pairs with, and a box sends +// it back with every notice; boxes never see the token, and the relay keeps +// nothing. Anyone holding a key can have the relay send that one phone the +// few fields a notice allows, which is what a box needs and no more. +// +// v1.. +// +// kid names the sealing key, so the relay can seal with a new one while +// keys sealed with the old one still open (SEAL_KEYS keeps both). + +export interface Sealed { + // The device token, as hex. + token: string; + // APNs' production or development (sandbox) service. + env: "production" | "sandbox"; + // The app it was issued for (its bundle id, APNs' topic). + bundle: string; + // When it was sealed, Unix seconds. + iat: number; +} + +export type SealKeys = Record; + +const aad = (kid: string) => Buffer.from(`berth-push v1|${kid}`); + +export function seal(keys: SealKeys, kid: string, s: Sealed): string { + const key = keys[kid]; + if (!key) throw new Error(`no sealing key ${kid}`); + const iv = randomBytes(12); + const c = createCipheriv("aes-256-gcm", key, iv); + c.setAAD(aad(kid)); + const body = Buffer.concat([c.update(JSON.stringify(s), "utf8"), c.final()]); + return `v1.${kid}.${Buffer.concat([iv, body, c.getAuthTag()]).toString("base64url")}`; +} + +// open returns what a key sealed, or undefined for anything else: another +// version, an unknown kid, a key changed by one bit. +export function open(keys: SealKeys, key: string): Sealed | undefined { + const m = /^v1\.([A-Za-z0-9_-]{1,32})\.([A-Za-z0-9_-]+)$/.exec(key); + if (!m) return undefined; + const k = keys[m[1]]; + if (!k) return undefined; + const raw = Buffer.from(m[2], "base64url"); + if (raw.length < 12 + 16 + 2) return undefined; + try { + const d = createDecipheriv("aes-256-gcm", k, raw.subarray(0, 12)); + d.setAAD(aad(m[1])); + d.setAuthTag(raw.subarray(raw.length - 16)); + const s = JSON.parse(Buffer.concat([d.update(raw.subarray(12, raw.length - 16)), d.final()]).toString("utf8")) as Sealed; + if (typeof s.token !== "string" || (s.env !== "production" && s.env !== "sandbox") || typeof s.bundle !== "string") return undefined; + return s; + } catch { + return undefined; + } +} + +// sealKeys reads SEAL_KEYS: {"": "", …}. +export function sealKeys(json: string | undefined): SealKeys { + const out: SealKeys = {}; + if (!json) return out; + for (const [kid, b64] of Object.entries(JSON.parse(json) as Record)) { + const key = Buffer.from(b64, "base64"); + if (!/^[A-Za-z0-9_-]{1,32}$/.test(kid) || key.length !== 32) throw new Error(`SEAL_KEYS: ${kid} must be 32 bytes, base64`); + out[kid] = key; + } + return out; +} diff --git a/push/test/relay.test.ts b/push/test/relay.test.ts new file mode 100644 index 000000000..3e848dbe2 --- /dev/null +++ b/push/test/relay.test.ts @@ -0,0 +1,244 @@ +// node --experimental-strip-types --test test/relay.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { generateKeyPairSync, randomBytes, verify } from "node:crypto"; +import { createServer, type ServerHttp2Session, type ServerHttp2Stream } from "node:http2"; +import type { AddressInfo } from "node:net"; +import { test } from "node:test"; + +import { Apns, http2Transport, payload, type ApnsRequest } from "../src/apns.ts"; +import { Limiter } from "../src/ratelimit.ts"; +import { relay, type Deps } from "../src/relay.ts"; +import { checkNotice, checkRegistration, type Notice } from "../src/schema.ts"; +import { open, seal, sealKeys } from "../src/seal.ts"; + +const TOPIC = "dev.berth.phone"; +const TOKEN = "a1".repeat(32); +const keys = { k1: randomBytes(32), k0: randomBytes(32) }; +const notice: Notice = { v: 1, kind: "session", state: "waiting", title: "Claude Code needs you", body: "acme/checkout on devbox", box: "devbox", session: "acme-fix", collapse: "devbox/acme-fix" }; + +test("a key opens to what it sealed, and nothing else opens", () => { + const key = seal(keys, "k1", { token: TOKEN, env: "sandbox", bundle: TOPIC, iat: 1 }); + assert.match(key, /^v1\.k1\.[A-Za-z0-9_-]+$/); + assert.deepEqual(open(keys, key), { token: TOKEN, env: "sandbox", bundle: TOPIC, iat: 1 }); + // A key sealed with the old kid still opens while SEAL_KEYS keeps it. + assert.equal(open(keys, seal(keys, "k0", { token: TOKEN, env: "production", bundle: TOPIC, iat: 1 }))?.env, "production"); + const body = key.split(".")[2]; + const flipped = body.slice(0, 20) + (body[20] === "A" ? "B" : "A") + body.slice(21); + for (const bad of [`v1.k1.${flipped}`, key.replace("v1.k1.", "v1.k0."), key.replace("v1.", "v2."), `v1.k9.${body}`, "v1.k1.", "", "nonsense"]) { + assert.equal(open(keys, bad), undefined, bad); + } + assert.equal(open({ k1: randomBytes(32) }, key), undefined, "another relay's key"); +}); + +test("SEAL_KEYS holds 32-byte keys by kid", () => { + assert.equal(sealKeys(JSON.stringify({ k1: randomBytes(32).toString("base64") })).k1.length, 32); + assert.throws(() => sealKeys(JSON.stringify({ k1: randomBytes(16).toString("base64") }))); + assert.throws(() => sealKeys(JSON.stringify({ "bad kid": randomBytes(32).toString("base64") }))); +}); + +test("a notice is exactly the fields a box sends", () => { + assert.deepEqual(checkNotice(notice), { ok: true, value: { ...notice, run: undefined } }); + const bad: unknown[] = [ + { ...notice, v: 2 }, + { ...notice, kind: "custom" }, + { ...notice, state: "urgent" }, + { ...notice, title: "x".repeat(81) }, + { ...notice, body: "line one\nline two" }, + { ...notice, body: "‮reversed" }, + { ...notice, box: "dev box" }, + { ...notice, session: "acme fix" }, + { ...notice, aps: { "content-available": 1 } }, + { ...notice, kind: "run" }, + [], + "notice", + ]; + for (const b of bad) assert.equal(checkNotice(b).ok, false, JSON.stringify(b)); + assert.equal(checkNotice({ ...notice, kind: "run", session: undefined, run: "r_1" }).ok, true); +}); + +test("a registration is for this app, with an APNs token", () => { + assert.deepEqual(checkRegistration({ platform: "ios", token: TOKEN.toUpperCase(), env: "production", bundle: TOPIC }, TOPIC), { + ok: true, + value: { platform: "ios", token: TOKEN, env: "production", bundle: TOPIC }, + }); + for (const b of [ + { platform: "android", token: TOKEN, env: "production", bundle: TOPIC }, + { platform: "ios", token: "xyz", env: "production", bundle: TOPIC }, + { platform: "ios", token: TOKEN, env: "staging", bundle: TOPIC }, + { platform: "ios", token: TOKEN, env: "production", bundle: "com.example.other" }, + { platform: "ios", token: TOKEN, env: "production", bundle: TOPIC, extra: 1 }, + ]) { + assert.equal(checkRegistration(b, TOPIC).ok, false, JSON.stringify(b)); + } +}); + +test("limits count per id, in every window", () => { + let now = 0; + const l = new Limiter([{ limit: 2, ms: 1000 }, { limit: 3, ms: 10_000 }], () => now); + assert.ok(l.take("a") && l.take("a")); + assert.equal(l.take("a"), false); + assert.ok(l.take("b"), "another id has its own count"); + now = 1500; + assert.ok(l.take("a")); + now = 2600; + assert.equal(l.take("a"), false, "the longer window still counts three"); + now = 11_000; + assert.ok(l.take("a")); +}); + +const p8 = () => generateKeyPairSync("ec", { namedCurve: "P-256" }); + +test("APNs gets a signed provider token and a notification the relay made", async () => { + const { privateKey, publicKey } = p8(); + const sent: ApnsRequest[] = []; + let now = 1_700_000_000_000; + const apns = new Apns( + { key: privateKey.export({ type: "pkcs8", format: "pem" }) as string, keyId: "KEY123", teamId: "TEAM456", topic: TOPIC }, + async (r) => (sent.push(r), { status: 200, body: "" }), + () => now, + ); + assert.deepEqual(await apns.send({ token: TOKEN, env: "sandbox" }, notice), { ok: true }); + const r = sent[0]; + assert.equal(r.host, "https://api.sandbox.push.apple.com"); + assert.equal(r.path, `/3/device/${TOKEN}`); + assert.equal(r.headers["apns-topic"], TOPIC); + assert.equal(r.headers["apns-priority"], "10"); + assert.match(r.headers["apns-collapse-id"], /^[0-9a-f]{32}$/); + assert.deepEqual(JSON.parse(r.body), payload(notice)); + assert.deepEqual(JSON.parse(r.body).berth, { v: 1, box: "devbox", kind: "session", session: "acme-fix" }); + + const [head, claims, sig] = r.headers.authorization.replace("bearer ", "").split("."); + assert.deepEqual(JSON.parse(Buffer.from(head, "base64url").toString()), { alg: "ES256", kid: "KEY123" }); + assert.deepEqual(JSON.parse(Buffer.from(claims, "base64url").toString()), { iss: "TEAM456", iat: 1_700_000_000 }); + assert.ok(verify("sha256", Buffer.from(`${head}.${claims}`), { key: publicKey, dsaEncoding: "ieee-p1363" }, Buffer.from(sig, "base64url"))); + + // The same token for 50 minutes, then a new one. + now += 49 * 60_000; + await apns.send({ token: TOKEN, env: "production" }, { ...notice, state: "finished" }); + assert.equal(sent[1].headers.authorization, r.headers.authorization); + assert.equal(sent[1].headers["apns-priority"], "5"); + assert.equal(sent[1].host, "https://api.push.apple.com"); + now += 2 * 60_000; + await apns.send({ token: TOKEN, env: "production" }, notice); + assert.notEqual(sent[2].headers.authorization, r.headers.authorization); +}); + +test("APNs' answers become gone, a retry with a fresh token, or a failure", async () => { + const { privateKey } = p8(); + const key = privateKey.export({ type: "pkcs8", format: "pem" }) as string; + const answers = (list: { status: number; body: string }[]) => { + const calls: ApnsRequest[] = []; + return { calls, apns: new Apns({ key, keyId: "K", teamId: "T", topic: TOPIC }, async (r) => (calls.push(r), list.shift()!)) }; + }; + let a = answers([{ status: 410, body: '{"reason":"Unregistered"}' }]); + assert.deepEqual(await a.apns.send({ token: TOKEN, env: "production" }, notice), { ok: false, gone: true, reason: "Unregistered" }); + a = answers([{ status: 400, body: '{"reason":"BadDeviceToken"}' }]); + assert.equal((await a.apns.send({ token: TOKEN, env: "production" }, notice)).ok, false); + a = answers([ + { status: 403, body: '{"reason":"ExpiredProviderToken"}' }, + { status: 200, body: "" }, + ]); + assert.deepEqual(await a.apns.send({ token: TOKEN, env: "production" }, notice), { ok: true }); + assert.equal(a.calls.length, 2); + a = answers([{ status: 500, body: '{"reason":"InternalServerError"}' }]); + assert.deepEqual(await a.apns.send({ token: TOKEN, env: "production" }, notice), { ok: false, gone: false, status: 500, reason: "InternalServerError" }); +}); + +test("the HTTP/2 transport posts and reads the answer", async () => { + const server = createServer(); + const sessions: ServerHttp2Session[] = []; + server.on("session", (s: ServerHttp2Session) => sessions.push(s)); + const seen: { path?: string; topic?: string; body: string } = { body: "" }; + server.on("stream", (stream: ServerHttp2Stream, headers) => { + seen.path = String(headers[":path"]); + seen.topic = String(headers["apns-topic"]); + stream.setEncoding("utf8"); + stream.on("data", (d: string) => (seen.body += d)); + stream.on("end", () => { + stream.respond({ ":status": 410 }); + stream.end('{"reason":"Unregistered"}'); + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as AddressInfo).port; + try { + const send = http2Transport(); + const res = await send({ host: `http://127.0.0.1:${port}`, path: "/3/device/abc", headers: { "apns-topic": TOPIC }, body: "{}" }); + assert.deepEqual(res, { status: 410, body: '{"reason":"Unregistered"}' }); + assert.deepEqual(seen, { path: "/3/device/abc", topic: TOPIC, body: "{}" }); + } finally { + for (const s of sessions) s.destroy(); + server.close(); + } +}); + +function relayFor(outcome: Awaited> | Error = { ok: true }) { + const sent: { device: { token: string; env: string }; n: Notice }[] = []; + const logs: string[] = []; + const handle = relay({ + keys, + kid: "k1", + topic: TOPIC, + send: async (device, n) => { + sent.push({ device, n }); + if (outcome instanceof Error) throw outcome; + return outcome; + }, + log: (l) => logs.push(l), + now: () => 1_700_000_000_000, + }); + return { handle, sent, logs }; +} + +const post = (path: string, body: unknown, headers: Record = {}) => + new Request(`https://push.berthd.app${path}`, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: typeof body === "string" ? body : JSON.stringify(body) }); + +test("a phone registers, and a box notifies it with the key", async () => { + const { handle, sent, logs } = relayFor(); + const reg = await handle(post("/v1/register", { platform: "ios", token: TOKEN, env: "sandbox", bundle: TOPIC }), "203.0.113.5"); + assert.equal(reg.status, 200); + const { key } = (await reg.json()) as { key: string }; + const res = await handle(post("/v1/send", notice, { authorization: `Bearer ${key}` })); + assert.equal(res.status, 200); + assert.deepEqual(sent, [{ device: { token: TOKEN, env: "sandbox", bundle: TOPIC, iat: 1_700_000_000 }, n: { ...notice, run: undefined } }]); + // The logs never hold the token, the key or what was said. + for (const l of logs) for (const secret of [TOKEN, key, "acme", "Claude"]) assert.ok(!l.includes(secret), l); +}); + +test("the relay refuses what isn't a box's notice with a key of its own", async () => { + const { handle, sent } = relayFor(); + const key = seal(keys, "k1", { token: TOKEN, env: "production", bundle: TOPIC, iat: 1 }); + const otherApp = seal(keys, "k1", { token: TOKEN, env: "production", bundle: "com.example.other", iat: 1 }); + assert.equal((await handle(post("/v1/send", notice))).status, 401); + assert.equal((await handle(post("/v1/send", notice, { authorization: "Bearer nonsense" }))).status, 401); + assert.equal((await handle(post("/v1/send", notice, { authorization: `Bearer ${otherApp}` }))).status, 401); + assert.equal((await handle(post("/v1/send", { ...notice, aps: {} }, { authorization: `Bearer ${key}` }))).status, 400); + assert.equal((await handle(post("/v1/send", "{", { authorization: `Bearer ${key}` }))).status, 400); + assert.equal((await handle(post("/v1/send", { ...notice, body: "x".repeat(3000) }, { authorization: `Bearer ${key}` }))).status, 413); + assert.equal((await handle(new Request("https://push.berthd.app/v1/send"))).status, 405); + assert.equal((await handle(new Request("https://push.berthd.app/admin"))).status, 404); + assert.equal((await handle(new Request("https://push.berthd.app/v1/health"))).status, 200); + assert.equal(sent.length, 0); +}); + +test("a phone that's gone answers 410, and Apple's trouble 502", async () => { + const key = seal(keys, "k1", { token: TOKEN, env: "production", bundle: TOPIC, iat: 1 }); + const auth = { authorization: `Bearer ${key}` }; + assert.equal((await relayFor({ ok: false, gone: true, reason: "Unregistered" }).handle(post("/v1/send", notice, auth))).status, 410); + assert.equal((await relayFor({ ok: false, gone: false, status: 500, reason: "InternalServerError" }).handle(post("/v1/send", notice, auth))).status, 502); + assert.equal((await relayFor({ ok: false, gone: false, status: 429, reason: "TooManyRequests" }).handle(post("/v1/send", notice, auth))).status, 429); + assert.equal((await relayFor(new Error("connect ECONNREFUSED")).handle(post("/v1/send", notice, auth))).status, 502); +}); + +test("one key or address can't send or register without end", async () => { + const { handle } = relayFor(); + const key = seal(keys, "k1", { token: TOKEN, env: "production", bundle: TOPIC, iat: 1 }); + const codes: number[] = []; + for (let i = 0; i < 31; i++) codes.push((await handle(post("/v1/send", notice, { authorization: `Bearer ${key}` }))).status); + assert.deepEqual(codes.slice(0, 30), Array(30).fill(200)); + assert.equal(codes[30], 429); + const regs: number[] = []; + for (let i = 0; i < 21; i++) regs.push((await handle(post("/v1/register", { platform: "ios", token: TOKEN, env: "production", bundle: TOPIC }), "203.0.113.9")).status); + assert.equal(regs[20], 429); + assert.equal((await handle(post("/v1/register", { platform: "ios", token: TOKEN, env: "production", bundle: TOPIC }), "203.0.113.10")).status, 200); +}); diff --git a/push/tsconfig.json b/push/tsconfig.json new file mode 100644 index 000000000..f4b44e0fa --- /dev/null +++ b/push/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/push/vercel.json b/push/vercel.json new file mode 100644 index 000000000..e57610602 --- /dev/null +++ b/push/vercel.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, + "installCommand": "pnpm install --frozen-lockfile", + "buildCommand": "pnpm build" +}