Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/01-package-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Decided **2026-08**, when the Postgres entity driver needed a home. `db` imports

| Package | Tier | Responsibility (one line) | Owns | Must never |
|---|---|---|---|---|
| `core` | 0 | `UltimateError`, ALS request context, ids, build ID, typed env | the error base + code registry, `ctx` shape, cross-tier interface types, the logger | import any `@ultimat3/*`; do I/O beyond `process.env` and stdout |
| `core` | 0 | `UltimateError`, ALS request context, ids, build ID, typed env, the image pipeline | the error base + code registry, `ctx` shape, cross-tier interface types, the logger, the one decode/resize/encode path | import any `@ultimat3/*`; do I/O beyond `process.env` and stdout |
| `schema` | 0 | Standard Schema façade; ArkType exposed as `t`; JSON Schema emit | `t`, `parse`, `toJsonSchema`, the env schema helper | know about HTTP, DB, or locales |
| `i18n` | 1 | translator, catalog flattening, locale negotiation, loud misses | `t()`, catalog format, `⟦key⟧` rendering, plural selection via CLDR | read a request object; format money |
| `money` | 1 | integer minor units with an attached currency | `Money`, arithmetic, `allocate`, ISO exponent table, `Intl` formatting | floats; cross-currency arithmetic; a bare number as a total |
Expand Down
7 changes: 6 additions & 1 deletion docs/idea/01-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,16 @@ Each row is a dependency subtree that never enters the lockfile.
| `bun test` | `vitest` / `jest`, `@types/jest`, coverage + mock + snapshot plugins | ~30 |
| `Bun.build` | `esbuild`/`rollup`/`vite` + framework plugin + postcss chain | ~40 |
| `Bun.Transpiler` / macros | `ts-node`, `tsx`, `swc`, babel presets | ~15 |
| Bun image (`sharp`-free resize/encode) | `sharp` + libvips native binary + `imagemin` plugins | ~12 |
| `Bun.password` | `bcrypt` / `argon2` native addons | ~4 |
| `Bun.file` / `Bun.write` | `fs-extra`, `graceful-fs`, `globby` | ~6 |
| `bun --hot` | `nodemon`, `concurrently`, HMR middleware | ~5 |

One more subtree dies with no Bun native behind it — a pure-TypeScript framework pipeline, written because native addons are blocked ([`15-risks.md`](./15-risks.md)):

| Framework primitive | Replaces | Deps killed (approx) |
|---|---|---|
| `@ultimat3/core` image (PNG/JPEG decode, resize, encode) | `sharp` + libvips native binary + `imagemin` plugins | ~12 |

Order of magnitude: a conventional equivalent stack is ~1,200 transitive packages; Ultimate's target is **under 40 direct dependencies for the whole framework**. Fewer packages is not vanity — it is fewer install failures, fewer CVE pages, and a smaller surface for an agent to misread.

Costs, stated plainly: no native-addon packages, and long-running-process maturity is less proven than Node's. See [`15-risks.md`](./15-risks.md).
Expand Down
5 changes: 4 additions & 1 deletion docs/idea/07-rendering-seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ Nothing here is a plugin. Deleting a route removes it from the sitemap in the sa

### Image pipeline

The canonical image capability contract — the wiki references this table rather than restating it.

```tsx
<Image src={post.cover} alt={post.title} sizes="(max-width: 700px) 100vw, 700px" priority />
```
Expand All @@ -128,7 +130,8 @@ Nothing here is a plugin. Deleting a route removes it from the sitemap in the sa
| Placeholder | blur hash inlined as a data URI, swapped on decode |
| Loading | `lazy` by default, `priority` → eager + `<link rel="preload">` for the LCP image |
| Where | build-time for `site/`, on-demand + cached for user uploads (`Bun.s3` + the cache tiers) |
| Runtime | Bun's native image APIs. No `sharp`, no vendor image CDN ([axiom 7](./00-thesis.md)) |
| Runtime | `@ultimat3/core`'s own pipeline — PNG/JPEG decode, resize, encode, zero dependencies. No `sharp`, no vendor image CDN ([axiom 7](./00-thesis.md)) |
| AVIF / WebP | measured from the header so dimensions still inline, never synthesised. A variant in those formats comes from an `ImageTransformDriver` |
Comment thread
sebyx07 marked this conversation as resolved.

### Budgets in `x verify`

Expand Down
2 changes: 1 addition & 1 deletion docs/idea/15-risks.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The Bun bet buys `Bun.sql`, `Bun.redis`, `Bun.s3`, native WebSockets, the test r

