Skip to content

feat(worker): the first administrator signs in by a link from the log or a command (FRU-221) - #291

Open
sakuga-agent[bot] wants to merge 3 commits into
mainfrom
mathieu/fru-221-worker-un-compte-dadministration-toujours-possible
Open

sakuga-agent[bot] wants to merge 3 commits into
mainfrom
mathieu/fru-221-worker-un-compte-dadministration-toujours-possible

Conversation

@sakuga-agent

@sakuga-agent sakuga-agent Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

For Mathieu: a new way in to the console, with no proof of the address. I do not merge it. Base main. No migration.

Two points to confirm first

  1. The command also gives a link to an owner who lost their session. The arbitration of 2026-10-13 (on delegation of Mathieu) says: a link only while the instance has no owner, never again after. I applied that to the log, to the letter. For the command I kept what the ticket asked, with the coordinator's agreement, and it is yours to confirm: a session of the console lives seven days, and an instance with no e-mail and no provider has no other way back in. The command makes a link for an instance with no owner, or for the address of an owner; it refuses every other address. It never makes a second owner, promotes nobody, ends no session, and leaves a line in the log of the worker (who and when, never the link). It needs a shell in the container, which already reads the database. Taking the recovery out is one condition.

  2. « No owner », and not « no account » as the ticket wrote. A person who opened the link and closed the browser before the workspace was made is an account that owns nothing: with « no account » that instance could never get a link again.

  3. On an instance with no e-mail and no provider, only an owner can sign in. Found in review. A member who was invited has no way in: the command refuses every address that is not an owner. The sign-in screen now says so, in place of telling everybody to ask for a link. Should the command also make a link for a person who is already a member? It would sign them in and promote nobody; it was not arbitrated, and it is not in this pull request.

What it does

An instance that sends no e-mail and has no GitHub or Google sign-in could make no account at all (FRU-217 made it say so). Mathieu, 2026-10-13: an account is always possible for whoever administers.

  • FRUITBACK_FIRST_ADMIN=<address>: a worker whose instance has no owner yet writes a sign-in link for that address in its log when it starts.
  • node server.mjs admin-link --email <address>: the same link, on the terminal.
  • It is the link of an e-mail, sent nowhere: better-auth makes its token and keeps a digest (sendLinkThrough, as the route of the e-mail). It opens <console>/sign-in#<token>, and the person goes on through the setup of feat(console): the setup on one screen: your account, your site (FRU-239) #267. No screen of its own, no route, no component asked of the kit.
  • The console says « This Fruitback sends no sign-in link by e-mail. Only its owner signs in for now, with a link that the person who runs it makes. Everybody else can sign in once it sends e-mail, or offers GitHub or Google. » in place of its form when the worker signs people in and has no mail and no provider. Three files, short additions: sign-in-form.tsx, api.ts, messages.fr.ts.
  • FRUITBACK_FIRST_REVIEWER stays refused on a worker with workspaces, with the same sentence (pair from the console, or pair --workspace). Once the database is required (FRU-225) it has no valid configuration, and goes with the SQLite sessions (FRU-226). Written in worker.md.
  • The secret of the sign-in is asked for and never made: FRUITBACK_FIRST_ADMIN without FRUITBACK_AUTH_SECRET refuses to start, and says that the worker does not make it.
  • docker-compose.yml, .env.example, docs/self-hosting.md (« The first administrator »), SECURITY.md, docs/decisions/worker.md.

The threat model, and the test that holds each line

apps/worker/src/first-admin.test.ts, 19 cases, through the routes of the console on PostgreSQL (PGlite) with better-auth on and no mailer.

