Skip to content

Remove dev-login: Cognito is the only authentication provider - #112

Closed
satvikOS wants to merge 4 commits into
mainfrom
chore/remove-dev-login
Closed

Remove dev-login: Cognito is the only authentication provider#112
satvikOS wants to merge 4 commits into
mainfrom
chore/remove-dev-login

Conversation

@satvikOS

@satvikOS satvikOS commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

⛔ DO NOT MERGE until Cognito is populated

Cognito holds 1 confirmed identity. The approved roster is 82 people. Merging this today removes the only way 81 of them can sign in. This PR is correct and complete; the condition that gates it is not in this repository.

The exact precondition for merging: every person on the approved Tenant #1 roster has a CONFIRMED identity in Cognito user pool us-east-1_EBxatAx2Z and can sign in with it. Confirmed, not created — an account left in FORCE_CHANGE_PASSWORD exists and still cannot authenticate, because src/lib/auth/cognito.ts refuses a Cognito challenge rather than half-handling it. The ops-status workflow reports both numbers; apps/web/scripts/provision-cognito-cohort.mjs does the provisioning and is a dry run by default.

Opened as a draft for that reason. PD-005 already set this order and it has not changed: provision → verify → remove.

One step that is not in the diff and must not be skipped. After merging and deploying, rotate AUTH_SECRET (Actions → Rotate Auth Secret). Removing the provider stops new dev-login sessions; it does not invalidate ones already issued. Sessions are JWTs validated by signature alone, so anyone holding a cookie minted while dev-login was on stays signed in as whoever they typed, for the life of that token, after the door is closed behind them. Then delete the now-unmanaged tenure-pilot/dev-login secret from Secrets Manager.


What this removes

Provider the dev-login Credentials provider in src/lib/auth.ts
Gate src/lib/dev-login.ts + dev-login.test.ts
Env AUTH_DEV_LOGIN, ALLOW_DEV_LOGIN_IN_PRODUCTION, DEV_LOGIN_PASSPHRASE — out of the schema, the task definition and the deploy workflow
Infra infrastructure/terraform/dev-login-gate.tf (generated passphrase, Secrets Manager secret, two outputs), the ARN in secrets.tf, the secret ref in ecs.tf
UI the second form on /signin, the "Pilot access" divider, and every conditional that arranged two providers

The three Cognito variables are now the whole of authentication.

The contract flipped direction

env.ts used to warn on a half-configured pool, because a missing Cognito field meant one provider was absent from a page that still had another. With one provider that same state is a site nobody can log into, so it is fatal at boot — and the message names which of the three variables is unset, rather than restating the rule.

The e2e suite kept its coverage

All 34 Playwright specs authenticated through the dev-login form. Cognito verifies credentials with an IAM-authorized AdminInitiateAuth call against a real user pool, which CI has no credentials for and a fork PR never will.

The suite now establishes the session directly (e2e/support/auth.ts): next-auth/jwt's own encode, the server's AUTH_SECRET, the salt and cookie name @auth/core derives for the deployment's URL, and the user id read out of the seeded database. That is the cookie the server issues, decrypted by the same code; the jwt and session callbacks run and every authorization decision downstream — tenant scope, the (app) entitlement gate, capability resolution — is taken for real against real rows.

ADR-0019 records the decision, the three options rejected (a real pool in CI; an endpoint override in production code; a second provider for tests), and the one property it costs: redirectTo: callbackUrl in the sign-in action is no longer executed end to end, and is asserted structurally instead. The destination behaviour is still exercised in a browser from the other side of the same code — /signin holding a session redirects to the same validated callbackUrl, so the off-origin case still has to land on /dashboard.

e2e/signin-cognito.spec.ts replaces dev-login-gate.spec.ts: the form is there, it is the only form, a credential the pool refuses becomes page state rather than a 500 or a blank page, a refusal leaves no session, and an authenticated person reaches the app with the server agreeing who they are. In CI the pool is real code talking to real AWS and being refused — the log shows Cognito sign-in unavailable: UnrecognizedClientException, mapped to the same generic refusal a wrong password would get.

