# JamAuth Integration Contract

Use this file with Cursor, Claude Code, Codex, Copilot, v0, or another AI coding tool to add JamAuth to an existing project.

JamAuth is magic-link login plus an access decision. The integration has three pieces:

1. A login page or widget.
2. A callback route that exchanges the magic-link grant and sets a first-party session cookie.
3. A Server Gate that checks the JamAuth session before returning protected content.

Do not protect paid or private content only with browser JavaScript. Visibility Mode can show or hide page sections after the page loads, but Server Gate is the path for content, files, pages, APIs, videos, or downloads that must not be reachable without access.

---

## Variables

Use these values unless the project owner gives you different ones:

```env
JAMAUTH_HOST=https://api.jamauth.com
JAMAUTH_CLIENT_ID=YOUR_JAMAUTH_CLIENT_ID
```

`JAMAUTH_CLIENT_ID` is not a secret. It identifies the JamAuth project.

`JAMAUTH_CLIENT_SECRET` is not required for the standard Server Gate session check described here. Do not put any secret in browser JavaScript or static HTML.

---

## Step 1 — Add the login widget

Create a login page that loads JamAuth's hosted widget.

Example HTML:

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Log in</title>
</head>
<body>
  <main>
    <h1>Log in</h1>
    <p>Enter your email to receive a magic link.</p>
    <p id="jamauth-status"></p>
    <div id="jamauth-login"></div>
  </main>

  <script>
    (function () {
      var params = new URLSearchParams(window.location.search);

      if (params.get("jamauth") === "complete") {
        document.getElementById("jamauth-status").textContent =
          "Login complete. You can continue.";
      }

      var script = document.createElement("script");
      script.src = "https://api.jamauth.com/jamauth.js";
      script.dataset.host = "https://api.jamauth.com";
      script.dataset.clientId = "YOUR_JAMAUTH_CLIENT_ID";
      script.dataset.redirect = window.location.origin + "/login.html?jamauth=complete";
      script.dataset.target = "#jamauth-login";
      document.head.appendChild(script);
    })();
  </script>
</body>
</html>
```

The redirect should point back to a page on the same site. The callback route described below is used by the magic-link completion flow to set the first-party session cookie.

---

## Step 2 — Add the callback route

Create a server route that receives the magic-link callback, exchanges the grant with JamAuth, and sets the returned session token as a first-party, `HttpOnly` cookie.

The exact route path can vary by framework. Examples:

- Netlify Functions: `/.netlify/functions/jamauth-callback`
- Next.js route handler: `/api/jamauth/callback`
- Express route: `/jamauth-callback`
- Cloudflare Worker route: `/jamauth-callback`

The route must:

1. Accept `GET`.
2. Read `code` and `state` from the query string.
3. Read the PKCE verifier from the `__jamauth_pkce` cookie.
4. POST `{ code, client_id, code_verifier }` to JamAuth `resolve-grant`.
5. Read the returned session token.
6. Set a first-party `HttpOnly` session cookie on the app's own domain.
7. Redirect to a safe same-origin path.

Grant exchange endpoint:

```txt
POST https://api.jamauth.com/.netlify/functions/resolve-grant
Content-Type: application/json
```

Body:

```json
{
  "code": "CODE_FROM_QUERY_STRING",
  "client_id": "YOUR_JAMAUTH_CLIENT_ID",
  "code_verifier": "PKCE_VERIFIER_FROM_COOKIE"
}
```

The successful response includes a session token, usually as `token` or `session_token`.

### Callback redirect rule

Do not redirect to arbitrary external URLs supplied by the request.

If your callback accepts a `next`, `redirect`, or `return_to` query parameter, only allow safe relative paths on the same site.

Use a helper like this:

```js
function safeRelativePath(value, fallback = "/") {
  if (!value || typeof value !== "string") return fallback;

  // Allow only same-origin relative paths like /members.
  // Block protocol-relative URLs like //evil.example.
  if (!value.startsWith("/") || value.startsWith("//")) return fallback;

  // Optional: block backslash tricks and control characters.
  if (/[\\\u0000-\u001f\u007f]/.test(value)) return fallback;

  return value;
}
```

Example:

```js
const requestedRedirect = url.searchParams.get("next") || url.searchParams.get("redirect");
const redirectTo = safeRelativePath(requestedRedirect, "/");
return redirect(redirectTo);
```

Hard rule: never redirect directly to a user-supplied absolute URL after login.

### Session cookie

Production cookie:

```txt
__Host-jam_session=<session token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000; Secure
```

Use `Secure` in production.

Important local-development note: `__Host-` cookies require `Secure`, and browsers will not accept `Secure` cookies over plain `http://localhost`. For local testing, either run HTTPS locally or use a clearly local-only fallback cookie name such as `jam_session_dev`. Keep `__Host-jam_session` in production.

Do not expose the session token to browser JavaScript.

---

## Step 3 — Add the Server Gate

Use a Server Gate for any page, file, API response, video, or download that must stay private. The check must happen before your server sends the protected content.

Create a same-origin server route in your app, then validate the JamAuth session server-to-server.

### Recommended cookie forwarding

Forward only the JamAuth session cookie when possible, not the user's entire incoming cookie header.

This keeps unrelated app cookies, analytics cookies, or framework cookies from being sent to JamAuth.

```js
function getCookie(cookieHeader, name) {
  const cookies = String(cookieHeader || "").split(";");

  for (const cookie of cookies) {
    const parts = cookie.trim().split("=");
    const key = parts.shift();
    const value = parts.join("=");

    if (decodeURIComponent(key || "") === name) {
      return decodeURIComponent(value || "");
    }
  }

  return "";
}

function buildJamAuthCookieHeader(incomingCookieHeader) {
  const productionToken = getCookie(incomingCookieHeader, "__Host-jam_session");
  const devToken = getCookie(incomingCookieHeader, "jam_session_dev");

  if (productionToken) return `__Host-jam_session=${encodeURIComponent(productionToken)}`;
  if (devToken) return `jam_session_dev=${encodeURIComponent(devToken)}`;

  return "";
}
```

### Stack-neutral verification helper

```js
async function verifyJamAuthSession(request) {
  const JAMAUTH_HOST = process.env.JAMAUTH_HOST || "https://api.jamauth.com";

  const incomingCookieHeader = request.headers.get
    ? request.headers.get("cookie")
    : request.headers.cookie;

  const jamAuthCookieHeader = buildJamAuthCookieHeader(incomingCookieHeader);

  if (!jamAuthCookieHeader) {
    return { authenticated: false, status: 401 };
  }

  const response = await fetch(`${JAMAUTH_HOST}/.netlify/functions/refresh-session`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Cookie": jamAuthCookieHeader
    }
  });

  if (response.status !== 200) {
    return { authenticated: false, status: response.status };
  }

  const session = await response.json();

  return {
    authenticated: true,
    user_id: session.user_id,
    email: session.email,
    client_id: session.client_id,
    scope: session.scope || [],
    rotated: Boolean(session.rotated),
    exp: session.exp,
    setCookie: response.headers.get("set-cookie") || null
  };
}
```

Use it inside every protected route:

```js
const session = await verifyJamAuthSession(request);

if (!session.authenticated) {
  if (session.status === 402) {
    return redirect("/billing");
  }

  return redirect("/login");
}

// Optional but important:
// enforce any app-specific rule here before sending content.
// Examples:
// - only allow certain emails
// - require an admin role
// - require ownership of this resource
// - require a plan tier
// - check project/team membership

return sendProtectedContent();
```

Important Server Gate rules:

- The browser should call your same-origin route, not JamAuth directly.
- Forward the JamAuth session cookie to JamAuth `refresh-session`.
- Treat only HTTP `200` as authenticated.
- Fail closed on every other status.
- Do not redirect every failure to login. A `402` means the session exists but access/billing is inactive; send that user to a billing, subscription, or access-required page.
- If JamAuth returns `Set-Cookie`, forward it back from your route so rotated sessions reach the browser.
- Do not send protected content until the session check and any app-specific authorization rule pass.
- `JAMAUTH_CLIENT_SECRET` is not required for this Server Gate session check.

### How JamAuth access modes relate to your Server Gate

JamAuth can enforce the access mode configured in the JamAuth dashboard, such as approved emails, Stripe customers, or a custom rule.

That access decision is made when the user logs in, and JamAuth sessions can later be rejected if the session is revoked or invalidated. Your Server Gate verifies that the request has a valid, non-revoked JamAuth session.

Do not re-check Stripe or duplicate JamAuth's dashboard access mode in every protected route unless your app has a specific reason to.

Add your own route-level checks only for concepts JamAuth does not model for you, such as:

- admin roles
- team/project membership
- ownership of a particular resource
- per-document permissions
- plan features inside your own app
- tenant-specific business rules

Before claiming instant mid-session deauthorization for Stripe cancellations, confirm the project's Stripe webhook revocation behavior. If cancellation writes a JamAuth revocation/invalidation record, the next session refresh should fail. If it does not, access may continue until the session is explicitly revoked or expires.

Until automatic live-session revocation on cancellation ships, to remove a cancelled user immediately, invalidate their session from the JamAuth dashboard or contact support.

### What `refresh-session` verifies

- The JamAuth session exists.
- The session token is valid.
- The session is not expired.
- The session has not been revoked or invalidated.
- The tenant/platform state allows refresh.
- Rate limits and metering allow the check.

### What `refresh-session` does not replace

- Your app's per-resource authorization.
- Ownership checks.
- Role checks.
- Project/team membership checks.
- Any route-specific business rule.

If your route has its own concept of ownership or roles, check those after JamAuth verifies the session and before sending the protected response.

---

## Step 4 — Handle refresh-session statuses

JamAuth `refresh-session` is called like this:

```txt
POST https://api.jamauth.com/.netlify/functions/refresh-session
Content-Type: application/json
Cookie: __Host-jam_session=<jwt>
```

No request body is required.

You may also use:

```txt
Authorization: Bearer <jwt>
```

Do not send both cookie and bearer token at the same time.

Successful cookie-mode response:

```json
{
  "rotated": false,
  "exp": 123,
  "client_id": "client_x",
  "user_id": "user@example.com",
  "email": "user@example.com",
  "scope": ["subscriber"]
}
```

If the session is rotated, the response body has `"rotated": true` and JamAuth may return a `Set-Cookie` header. Forward that `Set-Cookie` header to the browser from your same-origin route.

Bearer mode may also include `session_token`.

Known failure statuses include:

- `400` — ambiguous auth, malformed request, or both cookie and bearer token supplied.
- `401` — no session, invalid session, invalidated session, or revoked session.
- `402` — subscription inactive or access inactive. Send the user to billing/access-required, not back into a login loop.
- `405` — method not allowed or unsupported CORS/browser call pattern.
- `429` — rate limited.
- `503` — billing check failed or service temporarily unavailable.
- `500` — internal error.

Treat all non-`200` responses as not authenticated. Do not guess. Do not serve protected content after a failed check.

---

## Step 5 — Framework examples

### Express-style example

```js
app.get("/members", async (req, res) => {
  const session = await verifyJamAuthSession(req);

  if (!session.authenticated) {
    if (session.status === 402) {
      return res.redirect("/billing");
    }

    return res.redirect("/login");
  }

  // Optional app-specific rule.
  // if (!isAllowed(session.email)) return res.status(403).send("Forbidden");

  res.setHeader("Cache-Control", "private, no-store");

  if (session.setCookie) {
    res.setHeader("Set-Cookie", session.setCookie);
  }

  return res.send(renderMembersPage({ email: session.email }));
});
```

### Next.js-style route handler sketch

```js
export async function GET(request) {
  const session = await verifyJamAuthSession(request);

  if (!session.authenticated) {
    const destination = session.status === 402 ? "/billing" : "/login";
    return Response.redirect(new URL(destination, request.url));
  }

  const headers = new Headers({
    "Content-Type": "text/html; charset=utf-8",
    "Cache-Control": "private, no-store"
  });

  if (session.setCookie) {
    headers.append("Set-Cookie", session.setCookie);
  }

  return new Response("<h1>Members only</h1>", { headers });
}
```

### Netlify Function sketch

```js
exports.handler = async (event) => {
  const requestLike = {
    headers: {
      cookie: event.headers.cookie || event.headers.Cookie || ""
    }
  };

  const session = await verifyJamAuthSession(requestLike);

  if (!session.authenticated) {
    return {
      statusCode: 302,
      headers: {
        Location: session.status === 402 ? "/billing" : "/login.html",
        "Cache-Control": "no-store"
      },
      body: ""
    };
  }

  const headers = {
    "Content-Type": "text/html; charset=utf-8",
    "Cache-Control": "private, no-store"
  };

  if (session.setCookie) {
    headers["Set-Cookie"] = session.setCookie;
  }

  return {
    statusCode: 200,
    headers,
    body: "<h1>Members only</h1>"
  };
};
```

Adapt the response format to the framework, but keep the sequence:

1. Read session cookie.
2. Call JamAuth from the server.
3. Fail closed unless JamAuth returns `200`.
4. Send `402` users to billing/access-required, not login.
5. Apply app-specific authorization if needed.
6. Only then send content.

---

## Step 6 — Visibility Mode

Visibility Mode is for showing or hiding page content after JamAuth confirms someone is signed in and allowed. It is useful for previews, member-only sections, lightweight course pages, and low-risk resources.

It is not a replacement for Server Gate when the file, page, video, or API response itself must stay private.

Visibility Mode requires JamAuth widget support for `data-mode="visibility"` and `data-protected`. Test the snippet on a real page before relying on it.

Example intended API:

```html
<!-- Put this near the end of any page you want to gate. -->
<script
  src="https://api.jamauth.com/jamauth.js"
  data-host="https://api.jamauth.com"
  data-client-id="YOUR_JAMAUTH_CLIENT_ID"
  data-mode="visibility"
  data-protected="[data-jamauth-members]"
  data-login-redirect="/login.html"
></script>

<!-- Shown only to signed-in, allowed visitors: -->
<div data-jamauth-members hidden>
  Members-only content goes here.
</div>
```

Visibility Mode controls what is shown after the page loads. The page content still reaches the browser. Use Server Gate when the content itself must not be reachable before access is approved.

Visibility Mode is still an access-control feature. Treat it as part of the JamAuth paid/trial gate, not as free login-only setup.

---

## Step 7 — Verification

### Server Gate verification

Run this test before calling the integration done:

1. Open a private/incognito window.
2. Visit the protected URL directly.
3. Confirm the protected content does not load.
4. Confirm the route redirects to login, billing/access-required, or returns a denied response.
5. Log in with a valid allowed email.
6. Confirm the protected content loads after login.
7. Log out or remove the session cookie.
8. Confirm the protected content is blocked again.

If the protected content loads in a private window before login, the gate is in the wrong place. Move the JamAuth check earlier so it happens before the content is returned.

### Visibility Mode verification

1. Load the page signed out.
2. Confirm the marked content is hidden.
3. Sign in.
4. Reload the page.
5. Confirm the marked content appears.
6. Sign out or remove the session cookie.
7. Confirm the marked content hides again.

This verifies visibility behavior. It does not verify server-side privacy.

---

## Common AI integration mistakes to avoid

Do not:

- Redirect to arbitrary external URLs from the callback route.
- Protect paid/private content only by hiding DOM elements.
- Call JamAuth from the browser and then render private content client-side.
- Send protected HTML, file URLs, video URLs, or API data before the server check passes.
- Put session tokens or secrets in browser JavaScript.
- Treat non-`200` refresh responses as allowed.
- Send `402` users into a login loop.
- Ignore a rotated `Set-Cookie` header from JamAuth.
- Claim OAuth, social login, password login, MFA, SSO, or org management support.
- Invent JamAuth endpoints. Use the endpoints in this file.
- Do not use any legacy or non-API JamAuth host. The default JamAuth API host is `https://api.jamauth.com`.

Do:

- Keep the session cookie `HttpOnly`.
- Use `SameSite=Lax`.
- Use `Secure` in production.
- Use HTTPS locally or a clearly local-only cookie fallback.
- Keep the gate on the server.
- Fail closed.
- Verify with a private-window test.
- Add app-specific authorization checks when the app has roles, ownership, teams, plans, or resource-specific rules.
- Forward only the JamAuth session cookie to JamAuth when possible.

---

## One-sentence model

JamAuth proves the email, refreshes a valid session, and gives your server enough information to decide whether to send the protected content.