Threat What bounds it Test
Whoever reads the log in the window owns an empty instance Not closed: it is the price of the feature, said in SECURITY.md. Ten minutes (ADMIN_LINK_TTL_SECONDS, the number of FRU-108) where the link of an e-mail lives fifteen; spent once; and its use is written in the log, so an operator who did not open it reads that somebody did is refused after ten minutes, where the link of an e-mail lives fifteen
Two links from two replicas The Kv (in the database, shared) counts the starts of a window: the first writes the link, the other says that one was written. And one link is current: a new one ends the one before is one link when two replicas start together, and the other one says that it was written, cancels the link before with each new one, whoever asked for each
A link played again better-auth spends it once, and its mark then says « ended » is spent once: the same link opened again is refused
A link kept, opened after an owner signed up by e-mail or OAuth The route that spends a link reads its mark first, and asks who owns the instance now is refused once somebody else owns a workspace, however they signed in (and it stays refused when that owner leaves)
The token in a log One line. It is after the #, which no browser sends: no access log and no proxy. No line of the worker about a use or a refusal holds it, and no table of the database writes its token on one line of the log, and nothing else of the worker ever writes it
A request that makes a link Only the start and the command call the function. The test sends every path the route sources spell, with GET and POST, with and without Origin, on a worker that names a first administrator, and reads that no mark was written; its control writes two whatever the path, the method and the body, on a worker that names a first administrator
The variable left in the environment Nothing is written once a workspace has an owner writes nothing once a workspace has an owner: the variable left in the environment opens nothing
The command used to take the instance It refuses an admin, a member, a role that better-auth would read as two (owner,admin), and a stranger refuses every other address once there is an owner: no second owner, and nobody promoted
The command cutting the owner off No session ends gives an owner who lost their session a link, and ends no session that is open
A link for an owner who was demoted since Refused when it is opened refuses the link of an owner who is no owner any more when it is opened
The store of the marks down No link is made, and none is spent makes no link when the store of the marks does not answer, and spends none

Two holes that the tests found while I wrote this, both fail-open. A link whose mark is gone is the link of an e-mail, and would be spent with no check. The mark first lived ten minutes: an eleven-minute-old link signed in. Then a refused link had its mark removed, and better-auth, which had not been asked, still held it: the second try signed in. So the mark holds the moment of the link, outlives it, and is never removed (« ended »). worker.md says it.

Played backwards, each check taken away in turn: the age → 1 test fails; « current » → 2; the owner at the spend → 1; the mark removed → 1; the count of the starts → 1; the command taking any address → 1; the log writing whatever the owners → 1.

Proof

  • In a browser: e2e/console-first-admin.spec.ts, against a worker on PGlite with no mailer. The sign-in screen says to ask for a link and shows no form; the link of the log opens the console signed in, the workspace is made, the setup is at « Your site »; a second start writes nothing; the link in another browser is refused. e2e/console-no-sign-in.spec.ts holds the sentence on /sign-in and /setup, and its control (a provider keeps the form).
  • On the image, once, on a laptop (built from this branch, PostgreSQL 17, no FRUITBACK_SCALEWAY_TEM, no provider):
    • the first start wrote the three lines, the token on 1 line of docker logs; /auth/providers answered {"betterAuth":{"email":false,"github":false,"google":false}};
    • the link spent with the Origin of the console: 200, then /console/me 200, POST /console/workspaces 201; the same link again 401; the log said « the sign-in link made for … was used »;
    • a second start with the variable still set: 0 line with a link;
    • docker exec … admin-link --email <the owner>: exit 0, « sign-in link for …, an owner », and docker logs got « a sign-in link was made by the command admin-link for … (an owner) » with 0 line holding its token; the session of before still answered 200; the link signed in;
    • admin-link --email <somebody else>: « is not one … never makes another owner », exit 1;
    • with no FRUITBACK_AUTH_SECRET: /health 503, naming it.
  • The whole worker suite with PostgreSQL 17.11 beside it: 1848 tests, 0 fail; test:postgres 1038, 0 fail; the console tests 107 pass; typecheck, oxlint, oxfmt clean.

The screen, at 1440 and 390, /sign-in and /setup: captures in agent-coord/scratch/fru-auth/221-shots/ (they cannot be uploaded). One sentence in the existing alert of FRU-217 (Problem): no new element, no value, no token of the kit. French: « Ce Fruitback n’envoie pas de lien de connexion par e-mail. Seul son propriétaire se connecte pour l’instant, avec un lien que crée la personne qui l’exploite. Les autres pourront se connecter quand il enverra des e-mails, ou proposera GitHub ou Google. » (The captures show the sentence of the first commit; the element is the same.)

