# simple-group.pilely.app — groups, members and per-action permissions for your Pilely apps

Base URL: `https://simple-group.pilely.app` (the `@simple_group` service). Every operation is a `POST` to a route below. This README is the only manual: `GET https://simple-group.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 platform's WHO store: it records **which users are in which groups** and **which users may perform which group actions**. Your app attaches all meaning.
- The user wants an audience — beta testers, editors, a team — that other services gate on: a `@simple_group` group is what `read_group` / `write_group` on `https://simple-db.pilely.app/README.md` (which also decide who reads and writes the files in a table's file columns) and `send_group` on `https://simple-email.pilely.app/README.md` name.
- A member is a user account or a registered app, always named by uuid — a handle is a display name, never an identifier here. To add a person you know only by handle, turn the exact handle into their user id with `POST /users/lookup` first.
- Your app needs a membership check without ever seeing the member list: `POST /groups/{group_nanoid}/members/resolve` answers `true` or `false`.

## Auth

- Every route needs `Authorization: Bearer <token>`. How to sign in and get a token: `https://pilely.app/README.md`; the sign-in page is `https://pilely.app/~/login`. Every request reaches this service as one caller — a person, a backend app, an agent, or an anonymous visitor — authenticated on one app (the app whose token was sent).
- **Changing a group goes through this service only.** `create`, `archive`, `unarchive`, `rename`, `set_members_can_list`, `members/add`, `members/remove` and every `permission/add` / `permission/remove` admit a person signed in with a token for `simple-group.pilely.app` itself. A group records no app and may guard the resources of several of your apps, so no app's page, no backend box and no app token for another app may change one: they get the uniform `404 not_found`.
- **Reading goes through any app.** `list`, `members/list`, `members/search`, `members/resolve` and `permission/list` admit a person with a token for any app, under the group rules below.
- Account lookup (`users/lookup`): a person with a token for any app, or a backend box (its app acting as itself, billed to the app's owner).
- An agent and an anonymous visitor get the uniform `404 not_found` on every route; a backend box on every route but `users/lookup`.
- Group lifecycle: you become the owner of every group you create, and `list` only ever shows your own groups.
- Owner-only, never delegable: `archive`, `unarchive`, `rename`, `set_members_can_list`, and every `permission/*` route — a grant never carries the right to grant.
- Member actions (`add`, `remove`, `list`, `search`, `resolve`) are permissioned per action — see `### Who may perform a member action`.
- Unknown groups and denied callers answer the same `404 not_found`: this service never reveals whether a group exists.

### Who may perform a member action

Member actions are permissioned **per action** (`add`, `remove`, `list`, `search`, `resolve`):

- The group **owner** may always perform every action.
- Anyone holding that action's **permission grant** may perform it. Grants are owner-managed at `POST https://simple-group.pilely.app/groups/<group_nanoid>/members/<action>/permission/add` (`/remove`, `/list`). Only `user` grants exist: an `app` grant authorizes nothing and is refused.
- While the owner has turned the group's `members_can_list` on (`POST https://simple-group.pilely.app/groups/<group_nanoid>/set_members_can_list`), every **user** member may also perform `list` — no grant needed. App members never gain access this way. The flag widens `list` only: `search` and `resolve` still need their grants. It is off by default and on every group created before it existed.
- One exception: a member may always `remove` **themselves** — no grant needed.

Access follows the current flag, grants and membership on every request: turning `members_can_list` off, revoking a grant or removing the member stops that caller's access on their very next request.

A denied caller and an unknown group get the same uniform `404`: this service never reveals whether a group exists.

### Archived groups

There is no delete; `archive` is the off switch. While a group is archived:

- membership checks answer `false` for the group immediately;
- every other action rejects with `409 group_archived` until the owner POSTs `…/unarchive` — `members/resolve` included, which answers `409` there rather than `false`;
- the owner may still read `members/list` and `permission/list`; a grant holder or a member listing through `members_can_list` gets `409 group_archived`;
- a member may still remove **themselves**.

## Limits

| Limit | Value |
|---|---|
| Groups per owner | no count limit; every group row counts toward your account's storage (`402 storage_exceeded`) |
| Members per group | no count limit; every member row counts toward the group's billing account's storage (`402 storage_exceeded`) |
| Grants | 200 per action per group (`409 limit_reached`) |
| `display_name` | 1–120 characters |
| Page size | `limit` 1–100, default 50 (an out-of-range `limit` is clamped, not rejected) |

This service sets no request-rate limit — none per caller, per group or on group creation.

### Over the storage allowance

When a group's billing account goes over its storage allowance (its holder gets an email each day for 7 days, then the account stays blocked until it is back within), every gated route answers anyone but the billing account `503 storage_over_quota`, as on the traffic block. The billing account may still list (`groups/list`, `members/list`, `members/search`, `members/{action}/permission/list`) and delete (`members/remove`, `members/{action}/permission/remove`, `archive`); every other gated call (`groups/create`, `rename`, `set_members_can_list`, `unarchive`, `members/add`, `members/{action}/permission/add`, `users/lookup`) answers it `402 storage_over_quota`. `members/resolve` and removing yourself are never gated. Nothing is deleted; it all clears as soon as the account is back within the allowance.

## Errors

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

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | a malformed body; the route lists its own field rules |
| 401 | `bad_forwarded_identity` | the forwarded identity headers reached this service missing or malformed (always JSON) |
| 404 | `not_found` | the uniform 404: no such group, or you may not perform that action on it, or not through this caller or app (see `## Auth`) — one answer for all; on `users/lookup`, no account holds that handle (the same body) |
| 409 | `group_archived` | the group is archived; unarchive it first |
| 402 | `storage_over_quota` | the billing account (you) is over its storage allowance and this call is not a list or a delete (see `### Over the storage allowance`) |
| 503 | `out_of_traffic_credits` | the billing account has no traffic balance left — top up and retry (each route says whether it is gated) |
| 503 | `storage_over_quota` | the group's billing account is over its storage allowance; only that account may still list and delete |
| 500 | `internal` | retry in a few seconds |

Route-specific codes: `limit_reached` (`409`) and `app_grant_unsupported` (`400`) on `permission/add` only; `unknown_subject` (`400`), `storage_exceeded` (`402`) and `quota_unavailable` (`503`) on the routes that add a row (`groups/create` has the last two only).

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. Every route answers `POST` only. Markdown comes back by default; send `Accept: application/json` for the JSON envelope `{ "ok": true, ... }`. Both forms carry the same status code.

Listings are keyset-paginated. The JSON form echoes the cursor to repeat with as `next_cursor`, `null` once the listing is exhausted; the markdown form ends with a `More pages remain — repeat with …` line naming the exact fields and values.

## Routes

| Route | Purpose | Who may call |
|---|---|---|
| `POST /groups/create` | create a group; the server mints its `group_nanoid` | a person, through this service only; the caller becomes the owner |
| `POST /groups/list` | your own groups, newest first, archived ones included | a person, through any app (scoped to your groups) |
| `POST /groups/{group_nanoid}/archive` | archive the group — the only "delete" | the owner, through this service only |
| `POST /groups/{group_nanoid}/unarchive` | bring an archived group back | the owner, through this service only |
| `POST /groups/{group_nanoid}/rename` | set or clear the display label | the owner, through this service only |
| `POST /groups/{group_nanoid}/set_members_can_list` | let user members list the group's members, or stop them | the owner, through this service only |
| `POST /groups/{group_nanoid}/members/add` | add a user or app member | the owner; holders of the `add` grant — through this service only |
| `POST /groups/{group_nanoid}/members/remove` | remove a member | the owner; holders of the `remove` grant; anyone removing themselves — through this service only |
| `POST /groups/{group_nanoid}/members/list` | page the members, oldest first | the owner; holders of the `list` grant; user members while `members_can_list` is on — through any app |
| `POST /groups/{group_nanoid}/members/search` | find user members by handle prefix | the owner; holders of the `search` grant — through any app |
| `POST /groups/{group_nanoid}/members/resolve` | boolean membership verdict | the owner; holders of the `resolve` grant — through any app |
| `POST /groups/{group_nanoid}/members/{action}/permission/add` | grant one member action | the owner, through this service only |
| `POST /groups/{group_nanoid}/members/{action}/permission/remove` | revoke one member action | the owner, through this service only |
| `POST /groups/{group_nanoid}/members/{action}/permission/list` | page who holds one action's grant | the owner, through any app |
| `POST /users/lookup` | turn an exact handle into that account's user id | a person through any app, or a backend box; the caller (a box's app owner) pays |