Governance

Record Was Now
IDENT-001-dev-login-in-production BLOCKED_ARCHITECTURE PASS, liveViolation: null, evidence re-pointed, review date kept
ADR-0008 Proposed (2026-08-17). Records a conflict; decides nothing. Accepted — one word, no qualifier — option C, with what it does not decide split out
ADR-0019 added, Accepted
ADR-0006 prerequisite 1 named AUTH_DEV_LOGIN=false dated amendment: that state will never exist; the substance is unchanged
PD-005 / PD-006 sequencing only + where the removal stands, and a correction: the @tenure.demo rows do not go with the provider
README index, HANDOFF, RUNBOOK, SESSION-STATE, PROGRAM-BACKLOG stale re-pinned

The register row was designed to trip, and it did. Its liveViolation asserted AUTH_DEV_LOGIN=true was still present in the task definition, so fixing the conflict turns the suite red and forces somebody to close the row. That worked exactly as intended — and it is one-shot. A closed row has no predicate left, so the standing guard is now a test: lib/auth/cognito-is-the-only-provider.test.ts fails if a second provider, any of the three retired switches, or the Secrets Manager entry returns — in the application, in the task definition, in Terraform, or in .env.example.

liveViolation is null rather than inverted to expect: "absent". The field's documented meaning is "a condition that holds because the conflict is unresolved"; there is no such condition here any more, and writing absent would keep the row red-on-fix while claiming the opposite of what the field means.

Deliberately still open. §19.1 asks for MFA, recovery and session controls alongside invitation-only authentication and verified email ownership. The pool enforces the first two; MFA is not enabled. IDENT-001 therefore keeps its 2026-09-30 review date instead of being cleared, and ADR-0008 says so under "What this ADR does not decide" rather than qualifying its status.

Verification

tsc --noEmit 0 · jest 106 suites, 1574 passed · next build 0 · Playwright 163/163 against a real Postgres, a production build and the CI environment.

Negative controls — each broken, confirmed RED, restored, confirmed GREEN:

Control Result
Re-add a dev-login Credentials provider to auth.ts RED — 2 suites
Put AUTH_DEV_LOGIN back in ecs.tf (app code untouched) RED
Read process.env.AUTH_DEV_LOGIN in a component, bypassing the schema RED
ADR-0008 back to Proposed while the register says PASS RED — 3 assertions
Register claims PASS with state: "proposed" RED — including the dedicated guard
Accepted (code done; cohort provisioning outstanding) RED — the one-word rule
Runner given a secret the server does not know RED — "there is still a working way in" fails, so the minted session is genuinely validated rather than waved through

The last one is the control that matters: it is the difference between a fixture that proves the app admits an authenticated person and one that proves nothing.

A defect the suite caught and I had missed: e2e/app.spec.ts located the sign-in form by getByRole("region", { name: "Pilot access" }) and contained no string my greps matched. Fixed, and it is why the full suite was run rather than the auth specs alone.

Out of scope, flagged not fixed

Resolved, not deferred. The four surviving "Office of Student Experience" strings this section used to list are gone: #119 landed the Engagement rename on main, and the merge below takes main's string in e2e/app.spec.ts. Nothing is outstanding here.

Left to its owner: OKTA_ISSUER / OKTA_CLIENT_ID / OKTA_CLIENT_SECRET are still in apps/web/.env.example. PD-004 retired Okta and src/lib/env.ts has no OKTA_* in its schema, so nothing reads them — but PR #123 is already removing the last Okta mentions from that exact file, and deleting them here would collide with it for no gain. They now carry a comment saying they are dead and why they are still there.

A defect on main, not in this PR: apps/web/prisma/migrations/20260820180000_seat_metering_events/migration.sql:14 and apps/web/src/lib/billing/seat-meter-boundary.test.ts:131 both cite ADR-0015 for the seat-metering boundary. That decision landed as ADR-0018; 0015 is reserved for the platform exception object. Two stale citations, already merged, and not this branch's to rewrite.


Adversarial verification (second agent, independent worktree)