| Constraint | Impact | Mitigation |
|---|---|---|
| **Native addons (N-API) are blocked or unreliable** | no `sharp`, no `bcrypt` addon, no native ML bindings, some legacy DB drivers | Bun natives cover image, password hashing, Postgres, Redis, S3. Anything else: a subprocess or an HTTP service, never a hidden dependency |
| **Native addons (N-API) are blocked or unreliable** | no `sharp`, no `bcrypt` addon, no native ML bindings, some legacy DB drivers | Bun natives cover password hashing, Postgres, Redis, S3; `@ultimat3/core` carries its own pure-TS PNG/JPEG pipeline. Anything else: a subprocess or an HTTP service, never a hidden dependency |
| **Long-running-process maturity is less proven than Node's** | memory growth under sustained load and edge-case GC behaviour are less battle-tested, and `sync` nodes are *designed* to run for days holding many sockets | **budget explicit memory-profiling work**: soak tests at milestone 6 and 11 (24h+ at target socket count, RSS tracked), leak assertions in the live test type, and per-role memory ceilings with a graceful restart rather than an OOM kill |
| Some npm packages assume Node internals | occasional breakage | prefer web-standard libraries; the small dependency count makes this rare by construction |
| Single-runtime risk | a Bun regression is a framework outage | pin exact versions, keep an upgrade branch with the full `x verify` suite, and never depend on undocumented internals |
Expand Down
12 changes: 12 additions & 0 deletions packages/auth/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ Tier 3. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
parameter to it re-opens account enumeration.
- Absolute and idle expiry are two separate computations in `sessionExpiry()`. Do not fold them.
- PKCE is not provider-dependent. `usesPkce: false` is not a valid provider config.
- The code flow carries `nonce` inside the id token, not on the redirect. `assertOAuthCallback`
checks an echoed one when present and never requires it; `verifyIdToken` is the real gate.
- A token endpoint's HTTP 200 is not success — GitHub reports a dead code that way. Read `error`.
- Link by address only when the provider **and** the local account both verified it.
- id token signatures are not checked: it is read only where it arrived over TLS straight from
the token endpoint (OIDC Core 3.1.3.7). Never parse one that reached the browser.
- An api key's scopes are the agent actor's scopes. Never union them with the owner's roles.
- Rotate the session id on any privilege change (`rotateSession`), never patch the row.

Expand All @@ -32,6 +38,12 @@ Tier 3. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
| `session.ts` | two expiries, rotation, revocation, device list, the cookie |
| `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
| `rate-limit.ts` | per-ip + per-account buckets, lockout, `loginFailed()` |
| `oauth.ts` | provider data, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
| `oauth-exchange.ts` | `oauthCredentials` + the one POST to the token endpoint |
| `id-token.ts` | id token → claims this handshake may believe |
| `id-token-fixture.ts` | the one string-input JWT builder the OAuth tests share. Off `index.ts` |
| `oauth-profile.ts` | claims or userinfo → one `OAuthProfile` |
| `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |

```bash
bun test packages/auth
Expand Down
51 changes: 40 additions & 11 deletions packages/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,45 @@ x db gen "auth tables" # emits AUTH_TABLES into a migration
| `__Host-` + `Path=/` + no `Domain` | a sibling subdomain overwriting it (session fixation) |
| `Max-Age` | a client keeping it past the server's absolute ceiling |

## OAuth providers
## OAuth

Pure data. Importing `oauth.ts` performs no network I/O and reads no env.
Two calls: one to leave, one to come back. Provider configs are pure data — importing
`oauth.ts` performs no network I/O and reads no env.

| Provider | PKCE | Nonce | Env |
|---|---|---|---|
| `github` | S256 | — | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` |
| `google` | S256 | required | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` |
| `apple` | S256 | required | `APPLE_CLIENT_ID` / `APPLE_CLIENT_SECRET` |
```ts
// GET /auth/oauth/:provider — store the handshake in a short-lived signed cookie
const handshake = beginOAuth({ provider: 'github', clientId, redirectUri });