`{group_nanoid}` is the group's id, returned by `create` and `list`. `{action}` is one of `add | remove | list | search | resolve` — the five member actions, each granted separately; any other value answers the uniform `404`.

### POST /groups/create

Create a **group** you own.

#### Auth

A person signed in with a token for this service itself may create a group (through any other app: `404 not_found`). You become the group's owner: you may always add/remove/list/search/resolve members, delegate those actions per action, rename, and archive/unarchive.

#### Request

Optional body:

```json
{ "display_name": "Beta testers", "members_can_list": false }
```

- `display_name` — optional string, 1–120 characters (an empty string is a `400`). Omit it — or send no body at all — for an unnamed group; `rename` sets a label later.
- `members_can_list` — optional boolean, default `false`. `true` lets every user member page `members/list`; `set_members_can_list` changes it later.

There is no limit on how many groups you own, active or archived.

#### Example

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

#### Response

`200`. JSON:

```json
{ "ok": true, "group": { "group_nanoid": "<22-char id>", "display_name": "Beta testers",
  "archived_time_stamp": null, "created_time_stamp": 1750000000000,
  "members_can_list": false } }
```

This is the group object every route that returns a group carries (`create`, `list`, `archive`, `unarchive`, `rename`, `set_members_can_list`): `group_nanoid` is a string; `display_name` a string or `null`; `archived_time_stamp` is epoch ms, or `null` while active (always `null` on a fresh group); `created_time_stamp` is epoch ms; `members_can_list` is a boolean. The markdown form is a `Group created` page with the id, the label, `members_can_list`, and the `members/add` URL to use next.