Not done, not verified

  • The ticket's « from an empty folder, docker compose up, the link opens the console »: the console is not in the compose file and its image is not published (FRU-222). The image run above spends the link with curl and the Origin of the console; the browser run is on PGlite.
  • The line of the command in the log goes through /proc/1/fd/1. Measured in the image with Docker on macOS. Not tried: Podman, a container whose first process is not the worker, a read-only /proc. The command says so when it could not write.
  • The lead of the sign-in page still says « We email you a link… » above the new sentence, as it does above the sentence of FRU-217. That screen belongs to the simpler-flow work; I changed the alert only.
  • Two real replicas: the test runs two starts at once on one Kv in memory; the Kv of PostgreSQL has its own concurrency tests (FRU-67).
  • A worker that starts again inside the ten minutes with no owner writes « a link was written less than 10 minutes ago » and no link: the command gives one at once.
  • The administration of an instance of the mock-up (screens 17 to 20, Q10) is not touched.

🤖 Generated with Claude Code


Sakuga Review · Score de confiance : 4/5

Mergeable après un regard sur le finding mineur : le mécanisme de lien (marques dans le Kv, lien courant, contrôle au moment de la consommation) tient dans le code lu. Le seul écart relevé est un texte affiché dans un cas où personne ne peut encore se connecter.

Constats

  1. P2 Message « seul le propriétaire se connecte » affiché sans propriétaire ni variable apps/console/app/api.ts:308
    Sur une instance sans e-mail ni fournisseur, byOperator vaut vrai dès que le worker répond betterAuth sans mail ni GitHub ni Google. Le texte dit alors que seul son propriétaire se connecte avec un lien de l'exploitant, même si aucun propriétaire n'existe et que FRUITBACK_FIRST_ADMIN n'est pas défini. Le visiteur croit qu'un lien va arriver et n'a aucune issue. Correction : Ne pas dériver byOperator du seul état du worker : n'afficher ce texte que si le worker dit qu'un propriétaire existe ou qu'un administrateur est configuré, sinon garder le message de configuration.
Résumé

La PR ajoute un lien de première connexion pour un instance sans e-mail ni fournisseur : le worker l'écrit dans son journal au démarrage (FRUITBACK_FIRST_ADMIN) ou via la commande admin-link, tant qu'aucun espace n'a de propriétaire. Le lien est marqué dans le Kv par son empreinte, il ne vaut qu'une fois, dix minutes, et il est refusé dès qu'un propriétaire existe. La console affiche alors un message à la place du formulaire. J'ai relu les routes de consommation, la configuration, le store (Postgres et SQLite), la commande et le composant d'interface. Je n'ai pas pu vérifier kvFor ni le contenu exact de sendLinkThrough.

Diagramme
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A["server.ts"] --> B["announceFirstAdmin"]
B --> C["firstAdminLines"]
C --> D["mintAdminLink"]
E["auth-routes.ts redeemLink"] --> F["adminLinkAt"]
F --> G["owners()"]
E --> H["endAdminLink"]
Loading
Non relu
  • docs/self-hosting.md : retiré pour tenir dans la taille maximale de la revue
  • apps/worker/src/account-conformance.fixture.ts : retiré pour tenir dans la taille maximale de la revue
  • apps/worker/src/kv.ts et kvFor (app.ts) : non fournis, donc on ne peut pas confirmer que le Kv du démarrage et celui de la commande sont partagés entre processus
  • sendLinkThrough et le mailer de better-auth (auth.ts) : non fournis, donc le format du jeton et l'absence d'envoi n'ont pas été vérifiés

Revues (5) · Dernier commit relu : Merge remote-tracking branch 'origin/main' into... · Niveau lite · anthropic/claude-haiku-5.5 · 0,013 $

… or a command (FRU-221)

An instance that sends no e-mail and has no GitHub or Google sign-in could make no account at all.
With FRUITBACK_FIRST_ADMIN=<address>, a worker whose instance has no owner yet writes a sign-in link
for that address in its log when it starts; `admin-link --email <address>` writes one on the
terminal. It is the link of an e-mail, sent nowhere, and it lands on the sign-in page of the console.

- No request makes one. Ten minutes, spent once, one at a time: a new link ends the one before, and
  two replicas that start together write one.
- Checked again when it is spent: refused once the instance has an owner, however they signed in.
- The command also makes one for an owner who lost their session, refuses any other address, ends
  no session, and leaves a line in the log of the worker with the address and no link.
