import { DataStore } from '@voltro/database'; import { Schema } from 'effect'; export declare interface ApiKeyRecord { readonly id: string; /** The key's organization id (D5 active org). */ readonly tenantId: string; /** * WHO MINTED THIS KEY. Provenance, and it is always knowable — somebody * pressed the button. An org key has a creator just as much as a personal one * does; "who created this org key" is a question you will be asked. * * This used to be documented as "the user this key was minted for / by", and * that slash was the bug: one field carrying two different relationships, so * a null had to mean BOTH "nobody created it" (never true) and "it belongs to * no person" (the actual thing being expressed). See `onBehalfOf`. */ readonly createdBy?: string | null; /** * WHO THIS KEY ACTS AS. `null` is meaningful here and only here: the key * belongs to the organization, not to a person — a CI credential, a shared * integration. * * Surfaced on the Subject as `metadata.userId`, and it is what the audit * columns stamp, so "created by …" resolves to a username with no join back * to the key table. A personal key acting as its owner is the common case; an * admin may also mint a key on behalf of another user, which is exactly the * case `createdBy` and this field disagree — and the reason they must be two * fields. */ readonly onBehalfOf?: string | null; readonly scopes: ReadonlyArray; /** * App-defined binding carried onto the Subject's `metadata`, for the axis the * framework's own row does not model. `tenantId` is one level of ownership; a * product whose keys belong to a TEAM, a project, or an environment needs a * second, and without a slot for it the only safe way to authorize is a DB * lookup on every check — on the auth hot path. * * resolveKey: async (hash) => { * const row = await findKey(hash) * return row && { ...row, metadata: { keyType: row.keyType, teamId: row.teamId } } * } * * A guard then reads `subject.metadata.teamId` with no second query. Merged * UNDER the framework's own `provider` / `userId` keys, so an app cannot * accidentally overwrite the strategy's own attribution. */ readonly metadata?: Readonly> | undefined; } export declare const apiKeyStrategy: (options: ApiKeyStrategyOptions) => AuthStrategy; export declare interface ApiKeyStrategyOptions { /** Token prefix gate, e.g. `'awb_'`. A bearer token without it is * SKIPPED (another strategy may own it). Empty = match any bearer. */ readonly prefix?: string; /** * App-supplied lookup: SHA-256 hex of the token → the key row (null = * unknown/revoked → `failed`). Apps store key HASHES, never the raw * token, and look up by hash (constant-time at the DB). * * The SECOND argument is the strategy input the composer handed this * resolution — the same object a hand-written `AuthStrategy.resolve` gets, so * `input.store` is the app's boot DataStore and `input.headers` are the * request's. Without it this helper was the one auth seam that could not * reach the store: an app using it had to wrap `apiKeyStrategy` in its own * strategy purely to close over a store it had already been given, or open a * second connection path to the same database. * * resolveKey: async (hash, { store }) => { * const rows = await store?.query({ table: 'api_keys', where: eq('hash', hash) }) * return (rows?.[0] as ApiKeyRecord | undefined) ?? null * } * * `store` is `undefined` only while the store is still being built — see * {@link AuthStrategyInput.store}. A resolver that needs it must handle that, * and returning `null` (→ `failed`) is the safe answer. */ readonly resolveKey: (tokenSha256Hex: string, input: AuthStrategyInput) => Promise | ApiKeyRecord | null; /** Strategy id (logs + `metadata.provider`). Default 'voltro-apikey'. */ readonly id?: string; } /** Pluggable auth strategy. Strategies are SYNC-fast on no-match * (cookie-name lookup) and cache any JWKS / DB roundtrips on match * so steady-state verification stays CPU-local. */ declare interface AuthStrategy { /** Stable id (`'voltro-password'`, `'workos'`, `'kinde'`, …). Used * in logs + `Subject.metadata.provider`. */ readonly id: string; readonly resolve: (input: AuthStrategyInput) => Promise | StrategyResolution; /** * The bearer-token PREFIX this strategy claims, when it gates on one * (`'sk_'`, `'awb_'`). Declared so a collision is DETECTABLE. * * Two strategies claiming the same prefix is not a harmless duplicate: the * chain is first-match-wins, so whichever runs first decides the Subject — * and if they resolve the same token to different authority, which one * answered decides whether authorization works. A downstream app hit exactly * this and had to pin a test asserting it never sets `apiKeys: true`, because * doing so would append the framework strategy alongside its own on the same * `sk_` prefix, with the framework one resolving without the app's team * binding. * * Optional: a cookie or JWKS strategy claims no prefix and omits it. Only * what is declared can be checked — a strategy that gates on a prefix without * saying so is invisible to the boot check, exactly as before. */ readonly claimsBearerPrefix?: string; } /** Per-call input the framework hands every strategy. */ declare interface AuthStrategyInput { readonly headers: Readonly>; readonly clientId: number; /** * The app's DataStore, for a strategy that must READ to identify the caller. * * Without it, a DB-backed strategy — a session row, an API-key record, a PAT * table — had to open a SECOND connection path beside the framework's, to the * same database the request store opens a moment later. One adopter's * `auth/db.ts` is 105 lines of exactly that: a second `ManagedRuntime` plus a * `MysqlClient`, load-bearing for session lookup and their ApiKeyStore. Every * DB-backed OIDC / SAML / PAT integration rebuilds it. * * It is the SAME value `auth.resolveScopes` receives — one store, handed to * both, rather than a second narrower type for the same object. A read-only * surface would be the better guarantee and it is not available cheaply here: * `DataStore` is the driver SPI, and a strategy that writes during subject * resolution is a design mistake the type system is not going to catch for * you. Read users / sessions / keys; do not run domain writes. * * It is the BOOT store, not a request-scoped one — strategies resolve before * a request store exists. `undefined` only while the store is still being * built (`voltro dev` builds it after the auth chain; `voltro serve` before), * and on an app with no store at all. * * **What it does and does not carry.** It applies the STORAGE codec — * `.encrypted()` columns decrypt on read and encrypt on write, and array * columns round-trip on dialects with no native array type. It applies NONE * of the Subject-dependent behaviour: no tenant scope, no soft-delete filter, * no audit-column stamping, no row-level security. That split is not an * omission on either side. Those need a Subject, and a strategy runs BEFORE * one exists — so a read of tenant-owned rows here must derive and apply that * scope itself. Encryption needs no Subject, and handing back `enc:v1:…` * would be a silent wrong answer: the ciphertext is a string, so it compares * and renders and simply never matches. */ readonly store?: DataStore; } declare type StrategyResolution = { readonly kind: 'matched'; readonly subject: Subject; /** * When this credential expires, in unix SECONDS — if the strategy knows. * * The strategy is the ONLY place in the system that has verified the token * and holds its `exp`, and until now it could not say so. The credential * bound therefore read one source: the `voltro:session` cookie. For an app * authenticating with Bearer JWTs — six of our own catalog strategies do, * and every one of them verifies an `exp` — it was silently `undefined`, * so "a subscription can no longer outlive the credential that authorized * it" was a no-op that read as a guarantee. * * A reporter found it by expecting black screens an hour after a deploy * and getting none. Their conclusion is the one to keep: the guarantee was * not false, it was scoped to an auth shape the sentence did not name. * * Optional, and absent still means no bound — the failure direction is the * behaviour that already existed. */ readonly credentialExpiresAt?: number; } | { readonly kind: 'skip'; } | { readonly kind: 'failed'; readonly reason: string; }; declare const Subject: Schema.Union<[Schema.Struct<{ type: Schema.Literal<["user"]>; id: typeof Schema.String; tenantId: typeof Schema.String; scopes: Schema.optional>; metadata: Schema.optional>; }>, Schema.Struct<{ type: Schema.Literal<["apiKey"]>; id: typeof Schema.String; tenantId: typeof Schema.String; scopes: Schema.optional>; metadata: Schema.optional>; }>, Schema.Struct<{ type: Schema.Literal<["serviceAccount"]>; id: typeof Schema.String; tenantId: typeof Schema.String; scopes: Schema.optional>; metadata: Schema.optional>; }>, Schema.Struct<{ type: Schema.Literal<["anonymous"]>; id: typeof Schema.Null; tenantId: Schema.NullOr; /** * Set when this caller PRESENTED a credential and it was rejected — an * expired token above all. Absent when they presented none. * * The two are the same Subject and must not be the same ANSWER. A deployment * measured the cost: a user's tab outlived their IdP's token lifetime, the * strategy logged `supabase jwt expired`, the caller fell through to * anonymous, and the guard then refused with `missing required scope * 'task:u:o'`. Technically true — an anonymous caller holds no scopes — and * it sent everyone who read it into the permissions system while the problem * was an expired session. They did that round. * * It stays a FALLBACK rather than a hard failure on purpose: a stale cookie * must not break an `openAccess` procedure that needs no session at all. The * fact travels, and only a guard that actually refuses spends it. */ credentialRejected: Schema.optional; }>, Schema.Struct<{ type: Schema.Literal<["system"]>; id: typeof Schema.String; tenantId: typeof Schema.Null; scopes: Schema.optional>; metadata: Schema.optional>; }>]>; declare type Subject = typeof Subject.Type; export { }