# Admin API for agents

This page is for an agent or script that does SocialScope admin setup on behalf of a SocialScope admin: listing clients, adding an origin or a return URL, changing a client's providers. It is not for integrating an app. An app uses the consumer API with its application key, described in [Authentication](authentication.md).

An admin API key acts as the admin who minted it. Every change it makes is recorded under that admin's name with the key's ID.

## Get a key

Only a SocialScope admin can mint a key. In the admin panel, open **API keys**, choose **Create key**, and confirm with Google when asked. Pick:

- a label that says where the key will live, such as `ci setup agent`
- a scope, `READ` or `WRITE` (see [Scopes](#scopes))
- a lifetime in days. Every key expires. On the development deployment the maximum is 90 days. On production it is 30.

The panel shows the key once, with the endpoint URL to use. Nothing can show it again. If it is lost, revoke it and mint a new one.

Each admin can hold at most 5 active keys.

## Store it

The key looks like this:

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

The key ID is a UUID and the secret is 64 lowercase hexadecimal characters. Keep the whole value in a secret store or an environment variable, next to the endpoint:

```dotenv
SOCIALSCOPE_ADMIN_GRAPHQL_URL=https://<admin host>/api/admin/graphql
SOCIALSCOPE_ADMIN_API_KEY=ssa_<key-id>.<secret>
```

Rules:

- Read the key from the environment. Never paste it into a prompt, a chat, a commit, a test fixture or a log.
- Never print it, echo it or include it in an error message. If your tool logs request headers, turn that off.
- Never put it in browser code, browser storage or any variable bundled into a browser or mobile app.
- If the key may have been exposed, ask an admin to revoke it now. Revoking costs nothing.

The admin host is in the panel's one-time key dialog and in your team's runbook. This page does not list it.

## Call the API

Send `POST` to `/api/admin/graphql` on the admin host, over HTTPS, with a JSON body and the key as a bearer token:

```http
POST /api/admin/graphql
Content-Type: application/json
Authorization: Bearer ssa_<key-id>.<secret>
```

Send exactly one `Authorization` header and no `Cookie` header. A request that carries a cookie is refused, so use a client with no cookie jar. You do not need an `Origin` header or a CSRF token.

With `curl`, pass the key through a header file read from standard input, not as a command-line argument. Arguments are visible to other users of the machine in the process list and often land in shell history:

```bash
printf 'Authorization: Bearer %s\n' "$SOCIALSCOPE_ADMIN_API_KEY" |
  curl --silent --fail-with-body "$SOCIALSCOPE_ADMIN_GRAPHQL_URL" \
    --header @- \
    --header "Content-Type: application/json" \
    --data '{"query":"{ adminClients { id slug webOrigins returnUrls enabled } }"}'
```

`printf` is a shell builtin, so the key never appears as an argument of a separate process.

With `fetch` in Node.js:

```ts
const response = await fetch(process.env.SOCIALSCOPE_ADMIN_GRAPHQL_URL!, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${process.env.SOCIALSCOPE_ADMIN_API_KEY}`,
  },
  body: JSON.stringify({
    query: `mutation($id: ID!, $origin: String!) {
      addClientOrigin(id: $id, origin: $origin) { id webOrigins }
    }`,
    variables: { id: "<client id>", origin: "https://app.example.com" },
  }),
});
const { data, errors } = await response.json();
```

Read `errors` on every response. A `200` status alone does not mean the operation succeeded.

Introspection is off. The operations and their arguments are listed below. Send one operation per request.

Send the full query text every time. Do not rely on persisted-query hashes: the server keeps no guaranteed cache of them, and a hash-only request it does not recognise answers `UPSTREAM_UNAVAILABLE`, which retrying will not fix.

## Scopes

| Scope   | Can call                                                        |
| ------- | --------------------------------------------------------------- |
| `READ`  | Every `READ` operation below                                    |
| `WRITE` | Every `READ` and `WRITE` operation below                        |

A `WRITE` key does not need the Google confirmation that the panel asks for before these changes. The key itself is that permission, which is why every key expires and every write names it.

## Operations

No key can call a panel-only operation, whatever its scope.

| Operation | Kind | Class |
| --- | --- | --- |
| `previewTemplate` | Query | `READ` |
| `adminMembers` | Query | `READ` |
| `adminAuditEntries(first, after, apiKeyId)` | Query | `READ` |
| `adminClients` | Query | `READ` |
| `adminClient(id)` | Query | `READ` |
| `adminProviderAvailability` | Query | `READ` |
| `adminRegistrations` | Query | `READ` |
| `createClient(input)` | Mutation | `WRITE` |
| `setClientEnabled(id, enabled)` | Mutation | `WRITE` |
| `setClientDisplayName(id, displayName)` | Mutation | `WRITE` |
| `setClientPolicy(id, providers, capabilities)` | Mutation | `WRITE` |
| `addClientTenantKey(id, tenantKey)` | Mutation | `WRITE` |
| `removeClientTenantKey(id, tenantKey)` | Mutation | `WRITE` |
| `addClientOrigin(id, origin)` | Mutation | `WRITE` |
| `removeClientOrigin(id, origin)` | Mutation | `WRITE` |
| `addClientReturnUrl(id, url, confirmedNoOnwardRedirect)` | Mutation | `WRITE` |
| `removeClientReturnUrl(id, url)` | Mutation | `WRITE` |
| `setClientPreviewOrigins(id, enabled)` | Mutation | `WRITE` |
| `addClientPreviewReturnPath(id, path)` | Mutation | `WRITE` |
| `removeClientPreviewReturnPath(id, path)` | Mutation | `WRITE` |
| `apiKeys` | Query | Panel only |
| `mintApiKey`, `revokeApiKey` | Mutation | Panel only |
| `addAdminMember`, `removeAdminMember` | Mutation | Panel only |
| `issueClientCredential`, `revokeClientCredential` | Mutation | Panel only |
| `publishRegistration`, `disableRegistration` | Mutation | Panel only |
| `setClientGoogleIdentity` | Mutation | Panel only |

`createClient` and `addClientReturnUrl` take `confirmedNoOnwardRedirect: true`. Pass it only after you have read the app's route for that return URL and confirmed it never redirects onward to an address taken from the request.

The operation is checked as a whole before anything runs. If one field in it is refused, nothing in it runs. Every root field in the document counts, including one marked `@skip` or `@include(if: false)`, so a refused field cannot be hidden behind a directive.

## What a key cannot do

A key cannot list, mint or revoke admin API keys, add or remove admins, issue or revoke an app's application key, publish or disable a provider registration, or turn Google sign-in on or off for a client. Ask an admin to do those in the panel. A leaked key therefore cannot create more access.

## Errors

Errors follow the shape in [Errors and result codes](errors.md): read `extensions.code`, and `extensions.reason` where one is listed.

| Code | Reason | Meaning | What to do |
| --- | --- | --- | --- |
| `UNAUTHENTICATED` | | The key is malformed, unknown, wrong, expired or revoked, or its admin was removed or changed Google account. Every case looks the same | Stop. Ask an admin for a new key. Do not retry |
| `FORBIDDEN` | `API_KEY_READ_ONLY` | A `READ` key sent a mutation | Use a `WRITE` key, or ask an admin to make the change |
| `FORBIDDEN` | `PANEL_ONLY` | The operation is panel only | Ask an admin to do it in the panel |
| `FORBIDDEN` | | Plain HTTP, a `Cookie` header, two `Authorization` headers, or the wrong host | Fix the request. Use HTTPS, one header and no cookies |
| `BAD_USER_INPUT` | | An argument failed validation, or the document is invalid | Fix the input. The message does not name the field |
| `NOT_FOUND` | | The ID does not exist | Re-read the list |
| `CONFLICT` | Sometimes | The change breaks a rule, such as removing a client's last origin, or the row was busy | Read `reason` if present, re-read the current state, then decide. A busy row can be retried once |
| `UPSTREAM_UNAVAILABLE` | | The server could not complete the request | Retry later with backoff. Re-read state first, because the change may have applied |

## Expiry and revocation

A key stops working the moment it expires or an admin revokes it. Any admin can revoke any key. Removing an admin also revokes all their keys. Keys are never extended, reactivated or rescoped. Mint a new one instead.

Every write a key makes appears in the panel's audit log as "Via API key" followed by the first 8 characters of the key ID. The key's first authenticated request is recorded as "API key first used", even when its operation is refused for the key's scope. An admin can filter the audit log to one key, so after a leak they can see everything it changed. Audit entries are kept for 30 days.

## Checklist for an agent

1. Read the key and the endpoint from the environment. Never print either secret value.
2. Start with a `READ` query, such as `adminClients`, to confirm the key works.
3. Make one change per request and check `errors` each time.
4. After a write, read the object back, and check `adminAuditEntries(apiKeyId: "<your key ID>")` when you need proof the change landed.
5. Report what you changed, by client slug and field, to the admin who gave you the key.
