Skip to content

Stage 4: real OAuth locally, over a tunnel - #21

Merged
guarzo merged 1 commit into
worktree-local-dev-stage3from
worktree-local-dev-stage4
Aug 3, 2026
Merged

Stage 4: real OAuth locally, over a tunnel#21
guarzo merged 1 commit into
worktree-local-dev-stage3from
worktree-local-dev-stage4

Conversation

@guarzo

@guarzo guarzo commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Stage 4 — the last one. Documentation only, one file, +85 lines.

Stacked on #20. Based on worktree-local-dev-stage3 so the diff shows only Stage 4's change; both stages edit the same docs/ops.md section. GitHub retargets this to main automatically when #20 merges. Merge #20 first.

Scope

The seeded cookie from Stage 3 covers most dev work. This covers the case it can't: changing the login or character-link flows themselves, which needs the real providers to redirect back to your machine. Written for ngrok with a stable domain — a fresh random hostname per run means re-registering redirect URIs in two portals every time.

Four steps: start the tunnel, override APP_BASE_URL in .env.local, register two redirect URIs, browse the tunnel origin.

Every claim was verified against the code, not written from memory

Trailing slash. The URIs are string-concatenated, not URL-joined, and z.string().url() accepts a trailing slash silently — so it fails much later as an unexplained redirect mismatch:

APP_BASE_URL=https://x.ngrok.app   →  https://x.ngrok.app/auth/eve/callback
APP_BASE_URL=https://x.ngrok.app/  →  https://x.ngrok.app//auth/eve/callback   ✗

Exact URIs, produced by running the real builders:

discord redirect_uri: https://<domain>/auth/discord/callback
discord scope       : identify
eve redirect_uri    : https://<domain>/auth/eve/callback
cookie Secure?      : true

One EVE entry covers both flows/auth/eve/login and /auth/eve/link both call buildEveAuthorizeUrl, so they share /auth/eve/callback.

The Secure cookie trap. Once APP_BASE_URL is https the session cookie is issued Secure (src/app/auth/eve/callback/route.ts), so the browser won't send it back over http://localhost:3000 — you appear logged out no matter how many times you log in.

The finding worth reading

EVE and Discord treat redirect_uri differently:

  • EVE sends it only on authorize. exchangeEveCode sends grant_type, code, code_verifier — no redirect_uri.
  • Discord sends it twice, on authorize and in the token exchange, where it must match the first exactly.

So changing APP_BASE_URL — or restarting with a different tunnel domain — between clicking "link Discord" and the redirect landing breaks the exchange, while EVE login keeps working. That asymmetry makes it read like a Discord outage when it's a mid-flight config change. Documented with the fix (restart the flow from the current origin).

Verification

$ npm run format:check   # clean
$ npm run lint           # 0 errors (4 pre-existing <img> warnings)
$ npm run typecheck      # clean
$ npm test               # 45 files, 315 tests passed

Docs-only, so the suites are unchanged from #20; re-run to confirm no drift. I could not test against the live EVE or Discord portals — that needs your developer-portal credentials and a running tunnel. Everything the code determines is verified above; what each portal accepts in its redirect-URI field is the part you'll confirm on first use.

Follow-up I did NOT implement

APP_BASE_URL could normalize its trailing slash in src/config.ts (a one-line .transform), turning this footgun into a non-issue rather than a documented caution. I left it out deliberately: CLAUDE.md says to stop and ask before touching the OAuth flow, and APP_BASE_URL drives both callbacks. Happy to send it as its own small PR if you want it.

🤖 Generated with Claude Code

Stage 4, documentation only. The seeded cookie from Stage 3 covers most dev
work; this covers the case it cannot — changing the login or character-link
flows themselves, which needs the real providers to redirect back to your
machine.

Every concrete claim was verified against the code rather than described from
memory:

  - Redirect URIs are string-concatenated, not URL-joined, so a trailing slash
    on APP_BASE_URL yields `//auth/eve/callback` and z.string().url() accepts it
    silently — it surfaces much later as an unexplained redirect mismatch.
  - One EVE entry covers both flows: /auth/eve/login and /auth/eve/link both
    call buildEveAuthorizeUrl, so they share /auth/eve/callback.
  - EVE sends redirect_uri only on authorize; exchangeEveCode sends grant_type,
    code and code_verifier. Discord sends it TWICE — authorize and token
    exchange — and they must match. Changing APP_BASE_URL mid-flow therefore
    breaks Discord linking while EVE login keeps working, which reads like a
    Discord outage and is not.
  - Once APP_BASE_URL is https the session cookie is issued Secure, so the
    browser will not return it over http://localhost — you appear logged out no
    matter how often you log in. Browse the tunnel origin.

Recommends .env.local for the override: it wins over .env, .env* is gitignored
apart from .env.example, and switching back is deleting one file.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c09adf2b-c87b-40b0-a38e-fd80296c797a

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.

@guarzo
guarzo merged commit 65429fc into worktree-local-dev-stage3 Aug 3, 2026
4 checks passed
guarzo added a commit that referenced this pull request Aug 3, 2026
Stage 4, documentation only. The seeded cookie from Stage 3 covers most dev
work; this covers the case it cannot — changing the login or character-link
flows themselves, which needs the real providers to redirect back to your
machine.

Every concrete claim was verified against the code rather than described from
memory:

  - Redirect URIs are string-concatenated, not URL-joined, so a trailing slash
    on APP_BASE_URL yields `//auth/eve/callback` and z.string().url() accepts it
    silently — it surfaces much later as an unexplained redirect mismatch.
  - One EVE entry covers both flows: /auth/eve/login and /auth/eve/link both
    call buildEveAuthorizeUrl, so they share /auth/eve/callback.
  - EVE sends redirect_uri only on authorize; exchangeEveCode sends grant_type,
    code and code_verifier. Discord sends it TWICE — authorize and token
    exchange — and they must match. Changing APP_BASE_URL mid-flow therefore
    breaks Discord linking while EVE login keeps working, which reads like a
    Discord outage and is not.
  - Once APP_BASE_URL is https the session cookie is issued Secure, so the
    browser will not return it over http://localhost — you appear logged out no
    matter how often you log in. Browse the tunnel origin.