The `group_nanoid` is the group's only identifier — it goes in every other group URL.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, `display_name` is empty / over 120 characters, or `members_can_list` is not a boolean |
| 402 | `storage_exceeded` | the platform storage check denied the request: your account's storage allowance is exhausted |
| 503 | `quota_unavailable` | the platform storage check couldn't reach a decisive answer; retry shortly |
| 503 | `out_of_traffic_credits` | your account has no traffic balance left — top up and retry |
| 500 | `internal` | the create failed; retry in a few seconds |

#### When to use

The user wants a new audience/group of people.

### POST /groups/list

List **your own groups**, newest first — archived ones included, so you can find and unarchive them.

#### Auth

A person, through any app. The listing is always the groups YOU own — there is no way to list anyone else's.

#### Request

All fields are optional; sending no body at all is the first page at the default size.

```json
{ "limit": 50, "after_created_time_stamp": 0, "after_group_nanoid": "..." }
```

- `limit` — optional integer, default 50, clamped to 1–100.
- `after_created_time_stamp` — optional integer, the last row's `created_time_stamp` (epoch ms).
- `after_group_nanoid` — optional string, the last row's `group_nanoid`.

Pagination is keyset: the two `after_*` fields travel together — one without the other is a `400`.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/groups/list \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"limit": 50}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "groups": [ { "group_nanoid": "<id>", "display_name": "Beta testers",
  "archived_time_stamp": null, "created_time_stamp": 1750000000000,
  "members_can_list": false, "member_count": 3 } ],
  "next_cursor": { "after_created_time_stamp": 1750000000000,
                   "after_group_nanoid": "<id>" } }
```

Each row is the group object (`group_nanoid`, `display_name`, `archived_time_stamp`, `created_time_stamp`, `members_can_list` — see `### POST /groups/create`) plus `member_count` (integer). `next_cursor` carries `after_created_time_stamp` + `after_group_nanoid` ready to repeat with, and is `null` once the listing is exhausted; following it until `null` returns every group you own exactly once, however many there are. The markdown form is one line per group with its member count, a `members can list` marker and an **archived** marker, followed by the same two cursor values.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, or only one of the two cursor fields was sent |
| 503 | `out_of_traffic_credits` | your account has no traffic balance left — top up and retry |
| 500 | `internal` | the listing failed; retry in a few seconds |

#### When to use

The user asks which groups they have.

### POST /groups/{group_nanoid}/archive

**Archive** the group. There is no delete; archive is the off switch — what it does is in `### Archived groups`.

#### Auth

You must be the group's OWNER — archiving is not delegable. Every other caller and every unknown group get the same `404 not_found`. Through any app other than this service itself — another app's page, its backend box, or your own app's page — the route answers the same `404 not_found`.

#### Request

No fields. Send no body (any body is ignored).

#### Example

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

#### Response

`200`. JSON: `{ "ok": true, "group": ... }` — the group object with `archived_time_stamp` now set (epoch ms). The markdown form is the id plus a line stating that membership checks now answer `false`, and `members_can_list`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 404 | `not_found` | no such group, or you are not its owner — one answer for both |
| 409 | `group_archived` | the group is already archived; the original archive time stands |
| 503 | `out_of_traffic_credits` | the group's billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the archive failed; retry in a few seconds |

