The Atlas MikeOSS.Azure's docs, bound to the code — and to the migration that built it
108 documents
AGENTS.md

Contributor/agent guidance carrying the load-bearing invariants that an innocent-looking refactor can silently break. It mandates: the mutating-vs-read split in backend/src/lib/storage.ts (mutating uploadFile/ deleteFile must go through requireProvider(), never optional chaining — commit efdb687 shipped that data-corruption bug, 2dbce7c fixed it); no committed .env files and no NEXT_PUBLIC_* baking beyond NEXT_PUBLIC_API_BASE_URL (runtime config now flows through GET /config and ConfigContext.tsx, issues 030–032); reading dynamic-route ids from usePathname() not useParams()/params under output:"export" (the findShell shell trick, commit 5f2e530); /install operator rules (Graph-scope changes force operator re-sign-in; register-redirect-uris.ps1 after any FQDN change); and the exact import order in backend/src/index.ts where ./telemetry must precede instrumented modules or App Insights silently goes dark. Read before editing storage, auth/config env vars, dynamic-route components, /install, or the backend boot sequence — these are the tripwires.

Agent Guidance

Upstream Compatibility

This project is based on an upstream open-source repository. Prefer the smallest practical changes that achieve the local/Azure migration goals so future upstream changes remain easy to merge.

  • Keep changes narrowly scoped to the migration or local-validation need being addressed.
  • Avoid broad rewrites, stylistic churn, file moves, or dependency swaps unless they remove a concrete blocker.
  • Prefer adapter layers, environment switches, and thin local overrides over changing shared application logic.
  • When removing Supabase, AWS, or other upstream dependencies, do it incrementally and preserve upstream-shaped interfaces where practical.
  • Document intentional divergence from upstream so future merges can evaluate conflicts quickly.

Storage Provider Boundary

backend/src/lib/storage.ts exposes four module-level helpers (uploadFile, downloadFile, deleteFile, getSignedUrl) backed by a _provider: StorageProvider | null singleton. The split between mutating and read operations is load-bearing:

  • Mutating ops (uploadFile, deleteFile) must use requireProvider("op").method(...). Never use optional chaining on _provider for these. Optional chaining on await _provider?.upload(...) resolves silently to await undefined when the provider is null, and route handlers insert DB rows pointing at blobs that were never written (commit efdb687 shipped exactly this bug; 2dbce7c fixed it).
  • Read ops (downloadFile, getSignedUrl) may use optional chaining and return null — "not found" is a valid response when storage is unconfigured.

If you add a new mutating storage operation, route it through requireProvider. If a future refactor "simplifies" the API by removing requireProvider, push back — the cost of the optional- chaining shape is silent data corruption, not a 500.

Environment Variables and Secrets

.env files must never contain real secrets in committed form

  • The only .env* files tracked in git are *.example files with placeholder values, used as templates for developers.
  • .gitignore excludes .env, .env.* and explicitly whitelists only *.example files. Do not add other whitelist exceptions.
  • Real values — including NEXT_PUBLIC_* values that are not technically "secret" but identify our deployment (tenant GUIDs, app-registration GUIDs, deployed FQDNs) — belong in CI/Bicep parameter stores, Key Vault, or build-time injection, never in the repo.

Don't commit .env.production

.gitignore rejects every .env and .env.* except *.example files. There is no whitelist exception. If you find yourself wanting to add one, stop — the rule exists because committing real values ties the source repo to a specific deployment, and NEXT_PUBLIC_* values count as "real values" even when they are not strictly secret (they identify a tenant).

Runtime config, not build-time baking

The previous design baked customer-specific values (NEXT_PUBLIC_ENTRA_*, NEXT_PUBLIC_AUTH_PROVIDER, NEXT_PUBLIC_REDIRECT_URI) into the JS bundle at build time. That made the image per-tenant. Issues 030–032 retired that pattern; the bundle is now tenant-portable.

How runtime config works now:

  • GET /config on the backend returns { authProvider, entra: {…} } from server env / Key Vault. Unauthenticated, cacheable.
  • frontend/src/contexts/ConfigContext.tsx fetches /config once on app load and exposes the values via useConfig().
  • The same React hook also caches authProvider in localStorage under mike.config.authProvider so module-level helpers (getBrowserAccessToken, getCachedAuthProvider) can answer "what mode are we in?" without a React context.
  • Sign-out goes through GET /auth/logout so the backend, not the browser, constructs the Microsoft logout URL.

