Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
name: Deploy Tool Registry
name: Verify and Deploy Workers
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: deploy-tool-registry-${{ github.ref }}
group: deploy-workers-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Verify then deploy tool-registry Worker
verify:
name: Verify workers
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
Expand All @@ -20,10 +21,25 @@ jobs:
- run: npm run lint
- run: npm run validate:json
- run: npm test
- run: npm run test:permit-poller
- run: npm run test:workers
- run: npm run validate:registry
- run: npm run wrangler:dry-run
- run: npm run wrangler:dry-run:permit-poller
- run: git diff --check

deploy-tool-registry:
name: Deploy tool-registry Worker
if: github.event_name != 'pull_request'
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
- run: npm ci
- name: Deploy tool-registry only
uses: cloudflare/wrangler-action@v4
with:
Expand All @@ -35,3 +51,26 @@ jobs:
run: sleep 25
- name: Run live acceptance harness
run: node scripts/live-acceptance.mjs

deploy-permit-poller:
name: Provision and deploy permit poller
if: github.event_name != 'pull_request'
needs: verify
runs-on: ubuntu-latest
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }}
TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }}
TWILIO_FROM_NUMBER: ${{ secrets.TWILIO_FROM_NUMBER }}
TWILIO_VERIFY_SERVICE_SID: ${{ secrets.TWILIO_VERIFY_SERVICE_SID }}
TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
- run: npm ci
- name: Provision resources, migrate D1, and deploy
run: npm run deploy:permit-poller
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
.wrangler/
workers/permit-poller/wrangler.deploy.json
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
"private": true,
"type": "module",
"scripts": {
"lint": "node --check tool-registry/worker.js && node --check lib/registry.js && node --check lib/queries.js && node --check lib/mcp-server.js && node --check lib/resources.js && node --check scripts/live-acceptance.mjs",
"lint": "node --check tool-registry/worker.js && node --check workers/permit-poller/worker.js && node --check workers/permit-poller/catalog.js && node --check workers/permit-poller/adapters.js && node --check workers/permit-poller/service.js && node --check lib/registry.js && node --check lib/queries.js && node --check lib/mcp-server.js && node --check lib/resources.js && node --check scripts/live-acceptance.mjs && node --check scripts/permit-poller-live-check.mjs && node --check scripts/deploy-permit-poller.mjs",
"test": "node --test tests/registry.test.mjs",
"test:workers": "vitest run --config vitest.config.mjs",
"test:permit-poller": "node --test tests/permit-poller.test.mjs && vitest run --config vitest.permit-poller.config.mjs",
"test:permit-poller:live": "node scripts/permit-poller-live-check.mjs",
"deploy:permit-poller": "node scripts/deploy-permit-poller.mjs",
"validate:json": "node scripts/validate-json.mjs",
"validate:registry": "node scripts/validate-registry.mjs",
"wrangler:dry-run": "wrangler deploy --config tool-registry/wrangler.jsonc --dry-run --outdir /tmp/trailgenic-worker-dry-run"
"wrangler:dry-run": "wrangler deploy --config tool-registry/wrangler.jsonc --dry-run --outdir /tmp/trailgenic-worker-dry-run",
"wrangler:dry-run:permit-poller": "wrangler deploy --config workers/permit-poller/wrangler.jsonc --dry-run --outdir /tmp/trailgenic-permit-poller-dry-run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
Expand Down
91 changes: 91 additions & 0 deletions scripts/deploy-permit-poller.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { execFileSync, spawnSync } from "node:child_process";
import { readFileSync, rmSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceConfigPath = path.join(root, "workers/permit-poller/wrangler.jsonc");
const deployConfigPath = path.join(root, "workers/permit-poller/wrangler.deploy.json");
const databaseName = "trailgenic-permit-poller";
const queueNames = [
"trailgenic-permit-polls-dlq",
"trailgenic-permit-notifications-dlq",
"trailgenic-permit-polls",
"trailgenic-permit-notifications",
];
const secretNames = [
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_FROM_NUMBER",
"TWILIO_VERIFY_SERVICE_SID",
"TURNSTILE_SECRET_KEY",
];

const missingSecrets = secretNames.filter((name) => !process.env[name]);
if (missingSecrets.length) {
throw new Error(`Missing required deployment secrets: ${missingSecrets.join(", ")}`);
}

const wrangler = (...args) =>
execFileSync("npx", ["wrangler", ...args], {
cwd: root,
encoding: "utf8",
env: process.env,
stdio: ["ignore", "pipe", "inherit"],
});

const listDatabases = () => JSON.parse(wrangler("d1", "list", "--json"));
let database = listDatabases().find((candidate) => candidate.name === databaseName);

if (!database) {
console.log(`Creating D1 database ${databaseName}`);
wrangler("d1", "create", databaseName);
database = listDatabases().find((candidate) => candidate.name === databaseName);
}

const databaseId = database?.uuid || database?.id;
if (!databaseId) throw new Error(`Unable to resolve D1 database ID for ${databaseName}`);

for (const queueName of queueNames) {
const result = spawnSync("npx", ["wrangler", "queues", "create", queueName], {
cwd: root,
encoding: "utf8",
env: process.env,
});
const output = `${result.stdout || ""}\n${result.stderr || ""}`;
if (result.status === 0) {
console.log(`Created queue ${queueName}`);
} else if (/already exists|already been taken|code\s*10010/i.test(output)) {
console.log(`Queue ${queueName} already exists`);
} else {
process.stderr.write(output);
throw new Error(`Unable to create queue ${queueName}`);
}
}

const config = JSON.parse(readFileSync(sourceConfigPath, "utf8"));
config.d1_databases[0].database_id = databaseId;
writeFileSync(deployConfigPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });

try {
console.log("Applying D1 migrations");
wrangler("d1", "migrations", "apply", databaseName, "--remote", "--config", deployConfigPath);

console.log("Deploying permit poller");
wrangler("deploy", "--config", deployConfigPath);

const secretPayload = Object.fromEntries(secretNames.map((name) => [name, process.env[name]]));
const secretResult = spawnSync("npx", ["wrangler", "secret", "bulk", "--config", deployConfigPath], {
cwd: root,
encoding: "utf8",
env: process.env,
input: JSON.stringify(secretPayload),
stdio: ["pipe", "inherit", "inherit"],
});
if (secretResult.status !== 0) throw new Error("Unable to install permit poller secrets");

console.log("Redeploying with production secrets");
wrangler("deploy", "--config", deployConfigPath);
} finally {
rmSync(deployConfigPath, { force: true });
}
32 changes: 32 additions & 0 deletions scripts/permit-poller-live-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { fetchAvailability } from "../workers/permit-poller/adapters.js";
import { PERMIT_PRODUCTS } from "../workers/permit-poller/catalog.js";

const today = new Date().toISOString().slice(0, 10);
const failures = [];

for (const product of PERMIT_PRODUCTS) {
try {
const result = await fetchAvailability(product, `${today.slice(0, 7)}-01`, [today]);
console.log(JSON.stringify({
permit_id: product.id,
adapter: product.adapter,
http_status: result.httpStatus,
parsed_records: result.records.length,
status: "ok"
}));
} catch (error) {
failures.push(product.id);
console.error(JSON.stringify({
permit_id: product.id,
adapter: product.adapter,
status: "failed",
code: error.code || "unexpected_error",
http_status: error.httpStatus || null,
message: error.message
}));
}
}

if (failures.length > 0) {
throw new Error(`Permit source validation failed: ${failures.join(", ")}`);
}
20 changes: 20 additions & 0 deletions tests/fixtures/permit-poller/division-availability.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"payload": {
"permit_id": "234652",
"availability": {
"31": {
"division_id": "31",
"date_availability": {
"2026-07-20T00:00:00Z": { "total": 275, "remaining": 3 },
"2026-07-21T00:00:00Z": { "total": 275, "remaining": 0 }
}
},
"32": {
"division_id": "32",
"date_availability": {
"2026-07-20T00:00:00Z": { "total": 300, "remaining": 100 }
}
}
}
}
}
20 changes: 20 additions & 0 deletions tests/fixtures/permit-poller/inyo-availability.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"payload": {
"2026-07-20": {
"166": {
"quota_usage_by_member_daily": { "total": 60, "remaining": 2 },
"is_walkup": false
},
"406": {
"quota_usage_by_member_daily": { "total": 100, "remaining": 5 },
"is_walkup": false
}
},
"2026-07-21": {
"406": {
"quota_usage_by_member_daily": { "total": 100, "remaining": 1 },
"is_walkup": false
}
}
}
}
136 changes: 136 additions & 0 deletions tests/permit-poller.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import {
buildAvailabilityUrl,
parseDivisionMonthAvailability,
parseInyoAvailability,
SourceResponseError
} from "../workers/permit-poller/adapters.js";
import { getPermitProduct, publicPermitCatalog } from "../workers/permit-poller/catalog.js";
import {
crossesPartyThreshold,
hashToken,
maskPhone,
validateTrackerInput
} from "../workers/permit-poller/service.js";
import { handleHttp } from "../workers/permit-poller/worker.js";

