# Member authentication

> How a tenant site gets its own visitors' accounts: registration, login,
> sessions, member-only pages, password reset, and the public query gating.
> Everything is flows — no custom backend code. The engine contract itself is
> in [flows-guide.md](flows-guide.md).

## 1. The member entity

A member is an entity with (at minimum):

- an identifier property — `email` (`string`, `required`, `unique`) by convention;
- a password property of type `bcrypt`;
- optionally `verified` (`boolean`).

The preset modal has a one-click button that creates exactly this entity
(slug `member`, `member-2`, … on collision). Do not invent extra password
fields; do not store passwords as plain `string` — the `bcrypt` type hashes
on write (bcrypt cost 10 with a per-tenant pepper mixed in BEFORE hashing, so
the 72-byte bcrypt input limit never applies to user input; passwords longer
than 72 BYTES are rejected with `FIELD_PASSWORD_TOO_LONG`).

Reads of bcrypt fields return `"***"` to every caller EXCEPT the render
service key — that is why the login flow's graphql node (executed by the
render service) can read the hash.

## 2. Presets (the only way to build auth flows)

The Flows tab → «Пресеты» opens a card gallery, six presets:

> **Presets are an ADMIN-UI feature.** The gallery assembles the flow graph
> client-side in the editor; there is NO preset API and the admin UI cannot
> be entered with an API key (login goes through the control plane). An
> API-only agent assembles the same flows BY HAND from the canonical chains
> in this section and §3 — they spell out exactly what the presets emit
> (queries, paths, secret_vars, cookies), so hand-built and preset-built
> flows are identical.

### Ready-made flows: `presets/*.dynflow.json`

The package ships every preset as a ready `.dynflow.json` file (the export
format, flows-guide.md §8): `login`, `logout`, `register`, `verify-page`,
`forgot-password`, `reset-page`, `change-password`, `flow-page`. Fetch each
file, one command:

```
curl -fsSL https://dynapi.ru/cms/agent-skill/presets/login.dynflow.json -o login.dynflow.json
```

**One-command path:** `flowctl apply login.dynflow.json --url <graphql>
--key <dca_…> --publish` runs the whole import recipe below (handle + flow
draft + templates + routes, then the flow publish). The manual mutations
stay for reference and for API-only agents without the binary.

**Before applying:** (1) the member entity must exist (`email` string unique
required, `password` bcrypt, `verified` boolean) and the schema must have
rebuilt — poll introspection until the `members` field resolves; (2) replace
`__SUB__` with the tenant's site base, and if the entity slug is not
`member`, rename the GraphQL names consistently (`members` plural field,
`member` singular, `Member`/`MemberInput` types); (3) fill the `smtp` block
on every `send_email` node with the user's own mail server (the `register`
and `forgot-password` presets ship a placeholder block).

**Applying = the import mutations, in order** (flow bodies are draft — each
flow goes live ONLY via its own `publishFlowDoc`):

```graphql
# 1. the handle
mutation { createFlow(slug: "login", route_path: "/login", name: "Login") { slug } }
# 2. the flow (flow is a STRING — JSON.stringify the file's flow object)
mutation($f: String!) { saveFlowDoc(slug: "login", flow: $f) { slug } }
# 3. templates from the file, draft, only when missing
mutation($c: String!) { saveSiteTemplate(path: "reset_page.liquid", content: $c, draft: true) { status } }
# 4. the route exactly as the file's routes[0] (draft)
mutation { saveSiteRoute(route: { path: "/login", methods: ["GET", "POST"],
  cache: { defaultTtl: 0 }, flows: [{ handle: "login", methods: ["POST"] }] }, draft: true) { status } }
# 5. publish THIS flow (publishSite does NOT promote flow drafts)
mutation { publishFlowDoc(slug: "login") { slug published } }
```

Then publish the non-flow drafts (templates/routes — per item or
`publishSite`). Caveats: the POST flows (`login`, `register`, `forgot`,
`change-password`) ride a page YOU author — add a GET-flow binding (a
`flow-page` preset works), otherwise GET on that path 404s; the form markup
must POST plain fields named exactly as the flow reads them (`email`,
`password`, `old_password`, `new_password`). The page presets
(`verify-page`, `reset-page`, `flow-page`) carry their own minimal template
and GET binding — they work as-is.

| Kind | What it builds |
|---|---|
| `page` | a template-less GET-flow page (handle + binding + route, TTL 300) |
| `login` | the login chain (§3) |
| `register` | DOUBLE preset: registration flow + a `/verify` companion page (email verification) |
| `forgot_password` | DOUBLE preset: email-with-reset-link flow + a `/reset` page |
| `change_password` | old-password check + set new |
| `logout` | cookie delete + redirect |

Options (per preset): entity slug, identifier property/field, password
property/field, `result_key`, cookie name (default `member`), extra cookie
payload fields, TTL days, success/failure paths, link TTLs.

The canonical login chain (what the preset generates — the live-probed
contract; note there is NO `nodes{}` wrapper around dynamic-entity lists):

