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 loginCreate 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-tokenCopy .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=trueUse 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-runStart the persistent runner in one terminal:
npm run runnerIn a second terminal, generate the one-time pairing link:
npm run pairPaste 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 WebTicketsThe 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 testSet 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:localby default, using an ordinary persistent Chrome profile; setkameleoonly 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.envor a service definition.POLL_INTERVAL_MS: 1000–60000 milliseconds.RUNNER_ID: stable name for this runner.HEADED:trueby default and recommended.DRY_RUN: uses the mock browser boundary whentrue.NJT_GUEST_EMAILandNJT_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 shutdownPair an iPhone
Keep the runner running, then create a short-lived link from the same configured machine:
npm run pairIn 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/jsonCommand 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.jsoncDo 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
- iOS creates a validated command (
quantityis limited to 1–4). - A Durable Object atomically leases it to one runner.
- The runner reaches final review, reads the USD fare, and stops before payment.
- The Worker returns a confirmation identifier that expires after five minutes.
- iOS posts
/confirm/:idwith the exact command ID, identifier, route, passenger type, ticket type, quantity, fare, and currency. - The Worker consumes that confirmation once and queues the confirm phase.
- ViaNJ encrypts the Keychain-stored card directly to the paired machine's X25519 public key. Cloudflare queues only the ciphertext and cannot decrypt it.
- 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.
- The runner waits for NJ Transit's purchase and validation APIs plus the
generated ticket barcode. Only then does it mark the command
completed. - 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:crxThe 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-run401: 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.