Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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/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
18 changes: 18 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,22 @@ 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`,
fix: `fix the "${input.embedder}" Embedder's embed() to return exactly one vector per input text`,
docs: docsFor('X_AI_EMBEDDER_INVALID'),
});
}
}
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
4 changes: 4 additions & 0 deletions packages/auth/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ 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`.
- 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 +42,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
44 changes: 37 additions & 7 deletions packages/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,45 @@ 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());
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.

| 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 |

| Provider | PKCE | id token | Env |
|---|---|---|---|
| `github` | S256 | — profile + verified-emails call | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` |
Expand All @@ -81,6 +110,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
12 changes: 12 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,17 @@ export {
OAUTH_PROVIDERS,
pkceChallenge,
} from './oauth';
export type { HandshakeCookieOptions, HandshakeSealOptions } from './oauth-cookie';
export {
clearHandshakeCookie,
DEFAULT_HANDSHAKE_TTL_MS,
handshakeCookie,
handshakeSecret,
OAUTH_HANDSHAKE_COOKIE,
openHandshake,
readHandshakeCookie,
sealHandshake,
} from './oauth-cookie';
export type {
OAuthClientCredentials,
OAuthExchangeOptions,
Expand Down Expand Up @@ -186,6 +197,7 @@ export {
DEFAULT_SESSION_POLICY,
listDevices,
parseSessionToken,
readCookie,
readSessionCookie,
revokeOtherSessions,
revokeSession,
Expand Down
Loading
Loading