#### When to use

The user wants a group gone or suspended (archive instead of delete — it is reversible).

### POST /groups/{group_nanoid}/unarchive

**Unarchive** the group — the one action an archived group accepts beyond the owner's `members/list` / `permission/list` reads. Membership, permissions and `members_can_list` come back exactly as they were, and membership checks answer `true` again for the group's members. Unarchiving a group that is already active succeeds and changes nothing, and it never depends on how many groups you own.

#### Auth

You must be the group's OWNER — unarchiving is not delegable. Every other caller and every unknown group get the same `404 not_found`. Through any app other than this service itself — another app's page, its backend box, or your own app's page — the route answers the same `404 not_found`.

#### Request

No fields. Send no body (any body is ignored).

#### Example

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

#### Response

`200`. JSON: `{ "ok": true, "group": ... }` — the group object with `archived_time_stamp` now `null`. The markdown form is the id plus a line confirming the group is active again, and `members_can_list`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 404 | `not_found` | no such group, or you are not its owner — one answer for both |
| 503 | `out_of_traffic_credits` | the group's billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the unarchive failed; retry in a few seconds |

#### When to use

The user wants an archived group back.

### POST /groups/{group_nanoid}/rename

Change the group's optional display label.

#### Auth

You must be the group's OWNER — renaming is not delegable. Every other caller and every unknown group get the same `404 not_found`. Through any app other than this service itself — another app's page, its backend box, or your own app's page — the route answers the same `404 not_found`. An archived group rejects this with `409` until you unarchive it.

#### Request

A JSON body is REQUIRED, with `Content-Type: application/json` — a missing header is a `400`, never treated as a clear.

```json
{ "display_name": "New label" }
```

- `display_name` — string or `null`. A string must be 1–120 characters; `null` clears the label, and so does an empty object `{}`.

The label is display-only: the group's identifier is always its `group_nanoid`.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/groups/<group_nanoid>/rename \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"display_name": "New label"}'
```

#### Response

`200`. JSON: `{ "ok": true, "group": ... }` — the group object with `display_name` set to the new value (or `null`). The markdown form is the id plus `Renamed.` and `members_can_list`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | no `Content-Type: application/json`, the body is not JSON, or `display_name` is empty / over 120 characters |
| 404 | `not_found` | no such group, or you are not its owner — one answer for both |
| 409 | `group_archived` | the group is archived; unarchive it first |
| 503 | `out_of_traffic_credits` | the group's billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the rename failed; retry in a few seconds |

#### When to use

The user wants to relabel a group.

### POST /groups/{group_nanoid}/set_members_can_list

Turn the group's `members_can_list` flag on or off. While it is on, every **user** member may page `members/list` — the whole page shape, user and app members alike — with no grant. It widens `members/list` only: `members/search` and `members/resolve` still need their grants.

#### Auth

You must be the group's OWNER — the flag is not delegable. Every other caller and every unknown group get the same `404 not_found`. Through any app other than this service itself — another app's page, its backend box, or your own app's page — the route answers the same `404 not_found`. An archived group rejects this with `409` until you unarchive it.

#### Request

A JSON body is REQUIRED, with `Content-Type: application/json`, and no other keys (an unknown key is a `400`):

```json
{ "members_can_list": true }
```

- `members_can_list` — required boolean. `true` lets user members list the group's members; `false` (the default on every group) leaves listing to the owner and `list` grant holders.

Setting the value the group already has succeeds and changes nothing. The change applies to each member's very next `members/list` request.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/groups/<group_nanoid>/set_members_can_list \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"members_can_list": true}'
```

#### Response

`200`. JSON: `{ "ok": true, "group": ... }` — the group object with `members_can_list` set to the new value. The markdown form is the id, a line saying who may now list the members, and `members_can_list`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | no `Content-Type: application/json`, the body is not JSON, an unknown key, or `members_can_list` missing or not a boolean |
| 404 | `not_found` | no such group, or you are not its owner — one answer for both |
| 409 | `group_archived` | the group is archived; unarchive it first |
| 503 | `out_of_traffic_credits` | the group's billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the change failed; retry in a few seconds |

#### When to use

The owner wants the group's members to see who else is in it (a members-only space), or wants to stop that.

### POST /groups/{group_nanoid}/members/add

Add a member to the group.

#### Auth