// GET /auth/oauth/:provider/callback — exchange, identify, sign in
const { actor, cookie } = await completeOAuthLogin(auth, {
handshake,
callback: { state: url.searchParams.get('state') ?? '', code },
});
```

`exchangeOAuthCode()` validates the callback, then throws `X_NOT_IMPLEMENTED` naming those
env vars. Mismatched `state`, a missing verifier and a bad `nonce` all throw
`X_OAUTH_STATE_INVALID`.
| Provider | PKCE | id token | Env |
|---|---|---|---|
| `github` | S256 | — profile + verified-emails call | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` |
| `google` | S256 | required, nonce-bound | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` |
| `apple` | S256 | required, nonce-bound | `APPLE_CLIENT_ID` / `APPLE_CLIENT_SECRET` |

Apple alone rejects a static secret: `APPLE_CLIENT_SECRET` must hold the ES256 client-secret
JWT signed with the `.p8` key, which Apple expires every six months.

| Step | Does | Fails with |
|---|---|---|
| `exchangeOAuthCode` | POSTs the code + PKCE verifier, verifies the id token | `X_OAUTH_EXCHANGE_FAILED`, `X_OAUTH_TOKEN_INVALID` |
| `oauthProfile` | id-token claims, else userinfo → one normalised identity | `X_OAUTH_EXCHANGE_FAILED` |
| `signInWithOAuth` | links the account, applies MFA, mints the session | `X_UNAUTHENTICATED`, `X_MFA_REQUIRED` |

- PKCE's verifier travels only in the exchange — it proves the code belongs to the browser
that started the flow.
- `state` is checked before anything reaches the network; `nonce` is checked inside the id
token, because that is where the code flow actually carries it.
- GitHub reports a bad, reused or expired code as **HTTP 200 with an `error` field**. Trusting
the status alone there mints a session from a failed exchange.
- An address is only linked to an existing account when **both** sides verified it. Otherwise
whoever registered the address first inherits the login.

## API keys — how an agent authenticates

Expand All @@ -89,10 +115,13 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
| `X_SESSION_EXPIRED` | idle or absolute expiry, named in `cause` |
| `X_MFA_REQUIRED` | password proven, second factor outstanding |
| `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
| `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
| `X_OAUTH_TOKEN_INVALID` | the id token failed its issuer, audience or expiry check |
| `X_PASSWORD_WEAK` | strength check rejected the password |
| `X_ACCOUNT_LOCKED` | per-ip or per-account bucket is inside its lockout |
| `X_API_KEY_INVALID` | key unknown, revoked, expired or wrong |
| `X_NOT_IMPLEMENTED` | OAuth token exchange without client credentials |
| `X_ENV_MISSING` | `oauthCredentials()` found no client id or secret for an enabled provider |
| `X_NOT_IMPLEMENTED` | an `AuthAdapter` refused a method (`authNotImplemented(feature, fix)`), or lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |

```bash
bun test packages/auth
Expand Down
81 changes: 80 additions & 1 deletion packages/auth/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const AUTH_ERROR_CODES = [
'X_SESSION_EXPIRED',
'X_MFA_REQUIRED',
'X_OAUTH_STATE_INVALID',
'X_OAUTH_EXCHANGE_FAILED',
'X_OAUTH_TOKEN_INVALID',
'X_PASSWORD_WEAK',
'X_ACCOUNT_LOCKED',
'X_API_KEY_INVALID',
Expand All @@ -23,6 +25,8 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthErrorCode, string>> = {
X_SESSION_EXPIRED: 'session passed its idle or absolute expiry',
X_MFA_REQUIRED: 'a second factor is required before this session is usable',
X_OAUTH_STATE_INVALID: 'oauth state, nonce or pkce verifier did not match',
X_OAUTH_EXCHANGE_FAILED: 'the oauth provider refused the exchange or returned no usable identity',
X_OAUTH_TOKEN_INVALID: 'id token failed its issuer, audience or expiry check',
X_PASSWORD_WEAK: 'password does not meet the configured policy',
X_ACCOUNT_LOCKED: 'too many failed attempts; this key is locked out',
X_API_KEY_INVALID: 'api key is unknown, revoked, expired or wrong',
Expand All @@ -49,12 +53,18 @@ export type AuthThrowCode = AuthErrorCode | keyof typeof AUTH_BORROWED_ERROR_TIT
export class AuthError extends UltimateError {
override readonly name = 'AuthError';

constructor(init: { code: AuthThrowCode; cause: string; fix: string }) {
constructor(init: {
code: AuthThrowCode;
cause: string;
fix: string;
meta?: Readonly<Record<string, unknown>> | undefined;
}) {
super({
code: init.code,
cause: init.cause,
fix: init.fix,
docs: `https://ultimate.dev/errors/${init.code}`,
meta: init.meta,
});
}
}
Expand Down Expand Up @@ -102,6 +112,74 @@ export const oauthStateInvalid = (provider: string, part: string): AuthError =>
fix: `restart the flow at GET /auth/oauth/${provider} — a callback URL is single-use`,
});

export interface OAuthExchangeFailure {
readonly provider: string;
/** Which leg of the server-to-server conversation failed. */
readonly stage: 'token' | 'userinfo';
readonly detail: string;
readonly status?: number | undefined;
readonly fix: string;
}

/**
* Deliberately specific, unlike every credential error above it. This one describes a
* conversation between two servers — naming the stage, the provider and its own status
* discloses nothing about any user, and is the difference between a fixable misconfiguration
* and a shrug.
*/
export const oauthExchangeFailed = (failure: OAuthExchangeFailure): AuthError =>
new AuthError({
code: 'X_OAUTH_EXCHANGE_FAILED',
cause:
`${failure.provider} ${failure.stage} request failed` +
`${failure.status === undefined ? '' : ` with HTTP ${failure.status}`}: ${failure.detail}`,
fix: failure.fix,
meta: {
provider: failure.provider,
stage: failure.stage,
...(failure.status === undefined ? {} : { status: failure.status }),
},
});

/**
* The address is proven to the provider, and an account that never proved it already holds it.
* Naming that is not account enumeration — this caller just demonstrated they own the address —
* and staying silent would leave them with a login that fails forever and no way out.
*
* The address itself rides in `meta`, never in `cause`: a log pipeline can redact a field by
* key, and cannot redact an address that was already interpolated into a sentence.
*/
export const oauthAccountNotLinked = (provider: string, email: string): AuthError =>
new AuthError({
code: 'X_UNAUTHENTICATED',
cause: `an account holds this ${provider} address but never verified it, so ${provider} may not claim it`,
fix: `sign in with that account's password and confirm the email-verify link, then retry ${provider}`,
meta: { provider, email },
});

/**
* `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
* write. Falling back to the unstamped row would mint a session for a user every later login
* reads as unverified — the exact state `resolveUser` refuses to link a provider to — so the
* flow fails closed on an adapter that loses the stamp instead of half-succeeding.
*/
export const emailVerifiedNotStored = (provider: string, userId: string): AuthError =>
new AuthError({
code: 'X_NOT_IMPLEMENTED',
cause: `the adapter returned no row for new user ${userId}, so the ${provider}-verified address was never stamped verified`,
fix: 'return the updated row from AuthAdapter.updateUser — MemoryAdapter.updateUser is the reference implementation',
meta: { provider, userId },
});
Comment thread
sebyx07 marked this conversation as resolved.

/** The token arrived, and is not one this handshake can trust: wrong `iss`, `aud`, or expired. */
export const oauthTokenInvalid = (provider: string, reason: string, fix: string): AuthError =>
new AuthError({
code: 'X_OAUTH_TOKEN_INVALID',
cause: `${provider} id token rejected: ${reason}`,
fix,
meta: { provider },
});

export const passwordWeak = (reasons: readonly string[]): AuthError =>
new AuthError({
code: 'X_PASSWORD_WEAK',
Expand All @@ -124,6 +202,7 @@ export const apiKeyInvalid = (): AuthError =>
fix: 'x auth keys list --json # then: x auth keys issue --scopes "<scope>"',
});

/** For a custom `AuthAdapter` that implements part of the seam. Nothing shipped throws it. */
export const authNotImplemented = (feature: string, fix: string): AuthError =>
new AuthError({
code: 'X_NOT_IMPLEMENTED',
Expand Down
16 changes: 16 additions & 0 deletions packages/auth/src/id-token-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Single responsibility: the one way this package's tests mint an id token. Three OAuth test
// files each needs a base64url-encoded JWT, and three private copies of the encoder is three
// chances for one to drift from what `decodeIdToken` actually parses. Not part of the public
// API — `index.ts` deliberately does not re-export it.

import { base64Url } from './tokens';

/** `base64Url` takes bytes because every real secret is bytes; a JWT segment is text. */
export const base64UrlText = (value: string): string => base64Url(new TextEncoder().encode(value));

/**
* Header, payload, and a signature that is not one. Signatures are never checked here — the
* token is only ever read straight off the token endpoint — so a fixture needs no signer.
*/
export const unsignedJwt = (claims: Readonly<Record<string, unknown>>): string =>
`${base64UrlText('{"alg":"RS256"}')}.${base64UrlText(JSON.stringify(claims))}.signature`;
Loading
Loading