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
6 changes: 4 additions & 2 deletions docs/architecture/05-type-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ export const config = defineConfig({
`defineEnv` is a purpose-built declarative record, not the `@ultimat3/schema` `t` used by actions and
entities — env vars are always strings on the wire and need coercion (`number`/`port`/`boolean`/`enum`),
a `role` gate, and `secret` redaction a generic object schema has no vocabulary for. `X_ENV_MISSING` is
the one code for this gate; `X_CONFIG_INVALID` is the unrelated failure of `app.config.ts` itself
failing its own schema (bad `defaultLocale`, `db.pool < 1`, a non-IANA `timeZone`) — see
the one code for a key this gate finds absent or unparseable; `X_CONFIG_INVALID` is the separate
failure of a configuration that parses and still cannot boot — `app.config.ts` against its own schema
(bad `defaultLocale`, `db.pool < 1`, a non-IANA `timeZone`), or two env keys that each parse and
contradict each other (`SMTP_URL` with `RESEND_API_KEY`, `FASTLY_*` with `CLOUDFLARE_*`) — see
[Configuration](../../wiki/Configuration.md).
2 changes: 1 addition & 1 deletion docs/idea/05-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const publishPost = action({
| Tier 2 in-process LRU (**all instances**) | tag-invalidation message on NATS | ~ms, best-effort; a missed message costs a stale read until TTL, never a wrong write |
| Tier 3 Redis | `SREM`/`DEL` over the tag's key set | immediate, transactional with the outbox |
| ISR pages | routes whose `revalidate.tags` include the tag are marked stale → regenerated in background | next request serves stale, regen enqueued as a job |
| CDN | purge-by-URL for the affected route set, via the configured purge webhook | seconds; `stale-while-revalidate` covers the gap |
| CDN | purge by surrogate key — the same tag strings — through the configured `PurgeDriver` | seconds; `stale-while-revalidate` covers the gap |
| Live queries | the same commit already flows through logical replication ([`03-realtime.md`](./03-realtime.md)) | independent path — realtime does not depend on cache invalidation |

Fanout is enqueued in the **same transaction** as the write (the outbox from [`04-jobs.md`](./04-jobs.md)). A rolled-back write never purges; a committed write always does.
Expand Down
52 changes: 51 additions & 1 deletion framework.manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": 1,
"buildId": "eb137ad0893091cac0c94a348bbfb44d4ef027e362a5d36855fd8a37fc1c36d0",
"buildId": "095b6e043bc54393088bc85a0fa1f6801cefb0c8a5a08c474487492423b9281a",
"tiers": {
"0": [
"core",
Expand Down Expand Up @@ -293,6 +293,11 @@
"owner": "ai",
"at": "packages/ai/src/errors.ts"
},
{
"code": "X_AI_EMBEDDER_INVALID",
"owner": "ai",
"at": "packages/ai/src/errors.ts"
},
{
"code": "X_AI_GATEWAY_MISSING",
"owner": "ai",
Expand Down Expand Up @@ -413,6 +418,11 @@
"owner": "cache",
"at": "packages/cache/src/errors.ts"
},
{
"code": "X_CACHE_PURGE_FAILED",
"owner": "cache",
"at": "packages/cache/src/errors.ts"
},
{
"code": "X_CACHE_TAG_UNKNOWN",
"owner": "cache",
Expand Down Expand Up @@ -663,6 +673,11 @@
"owner": "core",
"at": "packages/core/src/error-codes.ts"
},
{
"code": "X_IMAGE_QUERY_INVALID",
"owner": "seo",
"at": "packages/seo/src/errors.ts"
},
{
"code": "X_IMAGE_TOO_LARGE",
"owner": "core",
Expand Down Expand Up @@ -993,6 +1008,21 @@
"owner": "pwa",
"at": "packages/pwa/src/errors.ts"
},
{
"code": "X_PWA_STRATEGY_EXHAUSTED",
"owner": "pwa",
"at": "packages/pwa/src/errors.ts"
},
{
"code": "X_PWA_SYNC_FLUSH_FAILED",
"owner": "pwa",
"at": "packages/pwa/src/errors.ts"
},
{
"code": "X_PWA_SYNC_INCOMPLETE",
"owner": "pwa",
"at": "packages/pwa/src/errors.ts"
},
{
"code": "X_QUERY_DUPLICATE",
"owner": "query",
Expand Down Expand Up @@ -1283,6 +1313,11 @@
"owner": "testing",
"at": "packages/testing/src/errors.ts"
},
{
"code": "X_TEST_EVAL_THRESHOLD",
"owner": "testing",
"at": "packages/testing/src/errors.ts"
},
{
"code": "X_TEST_FAILED",
"owner": "cli",
Expand All @@ -1298,11 +1333,21 @@
"owner": "testing",
"at": "packages/testing/src/errors.ts"
},
{
"code": "X_TEST_JOB_EXPECTED",
"owner": "testing",
"at": "packages/testing/src/errors.ts"
},
{
"code": "X_TEST_NETWORK_OFFLINE",
"owner": "testing",
"at": "packages/testing/src/errors.ts"
},
{
"code": "X_TEST_NETWORK_RACE",
"owner": "testing",
"at": "packages/testing/src/errors.ts"
},
{
"code": "X_TEST_NETWORK_SEALED",
"owner": "testing",
Expand All @@ -1318,6 +1363,11 @@
"owner": "testing",
"at": "packages/testing/src/errors.ts"
},
{
"code": "X_TEST_SCHEMA_EXPECTED",
"owner": "testing",
"at": "packages/testing/src/errors.ts"
},
{
"code": "X_TEST_SHARD_FAILED",
"owner": "cli",
Expand Down
4 changes: 3 additions & 1 deletion packages/ai/src/embeddings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// queried with another is a silent relevance collapse, and the only place to catch it is
// where the two meet. `VectorStore` compares the declared dimension and refuses.

import { AiEmbedderInvalidError } from './errors';

export interface Embedder {
readonly name: string;
/** Declared once, checked everywhere. */
Expand All @@ -16,7 +18,7 @@ export interface Embedder {
/** Embed one text without building an array at the call site. */
export async function embedOne(embedder: Embedder, text: string): Promise<Float32Array> {
const [vector] = await embedder.embed([text]);
if (vector === undefined) throw new Error(`embedder ${embedder.name} returned no vector`);
if (vector === undefined) throw new AiEmbedderInvalidError({ embedder: embedder.name });
return vector;
}

Expand Down
22 changes: 22 additions & 0 deletions packages/ai/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const AI_ERROR_CODES = [
'X_EVAL_RECORDING',
'X_VECTOR_DIM_MISMATCH',
'X_VECTOR_SCOPE_WIDENED',
'X_AI_EMBEDDER_INVALID',
] as const;

export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
Expand All @@ -41,6 +42,7 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
X_EVAL_RECORDING: 'the gate ran with baseline recording switched on',
X_VECTOR_DIM_MISMATCH: 'embedding dimensions differ from the store',
X_VECTOR_SCOPE_WIDENED: 'a derived vector scope tried to leave its tenant',
X_AI_EMBEDDER_INVALID: 'an Embedder returned fewer vectors than texts it was given',
};

// Titles must be registered for `format()` to render the contract's first line. Unconditional and
Expand Down Expand Up @@ -334,6 +336,26 @@ export class EmbedderDimMismatchError extends UltimateError {
}
}

/**
* `embedOne` asked an `Embedder` for one vector and got none back — a batch-size invariant the
* embedder itself broke, not a caller mistake. Distinct from `X_VECTOR_DIM_MISMATCH`: this fires
* before there is a vector at all, so there is nothing yet to measure the width of.
*/
export class AiEmbedderInvalidError extends UltimateError {
constructor(input: { embedder: string }) {
super({
code: 'X_AI_EMBEDDER_INVALID',
cause: `embedder "${input.embedder}" returned no vector for a batch of one text`,
// The `${…}` the fix used to carry is unreadable to the `errors` gate, which blanks every
// interpolation — so the literal half alone has to name the call. Which embedder broke the
// invariant is a fact of the failure, and the cause and `meta` are where facts live.
fix: 'return one vector per input text from embed(), in the order the texts arrived',
docs: docsFor('X_AI_EMBEDDER_INVALID'),
meta: { embedder: input.embedder },
});
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** No credential at call time. Named env var, because that is the whole fix. */
export class AiKeyMissingError extends UltimateError {
constructor(input: { provider: string; envVar: string }) {
Expand Down
10 changes: 10 additions & 0 deletions packages/auth/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ Tier 3. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
- 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.
- The handshake crosses two requests, so it is sealed (`sealHandshake`), never handed over in a
variable. `openHandshake` takes the provider as an argument for the reason `decodeCursor` takes
a scope: an optional check is one a call site forgets. Expiry is the server's clock, not `Max-Age`.
- One handshake cookie **per provider** (`handshakeCookieName`), never one shared slot. Two tabs
are two handshakes in one jar, and a shared name makes the second redirect overwrite the first.
`clearHandshakeCookie(provider)` for the same reason: clearing all of them cancels the other tab.
- `readCookie` never throws on a malformed value. The `Cookie:` header is attacker-controlled and
`decodeURIComponent('%')` is a bare `URIError`, which would escape every coded path in this
package — the raw value goes to the signature or hash check, which is the readable refusal.
- 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
Expand All @@ -39,6 +48,7 @@ Tier 3. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
| `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-cookie.ts` | the handshake's home between the two legs: seal, open, the cookie |
| `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` |
Expand Down
53 changes: 46 additions & 7 deletions packages/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,54 @@ Two calls: one to leave, one to come back. Provider configs are pure data — im
`oauth.ts` performs no network I/O and reads no env.

```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 — redirect, keeping nothing on the server
export async function GET(request: Request): Promise<Response> {
const handshake = beginOAuth({ provider: 'github', clientId, redirectUri });
return new Response(null, {
status: 302,
headers: { location: handshake.authorizeUrl, 'set-cookie': handshakeCookie(handshake) },
});
}
```

// GET /auth/oauth/:provider/callback — exchange, identify, sign in
const { actor, cookie } = await completeOAuthLogin(auth, {
handshake,
callback: { state: url.searchParams.get('state') ?? '', code },
});
```ts
// GET /auth/oauth/:provider/callback — a separate request; the cookie is all that crossed
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const { cookie } = await completeOAuthLogin(auth, {
handshake: readHandshakeCookie(request, 'github'),
callback: { state: url.searchParams.get('state') ?? '', code: url.searchParams.get('code') ?? '' },
});
const headers = new Headers({ location: '/' });
// Both, always: a code is single-use, so the handshake that authorised it must not outlive it.
headers.append('set-cookie', cookie);
headers.append('set-cookie', clearHandshakeCookie('github'));
return new Response(null, { status: 302, headers });
}
```

The handshake carries `state`, `nonce` and the PKCE verifier across two requests, so it needs a
home. `handshakeCookie` is that home — sealed with `SESSION_SECRET`, `HttpOnly; Secure;
SameSite=Lax` under a `__Host-` name, and expired against the server's clock rather than the
client's copy of `Max-Age`. `sealHandshake` / `openHandshake` are the same codec without the
cookie, for an app that would rather keep it server-side.

**One cookie per provider:** `handshakeCookieName(provider)` → `__Host-x_oauth_github`. A browser
is one cookie jar and a user is allowed two tabs, so a single shared name means the `google`
redirect overwrites a `github` handshake still in flight — and the github callback then opens
google's and fails `X_OAUTH_STATE_INVALID` for a reason no restart clears. `handshakeCookie` takes
the name off `handshake.provider`, `clearHandshakeCookie(provider)` clears only that provider's,
and `readHandshakeCookie(request, provider)` reads only that provider's. Pass `{ name }` to
override all three at once.

| Refused | Because |
|---|---|
| a handshake with no signature, or one signed with another secret | a browser that can mint a handshake can pair its own code with someone else's session |
| a `github` handshake opened on the `google` callback | `openHandshake(sealed, provider)` requires the provider, so it cannot be forgotten |
| a handshake older than `DEFAULT_HANDSHAKE_TTL_MS` (10 min) | a client may ignore `Max-Age`; the server's clock decides |
| a callback with no handshake cookie | there is nothing to check `state` against |
| a cookie value that is not valid percent-encoding | the header is the client's; the raw value reaches the signature check and fails it, never a bare `URIError` |

| Provider | PKCE | id token | Env |
|---|---|---|---|
| `github` | S256 | — profile + verified-emails call | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` |
Expand All @@ -81,6 +119,7 @@ JWT signed with the `.p8` key, which Apple expires every six months.

| Step | Does | Fails with |
|---|---|---|
| `handshakeCookie` / `readHandshakeCookie` | seals the handshake onto the redirect, opens it on the callback | `X_OAUTH_STATE_INVALID`, `X_ENV_MISSING` |
| `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` |
Expand Down
13 changes: 13 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ export {
OAUTH_PROVIDERS,
pkceChallenge,
} from './oauth';
export type { HandshakeCookieOptions, HandshakeSealOptions } from './oauth-cookie';
export {
clearHandshakeCookie,
DEFAULT_HANDSHAKE_TTL_MS,
handshakeCookie,
handshakeCookieName,
handshakeSecret,
OAUTH_HANDSHAKE_COOKIE_PREFIX,
openHandshake,
readHandshakeCookie,
sealHandshake,
} from './oauth-cookie';
export type {
OAuthClientCredentials,
OAuthExchangeOptions,
Expand Down Expand Up @@ -186,6 +198,7 @@ export {
DEFAULT_SESSION_POLICY,
listDevices,
parseSessionToken,
readCookie,
readSessionCookie,
revokeOtherSessions,
revokeSession,
Expand Down
Loading
Loading