You must be the group's owner or hold its `add` grant. Every other caller and every unknown group get the same `404 not_found`. Through any app other than this service itself — another app's page, its backend box, or your own app's page — the route answers the same `404 not_found`.

#### Request

The typed pair — both fields required, and no other keys (an unknown key is a `400`):

```json
{ "subject_type": "user" | "app", "subject_id": "<uuid>" }
```

- `subject_type` — required, exactly `"user"` or `"app"`.
- `subject_id` — required uuid: a user account's uuid, or a registered app's (pile's) uuid. UUIDs only — a handle is a display name, never an identifier here (`POST /users/lookup` turns an exact handle into the user's uuid). The uuid must belong to a registered user/app.

Re-adding an existing member is a no-op "ok". There is no limit on how many members a group holds; each new member row counts toward the group's billing account's storage.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/groups/<group_nanoid>/members/add \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"subject_type": "user", "subject_id": "<user uuid>"}'
```

#### Response

`200`. JSON: `{ "ok": true }` — nothing else. The markdown form is `# OK` with `Member added (re-adding is a no-op).`

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, carries an unknown key, or `subject_type`/`subject_id` is missing or malformed |
| 400 | `unknown_subject` | no registered user or app has that uuid |
| 402 | `storage_exceeded` | the platform storage check denied the request: the group's billing account's storage allowance is exhausted |
| 404 | `not_found` | no such group, or you may not `add` to it — one answer for both |
| 409 | `group_archived` | the group is archived; unarchive it first |
| 503 | `quota_unavailable` | the platform storage check couldn't reach a decisive answer; retry shortly |
| 503 | `out_of_traffic_credits` | the group's billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the add failed; retry in a few seconds |

#### When to use

The user wants someone — a user or an app — in the audience.

### POST /groups/{group_nanoid}/members/remove

Remove a member.

#### Auth

You must be the group's owner, hold its `remove` grant, or be removing **yourself** (always allowed, no grant needed). Every other caller and every unknown group get the same `404 not_found`. Through any app other than this service itself — another app's page, its backend box, or your own app's page — the route answers the same `404 not_found`.

#### Request

The typed pair — both fields required, and no other keys (an unknown key is a `400`):

```json
{ "subject_type": "user" | "app", "subject_id": "<uuid>" }
```

- `subject_type` — required, exactly `"user"` or `"app"`.
- `subject_id` — required uuid, the member's account or app uuid.

Removing **yourself** (`subject_type: "user"` plus your own uuid) needs no permission and answers "ok" even for an unknown or archived group, and even when you were not a member. Removing anyone else needs the grant, and the group must not be archived.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/groups/<group_nanoid>/members/remove \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"subject_type": "user", "subject_id": "<user uuid>"}'
```

#### Response

`200`. JSON: `{ "ok": true }` — the same body whether or not that subject was a member, so removal is never a membership probe. The markdown form is `# OK` with `If that subject was a member, it is not anymore.`

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, carries an unknown key, or `subject_type`/`subject_id` is missing or malformed |
| 404 | `not_found` | no such group, or you may not `remove` from it — one answer for both. Never reached when you are removing yourself |
| 409 | `group_archived` | the group is archived; unarchive it first. Never reached when you are removing yourself |
| 503 | `out_of_traffic_credits` | the group's billing account has no traffic balance left. Removing yourself is never blocked by the traffic balance |
| 500 | `internal` | the removal failed; retry in a few seconds |

#### When to use

The user wants someone out of the audience, or wants to leave a group themselves.

### POST /groups/{group_nanoid}/members/list

Page the group's members, oldest first.

#### Auth

You must be the group's owner, hold its `list` grant — a separate permission from `search` — or be a **user** member while the owner has `members_can_list` on. Every other caller and every unknown group get the same `404 not_found`. A person may call it through any app. The owner keeps this read while the group is archived; a grant holder or a member does not (`409`). Turning the flag off or removing the member stops their listing on their next request.

#### Request

All fields are optional; sending no body at all is the first page at the default size. No other keys are accepted (an unknown key is a `400`).

```json
{ "limit": 50, "after_created_time_stamp": 0,
  "after_subject_type": "user" | "app", "after_subject_id": "<uuid>" }
```

- `limit` — optional integer, default 50, clamped to 1–100.
- `after_created_time_stamp` — optional integer, the last row's `created_time_stamp` (epoch ms).
- `after_subject_type` — optional, exactly `"user"` or `"app"`, the last row's `subject_type`.
- `after_subject_id` — optional uuid, the last row's `subject_id`.

