ViaNJ Companion

This is the optional, public self-hosting edition of ViaNJ's checkout server. Regular ViaNJ purchases do not require it. Use this project only if you want purchases to run through an always-on Mac or PC you control.

This repository started from a security-reviewed source snapshot with clean Git history. It intentionally contains no ViaNJ production credentials, deployment bindings, browser profiles, payment data, or private operational history.

Fastest setup: use an AI coding agent

In ViaNJ, open Settings → Advanced Purchase Setup, tap Copy AI Setup Prompt, and paste it into a coding agent running on the Mac or PC that will host checkout. The prompt directs the agent to this repository and requires it to deploy, validate, install the persistent service, and return a short-lived pairing link.

Do not paste card details, a permanent Worker token, or a browser profile into an AI prompt. ViaNJ stores the card in the iPhone Keychain and encrypts each approved purchase directly to the paired machine.

Manual end-to-end setup

You need Node.js, a Cloudflare account, and an always-on Mac or PC with a desktop session available for the NJ Transit browser.

git clone https://github.com/perryraskin/vianj-companion.git
cd vianj-companion
npm install
npx wrangler login

Create a machine-only token, deploy your personal Worker and Durable Object, then add that token as a Worker secret:

umask 077
openssl rand -hex 32 > .vianj-api-token
npx wrangler deploy --config worker/wrangler.jsonc
npx wrangler secret put API_TOKEN --config worker/wrangler.jsonc < .vianj-api-token

Copy .env.example to .env and set:

BROWSER_BACKEND=local
WORKER_URL=https://vianj-companion.<your-workers-subdomain>.workers.dev
API_TOKEN_FILE=.vianj-api-token
RUNNER_ID=my-vianj-machine
HEADED=true

Use the exact Worker URL printed by wrangler deploy. Validate the installation before pairing:

npm run scan:secrets
npm run build
npm run typecheck
npm test
npm run runner:dry-run

Start the persistent runner in one terminal:

npm run runner

In a second terminal, generate the one-time pairing link:

npm run pair

Paste it into ViaNJ → Settings → Advanced Purchase Setup → Link Checkout Server within ten minutes. After ViaNJ reports that the server is online, enable Use Self-Hosted Checkout. Keep the runner active whenever you want to purchase through your machine.

ViaNJ uses a long-running local Node process to prepare NJ Transit guest WebTickets purchases in a dedicated persistent browser profile. It never purchases during the prepare phase. A separate authenticated, exact-match, short-lived confirmation is required before the final payment control can be clicked.

Architecture

ViaNJ iOS -> authenticated Cloudflare Worker -> local Node runner
         -> persistent Playwright browser profile -> NJT WebTickets

The Manifest V3 extension remains an optional status/fallback UI. It uses chrome.alarms, not service-worker timers. The default runner uses its own local Chrome for Testing profile, separate from the user's personal Chrome.

The Worker is the ViaNJ command queue. It is unrelated to Cloudflare protection shown by NJT. The persistent local profile preserves a consistent browser identity; the runner does not alter fingerprint APIs, spoof headers, solve challenges, or modify Cloudflare scripts. Kameleo is optional and unnecessary for the supported local-browser setup.

Configuration reference

Install Node.js, then:

npm install
cp .env.example .env
npm run build
npm run typecheck
npm test

