# Authentication

Your backend authenticates to SocialScope with an application key sent as a bearer token on every GraphQL request. The key identifies your application, not a user or a tenant. SocialScope is server-to-server only: the endpoint does not enable CORS, so a browser on your origin cannot call it, and the key must never reach browser code anyway. This page covers the key's format, where to store it, how to call the endpoint, rotation and what each authentication failure means.

## The endpoint

| Environment | GraphQL URL                                                |
| ----------- | ---------------------------------------------------------- |
| Dev         | `https://connect.socialscope.hardscope.incdev.dev/graphql` |
| Production  | To be announced                                            |

The endpoint accepts only `POST /graphql` with a JSON body. A `GET` returns `404`. The other paths on that host serve the browser side of Connect and provider callbacks, and your server never calls them.

## The key

An admin issues the key when your app is registered and prints it once. It looks like this:

```text
<key-id>.<secret>
```

The key ID is a UUID and the secret is 64 hexadecimal characters. Send it as:

```http
Authorization: Bearer <key-id>.<secret>
```

Anything that does not match `Bearer <uuid>.<64 hex>` exactly is rejected with `UNAUTHENTICATED`.

## Store it on the server only

```dotenv
SOCIALSCOPE_GRAPHQL_URL=https://connect.socialscope.hardscope.incdev.dev/graphql
SOCIALSCOPE_CONSUMER_KEY=<key-id>.<secret>
```

Rules:

- Keep it in your server's secret store or environment, next to the GraphQL URL.
- Never put it in `NEXT_PUBLIC_*`, `VITE_*` or any variable that is bundled into browser code, in a mobile app, in browser storage, in source control or in client logs.
- Never log the `Authorization` header, request bodies that contain launch tokens or result proofs, or raw response bodies.
- Use a separate key for each environment. Use only the key issued to your app.

The same rule covers the other secrets in the flows: the Connect `launchToken` goes only into the auto-submitted form on your page, and the Google identity `resultProof` never leaves your server.

## Call it

```ts
// lib/socialscope.ts
// Server only. Never import this file from browser code.
const TIMEOUT_MS = 10_000; // 10 s

export class SocialScopeError extends Error {
  constructor(
    readonly code: string,
    readonly correlationId?: string,
    readonly retryAfterSeconds?: number,
  ) {
    super(code);
    this.name = "SocialScopeError";
  }
}

type GraphQLResponse<T> = {
  data?: T;
  errors?: {
    message: string;
    extensions?: { code?: string; correlationId?: string; retryAfterSeconds?: number };
  }[];
};

export async function socialscope<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
  const url = process.env.SOCIALSCOPE_GRAPHQL_URL;
  const key = process.env.SOCIALSCOPE_CONSUMER_KEY;
  if (!url || !key) throw new SocialScopeError("SOCIALSCOPE_NOT_CONFIGURED");

  let response: Response;
  try {
    response = await fetch(url, {
      method: "POST",
      cache: "no-store", // never let a framework cache a SocialScope response
      signal: AbortSignal.timeout(TIMEOUT_MS),
      headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
      body: JSON.stringify({ query, variables }),
    });
  } catch {
    // Network failure or timeout. Never log the request: it carries the key.
    throw new SocialScopeError("SOCIALSCOPE_UNAVAILABLE");
  }

  // A body over 64 KiB is refused with HTTP 413 before GraphQL runs, so there is no GraphQL error body.
  if (response.status === 413) throw new SocialScopeError("SOCIALSCOPE_REQUEST_TOO_LARGE");

  // Errors arrive with HTTP 200, or 400 for validation, limit and batching errors. Read the body either way.
  const payload = (await response.json().catch(() => null)) as GraphQLResponse<T> | null;
  const first = payload?.errors?.[0];
  if (first) {
    throw new SocialScopeError(first.extensions?.code ?? "UPSTREAM_UNAVAILABLE", first.extensions?.correlationId, first.extensions?.retryAfterSeconds);
  }
  if (!response.ok || !payload?.data) throw new SocialScopeError("SOCIALSCOPE_UNAVAILABLE");
  return payload.data;
}

export type Scope = { tenantKey: string; externalSubjectId: string };
```