Pagination is keyset: the three `after_*` fields travel all-or-nothing — a partial triple is a `400`.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/groups/<group_nanoid>/members/list \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"limit": 50}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "members": [ { "subject_type": "user", "subject_id": "<uuid>",
  "label": "alice", "added_by_user_id": "<uuid>",
  "created_time_stamp": 1750000000000 } ],
  "next_cursor": { "after_created_time_stamp": 1750000000000,
                   "after_subject_type": "user", "after_subject_id": "<uuid>" } }
```

Each row is `subject_type` (`"user"`/`"app"`), `subject_id` (uuid string), `label` (string or `null`), `added_by_user_id` (uuid) and `created_time_stamp` (epoch ms). `next_cursor` carries the three `after_*` fields of the last row, and is `null` once the listing is exhausted; following it until `null` returns every member exactly once, however many there are. A member listing through `members_can_list` sees the same rows. The markdown form is one line per member, followed by the same three cursor values.

`label` is the CURRENT display name — a user's handle, or an app's host label (`rock-gym` for `rock-gym.pilely.app`) — never an identifier, and never accepted as input.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, carries an unknown key, or the cursor triple is incomplete / malformed |
| 404 | `not_found` | no such group, or you may not `list` it — one answer for both |
| 409 | `group_archived` | the group is archived and you are not its owner (a grant holder or a member through `members_can_list`) |
| 503 | `out_of_traffic_credits` | the group's billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the listing failed; retry in a few seconds |

#### When to use

The user wants to see who is in a group, app members included — as the owner, a `list` grant holder, or a member of a group with `members_can_list` on.

### POST /groups/{group_nanoid}/members/search

Find the group's USER members by label (handle) prefix.

#### Auth

You must be the group's owner or hold its `search` grant — a separate permission from `list`, so an owner can let someone look up specific handles without handing over the full roster. Every other caller and every unknown group get the same `404 not_found`. A person may call it through any app. An archived group rejects this with `409`.

#### Request

A JSON body is required (`Content-Type: application/json`), and no keys beyond these three (an unknown key is a `400`):

```json
{ "q": "ali", "limit": 50, "after_label": null }
```

- `q` — required string. Surrounding whitespace and a leading `@` are stripped before matching; what remains must be non-empty. Matching is a case-insensitive PREFIX match against the member's handle, with `_` and `%` treated literally (no wildcards).
- `limit` — optional integer, default 50, clamped to 1–100.
- `after_label` — optional string, the last row's `label`; pagination is keyset on the label, ascending.

Only `user` members can match — app members have no handle and never appear here; use `list` for those.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/groups/<group_nanoid>/members/search \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"q": "ali"}'
```

#### Response

`200`. JSON: `{ "ok": true, "members": [...], "next_cursor": ... }` — the same row shape as `members/list` (`subject_type`, `subject_id`, `label`, `added_by_user_id`, `created_time_stamp`), but `next_cursor` here is `{ "after_label": ... }`, and `null` once the listing is exhausted. The markdown form is one line per member, followed by the `after_label` value to repeat with.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | no JSON body / `Content-Type`, an unknown key, or `q` missing or blank once trimmed |
| 404 | `not_found` | no such group, or you may not `search` it — one answer for both |
| 409 | `group_archived` | the group is archived; unarchive it first |
| 503 | `out_of_traffic_credits` | the group's billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the search failed; retry in a few seconds |

#### When to use

The user wants to know whether specific handles are in a group, without listing the whole roster.

### POST /groups/{group_nanoid}/members/resolve

A **boolean membership verdict**.

#### Auth

You must be the group's owner or hold its `resolve` grant. Self-resolve is not special-cased: to let members check their own membership, grant them `resolve`. Every other caller and every unknown group get the same `404 not_found`. A person may call it through any app.

#### Request

The typed pair — both fields required, and no other keys (an unknown key is a `400`):

```json
{ "subject_type": "user" | "app", "subject_id": "<uuid>" }
```

- `subject_type` — required, exactly `"user"` or `"app"`.
- `subject_id` — required uuid. App subjects resolve exactly like user subjects.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/groups/<group_nanoid>/members/resolve \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"subject_type": "user", "subject_id": "<user uuid>"}'
```

#### Response

`200`. JSON: `{ "ok": true, "member": true }` or `{ "ok": true, "member": false }` — never a member list. The markdown form is `# Membership verdict` with `member: true` / `false`.

An unknown subject is simply `false`. An archived group answers `409` here rather than `false`.