Set these values locally in .env:

  • KAMELEO_URL: Local API URL.
  • KAMELEO_PROFILE_ID: preferred existing persistent Chroma profile ID.
  • KAMELEO_PROFILE_NAME: profile name to reuse or create when no ID is set.
  • BROWSER_BACKEND: local by default, using an ordinary persistent Chrome profile; set kameleo only if that optional service is already available.
  • LOCAL_BROWSER_PROFILE_DIR: ignored local Chrome profile directory.
  • LOCAL_CHROME_EXECUTABLE: optional Chrome path override for non-standard installs.
  • WORKER_URL: ViaNJ Worker origin.
  • API_TOKEN: bearer token shared with the Worker.
  • API_TOKEN_FILE: preferred ignored, mode-0600 file containing that bearer token, so the secret does not appear in .env or a service definition.
  • POLL_INTERVAL_MS: 1000–60000 milliseconds.
  • RUNNER_ID: stable name for this runner.
  • HEADED: true by default and recommended.
  • DRY_RUN: uses the mock browser boundary when true.
  • NJT_GUEST_EMAIL and NJT_GUEST_PHONE: legacy optional fallback for older app builds. Current ViaNJ builds securely store contact details on the iPhone and include them with each authenticated purchase command.
  • RUNNER_STATE_FILE: ignored, mode-0600 local final-submission ledger.
  • PAYMENT_KEY_FILE: ignored, mode-0600 X25519 private key used to decrypt payment details sent only by paired phones.

The default local backend launches Playwright's isolated Chrome for Testing with a dedicated persistent profile. The browser is installed automatically by npm install, does not interfere with the user's personal Chrome, and requires no Kameleo plan or profile-minute subscription. NJ Transit challenges remain visible in headed mode so the machine owner can complete one when necessary. The optional Kameleo backend still reuses the configured Chroma profile for installations that already depend on it.

When HEADED=false, the runner explicitly starts a stopped Chroma profile with Kameleo's supported headless argument before connecting. Headless capability depends on the installed Kameleo plan; use headed mode for manual challenges.

Start and stop:

npm run runner
# Ctrl-C or SIGTERM for graceful shutdown

Pair an iPhone

Keep the runner running, then create a short-lived link from the same configured machine:

npm run pair

In ViaNJ, open Settings → Advanced Purchase Setup → Link Checkout Server, paste the printed vianj://pair link, and tap Pair. The link expires after ten minutes and can be used only once. Pairing exchanges it for a device-specific credential that the app stores in the iPhone Keychain; the Worker's master token is never sent to the phone or included in the link.

The phone talks to the public HTTPS Cloudflare Worker, while the machine polls outbound to that Worker. No inbound port, public machine address, or Tailscale network is required. ViaNJ shows whether the runner is online. Pairing also gives the phone the machine's public payment key; the private key never leaves the machine. A paired app cannot poll runner work or create new pairing links.

If NJT shows a browser challenge, the headed runner emits a redacted manual_challenge_required event and waits up to ten minutes for a person to complete it. Do not attempt to automate the challenge.

Automatic guest-ticket ingestion

After fare confirmation, the companion completes payment and ticket generation inside the same NJ Transit browser session. It reads the generated barcode and ticket metadata, returns the result through the authenticated Worker, and clears the plaintext contact and encrypted payment payload at the terminal state. The iOS app persists the command ID and resumes watching after foregrounding, so the ticket appears automatically even if the purchase screen was closed. No receipt email is required for automatic ingestion.

The receipt-email route remains only as a recovery path for older commands and existing guest tickets. Raw receipt contents and plaintext card details are never logged.

Recover an existing guest ticket

Guest purchases do not need an NJ Transit account. NJ Transit emails the buyer a VIEW TICKET(S) link containing the checkout email and confirmation number. In ViaNJ, open My Tickets → + → Import Guest Ticket, paste that receipt link, and tap Import Guest Ticket. The paired runner opens only NJ Transit's exact HTTPS guest-ticket page, reads the ticket details, and returns them to the app.

The app also accepts the receipt email and confirmation number as manual input. Those values are validated, are not written to debug logs, and are never sent to the payment page. The runner must be online, but recovery does not create or charge a purchase.

Worker authentication

Runner endpoints use the Worker's master token. App endpoints accept either the master token or the device-specific token issued during pairing:

Authorization: Bearer <shared secret>
Content-Type: application/json

