mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/codex-subagent-worklog
# Conflicts: # src/renderer/src/i18n/locales/en.json
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 ?? ''] : [])
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)])
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test-fixture.ts"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "noEmit": true },
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user