- The token is on one line. The secret of the sign-in is asked for, and never made by the worker.
- The console says to ask for a link when the worker has no mail and no provider.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@sakuga-review sakuga-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Score de confiance : 4/5 · ✅ Rien de bloquant · Mergeable en l'état : aucun défaut certain n'a été trouvé dans le code visible, mais le lien de premier administrateur touche l'authentification et la concurrence, et deux implémentations de AccountStore et les appelants de kv ne sont pas dans le diff.

🔍 Une revue plus poussée est conseillée : Vérifier redeemLink et adminLinkAt contre les appelants réels et les autres implémentations de AccountStore, puis le partage du Kv entre réplicas, car le lien touche l'authentification et la concurrence.
Commentez @sakuga-review review balanced (environ 5 centimes) ou @sakuga-review review heavy (10 centimes à 1 dollar).

Niveau lite · anthropic/claude-haiku-5.5 · 0,012 $ · Le résumé, le diagramme et la liste des constats sont à la fin de la description de la PR.

@sakuga-review sakuga-review Bot added the review: 4/5 Score de confiance de Sakuga Review (5 : prête à merger) label Oct 13, 2026
@sakuga-agent

sakuga-agent Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

@sakuga-review review heavy

@sakuga-review sakuga-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Score de confiance : 4/5 · ⚠️ Rien de bloquant · La PR peut être fusionnée : le chemin d'authentification ajouté est cohérent et largement testé ; il reste deux défauts mineurs, un message de la console trompeur et un compteur de démarrages qui peut annoncer un lien jamais écrit.

Niveau heavy · anthropic/claude-opus-5.5 · 0,540 $ · Le résumé, le diagramme et la liste des constats sont à la fin de la description de la PR.

Comment thread apps/worker/src/first-admin.ts Outdated
'Read the log of that start, wait and start again, or run `node server.mjs admin-link --email <address>`.',
];
}
const link = await mintAdminLink(links, { kind: 'first', email, at: now });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Compteur de démarrages incrémenté même si aucun lien n'est créé

Au démarrage, incr(STARTS) renvoie 1, puis mintAdminLink échoue : better-auth n'est pas prêt, ou le Kv.set rejette. Aucun lien n'est écrit, mais le compteur reste à 1. Pendant dix minutes, chaque redémarrage écrit qu'« un lien a été écrit il y a moins de 10 minutes », alors qu'il n'existe nulle part.

Correction : Si mintAdminLink échoue, décrémenter ou effacer STARTS avant de relancer l'erreur. On peut aussi n'incrémenter qu'après une création réussie, avec un autre verrou pour les réplicas.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, fixed in e8a717a. When the link cannot be made, the count of the starts goes back before the error goes up, so the next start writes a link. test:does not say that a link was written when the start that should have written it failed starts once with a better-auth that does not start, then again: the second start writes a link, and it signs in. Without the reset, that test fails.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ce constat n'apparaît plus au commit e8a717a88, et le fichier a changé depuis. Le fil peut être résolu.

