Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion docs/idea/01-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ 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 |
| `@ultimat3/core` image (PNG/JPEG decode, resize, encode) | `sharp` + libvips native binary + `imagemin` plugins | ~12 |
Comment thread
sebyx07 marked this conversation as resolved.
Outdated
| `Bun.password` | `bcrypt` / `argon2` native addons | ~4 |
| `Bun.file` / `Bun.write` | `fs-extra`, `graceful-fs`, `globby` | ~6 |
| `bun --hot` | `nodemon`, `concurrently`, HMR middleware | ~5 |
Expand Down
3 changes: 2 additions & 1 deletion docs/idea/07-rendering-seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,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
11 changes: 11 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,11 @@ 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 |
| `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` | a custom `AuthAdapter` refused a method — `authNotImplemented(feature, fix)` |

```bash
bun test packages/auth
Expand Down
64 changes: 63 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,57 @@ 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.
*/
export const oauthAccountNotLinked = (provider: string, email: string): AuthError =>
new AuthError({
code: 'X_UNAUTHENTICATED',
cause: `an account holds ${email} 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 },
});
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 +185,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
129 changes: 129 additions & 0 deletions packages/auth/src/id-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, test } from 'bun:test';
import { frozenClock } from '@ultimat3/core';
import { AuthError } from './errors';
import { decodeIdToken, idTokenEmailVerified, verifyIdToken } from './id-token';

const NOW = new Date('2026-08-09T12:00:00.000Z');
const clock = frozenClock(NOW);
const seconds = (offsetMs: number): number => Math.floor((NOW.getTime() + offsetMs) / 1000);

const base64Url = (value: string): string => {
const bytes = new TextEncoder().encode(value);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};

const jwt = (claims: Record<string, unknown>): string =>
`${base64Url('{"alg":"RS256"}')}.${base64Url(JSON.stringify(claims))}.signature`;

const googleClaims = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
iss: 'https://accounts.google.com',
aud: 'client-id',
sub: '108122122550',
exp: seconds(3_600_000),
nonce: 'stored-nonce',
email: 'ada@example.com',
email_verified: true,
name: 'Ada Lovelace',
...overrides,
});

const codeOf = (call: () => unknown): string => {
try {
call();
} catch (error) {
return error instanceof AuthError ? error.code : `not-an-AuthError: ${String(error)}`;
}
return 'did-not-throw';
};

const verify = (idToken: string, nonce = 'stored-nonce'): unknown =>
verifyIdToken({ provider: 'google', idToken, clientId: 'client-id', nonce, clock });

describe('decodeIdToken', () => {
test('reads the claims this package acts on, including non-ASCII names', () => {
const claims = decodeIdToken('google', jwt(googleClaims({ name: 'Ada Lovelace 👑' })));
expect(claims.sub).toBe('108122122550');
expect(claims.email).toBe('ada@example.com');
expect(claims.name).toBe('Ada Lovelace 👑');
expect(claims.exp).toBe(seconds(3_600_000));
});

test('a token that is not three segments is X_OAUTH_TOKEN_INVALID', () => {
expect(codeOf(() => decodeIdToken('google', 'not.a-jwt'))).toBe('X_OAUTH_TOKEN_INVALID');
});

test('a payload that is not base64url JSON is rejected rather than guessed at', () => {
expect(codeOf(() => decodeIdToken('google', 'aGVhZGVy.$$$$.sig'))).toBe(
'X_OAUTH_TOKEN_INVALID',
);
});

test('a payload missing iss, sub, aud or a numeric exp is rejected', () => {
for (const missing of ['iss', 'sub', 'aud', 'exp'] as const) {
const claims = googleClaims();
delete claims[missing];
expect(codeOf(() => decodeIdToken('google', jwt(claims)))).toBe('X_OAUTH_TOKEN_INVALID');
}
});

test('email_verified counts only as a real boolean or the string Apple sends', () => {
expect(idTokenEmailVerified(decodeIdToken('google', jwt(googleClaims())))).toBe(true);
const asString = decodeIdToken('apple', jwt(googleClaims({ email_verified: 'true' })));
expect(idTokenEmailVerified(asString)).toBe(true);
const asFalse = decodeIdToken('google', jwt(googleClaims({ email_verified: 'false' })));
expect(idTokenEmailVerified(asFalse)).toBe(false);
const absent = googleClaims();
delete absent['email_verified'];
expect(idTokenEmailVerified(decodeIdToken('google', jwt(absent)))).toBe(false);
});
});

describe('verifyIdToken', () => {
test('accepts both issuer spellings Google has shipped', () => {
expect(() => verify(jwt(googleClaims()))).not.toThrow();
expect(() => verify(jwt(googleClaims({ iss: 'accounts.google.com' })))).not.toThrow();
});

test('an issuer the provider never claims is rejected', () => {
expect(codeOf(() => verify(jwt(googleClaims({ iss: 'https://evil.test' }))))).toBe(
'X_OAUTH_TOKEN_INVALID',
);
});

test('aud may be an array, and must contain this handshake client id', () => {
expect(() => verify(jwt(googleClaims({ aud: ['other', 'client-id'] })))).not.toThrow();
expect(codeOf(() => verify(jwt(googleClaims({ aud: 'another-app' }))))).toBe(
'X_OAUTH_TOKEN_INVALID',
);
});

test('an expired token is rejected, and one inside the skew window is not', () => {
expect(codeOf(() => verify(jwt(googleClaims({ exp: seconds(-120_000) }))))).toBe(
'X_OAUTH_TOKEN_INVALID',
);
expect(() => verify(jwt(googleClaims({ exp: seconds(-30_000) })))).not.toThrow();
});

test('a nonce from another browser is X_OAUTH_STATE_INVALID, not a token error', () => {
expect(codeOf(() => verify(jwt(googleClaims({ nonce: 'someone-elses-nonce' })))))
// Same class of event as a forged `state`: a token minted elsewhere, replayed here.
.toBe('X_OAUTH_STATE_INVALID');
const withoutNonce = googleClaims();
delete withoutNonce['nonce'];
expect(codeOf(() => verify(jwt(withoutNonce)))).toBe('X_OAUTH_STATE_INVALID');
});

test('a provider that issues no id token can never have one accepted for it', () => {
const asGithub = (): unknown =>
verifyIdToken({
provider: 'github',
idToken: jwt(googleClaims()),
clientId: 'client-id',
nonce: 'stored-nonce',
clock,
});
expect(codeOf(asGithub)).toBe('X_OAUTH_TOKEN_INVALID');
});
});
Loading
Loading