From a3add5d81149cd1dff00375c40fb09d3f11c67c0 Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Fri, 2 Oct 2026 22:48:06 -0500 Subject: [PATCH 01/13] feat(azure-migration): port admin/API to Azure Static Web Apps + Entra ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Vercel-to-Azure migration (see the approved plan): ports the board-admin app's backend from Vercel serverless functions to an Azure Static Web Apps managed Functions API, and replaces the shared passphrase with Microsoft Entra ID sign-in restricted to an explicit board-member allow-list. This is pure code — no Azure resource exists yet, and Vercel keeps running unaffected throughout. Deleted (replaced by Azure Static Web Apps' built-in auth, not ported): - api/auth.js, api/logout.js, api/_lib/session.js — SWA's own /.auth/login/aad and /.auth/logout replace the custom passphrase check and HMAC-signed session cookie entirely. - admin/index.html (passphrase login form) and admin/admin-auth.js. admin/dashboard.html is renamed to admin/index.html as the single admin entry point, since there's no separate login page anymore. - The passphrase rate-limiting functions in api/_lib/rateLimit.js (checkRateLimit/recordRateLimitFailure/clearRateLimit/getClientIp) — nothing left to rate-limit locally. throttleGeocodeCall (unrelated to auth) is kept. Added: - staticwebapp.config.json: Entra ID (azureActiveDirectory) custom auth provider config, route rules requiring the "authenticated" role on /admin/* and /api/*, and the ported CSP/security headers from vercel.json. The tenant ID is a placeholder (REPLACE_WITH_PRAXIUM_TENANT_ID) filled in during the actual Azure deployment phase. - api/_lib/clientPrincipal.js: reads the x-ms-client-principal header SWA injects into authenticated requests, for a defense-in-depth check inside each function (SWA's route rules already block unauthenticated requests before they arrive). - api/host.json, api/package.json (+ lockfile): Azure Functions v4 Node.js project scaffolding. @azure/functions is the one new runtime dependency this project gains — everything else stays dependency-free. Rewritten (business logic unchanged, only the handler wrapper and I/O calls changed) as api/src/functions/{gist,geocode,publish}.js: - Vercel's `module.exports = async (req, res) => { res.status(x).end() }` becomes Azure Functions v4's `app.http(name, { handler })` returning `{ status, jsonBody, headers }`. Each handler function is also exported directly (gistHandler/geocodeHandler/publishHandler) so tests can call it without needing the real Functions runtime. - req.headers.cookie / req.headers['x-forwarded-for'] access becomes request.headers.get('...'); readJsonBody(req) becomes await request.json(); requireSession(req) becomes getClientPrincipal(request). - api/_lib/http.js's sendJson/methodNotAllowed rewritten from res-mutating to return-value style (jsonResponse/methodNotAllowed/ unauthorized) — a simplification, not just a port. - api/_lib/githubGist.js and api/_lib/categories.js are unchanged — pure Node, never touched req/res. - All validation rules, reciprocal-connection logic, cascade-delete, and optimistic-concurrency (409) behavior in publish.js carries over byte-for-byte in behavior. admin/admin.js: every 401 redirect now goes to /.auth/login/aad instead of the deleted local login page; the logout button links to /.auth/logout instead of calling a local /api/logout. test/api.test.js: ported to the new handler shape (a mock Azure Functions HttpRequest with headers.get()/json(), calling the exported handlers directly and asserting on their returned response objects). The passphrase/rate-limit tests are gone (nothing left to test locally — Entra ID auth is enforced by the platform, outside this repo); two new tests cover missing/incomplete x-ms-client-principal returning 401. Every other behavior (session-gating semantics, validation, reciprocal connections, cascade delete, conflict handling) is preserved with equivalent assertions. All 13 tests pass locally. CI (.github/workflows/ci.yml) and package.json's `check`/`test` scripts updated for the new file layout (api/src/functions/ instead of api/*.js, no more admin/admin-auth.js, a new api/ npm install + audit step since it now has its own dependency tree). Also fixes two small things found while reading these files closely: removed now-dead .login-body/.login-card CSS from admin/admin.css (the page that used them is gone), and added api/local.settings.json to .gitignore (Azure Functions' local-dev secrets file). Not in this commit (later phases, per the plan): creating any actual Azure resources, the Entra app registrations, or merging to main. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- .github/workflows/ci.yml | 26 +-- .gitignore | 4 + admin/admin-auth.js | 30 ---- admin/admin.css | 27 +-- admin/admin.js | 15 +- admin/dashboard.html | 132 -------------- admin/index.html | 131 ++++++++++++-- api/_lib/clientPrincipal.js | 29 +++ api/_lib/http.js | 49 +++--- api/_lib/rateLimit.js | 48 +---- api/_lib/session.js | 88 ---------- api/auth.js | 46 ----- api/gist.js | 20 --- api/host.json | 15 ++ api/logout.js | 8 - api/package-lock.json | 49 ++++++ api/package.json | 16 ++ api/{ => src/functions}/geocode.js | 40 +++-- api/src/functions/gist.js | 30 ++++ api/{ => src/functions}/publish.js | 48 +++-- package.json | 2 +- staticwebapp.config.json | 41 +++++ test/api.test.js | 271 ++++++++++------------------- 23 files changed, 515 insertions(+), 650 deletions(-) delete mode 100644 admin/admin-auth.js delete mode 100644 admin/dashboard.html create mode 100644 api/_lib/clientPrincipal.js delete mode 100644 api/_lib/session.js delete mode 100644 api/auth.js delete mode 100644 api/gist.js create mode 100644 api/host.json delete mode 100644 api/logout.js create mode 100644 api/package-lock.json create mode 100644 api/package.json rename api/{ => src/functions}/geocode.js (55%) create mode 100644 api/src/functions/gist.js rename api/{ => src/functions}/publish.js (86%) create mode 100644 staticwebapp.config.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d59bba..385689b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,16 +21,24 @@ jobs: run: npm ci --ignore-scripts - name: Audit dependencies - # Production-only: this project ships zero runtime npm dependencies (the - # API functions use Node built-ins, the frontend loads Leaflet from a CDN). - # electron/electron-builder are devDependencies for the desktop packaging - # tool only — never shipped to the deployed map/admin/API — so their - # transitive vulnerabilities shouldn't block every unrelated PR. + # Production-only: the root project ships zero runtime npm dependencies + # (the public map loads Leaflet from a CDN). electron/electron-builder + # are devDependencies for the desktop packaging tool only — never + # shipped to the deployed map/admin/API — so their transitive + # vulnerabilities shouldn't block every unrelated PR. run: npm audit --omit=dev --audit-level=high + - name: Install api/ dependencies + # The Azure Functions API has its own package.json (@azure/functions is + # its one real runtime dependency), separate from the root project. + run: cd api && npm ci --ignore-scripts + + - name: Audit api/ dependencies + run: cd api && npm audit --omit=dev --audit-level=high + - name: Validate JSON files run: | - for f in $(find . -name "*.json" -not -path "./node_modules/*" -not -path "./.git/*"); do + for f in $(find . -name "*.json" -not -path "./node_modules/*" -not -path "./api/node_modules/*" -not -path "./.git/*"); do echo "Checking $f" node -e "JSON.parse(require('fs').readFileSync('$f', 'utf8'))" done @@ -50,15 +58,13 @@ jobs: - name: Check api/ function syntax run: | - for f in api/*.js api/_lib/*.js; do + for f in api/_lib/*.js api/src/functions/*.js; do echo "Checking $f" node --check "$f" done - name: Check admin/ front-end syntax - run: | - node --check admin/admin.js - node --check admin/admin-auth.js + run: node --check admin/admin.js - name: Run api/ unit tests (mocked Gist backend) run: node test/api.test.js diff --git a/.gitignore b/.gitignore index f03a7fe..3b493bc 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,10 @@ wheels/ *.pem *.key +# Azure Functions local development settings (holds secrets like GITHUB_PAT, +# AZURE_CLIENT_SECRET when running `func start` locally against api/) +api/local.settings.json + # IDE .vscode/ .idea/ diff --git a/admin/admin-auth.js b/admin/admin-auth.js deleted file mode 100644 index 01e2a60..0000000 --- a/admin/admin-auth.js +++ /dev/null @@ -1,30 +0,0 @@ -const form = document.getElementById('login-form'); -const errorEl = document.getElementById('login-error'); - -form.addEventListener('submit', async (e) => { - e.preventDefault(); - errorEl.hidden = true; - const passphrase = document.getElementById('passphrase').value; - - try { - const resp = await fetch('/api/auth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ passphrase }), - }); - const data = await resp.json(); - if (resp.ok && data.ok) { - window.location.href = '/admin/dashboard.html'; - return; - } - if (resp.status === 429) { - errorEl.textContent = 'Too many attempts. Please wait 15 minutes and try again.'; - } else { - errorEl.textContent = 'Incorrect passphrase.'; - } - errorEl.hidden = false; - } catch (err) { - errorEl.textContent = 'Could not reach the server. Please try again.'; - errorEl.hidden = false; - } -}); diff --git a/admin/admin.css b/admin/admin.css index 36fe6c0..fb44de0 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -7,29 +7,10 @@ body { color: #1f2937; } -/* --- Login page --- */ -.login-body { - height: 100vh; - display: flex; - align-items: center; - justify-content: center; -} -.login-card { - background: white; - padding: 32px; - border-radius: 10px; - box-shadow: 0 4px 16px rgba(0,0,0,0.1); - width: 320px; -} -.login-card h1 { font-size: 20px; margin: 0 0 4px; } -.login-card .subtitle { margin: 0 0 20px; color: #6b7280; font-size: 14px; } -.login-card form { display: flex; flex-direction: column; gap: 8px; } -.login-card label { font-size: 13px; font-weight: 600; } -.login-card input { padding: 10px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; } -.login-card button { margin-top: 8px; padding: 10px; border: none; border-radius: 6px; background: #2563eb; color: white; font-weight: 600; cursor: pointer; } -.login-card button:hover { background: #1d4ed8; } - -/* --- Dashboard --- */ +/* --- Dashboard --- + The custom passphrase login page (and its .login-body/.login-card styles) + was removed — Microsoft Entra ID sign-in is handled entirely by Azure + Static Web Apps' built-in /.auth/login/aad, with no local login page. */ #admin-header { display: flex; justify-content: space-between; diff --git a/admin/admin.js b/admin/admin.js index aae93c8..184508e 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -35,7 +35,7 @@ function hideFormError() { async function loadData() { const resp = await fetch('/api/gist', { credentials: 'same-origin' }); if (resp.status === 401) { - window.location.href = '/admin/index.html'; + window.location.href = '/.auth/login/aad'; return; } if (!resp.ok) { @@ -229,7 +229,7 @@ document.getElementById('geocode-btn').addEventListener('click', async () => { body: JSON.stringify({ address }), }); if (resp.status === 401) { - window.location.href = '/admin/index.html'; + window.location.href = '/.auth/login/aad'; return; } if (resp.status === 404) { @@ -261,7 +261,7 @@ document.getElementById('delete-btn').addEventListener('click', async () => { body: JSON.stringify({ mode: 'delete', orgId: editingOrgId, expectedVersion: currentVersion }), }); const data = await resp.json(); - if (resp.status === 401) { window.location.href = '/admin/index.html'; return; } + if (resp.status === 401) { window.location.href = '/.auth/login/aad'; return; } if (resp.status === 409) { showFormError(data.message + ' Reloading current data…'); await loadData(); @@ -279,9 +279,10 @@ document.getElementById('delete-btn').addEventListener('click', async () => { } }); -document.getElementById('logout-btn').addEventListener('click', async () => { - await fetch('/api/logout', { method: 'POST' }); - window.location.href = '/admin/index.html'; +document.getElementById('logout-btn').addEventListener('click', () => { + // Azure Static Web Apps' built-in auth handles sign-out; there's no local + // session/cookie of our own to clear anymore. + window.location.href = '/.auth/logout'; }); form.addEventListener('submit', async (e) => { @@ -329,7 +330,7 @@ form.addEventListener('submit', async (e) => { }); const data = await resp.json(); - if (resp.status === 401) { window.location.href = '/admin/index.html'; return; } + if (resp.status === 401) { window.location.href = '/.auth/login/aad'; return; } if (resp.status === 409) { showFormError(data.message); diff --git a/admin/dashboard.html b/admin/dashboard.html deleted file mode 100644 index 0f12073..0000000 --- a/admin/dashboard.html +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - - - Praxium - Allies and Partners: Admin - - - - -
-

Praxium - Allies and Partners: Admin

-
- - -
-
- - - -
-
-
- - -
-
    -
    - - -
    - - - - - diff --git a/admin/index.html b/admin/index.html index 660ba8a..0f12073 100644 --- a/admin/index.html +++ b/admin/index.html @@ -5,23 +5,128 @@ - Praxium - Allies and Partners: Admin Login + Praxium - Allies and Partners: Admin + - -
    -

    Praxium - Allies and Partners

    -

    Board member admin

    -
    - - - - -
    + +
    +

    Praxium - Allies and Partners: Admin

    +
    + + +
    +
    + + + +
    +
    +
    + + +
    +
      +
      + +
      - + + + diff --git a/api/_lib/clientPrincipal.js b/api/_lib/clientPrincipal.js new file mode 100644 index 0000000..3bf14da --- /dev/null +++ b/api/_lib/clientPrincipal.js @@ -0,0 +1,29 @@ +// Reads the identity Azure Static Web Apps injects into every authenticated request. +// +// SWA's own route rules (staticwebapp.config.json: "allowedRoles": ["authenticated"]) already +// block unauthenticated requests to /api/* before they reach this code — nobody without a valid +// Entra session, who's also been explicitly assigned to the app registration, can get this far. +// This check is defense-in-depth only, in case a function is ever invoked by a path that bypasses +// the configured routes (e.g. direct Functions-level invocation during local development). +const HEADER_NAME = 'x-ms-client-principal'; + +// Returns the parsed principal ({ identityProvider, userId, userDetails, userRoles }) if the +// request carries a valid, authenticated SWA identity, or null otherwise. +function getClientPrincipal(request) { + const header = request.headers.get(HEADER_NAME); + if (!header) return null; + + let principal; + try { + principal = JSON.parse(Buffer.from(header, 'base64').toString('utf8')); + } catch (err) { + return null; + } + + if (!principal || !Array.isArray(principal.userRoles) || !principal.userRoles.includes('authenticated')) { + return null; + } + return principal; +} + +module.exports = { getClientPrincipal }; diff --git a/api/_lib/http.js b/api/_lib/http.js index b8e76cf..2e5f44a 100644 --- a/api/_lib/http.js +++ b/api/_lib/http.js @@ -1,29 +1,34 @@ -// Small helpers shared by the /api handlers. +// Small helpers shared by the /api functions. +// +// Azure Functions' v4 Node.js model has handlers *return* a response object +// ({ status, jsonBody, headers }) instead of mutating a res parameter like +// Vercel's model did — these helpers build that object consistently. -// Vercel's Node.js runtime auto-parses JSON bodies into req.body, but guard against it being -// a raw string (e.g. when a different content-type or invocation path is used). -function readJsonBody(req) { - if (req.body === undefined || req.body === null) return {}; - if (typeof req.body === 'object') return req.body; - try { - return JSON.parse(req.body); - } catch (err) { - return {}; - } +function jsonResponse(status, payload) { + return { + status, + jsonBody: payload, + headers: { + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }; } -function sendJson(res, status, payload) { - res - .status(status) - .setHeader('Content-Type', 'application/json; charset=utf-8') - .setHeader('Cache-Control', 'no-store') - .setHeader('X-Content-Type-Options', 'nosniff') - .end(JSON.stringify(payload)); +function methodNotAllowed(allowed) { + return { + status: 405, + jsonBody: { error: 'method_not_allowed' }, + headers: { + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + Allow: allowed.join(', '), + }, + }; } -function methodNotAllowed(res, allowed) { - res.setHeader('Allow', allowed.join(', ')); - sendJson(res, 405, { error: 'method_not_allowed' }); +function unauthorized() { + return jsonResponse(401, { error: 'unauthorized' }); } -module.exports = { readJsonBody, sendJson, methodNotAllowed }; +module.exports = { jsonResponse, methodNotAllowed, unauthorized }; diff --git a/api/_lib/rateLimit.js b/api/_lib/rateLimit.js index d3580cb..8a22b11 100644 --- a/api/_lib/rateLimit.js +++ b/api/_lib/rateLimit.js @@ -1,39 +1,11 @@ -// Best-effort in-memory rate limiting. Serverless cold starts reset this state, so it is -// defense-in-depth on top of the passphrase itself, not a hard guarantee — acceptable for a -// small trusted-board internal tool. Do not rely on this alone for a public-facing surface. -const buckets = new Map(); - -function recentAttempts(key, windowMs) { - const now = Date.now(); - const recent = (buckets.get(key) || []).filter((ts) => now - ts < windowMs); - if (recent.length) buckets.set(key, recent); - else buckets.delete(key); - return recent; -} - -// Returns true if the caller (keyed by e.g. IP) may make another attempt. -// Checking does not consume the budget; only failed authentication should. -function checkRateLimit(key, { max, windowMs }) { - return recentAttempts(key, windowMs).length < max; -} - -function recordRateLimitFailure(key, { windowMs }) { - const recent = recentAttempts(key, windowMs); - recent.push(Date.now()); - buckets.set(key, recent); -} - -function clearRateLimit(key) { - buckets.delete(key); -} - -function getClientIp(req) { - const forwarded = req.headers['x-forwarded-for']; - if (forwarded) return forwarded.split(',')[0].trim(); - return req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : 'unknown'; -} +// Throttling helper for the outbound Nominatim geocode proxy. The passphrase +// brute-force rate limiting that used to live here was removed along with the +// passphrase itself — Microsoft Entra ID now gates sign-in, so there's no +// local login attempt to rate-limit. // Simple last-call throttle used for the Nominatim geocode proxy (policy: max ~1 req/sec). +// Same best-effort caveat as before: serverless cold starts reset this, so it's a courtesy +// to Nominatim's shared infrastructure, not a hard per-instance guarantee. let lastGeocodeCallAt = 0; async function throttleGeocodeCall(minIntervalMs) { const now = Date.now(); @@ -44,10 +16,4 @@ async function throttleGeocodeCall(minIntervalMs) { lastGeocodeCallAt = Date.now(); } -module.exports = { - checkRateLimit, - recordRateLimitFailure, - clearRateLimit, - getClientIp, - throttleGeocodeCall, -}; +module.exports = { throttleGeocodeCall }; diff --git a/api/_lib/session.js b/api/_lib/session.js deleted file mode 100644 index 4303937..0000000 --- a/api/_lib/session.js +++ /dev/null @@ -1,88 +0,0 @@ -// Signs and verifies the admin session cookie using HMAC-SHA256. -// The token is `${expiresAtMs}.${signatureHex}` — no external JWT dependency needed -// for a single-purpose, single-role session like this. -const crypto = require('crypto'); - -const COOKIE_NAME = 'praxium_admin_session'; -const SESSION_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours - -function getSecret() { - const secret = process.env.SESSION_SECRET; - if (!secret) throw new Error('SESSION_SECRET is not configured'); - if (Buffer.byteLength(secret, 'utf8') < 32) { - throw new Error('SESSION_SECRET must be at least 32 bytes'); - } - return secret; -} - -function sign(expiresAt) { - const hmac = crypto.createHmac('sha256', getSecret()); - hmac.update(String(expiresAt)); - return hmac.digest('hex'); -} - -function createSessionToken() { - const expiresAt = Date.now() + SESSION_TTL_MS; - const signature = sign(expiresAt); - return `${expiresAt}.${signature}`; -} - -function verifySessionToken(token) { - if (!token || typeof token !== 'string') return false; - const parts = token.split('.'); - if (parts.length !== 2) return false; - const [expiresAtStr, signature] = parts; - const expiresAt = Number(expiresAtStr); - if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) return false; - - const expectedSignature = sign(expiresAt); - const a = Buffer.from(signature, 'hex'); - const b = Buffer.from(expectedSignature, 'hex'); - if (a.length !== b.length) return false; - return crypto.timingSafeEqual(a, b); -} - -function parseCookies(cookieHeader) { - const out = {}; - if (!cookieHeader) return out; - cookieHeader.split(';').forEach((pair) => { - const idx = pair.indexOf('='); - if (idx === -1) return; - const key = pair.slice(0, idx).trim(); - const value = pair.slice(idx + 1).trim(); - out[key] = decodeURIComponent(value); - }); - return out; -} - -function buildSessionCookie(token) { - const maxAgeSeconds = Math.floor(SESSION_TTL_MS / 1000); - return [ - `${COOKIE_NAME}=${encodeURIComponent(token)}`, - 'HttpOnly', - 'Secure', - 'SameSite=Strict', - 'Path=/', - `Max-Age=${maxAgeSeconds}`, - ].join('; '); -} - -function buildClearCookie() { - return `${COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`; -} - -// Returns true if the request carries a valid, unexpired session cookie. -function requireSession(req) { - const cookies = parseCookies(req.headers.cookie); - return verifySessionToken(cookies[COOKIE_NAME]); -} - -module.exports = { - COOKIE_NAME, - createSessionToken, - verifySessionToken, - parseCookies, - buildSessionCookie, - buildClearCookie, - requireSession, -}; diff --git a/api/auth.js b/api/auth.js deleted file mode 100644 index 37da147..0000000 --- a/api/auth.js +++ /dev/null @@ -1,46 +0,0 @@ -const crypto = require('crypto'); -const { createSessionToken, buildSessionCookie } = require('./_lib/session'); -const { - checkRateLimit, - recordRateLimitFailure, - clearRateLimit, - getClientIp, -} = require('./_lib/rateLimit'); -const { readJsonBody, sendJson, methodNotAllowed } = require('./_lib/http'); - -const MAX_ATTEMPTS = 5; -const WINDOW_MS = 15 * 60 * 1000; // 15 minutes - -// Compares two strings in constant time regardless of length, by comparing fixed-length -// SHA-256 digests instead of the raw values (avoids leaking passphrase length via timing). -function constantTimeEquals(a, b) { - const digestA = crypto.createHash('sha256').update(String(a)).digest(); - const digestB = crypto.createHash('sha256').update(String(b)).digest(); - return crypto.timingSafeEqual(digestA, digestB); -} - -module.exports = async (req, res) => { - if (req.method !== 'POST') return methodNotAllowed(res, ['POST']); - - const ip = getClientIp(req); - const rateLimitKey = `auth:${ip}`; - if (!checkRateLimit(rateLimitKey, { max: MAX_ATTEMPTS, windowMs: WINDOW_MS })) { - return sendJson(res, 429, { ok: false, error: 'too_many_attempts' }); - } - - const { passphrase } = readJsonBody(req); - const expected = process.env.ADMIN_PASSPHRASE; - if (!expected) { - return sendJson(res, 500, { ok: false, error: 'server_misconfigured' }); - } - - if (typeof passphrase !== 'string' || !passphrase || !constantTimeEquals(passphrase, expected)) { - recordRateLimitFailure(rateLimitKey, { windowMs: WINDOW_MS }); - return sendJson(res, 401, { ok: false, error: 'invalid_passphrase' }); - } - - clearRateLimit(rateLimitKey); - const token = createSessionToken(); - res.setHeader('Set-Cookie', buildSessionCookie(token)); - return sendJson(res, 200, { ok: true }); -}; diff --git a/api/gist.js b/api/gist.js deleted file mode 100644 index 2252f4e..0000000 --- a/api/gist.js +++ /dev/null @@ -1,20 +0,0 @@ -const { requireSession } = require('./_lib/session'); -const { fetchGist } = require('./_lib/githubGist'); -const { readCategories } = require('./_lib/categories'); -const { sendJson, methodNotAllowed } = require('./_lib/http'); - -module.exports = async (req, res) => { - if (req.method !== 'GET') return methodNotAllowed(res, ['GET']); - if (!requireSession(req)) return sendJson(res, 401, { error: 'unauthorized' }); - - try { - const [{ organizations, version }, categories] = await Promise.all([ - fetchGist(), - Promise.resolve(readCategories()), - ]); - return sendJson(res, 200, { organizations, categories, version }); - } catch (err) { - console.error('GET /api/gist failed:', err); - return sendJson(res, 502, { error: 'gist_fetch_failed' }); - } -}; diff --git a/api/host.json b/api/host.json new file mode 100644 index 0000000..06d01bd --- /dev/null +++ b/api/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/api/logout.js b/api/logout.js deleted file mode 100644 index 5bec6f6..0000000 --- a/api/logout.js +++ /dev/null @@ -1,8 +0,0 @@ -const { buildClearCookie } = require('./_lib/session'); -const { sendJson, methodNotAllowed } = require('./_lib/http'); - -module.exports = async (req, res) => { - if (req.method !== 'POST') return methodNotAllowed(res, ['POST']); - res.setHeader('Set-Cookie', buildClearCookie()); - return sendJson(res, 200, { ok: true }); -}; diff --git a/api/package-lock.json b/api/package-lock.json new file mode 100644 index 0000000..4550d93 --- /dev/null +++ b/api/package-lock.json @@ -0,0 +1,49 @@ +{ + "name": "praxium-map-admin-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "praxium-map-admin-api", + "version": "1.0.0", + "dependencies": { + "@azure/functions": "^4.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@azure/functions": { + "version": "4.16.5", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.16.5.tgz", + "integrity": "sha512-h0lVUeI/Q36SyE2xGQOXZqBbmSgQHNLk8pvMxhq3Dw1TaT3eGWs2X25dceGJ153aXXkjcTEdkxg3D2Gcz1uJ0w==", + "license": "MIT", + "dependencies": { + "@azure/functions-extensions-base": "0.3.0", + "cookie": "^0.7.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@azure/functions-extensions-base": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@azure/functions-extensions-base/-/functions-extensions-base-0.3.0.tgz", + "integrity": "sha512-Cux0hLu5ZXlC/Kb+yvJVhRLIdkfFwui2HeT5oGZL00r/GCUUkhGTzRfZUjRN4Bq729mPv3okPucz2z7SMQLStA==", + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + } + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..38d7c0c --- /dev/null +++ b/api/package.json @@ -0,0 +1,16 @@ +{ + "name": "praxium-map-admin-api", + "version": "1.0.0", + "private": true, + "description": "Azure Functions API backing the Praxium board admin (/admin). Auth is handled entirely by Azure Static Web Apps' built-in Microsoft Entra ID integration, not by this code.", + "main": "src/functions/*.js", + "scripts": { + "start": "func start" + }, + "engines": { + "node": ">=22.12.0" + }, + "dependencies": { + "@azure/functions": "^4.0.0" + } +} diff --git a/api/geocode.js b/api/src/functions/geocode.js similarity index 55% rename from api/geocode.js rename to api/src/functions/geocode.js index f3a5056..9dac088 100644 --- a/api/geocode.js +++ b/api/src/functions/geocode.js @@ -1,6 +1,7 @@ -const { requireSession } = require('./_lib/session'); -const { throttleGeocodeCall } = require('./_lib/rateLimit'); -const { readJsonBody, sendJson, methodNotAllowed } = require('./_lib/http'); +const { app } = require('@azure/functions'); +const { throttleGeocodeCall } = require('../../_lib/rateLimit'); +const { getClientPrincipal } = require('../../_lib/clientPrincipal'); +const { jsonResponse, methodNotAllowed, unauthorized } = require('../../_lib/http'); // Nominatim's usage policy allows ~1 request/second per app and requires an identifying // User-Agent. This function is the *only* place that ever talks to Nominatim — the browser @@ -9,16 +10,16 @@ const NOMINATIM_MIN_INTERVAL_MS = 1100; const MAX_ADDRESS_LENGTH = 300; const USER_AGENT = 'Praxium-Map-Admin/1.0 (contact: info@praxiumfoundation.com)'; -module.exports = async (req, res) => { - if (req.method !== 'POST') return methodNotAllowed(res, ['POST']); - if (!requireSession(req)) return sendJson(res, 401, { error: 'unauthorized' }); +async function geocodeHandler(request) { + if (request.method !== 'POST') return methodNotAllowed(['POST']); + if (!getClientPrincipal(request)) return unauthorized(); - const { address } = readJsonBody(req); + const { address } = await request.json().catch(() => ({})); if (typeof address !== 'string' || !address.trim()) { - return sendJson(res, 400, { error: 'address_required' }); + return jsonResponse(400, { error: 'address_required' }); } if (address.trim().length > MAX_ADDRESS_LENGTH) { - return sendJson(res, 400, { error: 'address_too_long' }); + return jsonResponse(400, { error: 'address_too_long' }); } await throttleGeocodeCall(NOMINATIM_MIN_INTERVAL_MS); @@ -27,11 +28,11 @@ module.exports = async (req, res) => { const url = `https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(address)}`; const resp = await fetch(url, { headers: { 'User-Agent': USER_AGENT } }); if (!resp.ok) { - return sendJson(res, 502, { error: 'geocode_service_error' }); + return jsonResponse(502, { error: 'geocode_service_error' }); } const results = await resp.json(); if (!Array.isArray(results) || results.length === 0) { - return sendJson(res, 404, { error: 'not_found' }); + return jsonResponse(404, { error: 'not_found' }); } const best = results[0]; const lat = Number(best.lat); @@ -44,15 +45,24 @@ module.exports = async (req, res) => { || lng < -180 || lng > 180 ) { - return sendJson(res, 502, { error: 'geocode_service_error' }); + return jsonResponse(502, { error: 'geocode_service_error' }); } - return sendJson(res, 200, { + return jsonResponse(200, { lat, lng, displayName: typeof best.display_name === 'string' ? best.display_name : address.trim(), }); } catch (err) { console.error('POST /api/geocode failed:', err); - return sendJson(res, 502, { error: 'geocode_service_error' }); + return jsonResponse(502, { error: 'geocode_service_error' }); } -}; +} + +app.http('geocode', { + methods: ['POST'], + route: 'geocode', + authLevel: 'anonymous', + handler: geocodeHandler, +}); + +module.exports = { geocodeHandler }; diff --git a/api/src/functions/gist.js b/api/src/functions/gist.js new file mode 100644 index 0000000..952acf9 --- /dev/null +++ b/api/src/functions/gist.js @@ -0,0 +1,30 @@ +const { app } = require('@azure/functions'); +const { fetchGist } = require('../../_lib/githubGist'); +const { readCategories } = require('../../_lib/categories'); +const { getClientPrincipal } = require('../../_lib/clientPrincipal'); +const { jsonResponse, methodNotAllowed, unauthorized } = require('../../_lib/http'); + +async function gistHandler(request) { + if (request.method !== 'GET') return methodNotAllowed(['GET']); + if (!getClientPrincipal(request)) return unauthorized(); + + try { + const [{ organizations, version }, categories] = await Promise.all([ + fetchGist(), + Promise.resolve(readCategories()), + ]); + return jsonResponse(200, { organizations, categories, version }); + } catch (err) { + console.error('GET /api/gist failed:', err); + return jsonResponse(502, { error: 'gist_fetch_failed' }); + } +} + +app.http('gist', { + methods: ['GET'], + route: 'gist', + authLevel: 'anonymous', // access is enforced by Static Web Apps routing, not a Functions key + handler: gistHandler, +}); + +module.exports = { gistHandler }; diff --git a/api/publish.js b/api/src/functions/publish.js similarity index 86% rename from api/publish.js rename to api/src/functions/publish.js index 1b33754..30493ce 100644 --- a/api/publish.js +++ b/api/src/functions/publish.js @@ -1,7 +1,8 @@ -const { requireSession } = require('./_lib/session'); -const { fetchGist, updateGist } = require('./_lib/githubGist'); -const { readCategories } = require('./_lib/categories'); -const { readJsonBody, sendJson, methodNotAllowed } = require('./_lib/http'); +const { app } = require('@azure/functions'); +const { fetchGist, updateGist } = require('../../_lib/githubGist'); +const { readCategories } = require('../../_lib/categories'); +const { getClientPrincipal } = require('../../_lib/clientPrincipal'); +const { jsonResponse, methodNotAllowed, unauthorized } = require('../../_lib/http'); const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const FIELD_LIMITS = Object.freeze({ @@ -188,16 +189,16 @@ function stripConnectionsTo(organizations, removedId) { }); } -module.exports = async (req, res) => { - if (req.method !== 'POST') return methodNotAllowed(res, ['POST']); - if (!requireSession(req)) return sendJson(res, 401, { error: 'unauthorized' }); +async function publishHandler(request) { + if (request.method !== 'POST') return methodNotAllowed(['POST']); + if (!getClientPrincipal(request)) return unauthorized(); - const { mode, org, orgId, expectedVersion } = readJsonBody(req); + const { mode, org, orgId, expectedVersion } = await request.json().catch(() => ({})); if (!['create', 'update', 'delete'].includes(mode)) { - return sendJson(res, 400, { error: 'invalid_mode' }); + return jsonResponse(400, { error: 'invalid_mode' }); } if (!expectedVersion) { - return sendJson(res, 400, { error: 'expected_version_required' }); + return jsonResponse(400, { error: 'expected_version_required' }); } let current; @@ -205,11 +206,11 @@ module.exports = async (req, res) => { current = await fetchGist(); } catch (err) { console.error('publish: failed to re-fetch gist:', err); - return sendJson(res, 502, { error: 'gist_fetch_failed' }); + return jsonResponse(502, { error: 'gist_fetch_failed' }); } if (current.version !== expectedVersion) { - return sendJson(res, 409, { + return jsonResponse(409, { error: 'conflict', currentVersion: current.version, message: 'Data changed since you loaded it — please reload and reapply your edit.', @@ -221,9 +222,9 @@ module.exports = async (req, res) => { let resultOrgId = null; if (mode === 'delete') { - if (!Number.isInteger(orgId)) return sendJson(res, 400, { error: 'organization_id_required' }); + if (!Number.isInteger(orgId)) return jsonResponse(400, { error: 'organization_id_required' }); const idx = organizations.findIndex((o) => o.id === orgId); - if (idx === -1) return sendJson(res, 404, { error: 'organization_not_found' }); + if (idx === -1) return jsonResponse(404, { error: 'organization_not_found' }); organizations.splice(idx, 1); stripConnectionsTo(organizations, orgId); } else { @@ -239,7 +240,7 @@ module.exports = async (req, res) => { } } if (errors.length) { - return sendJson(res, 400, { error: 'validation_failed', details: errors }); + return jsonResponse(400, { error: 'validation_failed', details: errors }); } if (mode === 'create') { @@ -254,7 +255,7 @@ module.exports = async (req, res) => { } else { // update const idx = organizations.findIndex((o) => o.id === orgId); - if (idx === -1) return sendJson(res, 404, { error: 'organization_not_found' }); + if (idx === -1) return jsonResponse(404, { error: 'organization_not_found' }); const previousConnections = organizations[idx].connections || []; const finalized = finalizeOrgFields(org, orgId, organizations[idx]); organizations[idx] = finalized; @@ -266,9 +267,18 @@ module.exports = async (req, res) => { try { const result = await updateGist(organizations); const savedOrg = resultOrgId === null ? null : result.organizations.find((o) => o.id === resultOrgId); - return sendJson(res, 200, { ok: true, org: savedOrg, version: result.version }); + return jsonResponse(200, { ok: true, org: savedOrg, version: result.version }); } catch (err) { console.error('publish: failed to update gist:', err); - return sendJson(res, 502, { error: 'gist_update_failed' }); + return jsonResponse(502, { error: 'gist_update_failed' }); } -}; +} + +app.http('publish', { + methods: ['POST'], + route: 'publish', + authLevel: 'anonymous', + handler: publishHandler, +}); + +module.exports = { publishHandler }; diff --git a/package.json b/package.json index 7de2eb1..33c4b24 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "electron/main.js", "scripts": { "start": "electron .", - "check": "node --check map.js && node --check network-data.js && node --check admin/admin.js && node --check admin/admin-auth.js && node --check admin-editor/app.js && node --check electron/main.js && node --check electron/preload.js", + "check": "node --check map.js && node --check network-data.js && node --check admin/admin.js && node --check admin-editor/app.js && node --check electron/main.js && node --check electron/preload.js && for f in api/_lib/*.js api/src/functions/*.js; do node --check \"$f\" || exit 1; done", "test": "node test/public-assets.test.js && node test/network-data.test.js && node test/api.test.js", "validate": "npm run check && npm test", "pack": "electron-builder --dir", diff --git a/staticwebapp.config.json b/staticwebapp.config.json new file mode 100644 index 0000000..a9cfec9 --- /dev/null +++ b/staticwebapp.config.json @@ -0,0 +1,41 @@ +{ + "routes": [ + { "route": "/", "redirect": "/admin", "statusCode": 302 }, + { + "route": "/admin/*", + "allowedRoles": ["authenticated"], + "headers": { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Content-Security-Policy": "default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: blob: https://server.arcgisonline.com https://cdnjs.cloudflare.com; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" + } + }, + { + "route": "/api/*", + "allowedRoles": ["authenticated"], + "headers": { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'" + } + } + ], + "responseOverrides": { + "401": { "redirect": "/.auth/login/aad", "statusCode": 302 } + }, + "auth": { + "identityProviders": { + "azureActiveDirectory": { + "registration": { + "openIdIssuer": "https://login.microsoftonline.com/REPLACE_WITH_PRAXIUM_TENANT_ID/v2.0", + "clientIdSettingName": "AZURE_CLIENT_ID", + "clientSecretSettingName": "AZURE_CLIENT_SECRET" + } + } + } + } +} diff --git a/test/api.test.js b/test/api.test.js index 2e40929..4955702 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -1,7 +1,13 @@ -// Lightweight functional test for the /api handlers, using an in-memory fake Gist backend -// (no real network calls to GitHub). Run with: node test/api.test.js -process.env.SESSION_SECRET = 'test-secret-value-not-real-at-least-32-bytes'; -process.env.ADMIN_PASSPHRASE = 'correct-horse-battery-staple'; +// Lightweight functional test for the /api Azure Functions, using an in-memory fake Gist +// backend (no real network calls to GitHub). Run with: node test/api.test.js +// +// Authentication itself (Microsoft Entra ID, restricted to an explicit board-member +// allow-list) is enforced by Azure Static Web Apps before a request ever reaches these +// functions — there's no local login/passphrase/rate-limiting logic left to test here. +// What IS tested: that each function still checks for a valid x-ms-client-principal +// (defense-in-depth, see api/_lib/clientPrincipal.js) and that every validation, +// optimistic-concurrency, reciprocal-connection, and cascade-delete behavior in +// publish.js carries over unchanged from the pre-migration Vercel version. process.env.GIST_ID = 'fake-gist-id'; process.env.GITHUB_PAT = 'fake-pat-not-real'; @@ -50,152 +56,81 @@ global.fetch = async (url, opts = {}) => { throw new Error('Unexpected fetch to ' + u); }; -function mockReqRes({ method = 'GET', body = {}, cookie = '', ip = '1.2.3.4' } = {}) { - let statusCode = 200; - let headers = {}; - let ended = ''; - const req = { method, body, headers: { cookie }, socket: { remoteAddress: ip } }; - const res = { - status(code) { statusCode = code; return res; }, - setHeader(k, v) { headers[k] = v; return res; }, - end(payload) { ended = payload; return res; }, - _result() { return { statusCode, headers, body: ended ? JSON.parse(ended) : null }; }, +// Builds a request matching Azure Functions v4's HttpRequest shape closely enough for our +// handlers: a `method`, a `headers.get(name)` accessor, and an async `json()` body reader. +function mockRequest({ method = 'GET', body = {}, principal = AUTHENTICATED_PRINCIPAL } = {}) { + const headerMap = new Map(); + if (principal) { + headerMap.set('x-ms-client-principal', Buffer.from(JSON.stringify(principal)).toString('base64')); + } + return { + method, + headers: { get: (name) => headerMap.get(String(name).toLowerCase()) ?? null }, + json: async () => body, }; - return { req, res }; } -function extractCookieValue(setCookieHeader) { - const match = setCookieHeader.match(/praxium_admin_session=([^;]+)/); - return match ? decodeURIComponent(match[1]) : null; -} +const AUTHENTICATED_PRINCIPAL = { + identityProvider: 'aad', + userId: 'test-user-id', + userDetails: 'board.member@praxiumfoundation.com', + userRoles: ['anonymous', 'authenticated'], +}; function assert(cond, message) { if (!cond) throw new Error('Assertion failed: ' + message); } async function run() { - const auth = require(path.join(REPO, 'api/auth.js')); - const gist = require(path.join(REPO, 'api/gist.js')); - const geocode = require(path.join(REPO, 'api/geocode.js')); - const publish = require(path.join(REPO, 'api/publish.js')); - const logout = require(path.join(REPO, 'api/logout.js')); - - console.log('--- wrong passphrase -> 401 ---'); - { - const { req, res } = mockReqRes({ method: 'POST', body: { passphrase: 'wrong' } }); - await auth(req, res); - assert(res._result().statusCode === 401, 'expected 401 for wrong passphrase'); - } - - console.log('--- correct passphrase -> 200 + session cookie ---'); - let sessionCookie; - { - const { req, res } = mockReqRes({ method: 'POST', body: { passphrase: 'correct-horse-battery-staple' } }); - await auth(req, res); - const r = res._result(); - assert(r.statusCode === 200, 'expected 200 for correct passphrase'); - assert(r.headers['Cache-Control'] === 'no-store', 'expected private API responses not to be cached'); - assert( - r.headers['X-Content-Type-Options'] === 'nosniff', - 'expected API responses to disable content-type sniffing', - ); - sessionCookie = extractCookieValue(r.headers['Set-Cookie']); - assert(sessionCookie, 'expected a session cookie to be set'); - } + const { gistHandler } = require(path.join(REPO, 'api/src/functions/gist.js')); + const { geocodeHandler } = require(path.join(REPO, 'api/src/functions/geocode.js')); + const { publishHandler } = require(path.join(REPO, 'api/src/functions/publish.js')); - console.log('--- rate limit kicks in after repeated wrong attempts ---'); + console.log('--- GET /api/gist without a client principal -> 401 ---'); { - let last; - for (let i = 0; i < 5; i++) { - const { req, res } = mockReqRes({ - method: 'POST', - body: { passphrase: 'nope' + i }, - ip: '5.6.7.8', - }); - await auth(req, res); - last = res._result(); - assert(last.statusCode === 401, `expected failed attempt ${i + 1} to return 401`); - } - const { req, res } = mockReqRes({ - method: 'POST', - body: { passphrase: 'one-too-many' }, - ip: '5.6.7.8', - }); - await auth(req, res); - last = res._result(); - assert(last.statusCode === 429, 'expected the request after 5 failures to be rate limited'); + const res = await gistHandler(mockRequest({ method: 'GET', principal: null })); + assert(res.status === 401, 'expected 401 without an authenticated client principal'); } - console.log('--- successful login clears prior failed attempts ---'); + console.log('--- GET /api/gist with an unauthenticated principal (missing "authenticated" role) -> 401 ---'); { - const ip = '9.8.7.6'; - for (let i = 0; i < 4; i++) { - const { req, res } = mockReqRes({ - method: 'POST', - body: { passphrase: 'wrong-' + i }, - ip, - }); - await auth(req, res); - assert(res._result().statusCode === 401, 'expected pre-success failure to return 401'); - } - { - const { req, res } = mockReqRes({ - method: 'POST', - body: { passphrase: 'correct-horse-battery-staple' }, - ip, - }); - await auth(req, res); - assert(res._result().statusCode === 200, 'expected success before limit'); - } - { - const { req, res } = mockReqRes({ - method: 'POST', - body: { passphrase: 'wrong-after-success' }, - ip, - }); - await auth(req, res); - assert(res._result().statusCode === 401, 'expected cleared failure bucket after success'); - } + const res = await gistHandler(mockRequest({ + method: 'GET', + principal: { identityProvider: 'aad', userId: 'x', userDetails: 'x@example.com', userRoles: ['anonymous'] }, + })); + assert(res.status === 401, 'expected 401 when the authenticated role is missing'); } - console.log('--- GET /api/gist without session -> 401 ---'); + console.log('--- GET /api/gist with a valid client principal -> 200 ---'); + let currentVersion; { - const { req, res } = mockReqRes({ method: 'GET' }); - await gist(req, res); - assert(res._result().statusCode === 401, 'expected 401 without session'); + const res = await gistHandler(mockRequest({ method: 'GET' })); + assert(res.status === 200, 'expected 200 with a valid client principal'); + assert(Array.isArray(res.jsonBody.organizations) && res.jsonBody.organizations.length === 2, 'expected 2 orgs'); + assert(Array.isArray(res.jsonBody.categories) && res.jsonBody.categories.length > 0, 'expected categories list'); + assert(res.jsonBody.version === 'revision-0', 'expected the immutable Gist revision as version'); + assert(res.headers['Cache-Control'] === 'no-store', 'expected private API responses not to be cached'); + assert(res.headers['X-Content-Type-Options'] === 'nosniff', 'expected content-type sniffing disabled'); + currentVersion = res.jsonBody.version; } - console.log('--- GET /api/gist with session -> 200 ---'); - let currentVersion; + console.log('--- geocode rejects overlong addresses before network access ---'); { - const { req, res } = mockReqRes({ method: 'GET', cookie: `praxium_admin_session=${sessionCookie}` }); - await gist(req, res); - const r = res._result(); - assert(r.statusCode === 200, 'expected 200 with session'); - assert(Array.isArray(r.body.organizations) && r.body.organizations.length === 2, 'expected 2 orgs'); - assert(Array.isArray(r.body.categories) && r.body.categories.length > 0, 'expected categories list'); - assert(r.body.version === 'revision-0', 'expected the immutable Gist revision as version'); - currentVersion = r.body.version; + const res = await geocodeHandler(mockRequest({ method: 'POST', body: { address: 'x'.repeat(301) } })); + assert(res.status === 400, 'expected an overlong address to be rejected'); + assert(res.jsonBody.error === 'address_too_long', 'expected address_too_long error'); } - console.log('--- geocode rejects overlong addresses before network access ---'); + console.log('--- geocode without a client principal -> 401 ---'); { - const { req, res } = mockReqRes({ - method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, - body: { address: 'x'.repeat(301) }, - }); - await geocode(req, res); - const r = res._result(); - assert(r.statusCode === 400, 'expected an overlong address to be rejected'); - assert(r.body.error === 'address_too_long', 'expected address_too_long error'); + const res = await geocodeHandler(mockRequest({ method: 'POST', body: { address: 'Vancouver' }, principal: null })); + assert(res.status === 401, 'expected 401 without an authenticated client principal'); } console.log('--- publish create with stale version -> 409 ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'create', expectedVersion: 'stale-version-value', @@ -206,17 +141,15 @@ async function run() { contact: { website: 'https://three.example' }, connections: [1], }, }, - }); - await publish(req, res); - assert(res._result().statusCode === 409, 'expected 409 conflict for stale version'); + })); + assert(res.status === 409, 'expected 409 conflict for stale version'); } console.log('--- publish create with valid version applies reciprocal connections ---'); let newOrgId; { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'create', expectedVersion: currentVersion, @@ -227,44 +160,49 @@ async function run() { contact: { website: 'https://three.example' }, connections: [1], }, }, - }); - await publish(req, res); - const r = res._result(); - assert(r.statusCode === 200, 'expected 200 for valid create'); - newOrgId = r.body.org.id; + })); + assert(res.status === 200, 'expected 200 for valid create'); + newOrgId = res.jsonBody.org.id; assert(newOrgId === 3, 'expected assigned id 3, got ' + newOrgId); - assert(r.body.org.country === 'Canada', 'expected country to be persisted'); + assert(res.jsonBody.org.country === 'Canada', 'expected country to be persisted'); assert( - r.body.org.relationship === 'A shared learning and community initiative.', + res.jsonBody.org.relationship === 'A shared learning and community initiative.', 'expected relationship context to be persisted', ); - currentVersion = r.body.version; + currentVersion = res.jsonBody.version; const orgs = JSON.parse(fakeGistFile); const orgOne = orgs.find((o) => o.id === 1); assert(orgOne.connections.includes(3), 'expected org 1 to reciprocally link to new org 3'); } + console.log('--- publish without a client principal -> 401 ---'); + { + const res = await publishHandler(mockRequest({ + method: 'POST', + principal: null, + body: { mode: 'delete', orgId: newOrgId, expectedVersion: currentVersion }, + })); + assert(res.status === 401, 'expected 401 without an authenticated client principal'); + } + console.log('--- validation failure (missing website) -> 400 ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'create', expectedVersion: currentVersion, org: { name: 'Bad Org', location: 'Nowhere', coordinates: [0, 0], category: 'Health', description: 'x', contact: {}, connections: [] }, }, - }); - await publish(req, res); - assert(res._result().statusCode === 400, 'expected 400 validation failure'); + })); + assert(res.status === 400, 'expected 400 validation failure'); } console.log('--- validation failure (overlong fields) -> 400 ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'create', expectedVersion: currentVersion, @@ -278,21 +216,18 @@ async function run() { connections: [], }, }, - }); - await publish(req, res); - const r = res._result(); - assert(r.statusCode === 400, 'expected 400 for an overlong field'); + })); + assert(res.status === 400, 'expected 400 for an overlong field'); assert( - r.body.details.some((detail) => detail.includes('name must be 200')), + res.jsonBody.details.some((detail) => detail.includes('name must be 200')), 'expected the name length limit in validation details', ); } console.log('--- validation failure (unknown connection) -> 400 ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'create', expectedVersion: currentVersion, @@ -302,21 +237,18 @@ async function run() { contact: { website: 'https://unknown.example' }, connections: [999], }, }, - }); - await publish(req, res); - const r = res._result(); - assert(r.statusCode === 400, 'expected 400 for an unknown connection ID'); + })); + assert(res.status === 400, 'expected 400 for an unknown connection ID'); assert( - r.body.details.some((detail) => detail.includes('999')), + res.jsonBody.details.some((detail) => detail.includes('999')), 'expected the unknown connection ID in the validation details', ); } console.log('--- update removes a connection -> reciprocal removal applies ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'update', orgId: newOrgId, @@ -326,16 +258,14 @@ async function run() { category: 'Health', description: 'new org updated', contact: { website: 'https://three.example' }, connections: [], }, }, - }); - await publish(req, res); - const r = res._result(); - assert(r.statusCode === 200, 'expected 200 for update'); - assert(r.body.org.country === 'Canada', 'older update payloads should preserve country'); + })); + assert(res.status === 200, 'expected 200 for update'); + assert(res.jsonBody.org.country === 'Canada', 'older update payloads should preserve country'); assert( - r.body.org.relationship === 'A shared learning and community initiative.', + res.jsonBody.org.relationship === 'A shared learning and community initiative.', 'older update payloads should preserve relationship context', ); - currentVersion = r.body.version; + currentVersion = res.jsonBody.version; const orgs = JSON.parse(fakeGistFile); const orgOne = orgs.find((o) => o.id === 1); @@ -344,13 +274,11 @@ async function run() { console.log('--- delete cascades connection removal ---'); { - const { req, res } = mockReqRes({ + const res = await publishHandler(mockRequest({ method: 'POST', - cookie: `praxium_admin_session=${sessionCookie}`, body: { mode: 'delete', orgId: 2, expectedVersion: currentVersion }, - }); - await publish(req, res); - assert(res._result().statusCode === 200, 'expected 200 for delete'); + })); + assert(res.status === 200, 'expected 200 for delete'); const orgs = JSON.parse(fakeGistFile); assert(!orgs.find((o) => o.id === 2), 'expected org 2 to be deleted'); @@ -358,13 +286,6 @@ async function run() { assert(!orgOne.connections.includes(2), 'expected org 1 to no longer reference deleted org 2'); } - console.log('--- logout clears cookie ---'); - { - const { req, res } = mockReqRes({ method: 'POST' }); - await logout(req, res); - assert(res._result().statusCode === 200, 'expected 200 for logout'); - } - console.log('\nALL TESTS PASSED'); } From cf5441beadd53d027194bceff4b7743f96fa48a2 Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sat, 3 Oct 2026 10:48:31 -0500 Subject: [PATCH 02/13] chore(azure-migration): fill in the Praxium Entra tenant ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the REPLACE_WITH_PRAXIUM_TENANT_ID placeholder with the real tenant ID now that the staging Entra app registration (Praxium_Map_Admin_Staging) exists. The tenant ID is shared across both the staging and production app registrations (same Entra tenant, different client ID/secret per app) — this one value covers both environments. The client ID/secret themselves are never committed; they're referenced by Application Settings name (AZURE_CLIENT_ID/AZURE_CLIENT_SECRET) and supplied per-environment in Azure during Phase 3/4. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- staticwebapp.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/staticwebapp.config.json b/staticwebapp.config.json index a9cfec9..62423df 100644 --- a/staticwebapp.config.json +++ b/staticwebapp.config.json @@ -31,7 +31,7 @@ "identityProviders": { "azureActiveDirectory": { "registration": { - "openIdIssuer": "https://login.microsoftonline.com/REPLACE_WITH_PRAXIUM_TENANT_ID/v2.0", + "openIdIssuer": "https://login.microsoftonline.com/5937888e-e143-44f6-92cc-3080289b15c9/v2.0", "clientIdSettingName": "AZURE_CLIENT_ID", "clientSecretSettingName": "AZURE_CLIENT_SECRET" } From 01d15582056ffcffbae2a8ccc8becc0fa93c95d0 Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sat, 3 Oct 2026 13:13:06 -0500 Subject: [PATCH 03/13] ci: add Azure Static Web Apps workflow file on-behalf-of: @Azure opensource@microsoft.com --- ...tic-web-apps-wonderful-field-0cd14c010.yml | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml diff --git a/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml b/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml new file mode 100644 index 0000000..25111d9 --- /dev/null +++ b/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml @@ -0,0 +1,46 @@ +name: Azure Static Web Apps CI/CD + +on: + push: + branches: + - azure-migration + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - azure-migration + +jobs: + build_and_deploy_job: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + name: Build and Deploy Job + steps: + - uses: actions/checkout@v3 + with: + submodules: true + lfs: false + - name: Build And Deploy + id: builddeploy + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_WONDERFUL_FIELD_0CD14C010 }} + repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) + action: "upload" + ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### + # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig + app_location: "/" # App source code path + api_location: "api" # Api source code path - optional + output_location: "" # Built app content directory - optional + ###### End of Repository/Build Configurations ###### + + close_pull_request_job: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + name: Close Pull Request Job + steps: + - name: Close Pull Request + id: closepullrequest + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_WONDERFUL_FIELD_0CD14C010 }} + action: "close" From 26ceef7982313e69b826e5f388375c2bb6eef055 Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sat, 3 Oct 2026 13:17:39 -0500 Subject: [PATCH 04/13] fix(azure-migration): skip Oryx's frontend build (static site, no bundler) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staging deployment failed on its first run: Oryx auto-detected Node.js via the root package.json (the Electron desktop packaging manifest, unrelated to the deployed site) and looked for a "build" or "build:azure" npm script that doesn't exist, since this is a plain static HTML/CSS/JS site with no bundler or build step at all. skip_app_build: true tells the deploy action not to attempt a frontend build. This only affects app_location — the api_location (api/) Azure Functions app still gets its normal Oryx build/npm install, so @azure/functions is actually installed and present at runtime. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- .../azure-static-web-apps-wonderful-field-0cd14c010.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml b/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml index 25111d9..875319b 100644 --- a/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml +++ b/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml @@ -31,6 +31,12 @@ jobs: app_location: "/" # App source code path api_location: "api" # Api source code path - optional output_location: "" # Built app content directory - optional + skip_app_build: true # No build step needed — plain static HTML/CSS/JS, no bundler. + # Without this, Oryx auto-detects Node.js (via the root package.json, which is + # actually just the Electron desktop packaging manifest, unrelated to the deployed + # site) and fails looking for a "build" npm script that doesn't exist. This only + # skips the *frontend* build — the api/ Functions app still gets its normal Oryx + # build/npm install so @azure/functions is actually present at runtime. ###### End of Repository/Build Configurations ###### close_pull_request_job: From 6434817d9364f3997f87f31edf403cc4ad83932a Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sat, 3 Oct 2026 13:19:08 -0500 Subject: [PATCH 05/13] fix(azure-migration): add a root index.html for SWA's deploy validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployment failed again after the skip_app_build fix: "Failed to find a default file in the app artifacts folder (/). Valid default files: index.html,Index.html." Azure's deploy validator requires an index.html to physically exist at the app_location root — this is separate from and in addition to staticwebapp.config.json's own "/" -> "/admin" redirect route rule, which only governs runtime routing, not the build-time artifact check. Added a minimal placeholder: a client-side meta-refresh to /admin, for defense-in-depth in the unlikely case the server-side redirect rule is ever bypassed. In normal operation nobody should actually see this page — the staticwebapp.config.json route rule redirects before this file would be served. Confirmed this doesn't conflict with the GitHub Pages workflow, which builds its own _site/index.html explicitly from organization-network-map.html and never reads any root-level file from the actual repo checkout. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- index.html | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 index.html diff --git a/index.html b/index.html new file mode 100644 index 0000000..7cf3cf8 --- /dev/null +++ b/index.html @@ -0,0 +1,21 @@ + + + + + + Praxium Map Admin + + + +

      Redirecting to the admin login…

      + + From 3735400659cbfc63a2112d84234168036cbb8d35 Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sat, 3 Oct 2026 13:56:21 -0500 Subject: [PATCH 06/13] debug(azure-migration): temporarily remove the 401->login redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staging sign-in is looping: Microsoft's login completes, but /.auth/me shows clientPrincipal: null afterward, meaning the token exchange on the callback is failing. We can't see *why* because our own responseOverrides.401 rule immediately redirects any 401 back to /.auth/login/aad, masking whatever real error SWA/Entra is actually returning and turning it into a loop instead of a visible error. Removing this temporarily so the next sign-in attempt surfaces the actual error (likely a clear Microsoft AADSTS error page, or a real 401 response body with detail). Re-add once the root cause is found and fixed — this rule is good production UX, it's only in the way for debugging right now. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- staticwebapp.config.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/staticwebapp.config.json b/staticwebapp.config.json index 62423df..54d04cc 100644 --- a/staticwebapp.config.json +++ b/staticwebapp.config.json @@ -24,9 +24,6 @@ } } ], - "responseOverrides": { - "401": { "redirect": "/.auth/login/aad", "statusCode": 302 } - }, "auth": { "identityProviders": { "azureActiveDirectory": { From 5ab6d8d89e7c5cca36a0e83e6046a46ce789e2e4 Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sat, 3 Oct 2026 16:46:33 -0500 Subject: [PATCH 07/13] debug(azure-migration): temporarily surface the real error from /api/gist Managed Functions logs aren't visible without Application Insights wired up, and gist_fetch_failed alone isn't enough to diagnose why the (already curl-verified) GITHUB_PAT is still failing in Azure. Staging-only; revert once root-caused. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- api/src/functions/gist.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/src/functions/gist.js b/api/src/functions/gist.js index 952acf9..9f53a39 100644 --- a/api/src/functions/gist.js +++ b/api/src/functions/gist.js @@ -16,7 +16,10 @@ async function gistHandler(request) { return jsonResponse(200, { organizations, categories, version }); } catch (err) { console.error('GET /api/gist failed:', err); - return jsonResponse(502, { error: 'gist_fetch_failed' }); + // TEMP DEBUG (staging only): surface the real error since managed Functions + // logs aren't visible without Application Insights wired up. Revert once + // gist_fetch_failed is root-caused. + return jsonResponse(502, { error: 'gist_fetch_failed', debug: err.message }); } } From 55baecc0c06c3c35f4c258a28c61ebb096bdf21d Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sat, 3 Oct 2026 16:59:52 -0500 Subject: [PATCH 08/13] fix(azure-migration): stage categories.json inside api/ for the Functions deploy The Static Web App only deploys api/, so the repo-root categories.json was missing at runtime and readCategories() threw before the Gist fetch ran. The workflow and npm pretest now copy it into api/ (gitignored, never committed) so the single source of truth stays at the repo root. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- .../azure-static-web-apps-wonderful-field-0cd14c010.yml | 2 ++ .gitignore | 3 +++ api/_lib/categories.js | 4 ++-- package.json | 1 + 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml b/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml index 875319b..e24f61e 100644 --- a/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml +++ b/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml @@ -19,6 +19,8 @@ jobs: with: submodules: true lfs: false + - name: Stage categories.json for the API + run: cp categories.json api/categories.json - name: Build And Deploy id: builddeploy uses: Azure/static-web-apps-deploy@v1 diff --git a/.gitignore b/.gitignore index 3b493bc..a03f29b 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ yarn-error.log dist/ out/ +# Staged copy of the repo-root categories.json for the Azure Functions deploy (see workflow + pretest) +api/categories.json + # Local testing *.log .tmp-chrome/ diff --git a/api/_lib/categories.js b/api/_lib/categories.js index 57e8525..2090e0f 100644 --- a/api/_lib/categories.js +++ b/api/_lib/categories.js @@ -1,9 +1,9 @@ -// Reads the repo's canonical categories.json (single source of truth, see Phase 1). +// Reads categories.json staged into api/ by the deploy workflow and npm pretest (copied from the repo root, never committed). const fs = require('fs'); const path = require('path'); function readCategories() { - const filePath = path.join(__dirname, '..', '..', 'categories.json'); + const filePath = path.join(__dirname, '..', 'categories.json'); const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } diff --git a/package.json b/package.json index 33c4b24..ef24d48 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "electron .", "check": "node --check map.js && node --check network-data.js && node --check admin/admin.js && node --check admin-editor/app.js && node --check electron/main.js && node --check electron/preload.js && for f in api/_lib/*.js api/src/functions/*.js; do node --check \"$f\" || exit 1; done", + "pretest": "node -e \"require('fs').copyFileSync('categories.json', 'api/categories.json')\"", "test": "node test/public-assets.test.js && node test/network-data.test.js && node test/api.test.js", "validate": "npm run check && npm test", "pack": "electron-builder --dir", From e65a414a881394e4fbe5c9abec0cf372a4336602 Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sat, 3 Oct 2026 17:07:53 -0500 Subject: [PATCH 09/13] fix(azure-migration): remove temporary gist debug output and restore the 401 sign-in redirect The gist_fetch_failed root cause is fixed, so the debug error field is reverted and unauthenticated requests go back to Entra sign-in. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- api/src/functions/gist.js | 5 +---- staticwebapp.config.json | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/functions/gist.js b/api/src/functions/gist.js index 9f53a39..952acf9 100644 --- a/api/src/functions/gist.js +++ b/api/src/functions/gist.js @@ -16,10 +16,7 @@ async function gistHandler(request) { return jsonResponse(200, { organizations, categories, version }); } catch (err) { console.error('GET /api/gist failed:', err); - // TEMP DEBUG (staging only): surface the real error since managed Functions - // logs aren't visible without Application Insights wired up. Revert once - // gist_fetch_failed is root-caused. - return jsonResponse(502, { error: 'gist_fetch_failed', debug: err.message }); + return jsonResponse(502, { error: 'gist_fetch_failed' }); } } diff --git a/staticwebapp.config.json b/staticwebapp.config.json index 54d04cc..62423df 100644 --- a/staticwebapp.config.json +++ b/staticwebapp.config.json @@ -24,6 +24,9 @@ } } ], + "responseOverrides": { + "401": { "redirect": "/.auth/login/aad", "statusCode": 302 } + }, "auth": { "identityProviders": { "azureActiveDirectory": { From 4859b7f1bfb84c400444735b4a829ea2137a0a8e Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sat, 3 Oct 2026 17:11:48 -0500 Subject: [PATCH 10/13] fix(admin): show the Gist's updated_at as "Last updated" instead of the revision id The concurrency token is a Git revision SHA, which new Date() cannot parse, so the label read "Invalid Date". This bug already exists on main. The version token is unchanged; the response gains an additive updatedAt field used only for display. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- admin/admin.js | 2 +- api/_lib/githubGist.js | 2 +- api/src/functions/gist.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/admin/admin.js b/admin/admin.js index 184508e..a4a4f03 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -46,7 +46,7 @@ async function loadData() { organizations = data.organizations; categories = data.categories; currentVersion = data.version; - lastUpdatedEl.textContent = `Last updated: ${new Date(currentVersion).toLocaleString()}`; + lastUpdatedEl.textContent = `Last updated: ${new Date(data.updatedAt).toLocaleString()}`; renderOrgList(); } diff --git a/api/_lib/githubGist.js b/api/_lib/githubGist.js index 3cd8829..5ffc048 100644 --- a/api/_lib/githubGist.js +++ b/api/_lib/githubGist.js @@ -70,7 +70,7 @@ async function fetchGist() { throw new Error('Gist file content must be a JSON array of organizations'); } - return { organizations, version: getGistVersion(gist), raw: content }; + return { organizations, version: getGistVersion(gist), updatedAt: gist.updated_at, raw: content }; } // Overwrites the Gist's data file with a new array of organizations. diff --git a/api/src/functions/gist.js b/api/src/functions/gist.js index 952acf9..ae6b4fc 100644 --- a/api/src/functions/gist.js +++ b/api/src/functions/gist.js @@ -9,11 +9,11 @@ async function gistHandler(request) { if (!getClientPrincipal(request)) return unauthorized(); try { - const [{ organizations, version }, categories] = await Promise.all([ + const [{ organizations, version, updatedAt }, categories] = await Promise.all([ fetchGist(), Promise.resolve(readCategories()), ]); - return jsonResponse(200, { organizations, categories, version }); + return jsonResponse(200, { organizations, categories, version, updatedAt }); } catch (err) { console.error('GET /api/gist failed:', err); return jsonResponse(502, { error: 'gist_fetch_failed' }); From aadaa6cc532565d7c66602a79eb0feca3632d0d2 Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sun, 4 Oct 2026 15:48:56 -0500 Subject: [PATCH 11/13] ci(azure-migration): add production Static Web Apps deploy on push to main Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- .../workflows/azure-static-web-apps-prod.yml | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/azure-static-web-apps-prod.yml diff --git a/.github/workflows/azure-static-web-apps-prod.yml b/.github/workflows/azure-static-web-apps-prod.yml new file mode 100644 index 0000000..5ba04c2 --- /dev/null +++ b/.github/workflows/azure-static-web-apps-prod.yml @@ -0,0 +1,31 @@ +name: Azure Static Web Apps CI/CD (production) + +on: + push: + branches: + - main + +concurrency: + group: azure-swa-prod + cancel-in-progress: false + +jobs: + build_and_deploy_job: + runs-on: ubuntu-latest + name: Build and Deploy (production) + steps: + - uses: actions/checkout@v3 + with: + submodules: true + lfs: false + - name: Stage categories.json for the API + run: cp categories.json api/categories.json + - name: Build And Deploy + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_PROD }} + action: "upload" + app_location: "/" + api_location: "api" + output_location: "" + skip_app_build: true # static site; api/ is still built by Oryx From 8480f27fda031dad07a7b121b77a7edc6b3cd394 Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sun, 4 Oct 2026 16:19:47 -0500 Subject: [PATCH 12/13] ci(azure-migration): use one Static Web App with main->Production and staging->branch environment Drops the separate production resource/workflow and the pull_request preview triggers, since new environments copy Production's settings (including the live GITHUB_PAT). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- .../workflows/azure-static-web-apps-prod.yml | 31 ------- ...tic-web-apps-wonderful-field-0cd14c010.yml | 91 ++++++++----------- 2 files changed, 37 insertions(+), 85 deletions(-) delete mode 100644 .github/workflows/azure-static-web-apps-prod.yml diff --git a/.github/workflows/azure-static-web-apps-prod.yml b/.github/workflows/azure-static-web-apps-prod.yml deleted file mode 100644 index 5ba04c2..0000000 --- a/.github/workflows/azure-static-web-apps-prod.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Azure Static Web Apps CI/CD (production) - -on: - push: - branches: - - main - -concurrency: - group: azure-swa-prod - cancel-in-progress: false - -jobs: - build_and_deploy_job: - runs-on: ubuntu-latest - name: Build and Deploy (production) - steps: - - uses: actions/checkout@v3 - with: - submodules: true - lfs: false - - name: Stage categories.json for the API - run: cp categories.json api/categories.json - - name: Build And Deploy - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_PROD }} - action: "upload" - app_location: "/" - api_location: "api" - output_location: "" - skip_app_build: true # static site; api/ is still built by Oryx diff --git a/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml b/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml index e24f61e..531ff03 100644 --- a/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml +++ b/.github/workflows/azure-static-web-apps-wonderful-field-0cd14c010.yml @@ -1,54 +1,37 @@ -name: Azure Static Web Apps CI/CD - -on: - push: - branches: - - azure-migration - pull_request: - types: [opened, synchronize, reopened, closed] - branches: - - azure-migration - -jobs: - build_and_deploy_job: - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') - runs-on: ubuntu-latest - name: Build and Deploy Job - steps: - - uses: actions/checkout@v3 - with: - submodules: true - lfs: false - - name: Stage categories.json for the API - run: cp categories.json api/categories.json - - name: Build And Deploy - id: builddeploy - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_WONDERFUL_FIELD_0CD14C010 }} - repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) - action: "upload" - ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### - # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig - app_location: "/" # App source code path - api_location: "api" # Api source code path - optional - output_location: "" # Built app content directory - optional - skip_app_build: true # No build step needed — plain static HTML/CSS/JS, no bundler. - # Without this, Oryx auto-detects Node.js (via the root package.json, which is - # actually just the Electron desktop packaging manifest, unrelated to the deployed - # site) and fails looking for a "build" npm script that doesn't exist. This only - # skips the *frontend* build — the api/ Functions app still gets its normal Oryx - # build/npm install so @azure/functions is actually present at runtime. - ###### End of Repository/Build Configurations ###### - - close_pull_request_job: - if: github.event_name == 'pull_request' && github.event.action == 'closed' - runs-on: ubuntu-latest - name: Close Pull Request Job - steps: - - name: Close Pull Request - id: closepullrequest - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_WONDERFUL_FIELD_0CD14C010 }} - action: "close" +name: Azure Static Web Apps CI/CD + +# main -> Production environment; staging -> "staging" branch environment. +# No pull_request trigger: new environments copy Production's settings (incl. the live GITHUB_PAT). +on: + push: + branches: + - main + - staging + +concurrency: + group: azure-swa-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + build_and_deploy_job: + runs-on: ubuntu-latest + name: Build and Deploy Job + steps: + - uses: actions/checkout@v3 + with: + submodules: true + lfs: false + - name: Stage categories.json for the API + run: cp categories.json api/categories.json + - name: Build And Deploy + id: builddeploy + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_WONDERFUL_FIELD_0CD14C010 }} + repo_token: ${{ secrets.GITHUB_TOKEN }} + action: "upload" + production_branch: "main" + app_location: "/" + api_location: "api" + output_location: "" + skip_app_build: true # static site; api/ is still built by Oryx From eef2970f3ec8c30618f2679a39e5d86a9ba5dfe2 Mon Sep 17 00:00:00 2001 From: Hezekiah Jenkins Date: Sun, 4 Oct 2026 17:03:44 -0500 Subject: [PATCH 13/13] ci: stage categories.json into api/ before running the API tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 385689b..5a46c89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,9 @@ jobs: run: node --check admin/admin.js - name: Run api/ unit tests (mocked Gist backend) - run: node test/api.test.js + run: | + cp categories.json api/categories.json + node test/api.test.js - name: Run public map data tests run: |