const fixture = async (name) => JSON.parse(await readFile(
new URL(`./fixtures/permit-poller/${name}`, import.meta.url), "utf8"
));

test("Whitney parser keeps Day Use and Overnight inventory separate", async () => {
const payload = await fixture("inyo-availability.json");
const dates = ["2026-07-20", "2026-07-21", "2026-07-22"];
const dayUse = parseInyoAvailability(payload, getPermitProduct("rec_gov_445860_day_use"), dates);
const overnight = parseInyoAvailability(payload, getPermitProduct("rec_gov_445860_overnight"), dates);
assert.deepEqual(dayUse, [
{ date: "2026-07-20", remaining: 5 },
{ date: "2026-07-21", remaining: 1 }
]);
assert.deepEqual(overnight, [
{ date: "2026-07-20", remaining: 2 },
{ date: "2026-07-21", remaining: 0 }
]);
});

test("missing Inyo dates remain unknown rather than becoming false zeroes", async () => {
const payload = await fixture("inyo-availability.json");
const records = parseInyoAvailability(
payload,
getPermitProduct("rec_gov_445860_day_use"),
["2026-08-01"]
);
assert.deepEqual(records, []);
});

test("Half Dome parser reads only the Daily division", async () => {
const payload = await fixture("division-availability.json");
const records = parseDivisionMonthAvailability(
payload,
getPermitProduct("rec_gov_234652_daily"),
["2026-07-20", "2026-07-21", "2026-07-22"]
);
assert.deepEqual(records, [
{ date: "2026-07-20", remaining: 3 },
{ date: "2026-07-21", remaining: 0 }
]);
});