1. `graphql`: `query($q: String!) { members(where: {email: {eq: $q}}, limit: 1) { id password } }`,
   `variables: { "q": "{{ form.email }}" }`, `result_key: "login"`,
   `secret_vars: ["password"]`.
2. `condition`: `{ "field": "form.password", "operator": "bcrypt_matches",
   "value": "{{ results.login.members[0].password }}" }` (the hash side comes
   from the graphql result; the plaintext side from the form).
3. `then` → `set_cookie` (`session: true`, `ttl_days: 30`, value = JSON
   `{"id":"{{ results.login.members[0].id }}"}`, payload fields like
   `name` ride along) → `redirect` 303 to the success path.
4. `else` → `redirect` 303 to `/login?failed=1`.

Registration checks for a duplicate BEFORE creating: a graphql lookup on the
identifier, `ne "[]"`-style condition on the list, "address already taken"
branch included by the preset.

## 3. The session cookie

- Name: preset option (default `member`). Value: an opaque AES-256-GCM
  wrapper around a signed JWT — nothing readable leaks from logs or history.
- Flags: `HttpOnly`, `Secure`, `SameSite=Lax`.
- `ttl_days > 0` sets MaxAge; `ttl_days <= 0` DELETES the cookie (that is the
  whole logout flow: set_cookie with `ttl_days: 0` → redirect).
- The cookie seals the pepper EPOCH inside; rotating the tenant's pepper
  (a vendor-console operation) invalidates every password AND every session
  at once.

## 4. members_only routes and the gate

Route fields: `members_only`, `login_path` (default `/`), `member_cookie`.

- Anonymous GET on a members_only page → `302` to `login_path` — the SAME
  rule on the preview host (a preview that lies about the gate is a bug
  factory): sign in as a member on the preview host to review a gated page
  (login POSTs run on preview).
- Anonymous POST is denied too, BEFORE any rate-limit budget is consumed —
  on route bindings. A members-only
  sign-up form cannot be hammered by anonymous bots.
- Routes using `$member` query vars are gated the same way automatically.
- `member.*` in the template context is the session payload (what the login
  preset put in the cookie: `id`, display fields).

NEVER identify a member by a client-supplied id. Identification is only:
`$member` query variables, or the `member.*` flow context.

## 5. The reserved `$member` variable

Member visibility lives in the FLOW: `member.*` conditions and the route's
`members_only` gate. What remains is a single server-side rule:

- EVERY query that declares `$member` gets the signed-in member's id
  injected, OVERWRITING any client value — the data layer can never be
  pointed at someone else:
  `query($member: ID!) { bookings(where: {owner: {eq: $member}}) { id } }`.
- Anonymous requests inject the empty string (an `eq: $member` filter
  matches nothing). Whether the page then shows an empty state or bounces
  to login is the FLOW's/route's decision (members_only → redirect).
- `$member.<path>` traverses the cookie payload; bare `$member` is the id.
- The preflight cache shards by member — one member never sees another's
  cached page.

## 6. Email tokens (reset & verify links)

- Stateless tokens: `purpose.exp.memberID.hmac` inside the AES wrapper. The
  HMAC is bound to the member's CURRENT bcrypt hash — changing the password
  kills all outstanding tokens (including reset links). Feature, not bug.
- TTLs: reset links 1 hour, verify links 24 hours.
- In a `send_email` `body_template` the `signed_token` filter mints the link
  token: `{{ results.m.members[0].password | signed_token: results.m.members[0].id, "reset", 3600 }}`.
- Every send_email node carries its own `smtp` block — ask the user for
  their own mail server (see flows-guide.md §3). Template
  bodies (`template`/`layout`) go through the site engine, which has no
  signed_token filter — token links must live in plain-text bodies.
- The companion page (`/reset`, `/verify`) is a GET flow: a graphql fetch by
  `{{ token_subject }}` (pre-resolved from `?token=`), then a `token_valid`
  condition whose value is `"reset|{{ results.m.<passwordProp> }}"`, then the
  cookie/redirect branches.

## 7. Protections already on (do not re-implement)

- Login-CSRF: a flow that sets a cookie on a cross-site Origin gets
  `403 REQUEST_ORIGIN_DENIED`.
- Turnstile: when `RENDER_TURNSTILE_SECRET` is configured, EVERY public POST
  (including login) must carry a `cf-turnstile-response` token, else
  `403 CAPTCHA_FAILED`.
- bcrypt hashes never leak into answers/emails/cookies (platform-enforced).
- Passwords are scrubbed from the persisted submissions (`[redacted]`),
  including nested JSON/XML bodies.

## 8. Gotchas

- No password minimum or email format validation exists in the member
  presets — add a condition node if the site needs one.
- `member.*` in a TEMPLATE does not scope a page QUERY — a query-bound page
  with member data in the template still caches anonymously. Use `$member`
  query variables (the platform gates and shards those).
- Pepper rotation is a vendor operation ("contact the platform operator"):
  it logs out and invalidates passwords for EVERY member of the tenant.