Membership checks are never blocked by the traffic balance: this route keeps answering when the group's billing account has no traffic balance left.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, carries an unknown key, or `subject_type`/`subject_id` is missing or malformed |
| 404 | `not_found` | no such group, or you may not `resolve` it — one answer for both |
| 409 | `group_archived` | the group is archived; unarchive it first |
| 500 | `internal` | the verdict could not be produced; retry in a few seconds |

#### When to use

Your app needs to know whether one user or app is in a group, without expanding the member list.

### POST /groups/{group_nanoid}/members/{action}/permission/add

Grant `{action}` on this group. `{action}` is one of `add | remove | list | search | resolve` — the five member actions, each granted separately. Granting `resolve` is how you let someone (for instance every member) run membership checks.

#### Auth

You must be the group's OWNER — a grant never carries the right to grant. A non-owner caller, an unknown group and an `{action}` outside the five all get the same `404 not_found`. Through any app other than this service itself — another app's page, its backend box, or your own app's page — the route answers the same `404 not_found`.

#### Request

The typed pair — both fields required, and no other keys (an unknown key is a `400`):

```json
{ "subject_type": "user", "subject_id": "<uuid>" }
```

- `subject_type` — required, `"user"`. `"app"` answers `400 app_grant_unsupported` and stores nothing: an app grant would authorize nothing, because every caller of this service is a signed-in user. That `400` comes before the group is looked up, so it is the same for an unknown group.
- `subject_id` — required uuid of a registered user account.

Re-granting is a no-op "ok". At most 200 grants per action per group.

Existing `app` grants still show in `permission/list` and can be removed with `permission/remove`. They authorize nothing.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/groups/<group_nanoid>/members/resolve/permission/add \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"subject_type": "user", "subject_id": "<user uuid>"}'
```

#### Response

`200`. JSON: `{ "ok": true }` — nothing else. The markdown form is `# OK` with `Permission granted (re-granting is a no-op).`

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, carries an unknown key, or `subject_type`/`subject_id` is missing or malformed |
| 400 | `app_grant_unsupported` | `subject_type` is `"app"`: `{ "ok": false, "code": "app_grant_unsupported", "reason": "app permission grants authorize nothing; grant a user instead" }` |
| 400 | `unknown_subject` | no registered user has that uuid |
| 402 | `storage_exceeded` | the platform storage check denied the request: the group's billing account's storage allowance is exhausted |
| 404 | `not_found` | no such group, you are not its owner, or `{action}` is not one of the five — one answer for all three |
| 409 | `group_archived` | the group is archived; unarchive it first |
| 409 | `limit_reached` | this action already has 200 grants on this group |
| 503 | `quota_unavailable` | the platform storage check couldn't reach a decisive answer; retry shortly |
| 503 | `out_of_traffic_credits` | the group's billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the grant failed; retry in a few seconds |

#### When to use

The owner wants someone else to manage or read the group's members — one action at a time.

### POST /groups/{group_nanoid}/members/{action}/permission/remove

Revoke an `{action}` grant. `{action}` is one of `add | remove | list | search | resolve`. Revocation takes effect immediately; the owner may always act regardless of grants.

#### Auth

You must be the group's OWNER. A non-owner caller, an unknown group and an `{action}` outside the five all get the same `404 not_found`. Through any app other than this service itself — another app's page, its backend box, or your own app's page — the route answers the same `404 not_found`.

#### Request

The typed pair — both fields required, and no other keys (an unknown key is a `400`):

```json
{ "subject_type": "user" | "app", "subject_id": "<uuid>" }
```

- `subject_type` — required, exactly `"user"` or `"app"` — `"app"` removes an `app` grant created before `permission/add` refused them.
- `subject_id` — required uuid.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/groups/<group_nanoid>/members/resolve/permission/remove \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"subject_type": "user", "subject_id": "<user uuid>"}'
```

#### Response

`200`. JSON: `{ "ok": true }` — the same body whether or not the grant existed, so revocation is never a probe. The markdown form is `# OK` with `If that subject held the grant, it does not anymore.`

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, carries an unknown key, or `subject_type`/`subject_id` is missing or malformed |
| 404 | `not_found` | no such group, you are not its owner, or `{action}` is not one of the five — one answer for all three |
| 409 | `group_archived` | the group is archived; unarchive it first |
| 503 | `out_of_traffic_credits` | the group's billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the revoke failed; retry in a few seconds |

#### When to use

The owner wants to take back one delegated action.

### POST /groups/{group_nanoid}/members/{action}/permission/list

