# The write API: keys, scopes, dry run, publish, invites, directory, usage

Everything that changes an organisation account goes through the signed admin API on the back end
(`tenant.json` `gateway.url`'s host). Personal accounts have no write API: the person imports the file in the app.

## Contents
- Keys
- Signing a request
- Scopes
- Publishing config
- Refusals you will see
- Invites, directory, usage, transcripts
- What the skill's scripts do

## Keys

Two Ed25519 key pairs, both made on the publisher's own machine. **Their private halves never leave it**: kept in
files with mode 0600 (or the Keychain), passed to scripts by path, never printed, pasted into chat, or committed.

| Key | Signs | Its public half lives in |
|---|---|---|
| **Server key** | each admin API request | `private/tenant.json` `serverKeys: [{ id, alg: "Ed25519", publicKey, scopes }]`, added by the account owner (or Streamliner, for a new account) |
| **Publisher key** | the manifest | `enrolment.json` `keys[]` (or the live manifest's `nextKeys`) |

Make a key pair (prints only the public half):

```bash
node -e 'const fs=require("fs"),c=require("crypto");const k=c.generateKeyPairSync("ed25519").privateKey.export({format:"jwk"});k.kid=process.argv[1];fs.writeFileSync(process.argv[2],JSON.stringify(k),{mode:0o600});console.log(k.kid,k.x)' claude-setup-oct ./server-key.jwk
```

Ask the owner to register the public half with the narrowest scopes and an expiry. Never ask for, accept or use an
employee's sign-in, session or password for automation: server credentials belong to the account.

## Signing a request

Headers: `X-Streamline-Key: <key id>`, `X-Streamline-Timestamp: <unix seconds>` (±300 s),
`X-Streamline-Nonce: <16–64 of [A-Za-z0-9_-], once per key>`, `X-Streamline-Signature:` base64url Ed25519 over

```
streamline.admin.v1|<account>|<METHOD>|<path?query>|<sha256hex(body)>|<timestamp>|<nonce>
```

## Scopes

Built: `config` (publish anything), `directory`, `invites`, `usage:read`, `transcripts:read`.
Designed, coming: `config:check` (dry run only), `config:streamlines` (agents, templates, forms; not `tenant.json`
auth, `enrolment.json`, connections, keys or networks), `config:keys` (owners only), per-key `expiresAt` (7 days
default for agent keys), rate limits and source CIDRs, and an email to the owners on every publish by a key.
Ask for the least that does the job: a dry-run-only key to draft, a streamlines-only key to publish streamlines.

## Publishing config

| Route | Body → answer |
|---|---|
| `GET /v1/admin/<a>/config` | → `{ tenant, manifest: { version, published, keyId } \| null, files: [{ path, sha256, bytes }] }` (hashes, never contents) |
| `POST /v1/admin/<a>/config/publish?dryRun=1` | `{ files: { <path>: <base64> }, delete: [<path>] }`, the signed `manifest.json` among the files → `{ ok, dryRun, changed, version, put, unchanged, deleted }`. Writes nothing. |
| `POST /v1/admin/<a>/config/publish` | The same, for real. ≤ 32 MB. All or nothing; the manifest is written last. |
| `PUT /v1/admin/<a>/config/<path>[?dryRun=1]` | One file, raw bytes (≤ 2 MB), live at once (for big assets). |
| `DELETE /v1/admin/<a>/config/<path>[?dryRun=1]` | A file the live manifest doesn't list. |

Everything is checked before anything is written: JSON parses, `tenant.json` `id` is the account, `private/tenant.json`
holds public keys only, the manifest is for this account with a higher `version`, every listed file exists with that
hash and size, the signature verifies with the account's publisher keys, every streamline in `kinds` has its file,
and an enabled `config` key remains.

## Refusals you will see

| Status / error | Fix |
|---|---|
| `401 unauthorized \| bad_signature \| stale_request \| replayed_request` | Wrong key id, wrong key, clock off by > 5 min, reused nonce. |
| `403 forbidden_scope` | The key lacks the scope; ask the owner (don't look for another key). |
| `400 bad_path`, `bad_file` | A file outside the accepted paths, or invalid JSON. |
| `400 manifest_required` | Sign first. |
| `409 stale_version` | Re-sign with `--min-version <published + 1>`. |
| `409 manifest_mismatch` (`missing`, `mismatched`) | A file changed after signing, or is missing: re-sign. |
| `bad_manifest_signature`, `no_publisher_keys` | The publisher key isn't the account's; ask the owner. |
| `inconsistent` | A streamline in `kinds` has no `agents/<kind>.json`. |
| `would_lock_out` | `private/tenant.json` would drop the last `config` key; keep the key's entry. |
| `409 publish_in_progress` | Wait and retry. |

## Invites, directory, usage, transcripts

| Route | Who | Body → answer |
|---|---|---|
| `POST /v1/admin/<a>/invites` | admin/owner, or key `invites` | `{ email?, username?, user?, roles?, name?, access? }` → `{ code, url, expiresAt, access }` (single use, 7 days) |
| `PUT \| PATCH \| GET /v1/admin/<a>/directory` | key `directory` | access-and-networks.md |
| `GET /v1/admin/<a>/usage/summary[?fresh=1]` | admin/owner, or `usage:read` | 7d / 30d / month-to-date tokens and estimated USD, by user, role, streamline, model |
| `GET /v1/admin/<a>/transcripts?user=&kind=&since=` | admin/owner, or `transcripts:read` | stream metadata; `?user=&chat=` for events |

An invite link is sensitive (it lets someone join): give it only to the person it is for.

## What the skill's scripts do

- `scripts/sign-config.mjs <folder> --publisher-key <jwk>` writes and signs `manifest.json`.
- `scripts/publish.mjs <folder> --host <base> --key <server-key.jwk>` reads the published index, sends the changed
  files with `?dryRun=1`, and prints a readable diff (`--baseline <folder>` for field-level JSON changes). Only with
  `--yes` does it write, and only after the owner has said yes to that diff.