test("adapter rejects schema drift instead of erasing the last known snapshot", () => {
assert.throws(
() => parseDivisionMonthAvailability(
{ payload: { availability: {} } },
getPermitProduct("rec_gov_234652_daily"),
["2026-07-20"]
),
(error) => error instanceof SourceResponseError && error.code === "division_missing"
);
});

test("party thresholds trigger only when inventory becomes sufficient", () => {
assert.equal(crossesPartyThreshold(0, 3, 1), true);
assert.equal(crossesPartyThreshold(0, 3, 3), true);
assert.equal(crossesPartyThreshold(0, 3, 4), false);
assert.equal(crossesPartyThreshold(2, 3, 2), false);
assert.equal(crossesPartyThreshold(3, 3, 3), false);
assert.equal(crossesPartyThreshold(null, 3, 1), false);
});

test("tracker validation normalizes dates and enforces consent and product limits", () => {
const now = new Date("2026-07-19T12:00:00.000Z");
const valid = validateTrackerInput({
phone: "+19515550123",
permit_id: "rec_gov_445860_day_use",
party_size: 2,
dates: ["2026-07-21", "2026-07-20", "2026-07-21"],
consent: true
}, now);
assert.equal(valid.ok, true);
assert.deepEqual(valid.value.dates, ["2026-07-20", "2026-07-21"]);

assert.equal(validateTrackerInput({ ...valid.value, consent: false }, now).ok, false);
assert.equal(validateTrackerInput({ ...valid.value, consent: true, party_size: 16 }, now).ok, false);
assert.equal(validateTrackerInput({ ...valid.value, consent: true, dates: ["2026-07-18"] }, now).ok, false);
});

test("catalog exposes only verified cancellation-inventory products", () => {
assert.deepEqual(publicPermitCatalog().map((product) => product.id), [
"rec_gov_445860_day_use",
"rec_gov_445860_overnight",
"rec_gov_234652_daily"
]);
});

test("adapter URLs use the correct Recreation.gov API family", () => {
assert.match(
buildAvailabilityUrl(getPermitProduct("rec_gov_445860_overnight"), "2026-07-01"),
/permitinyo\/445860\/availabilityv2\?start_date=2026-07-01&end_date=2026-07-31/
);
assert.match(
buildAvailabilityUrl(getPermitProduct("rec_gov_234652_daily"), "2026-07-01"),
/api\/permits\/234652\/availability\/month/
);
});

test("management tokens hash deterministically and phone responses stay masked", async () => {
assert.equal(await hashToken("token-a"), await hashToken("token-a"));
assert.notEqual(await hashToken("token-a"), await hashToken("token-b"));
assert.equal(maskPhone("+19515550123"), "+1******0123");
});

test("HTTP catalog is no-store and rejects unapproved browser origins", async () => {
const env = { ALLOWED_ORIGINS: "https://www.trailgenic.com" };
const ok = await handleHttp(new Request("https://alerts.trailgenic.com/permits", {
headers: { Origin: "https://www.trailgenic.com" }
}), env);
assert.equal(ok.status, 200);
assert.equal(ok.headers.get("Cache-Control"), "no-store");
assert.equal(ok.headers.get("Access-Control-Allow-Origin"), "https://www.trailgenic.com");

const bad = await handleHttp(new Request("https://alerts.trailgenic.com/permits", {
headers: { Origin: "https://evil.example" }
}), env);
assert.equal(bad.status, 403);
});
Loading