> ## 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.

# Configuration

> The site's URL and key in .env, the config file node ace configure writes, and more sites by slug.

A single site needs only `.env`. The configure command writes it for you:

```bash Terminal theme={"dark"}
node ace configure @fetchski/attract-cms-adonis
```

It adds these lines:

```ini .env theme={"dark"}
FETCHSKI_ATTRACT_SITE=acme
FETCHSKI_ATTRACT_URL=https://acme.example
FETCHSKI_ATTRACT_API_KEY=fsk_acme_…
FETCHSKI_ATTRACT_SIGNING_SECRET=fss_…   # only when the key has one
```

**FETCHSKI\_ATTRACT\_SITE** `string` *required*

The site's slug: the `acme` in its key, `fsk_acme_…`, which the command reads from the key. A key
filed under another slug is refused, naming the right one.

**FETCHSKI\_ATTRACT\_URL** `string` *required*

The site's public origin: `https://` and the host it answers on, with no path, query or credentials.
A redirect is never followed; plain `http://` reaches only localhost or a private service name.

**FETCHSKI\_ATTRACT\_API\_KEY** `string` *required*

`fsk_<site>_<key id>_<secret>`, as the site showed it. An empty value is refused, never taken as the
public.

**FETCHSKI\_ATTRACT\_SIGNING\_SECRET** `string`