Rules to keep this honest:

  • Don't reintroduce NEXT_PUBLIC_ENTRA_* or NEXT_PUBLIC_AUTH_PROVIDER. If you need a new value in the browser at runtime, add it to the /config response and the RuntimeConfig type in ConfigContext.tsx, and read it via useConfig().
  • The only NEXT_PUBLIC_* that survives is NEXT_PUBLIC_API_BASE_URL. It is build-time-needed because the bundle has to know where to fetch /config from before the runtime config has loaded. It is not customer-specific (defaults to empty / same-origin); pass it as a Docker --build-arg for split-origin deployments.
  • Supabase env vars (NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY) are needed only when the deployment runs in supabase mode. They are not committed; in non-supabase modes the lazy getSupabaseClient() factory throws if anything reaches for them.
  • Naming convention for any new runtime config. Server var name is FOO; runtime-config field is foo in RuntimeConfig. No NEXT_PUBLIC_FOO companion.

Frontend Dynamic Routes (Next.js output: "export")

The frontend builds with output: "export" and dynamic routes ([id], [chatId], etc.) declare generateStaticParams() returning the placeholder [{ id: "_" }]. The backend's findShell (backend/src/index.ts) substitutes _ for unknown URL segments and serves the same prerendered shell for every URL. As a result:

  • useParams() and server-baked params always report _ for dynamic-route pages, regardless of the URL the browser is on. They reflect the matched static route, not the live URL.
  • The only safe runtime-id source is usePathname() (a client hook that reflects window.location after hydration), parsed with a route-specific regex.

Rules:

  1. In any "use client" component on a dynamic route, read the id from usePathname() and parse it yourself. Never read it from useParams() or a server-passed prop derived from params.
  2. Gate fetching useEffects on the parsed id being non-empty — usePathname() returns null briefly before hydration, and a stray fetch with "" shows up as a 400 on the API.
  3. Page wrappers (app/(pages)/.../[id]/page.tsx) keep generateStaticParams (Next.js requires it for export) but should NOT pass an id prop to their client child. The child reads usePathname() itself.

The pattern is in frontend/src/app/components/projects/ProjectPage.tsx. Six routes were broken by this gotcha (commit 5f2e530); don't reintroduce. The diagnostic page that proved the hook behavior is at /diagnostics/route/[id].

/install Operational Rules

/install/auth/microsoft/start scope changes require operator re-sign-in

The /install OIDC flow caches the operator's Graph access token in an in-process map (backend/src/lib/install/sessionTokens.ts) keyed by session id. Existing sessions don't get rewritten when the scope list in /install/auth/microsoft/start changes — the cached token was minted with the old scopes.

When you add or change a Graph scope:

  1. Update both /install/auth/microsoft/start and the token-exchange scope parameter in the callback. They must match or Entra returns invalid_grant.
  2. After deploy, instruct the operator: "Sign out of /install, sign in again with Microsoft, accept any consent prompt." A backend redeploy alone does not refresh the cached token.

register-redirect-uris.ps1 after any backend FQDN change

Whenever the backend FQDN changes — custom-domain bind, region migration, anything — run scripts/install/register-redirect-uris.ps1 -KeyVaultName <kv> -BackendFqdn <new-fqdn>. The script registers BOTH redirect URIs the frontend Entra app needs:

  • https://<fqdn>/api/auth/openid-callback/microsoft (main app sign-in)
  • https://<fqdn>/install/auth/microsoft/callback (operator /install flow)

Earlier versions of the script only registered the first URI, so sign-in to /install kept failing with AADSTS50011 after FQDN changes. The fix landed in commit 4843e78, but the operator-action requirement remains: redirects don't auto-update with the FQDN.

Issue 030 (custom-domain orchestrator) should automate this step.

Backend init order — applicationinsights must hook before instrumented modules

backend/src/index.ts imports MUST be in this exact order at the top:

import "dotenv/config";
import "./telemetry";
import express from "express";
// ... everything else

applicationinsights patches require() / import at module-load time to auto-instrument http, express, pg, and console. Anything network-y imported BEFORE ./telemetry does not get instrumented, silently. dotenv/config is safe to load first — it's a one-shot file reader with no network/DB work, and must come first so .env-sourced APPLICATIONINSIGHTS_CONNECTION_STRING values are visible to the SDK init.

If you reshuffle imports for any reason, preserve this ordering. A "tidy the imports" PR can silently kill telemetry in production without anything failing.

Pre-commit checks the agent should run

Before committing changes that touch .env* files or env-var lookups:

  1. git ls-files | grep -E '(^|/)\.env($|\.)' | grep -v '\.example$' — must return nothing. If it does, you are about to commit a .env file that isn't a template.
  2. grep -rE "process\.env\.NEXT_PUBLIC_[A-Z_]+" frontend/src — every match should be one of: NEXT_PUBLIC_API_BASE_URL, NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY. Anything else means someone tried to reintroduce build-time baking — push back to runtime config in ConfigContext.
  3. No tenant-specific identifiers (Entra GUIDs, deployment FQDNs, resource names) should appear in any tracked .env*.example or any committed source file. Use placeholders.