Recommends .env.local for the override: it wins over .env, .env* is gitignored
apart from .env.example, and switching back is deleting one file.
guarzo added a commit that referenced this pull request Aug 3, 2026
* feat: dev seed script with paste-able session cookies

EVE SSO rejects the fake client id in .env.example, so there was no way to
browse a running dev app as a real account — least of all an admin. The
seeding machinery existed but was e2e-only: pinned to the test database and
port 3111, and insert-only, because e2e gets a truncated database before every
test and never had to survive a second run.

  npm run db:seed              upsert; safe to re-run
  npm run db:seed -- --reset   TRUNCATE first, for a clean slate

Seeds six accounts — an admin with two alts, a flygd member with an alt, blue,
green, plus cryo and tier_locked so the admin pages have those states to
render — and prints a session cookie for each.

IDEMPOTENT BY DEFAULT, which is the whole design. Character ids are fixed (a
reserved 91_000_0xx block, clear of e2e's 90_000_0xx counter so the two can
never collide) and every row is upserted, so a second run converges instead of
dying on a duplicate primary key. The account and its main character are
upserted in ONE transaction because account_main_character_fk is DEFERRABLE
INITIALLY DEFERRED and only checked at commit.

Both paths write rows — --reset destructively, the default by adding fixture
accounts — and a dev seed has no legitimate remote use, so the script refuses
any DATABASE_URL that is not provably loopback. ALLOW_REMOTE_SEED=1 overrides.
The host is lower-cased first: `postgres:` is a NON-SPECIAL URL scheme, so
WHATWG preserves host case (`http:` would not), and without that
postgres://LOCALHOST/... is refused — which would train developers to reach
for the override to work around a false rejection.

Deliberately NOT shared with e2e/helpers.ts or tests/helpers/seed.ts. The repo
already runs two independent seed helpers with different contracts; this is a
third. Unifying them would need one helper parameterised for insert-only AND
upsert, which costs more than the ~20 duplicated lines and puts the e2e suite
at risk to make dev nicer. The session hashing is NOT duplicated — createSession
from src/services/session.ts is the real one.

Also removes a duplicate "engines" key in package.json: main carried both
">=22" (#13) and ">=22.9" (#12). JSON.parse keeps the last, so it resolved
correctly by luck of ordering; a tool taking the first would have read a floor
below the one fly.toml's release command depends on.

Stage 3 of docs/superpowers/specs/2026-08-03-local-dev-setup.md

* docs: testing EVE SSO and Discord linking over a tunnel (#21)

Stage 4, documentation only. The seeded cookie from Stage 3 covers most dev
work; this covers the case it cannot — changing the login or character-link
flows themselves, which needs the real providers to redirect back to your
machine.

Every concrete claim was verified against the code rather than described from
memory:

  - Redirect URIs are string-concatenated, not URL-joined, so a trailing slash
    on APP_BASE_URL yields `//auth/eve/callback` and z.string().url() accepts it
    silently — it surfaces much later as an unexplained redirect mismatch.
  - One EVE entry covers both flows: /auth/eve/login and /auth/eve/link both
    call buildEveAuthorizeUrl, so they share /auth/eve/callback.
  - EVE sends redirect_uri only on authorize; exchangeEveCode sends grant_type,
    code and code_verifier. Discord sends it TWICE — authorize and token
    exchange — and they must match. Changing APP_BASE_URL mid-flow therefore
    breaks Discord linking while EVE login keeps working, which reads like a
    Discord outage and is not.
  - Once APP_BASE_URL is https the session cookie is issued Secure, so the
    browser will not return it over http://localhost — you appear logged out no
    matter how often you log in. Browse the tunnel origin.

Recommends .env.local for the override: it wins over .env, .env* is gitignored
apart from .env.example, and switching back is deleting one file.

* fix: address CodeRabbit review on #20

Seven findings, all verified against the code first. Two were real bugs:

  - The non-local refusal message called new URL(cfg.databaseUrl).host to name
    the host — but that branch is reached BY unparseable URLs, so it threw a
    TypeError and printed a stack trace instead of the guidance. Confirmed:
    `new URL("not a url")` throws. Now degrades to "unparseable DATABASE_URL".
  - The entry-point guard was process.argv[1]?.endsWith("seed-dev.ts"). Rename
    the file or emit it as .js and main() silently stops running: the command
    exits 0 having seeded nothing. Now compares resolved paths against
    fileURLToPath(import.meta.url).

The truncation table list was duplicated between scripts/seed-dev.ts and
tests/helpers/db.ts. Drift there is silent — a new table missing from one copy
leaves stale rows after --reset and fails no test. Both now import
src/db/tables.ts, and a new test asserts the list matches information_schema,
so adding a table without registering it breaks a test instead of leaking rows.

Test fix: the session-reissue assertion compared two Sets for inequality, which
passes if only one of six cookies changed. Now asserts full disjointness.

Docs: the cookie step said the "value" is the printed string, but the script
prints a full name=value assignment and devtools has separate fields — pasting
the whole line fails to authenticate. The documented id range said 91_000_0xx
while alts use 91_000_1xx; corrected to the reserved 91_000_000-91_999_999
range. And "later file first" was a confusing way to describe env precedence —
now states that Next applies its own .env.local-wins rule while the tsx scripts
get the same result from flag order.
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.

1 participant