Personas
A persona is a user of your application. Agents sign in as one, and different roles see different things, which is the point.
personas: - name: owner email: owner@example.test role: admin login: password
- name: member email: member@example.test role: member login: magic_link
- name: secured email: secured@example.test role: member login: totp mfa: true attributes: plan: free onboarded: "false"example.test is a reserved domain that can never receive mail, so a persona
address is safe by construction even before capture mode is considered. A
persona that signs in by SMS gets a number from the +1 555 0100 block, which
is reserved for fictional use, for the same reason.
Login strategies
Section titled “Login strategies”| Strategy | How the agent signs in |
|---|---|
none |
Does not sign in at all, for an application with no sign in or a page that is public. |
password |
Types the password. |
magic_link |
Waits for the mail, opens the link. |
email_code |
Waits for the mail, reads the code. |
sms_code |
Reads the code from the captured text. |
totp |
Types the password, then generates the code from the enrolled secret. |
session |
Does not sign in through a form. |
Everything except none and password depends on capture mode: a magic link
that was really emailed is a link the agent cannot read, and a real address
receiving it is a person getting mail from a pull request.
A persona set to none has no account, so nothing is created for it and your
application does not need anywhere to put one. That is the shape an API with no
sign in has, and until it was actually run, such a manifest was refused with
“no users table could be found” for an account that was never going to be used.
A workflow still has to name a persona, because a workflow runs as somebody
even when that somebody is a visitor who never signed in.
Where personas come from
Section titled “Where personas come from”They are created before the agents run, by an authentication adapter, and the
adapter is chosen for you. A repository that depends on @supabase/supabase-js
gets the Supabase adapter written into its manifest by af init; at run time
the engine looks at the branch’s actual schema, and what it finds there wins,
because a table is a fact and a dependency list is an intention.
| Adapter | Where the persona is created | Chosen when |
|---|---|---|
direct |
Your own users table | You own authentication |
supabase |
Supabase’s auth schema, in the branch |
@supabase/supabase-js and friends |
supabase_api |
A Supabase project’s auth admin API | You set it, and give a project URL |
nextauth |
The NextAuth and Auth.js tables | next-auth, @auth/core |
clerk |
Clerk, through its backend API | @clerk/nextjs and friends |
auth0 |
Auth0, through the Management API | auth0, @auth0/nextjs-auth0 |
workos |
WorkOS User Management | @workos-inc/node |
seed |
A command you name | Nothing else fits |
Provisioning is idempotent and reconciles rather than duplicates. That matters because a golden is a masked copy of production, so a persona’s address may already be there as a real user who has since been masked. Two rows with one email is a broken fixture that looks exactly like a broken application, and takes a day to find. Running twice is safe, which is also what lets a persona be created once in the golden and reconciled again on every branch.
Passwords and TOTP secrets are derived from the environment and the persona name, never stored and never transmitted. The adapter that writes the hash and the runner that types the password compute the same value independently. Two branches of the same repository therefore have different passwords for the same persona, and neither is a secret that outlives its branch.
Sessions do not survive
Section titled “Sessions do not survive”Masking rewrites a customer’s name and address. It does not touch the row in
auth.sessions that still authenticates as them, because a session token is
not personal data by any rule a scanner applies. A branch published with those
rows intact hands anybody who can reach it a working login belonging to a real
person.
So provisioning empties the session and token tables of whichever scheme is in use, every time. If your application keeps its own alongside the framework’s, name them:
auth: sessions: [app_sessions, api_tokens]Hosted providers
Section titled “Hosted providers”Clerk, Auth0 and WorkOS will not accept a row written into a table, because the table is not in your database. Personas are created through their APIs instead, and they need somewhere that is not production to create them:
auth: adapter: clerk token_env: CLERK_SECRET_KEY sandbox: truetoken_env is the name of the variable holding the admin key, never the key
itself. sandbox: true says that the tenant the key belongs to is a
development instance, a sandbox or a staging environment. Without it,
provisioning refuses:
AF-DB-020 Personas cannot be provisioned because clerk creates users onlythrough its own API, and no sandbox tenant is configured.That refusal is deliberate and af init will never set sandbox for you. The
only tenant left to fall back to is the production one, and a persona created
there is a real user of your real product with a password Antifailure
generated. It is the one setting that has to come from a person.
A hosted persona’s credentials
Section titled “A hosted persona’s credentials”A hosted provider keeps one account per address, so every environment that
reaches the tenant uses the same account. Its password and second factor are
derived from the tenant’s admin token, the one token_env names, so every
environment arrives at the same values and one environment’s af up does not
lock another out. Two rules follow from that.
- Environments that share a tenant share its admin token. Two environments
that reach one tenant with different tokens, such as two Auth0 machine to
machine clients, derive different passwords and overwrite each other’s on
every
af up. - Rotating the admin token changes every persona’s password. The next
af upfinds each account and sets the new one, so there is nothing to do by hand.
An empty admin token is refused with AF-DB-025 rather than used.
What af down leaves in the tenant
Section titled “What af down leaves in the tenant”af down does not delete a hosted persona, because the account does not belong
to one environment: another environment reaching the same tenant may be signed
in with it. So one account per persona address stays in the tenant after the
last environment is gone. To remove it, delete the user with that address in the
provider’s dashboard or through its admin API, and the next af up creates it
again:
- Clerk: Users in the development instance, or
DELETE /v1/users/{id}. - Auth0: User Management, then Users, or
DELETE /api/v2/users/{id}. - WorkOS: User Management, then Users, or
DELETE /user_management/users/{id}. - Supabase: Authentication, then Users, or
DELETE /auth/v1/admin/users/{id}.
An application that owns its users
Section titled “An application that owns its users”With no auth block the engine looks for a users table and reads its columns.
Where the names are not ones it would guess, say them:
auth: adapter: direct table: name: accounts id: account_id email: email_address password: password_digest role: kind attributes: plan: subscription_tier timestamps: [created_at, updated_at]Passwords are hashed with bcrypt at cost 10, which is what most frameworks write. If your application’s rules are stricter than the generator, say so, and the generated password is shaped to fit rather than being refused at sign in:
auth: password: min_length: 16 forbid: "!"For anything else. The command runs once per persona, against the branch, with the persona in its environment:
auth: adapter: seed seed: npm run seed:persona| Variable | What it holds |
|---|---|
AF_PERSONA_NAME |
The persona’s name |
AF_PERSONA_EMAIL |
Its address |
AF_PERSONA_PHONE |
Its number, for sms_code |
AF_PERSONA_ROLE |
Its role |
AF_PERSONA_LOGIN |
Its login strategy |
AF_PERSONA_PASSWORD |
The password it must end up with |
AF_PERSONA_TOTP_SECRET |
The base32 secret to enrol, when mfa is set |
AF_PERSONA_MFA |
1 when a second factor is wanted |
AF_PERSONA_ATTRIBUTES |
The attributes, as a JSON object |
AF_DATABASE_URL |
The branch to write to, also as DATABASE_URL |
Two rules. It must be idempotent, because it runs again on every branch. And it must exit non zero if it did not create the account, because an exit code of zero is read as “the persona exists”, and an agent told about an account that was never created reports the application refusing a correct password.
It can print the account’s identifier on its last line, and that is recorded. Anything else it prints is ignored unless it fails, in which case its output is what explains why.
sign_in_path
Section titled “sign_in_path”Where this persona’s sign-in form is, when it is not where the workflow starts.
personas: - name: operator role: owner login: password sign_in_path: /adminThe runner looks for a sign-in form at the workflow’s start path first, then at
the usual paths. That is right for the persona the workflow acts as, and wrong
for one whose form is somewhere else on the same origin: an operator portal at
/admin beside a console that answers every other route with the console’s own
sign-in screen. Without this the runner finds the console’s email field at the
start path and types an operator’s address into the wrong form. A persona’s own
path is tried ahead of the workflow’s.
attributes
Section titled “attributes”Anything your application reads to decide what a user sees: plan, feature
flags, onboarding state. An attribute with a column of its own goes there; the
rest go to the scheme’s JSON column, which for Supabase is
raw_user_meta_data. An attribute with nowhere to go is an error rather than a
silent omission, because a persona quietly in the wrong state fails a workflow
for a reason nobody can see.
Use them to reach states that are otherwise hard to arrange. A persona that has never onboarded is one line here and twenty minutes of clicking otherwise.