Skip to content

Add SaaS pairing-code handshake endpoints#18

Merged
andrew-jon-p7a merged 3 commits intomainfrom
feature/pairing-code-connect
Apr 23, 2026
Merged

Add SaaS pairing-code handshake endpoints#18
andrew-jon-p7a merged 3 commits intomainfrom
feature/pairing-code-connect

Conversation

@andrew-jon-p7a
Copy link
Copy Markdown
Contributor

Summary

Three endpoints so the OSS side is authoritative for member identity in the SaaS self-hosted connect flow:

  • POST `/saas-connect/bind` (auth-required) — stores `(code → memberName)` in a process-local Map. 10-min TTL. Member is read from the authenticated `LoadedMember`.
  • GET `/saas-connect/lookup?code=X` (no auth) — returns `{ memberName }` if bound, 404 otherwise. Single-use: consumed on read so a stale retry fails.
  • GET `/setup/connect-saas?code=X` — vanilla-HTML confirmation page. Probes `/session` client-side; if signed in, shows "Authorize AgentC7 SaaS as ``" with a button that POSTs to `/saas-connect/bind` with cookies. If not, renders a "Sign in first" fallback that preserves the code.

Self-contained HTML — no `@agentc7/web-shell` changes. Keeps the SaaS-embedded shell from accidentally inheriting the route.

Trust model

SaaS user hands you a pairing code. You confirm the binding on your ac7 while signed in as the real member. Mallory with a SaaS account can't impersonate Alice on your server because she can't sign into your server as Alice to complete step 2. The callback endpoint is single-use, which removes replay as an attack vector.

Pair

Platform PR: https://github.com/agentc7/platform/pull/new/feature/pairing-code-connect — the SaaS side of the handshake + the SPA flow.

Test plan

  • `pnpm -r test` — 10 new cases in `apps/server/test/saas-connect.test.ts` cover: bind requires auth, bind stores memberName, lookup returns + 404s on replay, lookup requires `?code=`, TTL sweeps expired bindings, HTML page embeds the code safely (HTML-escaped), HTML page wires `/session` + `/saas-connect/bind`.

🤖 Generated with Claude Code

andrew-jon-p7a and others added 3 commits April 23, 2026 14:56
Three endpoints on the OSS side so member identity can flow OSS →
SaaS instead of being asserted by a SaaS account holder:

  - POST /saas-connect/bind (auth-required)
      Stores (code → memberName) in a process-local Map with 10-min
      TTL. Member is read from the authenticated `LoadedMember`.

  - GET  /saas-connect/lookup?code=X (no auth)
      Returns { memberName } if bound, 404 otherwise. Single-use:
      consumed on read so a stale SaaS retry (or any replay) fails.

  - GET  /setup/connect-saas?code=X (no auth at the request layer;
      client-side probes /session)
      Vanilla-HTML confirmation page. If signed in, shows a
      "Authorize AgentC7 SaaS as <member>" button that POSTs to
      /saas-connect/bind with cookies. If not, shows a sign-in link
      that preserves the code. Self-contained — no @agentc7/
      web-shell changes so the SaaS-embedded shell doesn't
      accidentally inherit the route.

Trust model (see updated docs/self-hosted-connect.mdx): the SaaS
never asserts who you are on your ac7. A SaaS user hands you a
pairing code; you confirm on your server while signed in as the
real member; the SaaS's status endpoint polls a callback to learn
which member you bound. Mallory with a SaaS account can't
impersonate Alice on acme-ac7.com because Mallory can't sign into
acme-ac7.com as Alice to complete step 2.

Tests: bind requires auth; lookup is single-use; TTL sweeps expired
bindings; HTML page embeds the code safely (HTML-escaped) and
wires /session + /saas-connect/bind. 10 new cases in
apps/server/test/saas-connect.test.ts.

Also updates the self-hosted-connect docs to describe the new
three-step handshake and drop the old "type your member name"
instructions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLI (ac7-connect-saas)
  * New binary `ac7-connect-saas --url <url> --member-name <name>
    [--saas <origin>] [--config-path <path>]`. Runs OAuth device flow
    against the SaaS: /register/start to get codes, prints the
    verification URL + user code, polls /register/status until
    authorized, writes the returned jwt block to a sidecar overlay
    file.
  * Second tsup entry + package.json bin alongside ac7-server.
  * Overlay write is atomic (write→fsync→rename→chmod 0600).

Config overlay (members.ts)
  * Primary `config.json` loader now checks for a sibling
    `<base>.saas.json` and merges its `jwt` block into the loaded
    config with overlay-beats-primary precedence.
  * Overlay schema is narrow — only `{ jwt: {...} }` — so future
    SaaS-managed settings can be added deliberately rather than
    leaking through.
  * User's primary config is never mutated by the CLI; removing
    the overlay disables the SaaS connection cleanly.
  * New exported helper `saasOverlayPathFor(configPath)` keeps path
    derivation centralized (used by the CLI and the loader).

/setup/connect-saas iframe mode
  * Page now accepts `?mode=iframe&parentOrigin=<url>` in addition
    to tab mode. Render-time validates parentOrigin against the URL
    parser; invalid values fall back to tab mode rather than using
    a wildcard postMessage target.
  * On successful bind, iframe mode postMessages
    `{ type: 'saas-connect-bound', code, memberName }` to the
    validated parent origin. Tab mode still renders the "close this
    tab" copy.
  * Used by the SaaS invite-redeem iframe flow — the SaaS-embedded
    iframe listens for the postMessage + calls its own
    /redeem/complete endpoint to exchange the code for the
    OSS-attested memberName.

Docs
  * Rewrote self-hosted-connect.mdx around the new architecture:
    server registration via CLI, invite-based teammate join via
    iframe, disconnect-from-either-side semantics, troubleshooting.

Tests
  * 3 new iframe-mode cases in saas-connect.test.ts covering
    postMessage wiring, parentOrigin validation, and the garbage-
    parentOrigin fallback to tab mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- escape shadowed the `escape` global; biome's noShadowRestrictedNames
  flagged it. Renamed to escapeHtml which is clearer anyway.
- connect-saas.ts: biome formatter preferred broken-out signatures for
  a couple of multi-line fns.
@andrew-jon-p7a andrew-jon-p7a merged commit ad1d569 into main Apr 23, 2026
1 check passed
andrew-jon-p7a added a commit that referenced this pull request Apr 29, 2026
Resolves textual conflicts in the import sort order of three files
where my new enrollment-related imports landed adjacent to imports
added by the SaaS pairing PR (#18) and dusk styling PR (#23):

- apps/server/src/app.ts (3 conflict blocks, all kept-mine)
- packages/sdk/src/client.ts (4 conflict blocks, all kept-mine)
- apps/server/test/channels-endpoints.test.ts (3 blocks, kept-mine)

All resolutions are pure "keep my additions" — origin/main has
nothing in those positions, the conflicts were textual artifacts
of git's auto-merge giving up on adjacent alphabetized sorts.

Also pulled in the dependabot-merged @hono/node-server 2.0.0 bump
(#22). Verified no breaking-change impact: the serve() factory
pattern we use is stable across the major version. All 717 tests
pass and biome is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: andrew-jon-p7a <andrewprzybilla@gmail.com>
@andrew-jon-p7a andrew-jon-p7a deleted the feature/pairing-code-connect branch April 29, 2026 05:03
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