> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fetchski-attract.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Key in the database

> Keep the site's API key and signing secret in your database, encrypted, and build the client from it.

By default the client reads the site's API key from the environment. You can keep it in your app's
database instead, encrypted, and change it by running one script, with no redeploy and no edit to your
environment. That suits an app where the key is rotated now and then, or where the person who holds the
key isn't the person who deploys the app.

> [card] Encrypted at rest
>
> AES-256-GCM from `node:crypto`, under an encryption key that never sits in the database.

> [card] Replaced in seconds
>
> Store a new key from the CMS and the old one is gone from your app.

> [card] Same calls
>
> `site.blogPosts().listBlogs()` and every other call work as before, signed as the key asks.

## What you'll build

The SDK has no database of its own, so this guide adds a few small files to your app. Each one is
complete: copy it as shown.

| File | What it does |
| - | - |
| `migrations/create_attract_api_keys.sql` | The `attract_api_keys` table, one row per site |
| `src/db.ts` | Your app's existing database connection (this guide uses PostgreSQL through `pg`) |
| `src/crypto.ts` | Encrypts and decrypts the key and secret |
| `src/attract.ts` | `storeKey()` writes the key; `siteFromDatabase()` builds the client from it |
| `scripts/store-key.ts` | Asks for the key in the terminal and stores it |