Page who holds the `{action}` grant on this group, oldest first. `{action}` is one of `add | remove | list | search | resolve`. The owner is never listed — ownership is implicit, not a grant.

#### Auth

You must be the group's OWNER. A non-owner caller, an unknown group and an `{action}` outside the five all get the same `404 not_found`. You keep this read while the group is archived. A person may call it through any app.

#### Request

All fields are optional; sending no body at all is the first page at the default size. No other keys are accepted (an unknown key is a `400`).

```json
{ "limit": 50, "after_created_time_stamp": 0,
  "after_subject_type": "user" | "app", "after_subject_id": "<uuid>" }
```

- `limit` — optional integer, default 50, clamped to 1–100.
- `after_created_time_stamp` — optional integer, the last row's `created_time_stamp` (epoch ms).
- `after_subject_type` — optional, exactly `"user"` or `"app"`.
- `after_subject_id` — optional uuid.

Pagination is keyset: the three `after_*` fields travel all-or-nothing — a partial triple is a `400`.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/groups/<group_nanoid>/members/resolve/permission/list \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"limit": 50}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "action": "resolve", "permissions": [ { "subject_type": "user",
  "subject_id": "<uuid>", "label": "alice", "granted_by_user_id": "<uuid>",
  "created_time_stamp": 1750000000000 } ],
  "next_cursor": null }
```

Each row is `subject_type`, `subject_id` (uuid string), `label` (string or `null`), `granted_by_user_id` (uuid) and `created_time_stamp` (epoch ms). `next_cursor` carries the three `after_*` fields of the last row, and is `null` once the listing is exhausted. An `app` row is a grant created before `permission/add` refused them: it authorizes nothing, and `permission/remove` removes it. The markdown form is the action, one line per holder, and the same three cursor values.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, carries an unknown key, or the cursor triple is incomplete / malformed |
| 404 | `not_found` | no such group, you are not its owner, or `{action}` is not one of the five — one answer for all three |
| 503 | `out_of_traffic_credits` | the group's billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the listing failed; retry in a few seconds |

#### When to use

The owner wants to review who was delegated an action.

### POST /users/lookup

Turn an exact **handle** into the user id of the account that holds it — the uuid every member and grant route takes.

#### Auth

A person, with their token for any app (an app's page, this service's own, or the platform login), or a backend box — its app acting as itself. An agent (its own agent ID token) and an anonymous visitor get `404 not_found`. A body this route refuses is its `400` before anything is read or billed. The route takes no group, so the group rules and `### Archived groups` don't apply. Each lookup is a metered request billed to the caller — to the app's owner for a box — and gated on that account's traffic balance.

#### Request

A JSON body is required, with exactly one key (an unknown key is a `400`):

```json
{ "handle": "@Alice" }
```

- `handle` — required string. Surrounding whitespace and one leading `@` are stripped, then it is lower-cased; it must not be blank after that. The match against registered handles is exact: `ali` never finds `alice`.

#### Example

```bash
curl -X POST https://simple-group.pilely.app/users/lookup \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"handle": "alice"}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "user_id": "<uuid>", "handle": "alice" }
```

`handle` is the account's handle as stored, without `@`. The markdown form is a `# User` page with the handle and the user id.

The lookup reads the live account registry on every request:

- An account pending deletion still resolves until the deletion runs.
- Once an account is deleted, its handle answers `404 not_found` — never the deleted account's id — until another account registers that handle; from then on it answers the new account's id.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | no JSON body or `Content-Type`, `handle` missing, not a string or blank, or an unknown key |
| 404 | `not_found` | no account holds that handle — the same body as an unknown group |
| 503 | `out_of_traffic_credits` | your account (the app owner's, for a box) has no traffic balance left — top up and retry |
| 500 | `internal` | the lookup failed; retry in a few seconds |

#### When to use

The user names a person by handle — to add them to a group, grant them an action, or start a conversation — and the next call needs their user id.

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

### Group ids

A group's id is a server-minted 22-character nanoid: the `group_nanoid` route param, returned by `create` and `list`, never chosen by the caller.

### Membership changes reach other services within 5 seconds

A membership change takes effect in the services that check groups (simple_db's `read_group`/`write_group`, simple_email's `send_group`) with no call to them: normally on the very next request, and within 5 seconds at worst. Verdicts are cached per group in Redis for at most 5 seconds and the cache is deleted on every group mutation (archive, unarchive, members add and remove including self-remove, permission add and remove); the TTL backstops a missed delete.