`fss_…`, when the key signs. With a secret, the client signs every request it can, uploads by their
SHA-256. [Signing requests](https://docs.fetchski-attract.com/signing.md) lists which requests must be signed.

Each one gets a rule in `start/env.ts`, so a missing key or a mistyped URL stops the app at boot, and
the key and secret are `Secret`s that print as `[redacted]`:

```ts start/env.ts theme={"dark"}
/*
|----------------------------------------------------------
| Variables for configuring @fetchski/attract-cms-adonis
|----------------------------------------------------------
*/
FETCHSKI_ATTRACT_SITE: Env.schema.string(),
FETCHSKI_ATTRACT_URL: Env.schema.string({ format: 'url', tld: false }),
FETCHSKI_ATTRACT_API_KEY: Env.schema.secret(),
FETCHSKI_ATTRACT_SIGNING_SECRET: Env.schema.secret.optional(),
```

`tld: false` lets a local site such as `http://localhost:4321` through.

> [card] [Or keep the key in your database](https://docs.fetchski-attract.com/adonisjs/key-in-database.md)
>
> The key and signing secret sealed in a table, replaced without touching `.env`.

## The config file

The command also writes `config/attract.ts`. Every option is here, with what it starts as:

```ts config/attract.ts theme={"dark"}
import env from '#start/env'
import { defineConfig } from '@fetchski/attract-cms-adonis'

/**
 * The Fetchski Attract sites this app calls. The container holds SiteClient
 * (the default site's client) and SiteClients (every site, by slug):
 * @inject() either one.
 */
const attractConfig = defineConfig({
  // The sites below. More sit beside the env one, each under its slug.
  sitesFrom: 'config',
  default: env.get('FETCHSKI_ATTRACT_SITE'),
  sites: {
    [env.get('FETCHSKI_ATTRACT_SITE')]: {
      baseUrl: env.get('FETCHSKI_ATTRACT_URL'),
      apiKey: env.get('FETCHSKI_ATTRACT_API_KEY'),
      signingSecret: env.get('FETCHSKI_ATTRACT_SIGNING_SECRET'),
    },
  },

  // Milliseconds per attempt, and extra attempts for a call safe to repeat.
  timeout: 15_000,
  retries: 2,

  // One debug line per call on the app's logger (never a header or body).
  logCalls: true,
})

export default attractConfig
```

| Option | Default | |
| - | - | - |
| `sitesFrom` | `'config'` | `'database'`: the sites come from the `attract_api_keys` table, and `sites` stays out |
| `default` | the only site (or row) | the site `SiteClient` reaches |
| `sites` | none | by slug: `baseUrl`, `apiKey`, `signingSecret`, and `signing` (`none`, `writes` or `all`) |
| `timeout` | `15_000` | milliseconds per attempt; AI routes carry their own, longer one |
| `retries` | `2` | extra attempts for a call safe to repeat; `0` for none |
| `userAgent` | `Fetchski-Attract-CMS-Adonis` | sent as `User-Agent` |
| `logCalls` | `true` | one `debug` line per call on the app's logger: site, method, path, status, ms |

`defineConfig` checks the file as it loads, so a mistake (a `default` no site is filed under, a
negative `timeout`) stops the app at boot with one message naming the fix.

> [!WARNING]
>
> Leave `apiKey` out only for a site you read as the public. An `apiKey` that is there but empty (an
> unset variable) is refused, so a key that was meant to be there never quietly turns the client into
> the public's.

## Ask for it

In a controller, service, listener or queue job, take the client in the constructor:

```ts app/controllers/blog_posts_controller.ts theme={"dark"}
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { SiteClient } from '@fetchski/attract-cms-sdk'

@inject()
export default class BlogPostsController {
  constructor(protected site: SiteClient) {}

  async index({ request }: HttpContext) {
    return this.site.blogPosts().listBlogs({ page: request.input('page', 1), perPage: 20 })
  }
}
```

The client is built the first time something asks for it, and the same one is handed out after.
Anywhere else, ask the container: `const site = await app.container.make(SiteClient)` (with
`import app from '@adonisjs/core/services/app'`).

> [!WARNING]
>
> Import `SiteClient` as a value, never `import type`: `@inject()` reads the class at runtime, and
> a type-only import hands the constructor `undefined`.

## The default site

**1. SiteClient reaches the default**

The site `default` names (`FETCHSKI_ATTRACT_SITE`), or, when that is unset, the one site
configured (with the key in the database, the table's one row).

**2. SiteClients reaches any site**

`await sites.forSite('globex')`: a client for that site, made on the spot (cheap: nothing is sent).

**3. No default, several sites: a clear error**

`No default site among acme, globex: set default in config/attract.ts (FETCHSKI_ATTRACT_SITE), or take SiteClients and call forSite('<slug>').`

## Many sites, by slug

List each further site under its slug, beside the env one:

```ts config/attract.ts theme={"dark"}
sites: {
  [env.get('FETCHSKI_ATTRACT_SITE')]: {
    baseUrl: env.get('FETCHSKI_ATTRACT_URL'),
    apiKey: env.get('FETCHSKI_ATTRACT_API_KEY'),
    signingSecret: env.get('FETCHSKI_ATTRACT_SIGNING_SECRET'),
  },
  globex: {
    baseUrl: 'https://globex.example',
    apiKey: env.get('GLOBEX_API_KEY'),
  },
},
```

Declare each new key in `start/env.ts` as `Env.schema.secret()`. Then take the `SiteClients`
directory and ask it for each site by its slug:

```ts app/services/sites_report.ts theme={"dark"}
import { inject } from '@adonisjs/core'
import { SiteClients } from '@fetchski/attract-cms-sdk'

@inject()
export class SitesReport {
  constructor(protected sites: SiteClients) {}

  /** The newest post's title on each site. */
  async latestTitles(slugs: string[]) {
    const titles: Record<string, string | undefined> = {}
    for (const slug of slugs) {
      const site = await this.sites.forSite(slug)
      const list = await site.blogPosts().listBlogs({ perPage: 1 })
      titles[slug] = list.results[0]?.title
    }
    return titles
  }
}
```

`SiteClient` keeps reaching the default site. A slug nothing lists throws `UnknownSite`; with the
keys in the database, every row is a site ([Key in the database](https://docs.fetchski-attract.com/adonisjs/key-in-database.md)).

## Give tests a placeholder

Tests boot the app, so `start/env.ts` checks the variables there too. Tests fake the client
([Testing](https://docs.fetchski-attract.com/adonisjs/testing.md)) and never call the site, so a placeholder does:

```ini .env.test theme={"dark"}
FETCHSKI_ATTRACT_SITE=acme
FETCHSKI_ATTRACT_URL=https://acme.example
FETCHSKI_ATTRACT_API_KEY=fsk_acme_0a1b2c3d_not-a-real-secret
```

## Keys stay secret

> [card] Secret until the client
>
> `Env.schema.secret()` values print as `[redacted]`, in logs and in `node ace repl`. Only the
> package releases them, into the client.

> [card] Never in the SDK's output
>
> No error, log line or dump of the client holds a key ([Keeping keys secret](https://docs.fetchski-attract.com/typescript/security.md)).

> [card] [Next: key in the database](https://docs.fetchski-attract.com/adonisjs/key-in-database.md)
>
> The key sealed under `APP_KEY`, stored with one Ace command.


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