Command creation also requires Idempotency-Key; runner polling requires X-Runner-ID. Pair creation, polling, and runner status updates are master-only. Pair exchange is unauthenticated because the one-time 256-bit token is the credential for that single request. Configure explicit comma-separated web origins in ALLOWED_ORIGINS; there is no wildcard CORS. Store the secret only with:

npx wrangler secret put API_TOKEN --config worker/wrangler.jsonc

Do not put the master token in the app, source control, screenshots, logs, or a setup prompt. The runner is the only long-lived holder of it.

Prepare and confirm lifecycle

  1. iOS creates a validated command (quantity is limited to 1–4).
  2. A Durable Object atomically leases it to one runner.
  3. The runner reaches final review, reads the USD fare, and stops before payment.
  4. The Worker returns a confirmation identifier that expires after five minutes.
  5. iOS posts /confirm/:id with the exact command ID, identifier, route, passenger type, ticket type, quantity, fare, and currency.
  6. The Worker consumes that confirmation once and queues the confirm phase.
  7. ViaNJ encrypts the Keychain-stored card directly to the paired machine's X25519 public key. Cloudflare queues only the ciphertext and cannot decrypt it.
  8. The runner advances to NJ Transit's signed TrustCommerce page, validates the exact host and fare, decrypts the card only in memory, and submits it in the original browser session.
  9. The runner waits for NJ Transit's purchase and validation APIs plus the generated ticket barcode. Only then does it mark the command completed.
  10. ViaNJ returns to My Tickets immediately, shows live progress, and imports the returned ticket automatically. The runner clears PAN/CVV variables and the Worker scrubs contact/payment payloads at terminal state.

Secure machine payment

The card is stored only in the iPhone Keychain. Every purchase uses a fresh ephemeral Curve25519 key, HKDF-SHA256, and AES-GCM to encrypt it to the paired machine. The encrypted payload is authenticated with the short-lived, exact-fare confirmation and is never included in a pairing link or log. The companion's private key and persistent browser profile remain local.

NJ Transit's payment URL is session-bound, so it must not be transferred to an iPhone web view. A redirect alone never proves success: the command completes only after NJ Transit returns a validated ticket and the companion extracts its barcode. If a card shows a charge but the command fails, do not retry payment; recover the existing ticket from its NJ Transit receipt.

Statuses retained for compatibility are pending, claimed, processing, awaiting_confirmation, awaiting_payment, importing_ticket, completed, failed, and expired. The current direct flow normally proceeds from awaiting_confirmation through processing to completed.

Extension and CRX3 packaging

npm run build produces the unpacked extension in dist/. It stores no card or login data and prefers the payment method already stored by NJT. If future fallback automation needs local secrets, add a native messaging host backed by the OS keychain; never put them in chrome.storage.local.

For a signed Kameleo extension package, keep the private key outside this repo:

CRX_PRIVATE_KEY=/absolute/path/outside/repo/key.pem npm run package:crx

The ignored output is artifacts/vianj-companion.crx. .pem, .crx, Kameleo archives, cookies, browser state, .env, and the runner ledger are ignored.

Validation and troubleshooting

npm run scan:secrets
npm run scan:history       # reports counts only; never prints matched values
npm run build
npm run typecheck
npm test
npm run runner:dry-run
  • 401: Worker and caller bearer tokens differ.
  • 409 claim: lease expired or another runner owns it; do not bypass.
  • local Chrome fails to start: reinstall Playwright Chromium with npm install.
  • optional Kameleo context missing: start its Local API and verify the profile.
  • challenge timeout: rerun headed and complete the challenge manually.
  • ambiguous payment crash: reconcile with NJT before touching the local ledger.

Credential incident follow-up

Tracked test/account values were removed from HANDOFF.md, but remain in Git history. Manually rotate every credential previously recorded there, including account credentials and any test/payment values still valid. Removing a value from the current tree does not revoke it or erase earlier commits. Shared-history rewriting and external rotation require explicit owner approval and coordination.