Merge remote-tracking branch 'origin/main' into brennanb2025/codex-subagent-worklog

# Conflicts:
#	src/renderer/src/i18n/locales/en.json
This commit is contained in:
Merge Sim
2026-09-06 23:21:07 -07:00
335 changed files with 5641 additions and 19465 deletions
+1
View File
@@ -4,6 +4,7 @@
/config/scripts/**/*.mjs text eol=lf
/skill-guides/*.md text eol=lf
/skill-stubs/*.md text eol=lf
/skill-stubs/_shared/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
/src/cli/bundled-skill-guides.ts text eol=lf
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
-340
View File
@@ -1,340 +0,0 @@
name: Deploy Push Gateway Production
on:
workflow_dispatch:
inputs:
confirmation:
description: Enter DEPLOY_PUSH_GATEWAY to shift production traffic
required: true
type: string
permissions:
contents: read
id-token: write
# The gateway applies its own schema at startup against the shared Cloud SQL instance, so a
# deploy is a connection-budget rollout and belongs in the same serialized group as the relay.
concurrency:
group: production-cloud-sql-rollout
cancel-in-progress: false
defaults:
run:
working-directory: cloud
jobs:
deploy:
if: >-
${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' &&
github.ref == 'refs/heads/main' }}
runs-on: blacksmith-2vcpu-ubuntu-2204
environment: production
env:
GCP_PROJECT_ID: onorca-cloud
GCP_REGION: ${{ vars.PRODUCTION_GCP_REGION }}
SERVICE_NAME: orca-cloud-push
REPOSITORY_ID: orca-cloud
IMAGE_NAME: push
PUSH_ORIGIN: https://push.onorca.dev
PUSH_RUNTIME_SERVICE_ACCOUNT: orca-cloud-push@onorca-cloud.iam.gserviceaccount.com
# Scaling the serving revision must already hold, matching push_min_instances and
# push_max_instances. Terraform owns both, and the candidate inherits them from the
# service, so this deploy never passes a scaling flag: doing so would write a
# Terraform-owned field that `lifecycle.ignore_changes` does not cover, and a later
# `push_max_instances` raise would then be reverted by every deploy. These two values
# are the expected shape, asserted before the candidate is created and again on the
# candidate itself, so a deploy that would change the gateway's Cloud SQL draw fails.
PUSH_MIN_INSTANCES: 1
PUSH_MAX_INSTANCES: 2
CONFIRMATION: ${{ inputs.confirmation }}
steps:
- uses: actions/checkout@v4
- name: Require the explicit deploy confirmation
shell: bash
run: |
set -euo pipefail
test "${CONFIRMATION}" = DEPLOY_PUSH_GATEWAY
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.PRODUCTION_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.PRODUCTION_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
- uses: docker/setup-buildx-action@v3
- name: Configure Docker auth
run: gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet
# Why: the build runs before the lease. Artifact Registry is not the Cloud SQL instance,
# and a multi-minute image build inside the lease blocks every relay deploy and rehome for
# its duration. The lease below covers exactly the connection-budget window: deploy, probe,
# shift.
- name: Build and publish the immutable gateway image
shell: bash
run: |
set -euo pipefail
image_tag="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPOSITORY_ID}/${IMAGE_NAME}:sha-${GITHUB_SHA}"
docker build -f apps/push/Dockerfile -t "${image_tag}" .
docker push "${image_tag}"
digest="$(gcloud artifacts docker images describe "${image_tag}" \
--format='value(image_summary.digest)')"
[[ "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]]
echo "IMAGE=${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPOSITORY_ID}/${IMAGE_NAME}@${digest}" \
>> "${GITHUB_ENV}"
echo "IMAGE_DIGEST=${digest}" >> "${GITHUB_ENV}"
# Held across the deploy, not just a separate schema step: the gateway opens its pool and
# applies its schema while the new revision starts, so the revision is the schema step.
- uses: ./.github/actions/cloud-sql-rollout-lease
with:
bucket: onorca-cloud-terraform-state
object: terraform/state/cloud-sql-rollout/production.lock
# Why: the candidate inherits the serving revision's scaling. A serving revision that has
# drifted below the floor would hand the candidate a cold start on every notification, and
# one that has drifted above the ceiling would hand it a larger Cloud SQL draw than the
# rollout lease was taken for. Refuse to inherit either rather than latch it.
- name: Record the serving revision and require its Terraform-owned scaling
shell: bash
run: |
set -euo pipefail
serving="$(gcloud run services describe "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
| jq -r '[.status.traffic[] | select((.percent // 0) > 0)]
| if length == 1 and .[0].percent == 100 then .[0].revisionName else empty end')"
test -n "${serving}"
floor="$(gcloud run revisions describe "${serving}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
--format="value(metadata.annotations['autoscaling.knative.dev/minScale'])")"
if [[ "${floor:-0}" -lt "${PUSH_MIN_INSTANCES}" ]]; then
echo "serving revision ${serving} holds ${floor:-0} minimum instances," \
"below ${PUSH_MIN_INSTANCES}; deploying would inherit and latch it." >&2
echo "Restore the floor first: gcloud run services update ${SERVICE_NAME}" \
"--region ${GCP_REGION} --min-instances=${PUSH_MIN_INSTANCES}" >&2
exit 1
fi
ceiling="$(gcloud run revisions describe "${serving}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
--format="value(metadata.annotations['autoscaling.knative.dev/maxScale'])")"
test "${ceiling}" = "${PUSH_MAX_INSTANCES}"
echo "serving revision ${serving} holds ${floor} minimum and ${ceiling} maximum instances"
echo "ROLLBACK_REVISION=${serving}" >> "${GITHUB_ENV}"
# No traffic and a per-revision tag: the candidate boots, applies schema, and is probed on
# its own URL while every phone and desktop still reaches the previous revision.
- name: Deploy the candidate revision with no traffic
shell: bash
run: |
set -euo pipefail
tag="c${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "CANDIDATE_TAG=${tag}" >> "${GITHUB_ENV}"
echo "CANDIDATE_REVISION=${SERVICE_NAME}-${tag}" >> "${GITHUB_ENV}"
gcloud run deploy "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--image "${IMAGE}" \
--tag "${tag}" \
--revision-suffix "${tag}" \
--no-traffic \
--quiet
candidate="$(gcloud run services describe "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
| jq -er --arg tag "${tag}" \
'[.status.traffic[] | select(.tag == $tag)]
| if length == 1 then .[0] else error("tagged candidate is not unique") end')"
test "$(jq -r '.revisionName' <<< "${candidate}")" = "${SERVICE_NAME}-${tag}"
echo "CANDIDATE_URL=$(jq -r '.url' <<< "${candidate}")" >> "${GITHUB_ENV}"
# A tagged revision is directly addressable and sits outside the service-wide cap, so the
# candidate and the serving revision each draw up to the ceiling during the probe window.
# The lease is taken for exactly that doubling; a candidate that inherited a wider ceiling
# would exceed it, so the inherited scaling is asserted here too.
- name: Require the candidate to serve the exact image and inherited scaling
shell: bash
run: |
set -euo pipefail
served="$(gcloud run revisions describe "${CANDIDATE_REVISION}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
--format='value(spec.containers[0].image)')"
test "${served}" = "${IMAGE}"
test "${CANDIDATE_REVISION}" != "${ROLLBACK_REVISION}"
candidate_ceiling="$(gcloud run revisions describe "${CANDIDATE_REVISION}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
--format="value(metadata.annotations['autoscaling.knative.dev/maxScale'])")"
test "${candidate_ceiling}" = "${PUSH_MAX_INSTANCES}"
- name: Probe the candidate readiness endpoint
shell: bash
run: |
set -euo pipefail
[[ "${CANDIDATE_URL}" =~ ^https://[^/]+$ ]]
for attempt in $(seq 1 30); do
code="$(curl -sS -o "${RUNNER_TEMP}/push-ready.json" -w '%{http_code}' \
--max-time 10 "${CANDIDATE_URL}/ready" || true)"
if test "${code}" = 200; then
jq -e . < "${RUNNER_TEMP}/push-ready.json" > /dev/null
echo "candidate ${CANDIDATE_REVISION} is ready after ${attempt} attempt(s)"
exit 0
fi
echo "attempt ${attempt}: /ready returned ${code}"
sleep 5
done
echo "candidate ${CANDIDATE_REVISION} never reported ready" >&2
exit 1
# Why: a gateway that boots and answers /ready can still be unable to send. This proves the
# runtime account's FCM grant end to end without delivering anything: validate_only stops
# Google before any push, and the deliberately invalid token means a healthy credential
# answers INVALID_ARGUMENT. PERMISSION_DENIED is the failure this step exists to catch.
#
# Only the four verdicts below are conclusive. A 429, a 5xx, or a transport failure says
# nothing about the credential, so it is retried rather than treated as either answer; a
# denied credential still fails on the first attempt, without burning the retries.
- name: Prove the runtime identity can reach FCM
shell: bash
run: |
set -euo pipefail
token="$(gcloud auth print-access-token \
--impersonate-service-account "${PUSH_RUNTIME_SERVICE_ACCOUNT}")"
test -n "${token}"
echo "::add-mask::${token}"
body='{"validate_only":true,"message":{"token":"orca-push-deploy-probe-invalid-token","notification":{"title":"Orca","body":"deploy probe"}}}'
for attempt in $(seq 1 5); do
code="$(curl -sS -o "${RUNNER_TEMP}/push-fcm.json" -w '%{http_code}' --max-time 20 \
-X POST "https://fcm.googleapis.com/v1/projects/${GCP_PROJECT_ID}/messages:send" \
-H "Authorization: Bearer ${token}" \
-H 'Content-Type: application/json' \
--data "${body}" || true)"
status="$(jq -r '.error.status // empty' < "${RUNNER_TEMP}/push-fcm.json" || true)"
echo "attempt ${attempt}: FCM validate-only send returned HTTP ${code} status ${status:-OK}"
if test "${status}" = PERMISSION_DENIED || test "${status}" = INVALID_ARGUMENT ||
test "${code}" = 401 || test "${code}" = 403; then
break
fi
sleep 5
done
if test "${status}" = PERMISSION_DENIED || test "${code}" = 401 || test "${code}" = 403; then
echo "the push runtime identity cannot send through FCM" >&2
exit 1
fi
test "${status}" = INVALID_ARGUMENT
- name: Shift all traffic to the verified candidate
shell: bash
run: |
set -euo pipefail
echo "TRAFFIC_SHIFT_ATTEMPTED=true" >> "${GITHUB_ENV}"
gcloud run services update-traffic "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--to-revisions "${CANDIDATE_REVISION}=100" \
--quiet
serving="$(gcloud run services describe "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
| jq -r '[.status.traffic[] | select((.percent // 0) > 0)]
| if length == 1 and .[0].percent == 100 then .[0].revisionName else empty end')"
test "${serving}" = "${CANDIDATE_REVISION}"
echo "TRAFFIC_SHIFTED=true" >> "${GITHUB_ENV}"
# Why: the summary is written before the origin check, not after it. Once traffic has
# moved, the rollback target is the single thing an operator needs, and a summary that only
# appeared on success would be missing in exactly the run that needs it.
- name: Publish the rollout summary
if: ${{ always() && env.CANDIDATE_REVISION != '' && env.ROLLBACK_REVISION != '' }}
shell: bash
run: |
set -euo pipefail
{
echo '### Push gateway rollout'
echo
echo "Revision: \`${CANDIDATE_REVISION}\`"
echo
echo "Image: \`${IMAGE_DIGEST}\`"
echo
echo "Rollback: \`gcloud run services update-traffic ${SERVICE_NAME}" \
"--region ${GCP_REGION} --to-revisions ${ROLLBACK_REVISION}=100\`"
} >> "${GITHUB_STEP_SUMMARY}"
- name: Verify the public origin after the shift
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 \
"${PUSH_ORIGIN}/ready" || true)"
if test "${code}" = 200; then
echo "${PUSH_ORIGIN} is ready after ${attempt} attempt(s)"
exit 0
fi
echo "attempt ${attempt}: ${PUSH_ORIGIN}/ready returned ${code}"
sleep 5
done
echo "${PUSH_ORIGIN} never reported ready after the shift" >&2
exit 1
# Why: everything after the shift runs with production on the candidate. A failure there
# is not a failure to deploy, it is a live gateway that has to go back, so the traffic move
# is undone here rather than left to whoever reads the run.
- name: Roll traffic back to the previous revision
if: ${{ (failure() || cancelled()) && env.TRAFFIC_SHIFT_ATTEMPTED == 'true' }}
shell: bash
run: |
set -euo pipefail
test -n "${ROLLBACK_REVISION:-}"
gcloud run services update-traffic "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--to-revisions "${ROLLBACK_REVISION}=100" \
--quiet
serving="$(gcloud run services describe "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
| jq -r '[.status.traffic[] | select((.percent // 0) > 0)]
| if length == 1 and .[0].percent == 100 then .[0].revisionName else empty end')"
test "${serving}" = "${ROLLBACK_REVISION}"
echo "TRAFFIC_ROLLED_BACK=true" >> "${GITHUB_ENV}"
{
echo
echo '### Push gateway rolled back'
echo
echo "Traffic returned to \`${ROLLBACK_REVISION}\`; the candidate" \
"\`${CANDIDATE_REVISION}\` no longer serves."
} >> "${GITHUB_STEP_SUMMARY}"
# Why: a candidate that never took traffic is a revision holding a warm floor and a Cloud
# SQL pool for nothing. Its tag comes off first, because Cloud Run refuses to delete a
# revision a traffic target still names, and clearing CANDIDATE_TAG makes the always() tag
# step below a no-op rather than a second failure.
- name: Delete the rejected candidate revision
if: ${{ (failure() || cancelled()) && (env.TRAFFIC_SHIFT_ATTEMPTED != 'true' || env.TRAFFIC_ROLLED_BACK == 'true') }}
shell: bash
run: |
set -euo pipefail
test -n "${CANDIDATE_REVISION:-}" || exit 0
if test -n "${CANDIDATE_TAG:-}"; then
gcloud run services update-traffic "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--remove-tags "${CANDIDATE_TAG}" \
--quiet
echo "CANDIDATE_TAG=" >> "${GITHUB_ENV}"
fi
gcloud run revisions delete "${CANDIDATE_REVISION}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--quiet
echo "deleted the candidate revision ${CANDIDATE_REVISION}"
- name: Drop the candidate traffic tag
if: always()
shell: bash
run: |
set -euo pipefail
test -n "${CANDIDATE_TAG:-}" || exit 0
gcloud run services update-traffic "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--remove-tags "${CANDIDATE_TAG}" \
--quiet
-1
View File
@@ -90,7 +90,6 @@ jobs:
--health-timeout 5s
--health-retries 10
env:
ORCA_PUSH_TEST_DATABASE_URL: postgres://relay_test:relay_test@127.0.0.1:5432/orca_relay_test
ORCA_RELAY_TEST_POSTGRES_URL: postgres://relay_test:relay_test@127.0.0.1:5432/orca_relay_test
steps:
- uses: actions/checkout@v4
-7
View File
@@ -94,13 +94,6 @@ jobs:
run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)'
- name: Expo prebuild
# Why the env var: app.config.js derives the expo-notifications plugin's
# `mode` from it, which is what writes `aps-environment: production` into the
# entitlements. push-token.ts reports a production APNs environment for every
# non-__DEV__ build, so a development entitlement here would leave TestFlight
# and App Store builds registered against a sandbox they never receive from.
env:
ORCA_IOS_APS_ENVIRONMENT: production
run: npx expo prebuild --platform ios --no-install
- name: Install CocoaPods
-1
View File
@@ -107,7 +107,6 @@ docs/**
!docs/reference/headless-linux-server.md
!docs/reference/ime-regression-checklist.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/mobile-push-contract.md
!docs/reference/macos-press-and-hold.md
!docs/reference/orcad-operations.md
!docs/reference/relay-grace-time-reconfiguration.md
+7 -35
View File
@@ -24,32 +24,6 @@ the repository's root [MIT license](../LICENSE).
- `apps/relay-ops`: the relay operations console and the incident monitor
behind `pnpm ops:relay`, `pnpm incident:relay`, and
`pnpm incident:relay-preflight`.
- `apps/push` and `packages/push-contract`: the mobile push gateway that holds
the APNs key and sends to phones through APNs and FCM, and its wire contract.
It is deployed and operated from here but is not part of the relay data path;
see [docs/push-gateway.md](docs/push-gateway.md).
## Mobile push gateway
`apps/push` is a separate Cloud Run service from the relay. Phones never hold an
Orca credential for it: the desktop host authenticates with the same X25519
key it uses for the relay, answering an encrypted challenge to mint a 24 hour
session, then registers each paired phone's native push token and asks the
gateway to push. The gateway coalesces a burst per registration into one
notification, enforces per-host and per-registration quotas, and retires a
registration as soon as Apple or Google reports the token unregistered.
Storage follows the relay pattern: PostgreSQL in production, SQLite for tests
and local development. Configure it with `ORCA_PUSH_PUBLIC_URL`,
`ORCA_PUSH_DATABASE_URL`, the three APNs variables (`ORCA_PUSH_APNS_KEY`,
`ORCA_PUSH_APNS_KEY_ID`, `ORCA_PUSH_APPLE_TEAM_ID`, all three or none), and
optionally `ORCA_PUSH_APNS_TOPIC`, `ORCA_PUSH_FCM_PROJECT_ID`, and
`ORCA_PUSH_COALESCE_MS`. The FCM credential comes from the runtime service
account, so no key material is configured for Android. The full contract lives
in `docs/reference/mobile-push-contract.md` at the repository root.
Logging is aggregate counters only. Tokens, notification titles, notification
bodies, and full host fingerprints never reach a log line.
## Infrastructure and operations
@@ -64,18 +38,16 @@ bodies, and full host fingerprints never reach a log line.
- `dev/contracts` and `dev/fixtures`: the checked-in data those contract tests
read, including the Terraform root partition.
- `docs/`: the relay runbooks, capacity-testing guide, incident-monitor
reference, the workflow variable reference in `docs/relay-workflows.md`, and
the push gateway runbook in `docs/push-gateway.md`.
reference, and the workflow variable reference in `docs/relay-workflows.md`.
## Workflows
The 25 `.github/workflows/cloud-*.yml` workflows are the deploy and operate
surface: publish and deploy the director, roll GCE cell capacity, operate Asia
admission and regional rehoming, prove staging capacity, monitor production,
power staging up and down, and deploy the mobile push gateway.
`.github/actions/cloud-sql-rollout-lease` is the compare-and-swap lease that
serializes every rollout against the shared Cloud SQL instance, the push
gateway deploy included.
The 24 `.github/workflows/cloud-*.yml` workflows are the relay's deploy and
operate surface: publish and deploy the director, roll GCE cell capacity,
operate Asia admission and regional rehoming, prove staging capacity, monitor
production, and power staging up and down. `.github/actions/cloud-sql-rollout-lease`
is the compare-and-swap lease that serializes every rollout against the shared
Cloud SQL instance.
Every one of them is inert. Each top-level job is gated on
`vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true'`, a repository variable that is
-29
View File
@@ -1,29 +0,0 @@
FROM node:24-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY packages/push-contract/package.json packages/push-contract/package.json
COPY packages/postgres-schema/package.json packages/postgres-schema/package.json
COPY apps/push/package.json apps/push/package.json
RUN pnpm install --frozen-lockfile
COPY packages/push-contract packages/push-contract
COPY apps/push apps/push
COPY packages/postgres-schema packages/postgres-schema
RUN pnpm --filter @orca-cloud/postgres-schema build && pnpm --filter @orca-cloud/push-contract build && pnpm --filter @orca-cloud/push build
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
ENV PORT=8080
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/push-contract/package.json packages/push-contract/package.json
COPY packages/postgres-schema/package.json packages/postgres-schema/package.json
COPY apps/push/package.json apps/push/package.json
COPY --from=build /app/packages/push-contract/dist packages/push-contract/dist
COPY --from=build /app/packages/postgres-schema/dist packages/postgres-schema/dist
COPY --from=build /app/apps/push/dist apps/push/dist
RUN pnpm install --prod --frozen-lockfile --filter @orca-cloud/push...
USER node
EXPOSE 8080
CMD ["node", "apps/push/dist/index.js"]
-34
View File
@@ -1,34 +0,0 @@
{
"name": "@orca-cloud/push",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "pnpm clean && tsc -p tsconfig.build.json",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"dev": "tsx watch src/index.ts",
"lint": "tsc -p tsconfig.json --noEmit",
"pretest": "pnpm --filter @orca-cloud/postgres-schema build && pnpm --filter @orca-cloud/push-contract build",
"start": "node dist/index.js",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.19.14",
"@orca-cloud/postgres-schema": "workspace:*",
"@orca-cloud/push-contract": "workspace:*",
"google-auth-library": "^10.5.0",
"hono": "^4.12.27",
"pg": "^8.22.0",
"tweetnacl": "^1.0.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.10.0",
"@types/pg": "^8.20.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.8"
}
}
@@ -1,42 +0,0 @@
import { createPrivateKey, type KeyObject, sign } from 'node:crypto'
import type { ApnsCredentials } from './config.js'
// Apple rejects a provider token older than an hour and throttles reissue
// under about 20 minutes, so 50 minutes is the safe rotation point.
export const APNS_TOKEN_ROTATION_MS = 50 * 60 * 1000
function base64UrlJson(value: Record<string, unknown>): string {
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')
}
export class ApnsAuthenticationToken {
private readonly privateKey: KeyObject
private cached: { token: string; issuedAtMs: number } | null = null
constructor(
private readonly credentials: ApnsCredentials,
private readonly now: () => number = Date.now,
private readonly rotationMs: number = APNS_TOKEN_ROTATION_MS
) {
this.privateKey = createPrivateKey(credentials.keyPem)
}
value(): string {
const nowMs = this.now()
if (this.cached && nowMs - this.cached.issuedAtMs < this.rotationMs) return this.cached.token
const header = base64UrlJson({ alg: 'ES256', kid: this.credentials.keyId })
const payload = base64UrlJson({
iss: this.credentials.teamId,
iat: Math.floor(nowMs / 1000)
})
const signingInput = `${header}.${payload}`
// ES256 requires the raw r||s pair; Node emits DER unless asked otherwise.
const signature = sign('sha256', Buffer.from(signingInput, 'utf8'), {
key: this.privateKey,
dsaEncoding: 'ieee-p1363'
}).toString('base64url')
const token = `${signingInput}.${signature}`
this.cached = { token, issuedAtMs: nowMs }
return token
}
}
-174
View File
@@ -1,174 +0,0 @@
import { generateKeyPairSync } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { ApnsAuthenticationToken, APNS_TOKEN_ROTATION_MS } from './apns-authentication-token.js'
import { ApnsClient } from './apns-client.js'
import type { ApnsRequest, ApnsResponse } from './apns-http2-transport.js'
import type { ApnsCredentials } from './config.js'
import { buildPushDelivery } from './push-delivery-message.js'
const HOST = 'abcdefghijklmnop'
function credentials(): ApnsCredentials {
const { privateKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
})
return { keyPem: privateKey, keyId: 'ABCDE12345', teamId: 'TEAM123456' }
}
function delivery(coalescedCount = 1) {
return buildPushDelivery({
registrationId: 'reg-1',
hostFingerprint: HOST,
notification: {
notificationId: 'note-1',
notificationSeq: 7,
notificationEpoch: 'epoch-1',
source: 'agent-task-complete',
agentState: 'needs-input',
title: 'Agent needs input',
body: 'Waiting on your answer',
worktreeId: 'wt-1'
},
title: 'Agent needs input',
body: 'Waiting on your answer',
coalescedCount
})
}
function fakeTransport(response: ApnsResponse) {
const requests: ApnsRequest[] = []
return {
requests,
transport: async (request: ApnsRequest): Promise<ApnsResponse> => {
requests.push(request)
return response
}
}
}
describe('apns authentication token', () => {
it('signs an ES256 provider token and caches it until the rotation point', () => {
let clock = 1_700_000_000_000
const authentication = new ApnsAuthenticationToken(credentials(), () => clock)
const first = authentication.value()
const [header, payload, signature] = first.split('.')
expect(JSON.parse(Buffer.from(header!, 'base64url').toString('utf8'))).toEqual({
alg: 'ES256',
kid: 'ABCDE12345'
})
expect(JSON.parse(Buffer.from(payload!, 'base64url').toString('utf8'))).toEqual({
iss: 'TEAM123456',
iat: Math.floor(clock / 1000)
})
expect(Buffer.from(signature!, 'base64url').byteLength).toBe(64)
clock += APNS_TOKEN_ROTATION_MS - 1
expect(authentication.value()).toBe(first)
clock += 1
expect(authentication.value()).not.toBe(first)
})
})
describe('apns client', () => {
it('sends the specified headers, path, and alert body', async () => {
const clock = 1_700_000_000_000
const fake = fakeTransport({ status: 200, body: '' })
const client = new ApnsClient({
topic: 'com.stably.orca.mobile',
credentials: credentials(),
transport: fake.transport,
now: () => clock
})
await expect(
client.send(delivery(), { token: 'a'.repeat(64), apnsEnvironment: 'production' })
).resolves.toEqual({ status: 'sent' })
const request = fake.requests[0]!
expect(request.host).toBe('api.push.apple.com')
expect(request.path).toBe(`/3/device/${'a'.repeat(64)}`)
expect(request.headers).toMatchObject({
'apns-topic': 'com.stably.orca.mobile',
'apns-push-type': 'alert',
'apns-priority': '10',
'apns-expiration': String(Math.floor(clock / 1000) + 4 * 60 * 60),
'apns-collapse-id': 'note-1'
})
expect(request.headers.authorization).toMatch(/^bearer /)
expect(JSON.parse(request.body)).toEqual({
aps: {
alert: { title: 'Agent needs input', body: 'Waiting on your answer' },
sound: 'default',
'thread-id': HOST
},
orca: {
hostFingerprint: HOST,
worktreeId: 'wt-1',
notificationId: 'note-1',
notificationSeq: 7,
notificationEpoch: 'epoch-1',
source: 'agent-task-complete',
agentState: 'needs-input',
coalescedCount: 1
}
})
})
it('targets the sandbox host and the host collapse id for a summary', async () => {
const fake = fakeTransport({ status: 200, body: '' })
const client = new ApnsClient({
topic: 'com.stably.orca.mobile',
credentials: credentials(),
transport: fake.transport
})
await client.send(delivery(3), { token: 'b'.repeat(64), apnsEnvironment: 'sandbox' })
expect(fake.requests[0]?.host).toBe('api.sandbox.push.apple.com')
expect(fake.requests[0]?.headers['apns-collapse-id']).toBe(`host:${HOST}`)
})
it.each([
[410, 'Unregistered'],
[400, 'BadDeviceToken'],
[400, 'Unregistered'],
[400, 'DeviceTokenNotForTopic']
])('classifies %i %s as a dead token', async (status, reason) => {
const fake = fakeTransport({ status, body: JSON.stringify({ reason }) })
const client = new ApnsClient({
topic: 'com.stably.orca.mobile',
credentials: credentials(),
transport: fake.transport
})
await expect(
client.send(delivery(), { token: 'a'.repeat(64), apnsEnvironment: 'production' })
).resolves.toEqual({ status: 'dead', reason })
})
it.each([
[400, 'PayloadTooLarge'],
[429, 'TooManyRequests'],
[500, 'InternalServerError']
])('treats %i %s with the appropriate retry policy', async (status, reason) => {
const fake = fakeTransport({ status, body: JSON.stringify({ reason }) })
const client = new ApnsClient({
topic: 'com.stably.orca.mobile',
credentials: credentials(),
transport: fake.transport
})
await expect(
client.send(delivery(), { token: 'a'.repeat(64), apnsEnvironment: 'production' })
).resolves.toEqual({ status: 'error', reason, retryable: status === 429 || status >= 500 })
})
it('reports a transport failure as an error rather than throwing', async () => {
const client = new ApnsClient({
topic: 'com.stably.orca.mobile',
credentials: credentials(),
transport: async () => {
throw new Error('socket hang up')
}
})
await expect(
client.send(delivery(), { token: 'a'.repeat(64), apnsEnvironment: 'production' })
).resolves.toEqual({ status: 'error', reason: 'Error', retryable: true })
})
})
-91
View File
@@ -1,91 +0,0 @@
import { PUSH_LIMITS, type ApnsEnvironment } from '@orca-cloud/push-contract'
import { ApnsAuthenticationToken } from './apns-authentication-token.js'
import type { ApnsTransport } from './apns-http2-transport.js'
import type { ApnsCredentials } from './config.js'
import type { PushDelivery } from './push-delivery-message.js'
import type { PushProviderOutcome } from './push-provider-outcome.js'
const APNS_HOSTS: Record<ApnsEnvironment, string> = {
production: 'api.push.apple.com',
sandbox: 'api.sandbox.push.apple.com'
}
const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic'])
export type ApnsClientOptions = {
topic: string
credentials: ApnsCredentials
transport: ApnsTransport
now?: () => number
}
function readReason(body: string): string {
try {
const parsed = JSON.parse(body) as { reason?: unknown }
return typeof parsed.reason === 'string' ? parsed.reason : 'unknown'
} catch {
return 'unparseable'
}
}
export function apnsBody(delivery: PushDelivery): string {
return JSON.stringify({
aps: {
alert: { title: delivery.title, body: delivery.body },
...(delivery.sound === false ? {} : { sound: 'default' }),
'thread-id': delivery.hostFingerprint
},
orca: delivery.orca
})
}
export class ApnsClient {
private readonly authentication: ApnsAuthenticationToken
private readonly now: () => number
constructor(private readonly options: ApnsClientOptions) {
this.now = options.now ?? Date.now
this.authentication = new ApnsAuthenticationToken(options.credentials, this.now)
}
async send(
delivery: PushDelivery,
device: { token: string; apnsEnvironment: ApnsEnvironment }
): Promise<PushProviderOutcome> {
const expiration = Math.floor(this.now() / 1000) + PUSH_LIMITS.notificationTtlSeconds
let response
try {
response = await this.options.transport({
host: APNS_HOSTS[device.apnsEnvironment],
path: `/3/device/${device.token}`,
headers: {
authorization: `bearer ${this.authentication.value()}`,
'apns-topic': this.options.topic,
'apns-push-type': 'alert',
'apns-priority': '10',
'apns-expiration': String(expiration),
'apns-collapse-id': delivery.collapseId
},
body: apnsBody(delivery)
})
} catch (error) {
return {
status: 'error',
reason: error instanceof Error ? error.name : 'transport_failed',
retryable: true
}
}
if (response.status === 200) return { status: 'sent' }
const reason = readReason(response.body)
if (response.status === 410) return { status: 'dead', reason }
if (response.status === 400 && DEAD_TOKEN_REASONS.has(reason)) {
return { status: 'dead', reason }
}
return {
status: 'error',
reason,
retryable: response.status === 429 || response.status >= 500,
...(response.retryAfterMs === undefined ? {} : { retryAfterMs: response.retryAfterMs })
}
}
}
@@ -1,50 +0,0 @@
import { connect, constants, type ClientHttp2Session } from 'node:http2'
import { readApnsStreamResponse, type ApnsResponse } from './apns-stream-response.js'
export type ApnsRequest = {
host: string
path: string
headers: Record<string, string>
body: string
}
export type { ApnsResponse }
export type ApnsTransport = (request: ApnsRequest) => Promise<ApnsResponse>
// APNs requires HTTP/2 and rewards a long-lived session per host, so sessions
// are cached and only dropped when the socket itself goes away.
export function createApnsHttp2Transport(): ApnsTransport & { close(): void } {
const sessions = new Map<string, ClientHttp2Session>()
const sessionFor = (host: string): ClientHttp2Session => {
const existing = sessions.get(host)
if (existing && !existing.closed && !existing.destroyed) return existing
const session = connect(`https://${host}`)
const forget = (): void => {
if (sessions.get(host) === session) sessions.delete(host)
}
session.on('error', forget)
session.on('close', forget)
sessions.set(host, session)
return session
}
const transport = async (request: ApnsRequest): Promise<ApnsResponse> => {
const stream = sessionFor(request.host).request({
...request.headers,
[constants.HTTP2_HEADER_METHOD]: 'POST',
[constants.HTTP2_HEADER_PATH]: request.path,
[constants.HTTP2_HEADER_AUTHORITY]: request.host,
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(request.body))
})
return await readApnsStreamResponse(stream, request.body)
}
return Object.assign(transport, {
close(): void {
for (const session of sessions.values()) session.close()
sessions.clear()
}
})
}
@@ -1,45 +0,0 @@
import { EventEmitter } from 'node:events'
import { expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
connect: vi.fn(),
read: vi.fn(async () => ({ status: 200, body: '' }))
}))
vi.mock('node:http2', async (original) => ({
...(await original<typeof import('node:http2')>()),
connect: mocks.connect
}))
vi.mock('./apns-stream-response.js', () => ({ readApnsStreamResponse: mocks.read }))
import { createApnsHttp2Transport } from './apns-http2-transport.js'
it('keeps the replacement cached when the draining session closes later', async () => {
const sessions: Array<
EventEmitter & {
closed: boolean
destroyed: boolean
request: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
}
> = []
mocks.connect.mockImplementation(() => {
const session = Object.assign(new EventEmitter(), {
closed: false,
destroyed: false,
request: vi.fn(() => ({})),
close: vi.fn()
})
sessions.push(session)
return session
})
const transport = createApnsHttp2Transport()
const request = { host: 'api.push.apple.com', path: '/synthetic', headers: {}, body: '{}' }
await transport(request)
sessions[0]!.closed = true
await transport(request)
sessions[0]!.emit('close')
sessions[0]!.emit('error', new Error('old-session'))
await transport(request)
expect(sessions).toHaveLength(2)
expect(sessions[1]!.request).toHaveBeenCalledTimes(2)
transport.close()
expect(sessions[1]!.close).toHaveBeenCalledOnce()
})
@@ -1,82 +0,0 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it } from 'vitest'
import { readApnsStreamResponse, type ApnsResponseStream } from './apns-stream-response.js'
type FakeStream = ApnsResponseStream & {
sentBody: string | null
destroyedWith: Error | null
fireTimeout(): void
}
function fakeApnsStream(): FakeStream {
const emitter = new EventEmitter() as FakeStream
emitter.sentBody = null
emitter.destroyedWith = null
let onTimeout: (() => void) | null = null
emitter.setTimeout = (_ms, callback) => {
onTimeout = callback
}
emitter.destroy = (error?: Error) => {
emitter.destroyedWith = error ?? null
if (error) emitter.emit('error', error)
}
emitter.end = (body: string) => {
emitter.sentBody = body
}
emitter.fireTimeout = () => onTimeout?.()
return emitter
}
describe('apns stream response', () => {
it('resolves with the status and the concatenated body', async () => {
const stream = fakeApnsStream()
const pending = readApnsStreamResponse(stream, '{"aps":{}}')
expect(stream.sentBody).toBe('{"aps":{}}')
stream.emit('response', { ':status': '200' })
stream.emit('data', Buffer.from('{"re'))
stream.emit('data', Buffer.from('ason":"ok"}'))
stream.emit('end')
await expect(pending).resolves.toEqual({ status: 200, body: '{"reason":"ok"}' })
})
it('rejects when the peer resets the stream without an end or an error', async () => {
const stream = fakeApnsStream()
const pending = readApnsStreamResponse(stream, 'body')
stream.emit('response', { ':status': '200' })
// NGHTTP2_NO_ERROR: node emits only 'close', so nothing else would settle.
stream.emit('close')
await expect(pending).rejects.toThrow('apns_stream_closed')
})
it('keeps the resolved response when close follows a completed end', async () => {
const stream = fakeApnsStream()
const pending = readApnsStreamResponse(stream, 'body')
stream.emit('response', { ':status': '410' })
stream.emit('end')
stream.emit('close')
await expect(pending).resolves.toEqual({ status: 410, body: '' })
})
it('keeps the original error when close follows a stream error', async () => {
const stream = fakeApnsStream()
const pending = readApnsStreamResponse(stream, 'body')
stream.emit('error', new Error('socket_hang_up'))
stream.emit('close')
await expect(pending).rejects.toThrow('socket_hang_up')
})
it('destroys the stream on timeout and surfaces the timeout error', async () => {
const stream = fakeApnsStream()
const pending = readApnsStreamResponse(stream, 'body', 10)
stream.fireTimeout()
await expect(pending).rejects.toThrow('apns_timeout')
expect(stream.destroyedWith?.message).toBe('apns_timeout')
})
it('reports a missing status header as zero rather than NaN', async () => {
const stream = fakeApnsStream()
const pending = readApnsStreamResponse(stream, 'body')
stream.emit('end')
await expect(pending).resolves.toEqual({ status: 0, body: '' })
})
})
@@ -1,53 +0,0 @@
import type { EventEmitter } from 'node:events'
import { providerRetryAfter } from './provider-retry-delay.js'
import { constants } from 'node:http2'
export type ApnsResponse = { status: number; body: string; retryAfterMs?: number }
// The subset of ClientHttp2Stream this module drives, so a fake emitter can
// stand in for a real APNs stream in tests.
export type ApnsResponseStream = EventEmitter & {
setTimeout(ms: number, callback: () => void): void
destroy(error?: Error): void
end(body: string): void
}
export const APNS_REQUEST_TIMEOUT_MS = 10_000
export function readApnsStreamResponse(
stream: ApnsResponseStream,
body: string,
timeoutMs = APNS_REQUEST_TIMEOUT_MS
): Promise<ApnsResponse> {
return new Promise<ApnsResponse>((resolve, reject) => {
let settled = false
const settle = (run: () => void): void => {
if (settled) return
settled = true
run()
}
let status = 0
let retryAfterMs: number | undefined
const chunks: Buffer[] = []
stream.setTimeout(timeoutMs, () => stream.destroy(new Error('apns_timeout')))
stream.on('response', (headers: Record<string, unknown>) => {
status = Number(headers[constants.HTTP2_HEADER_STATUS] ?? 0)
retryAfterMs = providerRetryAfter(String(headers['retry-after'] ?? ''))
})
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
stream.on('error', (error: Error) => settle(() => reject(error)))
stream.on('end', () =>
settle(() =>
resolve({
status,
body: Buffer.concat(chunks).toString('utf8'),
...(retryAfterMs === undefined ? {} : { retryAfterMs })
})
)
)
// A peer reset with NGHTTP2_NO_ERROR emits neither 'end' nor 'error', which
// would leave the coalescer's delivery pending for the life of the process.
stream.on('close', () => settle(() => reject(new Error('apns_stream_closed'))))
stream.end(body)
})
}
-9
View File
@@ -1,9 +0,0 @@
// Rejects the many base64 spellings of the same bytes: a non-canonical
// encoding would change the transcript the host signs without changing the key.
export function decodeCanonicalBase64(value: string, expectedBytes: number): Buffer | null {
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null
const decoded = Buffer.from(value, 'base64')
return decoded.byteLength === expectedBytes && decoded.toString('base64') === value
? decoded
: null
}
@@ -1,145 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { Hono } from 'hono'
import { describe, expect, it } from 'vitest'
import { ClientIpRateLimiter, clientIpRateLimit } from './client-ip-rate-limit.js'
const CAPACITY = PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp
function limiterApp(limiter: ClientIpRateLimiter, trustedProxyHops = 0): Hono {
const app = new Hono()
app.post('/probe', clientIpRateLimit(limiter, { trustedProxyHops }), (context) =>
context.json({ ok: true })
)
return app
}
describe('client ip rate limiter', () => {
it('admits exactly the per-minute allowance and refuses the next request', () => {
const limiter = new ClientIpRateLimiter({ now: () => 1_000 })
for (let index = 0; index < CAPACITY; index++) {
expect(limiter.allow('203.0.113.7')).toBe(true)
}
expect(limiter.allow('203.0.113.7')).toBe(false)
})
it('keeps one client ip from spending another one budget', () => {
const limiter = new ClientIpRateLimiter({ now: () => 1_000 })
for (let index = 0; index < CAPACITY; index++) limiter.allow('203.0.113.7')
expect(limiter.allow('203.0.113.7')).toBe(false)
expect(limiter.allow('198.51.100.9')).toBe(true)
})
it('refills over the window rather than resetting on a boundary', () => {
let clock = 1_000
const limiter = new ClientIpRateLimiter({ now: () => clock })
for (let index = 0; index < CAPACITY; index++) limiter.allow('203.0.113.7')
expect(limiter.allow('203.0.113.7')).toBe(false)
// Half a window buys back half the allowance, no more.
clock += 30_000
for (let index = 0; index < CAPACITY / 2; index++) {
expect(limiter.allow('203.0.113.7')).toBe(true)
}
expect(limiter.allow('203.0.113.7')).toBe(false)
})
it('bounds what it remembers when a flood of distinct ips arrives', () => {
let clock = 1_000
const limiter = new ClientIpRateLimiter({ now: () => clock, maxTrackedIps: 8 })
for (let index = 0; index < 200; index++) {
clock += 1
limiter.allow(`198.51.100.${index}`)
}
expect(limiter.trackedIpCount()).toBeLessThanOrEqual(8)
})
it('answers 429 with a rate_limited body once the bucket is empty', async () => {
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 }))
const headers = { 'x-forwarded-for': '10.0.0.1, 10.0.0.2, 203.0.113.7' }
for (let index = 0; index < CAPACITY; index++) {
expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200)
}
const limited = await app.request('/probe', { method: 'POST', headers })
expect(limited.status).toBe(429)
expect(await limited.json()).toEqual({ error: 'rate_limited' })
})
it('buckets on the last forwarded hop, the only one the platform appended', async () => {
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 }))
for (let index = 0; index < CAPACITY; index++) {
await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': `10.0.0.${index}, 203.0.113.7` }
})
}
const sameClient = await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': '10.9.9.9, 203.0.113.7' }
})
expect(sameClient.status).toBe(429)
const otherClient = await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': '10.0.0.1, 198.51.100.9' }
})
expect(otherClient.status).toBe(200)
})
it('gives a spoofed left-most hop no escape from the caller own bucket', async () => {
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 }))
// A caller that rewrites its own x-forwarded-for on every request still ends
// up behind the one value Cloud Run appended.
for (let index = 0; index < CAPACITY; index++) {
const allowed = await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': `198.51.100.${index}, 203.0.113.7` }
})
expect(allowed.status).toBe(200)
}
const spoofed = await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': '198.51.100.250, 10.1.1.1, 203.0.113.7' }
})
expect(spoofed.status).toBe(429)
})
it('skips the configured trusted proxies when counting from the right', async () => {
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }), 1)
// <client>, <cloud run>, <load balancer>: one trusted hop after the client.
const headers = { 'x-forwarded-for': '203.0.113.7, 10.0.0.1' }
expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200)
expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(429)
expect(
(await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': '198.51.100.9, 10.0.0.1' }
})).status
).toBe(200)
})
it('trusts nothing when the header is shorter than the configured depth', async () => {
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }), 1)
// Only one hop, so the client value the depth points at does not exist.
const headers = { 'x-forwarded-for': '203.0.113.7' }
expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200)
expect(
(await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': '198.51.100.9' }
})).status
).toBe(429)
})
it('falls back to x-real-ip and then to a single shared bucket', async () => {
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }))
expect(
(await app.request('/probe', { method: 'POST', headers: { 'x-real-ip': '203.0.113.7' } }))
.status
).toBe(200)
expect(
(await app.request('/probe', { method: 'POST', headers: { 'x-real-ip': '203.0.113.7' } }))
.status
).toBe(429)
expect((await app.request('/probe', { method: 'POST' })).status).toBe(200)
expect((await app.request('/probe', { method: 'POST' })).status).toBe(429)
})
})
-110
View File
@@ -1,110 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import type { Context, MiddlewareHandler } from 'hono'
const REFILL_WINDOW_MS = 60_000
const MAX_TRACKED_IPS = 10_000
const UNKNOWN_CLIENT_IP = 'unknown'
export type ClientIpRateLimiterOptions = {
capacity?: number
windowMs?: number
maxTrackedIps?: number
now?: () => number
}
type Bucket = { tokens: number; updatedAt: number }
// Read x-forwarded-for from the right. Cloud Run appends the connecting peer,
// so the last value is the only one it wrote; everything to its left is
// whatever the caller sent and can be a fresh forgery on every request.
// trustedProxyHops is how many appenders sit between Cloud Run and the client
// (0 today, 1 once a load balancer fronts it). A header too short for that
// depth is not trusted at all and falls through to the shared bucket, which
// throttles rather than opens.
export function readClientIp(context: Context, trustedProxyHops = 0): string {
const hops =
context.req
.header('x-forwarded-for')
?.split(',')
.map((hop) => hop.trim())
.filter((hop) => hop.length > 0) ?? []
const client = hops[hops.length - 1 - trustedProxyHops]
if (client) return client
return context.req.header('x-real-ip')?.trim() || UNKNOWN_CLIENT_IP
}
// In-memory and per-instance on purpose. A shared counter would put a database
// round trip in front of the only routes an attacker can reach unauthenticated,
// and Cloud Run's instance fan-out only loosens the cap by the instance count.
export class ClientIpRateLimiter {
private readonly buckets = new Map<string, Bucket>()
private readonly capacity: number
private readonly windowMs: number
private readonly maxTrackedIps: number
private readonly now: () => number
constructor(options: ClientIpRateLimiterOptions = {}) {
this.capacity = options.capacity ?? PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp
this.windowMs = options.windowMs ?? REFILL_WINDOW_MS
this.maxTrackedIps = options.maxTrackedIps ?? MAX_TRACKED_IPS
this.now = options.now ?? Date.now
}
allow(clientIp: string): boolean {
const now = this.now()
const tokens = this.tokensAt(this.buckets.get(clientIp), now)
if (tokens < 1) {
this.buckets.set(clientIp, { tokens, updatedAt: now })
return false
}
this.buckets.set(clientIp, { tokens: tokens - 1, updatedAt: now })
this.evict(now)
return true
}
trackedIpCount(): number {
return this.buckets.size
}
private tokensAt(bucket: Bucket | undefined, now: number): number {
if (!bucket) return this.capacity
const refilled = ((now - bucket.updatedAt) * this.capacity) / this.windowMs
return Math.min(this.capacity, bucket.tokens + Math.max(0, refilled))
}
private evict(now: number): void {
if (this.buckets.size <= this.maxTrackedIps) return
// A bucket that has refilled to capacity is indistinguishable from an
// absent one, so dropping it changes no decision.
for (const [clientIp, bucket] of this.buckets) {
if (this.tokensAt(bucket, now) >= this.capacity) this.buckets.delete(clientIp)
}
if (this.buckets.size <= this.maxTrackedIps) return
// A flood of distinct live IPs can still overflow. The least recently seen
// are the least likely to be mid-burst.
const excess = [...this.buckets.entries()]
.sort((left, right) => left[1].updatedAt - right[1].updatedAt)
.slice(0, this.buckets.size - this.maxTrackedIps)
for (const [clientIp] of excess) this.buckets.delete(clientIp)
}
}
export type ClientIpRateLimitOptions = {
trustedProxyHops?: number
onLimited?: () => void
}
export function clientIpRateLimit(
limiter: ClientIpRateLimiter,
options: ClientIpRateLimitOptions = {}
): MiddlewareHandler {
const trustedProxyHops = options.trustedProxyHops ?? 0
return async (context, next) => {
if (!limiter.allow(readClientIp(context, trustedProxyHops))) {
options.onLimited?.()
return context.json({ error: 'rate_limited' }, 429)
}
await next()
return
}
}
-173
View File
@@ -1,173 +0,0 @@
import type { PushNotification } from '@orca-cloud/push-contract'
import { describe, expect, it } from 'vitest'
import { PushCoalescer, summaryBody, type CoalescerTimer } from './coalescer.js'
import type { PushDelivery } from './push-delivery-message.js'
const HOST = 'abcdefghijklmnop'
function notification(overrides: Partial<PushNotification> = {}): PushNotification {
return {
notificationId: 'note-1',
notificationSeq: 1,
notificationEpoch: 'epoch-1',
source: 'agent-task-complete',
agentState: 'needs-input',
title: 'Agent needs input',
body: 'Waiting on your answer',
worktreeId: 'wt-1',
...overrides
}
}
// A manual timer queue so a 3s window is exercised without waiting 3s.
function createTimerHarness() {
const pending = new Map<number, () => void>()
let nextId = 0
return {
delays: [] as number[],
setTimer(callback: () => void, delayMs: number): CoalescerTimer {
const handle = nextId++
pending.set(handle, callback)
this.delays.push(delayMs)
return { handle }
},
clearTimer(timer: CoalescerTimer): void {
pending.delete(timer.handle as number)
},
fireAll(): void {
for (const callback of [...pending.values()]) callback()
}
}
}
function createCoalescer(windowMs = 3_000) {
const timers = createTimerHarness()
const delivered: PushDelivery[] = []
const coalescer = new PushCoalescer({
windowMs,
deliver: async (delivery) => {
delivered.push(delivery)
},
setTimer: (callback, delayMs) => timers.setTimer(callback, delayMs),
clearTimer: (timer) => timers.clearTimer(timer)
})
return { coalescer, delivered, timers }
}
describe('push coalescer', () => {
it('sends a single event unchanged with the notification collapse id', async () => {
const { coalescer, delivered, timers } = createCoalescer()
coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
expect(timers.delays).toEqual([3_000])
expect(delivered).toHaveLength(0)
await coalescer.flush('reg-1')
expect(delivered).toHaveLength(1)
expect(delivered[0]).toMatchObject({
registrationId: 'reg-1',
title: 'Agent needs input',
body: 'Waiting on your answer',
collapseId: 'note-1'
})
expect(delivered[0]?.orca).toMatchObject({
hostFingerprint: HOST,
notificationId: 'note-1',
notificationSeq: 1,
worktreeId: 'wt-1',
coalescedCount: 1
})
})
it('falls back to the host collapse id when the event carries no notification id', async () => {
const { coalescer, delivered } = createCoalescer()
const { notificationId: _absent, ...bell } = notification({ source: 'terminal-bell' })
coalescer.enqueue({
registrationId: 'reg-1',
hostFingerprint: HOST,
notification: { ...bell, agentState: null }
})
await coalescer.flush('reg-1')
expect(delivered[0]?.collapseId).toBe(`host:${HOST}`)
expect(delivered[0]?.orca.notificationId).toBeUndefined()
})
it('summarises a burst and collapses it under the host id', async () => {
const { coalescer, delivered } = createCoalescer()
for (const seq of [1, 2, 3]) {
coalescer.enqueue({
registrationId: 'reg-1',
hostFingerprint: HOST,
notification: notification({ notificationId: `note-${seq}`, notificationSeq: seq })
})
}
expect(coalescer.pendingCount('reg-1')).toBe(3)
await coalescer.flush('reg-1')
expect(delivered).toHaveLength(1)
expect(delivered[0]).toMatchObject({
title: 'Orca',
body: '3 agents need attention',
collapseId: `host:${HOST}`
})
// The data carries the latest event, so a tap still opens the newest work.
expect(delivered[0]?.orca).toMatchObject({
notificationId: 'note-3',
notificationSeq: 3,
coalescedCount: 3
})
})
it('says updates when no event in the burst needs input', async () => {
const { coalescer, delivered } = createCoalescer()
for (const seq of [1, 2]) {
coalescer.enqueue({
registrationId: 'reg-1',
hostFingerprint: HOST,
notification: notification({ notificationSeq: seq, agentState: 'finished' })
})
}
await coalescer.flush('reg-1')
expect(delivered[0]?.body).toBe('2 updates')
expect(summaryBody([notification({ agentState: null }), notification({ agentState: null })]))
.toBe('2 updates')
})
it('keeps one window per registration', async () => {
const { coalescer, delivered, timers } = createCoalescer()
coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
coalescer.enqueue({ registrationId: 'reg-2', hostFingerprint: HOST, notification: notification() })
coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
expect(timers.delays).toHaveLength(2)
await coalescer.flushAll()
expect(delivered.map((delivery) => delivery.registrationId).sort()).toEqual(['reg-1', 'reg-2'])
expect(delivered.find((d) => d.registrationId === 'reg-1')?.orca.coalescedCount).toBe(2)
expect(delivered.find((d) => d.registrationId === 'reg-2')?.orca.coalescedCount).toBe(1)
})
it('flushes when the window timer fires and starts a fresh window after', async () => {
const { coalescer, delivered, timers } = createCoalescer()
coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
timers.fireAll()
await Promise.resolve()
expect(delivered).toHaveLength(1)
coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
expect(coalescer.pendingCount('reg-1')).toBe(1)
await coalescer.flushAll()
expect(delivered).toHaveLength(2)
})
it('reports a delivery failure instead of throwing into the caller', async () => {
const failures: unknown[] = []
const coalescer = new PushCoalescer({
windowMs: 0,
deliver: async () => {
throw new Error('provider down')
},
setTimer: () => ({ handle: null }),
clearTimer: () => undefined,
onDeliveryFailed: (error) => failures.push(error)
})
coalescer.enqueue({ registrationId: 'reg-1', hostFingerprint: HOST, notification: notification() })
await expect(coalescer.flush('reg-1')).resolves.toBeUndefined()
expect(failures).toHaveLength(1)
coalescer.stop()
})
})
-117
View File
@@ -1,117 +0,0 @@
import { PUSH_LIMITS, type PushNotification } from '@orca-cloud/push-contract'
import { buildPushDelivery, type PushDelivery } from './push-delivery-message.js'
export type CoalescerTimer = { readonly handle: unknown }
export type PushCoalescerOptions = {
windowMs?: number
deliver: (delivery: PushDelivery) => Promise<void>
setTimer?: (callback: () => void, delayMs: number) => CoalescerTimer
clearTimer?: (timer: CoalescerTimer) => void
onDeliveryFailed?: (error: unknown) => void
}
type PendingWindow = {
hostFingerprint: string
notifications: PushNotification[]
timer: CoalescerTimer
}
function defaultSetTimer(callback: () => void, delayMs: number): CoalescerTimer {
const handle = setTimeout(callback, delayMs)
handle.unref?.()
return { handle }
}
function defaultClearTimer(timer: CoalescerTimer): void {
clearTimeout(timer.handle as NodeJS.Timeout)
}
export function summaryBody(notifications: readonly PushNotification[]): string {
const count = notifications.length
return notifications.some((notification) => notification.agentState === 'needs-input')
? `${count} agents need attention`
: `${count} updates`
}
// Holds sends per registration for one window so a burst of desktop events
// reaches the phone as a single banner instead of a stack of near-duplicates.
export class PushCoalescer {
private readonly deliveries = new Set<Promise<void>>()
private stopped = false
private readonly windows = new Map<string, PendingWindow>()
private readonly windowMs: number
private readonly setTimer: (callback: () => void, delayMs: number) => CoalescerTimer
private readonly clearTimer: (timer: CoalescerTimer) => void
constructor(private readonly options: PushCoalescerOptions) {
this.windowMs = options.windowMs ?? PUSH_LIMITS.coalesceWindowMs
this.setTimer = options.setTimer ?? defaultSetTimer
this.clearTimer = options.clearTimer ?? defaultClearTimer
}
enqueue(input: {
registrationId: string
hostFingerprint: string
notification: PushNotification
}): void {
if (this.stopped) throw new Error('push_coalescer_stopped')
const existing = this.windows.get(input.registrationId)
if (existing) {
existing.notifications.push(input.notification)
return
}
this.windows.set(input.registrationId, {
hostFingerprint: input.hostFingerprint,
notifications: [input.notification],
timer: this.setTimer(() => {
void this.flush(input.registrationId)
}, this.windowMs)
})
}
pendingCount(registrationId: string): number {
return this.windows.get(registrationId)?.notifications.length ?? 0
}
async flush(registrationId: string): Promise<void> {
const window = this.windows.get(registrationId)
if (!window) return
this.windows.delete(registrationId)
this.clearTimer(window.timer)
const latest = window.notifications.at(-1)!
const coalescedCount = window.notifications.length
const delivery = buildPushDelivery({
registrationId,
hostFingerprint: window.hostFingerprint,
notification: latest,
title: coalescedCount > 1 ? 'Orca' : latest.title,
body: coalescedCount > 1 ? summaryBody(window.notifications) : latest.body,
coalescedCount
})
const pending = Promise.resolve()
.then(() => this.options.deliver(delivery))
.catch((error) => {
this.options.onDeliveryFailed?.(error)
})
this.deliveries.add(pending)
try {
await pending
} finally {
this.deliveries.delete(pending)
}
}
async flushAll(): Promise<void> {
do {
await Promise.all([...this.windows.keys()].map((id) => this.flush(id)))
await Promise.all([...this.deliveries])
} while (this.windows.size || this.deliveries.size)
}
stop(): void {
this.stopped = true
for (const window of this.windows.values()) this.clearTimer(window.timer)
this.windows.clear()
}
}
-90
View File
@@ -1,90 +0,0 @@
import { generateKeyPairSync } from 'node:crypto'
import { PUSH_DEFAULTS, PUSH_LIMITS } from '@orca-cloud/push-contract'
import { describe, expect, it } from 'vitest'
import { loadPushConfig, PUSH_DATABASE_POOL_MAX } from './config.js'
function apnsKeyPem(): string {
return generateKeyPairSync('ec', {
namedCurve: 'P-256',
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
}).privateKey
}
const MINIMAL = { ORCA_PUSH_PUBLIC_URL: 'https://push.onorca.dev' }
describe('push gateway config', () => {
it('applies the documented defaults', () => {
expect(loadPushConfig(MINIMAL)).toEqual({
port: 8080,
publicUrl: 'https://push.onorca.dev',
databaseUrl: undefined,
dataDir: './data/push',
databasePoolMax: PUSH_DATABASE_POOL_MAX,
apns: undefined,
apnsTopic: PUSH_DEFAULTS.apnsTopic,
fcmProjectId: PUSH_DEFAULTS.fcmProjectId,
coalesceMs: PUSH_LIMITS.coalesceWindowMs,
trustedProxyHops: 0
})
})
it('reads a full APNs credential and the overridable knobs', () => {
const keyPem = apnsKeyPem()
const config = loadPushConfig({
...MINIMAL,
PORT: '9090',
ORCA_PUSH_DATABASE_URL: 'postgres://localhost/orca_push',
ORCA_PUSH_DATA_DIR: '/var/lib/push',
ORCA_PUSH_APNS_KEY: keyPem,
ORCA_PUSH_APNS_KEY_ID: 'ABCDE12345',
ORCA_PUSH_APPLE_TEAM_ID: 'TEAM123456',
ORCA_PUSH_APNS_TOPIC: 'com.stably.orca.mobile.dev',
ORCA_PUSH_FCM_PROJECT_ID: 'onorca-staging',
ORCA_PUSH_COALESCE_MS: '1500',
ORCA_PUSH_TRUSTED_PROXY_HOPS: '1'
})
expect(config).toMatchObject({
port: 9090,
databaseUrl: 'postgres://localhost/orca_push',
dataDir: '/var/lib/push',
apns: { keyPem, keyId: 'ABCDE12345', teamId: 'TEAM123456' },
apnsTopic: 'com.stably.orca.mobile.dev',
trustedProxyHops: 1,
fcmProjectId: 'onorca-staging',
coalesceMs: 1500
})
})
it('refuses a partial APNs credential', () => {
expect(() =>
loadPushConfig({ ...MINIMAL, ORCA_PUSH_APNS_KEY: apnsKeyPem() })
).toThrow('configured together')
expect(() =>
loadPushConfig({
...MINIMAL,
ORCA_PUSH_APNS_KEY: 'not-a-pem',
ORCA_PUSH_APNS_KEY_ID: 'ABCDE12345',
ORCA_PUSH_APPLE_TEAM_ID: 'TEAM123456'
})
).toThrow('PEM text')
})
it('requires a canonical HTTPS origin outside loopback', () => {
expect(() => loadPushConfig({ ORCA_PUSH_PUBLIC_URL: 'https://push.onorca.dev/v1' })).toThrow(
'must be an origin'
)
expect(() => loadPushConfig({ ORCA_PUSH_PUBLIC_URL: 'http://push.onorca.dev' })).toThrow(
'must use HTTPS'
)
expect(loadPushConfig({ ORCA_PUSH_PUBLIC_URL: 'http://localhost:8080' }).publicUrl).toBe(
'http://localhost:8080'
)
})
it('treats an empty optional variable as unset', () => {
expect(
loadPushConfig({ ...MINIMAL, ORCA_PUSH_DATABASE_URL: '', ORCA_PUSH_APNS_KEY_ID: '' })
).toMatchObject({ databaseUrl: undefined, apns: undefined })
})
})
-105
View File
@@ -1,105 +0,0 @@
import { PUSH_DEFAULTS, PUSH_LIMITS } from '@orca-cloud/push-contract'
import { z } from 'zod'
export const PUSH_DATABASE_POOL_MAX = 10
const OptionalTextSchema = z.preprocess(
(value) => (value === '' ? undefined : value),
z.string().min(1).optional()
)
const EnvSchema = z.object({
PORT: z.coerce.number().int().positive().default(8080),
ORCA_PUSH_PUBLIC_URL: z.string().url(),
ORCA_PUSH_DATABASE_URL: OptionalTextSchema,
ORCA_PUSH_DATA_DIR: z.string().min(1).default('./data/push'),
ORCA_PUSH_DATABASE_POOL_MAX: z.coerce.number().int().positive().max(100).optional(),
ORCA_PUSH_APNS_KEY: OptionalTextSchema,
ORCA_PUSH_APNS_KEY_ID: z.preprocess(
(value) => (value === '' ? undefined : value),
z.string().regex(/^[A-Z0-9]{10}$/).optional()
),
ORCA_PUSH_APPLE_TEAM_ID: z.preprocess(
(value) => (value === '' ? undefined : value),
z.string().regex(/^[A-Z0-9]{10}$/).optional()
),
ORCA_PUSH_APNS_TOPIC: z.string().min(1).max(255).default(PUSH_DEFAULTS.apnsTopic),
ORCA_PUSH_FCM_PROJECT_ID: z
.string()
.regex(/^[a-z0-9-]{4,64}$/)
.default(PUSH_DEFAULTS.fcmProjectId),
ORCA_PUSH_COALESCE_MS: z.coerce
.number()
.int()
.nonnegative()
.max(60_000)
.default(PUSH_LIMITS.coalesceWindowMs),
// How many proxies append to x-forwarded-for after the client. 0 is Cloud Run
// alone; raise it to 1 when a load balancer fronts the service.
ORCA_PUSH_TRUSTED_PROXY_HOPS: z.coerce.number().int().nonnegative().max(8).default(0)
})
export type ApnsCredentials = { keyPem: string; keyId: string; teamId: string }
export type PushConfig = {
port: number
publicUrl: string
databaseUrl?: string
dataDir: string
databasePoolMax: number
apns?: ApnsCredentials
apnsTopic: string
fcmProjectId: string
coalesceMs: number
trustedProxyHops: number
}
function canonicalOrigin(value: string, name: string): string {
const url = new URL(value)
if (url.origin !== value || url.pathname !== '/') throw new Error(`${name} must be an origin`)
const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)
if (url.protocol !== 'https:' && !(loopback && url.protocol === 'http:')) {
throw new Error(`${name} must use HTTPS outside loopback development`)
}
return value
}
// The APNs key, key id, and team id are one credential; a partial set would
// pass startup and then fail every iOS send at runtime.
function readApnsCredentials(
parsed: z.infer<typeof EnvSchema>
): ApnsCredentials | undefined {
const parts = [
parsed.ORCA_PUSH_APNS_KEY,
parsed.ORCA_PUSH_APNS_KEY_ID,
parsed.ORCA_PUSH_APPLE_TEAM_ID
]
const present = parts.filter((value) => value !== undefined).length
if (present === 0) return undefined
if (present !== parts.length) {
throw new Error('APNs key, key id, and team id must be configured together')
}
const keyPem = parsed.ORCA_PUSH_APNS_KEY!
if (!keyPem.includes('-----BEGIN')) throw new Error('ORCA_PUSH_APNS_KEY must be PEM text')
return {
keyPem,
keyId: parsed.ORCA_PUSH_APNS_KEY_ID!,
teamId: parsed.ORCA_PUSH_APPLE_TEAM_ID!
}
}
export function loadPushConfig(env: NodeJS.ProcessEnv = process.env): PushConfig {
const parsed = EnvSchema.parse(env)
return {
port: parsed.PORT,
publicUrl: canonicalOrigin(parsed.ORCA_PUSH_PUBLIC_URL, 'ORCA_PUSH_PUBLIC_URL'),
databaseUrl: parsed.ORCA_PUSH_DATABASE_URL,
dataDir: parsed.ORCA_PUSH_DATA_DIR,
databasePoolMax: parsed.ORCA_PUSH_DATABASE_POOL_MAX ?? PUSH_DATABASE_POOL_MAX,
apns: readApnsCredentials(parsed),
apnsTopic: parsed.ORCA_PUSH_APNS_TOPIC,
fcmProjectId: parsed.ORCA_PUSH_FCM_PROJECT_ID,
coalesceMs: parsed.ORCA_PUSH_COALESCE_MS,
trustedProxyHops: parsed.ORCA_PUSH_TRUSTED_PROXY_HOPS
}
}
@@ -1,47 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createHmac } from 'node:crypto'
import vector from '../../../packages/push-contract/src/push-host-proof-vector.json' with { type: 'json' }
import { answerPushHostChallenge, createPushHostKeypair } from './host-challenge-answering.test-fixture.js'
import { PushHostChallengeStore } from './host-challenge-store.js'
import { deriveHostFingerprint } from './host-fingerprint.js'
import { openInMemoryPushDatabase } from './push-database.js'
// Why: the desktop answers challenges in a workspace this one cannot import.
// Both sides replay the same checked-in vector, so a transcript drift on
// either side fails in that side's own suite.
describe('desktop host proof interop', () => {
it('the checked-in vector answers to the same proof the fixture host computes', () => {
const secretKey = new Uint8Array(Buffer.from(vector.hostSecretKeyB64, 'base64'))
const keypair = { publicKey: new Uint8Array(Buffer.from(vector.hostPublicKeyB64, 'base64')), secretKey }
expect(deriveHostFingerprint(keypair.publicKey)).toBe(vector.hostFingerprint)
const proof = answerPushHostChallenge(vector.challenge, {
gatewayOrigin: vector.gatewayOrigin,
keypair,
now: () => vector.issuedAt + 1_000
})
const expected = createHmac('sha256', Buffer.from(vector.challengeSecretB64, 'base64'))
.update(Buffer.from('orca-push-host-proof/v1\0ack\0'))
.update(Buffer.from(vector.transcriptB64, 'base64'))
.digest('base64')
expect(proof).toBe(expected)
})
it('a live challenge from the store round-trips through the fixture host once', async () => {
const database = await openInMemoryPushDatabase()
const store = new PushHostChallengeStore(database, vector.gatewayOrigin)
const keypair = createPushHostKeypair(11)
const challenge = await store.issue(Buffer.from(keypair.publicKey).toString('base64'))
expect(challenge).not.toBeNull()
const proof = answerPushHostChallenge(challenge!, { gatewayOrigin: vector.gatewayOrigin, keypair })
expect(proof).not.toBeNull()
expect(await store.verify(challenge!.challengeId, proof!)).toEqual({
ok: true,
hostFingerprint: deriveHostFingerprint(keypair.publicKey)
})
expect(await store.verify(challenge!.challengeId, proof!)).toEqual({
ok: false,
reason: 'already_consumed'
})
await database.close()
})
})
@@ -1,205 +0,0 @@
import { PUSH_LIMITS, type PushNotificationFilter } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { PushDeviceRegistryStore, type PushDeviceUpsert } from './device-registry-store.js'
import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
const OWNER = 'abcdefghijklmnop'
const OTHER = 'ponmlkjihgfedcba'
const FILTER: PushNotificationFilter = {
sources: ['agent-task-complete'],
agentStates: ['needs-input']
}
describe('push device registry store', () => {
let database: PushDatabase
let clock = 1_700_000_000_000
let devices: PushDeviceRegistryStore
beforeEach(async () => {
database = await openInMemoryPushDatabase()
clock = 1_700_000_000_000
devices = new PushDeviceRegistryStore(database, () => clock)
})
afterEach(async () => {
await database.close()
})
async function upsertOk(input: PushDeviceUpsert): Promise<string> {
const result = await devices.upsert(input)
if (!result.ok) throw new Error(`unexpected upsert refusal: ${result.reason}`)
return result.registrationId
}
function androidDevice(deviceId: string): PushDeviceUpsert {
return {
hostFingerprint: OWNER,
deviceId,
platform: 'android',
token: `token-${deviceId}`,
filter: FILTER
}
}
it('keeps one registration per host and device while replacing the token', async () => {
const first = await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'ios',
token: 'a'.repeat(64),
apnsEnvironment: 'sandbox',
filter: FILTER
})
clock += 1_000
const second = await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'ios',
token: 'b'.repeat(64),
apnsEnvironment: 'production',
filter: FILTER
})
expect(second).toBe(first)
const registration = await devices.findById(first)
expect(registration).toMatchObject({
token: 'b'.repeat(64),
apnsEnvironment: 'production',
dead: false
})
expect(await devices.list(OWNER)).toHaveLength(1)
})
it('revives a registration that a re-registered token replaces', async () => {
const registrationId = await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'android',
token: 'token-one',
filter: FILTER
})
await devices.markDead(registrationId)
expect((await devices.findById(registrationId))?.dead).toBe(true)
await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'android',
token: 'token-two',
filter: FILTER
})
expect(await devices.findById(registrationId)).toMatchObject({
token: 'token-two',
dead: false
})
})
it('lets only the owning host delete a registration', async () => {
const registrationId = await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'android',
token: 'token-one',
filter: FILTER
})
expect(await devices.deleteOwned(OTHER, registrationId)).toBe(false)
expect(await devices.findById(registrationId)).not.toBeNull()
expect(await devices.deleteOwned(OWNER, registrationId)).toBe(true)
expect(await devices.findById(registrationId)).toBeNull()
})
it('scopes lookups and listings to the owning host', async () => {
const owned = await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'android',
token: 'token-one',
filter: FILTER
})
const foreign = await upsertOk({
hostFingerprint: OTHER,
deviceId: 'device-2',
platform: 'android',
token: 'token-two',
filter: FILTER
})
const found = await devices.findOwned(OWNER, [owned, foreign])
expect([...found.keys()]).toEqual([owned])
expect(await devices.list(OTHER)).toEqual([
{ registrationId: foreign, deviceId: 'device-2', platform: 'android', dead: false }
])
expect(await devices.findOwned(OWNER, [])).toEqual(new Map())
})
it('refuses a new device once the host reaches its registration cap', async () => {
for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
await upsertOk(androidDevice(`device-${index}`))
}
expect(await devices.upsert(androidDevice('one-too-many'))).toEqual({
ok: false,
reason: 'too_many_devices'
})
expect(await devices.list(OWNER)).toHaveLength(PUSH_LIMITS.maxDevicesPerHost)
})
it('still lets a capped host re-register a device it already owns', async () => {
for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
await upsertOk(androidDevice(`device-${index}`))
}
const rotated = await devices.upsert({ ...androidDevice('device-0'), token: 'rotated-token' })
expect(rotated.ok).toBe(true)
expect(await devices.list(OWNER)).toHaveLength(PUSH_LIMITS.maxDevicesPerHost)
})
it('frees a slot when a registration is deleted', async () => {
const first = await upsertOk(androidDevice('device-0'))
for (let index = 1; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
await upsertOk(androidDevice(`device-${index}`))
}
expect((await devices.upsert(androidDevice('extra'))).ok).toBe(false)
expect(await devices.deleteOwned(OWNER, first)).toBe(true)
expect((await devices.upsert(androidDevice('extra'))).ok).toBe(true)
})
it('counts the cap per host, not across the whole table', async () => {
for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
await upsertOk(androidDevice(`device-${index}`))
}
expect((await devices.upsert(androidDevice('extra'))).ok).toBe(false)
expect(
(await devices.upsert({ ...androidDevice('device-0'), hostFingerprint: OTHER })).ok
).toBe(true)
})
it('never returns more devices than the list response schema accepts', async () => {
// Straight past the per-host cap, so only the query LIMIT can bound this.
const rows = PUSH_LIMITS.maxDevicesPerListResponse + 5
for (let index = 0; index < rows; index++) {
await database.query(
`INSERT INTO push_devices (registration_id, host_fingerprint, device_id, platform, token,
filter_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[`reg-${index}`, OWNER, `device-${index}`, 'android', 'token', '{}', clock + index, clock]
)
}
expect(await devices.list(OWNER)).toHaveLength(PUSH_LIMITS.maxDevicesPerListResponse)
})
it('separates the same device id registered against two hosts', async () => {
const first = await upsertOk({
hostFingerprint: OWNER,
deviceId: 'shared-device',
platform: 'ios',
token: 'a'.repeat(64),
apnsEnvironment: 'sandbox',
filter: FILTER
})
const second = await upsertOk({
hostFingerprint: OTHER,
deviceId: 'shared-device',
platform: 'ios',
token: 'c'.repeat(64),
apnsEnvironment: 'sandbox',
filter: FILTER
})
expect(first).not.toBe(second)
})
})
@@ -1,185 +0,0 @@
import { randomUUID } from 'node:crypto'
import {
PUSH_LIMITS,
type ApnsEnvironment,
type PushDeviceSummary,
type PushNotificationFilter,
type PushPlatform
} from '@orca-cloud/push-contract'
import type { PushDatabase, SqlRow } from './push-database.js'
const DEVICE_CAP_LOCK_PREFIX = 'orca-push-device-cap:'
export type PushDeviceRegistration = {
registrationId: string
hostFingerprint: string
deviceId: string
platform: PushPlatform
token: string
apnsEnvironment?: ApnsEnvironment
dead: boolean
}
export type PushDeviceUpsertResult =
| { ok: true; registrationId: string }
| { ok: false; reason: 'too_many_devices' }
export type PushDeviceUpsert = {
hostFingerprint: string
deviceId: string
platform: PushPlatform
token: string
apnsEnvironment?: ApnsEnvironment
filter: PushNotificationFilter
}
function toRegistration(row: SqlRow): PushDeviceRegistration {
const apnsEnvironment = row.apns_environment
return {
registrationId: String(row.registration_id),
hostFingerprint: String(row.host_fingerprint),
deviceId: String(row.device_id),
platform: String(row.platform) as PushPlatform,
token: String(row.token),
...(apnsEnvironment === null || apnsEnvironment === undefined
? {}
: { apnsEnvironment: String(apnsEnvironment) as ApnsEnvironment }),
dead: row.dead_at !== null && row.dead_at !== undefined
}
}
export class PushDeviceRegistryStore {
constructor(
private readonly database: PushDatabase,
private readonly now: () => number = Date.now
) {}
// The registration id is stable for a (host, device) pair so a re-registered
// phone keeps the id the desktop already persisted; only the token rotates.
async upsert(input: PushDeviceUpsert): Promise<PushDeviceUpsertResult> {
const now = this.now()
const filterJson = JSON.stringify(input.filter)
return await this.database.transaction<PushDeviceUpsertResult>(async (transaction) => {
// deviceId is caller-chosen, so counting and inserting must not interleave
// or a burst of new ids would walk straight past the cap.
await transaction.lockQuotaScope(`${DEVICE_CAP_LOCK_PREFIX}${input.hostFingerprint}`)
const [existing] = await transaction.query(
'SELECT registration_id FROM push_devices WHERE host_fingerprint = ? AND device_id = ?',
[input.hostFingerprint, input.deviceId]
)
if (existing) {
const registrationId = String(existing.registration_id)
await transaction.query(
`UPDATE push_devices
SET platform = ?, token = ?, apns_environment = ?, filter_json = ?,
dead_at = NULL, updated_at = ?
WHERE registration_id = ?`,
[
input.platform,
input.token,
input.apnsEnvironment ?? null,
filterJson,
now,
registrationId
]
)
return { ok: true, registrationId }
}
const [countRow] = await transaction.query(
'SELECT COUNT(*) AS devices FROM push_devices WHERE host_fingerprint = ?',
[input.hostFingerprint]
)
if (Number(countRow?.devices ?? 0) >= PUSH_LIMITS.maxDevicesPerHost) {
return { ok: false, reason: 'too_many_devices' }
}
const registrationId = randomUUID()
await transaction.query(
`INSERT INTO push_devices
(registration_id, host_fingerprint, device_id, platform, token, apns_environment,
filter_json, dead_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
[
registrationId,
input.hostFingerprint,
input.deviceId,
input.platform,
input.token,
input.apnsEnvironment ?? null,
filterJson,
now,
now
]
)
return { ok: true, registrationId }
})
}
async deleteOwned(hostFingerprint: string, registrationId: string): Promise<boolean> {
const [result] = await this.database.query(
'DELETE FROM push_devices WHERE registration_id = ? AND host_fingerprint = ?',
[registrationId, hostFingerprint]
)
return Number(result?.changes ?? 0) > 0
}
async list(hostFingerprint: string): Promise<PushDeviceSummary[]> {
const rows = await this.database.query(
// Bounded to what PushDeviceListResponseSchema will accept, so an
// oversized table degrades to a truncated list instead of a 500.
`SELECT registration_id, device_id, platform, dead_at
FROM push_devices WHERE host_fingerprint = ? ORDER BY created_at ASC LIMIT ?`,
[hostFingerprint, PUSH_LIMITS.maxDevicesPerListResponse]
)
return rows.map((row) => ({
registrationId: String(row.registration_id),
deviceId: String(row.device_id),
platform: String(row.platform) as PushPlatform,
dead: row.dead_at !== null && row.dead_at !== undefined
}))
}
async findOwned(
hostFingerprint: string,
registrationIds: readonly string[]
): Promise<Map<string, PushDeviceRegistration>> {
if (registrationIds.length === 0) return new Map()
const placeholders = registrationIds.map(() => '?').join(', ')
const rows = await this.database.query(
`SELECT registration_id, host_fingerprint, device_id, platform, token, apns_environment,
dead_at
FROM push_devices
WHERE host_fingerprint = ? AND registration_id IN (${placeholders})`,
[hostFingerprint, ...registrationIds]
)
return new Map(
rows.map((row) => {
const registration = toRegistration(row)
return [registration.registrationId, registration]
})
)
}
async findById(registrationId: string): Promise<PushDeviceRegistration | null> {
const [row] = await this.database.query(
`SELECT registration_id, host_fingerprint, device_id, platform, token, apns_environment,
dead_at
FROM push_devices WHERE registration_id = ?`,
[registrationId]
)
return row ? toRegistration(row) : null
}
async markDead(registrationId: string, observed?: PushDeviceRegistration): Promise<void> {
await this.database.query(
`UPDATE push_devices SET dead_at = ?, updated_at = ? WHERE registration_id = ?${
observed ? " AND token = ? AND platform = ? AND COALESCE(apns_environment, '') = ?" : ''
}`,
[
this.now(),
this.now(),
registrationId,
...(observed ? [observed.token, observed.platform, observed.apnsEnvironment ?? ''] : [])
]
)
}
}
-15
View File
@@ -1,15 +0,0 @@
import { GoogleAuth } from 'google-auth-library'
import { FCM_SCOPE } from './fcm-client.js'
// Resolves the runtime service account credential from the GCE metadata server
// in Cloud Run and from GOOGLE_APPLICATION_CREDENTIALS locally; the library
// caches and refreshes the token itself.
export function createFcmAccessTokenProvider(): () => Promise<string> {
const auth = new GoogleAuth({ scopes: [FCM_SCOPE] })
return async () => {
const client = await auth.getClient()
const token = await client.getAccessToken()
if (!token.token) throw new Error('fcm_access_token_unavailable')
return token.token
}
}
-182
View File
@@ -1,182 +0,0 @@
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { fcmCollapseKey, FcmClient, type FcmRequest, type FcmResponse } from './fcm-client.js'
import { buildPushDelivery } from './push-delivery-message.js'
const HOST = 'abcdefghijklmnop'
const TOKEN = 'cQ1abcDEF_gh:APA91bZZ-zz0123456789abcdefghijklmnopqrstuvwxyz'
function delivery(coalescedCount = 1, agentState: 'needs-input' | null = 'needs-input') {
return buildPushDelivery({
registrationId: 'reg-1',
hostFingerprint: HOST,
notification: {
notificationId: 'note-1',
notificationSeq: 7,
notificationEpoch: 'epoch-1',
source: 'agent-task-complete',
agentState,
title: 'Agent needs input',
body: 'Waiting on your answer',
worktreeId: 'wt-1'
},
title: coalescedCount > 1 ? 'Orca' : 'Agent needs input',
body: coalescedCount > 1 ? '3 agents need attention' : 'Waiting on your answer',
coalescedCount
})
}
function fakeTransport(response: FcmResponse) {
const requests: FcmRequest[] = []
return {
requests,
transport: async (request: FcmRequest): Promise<FcmResponse> => {
requests.push(request)
return response
}
}
}
function client(response: FcmResponse) {
const fake = fakeTransport(response)
return {
fake,
client: new FcmClient({
projectId: 'onorca-cloud',
accessToken: async () => 'access-token',
transport: fake.transport
})
}
}
describe('fcm client', () => {
it('posts the v1 send payload for the configured project', async () => {
const { fake, client: fcm } = client({ status: 200, body: '{"name":"projects/x/messages/1"}' })
await expect(fcm.send(delivery(), { token: TOKEN })).resolves.toEqual({ status: 'sent' })
const request = fake.requests[0]!
expect(request.url).toBe('https://fcm.googleapis.com/v1/projects/onorca-cloud/messages:send')
expect(request.accessToken).toBe('access-token')
expect(JSON.parse(request.body)).toEqual({
message: {
token: TOKEN,
notification: { title: 'Agent needs input', body: 'Waiting on your answer' },
android: {
priority: 'HIGH',
ttl: '14400s',
collapse_key: createHash('sha256').update('note-1').digest('hex').slice(0, 32),
notification: { channel_id: 'orca-desktop', tag: 'note-1' }
},
data: {
hostFingerprint: HOST,
worktreeId: 'wt-1',
notificationId: 'note-1',
notificationSeq: '7',
notificationEpoch: 'epoch-1',
source: 'agent-task-complete',
agentState: 'needs-input',
coalescedCount: '1'
}
}
})
})
it('carries every data value as a string and omits a null agent state', async () => {
const { fake, client: fcm } = client({ status: 200, body: '{}' })
await fcm.send(delivery(3, null), { token: TOKEN })
const message = JSON.parse(fake.requests[0]!.body) as {
message: {
android: { collapse_key: string; notification: { tag: string } }
data: Record<string, string>
}
}
expect(Object.values(message.message.data).every((value) => typeof value === 'string')).toBe(
true
)
expect(message.message.data.agentState).toBeUndefined()
expect(message.message.data.coalescedCount).toBe('3')
expect(message.message.android.notification.tag).toBe(`host:${HOST}`)
expect(message.message.android.collapse_key).toBe(fcmCollapseKey(`host:${HOST}`))
expect(message.message.android.collapse_key).toHaveLength(32)
})
it('passes validate_only through for the deploy probe', async () => {
const { fake, client: fcm } = client({ status: 200, body: '{}' })
await fcm.send(delivery(), { token: TOKEN }, { validateOnly: true })
expect(JSON.parse(fake.requests[0]!.body)).toMatchObject({ validate_only: true })
})
it('marks an unregistered token dead from the status or the error detail', async () => {
const byStatus = client({
status: 404,
body: JSON.stringify({ error: { status: 'UNREGISTERED', message: 'not registered' } })
})
await expect(byStatus.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
status: 'dead',
reason: 'UNREGISTERED'
})
const byDetail = client({
status: 404,
body: JSON.stringify({
error: {
status: 'NOT_FOUND',
message: 'Requested entity was not found.',
details: [{ errorCode: 'UNREGISTERED' }]
}
})
})
await expect(byDetail.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
status: 'dead',
reason: 'UNREGISTERED'
})
})
it('marks an invalid-argument that names the token dead, and others an error', async () => {
const named = client({
status: 400,
body: JSON.stringify({
error: { status: 'INVALID_ARGUMENT', message: 'The registration token is not valid.' }
})
})
await expect(named.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
status: 'dead',
reason: 'INVALID_ARGUMENT'
})
const unnamed = client({
status: 400,
body: JSON.stringify({
error: { status: 'INVALID_ARGUMENT', message: 'Invalid value at message.android.ttl' }
})
})
await expect(unnamed.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
status: 'error',
reason: 'INVALID_ARGUMENT',
retryable: false,
retryAfterMs: 10000
})
})
it('treats a server fault and a transport failure as errors', async () => {
const faulted = client({
status: 503,
body: JSON.stringify({ error: { status: 'UNAVAILABLE', message: 'backend busy' } })
})
await expect(faulted.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
status: 'error',
reason: 'UNAVAILABLE',
retryable: true,
retryAfterMs: 10000
})
const broken = new FcmClient({
projectId: 'onorca-cloud',
accessToken: async () => 'access-token',
transport: async () => {
throw new Error('ECONNRESET')
}
})
await expect(broken.send(delivery(), { token: TOKEN })).resolves.toEqual({
status: 'error',
reason: 'Error',
retryable: true
})
})
})
-138
View File
@@ -1,138 +0,0 @@
import { providerRetryAfter } from './provider-retry-delay.js'
import { createHash } from 'node:crypto'
import { PUSH_DEFAULTS, PUSH_LIMITS } from '@orca-cloud/push-contract'
import { orcaDataStrings, type PushDelivery } from './push-delivery-message.js'
import type { PushProviderOutcome } from './push-provider-outcome.js'
export const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
export type FcmRequest = { url: string; accessToken: string; body: string }
export type FcmResponse = { status: number; body: string; retryAfterMs?: number }
export type FcmTransport = (request: FcmRequest) => Promise<FcmResponse>
export type FcmClientOptions = {
projectId: string
accessToken: () => Promise<string>
transport: FcmTransport
channelId?: string
}
type FcmErrorBody = {
error?: { status?: unknown; message?: unknown; details?: { errorCode?: unknown }[] }
}
// FCM collapse_key is a short opaque string, so the collapse id is hashed
// rather than truncated: truncation would merge unrelated notifications.
export function fcmCollapseKey(collapseId: string): string {
return createHash('sha256').update(collapseId).digest('hex').slice(0, 32)
}
export function fcmMessageBody(input: {
delivery: PushDelivery
token: string
channelId: string
validateOnly?: boolean
}): string {
const { delivery } = input
return JSON.stringify({
...(input.validateOnly ? { validate_only: true } : {}),
message: {
token: input.token,
notification: { title: delivery.title, body: delivery.body },
android: {
priority: 'HIGH',
ttl: `${PUSH_LIMITS.notificationTtlSeconds}s`,
collapse_key: fcmCollapseKey(delivery.collapseId),
notification: {
channel_id: delivery.sound === false ? `${input.channelId}-silent` : input.channelId,
tag: delivery.collapseId
}
},
data: orcaDataStrings(delivery.orca)
}
})
}
function readFcmError(body: string): { status: string; message: string; errorCodes: string[] } {
try {
const parsed = JSON.parse(body) as FcmErrorBody
return {
status: typeof parsed.error?.status === 'string' ? parsed.error.status : 'unknown',
message: typeof parsed.error?.message === 'string' ? parsed.error.message : '',
errorCodes: (parsed.error?.details ?? [])
.map((detail) => detail.errorCode)
.filter((code): code is string => typeof code === 'string')
}
} catch {
return { status: 'unparseable', message: '', errorCodes: [] }
}
}
export class FcmClient {
private readonly channelId: string
constructor(private readonly options: FcmClientOptions) {
this.channelId = options.channelId ?? PUSH_DEFAULTS.androidChannelId
}
async send(
delivery: PushDelivery,
device: { token: string },
options: { validateOnly?: boolean } = {}
): Promise<PushProviderOutcome> {
let response: FcmResponse
try {
response = await this.options.transport({
url: `https://fcm.googleapis.com/v1/projects/${this.options.projectId}/messages:send`,
accessToken: await this.options.accessToken(),
body: fcmMessageBody({
delivery,
token: device.token,
channelId: this.channelId,
...(options.validateOnly === undefined ? {} : { validateOnly: options.validateOnly })
})
})
} catch (error) {
return {
status: 'error',
reason: error instanceof Error ? error.name : 'transport_failed',
retryable: true
}
}
if (response.status >= 200 && response.status < 300) return { status: 'sent' }
const failure = readFcmError(response.body)
if (failure.status === 'UNREGISTERED' || failure.errorCodes.includes('UNREGISTERED')) {
return { status: 'dead', reason: 'UNREGISTERED' }
}
// A revoked token also surfaces as INVALID_ARGUMENT naming the token field.
if (failure.status === 'INVALID_ARGUMENT' && /\btoken\b/i.test(failure.message)) {
return { status: 'dead', reason: 'INVALID_ARGUMENT' }
}
return {
status: 'error',
reason: failure.status,
retryable: response.status === 429 || response.status >= 500,
retryAfterMs: Math.max(response.status === 429 ? 60_000 : 10_000, response.retryAfterMs ?? 0)
}
}
}
export function createFcmFetchTransport(fetchImpl: typeof fetch = fetch): FcmTransport {
return async (request) => {
const response = await fetchImpl(request.url, {
method: 'POST',
headers: {
authorization: `Bearer ${request.accessToken}`,
'content-type': 'application/json'
},
body: request.body,
redirect: 'error',
signal: AbortSignal.timeout(10_000)
})
return {
status: response.status,
body: await response.text(),
retryAfterMs: providerRetryAfter(response.headers.get('retry-after') ?? undefined)
}
}
}
@@ -1,163 +0,0 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import {
PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN,
PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN,
PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT,
PUSH_LIMITS
} from '@orca-cloud/push-contract'
import nacl from 'tweetnacl'
import { decodeCanonicalBase64 } from './canonical-base64.js'
import { deriveHostFingerprint } from './host-fingerprint.js'
// The desktop side of the push challenge, written the way the shipped host
// will answer it, so the gateway is exercised against a real box-opening peer.
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
export type PushHostKeypair = { publicKey: Uint8Array; secretKey: Uint8Array }
export type PushChallengeWire = {
challengeId: string
gatewayEphemeralPublicKeyB64: string
nonceB64: string
ciphertextB64: string
expiresAt: number
}
export function createPushHostKeypair(seed?: number): PushHostKeypair {
const pair =
seed === undefined
? nacl.box.keyPair()
: nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(seed))
return { publicKey: pair.publicKey, secretKey: pair.secretKey }
}
export function hostPublicKeyB64(keypair: PushHostKeypair): string {
return Buffer.from(keypair.publicKey).toString('base64')
}
function equal(left: Uint8Array | undefined, right: Uint8Array): boolean {
return Boolean(left && left.byteLength === right.byteLength && timingSafeEqual(left, right))
}
function uint64(value: number): Uint8Array {
const bytes = new Uint8Array(8)
new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false)
return bytes
}
function parseTranscript(transcript: Uint8Array): Map<string, Uint8Array> | null {
const fields = new Map<string, Uint8Array>()
const view = new DataView(transcript.buffer, transcript.byteOffset, transcript.byteLength)
let offset = 0
try {
while (offset < transcript.byteLength) {
const nameLength = view.getUint32(offset, false)
offset += 4
const name = textDecoder.decode(transcript.slice(offset, offset + nameLength))
offset += nameLength
const valueLength = view.getUint32(offset, false)
offset += 4
if (fields.has(name) || offset + valueLength > transcript.byteLength) return null
fields.set(name, transcript.slice(offset, offset + valueLength))
offset += valueLength
}
} catch {
return null
}
return offset === transcript.byteLength ? fields : null
}
function readUint64(value: Uint8Array | undefined): number | null {
if (!value || value.byteLength !== 8) return null
const parsed = new DataView(value.buffer, value.byteOffset, value.byteLength).getBigUint64(0, false)
return parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : null
}
export type PushHostProofContext = {
gatewayOrigin: string
keypair: PushHostKeypair
now?: () => number
onInvalid?: (reason: string) => void
}
function validateTranscript(
transcript: Uint8Array,
challenge: PushChallengeWire,
context: PushHostProofContext,
gatewayKey: Uint8Array,
nonce: Uint8Array
): boolean {
const fields = parseTranscript(transcript)
if (!fields || fields.size !== PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT) {
context.onInvalid?.('transcript-structure')
return false
}
const now = (context.now ?? Date.now)()
const issuedAt = readUint64(fields.get('issuedAt'))
const expiresAt = readUint64(fields.get('expiresAt'))
const fingerprint = deriveHostFingerprint(context.keypair.publicKey)
const checks: [string, boolean][] = [
['issuedAt-readable', issuedAt !== null],
[
'issuedAt-not-future',
issuedAt === null || issuedAt - PUSH_LIMITS.clockSkewToleranceMs <= now
],
['not-expired', now - PUSH_LIMITS.clockSkewToleranceMs <= challenge.expiresAt],
['issuedAt-before-expiry', issuedAt === null || issuedAt <= challenge.expiresAt],
[
'window',
issuedAt === null || challenge.expiresAt - issuedAt <= PUSH_LIMITS.challengeTtlMs
],
['expiry-consistent', expiresAt === challenge.expiresAt],
['protocol', equal(fields.get('protocol'), textEncoder.encode(PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN))],
['version', equal(fields.get('version'), new Uint8Array([1]))],
['gatewayOrigin', equal(fields.get('gatewayOrigin'), textEncoder.encode(context.gatewayOrigin))],
['gatewayEphemeralPublicKey', equal(fields.get('gatewayEphemeralPublicKey'), gatewayKey)],
['challengeNonce', equal(fields.get('challengeNonce'), nonce)],
['challengeId', equal(fields.get('challengeId'), textEncoder.encode(challenge.challengeId))],
['hostFingerprint', equal(fields.get('hostFingerprint'), textEncoder.encode(fingerprint))],
['hostPublicKey', equal(fields.get('hostPublicKey'), context.keypair.publicKey)],
['issuedAt-value', issuedAt === null || uint64(issuedAt).byteLength === 8]
]
const failed = checks.filter(([, ok]) => !ok).map(([name]) => name)
if (failed.length === 0) return true
context.onInvalid?.(`transcript:${failed.join('+')}`)
return false
}
export function answerPushHostChallenge(
challenge: PushChallengeWire,
context: PushHostProofContext
): string | null {
const gatewayKey = decodeCanonicalBase64(challenge.gatewayEphemeralPublicKeyB64, 32)
const nonce = decodeCanonicalBase64(challenge.nonceB64, 24)
const ciphertext = Buffer.from(challenge.ciphertextB64, 'base64')
if (!gatewayKey || !nonce || ciphertext.toString('base64') !== challenge.ciphertextB64) return null
const plaintext = nacl.box.open(ciphertext, nonce, gatewayKey, context.keypair.secretKey)
if (!plaintext) {
context.onInvalid?.('challenge-box-open')
return null
}
const domain = textEncoder.encode(`${PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`)
if (
!equal(plaintext.slice(0, domain.byteLength), domain) ||
plaintext.byteLength < domain.byteLength + 36
) {
return null
}
const transcriptLength = new DataView(
plaintext.buffer,
plaintext.byteOffset + domain.byteLength,
4
).getUint32(0, false)
const transcriptStart = domain.byteLength + 4
const secretStart = transcriptStart + transcriptLength
if (secretStart + 32 !== plaintext.byteLength) return null
const transcript = plaintext.slice(transcriptStart, secretStart)
if (!validateTranscript(transcript, challenge, context, gatewayKey, nonce)) return null
return createHmac('sha256', plaintext.slice(secretStart))
.update(textEncoder.encode(`${PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN}\0ack\0`))
.update(transcript)
.digest('base64')
}
@@ -1,245 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
answerPushHostChallenge,
createPushHostKeypair,
hostPublicKeyB64
} from './host-challenge-answering.test-fixture.js'
import { PushHostChallengeStore } from './host-challenge-store.js'
import { deriveHostFingerprint } from './host-fingerprint.js'
import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
const GATEWAY_ORIGIN = 'https://push.onorca.dev'
describe('push host challenge store', () => {
let database: PushDatabase
let clock = 1_700_000_000_000
let store: PushHostChallengeStore
beforeEach(async () => {
database = await openInMemoryPushDatabase()
clock = 1_700_000_000_000
store = new PushHostChallengeStore(database, GATEWAY_ORIGIN, () => clock)
})
afterEach(async () => {
await database.close()
})
it('completes a challenge, proof, and consume round trip', async () => {
const host = createPushHostKeypair(1)
const challenge = await store.issue(hostPublicKeyB64(host))
expect(challenge).not.toBeNull()
expect(challenge!.expiresAt).toBe(clock + PUSH_LIMITS.challengeTtlMs)
expect(challenge!.hostFingerprint).toBe(deriveHostFingerprint(host.publicKey))
const proof = answerPushHostChallenge(challenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: host,
now: () => clock
})
expect(proof).not.toBeNull()
await expect(store.verify(challenge!.challengeId, proof!)).resolves.toEqual({
ok: true,
hostFingerprint: deriveHostFingerprint(host.publicKey)
})
const [hostRow] = await database.query('SELECT host_fingerprint, last_seen_at FROM push_hosts')
expect(hostRow?.host_fingerprint).toBe(deriveHostFingerprint(host.publicKey))
})
it('never stores material that reproduces the proof', async () => {
const host = createPushHostKeypair(2)
const challenge = await store.issue(hostPublicKeyB64(host))
const proof = answerPushHostChallenge(challenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: host,
now: () => clock
})
const [row] = await database.query('SELECT secret_hash FROM push_challenges')
expect(String(row?.secret_hash)).not.toBe(proof)
expect(Buffer.from(String(row?.secret_hash), 'base64url').byteLength).toBe(32)
})
it('rejects a replayed challenge', async () => {
const host = createPushHostKeypair(3)
const challenge = await store.issue(hostPublicKeyB64(host))
const proof = answerPushHostChallenge(challenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: host,
now: () => clock
})!
await expect(store.verify(challenge!.challengeId, proof)).resolves.toMatchObject({ ok: true })
await expect(store.verify(challenge!.challengeId, proof)).resolves.toEqual({
ok: false,
reason: 'already_consumed'
})
})
it('rejects a challenge the moment its own ttl elapses', async () => {
const host = createPushHostKeypair(4)
const challenge = await store.issue(hostPublicKeyB64(host))
const proof = answerPushHostChallenge(challenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: host,
now: () => clock
})!
clock += PUSH_LIMITS.challengeTtlMs + 1
await expect(store.verify(challenge!.challengeId, proof)).resolves.toEqual({
ok: false,
reason: 'expired'
})
})
it('spends no skew tolerance on its own expiry, so the ttl is the whole window', async () => {
const host = createPushHostKeypair(5)
const challenge = await store.issue(hostPublicKeyB64(host))
const proof = answerPushHostChallenge(challenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: host,
now: () => clock
})!
// A proof that the host would still consider in-window is refused here: the
// gateway issued expires_at against this clock and needs no allowance.
clock += PUSH_LIMITS.challengeTtlMs + PUSH_LIMITS.clockSkewToleranceMs - 1
await expect(store.verify(challenge!.challengeId, proof)).resolves.toEqual({
ok: false,
reason: 'expired'
})
})
it('accepts a proof that lands just inside the ttl', async () => {
const host = createPushHostKeypair(26)
const challenge = await store.issue(hostPublicKeyB64(host))
const proof = answerPushHostChallenge(challenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: host,
now: () => clock
})!
clock += PUSH_LIMITS.challengeTtlMs
await expect(store.verify(challenge!.challengeId, proof)).resolves.toMatchObject({ ok: true })
})
it('keeps an expired row long enough to answer expired rather than unknown', async () => {
const host = createPushHostKeypair(27)
const challenge = await store.issue(hostPublicKeyB64(host))
const proof = answerPushHostChallenge(challenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: host,
now: () => clock
})!
clock += PUSH_LIMITS.challengeTtlMs + 1
expect(await store.pruneExpired()).toBe(0)
await expect(store.verify(challenge!.challengeId, proof)).resolves.toEqual({
ok: false,
reason: 'expired'
})
})
it('refuses a wrong host: the box will not open and a foreign proof will not match', async () => {
const owner = createPushHostKeypair(6)
const intruder = createPushHostKeypair(7)
const ownerChallenge = await store.issue(hostPublicKeyB64(owner))
expect(
answerPushHostChallenge(ownerChallenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: intruder,
now: () => clock
})
).toBeNull()
const intruderChallenge = await store.issue(hostPublicKeyB64(intruder))
const intruderProof = answerPushHostChallenge(intruderChallenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: intruder,
now: () => clock
})!
await expect(store.verify(ownerChallenge!.challengeId, intruderProof)).resolves.toEqual({
ok: false,
reason: 'proof_mismatch'
})
})
it('rejects a proof bound to a different gateway origin', async () => {
const host = createPushHostKeypair(8)
const challenge = await store.issue(hostPublicKeyB64(host))
const reasons: string[] = []
expect(
answerPushHostChallenge(challenge!, {
gatewayOrigin: 'https://push.example.test',
keypair: host,
now: () => clock,
onInvalid: (reason) => reasons.push(reason)
})
).toBeNull()
expect(reasons.join()).toContain('gatewayOrigin')
})
it('rejects an unknown challenge id and a malformed public key', async () => {
await expect(store.verify('missing', Buffer.alloc(32, 9).toString('base64'))).resolves.toEqual({
ok: false,
reason: 'unknown_challenge'
})
await expect(store.issue('not-base64!!')).resolves.toBeNull()
await expect(store.issue(Buffer.alloc(31, 1).toString('base64'))).resolves.toBeNull()
})
it('creates no host row until a proof succeeds', async () => {
const host = createPushHostKeypair(30)
const challenge = await store.issue(hostPublicKeyB64(host))
const [beforeProof] = await database.query('SELECT COUNT(*) AS hosts FROM push_hosts')
expect(Number(beforeProof?.hosts)).toBe(0)
const proof = answerPushHostChallenge(challenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: host,
now: () => clock
})!
await expect(store.verify(challenge!.challengeId, proof)).resolves.toMatchObject({ ok: true })
const [row] = await database.query('SELECT host_public_key, last_seen_at FROM push_hosts')
expect(row?.host_public_key).toBe(hostPublicKeyB64(host))
expect(Number(row?.last_seen_at)).toBe(clock)
})
it('leaves no host row behind when a challenge is never answered', async () => {
for (let index = 0; index < 5; index++) {
await store.issue(hostPublicKeyB64(createPushHostKeypair(40 + index)))
}
const [row] = await database.query('SELECT COUNT(*) AS hosts FROM push_hosts')
expect(Number(row?.hosts)).toBe(0)
})
it('prunes a host past retention only when it has no registration left', async () => {
const stale = createPushHostKeypair(50)
const kept = createPushHostKeypair(51)
for (const host of [stale, kept]) {
const challenge = await store.issue(hostPublicKeyB64(host))
const proof = answerPushHostChallenge(challenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: host,
now: () => clock
})!
await store.verify(challenge!.challengeId, proof)
}
await database.query(
`INSERT INTO push_devices (registration_id, host_fingerprint, device_id, platform, token,
filter_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
['reg-1', deriveHostFingerprint(kept.publicKey), 'device-1', 'android', 'token', '{}', clock, clock]
)
clock += PUSH_LIMITS.hostRetentionMs
expect(await store.pruneStaleHosts()).toBe(0)
clock += 1
expect(await store.pruneStaleHosts()).toBe(1)
const [row] = await database.query('SELECT host_fingerprint FROM push_hosts')
expect(row?.host_fingerprint).toBe(deriveHostFingerprint(kept.publicKey))
})
it('prunes challenges that fell out of the skew window', async () => {
const host = createPushHostKeypair(9)
await store.issue(hostPublicKeyB64(host))
expect(await store.pruneExpired()).toBe(0)
clock += PUSH_LIMITS.challengeTtlMs + PUSH_LIMITS.clockSkewToleranceMs + 1
expect(await store.pruneExpired()).toBe(1)
})
})
-175
View File
@@ -1,175 +0,0 @@
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
import {
buildPushHostChallengePlaintext,
buildPushHostProofMacInput,
buildPushHostProofTranscript,
PUSH_LIMITS
} from '@orca-cloud/push-contract'
import nacl from 'tweetnacl'
import { decodeCanonicalBase64 } from './canonical-base64.js'
import { deriveHostFingerprint } from './host-fingerprint.js'
import type { PushDatabase } from './push-database.js'
export type IssuedPushChallenge = {
challengeId: string
gatewayEphemeralPublicKeyB64: string
nonceB64: string
ciphertextB64: string
expiresAt: number
hostFingerprint: string
}
export type PushProofVerification =
| { ok: true; hostFingerprint: string }
| { ok: false; reason: 'unknown_challenge' | 'already_consumed' | 'expired' | 'proof_mismatch' }
function sha256(value: Uint8Array): string {
return createHash('sha256').update(value).digest('base64url')
}
function equalDigest(left: string, right: string): boolean {
const leftBytes = Buffer.from(left)
const rightBytes = Buffer.from(right)
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes)
}
export class PushHostChallengeStore {
constructor(
private readonly database: PushDatabase,
private readonly gatewayOrigin: string,
private readonly now: () => number = Date.now
) {}
async issue(hostPublicKeyB64: string): Promise<IssuedPushChallenge | null> {
const hostPublicKey = decodeCanonicalBase64(hostPublicKeyB64, 32)
if (!hostPublicKey) return null
const hostFingerprint = deriveHostFingerprint(hostPublicKey)
const ephemeral = nacl.box.keyPair()
const challengeNonce = randomBytes(nacl.box.nonceLength)
const challengeSecret = randomBytes(32)
const challengeId = randomUUID()
const issuedAt = this.now()
const expiresAt = issuedAt + PUSH_LIMITS.challengeTtlMs
const transcript = buildPushHostProofTranscript({
gatewayOrigin: this.gatewayOrigin,
gatewayEphemeralPublicKey: ephemeral.publicKey,
challengeNonce,
challengeId,
issuedAt,
expiresAt,
hostFingerprint,
hostPublicKey
})
const ciphertext = nacl.box(
buildPushHostChallengePlaintext(transcript, challengeSecret),
challengeNonce,
hostPublicKey,
ephemeral.secretKey
)
const expectedProof = createHmac('sha256', challengeSecret)
.update(buildPushHostProofMacInput(transcript))
.digest()
// No push_hosts row yet: issuing is unauthenticated, so anyone could
// otherwise fill the table. The key rides the challenge until verify() proves it.
await this.database.query(
`INSERT INTO push_challenges
(challenge_id, host_fingerprint, host_public_key, secret_hash, transcript, expires_at,
consumed_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
[
challengeId,
hostFingerprint,
hostPublicKeyB64,
// The stored digest is of the ack the secret produces, never of the
// secret itself: a database reader must not be able to forge a proof.
sha256(expectedProof),
Buffer.from(transcript).toString('base64'),
expiresAt
]
)
return {
challengeId,
gatewayEphemeralPublicKeyB64: Buffer.from(ephemeral.publicKey).toString('base64'),
nonceB64: Buffer.from(challengeNonce).toString('base64'),
ciphertextB64: Buffer.from(ciphertext).toString('base64'),
expiresAt,
hostFingerprint
}
}
async verify(challengeId: string, proofB64: string): Promise<PushProofVerification> {
const proof = decodeCanonicalBase64(proofB64, 32)
return await this.database.transaction<PushProofVerification>(async (transaction) => {
const [row] = await transaction.query(
`SELECT host_fingerprint, host_public_key, secret_hash, expires_at, consumed_at
FROM push_challenges WHERE challenge_id = ?`,
[challengeId]
)
if (!row) return { ok: false, reason: 'unknown_challenge' }
if (row.consumed_at !== null && row.consumed_at !== undefined) {
return { ok: false, reason: 'already_consumed' }
}
const now = this.now()
// No skew allowance here: the gateway set expires_at from this same clock.
// The tolerance belongs to the host, which validates a foreign timestamp.
if (now > Number(row.expires_at)) return { ok: false, reason: 'expired' }
if (!proof || !equalDigest(sha256(proof), String(row.secret_hash))) {
return { ok: false, reason: 'proof_mismatch' }
}
// Consume under the same predicate the read used, so two concurrent
// proofs for one challenge cannot both mint a session.
const [consumed] = await transaction.query(
'UPDATE push_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL',
[now, challengeId]
)
if (Number(consumed?.changes ?? 0) !== 1) return { ok: false, reason: 'already_consumed' }
await this.rememberHost(
transaction,
String(row.host_fingerprint),
String(row.host_public_key),
now
)
return { ok: true, hostFingerprint: String(row.host_fingerprint) }
})
}
// Rows outlive the expiry check by the skew tolerance so a late proof reads
// as 'expired' rather than as an unknown challenge.
async pruneExpired(): Promise<number> {
const cutoff = this.now() - PUSH_LIMITS.clockSkewToleranceMs
const [result] = await this.database.query('DELETE FROM push_challenges WHERE expires_at < ?', [
cutoff
])
return Number(result?.changes ?? 0)
}
// A host that stopped proving and has no registration left is dead weight;
// its public key is recoverable from the desktop on the next challenge.
async pruneStaleHosts(): Promise<number> {
const [result] = await this.database.query(
`DELETE FROM push_hosts
WHERE last_seen_at < ?
AND host_fingerprint NOT IN (SELECT host_fingerprint FROM push_devices)`,
[this.now() - PUSH_LIMITS.hostRetentionMs]
)
return Number(result?.changes ?? 0)
}
private async rememberHost(
transaction: PushDatabase,
hostFingerprint: string,
hostPublicKeyB64: string,
now: number
): Promise<void> {
const [updated] = await transaction.query(
'UPDATE push_hosts SET last_seen_at = ?, host_public_key = ? WHERE host_fingerprint = ?',
[now, hostPublicKeyB64, hostFingerprint]
)
if (Number(updated?.changes ?? 0) > 0) return
await transaction.query(
`INSERT INTO push_hosts (host_fingerprint, host_public_key, created_at, last_seen_at)
VALUES (?, ?, ?, ?)`,
[hostFingerprint, hostPublicKeyB64, now, now]
)
}
}
-16
View File
@@ -1,16 +0,0 @@
import { createHash } from 'node:crypto'
import { PUSH_HOST_FINGERPRINT_LENGTH } from '@orca-cloud/push-contract'
// Identical derivation to deriveRelayHostId on the desktop, so a host and a
// phone reach the same fingerprint from the same X25519 public key.
export function deriveHostFingerprint(hostPublicKey: Uint8Array): string {
return createHash('sha256')
.update(hostPublicKey)
.digest('base64url')
.slice(0, PUSH_HOST_FINGERPRINT_LENGTH)
}
// Logs may carry at most this much of a fingerprint.
export function fingerprintLogPrefix(hostFingerprint: string): string {
return hostFingerprint.slice(0, 4)
}
@@ -1,70 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { PushHostSessionStore } from './host-session-store.js'
import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
const HOST = 'abcdefghijklmnop'
describe('push host session store', () => {
let database: PushDatabase
let clock = 1_700_000_000_000
let sessions: PushHostSessionStore
beforeEach(async () => {
database = await openInMemoryPushDatabase()
clock = 1_700_000_000_000
sessions = new PushHostSessionStore(database, () => clock)
})
afterEach(async () => {
await database.close()
})
it('mints a 24 hour session and stores only its hash', async () => {
const session = await sessions.create(HOST)
expect(session.expiresAt).toBe(clock + PUSH_LIMITS.sessionTtlMs)
expect(Buffer.from(session.sessionToken, 'base64url').byteLength).toBe(32)
const [row] = await database.query('SELECT token_hash FROM push_sessions')
expect(String(row?.token_hash)).not.toBe(session.sessionToken)
await expect(sessions.resolve(session.sessionToken)).resolves.toMatchObject({
ok: true,
hostFingerprint: HOST
})
})
it('reports expiry separately from an unknown token', async () => {
const session = await sessions.create(HOST)
clock += PUSH_LIMITS.sessionTtlMs + 1
await expect(sessions.resolve(session.sessionToken)).resolves.toEqual({
ok: false,
reason: 'session_expired'
})
await expect(sessions.resolve('not-a-session')).resolves.toEqual({
ok: false,
reason: 'unknown_session'
})
})
it('accepts a session on its final millisecond', async () => {
const session = await sessions.create(HOST)
clock += PUSH_LIMITS.sessionTtlMs
await expect(sessions.resolve(session.sessionToken)).resolves.toMatchObject({ ok: true })
})
it('keeps one live session per host and prunes it once expired', async () => {
const first = await sessions.create(HOST)
const second = await sessions.create(HOST)
// The earlier session is gone the moment its host proves again, so a flood
// of proofs leaves one row per host rather than one per proof.
await expect(sessions.resolve(first.sessionToken)).resolves.toEqual({
ok: false,
reason: 'unknown_session'
})
await expect(sessions.resolve(second.sessionToken)).resolves.toMatchObject({ ok: true })
const other = await sessions.create('ponmlkjihgfedcba')
await expect(sessions.resolve(second.sessionToken)).resolves.toMatchObject({ ok: true })
clock += PUSH_LIMITS.sessionTtlMs + 1
expect(await sessions.pruneExpired()).toBe(2)
await expect(sessions.resolve(other.sessionToken)).resolves.toMatchObject({ ok: false })
})
})
-65
View File
@@ -1,65 +0,0 @@
import { createHash, randomBytes } from 'node:crypto'
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import type { PushDatabase } from './push-database.js'
export type IssuedPushSession = {
sessionToken: string
expiresAt: number
hostFingerprint: string
}
export type PushSessionLookup =
| { ok: true; hostFingerprint: string; expiresAt: number }
| { ok: false; reason: 'unknown_session' | 'session_expired' }
function hashSessionToken(sessionToken: string): string {
return createHash('sha256').update(sessionToken).digest('base64url')
}
export class PushHostSessionStore {
constructor(
private readonly database: PushDatabase,
private readonly now: () => number = Date.now
) {}
async create(hostFingerprint: string): Promise<IssuedPushSession> {
const sessionToken = randomBytes(32).toString('base64url')
const createdAt = this.now()
const expiresAt = createdAt + PUSH_LIMITS.sessionTtlMs
await this.database.transaction(async (transaction) => {
// Why: a desktop holds one session at a time and only re-proves once it is
// gone, so an earlier row is dead weight. It also bounds the table to one
// row per host however many proofs a self-minted identity answers.
await transaction.lockQuotaScope(`orca-push-session:${hostFingerprint}`)
await transaction.query('DELETE FROM push_sessions WHERE host_fingerprint = ?', [
hostFingerprint
])
await transaction.query(
`INSERT INTO push_sessions (token_hash, host_fingerprint, expires_at, created_at)
VALUES (?, ?, ?, ?)`,
[hashSessionToken(sessionToken), hostFingerprint, expiresAt, createdAt]
)
})
return { sessionToken, expiresAt, hostFingerprint }
}
async resolve(sessionToken: string): Promise<PushSessionLookup> {
const [row] = await this.database.query(
'SELECT host_fingerprint, expires_at FROM push_sessions WHERE token_hash = ?',
[hashSessionToken(sessionToken)]
)
if (!row) return { ok: false, reason: 'unknown_session' }
const expiresAt = Number(row.expires_at)
// No skew grace here: a 24h session that just expired should be re-minted
// through the challenge, which is cheap and already handled by the host.
if (this.now() > expiresAt) return { ok: false, reason: 'session_expired' }
return { ok: true, hostFingerprint: String(row.host_fingerprint), expiresAt }
}
async pruneExpired(): Promise<number> {
const [result] = await this.database.query('DELETE FROM push_sessions WHERE expires_at < ?', [
this.now()
])
return Number(result?.changes ?? 0)
}
}
-81
View File
@@ -1,81 +0,0 @@
import { loadPushConfig } from './config.js'
import { openPushDatabase } from './push-database.js'
import { createPushServer } from './push-server.js'
const CHALLENGE_PRUNE_INTERVAL_MS = 60_000
const SESSION_PRUNE_INTERVAL_MS = 10 * 60_000
const SEND_LOG_PRUNE_INTERVAL_MS = 30 * 60_000
const STALE_HOST_PRUNE_INTERVAL_MS = 30 * 60_000
const config = loadPushConfig()
const database = await openPushDatabase({
...(config.databaseUrl === undefined ? {} : { databaseUrl: config.databaseUrl }),
dataDir: config.dataDir,
poolMax: config.databasePoolMax,
applicationName: 'orca-push'
})
const {
server,
challenges,
sessions,
quota,
coalescer,
observability,
closeTransports,
requestDrain
} = createPushServer(config, database)
function prune(label: string, run: () => Promise<number>, intervalMs: number): NodeJS.Timeout {
const timer = setInterval(() => {
void run().catch((error: unknown) => {
console.warn(
JSON.stringify({
event: 'orca_push_prune_failed',
target: label,
error: error instanceof Error ? error.name : 'unknown'
})
)
})
}, intervalMs)
timer.unref()
return timer
}
const timers = [
prune('challenges', () => challenges.pruneExpired(), CHALLENGE_PRUNE_INTERVAL_MS),
prune('sessions', () => sessions.pruneExpired(), SESSION_PRUNE_INTERVAL_MS),
prune('send_log', () => quota.prune(), SEND_LOG_PRUNE_INTERVAL_MS),
prune('stale_hosts', () => challenges.pruneStaleHosts(), STALE_HOST_PRUNE_INTERVAL_MS)
]
observability.start()
server.listen(config.port, () => {
console.log(`[orca-push] listening on ${config.publicUrl} (port ${config.port})`)
})
let stopping = false
const shutdown = (): void => {
if (stopping) return
stopping = true
for (const timer of timers) clearInterval(timer)
// Cloud Run sends SIGKILL after ten seconds; leave time for explicit cleanup.
const deadline = setTimeout(() => process.exit(1), 9_000)
deadline.unref()
const requests = requestDrain.begin()
const connections = new Promise<void>((resolve) => server.close(() => resolve()))
void Promise.all([requests, connections])
.then(async () => {
await coalescer.flushAll()
coalescer.stop()
closeTransports()
await database.close()
observability.stop()
clearTimeout(deadline)
})
.catch(() => {
console.warn(JSON.stringify({ event: 'orca_push_shutdown_failed' }))
process.exitCode = 1
})
}
process.once('SIGTERM', shutdown)
process.once('SIGINT', shutdown)
@@ -1,9 +0,0 @@
export function providerRetryAfter(
value: string | undefined,
now = Date.now()
): number | undefined {
if (!value) return undefined
const seconds = Number(value)
const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - now
return Number.isFinite(delay) ? Math.max(0, delay) : undefined
}
@@ -1,89 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const fakes = vi.hoisted(() => ({
configs: [] as Array<Record<string, unknown>>,
lifecycle: [] as string[],
query: vi.fn(async (_sql: string) => ({ rows: [], rowCount: 0 })),
release: vi.fn()
}))
vi.mock('pg', () => ({
default: {
Pool: class {
on = vi.fn()
connect = vi.fn(async () => ({ query: fakes.query, release: fakes.release }))
private readonly label: string
constructor(config: Record<string, unknown>) {
fakes.configs.push(config)
this.label = `max=${String(config.max)} statement_timeout=${String(config.statement_timeout)}`
fakes.lifecycle.push(`open ${this.label}`)
}
async end(): Promise<void> {
fakes.lifecycle.push(`end ${this.label}`)
}
}
}
}))
import { openPushDatabase } from './push-database.js'
import { pushSchemaStatements } from './push-schema.js'
describe('PostgreSQL push gateway startup', () => {
beforeEach(() => {
fakes.configs.length = 0
fakes.lifecycle.length = 0
fakes.query.mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
})
// Why: a CREATE INDEX on a grown table can outlive the 5s request deadline,
// and a schema that inherits it fails every startup at the same statement.
it('applies the schema on an untimed pool that is gone before the serving pool opens', async () => {
const database = await openPushDatabase({
databaseUrl: 'postgresql://push@localhost:55440/orca_push',
dataDir: '/unused',
poolMax: 2,
applicationName: 'orca-push'
})
expect(fakes.lifecycle).toEqual([
'open max=1 statement_timeout=0',
'end max=1 statement_timeout=0',
'open max=2 statement_timeout=5000'
])
expect(fakes.configs[0]).toMatchObject({
application_name: 'orca-push/schema',
lock_timeout: 1_000,
idle_in_transaction_session_timeout: 5_000
})
expect(
fakes.query.mock.calls.map(([sql]) => sql).slice(0, pushSchemaStatements().length)
).toEqual(pushSchemaStatements())
await database.close()
})
it('retries a transaction the pool statement_timeout aborted', async () => {
const database = await openPushDatabase({
databaseUrl: 'postgresql://push@localhost:55440/orca_push',
dataDir: '/unused'
})
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
let attempts = 0
const result = await database.transaction(async () => {
attempts += 1
if (attempts === 1) throw Object.assign(new Error('canceling statement'), { code: '57014' })
return 'done'
})
expect(result).toBe('done')
expect(attempts).toBe(2)
expect(warn.mock.calls.map(([line]) => String(line))).toEqual([
expect.stringContaining('"code":"57014"')
])
warn.mockRestore()
await database.close()
})
})
-275
View File
@@ -1,275 +0,0 @@
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import pg from 'pg'
import { applyPostgresSchema } from '@orca-cloud/postgres-schema'
import { ensurePushSessionIndex } from './push-session-schema.js'
import { pushSchemaStatements } from './push-schema.js'
const POSTGRES_LOCK_TIMEOUT_MS = 1_000
const POSTGRES_CONNECTION_TIMEOUT_MS = 2_000
const POSTGRES_STATEMENT_TIMEOUT_MS = 5_000
const POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS = 5_000
const POSTGRES_TRANSACTION_ATTEMPTS = 3
const POSTGRES_RETRY_MAX_DELAY_MS = 25
export type SqlRow = Record<string, unknown>
export interface PushDatabase {
readonly dialect: 'sqlite' | 'postgres'
query(sql: string, params?: unknown[]): Promise<SqlRow[]>
transaction<T>(operation: (transaction: PushDatabase) => Promise<T>): Promise<T>
// Serializes every transaction that reads then writes the same identity's
// quota rows. Must be called inside a transaction; it releases at commit.
lockQuotaScope(key: string): Promise<void>
close(): Promise<void>
}
function postgresSql(sql: string): string {
let index = 0
return sql.replace(/\?/g, () => `$${++index}`)
}
function returnsRows(sql: string): boolean {
return /^\s*(select|with)/i.test(sql) || /returning/i.test(sql)
}
class SqliteTransaction implements PushDatabase {
readonly dialect = 'sqlite' as const
constructor(protected readonly database: DatabaseSync) {}
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
const statement = this.database.prepare(sql)
const bound = params.map((value) => (value === undefined ? null : value)) as never[]
if (returnsRows(sql)) return statement.all(...bound) as SqlRow[]
const result = statement.run(...bound)
return [{ changes: Number(result.changes) }]
}
async transaction<T>(operation: (transaction: PushDatabase) => Promise<T>): Promise<T> {
return await operation(this)
}
// BEGIN IMMEDIATE already holds the single writer lock for the whole
// transaction, so there is nothing narrower left to take.
async lockQuotaScope(): Promise<void> {}
async close(): Promise<void> {}
}
class SqliteDatabase extends SqliteTransaction {
// node:sqlite is synchronous and has no nested transactions, so overlapping
// callers are serialized behind one tail promise instead of racing BEGIN.
private tail: Promise<void> = Promise.resolve()
override async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
await this.tail
return await super.query(sql, params)
}
override async transaction<T>(operation: (transaction: PushDatabase) => Promise<T>): Promise<T> {
const previous = this.tail
let release!: () => void
this.tail = new Promise((resolve) => (release = resolve))
await previous
this.database.exec('BEGIN IMMEDIATE')
const transaction = new SqliteTransaction(this.database)
try {
const result = await operation(transaction)
this.database.exec('COMMIT')
return result
} catch (error) {
this.database.exec('ROLLBACK')
throw error
} finally {
release()
}
}
override async close(): Promise<void> {
await this.tail
this.database.close()
}
}
class PostgresTransaction implements PushDatabase {
readonly dialect = 'postgres' as const
constructor(private readonly client: pg.PoolClient) {}
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
const result = await this.client.query(postgresSql(sql), params)
return returnsRows(sql) ? (result.rows as SqlRow[]) : [{ changes: result.rowCount ?? 0 }]
}
async transaction<T>(operation: (transaction: PushDatabase) => Promise<T>): Promise<T> {
return await operation(this)
}
// READ COMMITTED lets a concurrent count-then-insert read the same
// under-quota total, so the identity is serialized for the whole transaction.
async lockQuotaScope(key: string): Promise<void> {
await this.query('SELECT pg_advisory_xact_lock(hashtext(?::text))', [key])
}
async close(): Promise<void> {}
}
function retryablePostgresTransactionError(error: unknown): boolean {
const code = String((error as { code?: unknown }).code)
// 57014 is the pool statement_timeout firing. It aborts the transaction the
// same way a lock timeout does, so it takes the bounded retry path too.
return code === '40P01' || code === '40001' || code === '55P03' || code === '57014'
}
async function waitForPostgresRetry(): Promise<void> {
const delayMs = Math.floor(Math.random() * (POSTGRES_RETRY_MAX_DELAY_MS + 1))
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
class PostgresDatabase implements PushDatabase {
readonly dialect = 'postgres' as const
constructor(private readonly pool: pg.Pool) {}
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
const client = await this.pool.connect()
try {
const result = await client.query(postgresSql(sql), params)
return returnsRows(sql) ? (result.rows as SqlRow[]) : [{ changes: result.rowCount ?? 0 }]
} finally {
client.release()
}
}
async transaction<T>(operation: (transaction: PushDatabase) => Promise<T>): Promise<T> {
for (let attempt = 1; attempt <= POSTGRES_TRANSACTION_ATTEMPTS; attempt++) {
const client = await this.pool.connect()
try {
await client.query('BEGIN')
const result = await operation(new PostgresTransaction(client))
await client.query('COMMIT')
return result
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined)
if (
!retryablePostgresTransactionError(error) ||
attempt === POSTGRES_TRANSACTION_ATTEMPTS
) {
throw error
}
console.warn(
JSON.stringify({
event: 'orca_push_postgres_transaction_retry',
code: String((error as { code?: unknown }).code),
attempt
})
)
} finally {
client.release()
}
// A PostgreSQL transaction is unusable after an abort, so retry all work
// on a fresh pooled client with a small full-jitter delay.
await waitForPostgresRetry()
}
throw new Error('postgres_transaction_retry_exhausted')
}
// An advisory transaction lock taken outside a transaction is released by the
// implicit commit before the caller reads anything, which protects nothing.
async lockQuotaScope(): Promise<void> {
throw new Error('lock_quota_scope_requires_transaction')
}
async close(): Promise<void> {
await this.pool.end()
}
}
async function applySchema(database: PushDatabase): Promise<void> {
for (const statement of pushSchemaStatements()) await database.query(statement)
await ensurePushSessionIndex(database)
}
// Why: DDL is not a request. A CREATE INDEX on a grown table can legitimately
// outlive the request statement_timeout, and inheriting it would fail every
// startup at the same statement instead of finishing once. One connection of
// its own, closed before the serving pool opens, keeps the untimed session off
// the request path entirely.
async function applySchemaOnUntimedPool(
databaseUrl: string,
applicationName: string | undefined
): Promise<void> {
const pool = new pg.Pool({
connectionString: databaseUrl,
max: 1,
application_name: applicationName ? `${applicationName}/schema` : undefined,
connectionTimeoutMillis: POSTGRES_CONNECTION_TIMEOUT_MS,
statement_timeout: 0,
lock_timeout: POSTGRES_LOCK_TIMEOUT_MS,
idle_in_transaction_session_timeout: POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS
})
absorbPostgresIdleClientErrors(pool)
const database = new PostgresDatabase(pool)
try {
await applyPostgresSchema(pushSchemaStatements(), (statement) => database.query(statement), {
eventPrefix: 'orca_push_postgres_schema'
})
await ensurePushSessionIndex(database)
} finally {
await database.close().catch(() => undefined)
}
}
export function absorbPostgresIdleClientErrors(pool: Pick<pg.Pool, 'on'>): void {
pool.on('error', () => {
// node-postgres removes failed idle clients itself; an unhandled 'error'
// would crash the service and turn a SQL blip into a restart loop.
console.warn('[orca-push] idle PostgreSQL client failed')
})
}
export async function openPushDatabase(input: {
databaseUrl?: string
dataDir: string
poolMax?: number
applicationName?: string
}): Promise<PushDatabase> {
let database: PushDatabase
if (input.databaseUrl) {
await applySchemaOnUntimedPool(input.databaseUrl, input.applicationName)
const pool = new pg.Pool({
connectionString: input.databaseUrl,
max: input.poolMax ?? 10,
application_name: input.applicationName,
connectionTimeoutMillis: POSTGRES_CONNECTION_TIMEOUT_MS,
statement_timeout: POSTGRES_STATEMENT_TIMEOUT_MS,
lock_timeout: POSTGRES_LOCK_TIMEOUT_MS,
idle_in_transaction_session_timeout: POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS
})
absorbPostgresIdleClientErrors(pool)
database = new PostgresDatabase(pool)
} else {
mkdirSync(input.dataDir, { recursive: true })
const sqlite = new DatabaseSync(join(input.dataDir, 'orca-push.sqlite'))
sqlite.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;')
database = new SqliteDatabase(sqlite)
}
if (database.dialect === 'postgres') return database
try {
await applySchema(database)
return database
} catch (error) {
await database.close().catch(() => undefined)
throw error
}
}
export async function openInMemoryPushDatabase(): Promise<PushDatabase> {
const sqlite = new DatabaseSync(':memory:')
sqlite.exec('PRAGMA foreign_keys = ON;')
const database = new SqliteDatabase(sqlite)
await applySchema(database)
return database
}
@@ -1,155 +0,0 @@
import { afterEach, expect, it, vi } from 'vitest'
import { Hono } from 'hono'
import { PushRequestDrain } from './push-request-drain.js'
import { PushCoalescer } from './coalescer.js'
import { PushDispatcher } from './push-dispatcher.js'
import { PushDeviceRegistryStore } from './device-registry-store.js'
import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
import { buildPushDelivery } from './push-delivery-message.js'
import { PushNotificationSchema } from '@orca-cloud/push-contract'
import { notification } from './push-server-harness.test-fixture.js'
const databases: PushDatabase[] = []
afterEach(async () => {
await Promise.all(databases.splice(0).map((db) => db.close()))
vi.restoreAllMocks()
})
const note = PushNotificationSchema.parse(notification())
const tick = () => new Promise((resolve) => setImmediate(resolve))
function deferred() {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
async function registered() {
const db = await openInMemoryPushDatabase()
databases.push(db)
const devices = new PushDeviceRegistryStore(db)
const input = {
hostFingerprint: 'abcdefghijklmnop',
deviceId: 'device',
platform: 'android' as const,
token: 'old-token',
filter: { sources: [], agentStates: [] }
}
const row = await devices.upsert(input)
if (!row.ok) throw new Error('registration failed')
const delivery = buildPushDelivery({
registrationId: row.registrationId,
hostFingerprint: input.hostFingerprint,
notification: note,
title: note.title,
body: note.body,
coalescedCount: 1
})
return { db, devices, input, delivery }
}
it('does not retire a refreshed token after the old token fails', async () => {
const h = await registered()
const gate = deferred()
const send = vi.fn(async () => {
await gate.promise
return { status: 'dead', reason: 'UNREGISTERED' }
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
const dispatcher = new PushDispatcher({ devices: h.devices, fcm: { send } as never })
const pending = dispatcher.deliver(h.delivery)
await tick()
await h.devices.upsert({ ...h.input, token: 'replacement-token' })
gate.resolve()
await pending
expect(await h.devices.findById(h.delivery.registrationId)).toMatchObject({
token: 'replacement-token',
dead: false
})
})
it('drains timer-triggered deliveries that already left the window map', async () => {
const gate = deferred()
const deliver = vi.fn(() => gate.promise)
const coalescer = new PushCoalescer({
deliver,
setTimer: () => ({ handle: null }),
clearTimer: () => {}
})
coalescer.enqueue({
registrationId: 'reg',
hostFingerprint: 'abcdefghijklmnop',
notification: note
})
const pending = coalescer.flush('reg')
let drained = false
const drain = coalescer.flushAll().then(() => {
drained = true
})
await tick()
expect(deliver).toHaveBeenCalledOnce()
expect(drained).toBe(false)
gate.resolve()
await Promise.all([pending, drain])
expect(drained).toBe(true)
})
it('rejects new requests during drain and waits for an admitted handler', async () => {
const gate = deferred()
const requests = new PushRequestDrain()
const app = new Hono().use('*', requests.middleware).post('/send', async (c) => {
await gate.promise
return c.json({ queued: true })
})
const pending = app.request('/send', { method: 'POST' })
await tick()
let drained = false
const drain = requests.begin().then(() => {
drained = true
})
expect((await app.request('/send', { method: 'POST' })).status).toBe(503)
expect(drained).toBe(false)
gate.resolve()
expect((await pending).status).toBe(200)
await drain
expect(drained).toBe(true)
})
it('retries transient failures with the provider delay and stops after success', async () => {
const h = await registered()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const send = vi
.fn()
.mockResolvedValueOnce({
status: 'error',
reason: 'UNAVAILABLE',
retryable: true,
retryAfterMs: 10000
})
.mockResolvedValue({ status: 'sent' })
const wait = vi.fn(async (_ms: number) => {})
await new PushDispatcher({ devices: h.devices, fcm: { send } as never, wait }).deliver(h.delivery)
expect(send).toHaveBeenCalledTimes(2)
expect(wait).toHaveBeenCalledExactlyOnceWith(expect.any(Number))
expect(wait.mock.calls[0]![0]).toBeGreaterThanOrEqual(10000)
})
it('bounds retries and rechecks registration after waiting', async () => {
const h = await registered()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const send = vi.fn().mockResolvedValue({ status: 'error', reason: 'timeout', retryable: true })
await new PushDispatcher({
devices: h.devices,
fcm: { send } as never,
wait: async () => {}
}).deliver(h.delivery)
expect(send).toHaveBeenCalledTimes(3)
send.mockClear()
await new PushDispatcher({
devices: h.devices,
fcm: { send } as never,
wait: async () => {
await h.devices.deleteOwned(h.input.hostFingerprint, h.delivery.registrationId)
}
}).deliver(h.delivery)
expect(send).toHaveBeenCalledOnce()
})
@@ -1,87 +0,0 @@
import { PUSH_LIMITS, type PushNotification } from '@orca-cloud/push-contract'
export type PushOrcaData = {
hostFingerprint: string
worktreeId?: string
notificationId?: string
notificationSeq: number
notificationEpoch: string
source: string
agentState: string | null
coalescedCount: number
}
export type PushDelivery = {
sound?: boolean
registrationId: string
hostFingerprint: string
title: string
body: string
collapseId: string
orca: PushOrcaData
}
export function hostCollapseId(hostFingerprint: string): string {
return `host:${hostFingerprint}`
}
// APNs rejects a collapse id over 64 bytes, and notification ids are opaque
// desktop strings that may be longer or carry multi-byte characters.
export function truncateUtf8(value: string, maxBytes: number): string {
const encoded = Buffer.from(value, 'utf8')
if (encoded.byteLength <= maxBytes) return value
let end = maxBytes
// Walk back off a continuation byte so the cut never splits a code point.
while (end > 0 && (encoded[end]! & 0b1100_0000) === 0b1000_0000) end -= 1
return encoded.subarray(0, end).toString('utf8')
}
export function collapseIdFor(
notification: PushNotification,
hostFingerprint: string,
coalescedCount: number
): string {
if (coalescedCount > 1 || notification.notificationId === undefined) {
return hostCollapseId(hostFingerprint)
}
return truncateUtf8(notification.notificationId, PUSH_LIMITS.apnsCollapseIdMaxBytes)
}
export function buildPushDelivery(input: {
registrationId: string
hostFingerprint: string
notification: PushNotification
title: string
body: string
coalescedCount: number
}): PushDelivery {
const { notification, hostFingerprint, coalescedCount } = input
return {
...(notification.sound === false ? { sound: false } : {}),
registrationId: input.registrationId,
hostFingerprint,
title: input.title,
body: input.body,
collapseId: collapseIdFor(notification, hostFingerprint, coalescedCount),
orca: {
hostFingerprint,
...(notification.worktreeId === undefined ? {} : { worktreeId: notification.worktreeId }),
...(notification.notificationId === undefined
? {}
: { notificationId: notification.notificationId }),
notificationSeq: notification.notificationSeq,
notificationEpoch: notification.notificationEpoch,
source: notification.source,
agentState: notification.agentState,
coalescedCount
}
}
}
export function orcaDataStrings(orca: PushOrcaData): Record<string, string> {
return Object.fromEntries(
Object.entries(orca)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => [key, String(value)])
)
}
-74
View File
@@ -1,74 +0,0 @@
import type { ApnsClient } from './apns-client.js'
import type { PushDeviceRegistryStore } from './device-registry-store.js'
import type { FcmClient } from './fcm-client.js'
import { fingerprintLogPrefix } from './host-fingerprint.js'
import type { PushDelivery } from './push-delivery-message.js'
import type { PushProviderOutcome } from './push-provider-outcome.js'
export type PushDispatcherOptions = {
devices: PushDeviceRegistryStore
apns?: ApnsClient
fcm?: FcmClient
wait?: (ms: number) => Promise<void>
now?: () => number
onRetry?: () => void
onOutcome?: (outcome: PushProviderOutcome['status']) => void
}
// Sends one coalesced delivery through the provider the registration belongs
// to, and retires the registration when the provider says the token is gone.
export class PushDispatcher {
constructor(private readonly options: PushDispatcherOptions) {}
async deliver(delivery: PushDelivery): Promise<void> {
const now = this.options.now ?? Date.now
const deadline = now() + 120_000
for (let attempt = 0; attempt < 3; attempt++) {
if (now() >= deadline) return
const retry = await this.deliverAttempt(delivery)
if (!retry || attempt === 2) return
const delay = Math.max(retry.delayMs, 1000 * 2 ** attempt) + Math.floor(Math.random() * 250)
if (now() + delay >= deadline) return
this.options.onRetry?.()
await (this.options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(
delay
)
}
}
private async deliverAttempt(delivery: PushDelivery): Promise<{ delayMs: number } | undefined> {
const device = await this.options.devices.findById(delivery.registrationId)
if (!device || device.dead) return
let outcome: PushProviderOutcome
if (device.platform === 'ios') {
outcome = this.options.apns
? await this.options.apns.send(delivery, {
token: device.token,
apnsEnvironment: device.apnsEnvironment ?? 'production'
})
: { status: 'error', reason: 'apns_not_configured' }
} else {
outcome = this.options.fcm
? await this.options.fcm.send(delivery, { token: device.token })
: { status: 'error', reason: 'fcm_not_configured' }
}
this.options.onOutcome?.(outcome.status)
if (outcome.status === 'dead') {
await this.options.devices.markDead(delivery.registrationId, device)
}
if (outcome.status !== 'sent') {
console.warn(
JSON.stringify({
event: 'orca_push_delivery_failed',
platform: device.platform,
status: outcome.status,
reason: outcome.reason,
host: fingerprintLogPrefix(delivery.hostFingerprint)
})
)
}
if (outcome.status === 'error' && outcome.retryable)
return { delayMs: outcome.retryAfterMs ?? 0 }
return undefined
}
}
@@ -1,31 +0,0 @@
import { expect, it } from 'vitest'
import { apnsBody } from './apns-client.js'
import { fcmMessageBody } from './fcm-client.js'
import { buildPushDelivery } from './push-delivery-message.js'
import { PushNotificationSchema } from '@orca-cloud/push-contract'
it('carries a silent preference through validation to APNs and Android payloads', () => {
const notification = PushNotificationSchema.parse({
notificationSeq: 1,
notificationEpoch: 'epoch',
source: 'terminal-bell',
agentState: null,
title: 'Bell',
body: '',
sound: false
})
const delivery = buildPushDelivery({
registrationId: 'reg',
hostFingerprint: 'host',
notification,
title: 'Bell',
body: '',
coalescedCount: 1
})
expect(JSON.parse(apnsBody(delivery)).aps).not.toHaveProperty('sound')
expect(
JSON.parse(fcmMessageBody({ delivery, token: 'test-token', channelId: 'orca-desktop' })).message
.android.notification.channel_id
).toBe('orca-desktop-silent')
expect(JSON.parse(apnsBody({ ...delivery, sound: undefined })).aps.sound).toBe('default')
})
-73
View File
@@ -1,73 +0,0 @@
type PushCounterName =
| 'ip_rate_limited'
| 'request_error'
| 'challenge_issued'
| 'challenge_rejected'
| 'session_issued'
| 'session_rejected'
| 'device_registered'
| 'device_rejected'
| 'device_deleted'
| 'send_queued'
| 'send_dead'
| 'send_rate_limited'
| 'send_error'
| 'delivery_sent'
| 'delivery_dead'
| 'delivery_error'
| 'delivery_retry'
const COUNTER_NAMES: PushCounterName[] = [
'ip_rate_limited',
'request_error',
'challenge_issued',
'challenge_rejected',
'session_issued',
'session_rejected',
'device_registered',
'device_rejected',
'device_deleted',
'send_queued',
'send_dead',
'send_rate_limited',
'send_error',
'delivery_sent',
'delivery_dead',
'delivery_error',
'delivery_retry'
]
// Aggregate counters only. Nothing here may accept a token, a title, a body,
// or more than the first four characters of a host fingerprint.
export class PushObservability {
private counters = new Map<PushCounterName, number>()
private timer: NodeJS.Timeout | null = null
record(name: PushCounterName, delta = 1): void {
this.counters.set(name, (this.counters.get(name) ?? 0) + delta)
}
consume(): Record<PushCounterName, number> {
const snapshot = Object.fromEntries(
COUNTER_NAMES.map((name) => [name, this.counters.get(name) ?? 0])
) as Record<PushCounterName, number>
this.counters = new Map()
return snapshot
}
start(intervalMs = 60_000): void {
if (this.timer) return
this.timer = setInterval(() => {
const counters = this.consume()
if (Object.values(counters).every((value) => value === 0)) return
console.warn(JSON.stringify({ event: 'orca_push_counters', ...counters }))
}, intervalMs)
this.timer.unref()
}
stop(): void {
if (!this.timer) return
clearInterval(this.timer)
this.timer = null
}
}
@@ -1,6 +0,0 @@
// What a provider send resolved to, before the send route maps it onto the
// contract's queued / dead / rate_limited / error statuses.
export type PushProviderOutcome =
| { status: 'sent' }
| { status: 'dead'; reason: string }
| { status: 'error'; reason: string; retryable?: boolean; retryAfterMs?: number }
-33
View File
@@ -1,33 +0,0 @@
import type { PushDatabase } from './push-database.js'
export type PushReadinessOptions = {
cacheMs?: number
now?: () => number
observe?: (observation: { ready: boolean; sqlLatencyMs: number }) => void
}
// The gateway holds no JWKS dependency, so readiness is exactly "can we reach
// the database": /health stays unconditional for the container probe.
export function createPushReadiness(
database: PushDatabase,
options: PushReadinessOptions = {}
): () => Promise<boolean> {
const cacheMs = options.cacheMs ?? 10_000
const now = options.now ?? Date.now
let cachedAt = Number.NEGATIVE_INFINITY
let cached = false
return async () => {
if (now() - cachedAt < cacheMs) return cached
const startedAt = now()
try {
await database.query('SELECT 1 AS ready')
cached = true
} catch {
cached = false
}
cachedAt = now()
options.observe?.({ ready: cached, sqlLatencyMs: Math.max(0, cachedAt - startedAt) })
return cached
}
}
-28
View File
@@ -1,28 +0,0 @@
import type { MiddlewareHandler } from 'hono'
export class PushRequestDrain {
private draining = false
private active = 0
private readonly waiters = new Set<() => void>()
readonly middleware: MiddlewareHandler = async (context, next) => {
if (this.draining) return context.json({ error: 'shutting_down' }, 503)
this.active++
try {
await next()
} finally {
this.active--
if (this.active === 0) {
for (const resolve of this.waiters) resolve()
this.waiters.clear()
}
}
}
begin(): Promise<void> {
this.draining = true
return this.active === 0
? Promise.resolve()
: new Promise((resolve) => this.waiters.add(resolve))
}
}
-71
View File
@@ -1,71 +0,0 @@
// The five tables the gateway spec names. Applied at startup for both dialects,
// so every column type has to read the same in SQLite and PostgreSQL.
const PUSH_SCHEMA = `
CREATE TABLE IF NOT EXISTS push_hosts (
host_fingerprint TEXT PRIMARY KEY,
host_public_key TEXT NOT NULL,
created_at BIGINT NOT NULL,
last_seen_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS push_challenges (
challenge_id TEXT PRIMARY KEY,
host_fingerprint TEXT NOT NULL,
-- Carried here so a host row is only written once a proof succeeds; an
-- unauthenticated challenge must not be able to create one.
host_public_key TEXT NOT NULL,
secret_hash TEXT NOT NULL,
transcript TEXT NOT NULL,
expires_at BIGINT NOT NULL,
consumed_at BIGINT
);
CREATE INDEX IF NOT EXISTS push_challenges_expires_at ON push_challenges(expires_at);
CREATE TABLE IF NOT EXISTS push_sessions (
token_hash TEXT PRIMARY KEY,
host_fingerprint TEXT NOT NULL,
expires_at BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS push_sessions_expires_at ON push_sessions(expires_at);
CREATE TABLE IF NOT EXISTS push_devices (
registration_id TEXT PRIMARY KEY,
host_fingerprint TEXT NOT NULL,
device_id TEXT NOT NULL,
platform TEXT NOT NULL,
token TEXT NOT NULL,
apns_environment TEXT,
filter_json TEXT NOT NULL,
dead_at BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS push_devices_host_device
ON push_devices(host_fingerprint, device_id);
CREATE TABLE IF NOT EXISTS push_send_log (
send_id TEXT PRIMARY KEY,
host_fingerprint TEXT NOT NULL,
registration_id TEXT NOT NULL,
sent_at BIGINT NOT NULL
);
-- Both quota windows scan by identity and time, and the pruner scans by time alone.
CREATE INDEX IF NOT EXISTS push_send_log_host_sent_at ON push_send_log(host_fingerprint, sent_at);
CREATE INDEX IF NOT EXISTS push_send_log_registration_sent_at
ON push_send_log(registration_id, sent_at);
CREATE INDEX IF NOT EXISTS push_send_log_sent_at ON push_send_log(sent_at);
-- The stale-host pruner scans by last contact. Its owning-host subquery rides
-- the push_devices_host_device index.
CREATE INDEX IF NOT EXISTS push_hosts_last_seen_at ON push_hosts(last_seen_at);
`
export function pushSchemaStatements(): string[] {
// Comments are stripped before the split so a ';' inside one cannot cut a
// statement in half and hand SQLite an "incomplete input" fragment.
return PUSH_SCHEMA.replace(/--[^\n]*/g, '')
.split(';')
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0)
}
@@ -1,34 +0,0 @@
import { afterEach, expect, it } from 'vitest'
import { createPushServerHarness, notification } from './push-server-harness.test-fixture.js'
import { createPushHostKeypair } from './host-challenge-answering.test-fixture.js'
const harnesses: Awaited<ReturnType<typeof createPushServerHarness>>[] = []
afterEach(async () => {
await Promise.all(harnesses.splice(0).map((h) => h.close()))
})
it('returns queued for concurrent retries without double quota or a false summary', async () => {
const h = await createPushServerHarness()
harnesses.push(h)
const token = await h.signIn(createPushHostKeypair(2))
const registrationId = await h.registerAndroid(token)
const body = { v: 1, registrationIds: [registrationId], notification: notification() }
const responses = await Promise.all(
Array.from({ length: 10 }, () => h.post('/v1/send', body, token))
)
for (const response of responses)
expect(await response.json()).toEqual({ results: [{ registrationId, status: 'queued' }] })
expect(h.server.coalescer.pendingCount(registrationId)).toBe(1)
await h.server.coalescer.flushAll()
await h.post('/v1/send', body, token)
await h.server.coalescer.flushAll()
expect(h.fcmRequests).toHaveLength(1)
expect(JSON.parse(h.fcmRequests[0]!.body).message.data.coalescedCount).toBe('1')
expect((await h.database.query('SELECT COUNT(*) AS count FROM push_send_log'))[0]?.count).toBe(1)
await h.post(
'/v1/send',
{ ...body, notification: notification({ notificationEpoch: 'new-epoch' }) },
token
)
await h.server.coalescer.flushAll()
expect(h.fcmRequests).toHaveLength(2)
})
@@ -1,162 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPushHostKeypair } from './host-challenge-answering.test-fixture.js'
import type { PushDatabase } from './push-database.js'
import { createPushServer } from './push-server.js'
import {
createPushServerHarness,
FILTER,
testPushConfig
} from './push-server-harness.test-fixture.js'
describe('push gateway authentication and device routes', () => {
let harness: Awaited<ReturnType<typeof createPushServerHarness>>
beforeEach(async () => {
harness = await createPushServerHarness()
})
afterEach(async () => {
await harness.close()
})
it('answers health unconditionally and ready from the database', async () => {
expect((await harness.server.app.request('/health')).status).toBe(200)
expect((await harness.server.app.request('/ready')).status).toBe(200)
})
it('reports not ready when the database is unreachable', async () => {
const unreachable: PushDatabase = {
dialect: 'sqlite',
query: async () => {
throw new Error('no connection')
},
transaction: async (operation) => await operation(unreachable),
lockQuotaScope: async () => undefined,
close: async () => undefined
}
const broken = createPushServer(testPushConfig(), unreachable, {
fcmAccessToken: async () => 'token',
fcmTransport: async () => ({ status: 200, body: '{}' })
})
expect((await broken.app.request('/health')).status).toBe(200)
expect((await broken.app.request('/ready')).status).toBe(503)
broken.coalescer.stop()
})
it('completes challenge, session, register, list, delete', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(11))
const registrationId = await harness.registerAndroid(sessionToken)
const list = await harness.authorized('/v1/devices', {}, sessionToken)
expect(await list.json()).toEqual({
devices: [{ registrationId, deviceId: 'device-1', platform: 'android', dead: false }]
})
const deleted = await harness.authorized(
`/v1/devices/${registrationId}`,
{ method: 'DELETE' },
sessionToken
)
expect(deleted.status).toBe(204)
expect(await harness.server.devices.findById(registrationId)).toBeNull()
})
it('refuses a request with no bearer, a bogus bearer, and an expired session', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(12))
expect((await harness.server.app.request('/v1/devices')).status).toBe(401)
const bogus = await harness.authorized('/v1/devices', {}, 'nonsense')
expect(bogus.status).toBe(401)
expect(await bogus.json()).toEqual({ error: 'invalid_token' })
harness.advanceClock(PUSH_LIMITS.sessionTtlMs + 1)
const expired = await harness.authorized('/v1/devices', {}, sessionToken)
expect(expired.status).toBe(401)
expect(await expired.json()).toEqual({ error: 'session_expired' })
})
it('refuses a replayed proof and an unknown challenge', async () => {
const host = createPushHostKeypair(13)
const challenge = await harness.issueChallenge(host)
const proof = harness.answer(challenge, host)
expect(
(await harness.post('/v1/host/session', {
v: 1,
challengeId: challenge.challengeId,
proofB64: proof
})).status
).toBe(200)
const replay = await harness.post('/v1/host/session', {
v: 1,
challengeId: challenge.challengeId,
proofB64: proof
})
expect(replay.status).toBe(401)
expect(await replay.json()).toEqual({ error: 'invalid_proof' })
const unknown = await harness.post('/v1/host/session', {
v: 1,
challengeId: 'no-such-challenge',
proofB64: proof
})
expect(await unknown.json()).toEqual({ error: 'invalid_challenge' })
})
it('never returns the host fingerprint on the challenge itself', async () => {
const challenge = await harness.issueChallenge(createPushHostKeypair(22))
expect(Object.keys(challenge).sort()).toEqual([
'challengeId',
'ciphertextB64',
'expiresAt',
'gatewayEphemeralPublicKeyB64',
'nonceB64'
])
})
it('lets only the owning host delete a registration', async () => {
const ownerToken = await harness.signIn(createPushHostKeypair(14))
const intruderToken = await harness.signIn(createPushHostKeypair(15))
const registrationId = await harness.registerAndroid(ownerToken)
const forbidden = await harness.authorized(
`/v1/devices/${registrationId}`,
{ method: 'DELETE' },
intruderToken
)
expect(forbidden.status).toBe(404)
expect(await forbidden.json()).toEqual({ error: 'not_found' })
expect(await harness.server.devices.findById(registrationId)).not.toBeNull()
})
it('replaces the token on a re-registration and keeps one registration id', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(23))
const first = await harness.registerAndroid(sessionToken)
const again = await harness.post(
'/v1/devices',
{
v: 1,
deviceId: 'device-1',
platform: 'android',
token: 'rotated_token:APA91b-newnewnewnewnewnewnewnewnewnew',
filter: FILTER
},
sessionToken
)
expect(await again.json()).toEqual({ registrationId: first })
expect(await harness.server.devices.findById(first)).toMatchObject({
token: 'rotated_token:APA91b-newnewnewnewnewnewnewnewnewnew'
})
})
it('rejects a malformed registration body', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(16))
const bad = await harness.post(
'/v1/devices',
{ v: 1, deviceId: 'device-1', platform: 'ios', token: 'not-hex', filter: FILTER },
sessionToken
)
expect(bad.status).toBe(400)
expect(await bad.json()).toEqual({ error: 'invalid_request' })
})
})
@@ -1,165 +0,0 @@
import { generateKeyPairSync } from 'node:crypto'
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { expect } from 'vitest'
import type { ApnsRequest, ApnsResponse } from './apns-http2-transport.js'
import type { PushConfig } from './config.js'
import type { FcmRequest, FcmResponse } from './fcm-client.js'
import {
answerPushHostChallenge,
hostPublicKeyB64,
type PushHostKeypair
} from './host-challenge-answering.test-fixture.js'
import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
import { createPushServer } from './push-server.js'
export const GATEWAY_ORIGIN = 'https://push.onorca.dev'
export const APNS_TOKEN = 'a'.repeat(64)
export const FCM_TOKEN = 'cQ1abcDEF_gh:APA91bZZ-zz0123456789abcdefghijklmnopqrstuvwxyz'
export const FILTER = { sources: ['agent-task-complete'], agentStates: ['needs-input'] }
export function notification(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
notificationId: 'note-1',
notificationSeq: 1,
notificationEpoch: 'epoch-1',
source: 'agent-task-complete',
agentState: 'needs-input',
title: 'Agent needs input',
body: 'Waiting on your answer',
worktreeId: 'wt-1',
...overrides
}
}
export function testPushConfig(): PushConfig {
const { privateKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
})
return {
port: 0,
publicUrl: GATEWAY_ORIGIN,
dataDir: './data/push-test',
databasePoolMax: 10,
apns: { keyPem: privateKey, keyId: 'ABCDE12345', teamId: 'TEAM123456' },
apnsTopic: 'com.stably.orca.mobile',
fcmProjectId: 'onorca-cloud',
coalesceMs: PUSH_LIMITS.coalesceWindowMs,
trustedProxyHops: 0
}
}
type ChallengeWire = {
challengeId: string
gatewayEphemeralPublicKeyB64: string
nonceB64: string
ciphertextB64: string
expiresAt: number
}
export async function createPushServerHarness() {
const database: PushDatabase = await openInMemoryPushDatabase()
let clock = 1_700_000_000_000
const apnsRequests: ApnsRequest[] = []
const fcmRequests: FcmRequest[] = []
let apnsResponse: ApnsResponse = { status: 200, body: '' }
let fcmResponse: FcmResponse = { status: 200, body: '{}' }
const server = createPushServer(testPushConfig(), database, {
now: () => clock,
providerRetryWait: async () => undefined,
apnsTransport: async (request) => {
apnsRequests.push(request)
return apnsResponse
},
fcmTransport: async (request) => {
fcmRequests.push(request)
return fcmResponse
},
fcmAccessToken: async () => 'access-token',
// Windows are flushed explicitly so the 3s timer never gates a test.
setTimer: () => ({ handle: null }),
clearTimer: () => undefined
})
const post = async (path: string, body: unknown, token?: string): Promise<Response> =>
await server.app.request(path, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify(body)
})
const issueChallenge = async (keypair: PushHostKeypair): Promise<ChallengeWire> => {
const response = await post('/v1/host/challenge', {
v: 1,
hostPublicKeyB64: hostPublicKeyB64(keypair)
})
expect(response.status).toBe(200)
return (await response.json()) as ChallengeWire
}
const answer = (challenge: ChallengeWire, keypair: PushHostKeypair): string => {
const proof = answerPushHostChallenge(challenge, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair,
now: () => clock
})
expect(proof).not.toBeNull()
return proof!
}
return {
server,
database,
apnsRequests,
fcmRequests,
post,
issueChallenge,
answer,
now: () => clock,
advanceClock: (deltaMs: number): void => {
clock += deltaMs
},
setApnsResponse: (response: ApnsResponse): void => {
apnsResponse = response
},
setFcmResponse: (response: FcmResponse): void => {
fcmResponse = response
},
authorized: async (path: string, init: RequestInit = {}, token?: string): Promise<Response> =>
await server.app.request(path, {
...init,
headers: {
...(init.headers as Record<string, string> | undefined),
...(token ? { authorization: `Bearer ${token}` } : {})
}
}),
signIn: async (keypair: PushHostKeypair): Promise<string> => {
const challenge = await issueChallenge(keypair)
const response = await post('/v1/host/session', {
v: 1,
challengeId: challenge.challengeId,
proofB64: answer(challenge, keypair)
})
expect(response.status).toBe(200)
return ((await response.json()) as { sessionToken: string }).sessionToken
},
registerAndroid: async (token: string, deviceId = 'device-1'): Promise<string> => {
const response = await post(
'/v1/devices',
{ v: 1, deviceId, platform: 'android', token: FCM_TOKEN, filter: FILTER },
token
)
expect(response.status).toBe(200)
return ((await response.json()) as { registrationId: string }).registrationId
},
close: async (): Promise<void> => {
server.coalescer.stop()
// A test may close the database itself to provoke a route failure.
await database.close().catch(() => undefined)
}
}
}
@@ -1,270 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createPushHostKeypair,
hostPublicKeyB64
} from './host-challenge-answering.test-fixture.js'
import {
createPushServerHarness,
FCM_TOKEN,
FILTER,
notification
} from './push-server-harness.test-fixture.js'
const CLIENT_IP = '203.0.113.7'
const OTHER_CLIENT_IP = '198.51.100.9'
function oversizedChallengeBody(): string {
return JSON.stringify({ v: 1, filler: 'x'.repeat(PUSH_LIMITS.maxHttpBodyBytes) })
}
function chunkedRequest(path: string, body: string): Request {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(body))
controller.close()
}
})
return new Request(`http://push.test${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: stream,
duplex: 'half'
} as RequestInit)
}
describe('push gateway request limits', () => {
let harness: Awaited<ReturnType<typeof createPushServerHarness>>
beforeEach(async () => {
harness = await createPushServerHarness()
})
afterEach(async () => {
await harness.close()
})
it('refuses an oversized chunked body that declares no content length', async () => {
const request = chunkedRequest('/v1/host/challenge', oversizedChallengeBody())
expect(request.headers.get('content-length')).toBeNull()
const response = await harness.server.app.request(request)
expect(response.status).toBe(413)
expect(await response.json()).toEqual({ error: 'request_too_large' })
})
it('still refuses an oversized body that declares a content length', async () => {
const body = oversizedChallengeBody()
const response = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(body))
},
body
})
expect(response.status).toBe(413)
expect(await response.json()).toEqual({ error: 'request_too_large' })
})
it('lets a chunked body under the cap through to schema validation', async () => {
const response = await harness.server.app.request(
chunkedRequest(
'/v1/host/challenge',
JSON.stringify({ v: 1, hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(60)) })
)
)
expect(response.status).toBe(200)
})
it('caps an authenticated oversized send as well', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(61))
const response = await harness.server.app.request(
new Request('http://push.test/v1/send', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${sessionToken}`
},
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversizedChallengeBody()))
controller.close()
}
}),
duplex: 'half'
} as RequestInit)
)
expect(response.status).toBe(413)
expect(await response.json()).toEqual({ error: 'request_too_large' })
})
it('rate limits one client ip across both unauthenticated routes', async () => {
const body = JSON.stringify({
v: 1,
hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(62))
})
// Cloud Run appends the peer, so the caller's own IP is the last value.
const headers = {
'content-type': 'application/json',
'x-forwarded-for': `10.0.0.1, ${CLIENT_IP}`
}
for (let index = 0; index < PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp; index++) {
const allowed = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers,
body
})
expect(allowed.status).toBe(200)
}
const limited = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers,
body
})
expect(limited.status).toBe(429)
expect(await limited.json()).toEqual({ error: 'rate_limited' })
// The session route draws on the same bucket, so a flood cannot simply move.
const session = await harness.server.app.request('/v1/host/session', {
method: 'POST',
headers,
body: JSON.stringify({ v: 1, challengeId: 'anything', proofB64: 'x'.repeat(44) })
})
expect(session.status).toBe(429)
const other = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers: { ...headers, 'x-forwarded-for': `10.0.0.1, ${OTHER_CLIENT_IP}` },
body
})
expect(other.status).toBe(200)
// A caller rewriting the left of the chain lands in its own bucket anyway.
const spoofed = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers: { ...headers, 'x-forwarded-for': `198.51.100.250, ${CLIENT_IP}` },
body
})
expect(spoofed.status).toBe(429)
})
it('lets a throttled client back in once the window refills', async () => {
const body = JSON.stringify({
v: 1,
hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(63))
})
const headers = { 'content-type': 'application/json', 'x-forwarded-for': CLIENT_IP }
for (let index = 0; index < PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp; index++) {
await harness.server.app.request('/v1/host/challenge', { method: 'POST', headers, body })
}
expect(
(await harness.server.app.request('/v1/host/challenge', { method: 'POST', headers, body }))
.status
).toBe(429)
harness.advanceClock(60_000)
expect(
(await harness.server.app.request('/v1/host/challenge', { method: 'POST', headers, body }))
.status
).toBe(200)
})
it('gives the authenticated routes their own, wider bucket per client ip', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(64))
const headers = { 'x-forwarded-for': CLIENT_IP }
for (let index = 0; index < PUSH_LIMITS.authenticatedRequestsPerMinutePerIp; index++) {
const listed = await harness.authorized('/v1/devices', { headers }, sessionToken)
expect(listed.status).toBe(200)
}
const limited = await harness.authorized('/v1/devices', { headers }, sessionToken)
expect(limited.status).toBe(429)
// The handshake bucket is untouched by any of that.
const challenge = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({ v: 1, hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(67)) })
})
expect(challenge.status).toBe(200)
})
it('caps a flood of forged bearers before any of them reaches the session lookup', async () => {
const headers = { 'x-forwarded-for': CLIENT_IP }
const [before] = await harness.database.query('SELECT COUNT(*) AS sessions FROM push_sessions')
for (let index = 0; index < PUSH_LIMITS.authenticatedRequestsPerMinutePerIp; index++) {
const refused = await harness.authorized('/v1/send', { method: 'POST', headers }, 'forged')
expect(refused.status).toBe(401)
}
const limited = await harness.authorized('/v1/send', { method: 'POST', headers }, 'forged')
expect(limited.status).toBe(429)
expect(await limited.json()).toEqual({ error: 'rate_limited' })
expect(harness.server.unauthenticatedIps.trackedIpCount()).toBe(0)
const [after] = await harness.database.query('SELECT COUNT(*) AS sessions FROM push_sessions')
expect(Number(after?.sessions)).toBe(Number(before?.sessions))
})
it('answers 409 once a host has registered its device allowance', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(66))
for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
const accepted = await harness.post(
'/v1/devices',
{ v: 1, deviceId: `device-${index}`, platform: 'android', token: FCM_TOKEN, filter: FILTER },
sessionToken
)
expect(accepted.status).toBe(200)
}
const refused = await harness.post(
'/v1/devices',
{ v: 1, deviceId: 'one-too-many', platform: 'android', token: FCM_TOKEN, filter: FILTER },
sessionToken
)
expect(refused.status).toBe(409)
expect(await refused.json()).toEqual({ error: 'too_many_devices' })
const listed = await harness.authorized('/v1/devices', {}, sessionToken)
expect(((await listed.json()) as { devices: unknown[] }).devices).toHaveLength(
PUSH_LIMITS.maxDevicesPerHost
)
})
// Why: a database error carries the failing row in its message. The response
// and the log must both stop at the error's name.
it('answers an unexpected route failure with a bare 500 and logs only the name', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(66))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
await harness.database.close()
const response = await harness.authorized('/v1/devices', {}, sessionToken)
expect(response.status).toBe(500)
expect(await response.json()).toEqual({ error: 'internal' })
const logged = warn.mock.calls.map((call) => String(call[0])).join('\n')
expect(logged).toContain('"event":"orca_push_request_failed"')
expect(logged).not.toContain('SELECT')
expect(logged).not.toContain('push_devices')
expect(harness.server.observability.consume().request_error).toBe(1)
} finally {
warn.mockRestore()
}
})
it('charges a repeated registration id once and returns one result', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(65))
const registrationId = await harness.registerAndroid(sessionToken)
const response = await harness.post(
'/v1/send',
{
v: 1,
registrationIds: [registrationId, registrationId, registrationId],
notification: notification()
},
sessionToken
)
expect(await response.json()).toEqual({ results: [{ registrationId, status: 'queued' }] })
expect(harness.server.coalescer.pendingCount(registrationId)).toBe(1)
const [row] = await harness.database.query('SELECT COUNT(*) AS sends FROM push_send_log')
expect(Number(row?.sends)).toBe(1)
})
})
@@ -1,182 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPushHostKeypair } from './host-challenge-answering.test-fixture.js'
import {
APNS_TOKEN,
createPushServerHarness,
FCM_TOKEN,
FILTER,
notification
} from './push-server-harness.test-fixture.js'
describe('push gateway send route', () => {
let harness: Awaited<ReturnType<typeof createPushServerHarness>>
beforeEach(async () => {
harness = await createPushServerHarness()
})
afterEach(async () => {
await harness.close()
})
it('rejects a batch over the registration cap', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(16))
const oversized = await harness.post(
'/v1/send',
{
v: 1,
registrationIds: Array.from(
{ length: PUSH_LIMITS.maxRegistrationIdsPerSend + 1 },
(_, index) => `reg-${index}`
),
notification: notification()
},
sessionToken
)
expect(oversized.status).toBe(400)
expect(await oversized.json()).toEqual({ error: 'invalid_request' })
})
it('queues a send, delivers it to fcm, and reports a dead token on the next send', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(17))
const registrationId = await harness.registerAndroid(sessionToken)
const queued = await harness.post(
'/v1/send',
{ v: 1, registrationIds: [registrationId], notification: notification() },
sessionToken
)
expect(await queued.json()).toEqual({ results: [{ registrationId, status: 'queued' }] })
harness.setFcmResponse({
status: 404,
body: JSON.stringify({ error: { status: 'UNREGISTERED', message: 'gone' } })
})
await harness.server.coalescer.flushAll()
expect(harness.fcmRequests).toHaveLength(1)
expect(JSON.parse(harness.fcmRequests[0]!.body)).toMatchObject({
message: { token: FCM_TOKEN, notification: { title: 'Agent needs input' } }
})
const afterDeath = await harness.post(
'/v1/send',
{ v: 1, registrationIds: [registrationId], notification: notification() },
sessionToken
)
expect(await afterDeath.json()).toEqual({ results: [{ registrationId, status: 'dead' }] })
const listed = await harness.authorized('/v1/devices', {}, sessionToken)
expect(await listed.json()).toEqual({
devices: [{ registrationId, deviceId: 'device-1', platform: 'android', dead: true }]
})
})
it('leaves a live registration alone when the provider reports a transient failure', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(24))
const registrationId = await harness.registerAndroid(sessionToken)
await harness.post(
'/v1/send',
{ v: 1, registrationIds: [registrationId], notification: notification() },
sessionToken
)
harness.setFcmResponse({
status: 503,
body: JSON.stringify({ error: { status: 'UNAVAILABLE', message: 'backend busy' } })
})
await harness.server.coalescer.flushAll()
expect(await harness.server.devices.findById(registrationId)).toMatchObject({ dead: false })
})
it('coalesces a burst into one apns summary under the host collapse id', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(18))
const registration = await harness.post(
'/v1/devices',
{
v: 1,
deviceId: 'iphone-1',
platform: 'ios',
token: APNS_TOKEN,
apnsEnvironment: 'sandbox',
filter: FILTER
},
sessionToken
)
const { registrationId } = (await registration.json()) as { registrationId: string }
for (const seq of [1, 2, 3]) {
await harness.post(
'/v1/send',
{
v: 1,
registrationIds: [registrationId],
notification: notification({ notificationId: `note-${seq}`, notificationSeq: seq })
},
sessionToken
)
}
await harness.server.coalescer.flushAll()
expect(harness.apnsRequests).toHaveLength(1)
const request = harness.apnsRequests[0]!
expect(request.host).toBe('api.sandbox.push.apple.com')
const body = JSON.parse(request.body) as {
aps: { alert: { title: string; body: string } }
orca: { coalescedCount: number; notificationSeq: number }
}
expect(body.aps.alert).toEqual({ title: 'Orca', body: '3 agents need attention' })
expect(body.orca.coalescedCount).toBe(3)
expect(body.orca.notificationSeq).toBe(3)
expect(request.headers['apns-collapse-id']).toMatch(/^host:/)
})
it('sends a lone event through unchanged with its own collapse id', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(25))
const registrationId = await harness.registerAndroid(sessionToken)
await harness.post(
'/v1/send',
{ v: 1, registrationIds: [registrationId], notification: notification() },
sessionToken
)
await harness.server.coalescer.flushAll()
const message = JSON.parse(harness.fcmRequests[0]!.body) as {
message: { android: { notification: { tag: string } }; data: Record<string, string> }
}
expect(message.message.android.notification.tag).toBe('note-1')
expect(message.message.data.coalescedCount).toBe('1')
})
it('reports an error for a registration the host does not own', async () => {
const ownerToken = await harness.signIn(createPushHostKeypair(19))
const intruderToken = await harness.signIn(createPushHostKeypair(20))
const registrationId = await harness.registerAndroid(ownerToken)
const foreign = await harness.post(
'/v1/send',
{ v: 1, registrationIds: [registrationId, 'made-up'], notification: notification() },
intruderToken
)
expect(await foreign.json()).toEqual({
results: [
{ registrationId, status: 'error' },
{ registrationId: 'made-up', status: 'error' }
]
})
expect(harness.server.coalescer.pendingCount(registrationId)).toBe(0)
})
it('rate limits a host that exhausted its hourly allowance', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(21))
const registrationId = await harness.registerAndroid(sessionToken)
const hostFingerprint = (await harness.server.devices.findById(registrationId))!.hostFingerprint
for (let index = 0; index < PUSH_LIMITS.hostSendsPerRollingHour; index++) {
expect(await harness.server.quota.reserve(hostFingerprint, registrationId)).toBe('allowed')
}
const limited = await harness.post(
'/v1/send',
{ v: 1, registrationIds: [registrationId], notification: notification() },
sessionToken
)
expect(limited.status).toBe(200)
expect(await limited.json()).toEqual({ results: [{ registrationId, status: 'rate_limited' }] })
expect(harness.server.coalescer.pendingCount(registrationId)).toBe(0)
})
})
-289
View File
@@ -1,289 +0,0 @@
import { createAdaptorServer } from '@hono/node-server'
import {
PUSH_LIMITS,
PushDeviceRegistrationRequestSchema,
PushHostChallengeRequestSchema,
PushHostSessionRequestSchema,
PushSendRequestSchema,
type PushSendResult
} from '@orca-cloud/push-contract'
import { Hono, type MiddlewareHandler } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { ApnsClient } from './apns-client.js'
import { createApnsHttp2Transport, type ApnsTransport } from './apns-http2-transport.js'
import { clientIpRateLimit, ClientIpRateLimiter } from './client-ip-rate-limit.js'
import { PushCoalescer } from './coalescer.js'
import type { PushConfig } from './config.js'
import { PushDeviceRegistryStore } from './device-registry-store.js'
import { createFcmAccessTokenProvider } from './fcm-access-token.js'
import { createFcmFetchTransport, FcmClient, type FcmTransport } from './fcm-client.js'
import { PushHostChallengeStore } from './host-challenge-store.js'
import { PushHostSessionStore } from './host-session-store.js'
import type { PushDatabase } from './push-database.js'
import { PushDispatcher } from './push-dispatcher.js'
import { PushObservability } from './push-observability.js'
import { createPushReadiness } from './push-readiness.js'
import { PushRequestDrain } from './push-request-drain.js'
import { PushSendQuota } from './send-quota.js'
export type PushServerOptions = {
now?: () => number
providerRetryWait?: (ms: number) => Promise<void>
apnsTransport?: ApnsTransport
fcmTransport?: FcmTransport
fcmAccessToken?: () => Promise<string>
setTimer?: PushCoalescerTimerFactory
clearTimer?: (timer: { readonly handle: unknown }) => void
}
type PushCoalescerTimerFactory = (
callback: () => void,
delayMs: number
) => { readonly handle: unknown }
type PushVariables = { hostFingerprint: string }
export function readBearer(header: string | undefined): string | null {
if (!header) return null
const [scheme, ...rest] = header.split(' ')
const token = rest.join(' ').trim()
return scheme?.toLowerCase() === 'bearer' && token.length > 0 ? token : null
}
// Hono's body limit, not a Content-Length check: a chunked body declares no
// length, and req.json() would buffer all of it before any handler ran.
const limitBody = bodyLimit({
maxSize: PUSH_LIMITS.maxHttpBodyBytes,
onError: (context) => context.json({ error: 'request_too_large' }, 413)
})
export function createPushServer(
config: PushConfig,
database: PushDatabase,
options: PushServerOptions = {}
) {
const now = options.now ?? Date.now
const observability = new PushObservability()
const challenges = new PushHostChallengeStore(database, config.publicUrl, now)
const sessions = new PushHostSessionStore(database, now)
const devices = new PushDeviceRegistryStore(database, now)
const quota = new PushSendQuota(database, now)
const apnsTransport = options.apnsTransport ?? (config.apns ? createApnsHttp2Transport() : null)
const dispatcher = new PushDispatcher({
devices,
now,
...(options.providerRetryWait ? { wait: options.providerRetryWait } : {}),
onRetry: () => observability.record('delivery_retry'),
...(config.apns && apnsTransport
? {
apns: new ApnsClient({
topic: config.apnsTopic,
credentials: config.apns,
transport: apnsTransport,
now
})
}
: {}),
fcm: new FcmClient({
projectId: config.fcmProjectId,
accessToken: options.fcmAccessToken ?? createFcmAccessTokenProvider(),
transport: options.fcmTransport ?? createFcmFetchTransport()
}),
onOutcome: (status) =>
observability.record(
status === 'sent' ? 'delivery_sent' : status === 'dead' ? 'delivery_dead' : 'delivery_error'
)
})
const coalescer = new PushCoalescer({
windowMs: config.coalesceMs,
deliver: (delivery) => dispatcher.deliver(delivery),
...(options.setTimer ? { setTimer: options.setTimer } : {}),
...(options.clearTimer ? { clearTimer: options.clearTimer } : {}),
onDeliveryFailed: () => observability.record('delivery_error')
})
const ready = createPushReadiness(database, { now })
const unauthenticatedIps = new ClientIpRateLimiter({ now })
const limitUnauthenticatedIp = clientIpRateLimit(unauthenticatedIps, {
trustedProxyHops: config.trustedProxyHops,
onLimited: () => observability.record('ip_rate_limited')
})
// Why a second bucket: a bearer has to be looked up before it can be refused,
// and that lookup takes one of very few pool connections. Capping the caller
// first keeps a flood of forged bearers from starving real hosts of the pool.
const authenticatedIps = new ClientIpRateLimiter({
now,
capacity: PUSH_LIMITS.authenticatedRequestsPerMinutePerIp
})
const limitAuthenticatedIp = clientIpRateLimit(authenticatedIps, {
trustedProxyHops: config.trustedProxyHops,
onLimited: () => observability.record('ip_rate_limited')
})
const app = new Hono<{ Variables: PushVariables }>()
const requestDrain = new PushRequestDrain()
app.use('*', requestDrain.middleware)
// Hono's default handler prints the whole error, and a pg error carries the
// offending row in `detail`. Only the error's name may reach the logs.
app.onError((error, context) => {
observability.record('request_error')
console.warn(
JSON.stringify({
event: 'orca_push_request_failed',
error: error instanceof Error ? error.name : 'unknown'
})
)
return context.json({ error: 'internal' }, 500)
})
app.get('/health', (context) => context.json({ ok: true, pushProtocol: 1 }))
app.get('/ready', async (context) =>
(await ready())
? context.json({ ok: true })
: context.json({ error: 'dependency_unavailable' }, 503)
)
const bearerSession: MiddlewareHandler<{ Variables: PushVariables }> = async (context, next) => {
const bearer = readBearer(context.req.header('authorization'))
if (!bearer) return context.json({ error: 'invalid_token' }, 401)
const session = await sessions.resolve(bearer)
if (!session.ok) {
return context.json(
{ error: session.reason === 'session_expired' ? 'session_expired' : 'invalid_token' },
401
)
}
context.set('hostFingerprint', session.hostFingerprint)
await next()
return
}
// `/v1/devices/*` matches `/v1/devices` itself; a second registration for the
// bare path would run both middlewares twice on it.
app.use('/v1/devices/*', limitAuthenticatedIp, bearerSession)
app.use('/v1/send', limitAuthenticatedIp, bearerSession)
app.post('/v1/host/challenge', limitUnauthenticatedIp, limitBody, async (context) => {
const body = PushHostChallengeRequestSchema.safeParse(
await context.req.json().catch(() => null)
)
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
const issued = await challenges.issue(body.data.hostPublicKeyB64)
if (!issued) {
observability.record('challenge_rejected')
return context.json({ error: 'invalid_request' }, 400)
}
observability.record('challenge_issued')
const { hostFingerprint: _bound, ...response } = issued
return context.json(response)
})
app.post('/v1/host/session', limitUnauthenticatedIp, limitBody, async (context) => {
const body = PushHostSessionRequestSchema.safeParse(await context.req.json().catch(() => null))
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
const verification = await challenges.verify(body.data.challengeId, body.data.proofB64)
if (!verification.ok) {
observability.record('session_rejected')
return context.json(
{
error: verification.reason === 'unknown_challenge' ? 'invalid_challenge' : 'invalid_proof'
},
401
)
}
observability.record('session_issued')
return context.json(await sessions.create(verification.hostFingerprint))
})
app.post('/v1/devices', limitBody, async (context) => {
const body = PushDeviceRegistrationRequestSchema.safeParse(
await context.req.json().catch(() => null)
)
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
const registered = await devices.upsert({
hostFingerprint: context.get('hostFingerprint'),
deviceId: body.data.deviceId,
platform: body.data.platform,
token: body.data.token,
...(body.data.apnsEnvironment === undefined
? {}
: { apnsEnvironment: body.data.apnsEnvironment }),
filter: body.data.filter
})
if (!registered.ok) {
observability.record('device_rejected')
return context.json({ error: 'too_many_devices' }, 409)
}
observability.record('device_registered')
return context.json({ registrationId: registered.registrationId })
})
app.delete('/v1/devices/:registrationId', async (context) => {
const deleted = await devices.deleteOwned(
context.get('hostFingerprint'),
context.req.param('registrationId')
)
if (!deleted) return context.json({ error: 'not_found' }, 404)
observability.record('device_deleted')
return context.body(null, 204)
})
app.get('/v1/devices', async (context) =>
context.json({ devices: await devices.list(context.get('hostFingerprint')) })
)
app.post('/v1/send', limitBody, async (context) => {
const body = PushSendRequestSchema.safeParse(await context.req.json().catch(() => null))
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
const hostFingerprint = context.get('hostFingerprint')
const owned = await devices.findOwned(hostFingerprint, body.data.registrationIds)
const results: PushSendResult[] = []
for (const registrationId of body.data.registrationIds) {
const device = owned.get(registrationId)
if (!device) {
observability.record('send_error')
results.push({ registrationId, status: 'error' })
continue
}
if (device.dead) {
observability.record('send_dead')
results.push({ registrationId, status: 'dead' })
continue
}
const reservation = await quota.reserve(
hostFingerprint,
registrationId,
body.data.notification
)
if (reservation === 'duplicate') {
results.push({ registrationId, status: 'queued' })
continue
}
if (reservation === 'rate_limited') {
observability.record('send_rate_limited')
results.push({ registrationId, status: 'rate_limited' })
continue
}
coalescer.enqueue({ registrationId, hostFingerprint, notification: body.data.notification })
observability.record('send_queued')
results.push({ registrationId, status: 'queued' })
}
return context.json({ results })
})
return {
app,
requestDrain,
server: createAdaptorServer(app),
challenges,
sessions,
devices,
quota,
unauthenticatedIps,
coalescer,
observability,
ready,
closeTransports: (): void => {
if (apnsTransport && 'close' in apnsTransport) {
;(apnsTransport as { close: () => void }).close()
}
}
}
}
@@ -1,73 +0,0 @@
import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import { openInMemoryPushDatabase, openPushDatabase, type PushDatabase } from './push-database.js'
import { PushHostSessionStore } from './host-session-store.js'
import { ensurePushSessionIndex } from './push-session-schema.js'
const databases: PushDatabase[] = []
afterEach(async () => {
await Promise.all(databases.splice(0).map((db) => db.close()))
})
async function concurrentSessions(db: PushDatabase) {
databases.push(db)
const host = randomUUID()
const store = new PushHostSessionStore(db)
try {
const sessions = await Promise.all(Array.from({ length: 20 }, () => store.create(host)))
const decisions = await Promise.all(
sessions.map((session) => store.resolve(session.sessionToken))
)
expect(decisions.filter((decision) => decision.ok)).toHaveLength(1)
const [row] = await db.query(
'SELECT COUNT(*) AS count FROM push_sessions WHERE host_fingerprint = ?',
[host]
)
expect(Number(row?.count)).toBe(1)
} finally {
await db.query('DELETE FROM push_sessions WHERE host_fingerprint = ?', [host])
}
}
it('serializes sessions on SQLite', async () => {
await concurrentSessions(await openInMemoryPushDatabase())
})
it('migrates existing duplicate hosts to the newest session and enforces uniqueness', async () => {
const db = await openInMemoryPushDatabase()
databases.push(db)
await db.query('DROP INDEX push_sessions_host')
for (const [token, created] of [
['old', 1],
['new', 2]
] as const) {
await db.query('INSERT INTO push_sessions VALUES (?, ?, ?, ?)', [token, 'host', 100, created])
}
await ensurePushSessionIndex(db)
expect(await db.query('SELECT token_hash FROM push_sessions')).toEqual([{ token_hash: 'new' }])
await expect(
db.query('INSERT INTO push_sessions VALUES (?, ?, ?, ?)', ['third', 'host', 100, 3])
).rejects.toThrow()
})
describe.skipIf(!process.env.ORCA_PUSH_TEST_DATABASE_URL)('PostgreSQL push sessions', () => {
it('leaves exactly one live token after concurrent creates', async () => {
await concurrentSessions(
await openPushDatabase({
databaseUrl: process.env.ORCA_PUSH_TEST_DATABASE_URL!,
dataDir: tmpdir()
})
)
})
it('allows concurrent schema startup', async () => {
const opened = await Promise.all(
Array.from({ length: 4 }, () =>
openPushDatabase({
databaseUrl: process.env.ORCA_PUSH_TEST_DATABASE_URL!,
dataDir: tmpdir()
})
)
)
databases.push(...opened)
for (const db of opened) expect(await db.query('SELECT 1 AS ok')).toEqual([{ ok: 1 }])
})
})
@@ -1,23 +0,0 @@
import type { PushDatabase } from './push-database.js'
export async function ensurePushSessionIndex(database: PushDatabase): Promise<void> {
await database.transaction(async (transaction) => {
await transaction.lockQuotaScope('orca-push-session-schema')
const indexQuery =
database.dialect === 'postgres'
? "SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'push_sessions' AND indexname = 'push_sessions_host'"
: "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'push_sessions_host'"
if ((await transaction.query(indexQuery)).length) return
// Retain the newest session when upgrading a database with duplicate hosts.
await transaction.query(`DELETE FROM push_sessions WHERE token_hash IN (
SELECT token_hash FROM (
SELECT token_hash, ROW_NUMBER() OVER (
PARTITION BY host_fingerprint ORDER BY created_at DESC, token_hash DESC
) AS position FROM push_sessions
) AS ranked WHERE position > 1
)`)
await transaction.query(
'CREATE UNIQUE INDEX IF NOT EXISTS push_sessions_host ON push_sessions(host_fingerprint)'
)
})
}
@@ -1,100 +0,0 @@
import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { PushDeviceRegistryStore } from './device-registry-store.js'
import { openPushDatabase, type PushDatabase } from './push-database.js'
import { PushSendQuota } from './send-quota.js'
// Cloud Verify supplies a disposable PostgreSQL; SQLite cannot expose these races.
const DATABASE_URL = process.env.ORCA_PUSH_TEST_DATABASE_URL
const CONCURRENT_RESERVES = 80
describe.skipIf(!DATABASE_URL)('push send quota on postgres', () => {
let database: PushDatabase
let hostFingerprint: string
beforeEach(async () => {
database = await openPushDatabase({
databaseUrl: DATABASE_URL!,
dataDir: tmpdir(),
applicationName: 'orca-push-test'
})
// Every run owns a fresh identity, so a shared database needs no truncation.
hostFingerprint = randomUUID().replaceAll('-', '').slice(0, 16)
})
afterEach(async () => {
await database.query('DELETE FROM push_send_log WHERE host_fingerprint = ?', [hostFingerprint])
await database.query('DELETE FROM push_devices WHERE host_fingerprint = ?', [hostFingerprint])
await database.close()
})
it('admits exactly the hourly allowance when every reserve races at once', async () => {
const quota = new PushSendQuota(database)
const decisions = await Promise.all(
Array.from({ length: CONCURRENT_RESERVES }, () => quota.reserve(hostFingerprint, 'reg-1'))
)
expect(decisions.filter((decision) => decision === 'allowed')).toHaveLength(
PUSH_LIMITS.hostSendsPerRollingHour
)
expect(decisions.filter((decision) => decision === 'rate_limited')).toHaveLength(
CONCURRENT_RESERVES - PUSH_LIMITS.hostSendsPerRollingHour
)
const [row] = await database.query(
'SELECT COUNT(*) AS sends FROM push_send_log WHERE host_fingerprint = ?',
[hostFingerprint]
)
expect(Number(row?.sends)).toBe(PUSH_LIMITS.hostSendsPerRollingHour)
})
it('holds the per-host device cap when every registration races at once', async () => {
const devices = new PushDeviceRegistryStore(database)
const attempts = PUSH_LIMITS.maxDevicesPerHost + 20
const results = await Promise.all(
Array.from({ length: attempts }, (_, index) =>
devices.upsert({
hostFingerprint,
deviceId: `device-${index}`,
platform: 'android',
token: `token-${index}`,
filter: { sources: ['agent-task-complete'], agentStates: ['needs-input'] }
})
)
)
expect(results.filter((result) => result.ok)).toHaveLength(PUSH_LIMITS.maxDevicesPerHost)
const [row] = await database.query(
'SELECT COUNT(*) AS devices FROM push_devices WHERE host_fingerprint = ?',
[hostFingerprint]
)
expect(Number(row?.devices)).toBe(PUSH_LIMITS.maxDevicesPerHost)
})
it('does not let one host lock block another host reserving at the same time', async () => {
const quota = new PushSendQuota(database)
const otherHost = randomUUID().replaceAll('-', '').slice(0, 16)
try {
const decisions = await Promise.all([
...Array.from({ length: 40 }, () => quota.reserve(hostFingerprint, 'reg-1')),
...Array.from({ length: 40 }, () => quota.reserve(otherHost, 'reg-2'))
])
expect(decisions.every((decision) => decision === 'allowed')).toBe(true)
} finally {
await database.query('DELETE FROM push_send_log WHERE host_fingerprint = ?', [otherHost])
}
})
it('reserves a retried event once under concurrent PostgreSQL transactions', async () => {
const quota = new PushSendQuota(database)
const event = { notificationEpoch: 'epoch', notificationSeq: 1 }
const results = await Promise.all(
Array.from({ length: 40 }, () => quota.reserve(hostFingerprint, 'reg-dedupe', event))
)
expect(results.filter((result) => result === 'allowed')).toHaveLength(1)
expect(results.filter((result) => result === 'duplicate')).toHaveLength(39)
expect(
await quota.reserve(hostFingerprint, 'reg-dedupe', { ...event, notificationEpoch: 'next' })
).toBe('allowed')
})
})
-70
View File
@@ -1,70 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
import { PushSendQuota } from './send-quota.js'
const HOST = 'abcdefghijklmnop'
const HOUR_MS = 60 * 60 * 1000
const DAY_MS = 24 * HOUR_MS
describe('push send quota', () => {
let database: PushDatabase
let clock = 1_700_000_000_000
let quota: PushSendQuota
beforeEach(async () => {
database = await openInMemoryPushDatabase()
clock = 1_700_000_000_000
quota = new PushSendQuota(database, () => clock)
})
afterEach(async () => {
await database.close()
})
async function reserveMany(count: number, registrationId: string): Promise<string[]> {
const decisions: string[] = []
for (let index = 0; index < count; index++) {
decisions.push(await quota.reserve(HOST, registrationId))
}
return decisions
}
it('admits exactly the hourly host allowance and refuses the next send', async () => {
const decisions = await reserveMany(PUSH_LIMITS.hostSendsPerRollingHour, 'reg-1')
expect(decisions.every((decision) => decision === 'allowed')).toBe(true)
await expect(quota.reserve(HOST, 'reg-1')).resolves.toBe('rate_limited')
})
it('lets the host window roll forward', async () => {
await reserveMany(PUSH_LIMITS.hostSendsPerRollingHour, 'reg-1')
clock += HOUR_MS
await expect(quota.reserve(HOST, 'reg-1')).resolves.toBe('allowed')
})
it('limits a single registration across a rolling day even as hosts rotate', async () => {
// Spread the day allowance across hours so the hourly host cap never binds.
for (let index = 0; index < PUSH_LIMITS.registrationSendsPerRollingDay; index++) {
expect(await quota.reserve(HOST, 'reg-1')).toBe('allowed')
if ((index + 1) % PUSH_LIMITS.hostSendsPerRollingHour === 0) clock += HOUR_MS + 1
}
await expect(quota.reserve(HOST, 'reg-1')).resolves.toBe('rate_limited')
await expect(quota.reserve(HOST, 'reg-2')).resolves.toBe('allowed')
clock += DAY_MS
await expect(quota.reserve(HOST, 'reg-1')).resolves.toBe('allowed')
})
it('never logs a send it refused', async () => {
await reserveMany(PUSH_LIMITS.hostSendsPerRollingHour + 5, 'reg-1')
const [row] = await database.query('SELECT COUNT(*) AS sends FROM push_send_log')
expect(Number(row?.sends)).toBe(PUSH_LIMITS.hostSendsPerRollingHour)
})
it('prunes the log past the retention window only', async () => {
await quota.reserve(HOST, 'reg-1')
clock += PUSH_LIMITS.sendLogRetentionMs
expect(await quota.prune()).toBe(0)
clock += 1
expect(await quota.prune()).toBe(1)
})
})
-75
View File
@@ -1,75 +0,0 @@
import { createHash, randomUUID } from 'node:crypto'
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import type { PushDatabase } from './push-database.js'
const QUOTA_LOCK_PREFIX = 'orca-push-send-quota:'
const ROLLING_HOUR_MS = 60 * 60 * 1000
const ROLLING_DAY_MS = 24 * ROLLING_HOUR_MS
export type PushQuotaDecision = 'allowed' | 'rate_limited' | 'duplicate'
export class PushSendQuota {
constructor(
private readonly database: PushDatabase,
private readonly now: () => number = Date.now
) {}
// One transaction is not enough on its own: PostgreSQL reads at READ
// COMMITTED, so concurrent reserves would each see the same under-quota count
// and all be admitted. The host lock serializes them. The registration count
// rides the same lock because a registration belongs to exactly one host.
async reserve(
hostFingerprint: string,
registrationId: string,
event?: { notificationEpoch: string; notificationSeq: number }
): Promise<PushQuotaDecision> {
const now = this.now()
const sendId = event
? createHash('sha256')
.update(
JSON.stringify([
hostFingerprint,
registrationId,
event.notificationEpoch,
event.notificationSeq
])
)
.digest('hex')
: randomUUID()
return await this.database.transaction<PushQuotaDecision>(async (transaction) => {
await transaction.lockQuotaScope(`${QUOTA_LOCK_PREFIX}${hostFingerprint}`)
if (
event &&
(await transaction.query('SELECT send_id FROM push_send_log WHERE send_id = ?', [sendId]))
.length
) {
return 'duplicate'
}
const [hostRow] = await transaction.query(
'SELECT COUNT(*) AS sends FROM push_send_log WHERE host_fingerprint = ? AND sent_at > ?',
[hostFingerprint, now - ROLLING_HOUR_MS]
)
if (Number(hostRow?.sends ?? 0) >= PUSH_LIMITS.hostSendsPerRollingHour) return 'rate_limited'
const [registrationRow] = await transaction.query(
'SELECT COUNT(*) AS sends FROM push_send_log WHERE registration_id = ? AND sent_at > ?',
[registrationId, now - ROLLING_DAY_MS]
)
if (Number(registrationRow?.sends ?? 0) >= PUSH_LIMITS.registrationSendsPerRollingDay) {
return 'rate_limited'
}
await transaction.query(
`INSERT INTO push_send_log (send_id, host_fingerprint, registration_id, sent_at)
VALUES (?, ?, ?, ?)`,
[sendId, hostFingerprint, registrationId, now]
)
return 'allowed'
})
}
async prune(): Promise<number> {
const [result] = await this.database.query('DELETE FROM push_send_log WHERE sent_at < ?', [
this.now() - PUSH_LIMITS.sendLogRetentionMs
])
return Number(result?.changes ?? 0)
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
},
"exclude": ["src/**/*.test.ts", "src/**/*.test-fixture.ts"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "noEmit": true },
"include": ["src/**/*.ts"]
}
-5
View File
@@ -1,5 +0,0 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: { name: 'push', include: ['src/**/*.test.ts'], testTimeout: 15_000, hookTimeout: 15_000 }
})
+1 -5
View File
@@ -3,13 +3,11 @@ WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY packages/relay-contract/package.json packages/relay-contract/package.json
COPY packages/postgres-schema/package.json packages/postgres-schema/package.json
COPY apps/relay/package.json apps/relay/package.json
RUN pnpm install --frozen-lockfile
COPY packages/relay-contract packages/relay-contract
COPY apps/relay apps/relay
COPY packages/postgres-schema packages/postgres-schema
RUN pnpm --filter @orca-cloud/postgres-schema build && pnpm --filter @orca-cloud/relay-contract build && pnpm --filter @orca-cloud/relay build
RUN pnpm --filter @orca-cloud/relay-contract build && pnpm --filter @orca-cloud/relay build
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
@@ -18,10 +16,8 @@ WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/relay-contract/package.json packages/relay-contract/package.json
COPY packages/postgres-schema/package.json packages/postgres-schema/package.json
COPY apps/relay/package.json apps/relay/package.json
COPY --from=build /app/packages/relay-contract/dist packages/relay-contract/dist
COPY --from=build /app/packages/postgres-schema/dist packages/postgres-schema/dist
COPY --from=build /app/apps/relay/dist apps/relay/dist
RUN pnpm install --prod --frozen-lockfile --filter @orca-cloud/relay...
USER node
+1 -2
View File
@@ -9,14 +9,13 @@
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"dev": "tsx watch src/index.ts",
"lint": "tsc -p tsconfig.json --noEmit",
"pretest": "pnpm --filter @orca-cloud/postgres-schema build && pnpm --filter @orca-cloud/relay-contract build",
"pretest": "pnpm --filter @orca-cloud/relay-contract build",
"start": "node dist/index.js",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.19.14",
"@orca-cloud/postgres-schema": "workspace:*",
"@orca-cloud/relay-contract": "workspace:*",
"hono": "^4.12.27",
"jose": "^6.1.3",
+105 -1
View File
@@ -1 +1,105 @@
export { applyPostgresSchema } from '@orca-cloud/postgres-schema'
const RETRYABLE_SCHEMA_CODES = new Set(['55P03', '57014'])
const DEFAULT_RETRY_DEADLINE_MS = 30_000
const RETRY_BASE_DELAY_MS = 250
const RETRY_MAX_DELAY_MS = 2_000
type SchemaStartupOptions = {
now?: () => number
random?: () => number
retryDeadlineMs?: number
wait?: (delayMs: number) => Promise<void>
}
function retryDelayMs(attempt: number, random: () => number): number {
const ceiling = Math.min(
RETRY_BASE_DELAY_MS * 2 ** (attempt - 1),
RETRY_MAX_DELAY_MS
)
return Math.ceil(ceiling * (0.5 + random() * 0.5))
}
function wait(delayMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, delayMs))
}
const CREATE_TABLE_IF_NOT_EXISTS = /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i
const CREATE_INDEX_IF_NOT_EXISTS = /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i
// `IF NOT EXISTS` only checks the name before the catalog inserts, so the loser of a concurrent
// CREATE can fail on the catalog unique index (23505) or, when the winner has already committed by
// the time the loser reaches TypeCreate/heap_create_with_catalog, on the name check those routines
// repeat (42710 duplicate type, 42P07 duplicate relation). Each is a no-op on the next attempt.
function concurrentCreateCollision(
value: { code?: unknown; constraint?: unknown },
statement: string
): boolean {
if (CREATE_TABLE_IF_NOT_EXISTS.test(statement)) {
return (
(value.code === '23505' && value.constraint === 'pg_type_typname_nsp_index') ||
value.code === '42710' ||
value.code === '42P07'
)
}
if (CREATE_INDEX_IF_NOT_EXISTS.test(statement)) {
return (
(value.code === '23505' && value.constraint === 'pg_class_relname_nsp_index') ||
value.code === '42P07'
)
}
return false
}
function retryableSchemaError(error: unknown, statement: string): boolean {
const value = error as { code?: unknown; constraint?: unknown }
return (
RETRYABLE_SCHEMA_CODES.has(String(value.code)) || concurrentCreateCollision(value, statement)
)
}
export async function applyPostgresSchema(
statements: string[],
query: (statement: string) => Promise<unknown>,
options: SchemaStartupOptions = {}
): Promise<void> {
const now = options.now ?? Date.now
const random = options.random ?? Math.random
const pause = options.wait ?? wait
const deadlineAt = now() + (options.retryDeadlineMs ?? DEFAULT_RETRY_DEADLINE_MS)
for (const statement of statements) {
let attempt = 1
while (true) {
try {
await query(statement)
break
} catch (error) {
const code = String((error as { code?: unknown }).code)
const remainingMs = deadlineAt - now()
const retryable = retryableSchemaError(error, statement)
if (!retryable || remainingMs <= 0) {
if (retryable) {
console.warn(
JSON.stringify({
event: 'orca_relay_postgres_schema_retry_exhausted',
code,
attempts: attempt
})
)
}
throw error
}
const delayMs = Math.min(remainingMs, retryDelayMs(attempt, random))
console.warn(
JSON.stringify({
event: 'orca_relay_postgres_schema_retry',
code,
attempt,
delayMs
})
)
await pause(delayMs)
attempt += 1
}
}
}
}
@@ -92,14 +92,11 @@
"google_certificate_manager_certificate_map.relay_gce",
"google_certificate_manager_certificate_map_entry.relay_gce",
"google_certificate_manager_dns_authorization.relay_gce",
"google_cloud_run_domain_mapping.push",
"google_cloud_run_domain_mapping.relay",
"google_cloud_run_domain_mapping.relay_cell",
"google_cloud_run_v2_service.push",
"google_cloud_run_v2_service.relay",
"google_cloud_run_v2_service.relay_cell",
"google_cloud_run_v2_service.relay_fence_broker",
"google_cloud_run_v2_service_iam_member.github_production_push_developer",
"google_cloud_run_v2_service_iam_member.github_production_relay_director_developer",
"google_cloud_run_v2_service_iam_member.github_production_relay_fence_broker_developer",
"google_cloud_run_v2_service_iam_member.github_staging_relay_capacity_developer",
@@ -172,9 +169,6 @@
"google_project_iam_member.github_staging_relay_capacity_viewer",
"google_project_iam_member.github_staging_relay_deploy_compute_viewer",
"google_project_iam_member.github_staging_relay_power",
"google_project_iam_member.push_runtime_cloudsql_client",
"google_project_iam_member.push_runtime_fcm_admin",
"google_project_iam_member.push_runtime_service_usage_consumer",
"google_project_iam_member.relay_director_runtime_cloudsql_client",
"google_project_iam_member.relay_fence_broker_artifact_reader",
"google_project_iam_member.relay_fence_broker_compute_viewer",
@@ -183,13 +177,9 @@
"google_project_iam_member.relay_runtime_artifact_reader",
"google_project_iam_member.relay_runtime_cloudsql_client",
"google_project_iam_member.relay_runtime_log_writer",
"google_secret_manager_secret.push_database_url",
"google_secret_manager_secret.push_provider",
"google_secret_manager_secret.relay_assignment_signing_key",
"google_secret_manager_secret.relay_database_url",
"google_secret_manager_secret.relay_regional_placement_enabled",
"google_secret_manager_secret_iam_member.push_database_url_runtime_accessor",
"google_secret_manager_secret_iam_member.push_provider_runtime_accessor",
"google_secret_manager_secret_iam_member.relay_assignment_signing_key_accessor",
"google_secret_manager_secret_iam_member.relay_assignment_signing_key_director_accessor",
"google_secret_manager_secret_iam_member.relay_database_url_accessor",
@@ -199,7 +189,6 @@
"google_secret_manager_secret_iam_member.relay_regional_placement_deploy_viewer",
"google_secret_manager_secret_iam_member.relay_regional_placement_director_accessor",
"google_secret_manager_secret_iam_member.relay_regional_placement_runtime_accessor",
"google_secret_manager_secret_version.push_database_url",
"google_secret_manager_secret_version.relay_assignment_signing_key",
"google_secret_manager_secret_version.relay_database_url",
"google_secret_manager_secret_version.relay_regional_placement_enabled",
@@ -210,15 +199,12 @@
"google_service_account.github_relay_asia_topology",
"google_service_account.github_staging_relay_capacity",
"google_service_account.github_staging_relay_deploy",
"google_service_account.push_runtime",
"google_service_account.relay_director_runtime",
"google_service_account.relay_fence_broker",
"google_service_account.relay_runtime",
"google_service_account_iam_member.github_accepted_repository_workload_identity_user",
"google_service_account_iam_member.github_fence_workload_identity_user",
"google_service_account_iam_member.github_monitor_workload_identity_user",
"google_service_account_iam_member.github_production_push_runtime_token_creator",
"google_service_account_iam_member.github_production_push_runtime_user",
"google_service_account_iam_member.github_production_relay_capacity_runtime_user",
"google_service_account_iam_member.github_production_relay_capacity_workload_identity_user",
"google_service_account_iam_member.github_relay_asia_proof_workload_identity_user",
@@ -232,9 +218,7 @@
"google_service_account_iam_member.github_staging_relay_deploy_auth_runtime_user",
"google_service_account_iam_member.github_staging_relay_deploy_workload_identity_user",
"google_service_account_iam_member.relay_fence_broker_requester_token_creator",
"google_sql_database.push",
"google_sql_database.relay",
"google_sql_user.push",
"google_sql_user.relay",
"google_storage_bucket_iam_member.github_production_relay_capacity_state",
"google_storage_bucket_iam_member.github_relay_asia_topology_state",
@@ -244,7 +228,6 @@
"google_storage_bucket_iam_member.github_staging_relay_deploy_state_list",
"google_storage_bucket_iam_member.relay_fence_broker_bucket_reader",
"google_storage_bucket_iam_member.relay_fence_broker_state_objects",
"random_password.push_database",
"random_password.relay_assignment_signing_key",
"random_password.relay_database"
],
@@ -283,8 +283,6 @@ export const LEASED_WORKFLOWS = named([
'operate-relay-production-rehome.yml',
production({ leaseFiles: ['operate-relay-production-rehome-job.yml'] })
],
// The gateway applies its schema at startup, so its deploy revision is the schema step.
['push-deploy.yml', production()],
['deploy-relay-asia-topology.yml', eitherEnvironment()],
['operate-relay-asia-admission.yml', eitherEnvironment()],
['deploy-relay-staging.yml', staging()],
@@ -1,93 +0,0 @@
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import test from 'node:test'
import { readRelayWorkflow } from './relay-repository.mjs'
const workflow = readRelayWorkflow('push-deploy.yml')
function step(name) {
const start = workflow.indexOf(` - name: ${name}\n`)
assert.notEqual(start, -1)
const end = workflow.indexOf('\n - name:', start + 1)
const block = workflow.slice(start, end === -1 ? undefined : end)
return block.slice(block.indexOf(' run: |\n') + ' run: |\n'.length)
.split('\n').filter((line) => line.startsWith(' ')).map((line) => line.slice(10)).join('\n')
}
const candidate = step('Deploy the candidate revision with no traffic')
const shift = step('Shift all traffic to the verified candidate')
const rollback = step('Roll traffic back to the previous revision')
const cleanup = step('Delete the rejected candidate revision')
const env = { SERVICE_NAME: 'push-test', GCP_PROJECT_ID: 'test', GCP_REGION: 'test',
GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '1', IMAGE: 'synthetic-image',
CANDIDATE_REVISION: 'push-test-c123-1', ROLLBACK_REVISION: 'push-test-old' }
function exercise(body) {
const dir = mkdtempSync(join(tmpdir(), 'push-workflow-'))
try {
const run = spawnSync('bash', ['-c', body], { encoding: 'utf8', timeout: 10000,
env: { ...process.env, ...env, GITHUB_ENV: join(dir, 'env'), GITHUB_STEP_SUMMARY: join(dir, 'summary'),
TRACE: join(dir, 'trace'), STATE: join(dir, 'state') } })
assert.equal(run.status, 0, run.stderr)
} finally { rmSync(dir, { recursive: true, force: true }) }
}
// Workflow shell behavior is Linux-specific; these tests never call a real cloud CLI.
test('failed candidate discovery retains enough state to remove tag and revision', { skip: process.platform === 'win32' }, () => {
exercise(`
gcloud() {
case "$*" in
'run deploy '*) echo deployed > "$STATE" ;;
'run services describe '*) return 1 ;;
*) echo "$*" >> "$TRACE" ;;
esac
}
jq() { return 1; }
( ${candidate} )
test "$?" != 0 || exit 1
source "$GITHUB_ENV"
test "$CANDIDATE_TAG" = c123-1 || exit 1
test "$CANDIDATE_REVISION" = push-test-c123-1 || exit 1
( ${cleanup} ) || exit 1
grep -q -- '--remove-tags c123-1' "$TRACE" || exit 1
grep -q 'run revisions delete push-test-c123-1' "$TRACE" || exit 1
`)
})
test('failed post-promotion read retains intent and restores previous traffic', { skip: process.platform === 'win32' }, () => {
exercise(`
gcloud() {
case "$*" in
'run services update-traffic '*) echo "$*" >> "$TRACE" ;;
'run services describe '*) return 1 ;;
esac
}
jq() { return 1; }
( ${shift} )
test "$?" != 0 || exit 1
source "$GITHUB_ENV"
test "$TRAFFIC_SHIFT_ATTEMPTED" = true || exit 1
gcloud() {
case "$*" in
'run services update-traffic '*) echo "$*" >> "$TRACE" ;;
'run services describe '*) echo '{}' ;;
esac
}
jq() { echo "$ROLLBACK_REVISION"; }
( ${rollback} ) || exit 1
source "$GITHUB_ENV"
test "$TRAFFIC_ROLLED_BACK" = true || exit 1
grep -q -- '--to-revisions push-test-old=100' "$TRACE" || exit 1
`)
})
test('ambiguous promotion failure also leaves rollback intent', { skip: process.platform === 'win32' }, () => {
exercise(`
gcloud() { return 1; }
( ${shift} )
test "$?" != 0 || exit 1
source "$GITHUB_ENV"
test "$TRAFFIC_SHIFT_ATTEMPTED" = true
`)
})
@@ -1,299 +0,0 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
concurrencyBlocks,
jobIf,
jobs,
LEASE_ACTION,
leaseSteps
} from './cloud-sql-rollout-lock-census.mjs'
import { readRelayWorkflow, relayWorkflowFile } from './relay-repository.mjs'
// Why: the push gateway holds the APNs key and is the only thing standing between a paired
// phone and a silent notification pipeline. Its deploy is a blue/green rollout against the
// shared Cloud SQL instance, and each of the guarantees below is one careless edit from gone.
const WORKFLOW = 'push-deploy.yml'
const workflow = readRelayWorkflow(WORKFLOW)
const deploy = () => {
const job = jobs(workflow).find((entry) => entry.id === 'deploy')
assert.ok(job, 'the workflow no longer declares a deploy job')
return job
}
function terraform(file) {
return readFileSync(new URL(`../../infra/terraform/${file}`, import.meta.url), 'utf8')
}
// The ordered step names; every assertion below reads positions out of this list rather than
// restating them, so a reordering that breaks the no-traffic guarantee fails here.
const stepNames = () => [...workflow.matchAll(/^ {6}- name: (.+)$/gm)].map((match) => match[1])
const indexOfStep = (name) => {
const index = stepNames().indexOf(name)
assert.notEqual(index, -1, `the workflow no longer has a "${name}" step`)
return index
}
test('the whole surface stays inert until the owner enables cloud operations', () => {
const guard = jobIf(deploy().text)
assert.ok(guard.includes("vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true'"), guard)
assert.ok(guard.includes("github.ref == 'refs/heads/main'"), guard)
assert.equal(jobs(workflow).length, 1, 'a second job would need its own gate')
})
test('it authenticates through Workload Identity and holds no repository secret', () => {
assert.match(workflow, /uses: google-github-actions\/auth@v2/)
assert.match(workflow, /workload_identity_provider: \$\{\{ vars\.PRODUCTION_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER \}\}/)
assert.match(workflow, /service_account: \$\{\{ vars\.PRODUCTION_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT \}\}/)
assert.match(workflow, /environment: production/)
for (const [, name] of workflow.matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/g)) {
assert.equal(name, 'GITHUB_TOKEN', `the workflow reads secrets.${name}`)
}
})
// Why: Terraform trusts exact workflow filenames, not a prefix. A rename here without the
// matching tfvars-independent list entry would fail authentication at dispatch time only.
test('Terraform trusts this exact workflow file on the production deploy provider', () => {
assert.match(terraform('relay-github-actions.tf'), /^\s*"push-deploy\.yml"$/m)
assert.equal(relayWorkflowFile(WORKFLOW), 'cloud-push-deploy.yml')
})
test('the rollout is serialized and leases the production Cloud SQL rollout lock', () => {
const blocks = concurrencyBlocks(workflow)
assert.equal(blocks.length, 1)
assert.equal(blocks[0].group, 'production-cloud-sql-rollout')
assert.equal(blocks[0].cancelInProgress, 'false')
const steps = leaseSteps(workflow)
assert.equal(steps.length, 1, 'exactly one lease step, held for the whole run')
assert.equal(steps[0].bucket, 'onorca-cloud-terraform-state')
assert.equal(steps[0].object, 'terraform/state/cloud-sql-rollout/production.lock')
assert.equal(steps[0].release, undefined, 'release stays at its default for a single-job run')
})
// Why: the ops guardrail is that a piped command only fails the step when pipefail is set, and
// pipefail only applies under an explicit bash shell. Every multi-line body here opts in.
test('every multi-line command runs under bash with pipefail', () => {
const bodies = [...workflow.matchAll(/^ {8}(shell: bash\n {8})?run: \|\n((?: {10}.*\n|\n)+)/gm)]
assert.ok(bodies.length >= 8, `only ${bodies.length} multi-line commands were found`)
for (const match of bodies) {
assert.ok(match[1], `a multi-line command does not declare shell: bash:\n${match[2].slice(0, 120)}`)
assert.match(match[2], /^ {10}set -euo pipefail$/m)
}
})
test('the candidate revision takes no traffic and is addressed by its own tag', () => {
assert.match(workflow, /gcloud run deploy "\$\{SERVICE_NAME\}"/)
assert.match(workflow, /^ {12}--no-traffic \\$/m)
assert.match(workflow, /--tag "\$\{tag\}"/)
assert.match(workflow, /test "\$\{CANDIDATE_REVISION\}" != "\$\{ROLLBACK_REVISION\}"/)
assert.ok(
indexOfStep('Record the serving revision and require its Terraform-owned scaling') <
indexOfStep('Deploy the candidate revision with no traffic'),
'the rollback target must be captured before the candidate exists'
)
})
// Why: scaling is a Terraform-owned field that `lifecycle.ignore_changes` does not cover, so a
// deploy that passed --max-instances would revert a later push_max_instances raise on every run.
// The workflow asserts the shape instead of writing it, on the serving revision before the
// candidate exists and on the candidate that inherits it.
test('the deploy asserts the Terraform-owned scaling instead of mutating it', () => {
assert.doesNotMatch(workflow, /--max-instances/, 'the deploy must not write a scaling field')
assert.doesNotMatch(workflow, /--min-instances "/, 'the deploy must not write a scaling field')
// The floor is the variables.tf default; production.tfvars overrides only the ceiling, down to
// the two instances the Cloud SQL connection budget leaves room for.
assert.match(workflow, /PUSH_MIN_INSTANCES: 1$/m)
assert.match(workflow, /PUSH_MAX_INSTANCES: 2$/m)
assert.match(terraform('variables.tf'), /variable "push_min_instances"[\s\S]*?default {5}= 1/)
assert.match(terraform('environments/production.tfvars'), /^push_max_instances {9}= 2$/m)
const gate = indexOfStep('Record the serving revision and require its Terraform-owned scaling')
assert.ok(gate < indexOfStep('Deploy the candidate revision with no traffic'))
assert.match(workflow, /autoscaling\.knative\.dev\/minScale/)
assert.match(workflow, /\[\[ "\$\{floor:-0\}" -lt "\$\{PUSH_MIN_INSTANCES\}" \]\]/)
assert.match(workflow, /test "\$\{ceiling\}" = "\$\{PUSH_MAX_INSTANCES\}"/)
assert.match(workflow, /test "\$\{candidate_ceiling\}" = "\$\{PUSH_MAX_INSTANCES\}"/)
})
// Why: the image build is not a Cloud SQL operation, and the lease is a global serialization
// point. A build inside it blocks every relay deploy and rehome for its duration.
test('the image is built before the rollout lease is taken', () => {
const lease = workflow.indexOf(`- uses: ${LEASE_ACTION}`)
assert.notEqual(lease, -1)
const build = workflow.indexOf('- name: Build and publish the immutable gateway image')
const deployCandidate = workflow.indexOf('- name: Deploy the candidate revision with no traffic')
assert.ok(build < lease, 'the build must finish before the run takes the lease')
assert.ok(lease < deployCandidate, 'the lease must still cover the deploy, probe, and shift')
})
// Why: the gateway's Cloud SQL draw is instances x pool, and the root that takes the rollout
// lease can only account for a pool it declares. Leaving it at the application default hid it.
test('the database pool size is Terraform-owned and bounded at plan time', () => {
const source = terraform('push-gateway.tf')
assert.match(source, /name {2}= "ORCA_PUSH_DATABASE_POOL_MAX"/)
assert.match(source, /value = tostring\(var\.push_database_pool_max\)/)
assert.match(terraform('variables.tf'), /variable "push_database_pool_max"[\s\S]*?default {5}= 2/)
const block = /resource "google_cloud_run_v2_service" "push"[\s\S]*?\n lifecycle \{([\s\S]*?)\n \}/.exec(source)
assert.ok(block, 'the push service no longer declares a lifecycle block')
assert.match(
block[1],
/var\.push_max_instances \* var\.push_database_pool_max <= 4/,
'instances x pool must be bounded at plan time'
)
assert.match(
readFileSync(new URL('../../apps/push/src/config.ts', import.meta.url), 'utf8'),
/ORCA_PUSH_DATABASE_POOL_MAX/,
'the gateway must read the variable Terraform sets'
)
})
test('the candidate is probed on its own URL before any traffic moves', () => {
const probe = indexOfStep('Probe the candidate readiness endpoint')
assert.ok(probe > indexOfStep('Deploy the candidate revision with no traffic'))
assert.ok(probe < indexOfStep('Shift all traffic to the verified candidate'))
assert.match(workflow, /"\$\{CANDIDATE_URL\}\/ready"/)
assert.match(workflow, /test "\$\{code\}" = 200/)
assert.doesNotMatch(workflow, /\$\{CANDIDATE_URL\}\/health/, 'liveness is not readiness')
})
// Why: a gateway that answers /ready can still hold no usable FCM credential. The probe must be
// validate-only, must use a token that cannot exist, and must treat a denied credential as the
// failure. Accepting PERMISSION_DENIED would make the whole step decorative.
test('the FCM probe is validate-only and separates a bad token from a bad credential', () => {
const fcm = indexOfStep('Prove the runtime identity can reach FCM')
assert.ok(fcm > indexOfStep('Probe the candidate readiness endpoint'))
assert.ok(fcm < indexOfStep('Shift all traffic to the verified candidate'))
assert.match(workflow, /"validate_only":true/)
assert.match(workflow, /https:\/\/fcm\.googleapis\.com\/v1\/projects\/\$\{GCP_PROJECT_ID\}\/messages:send/)
assert.match(workflow, /GCP_PROJECT_ID: onorca-cloud$/m)
assert.match(workflow, /orca-push-deploy-probe-invalid-token/)
assert.match(workflow, /test "\$\{status\}" = INVALID_ARGUMENT/)
assert.match(workflow, /test "\$\{status\}" = PERMISSION_DENIED/)
// Only those four answers are conclusive; a 429 or a 5xx says nothing about the credential, so
// it is retried rather than read as either verdict. A denied credential still fails at once.
assert.match(workflow, /for attempt in \$\(seq 1 5\); do/)
const probe = workflow.slice(
workflow.indexOf('- name: Prove the runtime identity can reach FCM'),
workflow.indexOf('- name: Shift all traffic to the verified candidate')
)
assert.match(probe, /for attempt in \$\(seq 1 5\); do/)
assert.match(probe, /test "\$\{code\}" = 401 \|\| test "\$\{code\}" = 403; then\n {14}break/)
assert.match(
workflow,
/--impersonate-service-account "\$\{PUSH_RUNTIME_SERVICE_ACCOUNT\}"/,
'the probe must exercise the runtime credential, not the deploy identity'
)
// Why: that token reads the Apple signing key. Masking it means a later `set -x` or a
// debug re-run cannot print it into a public log.
assert.match(
probe,
/test -n "\$\{token\}"\n {10}echo "::add-mask::\$\{token\}"/,
'the impersonated token must be masked before anything else runs'
)
assert.match(workflow, /PUSH_RUNTIME_SERVICE_ACCOUNT: orca-cloud-push@onorca-cloud\.iam\.gserviceaccount\.com/)
})
// Why: a deploy ends with traffic pinned to an exact revision, and a rollback pins it to the
// previous one. Terraform reverting the service to 100% LATEST would undo either silently.
test('Terraform does not own the image or the traffic split', () => {
const source = terraform('push-gateway.tf')
const block = /resource "google_cloud_run_v2_service" "push"[\s\S]*?\n lifecycle \{([\s\S]*?)\n \}/.exec(source)
assert.ok(block, 'the push service no longer declares a lifecycle block')
assert.match(block[1], /template\[0\]\.containers\[0\]\.image/)
assert.match(block[1], /^\s*traffic$/m)
})
test('impersonating the runtime identity is a Terraform-declared grant', () => {
const source = terraform('push-gateway.tf')
assert.match(source, /resource "google_service_account_iam_member" "github_production_push_runtime_token_creator"/)
assert.match(source, /role\s+= "roles\/iam\.serviceAccountTokenCreator"/)
assert.match(source, /resource "google_cloud_run_v2_service_iam_member" "github_production_push_developer"/)
})
test('the traffic shift is all-or-nothing and is verified after the fact', () => {
const shift = indexOfStep('Shift all traffic to the verified candidate')
assert.match(workflow, /gcloud run services update-traffic "\$\{SERVICE_NAME\}"/)
assert.match(workflow, /--to-revisions "\$\{CANDIDATE_REVISION\}=100"/)
assert.match(workflow, /test "\$\{serving\}" = "\$\{CANDIDATE_REVISION\}"/)
assert.ok(shift < indexOfStep('Verify the public origin after the shift'))
assert.match(workflow, /PUSH_ORIGIN: https:\/\/push\.onorca\.dev/)
assert.match(workflow, /"\$\{PUSH_ORIGIN\}\/ready"/)
})
// Why: the origin can lag the traffic move by seconds, and a single unlucky curl would otherwise
// roll a healthy deploy back. It retries on the same schedule as the candidate probe.
test('the post-shift origin check retries like the candidate probe', () => {
const check = workflow.slice(
workflow.indexOf('- name: Verify the public origin after the shift'),
workflow.indexOf('- name: Roll traffic back to the previous revision')
)
assert.match(check, /for attempt in \$\(seq 1 30\); do/)
assert.match(check, /sleep 5/)
assert.match(check, /test "\$\{code\}" = 200/)
})
// Why: the summary carries the rollback target. Writing it after the origin check meant the one
// run that needed it, the run whose check failed, was the one run that never got it.
test('the summary is written before anything that can fail after the shift', () => {
const summary = indexOfStep('Publish the rollout summary')
assert.ok(summary > indexOfStep('Shift all traffic to the verified candidate'))
assert.ok(summary < indexOfStep('Verify the public origin after the shift'))
assert.match(workflow, /--to-revisions \$\{ROLLBACK_REVISION\}=100/)
assert.match(workflow, /GITHUB_STEP_SUMMARY/)
})
// Why: everything after the shift runs with production on the candidate, so a failure there is a
// live gateway that has to go back. The marker is what separates that case from a failure before
// the shift, where production never moved and the candidate is the thing to clean up.
test('a failure after the shift rolls production back automatically', () => {
const rollback = indexOfStep('Roll traffic back to the previous revision')
assert.ok(rollback > indexOfStep('Verify the public origin after the shift'))
assert.match(workflow, /echo "TRAFFIC_SHIFTED=true" >> "\$\{GITHUB_ENV\}"/)
const shift = workflow.indexOf('- name: Shift all traffic to the verified candidate')
assert.ok(
workflow.indexOf('echo "TRAFFIC_SHIFTED=true"') > shift,
'the success marker follows the shift step'
)
const body = workflow.slice(
workflow.indexOf('- name: Roll traffic back to the previous revision'),
workflow.indexOf('- name: Delete the rejected candidate revision')
)
assert.match(
body,
/if: \$\{\{ \(failure\(\) \|\| cancelled\(\)\) && env\.TRAFFIC_SHIFT_ATTEMPTED == 'true' \}\}/,
'the rollback must be conditioned on both failure and the shift marker'
)
assert.match(body, /test -n "\$\{ROLLBACK_REVISION:-\}"/)
assert.match(body, /--to-revisions "\$\{ROLLBACK_REVISION\}=100"/)
assert.match(body, /test "\$\{serving\}" = "\$\{ROLLBACK_REVISION\}"/)
assert.match(body, /GITHUB_STEP_SUMMARY/, 'the rollback must be reported in the summary')
})
// Why: a candidate that never took traffic still holds a warm instance and a Cloud SQL pool. Its
// tag comes off first, because Cloud Run refuses to delete a revision a traffic target names.
test('a failure before the shift deletes the candidate it created', () => {
const body = workflow.slice(
workflow.indexOf('- name: Delete the rejected candidate revision'),
workflow.indexOf('- name: Drop the candidate traffic tag')
)
assert.match(
body,
/env\.TRAFFIC_SHIFT_ATTEMPTED != 'true' \|\| env\.TRAFFIC_ROLLED_BACK == 'true'/,
'the cleanup must be conditioned on both failure and the absence of the shift marker'
)
assert.match(body, /test -n "\$\{CANDIDATE_REVISION:-\}" \|\| exit 0/)
assert.ok(
body.indexOf('--remove-tags') < body.indexOf('gcloud run revisions delete'),
'the tag must come off before the revision is deleted'
)
assert.match(body, /echo "CANDIDATE_TAG=" >> "\$\{GITHUB_ENV\}"/)
})
test('the run always drops its traffic tag', () => {
const cleanup = indexOfStep('Drop the candidate traffic tag')
assert.equal(cleanup, stepNames().length - 1, 'tag cleanup must be the last step')
assert.match(workflow, /--remove-tags "\$\{CANDIDATE_TAG\}"/)
const body = workflow.slice(workflow.indexOf('- name: Drop the candidate traffic tag'))
assert.match(body, /if: always\(\)/)
assert.match(body, /test -n "\$\{CANDIDATE_TAG:-\}" \|\| exit 0/)
})
@@ -33,13 +33,6 @@ function requiredInteger(source, pattern, label) {
return value
}
// A tfvars file states only what it overrides, so an absent key means the variable default holds.
// Reading the default as the fallback keeps this honest either way.
function overriddenInteger(override, overridePattern, source, pattern, label) {
if (!overridePattern.test(override)) return requiredInteger(source, pattern, label)
return requiredInteger(override, overridePattern, label)
}
function productionCells(source, defaultPoolMax) {
const fencedMatch = source.match(/relay_gce_fenced_cells\s*=\s*\[([^\]]*)\]/)
if (!fencedMatch) throw new Error('could not read fenced Relay cells')
@@ -59,13 +52,11 @@ function productionCells(source, defaultPoolMax) {
}
export function calculateRelayCloudSqlConnectionBudget(inputs) {
const pushDraw = inputs.pushInstances * inputs.pushPoolMax
const consumers = {
cells: inputs.cellPoolTotal + inputs.asiaCellCount * inputs.asiaPoolMax,
directors: inputs.directorInstances * inputs.directorPoolMax,
auth: inputs.authInstances * inputs.authPoolMax,
api: inputs.apiInstances * inputs.apiPoolMax,
push: pushDraw
api: inputs.apiInstances * inputs.apiPoolMax
}
const configuredMaximum = Object.values(consumers).reduce((total, value) => total + value, 0)
const retainedDirectorRollback = inputs.directorInstances * inputs.directorPoolMax
@@ -73,11 +64,6 @@ export function calculateRelayCloudSqlConnectionBudget(inputs) {
relayDirectorCandidate: retainedDirectorRollback * 2,
apiCandidate: retainedDirectorRollback + inputs.apiInstances * inputs.apiPoolMax,
authCandidate: retainedDirectorRollback + inputs.authInstances * inputs.authPoolMax,
// The push candidate doubles rather than adding one copy, like the director candidate and
// unlike the API and auth ones: cloud-push-deploy.yml probes a *tagged* revision, which is
// directly addressable and so sits outside the service-wide instance cap, letting the
// candidate and the serving revision each reach push_max_instances at the same time.
pushCandidate: retainedDirectorRollback + pushDraw * 2,
relayCells: retainedDirectorRollback
}
const rolloutOverlap = Math.max(...Object.values(candidateOverlap))
@@ -145,20 +131,6 @@ export function readRelayCloudSqlConnectionBudget({
/variable\s+"relay_director_database_pool_max"[\s\S]*?default\s*=\s*(\d+)/,
'director pool maximum'
),
// The mobile push gateway shares this instance. Its draw was invisible here until Terraform
// declared the pool: docs/push-gateway.md, "Shape".
pushInstances: overriddenInteger(
productionTfvars,
/^\s*push_max_instances\s*=\s*(\d+)/m,
terraformVariables,
/variable\s+"push_max_instances"[\s\S]*?default\s*=\s*(\d+)/,
'push gateway instances'
),
pushPoolMax: requiredInteger(
terraformVariables,
/variable\s+"push_database_pool_max"[\s\S]*?default\s*=\s*(\d+)/,
'push gateway pool maximum'
),
authInstances: apps.authInstances,
authPoolMax: apps.authPoolMax,
apiInstances: apps.apiInstances,
@@ -6,91 +6,28 @@ import {
readRelayCloudSqlConnectionBudget
} from './relay-cloud-sql-connection-budget.mjs'
// Why these numbers are this tight: the shared instance's 400 connections were already spoken
// for, and the relay shape below leaves exactly five. The gateway is sized to fit in four, two
// instances times a two-connection pool, and its rollout overlap of 23 stays under the API
// candidate's 65, so the Math.max is the API candidate rather than the gateway.
//
// `Deploy Relay Asia Topology` gates on `withinBudget == true`, so the single remaining
// connection is the whole margin. Anything that raises a pool or an instance count moves it.
test('production plus the push gateway keeps allowance and reserve below the ceiling', () => {
test('production plus three Asia pools preserves allowance and reserve below the ceiling', () => {
const report = readRelayCloudSqlConnectionBudget()
assert.deepEqual(report.consumers, { cells: 230, directors: 15, auth: 20, api: 50, push: 4 })
assert.deepEqual(report.consumers, { cells: 230, directors: 15, auth: 20, api: 50 })
assert.deepEqual(report.asia, { cells: 3, poolMax: 10 })
assert.equal(report.configuredMaximum, 319)
assert.equal(report.configuredMaximum, 315)
assert.equal(report.rolloutOverlap.relayDirectorCandidate, 30)
assert.equal(report.rolloutOverlap.apiCandidate, 65)
assert.equal(report.rolloutOverlap.authCandidate, 35)
assert.equal(report.rolloutOverlap.pushCandidate, 23)
assert.equal(report.rolloutOverlap.relayCells, 15)
assert.equal(report.rolloutOverlap.retainedDirectorRollback, 15)
// The gateway does not set the maximum; the API candidate does, as it did before it existed.
assert.equal(report.rolloutOverlap.maximum, 65)
assert.equal(report.maintenanceAdminAllowance, 5)
assert.equal(report.explicitReserve, 10)
assert.equal(report.usableCeiling, 390)
assert.equal(report.operatingMaximum, 389)
assert.equal(report.remainingWithinUsableCeiling, 1)
assert.equal(report.budgetedTotal, 399)
assert.equal(report.unallocated, 1)
assert.equal(report.withinBudget, true)
})
// Why: the same relay shape without a push gateway is the before picture, and it stood at five
// connections clear. Holding it here keeps the gateway's cost visible as the four it takes,
// rather than letting drift elsewhere in the budget hide inside the same margin.
test('the same relay shape without the gateway stays inside the ceiling', () => {
const report = calculateRelayCloudSqlConnectionBudget({
cellPoolTotal: 200,
asiaCellCount: 3,
asiaPoolMax: 10,
directorInstances: 5,
directorPoolMax: 3,
authInstances: 2,
authPoolMax: 10,
apiInstances: 10,
apiPoolMax: 5,
pushInstances: 0,
pushPoolMax: 0,
maxConnections: 400,
maintenanceAdminAllowance: 5,
explicitReserve: 10
})
assert.equal(report.consumers.push, 0)
assert.equal(report.rolloutOverlap.maximum, 65)
assert.equal(report.operatingMaximum, 385)
assert.equal(report.remainingWithinUsableCeiling, 5)
assert.equal(report.budgetedTotal, 395)
assert.equal(report.unallocated, 5)
assert.equal(report.withinBudget, true)
})
// Why: a tagged candidate is directly addressable and sits outside the service-wide cap, so both
// push revisions can reach the ceiling at once. The API and auth candidates add one copy; this
// one adds two, like the director candidate.
test('the push rollout scenario doubles the gateway draw over the retained director', () => {
const report = calculateRelayCloudSqlConnectionBudget({
cellPoolTotal: 0,
asiaCellCount: 0,
asiaPoolMax: 0,
directorInstances: 5,
directorPoolMax: 3,
authInstances: 0,
authPoolMax: 0,
apiInstances: 0,
apiPoolMax: 0,
pushInstances: 2,
pushPoolMax: 2,
maxConnections: 400,
maintenanceAdminAllowance: 5,
explicitReserve: 10
})
assert.equal(report.consumers.push, 4)
// 15 retained director rollback, plus the 4-connection draw counted twice.
assert.equal(report.rolloutOverlap.pushCandidate, 23)
})
test('fails closed when pool growth consumes the explicit reserve', () => {
const report = calculateRelayCloudSqlConnectionBudget({
cellPoolTotal: 200,
@@ -102,14 +39,12 @@ test('fails closed when pool growth consumes the explicit reserve', () => {
authPoolMax: 10,
apiInstances: 20,
apiPoolMax: 5,
pushInstances: 4,
pushPoolMax: 10,
maxConnections: 400,
maintenanceAdminAllowance: 5,
explicitReserve: 10
})
assert.equal(report.operatingMaximum, 555)
assert.equal(report.operatingMaximum, 515)
assert.equal(report.withinBudget, false)
})
@@ -128,11 +63,7 @@ test('excludes fenced cell pools and reads per-cell pool overrides', () => {
}
}
`,
terraformVariables: [
'variable "relay_director_database_pool_max" { default = 3 }',
'variable "push_max_instances" { default = 1 }',
'variable "push_database_pool_max" { default = 2 }'
].join('\n'),
terraformVariables: 'variable "relay_director_database_pool_max" { default = 3 }',
relayConfig: 'export const RELAY_DATABASE_POOL_MAX = 10'
},
maxConnections: 100,
@@ -141,42 +72,8 @@ test('excludes fenced cell pools and reads per-cell pool overrides', () => {
})
assert.equal(report.consumers.cells, 14)
// No push_max_instances in this tfvars, so the variable default of one instance holds.
assert.equal(report.consumers.push, 2)
assert.equal(report.operatingMaximum, 48)
assert.equal(report.budgetedTotal, 49)
})
// Why: production.tfvars overrides push_max_instances down to 2 while variables.tf still defaults
// to 4, so reading the default instead of the override would overstate the live draw by half.
test('a tfvars push_max_instances override wins over the variable default', () => {
const report = readRelayCloudSqlConnectionBudget({
proposedAsiaCellCount: 1,
appConsumers: { authInstances: 1, authPoolMax: 10, apiInstances: 1, apiPoolMax: 5, maxConnections: 100 },
sources: {
productionTfvars: `
relay_max_instances = 1
push_max_instances = 3
relay_gce_fenced_cells = []
relay_gce_cells = {
"production-gce-c2" = { database_pool_max = 4
}
}
`,
terraformVariables: [
'variable "relay_director_database_pool_max" { default = 3 }',
'variable "push_max_instances" { default = 1 }',
'variable "push_database_pool_max" { default = 2 }'
].join('\n'),
relayConfig: 'export const RELAY_DATABASE_POOL_MAX = 10'
},
maxConnections: 100,
maintenanceAdminAllowance: 1,
explicitReserve: 1
})
assert.equal(report.consumers.push, 6)
assert.equal(report.rolloutOverlap.pushCandidate, 15)
assert.equal(report.operatingMaximum, 46)
assert.equal(report.budgetedTotal, 47)
})
test('requires strict headroom below the physical ceiling', () => {
@@ -190,14 +87,12 @@ test('requires strict headroom below the physical ceiling', () => {
authPoolMax: 10,
apiInstances: 1,
apiPoolMax: 5,
pushInstances: 1,
pushPoolMax: 2,
maxConnections: 50,
maintenanceAdminAllowance: 9,
explicitReserve: 3
})
assert.equal(report.budgetedTotal, 65)
assert.equal(report.budgetedTotal, 63)
assert.equal(report.withinBudget, false)
})
@@ -32,8 +32,7 @@ test('no workflow names the retired generic production deploy identity', async (
'deploy-relay-production.yml',
'operate-relay-asia-admission.yml',
'operate-relay-production-rehome-job.yml',
'publish-relay-production.yml',
'push-deploy.yml'
'publish-relay-production.yml'
].map((name) => relayWorkflowFile(name)).sort())
})
@@ -20,7 +20,7 @@ const UNGATED = relayWorkflowFile('verify.yml')
const relayWorkflows = () => workflowFiles().filter((file) => file !== UNGATED)
test('the copy carries every relay workflow', () => {
assert.equal(relayWorkflows().length, 25)
assert.equal(relayWorkflows().length, 24)
})
// Why: workflow_run chains match by display name, not filename. Renaming a file is safe; renaming
@@ -31,7 +31,7 @@ const EXPECTED_CONDITIONS = {
production: {
relay: {
github:
"assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-fence-broker.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-director.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-publish-relay-production.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-push-deploy.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main' && (assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml@refs/heads/main' || assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main')))",
"assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-fence-broker.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-director.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-publish-relay-production.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main' && (assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml@refs/heads/main' || assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main')))",
github_monitor:
"assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-monitor-relay-production.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-monitor-relay-production-job.yml@refs/heads/main'",
github_fence:
-337
View File
@@ -1,337 +0,0 @@
# Orca mobile push gateway
`orca-cloud-push` is a public Cloud Run service in `onorca-cloud` that turns a desktop
notification into an APNs or FCM push for a paired phone. The desktop registers each phone's
native token with it and calls `POST /v1/send` after the socket fan-out it already does; the
phone dedupes by `notificationId#notificationSeq`. The service is the only place the Apple
`.p8` signing key is readable, which is the reason it exists as a service at all.
The contract every lane builds against is `docs/reference/mobile-push-contract.md` in the
repository root. This document covers only the deploy surface: what Terraform owns, how the
credentials rotate, and what the other repository still has to publish.
**There is no staging push gateway.** That is a decision, not an omission. `push_gateway_enabled`
is false in `environments/staging.tfvars` and true in `environments/production.tfvars`, and every
resource in `infra/terraform/push-gateway.tf` is behind it. A staging gateway would be a tfvars
edit plus a second set of Apple credentials.
## Shape
| Setting | Value | Where |
| --- | --- | --- |
| Cloud Run service | `orca-cloud-push` | `push_cloud_run_service_name` |
| Region | `us-central1` | `region` |
| Instances | min 1, max 2 | `push_min_instances`, `push_max_instances` |
| Database pool | 2 per instance | `push_database_pool_max` |
| Concurrency | 80 | `push_concurrency` |
| Ingress | all | `INGRESS_TRAFFIC_ALL` |
| Invoker | IAM disabled | `invoker_iam_disabled = true` on the service |
| Runtime identity | `orca-cloud-push@onorca-cloud.iam.gserviceaccount.com` | `google_service_account.push_runtime` |
| Database | `orca_push` on the shared Cloud SQL instance | `google_sql_database.push` |
| Hostname | `push.onorca.dev` | `push_base_url` |
The minimum of one instance is deliberate and did not move when the ceiling came down to two. A
cold start delays a notification past the point where it is worth showing, and the three-second
coalescing window lives in instance memory, so the floor is what keeps a notification prompt. The
ceiling is a different question, answered below.
The maximum and the pool are set by the connection budget, not by the gateway's own appetite. Two
instances times a two-connection pool is a draw of 4, and a rollout doubles it to 8, because the
tagged candidate is directly addressable and sits outside the service-wide cap. The shared Cloud
SQL instance's 400 connections were already spoken for by the relay cells, the directors, auth,
and the API, which left five. Four is the whole of the room there was, and the gateway fits in
it.
Two connections per instance is enough for the work. A send runs two or three short queries, so
at concurrency 80 requests queue against the pool for microseconds rather than holding it. A
`lifecycle` precondition refuses a plan whose instances times pool exceeds 4, because a fifth
connection puts the checked budget over its ceiling and blocks `Deploy Relay Asia Topology`,
which gates on it. `dev/scripts/relay-cloud-sql-connection-budget.mjs` counts the gateway and
prints the whole picture.
Authentication is the host proof in `POST /v1/host/challenge`, not Cloud Run IAM, so the service
opts out of invoker IAM with `invoker_iam_disabled = true`, exactly as the relay director does.
The project's domain-restricted-sharing policy refuses an `allUsers` invoker binding, so that is
the only way to reach an open service here.
## Environment
Set on the container by Terraform:
| Variable | Source |
| --- | --- |
| `PORT` | Cloud Run, container port 8080 |
| `ORCA_PUSH_PUBLIC_URL` | `push_base_url` |
| `ORCA_PUSH_FCM_PROJECT_ID` | `push_fcm_project_id`, empty means `project_id` |
| `ORCA_PUSH_DATABASE_URL` | Secret `orca-cloud-push-database-url`, version `latest` |
| `ORCA_PUSH_DATABASE_POOL_MAX` | `push_database_pool_max`, 2 per instance |
| `ORCA_PUSH_APNS_KEY` | Secret `orca-cloud-push-apns-key`, version `latest` |
| `ORCA_PUSH_APNS_KEY_ID` | Secret `orca-cloud-push-apns-key-id`, version `latest` |
| `ORCA_PUSH_APPLE_TEAM_ID` | Secret `orca-cloud-push-apple-team-id`, version `latest` |
`ORCA_PUSH_APNS_TOPIC` and `ORCA_PUSH_COALESCE_MS` are left to their application defaults
(`com.stably.orca.mobile` and `3000`). Add them here only when one of them has to differ from
the code default, so that a code-side change stays visible rather than silently overridden.
Terraform owns the three Apple secret **names, labels, and replication, and never a version.**
The `.p8` is issued by the Apple developer portal, so a Terraform-managed version would put the
private key in state and would fight the rotation below. The database URL secret is different:
Terraform generates that password, so it owns that version, exactly as `relay-database.tf` does.
That puts the generated password and the full database URL in the state bucket, which the shared
deploy identity can read; the Apple key never appears there. The three Apple secrets and the
`orca_push` database carry `prevent_destroy`, so disabling the gateway fails the plan instead
of deleting the only copy of the signing key or every live device token.
## Importing what already exists
The runtime account, the three Apple secrets, and their accessor bindings were created out of
band alongside the Apple credentials. They are declared so a plan is clean, and imported once.
Run these from `cloud/` after `pnpm infra:init --env production`, review the resulting plan, and
expect the imported resources to show no changes.
```sh
terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \
'google_service_account.push_runtime[0]' \
projects/onorca-cloud/serviceAccounts/orca-cloud-push@onorca-cloud.iam.gserviceaccount.com
terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \
'google_project_iam_member.push_runtime_fcm_admin[0]' \
'onorca-cloud roles/firebasecloudmessaging.admin serviceAccount:orca-cloud-push@onorca-cloud.iam.gserviceaccount.com'
terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \
'google_project_iam_member.push_runtime_service_usage_consumer[0]' \
'onorca-cloud roles/serviceusage.serviceUsageConsumer serviceAccount:orca-cloud-push@onorca-cloud.iam.gserviceaccount.com'
terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \
'google_secret_manager_secret.push_provider["orca-cloud-push-apns-key"]' \
projects/onorca-cloud/secrets/orca-cloud-push-apns-key
terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \
'google_secret_manager_secret.push_provider["orca-cloud-push-apns-key-id"]' \
projects/onorca-cloud/secrets/orca-cloud-push-apns-key-id
terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \
'google_secret_manager_secret.push_provider["orca-cloud-push-apple-team-id"]' \
projects/onorca-cloud/secrets/orca-cloud-push-apple-team-id
terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \
'google_secret_manager_secret_iam_member.push_provider_runtime_accessor["orca-cloud-push-apns-key"]' \
'projects/onorca-cloud/secrets/orca-cloud-push-apns-key roles/secretmanager.secretAccessor serviceAccount:orca-cloud-push@onorca-cloud.iam.gserviceaccount.com'
terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \
'google_secret_manager_secret_iam_member.push_provider_runtime_accessor["orca-cloud-push-apns-key-id"]' \
'projects/onorca-cloud/secrets/orca-cloud-push-apns-key-id roles/secretmanager.secretAccessor serviceAccount:orca-cloud-push@onorca-cloud.iam.gserviceaccount.com'
terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \
'google_secret_manager_secret_iam_member.push_provider_runtime_accessor["orca-cloud-push-apple-team-id"]' \
'projects/onorca-cloud/secrets/orca-cloud-push-apple-team-id roles/secretmanager.secretAccessor serviceAccount:orca-cloud-push@onorca-cloud.iam.gserviceaccount.com'
```
Everything else in `push-gateway.tf` is new and is created by the apply: the `orca_push`
database and user, the database-URL secret and its accessor, the `roles/cloudsql.client` binding
on the runtime account, the Cloud Run service, the domain mapping, and the
three deploy-identity bindings. Save that plan and review it before applying; this root carries
unrelated standing drift, so an untargeted apply is never automatic.
Two things this root does **not** declare, because the carve assigns them elsewhere. Neither
affects whether this root's plan is clean, since an undeclared resource is invisible to it.
- `firebase.googleapis.com` and `fcm.googleapis.com` are project service enablement, which is
`google_project_service.required` in the foundation root. They are already enabled; add them
to the foundation root's list so a foundation plan stays clean.
- The Firebase attachment on `onorca-cloud` is project-level and belongs with foundation for the
same reason. It exists already.
## Deploying
`Deploy Push Gateway Production` (`.github/workflows/cloud-push-deploy.yml`) is the only
supported path. Like every `cloud-*` workflow it does nothing until `ORCA_CLOUD_OPERATIONS_ENABLED`
is `true`, it runs only on `main`, and it needs the confirmation string `DEPLOY_PUSH_GATEWAY`.
It authenticates as the shared production deploy identity through
`PRODUCTION_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER` and
`PRODUCTION_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT`, which are already published. No new GitHub
variable is required. That account was chosen because the Cloud SQL rollout lease grant is
foundation-owned and names only that account; a dedicated identity could not take that lease from
this root, and the gateway's schema rollout has to serialize against the relay's.
**That choice widens what this workflow can reach, and the widening is deliberate.** Adding
`push-deploy.yml` to the provider allowlist gives the run the account's whole existing authority,
not only the push bindings: Artifact Registry writer on `orca-cloud`, `roles/run.developer` on
the relay director and the fence broker, accessor and version-adder on the relay
regional-placement secret, and service-account user on the relay runtime identities. It was
accepted as the price of the lease. What `push-gateway.tf` adds on top is three bindings scoped
to the gateway alone: Cloud Run developer on this one service, and service-account user plus
token creator on the runtime account. The bound on the rest is the provider condition, which
admits this exact workflow file on `main` in the `production` environment only, and the workflow
itself, which is dispatch-only behind a typed confirmation.
The run, in order:
1. Builds `apps/push/Dockerfile` with the `cloud/` build context and pushes to the existing
`orca-cloud` Artifact Registry repository as `push:sha-<commit>`, then resolves the digest.
This happens **before** the lease is taken. Artifact Registry is not the Cloud SQL instance,
and a multi-minute build inside the lease would block every relay deploy and rehome for its
duration.
2. Takes the production Cloud SQL rollout lease and holds it from here to the end. The gateway
applies its schema while the new revision starts, so the revision **is** the schema step
(on a one-connection pool with no statement timeout, closed before the serving pool opens,
exactly as the relay does since #18722);
there is no separate migration command to wrap. The lease therefore covers exactly the
connection-budget window: deploy, probe, shift.
3. Records the currently serving revision as the rollback target, and requires it to still hold
the Terraform-owned floor and ceiling. The candidate inherits that scaling, so a drifted
serving revision would be latched rather than corrected.
4. `gcloud run deploy --no-traffic` with a per-run traffic tag, so the candidate boots and
applies schema while every phone still reaches the previous revision. The deploy passes no
scaling flag: the shape is Terraform's, and the candidate's inherited ceiling is asserted
instead.
5. Probes the tagged candidate's own `/ready`, up to 30 times at five-second intervals.
6. Sends a validate-only FCM message as the runtime identity, by impersonation. See below.
7. Shifts 100% of traffic to the candidate and verifies it is the only revision serving.
8. Writes the run summary, including the rollback command, before checking the public origin, so
the summary exists even when the check that follows does not pass.
9. Checks `https://push.onorca.dev/ready`, up to 30 times at five-second intervals, since the
origin can lag the traffic move by a few seconds.
10. Always removes the traffic tag, so tags do not accumulate across runs.
**Failure after the shift rolls itself back.** Everything from step 8 on runs with production
already on the candidate, so a failure there is not a failed deploy, it is a live gateway that
has to go back. The run returns traffic to the recorded rollback revision, verifies the move, and
reports it in the summary. A failure *before* the shift leaves production untouched and deletes
the candidate revision, which otherwise sits holding a warm instance and a Cloud SQL pool for
nothing.
To move traffic by hand, from the revision named in the run summary:
```sh
gcloud run services update-traffic orca-cloud-push \
--project onorca-cloud --region us-central1 \
--to-revisions <previous-revision>=100
```
### Why the FCM probe impersonates the runtime account
A gateway that boots and answers `/ready` can still be unable to send: the FCM grant lives on
the runtime service account, not on anything the readiness check touches. The probe therefore
mints an access token for `orca-cloud-push@onorca-cloud.iam.gserviceaccount.com` and posts
`validate_only: true` with a token that cannot exist. `validate_only` stops Google before any
delivery, and a healthy credential answers `INVALID_ARGUMENT` because the device token is
garbage. `PERMISSION_DENIED`, `401`, and `403` are the failures the step exists to catch, and
they fail the run immediately, before traffic moves. Those four answers are the only conclusive
ones: a `429`, a `5xx`, or a transport failure says nothing about the credential, so the send is
retried up to five times at five-second intervals rather than read as either verdict. Probing as the deploy identity instead would prove
something true about the wrong account.
## Rotating the APNs key
Apple keys do not expire, so this is for a suspected compromise or a routine rotation. Order
matters: the new key must be serving before the old one is revoked, or every iOS push fails in
the window between.
1. In the Apple developer portal, create a **new** APNs authentication key. Download the `.p8`
once; Apple will not show it again. Note the new key ID. A team may hold two APNs keys at a
time, which is what makes this overlap possible.
2. Add a version to each changed secret, without printing the value:
```sh
gcloud secrets versions add orca-cloud-push-apns-key \
--project onorca-cloud --data-file /path/to/AuthKey_NEW.p8
printf '%s' '<new key id>' | gcloud secrets versions add orca-cloud-push-apns-key-id \
--project onorca-cloud --data-file=-
```
The team ID does not change, so `orca-cloud-push-apple-team-id` is untouched.
3. Dispatch `Deploy Push Gateway Production`. The container reads `latest` at start, so only a
new revision picks the key up; there is no in-place reload.
4. Verify from a real device that an iOS notification still arrives. The workflow's FCM probe
covers Android only, and APNs has no validate-only equivalent.
5. Only then revoke the old key in the Apple portal, and disable the superseded secret versions:
```sh
gcloud secrets versions disable <old-version> \
--project onorca-cloud --secret orca-cloud-push-apns-key
```
Disable rather than destroy, so a rollback to the previous revision still works. Destroy
after the next clean deploy.
Delete the downloaded `.p8` from disk when you are done. It is the whole credential.
## Dead tokens
A push token stops working when the app is uninstalled, when the user restores to a new device,
or when iOS reissues it. Both providers report this, and the shapes differ:
- APNs: HTTP 410, or 400 with `BadDeviceToken`, `Unregistered`, or `DeviceTokenNotForTopic`.
`DeviceTokenNotForTopic` also fires when a sandbox token is sent to the production host, which
is a configuration bug rather than a dead token; check `apns_environment` on the registration
before concluding the device is gone.
- FCM: `UNREGISTERED`, or `INVALID_ARGUMENT` whose message names the token.
The gateway marks the registration `dead_at` and returns `status: "dead"` for it, and the
desktop drops the registration when it sees that. Nothing here retries a dead token. A phone
that comes back registers again and gets a fresh `registrationId`, so a rising dead count is
normal churn; a dead count that spikes across many hosts at once is a credential or topic
problem, not device churn.
## Quotas
Two independent limits, both enforced in the gateway and both returning HTTP 200 with
`status: "rate_limited"` per result rather than failing the request:
| Limit | Scope |
| --- | --- |
| 60 sends per rolling hour | per `hostFingerprint` |
| 200 sends per rolling day | per `registrationId` |
| 20 `registrationIds` | per request, hard cap, HTTP 400 over it |
Ahead of all three sit two per-client-IP token buckets that answer HTTP 429: 30 requests per
minute on the two unauthenticated handshake routes, and 240 per minute on every other `/v1`
route, applied before the bearer is looked up so that a flood of forged bearers cannot spend
the two-connection pool on session lookups. Both are per instance and in memory.
`push_send_log` backs the two rolling counts and is pruned after 25 hours. Upstream of all
three, FCM V1 bills project quota against `ORCA_PUSH_FCM_PROJECT_ID`, which is why the runtime
account holds `roles/serviceusage.serviceUsageConsumer`; a project-level FCM quota exhaustion
surfaces as `RESOURCE_EXHAUSTED` and is not something the per-host limits can prevent.
Logging is aggregate counters only. Never log a token, a title, a body, or a full fingerprint;
the first four characters of a fingerprint are the most that may appear.
## DNS: one hand-managed record
The Cloud Run domain mapping is created here, and Google issues and renews the certificate. The
`onorca.dev` zone is not in this root: it is a Cloudflare zone whose Terraform-managed records
live in the apps root in `stablyai/orca-cloud`, and whose relay and auth records are managed by
hand. The push record follows the relay's precedent and was created by hand on 2026-09-04:
```text
push.onorca.dev. CNAME ghs.googlehosted.com. (DNS only, not proxied)
```
`terraform -chdir=infra/terraform output push_dns_record` prints the same three fields. If the
record is ever lost, recreate it exactly like that; Cloudflare proxying blocks certificate
issuance and breaks Cloud Run host routing.
### Recovery and delivery guarantees
Candidate tags and deterministic revision names are recorded before deployment. Promotion intent is
recorded before changing traffic, so a failed verification or ambiguous mutation result still triggers
rollback. Failed candidates are deleted only before attempted promotion or after verified rollback.
The summary runs even if candidate discovery or traffic verification fails.
Push uses the relay's schema-startup retry implementation through `@orca-cloud/postgres-schema`.
Session replacement is serialized per host and a unique host index upgrades older databases by
retaining their newest session. Cloud Verify runs push concurrency tests against PostgreSQL.
Accepted sends deduplicate by host, registration, epoch, and sequence for the quota ledger's 25-hour
retention period. Provider failures retry at most three times within two minutes, respecting provider
retry delays. Queues remain in memory; a crash or the nine-second shutdown deadline can still lose work.
Graceful shutdown first refuses new requests, waits for admitted handlers, and drains pending and active
deliveries before closing transports and SQL. `delivery_retry` counters accompany existing outcomes.
Notification and worktree IDs allow 2048 characters each, subject to a combined notification JSON
budget of 3000 UTF-8 bytes. This preserves normal long and Unicode paths without exceeding provider
envelope space. No identity is truncated to meet this budget.
-39
View File
@@ -400,42 +400,3 @@ after checkout and authentication, before package installation, revision checks,
Their typed confirmations are `PAUSE_REGIONAL_REHOMING` and `DISABLE_REGIONAL_REHOMING`. Keep the
default 3,600,000 ms drain grace so existing splices can finish. The job summary contains only fresh
aggregate active, receipt, registration, completion, and abort counts.
## Mobile push gateway
`Deploy Push Gateway Production` (`.github/workflows/cloud-push-deploy.yml`) is the deploy path
for `orca-cloud-push`, the mobile push gateway. It is the one `cloud-*` workflow that is not a
relay operation, and it is here because it shares this repository's Cloud SQL instance, its
Artifact Registry repository, and its rollout lease.
It needs **no new GitHub environment variable.** It authenticates as the shared production deploy
identity through the already-published `PRODUCTION_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER`
and `PRODUCTION_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT`, and reads `PRODUCTION_GCP_REGION` like the
rest. That account holds the foundation-owned Cloud SQL rollout lease grant, which names it and nothing
else, so a dedicated identity could not be given that lease from this root.
`infra/terraform/push-gateway.tf` adds three bindings scoped to the gateway: Cloud Run developer
on that one service, and service-account user plus token creator on the gateway's runtime
account. Those three are not the workflow's whole authority. Running as the shared account gives
the run every role that account already holds for the relay: Artifact Registry writer on
`orca-cloud`, `roles/run.developer` on the relay director and the fence broker, accessor and
version-adder on the relay regional-placement secret, and service-account user on the relay
runtime identities. That widening was accepted as the price of the lease, and it is bounded by
the provider condition and by the workflow being dispatch-only behind a typed confirmation.
The provider's workflow allowlist gained exactly one entry, `cloud-push-deploy.yml`, on `main` in
the `production` environment. That entry is required: the allowlist compares complete workflow
refs by equality, so the `cloud-` filename prefix alone does not admit a new file.
The run builds `apps/push/Dockerfile` **before** taking the lease, so an image build never blocks
a relay deploy or rehome, then holds the production rollout lease across the deploy itself,
because the gateway applies its schema while the new revision starts. Under the lease it checks
the serving revision's Terraform-owned scaling, deploys with `--no-traffic` behind a per-run
traffic tag and no scaling flag of its own, probes the candidate's own `/ready`, proves the
runtime identity can reach FCM with a validate-only send, and only then shifts 100% of traffic. A
failure after the shift returns traffic to the recorded rollback revision; a failure before it
deletes the candidate. There is no staging gateway, so there is no staging counterpart to run
first.
Full runbook, including the APNs key rotation and the DNS record the `stablyai/orca-cloud` apps
root still owes, is in `docs/push-gateway.md`.
@@ -408,13 +408,3 @@ relay_region_rehome_source_cell_ids = [
# Slack #orca-relay-alerts, created out of band on 2026-08-05. Declared here because an apply
# was otherwise going to strip it from every policy, leaving the alerts firing at nobody.
relay_alert_notification_channels = ["projects/onorca-cloud/notificationChannels/4879431412695417284"]
# Mobile push gateway. Production is the only environment that runs one; the runtime account,
# the three Apple secrets, and their accessor bindings already exist and are imported once
# (see docs/push-gateway.md).
push_gateway_enabled = true
push_base_url = "https://push.onorca.dev"
# Sized so the gateway's rollout overlap, the retained director rollback plus its doubled draw,
# stays under the API candidate's, which keeps the checked Cloud SQL connection budget green.
push_max_instances = 2
manage_push_domain_mapping = true
@@ -81,7 +81,3 @@ relay_gce_cells = {
}
relay_region_rehome_source_cell_ids = ["staging-gce-c2", "staging-gce-c3"]
# No staging push gateway by decision (mobile-push-contract.md, "Non-goals"). Stated rather than
# left to the default so a future staging gateway is one obvious edit.
push_gateway_enabled = false
-24
View File
@@ -189,27 +189,3 @@ output "relay_gce_cell_deployments" {
error_message = "relay_gce_fenced_cells may contain only configured relay_gce_cells keys."
}
}
output "push_cloud_run_service_uri" {
value = try(google_cloud_run_v2_service.push[0].uri, null)
description = "Default push gateway service URI for pre-domain smoke tests."
}
output "push_runtime_service_account" {
value = try(google_service_account.push_runtime[0].email, null)
description = "Runtime identity that holds the APNs key and sends through FCM."
}
output "push_database_name" {
value = try(google_sql_database.push[0].name, null)
description = "Database isolated for durable push gateway state."
}
output "push_dns_record" {
value = var.push_gateway_enabled ? {
name = local.push_fqdn
type = "CNAME"
data = "ghs.googlehosted.com."
} : null
description = "Record the stablyai/orca-cloud apps root must publish in the onorca.dev zone."
}
-405
View File
@@ -1,405 +0,0 @@
# Orca mobile push gateway (`cloud/apps/push`).
#
# One public Cloud Run service that holds the APNs key and sends through APNs and FCM V1 on
# behalf of paired phones. Contract: `docs/reference/mobile-push-contract.md`, "Infra" and
# "Gateway env". Operations: `docs/push-gateway.md`.
#
# There is no staging push gateway by decision, so every resource here is behind
# `var.push_gateway_enabled`, which only `environments/production.tfvars` sets true. The file
# still reads every environment-shaped value from a variable, like the rest of this root, so a
# future staging gateway is a tfvars edit rather than a rewrite.
#
# Several resources below already exist in `onorca-cloud`; they are declared so a plan is clean
# and imported once. `docs/push-gateway.md` carries the exact `terraform import` commands.
locals {
push_gateway_count = var.push_gateway_enabled ? 1 : 0
# The runtime account, the three provider secrets, and their accessor bindings already exist in
# production and were created out of band with the Apple credentials.
push_runtime_service_account_id = "${var.name_prefix}-push"
# Secret Manager holds the Apple credentials. Terraform owns the secret names, labels, and
# replication; it never owns a version. The `.p8` is issued by the Apple developer portal and
# rotated by `docs/push-gateway.md`, so a Terraform-managed version would either put the key in
# state or fight the rotation. `ignore_changes` on the whole resource is not available, so the
# versions are simply not declared and every consumer reads `latest`.
push_provider_secret_ids = var.push_gateway_enabled ? toset([
"${var.name_prefix}-push-apns-key",
"${var.name_prefix}-push-apns-key-id",
"${var.name_prefix}-push-apple-team-id"
]) : toset([])
push_provider_secret_env = {
"${var.name_prefix}-push-apns-key" = "ORCA_PUSH_APNS_KEY"
"${var.name_prefix}-push-apns-key-id" = "ORCA_PUSH_APNS_KEY_ID"
"${var.name_prefix}-push-apple-team-id" = "ORCA_PUSH_APPLE_TEAM_ID"
}
push_fcm_project_id = var.push_fcm_project_id == "" ? var.project_id : var.push_fcm_project_id
push_fqdn = replace(replace(var.push_base_url, "https://", ""), "http://", "")
# The shared production deploy identity runs `cloud-push-deploy.yml`. The grants this file adds
# are scoped to this service and its runtime account alone, but the workflow inherits every
# other grant that account already holds for the relay; see the deploy-identity section below.
# The account itself is declared in relay-github-actions.tf and is production-only.
push_gateway_deploy_count = (
var.push_gateway_enabled && local.relay_create_production_ops_identity ? 1 : 0
)
}
# --- Runtime identity ---------------------------------------------------------------------
resource "google_service_account" "push_runtime" {
count = local.push_gateway_count
project = var.project_id
account_id = local.push_runtime_service_account_id
display_name = "Orca mobile push gateway"
description = "Runtime identity for the Orca mobile push gateway; sends through FCM V1."
}
# FCM V1 sends are authorized by the runtime account's own metadata-server token.
resource "google_project_iam_member" "push_runtime_fcm_admin" {
count = local.push_gateway_count
project = var.project_id
role = "roles/firebasecloudmessaging.admin"
member = google_service_account.push_runtime[0].member
}
# The FCM V1 endpoint bills against the caller's project quota, which the caller must consume.
resource "google_project_iam_member" "push_runtime_service_usage_consumer" {
count = local.push_gateway_count
project = var.project_id
role = "roles/serviceusage.serviceUsageConsumer"
member = google_service_account.push_runtime[0].member
}
resource "google_project_iam_member" "push_runtime_cloudsql_client" {
count = local.push_gateway_count
project = var.project_id
role = "roles/cloudsql.client"
member = google_service_account.push_runtime[0].member
}
# --- Database -----------------------------------------------------------------------------
# Gateway state shares the foundation-owned Cloud SQL instance with auth and the relay, and uses
# an isolated database and principal, exactly as relay-database.tf does. The application applies
# its own schema at startup.
resource "google_sql_database" "push" {
count = local.push_gateway_count
project = var.project_id
name = "orca_push"
instance = local.relay_database_instance_name
# Why: this database holds every live device token. Disabling the gateway must not drop it.
lifecycle {
prevent_destroy = true
}
}
resource "random_password" "push_database" {
count = local.push_gateway_count
length = 32
special = false
}
resource "google_sql_user" "push" {
count = local.push_gateway_count
project = var.project_id
name = "orca_push"
instance = local.relay_database_instance_name
password = random_password.push_database[0].result
}
resource "google_secret_manager_secret" "push_database_url" {
count = local.push_gateway_count
project = var.project_id
secret_id = "${var.name_prefix}-push-database-url"
labels = local.relay_shared_labels
replication {
auto {}
}
}
resource "google_secret_manager_secret_version" "push_database_url" {
count = local.push_gateway_count
secret = google_secret_manager_secret.push_database_url[0].id
secret_data = format(
"postgresql://%s:%s@/%s?host=/cloudsql/%s",
google_sql_user.push[0].name,
random_password.push_database[0].result,
google_sql_database.push[0].name,
local.relay_database_connection_name
)
}
resource "google_secret_manager_secret_iam_member" "push_database_url_runtime_accessor" {
count = local.push_gateway_count
project = var.project_id
secret_id = google_secret_manager_secret.push_database_url[0].secret_id
role = "roles/secretmanager.secretAccessor"
member = google_service_account.push_runtime[0].member
}
# --- Apple credentials ----------------------------------------------------------------------
resource "google_secret_manager_secret" "push_provider" {
for_each = local.push_provider_secret_ids
project = var.project_id
secret_id = each.value
labels = local.relay_shared_labels
replication {
auto {}
}
# Why: Apple issues a `.p8` once and Secret Manager has no undelete. Turning the gateway off
# must fail the plan rather than destroy the only copy of the signing key.
lifecycle {
prevent_destroy = true
}
}
resource "google_secret_manager_secret_iam_member" "push_provider_runtime_accessor" {
for_each = local.push_provider_secret_ids
project = var.project_id
secret_id = google_secret_manager_secret.push_provider[each.value].secret_id
role = "roles/secretmanager.secretAccessor"
member = google_service_account.push_runtime[0].member
}
# --- Service --------------------------------------------------------------------------------
resource "google_cloud_run_v2_service" "push" {
count = local.push_gateway_count
project = var.project_id
name = var.push_cloud_run_service_name
location = var.region
ingress = "INGRESS_TRAFFIC_ALL"
# Why: the host proof in `POST /v1/host/challenge` is the authentication, not Cloud Run IAM.
# The project's domain-restricted-sharing policy refuses an `allUsers` invoker binding, so the
# service opts out of invoker IAM exactly as the relay director does.
invoker_iam_disabled = true
deletion_protection = var.environment == "production"
labels = local.relay_shared_labels
template {
service_account = google_service_account.push_runtime[0].email
timeout = "${var.push_request_timeout_seconds}s"
max_instance_request_concurrency = var.push_concurrency
scaling {
min_instance_count = var.push_min_instances
max_instance_count = var.push_max_instances
}
volumes {
name = "cloudsql"
cloud_sql_instance {
instances = [local.relay_database_connection_name]
}
}
containers {
image = var.push_cloud_run_image
ports {
container_port = 8080
}
volume_mounts {
name = "cloudsql"
mount_path = "/cloudsql"
}
env {
name = "ORCA_PUSH_PUBLIC_URL"
value = var.push_base_url
}
env {
name = "ORCA_PUSH_FCM_PROJECT_ID"
value = local.push_fcm_project_id
}
# Declared rather than left to the application default, so the gateway's share of the
# shared Cloud SQL connection budget is a value this root states and the precondition
# below can bound.
env {
name = "ORCA_PUSH_DATABASE_POOL_MAX"
value = tostring(var.push_database_pool_max)
}
env {
name = "ORCA_PUSH_DATABASE_URL"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.push_database_url[0].secret_id
version = "latest"
}
}
}
# Rotation adds a new version and redeploys; `latest` is what the redeploy picks up.
dynamic "env" {
for_each = local.push_provider_secret_env
content {
name = env.value
value_source {
secret_key_ref {
secret = google_secret_manager_secret.push_provider[env.key].secret_id
version = "latest"
}
}
}
}
resources {
limits = {
cpu = var.push_cloud_run_cpu
memory = var.push_cloud_run_memory
}
cpu_idle = false
}
startup_probe {
failure_threshold = 12
initial_delay_seconds = 0
period_seconds = 5
timeout_seconds = 2
http_get {
path = "/health"
port = 8080
}
}
}
}
# Deploys update the immutable image and shift traffic; Terraform owns the shape and IAM.
#
# `traffic` is ignored as well as the image. A deploy ends with traffic pinned to an exact
# revision and a rollback pins it to the previous one; an apply that reset the service to
# 100% LATEST would silently undo either, and this root carries unrelated standing drift, so
# that apply need not be a push change at all.
lifecycle {
# Why: the gateway draws instances x pool from the shared Cloud SQL instance, and a rollout
# doubles it, because the tagged candidate is directly addressable and sits outside the
# service-wide cap. The instance's 400 connections were already spoken for by the relay
# cells, directors, auth, and API, which left five: 4 is the whole of the gateway's share and
# it fits, with the doubled 8 still under the API candidate's rollout overlap, the term
# dev/scripts/relay-cloud-sql-connection-budget.mjs maximizes over. A fifth connection here
# puts the checked budget over its ceiling and blocks Deploy Relay Asia Topology, which gates
# on it, so catch a raise at plan time rather than in someone else's rollout.
precondition {
condition = var.push_max_instances * var.push_database_pool_max <= 4
error_message = "Push gateway instances x database pool must stay within its 4-connection share of the shared Cloud SQL instance."
}
ignore_changes = [
client,
client_version,
template[0].containers[0].image,
traffic
]
}
depends_on = [
data.google_artifact_registry_repository.relay_images,
google_project_iam_member.push_runtime_cloudsql_client,
google_secret_manager_secret_iam_member.push_database_url_runtime_accessor,
google_secret_manager_secret_iam_member.push_provider_runtime_accessor,
google_secret_manager_secret_version.push_database_url
]
}
# Google issues and renews the certificate for the mapping. The DNS record itself is a
# hand-managed Cloudflare CNAME to ghs.googlehosted.com, like relay.onorca.dev; this root has no
# Cloudflare surface by design. `terraform output push_dns_record` prints the record.
resource "google_cloud_run_domain_mapping" "push" {
count = var.push_gateway_enabled && var.manage_push_domain_mapping ? 1 : 0
location = var.region
name = local.push_fqdn
metadata {
namespace = var.project_id
}
spec {
route_name = google_cloud_run_v2_service.push[0].name
}
# Same reason as relay-dns.tf: a gcloud-created mapping reports an empty legacy
# certificate_mode, and replacing it would reset issuance for no behavioral change.
lifecycle {
ignore_changes = [spec[0].certificate_mode]
}
}
# --- Deploy identity grants -------------------------------------------------------------------
# `cloud-push-deploy.yml` authenticates as the shared production deploy account, because that
# account is the one the foundation root grants the Cloud SQL rollout lease to; the grant names
# that account and nothing else, so a dedicated push identity could not take the lease from this
# root and the gateway's schema rollout could not be serialized against the relay's.
#
# The three bindings below are the whole of that account's authority over the *push gateway*, but
# they are not the whole of what the workflow can do. Adding `push-deploy.yml` to the provider's
# allowlist in relay-github-actions.tf gives the run the account's entire existing authority:
# Artifact Registry writer on `orca-cloud`, `roles/run.developer` on the relay director and the
# fence broker, accessor and version-adder on the relay regional-placement secret, and
# service-account user on the relay runtime identities. That widening was accepted deliberately
# as the price of the lease. It is bounded by the provider condition, which admits this exact
# workflow file on `main` in the `production` environment only, and by the workflow itself, which
# is dispatch-only behind a typed confirmation.
resource "google_cloud_run_v2_service_iam_member" "github_production_push_developer" {
count = local.push_gateway_deploy_count
project = var.project_id
location = var.region
name = google_cloud_run_v2_service.push[0].name
role = "roles/run.developer"
member = local.relay_github_deploy_service_account_member
}
resource "google_service_account_iam_member" "github_production_push_runtime_user" {
count = local.push_gateway_deploy_count
service_account_id = google_service_account.push_runtime[0].name
role = "roles/iam.serviceAccountUser"
member = local.relay_github_deploy_service_account_member
}
# Why: the deploy workflow's validate-only FCM send has to exercise the credential the gateway
# will actually use. Impersonating the runtime account proves its firebasecloudmessaging grant;
# granting the deploy account FCM admin outright would prove nothing about the runtime account
# and would widen a project-level role on the shared identity.
resource "google_service_account_iam_member" "github_production_push_runtime_token_creator" {
count = local.push_gateway_deploy_count
service_account_id = google_service_account.push_runtime[0].name
role = "roles/iam.serviceAccountTokenCreator"
member = local.relay_github_deploy_service_account_member
}
+1 -10
View File
@@ -19,16 +19,7 @@ locals {
"deploy-relay-production-multi-target.yml",
"deploy-relay-production.yml",
"operate-relay-asia-admission.yml",
"publish-relay-production.yml",
# The push gateway deploy runs as this account because the Cloud SQL rollout lease grant is
# foundation-owned and names only this account; a dedicated identity could not take that
# lease, and the gateway's schema rollout has to serialize against the relay's.
#
# This entry therefore grants that workflow every role the account already holds, not just
# the three push bindings in push-gateway.tf: Artifact Registry writer, run.developer on the
# relay director and fence broker, relay secret accessor and version-adder, and
# serviceAccountUser on the relay runtime identities. Accepted as the price of the lease.
"push-deploy.yml"
"publish-relay-production.yml"
]
github_production_relay_capacity_workflow_file = "deploy-relay-production-capacity.yml"
github_production_relay_capacity_job_workflow_file = "deploy-relay-production-capacity-job.yml"
-105
View File
@@ -484,108 +484,3 @@ variable "relay_gce_cloud_sql_proxy_image" {
error_message = "relay_gce_cloud_sql_proxy_image must be pinned by sha256 digest."
}
}
# --- Mobile push gateway ---------------------------------------------------------------------
# There is no staging push gateway by decision, so this defaults false and only
# environments/production.tfvars turns it on. Everything in push-gateway.tf is behind it.
variable "push_gateway_enabled" {
type = bool
description = "Create the Orca mobile push gateway, its database, secrets, and identity."
default = false
}
variable "push_base_url" {
type = string
description = "Public TLS origin of the mobile push gateway."
default = "https://push.onorca.dev"
validation {
condition = can(regex("^https://[^/]+$", var.push_base_url))
error_message = "push_base_url must be an HTTPS origin with no path."
}
}
variable "push_cloud_run_service_name" {
type = string
description = "Cloud Run service name for the mobile push gateway."
default = "orca-cloud-push"
}
variable "push_cloud_run_image" {
type = string
description = "Initial image for the Terraform-created push gateway service; deploys own it after."
default = "us-docker.pkg.dev/cloudrun/container/hello"
}
variable "push_cloud_run_cpu" {
type = string
description = "CPU limit for the push gateway container."
default = "1"
}
variable "push_cloud_run_memory" {
type = string
description = "Memory limit for the push gateway container."
default = "512Mi"
}
# Why: a cold start would delay a notification past the point where it is worth showing, and the
# 3 s coalescing window lives in instance memory, so the floor is one warm instance.
variable "push_min_instances" {
type = number
description = "Minimum instances for the push gateway."
default = 1
}
variable "push_max_instances" {
type = number
description = "Maximum instances for the push gateway."
default = 4
validation {
condition = var.push_max_instances >= 1
error_message = "The push gateway needs at least one instance."
}
}
# Why: the gateway's draw on the shared Cloud SQL instance is instances x pool, and the rollout
# lease is taken for twice that, because a tagged candidate is directly addressable and sits
# outside the service-wide cap. Leaving the pool at its application default made that draw
# invisible to this root, so it is declared here and set on the container.
#
# Two is sized to the work, not to the default: a send runs two or three short queries, and at
# concurrency 80 those queue against the pool for microseconds rather than holding it.
variable "push_database_pool_max" {
type = number
description = "Push gateway database pool size per instance; instances x pool is its Cloud SQL draw."
default = 2
validation {
condition = var.push_database_pool_max >= 1 && var.push_database_pool_max <= 100
error_message = "The push gateway pool must hold at least one connection and stay under the per-service bound."
}
}
variable "push_concurrency" {
type = number
description = "Cloud Run concurrency for short-lived push gateway HTTP requests."
default = 80
}
variable "push_request_timeout_seconds" {
type = number
description = "Cloud Run timeout for push gateway requests; every route is short-lived."
default = 30
}
variable "push_fcm_project_id" {
type = string
description = "Firebase project for FCM V1 sends; empty uses project_id."
default = ""
}
variable "manage_push_domain_mapping" {
type = bool
description = "Manage the push gateway Cloud Run domain mapping; the DNS record stays in the apps root."
default = false
}
+1 -1
View File
@@ -21,7 +21,7 @@
"load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs",
"ops:relay": "pnpm --filter @orca-cloud/relay-ops dev",
"pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-admission-workflow.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs",
"test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs",
"test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": {
@@ -1,20 +0,0 @@
{
"name": "@orca-cloud/postgres-schema",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"lint": "tsc -p tsconfig.json --noEmit",
"test": "pnpm build",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@types/node": "^24.10.0",
"typescript": "^5.9.3",
"vitest": "^4.0.8"
}
}
-103
View File
@@ -1,103 +0,0 @@
const RETRYABLE_SCHEMA_CODES = new Set(['55P03', '57014'])
const DEFAULT_RETRY_DEADLINE_MS = 30_000
const RETRY_BASE_DELAY_MS = 250
const RETRY_MAX_DELAY_MS = 2_000
type SchemaStartupOptions = {
eventPrefix?: string
now?: () => number
random?: () => number
retryDeadlineMs?: number
wait?: (delayMs: number) => Promise<void>
}
function retryDelayMs(attempt: number, random: () => number): number {
const ceiling = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS)
return Math.ceil(ceiling * (0.5 + random() * 0.5))
}
function wait(delayMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, delayMs))
}
const CREATE_TABLE_IF_NOT_EXISTS = /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i
const CREATE_INDEX_IF_NOT_EXISTS = /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i
// `IF NOT EXISTS` only checks the name before the catalog inserts, so the loser of a concurrent
// CREATE can fail on the catalog unique index (23505) or, when the winner has already committed by
// the time the loser reaches TypeCreate/heap_create_with_catalog, on the name check those routines
// repeat (42710 duplicate type, 42P07 duplicate relation). Each is a no-op on the next attempt.
function concurrentCreateCollision(
value: { code?: unknown; constraint?: unknown },
statement: string
): boolean {
if (CREATE_TABLE_IF_NOT_EXISTS.test(statement)) {
return (
(value.code === '23505' && value.constraint === 'pg_type_typname_nsp_index') ||
value.code === '42710' ||
value.code === '42P07'
)
}
if (CREATE_INDEX_IF_NOT_EXISTS.test(statement)) {
return (
(value.code === '23505' && value.constraint === 'pg_class_relname_nsp_index') ||
value.code === '42P07'
)
}
return false
}
function retryableSchemaError(error: unknown, statement: string): boolean {
const value = error as { code?: unknown; constraint?: unknown }
return (
RETRYABLE_SCHEMA_CODES.has(String(value.code)) || concurrentCreateCollision(value, statement)
)
}
export async function applyPostgresSchema(
statements: string[],
query: (statement: string) => Promise<unknown>,
options: SchemaStartupOptions = {}
): Promise<void> {
const now = options.now ?? Date.now
const random = options.random ?? Math.random
const pause = options.wait ?? wait
const deadlineAt = now() + (options.retryDeadlineMs ?? DEFAULT_RETRY_DEADLINE_MS)
for (const statement of statements) {
let attempt = 1
while (true) {
try {
await query(statement)
break
} catch (error) {
const code = String((error as { code?: unknown }).code)
const remainingMs = deadlineAt - now()
const retryable = retryableSchemaError(error, statement)
if (!retryable || remainingMs <= 0) {
if (retryable) {
console.warn(
JSON.stringify({
event: `${options.eventPrefix ?? 'orca_relay_postgres_schema'}_retry_exhausted`,
code,
attempts: attempt
})
)
}
throw error
}
const delayMs = Math.min(remainingMs, retryDelayMs(attempt, random))
console.warn(
JSON.stringify({
event: `${options.eventPrefix ?? 'orca_relay_postgres_schema'}_retry`,
code,
attempt,
delayMs
})
)
await pause(delayMs)
attempt += 1
}
}
}
}
@@ -1,11 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": false,
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
},
"exclude": ["src/**/*.test.ts"]
}
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "noEmit": true },
"include": ["src/**/*.ts"]
}
-23
View File
@@ -1,23 +0,0 @@
{
"name": "@orca-cloud/push-contract",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "pnpm clean && tsc -p tsconfig.build.json",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"lint": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.10.0",
"typescript": "^5.9.3",
"vitest": "^4.0.8"
}
}
@@ -1,27 +0,0 @@
import { expect, it } from 'vitest'
import { PushDeviceRegistrationRequestSchema } from './device-registration-messages.js'
const registration = (token: string) => ({
v: 1,
deviceId: 'qa-device',
platform: 'ios',
token,
apnsEnvironment: 'sandbox',
filter: { sources: ['agent-task-complete'], agentStates: ['finished'] }
})
it.each([32, 64, 160, 256])(
'accepts variable-length APNs device tokens (%i hex characters)',
(length) => {
expect(
PushDeviceRegistrationRequestSchema.safeParse(registration('aB'.repeat(length / 2))).success
).toBe(true)
}
)
it.each(['', 'abc', 'not-hex', 'ab cd', 'ab'.repeat(2049)])(
'rejects malformed or oversized APNs tokens',
(token) => {
expect(PushDeviceRegistrationRequestSchema.safeParse(registration(token)).success).toBe(false)
}
)
@@ -1,216 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
ApnsEnvironmentSchema,
PushDeviceListResponseSchema,
PushDeviceRegistrationRequestSchema,
PushDeviceRegistrationResponseSchema,
PushNotificationFilterSchema
} from './device-registration-messages.js'
import {
PushErrorResponseSchema,
PushHostChallengeRequestSchema,
PushHostChallengeResponseSchema,
PushHostSessionRequestSchema,
PushHostSessionResponseSchema
} from './host-auth-messages.js'
import { PUSH_DEFAULTS, PUSH_LIMITS } from './push-limits.js'
const KEY_B64 = Buffer.alloc(32, 1).toString('base64')
const NONCE_B64 = Buffer.alloc(24, 2).toString('base64')
const SESSION_TOKEN = Buffer.alloc(32, 3).toString('base64url')
const FINGERPRINT = 'abcdefghijklmnop'
const APNS_TOKEN = 'a'.repeat(64)
const FCM_TOKEN = 'cQ1abcDEF_gh:APA91bZZ-zz0123456789abcdefghijklmnopqrstuvwxyz'
function notification(): Record<string, unknown> {
return {
notificationId: 'note-1',
notificationSeq: 4,
notificationEpoch: '5c9e9a1e-0000-4000-8000-000000000000',
source: 'agent-task-complete',
agentState: 'needs-input',
title: 'Agent needs input',
body: 'Waiting on your answer',
worktreeId: 'wt-1'
}
}
describe('push contract limits', () => {
it('locks the normative limits the desktop and gateway both assume', () => {
expect(PUSH_LIMITS).toMatchObject({
titleMaxChars: 80,
bodyMaxChars: 180,
maxRegistrationIdsPerSend: 20,
maxDevicesPerHost: 64,
maxDevicesPerListResponse: 1_024,
hostSendsPerRollingHour: 60,
registrationSendsPerRollingDay: 200,
coalesceWindowMs: 3_000,
challengeTtlMs: 10_000,
clockSkewToleranceMs: 30_000,
sessionTtlMs: 86_400_000,
sendLogRetentionMs: 90_000_000,
notificationTtlSeconds: 14_400,
apnsCollapseIdMaxBytes: 64,
hostRetentionMs: 3_600_000,
unauthenticatedRequestsPerMinutePerIp: 30,
authenticatedRequestsPerMinutePerIp: 240
})
expect(PUSH_DEFAULTS.apnsTopic).toBe('com.stably.orca.mobile')
expect(PUSH_DEFAULTS.fcmProjectId).toBe('onorca-cloud')
expect(PUSH_DEFAULTS.androidChannelId).toBe('orca-desktop')
})
})
describe('host authentication schemas', () => {
it('accepts a well formed challenge round trip', () => {
expect(
PushHostChallengeRequestSchema.safeParse({ v: 1, hostPublicKeyB64: KEY_B64 }).success
).toBe(true)
expect(
PushHostChallengeResponseSchema.safeParse({
challengeId: 'challenge-1',
gatewayEphemeralPublicKeyB64: KEY_B64,
nonceB64: NONCE_B64,
ciphertextB64: Buffer.alloc(96, 5).toString('base64'),
expiresAt: 1_700_000_010_000
}).success
).toBe(true)
expect(
PushHostSessionRequestSchema.safeParse({
v: 1,
challengeId: 'challenge-1',
proofB64: KEY_B64
}).success
).toBe(true)
expect(
PushHostSessionResponseSchema.safeParse({
sessionToken: SESSION_TOKEN,
expiresAt: 1_700_086_400_000,
hostFingerprint: FINGERPRINT
}).success
).toBe(true)
})
it('rejects unknown keys, wrong versions, and mis-sized keys', () => {
expect(
PushHostChallengeRequestSchema.safeParse({
v: 1,
hostPublicKeyB64: KEY_B64,
extra: true
}).success
).toBe(false)
expect(PushHostChallengeRequestSchema.safeParse({ v: 2, hostPublicKeyB64: KEY_B64 }).success)
.toBe(false)
expect(
PushHostChallengeRequestSchema.safeParse({
v: 1,
hostPublicKeyB64: Buffer.alloc(31, 1).toString('base64')
}).success
).toBe(false)
expect(
PushHostSessionResponseSchema.safeParse({
sessionToken: SESSION_TOKEN,
expiresAt: 1_700_086_400_000,
hostFingerprint: 'short'
}).success
).toBe(false)
})
it('names only the error codes the gateway may return', () => {
expect(PushErrorResponseSchema.safeParse({ error: 'session_expired' }).success).toBe(true)
expect(PushErrorResponseSchema.safeParse({ error: 'too_many_devices' }).success).toBe(true)
expect(PushErrorResponseSchema.safeParse({ error: 'rate_limited' }).success).toBe(true)
expect(PushErrorResponseSchema.safeParse({ error: 'teapot' }).success).toBe(false)
})
})
describe('device registration schemas', () => {
it('requires an apns environment and a hex token for ios', () => {
expect(
PushDeviceRegistrationRequestSchema.safeParse({
v: 1,
deviceId: 'device-1',
platform: 'ios',
token: APNS_TOKEN,
apnsEnvironment: 'sandbox',
filter: { sources: ['agent-task-complete'], agentStates: ['needs-input'] }
}).success
).toBe(true)
expect(
PushDeviceRegistrationRequestSchema.safeParse({
v: 1,
deviceId: 'device-1',
platform: 'ios',
token: APNS_TOKEN,
filter: { sources: [], agentStates: [] }
}).success
).toBe(false)
expect(
PushDeviceRegistrationRequestSchema.safeParse({
v: 1,
deviceId: 'device-1',
platform: 'ios',
token: 'not-hex',
apnsEnvironment: 'production',
filter: { sources: [], agentStates: [] }
}).success
).toBe(false)
})
it('rejects an apns environment on android and accepts an fcm token', () => {
expect(
PushDeviceRegistrationRequestSchema.safeParse({
v: 1,
deviceId: 'device-2',
platform: 'android',
token: FCM_TOKEN,
filter: { sources: ['plugin', 'terminal-bell'], agentStates: [] }
}).success
).toBe(true)
expect(
PushDeviceRegistrationRequestSchema.safeParse({
v: 1,
deviceId: 'device-2',
platform: 'android',
token: FCM_TOKEN,
apnsEnvironment: 'sandbox',
filter: { sources: [], agentStates: [] }
}).success
).toBe(false)
})
it('rejects duplicate filter entries and unknown filter keys', () => {
expect(
PushNotificationFilterSchema.safeParse({
sources: ['plugin', 'plugin'],
agentStates: []
}).success
).toBe(false)
expect(
PushNotificationFilterSchema.safeParse({
sources: [],
agentStates: ['finished'],
worktrees: []
}).success
).toBe(false)
expect(ApnsEnvironmentSchema.safeParse('adhoc').success).toBe(false)
})
it('shapes the registration and list responses', () => {
expect(PushDeviceRegistrationResponseSchema.safeParse({ registrationId: 'reg-1' }).success)
.toBe(true)
expect(
PushDeviceListResponseSchema.safeParse({
devices: [
{ registrationId: 'reg-1', deviceId: 'device-1', platform: 'ios', dead: false }
]
}).success
).toBe(true)
expect(
PushDeviceListResponseSchema.safeParse({
devices: [{ registrationId: 'reg-1', deviceId: 'device-1', platform: 'ios' }]
}).success
).toBe(false)
})
})
@@ -1,104 +0,0 @@
import { z } from 'zod'
import { PUSH_LIMITS } from './push-limits.js'
import { OpaqueIdSchema } from './wire-scalars.js'
export const PushPlatformSchema = z.enum(['ios', 'android'])
export const ApnsEnvironmentSchema = z.enum(['sandbox', 'production'])
export const PushNotificationSourceSchema = z.enum([
'agent-task-complete',
'terminal-bell',
'plugin'
])
export const PushAgentStateSchema = z.enum(['needs-input', 'finished'])
// APNs tokens are variable-length byte strings, including longer simulator tokens.
const APNS_TOKEN_PATTERN = /^(?:[0-9a-fA-F]{2})+$/
const FCM_TOKEN_PATTERN = /^[A-Za-z0-9_:.\-]{32,4096}$/
export const PushNotificationFilterSchema = z
.object({
sources: z.array(PushNotificationSourceSchema).max(3),
agentStates: z.array(PushAgentStateSchema).max(2)
})
.strict()
.superRefine((value, context) => {
if (new Set(value.sources).size !== value.sources.length) {
context.addIssue({ code: 'custom', path: ['sources'], message: 'sources must be unique' })
}
if (new Set(value.agentStates).size !== value.agentStates.length) {
context.addIssue({
code: 'custom',
path: ['agentStates'],
message: 'agentStates must be unique'
})
}
})
export const PushDeviceRegistrationRequestSchema = z
.object({
v: z.literal(1),
deviceId: OpaqueIdSchema,
platform: PushPlatformSchema,
token: z.string().min(1).max(4096),
apnsEnvironment: ApnsEnvironmentSchema.optional(),
filter: PushNotificationFilterSchema
})
.strict()
.superRefine((value, context) => {
if (value.platform === 'ios') {
if (value.apnsEnvironment === undefined) {
context.addIssue({
code: 'custom',
path: ['apnsEnvironment'],
message: 'apnsEnvironment is required for ios'
})
}
if (!APNS_TOKEN_PATTERN.test(value.token)) {
context.addIssue({
code: 'custom',
path: ['token'],
message: 'ios token must be hex-encoded bytes'
})
}
return
}
if (value.apnsEnvironment !== undefined) {
context.addIssue({
code: 'custom',
path: ['apnsEnvironment'],
message: 'apnsEnvironment is ios only'
})
}
if (!FCM_TOKEN_PATTERN.test(value.token)) {
context.addIssue({
code: 'custom',
path: ['token'],
message: 'android token must be an FCM registration string'
})
}
})
export const PushDeviceRegistrationResponseSchema = z
.object({ registrationId: OpaqueIdSchema })
.strict()
export const PushDeviceSummarySchema = z
.object({
registrationId: OpaqueIdSchema,
deviceId: OpaqueIdSchema,
platform: PushPlatformSchema,
dead: z.boolean()
})
.strict()
export const PushDeviceListResponseSchema = z
.object({ devices: z.array(PushDeviceSummarySchema).max(PUSH_LIMITS.maxDevicesPerListResponse) })
.strict()
export type PushPlatform = z.infer<typeof PushPlatformSchema>
export type ApnsEnvironment = z.infer<typeof ApnsEnvironmentSchema>
export type PushNotificationSource = z.infer<typeof PushNotificationSourceSchema>
export type PushAgentState = z.infer<typeof PushAgentStateSchema>
export type PushNotificationFilter = z.infer<typeof PushNotificationFilterSchema>
export type PushDeviceRegistrationRequest = z.infer<typeof PushDeviceRegistrationRequestSchema>
export type PushDeviceSummary = z.infer<typeof PushDeviceSummarySchema>
@@ -1,59 +0,0 @@
import { z } from 'zod'
import {
Base6432ByteSchema,
Base64Raw24ByteSchema,
Base64Url32ByteSchema,
BoundedCiphertextSchema,
EpochMsSchema,
OpaqueIdSchema,
PushHostFingerprintSchema
} from './wire-scalars.js'
export const PushHostChallengeRequestSchema = z
.object({ v: z.literal(1), hostPublicKeyB64: Base6432ByteSchema })
.strict()
export const PushHostChallengeResponseSchema = z
.object({
challengeId: OpaqueIdSchema,
gatewayEphemeralPublicKeyB64: Base6432ByteSchema,
nonceB64: Base64Raw24ByteSchema,
ciphertextB64: BoundedCiphertextSchema,
expiresAt: EpochMsSchema
})
.strict()
export const PushHostSessionRequestSchema = z
.object({ v: z.literal(1), challengeId: OpaqueIdSchema, proofB64: Base6432ByteSchema })
.strict()
export const PushHostSessionResponseSchema = z
.object({
sessionToken: Base64Url32ByteSchema,
expiresAt: EpochMsSchema,
hostFingerprint: PushHostFingerprintSchema
})
.strict()
export const PUSH_ERROR_CODES = [
'invalid_request',
'invalid_challenge',
'invalid_proof',
'invalid_token',
'session_expired',
'not_found',
'too_many_devices',
'request_too_large',
'rate_limited',
'dependency_unavailable'
] as const
export const PushErrorResponseSchema = z
.object({ error: z.enum(PUSH_ERROR_CODES) })
.strict()
export type PushHostChallengeRequest = z.infer<typeof PushHostChallengeRequestSchema>
export type PushHostChallengeResponse = z.infer<typeof PushHostChallengeResponseSchema>
export type PushHostSessionRequest = z.infer<typeof PushHostSessionRequestSchema>
export type PushHostSessionResponse = z.infer<typeof PushHostSessionResponseSchema>
export type PushErrorCode = (typeof PUSH_ERROR_CODES)[number]
@@ -1,6 +0,0 @@
export * from './device-registration-messages.js'
export * from './host-auth-messages.js'
export * from './push-host-proof-transcript.js'
export * from './push-limits.js'
export * from './send-messages.js'
export * from './wire-scalars.js'
@@ -1,32 +0,0 @@
import { expect, it } from 'vitest'
import { PushNotificationSchema } from './send-messages.js'
const base = {
source: 'agent-task-complete',
agentState: 'finished',
notificationSeq: 1,
notificationEpoch: 'epoch',
title: 'Done',
body: ''
}
it.each([
'repo::/Users/developer/orca/workspaces/monorepo/packages/desktop/integrations/feature-mobile-background-notifications',
'repo::C:\\Users\\developer\\Documents\\projects\\monorepo\\packages\\desktop\\feature-mobile-notifications',
'folder::/home/developer/projects/通知/作業ディレクトリ/機能',
'ssh:host::/home/developer/workspaces/monorepo/packages/desktop/feature-mobile-background-notifications'
])('preserves long desktop identities: %s', (path) => {
const worktreeId = `12345678-1234-1234-1234-123456789012::${path}`
const notificationId = [
'agent',
encodeURIComponent(worktreeId),
encodeURIComponent('12345678-1234-1234-1234-123456789012:87654321-4321-4321-4321-210987654321'),
'1780000000123'
].join(':')
const result = PushNotificationSchema.parse({ ...base, worktreeId, notificationId })
expect(result.worktreeId).toBe(worktreeId)
expect(result.notificationId).toBe(notificationId)
})
it('rejects oversized provider data by UTF-8 bytes instead of truncating identities', () => {
expect(PushNotificationSchema.safeParse({ ...base, worktreeId: '界'.repeat(1100) }).success).toBe(
false
)
})
@@ -1,106 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildPushHostChallengePlaintext,
buildPushHostProofMacInput,
buildPushHostProofTranscript,
PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN,
PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN,
PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT
} from './push-host-proof-transcript.js'
import { PUSH_LIMITS } from './push-limits.js'
const transcriptInput = {
gatewayOrigin: 'https://push.onorca.dev',
gatewayEphemeralPublicKey: new Uint8Array(32).fill(7),
challengeNonce: new Uint8Array(24).fill(9),
challengeId: 'challenge-1',
issuedAt: 1_700_000_000_000,
expiresAt: 1_700_000_000_000 + PUSH_LIMITS.challengeTtlMs,
hostFingerprint: 'abcdefghijklmnop',
hostPublicKey: new Uint8Array(32).fill(4)
}
describe('push host proof transcript', () => {
it('is deterministic and order dependent', () => {
const first = buildPushHostProofTranscript(transcriptInput)
const second = buildPushHostProofTranscript({ ...transcriptInput })
expect(Buffer.from(first).equals(Buffer.from(second))).toBe(true)
const different = buildPushHostProofTranscript({
...transcriptInput,
challengeId: 'challenge-2'
})
expect(Buffer.from(first).equals(Buffer.from(different))).toBe(false)
})
it('encodes exactly the ten specified fields in order', () => {
const transcript = buildPushHostProofTranscript(transcriptInput)
const view = new DataView(transcript.buffer, transcript.byteOffset, transcript.byteLength)
const names: string[] = []
let offset = 0
while (offset < transcript.byteLength) {
const nameLength = view.getUint32(offset, false)
offset += 4
names.push(Buffer.from(transcript.slice(offset, offset + nameLength)).toString('utf8'))
offset += nameLength
offset += 4 + view.getUint32(offset, false)
}
expect(names).toEqual([
'protocol',
'version',
'gatewayOrigin',
'gatewayEphemeralPublicKey',
'challengeNonce',
'challengeId',
'issuedAt',
'expiresAt',
'hostFingerprint',
'hostPublicKey'
])
expect(names).toHaveLength(PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT)
expect(offset).toBe(transcript.byteLength)
})
it('rejects mis-sized key material', () => {
expect(() =>
buildPushHostProofTranscript({
...transcriptInput,
hostPublicKey: new Uint8Array(31)
})
).toThrow('hostPublicKey must be 32 bytes')
expect(() =>
buildPushHostProofTranscript({ ...transcriptInput, challengeNonce: new Uint8Array(23) })
).toThrow('challengeNonce must be 24 bytes')
})
it('frames the challenge plaintext as domain, length, transcript, secret', () => {
const transcript = buildPushHostProofTranscript(transcriptInput)
const secret = new Uint8Array(32).fill(11)
const plaintext = buildPushHostChallengePlaintext(transcript, secret)
const domain = Buffer.from(`${PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`, 'utf8')
expect(Buffer.from(plaintext.slice(0, domain.byteLength)).equals(domain)).toBe(true)
const declared = new DataView(
plaintext.buffer,
plaintext.byteOffset + domain.byteLength,
4
).getUint32(0, false)
expect(declared).toBe(transcript.byteLength)
expect(plaintext.byteLength).toBe(domain.byteLength + 4 + transcript.byteLength + 32)
expect(
Buffer.from(plaintext.slice(plaintext.byteLength - 32)).equals(Buffer.from(secret))
).toBe(true)
expect(() => buildPushHostChallengePlaintext(transcript, new Uint8Array(16))).toThrow(
'challengeSecret must be 32 bytes'
)
})
it('separates the ack mac input from the challenge domain', () => {
const transcript = buildPushHostProofTranscript(transcriptInput)
const macInput = buildPushHostProofMacInput(transcript)
expect(Buffer.from(macInput).toString('utf8')).toContain(
`${PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN}\0ack\0`
)
expect(macInput.byteLength).toBe(
Buffer.byteLength(`${PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN}\0ack\0`) + transcript.byteLength
)
})
})
@@ -1,90 +0,0 @@
const textEncoder = new TextEncoder()
export const PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN = 'orca-push-host-proof/v1'
export const PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN = 'orca-push-host-challenge/v1'
export const PUSH_HOST_CHALLENGE_BOX_ALGORITHM = 'Curve25519-XSalsa20-Poly1305'
export const PUSH_HOST_PROOF_ALGORITHM = 'HMAC-SHA-256'
export interface PushHostProofTranscriptInput {
gatewayOrigin: string
gatewayEphemeralPublicKey: Uint8Array
challengeNonce: Uint8Array
challengeId: string
issuedAt: number
expiresAt: number
hostFingerprint: string
hostPublicKey: Uint8Array
}
export const PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT = 10
function uint32(value: number): Uint8Array {
const bytes = new Uint8Array(4)
new DataView(bytes.buffer).setUint32(0, value, false)
return bytes
}
function uint64(value: number): Uint8Array {
const bytes = new Uint8Array(8)
new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false)
return bytes
}
function concat(parts: readonly Uint8Array[]): Uint8Array {
const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0))
let offset = 0
for (const part of parts) {
output.set(part, offset)
offset += part.byteLength
}
return output
}
function field(name: string, value: Uint8Array): Uint8Array {
const encodedName = textEncoder.encode(name)
return concat([uint32(encodedName.byteLength), encodedName, uint32(value.byteLength), value])
}
function text(value: string): Uint8Array {
return textEncoder.encode(value)
}
function requireByteLength(value: Uint8Array, expected: number, name: string): void {
if (value.byteLength !== expected) throw new Error(`${name} must be ${expected} bytes`)
}
export function buildPushHostProofTranscript(input: PushHostProofTranscriptInput): Uint8Array {
requireByteLength(input.gatewayEphemeralPublicKey, 32, 'gatewayEphemeralPublicKey')
requireByteLength(input.challengeNonce, 24, 'challengeNonce')
requireByteLength(input.hostPublicKey, 32, 'hostPublicKey')
return concat([
field('protocol', text(PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN)),
field('version', new Uint8Array([1])),
field('gatewayOrigin', text(input.gatewayOrigin)),
field('gatewayEphemeralPublicKey', input.gatewayEphemeralPublicKey),
field('challengeNonce', input.challengeNonce),
field('challengeId', text(input.challengeId)),
field('issuedAt', uint64(input.issuedAt)),
field('expiresAt', uint64(input.expiresAt)),
field('hostFingerprint', text(input.hostFingerprint)),
field('hostPublicKey', input.hostPublicKey)
])
}
export function buildPushHostChallengePlaintext(
transcript: Uint8Array,
challengeSecret: Uint8Array
): Uint8Array {
if (challengeSecret.byteLength !== 32) throw new Error('challengeSecret must be 32 bytes')
// Why: the encrypted random secret makes the public transcript insufficient to forge the ack.
return concat([
text(`${PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`),
uint32(transcript.byteLength),
transcript,
challengeSecret
])
}
export function buildPushHostProofMacInput(transcript: Uint8Array): Uint8Array {
return concat([text(`${PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN}\0ack\0`), transcript])
}
@@ -1,16 +0,0 @@
{
"hostSecretKeyB64": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
"hostPublicKeyB64": "E75P6uryBMf9M1j8nAByGIHRdCeBKCJ+xnTzf3/pe20=",
"hostFingerprint": "D20lU_8MD0R64gLt",
"gatewayOrigin": "https://push.onorca.dev",
"challenge": {
"challengeId": "vector-challenge-1",
"gatewayEphemeralPublicKeyB64": "V9tLNZ8jrl4Ubk4lEgVnBHIlBjSMFQwUdT0Mkz0E1CE=",
"nonceB64": "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD",
"ciphertextB64": "znNOCR0fq0KKa5dwfTAwbhE6GmfC4TUjgB5n+/0BXrrG0A9oKjo38uvUY3VoBvTfCvlkLOmI2bu8kGN/yAHmMz6jhY77FIztAywVQ1WfBlu/tbxgiK/9QHxydUQwTAjc2vGjgPENC2EPH2VYZWEB10a6p6nlV3uezJda2exBLbJE/hPZGUkRJVedSa0WlQQpro/FwYqcqmI2iSpJ28nIQHn1wylc/Vgv7xw+/EBY39SzuR7HpY48h1MU0lzlsS1wcO2c/F7xEFYWUtfkbZGxET+b/eF6tzdLM5/MPJr8ibiwcPwfFfLnaYJYHpsFP0Tpu/ZQ3lLblX5Gqjf0vPn0MXB45RR/ZcMds1UUfC1WtDkFd2Z74xnN7GHTXNPYZwRChNC6TCxtK83UvqRfUqydzpTL5Z3R+zsunmSJvV8xONjW/ikwOqitjrMiqlnNGf7dFh4FC2vOfgg7HxwVQd8VumWeW2oT3WCcQH4FkxM2LjAvej34vE4WGPw9s6vcKoP4ESMG34TTVBz6Tyjm4oZv9ylLFrFISSkaZoZ5smKi/F0/xOscHKg4u4Sfz7wK+8Ve3Uc5eTos9yBkf1Ydbht7mbWqBSQTMC9BazmRZ5UlrM+GzGgI",
"expiresAt": 1800000010000
},
"issuedAt": 1800000000000,
"challengeSecretB64": "BQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU=",
"transcriptB64": "AAAACHByb3RvY29sAAAAF29yY2EtcHVzaC1ob3N0LXByb29mL3YxAAAAB3ZlcnNpb24AAAABAQAAAA1nYXRld2F5T3JpZ2luAAAAF2h0dHBzOi8vcHVzaC5vbm9yY2EuZGV2AAAAGWdhdGV3YXlFcGhlbWVyYWxQdWJsaWNLZXkAAAAgV9tLNZ8jrl4Ubk4lEgVnBHIlBjSMFQwUdT0Mkz0E1CEAAAAOY2hhbGxlbmdlTm9uY2UAAAAYAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAAAAC2NoYWxsZW5nZUlkAAAAEnZlY3Rvci1jaGFsbGVuZ2UtMQAAAAhpc3N1ZWRBdAAAAAgAAAGjGFxQAAAAAAlleHBpcmVzQXQAAAAIAAABoxhcdxAAAAAPaG9zdEZpbmdlcnByaW50AAAAEEQyMGxVXzhNRDBSNjRnTHQAAAANaG9zdFB1YmxpY0tleQAAACATvk/q6vIEx/0zWPycAHIYgdF0J4EoIn7GdPN/f+l7bQ=="
}
@@ -1,43 +0,0 @@
export const PUSH_LIMITS = {
titleMaxChars: 80,
bodyMaxChars: 180,
maxRegistrationIdsPerSend: 20,
// A host pairs phones, not a fleet. The cap bounds what one session can write
// through a caller-chosen deviceId.
maxDevicesPerHost: 64,
// The list response is bounded well above the per-host cap so the query LIMIT
// and the response schema can never disagree.
maxDevicesPerListResponse: 1024,
maxHttpBodyBytes: 16 * 1024,
hostSendsPerRollingHour: 60,
registrationSendsPerRollingDay: 200,
coalesceWindowMs: 3_000,
challengeTtlMs: 10_000,
// Covers routine NTP drift without extending the signed challenge window.
clockSkewToleranceMs: 30_000,
sessionTtlMs: 24 * 60 * 60 * 1000,
// One hour past the widest quota window so a rolling day never reads a pruned row.
sendLogRetentionMs: 25 * 60 * 60 * 1000,
notificationTtlSeconds: 4 * 60 * 60,
apnsCollapseIdMaxBytes: 64,
// Nothing reads a host row, and any keypair mints one for free, so a host
// with no registration left is kept only long enough to survive a phone swap.
hostRetentionMs: 60 * 60 * 1000,
// The challenge and session routes are the only unauthenticated writes, so
// they are capped per client IP before any key material is generated.
unauthenticatedRequestsPerMinutePerIp: 30,
// Every other route looks its bearer up in the database before it can refuse
// it, so a flood of forged bearers is capped per client IP ahead of that.
// Wide enough for an office NAT full of hosts, each of which sends at most
// its hourly quota plus a registration per connect.
authenticatedRequestsPerMinutePerIp: 240
} as const
export const PUSH_DEFAULTS = {
apnsTopic: 'com.stably.orca.mobile',
fcmProjectId: 'onorca-cloud',
androidChannelId: 'orca-desktop',
gatewayUrl: 'https://push.onorca.dev'
} as const
export const PUSH_HOST_FINGERPRINT_LENGTH = 16
@@ -1,126 +0,0 @@
import { describe, expect, it } from 'vitest'
import { PUSH_LIMITS } from './push-limits.js'
import {
PushSendRequestSchema,
PushSendResponseSchema,
PushSendStatusSchema
} from './send-messages.js'
function notification(): Record<string, unknown> {
return {
notificationId: 'note-1',
notificationSeq: 4,
notificationEpoch: '5c9e9a1e-0000-4000-8000-000000000000',
source: 'agent-task-complete',
agentState: 'needs-input',
title: 'Agent needs input',
body: 'Waiting on your answer',
worktreeId: 'wt-1'
}
}
describe('send schemas', () => {
it('accepts a batch at the registration cap and a terminal bell without an id', () => {
const ids = Array.from({ length: PUSH_LIMITS.maxRegistrationIdsPerSend }, (_, i) => `reg-${i}`)
expect(
PushSendRequestSchema.safeParse({ v: 1, registrationIds: ids, notification: notification() })
.success
).toBe(true)
const { notificationId: _dropped, ...bell } = notification()
expect(
PushSendRequestSchema.safeParse({
v: 1,
registrationIds: ['reg-1'],
notification: { ...bell, source: 'terminal-bell', agentState: null }
}).success
).toBe(true)
})
it('rejects an oversized batch, over-long copy, and unknown notification keys', () => {
const ids = Array.from(
{ length: PUSH_LIMITS.maxRegistrationIdsPerSend + 1 },
(_, i) => `reg-${i}`
)
expect(
PushSendRequestSchema.safeParse({ v: 1, registrationIds: ids, notification: notification() })
.success
).toBe(false)
expect(
PushSendRequestSchema.safeParse({
v: 1,
registrationIds: ['reg-1'],
notification: { ...notification(), title: 'x'.repeat(PUSH_LIMITS.titleMaxChars + 1) }
}).success
).toBe(false)
expect(
PushSendRequestSchema.safeParse({
v: 1,
registrationIds: ['reg-1'],
notification: { ...notification(), body: 'x'.repeat(PUSH_LIMITS.bodyMaxChars + 1) }
}).success
).toBe(false)
expect(
PushSendRequestSchema.safeParse({
v: 1,
registrationIds: ['reg-1'],
notification: { ...notification(), coalescedCount: 2 }
}).success
).toBe(false)
expect(PushSendRequestSchema.safeParse({ v: 1, registrationIds: [], notification: notification() }).success)
.toBe(false)
})
it('rejects a notification id that could not be sent as a collapse header', () => {
for (const notificationId of ['line\nbreak', 'nul\0byte', 'émoji', '\t']) {
expect(
PushSendRequestSchema.safeParse({
v: 1,
registrationIds: ['reg-1'],
notification: { ...notification(), notificationId }
}).success
).toBe(false)
}
expect(
PushSendRequestSchema.safeParse({
v: 1,
registrationIds: ['reg-1'],
notification: {
...notification(),
notificationId: 'agent:repo%3A%3A%2FUsers%2Fme:pane-1:1700000000000'
}
}).success
).toBe(true)
})
it('dedupes repeated registration ids and keeps the first-seen order', () => {
const parsed = PushSendRequestSchema.safeParse({
v: 1,
registrationIds: ['reg-b', 'reg-a', 'reg-b', 'reg-c', 'reg-a'],
notification: notification()
})
expect(parsed.success).toBe(true)
expect(parsed.success && parsed.data.registrationIds).toEqual(['reg-b', 'reg-a', 'reg-c'])
})
it('counts duplicates against the batch cap before deduping them', () => {
const ids = Array.from({ length: PUSH_LIMITS.maxRegistrationIdsPerSend + 1 }, () => 'reg-1')
expect(
PushSendRequestSchema.safeParse({ v: 1, registrationIds: ids, notification: notification() })
.success
).toBe(false)
})
it('locks the send result statuses', () => {
expect(PushSendStatusSchema.options).toEqual(['queued', 'dead', 'rate_limited', 'error'])
expect(
PushSendResponseSchema.safeParse({
results: [{ registrationId: 'reg-1', status: 'queued' }]
}).success
).toBe(true)
expect(
PushSendResponseSchema.safeParse({
results: [{ registrationId: 'reg-1', status: 'sent' }]
}).success
).toBe(false)
})
})
@@ -1,67 +0,0 @@
import { z } from 'zod'
import {
PushAgentStateSchema,
PushNotificationSourceSchema
} from './device-registration-messages.js'
import { PUSH_LIMITS } from './push-limits.js'
import { OpaqueIdSchema, SequenceSchema } from './wire-scalars.js'
export const PushNotificationSchema = z
.object({
// Absent for terminal-bell, which the desktop raises without a notification record.
// Printable ASCII only: the id becomes the APNs collapse header, and the
// desktop builds it from URL-encoded parts, so anything else is not Orca's.
notificationId: z
.string()
.min(1)
.max(2048)
.regex(/^[\x20-\x7e]+$/)
.optional(),
notificationSeq: SequenceSchema,
notificationEpoch: OpaqueIdSchema,
source: PushNotificationSourceSchema,
sound: z.boolean().optional(),
agentState: PushAgentStateSchema.nullable(),
title: z.string().min(1).max(PUSH_LIMITS.titleMaxChars),
body: z.string().max(PUSH_LIMITS.bodyMaxChars),
worktreeId: z.string().min(1).max(2048).optional()
})
.strict()
.refine(
(notification) => new TextEncoder().encode(JSON.stringify(notification)).byteLength <= 3000,
{
message: 'notification exceeds provider payload budget'
}
)
export const PushSendRequestSchema = z
.object({
v: z.literal(1),
// Deduped before the gateway sees it: a repeated id would otherwise reserve
// quota twice and inflate the coalesced count for one banner.
registrationIds: z
.array(OpaqueIdSchema)
.min(1)
.max(PUSH_LIMITS.maxRegistrationIdsPerSend)
.transform((ids) => [...new Set(ids)]),
notification: PushNotificationSchema
})
.strict()
export const PushSendStatusSchema = z.enum(['queued', 'dead', 'rate_limited', 'error'])
export const PushSendResultSchema = z
.object({ registrationId: OpaqueIdSchema, status: PushSendStatusSchema })
.strict()
export const PushSendResponseSchema = z
.object({
results: z.array(PushSendResultSchema).max(PUSH_LIMITS.maxRegistrationIdsPerSend)
})
.strict()
export type PushNotification = z.infer<typeof PushNotificationSchema>
export type PushSendRequest = z.infer<typeof PushSendRequestSchema>
export type PushSendStatus = z.infer<typeof PushSendStatusSchema>
export type PushSendResult = z.infer<typeof PushSendResultSchema>
export type PushSendResponse = z.infer<typeof PushSendResponseSchema>
@@ -1,25 +0,0 @@
import { z } from 'zod'
// Copied from relay-contract rather than imported: the push gateway ships as a
// standalone image and must not pull the relay wire contract into its closure.
export const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
export const Base6432ByteSchema = z.string().regex(/^(?:[A-Za-z0-9+/]{4}){10}[A-Za-z0-9+/]{3}=$/)
export const Base64Raw24ByteSchema = z.string().regex(/^(?:[A-Za-z0-9+/]{4}){8}$/)
export const PushHostFingerprintSchema = z.string().regex(/^[A-Za-z0-9_-]{16}$/)
export const OpaqueIdSchema = z.string().min(1).max(128)
export const EpochMsSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
export const SequenceSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
export const BoundedCiphertextSchema = z
.string()
.min(1)
.max(16 * 1024)
.regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/)
export const CanonicalHttpsOriginSchema = z.string().max(2048).refine((value) => {
try {
const url = new URL(value)
return url.protocol === 'https:' && url.origin === value && url.pathname === '/'
} catch {
return false
}
}, 'must be a canonical HTTPS origin')

Some files were not shown because too many files have changed in this diff Show More