Using another database or an ORM? Only `src/db.ts` and the two queries in `src/attract.ts` change: see
[Other databases](#other-databases).

## Before you start

**1. The SDK is installed**

From your own site, with any of its API keys: see [Installation](https://docs.fetchski-attract.com/typescript/installation.md).

**2. Your app has a database**

The one your app already uses. This guide's code is for PostgreSQL through `pg`; on another
database, see [Other databases](#other-databases). If your app doesn't use `pg` yet, add it and its
type declarations:

```bash npm theme={"dark"}
npm install pg
```

```bash pnpm theme={"dark"}
pnpm add pg
```

```bash bun theme={"dark"}
bun add pg
```

```bash npm theme={"dark"}
npm install --save-dev @types/pg
```

```bash pnpm theme={"dark"}
pnpm add -D @types/pg
```

```bash bun theme={"dark"}
bun add -d @types/pg
```

The guide's script runs with `tsx` and asks its questions with `@inquirer/prompts` (which hides the
key and secret as you type them). Add whichever your app doesn't have yet as dev dependencies:

```bash npm theme={"dark"}
npm install --save-dev tsx @inquirer/prompts
```

```bash pnpm theme={"dark"}
pnpm add -D tsx @inquirer/prompts
```

```bash bun theme={"dark"}
bun add -d tsx @inquirer/prompts
```

**3. You have an API key from the site**

In the site's CMS, **API Keys → New API Key** shows three things, once. Keep the page open until you've
stored them:

* the **API key**, `fsk_acme_…`
* its **signing secret**, `fss_…` (none for a token-only key)
* its **signing mode**: the key's **Request signing** setting, on its **Security** tab. You'll pick
  from *Token + signature on writes*, *Token + signature on every request* or *Token only*

## Set it up

**1. Create the table**

One row per site; most apps have one.

```sql migrations/create_attract_api_keys.sql theme={"dark"}
create table attract_api_keys (
  slug           text primary key, -- the acme in fsk_acme_…
  url            text not null,    -- the site's public address
  token          text not null,    -- fsk_…, encrypted
  signing_secret text,             -- fss_…, encrypted; null for a token-only key
  signing        text not null     -- none, writes or all
);
```

Run it with your migration tool, or once by hand:

```bash Terminal theme={"dark"}
psql "$DATABASE_URL" -f migrations/create_attract_api_keys.sql
```

**2. Connect to the database**

If your app already has a `pg` `Pool`, export it from `src/db.ts`. Otherwise:

```ts src/db.ts theme={"dark"}
import pg from 'pg';

export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
```

**3. Create an encryption key**

Generate 32 random bytes:

```bash Terminal theme={"dark"}
openssl rand -base64 32
```

Save the output as `APP_ENCRYPTION_KEY` wherever your app keeps its other secrets (a `.env` file
that isn't committed, or your host's secret settings), never in the database:

```ini .env theme={"dark"}
APP_ENCRYPTION_KEY=q8Jx…base64…=
```

Then the helper that uses it:

```ts src/crypto.ts theme={"dark"}
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

// 32 random bytes, base64: openssl rand -base64 32
const key = Buffer.from(process.env.APP_ENCRYPTION_KEY ?? '', 'base64');
if (key.length !== 32) {
  throw new Error('APP_ENCRYPTION_KEY must be 32 bytes, base64: openssl rand -base64 32');
}

/** `iv.tag.ciphertext`, each base64url. */
export function encrypt(plain: string): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', key, iv);
  const data = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
  return [iv, cipher.getAuthTag(), data].map((part) => part.toString('base64url')).join('.');
}

export function decrypt(sealed: string): string {
  const [iv, tag, data] = sealed.split('.').map((part) => Buffer.from(part, 'base64url'));
  if (!iv || !tag || !data) {
    throw new Error('Not a value encrypt() sealed.');
  }
  const decipher = createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
}
```

**4. Store and read the key**

`storeKey()` builds a client first, so a bad URL, key or secret throws before anything is written.
The slug comes from the key itself (the `acme` in `fsk_acme_…`), and storing a key for a slug that
already has a row replaces that row. `siteFromDatabase()` builds the client from the row, once.

```ts src/attract.ts theme={"dark"}
import { createSiteClient, type SigningMode, type SiteClient } from '@fetchski/attract-cms-sdk';
import { decrypt, encrypt } from './crypto.js';
import { pool } from './db.js';

export interface StoredKey {
  url: string;
  apiKey: string;
  signingSecret?: string;
  signing: SigningMode;
}

export async function storeKey(key: StoredKey): Promise<SiteClient> {
  const site = createSiteClient({
    baseUrl: key.url,
    apiKey: key.apiKey,
    signing: key.signing,
    ...(key.signingSecret ? { signingSecret: key.signingSecret } : {})
  });
  await pool.query(
    `insert into attract_api_keys (slug, url, token, signing_secret, signing) values ($1, $2, $3, $4, $5)
     on conflict (slug) do update set
       url = excluded.url, token = excluded.token,
       signing_secret = excluded.signing_secret, signing = excluded.signing`,
    [site.slug, key.url, encrypt(key.apiKey), key.signingSecret ? encrypt(key.signingSecret) : null, key.signing]
  );
  return site;
}

let cached: SiteClient | undefined;

/** The site's client, from its one row; built once. */
export async function siteFromDatabase(): Promise<SiteClient> {
  if (cached) {
    return cached;
  }
  const { rows } = await pool.query('select url, token, signing_secret, signing from attract_api_keys');
  const [row] = rows;
  if (rows.length !== 1 || !row) {
    throw new Error(`attract_api_keys holds ${rows.length} keys, not one: run npm run attract:store-key.`);
  }
  cached = createSiteClient({
    baseUrl: row.url,
    apiKey: decrypt(row.token),
    signing: row.signing as SigningMode,
    ...(row.signing_secret ? { signingSecret: decrypt(row.signing_secret) } : {})
  });
  return cached;
}
```

**5. Add the store-key script**

It asks for what the CMS showed when the key was made, stores it, then makes one call to the site to
prove the key works:

```ts scripts/store-key.ts theme={"dark"}
import { input, password, select } from '@inquirer/prompts';
import { Forbidden, type SigningMode } from '@fetchski/attract-cms-sdk';
import { storeKey } from '../src/attract.js';

const url = await input({ message: "The site's URL (https://acme.example):", required: true });
const apiKey = await password({ message: 'The API key (fsk_…):', mask: '*' });
const signingSecret = await password({ message: 'Its signing secret (fss_…, empty for a token-only key):', mask: '*' });
const signing = await select<SigningMode>({
  message: 'Its signing mode, as the key\'s Security tab shows:',
  choices: [
    { name: 'Token + signature on writes', value: 'writes' },
    { name: 'Token + signature on every request', value: 'all' },
    { name: 'Token only', value: 'none' }
  ]
});

const site = await storeKey({ url, apiKey, signing, ...(signingSecret ? { signingSecret } : {}) });
try {
  await site.pages().listPages();
} catch (error) {
  // A narrow key may not list pages: Forbidden still proves the site accepted it.
  if (!(error instanceof Forbidden)) throw error;
  console.warn('The key works, but its scopes may not list pages.');
}
console.log(`Stored the API key for ${site.slug}, encrypted, and the site accepted it.`);
```

Add it to `package.json`. `--env-file` loads `.env`; leave it out where your host already sets the
variables:

```json package.json theme={"dark"}
{
  "scripts": {
    "attract:store-key": "tsx --env-file=.env scripts/store-key.ts"
  }
}
```

**6. Store the key**

```bash Terminal theme={"dark"}
npm run attract:store-key
```

```text Terminal theme={"dark"}
✔ The site's URL (https://acme.example): https://acme.example
✔ The API key (fsk_…): ********************************
✔ Its signing secret (fss_…, empty for a token-only key): ********************************
✔ Its signing mode, as the key's Security tab shows: Token + signature on writes
Stored the API key for acme, encrypted, and the site accepted it.
```

**7. Call the site**

Anywhere in your app:

```ts src/posts.ts theme={"dark"}
import { siteFromDatabase } from './attract.js';

const site = await siteFromDatabase();
const posts = await site.blogPosts().listBlogs({});
```

## Replace the key

When you have a new key for the site from its CMS, or its signing mode changes, run the script again with the new
values:

```bash Terminal theme={"dark"}
npm run attract:store-key
```

The site's row is replaced, so the old key and secret are gone the moment the new ones are stored. A
running server keeps the client it already built: restart it to pick up the new key.

## Forget the key

To take the key out of your app, delete its row:

```bash Terminal theme={"dark"}
psql "$DATABASE_URL" -c "delete from attract_api_keys where slug = 'acme'"
```

> [!WARNING]
>
> Deleting the row doesn't switch the key off: it keeps working on the site until you revoke it there too,
> in the CMS under **API Keys**.

## Other databases

Only the connection and the two queries change. With Prisma, for example, model the same table:

```prisma prisma/schema.prisma theme={"dark"}
model AttractApiKey {
  slug          String  @id
  url           String
  token         String
  signingSecret String? @map("signing_secret")
  signing       String

  @@map("attract_api_keys")
}
```

Then `storeKey()` writes with `prisma.attractApiKey.upsert({ where: { slug }, create, update })` and
`siteFromDatabase()` reads with `prisma.attractApiKey.findMany()`. Keep the `encrypt()` and `decrypt()`
calls exactly where they are.

## Test it

The encryption round trip needs no database:

```ts tests/crypto.test.ts theme={"dark"}
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { decrypt, encrypt } from '../src/crypto.js';

test('a signing secret is stored sealed and read back', () => {
  const sealed = encrypt('fss_not-a-real-signing-secret');

  assert.doesNotMatch(sealed, /not-a-real/);
  assert.equal(decrypt(sealed), 'fss_not-a-real-signing-secret');
});
```

Tests that call the site keep using `fakeSite()` from `@fetchski/attract-cms-sdk/testing`
([Testing](https://docs.fetchski-attract.com/typescript/testing.md)).

> [!WARNING]
>
> Losing `APP_ENCRYPTION_KEY` loses the stored key, so back it up with your other secrets. To change the
> encryption key, make a new API key in the CMS and store it under the new encryption key.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.