Skip to content

Move board admin to Azure Static Web Apps with Entra ID sign-in - #21

Closed
selectdimensions wants to merge 13 commits into
mainfrom
azure-migration
Closed

selectdimensions wants to merge 13 commits into
mainfrom
azure-migration

Conversation

@selectdimensions

Copy link
Copy Markdown
Collaborator

Summary

  • Ports /api from Vercel functions to Azure Functions v4 (managed by Azure Static Web Apps); Gist read/publish logic, validation, reciprocal connections, cascade delete and 409 concurrency are unchanged.
  • Replaces the shared passphrase with Microsoft Entra ID sign-in (/.auth/login/aad), restricted to assigned board members via "Assignment required" on the Enterprise Application. Custom session/passphrase code is removed.
  • One Static Web App: main deploys to Production, staging deploys to a branch environment. No PR-preview environments (they would copy Production's live secrets).
  • Fixes "Last updated: Invalid Date" by displaying the Gist's updated_at.

Vercel is unaffected: it now builds from vercel-fallback and stays live as the fallback until decommissioned.

Test plan

  • npm run validate passes
  • Staging: sign-in, non-assigned account rejected, create/edit/connect/disconnect/delete, two-tab 409 conflict, geocode
  • After merge: Production sign-in and organization list load on https://wonderful-field-0cd14c010.1.azurestaticapps.net/admin
  • One harmless production edit, then revert it

🤖 Generated with Claude Code

https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v

PraxiumLearning03 and others added 12 commits October 2, 2026 22:48
…a ID

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v
on-behalf-of: @Azure opensource@microsoft.com
…dler)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v
…ions 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v
…he 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v
… 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KR2ZSHK8Skmyd1zyTaw7v
Copilot AI balanced review requested due to automatic review settings October 4, 2026 21:55
@vercel

vercel Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
interactive-map Ready Ready Preview Oct 4, 2026 10:03pm UTC

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Clean-checkout validation, expired-session recovery, and null request-body handling have unresolved regressions.

Review effort: Balanced
Findings: 3 Medium severity

Open (3)
What changed in this PR

Migrates the board admin to Azure Static Web Apps and Azure Functions, replacing shared-passphrase authentication with Microsoft Entra ID.

Changes:

  • Ports API handlers and tests to Azure Functions v4.
  • Adds protected admin routing and branch-based Azure deployment.
  • Displays the Gist update timestamp instead of its revision identifier.
File Description
test/​api.test.js Adapts API tests to Azure requests and principals.
staticwebapp.config.json Configures authentication, routes, and security headers.
package.json Updates validation and stages test categories.
index.html Adds the root admin redirect fallback.
api/​src/​functions/​publish.js Ports publishing to Azure Functions.
api/​src/​functions/​gist.js Adds authenticated Gist retrieval with timestamps.
api/​src/​functions/​geocode.js Ports the geocoding endpoint.
api/​package.json Defines API dependencies and runtime requirements.
api/​package-lock.json Locks API dependencies.
api/​logout.js Removes custom logout.
api/​host.json Configures the Azure Functions host.
api/​gist.js Removes the Vercel Gist handler.
api/​auth.js Removes passphrase authentication.
api/​_lib/​session.js Removes custom session handling.
api/​_lib/​rateLimit.js Retains geocoding throttling only.
api/​_lib/​http.js Returns Azure-compatible responses.
api/​_lib/​githubGist.js Exposes the Gist update timestamp.
api/​_lib/​clientPrincipal.js Checks injected authentication principals.
api/​_lib/​categories.js Reads staged API categories.
admin/​index.html Replaces the login page with the dashboard.
admin/​dashboard.html Removes the separate dashboard page.
admin/​admin.js Updates authentication navigation and timestamp display.
admin/​admin.css Removes passphrase-login styles.
admin/​admin-auth.js Removes the custom login client.
.gitignore Excludes local secrets and staged categories.
.github/​workflows/​ci.yml Adds API dependency installation and validation.
.github/​workflows/​azure-static-web-apps-wonderful-field-0cd14c010.yml Deploys production and staging to Azure.
Files not reviewed (1)
  • api/package-lock.json: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/ci.yml
- 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
Comment thread package.json
"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",
"pretest": "node -e \"require('fs').copyFileSync('categories.json', 'api/categories.json')\"",
Comment thread staticwebapp.config.json
}
],
"responseOverrides": {
"401": { "redirect": "/.auth/login/aad", "statusCode": 302 }
@selectdimensions

Copy link
Copy Markdown
Collaborator Author

Superseded by #22 (same changes, single signed commit).

This branch was successfully deployed

1 active deployment
Preview — eef2970f Deployed Oct 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants