First hour with the repo
Orientation: what MikeOSS.Azure is, the invariants that keep the fork mergeable, the code-verified architecture, and where the migration record lives.
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 userequireProvider("op").method(...). Never use optional chaining on_providerfor these. Optional chaining onawait _provider?.upload(...)resolves silently toawait undefinedwhen the provider is null, and route handlers insert DB rows pointing at blobs that were never written (commitefdb687shipped exactly this bug;2dbce7cfixed 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*.examplefiles with placeholder values, used as templates for developers. .gitignoreexcludes.env,.env.*and explicitly whitelists only*.examplefiles. 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 /configon the backend returns{ authProvider, entra: {…} }from server env / Key Vault. Unauthenticated, cacheable.- frontend/src/contexts/ConfigContext.tsx fetches
/configonce on app load and exposes the values viauseConfig(). - The same React hook also caches
authProviderinlocalStorageundermike.config.authProviderso module-level helpers (getBrowserAccessToken,getCachedAuthProvider) can answer "what mode are we in?" without a React context. - Sign-out goes through
GET /auth/logoutso the backend, not the browser, constructs the Microsoft logout URL.
Rules to keep this honest:
- Don't reintroduce
NEXT_PUBLIC_ENTRA_*orNEXT_PUBLIC_AUTH_PROVIDER. If you need a new value in the browser at runtime, add it to the/configresponse and theRuntimeConfigtype inConfigContext.tsx, and read it viauseConfig(). - The only
NEXT_PUBLIC_*that survives isNEXT_PUBLIC_API_BASE_URL. It is build-time-needed because the bundle has to know where to fetch/configfrom before the runtime config has loaded. It is not customer-specific (defaults to empty / same-origin); pass it as a Docker--build-argfor 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 lazygetSupabaseClient()factory throws if anything reaches for them. - Naming convention for any new runtime config. Server var name is
FOO; runtime-config field isfooinRuntimeConfig. NoNEXT_PUBLIC_FOOcompanion.
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-bakedparamsalways 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 reflectswindow.locationafter hydration), parsed with a route-specific regex.
Rules:
- In any
"use client"component on a dynamic route, read the id fromusePathname()and parse it yourself. Never read it fromuseParams()or a server-passed prop derived fromparams. - 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. - Page wrappers (
app/(pages)/.../[id]/page.tsx) keepgenerateStaticParams(Next.js requires it for export) but should NOT pass an id prop to their client child. The child readsusePathname()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:
- Update both
/install/auth/microsoft/startand the token-exchangescopeparameter in the callback. They must match or Entra returnsinvalid_grant. - 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:
git ls-files | grep -E '(^|/)\.env($|\.)' | grep -v '\.example$'— must return nothing. If it does, you are about to commit a.envfile that isn't a template.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 inConfigContext.- No tenant-specific identifiers (Entra GUIDs, deployment FQDNs,
resource names) should appear in any tracked
.env*.exampleor any committed source file. Use placeholders.