{closedByOperator
? t(
'This Fruitback sends no sign-in link by e-mail. The person who runs it makes one for you: ask them for a sign-in link, then open it in this browser.',
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Le message promet un lien que la commande refuse

Une instance a déjà un propriétaire, pas d'e-mail et pas de fournisseur. Un membre invité lit « The person who runs it makes one for you: ask them for a sign-in link ». L'opérateur lance admin-link --email membre@…, et adminLinkFor refuse toute adresse qui n'est pas un propriétaire. La console oriente donc ce membre vers une voie qui n'existe pas pour lui.

Correction : Restreindre le texte au premier administrateur et aux propriétaires, ou dire qu'un membre ne peut pas se connecter tant que l'instance n'envoie pas d'e-mail ou n'a pas de fournisseur.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and it shows a limit that is worth saying out loud: on an instance with no e-mail and no provider, only an owner can sign in. A member who was invited has no way in, since the command refuses every address that is not an owner (arbitrated: never a second owner, nobody promoted). Fixed in e8a717a: the sentence no longer tells anybody to ask for a link. It says « This Fruitback sends no sign-in link by e-mail. Only its owner signs in for now, with a link that the person who runs it makes. Everybody else can sign in once it sends e-mail, or offers GitHub or Google. » Whether the command should also make a link for an existing member (it would sign them in, and promote nobody) is a question for Mathieu; I put it at the top of the description.

… console does not send a member to ask for one (FRU-221)

Raised in review.

- The count of the starts stayed at one when the link could not be made: every start of the next ten
  minutes then said that a link was written, and there was none. The count goes back on a failure.
- The sentence of the sign-in screen told anybody to ask for a link, and the command makes one for
  the first administrator and for an owner only. It now says that only the owner signs in for now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@sakuga-review sakuga-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Score de confiance : 4/5 · ✅ Rien de bloquant · Mergeable après revue humaine : aucun défaut certain n'est ressorti de la lecture du diff, mais la logique de liens à usage unique (marques dans le Kv, concurrence entre réplicas, contrôle à la consommation) n'a pas pu être vérifiée jusqu'au bout depuis ce seul message.

🔍 Une revue plus poussée est conseillée : Vérifier dans redeemLink et adminLinkAt l'ordre des contrôles de marque et la concurrence entre deux réplicas sur le Kv partagé, car ce chemin d'authentification n'a pu être confirmé qu'en partie.
Commentez @sakuga-review review balanced (environ 5 centimes) ou @sakuga-review review heavy (10 centimes à 1 dollar).

Niveau lite · anthropic/claude-haiku-5.5 · 0,012 $ · Le résumé, le diagramme et la liste des constats sont à la fin de la description de la PR.

@sakuga-agent

sakuga-agent Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

@sakuga-review review heavy

What changed since the heavy review of e7e3def (4/5, two P2): one commit, e8a717a, their two fixes. firstAdminLines takes the count of the starts back when the link could not be made (with its test), and the sentence of SignInClosed says that only the owner signs in for now. On what could not be read: the times of Kv.set and Kv.incr are milliseconds (kv.ts, kv-postgres.ts); AuthRouteContext.kv is a required field; and no route of better-auth that is served spends a link (/magic-link/verify is not in SERVED_PATHS: redeemLink calls it in the process).

@sakuga-review sakuga-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Score de confiance : 4/5 · ✅ Rien de bloquant · Fusion possible : aucun défaut bloquant n'apparaît dans le flux du lien du premier administrateur. Quelques dépendances restent non vérifiées dans le message : hasAuth, kvFor, sendLinkThrough et la sémantique de Kv.incr.

Niveau heavy · anthropic/claude-opus-5.5 · 0,566 $ · Le résumé, le diagramme et la liste des constats sont à la fin de la description de la PR.

…r-un-compte-dadministration-toujours-possible

# Conflicts:
#	apps/worker/src/cli.ts
#	apps/worker/src/env.ts

@sakuga-review sakuga-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Score de confiance : 4/5 · ⚠️ Rien de bloquant · Mergeable après un regard sur le finding mineur : le mécanisme de lien (marques dans le Kv, lien courant, contrôle au moment de la consommation) tient dans le code lu. Le seul écart relevé est un texte affiché dans un cas où personne ne peut encore se connecter.

Niveau lite · anthropic/claude-haiku-5.5 · 0,013 $ · Le résumé, le diagramme et la liste des constats sont à la fin de la description de la PR.

@sakuga-agent

sakuga-agent Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Reply to the finding of the lite review of fd73733 (apps/console/app/api.ts:308, « only the owner signs in » shown with no owner and no variable):

The sentence holds without FRUITBACK_FIRST_ADMIN and without an owner. The link is made by the person who runs the instance, with the admin-link command (node server.mjs admin-link <email>, runAdminLink of cli.ts). The command works on every worker with accounts, whatever the variable says. With no owner yet, it makes the link of the first owner. FRUITBACK_FIRST_ADMIN only also writes that first link to the container log at start. So on an instance with no e-mail and no provider, the operator's link is always the one way in, and the screen says exactly that: « Only its owner signs in for now, with a link that the person who runs it makes. » The configuration message would be wrong there, since nothing is missing for the operator. test:makes the link of an instance with no owner, and leaves a line with the address and no token (first-admin.test.ts) covers the command with no owner. No code change.

This head (fd73733) is only the merge of main; nothing changed in this file.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review: 4/5 Score de confiance de Sakuga Review (5 : prête à merger)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant