import type { Db } from "../db/connect.js"; import type { Persona, SchemaSnapshot, SeededRow } from "../types.js"; /** * Become a real signed-in Supabase user on a project Crossline cannot sign for. * * Supabase changed the default. Projects created from 1 October 2025 sign * sessions with an asymmetric key — ES256 by default — and the shared HS256 * secret is kept only for backward compatibility. On such a project the token * `mintSupabaseJwt` produces is rejected outright: GoTrue answers * `bad_jwt` / "signing method HS256 is invalid", so every request 401s, the * owner check fails and the run is honestly but uselessly inconclusive. That * gap widens by one day per day. * * There is no way to sign an ES256 token ourselves without the project's * private key, which never leaves Supabase and which we would refuse to handle * if it did. So we do not sign anything: we ask the project to issue a real * session for a user we control, through the Auth Admin API. * * 1. make sure the persona exists as an auth user (create it with its own id, * or set a password on the one Crossline already seeded); * 2. exchange that password for a session at the token endpoint; * 3. send the access token the project handed back. * * The identity stays a fact rather than an inference. We never decode, guess at * or approximate a token — the token grant's own response says which user the * session belongs to, and if that is not the persona we asked for, we assert * nothing and say so. The whole exchange is provider-mediated: what Crossline * knows is what Supabase told it. * * The one credential this needs is the project's secret (`service_role`) key, * which the developer supplies from their own environment. It is used here and * nowhere else: it is never put into a request to the application, so it cannot * reach a reproduction command, `last-run.json` or a report — none of which are * built from anything but the headers the probes actually sent. * * Documentation this is built from, current as of July 2026: * https://supabase.com/blog/jwt-signing-keys (asymmetric by default from 2025-10-01) * https://supabase.com/docs/guides/auth/signing-keys (ES256/RS256, key states, JWKS) * https://supabase.com/docs/guides/api/api-keys (publishable / secret keys) * https://supabase.com/docs/reference/javascript/auth-admin-createuser * * ## Becoming a user with no secret key at all * * The above asks a founder to find their `service_role` key and paste it into * an environment variable, and a key pasted anywhere is the thing that stops a * tool being adopted. It turns out not to be necessary, and the reason is worth * stating plainly: of the four calls above, the secret key buys exactly two * facts — that the persona has a password we know, and that its email counts as * confirmed. Both of those are *columns on `auth.users`*, and Crossline is * already connected to the database that holds them, with enough privilege to * have planted the persona rows there in the first place. A connection string * is strictly more powerful than a `service_role` key; asking for the key as * well buys nothing. * * So `supabase_anon` writes those two columns itself and then signs in through * the public front door: * * 1. `UPDATE auth.users SET encrypted_password = crypt(, gen_salt('bf'))`, * and confirm the email, for the persona the seeder already planted. The * hash is bcrypt because that is what GoTrue's own writes are, and it is * produced by pgcrypto inside Postgres rather than by anything here. * 2. `POST /auth/v1/token?grant_type=password` carrying only `apikey: * ` — the same request, with the same key, that the founder's * own sign-in page makes from the browser. * * The identity check does not move. Step 2's response names the user the * session belongs to, and if that is not the persona whose rows this run is * about, nothing is sent and nothing is claimed — exactly as on the admin path. * What changes is only where the password came from, and that is a fact about * a row we own. * * Three things this deliberately does not do: * * - It does not call `POST /auth/v1/signup`. That endpoint works with the * publishable key alone, but it assigns the id itself — so the user it * creates would own none of the planted rows — and on a hosted project * "Confirm email" is on by default, so it returns a user and no session * ("On hosted Supabase projects, this is true by default", * https://supabase.com/docs/guides/auth/passwords). It would also spend * the built-in mailer's default budget of two emails per hour * (https://supabase.com/docs/guides/auth/rate-limits), which a test that * runs on every commit cannot afford. * - It does not weaken the project. `email_confirmed_at` is set on the two * rows Crossline planted and nowhere else, and the run removes them. * - It does not guess when it cannot do the work. No `auth.users`, no * `encrypted_password` column, or no pgcrypto to hash with, and it declines * and says which, rather than falling back to something weaker. */ export interface SupabaseAdminAuth { kind?: "supabase_admin"; /** The project URL, e.g. `https://abcdefgh.supabase.co`. */ url: string; /** Secret (`service_role`) key. Used only against the Auth Admin API. */ secretKey: string; /** Publishable (`anon`) key, sent as `apikey` the way the JS client does. */ publishableKey: string; } /** * The same project, with nothing secret in it. * * See "Becoming a user with no secret key" above. The two values here are the * two a Supabase founder already has in `.env.local` and already ships to every * visitor's browser, so this strategy asks for nothing that was not already * public. */ export interface SupabaseAnonAuth { kind: "supabase_anon"; /** The project URL, e.g. `https://abcdefgh.supabase.co`. */ url: string; /** Publishable (`anon`) key, sent as `apikey` the way the JS client does. */ publishableKey: string; } type SupabaseAuth = SupabaseAdminAuth | SupabaseAnonAuth; export interface BecomeSupabaseResult { /** Headers per persona id, when every persona could be signed in. */ byId: Map>; /** Headers for an anonymous caller. Always usable. */ anon: Record; /** Auth users created here, so the run removes exactly what it added. */ rows: SeededRow[]; /** Why no signed-in request can be made. Null when one can. */ unavailable: string | null; established: string | null; } export declare function becomeSupabaseUsers(auth: SupabaseAuth, personas: Persona[], ctx: { db?: Db | undefined; snapshot?: SchemaSnapshot | undefined; }): Promise; /** `https://x.supabase.co` and `https://x.supabase.co/auth/v1` both work. */ export declare function authBase(url: string): string; export {};