# simple-env.pilely.app — encrypted env sets for you and your app's boxes

Base URL: `https://simple-env.pilely.app` (the `@simple_env` service). Every operation is a `POST` to a route below. This README is the only manual: `GET https://simple-env.pilely.app/README.md`, public, no credential needed. The platform directory of every service README is `https://pilely.app/README.md`.

## When to use

- The user needs secrets or configuration — typically environment variables such as `STRIPE_SECRET_KEY` — kept out of the app's source and handed to its backend at boot.
- An **env set** is one JSON object owned by you, encrypted at rest, and handed out only to you, to the members of the set's groups, and to the boxes of apps you put in those groups.
- A `@simple_server` box (`https://simple-server.pilely.app/README.md`) reads its set once at boot with its `PILELY_BACKEND_TOKEN`; `server/add` tells the box which set through `SIMPLE_ENV_ID`. See `### Giving a box its env` under `## Notes`.

## Auth

Send `Authorization: Bearer <token>`. Who can call:

- **You, signed in, with your own token for this service** (the sign-in page is `https://pilely.app/~/login`). You own every set you create: you can always read, write, delete and change access on it. A token for any other app — another app's page, or your own app's — reaches no set.
- **Members of a set's groups.** A `user` member of the set's `read_group` may `get` it; a `user` member of its `write_group` may `set` and `patch` it. Nothing else — never `delete`, never `~/access/*`.
- **A box of your backend app.** A `@simple_server` box calling with its `PILELY_BACKEND_TOKEN` is its app acting as itself. It may `get` a set when its app (`subject_type: "app"`, the app's uuid) is a member of the set's `read_group`, and `set` or `patch` it when the app is a member of its `write_group`. A box is never the owner: put its app in a group to give it access. A box may call only `/{env_id}/get`, `/{env_id}/set` and `/{env_id}/patch`; every other route answers it `404 not_found`.

A `null` group means nobody but the owner. In `simple_db` a `null` group means the opposite — every signed-in user, through the db's own app (`https://simple-db.pilely.app/README.md` → `### Table access groups`) — so set each service's groups on purpose. Groups are `@simple_group` groups you own (`https://simple-group.pilely.app/README.md`); an archived group admits nobody.

Everyone else gets the uniform `404 not_found` on every route: anonymous visitors, agents, and a person with a token minted for any other app.

An unknown set, a set you may not use, and a deleted set all answer the same `404 not_found`.

## Limits

| Limit | Value |
|---|---|
| Key count, key names, value size, set count | no limit |
| Storage | a set's encrypted size counts toward your account storage allowance; a write that would grow it past the allowance is `402 storage_exceeded` |
| `/{env_id}/set` and `/{env_id}/patch` body | up to the service's transport ceiling, 10 MiB by default (`413` above it) |
| `list` page size | `limit` default 50, clamped into 1..100 |

This service sets no request-rate limit — none per user and none per box.

### Billing

The set's owner pays for every `create`, `list`, `get`, `set`, `patch` and `~/access/*` call — also when a group member or a box makes it: a per-request fee plus the bytes of the answer. `delete` is free and always works, even with no traffic balance left.

### Over the storage allowance

When the set's owner's stored data goes over their storage allowance, the owner gets an email each day for 7 days. During those days every caller but the owner (a group member, a box) gets the uniform `404`, and the owner may only `list`, `get` (download a set) and `delete`; after them `get` is refused too. Every other owner call (`create`, `set`, `patch`, `~/access/*`) answers `402 storage_over_quota`, whose reason says what the owner may still do: during the 7 days it names the days left and `list`, `get` and `delete`; after them only `list` and `delete`. Nothing is deleted; it all clears as soon as the owner is back within the allowance.

## Errors

JSON errors are `{ "ok": false, "code": "...", "reason": "..." }` with the HTTP status; the markdown form is a `Request failed (<status>)` page with the same reason and status. Codes shared by the routes:

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | a malformed body; each route lists its own body rules |
| 401 | `bad_forwarded_identity` | the forwarded identity headers reached this service missing or malformed (always JSON) |
| 402 | `storage_exceeded` | your account storage allowance is used up (`create`, `set`, `patch`) |
| 402 | `storage_over_quota` | your stored data is over your storage allowance and this call is not `list`, `delete` or (during the first 7 days) `get` — shown to the owner only; everyone else gets the uniform `404` (see `### Over the storage allowance`). The reason names what the owner may still do: during the first 7 days the days left and `list`, `get` and `delete`; after them only `list` and `delete` |
| 404 | `not_found` | unknown set, a set you may not use, a deleted set, or a caller this route does not serve (see `## Auth`) — one identical answer |
| 500 | `internal` | retry in a few seconds |
| 503 | `out_of_traffic_credits` | the billing account has no traffic balance left; top up |
| 503 | `quota_unavailable` | the storage check could not answer; retry shortly (`create`, `set`, `patch`) |

A `GET` (or any method other than `POST`) on a route below, and any unknown path, answers an empty-body `404`.

## Response format

Make these calls with an HTTP client (`curl` or your runtime's equivalent), never a browser. This service has no web pages. Every route answers `POST` only. Markdown comes back by default; send `Accept: application/json` for JSON. Both forms carry the same status code.

JSON success bodies are the `{ "ok": true, ... }` envelope, except `/{env_id}/get`, which answers the stored JSON object itself. Timestamps (`created_at`, `updated_at`) are epoch milliseconds. A set's id is minted by the server: 8 characters, `a-zA-Z0-9`. `stored_bytes` is the set's encrypted size — what it counts against the owner's storage allowance. Values are never listed and never appear in an error.

## Routes

| Route | Purpose | Who may call |
|---|---|---|
| `POST /create` | a new, empty set; returns its 8-character `id` | a person, through this service only |
| `POST /list` | your sets, newest first, with their key counts and sizes — never key names or values | a person, through this service only |
| `POST /{env_id}/get` | the set's JSON object | the owner or a `read_group` user member through this service; a box whose app is in `read_group` |
| `POST /{env_id}/set` | replace the whole object with the body; `{}` clears it | the owner or a `write_group` user member through this service; a box whose app is in `write_group` |
| `POST /{env_id}/patch` | change only the keys you name; answers like `set` | the owner or a `write_group` user member through this service; a box whose app is in `write_group` |
| `POST /{env_id}/delete` | delete the set | the owner, through this service only |
| `POST /{env_id}/~/access/read_group/get` | who may `get` | the owner, through this service only |
| `POST /{env_id}/~/access/read_group/set` | change who may `get` | the owner, through this service only |
| `POST /{env_id}/~/access/write_group/get` | who may `set` | the owner, through this service only |
| `POST /{env_id}/~/access/write_group/set` | change who may `set` | the owner, through this service only |

`{env_id}` is the set's 8-character `id`, returned by `create` and `list`.

### POST /create

Create a new, **empty** env set owned by you. You own the set and pay for its calls.

#### Auth

A person only, with their own token for this service; a box, an agent, an anonymous visitor and any other app's token get `404 not_found`.

#### Request

Optional JSON body; an empty body is fine:

```json
{ "name": "prod" }
```

- `name` — optional string, a label for your own lists (trimmed; blank is no name). Never part of the encrypted map.

#### Example

```bash
curl -X POST https://simple-env.pilely.app/create \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"name": "prod"}'
```

#### Response

```json
{ "ok": true, "id": "aB3dE6gH", "name": "prod", "read_group": null, "write_group": null, "created_at": 1750000000000 }
```

`created_at` is epoch milliseconds. The markdown form is an `Env set <id>` card. Next: put your variables in with `POST https://simple-env.pilely.app/{env_id}/set`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | a body that is not empty and not a JSON object |
| 402 | `storage_exceeded` | your account storage allowance is used up |
| 503 | `out_of_traffic_credits` | your account has no traffic balance left; top up |
| 503 | `quota_unavailable` | the storage check could not answer; retry shortly |

#### When to use

The user wants a new place to keep an app's secrets or configuration.

### POST /list

List **your own env sets**, newest first, each with its key count and size. **Key names and values are never listed** — read a set with `/{env_id}/get`, the one route that answers a set's key names.

#### Auth

A person only, with their own token for this service; a box, an agent, an anonymous visitor and any other app's token get `404 not_found`.

#### Request

Optional JSON body — every key is optional, and a POST with no body at all lists your first page:

```json
{ "limit": 50, "after_created_at": 1750000000000, "after_id": "aB3dE6gH" }
```

- `limit` — number, default 50, clamped into 1..100.
- `after_created_at` (epoch ms) + `after_id` — the cursor: repeat with the last item's pair (the `next` object). Send BOTH or neither.

#### Example

```bash
curl -X POST https://simple-env.pilely.app/list \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

```json
{ "ok": true,
  "items": [ { "id": "aB3dE6gH", "name": "prod", "read_group": null, "write_group": null, "key_count": 2, "stored_bytes": 64, "created_at": 1750000000000, "updated_at": 1750000000500 } ],
  "next": { "after_created_at": 1750000000000, "after_id": "aB3dE6gH" } }
```

`key_count` is the number of top-level keys in the set. `next` is `null` on the last page. `stored_bytes` is the set's encrypted size — what it counts against your storage allowance. The markdown form lists one line per set, without key names, and spells the next cursor out when more pages remain.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | a malformed body, half a cursor, or an `after_id` that is not a set id |
| 503 | `out_of_traffic_credits` | your account has no traffic balance left; top up |

#### When to use

The user wants to see their sets or needs a set's `id`. To learn which keys a set holds, read it with `/{env_id}/get`.

### POST /{env_id}/get

Return the set's JSON object — with `Accept: application/json`, the object itself, exactly as it was last stored; without it, a markdown page showing the same JSON.

#### Auth

- the set's owner, with their own token for this service;
- a `user` member of the set's `read_group`, with their own token for this service;
- a `@simple_server` box whose app is an `app` member of the set's `read_group`.

Anyone else — a person through any other app, an agent, an anonymous visitor — gets `404 not_found`.

#### Request

No body.

#### Example

From a box at boot:

```bash
curl -sS -X POST "https://simple-env.pilely.app/$SIMPLE_ENV_ID/get" \
  -H "Authorization: Bearer $PILELY_BACKEND_TOKEN" \
  -H "Accept: application/json"
```

A non-`200` answer means the box has no env: treat it as fatal at boot.

#### Response

```json
{ "STRIPE_SECRET_KEY": "sk_live_...", "FEATURE_FLAGS": { "beta": true } }
```

An empty set answers `{}`. The owner pays for the call: a per-request fee plus the bytes of this answer. The markdown form is an `Env set <id>` page with the JSON in a code block.

#### Errors

| Status | `code` | When |
|---|---|---|
| 404 | `not_found` | unknown set, a set you may not read, or a deleted set; also a box or member reading a set whose owner has no traffic balance left |
| 503 | `out_of_traffic_credits` | the owner, reading their own set, has no traffic balance left; top up |

#### When to use

A box loads its env at boot; the owner or a member wants the current values.

### POST /{env_id}/set

**Replace** the set's whole JSON object with the request body.

#### Auth

- the set's owner, with their own token for this service;
- a `user` member of the set's `write_group`, with their own token for this service;
- a `@simple_server` box whose app is an `app` member of the set's `write_group`.

Anyone else — a person through any other app, an agent, an anonymous visitor — gets `404 not_found`.

#### Request

```json
{ "STRIPE_SECRET_KEY": "sk_live_...", "PORT_OVERRIDE": 8081, "FLAGS": { "beta": true } }
```

- The body is any JSON object. Values may be of any JSON type; the object is stored and returned by `get` exactly as you sent it.
- It is a full replace: keys you leave out are gone. There is no merge and no per-key delete.
- `{}` clears the set.
- A body that is not a JSON object is a `400`.

No limits on key count, key names or value size. The set's encrypted size counts toward the owner's storage allowance; a write that would grow it past the allowance is refused with `402`. The body may be up to the service's transport ceiling (10 MiB by default).

A running box does not see the change: a box reads its env once, at boot. Add a new box and remove the old one to pick it up.

#### Example

```bash
curl -X POST https://simple-env.pilely.app/aB3dE6gH/set \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"STRIPE_SECRET_KEY": "sk_live_...", "FLAGS": {"beta": true}}'
```

#### Response

```json
{ "ok": true, "id": "aB3dE6gH", "key_count": 3, "stored_bytes": 112, "updated_at": 1750000000500 }
```

`stored_bytes` is the encrypted size. The markdown form is an `Env set <id>` card.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not a JSON object |
| 402 | `storage_exceeded` | the owner's storage allowance is used up |
| 404 | `not_found` | unknown set, a set you may not write, or a deleted set; also a box or member writing a set whose owner has no traffic balance left |
| 413 | — | the body is over the transport ceiling |
| 503 | `out_of_traffic_credits` | the owner, writing their own set, has no traffic balance left; top up |
| 503 | `quota_unavailable` | the storage check could not answer; retry shortly |

#### When to use

The user wants to put a whole set of variables into a set, or clear it. To change one key and keep the rest, use `/{env_id}/patch`.

### POST /{env_id}/patch

**Change only the keys you name**: `set` writes keys with their new values, `unset` removes keys. Every other key keeps its value exactly as stored. Answers like `/{env_id}/set` — never a key name or a value.

#### Auth

The same callers as `/{env_id}/set`:

- the set's owner, with their own token for this service;
- a `user` member of the set's `write_group`, with their own token for this service;
- a `@simple_server` box whose app is an `app` member of the set's `write_group`.

Anyone else — a person through any other app, an agent, an anonymous visitor — gets `404 not_found`.

#### Request

```json
{ "set": { "STRIPE_SECRET_KEY": "sk_live_..." }, "unset": ["OLD_KEY"] }
```

- `set` — optional JSON object: each key is written with its value, exactly as you send it. Values may be of any JSON type.
- `unset` — optional array of key names to remove. A key that is not in the set is fine: it is simply absent afterwards.
- A key you do not name keeps its value exactly as stored, number formatting included (`1.50` stays `1.50`).
- A patch can empty the set.

Body rules — each broken rule is a `400 bad_request` with a fixed reason that never quotes your body:

- the body is a JSON object;
- it holds no field other than `set` and `unset`;
- `set`, when present, is a JSON object;
- `unset`, when present, is an array of strings;
- it changes something: at least one key in `set` or `unset`;
- no key is in both `set` and `unset`.

No limits on key count, key names or value size. The set's encrypted size counts toward the owner's storage allowance; a patch that would grow it past the allowance is refused with `402` and changes nothing. The body may be up to the service's transport ceiling (10 MiB by default), as on `set`.

A patch never loses a write: a patch and any other write to the same set (a `set` or another patch) land one after the other, never mixed, so two patches sent at the same time on different keys both take effect. If the set keeps changing under a patch and it cannot land after the service's own retries, it answers `409 conflict` and writes nothing; send it again.

A running box does not see the change: a box reads its env once, at boot. Add a new box and remove the old one to pick it up.

#### Example

Switch one key:

```bash
curl -X POST https://simple-env.pilely.app/aB3dE6gH/patch \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"set": {"STRIPE_SECRET_KEY": "sk_live_..."}}'
```

Remove one:

```bash
curl -X POST https://simple-env.pilely.app/aB3dE6gH/patch \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"unset": ["OLD_KEY"]}'
```

#### Response

```json
{ "ok": true, "id": "aB3dE6gH", "key_count": 2, "stored_bytes": 112, "updated_at": 1750000000500 }
```

The same shape as `/{env_id}/set`: `key_count` is the number of top-level keys after the patch and `stored_bytes` is the encrypted size. The markdown form is an `Env set <id>` card that names no key. To see the set's key names, read it with `/{env_id}/get`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | a body rule above is broken |
| 402 | `storage_exceeded` | the owner's storage allowance is used up |
| 404 | `not_found` | unknown set, a set you may not write, or a deleted set; also a box or member writing a set whose owner has no traffic balance left |
| 409 | `conflict` | the set kept changing during the patch; nothing was written; retry |
| 413 | — | the body is over the transport ceiling |
| 503 | `out_of_traffic_credits` | the owner, patching their own set, has no traffic balance left; top up |
| 503 | `quota_unavailable` | the storage check could not answer; retry shortly |

#### When to use

The user wants to change or remove one key, or a few, without resending the whole set — for example switching `STRIPE_SECRET_KEY` to its live value.

### POST /{env_id}/delete

**Permanently delete** the set.

#### Auth

Owner only, with their own token for this service; a box and any other app's token get `404 not_found`.

#### Request

No body.

Delete is free and always works, even when your account has no traffic balance left. The set's encrypted size leaves your storage usage at once. A box that still names this set in `SIMPLE_ENV_ID` gets `404` on its next boot.

#### Example

```bash
curl -X POST https://simple-env.pilely.app/aB3dE6gH/delete \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

```json
{ "ok": true, "id": "aB3dE6gH", "deleted": true }
```

The markdown form is an `OK` page reading `Deleted.`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 404 | `not_found` | unknown set, not yours, or already deleted |
| 500 | `internal` | the delete did not finish; retrying is safe |

#### When to use

The user no longer needs a set, or wants to shed its storage.

### POST /{env_id}/~/access/read_group/get

Return the set's `read_group` — the `@simple_group` group whose members may `get` the set: its `user` members, and the boxes of its `app` members.

#### Auth

Owner only, with their own token for this service; a box and any other app's token get `404 not_found`.

#### Request

No body.

#### Example

```bash
curl -X POST https://simple-env.pilely.app/aB3dE6gH/~/access/read_group/get \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

```json
{ "ok": true, "read_group": "<group nanoid>" }
```

`"read_group": null` means nobody but you. The markdown form is an `OK` page naming the group. Change it with `POST https://simple-env.pilely.app/{env_id}/~/access/read_group/set`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 404 | `not_found` | unknown set, not yours, or deleted |
| 503 | `out_of_traffic_credits` | your account has no traffic balance left; top up |

#### When to use

The user wants to know who, besides themselves, may read a set.

### POST /{env_id}/~/access/read_group/set

Set the set's `read_group` — the `@simple_group` group whose members may `get` the set: its `user` members, and the boxes of its `app` members.

#### Auth

Owner only, with their own token for this service; a box and any other app's token get `404 not_found`.

#### Request

```json
{ "group": "<group nanoid>" }
{ "group": null }
```

- `group` is **required**. A missing key is a `400`, so a typo never changes access.
- A group nanoid must name an **active group you own**; anything else is a `400`.
- `null` clears the group: nobody but you.

To let a box use the set, add its app to the group as a member with `subject_type: "app"` and the app's uuid, then attach the group here. Removing the app from the group, or setting `null`, cuts the box off on its next call.

#### Example

```bash
curl -X POST https://simple-env.pilely.app/aB3dE6gH/~/access/read_group/set \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"group": "<group nanoid>"}'
```

#### Response

```json
{ "ok": true, "id": "aB3dE6gH", "read_group": "<group nanoid>" }
```

The markdown form is an `Env set <id>` card.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | a malformed body, a missing `group`, a value that is not a group nanoid, or a group that is not an active group you own |
| 404 | `not_found` | unknown set, not yours, or deleted |
| 503 | `out_of_traffic_credits` | your account has no traffic balance left; top up |

#### When to use

The user wants a box, or other users, to read a set — or wants to take that access away.

### POST /{env_id}/~/access/write_group/get

Return the set's `write_group` — the `@simple_group` group whose members may `set` the set: its `user` members, and the boxes of its `app` members.

#### Auth

Owner only, with their own token for this service; a box and any other app's token get `404 not_found`.

#### Request

No body.

#### Example

```bash
curl -X POST https://simple-env.pilely.app/aB3dE6gH/~/access/write_group/get \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

```json
{ "ok": true, "write_group": "<group nanoid>" }
```

`"write_group": null` means nobody but you. The markdown form is an `OK` page naming the group. Change it with `POST https://simple-env.pilely.app/{env_id}/~/access/write_group/set`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 404 | `not_found` | unknown set, not yours, or deleted |
| 503 | `out_of_traffic_credits` | your account has no traffic balance left; top up |

#### When to use

The user wants to know who, besides themselves, may replace a set's values.

### POST /{env_id}/~/access/write_group/set

Set the set's `write_group` — the `@simple_group` group whose members may `set` the set: its `user` members, and the boxes of its `app` members.

#### Auth

Owner only, with their own token for this service; a box and any other app's token get `404 not_found`.

#### Request

```json
{ "group": "<group nanoid>" }
{ "group": null }
```

- `group` is **required**. A missing key is a `400`, so a typo never changes access.
- A group nanoid must name an **active group you own**; anything else is a `400`.
- `null` clears the group: nobody but you.

To let a box use the set, add its app to the group as a member with `subject_type: "app"` and the app's uuid, then attach the group here. Removing the app from the group, or setting `null`, cuts the box off on its next call.

#### Example

```bash
curl -X POST https://simple-env.pilely.app/aB3dE6gH/~/access/write_group/set \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"group": null}'
```

#### Response

```json
{ "ok": true, "id": "aB3dE6gH", "write_group": "<group nanoid>" }
```

The markdown form is an `Env set <id>` card.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | a malformed body, a missing `group`, a value that is not a group nanoid, or a group that is not an active group you own |
| 404 | `not_found` | unknown set, not yours, or deleted |
| 503 | `out_of_traffic_credits` | your account has no traffic balance left; top up |

#### When to use

The user wants a box, or other users, to write a set — or wants to take that access away.

## Notes

### Other GETs on this host

- `GET /README.md` — this page; public, the same bytes for every caller and every `Accept`.
- `GET /` — `302` to `/README.md`.
- `GET /healthz` — liveness probe, plain `ok`.

### Giving a box its env

1. `create` a set and `set` your variables (`patch` changes one key later).
2. Create a `@simple_group` group and add your backend app as a member with `subject_type: "app"` and the app's uuid (`https://simple-group.pilely.app/README.md`).
3. Attach the group as the set's `read_group` (`/{env_id}/~/access/read_group/set`).
4. `server/add` on the app with `env_id: "<id>"` (`https://simple-server.pilely.app/README.md`). The box reads `SIMPLE_ENV_ID` and calls `POST https://simple-env.pilely.app/<id>/get` with `Authorization: Bearer $PILELY_BACKEND_TOKEN` at boot.

A box reads its env once, at boot. To change it, `patch` the keys that change (or `set` the whole object), then add a new box and remove the old one.