Every number in "Verification" above was reproduced from a clean checkout of 7caa9e5
(npm ci, own Postgres, own production build):

Gate Claimed Reproduced
tsc --noEmit 0 0
jest 106 suites / 1574 106 suites / 1574 passed, 1 skipped
next build 0 0
Playwright 163/163 163/163 (59.5s, real Postgres + next start)

All seven claimed negative controls were re-run independently and each went RED for the
stated reason, then GREEN on restore — including the decisive one: runner given a secret the server does not
know → "there is still a working way in" fails
(waitForURL(/\/dashboard/) times out, not a
connection error). The constant-time claim on secretsMatch was measured, not read: ~1700 ns
across exact-match, same-length-differs-at-byte-0, same-length-differs-at-byte-63 and 1-char
candidates — indistinguishable. Sign-in is non-enumerable in observable output: an address in
the database and one absent from it both produce one identical message, one identical URL.

Three escapes were found in the guards this PR relies on, and are now closed (c061937)

The register row is one-shot, so cognito-is-the-only-provider.test.ts is the standing guard.
It was escapable. Each escape was demonstrated before being fixed:

  1. A working passwordless provider was invisible to both guards. Both files found providers
    with /Credentials\(\{\s*\n\s*id:\s*"..."/, which only matches when id is the first
    key. Writing name: before id: — a one-line reordering — hid a second dev-login provider
    that signed in any seeded address with no password. tsc 0, all 106 suites and 1574
    tests green
    , with that backdoor installed.
  2. "Every Credentials provider gates before returning a user" could not fail for an appended
    provider.
    It split the file on Credentials({, so the last block ran to end-of-file and
    absorbed the callbacks section; signIn there mentions gateOnEligibility, so the final
    provider always read as gated. An appended provider with no eligibility check passed.
    Appending is how a provider arrives.
  3. The retired-switch scan missed process.env["AUTH_DEV_LOGIN"]. It matched only the dotted
    form. The bracket form is the same read and the same schema bypass the test's own comment says
    it covers.

Fixed by bounding each provider at its own closing brace in one shared parser
(src/lib/auth/provider-blocks.ts), reading id wherever it sits among the keys, asserting a
textual count of Credentials( against the parsed count so an unreadable declaration fails
closed
, and matching the bracket form. All three escapes now go RED, plus a fourth control
(Credentials(someVariable)) which the new count assertion catches. Gate re-run after the fix:
tsc 0 · jest 106 suites / 1575 passed · next build 0 · Playwright 163/163.

Confirmed by test, not by reading: sessions are replayable across sign-out

The AUTH_SECRET rotation step above is load-bearing, not hygiene. Measured: a session cookie
captured before sign-out, replayed in a fresh browser context after that user signed out,
returns 200 from /api/auth/session as director@tenure.demo and loads /dashboard. Sign-out
clears the cookie in one browser; it does not end the session. So rotating AUTH_SECRET after
deploy is the only thing that invalidates sessions minted while dev-login was live. (PR #104,
"Sessions the server can actually end", is the durable fix and is not merged.)

Scope and asset checks

  • The PR is a draft — as instructed. ✅
  • The branding agent did not remove dev-login on its branch; it preserved the provider. ✅
  • No second authorization path. The changes to eligibility.ts, csp.ts, [tenant]/page.tsx
    and slack/install.ts are comment/docstring-only; secretsMatch's body is byte-identical. ✅
  • No fabricated or trademarked asset. Zero image/font files added, zero data: URIs, zero
    base64 payloads in the diff; the only Simon reference in an added line is a test fixture
    address. ✅
  • Zero-mock rule holds. Two matches for stub markers across the whole diff, both benign: a
    test counting the existing "Coming soon" SSO notice, and a comment in env.ts about rejecting
    placeholders. ✅
  • One honest caveat on the e2e suite, which the spec file itself already states: because the CI
    pool is unreachable, "an address with no account behind it is refused the same way" passes via
    the unavailable branch rather than by exercising Cognito's user-existence collapsing. The
    non-enumerability is structural — refuseToSignIn always redirects to ?error=1 and the page
    renders exactly one message string — so the property holds, but that spec is not what proves it.

🤖 Generated with Claude Code


Independent review (third agent, own worktree, own database)

The gate was reproduced from scratch at c061937 — own npm ci, own Postgres, own production build, own port, because port 3000 was another agent's server and reuseExistingServer would have run the suite against their build without saying so.

Gate Claimed Reproduced at c061937
tsc --noEmit 0 0
jest 106 suites / 1574 106 suites / 1575 passed, 1 skipped
next build 0 0
Playwright 163/163 163/163 (59.7s, real Postgres + next start)

The decisive control re-run independently: runner given a secret the server does not know → "there is still a working way in" fails on waitForURL(/\/dashboard/) timing out, not a connection error. The session fixture is genuinely validated by the server.

Answering the review's own question — was e2e coverage migrated or deleted? Migrated. main had 34 specs, all 34 importing support/auth.ts, whose submitSignIn hard-asserted getByRole("region", { name: "Pilot access" }). The branch has 33 (dev-login-gate.spec.ts retired, signin-cognito.spec.ts added), all 33 still importing it, now via establishSession. No spec was silently dropped: the one other list difference, seat-metering.spec.ts, is a file main gained after the branch point.

The branch could not merge, and fixing that surfaced four defects

mergeable was CONFLICTING. main had moved on and #119 rebuilt /signin into two zones — while deliberately keeping dev-login as a de-emphasised "Pilot access" panel, because removing it was this PR's job. The resolution is main's layout with that panel taken out. Everything below was found by doing that merge and running the suite, not by reading the diff.

  1. The unit divider test asserted a divider EXISTS. expect(divider).toBeGreaterThan(-1)Name the unit Engagement, and give the sign-in page a hierarchy #119's card draws no "Or" at all, so the test failed on a page that is correct because it has none. It now asserts the invariant (every separator is guarded by something governing its neighbour), which a page with no separators satisfies, plus a separate row recording that today there are none.
  2. The e2e divider check had the same artefact. dividers === ssoNoticesdividers <= ssoNotices. Caught by running the suite.
  3. ADR-0015 collided. main's index reserves 0015 by name for the platform exception object (One exception object and one operator worklist — and the ADR-0013 fork answered on a new table #101) and 0016 for another change in the same sequence — and five open PRs each wrote themselves an ADR-0015. decision-records.test.ts fails a reserved number that has a file (verified: 4 assertions RED). This change's ADR is now ADR-0019; all 13 references moved with it.
  4. .env.example was missed entirely. RUNBOOK calls it the file that "documents every variable for local setup", and it still assigned all three retired switches and named no COGNITO_* variable — so following the documentation produced an app with no provider at all and a sign-in page with no form. Neither standing guard could see it: one walks src for TypeScript, the other walks *.tf. Both now also read .env.example, for the dead switches and for the live ones.

Also fixed: RUNBOOK's "while it is still on" procedure told an operator to set DEV_LOGIN_PASSPHRASE as a repository secret or let Terraform generate one into tenure-pilot/dev-loginboth mechanisms are deleted by this PR, so read after the merge it was a procedure that appears to work and does nothing.

Gate on the merged tree

tsc 0 · jest 122 suites / 1873 passed, 1 skipped · next build 0 · Playwright 168/168.

Negative controls — each broken, confirmed RED, restored, confirmed GREEN:

Control Result
AUTH_DEV_LOGIN=true back in .env.example RED — the new guard
COGNITO_USER_POOL_ID removed from .env.example RED — the other half
An "Or" divider gated on the tenant alone RED — 2 assertions
The ADR put back at the reserved 0015 RED — 4 assertions, incl. "a reserved number has no ADR file"
A second Credentials provider, name: before id:, no gate RED — 3 assertions

Confirmed, and unchanged by the merge

  • It stays a draft. ✅ Cognito still holds 1 identity against ~82 people; the precondition in the first paragraph is the merge gate and is unchanged.
  • AUTH_SECRET rotation is in the plan and was not erased with the thing it warns about: RUNBOOK keeps it as step 3 of 4, the rotate-auth-secret.yml workflow is real (openssl rand, put-secret-value, ecs update-service), and the first paragraph of this body repeats it.
  • The removal is complete. No dev-login / devLogin / AUTH_DEV_LOGIN reference survives in src, e2e, infrastructure or .github except deliberate history comments and the negative tests that assert the switches no longer do anything. No terraform plan hard error: nothing references dev-login-gate.tf's resources.
  • Governance. PD-005 Accepted with the sequencing intact · ADR-0008 Accepted, one word, no qualifier · IDENT-001 PASS with liveViolation: null and a written rationale for not inverting it — the backwards predicate was tripped on purpose and closed, and the standing guard is now a test. 38 governance assertions pass.
  • The sign-in page is coherent with one provider — verified in a browser at 1440px and 390px: one form, zero dividers, one SSO notice, zero passphrase fields, and "Office of Student Engagement".

DO NOT MERGE until the Cognito pool holds a CONFIRMED identity for every
person on the approved roster. The pool holds 1; the roster is 82. Merging
this today takes the workspace away from 81 people who have done nothing
wrong. The change being ready is not the condition — the pool being
populated is.

Deletes the passwordless Credentials provider and everything that switched
it on: `AUTH_DEV_LOGIN`, `ALLOW_DEV_LOGIN_IN_PRODUCTION`,
`DEV_LOGIN_PASSPHRASE`, `src/lib/dev-login.ts`, `dev-login-gate.tf` with its
generated passphrase and Secrets Manager entry, the `TF_VAR` in deploy.yml,
and the second form on /signin along with every conditional that arranged
the two of them.

`env.ts` now states the contract the other way round: with one provider, an
incomplete Cognito pool is not a degraded sign-in page but a site nobody can
log into, so it is fatal at boot rather than a warning, and the message names
which of the three variables is unset.

The e2e suite keeps its coverage. All 34 specs authenticated through the
`dev-login` form; Cognito verifies credentials with an IAM-authorized
AdminInitiateAuth call that CI cannot make. The suite now establishes the
session directly with `next-auth/jwt`'s own `encode`, the server's
AUTH_SECRET and the user id read out of the seeded database — the cookie the
server issues, decrypted by the same code, with every authorization decision
downstream taken for real. ADR-0015 records that, including the one property
it costs. `e2e/signin-cognito.spec.ts` replaces `dev-login-gate.spec.ts`.

Governance: register row IDENT-001 → PASS with liveViolation null (its
backwards predicate fired on this change, as designed, and is one-shot);
ADR-0008 → Accepted, option C, one word and no qualifier; ADR-0015 added;
PD-005/PD-006, HANDOFF, RUNBOOK, SESSION-STATE and the backlog re-pinned.
The standing guard against the provider returning is now a test,
`lib/auth/cognito-is-the-only-provider.test.ts`.

Verified: tsc 0, jest 106 suites / 1574 passed, next build 0, Playwright
163/163 against a real Postgres and a production build.

Negative controls, each confirmed RED then restored GREEN: re-adding a
dev-login provider (2 suites); AUTH_DEV_LOGIN back in ecs.tf; a component
reading process.env.AUTH_DEV_LOGIN; ADR-0008 back to Proposed while the
register says PASS (3 assertions); the register claiming PASS on a proposed
ADR; `Accepted` carrying a qualifier. And the one that matters most — the
runner given a secret the server does not know: "there is still a working
way in" fails, so the minted session is genuinely validated rather than
waved through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1fe66323-0f74-4337-bbfd-2d9cb0ca05bc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

The guards this PR relies on to keep dev-login gone were escapable, and
each escape was demonstrated before it was fixed:

1. Both files found providers with /Credentials\(\{\s*\n\s*id:\s*"..."/,
   which only matches when `id` is the FIRST key. Writing `name:` before
   `id:` hid a fully working passwordless provider from both guards --
   tsc, all 106 suites and 1574 tests stayed green with it installed.

2. `every-provider-is-gated` split the file on `Credentials({`, so the
   LAST provider's block ran to end-of-file and absorbed the `callbacks`
   section. `signIn` there mentions gateOnEligibility, so the final
   provider always read as gated -- and appending is how a provider is
   added. An appended provider with no eligibility check passed.

3. The retired-switch scan matched `process.env.AUTH_DEV_LOGIN` but not
   `process.env["AUTH_DEV_LOGIN"]`, which is the same read and the same
   schema bypass the test's own comment says it covers.

Providers are now bounded by their own closing brace in one shared
parser, ids are read wherever they sit among the keys, a textual count of
`Credentials(` is asserted against the parsed count so an unreadable
declaration fails closed, and the switch scan matches the bracket form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@satvikOS

Copy link
Copy Markdown
Collaborator Author

ADR number arbitration — this PR must renumber to ADR-0019

Five open PRs each independently claim ADR-0015. main is at ADR-0014, so every parallel agent picked "the next free number":

PR claims assigned
#101 the platform exception object 0015 0015
#107 the billable unit 0015 0016
#107 seat metering without an outbox 0016 0017
#110 workspaces are a function of role 0015 0018
#112 e2e authentication without a second provider 0015 0019
#106 tenant configuration packs 0015 0020

Why numbers cannot simply be assigned per-PR

decision-records.test.ts asserts expect(gaps).toEqual([5]) — the reserved Cognito-cutover number is the only permitted gap. So the numbering must be contiguous, which means it follows merge order, not PR order.

Merge order is therefore fixed: #101#107#110#112#106. Merging out of that order makes the next PR's CI red on the gap check — which is the guard working, not a flake.

What this PR needs to change

ADR-0015-e2e-authentication-without-a-second-provider.mdADR-0019-e2e-authentication-without-a-second-provider.md

Rename the file and update every cross-reference: the ADR body, the docs/decisions/README.md table, the backlog, code comments, and any test that pins an ADR number. decision-records.test.ts fails if the README table and the files disagree; constitution-completeness-compiler.test.ts checks supersession shape.

Do not create a new gap, and do not touch 0005.

(Assigned from the collision audit.)

@satvikOS

Copy link
Copy Markdown
Collaborator Author

ADR allocation — CORRECTED. This PR takes ADR-0020

My earlier table missed #104, which also adds an ADR-0015. Six open PRs claim that number: #101, #104, #106, #107, #110, #112.

decision-records.test.ts asserts expect(gaps).toEqual([5]) — the reserved Cognito number is the only permitted gap. So numbering must be contiguous as merged, which makes merge order and number order the same thing.

merge order PR number
1 #101 the platform exception object 0015
2 #104 session revocation event emission 0016
3 #107 the billable unit / seat metering 0017 + 0018
4 #110 workspaces are a function of role 0019
5 #112 e2e authentication without a second provider 0020
6 #106 tenant configuration packs 0021

#116 is out of this sequence entirely — it takes no number at all, deferring to ADR-0009, which already exists, is already Proposed, and already owns the same fork (which of RestrictedIdentity / DirectoryPerson / User is canonical), tracked by register row IDENT-002. One record beats two restating one conflict. #115 only edits ADR-0013 and is unconstrained.

#104 is placed second, not last, because it is verified and ready while #106 is blocked on two real defects — a ready PR must not queue behind a stuck one.

Verified the hard way: renaming an ADR to 0021 on a branch whose numbers stop at 0014 yields gaps [5,15,16,17,18,19,20] and CI goes red. Measured, not predicted.

Rename the file and update every cross-reference — the ADR body, the docs/decisions/README.md table, the backlog, code comments, and any test pinning a number. A code comment contradicting the ADR it cites is the one a reader finds first.

satvikOS added a commit that referenced this pull request Aug 21, 2026
* Name the unit Engagement, and give the sign-in page a hierarchy

The unit is the Office of Student **Engagement**. `lib/policies.ts` already said
so; `lib/tenant/brand.ts` said Experience, and that is the copy the live
sign-in page renders. Renamed at all five sites — the registry, its test, the
e2e assertion, HANDOFF and ADR-0014, whose text describes what this page shows
and would otherwise have become false by standing still. `grep -rn "Student
Experience"` now returns nothing.

The `Simon-OSE` pill under the heading is gone, and `displaySlug` went with it.
A survey of the tree found the pill was its only reader: everything else naming
it was the interface, the two registry entries, and two tests asserting it
agreed with `slug`. A slug is a key — the thing a hostname, a URL segment and a
row are keyed by — and the names a person should read are `institutionName` and
`unitName`. The two tests are replaced by their inverse, which fails if the
field comes back.

The page itself is now two zones. The backdrop answers "whose system is this?"
— the institution's mark, the unit, and one line on what it is for. The card
answers "how do I get in?" and holds nothing else; it used to open with the
Tenure wordmark at 24px and the vendor's tagline, so the first two things a
person read on their university's login page were ours and the field they came
to fill in was third. Tenure is an eyebrow above the rule now, and "Sign in" is
the first line of the card.

A slot for the institution's logo, and deliberately no logo. We hold no licence
to Simon's lockup, and an unlicensed approximation on the institution's own
login page is the worst place to be wrong. `InstitutionMark` renders
`public/brand/<slug>.svg|png` when one is supplied and otherwise sets the
institution's name as a wordmark — a finished treatment, not a placeholder.
public/brand/README.md carries the spec, including the requirement that is
easiest to get wrong: the mark sits on the tenant's dark field, so it has to be
the reversed lockup. The slug reaches a filesystem path, so it is whitelisted
rather than `basename`d — a test plants a real file where a basename
implementation would land and proves it is still refused.

Cognito is the visual primary, because it is the only real way in. The two
forms were identical — same fields, same green button, same word on it — which
offered two equal doors and said nothing about which one disappears. The
interim block is now a recessed panel with a heading and a secondary button.
`CredentialsSignInForm` grew a `tone` rather than a class string because the
pending spinner has to be recoloured with the button, and the primary treatment
follows whichever door is actually the way in: with no Cognito configured
(local, and the e2e job) the pilot form is the only one on the page and takes
it back.

Dev login is untouched and still rendering. Removing it is PR #112, and it
cannot land until Cognito is populated — ~81 people have no account yet and
would be locked out. What changed here is its weight, not its presence.

The tenant's second brand colour is used once, as a 32px decorative rule, and
never for text: measured, Simon's dandelion on Rochester Blue is 7.7:1 but the
neutral tenant's own pair is 2.65:1, and the registry cannot promise anything
about contrast between two colours a tenant supplies.

Also fixes a focus indicator that was a 1px hue shift on a hairline — on a
password field, which is where it is needed most.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Label the fenced block in the brand README (MD040)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude added 2 commits August 21, 2026 02:27
`main` moved five commits under this branch and two of them landed in the same
files. #119 rebuilt /signin as two zones — an institution backdrop and a card —
and deliberately KEPT dev-login inside it as a de-emphasised "Pilot access"
panel, because removing it was this change's job and doing it there would have
locked out the ~81 people with no Cognito account. So the resolution is the new
layout with that panel taken out: `devLoginEnabled`, `passphraseRequired`,
`devSignIn` and `PILOT_TITLE_ID` are gone, the Cognito form's `: null` becomes
the sentence that says why there is no form, and the card is one form and one
notice.

Four things the merge exposed, each a real defect rather than a textual clash:

- The divider test asserted a divider EXISTS. #119's card has no "Or" at all —
  the SSO notice is a labelled panel, not an alternative — so the test failed on
  a page that is correct precisely because it draws none. It now asserts the
  invariant (every separator is guarded by something governing its neighbour),
  which a page with no separators satisfies, plus a separate row recording that
  today there are none.

- ADR-0015 collided. `main`'s index reserves 0015 BY NAME for the platform
  exception object and 0016 for another change in the same merge sequence, and
  `decision-records.test.ts` fails a reserved number that has a file. This
  change's ADR takes 0019; all 13 references moved with it.

- `.env.example` — which RUNBOOK.md calls the file that documents every variable
  for local setup — still assigned all three retired switches and named no
  COGNITO_* variable at all, so following the documentation produced an app with
  no provider and a sign-in page with no form. Neither standing guard could see
  it: one walks `src` for TypeScript, the other walks `*.tf`. Both now also read
  `.env.example`, for the dead switches and for the live ones.

- RUNBOOK's "while it is still on" procedure told an operator to set
  DEV_LOGIN_PASSPHRASE as a repository secret, or let Terraform generate one
  into `tenure-pilot/dev-login`. This change deletes both mechanisms, so read
  after the merge it was a procedure that appears to work and does nothing.

`restricted-registry.test.ts` takes main's SEEDED_AND_SEALED() with this
branch's provider name; `app.spec.ts` takes main's "Office of Student
Engagement", which retires the last of the four Experience strings the PR body
had flagged as out of scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`signin-cognito.spec.ts` asserted `dividers === ssoNotices` — one "Or" for each
SSO notice. That was a fact about the old single-column card, not the property
being defended, and it failed against #119's card, which introduces the notice
with its own heading and draws no separator at all.

The direction is what matters: never a divider with less than two things to
divide. `dividers <= ssoNotices` keeps the regression it was written for (a
separator with one option beside it) and stops asserting a separator has to
exist. Caught by running the suite, not by reading it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
satvikOS pushed a commit that referenced this pull request Aug 21, 2026
Resolved five conflicts, all of them two changes adding beside each other
rather than disagreeing:

  * prisma/schema.prisma — Institution gains all three back-relations;
    TenantConfigPack and the seat-metering block are both kept.
  * tenancy/registry.ts + registry.test.ts — the pins are MEASURED against
    the merged schema, not incremented. grep -c '^model ' = 44, and the
    three buckets are 25 / 5 / 14, which sums to 44. #125's new
    prose-agreement case is kept and the doc sentence updated with it.
  * docs/decisions/README.md — this change takes ADR-0021 per the
    allocation on PR #106, and declares 0020 as a reservation because
    #112 has not merged yet.
  * docs/implementation/global-engine-execution-ledger.md — same measured
    counts, with this change's step appended rather than restated.

ADR-0015-tenant-configuration-packs.md is renamed to ADR-0021 and every
cross-reference moved with it: the ADR body, the index table, the backlog,
.env.example, publish-tenant-packs.yml and four source comments.
@satvikOS

Copy link
Copy Markdown
Collaborator Author

Closing. #140 does this properly, and #112 carries two defects that make it unsafe to land as-is.

#112's intent is right and is not being dropped — /signin becomes Cognito-only, the pilot block and passphrase go, and the credentials provider is deleted. #140 does all of that AND rebuilds the page, so keeping both open means two branches removing the same provider.

Two measured reasons this specific branch should not be the one that lands:

  1. It would make a failed deploy unrecoverable. Deleting infrastructure/terraform/dev-login-gate.tf takes aws_secretsmanager_secret.dev_login with it, and that resource carries recovery_window_in_days = 0 — which the AWS provider turns into ForceDeleteWithoutRecovery. The ECS rollback revision still references that secret, so a deploy failing after the delete could not roll back.

  2. Resolving its ecs.tf in its favour silently deletes MASTER_ACCESS_EMAILS from the production task definition. The rollout preview then goes dark with no test failingresolvePreviewIdentity returns null for an unset value by design, so /preview 404s and the role chooser goes inert. It reads as "never deployed" rather than as a merge error.

Also carried forward to #140: this branch adds ADR-0019-e2e-authentication-without-a-second-provider.md while main already has ADR-0019-workspaces-are-a-function-of-role.md. Two different filenames on the same number produce no gap, and the ADR checks are gap-based — so it merges green and the log permanently carries two ADR-0019s. It needs renumbering, and the repo should gain the uniqueness assertion it believed it had.

One requirement from here that is currently prose and must become a step: sessions are JWT and signature-only, so cookies minted while dev-login was live stay valid after the provider is gone. AUTH_SECRET has to be rotated at the deploy that removes it — nothing enforces that today.

@satvikOS satvikOS closed this Aug 21, 2026
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.

2 participants