# Server example

Node.js handlers for session-bound state, confidential token exchange, and protected member reads.

Source: https://zetapad.com/developers/docs/examples/server

## Where this runs

Use these handlers inside an existing Node.js server or serverless backend with server-side session middleware. Backend login is required for every app, including with zero scopes. Set `ZETAPAD_CLIENT_ID` and `ZETAPAD_CLIENT_SECRET` on that backend. The production REST origin is fixed as `https://zetapad.com`.

Resolve the visitor's session through your server middleware. Bind the challenge to the same session used for login, persist session changes, and return `Cache-Control: no-store` on state, login, and protected responses. Never accept session identity from the request body or share one state value across visitors.

Use HttpOnly, Secure cookies on HTTPS and configure a session mechanism that works in your embedded context. Add CSRF protection for state-changing routes. Token exchange and userinfo calls must stay on the backend.

## Complete server example

```javascript
import { randomBytes } from "node:crypto";

const ZETAPAD_ORIGIN = "https://zetapad.com";
const CLIENT_ID = process.env.ZETAPAD_CLIENT_ID;
const CLIENT_SECRET = process.env.ZETAPAD_CLIENT_SECRET;

// session is the visitor's server-side session, loaded from a secure cookie.
// It must never come from the request body. Return responses with no-store.
export function createState(session) {
  const state = randomBytes(32).toString("base64url");
  session.zetapadChallenge = { state, expiresAt: Date.now() + 300000 };
  return { state }; // GET /zetapad/state
}

export async function loginWithZetapad(session, body) {
  delete session.zetapadAccess; // Failed reauthentication must not retain access.
  const challenge = session.zetapadChallenge;
  delete session.zetapadChallenge; // A challenge is used only once.
  if (!challenge || Date.now() >= challenge.expiresAt
      || typeof body.state !== "string" || body.state !== challenge.state
      || typeof body.code !== "string" || !body.code.length || body.code.length > 128) {
    throw new Error("Invalid login state");
  }
  const basic = Buffer.from(CLIENT_ID + ":" + CLIENT_SECRET).toString("base64");
  const response = await fetch(ZETAPAD_ORIGIN + "/v1/apps/token", {
    method: "POST", headers: { Authorization: "Basic " + basic,
      "Content-Type": "application/json" },
    body: JSON.stringify({ code: body.code })
  });
  if (!response.ok) throw new Error("Code expired, revoked, or invalid");
  const token = await response.json();
  session.zetapadAccess = { token: token.access_token,
    expiresAt: Date.now() + token.expires_in * 1000 };
  return readZetapadMember(session); // POST /zetapad/login; return display fields.
}

export async function readZetapadMember(session) {
  const access = session.zetapadAccess;
  if (!access || Date.now() >= access.expiresAt) {
    delete session.zetapadAccess;
    throw new Error("Authenticate in the iframe again");
  }
  // Call this before every protected read or action, including after app login.
  const response = await fetch(ZETAPAD_ORIGIN + "/v1/apps/userinfo", {
    headers: { Authorization: "Bearer " + access.token }, cache: "no-store"
  });
  if (!response.ok) {
    delete session.zetapadAccess;
    throw new Error("Zetapad access ended. Authenticate in the iframe again.");
  }
  return response.json(); // Verified userId, scopes, and approved fields.
}
```

## Connect the handlers

| Your app route | Handler | Purpose |
| --- | --- | --- |
| `GET /zetapad/state` | `createState(session)` | Issue a random, five-minute visitor challenge |
| `POST /zetapad/login` | `loginWithZetapad(session, body)` | Consume state, exchange the one-use code, and read the verified member |
| Every protected route | `readZetapadMember(session)` before its action | Reject missing, expired, or revoked Zetapad access |

Your routing layer should catch handler failures, return an authentication error, persist the cleared session state, and stop the protected action. Do not catch an error and then continue serving protected content.

`readZetapadMember` returns the backend-verified `userId` and `scopes` even with no permissions. Use this `userId` as the member key for persistent experiences across apps. Resolve it from userinfo through the server session; never use a caller-supplied ID to authorize saved-data access.

The login handler returns this member to the browser for display after verification. You may limit that response to fields your UI needs. The iframe authorization reply contains no identity or profile data; returning display IDs from your own backend does not make them login credentials.

## End access correctly

The token expires after 15 minutes. Obtain a fresh code through the iframe rather than attempting a refresh-token exchange. `readZetapadMember` checks current userinfo before returning identity and removes access when userinfo fails.

Member disconnection, unpublishing, deletion, and secret rotation invalidate issued access immediately. Your app must stop its own protected reads and actions when checks fail; Zetapad cannot terminate arbitrary sessions stored by your app.

## Restrict document access

These handlers protect server-verified member access. To block direct browser navigation, apply the separate [document guard](https://zetapad.com/developers/docs/access-control#restrict-app-document-requests) and use the [browser startup check](https://zetapad.com/developers/docs/examples/browser).

For a complete local Zetapad stack, replace `ZETAPAD_ORIGIN` with `http://localhost:3000`. Keep the production value for deployed integrations.