In Next.js, call this only from route handlers, server actions or server components. The `cache: "no-store"` option stops the framework from serving a cached SocialScope response. `SOCIALSCOPE_NOT_CONFIGURED`, `SOCIALSCOPE_REQUEST_TOO_LARGE` and `SOCIALSCOPE_UNAVAILABLE` are local codes this helper invents. Every other code comes from SocialScope.

Do not rely on the HTTP status alone. A failed operation usually arrives with HTTP 200 and an `errors` array. Invalid queries, request-limit errors and batched requests arrive with HTTP 400 and the same `errors` shape. A body over 64 KiB gets HTTP 413 before GraphQL runs, with no GraphQL body. Always read the body when there is one.

### Smoke test with curl

`status` is the only operation that needs no key. Use it to confirm the route works:

```sh
curl -sS -X POST https://connect.socialscope.hardscope.incdev.dev/graphql \
  -H 'content-type: application/json' \
  -d '{"query":"query Status { status { ok message } }"}'
```

Then check the key with `integrationReadiness`. Read the key from your environment rather than typing it, so it does not land in shell history:

```sh
curl -sS -X POST "$SOCIALSCOPE_GRAPHQL_URL" \
  -H 'content-type: application/json' \
  -H "authorization: Bearer $SOCIALSCOPE_CONSUMER_KEY" \
  -d '{"query":"query Readiness { integrationReadiness { providers { provider available } googleIdentityAvailable } }"}'
```

## What the key does and does not prove

The key proves which application is calling. It does not prove which of your users is calling. Every connection operation takes a `scope` of `{ tenantKey, externalSubjectId }`, and SocialScope trusts your backend to send the right one.

- Derive `tenantKey` from the workspace your signed-in user selected, checked against your own authorization rules.
- Derive `externalSubjectId` from your signed-in user's stable ID.
- Never read either value from a browser form, URL or header that the user controls. Doing so would let one user connect or read accounts in another user's name.

`tenantKey` must be one of your registered tenant keys. An unregistered tenant gets `FORBIDDEN` or `NOT_FOUND`, depending on the operation.

## Rotation and expiry

- A key can have an expiry chosen by the admin, or no expiry.
- Your application can have at most two active keys at once, so you can rotate without downtime: ask for a new key, deploy it, confirm traffic works, then ask the admin to revoke the old one.
- A key with no expiry stays valid until revoked. Rotate it on your own schedule.
- If a key may have leaked, ask the admin to revoke it at once and issue a replacement.

## Authentication failures

| Code              | Meaning                                                                                      | What to do                                                         |
| ----------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `UNAUTHENTICATED` | The key is missing, malformed, unknown, revoked or expired                                   | Check the configured value and expiry. Ask the admin for a new key |
| `FORBIDDEN`       | The key is valid but your application is disabled, or this call is outside your app's policy | Ask the admin to check your client                                 |

Each error carries `extensions.correlationId`, a UUID. Log it, without the key, and quote it when you ask the SocialScope team for help.

## Request limits

Every request is checked against these limits before it runs:

| Limit                  | Value                                                       |
| ---------------------- | ----------------------------------------------------------- |
| Operations per request | 1. Batched requests are rejected                            |
| Aliases                | 8                                                           |
| Field depth            | 8                                                           |
| Cost                   | 100, where each root field costs 10 and each nested field 1 |
| JSON body              | 64 KiB                                                      |

A request over the alias, depth, cost or operation limit fails with HTTP 400 and `BAD_USER_INPUT`, with a message such as `GraphQL cost limit exceeded`. A body over 64 KiB fails with HTTP 413 before GraphQL runs. In practice this means one or two root fields per request. Send separate requests rather than combining many operations with aliases.
