Skip to content

Gateway - #1

Merged
g-eoj merged 13 commits into
mainfrom
demo-gateway
Sep 18, 2026
Merged

g-eoj merged 13 commits into
mainfrom
demo-gateway

Conversation

@g-eoj

@g-eoj g-eoj commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

Demo gateway: invite links and sessions

Adds gateway/, a FastAPI app on Modal that gates demos behind signed invite links.

  • Operator creates an invite with modal run invites.py::create. The record lives in a Modal Dict; the link carries a signed {iid, exp} token.
  • Redeeming the link checks the signature and the Dict record, then sets a signed session cookie capped at 8 hours or the invite's expiry, whichever is sooner.
  • Every gated route re-reads the invite from the Dict, so revoke takes effect on the next request.
  • CI runs ruff, mypy, and pytest on PRs and deploys on push to main. The container installs from uv.lock.
sequenceDiagram
    participant Op as Operator (CLI)
    participant D as Modal Dict
    participant G as Guest
    participant W as Gateway

    Op->>D: put invite:{iid} {expires, revoked, budget}
    Op-->>Op: sign_invite({iid, exp}) → /i/<token>

    G->>W: GET /i/<token>
    W->>W: verify_invite (signature + exp)
    W->>D: get invite:{iid}
    W->>W: is_invite_valid (revoked, expires)
    W-->>G: 303 /menu + Set-Cookie session (signed {iid, sid, exp})

    G->>W: GET /menu (cookie)
    W->>W: read_session (signature + exp)
    W->>D: get invite:{iid}
    W->>W: is_invite_valid
    W-->>G: 200 menu

    Op->>D: revoke: revoked = true
    G->>W: GET /menu (cookie)
    W->>D: get invite:{iid}
    W-->>G: 303 /expired
Loading

g-eoj and others added 13 commits September 15, 2026 12:54
Modal app "gateway" serving a FastAPI instance at the "gateway" label with
placeholder / and /healthz routes. Ignore .env so local secrets stay out
of the repo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CJGmXer6J7V6XZ52t8Ujd
itsdangerous URLSafeSerializer with separate salts for invite links and
session cookies. Expiry is an explicit exp claim in the payload, checked
in one _verify helper with an injectable now for testing. The signing key
is read from the environment at call time so the module imports without
Modal or a .env file.

Tests cover round trips, exclusive expiry boundary, spliced-signature
forgery, cross-salt rejection, key rotation, and missing key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CJGmXer6J7V6XZ52t8Ujd
state.py owns the gateway-state Dict. new_invite builds a record with an
injectable now for testing and returns the id alongside it; get, put, and
list wrap the Dict under an invite: key prefix that never leaks past the
module. Budget is tracked in GPU-seconds.

Tests cover the record shape against a literal dict, id length, and id
uniqueness. Dict accessors are untested by design.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CJGmXer6J7V6XZ52t8Ujd
Local entrypoints that build and store an invite record, sign the link
against the deployed gateway URL resolved via get_web_url, list invites,
and flip the revoked flag. Reads SIGNING_KEY from a local .env.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CJGmXer6J7V6XZ52t8Ujd
GET /i/{token} verifies the signed invite, checks the Dict record for
revocation and expiry, marks first redemption, and redirects to /menu
with a signed session cookie capped by the invite's expiry. /menu is a
placeholder that echoes the session payload. FastAPI app moved to
module level; the gateway Secret is attached at the App.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CJGmXer6J7V6XZ52t8Ujd
require_session reads and verifies the cookie, then re-checks the Dict
so revocation takes effect on the next request. /menu renders a Jinja2
template with the invite label and a placeholder demo list. Templates
ship in the image via add_local_dir anchored to app.py's location.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CJGmXer6J7V6XZ52t8Ujd
ruff lint and format via the ruff-pre-commit mirror, plus local mypy and
pytest hooks that run from the gateway venv, all scoped to gateway/.
Tool config lives in the root pyproject.toml. The unused gateway/__init__.py
is removed so mypy, pytest, and Modal all see gateway/ as a folder of
top-level modules. Remaining changes are the first ruff pass: import
sorting, spacing, and Annotated[..., Depends(...)] for the menu route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CJGmXer6J7V6XZ52t8Ujd
Give the gateway its own pyproject.toml and uv.lock so CI can rebuild the
venv with --locked. Ruff and mypy config move there from the repo root,
which also lets ruff recognize auth and state as first-party imports.

The workflow runs ruff, mypy, and pytest on pull requests and pushes that
touch gateway/, and runs modal deploy only on pushes to main.

Also shorten the invite CLI defaults to 1 day and 60 seconds, and ignore
plans/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CJGmXer6J7V6XZ52t8Ujd
- Resolve the web URL before persisting a new invite so a missing deploy
  cannot leave an orphan record.
- Stop writing redeemed_at in redeem; the unguarded read-modify-write
  could overwrite a concurrent revoke.
- Build the container with uv_sync so it installs the locked versions CI
  tests instead of a hand-copied unpinned list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CJGmXer6J7V6XZ52t8Ujd
Registering the local entrypoints on the gateway App made every
`modal run invites.py` build the image, hydrate the Secret, and start
an ephemeral web endpoint. The CLI only needs the named Dict and the
signing key from .env.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CJGmXer6J7V6XZ52t8Ujd
redeem and require_session now share one check for revoked and expired,
so require_session also honors record expiry and the planned budget
rule has a single home.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CJGmXer6J7V6XZ52t8Ujd
@g-eoj
g-eoj merged commit 1f801d6 into main Sep 18, 2026
2 checks passed
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