Administrative operations
How to review a claim, reclassify a name, suspend one, or change a price — and
what each of those does to the record. The engineering account of why the
boundary is shaped this way is in
appsterisk-svc/docs/admin-boundary.md.
:::info No console UI
The API exists and is deployed. There is no admin console front end — everything
below is curl.
:::
Turning the surface on
Three variables decide whether /admin answers at all. Leave any of them unset
and every route refuses every request, which is the correct state anywhere the
console is not deliberately being served — and the state a fresh environment
starts in.
They are not in the gitops repository. appsterisk-svc-secrets is created
out-of-band, on the node, because the repository holds desired state rather than
credentials. None of these three is a secret — an issuer, an audience and a
public JWKS URL — but they live beside ones that are, so they are set the same
way:
ssh root@<the node on the tailnet>
kubectl -n appsterisk-prd patch secret appsterisk-svc-secrets --type merge -p '{"stringData":{
"ADMIN_JWKS_URI":"https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com",
"ADMIN_JWT_ISSUER":"https://securetoken.google.com/appsterisk-admin",
"ADMIN_JWT_AUDIENCE":"appsterisk-admin"
}}'
kubectl -n appsterisk-prd rollout restart deploy/appsterisk-svc
kubectl -n appsterisk-prd rollout status deploy/appsterisk-svc
ADMIN_JWKS_URI is Google's shared endpoint for every Firebase project — the
iss and aud are what separate one project from another, and both name
appsterisk-admin, never the project users sign in to.
Setting them opens nothing on its own. The surface still refuses everyone until
an AdminUser row exists, and it stays off the public internet either way.
Reaching it
/admin is blocked at the edge alongside /health, /metrics and /docs — it
is not reachable through Cloudflare at any hostname. Operators go straight to the
pod, over the tailnet:
kubectl port-forward -n appsterisk deploy/appsterisk-svc 3000:3000
Requests then go to http://localhost:3000/admin/v1/… with an admin bearer
token. A port-forward bypasses Traefik entirely, so the edge rule does not affect
it.
Being an administrator
Four things must all be true. Any one of them missing is a refusal:
- The service is configured with the admin issuer.
- Your token was minted by that project — not the one you sign in to the product with. There is no product account that grants admin access, by design.
- An
AdminUserrow exists for your(issuer, subject)and is not disabled. - The row carries the capability the route requires.
Granting access
There is no endpoint that creates an administrator — bootstrapping one through the API would mean the first account belongs to whoever reaches the route first. Access is granted by a script run against the production database, which is a deliberate act requiring credentials nobody has by accident.
It has to be a script rather than an INSERT. emailEncrypted and
emailLookupHash are NOT NULL and can only be produced by the same cipher the
service uses — the sealed value is randomized, and the lookup hash is an HMAC
under the data key — so there is no SQL an operator can type by hand.
# From appsterisk-svc, with DATABASE_URL, ENCRYPTION_KEYS, ENCRYPTION_ACTIVE_KEY
# and ADMIN_JWT_ISSUER set to the production values.
npm run admin -- grant jane@example.com \
--subject <the firebase uid from the admin project> \
--name "Jane Operator" \
--review-claims
The subject is the Firebase uid from appsterisk-admin, not from the project
users sign in to. Get it by signing the person in once — the sign-in will
succeed and every /admin route will still refuse them until the row exists.
npm run admin -- list # who has access, what they hold, when they last used it
npm run admin -- revoke jane@example.com
list does not print addresses. They are sealed in the row, and a script that
decrypts every one of them to render a table is the same mistake the claim queue
avoids.
Grant the narrowest set that lets the person do their job. The capabilities are coarse on purpose — five booleans somebody can audit at a glance beat a permission matrix nobody can reason about.
| Capability | Grants |
|---|---|
canReviewClaims | The claim queue, opening evidence, recording decisions |
canReclassify | Moving a name between price bands |
canSuspend | Suspending and restoring a name |
canEditPricing | Reading and publishing pricing rules |
canTransfer | Reserved for ownership transfer, which is not implemented |
revoke sets disabledAt; it never deletes the row. The audit trail refers to
it, and deleting it turns every action the person took into an anonymous one.
Re-granting a revoked account restores it.
Both grant and revoke write an audit event with actorType: operator and no
actor id. Nothing else would be true — whoever ran the script held the database
credentials, and attributing it to an administrator who did not exist yet would
be a lie in the one record that exists to be trusted.
Confirm who a token resolves to before doing anything with it:
curl -H "Authorization: Bearer $ADMIN_TOKEN" localhost:3000/admin/v1/me
Reviewing a claim
A claim is somebody asserting a commercial relationship to a brand over a name that is held back or already owned. Submitting one changes nothing — no name moves because a form was filled in.
# The queue. Carries nothing encrypted: a queue is a list to triage, not a file
# to read, and decrypting every applicant's details to render a table would
# expose the whole set to anyone who opens the page.
curl -H "Authorization: Bearer $ADMIN_TOKEN" localhost:3000/admin/v1/claims
# One claim, with the corporate email and evidence decrypted.
curl -H "Authorization: Bearer $ADMIN_TOKEN" localhost:3000/admin/v1/claims/<id>
Opening a claim is recorded. It decrypts a stranger's corporate email and supporting documents, and the audit trail answers "who read this", not only "who changed this". Open the ones you are reviewing.
Recording a decision:
curl -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"decision":"APPROVED","reason":"Registration BR 900123456 matches the applicant domain"}' \
localhost:3000/admin/v1/claims/<id>
| Decision | Meaning |
|---|---|
UNDER_REVIEW | You have picked it up. Not terminal. |
MORE_INFORMATION_REQUIRED | Back to the applicant, with your reason as the message. Not terminal. |
APPROVED | The evidence is accepted. Terminal. |
REJECTED | The evidence is not accepted. Terminal. |
Two things worth being clear about:
Approving does not move the name. It records that the evidence was accepted. The transfer is a separate act behind a separate capability, so the person who judges a dispute is not automatically the person who executes its outcome — otherwise one compromised reviewer account is a way to take any namespace on the platform. Transfer is not implemented yet, so an approval today is a decision with no mechanical consequence.
A terminal decision cannot be revisited. Reopening is a real need — evidence turns out to be forged, a decision was wrong — but the applicant submits again, and the record then shows two claims and two decisions rather than one row quietly changing its mind.
The reason is mandatory, ten characters minimum, and is shown to the applicant. The person who needs it most is whoever handles the complaint about the decision months from now.
Namespaces
# State, ownership history and every claim against it.
curl -H "Authorization: Bearer $ADMIN_TOKEN" localhost:3000/admin/v1/namespaces/acme
# Move it between price bands.
curl -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"classification":"PREMIUM","reason":"Recognised brand term, seed classified it generically"}' \
localhost:3000/admin/v1/namespaces/acme/classification
# Stop it resolving. Reversible; destroys nothing.
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"reason":"Reported for impersonating a bank; pending review"}' \
localhost:3000/admin/v1/namespaces/acme/suspend
# Put it back.
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"reason":"Report withdrawn by the reporter"}' \
localhost:3000/admin/v1/namespaces/acme/restore
Suspension leaves ownership, identity and collections exactly where they were — the response to an abuse report has to be reversible, because the report may be wrong.
Restoring derives the status from ownership: an owner means OWNED, no owner
means AVAILABLE. It is not the status the name had before, deliberately — a
name suspended mid-purchase whose payment has long since expired must not come
back pending.
A name that is mid-purchase cannot be reclassified or suspended. Someone holds a checkout session quoting a price; changing the ground under it either charges them for something else or strands the payment. Wait for the hold to lapse.
Reclassifying does not touch the seed table. The seed is bootstrap data and the namespace row is the live fact, so the next seed run will not undo your change.
Pricing
curl -H "Authorization: Bearer $ADMIN_TOKEN" localhost:3000/admin/v1/pricing
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"classification":"PREMIUM","currency":"usd","amountCents":9900,"reason":"Launch pricing agreed with product"}' \
localhost:3000/admin/v1/pricing
Publishing closes the currently open rule and inserts a new one, in a single transaction. Nothing is ever edited in place: a purchase made last week must still be explainable by the rule that was open last week, and the two writes are one transaction because closing without opening leaves the classification unpriced — and an unpriced classification cannot be bought at all.
Omit country for the global price. A country rule beats the global one for
buyers in that market; that is the whole of market pricing for now.
Amounts are whole cents, and anything above $1,000,000 is refused. That is not a business rule, it is a fat-finger guard — an extra keystroke turning $20 into $200,000 should fail here rather than at a customer's card.
The audit trail
Every administrative act writes an event carrying who did it, what changed from, what it changed to, and why.
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
'localhost:3000/admin/v1/audit?resourceType=namespace&resourceId=<uuid>'
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
'localhost:3000/admin/v1/audit?actorId=<admin uuid>&limit=100'
Filters: actorId, action, resourceType, resourceId. Paginate with
cursor from nextCursor; limit defaults to 50 and is capped at 200.
Reading it needs no capability beyond an active account. The trail is what makes every other capability reviewable, and an operator who can act but cannot see who else acted is being asked to work blind.
Actions recorded today: claim.opened, claim.decided, namespace.reclassified,
namespace.suspended, namespace.restored, pricing.published.
Audit metadata holds scalars only. That is a deliberate constraint: a nested object is where a decrypted field eventually gets copied in without anyone noticing, and an audit log carrying the plaintext defeats the encryption sitting beside it.