diff --git a/docs/architecture/05-type-chain.md b/docs/architecture/05-type-chain.md index 7d3b8857..98c2a158 100644 --- a/docs/architecture/05-type-chain.md +++ b/docs/architecture/05-type-chain.md @@ -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). diff --git a/docs/idea/05-caching.md b/docs/idea/05-caching.md index 3c49e180..f7e33a33 100644 --- a/docs/idea/05-caching.md +++ b/docs/idea/05-caching.md @@ -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. diff --git a/framework.manifest.json b/framework.manifest.json index 89967265..0e4e9e63 100644 --- a/framework.manifest.json +++ b/framework.manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "buildId": "eb137ad0893091cac0c94a348bbfb44d4ef027e362a5d36855fd8a37fc1c36d0", + "buildId": "095b6e043bc54393088bc85a0fa1f6801cefb0c8a5a08c474487492423b9281a", "tiers": { "0": [ "core", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/packages/ai/src/embeddings.ts b/packages/ai/src/embeddings.ts index bbaa86c4..a74d2842 100644 --- a/packages/ai/src/embeddings.ts +++ b/packages/ai/src/embeddings.ts @@ -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. */ @@ -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 { 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; } diff --git a/packages/ai/src/errors.ts b/packages/ai/src/errors.ts index 3c4bf5f1..9b8c3d53 100644 --- a/packages/ai/src/errors.ts +++ b/packages/ai/src/errors.ts @@ -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]; @@ -41,6 +42,7 @@ export const AI_ERROR_TITLES: Readonly> = { 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 @@ -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 }, + }); + } +} + /** 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 }) { diff --git a/packages/auth/CLAUDE.md b/packages/auth/CLAUDE.md index 93d04df0..9382f1b7 100644 --- a/packages/auth/CLAUDE.md +++ b/packages/auth/CLAUDE.md @@ -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 @@ -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` | diff --git a/packages/auth/README.md b/packages/auth/README.md index 0685b191..59e3ebf7 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -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 { + 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 { + 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` | @@ -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` | diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 0b8ebe00..071b9c51 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -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, @@ -186,6 +198,7 @@ export { DEFAULT_SESSION_POLICY, listDevices, parseSessionToken, + readCookie, readSessionCookie, revokeOtherSessions, revokeSession, diff --git a/packages/auth/src/oauth-cookie.test.ts b/packages/auth/src/oauth-cookie.test.ts new file mode 100644 index 00000000..7330faaf --- /dev/null +++ b/packages/auth/src/oauth-cookie.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, test } from 'bun:test'; +import { frozenClock, isUltimateError } from '@ultimat3/core'; +import { type Auth, defineAuth } from './auth'; +import { MemoryAdapter } from './memory-adapter'; +import { beginOAuth, type OAuthHandshake } from './oauth'; +import { + clearHandshakeCookie, + DEFAULT_HANDSHAKE_TTL_MS, + handshakeCookie, + handshakeCookieName, + handshakeSecret, + openHandshake, + readHandshakeCookie, + sealHandshake, +} from './oauth-cookie'; +import type { OAuthFetch } from './oauth-exchange'; +import { completeOAuthLogin } from './oauth-login'; + +const NOW = new Date('2026-08-09T12:00:00.000Z'); +const SECRET = 'a'.repeat(32); +const clock = frozenClock(NOW); +const options = { secret: SECRET, clock }; + +const start = (provider: 'github' | 'google' = 'github'): OAuthHandshake => + beginOAuth({ provider, clientId: 'client-id', redirectUri: 'https://app.test/auth/callback' }); + +/** What a browser keeps out of a `set-cookie`: the name=value pair, none of the attributes. */ +const kept = (setCookie: string): string => setCookie.slice(0, setCookie.indexOf(';')); + +/** The callback leg as it really arrives: a fresh request carrying only what the browser kept. */ +const callbackRequest = (setCookie: string): Request => + new Request('https://app.test/auth/oauth/github/callback?code=the-code', { + headers: { cookie: kept(setCookie) }, + }); + +const codeOf = (run: () => unknown): string => { + try { + run(); + } catch (error) { + return isUltimateError(error) ? error.code : `not-an-UltimateError: ${String(error)}`; + } + return 'did-not-throw'; +}; + +const json = (body: unknown): Response => + new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }); + +describe('the handshake seal', () => { + test('a sealed handshake opens back into the same handshake', () => { + const handshake = start(); + expect(openHandshake(sealHandshake(handshake, options), 'github', options)).toEqual(handshake); + }); + + test('the PKCE verifier survives the round trip — without it the exchange proves nothing', () => { + const handshake = start(); + const reopened = openHandshake(sealHandshake(handshake, options), 'github', options); + expect(reopened.verifier).toBe(handshake.verifier); + expect(reopened.verifier.length).toBeGreaterThanOrEqual(43); + expect(reopened.nonce).toBe(handshake.nonce); + }); + + test('a handshake invented without the secret is refused', () => { + // Login CSRF: an attacker who can mint a handshake pairs their own code with a victim's + // browser, and the victim's session ends up holding the attacker's provider account. + const forged = `${btoa(JSON.stringify(['github', 's', 'n', 'v', 'https://app.test', 'https://github.test', NOW.getTime()]))}.${'0'.repeat(64)}`; + expect(codeOf(() => openHandshake(forged, 'github', options))).toBe('X_OAUTH_STATE_INVALID'); + }); + + test('an edited payload is refused', () => { + const sealed = sealHandshake(start(), options); + const dot = sealed.lastIndexOf('.'); + const tampered = `${sealed.slice(0, dot - 1)}${sealed[dot - 1] === 'A' ? 'B' : 'A'}${sealed.slice(dot)}`; + expect(codeOf(() => openHandshake(tampered, 'github', options))).toBe('X_OAUTH_STATE_INVALID'); + }); + + test('a handshake signed with another secret is refused', () => { + const sealed = sealHandshake(start(), { ...options, secret: 'b'.repeat(32) }); + expect(codeOf(() => openHandshake(sealed, 'github', options))).toBe('X_OAUTH_STATE_INVALID'); + }); + + test('a github handshake cannot finish a google callback', () => { + const sealed = sealHandshake(start('github'), options); + expect(codeOf(() => openHandshake(sealed, 'google', options))).toBe('X_OAUTH_STATE_INVALID'); + }); + + test('an unsigned or unreadable value is refused rather than parsed', () => { + expect(codeOf(() => openHandshake('not-signed', 'github', options))).toBe( + 'X_OAUTH_STATE_INVALID', + ); + const body = btoa('{"not":"an array"}').replaceAll('=', ''); + const secret = SECRET; + const signature = new Bun.CryptoHasher('sha256', secret).update(body).digest('hex'); + expect(codeOf(() => openHandshake(`${body}.${signature}`, 'github', options))).toBe( + 'X_OAUTH_STATE_INVALID', + ); + }); + + test('the age that decides is the server clock, not the cookie the client kept', () => { + const sealed = sealHandshake(start(), options); + const late = frozenClock(new Date(NOW.getTime() + DEFAULT_HANDSHAKE_TTL_MS + 1)); + expect(codeOf(() => openHandshake(sealed, 'github', { secret: SECRET, clock: late }))).toBe( + 'X_OAUTH_STATE_INVALID', + ); + const inTime = frozenClock(new Date(NOW.getTime() + DEFAULT_HANDSHAKE_TTL_MS - 1)); + expect(openHandshake(sealed, 'github', { secret: SECRET, clock: inTime }).provider).toBe( + 'github', + ); + }); +}); + +describe('the handshake cookie', () => { + test('every attribute the browser is trusted to enforce is set', () => { + const cookie = handshakeCookie(start(), options); + expect(cookie.startsWith(`${handshakeCookieName('github')}=`)).toBe(true); + expect(handshakeCookieName('github').startsWith('__Host-')).toBe(true); + // `__Host-` is only honoured with Path=/ and no Domain; Lax is what a top-level cross-site + // GET back from the provider still carries, and HttpOnly keeps an XSS off the verifier. + for (const attribute of ['Path=/', 'HttpOnly', 'Secure', 'SameSite=Lax', 'Max-Age=600']) { + expect(cookie).toContain(attribute); + } + expect(cookie).not.toContain('Domain='); + }); + + test('Max-Age tracks the ttl the server will enforce', () => { + expect(handshakeCookie(start(), { ...options, ttlMs: 120_000 })).toContain('Max-Age=120'); + }); + + test('clearing it names the provider it is clearing, with the attributes that set it', () => { + const cleared = clearHandshakeCookie('github'); + expect(cleared.startsWith(`${handshakeCookieName('github')}=;`)).toBe(true); + expect(cleared).toContain('Max-Age=0'); + for (const attribute of ['Path=/', 'HttpOnly', 'Secure', 'SameSite=Lax']) { + expect(cleared).toContain(attribute); + } + // A github callback must not cancel a google login the same browser has in another tab. + expect(clearHandshakeCookie('google')).not.toContain(handshakeCookieName('github')); + }); + + test('a callback with no handshake cookie is refused', () => { + const request = new Request('https://app.test/auth/oauth/github/callback'); + expect(codeOf(() => readHandshakeCookie(request, 'github', options))).toBe( + 'X_OAUTH_STATE_INVALID', + ); + }); + + test('the handshake is found among the other cookies on the request', () => { + const handshake = start(); + const sealed = sealHandshake(handshake, options); + const request = new Request('https://app.test/auth/oauth/github/callback', { + headers: { + cookie: `theme=dark; ${handshakeCookieName('github')}=${sealed}; __Host-x_session=abc`, + }, + }); + expect(readHandshakeCookie(request, 'github', options)).toEqual(handshake); + }); + + /** + * Two tabs, two providers, one cookie jar. Under a single shared name the second redirect's + * `set-cookie` replaces the first, and the earlier callback opens the later handshake — a + * login that fails for a reason the user cannot see and cannot avoid by restarting. + */ + test('a second provider handshake does not clobber the first', () => { + const github = start('github'); + const google = start('google'); + const request = new Request('https://app.test/auth/oauth/github/callback', { + headers: { + cookie: [ + kept(handshakeCookie(github, options)), + kept(handshakeCookie(google, options)), + ].join('; '), + }, + }); + expect(readHandshakeCookie(request, 'github', options)).toEqual(github); + expect(readHandshakeCookie(request, 'google', options)).toEqual(google); + }); + + /** + * A `Cookie:` header is whatever the client sent, and `%` alone is not a valid escape. The + * parser used to hand that straight to `decodeURIComponent`, so a bare `URIError` escaped the + * whole coded callback path and the leg answered 500 instead of naming the real refusal. + */ + test('a malformed percent-escape is the coded refusal, never a URIError', () => { + const request = new Request('https://app.test/auth/oauth/github/callback?code=the-code', { + headers: { cookie: `${handshakeCookieName('github')}=%` }, + }); + expect(codeOf(() => readHandshakeCookie(request, 'github', options))).toBe( + 'X_OAUTH_STATE_INVALID', + ); + }); +}); + +describe('handshakeSecret', () => { + test('an unset SESSION_SECRET names the key and a runnable fix', () => { + let thrown: unknown; + try { + handshakeSecret({}); + } catch (error) { + thrown = error; + } + expect(isUltimateError(thrown) ? thrown.code : thrown).toBe('X_ENV_MISSING'); + expect(isUltimateError(thrown) ? thrown.fix : '').toContain('SESSION_SECRET'); + }); + + test('a secret too short to be one is refused, not quietly used', () => { + expect(codeOf(() => handshakeSecret({ SESSION_SECRET: 'short' }))).toBe('X_ENV_MISSING'); + expect(handshakeSecret({ SESSION_SECRET: SECRET })).toBe(SECRET); + }); +}); + +describe('the two legs of a login', () => { + /** + * The point of the whole file: the redirect and the callback are separate requests, and the + * only thing that crosses between them is the cookie. Nothing here hands the handshake over + * in a variable, because a real callback cannot. + */ + test('a github login finishes across two requests carrying only the cookie', async () => { + const auth: Auth = defineAuth({ + adapter: new MemoryAdapter(), + clock, + providers: ['github', 'google'], + }); + + // Leg one: GET /auth/oauth/github — redirect the browser, keep nothing on the server. + const redirect = handshakeCookie(start(), options); + + // Leg two: GET /auth/oauth/github/callback — a new request, a new process for all we know. + const request = callbackRequest(redirect); + const handshake = readHandshakeCookie(request, 'github', options); + const url = new URL(request.url); + + let sentVerifier: string | null = null; + const fetch: OAuthFetch = async (endpoint, init) => { + if (endpoint === 'https://github.com/login/oauth/access_token') { + sentVerifier = new URLSearchParams(String(init.body)).get('code_verifier'); + return json({ access_token: 'gho_token', token_type: 'bearer' }); + } + if (endpoint === 'https://api.github.com/user') return json({ id: 583231, login: 'octocat' }); + if (endpoint === 'https://api.github.com/user/emails') { + return json([{ email: 'ada@example.com', primary: true, verified: true }]); + } + return expect.unreachable(`unexpected endpoint: ${endpoint}`); + }; + + const result = await completeOAuthLogin(auth, { + handshake, + callback: { state: handshake.state, code: url.searchParams.get('code') ?? '' }, + credentials: { clientId: 'client-id', clientSecret: 'client-secret' }, + fetch, + }); + + expect(result.actor.kind).toBe('user'); + expect(result.cookie).toContain('__Host-x_session='); + // The verifier reached the token endpoint from the cookie alone — that is what PKCE is. + expect(sentVerifier).toBe(handshake.verifier); + }); + + test('a callback whose cookie belongs to another browser never reaches the network', async () => { + const auth: Auth = defineAuth({ adapter: new MemoryAdapter(), clock, providers: ['github'] }); + const victim = start(); + const attacker = start(); + const request = callbackRequest(handshakeCookie(victim, options)); + const handshake = readHandshakeCookie(request, 'github', options); + + const fetch: OAuthFetch = async (endpoint) => + expect.unreachable(`the exchange must not run: ${endpoint}`); + + let thrown: unknown; + try { + await completeOAuthLogin(auth, { + handshake, + // The state the attacker put on the redirect URL, against the victim's stored handshake. + callback: { state: attacker.state, code: 'the-attackers-code' }, + credentials: { clientId: 'client-id', clientSecret: 'client-secret' }, + fetch, + }); + } catch (error) { + thrown = error; + } + expect(isUltimateError(thrown) ? thrown.code : thrown).toBe('X_OAUTH_STATE_INVALID'); + }); +}); diff --git a/packages/auth/src/oauth-cookie.ts b/packages/auth/src/oauth-cookie.ts new file mode 100644 index 00000000..bbfd246c --- /dev/null +++ b/packages/auth/src/oauth-cookie.ts @@ -0,0 +1,209 @@ +// Single responsibility: where the handshake lives between the redirect and the callback. The +// two legs of an authorization-code login are separate HTTP requests, so `beginOAuth`'s state, +// nonce and PKCE verifier have to survive the round trip — and a framework that leaves that to +// the app gets one hand-rolled store per app, which is exactly where PKCE quietly stops proving +// anything. Sealed here and opened here, so the cookie below and a server-side store share one +// format at one trust level. + +import type { Clock } from '@ultimat3/core'; +import { EnvMissingError, systemClock } from '@ultimat3/core'; +import { oauthStateInvalid } from './errors'; +import { OAUTH_PROVIDERS, type OAuthHandshake, type OAuthProviderId } from './oauth'; +import { type RequestLike, readCookie } from './session'; +import { base64Url, timingSafeEqual } from './tokens'; + +/** `__Host-` for the same reason the session cookie carries it: no subdomain can plant one. */ +export const OAUTH_HANDSHAKE_COOKIE_PREFIX = '__Host-x_oauth'; + +/** + * One cookie per provider, because a browser is one cookie jar and a user is allowed two tabs. + * Under a single shared name, starting a `google` login while a `github` one is mid-flight + * overwrites the github handshake — and the github callback then opens google's, fails + * `X_OAUTH_STATE_INVALID` and tells the user to restart the flow that just collided again. + * Scoping the name makes the two handshakes independent instead of the last writer's. + */ +export function handshakeCookieName(provider: OAuthProviderId): string { + return `${OAUTH_HANDSHAKE_COOKIE_PREFIX}_${provider}`; +} + +/** Long enough to read a consent screen, short enough that a lifted cookie is already stale. */ +export const DEFAULT_HANDSHAKE_TTL_MS = 10 * 60 * 1000; + +/** `wiki/Configuration.md` requires it at >=32 chars for the `web` role; this is that gate. */ +const MIN_SECRET_LENGTH = 32; + +/** + * The app secret, read at call time rather than module scope so importing this file still reads + * no env — the same rule `oauthCredentials()` follows for the client id and secret. + */ +export function handshakeSecret( + env: Readonly> = Bun.env, +): string { + const secret = env['SESSION_SECRET']?.trim() ?? ''; + if (secret.length < MIN_SECRET_LENGTH) { + throw new EnvMissingError({ + cause: + secret === '' + ? 'SESSION_SECRET is not set, so an oauth handshake cannot be signed' + : `SESSION_SECRET is ${secret.length} characters and at least ${MIN_SECRET_LENGTH} are required`, + fix: 'export SESSION_SECRET="$(openssl rand -hex 32)"', + meta: { key: 'SESSION_SECRET', minLength: MIN_SECRET_LENGTH }, + }); + } + return secret; +} + +export interface HandshakeSealOptions { + /** Defaults to `SESSION_SECRET`. */ + readonly secret?: string | undefined; + /** No `Date.now()` in this package: the handshake's age is measured against this. */ + readonly clock?: Clock | undefined; + readonly ttlMs?: number | undefined; +} + +export interface HandshakeCookieOptions extends HandshakeSealOptions { + /** + * Defaults to `handshakeCookieName(provider)`. Overriding it opts out of the per-provider + * scoping, so both legs have to pass the same one — a name set on the redirect and defaulted on + * the callback reads a cookie that is not there. + */ + readonly name?: string | undefined; +} + +/** + * Signed, not encrypted, for the reason the cursor codec is: every field here is already the + * browser's own — `state` travelled in the URL it was just sent to, and the verifier and nonce + * are that browser's halves of this handshake. What the signature buys is that the browser + * cannot *invent* a handshake, which is what would let an attacker's code land in a victim's + * session. What secrecy is needed against everyone else is the cookie's `HttpOnly; Secure`. + */ +export function sealHandshake(handshake: OAuthHandshake, options?: HandshakeSealOptions): string { + const clock = options?.clock ?? systemClock; + const body = base64Url( + new TextEncoder().encode( + JSON.stringify([ + handshake.provider, + handshake.state, + handshake.nonce, + handshake.verifier, + handshake.redirectUri, + handshake.authorizeUrl, + clock.now().getTime(), + ]), + ), + ); + return `${body}.${sign(body, options?.secret ?? handshakeSecret())}`; +} + +/** + * The only way back. `provider` is required rather than read from the payload because a + * handshake opened without one is a github handshake finishing a google callback — and an + * optional check is one a call site can forget. + * + * Every rejection is `X_OAUTH_STATE_INVALID`, like `assertOAuthCallback`'s: the handshake is one + * object, and naming which field failed tells an attacker which field to keep guessing at. + */ +export function openHandshake( + sealed: string, + provider: OAuthProviderId, + options?: HandshakeSealOptions, +): OAuthHandshake { + const dot = sealed.lastIndexOf('.'); + if (dot <= 0) throw oauthStateInvalid(provider, 'the stored handshake is not signed'); + + const body = sealed.slice(0, dot); + const expected = sign(body, options?.secret ?? handshakeSecret()); + if (!timingSafeEqual(expected, sealed.slice(dot + 1))) { + throw oauthStateInvalid( + provider, + 'the stored handshake was tampered with, or the secret rotated', + ); + } + + const parsed = parseBody(body, provider); + if (!Array.isArray(parsed) || parsed.length !== 7) { + throw oauthStateInvalid(provider, 'the stored handshake is not a handshake'); + } + const [sealedProvider, state, nonce, verifier, redirectUri, authorizeUrl, issuedAt] = + parsed as readonly unknown[]; + if ( + typeof sealedProvider !== 'string' || + typeof state !== 'string' || + typeof nonce !== 'string' || + typeof verifier !== 'string' || + typeof redirectUri !== 'string' || + typeof authorizeUrl !== 'string' || + typeof issuedAt !== 'number' || + !Number.isFinite(issuedAt) + ) { + throw oauthStateInvalid(provider, 'the stored handshake is not a handshake'); + } + if (!Object.hasOwn(OAUTH_PROVIDERS, sealedProvider) || sealedProvider !== provider) { + throw oauthStateInvalid(provider, 'the stored handshake belongs to a different provider'); + } + + // The cookie's own `Max-Age` is the client's copy of this deadline, and a client is free to + // ignore it — so the age that decides is measured here, against the server's clock. + const clock = options?.clock ?? systemClock; + if (clock.now().getTime() - issuedAt > (options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS)) { + throw oauthStateInvalid(provider, 'the stored handshake expired before the callback arrived'); + } + + return { provider, state, nonce, verifier, redirectUri, authorizeUrl }; +} + +/** + * Set on the redirect. `SameSite=Lax` is the one attribute that differs in reasoning from the + * session cookie's: the callback is a top-level cross-site GET from the provider, which `Lax` + * still attaches the cookie to and `Strict` would strip — leaving every login to fail its state + * check. A provider answering with `response_mode=form_post` POSTs instead, and this cookie + * would not reach it; no provider in `OAUTH_PROVIDERS` is configured that way. + */ +export function handshakeCookie( + handshake: OAuthHandshake, + options?: HandshakeCookieOptions, +): string { + const maxAge = Math.floor((options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS) / 1000); + // The handshake already names its provider, so the two legs cannot disagree about the name. + const name = options?.name ?? handshakeCookieName(handshake.provider); + return `${name}=${sealHandshake(handshake, options)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`; +} + +/** + * Send this with the callback's response, always — an authorization code is single-use, so the + * handshake that authorised it must not outlive it. A mismatched attribute set leaves a live twin. + * + * `provider` is required for the reason `openHandshake`'s is: clearing the unscoped name would + * clear nothing, and clearing every provider's would cancel a login running in another tab. + */ +export function clearHandshakeCookie(provider: OAuthProviderId, name?: string): string { + return `${name ?? handshakeCookieName(provider)}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`; +} + +/** Reads the cookie set on the redirect and returns the handshake `completeOAuthLogin` takes. */ +export function readHandshakeCookie( + request: RequestLike, + provider: OAuthProviderId, + options?: HandshakeCookieOptions, +): OAuthHandshake { + const sealed = readCookie(request, options?.name ?? handshakeCookieName(provider)); + if (sealed === null) { + throw oauthStateInvalid(provider, 'no handshake cookie arrived with the callback'); + } + return openHandshake(sealed, provider, options); +} + +/** Keyed SHA-256. Untruncated, unlike a cursor's: a cookie has room and this authorises a login. */ +function sign(body: string, secret: string): string { + return new Bun.CryptoHasher('sha256', secret).update(body).digest('hex'); +} + +function parseBody(body: string, provider: OAuthProviderId): unknown { + const padded = body.replaceAll('-', '+').replaceAll('_', '/'); + try { + const binary = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), '=')); + return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)))); + } catch { + throw oauthStateInvalid(provider, 'the stored handshake is not readable'); + } +} diff --git a/packages/auth/src/session.test.ts b/packages/auth/src/session.test.ts index 56a71379..fdc6e471 100644 --- a/packages/auth/src/session.test.ts +++ b/packages/auth/src/session.test.ts @@ -6,6 +6,7 @@ import { createSession, DEFAULT_SESSION_POLICY, listDevices, + readSessionCookie, revokeSession, type SessionPolicy, type SessionRuntime, @@ -109,4 +110,33 @@ describe('session', () => { } expect(cookie).not.toContain('Domain='); }); + + test('a percent-escaped cookie value is decoded on the way back in', () => { + const request = new Request('https://app.test/', { + headers: { cookie: `${POLICY.cookieName}=a%20b` }, + }); + expect(readSessionCookie(request, POLICY)).toBe('a b'); + }); + + /** + * The header is whatever the client sent. `decodeURIComponent('%')` throws a bare `URIError`, + * which every caller of this parser would have propagated as a 500 — a request carrying a + * malformed cookie must fail as "no valid session", the same as one carrying junk. + */ + test('a malformed percent-escape reads back raw instead of throwing', () => { + const request = new Request('https://app.test/', { + headers: { cookie: `${POLICY.cookieName}=%` }, + }); + expect(readSessionCookie(request, POLICY)).toBe('%'); + }); + + test('a malformed cookie value never verifies as a session', async () => { + const rt = runtime(); + await createSession(rt, { userId: 'user-1' }); + const request = new Request('https://app.test/', { + headers: { cookie: `${POLICY.cookieName}=%` }, + }); + const raw = readSessionCookie(request, POLICY) ?? ''; + expect((await caught(() => verifySession(rt, raw))).code).toBe('X_UNAUTHENTICATED'); + }); }); diff --git a/packages/auth/src/session.ts b/packages/auth/src/session.ts index 0e632406..f2b2112b 100644 --- a/packages/auth/src/session.ts +++ b/packages/auth/src/session.ts @@ -217,14 +217,37 @@ export function clearSessionCookie(policy: SessionPolicy, name?: string): string return `${name ?? policy.cookieName}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`; } -export function readSessionCookie(request: RequestLike, policy: SessionPolicy): string | null { +/** + * The one `Cookie:` parser in this package — the oauth handshake reads through it too, so it never + * throws: a missing or unreadable cookie is `null` or the raw value, never an exception. + */ +export function readCookie(request: RequestLike, name: string): string | null { const header = request.headers.get('cookie'); if (header === null) return null; for (const part of header.split(';')) { const equals = part.indexOf('='); if (equals < 0) continue; - if (part.slice(0, equals).trim() !== policy.cookieName) continue; - return decodeURIComponent(part.slice(equals + 1).trim()); + if (part.slice(0, equals).trim() !== name) continue; + return decodeCookieValue(part.slice(equals + 1).trim()); } return null; } + +/** + * A `Cookie:` header is attacker-controlled, and `decodeURIComponent('%')` throws a bare + * `URIError` — which escapes every coded path that reads through here: an OAuth callback would + * answer 500 instead of `X_OAUTH_STATE_INVALID`. The raw value is returned instead, so the + * caller's own rejection stays the readable failure. Nothing is loosened by it: a raw value is + * still checked against a signature or a stored hash, and neither matches a mangled one. + */ +function decodeCookieValue(raw: string): string { + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + +export function readSessionCookie(request: RequestLike, policy: SessionPolicy): string | null { + return readCookie(request, policy.cookieName); +} diff --git a/packages/cache/CLAUDE.md b/packages/cache/CLAUDE.md index b5411d59..f65728aa 100644 --- a/packages/cache/CLAUDE.md +++ b/packages/cache/CLAUDE.md @@ -20,6 +20,26 @@ Tier 1. Tagged caching + THE invalidation graph. - Tier failures go into `report.errors`. A cache tier may never fail a business write. - `tag.x` typing comes from the `CacheTagRegistry` augmentation, generated by `x manifest`. - Clocks are injected (`LruOptions.clock`); read them through `nowMs()`. +- A purge driver is selected by `selectPurgeDriver` from the environment, never from an + `app.config.ts` field — nothing loads that file's contents at runtime. Two CDN credentials at + once is refused, not resolved, and half a pair is refused too: "no CDN" is the one wrong answer, + because a deployment then ships believing it purges. The token never reaches a printed string. +- A purge key is a wire tag unchanged. Every CDN splits a key list on whitespace and a comma, so + `assertPurgeableKeys` refuses either **before** the request — a split key is purged successfully + and clears nothing, which is the one CDN failure no later read can catch. +- `retryable` on `X_CACHE_PURGE_FAILED` is derived, never guessed: 408/409/425/429 and 5xx, plus + any request that never got a status. That table lives in `purge-http.ts` and is edited there. +- `X_CACHE_PURGE_FAILED` means a provider refused. A batch size that is not a positive integer is + this package miswired, so `chunked()` raises `X_CACHE_DRIVER_UNAVAILABLE` instead — and it raises + it *before* the loop, because a `0` spins forever and a `NaN` yields one empty batch, a purge that + reports success having cleared nothing. +- Every `fixFor()` branch names a command, an env key or a call. The gate's `fix:` scanner reads + `fix:` properties, not the `return` literals in those functions, so the colocated + "every failure fix names a command" tests are the only thing enforcing it. +- A refusal's diagnostic reads the environment, never a hardcoded pair: `meta.configured` and the + cause name the keys that are actually set. Names only — all four keys can hold a credential. +- A remote driver takes an injected `fetch` so a test never unseals the network; the loopback + proof in `purge-fastly.test.ts` is the only place the default one runs. ## Files @@ -31,7 +51,11 @@ Tier 1. Tagged caching + THE invalidation graph. | `memo.ts` | request memo over the ALS ctx (WeakMap, no lifecycle) | | `lru.ts` | byte-budgeted LRU (linked list + map + tag index) | | `redis.ts` | `Bun.redis` tier, one-`EVAL` tag invalidation | -| `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission + purge drivers | +| `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission, the `PurgeDriver` seam, `noopPurgeDriver` | +| `purge-http.ts` | the HTTP half both remote drivers share: one POST, retryable table, batching, key guard | +| `purge-fastly.ts` | `fastlyPurgeDriver`: surrogate-key batch purge, `purge_all` | +| `purge-cloudflare.ts` | `cloudflarePurgeDriver`: cache-tag purge, `purge_everything` | +| `purge-env.ts` | `selectPurgeDriver`: which edge an environment purges, and nothing else | | `invalidate.ts` | the single entry point, `InvalidationReport`, and the bounded log `/_x` renders | | `semantic.ts` | embedding cache for LLM calls | diff --git a/packages/cache/README.md b/packages/cache/README.md index 48a10d37..6db9ba91 100644 --- a/packages/cache/README.md +++ b/packages/cache/README.md @@ -102,8 +102,30 @@ cacheHeaders({ sMaxAge: 300, staleWhileRevalidate: 86_400, tags: [tag('post', id // 'Surrogate-Key': 'post:1' } ``` -`purge()` drivers: `noop` (default), `fastly` and `cloudflare` are interface-complete and -throw `X_NOT_IMPLEMENTED` with the fix line. +The surrogate keys **are** the tags, byte for byte, so an edge purge and an app-level +invalidation can never mean different things. Three `PurgeDriver`s ship: + +| Driver | Purge | Purge all | Batch | +|---|---|---|---| +| `noopPurgeDriver()` | echoes the keys back | resolves | — | +| `fastlyPurgeDriver({ apiToken, serviceId })` | `POST /service//purge` with `surrogate_keys` | `POST /service//purge_all` | 256 keys | +| `cloudflarePurgeDriver({ apiToken, zoneId })` | `POST /zones//purge_cache` with `tags` | same call, `purge_everything` | 30 tags | + +Which one a process installs comes from the environment, never from `app.config.ts` — +nothing loads that file's contents at runtime: + +| Set | Selects | +|---|---| +| `FASTLY_API_TOKEN` + `FASTLY_SERVICE_ID` | Fastly | +| `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ZONE_ID` | Cloudflare | +| neither | nothing is purged, and `x dev` prints `cdn=none` | + +Both pairs at once is `X_CONFIG_INVALID`: one process purges exactly one edge. Half a pair +is refused the same way — treating it as "no CDN" is how a deployment ships believing it +purges. Either refusal names the keys that are actually set, in `cause` and in +`meta.configured`, so the diagnostic can never point at a variable nobody set. A refused +purge is `X_CACHE_PURGE_FAILED` carrying `meta.retryable`, and it lands in `report.errors` +rather than failing the write that triggered it. ## Semantic cache @@ -119,10 +141,10 @@ cache). | Code | Cause | |---|---| -| `X_CACHE_DRIVER_UNAVAILABLE` | `Bun.redis` missing, or no CDN token | +| `X_CACHE_DRIVER_UNAVAILABLE` | `Bun.redis` missing, a purge driver built without its token, or a batch size that is not a positive integer | +| `X_CACHE_PURGE_FAILED` | the CDN refused a purge, or a key it would split on whitespace | | `X_CACHE_TAG_UNKNOWN` | a tag no entity declared — usually a typo | | `X_CACHE_TOO_LARGE` | one entry exceeds a tier's whole byte budget | -| `X_NOT_IMPLEMENTED` | remote CDN purge driver | ## Boundary diff --git a/packages/cache/src/cdn.test.ts b/packages/cache/src/cdn.test.ts index 8913eeb6..726b981a 100644 --- a/packages/cache/src/cdn.test.ts +++ b/packages/cache/src/cdn.test.ts @@ -4,14 +4,7 @@ import { describe, expect, test } from 'bun:test'; import type { PurgeDriver } from './cdn'; -import { - cacheHeaders, - cloudflarePurgeDriver, - createCdnTier, - fastlyPurgeDriver, - noopPurgeDriver, -} from './cdn'; -import { CacheNotImplementedError } from './errors'; +import { cacheHeaders, createCdnTier, noopPurgeDriver } from './cdn'; import { tag } from './tags'; describe('cacheHeaders', () => { @@ -75,22 +68,6 @@ describe('noopPurgeDriver', () => { }); }); -describe('remote purge drivers', () => { - test('fastlyPurgeDriver is named "fastly" and throws synchronously, unimplemented', () => { - const driver = fastlyPurgeDriver(); - expect(driver.name).toBe('fastly'); - expect(() => driver.purge(['post'])).toThrow(CacheNotImplementedError); - expect(() => driver.purgeAll()).toThrow(CacheNotImplementedError); - }); - - test('cloudflarePurgeDriver is named "cloudflare" and throws synchronously, unimplemented', () => { - const driver = cloudflarePurgeDriver(); - expect(driver.name).toBe('cloudflare'); - expect(() => driver.purge(['post'])).toThrow(CacheNotImplementedError); - expect(() => driver.purgeAll()).toThrow(CacheNotImplementedError); - }); -}); - const purgeSpy = (accept?: (keys: readonly string[]) => readonly string[]) => { const calls: (readonly string[])[] = []; const driver: PurgeDriver = { diff --git a/packages/cache/src/cdn.ts b/packages/cache/src/cdn.ts index 9491f4ad..1ff488f0 100644 --- a/packages/cache/src/cdn.ts +++ b/packages/cache/src/cdn.ts @@ -3,7 +3,6 @@ // the response, and purging by surrogate key when a tag changes. Surrogate keys ARE the // tags — same strings, so a CDN purge cannot drift from an app-level invalidation. -import { CacheNotImplementedError } from './errors'; import type { CacheTag } from './tags'; import { serializeTags } from './tags'; import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers'; @@ -66,26 +65,13 @@ export function noopPurgeDriver(): PurgeDriver { }; } -const remoteDriver = (name: string): PurgeDriver => { - const unimplemented = (): never => { - throw new CacheNotImplementedError({ - feature: `CDN purge driver "${name}"`, - fix: `use cdn: { purge: 'noop' } in app.config.ts, or implement PurgeDriver — see docs/caching/cdn.md#${name}`, - }); - }; - return { - name, - purge: unimplemented, - purgeAll: unimplemented, - }; -}; - -export const fastlyPurgeDriver = (): PurgeDriver => remoteDriver('fastly'); -export const cloudflarePurgeDriver = (): PurgeDriver => remoteDriver('cloudflare'); - export interface CdnTierOptions { readonly purge?: PurgeDriver; - /** Maps a cache key to the CDN path(s) it renders, for key-level purges. */ + /** + * Maps a cache key to the CDN path(s) it renders, for key-level purges. Those paths are + * purged **as surrogate keys** — `PurgeDriver.purge` has one currency and this is it — so a + * host using this must tag those responses with their own path. + */ readonly pathsForKey?: (key: string) => readonly string[]; } diff --git a/packages/cache/src/errors.test.ts b/packages/cache/src/errors.test.ts index d948a054..850b93c3 100644 --- a/packages/cache/src/errors.test.ts +++ b/packages/cache/src/errors.test.ts @@ -11,12 +11,11 @@ import { resetErrorCodes, } from '@ultimat3/core'; import { - CACHE_BORROWED_ERROR_CODES, CACHE_ERROR_CODES, CACHE_ERROR_TITLES, CACHE_OWNED_ERROR_CODES, CacheDriverUnavailableError, - CacheNotImplementedError, + CachePurgeFailedError, CacheTagUnknownError, CacheTooLargeError, } from './errors'; @@ -40,12 +39,10 @@ describe('CACHE_ERROR_CODES', () => { expect(new Set(CACHE_ERROR_CODES).size).toBe(CACHE_ERROR_CODES.length); }); - test('owned and borrowed are disjoint and together are every code cache throws', () => { - const owned = new Set(CACHE_OWNED_ERROR_CODES); - for (const code of CACHE_BORROWED_ERROR_CODES) expect(owned.has(code)).toBe(false); - expect([...CACHE_ERROR_CODES].sort()).toEqual( - [...CACHE_OWNED_ERROR_CODES, ...CACHE_BORROWED_ERROR_CODES].sort(), - ); + // Cache borrows nothing: it owns every code it throws, because both remote purge drivers are + // implemented. A `CACHE_BORROWED_ERROR_CODES` reappearing here means a stub came back with it. + test('every code cache throws is a code cache owns', () => { + expect([...CACHE_ERROR_CODES].sort()).toEqual([...CACHE_OWNED_ERROR_CODES].sort()); }); }); @@ -101,18 +98,50 @@ describe('CacheTooLargeError', () => { }); }); -describe('CacheNotImplementedError', () => { - test('embeds the missing feature', () => { - const err = new CacheNotImplementedError({ - feature: 'redis cluster mode', - fix: 'use a single-node redis client until clustering lands', +describe('CachePurgeFailedError', () => { + test('names the driver, the status and the detail, and carries the given fix', () => { + const err = new CachePurgeFailedError({ + driver: 'fastly', + detail: 'Provided credentials are missing or invalid', + status: 401, + retryable: false, + fix: 'set FASTLY_API_TOKEN in .env.production', }); - expect(err.code).toBe('X_NOT_IMPLEMENTED'); - expect(err.cause).toContain('redis cluster mode'); - expect(err.fix).toBe('use a single-node redis client until clustering lands'); + expect(err.code).toBe('X_CACHE_PURGE_FAILED'); + expect(err.cause).toContain('fastly'); + expect(err.cause).toContain('HTTP 401'); + expect(err.cause).toContain('Provided credentials are missing or invalid'); + expect(err.fix).toBe('set FASTLY_API_TOKEN in .env.production'); + expect(err.docs).toBe('https://ultimate.dev/errors/X_CACHE_PURGE_FAILED'); expect(EVERY_CODE).toContain(err.code); }); + + // The fan-out catches this error and reports it; `retryable` is the only thing that tells a + // caller whether the identical purge is worth sending again, so it must survive on `meta`. + test('meta carries the retry verdict a caller branches on', () => { + const err = new CachePurgeFailedError({ + driver: 'cloudflare', + detail: 'rate limited', + status: 429, + retryable: true, + fix: 'bust fewer tags per write', + }); + + expect(err.meta).toEqual({ driver: 'cloudflare', retryable: true, status: 429 }); + }); + + test('a failure with no status omits it rather than inventing one', () => { + const err = new CachePurgeFailedError({ + driver: 'fastly', + detail: 'connection refused', + retryable: true, + fix: 'curl -sS -m 5 -o /dev/null https://api.fastly.com', + }); + + expect(err.cause).not.toContain('HTTP'); + expect(err.meta).toEqual({ driver: 'fastly', retryable: true }); + }); }); describe('CACHE_ERROR_TITLES', () => { @@ -134,11 +163,12 @@ describe('registration', () => { } }); - test('X_NOT_IMPLEMENTED is borrowed from core, not re-registered by cache', () => { - // cache declares no title for it, so the string below can only have come from core. - expect(describeErrorCode('X_NOT_IMPLEMENTED').title).toBe( - 'this driver does not implement the requested feature', - ); + // The stub this package used to ship: `X_NOT_IMPLEMENTED` was cache's only borrowed code, and + // the CDN purge drivers were its only throw site. Both remote drivers are real now, so cache + // must not be reachable through that code at all — a title here again means a stub came back. + test('cache no longer throws X_NOT_IMPLEMENTED for anything', () => { + expect(EVERY_CODE).not.toContain('X_NOT_IMPLEMENTED'); + for (const code of CACHE_ERROR_CODES) expect(code.startsWith('X_CACHE_')).toBe(true); }); // The regression this file exists for. Behind a `hasErrorCode()` guard, a foreign package that diff --git a/packages/cache/src/errors.ts b/packages/cache/src/errors.ts index d050b1e6..3260930e 100644 --- a/packages/cache/src/errors.ts +++ b/packages/cache/src/errors.ts @@ -5,27 +5,20 @@ import { registerErrorCodes, UltimateError } from '@ultimat3/core'; /** Codes this package declares and owns. */ export const CACHE_OWNED_ERROR_CODES = [ 'X_CACHE_DRIVER_UNAVAILABLE', + 'X_CACHE_PURGE_FAILED', 'X_CACHE_TAG_UNKNOWN', 'X_CACHE_TOO_LARGE', ] as const; -/** - * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `CacheNotImplementedError` throws it; this package - * neither titles nor registers it, because the owner's title is the only one that may exist. - */ -export const CACHE_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const; - -/** Every code cache can throw: the ones it owns plus the one it borrows. */ -export const CACHE_ERROR_CODES = [ - ...CACHE_OWNED_ERROR_CODES, - ...CACHE_BORROWED_ERROR_CODES, -] as const; +/** Every code cache can throw. It borrows none: every remote driver here is implemented. */ +export const CACHE_ERROR_CODES = [...CACHE_OWNED_ERROR_CODES] as const; export type CacheOwnedErrorCode = (typeof CACHE_OWNED_ERROR_CODES)[number]; export type CacheErrorCode = (typeof CACHE_ERROR_CODES)[number]; export const CACHE_ERROR_TITLES: Readonly> = { X_CACHE_DRIVER_UNAVAILABLE: "a tier's backing store is missing", + X_CACHE_PURGE_FAILED: 'the CDN refused a purge', X_CACHE_TAG_UNKNOWN: 'a tag no entity declared', X_CACHE_TOO_LARGE: "one entry exceeds the tier's byte budget", }; @@ -79,14 +72,31 @@ export class CacheTooLargeError extends UltimateError { } } -/** An interface-complete driver whose remote half is not written yet. */ -export class CacheNotImplementedError extends UltimateError { - constructor(input: { feature: string; fix: string }) { +/** + * A remote purge did not happen. Never fatal on its own — `invalidateTags` collects it into + * `report.errors` so a dead CDN cannot fail the write that triggered the bust — which is exactly + * why `retryable` is carried rather than guessed: the caller decides whether the same purge, + * unchanged, is worth sending again, and a stale edge until TTL is the cost of getting it wrong. + */ +export class CachePurgeFailedError extends UltimateError { + constructor(input: { + driver: string; + detail: string; + status?: number | undefined; + retryable: boolean; + fix: string; + }) { + const status = input.status === undefined ? '' : ` (HTTP ${input.status})`; super({ - code: 'X_NOT_IMPLEMENTED', - cause: `${input.feature} is declared but not implemented in @ultimat3/cache`, + code: 'X_CACHE_PURGE_FAILED', + cause: `${input.driver} refused the purge${status}: ${input.detail}`, fix: input.fix, - docs: docsFor('X_NOT_IMPLEMENTED'), + docs: docsFor('X_CACHE_PURGE_FAILED'), + meta: { + driver: input.driver, + retryable: input.retryable, + ...(input.status === undefined ? {} : { status: input.status }), + }, }); } } diff --git a/packages/cache/src/index.ts b/packages/cache/src/index.ts index 7805506e..6932d461 100644 --- a/packages/cache/src/index.ts +++ b/packages/cache/src/index.ts @@ -1,19 +1,13 @@ // Public API of @ultimat3/cache. Explicit, no `export *`. export type { CacheHeaderOptions, CdnTierOptions, PurgeDriver } from './cdn'; -export { - cacheHeaders, - cloudflarePurgeDriver, - createCdnTier, - fastlyPurgeDriver, - noopPurgeDriver, -} from './cdn'; +export { cacheHeaders, createCdnTier, noopPurgeDriver } from './cdn'; export type { CacheErrorCode } from './errors'; export { CACHE_ERROR_CODES, CACHE_ERROR_TITLES, CacheDriverUnavailableError, - CacheNotImplementedError, + CachePurgeFailedError, CacheTagUnknownError, CacheTooLargeError, } from './errors'; @@ -41,6 +35,18 @@ export type { LruOptions, LruStats } from './lru'; export { createLruTier, estimateBytes, LruCache } from './lru'; export { clearMemo, createMemoTier, memoSize } from './memo'; +export type { CloudflarePurgeOptions } from './purge-cloudflare'; +export { + CLOUDFLARE_API_URL, + CLOUDFLARE_MAX_TAGS_PER_REQUEST, + cloudflarePurgeDriver, +} from './purge-cloudflare'; +export type { PurgeEnvironment, PurgeSelection } from './purge-env'; +export { CDN_PURGE_ENV_KEYS, isNoopPurgeDriver, selectPurgeDriver } from './purge-env'; +export type { FastlyPurgeOptions } from './purge-fastly'; +export { FASTLY_API_URL, FASTLY_MAX_KEYS_PER_REQUEST, fastlyPurgeDriver } from './purge-fastly'; +export type { PurgeFetch } from './purge-http'; +export { DEFAULT_PURGE_TIMEOUT_MS } from './purge-http'; export type { RedisLike, RedisTierOptions } from './redis'; export { createRedisTier, REDIS_INVALIDATE_SCRIPT } from './redis'; export type { diff --git a/packages/cache/src/invalidate.test.ts b/packages/cache/src/invalidate.test.ts index 351f0553..e05f99e2 100644 --- a/packages/cache/src/invalidate.test.ts +++ b/packages/cache/src/invalidate.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from 'bun:test'; import { systemClock, withSpan } from '@ultimat3/core'; -import { CacheTagUnknownError } from './errors'; +import { CacheDriverUnavailableError, CacheTagUnknownError } from './errors'; import { registerDependent, resetGraph } from './graph'; import type { InvalidationEvent } from './invalidate'; import { @@ -90,6 +90,33 @@ const cdnSpy = (): CacheTier & { readonly purged: string[] } => { }; }; +/** + * A tier whose store is gone. It rejects with the coded error a real dead Redis raises rather than + * a bare `Error`, which is what pins the useful half of `report.errors`: the code survives the hop + * into the report, so "which tier, and why" is answerable from the `/_x` panel alone. + */ +const brokenRedisTier = (): CacheTier => ({ + name: 'redis', + get() { + return Promise.resolve(undefined); + }, + set() { + return Promise.resolve(); + }, + del() { + return Promise.resolve(); + }, + invalidateTags() { + return Promise.reject( + new CacheDriverUnavailableError({ + driver: 'redis', + cause: 'ECONNREFUSED', + fix: 'x doctor --json', + }), + ); + }, +}); + beforeEach(() => { resetTiers(); resetGraph(); @@ -128,29 +155,17 @@ describe('invalidateTags fan-out', () => { }); test('a failing tier is reported, never thrown — the write that triggered it must not fail', async () => { - const broken: CacheTier = { - name: 'redis', - get() { - return Promise.resolve(undefined); - }, - set() { - return Promise.resolve(); - }, - del() { - return Promise.resolve(); - }, - invalidateTags() { - return Promise.reject(new Error('ECONNREFUSED')); - }, - }; const lru = createLruTier({ maxBytes: 1_000, defaultTtlMs: 0 }); registerTier(lru); - registerTier(broken); + registerTier(brokenRedisTier()); await lru.set('k', 1, { tags: [tag('post')] }); const report = await invalidateTags([tag('post')]); - expect(report.errors).toEqual([{ tier: 'redis', message: 'ECONNREFUSED' }]); + expect(report.errors).toHaveLength(1); + expect(report.errors[0]?.tier).toBe('redis'); + expect(report.errors[0]?.message).toContain('X_CACHE_DRIVER_UNAVAILABLE'); + expect(report.errors[0]?.message).toContain('ECONNREFUSED'); expect(report.tiers.map((entry) => entry.tier)).toEqual(['lru']); expect(await lru.get('k')).toBeUndefined(); }); @@ -245,27 +260,14 @@ describe('recentInvalidations log', () => { }); test('a tier that throws still records an event, and its errors names the tier', async () => { - const broken: CacheTier = { - name: 'redis', - get() { - return Promise.resolve(undefined); - }, - set() { - return Promise.resolve(); - }, - del() { - return Promise.resolve(); - }, - invalidateTags() { - return Promise.reject(new Error('ECONNREFUSED')); - }, - }; - registerTier(broken); + registerTier(brokenRedisTier()); await invalidateTags([tag('post')]); const [event] = recentInvalidations(); - expect(event?.errors).toEqual([{ tier: 'redis', message: 'ECONNREFUSED' }]); + expect(event?.errors).toHaveLength(1); + expect(event?.errors[0]?.tier).toBe('redis'); + expect(event?.errors[0]?.message).toContain('ECONNREFUSED'); }); test('recentInvalidations hands back a copy: mutating it does not change the next answer', async () => { diff --git a/packages/cache/src/purge-cloudflare.test.ts b/packages/cache/src/purge-cloudflare.test.ts new file mode 100644 index 00000000..f2c8f1b0 --- /dev/null +++ b/packages/cache/src/purge-cloudflare.test.ts @@ -0,0 +1,197 @@ +// The Cloudflare transport, asserted over an injected `fetch`. The load-bearing case is the one +// `response.ok` cannot see: Cloudflare refuses a purge with HTTP 200 and `"success": false`, and +// reading that as a completed purge leaves the zone stale with no failure recorded anywhere. + +import { describe, expect, test } from 'bun:test'; +import { CLOUDFLARE_MAX_TAGS_PER_REQUEST, cloudflarePurgeDriver } from './purge-cloudflare'; +import type { PurgeFetch } from './purge-http'; + +interface Call { + readonly url: string; + readonly init: RequestInit; +} + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status }); + +const ok = (): Response => json({ success: true, errors: [], result: { id: 'zone_1' } }); + +function recorder(reply: (call: Call) => Response = ok): { calls: Call[]; fetch: PurgeFetch } { + const calls: Call[] = []; + const fetch: PurgeFetch = (url, init) => { + const call = { url, init }; + calls.push(call); + return Promise.resolve(reply(call)); + }; + return { calls, fetch }; +} + +const bodyOf = (call: Call | undefined): unknown => JSON.parse(String(call?.init.body)); + +const driverWith = (fetch: PurgeFetch) => + cloudflarePurgeDriver({ + apiToken: 'cf-token', + zoneId: 'zone_1', + baseUrl: 'https://api.cloudflare.test/client/v4', + fetch, + }); + +const failureOf = async ( + response: Response, + keys: readonly string[] = ['post'], +): Promise<{ code?: string; cause?: string; fix?: string; meta?: Record }> => { + const { fetch } = recorder(() => response); + const failure = await driverWith(fetch) + .purge(keys) + .then( + () => undefined, + (error: unknown) => + error as { code?: string; cause?: string; meta?: Record }, + ); + // `expect.unreachable`, never a bare `Error`: a purge that was accepted when the test expected a + // refusal is reported as the assertion failure it is, with no code-less throw in the way. + if (failure === undefined) return expect.unreachable('expected the purge to be refused'); + return failure; +}; + +describe('cloudflarePurgeDriver construction', () => { + test('is named "cloudflare"', () => { + expect(driverWith(recorder().fetch).name).toBe('cloudflare'); + }); + + test('an unset token or zone refuses at construction, naming the env key', () => { + expect(() => cloudflarePurgeDriver({ apiToken: '', zoneId: 'zone_1' })).toThrow( + /CLOUDFLARE_API_TOKEN/, + ); + expect(() => cloudflarePurgeDriver({ apiToken: 'token', zoneId: ' ' })).toThrow( + /CLOUDFLARE_ZONE_ID/, + ); + }); +}); + +describe('cloudflarePurgeDriver.purge', () => { + test('posts the tags to the zone purge endpoint as a bearer token', async () => { + const { calls, fetch } = recorder(); + + const accepted = await driverWith(fetch).purge(['post', 'post:1']); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe('https://api.cloudflare.test/client/v4/zones/zone_1/purge_cache'); + expect(new Headers(calls[0]?.init.headers).get('Authorization')).toBe('Bearer cf-token'); + expect(bodyOf(calls[0])).toEqual({ tags: ['post', 'post:1'] }); + expect(accepted).toEqual(['post', 'post:1']); + }); + + test('an empty tag list sends no request at all', async () => { + const { calls, fetch } = recorder(); + expect(await driverWith(fetch).purge([])).toEqual([]); + expect(calls).toHaveLength(0); + }); + + test('batches at the 30-tag API cap', async () => { + const keys = Array.from({ length: 65 }, (_, index) => `post:${index}`); + const { calls, fetch } = recorder(); + + const accepted = await driverWith(fetch).purge(keys); + + expect(CLOUDFLARE_MAX_TAGS_PER_REQUEST).toBe(30); + expect(calls.map((call) => (bodyOf(call) as { tags: string[] }).tags.length)).toEqual([ + 30, 30, 5, + ]); + expect(accepted).toEqual(keys); + }); + + test('a key a CDN would split is refused before any request leaves', async () => { + const { calls, fetch } = recorder(); + await expect(driverWith(fetch).purge(['post,feed'])).rejects.toThrow(/X_CACHE_PURGE_FAILED/); + expect(calls).toHaveLength(0); + }); + + // The whole reason this driver reads the body on the success path too. + test('HTTP 200 with success:false is a refusal, not a purge', async () => { + const failure = await failureOf( + json({ success: false, errors: [{ code: 1122, message: 'Rate limited' }] }), + ); + + expect(failure.code).toBe('X_CACHE_PURGE_FAILED'); + expect(failure.cause).toContain('Rate limited'); + expect(failure.meta?.['retryable']).toBe(false); + }); + + test('a 200 with success:false and no message still says what happened', async () => { + const failure = await failureOf(json({ success: false })); + expect(failure.cause).toContain('success: false'); + }); + + test('a later batch failing rejects the whole purge rather than reporting it accepted', async () => { + const keys = Array.from({ length: 35 }, (_, index) => `post:${index}`); + let sent = 0; + const { fetch } = recorder(() => { + sent += 1; + return sent === 1 ? ok() : json({ success: false, errors: [{ message: 'nope' }] }); + }); + + await expect(driverWith(fetch).purge(keys)).rejects.toThrow(/X_CACHE_PURGE_FAILED/); + }); +}); + +describe('cloudflarePurgeDriver failures', () => { + test('a bad token is not retryable and the fix names the permission', async () => { + const failure = await failureOf( + json({ success: false, errors: [{ message: 'bad token' }] }, 403), + ); + expect(failure.cause).toContain('HTTP 403'); + expect(failure.cause).toContain('bad token'); + expect(failure.meta?.['retryable']).toBe(false); + expect(failure.fix).toContain('Cache Purge'); + }); + + // Purge-by-tag is an Enterprise feature; a zone without it answers 400 forever, so the fix has + // to name the plan rather than send an agent round the retry loop. + test('a 400 names the plan the feature needs', async () => { + const failure = await failureOf( + json({ success: false, errors: [{ message: 'not allowed' }] }, 400), + ); + expect(failure.fix).toContain('Enterprise'); + }); + + test('a throttle and a 5xx are retryable', async () => { + expect((await failureOf(json({}, 429))).meta?.['retryable']).toBe(true); + expect((await failureOf(json({}, 502))).meta?.['retryable']).toBe(true); + }); + + // The gate's `fix:` scanner reads `fix:` properties, so it never sees the literals `fixFor` + // returns — this test is the whole enforcement. The 429 was "bust fewer tags per write", which + // names nothing to open: the zone ceiling is not raisable from here, so the fix names the one + // lever that exists, the `invalidates` list deciding how many 30-tag requests a write sends. + test('every failure fix names a command, an env key or the call to narrow', async () => { + for (const status of [400, 401, 403, 404, 429, 502]) { + const fix = (await failureOf(json({}, status))).fix ?? ''; + expect(fix).toMatch(/^curl -sS |\.env\.production|\btag\(/); + } + const throttled = (await failureOf(json({}, 429))).fix ?? ''; + expect(throttled).toContain('cache.invalidates'); + expect(throttled).toContain('tag('); + }); + + test('an html error page is reported as text rather than swallowed', async () => { + const failure = await failureOf(new Response('bad gateway', { status: 502 })); + expect(failure.cause).toContain('bad gateway'); + }); +}); + +describe('cloudflarePurgeDriver.purgeAll', () => { + test('purges the whole zone through the same endpoint', async () => { + const { calls, fetch } = recorder(); + + await driverWith(fetch).purgeAll(); + + expect(calls[0]?.url).toBe('https://api.cloudflare.test/client/v4/zones/zone_1/purge_cache'); + expect(bodyOf(calls[0])).toEqual({ purge_everything: true }); + }); + + test('a refused purge_everything throws rather than resolving quietly', async () => { + const { fetch } = recorder(() => json({ success: false, errors: [{ message: 'nope' }] })); + await expect(driverWith(fetch).purgeAll()).rejects.toThrow(/X_CACHE_PURGE_FAILED/); + }); +}); diff --git a/packages/cache/src/purge-cloudflare.ts b/packages/cache/src/purge-cloudflare.ts new file mode 100644 index 00000000..cb67a39c --- /dev/null +++ b/packages/cache/src/purge-cloudflare.ts @@ -0,0 +1,138 @@ +// Single responsibility: Cloudflare's cache-tag purge. One `POST /zones//purge_cache` per +// batch of tags, the same call with `purge_everything` for the whole zone. Cloudflare's cache +// tags ARE Ultimate's wire tags, so a `Cache-Tag` response header and an `invalidates: [tag.post]` +// name the same string — the alternative, purging by URL, would need a route list nobody keeps. + +import type { PurgeDriver } from './cdn'; +import { CachePurgeFailedError } from './errors'; +import type { PurgeFetch } from './purge-http'; +import { + assertPurgeableKeys, + chunked, + DEFAULT_PURGE_TIMEOUT_MS, + defaultPurgeFetch, + detailFrom, + isRecord, + isRetryableStatus, + purgeBody, + purgePost, + requireCredential, +} from './purge-http'; + +export const CLOUDFLARE_API_URL = 'https://api.cloudflare.com/client/v4'; + +/** Cloudflare takes 30 cache tags per purge call; a longer list is more requests, not a refusal. */ +export const CLOUDFLARE_MAX_TAGS_PER_REQUEST = 30; + +export interface CloudflarePurgeOptions { + /** Read from `CLOUDFLARE_API_TOKEN`; needs the zone "Cache Purge" permission. */ + readonly apiToken: string; + /** Read from `CLOUDFLARE_ZONE_ID` — the zone this deployment is served from. */ + readonly zoneId: string; + /** Override for a proxy or a test double. Defaults to `CLOUDFLARE_API_URL`. */ + readonly baseUrl?: string | undefined; + readonly timeoutMs?: number | undefined; + /** Injected in tests; production uses the global. */ + readonly fetch?: PurgeFetch | undefined; +} + +// Every branch names the env key to edit, the call to narrow, or a command to run. The 429 named +// none of them: the ceiling is per zone and not raisable from here, so the only lever is the +// `invalidates` list that decides how many 30-tag requests one write sends. `retryable` already +// says the same purge can land — the fix is what stops the next write hitting the wall again. +const fixFor = (status: number): string => { + if (status === 401 || status === 403) { + return 'set CLOUDFLARE_API_TOKEN in .env.production to a token holding the zone "Cache Purge" permission'; + } + if (status === 400) { + return 'unset CLOUDFLARE_API_TOKEN in .env.production to purge nothing — purge by cache tag needs an Enterprise zone'; + } + if (status === 404) { + return 'set CLOUDFLARE_ZONE_ID in .env.production to the zone id on the Cloudflare dashboard overview page'; + } + if (status === 429) { + return "narrow the action's cache.invalidates to fewer tag(...) entries — the zone allows 1000 purge calls per minute"; + } + return 'curl -sS -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID'; +}; + +/** Cloudflare's own errors, when it sent any: `{"errors":[{"code":1122,"message":"…"}]}`. */ +function messagesFrom(payload: unknown): string | undefined { + if (!isRecord(payload)) return undefined; + const errors = payload['errors']; + if (!Array.isArray(errors) || errors.length === 0) return undefined; + const messages = errors + .map((entry) => + isRecord(entry) && typeof entry['message'] === 'string' ? entry['message'] : undefined, + ) + .filter((message): message is string => message !== undefined); + return messages.length > 0 ? messages.join('; ') : undefined; +} + +export function cloudflarePurgeDriver(options: CloudflarePurgeOptions): PurgeDriver { + const apiToken = requireCredential(options.apiToken, 'CLOUDFLARE_API_TOKEN', 'cloudflare'); + const zoneId = requireCredential(options.zoneId, 'CLOUDFLARE_ZONE_ID', 'cloudflare'); + const baseUrl = options.baseUrl ?? CLOUDFLARE_API_URL; + const timeoutMs = options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS; + const doFetch = options.fetch ?? defaultPurgeFetch; + const headers = { Authorization: `Bearer ${apiToken}` }; + + const post = (body: unknown): Promise => + purgePost({ + driver: 'cloudflare', + url: `${baseUrl}/zones/${zoneId}/purge_cache`, + headers, + body, + fetch: doFetch, + timeoutMs, + }); + + /** + * Cloudflare answers a refusal with HTTP 200 and `"success": false`, so `response.ok` alone + * would read a rejected purge as a completed one and leave the edge stale with no failure + * anywhere. Both halves are checked, and only here. + */ + const settle = async (response: Response): Promise => { + const body = await purgeBody(response); + if (!response.ok) { + throw new CachePurgeFailedError({ + driver: 'cloudflare', + detail: messagesFrom(body.json) ?? detailFrom(body), + status: response.status, + retryable: isRetryableStatus(response.status), + fix: fixFor(response.status), + }); + } + if (isRecord(body.json) && body.json['success'] === false) { + throw new CachePurgeFailedError({ + driver: 'cloudflare', + detail: + messagesFrom(body.json) ?? 'the api answered 200 with success: false and no message', + status: response.status, + retryable: false, + fix: fixFor(400), + }); + } + }; + + return { + name: 'cloudflare', + + async purge(keys: readonly string[]): Promise { + if (keys.length === 0) return []; + assertPurgeableKeys('cloudflare', keys); + const accepted: string[] = []; + // Sequential on purpose: the zone rate-limits purges, and nothing downstream reads a + // purge before it lands, so parallelism would buy latency the caller never waits on. + for (const batch of chunked('cloudflare', keys, CLOUDFLARE_MAX_TAGS_PER_REQUEST)) { + await settle(await post({ tags: batch })); + accepted.push(...batch); + } + return accepted; + }, + + async purgeAll(): Promise { + await settle(await post({ purge_everything: true })); + }, + }; +} diff --git a/packages/cache/src/purge-env.test.ts b/packages/cache/src/purge-env.test.ts new file mode 100644 index 00000000..d077c5bc --- /dev/null +++ b/packages/cache/src/purge-env.test.ts @@ -0,0 +1,119 @@ +// Which CDN a boot purges against is decided exactly once, here. These tests are what keep the +// two failure modes out: a half-set pair reading as "no CDN" (an environment that ships believing +// it purges), and two credentials resolving to a winner (one edge left serving a stale page). + +import { describe, expect, test } from 'bun:test'; +import { CDN_PURGE_ENV_KEYS, isNoopPurgeDriver, selectPurgeDriver } from './purge-env'; + +const FASTLY = { FASTLY_API_TOKEN: 'fastly-token', FASTLY_SERVICE_ID: 'svc_1' }; +const CLOUDFLARE = { CLOUDFLARE_API_TOKEN: 'cf-token', CLOUDFLARE_ZONE_ID: 'zone_1' }; + +interface Refusal { + readonly code?: string; + readonly cause?: string; + readonly meta?: Record; +} + +const refusal = (env: Record): Refusal => { + try { + selectPurgeDriver(env); + } catch (error) { + return error as Refusal; + } + // `expect.unreachable`, never a bare `Error`: a selection that succeeded where the test expected + // a refusal is the assertion, and a code-less throw would report a stack from in here instead. + return expect.unreachable('expected selectPurgeDriver to refuse'); +}; + +describe('CDN_PURGE_ENV_KEYS', () => { + test('names the four keys this module reads, and nothing else', () => { + expect([...CDN_PURGE_ENV_KEYS]).toEqual([ + 'FASTLY_API_TOKEN', + 'FASTLY_SERVICE_ID', + 'CLOUDFLARE_API_TOKEN', + 'CLOUDFLARE_ZONE_ID', + ]); + }); +}); + +describe('selectPurgeDriver', () => { + test('a complete Fastly pair selects fastly', () => { + const selection = selectPurgeDriver(FASTLY); + expect(selection.driver.name).toBe('fastly'); + expect(selection.detail).toBe('FASTLY_API_TOKEN'); + }); + + test('a complete Cloudflare pair selects cloudflare', () => { + const selection = selectPurgeDriver(CLOUDFLARE); + expect(selection.driver.name).toBe('cloudflare'); + expect(selection.detail).toBe('CLOUDFLARE_API_TOKEN'); + }); + + test('no credential purges nothing, and says what to set', () => { + const selection = selectPurgeDriver({}); + expect(isNoopPurgeDriver(selection.driver)).toBe(true); + expect(selection.detail).toContain('FASTLY_API_TOKEN'); + expect(selection.detail).toContain('CLOUDFLARE_API_TOKEN'); + }); + + test('a blank credential is no credential, not an empty token', () => { + expect(isNoopPurgeDriver(selectPurgeDriver({ FASTLY_API_TOKEN: ' ' }).driver)).toBe(true); + expect(isNoopPurgeDriver(selectPurgeDriver({ CLOUDFLARE_ZONE_ID: '' }).driver)).toBe(true); + }); + + // A token with no service id is a half-finished deploy. Treating it as "no CDN" is how an + // environment ships believing it purges — so either key selects, and the other is required. + test('a token without its id refuses, naming the missing key', () => { + const failure = refusal({ FASTLY_API_TOKEN: 'fastly-token' }); + expect(failure.code).toBe('X_CONFIG_INVALID'); + expect(failure.cause).toContain('FASTLY_SERVICE_ID'); + }); + + test('an id without its token refuses, naming the missing key', () => { + const failure = refusal({ CLOUDFLARE_ZONE_ID: 'zone_1' }); + expect(failure.code).toBe('X_CONFIG_INVALID'); + expect(failure.cause).toContain('CLOUDFLARE_API_TOKEN'); + }); + + test('two CDNs at once are refused rather than resolved to a winner', () => { + const failure = refusal({ ...FASTLY, ...CLOUDFLARE }); + expect(failure.code).toBe('X_CONFIG_INVALID'); + expect(failure.cause).toContain('two CDNs'); + }); + + test('a stray key from the other provider still refuses', () => { + expect(refusal({ ...FASTLY, CLOUDFLARE_ZONE_ID: 'zone_1' }).code).toBe('X_CONFIG_INVALID'); + }); + + // The diagnostic named a fixed token pair, so an operator who set only the two ids was sent to + // look at two variables they had never set — a false diagnostic is worse than none. + test('the refusal names the keys actually set, not a hardcoded token pair', () => { + const failure = refusal({ FASTLY_SERVICE_ID: 'svc_1', CLOUDFLARE_ZONE_ID: 'zone_1' }); + + expect(failure.meta?.['configured']).toEqual(['FASTLY_SERVICE_ID', 'CLOUDFLARE_ZONE_ID']); + expect(failure.cause).toContain('FASTLY_SERVICE_ID'); + expect(failure.cause).toContain('CLOUDFLARE_ZONE_ID'); + expect(failure.cause).not.toContain('FASTLY_API_TOKEN'); + expect(failure.cause).not.toContain('CLOUDFLARE_API_TOKEN'); + }); + + test('all four keys set reports all four, in the order this module reads them', () => { + expect(refusal({ ...FASTLY, ...CLOUDFLARE }).meta?.['configured']).toEqual([ + ...CDN_PURGE_ENV_KEYS, + ]); + }); + + // Every one of the four keys can hold a credential, and the refusal reaches a log. + test('the refusal carries key names, never the values behind them', () => { + const failure = refusal({ ...FASTLY, ...CLOUDFLARE }); + expect(failure.cause).not.toContain('fastly-token'); + expect(failure.cause).not.toContain('cf-token'); + expect(JSON.stringify(failure.meta)).not.toContain('fastly-token'); + }); + + // The detail reaches a boot line and a log. `FASTLY_API_TOKEN` holds a credential. + test('the detail carries the env key, never the value behind it', () => { + expect(selectPurgeDriver(FASTLY).detail).not.toContain('fastly-token'); + expect(selectPurgeDriver(CLOUDFLARE).detail).not.toContain('cf-token'); + }); +}); diff --git a/packages/cache/src/purge-env.ts b/packages/cache/src/purge-env.ts new file mode 100644 index 00000000..4aa9117b --- /dev/null +++ b/packages/cache/src/purge-env.ts @@ -0,0 +1,109 @@ +// Single responsibility: environment → purge driver. The one place that decides which CDN a boot +// purges against, so `x dev`, a worker container and any custom host resolve it identically. Keyed +// on env rather than an `app.config.ts` field because nothing loads that file's contents at +// runtime — a `cache.cdn` block would be a setting no boot could read. + +import { ConfigInvalidError } from '@ultimat3/core'; +import type { PurgeDriver } from './cdn'; +import { noopPurgeDriver } from './cdn'; +import { cloudflarePurgeDriver } from './purge-cloudflare'; +import { fastlyPurgeDriver } from './purge-fastly'; + +/** The keys read here, and nothing else. Named once so docs and tests cannot drift from the code. */ +export const CDN_PURGE_ENV_KEYS = [ + 'FASTLY_API_TOKEN', + 'FASTLY_SERVICE_ID', + 'CLOUDFLARE_API_TOKEN', + 'CLOUDFLARE_ZONE_ID', +] as const; + +export type PurgeEnvironment = Readonly>; + +export interface PurgeSelection { + readonly driver: PurgeDriver; + /** + * Why this driver, in one line: the env key that selected it, or what to set to change it. + * A boot prints it, so "does this replica purge anything" is never a guess. The key's name + * only — `FASTLY_API_TOKEN` holds a credential, and this string reaches a log. + */ + readonly detail: string; +} + +const nonEmpty = (value: string | undefined): string | undefined => + value === undefined || value.trim().length === 0 ? undefined : value.trim(); + +/** + * The keys this environment actually set, in declaration order. Read rather than assumed, because + * the refusal below reaches a JSON diagnostic: a hardcoded token pair sends an operator who set + * only `FASTLY_SERVICE_ID` and `CLOUDFLARE_ZONE_ID` to look at two variables they never set. + * Names only — every one of these four keys may hold a credential. + */ +const configuredKeys = (env: PurgeEnvironment): readonly string[] => + CDN_PURGE_ENV_KEYS.filter((key) => nonEmpty(env[key]) !== undefined); + +/** A driver that reaches no CDN, so a caller can report "purges nothing" without a name match. */ +export const isNoopPurgeDriver = (driver: PurgeDriver): boolean => driver.name === 'noop'; + +/** + * Either key selects its provider, and the other is then required: a `FASTLY_SERVICE_ID` with no + * token is a half-finished deploy, and treating it as "no CDN" is how an environment ships + * believing it purges. The pair is named in the cause, so the missing half is the fix. + */ +function requirePair(env: PurgeEnvironment, selectedBy: string, missingKey: string): string { + const value = nonEmpty(env[missingKey]); + if (value === undefined) { + throw new ConfigInvalidError({ + cause: `${selectedBy} selects a CDN purge driver, but ${missingKey} is unset — the pair is incomplete`, + fix: `set ${missingKey} in .env.production, or unset ${selectedBy} to purge nothing`, + meta: { selectedBy, missing: missingKey }, + }); + } + return value; +} + +/** + * A credential selects its CDN; no credential purges nothing, which is the honest default for a + * process with no edge in front of it. Two CDNs at once is refused rather than resolved: whichever + * this picked would be the one an operator did not mean half the time, and the other edge would + * serve a stale page nobody can explain. + */ +export function selectPurgeDriver(env: PurgeEnvironment): PurgeSelection { + const fastlyKey = nonEmpty(env['FASTLY_API_TOKEN']) ?? nonEmpty(env['FASTLY_SERVICE_ID']); + const cloudflareKey = + nonEmpty(env['CLOUDFLARE_API_TOKEN']) ?? nonEmpty(env['CLOUDFLARE_ZONE_ID']); + + if (fastlyKey !== undefined && cloudflareKey !== undefined) { + const configured = configuredKeys(env); + throw new ConfigInvalidError({ + cause: `two CDNs claim the same purge: ${configured.join(', ')} are set`, + fix: 'unset one pair in .env.production: a process purges exactly one edge', + meta: { configured }, + }); + } + + if (fastlyKey !== undefined) { + return { + driver: fastlyPurgeDriver({ + apiToken: requirePair(env, 'FASTLY_SERVICE_ID', 'FASTLY_API_TOKEN'), + serviceId: requirePair(env, 'FASTLY_API_TOKEN', 'FASTLY_SERVICE_ID'), + }), + detail: 'FASTLY_API_TOKEN', + }; + } + + if (cloudflareKey !== undefined) { + return { + driver: cloudflarePurgeDriver({ + apiToken: requirePair(env, 'CLOUDFLARE_ZONE_ID', 'CLOUDFLARE_API_TOKEN'), + zoneId: requirePair(env, 'CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ZONE_ID'), + }), + detail: 'CLOUDFLARE_API_TOKEN', + }; + } + + return { + driver: noopPurgeDriver(), + detail: + 'no edge in front of this process — set FASTLY_API_TOKEN + FASTLY_SERVICE_ID, or CLOUDFLARE_API_TOKEN + CLOUDFLARE_ZONE_ID', + }; +} diff --git a/packages/cache/src/purge-fastly.test.ts b/packages/cache/src/purge-fastly.test.ts new file mode 100644 index 00000000..b24430ce --- /dev/null +++ b/packages/cache/src/purge-fastly.test.ts @@ -0,0 +1,252 @@ +// The Fastly transport, asserted over an injected `fetch`: the sealed test network covers real +// egress, and the request itself is the contract here — a wrong URL, a dropped header or a batch +// that silently truncates is a stale edge no later read can catch. + +import { describe, expect, test } from 'bun:test'; +import { markListening } from '@ultimat3/core'; +import { createCdnTier } from './cdn'; +import { FASTLY_MAX_KEYS_PER_REQUEST, fastlyPurgeDriver } from './purge-fastly'; +import type { PurgeFetch } from './purge-http'; +import { tag } from './tags'; + +interface Call { + readonly url: string; + readonly init: RequestInit; +} + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status }); + +function recorder(reply: (call: Call) => Response = () => json({})): { + calls: Call[]; + fetch: PurgeFetch; +} { + const calls: Call[] = []; + const fetch: PurgeFetch = (url, init) => { + const call = { url, init }; + calls.push(call); + return Promise.resolve(reply(call)); + }; + return { calls, fetch }; +} + +const bodyOf = (call: Call | undefined): unknown => JSON.parse(String(call?.init.body)); + +const driverWith = (fetch: PurgeFetch) => + fastlyPurgeDriver({ + apiToken: 'fastly-token', + serviceId: 'svc_1', + baseUrl: 'https://api.fastly.test', + fetch, + }); + +describe('fastlyPurgeDriver construction', () => { + test('is named "fastly"', () => { + expect(driverWith(recorder().fetch).name).toBe('fastly'); + }); + + // Refused where the env key is still nameable, rather than on the first purge nobody watches. + test('an unset token refuses at construction, naming the env key', () => { + const failure = (): unknown => fastlyPurgeDriver({ apiToken: ' ', serviceId: 'svc_1' }); + expect(failure).toThrow(/X_CACHE_DRIVER_UNAVAILABLE/); + expect(failure).toThrow(/FASTLY_API_TOKEN/); + }); + + test('an unset service id refuses at construction, naming the env key', () => { + const failure = (): unknown => fastlyPurgeDriver({ apiToken: 'token', serviceId: '' }); + expect(failure).toThrow(/X_CACHE_DRIVER_UNAVAILABLE/); + expect(failure).toThrow(/FASTLY_SERVICE_ID/); + }); +}); + +describe('fastlyPurgeDriver.purge', () => { + test('posts the surrogate keys to the service purge endpoint with the api token', async () => { + const { calls, fetch } = recorder(); + + await driverWith(fetch).purge(['post', 'post:1']); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe('https://api.fastly.test/service/svc_1/purge'); + expect(new Headers(calls[0]?.init.headers).get('Fastly-Key')).toBe('fastly-token'); + expect(bodyOf(calls[0])).toEqual({ surrogate_keys: ['post', 'post:1'] }); + }); + + test('an empty key list sends no request at all', async () => { + const { calls, fetch } = recorder(); + expect(await driverWith(fetch).purge([])).toEqual([]); + expect(calls).toHaveLength(0); + }); + + test('reports the keys Fastly named in its response', async () => { + const { fetch } = recorder(() => json({ post: 'purge-id-1' })); + expect(await driverWith(fetch).purge(['post', 'post:1'])).toEqual(['post']); + }); + + // Fastly answers a single-key purge with `{status, id}` — a shape that names no key. A 2xx is + // acceptance, so the requested batch is the honest answer rather than "nothing was purged". + test('a response naming no key still reports the batch as accepted', async () => { + const { fetch } = recorder(() => json({ status: 'ok', id: '1' })); + expect(await driverWith(fetch).purge(['post', 'post:1'])).toEqual(['post', 'post:1']); + }); + + test('a non-JSON 2xx body is still acceptance, not a failure', async () => { + const { fetch } = recorder(() => new Response('ok')); + expect(await driverWith(fetch).purge(['post'])).toEqual(['post']); + }); + + test('batches at the 256-key API cap and reports every batch as accepted', async () => { + const keys = Array.from({ length: 600 }, (_, index) => `post:${index}`); + const { calls, fetch } = recorder(); + + const accepted = await driverWith(fetch).purge(keys); + + expect(calls).toHaveLength(3); + expect(FASTLY_MAX_KEYS_PER_REQUEST).toBe(256); + const sizes = calls.map((call) => { + const body = bodyOf(call) as { surrogate_keys: string[] }; + return body.surrogate_keys.length; + }); + expect(sizes).toEqual([256, 256, 88]); + expect(accepted).toEqual(keys); + }); + + test('a key a CDN would split is refused before any request leaves', async () => { + const { calls, fetch } = recorder(); + await expect(driverWith(fetch).purge(['post 1'])).rejects.toThrow(/X_CACHE_PURGE_FAILED/); + expect(calls).toHaveLength(0); + }); +}); + +describe('fastlyPurgeDriver failures', () => { + const failureOf = async (response: Response, keys: readonly string[] = ['post']) => { + const { fetch } = recorder(() => response); + return await driverWith(fetch) + .purge(keys) + .then( + () => undefined, + (error: unknown) => + error as { code?: string; cause?: string; fix?: string; meta?: Record }, + ); + }; + + test('a bad credential is not retryable and the fix names the token', async () => { + const failure = await failureOf(json({ msg: 'Provided credentials are missing' }, 401)); + expect(failure?.code).toBe('X_CACHE_PURGE_FAILED'); + expect(failure?.cause).toContain('HTTP 401'); + expect(failure?.cause).toContain('Provided credentials are missing'); + expect(failure?.meta?.['retryable']).toBe(false); + expect(failure?.fix).toContain('FASTLY_API_TOKEN'); + }); + + test('an unknown service names FASTLY_SERVICE_ID', async () => { + const failure = await failureOf(json({ msg: 'Record not found' }, 404)); + expect(failure?.fix).toContain('FASTLY_SERVICE_ID'); + }); + + test('a throttle and a 5xx are retryable', async () => { + expect((await failureOf(json({}, 429)))?.meta?.['retryable']).toBe(true); + expect((await failureOf(json({}, 503)))?.meta?.['retryable']).toBe(true); + }); + + // The gate's `fix:` scanner reads `fix:` properties, so it never sees the literals `fixFor` + // returns — this test is the whole enforcement. The 429 was "raise the purge rate limit on the + // Fastly account", which is advice no agent can run; Fastly answers with `Fastly-RateLimit-*`, + // so the remaining budget is readable and that is what the fix hands over. + test('every failure fix names a command to run or an env key to edit', async () => { + for (const status of [401, 403, 404, 429, 500, 503]) { + const fix = (await failureOf(json({}, status)))?.fix ?? ''; + expect(fix).toMatch(/^curl -sS |\.env\.production/); + } + expect((await failureOf(json({}, 429)))?.fix).toContain('fastly-ratelimit'); + }); + + test('purgeAll posts purge_all, and its failure is reported the same way', async () => { + const { calls, fetch } = recorder(); + await driverWith(fetch).purgeAll(); + expect(calls[0]?.url).toBe('https://api.fastly.test/service/svc_1/purge_all'); + + const refusing = recorder(() => json({ msg: 'forbidden' }, 403)); + await expect(driverWith(refusing.fetch).purgeAll()).rejects.toThrow(/X_CACHE_PURGE_FAILED/); + }); +}); + +describe('over a real socket', () => { + /** + * Everything above injects `fetch`, which proves the request this driver *builds*. This proves + * the one it *sends*: the default fetch, a real HTTP round trip, real header casing and a real + * `Response` parsed back. The loopback server announces itself to core's listener registry, so + * the sealed test network reads it as this process talking to itself rather than egress. + */ + test('purges through the default fetch, with the token on the wire', async () => { + const seen: { path: string; token: string | null; body: string }[] = []; + const server = Bun.serve({ + port: 0, + async fetch(request) { + seen.push({ + path: new URL(request.url).pathname, + token: request.headers.get('Fastly-Key'), + body: await request.text(), + }); + return Response.json({ post: 'purge-id-1', 'post:1': 'purge-id-2' }); + }, + }); + const release = markListening(server.url.origin); + + try { + const driver = fastlyPurgeDriver({ + apiToken: 'live-token', + serviceId: 'svc_live', + baseUrl: server.url.origin, + }); + + expect(await driver.purge(['post', 'post:1'])).toEqual(['post', 'post:1']); + await driver.purgeAll(); + + expect(seen.map((request) => request.path)).toEqual([ + '/service/svc_live/purge', + '/service/svc_live/purge_all', + ]); + expect(seen[0]?.token).toBe('live-token'); + expect(seen[0]?.body).toBe('{"surrogate_keys":["post","post:1"]}'); + } finally { + release(); + await server.stop(true); + } + }); + + test('a refusal on the wire becomes X_CACHE_PURGE_FAILED, not an unhandled status', async () => { + const server = Bun.serve({ + port: 0, + fetch: () => + Response.json({ msg: 'Provided credentials are missing or invalid' }, { status: 401 }), + }); + const release = markListening(server.url.origin); + + try { + const driver = fastlyPurgeDriver({ + apiToken: 'wrong', + serviceId: 'svc_live', + baseUrl: server.url.origin, + }); + await expect(driver.purge(['post'])).rejects.toThrow(/X_CACHE_PURGE_FAILED/); + } finally { + release(); + await server.stop(true); + } + }); +}); + +describe('the cdn tier over a real driver', () => { + // Surrogate keys ARE the tags: the strings `invalidateTags` fans out are the strings Fastly + // is asked to purge, byte for byte. A translation step anywhere here is a drift no test could + // catch later, because the edge would answer 200 for a key nothing was ever tagged with. + test('the wire tags reach the provider unchanged', async () => { + const { calls, fetch } = recorder(); + const tier = createCdnTier({ purge: driverWith(fetch) }); + + const result = await tier.invalidateTags([tag('post'), tag('post', '1')]); + + expect(bodyOf(calls[0])).toEqual({ surrogate_keys: ['post', 'post:1'] }); + expect(result).toEqual({ tier: 'cdn', keys: ['post', 'post:1'] }); + }); +}); diff --git a/packages/cache/src/purge-fastly.ts b/packages/cache/src/purge-fastly.ts new file mode 100644 index 00000000..6c122228 --- /dev/null +++ b/packages/cache/src/purge-fastly.ts @@ -0,0 +1,119 @@ +// Single responsibility: Fastly's surrogate-key purge. One `POST /service//purge` per batch +// of keys, one `POST /service//purge_all` for the whole service — no SDK, `fetch` is the whole +// client. The keys are Ultimate's wire tags unchanged (`post`, `post:1`), which is the property +// that keeps an edge purge and an app-level invalidation from ever meaning different things. + +import type { PurgeDriver } from './cdn'; +import { CachePurgeFailedError } from './errors'; +import type { PurgeFetch } from './purge-http'; +import { + assertPurgeableKeys, + chunked, + DEFAULT_PURGE_TIMEOUT_MS, + defaultPurgeFetch, + detailFrom, + isRecord, + isRetryableStatus, + type PurgeBody, + purgeBody, + purgePost, + requireCredential, +} from './purge-http'; + +export const FASTLY_API_URL = 'https://api.fastly.com'; + +/** Fastly accepts 256 surrogate keys in one batch purge; more is a second request, not a refusal. */ +export const FASTLY_MAX_KEYS_PER_REQUEST = 256; + +export interface FastlyPurgeOptions { + /** Read from `FASTLY_API_TOKEN`. A literal token in app.config.ts is a token in git. */ + readonly apiToken: string; + /** Read from `FASTLY_SERVICE_ID` — which service this deployment fronts. */ + readonly serviceId: string; + /** Override for a proxy or a test double. Defaults to `FASTLY_API_URL`. */ + readonly baseUrl?: string | undefined; + readonly timeoutMs?: number | undefined; + /** Injected in tests; production uses the global. */ + readonly fetch?: PurgeFetch | undefined; +} + +// Every branch names the env key to edit or a command to run. "raise the rate limit, or bust fewer +// tags" was the one that named neither: Fastly answers every API call with `Fastly-RateLimit-*`, +// so the remaining budget and its reset are readable — which is the half an agent can act on. +const fixFor = (status: number): string => { + if (status === 401 || status === 403) { + return 'set FASTLY_API_TOKEN in .env.production to a token with the purge scope from https://manage.fastly.com/account/personal/tokens'; + } + if (status === 404) { + return 'set FASTLY_SERVICE_ID in .env.production to the id at https://manage.fastly.com/configure/services'; + } + if (status === 429) { + return 'curl -sS -D - -o /dev/null -H "Fastly-Key: $FASTLY_API_TOKEN" https://api.fastly.com/service/$FASTLY_SERVICE_ID | grep -i fastly-ratelimit'; + } + return 'curl -sS -H "Fastly-Key: $FASTLY_API_TOKEN" https://api.fastly.com/service/$FASTLY_SERVICE_ID'; +}; + +/** + * Fastly answers a batch purge with `{ "": "" }`, and a single-key purge with + * `{ "status": "ok", "id": … }`. Only the first shape names keys, so anything else is read as + * "the whole batch was accepted" — which a 2xx already means. + */ +function acceptedFrom(body: PurgeBody, batch: readonly string[]): string[] { + const payload = body.json; + if (!isRecord(payload)) return [...batch]; + const named = batch.filter((key) => key in payload); + return named.length > 0 ? named : [...batch]; +} + +export function fastlyPurgeDriver(options: FastlyPurgeOptions): PurgeDriver { + const apiToken = requireCredential(options.apiToken, 'FASTLY_API_TOKEN', 'fastly'); + const serviceId = requireCredential(options.serviceId, 'FASTLY_SERVICE_ID', 'fastly'); + const baseUrl = options.baseUrl ?? FASTLY_API_URL; + const timeoutMs = options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS; + const doFetch = options.fetch ?? defaultPurgeFetch; + const headers = { 'Fastly-Key': apiToken }; + + const post = (path: string, body: unknown): Promise => + purgePost({ + driver: 'fastly', + url: `${baseUrl}/service/${serviceId}${path}`, + headers, + body, + fetch: doFetch, + timeoutMs, + }); + + /** The body is read here whether or not the call failed, because a `Response` gives it up once. */ + const settle = async (response: Response): Promise => { + const body = await purgeBody(response); + if (response.ok) return body; + throw new CachePurgeFailedError({ + driver: 'fastly', + detail: detailFrom(body), + status: response.status, + retryable: isRetryableStatus(response.status), + fix: fixFor(response.status), + }); + }; + + return { + name: 'fastly', + + async purge(keys: readonly string[]): Promise { + if (keys.length === 0) return []; + assertPurgeableKeys('fastly', keys); + const accepted: string[] = []; + // Sequential on purpose: a bust of thousands of keys must not open thousands of sockets + // against an API that rate-limits, and nothing downstream reads a purge before it lands. + for (const batch of chunked('fastly', keys, FASTLY_MAX_KEYS_PER_REQUEST)) { + const body = await settle(await post('/purge', { surrogate_keys: batch })); + accepted.push(...acceptedFrom(body, batch)); + } + return accepted; + }, + + async purgeAll(): Promise { + await settle(await post('/purge_all', {})); + }, + }; +} diff --git a/packages/cache/src/purge-http.test.ts b/packages/cache/src/purge-http.test.ts new file mode 100644 index 00000000..8011d4b7 --- /dev/null +++ b/packages/cache/src/purge-http.test.ts @@ -0,0 +1,223 @@ +// The shared HTTP half is where both drivers agree on what a failure means. Every assertion here +// is a decision a purge cannot get wrong twice: a key a CDN would split, a body read twice, and +// which status is worth sending the identical request for again. + +import { describe, expect, test } from 'bun:test'; +import type { UltimateError } from '@ultimat3/core'; +import { isUltimateError } from '@ultimat3/core'; +import { CacheDriverUnavailableError } from './errors'; +import { + assertPurgeableKeys, + chunked, + defaultPurgeFetch, + detailFrom, + isRecord, + isRetryableStatus, + purgeBody, + purgePost, +} from './purge-http'; + +/** + * The thrown error itself, so a test asserts on `code` and `cause` together. Anything else — no + * throw, or a throw carrying neither — fails through the runner with `expect.unreachable` rather + * than a bare `Error`, which would report a stack from inside this helper and no code at all. + */ +function refusalOf(call: () => unknown): UltimateError { + try { + call(); + } catch (error) { + if (isUltimateError(error)) return error; + } + return expect.unreachable('expected a typed cache refusal'); +} + +describe('chunked', () => { + test('splits into batches of at most size, in order', () => { + expect(chunked('fastly', [1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); + + test('an exact multiple leaves no trailing empty batch', () => { + expect(chunked('fastly', [1, 2, 3, 4], 2)).toEqual([ + [1, 2], + [3, 4], + ]); + }); + + test('an empty list is no batches, so a caller sends no request', () => { + expect(chunked('cloudflare', [], 30)).toEqual([]); + }); + + // A size of 0 or less never advances the index: the loop spins forever, holding open the write's + // own invalidation. Refused before the first iteration, so there is no loop to hang in. + test('a batch size of 0 or -1 is refused instead of looping forever', () => { + for (const size of [0, -1]) { + const failure = refusalOf(() => chunked('fastly', ['post', 'feed'], size)); + expect(failure.code).toBe('X_CACHE_DRIVER_UNAVAILABLE'); + expect(failure.cause).toContain(`batch size ${size}`); + expect(failure.fix).toContain('chunked('); + } + }); + + // NaN fails every comparison, so `index += NaN` ends the loop after one pass and that pass slices + // to NaN: one empty batch. The driver posts an empty key list, the CDN answers 200, and the purge + // reports having cleared nothing — the one CDN failure no later read can catch. + test('a NaN batch size is refused rather than silently purging nothing', () => { + const failure = refusalOf(() => chunked('cloudflare', ['post', 'feed'], Number.NaN)); + expect(failure.code).toBe('X_CACHE_DRIVER_UNAVAILABLE'); + expect(failure.cause).toContain('NaN'); + expect(failure.cause).toContain('cloudflare'); + }); + + test('a fractional or infinite size is refused too — no provider caps keys at either', () => { + expect(refusalOf(() => chunked('fastly', ['post'], 2.5)).code).toBe( + 'X_CACHE_DRIVER_UNAVAILABLE', + ); + expect(refusalOf(() => chunked('fastly', ['post'], Number.POSITIVE_INFINITY)).code).toBe( + 'X_CACHE_DRIVER_UNAVAILABLE', + ); + }); +}); + +describe('isRetryableStatus', () => { + test('a throttle or a momentary conflict can land unchanged', () => { + for (const status of [408, 409, 425, 429, 500, 502, 503]) { + expect(isRetryableStatus(status)).toBe(true); + } + }); + + test('a credential, a plan or a wrong id cannot be fixed by retrying', () => { + for (const status of [400, 401, 403, 404, 422]) { + expect(isRetryableStatus(status)).toBe(false); + } + }); +}); + +describe('assertPurgeableKeys', () => { + test('accepts the wire tags the framework actually mints', () => { + expect(() => { + assertPurgeableKeys('fastly', ['post', 'post:1', 'feed', 'org:9f3b-1']); + }).not.toThrow(); + }); + + // The failure this guard exists for: a CDN splits a key list on whitespace and commas, so a key + // carrying either purges two keys that do not exist — and answers 200 while doing it. + test('refuses a key a CDN would split, naming the key', () => { + const failure = (): unknown => assertPurgeableKeys('cloudflare', ['post 1']); + expect(failure).toThrow(/X_CACHE_PURGE_FAILED/); + expect(failure).toThrow(/post 1/); + }); + + test('refuses a comma, an empty key, and a key over the 1024-byte limit', () => { + expect(() => assertPurgeableKeys('fastly', ['a,b'])).toThrow(/X_CACHE_PURGE_FAILED/); + expect(() => assertPurgeableKeys('fastly', [''])).toThrow(/X_CACHE_PURGE_FAILED/); + expect(() => assertPurgeableKeys('fastly', ['x'.repeat(1025)])).toThrow(/X_CACHE_PURGE_FAILED/); + }); + + test('the refusal is not retryable — the same key would fail identically', () => { + const failure = refusalOf(() => { + assertPurgeableKeys('fastly', ['post 1']); + }); + expect(failure.code).toBe('X_CACHE_PURGE_FAILED'); + expect(failure.meta?.['retryable']).toBe(false); + }); +}); + +describe('purgeBody', () => { + test('parses JSON once and keeps the text beside it', async () => { + const body = await purgeBody(new Response('{"success":true}')); + expect(body.json).toEqual({ success: true }); + expect(body.text).toBe('{"success":true}'); + }); + + // A `Response` streams: the failure path reads the body for its detail, and a second read + // would throw "Body already used" and lose the message it exists to report. + test('a non-JSON error page degrades to text rather than throwing', async () => { + const body = await purgeBody(new Response('502 Bad Gateway', { status: 502 })); + expect(body.json).toBeUndefined(); + expect(detailFrom(body)).toBe('502 Bad Gateway'); + }); + + test('an empty body says so instead of rendering an empty detail', async () => { + expect(detailFrom(await purgeBody(new Response('')))).toBe('the response body was empty'); + }); + + test('a huge body is truncated so one error cannot flood a log line', async () => { + const detail = detailFrom(await purgeBody(new Response('x'.repeat(5000)))); + expect(detail.length).toBeLessThan(300); + expect(detail.endsWith('…')).toBe(true); + }); +}); + +describe('isRecord', () => { + test('an object is a record; an array, null and a primitive are not', () => { + expect(isRecord({ a: 1 })).toBe(true); + expect(isRecord([1])).toBe(false); + expect(isRecord(null)).toBe(false); + expect(isRecord('post')).toBe(false); + }); +}); + +describe('purgePost', () => { + test('sends JSON with the driver headers and the caller deadline', async () => { + const calls: { url: string; init: RequestInit }[] = []; + await purgePost({ + driver: 'fastly', + url: 'https://cdn.test/purge', + headers: { 'Fastly-Key': 'token' }, + body: { surrogate_keys: ['post'] }, + timeoutMs: 5000, + fetch: (url, init) => { + calls.push({ url, init }); + return Promise.resolve(new Response('{}')); + }, + }); + + const [call] = calls; + expect(call?.url).toBe('https://cdn.test/purge'); + expect(call?.init.method).toBe('POST'); + expect(new Headers(call?.init.headers).get('Fastly-Key')).toBe('token'); + expect(new Headers(call?.init.headers).get('Content-Type')).toBe('application/json'); + expect(call?.init.body).toBe('{"surrogate_keys":["post"]}'); + expect(call?.init.signal).toBeInstanceOf(AbortSignal); + }); + + // Nothing at the edge saw this request, so the identical one can still land: that is the whole + // difference between a transport failure and a refusal, and it is why `retryable` is not a guess. + test('a request that never got a status is retryable and the fix is a reachability probe', async () => { + const failure = await purgePost({ + driver: 'cloudflare', + url: 'https://cdn.test/purge', + headers: {}, + body: {}, + timeoutMs: 10, + // A coded transport failure, not a bare `Error`: `purgePost` reads `error.message` for its + // own cause, so the code and the reason both have to survive into the reported detail. + fetch: () => + Promise.reject( + new CacheDriverUnavailableError({ + driver: 'cloudflare', + cause: 'ECONNREFUSED reaching api.cloudflare.test from this host', + fix: 'curl -sS -m 5 -o /dev/null https://api.cloudflare.com/client/v4', + }), + ), + }).then( + () => undefined, + (error: unknown) => + error as { code?: string; cause?: string; fix?: string; meta?: { retryable?: boolean } }, + ); + + expect(failure?.code).toBe('X_CACHE_PURGE_FAILED'); + expect(failure?.meta?.retryable).toBe(true); + expect(failure?.fix).toBe('curl -sS -m 5 -o /dev/null https://cdn.test/purge'); + expect(failure?.cause).toContain('ECONNREFUSED'); + }); +}); + +describe('defaultPurgeFetch', () => { + // A bare `globalThis.fetch` reference throws "Illegal invocation" on some hosts; this is the + // assertion that keeps the call detached from a receiver rather than aliased to one. + test('is a plain function, not a bound reference to the global', () => { + expect(typeof defaultPurgeFetch).toBe('function'); + expect(defaultPurgeFetch).not.toBe(globalThis.fetch); + }); +}); diff --git a/packages/cache/src/purge-http.ts b/packages/cache/src/purge-http.ts new file mode 100644 index 00000000..5cb5d5eb --- /dev/null +++ b/packages/cache/src/purge-http.ts @@ -0,0 +1,163 @@ +// Single responsibility: the HTTP half both remote purge drivers share — one POST with a +// deadline, the status → retryable table, and the batching a provider's per-request key cap +// forces. Kept apart from the drivers because "which failure can succeed unchanged" is one +// judgement, and two copies of it would drift into two answers for the same 429. + +import { CacheDriverUnavailableError, CachePurgeFailedError } from './errors'; + +/** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */ +export type PurgeFetch = (input: string, init: RequestInit) => Promise; + +/** A purge is behind the write, not in front of it: a slow CDN must not hold the fan-out open. */ +export const DEFAULT_PURGE_TIMEOUT_MS = 10_000; + +const MAX_DETAIL_LENGTH = 200; + +// A 4xx here means the same request, unchanged, might land: a throttle or a momentary conflict. +// Every other 4xx is a credential or a plan, which no retry fixes. +const RETRYABLE_STATUSES = new Set([408, 409, 425, 429]); + +export const isRetryableStatus = (status: number): boolean => + status >= 500 || RETRYABLE_STATUSES.has(status); + +/** + * A bare reference to `globalThis.fetch` risks "Illegal invocation" on some hosts; closing over + * the call keeps it detached from any receiver, in production and in tests alike. + */ +export const defaultPurgeFetch: PurgeFetch = (input, init) => globalThis.fetch(input, init); + +/** + * Providers cap keys per request. A bust of 300 tags is still one purge — several requests. + * + * The size is refused before the loop, not trusted: a `0` or a negative never advances `index`, so + * the fan-out hangs holding the write's invalidation open, and a `NaN` ends the loop after one pass + * that slices to nothing — an empty key list posted to a CDN that answers 200 and clears nothing. + * `X_CACHE_DRIVER_UNAVAILABLE` rather than `X_CACHE_PURGE_FAILED`: the only sizes this ever sees + * are the drivers' own caps, so a bad one is this package miswired, and no CDN refused anything. + */ +export function chunked( + driver: string, + values: readonly T[], + size: number, +): readonly (readonly T[])[] { + if (!Number.isSafeInteger(size) || size < 1) { + throw new CacheDriverUnavailableError({ + driver, + cause: `batch size ${String(size)} is not a positive integer, so ${values.length} keys cannot be split into requests`, + fix: 'pass a positive integer batch size to chunked(), as FASTLY_MAX_KEYS_PER_REQUEST does', + }); + } + const batches: T[][] = []; + for (let index = 0; index < values.length; index += size) { + batches.push(values.slice(index, index + size)); + } + return batches; +} + +/** + * A credential the driver cannot run without, refused at construction — where the env key is + * still nameable — rather than on the first purge nobody watches. "no CDN token" is what + * `X_CACHE_DRIVER_UNAVAILABLE` already means, so this is that code and not a second one. + */ +export function requireCredential(value: string, envKey: string, driver: string): string { + if (value.trim() !== '') return value.trim(); + throw new CacheDriverUnavailableError({ + driver, + cause: `${envKey} is unset, so the ${driver} purge driver has no credential`, + fix: `set ${envKey} in .env.production, or use noopPurgeDriver() to purge nothing`, + }); +} + +// Every CDN splits a key list on whitespace or a comma, so a key carrying either purges two +// things that do not exist instead of the one that does — silently, since the request succeeds. +const UNSAFE_KEY = /[\s,]/; +const MAX_KEY_LENGTH = 1024; + +const keyProblem = (key: string): string | undefined => { + if (key === '') return 'is empty'; + if (UNSAFE_KEY.test(key)) + return 'contains whitespace or a comma, which a CDN reads as a separator'; + if (key.length > MAX_KEY_LENGTH) + return `is ${key.length} characters, over the 1024-byte key limit`; + return undefined; +}; + +/** + * Refused before the request, not after: a malformed key comes back as an accepted purge that + * cleared nothing, which is the one CDN failure no later read can catch. + */ +export function assertPurgeableKeys(driver: string, keys: readonly string[]): void { + for (const key of keys) { + const problem = keyProblem(key); + if (problem === undefined) continue; + throw new CachePurgeFailedError({ + driver, + detail: `surrogate key ${JSON.stringify(key)} ${problem}`, + retryable: false, + fix: 'rename the tag in its declareTags(...) call so the key carries no space or comma', + }); + } +} + +export interface PurgeBody { + readonly text: string; + /** `undefined` when the provider sent something that is not JSON — an html error page, or nothing. */ + readonly json: unknown; +} + +/** + * The body, read exactly once. A `Response` streams: `json()` followed by `text()` throws "Body + * already used", so the failure path would lose the very message it exists to report. + */ +export async function purgeBody(response: Response): Promise { + const text = await response.text().catch(() => ''); + try { + return { text, json: JSON.parse(text) as unknown }; + } catch { + return { text, json: undefined }; + } +} + +/** Raw text, capped so a provider's error page cannot flood a log line. */ +export function detailFrom(body: PurgeBody): string { + if (body.text === '') return 'the response body was empty'; + return body.text.length > MAX_DETAIL_LENGTH + ? `${body.text.slice(0, MAX_DETAIL_LENGTH)}…` + : body.text; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export interface PurgePostInput { + readonly driver: string; + readonly url: string; + readonly headers: Readonly>; + readonly body: unknown; + readonly fetch: PurgeFetch; + readonly timeoutMs: number; +} + +/** + * One POST, with the transport failure already translated. A request that never got a status — + * DNS, TLS, a reset, the deadline — is retryable by definition: nothing at the edge has seen it. + */ +export async function purgePost(input: PurgePostInput): Promise { + try { + return await input.fetch(input.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...input.headers }, + body: JSON.stringify(input.body), + signal: AbortSignal.timeout(input.timeoutMs), + }); + } catch (error) { + const reason = error instanceof Error ? error.message : 'the request failed before a response'; + throw new CachePurgeFailedError({ + driver: input.driver, + detail: `${reason} — nothing left this host for ${input.url} (egress, DNS or TLS)`, + retryable: true, + fix: `curl -sS -m 5 -o /dev/null ${input.url}`, + }); + } +} diff --git a/packages/cli/CLAUDE.md b/packages/cli/CLAUDE.md index 584aa53b..dbce956a 100644 --- a/packages/cli/CLAUDE.md +++ b/packages/cli/CLAUDE.md @@ -92,6 +92,7 @@ that trace is the live panel's question, so the panel degrades to its own note i | `dev-queue.ts` | the db + queue pair alone, and the one place that takes `db()` and `jobDriver()` back | | `dev-runtime.ts` | start the rest on top of it and install the remaining accessors (storage, mail, transport) | | `dev-render.ts` | one HTTP route per registered `route`, through render's own mode function | +| `dev-assets.ts` | the image pipeline's only HTTP surface: `/icons/*` and `/media/*` | | `dev-hooks.ts` | the pipeline's `authorize` seam, decided from the app's own `Policy` objects | | `dev-roles.ts` | `--role` selection plus start/stop for `web`, `sync`, `worker`, `scheduler` | | `dev-dashboard.ts` | the `DevSources` hooks only this process can answer, and the two CLI panels | @@ -109,6 +110,27 @@ The roles live in `@ultimat3/core` (`ROLES`, `isRole`), never in a second list h driver, a dev-only authorizer or a dev-only queue is the bug this design exists to prevent — the only thing dev changes is which driver is behind an interface. +### `dev-assets.ts` is where the image pipeline meets HTTP + +Three packages declare what an image is and none of them serves one: `@ultimat3/seo` says what a +variant URL means (`parseImageQuery`) and produces the bytes (`builtinImageDriver`), +`@ultimat3/storage` says what a variant is called and where it is cached (`variantKey`), and +`@ultimat3/pwa` says which icons a web manifest promises (`planIcons`, `BuiltinImagePipeline`). +Pixels are `@ultimat3/core`'s pipeline, only ever. This file picks two base paths — `ICON_BASE_PATH` +and `MEDIA_BASE_PATH` — and decides nothing else; a resize, a format table or a second cache key +here is the drift the split exists to prevent. + +`ICON_SOURCE` lives here, not in `cmd-doctor.ts`, because this is the module that reads it: the +diagnostic checks what `x dev` serves, so one constant cannot pass the check and serve nothing. +It is a **PNG** — core decodes PNG and JPEG only, and the SVG this used to name could never +become an icon. + +The routes mount whether or not the source icon exists, and a missing one is refused with +`X_PWA_ICON_MISSING` and its fix — a route that silently disappears is a 404 whose meaning an agent +has to guess. Deliberately **not** also a boot finding: `x doctor` already reports this condition, +with this code, and two reporters of one condition is the duplication this package's own rule +forbids. `x dev` owns the runtime half; the diagnostic owns the other. + ### `hold.ts` is why a long-running command outlives its own result `dispatch` renders a `CommandResult` and `bin.ts` exits on the code — so a command whose server is diff --git a/packages/cli/package.json b/packages/cli/package.json index e20a39a0..a7b92cb8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,9 +47,11 @@ "@ultimat3/manifest": "1.0.0", "@ultimat3/mcp": "1.0.0", "@ultimat3/policy": "1.0.0", + "@ultimat3/pwa": "1.0.0", "@ultimat3/query": "1.0.0", "@ultimat3/realtime": "1.0.0", "@ultimat3/render": "1.0.0", + "@ultimat3/seo": "1.0.0", "@ultimat3/storage": "1.0.0", "@ultimat3/testing": "1.0.0" } diff --git a/packages/cli/src/cmd-dev.ts b/packages/cli/src/cmd-dev.ts index ce6a9640..cb709dd8 100644 --- a/packages/cli/src/cmd-dev.ts +++ b/packages/cli/src/cmd-dev.ts @@ -16,13 +16,14 @@ import { loadApp } from './app-load'; import { appManifest } from './app-manifest'; import { requireAppRoot } from './app-root'; import type { CliCommand, CommandContext } from './command'; +import { assetRoutes } from './dev-assets'; import type { DevDashboardInput, DevStatus } from './dev-dashboard'; import { devDashboardRoutes, devPanels } from './dev-dashboard'; import { appRoutes } from './dev-render'; import type { RunningRoles } from './dev-roles'; import { DEV_ROLES, selectRoles, startRoles } from './dev-roles'; import type { RunningServices } from './dev-runtime'; -import { startServices } from './dev-runtime'; +import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './dev-runtime'; import type { DevServices } from './dev-services'; import { describeServices, resolveServices } from './dev-services'; import { createTraceRecorder } from './dev-traces'; @@ -98,7 +99,7 @@ const envOf = (env: StartDevOptions['env']): { env?: string } => { */ export async function startDev(options: StartDevOptions): Promise { const services = resolveServices(options.root, options.env); - const runtime: RunningServices = await startServices(services); + const runtime: RunningServices = await startServices(services, options.env); // Installed before the app loads, so a span opened during registration is already recorded. // Tracing is always on in the framework and free until an exporter is configured; `x dev` is // what configures one, which is the whole reason `/_x/timeline` has anything to draw. @@ -138,6 +139,10 @@ export async function startDev(options: StartDevOptions): Promise { const routes: readonly Route[] = [ ...devDashboardRoutes(dashboard), ...listActions().map(toRoute), + // The image pipeline's only HTTP surface: the icons the web manifest declares, and the + // variants every `srcset` promises. Mounted before the app's own routes so a page route can + // never shadow `/icons` or `/media`. + ...assetRoutes({ root: options.root, storage: runtime.storage }), ...appRoutes({ buildId }), ]; @@ -232,7 +237,9 @@ export const devCommand: CliCommand = { summary: msg('cli.dev.ready', { url: server.url, panels: server.panels.length, - services: describeServices(server.services), + // Rendered text, so the mail and CDN halves come from the catalog; `data` below carries the + // status values a script parses, which is why the two are different calls and not one. + services: `${describeServices(server.services)} ${mailLabel(server.runtime)} ${cdnLabel(server.runtime)}`, }), findings: server.findings, // Every fact `lines` prints is a fact `--json` carries, `manifest` included — or the two @@ -245,6 +252,10 @@ export const devCommand: CliCommand = { db: server.services.db.url, events: server.services.events.url, storage: server.services.storage.url, + // The selecting env key, never the credential behind it: `SMTP_URL` carries a password + // and this line is printed, logged and scraped. + mail: describeMail(server.runtime), + cdn: describeCdn(server.runtime), buildId: server.buildId, manifest: join(root, MANIFEST_FILENAME), introspect: `${server.url}/_x`, diff --git a/packages/cli/src/cmd-doctor.test.ts b/packages/cli/src/cmd-doctor.test.ts index d45109b0..a1554ff9 100644 --- a/packages/cli/src/cmd-doctor.test.ts +++ b/packages/cli/src/cmd-doctor.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import type { DoctorProbe } from './cmd-doctor'; -import { ICON_SOURCE, OFFLINE_FALLBACK, probeFor, runDoctor } from './cmd-doctor'; +import { OFFLINE_FALLBACK, probeFor, runDoctor } from './cmd-doctor'; +import { ICON_SOURCE } from './dev-assets'; const probe = (over: Partial = {}): DoctorProbe => ({ bunVersion: '1.3.14', @@ -33,9 +34,12 @@ describe('unit · x doctor', () => { }); test('a missing source icon is reported separately from the fallback', async () => { - expect(await codes(probe({ exists: (path) => path !== ICON_SOURCE }))).toEqual([ - 'X_PWA_ICON_MISSING', - ]); + const findings = await runDoctor(probe({ exists: (path) => path !== ICON_SOURCE })); + expect(findings.map((finding) => finding.code)).toEqual(['X_PWA_ICON_MISSING']); + // An edit naming the file, pinned verbatim. `x new` was here and could never run: it takes an + // app name, so it is not an instruction anyone inside the broken app can follow. + expect(findings[0]?.fix).toBe(`add a 1024x1024 square PNG at ${ICON_SOURCE}`); + expect(findings[0]?.fix).not.toContain('x new'); }); test('an occupied port suggests the next one', async () => { diff --git a/packages/cli/src/cmd-doctor.ts b/packages/cli/src/cmd-doctor.ts index 7b28534d..7e383ee9 100644 --- a/packages/cli/src/cmd-doctor.ts +++ b/packages/cli/src/cmd-doctor.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import { usesDevCursorSecret } from '@ultimat3/core'; import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root'; import type { CliCommand, CommandContext } from './command'; +import { ICON_SOURCE } from './dev-assets'; import { checkDrift } from './drift'; import { msg } from './messages'; import type { CommandResult, Finding } from './output'; @@ -39,7 +40,6 @@ const finding = (code: string, cause: string, fix: string, at?: string): Finding ? { code, cause, fix, docs: docs(code) } : { code, cause, fix, docs: docs(code), at }; -export const ICON_SOURCE = 'apps/web/site/icon.svg'; export const OFFLINE_FALLBACK = 'apps/web/app/offline.tsx'; /** @@ -106,7 +106,10 @@ export async function runDoctor(probe: DoctorProbe): Promise finding( 'X_PWA_ICON_MISSING', `${ICON_SOURCE} is missing, so install icons and og images cannot be generated`, - `add a 1024px square ${ICON_SOURCE}, then run x manifest`, + // An edit naming the file, in `@ultimat3/pwa`'s own words (`requireSourceIcon`). Not + // `x new`: it takes an app name and refuses to run inside the app that is missing the icon, + // so offering it here hands the reader a command that cannot work where they are standing. + `add a 1024x1024 square PNG at ${ICON_SOURCE}`, ICON_SOURCE, ), ); diff --git a/packages/cli/src/cmd-generate.ts b/packages/cli/src/cmd-generate.ts index ad946ae3..291dfac8 100644 --- a/packages/cli/src/cmd-generate.ts +++ b/packages/cli/src/cmd-generate.ts @@ -221,9 +221,14 @@ export function containedPath(root: string, path: string): string { * new keys are added — so a second, third… generator run keeps growing the same file instead of * fighting over it. A file that exists but does not parse as a JSON object cannot be merged into * without risking silent data loss, so that alone is reported rather than clobbered or thrown past. + * + * Typed to the `merge: 'json'` variant alone, not the general `GeneratedFile` union: a + * byte-carrying file has no `contents: string` to merge, and this is what stops one from ever + * reaching `parseJsonObject` even if a future caller forgets the `file.merge === 'json'` guard + * its one call site already applies. */ async function mergeJsonFile( - file: GeneratedFile, + file: Extract, absolute: string, ): Promise<{ written: boolean; conflict?: Finding }> { const generated = parseJsonObject(file.contents) ?? {}; diff --git a/packages/cli/src/cmd-new.test.ts b/packages/cli/src/cmd-new.test.ts new file mode 100644 index 00000000..468b0cd9 --- /dev/null +++ b/packages/cli/src/cmd-new.test.ts @@ -0,0 +1,66 @@ +// The scaffolded app icon is the one source `@ultimat3/pwa` derives every install icon from. It +// has to be bytes `@ultimat3/core`'s image pipeline can actually decode — the pipeline reads PNG +// and JPEG only — so these tests pin the shape a silent regression would otherwise break quietly: +// an app that scaffolds with an icon nothing can ever turn into `/icons/icon-192.png`. + +import { describe, expect, test } from 'bun:test'; +import { decodeImage, probeImage } from '@ultimat3/core'; +import { BuiltinImagePipeline } from '@ultimat3/pwa'; +import { planNewApp } from './cmd-new'; +import { icon } from './templates/scaffold-icon'; + +/** The scaffolded bytes, proven to be bytes — `contents` is `string | Uint8Array`. */ +function iconBytes(): Uint8Array { + const file = planNewApp({ name: 'demo-app', example: false }).find( + (candidate) => candidate.path === 'apps/web/site/icon.png', + ); + expect(file).toBeDefined(); + const contents = file?.contents; + expect(contents).toBeInstanceOf(Uint8Array); + return contents instanceof Uint8Array ? contents : new Uint8Array(); +} + +describe('unit · x new · scaffolded icon', () => { + test('emits apps/web/site/icon.png, never the old icon.svg', () => { + const paths = planNewApp({ name: 'demo-app', example: false }).map((file) => file.path); + expect(paths).toContain('apps/web/site/icon.png'); + expect(paths).not.toContain('apps/web/site/icon.svg'); + }); + + // The load-bearing assertion: this is what proves the source icon is decodable by the pipeline + // that @ultimat3/pwa feeds it through — a byte-for-byte guarantee `.svg` could never make. + test('the icon is a real, pipeline-decodable 1024x1024 PNG', () => { + const info = probeImage(iconBytes()); + expect(info.format).toBe('png'); + expect(info.width).toBe(1024); + expect(info.height).toBe(1024); + }); + + // The end of the chain: scaffolded source -> pwa's pipeline -> the exact PNG the generated web + // manifest names. A source the pipeline cannot decode fails here, not on an install nobody watches. + test('the icon bytes survive a BuiltinImagePipeline resize to 192x192', async () => { + const png = await new BuiltinImagePipeline().resize(iconBytes(), { size: 192, padding: 0.1 }); + expect(probeImage(png)).toMatchObject({ format: 'png', width: 192, height: 192 }); + }); + + test('icon() is deterministic: the same bytes on every call', () => { + expect(icon()).toEqual(icon()); + }); + + // Enforced rather than commented (axiom 3). The CLI cannot reach `@ultimat3/ui`'s colour roles — + // both are tier 5 — so the one honest placeholder is no colour at all: a grey level on all three + // channels. A palette value pasted in here fails this test instead of surviving to a review. + test('the mark is greyscale on a transparent canvas — no palette value to drift from', () => { + const raster = decodeImage(iconBytes()); + const at = (x: number, y: number): readonly number[] => { + const i = (y * raster.width + x) * 4; + return [...raster.pixels.slice(i, i + 4)]; + }; + + const [r, g, b, a] = at(raster.width / 2, raster.height / 2); + expect([g, b]).toEqual([r, r]); + expect(a).toBe(255); + // The maskable safe zone stops short of the edge, so the corner is canvas, not mark. + expect(at(0, 0)[3]).toBe(0); + }); +}); diff --git a/packages/cli/src/cmd-verify.test.ts b/packages/cli/src/cmd-verify.test.ts index 64f14b93..e5f02503 100644 --- a/packages/cli/src/cmd-verify.test.ts +++ b/packages/cli/src/cmd-verify.test.ts @@ -216,6 +216,10 @@ describe('unit · x verify', () => { name: 'boundaries', summary: 'imports', run: async () => { + // Deliberately a bare Error, and the only shape that tests this: the subject is a step + // that fails with something the framework never coded — a transpiler, a driver, an OOM. + // Coding it here would assert that `runVerify` re-reports codes, which is a different + // claim than "an unstructured throw still lands as X_VERIFY_FAILED with a fix". throw new Error('transpiler exploded'); }, }, diff --git a/packages/cli/src/dev-assets.test.ts b/packages/cli/src/dev-assets.test.ts new file mode 100644 index 00000000..9a41621f --- /dev/null +++ b/packages/cli/src/dev-assets.test.ts @@ -0,0 +1,154 @@ +// The image pipeline is only shipped if a running app answers with bytes. These tests drive the +// routes `x dev` mounts, not the functions behind them: the icon a generated web manifest names, +// and the exact `srcset` URL `@ultimat3/seo` mints — because a variant contract that is declared +// in two packages and answered by neither is what this whole path exists to close. + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +// `node:` by necessity: Bun has no temp-directory, no mkdtemp and no recursive remove — and each +// case needs its own root, or a leftover `apps/web/site/icon.png` decides the next one's answer. +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRaster, encodeImage, probeImage } from '@ultimat3/core'; +import type { Route } from '@ultimat3/http'; +import { createRequestContext, defineHttpConfig, UltimateRequest } from '@ultimat3/http'; +import { responsiveImage } from '@ultimat3/seo'; +import type { Storage } from '@ultimat3/storage'; +import { defineStorage, localDriver, variantKey } from '@ultimat3/storage'; +import { assetRoutes, ICON_SOURCE, MEDIA_BASE_PATH } from './dev-assets'; + +const SOURCE_KEY = 'covers/hero.png'; + +/** + * A real PNG, so the pipeline decodes rather than refuses — the point of the whole change. The + * raster keeps `createRaster`'s own bytes: every assertion below is about format and size, so + * painting channel values would only claim a colour the tests never read. + */ +function png(width: number, height: number): Uint8Array { + return encodeImage(createRaster(width, height, 'fixture'), 'png'); +} + +let root = ''; +let storage: Storage; + +const call = async (routes: readonly Route[], path: string): Promise => { + const url = new URL(`http://dev.test${path}`); + const route = routes.find((candidate) => matches(candidate.path, url.pathname)); + expect(route).toBeDefined(); + if (route === undefined) return new Response(null, { status: 404 }); + const config = defineHttpConfig({}); + const ctx = createRequestContext({ url, method: 'GET', role: 'web', config }); + ctx.params = params(route.path, url.pathname); + return route.handler(new UltimateRequest(new Request(url), ctx), ctx); +}; + +/** The trie is `@ultimat3/http`'s; this only needs to pick the same route it would. */ +function matches(pattern: string, pathname: string): boolean { + if (!pattern.includes('*')) return pattern === pathname; + return pathname.startsWith(`${pattern.slice(0, pattern.indexOf('*'))}`); +} + +function params(pattern: string, pathname: string): Record { + const star = pattern.indexOf('*'); + if (star === -1) return {}; + return { [pattern.slice(star + 1)]: pathname.slice(star) }; +} + +beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), 'x-assets-')); + storage = defineStorage({ disks: { local: localDriver({ root: join(root, '.storage') }) } }); + await storage.disk().put(SOURCE_KEY, png(1200, 600), { contentType: 'image/png' }); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('unit · dev assets · pwa icons', () => { + test('every icon the matrix declares is a mounted route', async () => { + const routes = assetRoutes({ root, storage }); + const paths = routes.map((route) => route.path); + expect(paths).toContain('/icons/icon-192.png'); + expect(paths).toContain('/icons/icon-maskable-512.png'); + expect(paths).toContain('/icons/apple-touch-icon.png'); + }); + + test('an icon route answers with a square PNG of that entry size', async () => { + await Bun.write(join(root, ICON_SOURCE), png(1024, 1024)); + const routes = assetRoutes({ root, storage }); + + const response = await call(routes, '/icons/icon-192.png'); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(response.headers.get('cache-control')).toContain('immutable'); + expect(probeImage(new Uint8Array(await response.arrayBuffer()))).toMatchObject({ + format: 'png', + width: 192, + height: 192, + }); + }); + + // The route stays mounted with no source behind it, so the answer is a coded refusal carrying a + // runnable fix — never a bare 404 an agent has to guess the meaning of. + test('a missing source icon is refused by code, not by a silent 404', async () => { + const routes = assetRoutes({ root, storage }); + expect(routes.map((route) => route.path)).toContain('/icons/icon-192.png'); + await expect(call(routes, '/icons/icon-192.png')).rejects.toBeUltimateError( + 'X_PWA_ICON_MISSING', + ); + }); +}); + +describe('unit · dev assets · responsive variants', () => { + test('no transform query serves the stored object untouched', async () => { + const routes = assetRoutes({ root, storage }); + const response = await call(routes, `${MEDIA_BASE_PATH}/${SOURCE_KEY}`); + expect(probeImage(new Uint8Array(await response.arrayBuffer()))).toMatchObject({ width: 1200 }); + }); + + test('?w= resizes and caches the variant under its storage key', async () => { + const routes = assetRoutes({ root, storage }); + const cached = variantKey(SOURCE_KEY, { width: 320, format: 'png' }); + expect(await storage.disk().exists(cached)).toBe(false); + + const response = await call(routes, `${MEDIA_BASE_PATH}/${SOURCE_KEY}?w=320&f=png`); + expect(probeImage(new Uint8Array(await response.arrayBuffer()))).toMatchObject({ + format: 'png', + width: 320, + }); + // Derived, not stored — but derived once. The second request is a disk read, not a decode. + expect(await storage.disk().exists(cached)).toBe(true); + const again = await call(routes, `${MEDIA_BASE_PATH}/${SOURCE_KEY}?w=320&f=png`); + expect(new Uint8Array(await again.arrayBuffer()).length).toBeGreaterThan(0); + }); + + test('the srcset URL seo mints is a URL this route answers', async () => { + const routes = assetRoutes({ root, storage }); + const image = responsiveImage({ + src: `${MEDIA_BASE_PATH}/${SOURCE_KEY}`, + width: 1200, + height: 600, + alt: 'hero', + }); + // Ascending widths; take the narrowest entry the markup actually promises. + const first = image.img.srcset.split(', ')[0] ?? ''; + const url = first.slice(0, first.lastIndexOf(' ')); + expect(url).toContain('w=320'); + + const response = await call(routes, url); + expect(probeImage(new Uint8Array(await response.arrayBuffer())).width).toBe(320); + }); + + test('a format core cannot encode is refused by the driver, not silently downgraded', async () => { + const routes = assetRoutes({ root, storage }); + await expect( + call(routes, `${MEDIA_BASE_PATH}/${SOURCE_KEY}?w=320&f=avif`), + ).rejects.toBeUltimateError('X_IMAGE_UNSUPPORTED'); + }); + + test('an unusable width is refused before any byte is decoded', async () => { + const routes = assetRoutes({ root, storage }); + await expect(call(routes, `${MEDIA_BASE_PATH}/${SOURCE_KEY}?w=0`)).rejects.toBeUltimateError( + 'X_IMAGE_QUERY_INVALID', + ); + }); +}); diff --git a/packages/cli/src/dev-assets.ts b/packages/cli/src/dev-assets.ts new file mode 100644 index 00000000..08197ae2 --- /dev/null +++ b/packages/cli/src/dev-assets.ts @@ -0,0 +1,177 @@ +// Projecting the framework's one image pipeline onto the routes `x dev` serves. Three packages +// declare what an image is — `@ultimat3/seo` a variant URL, `@ultimat3/storage` a variant key, +// `@ultimat3/pwa` the icons a web manifest promises — and `@ultimat3/core`'s pipeline owns every +// pixel, so this file picks the two base paths they hang off and decides nothing else. + +// `join` is `node:`-only by necessity: Bun exposes no path-join primitive, and `ICON_SOURCE` is +// app-root-relative, so resolving it against the root is string work no `Bun.file` overload does. +import { join } from 'node:path'; +import { probeImage } from '@ultimat3/core'; +import type { Route, UltimateRequest } from '@ultimat3/http'; +import { applyCacheHeaders } from '@ultimat3/http'; +import type { IconPlan } from '@ultimat3/pwa'; +import { BuiltinImagePipeline, PwaIconMissingError, planIcons } from '@ultimat3/pwa'; +import type { ImageQuery } from '@ultimat3/seo'; +import { builtinImageDriver, parseImageQuery } from '@ultimat3/seo'; +import type { ImageFormat, ImageTransform, Storage } from '@ultimat3/storage'; +import { IMAGE_FORMATS, variantKey } from '@ultimat3/storage'; + +/** + * The one source image every generated icon derives from. `x new` scaffolds it, `x doctor` checks + * it and this file reads it — one constant, because a second spelling is an app that passes the + * diagnostic and still serves no icons. PNG, not SVG: core's pipeline decodes PNG and JPEG only. + */ +export const ICON_SOURCE = 'apps/web/site/icon.png'; + +/** Where `planIcons` writes, and therefore the paths the generated web manifest names. */ +export const ICON_BASE_PATH = '/icons'; + +/** Storage-backed images. `responsiveImage({ src: '/media/' })` mints its variants under it. */ +export const MEDIA_BASE_PATH = '/media'; + +/** + * Variants are content-addressed by `variantKey`, so a URL that answers once answers forever with + * the same bytes — the immutable hint is a fact about the key, not an optimism about the source. + */ +const imageResponse = (bytes: Uint8Array, contentType: string): Response => + applyCacheHeaders( + // Copied, not passed through: a `Uint8Array` may be backed by a + // `SharedArrayBuffer`, which `Response` does not accept, and copying is what makes that true + // by construction rather than by a cast that would only silence it. + new Response(new Uint8Array(bytes), { headers: { 'content-type': contentType } }), + { mode: 'immutable' }, + ); + +const isImageFormat = (value: string): value is ImageFormat => + (IMAGE_FORMATS as readonly string[]).includes(value); + +/** + * `exactOptionalPropertyTypes` makes an explicit `undefined` a different answer from an absent + * key, and `variantKey` reads presence — so a spread, never an assignment. + */ +function storageTransform(query: ImageQuery, format: ImageFormat | undefined): ImageTransform { + return { + ...(query.width === undefined ? {} : { width: query.width }), + ...(format === undefined ? {} : { format }), + ...(query.quality === undefined ? {} : { quality: query.quality }), + }; +} + +/** + * A cache hit costs one `exists` and one `get`; a miss costs a decode. The source is read once + * either way and handed to the driver rather than fetched again — `read` exists so seo never has + * to guess whether a `src` is a path, a key or a URL, and here it is unambiguously a storage key. + */ +async function transformedVariant( + storage: Storage, + key: string, + query: ImageQuery, +): Promise { + const disk = storage.disk(); + // A format storage cannot name has no variant key, so it cannot be cached. The driver refuses + // it with core's `X_IMAGE_UNSUPPORTED`; refusing it here too would give one bad URL two codes. + const format = + query.format !== undefined && isImageFormat(query.format) ? query.format : undefined; + const cacheable = query.format === undefined || format !== undefined; + const cached = cacheable ? variantKey(key, storageTransform(query, format)) : undefined; + if (cached !== undefined && (await disk.exists(cached))) { + const hit = await disk.get(cached); + return imageResponse(hit.bytes, hit.object.contentType); + } + + const source = await disk.get(key); + const variant = await builtinImageDriver({ read: async () => source.bytes }).transform({ + src: key, + // A header read, not a decode: `?f=webp` alone still needs a width, and the source's own is + // the only one that does not resize an image the caller never asked to resize. + width: query.width ?? probeImage(source.bytes).width, + ...(query.format === undefined ? {} : { format: query.format }), + ...(query.quality === undefined ? {} : { quality: query.quality }), + }); + if (cached !== undefined) { + await disk.put(cached, variant.bytes, { contentType: variant.contentType }); + } + return imageResponse(variant.bytes, variant.contentType); +} + +async function mediaResponse(request: UltimateRequest, storage: Storage): Promise { + const key = request.params['key'] ?? ''; + const query = parseImageQuery(request.url.searchParams); + if (query !== null) return transformedVariant(storage, key, query); + // No transform asked for: the object itself, still under the storage key's own safety checks. + const read = await storage.disk().get(key); + return imageResponse(read.bytes, read.object.contentType); +} + +/** + * Rendered once per process, not per request: the fourteen matrix entries are pure functions of + * one source file, and re-encoding a 512px PNG on every hit would be work no caller can observe. + */ +function iconRenderer(root: string): (plan: IconPlan, path: string) => Promise { + const pipeline = new BuiltinImagePipeline(); + const rendered = new Map>(); + const sourceBytes = async (): Promise => { + const file = Bun.file(join(root, ICON_SOURCE)); + if (!(await file.exists())) { + throw new PwaIconMissingError( + `${ICON_SOURCE} does not exist, so every icon the web manifest declares is unbacked and ` + + 'the app is not installable', + // The same edit `x doctor` reports for the same condition, in `@ultimat3/pwa`'s own words. + // `x new` was here and takes an app name, so it could never run inside the broken app. + `add a 1024x1024 square PNG at ${ICON_SOURCE}`, + ); + } + return file.bytes(); + }; + return async (plan, path) => { + const entry = plan.entries.find((candidate) => candidate.outputPath === path); + if (entry === undefined) { + throw new PwaIconMissingError( + `${path} is not in the icon matrix, so no transform describes it`, + `request one of ${plan.entries.map((one) => one.outputPath).join(', ')}`, + ); + } + const existing = rendered.get(path); + if (existing !== undefined) return existing; + const bytes = sourceBytes().then((source) => pipeline.resize(source, entry.transform)); + rendered.set(path, bytes); + // A failed render must not be remembered — the next request comes after the source was added. + bytes.catch(() => rendered.delete(path)); + return bytes; + }; +} + +export interface AssetRoutesOptions { + /** App root. The source icon is resolved against it; storage keys never are. */ + readonly root: string; + readonly storage: Storage; +} + +/** + * The icon routes mount whether or not the source exists, and a missing source is refused on the + * wire with `X_PWA_ICON_MISSING` and its fix — a route that silently disappears is a 404 whose + * meaning an agent has to guess. Deliberately NOT a boot finding: `x doctor` already reports this + * exact condition with this exact code, and a second reporter of one condition is the duplication + * this package's own rule forbids. `x dev` owns the runtime half, the diagnostic owns the other. + */ +export function assetRoutes(options: AssetRoutesOptions): readonly Route[] { + const plan = planIcons({ sourceIcon: ICON_SOURCE, outDir: ICON_BASE_PATH }); + const render = iconRenderer(options.root); + + const routes: Route[] = plan.entries.map((entry) => ({ + method: 'GET', + path: entry.outputPath, + meta: { name: `assets.icon.${entry.spec.filename}`, auth: 'public', tags: ['assets'] }, + handler: async (request: UltimateRequest): Promise => + imageResponse(await render(plan, request.pathname), 'image/png'), + })); + routes.push({ + method: 'GET', + path: `${MEDIA_BASE_PATH}/*key`, + meta: { name: 'assets.media', auth: 'public', tags: ['assets'] }, + handler: async (request: UltimateRequest): Promise => + mediaResponse(request, options.storage), + }); + + return routes; +} diff --git a/packages/cli/src/dev-dashboard.test.ts b/packages/cli/src/dev-dashboard.test.ts index 89318ea9..45ce6185 100644 --- a/packages/cli/src/dev-dashboard.test.ts +++ b/packages/cli/src/dev-dashboard.test.ts @@ -10,7 +10,7 @@ import type { DevPanel } from '@ultimat3/admin/dev'; import { DEV_PANELS, panelPayload, staticDevSources, timelinePanel } from '@ultimat3/admin/dev'; import { declareTags, invalidateTags, tag } from '@ultimat3/cache'; import { configureTelemetry, resetTelemetry, withSpan } from '@ultimat3/core'; -import type { MailMessage, SendResult, SentMail } from '@ultimat3/mail'; +import type { MailMessage, MemoryMailDriver, SendResult, SentMail } from '@ultimat3/mail'; import { appManifest, writeAppManifest } from './app-manifest'; import type { DevDashboardInput, DevStatus } from './dev-dashboard'; import { devDashboardRoutes, devPanels, devSources } from './dev-dashboard'; @@ -59,6 +59,39 @@ interface FakeRuntime { readonly outbox?: readonly SentMail[]; /** Every statement the dashboard sent, so a test can prove it was not rewritten. */ readonly seen?: string[]; + /** A credential-selected transport, which retains nothing and so has no outbox to project. */ + readonly transport?: 'smtp' | 'resend'; +} + +/** + * A caught outbox with the fixture's own ids and timestamps. Not `createMemoryDriver()` fed through + * `send()`: that mints a `mem_` and a `new Date()`, and the projection those two fields land + * in is exactly what the case below asserts. Every member `MemoryMailDriver` declares is real, + * because `isMemoryDriver` checks all of them — a look-alike carrying `name` and `outbox()` alone + * makes `sent`, `lastTo()` and `clear()` a promise the object cannot keep, and the panel degrades + * to its refusal instead of projecting anything. + */ +function caughtOutbox(fixture: readonly SentMail[]): MemoryMailDriver { + const sent: SentMail[] = [...fixture]; + return { + name: 'memory', + sent, + // The panel narrows on the driver and reads `outbox()`; nothing here delivers. Coded for + // `panelFor`'s reason: a throw with no code and no fix is not an instruction. + send: (): Promise => + Promise.reject( + new CliNotImplementedError({ + feature: 'sending through the caught-outbox fixture', + fix: 'x dev # boots the memory driver that does catch mail', + }), + ), + // Fixtures are written newest-first, the order the real driver hands back. + outbox: () => sent, + lastTo: (address) => sent.find((entry) => entry.message.to.includes(address)), + clear: () => { + sent.length = 0; + }, + }; } /** Only the two members the hooks touch; a PGlite boot proves nothing about the projection. */ @@ -70,7 +103,25 @@ const fakeRuntime = (fake: FakeRuntime = {}): RunningServices => return Promise.resolve(fake.rows ?? []); }, }, - mail: { outbox: (): readonly SentMail[] => fake.outbox ?? [] }, + // `name` is load-bearing, not decoration: `isMemoryDriver` narrows on it before the panel + // reads an outbox, which is the whole reason a real transport degrades instead of throwing. + mail: + fake.transport === undefined + ? caughtOutbox(fake.outbox ?? []) + : { + name: fake.transport, + // `send` exists only to satisfy `MailDriver` — the panel narrows on `name` and never + // calls it. Coded even so, for `panelFor`'s reason: a throw with no code and no fix is + // not an instruction to whoever does reach it. + send: (): Promise => + Promise.reject( + new CliNotImplementedError({ + feature: `sending through the ${fake.transport} fixture transport`, + fix: 'x dev # boots the transport the credential selects, which does send', + }), + ), + }, + mailDetail: fake.transport === undefined ? 'caught in memory' : 'SMTP_URL', }) as unknown as RunningServices; const inputFor = ( @@ -213,6 +264,16 @@ describe('unit · x dev mounts the dashboard', () => { ]); }); + // An empty outbox would read as "nothing was mailed". The messages went to the provider, so + // the only honest answer is that this process cannot see them. + test('a credential-selected transport refuses the mail source instead of claiming an empty outbox', async () => { + const caught = (await devSources(inputFor({ transport: 'smtp' })) + .mail() + .catch((error: unknown) => error)) as { code?: string; cause?: string }; + expect(caught.code).toBe('X_NOT_IMPLEMENTED'); + expect(caught.cause).toContain('mail'); + }); + test('a host that installed no exporter answers with the wiring line, not an empty timeline', async () => { const payload = await panelPayload( timelinePanel, diff --git a/packages/cli/src/dev-dashboard.ts b/packages/cli/src/dev-dashboard.ts index 3b06b607..afda15e6 100644 --- a/packages/cli/src/dev-dashboard.ts +++ b/packages/cli/src/dev-dashboard.ts @@ -18,6 +18,8 @@ import { recentInvalidations } from '@ultimat3/cache'; import type { Role } from '@ultimat3/core'; import type { Route, UltimateRequest } from '@ultimat3/http'; import { json as jsonResponse } from '@ultimat3/http'; +import type { MemoryMailDriver } from '@ultimat3/mail'; +import { isMemoryDriver } from '@ultimat3/mail'; import type { Manifest } from '@ultimat3/manifest'; import { checkAppBoundaries } from './app-boundaries'; import { appManifest, readAppManifest } from './app-manifest'; @@ -65,8 +67,8 @@ async function runSql(input: DevDashboardInput, sql: string): Promise } /** `MailMessage.locale` is non-optional in `@ultimat3/mail`, so the panel never has to guess. */ -function mailFacts(input: DevDashboardInput): readonly MailFact[] { - return input.runtime.mail.outbox().map((entry) => ({ +function mailFacts(outbox: MemoryMailDriver): readonly MailFact[] { + return outbox.outbox().map((entry) => ({ id: entry.result.id, to: entry.message.to.join(', '), subject: entry.message.subject, @@ -125,10 +127,16 @@ const invalidationFacts = (): readonly InvalidationFact[] => */ export function devSources(input: DevDashboardInput): DevSources { const traces = input.traces; + // Only the memory driver retains what it accepted. Once a credential selects a real transport + // the messages are at the provider, so the hook is omitted rather than answered with `[]` — + // an empty outbox claims nobody was mailed, which is a different and unearned answer. + const outbox = isMemoryDriver(input.runtime.mail) ? input.runtime.mail : undefined; return defaultDevSources({ hooks: { runSql: (sql: string): Promise => runSql(input, sql), - mail: (): Promise => Promise.resolve(mailFacts(input)), + ...(outbox === undefined + ? {} + : { mail: (): Promise => Promise.resolve(mailFacts(outbox)) }), manifest: (): Promise => manifestFact(input.root), invalidations: (): Promise => Promise.resolve(invalidationFacts()), diff --git a/packages/cli/src/dev-hooks.test.ts b/packages/cli/src/dev-hooks.test.ts index af442b0f..6d1c64df 100644 --- a/packages/cli/src/dev-hooks.test.ts +++ b/packages/cli/src/dev-hooks.test.ts @@ -18,6 +18,7 @@ import { import { clearRoutes, defineRoute, registerRoute } from '@ultimat3/render'; import { devHooks } from './dev-hooks'; import { appRoutes } from './dev-render'; +import { CliNotImplementedError } from './errors'; const context = (path: string): RequestContext => createRequestContext({ @@ -30,7 +31,13 @@ const context = (path: string): RequestContext => /** The hook takes a request only to pass it to app code; nothing in `authorize` reads it. */ const decide = async (route: Route, ctx: RequestContext): Promise => { const authorize = devHooks().authorize; - if (authorize === undefined) throw new Error('x dev must wire an authorizer'); + // Never a bare Error, tests included: a throw without a code and a fix is not an instruction. + if (authorize === undefined) { + throw new CliNotImplementedError({ + feature: 'an authorize hook on devHooks()', + fix: 'return authorize from devHooks() in packages/cli/src/dev-hooks.ts', + }); + } return authorize(route, undefined as unknown as UltimateRequest, ctx); }; @@ -94,7 +101,12 @@ describe('unit · x dev authorizes from the app’s own policies', () => { }), }); const route = appRoutes({ buildId: 'test' })[0]; - if (route === undefined) throw new Error('the settings route did not register'); + if (route === undefined) { + throw new CliNotImplementedError({ + feature: 'a route table for the registered settings page', + fix: 'x routes --json # every route registerRoute() holds', + }); + } expect((await decide(route, context('/settings'))).allowed).toBe(false); }); diff --git a/packages/cli/src/dev-runtime.test.ts b/packages/cli/src/dev-runtime.test.ts new file mode 100644 index 00000000..e24e0caa --- /dev/null +++ b/packages/cli/src/dev-runtime.test.ts @@ -0,0 +1,286 @@ +// The mail and CDN seams of the dev/production boot: which transport and which edge a process +// installs, and how it says so. The other services are covered by `cmd-dev.test.ts`, which boots +// them for real. + +import { afterEach, describe, expect, test } from 'bun:test'; +// `node:` by necessity: Bun has no temp-directory, no mkdtemp and no recursive remove, and every +// boot below needs a state directory of its own — a shared one would hand the next case a PGlite +// data dir the previous one locked. +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { PurgeDriver } from '@ultimat3/cache'; +import { noopPurgeDriver, registeredTiers, resetTiers } from '@ultimat3/cache'; +import { jobDriver } from '@ultimat3/jobs'; +import type { MailDriver } from '@ultimat3/mail'; +import { createMemoryDriver, tryMailDriver } from '@ultimat3/mail'; +import { TransportUnavailableError } from '@ultimat3/realtime'; +import { + cdnLabel, + describeCdn, + describeMail, + mailLabel, + type RunningServices, + startServices, +} from './dev-runtime'; +import type { DevServices } from './dev-services'; +import { resolveServices } from './dev-services'; +import { CliNotImplementedError } from './errors'; + +const runtimeWith = (mail: MailDriver, mailDetail: string): RunningServices => + ({ mail, mailDetail }) as unknown as RunningServices; + +const cdnRuntimeWith = (purge: PurgeDriver, purgeDetail: string): RunningServices => + ({ purge, purgeDetail }) as unknown as RunningServices; + +/** + * `describeMail` reads `name` and `mailDetail`, never `send` — so this one exists only to satisfy + * `MailDriver`, and it refuses with a code carrying a runnable fix. Never a bare Error, tests + * included: a throw without a code and a fix is not an instruction to whoever reaches it. + */ +const fakeSmtp = (): MailDriver => ({ + name: 'smtp', + send: (): Promise => + Promise.reject( + new CliNotImplementedError({ + feature: 'sending through the describeMail fixture transport', + fix: 'x dev # boots the transport SMTP_URL selects, which does send', + }), + ), +}); + +/** Every embedded Postgres directory a boot created, removed once the process is done with it. */ +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('describeMail', () => { + test('a caught outbox reports as embedded, like the other bindings', () => { + expect(describeMail(runtimeWith(createMemoryDriver(), 'caught in memory'))).toBe( + 'mail=embedded', + ); + }); + + test('a real transport names itself and the env key that selected it', () => { + expect(describeMail(runtimeWith(fakeSmtp(), 'SMTP_URL'))).toBe( + 'mail=external(smtp via SMTP_URL)', + ); + }); + + // The boot line is printed, logged and scraped. `SMTP_URL` holds a password, so the detail is + // the key's name and never its value — this is the assertion that keeps it that way. + test('the report carries the env key, never the credential behind it', () => { + expect(describeMail(runtimeWith(fakeSmtp(), 'SMTP_URL'))).not.toContain('@'); + }); +}); + +/** + * The status value `--json` carries and the label the boot line prints are two surfaces of one + * fact, and `wiki/Configuration.md` quotes both — so a catalog edit that moves the printed line + * without moving the documented status has to fail here rather than in a script that parses it. + */ +describe('the rendered label and the machine status', () => { + test('agree for every mail case', () => { + const memory = runtimeWith(createMemoryDriver(), 'caught in memory'); + expect(mailLabel(memory)).toBe(describeMail(memory)); + const smtp = runtimeWith(fakeSmtp(), 'SMTP_URL'); + expect(mailLabel(smtp)).toBe(describeMail(smtp)); + }); + + test('agree for every cdn case', () => { + const none = cdnRuntimeWith(noopPurgeDriver(), 'no edge'); + expect(cdnLabel(none)).toBe(describeCdn(none)); + const fastly = cdnRuntimeWith( + { name: 'fastly', purge: () => Promise.resolve([]), purgeAll: () => Promise.resolve() }, + 'FASTLY_API_TOKEN', + ); + expect(cdnLabel(fastly)).toBe(describeCdn(fastly)); + }); +}); + +describe('describeCdn', () => { + // There is no embedded CDN. Reporting one would read as a fifth service this boot started. + test('no credential reports no edge, not an embedded one', () => { + expect(describeCdn(cdnRuntimeWith(noopPurgeDriver(), 'no edge'))).toBe('cdn=none'); + }); + + test('a real driver names itself and the env key that selected it', () => { + const fastly: PurgeDriver = { + name: 'fastly', + purge: () => Promise.resolve([]), + purgeAll: () => Promise.resolve(), + }; + expect(describeCdn(cdnRuntimeWith(fastly, 'FASTLY_API_TOKEN'))).toBe( + 'cdn=external(fastly via FASTLY_API_TOKEN)', + ); + }); +}); + +describe('startServices', () => { + /** + * Selection runs before the queue, so a bad credential rejects without booting PGlite. That + * ordering is the assertion: this test hands `startServices` a `DevServices` with no usable + * state directory, and it must still reject on the environment alone. If the env stopped being + * threaded through, or selection moved after `startQueue`, this would boot instead of throwing. + */ + test('refuses two credentials before any service starts', async () => { + const unusable = { stateDir: '/nonexistent/x-dev-should-never-be-read' } as DevServices; + const failure = await startServices(unusable, { + SMTP_URL: 'smtps://user:pass@mail.test:465', + RESEND_API_KEY: 're_test_key', + MAIL_FROM: 'Postly ', + }).then( + () => undefined, + (error: unknown) => error as { code?: string; cause?: string }, + ); + + expect(failure?.code).toBe('X_CONFIG_INVALID'); + expect(failure?.cause).toContain('both set'); + }); + + /** + * The whole point of the task: a credential in the environment makes `mailDriver()` a real + * transport, so `send()` reaches a server instead of an outbox nobody drains. Booted for real + * because the ambient install is the thing under test — a fake `DevServices` would prove that + * `selectMailDriver` returns an object, which is already covered in `@ultimat3/mail`. + */ + test( + 'a credential in the environment installs the transport as the ambient driver', + async () => { + const root = mkdtempSync(join(tmpdir(), 'x-mail-boot-')); + roots.push(root); + const runtime = await startServices(resolveServices(root, {}), { + SMTP_URL: 'smtps://user:pass@mail.postly.test:465', + MAIL_FROM: 'Postly ', + }); + try { + // The ambient accessor, not the return value: `send()` resolves the driver through this. + expect(tryMailDriver()?.name).toBe('smtp'); + expect(runtime.mail.name).toBe('smtp'); + expect(describeMail(runtime)).toBe('mail=external(smtp via SMTP_URL)'); + } finally { + await runtime.stop(); + } + // Released on stop, so the next process does not inherit a transport it never configured. + expect(tryMailDriver()).toBeUndefined(); + }, + { timeout: 60_000 }, + ); + + test( + 'no credential leaves the caught outbox in place', + async () => { + const root = mkdtempSync(join(tmpdir(), 'x-mail-boot-')); + roots.push(root); + const runtime = await startServices(resolveServices(root, {}), {}); + try { + expect(tryMailDriver()?.name).toBe('memory'); + expect(describeMail(runtime)).toBe('mail=embedded'); + } finally { + await runtime.stop(); + } + }, + { timeout: 60_000 }, + ); + + /** + * The CDN leg of `invalidates: [tag.post]`: without this registration the purge drivers are + * code nothing can reach, and a bust that should have cleared the edge reports four tiers and + * no fifth. Booted for real, because the registry is process-global and the install is the + * thing under test. + */ + test( + 'a CDN credential registers the cdn tier, and stopping releases it', + async () => { + const root = mkdtempSync(join(tmpdir(), 'x-cdn-boot-')); + roots.push(root); + resetTiers(); + const runtime = await startServices(resolveServices(root, {}), { + FASTLY_API_TOKEN: 'fastly-token', + FASTLY_SERVICE_ID: 'svc_1', + }); + try { + expect(registeredTiers().map((tier) => tier.name)).toEqual(['cdn']); + expect(runtime.purge.name).toBe('fastly'); + expect(describeCdn(runtime)).toBe('cdn=external(fastly via FASTLY_API_TOKEN)'); + } finally { + await runtime.stop(); + } + expect(registeredTiers()).toHaveLength(0); + }, + { timeout: 60_000 }, + ); + + /** + * A noop tier would put a `cdn` line in every invalidation report — keys accepted by an edge + * that does not exist — and the `/_x` cache panel renders those reports verbatim. + */ + test( + 'no CDN credential registers no cdn tier at all', + async () => { + const root = mkdtempSync(join(tmpdir(), 'x-cdn-boot-')); + roots.push(root); + resetTiers(); + const runtime = await startServices(resolveServices(root, {}), {}); + try { + expect(registeredTiers()).toHaveLength(0); + expect(describeCdn(runtime)).toBe('cdn=none'); + } finally { + await runtime.stop(); + } + }, + { timeout: 60_000 }, + ); + + /** + * The leak this pins: `stop()` awaited `transport.close()` and returned on its rejection, so + * `resetTiers()`, `resetMailDriver()` and `queue.stop()` never ran — and the next boot in this + * process inherited a CDN tier purging for a stopped server, an ambient mail driver over a dead + * transport, and a queue nobody owns. Every release must run; the FIRST failure is what surfaces, + * because a shutdown that reports the cleanup it did after the real fault buries the fault. + */ + test( + 'a transport that will not close still releases the tier, the mail driver and the queue', + async () => { + const root = mkdtempSync(join(tmpdir(), 'x-stop-boot-')); + roots.push(root); + resetTiers(); + const runtime = await startServices(resolveServices(root, {}), { + SMTP_URL: 'smtps://user:pass@mail.postly.test:465', + MAIL_FROM: 'Postly ', + FASTLY_API_TOKEN: 'fastly-token', + FASTLY_SERVICE_ID: 'svc_1', + }); + expect(registeredTiers()).toHaveLength(1); + expect(tryMailDriver()?.name).toBe('smtp'); + expect(jobDriver()).toBeDefined(); + // The only thing this case changes: a bus that is already gone by the time shutdown asks. + runtime.transport.close = (): Promise => + Promise.reject( + new TransportUnavailableError({ transport: 'inproc', reason: 'closed by the fixture' }), + ); + + await expect(runtime.stop()).rejects.toBeUltimateError('X_TRANSPORT_UNAVAILABLE'); + + // The three releases the rejection used to skip, each read back through the accessor a later + // boot would inherit — asserting `stop()` rejected proves nothing about what it released. + expect(registeredTiers()).toHaveLength(0); + expect(tryMailDriver()).toBeUndefined(); + expect(jobDriver()).toBeUndefined(); + }, + { timeout: 60_000 }, + ); + + test('a half-set CDN pair refuses before any service starts', async () => { + const unusable = { stateDir: '/nonexistent/x-dev-should-never-be-read' } as DevServices; + const failure = await startServices(unusable, { FASTLY_API_TOKEN: 'fastly-token' }).then( + () => undefined, + (error: unknown) => error as { code?: string; cause?: string }, + ); + + expect(failure?.code).toBe('X_CONFIG_INVALID'); + expect(failure?.cause).toContain('FASTLY_SERVICE_ID'); + }); +}); diff --git a/packages/cli/src/dev-runtime.ts b/packages/cli/src/dev-runtime.ts index 77e659e1..534dfbca 100644 --- a/packages/cli/src/dev-runtime.ts +++ b/packages/cli/src/dev-runtime.ts @@ -5,17 +5,26 @@ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; +import type { PurgeDriver } from '@ultimat3/cache'; +import { + createCdnTier, + isNoopPurgeDriver, + registerTier, + resetTiers, + selectPurgeDriver, +} from '@ultimat3/cache'; import type { EventBus, JobDriver } from '@ultimat3/jobs'; import { createMemoryEventBus, setEventBus } from '@ultimat3/jobs'; -import type { MemoryMailDriver } from '@ultimat3/mail'; -import { createMemoryDriver, resetMailDriver, setMailDriver } from '@ultimat3/mail'; +import type { MailDriver } from '@ultimat3/mail'; +import { isMemoryDriver, resetMailDriver, selectMailDriver, setMailDriver } from '@ultimat3/mail'; import type { Transport } from '@ultimat3/realtime'; import { InProcessTransport, NatsTransport } from '@ultimat3/realtime'; import type { Storage } from '@ultimat3/storage'; import { defineStorage, localDriver } from '@ultimat3/storage'; import type { DevDbClient } from './dev-queue'; import { startQueue } from './dev-queue'; -import type { DevServices } from './dev-services'; +import type { DevServices, Env } from './dev-services'; +import { msg } from './messages'; export interface RunningServices { readonly services: DevServices; @@ -24,10 +33,61 @@ export interface RunningServices { readonly events: EventBus; readonly transport: Transport; readonly storage: Storage; - readonly mail: MemoryMailDriver; + readonly mail: MailDriver; + /** + * Which env key selected the transport, or why nothing was selected. The credential itself is + * never carried: `SMTP_URL` holds a password, and this string reaches the boot line and `--json`. + */ + readonly mailDetail: string; + readonly purge: PurgeDriver; + /** Same rule as `mailDetail`: the env key that selected the CDN, never the token behind it. */ + readonly purgeDetail: string; stop(): Promise; } +/** + * `mail=embedded` is the honest report for a process that caught the message instead of sending + * it — the same vocabulary the other three bindings use, so an operator reading a boot line sees + * at a glance that this replica delivers nothing. This is the machine half: `x dev --json` carries + * it verbatim and `wiki/Configuration.md` documents it, so it is a fixed status value and NOT a + * catalog lookup — a translated boot line must never move a field a script parses. `mailLabel` is + * the human half. + */ +export function describeMail(runtime: RunningServices): string { + return isMemoryDriver(runtime.mail) + ? 'mail=embedded' + : `mail=external(${runtime.mail.name} via ${runtime.mailDetail})`; +} + +/** + * `cdn=none` rather than `cdn=embedded`: there is no embedded CDN, and a process with no edge in + * front of it purges nothing. Saying "embedded" would read as a fifth service this boot started. + * Machine half, same rule as `describeMail`; `cdnLabel` is what a human reads. + */ +export function describeCdn(runtime: RunningServices): string { + return isNoopPurgeDriver(runtime.purge) + ? 'cdn=none' + : `cdn=external(${runtime.purge.name} via ${runtime.purgeDetail})`; +} + +/** + * The boot line's mail label. Same fact as `describeMail`, through the catalog, because this string + * is rendered to a person and every rendered string in the CLI is a `messages.ts` key — the status + * value stays where `--json` can depend on it. + */ +export function mailLabel(runtime: RunningServices): string { + return isMemoryDriver(runtime.mail) + ? msg('cli.dev.mail.embedded') + : msg('cli.dev.mail.external', { driver: runtime.mail.name, detail: runtime.mailDetail }); +} + +/** The boot line's CDN label, for the reason `mailLabel` gives. */ +export function cdnLabel(runtime: RunningServices): string { + return isNoopPurgeDriver(runtime.purge) + ? msg('cli.dev.cdn.none') + : msg('cli.dev.cdn.external', { driver: runtime.purge.name, detail: runtime.purgeDetail }); +} + const FILE_SCHEME = 'file://'; function startStorage(services: DevServices): Storage { @@ -53,18 +113,32 @@ async function startTransport(services: DevServices): Promise { return transport; } -/** Undo what has already started, newest first. A failure here must not hide the boot failure. */ -async function unwind(steps: readonly (() => void | Promise)[]): Promise { +/** + * Release what has already started, newest first, and return every failure instead of throwing on + * the first: a step that rejects must not skip the ones after it, or one transport that will not + * close strands the CDN tier, the ambient mail driver and the queue in the next boot of this + * process. The two callers differ only in what they do with the failures. + */ +async function release(steps: readonly (() => void | Promise)[]): Promise { + const failures: unknown[] = []; for (const step of [...steps].reverse()) { try { await step(); - } catch { - // The rejection that started the unwind is the one worth reporting; this one is noise. + } catch (error) { + failures.push(error); } } + return failures; } -export async function startServices(services: DevServices): Promise { +export async function startServices(services: DevServices, env: Env): Promise { + // Before the queue: selection is pure — it parses `SMTP_URL` and builds a transport, it does + // not dial. A typo'd credential must fail on the spot rather than after PGlite has started and + // been unwound again, and it must fail at boot rather than on the first mail nobody receives. + const selection = selectMailDriver(env); + // Same reason, same place: building a purge driver reads env and dials nothing, so a half-set + // `FASTLY_API_TOKEN` without its service id fails here rather than on the first stale page. + const cdn = selectPurgeDriver(env); const queue = await startQueue(services); const { db, jobs } = queue; // Boot is a sequence of external resources, and every step after the first can reject — the @@ -76,12 +150,23 @@ export async function startServices(services: DevServices): Promise transport.close()); const storage = startStorage(services); - // Caught, not sent: the `/_x` mail panel reads this outbox, so the local loop can check what a - // template renders in every locale without a mailbox, an API key, or a message escaping to a - // real address. - const mail = createMemoryDriver(); + // With no credential this is the memory driver: caught, not sent, so the `/_x` mail panel can + // show what a template renders in every locale without a mailbox or a message escaping to a + // real address. `SMTP_URL` or `RESEND_API_KEY` makes it a real transport instead — the same + // "an unset variable means the embedded default" law the other three bindings follow. + const mail = selection.driver; setMailDriver(mail); started.push(() => resetMailDriver()); + // Registered only when a credential named a real edge. A noop tier would put a `cdn` line in + // every invalidation report claiming keys an edge that does not exist had accepted — and the + // `/_x` cache panel renders those reports, so the lie would be the thing an agent reads. + // Released with `resetTiers()`, which drops the whole registry: this boot is the only thing + // that registers one, and a tier left behind would purge for a process that has stopped. + const purging = !isNoopPurgeDriver(cdn.driver); + if (purging) { + registerTier(createCdnTier({ purge: cdn.driver })); + started.push(() => resetTiers()); + } return { services, @@ -91,16 +176,23 @@ export async function startServices(services: DevServices): Promise 0) throw failures[0]; }, }; } catch (error) { - await unwind(started); + // The rejection that started the unwind is the one worth reporting; a cleanup failure under it + // is noise, so these are collected and dropped rather than allowed to replace the cause. + await release(started); throw error; } } diff --git a/packages/cli/src/error-catalog.test.ts b/packages/cli/src/error-catalog.test.ts index 506282a2..c6135769 100644 --- a/packages/cli/src/error-catalog.test.ts +++ b/packages/cli/src/error-catalog.test.ts @@ -109,6 +109,9 @@ describe('unit · a package that will not load', () => { ]); }); + // The bare Errors below are the subject: a module that will not import throws whatever the + // runtime threw — a syntax error, a missing native — and never an `X_*` code. Coding them would + // test the branch two cases above this one, which is the one that already covers coded failures. test('an unstructured throw still names the package that broke', async () => { const catalog = await buildErrorCatalog(loaderFailing('@ultimat3/mail', new Error('boom'))); const [finding] = catalog.failed; diff --git a/packages/cli/src/exec.ts b/packages/cli/src/exec.ts index 3a74b66b..7db5efef 100644 --- a/packages/cli/src/exec.ts +++ b/packages/cli/src/exec.ts @@ -2,6 +2,11 @@ // output capture and the "command not found" failure mode are identical everywhere, and so a // test can substitute a fake runner instead of spawning anything. +// `UltimateError` straight from core rather than a class in `./errors`: this module is imported by +// every command, and `./errors` runs `registerErrorCodes` on import — a subprocess boundary must +// not decide when the CLI's registry is populated. `X_CLI_UNEXPECTED` is owned there all the same. +import { UltimateError } from '@ultimat3/core'; + export interface ExecResult { readonly command: readonly string[]; readonly code: number; @@ -25,7 +30,16 @@ const now = (): number => performance.now(); export const exec: Runner = async (command, options) => { const started = now(); const [head, ...rest] = command; - if (head === undefined) throw new RangeError('exec requires at least one argument'); + // A caller bug, never a user's: an empty argv reaches `Bun.spawn` as "spawn nothing" and there is + // no shell-out to report on. Coded like every other CLI failure, because a bare Error here would + // surface as an unexplained crash from the one boundary every command goes through. + if (head === undefined) { + throw new UltimateError({ + code: 'X_CLI_UNEXPECTED', + cause: 'exec() was called with an empty command, so there is no program to spawn', + fix: 'pass the program as the first element: exec(["bun", "test"], { cwd })', + }); + } const proc = Bun.spawn([head, ...rest], { cwd: options.cwd, env: options.env === undefined ? Bun.env : { ...Bun.env, ...options.env }, diff --git a/packages/cli/src/hold.test.ts b/packages/cli/src/hold.test.ts index bdfd1926..6d8e860a 100644 --- a/packages/cli/src/hold.test.ts +++ b/packages/cli/src/hold.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { drain, onShutdown, resetLifecycle } from '@ultimat3/core'; +import { dbUnavailable } from '@ultimat3/db'; import { holdUntilShutdown } from './hold'; afterEach(() => { @@ -84,11 +85,15 @@ describe('holdUntilShutdown', () => { test('a release that fails rejects the hold rather than exiting 0 over it', async () => { // `dispatch` awaits the hold inside its own try, so a database that would not close is a // finding on the way out. Swallowing it would report a clean shutdown of a process that - // still holds the PGlite directory. - const hold = holdUntilShutdown('probe', () => Promise.reject(new Error('db would not close'))); + // still holds the PGlite directory. Coded, like the real release: `RunningServices.stop()` + // rethrows the first failure it hit, and a bare Error would reach `dispatch` with no fix. + const hold = holdUntilShutdown('probe', () => + Promise.reject(dbUnavailable('the embedded PGlite would not close')), + ); const held = hold(); void drain('SIGINT'); - expect(held).rejects.toThrow('db would not close'); + // Awaited: an unawaited `.rejects` is an assertion the runner never sees fail. + await expect(held).rejects.toBeUltimateError('X_DB_UNAVAILABLE'); }); }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 647dbad1..47be9e6c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -31,13 +31,7 @@ export { deployCommand, planDeploy } from './cmd-deploy'; export type { DevServer, StartDevOptions } from './cmd-dev'; export { devCommand, startDev } from './cmd-dev'; export type { DoctorProbe } from './cmd-doctor'; -export { - doctorCommand, - ICON_SOURCE, - OFFLINE_FALLBACK, - probeFor, - runDoctor, -} from './cmd-doctor'; +export { doctorCommand, OFFLINE_FALLBACK, probeFor, runDoctor } from './cmd-doctor'; export { ERRORS_SUBCOMMANDS, errorsCommand } from './cmd-errors'; export { FIX_SUBCOMMANDS, fixCommand } from './cmd-fix'; export type { GenerateOptions, Generator } from './cmd-generate'; @@ -57,6 +51,13 @@ export { availableCpus, testCommand } from './cmd-test'; export { runVerify, VERIFY_STEPS, verifyCommand, verifyStepNames } from './cmd-verify'; export type { CliCommand, CommandContext } from './command'; export { failed, ok } from './command'; +export type { AssetRoutesOptions } from './dev-assets'; +export { + assetRoutes, + ICON_BASE_PATH, + ICON_SOURCE, + MEDIA_BASE_PATH, +} from './dev-assets'; export type { DevDashboardInput, DevStatus } from './dev-dashboard'; export { devDashboardRoutes, devPanels, devSources } from './dev-dashboard'; export { devHooks } from './dev-hooks'; diff --git a/packages/cli/src/mcp-host.ts b/packages/cli/src/mcp-host.ts index 95f66605..7abe2c62 100644 --- a/packages/cli/src/mcp-host.ts +++ b/packages/cli/src/mcp-host.ts @@ -94,7 +94,7 @@ export function lazyServices(input: DevHostInput): LazyServices { fix: 'x mcp serve --transport stdio # keep the host open for the whole session', }); } - started ??= startServices(services); + started ??= startServices(services, input.env); return started; }, async close(): Promise { diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 46eef13b..42189029 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -12,6 +12,13 @@ const CATALOG = { 'cli.build.done': 'built {target}', 'cli.db.branch.ready': 'branch {name} ready', 'cli.dev.ready': 'dev ready on {url} — /_x mounted ({panels} panels), {services}', + // The mail and CDN halves of that boot line. Rendered text, so it lives here — while + // `describeMail`/`describeCdn` keep the same wording as the fixed vocabulary `x dev --json` + // carries, and `dev-runtime.test.ts` pins the two together so neither can drift alone. + 'cli.dev.cdn.external': 'cdn=external({driver} via {detail})', + 'cli.dev.cdn.none': 'cdn=none', + 'cli.dev.mail.embedded': 'mail=embedded', + 'cli.dev.mail.external': 'mail=external({driver} via {detail})', 'cli.dev.hmr': 'reloaded {file} in {ms}ms', 'cli.dev.roles': ' roles {roles}', 'cli.dev.panels': ' panels {panels}', diff --git a/packages/cli/src/output.test.ts b/packages/cli/src/output.test.ts index 74757093..4ad69d65 100644 --- a/packages/cli/src/output.test.ts +++ b/packages/cli/src/output.test.ts @@ -86,6 +86,8 @@ describe('unit · output', () => { }); test('an unknown throw still produces a finding with a fix command', () => { + // The bare Error is the subject, not an oversight: `findingFrom` exists for throws no package + // coded, so a coded input here would test the branch above this one instead. const finding = findingFrom(new Error('boom')); expect(finding.code).toBe('X_CLI_UNEXPECTED'); expect(finding.fix).toBe('x doctor --json'); diff --git a/packages/cli/src/templates/naming.ts b/packages/cli/src/templates/naming.ts index 02cdf8a7..990a3898 100644 --- a/packages/cli/src/templates/naming.ts +++ b/packages/cli/src/templates/naming.ts @@ -1,15 +1,40 @@ // Name derivation for generators. One place, because `x g resource post` has to agree with itself // across eight emitted files — a second casing helper is how `Post`/`post`/`posts` drift apart. -export interface GeneratedFile { +/** + * A catalog is one file per locale that many generators contribute keys to; a whole-file write + * would delete every key already in it, so a `'json'` file merges instead of overwriting. Always + * text: `cmd-generate.ts`'s `dedupe`/`mergeJsonFile` parse and merge `contents` as a JSON object, + * a step that only ever runs against this variant. + */ +export interface GeneratedJsonFile { /** POSIX path relative to the app root. */ readonly path: string; readonly contents: string; - /** A catalog is one file per locale that many generators contribute keys to; a whole-file write - * would delete every key already in it, so a `'json'` file merges instead of overwriting. */ - readonly merge?: 'json'; + readonly merge: 'json'; } +/** + * Every other generated file. Text in almost every case, but the scaffolded app icon is bytes, + * not prose: a PNG cannot survive a UTF-8 string round-trip (every byte above 0x7F comes back + * mangled), so `contents` has to admit raw bytes too — `Bun.write` already accepts either, so + * nothing downstream needs a second write path. + */ +export interface GeneratedSourceFile { + /** POSIX path relative to the app root. */ + readonly path: string; + readonly contents: string | Uint8Array; + readonly merge?: undefined; +} + +/** + * Split on `merge` rather than widening one shape's `contents` in place, so the split is + * load-bearing, not cosmetic: a `merge: 'json'` file's `contents` stays a plain `string` at the + * type level, which is what stops a byte-carrying file from ever reaching + * `cmd-generate.ts`'s JSON parser — the compiler refuses the call before the code can run. + */ +export type GeneratedFile = GeneratedJsonFile | GeneratedSourceFile; + const words = (input: string): readonly string[] => input .replace(/([a-z0-9])([A-Z])/g, '$1 $2') diff --git a/packages/cli/src/templates/scaffold-app.ts b/packages/cli/src/templates/scaffold-app.ts index 0e6d7f8b..61a265ff 100644 --- a/packages/cli/src/templates/scaffold-app.ts +++ b/packages/cli/src/templates/scaffold-app.ts @@ -3,6 +3,7 @@ // a restructure. Every file here is real, typed and covered — no placeholder that fails to boot. import type { GeneratedFile, NameSet } from './naming'; +import { icon } from './scaffold-icon'; const webPackage = (app: NameSet): string => `{ "name": "@${app.kebab}/web", @@ -292,21 +293,11 @@ restructure. | Start | \`x new ${app.kebab}-${surface}\` inside this directory, or wire it by hand | `; -const icon = - (): string => ` - - - -`; - export function appFiles(app: NameSet): readonly GeneratedFile[] { return [ { path: 'apps/web/package.json', contents: webPackage(app) }, { path: 'apps/web/tsconfig.json', contents: tsconfig() }, - { path: 'apps/web/site/icon.svg', contents: icon() }, + { path: 'apps/web/site/icon.png', contents: icon() }, { path: 'apps/web/site/page.tsx', contents: sitePage(app) }, { path: 'apps/web/site/page.module.scss', contents: siteStyle() }, { path: 'apps/web/site/page.test.ts', contents: sitePageTest() }, diff --git a/packages/cli/src/templates/scaffold-icon.ts b/packages/cli/src/templates/scaffold-icon.ts new file mode 100644 index 00000000..32adc4b2 --- /dev/null +++ b/packages/cli/src/templates/scaffold-icon.ts @@ -0,0 +1,54 @@ +// The one source icon `x new` scaffolds. @ultimat3/core's image pipeline decodes PNG and JPEG +// only (`DECODABLE_FORMATS`, packages/core/src/image/pipeline.ts) — an SVG source, which is what +// this used to emit, can never be decoded, so `@ultimat3/pwa`'s `BuiltinImagePipeline` could +// never turn it into the fourteen `ICON_MATRIX` PNGs the generated web manifest declares. + +import type { Raster } from '@ultimat3/core'; +import { createRaster, encodeImage } from '@ultimat3/core'; + +/** `@ultimat3/pwa`'s own contract (`requireSourceIcon`'s fix line): square, 1024 or larger. */ +const ICON_SIZE = 1024; + +/** + * Mirrors `MASKABLE_PADDING` (`packages/pwa/src/icons.ts`): a maskable icon is cropped to the + * middle ~80% of the edge, so the mark has to stay inside that fraction or an installed Android + * icon clips it. Inlined rather than imported — `@ultimat3/pwa` is not a dependency of this + * package, and a placeholder icon does not need the rest of it. + */ +const MASKABLE_PADDING = 0.1; + +/** + * Not a colour: one mid-grey LEVEL, written to all three channels, so this file recreates no + * palette value that could drift from one. A token cannot supply it either — `@ultimat3/ui` owns + * the colour roles and is tier 5 like this package, so `cli -> ui` is a boundary error and + * `cli -> admin -> ui` does not transit. A placeholder must claim no brand colour to begin with. + */ +const MARK_LEVEL = 128; + +/** Opaque over the transparent canvas — the mark is what `probeImage` and a human both see. */ +const MARK_ALPHA = 255; + +/** Fills the maskable-safe inner square, transparent canvas left untouched around it. */ +function paintMark(raster: Raster, inset: number): void { + const { width, height, pixels } = raster; + for (let y = inset; y < height - inset; y += 1) { + for (let x = inset; x < width - inset; x += 1) { + const i = (y * width + x) * 4; + pixels[i] = MARK_LEVEL; + pixels[i + 1] = MARK_LEVEL; + pixels[i + 2] = MARK_LEVEL; + pixels[i + 3] = MARK_ALPHA; + } + } +} + +/** + * A 1024x1024 PNG: a solid square mark inside the maskable safe zone, transparent elsewhere — + * exactly what a placeholder needs to be, not art. Deterministic: no `Date.now()`, no randomness, + * so `x new` output never depends on run order or the clock. + */ +export function icon(): Uint8Array { + const raster = createRaster(ICON_SIZE, ICON_SIZE, 'scaffold-icon'); + paintMark(raster, Math.round(ICON_SIZE * MASKABLE_PADDING)); + return encodeImage(raster, 'png'); +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index c851926b..a46a1920 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -50,6 +50,9 @@ { "path": "../policy" }, + { + "path": "../pwa" + }, { "path": "../query" }, @@ -59,6 +62,9 @@ { "path": "../render" }, + { + "path": "../seo" + }, { "path": "../storage" }, diff --git a/packages/http/src/error-map.test.ts b/packages/http/src/error-map.test.ts index e6faf346..ced546ac 100644 --- a/packages/http/src/error-map.test.ts +++ b/packages/http/src/error-map.test.ts @@ -26,6 +26,13 @@ describe('error -> status', () => { expect(statusFor('X_NOT_IMPLEMENTED')).toBe(501); }); + // The image routes are the framework's only caller-supplied query string, so both of these are + // the caller's mistake to fix — a 500 would send an agent hunting a server fault it cannot see. + test('a bad image transform request blames the caller, not the server', () => { + expect(statusFor('X_IMAGE_QUERY_INVALID')).toBe(400); + expect(statusFor('X_IMAGE_UNSUPPORTED')).toBe(415); + }); + test('an unmapped code is a loud 500, never a quiet 200', () => { expect(statusFor('X_SOMETHING_NEW')).toBe(500); }); diff --git a/packages/http/src/error-map.ts b/packages/http/src/error-map.ts index 91d4e3c3..4cc50093 100644 --- a/packages/http/src/error-map.ts +++ b/packages/http/src/error-map.ts @@ -29,7 +29,12 @@ export const ERROR_STATUS: Readonly> = { // @ultimat3/policy X_POLICY_MISSING: 500, X_PERMISSION_UNKNOWN: 500, + // @ultimat3/seo — a transform query the caller wrote, so the caller is the one who can fix it. + X_IMAGE_QUERY_INVALID: 400, // @ultimat3/core + // The caller asked for a format the pipeline cannot produce (`?f=avif`): the request names an + // unsupported representation, which is 415 — not a 500, which would blame the server for it. + X_IMAGE_UNSUPPORTED: 415, X_NOT_IMPLEMENTED: 501, X_TIMEOUT: 504, X_ABORTED: 499, diff --git a/packages/mail/CLAUDE.md b/packages/mail/CLAUDE.md index 6f4b64ad..4936bdf5 100644 --- a/packages/mail/CLAUDE.md +++ b/packages/mail/CLAUDE.md @@ -1,6 +1,6 @@ # @ultimat3/mail — agent notes -**Tier 3.** May import `core`, `schema`, `i18n`, `time`, `money`, `jobs`; never `auth`, `http`, `ui`, `render`. **Zero external deps** — no nodemailer, no MJML, no CSS library. +**Tier 4** (`scripts/lib/tiers.ts`). May import `core`, `schema`, `i18n`, `time`, `money`, `jobs`; never `auth`, `http`, `ui`, `render`. **Zero external deps** — no nodemailer, no MJML, no CSS library. ## Boundary @@ -12,6 +12,7 @@ | `render.ts` | blocks → HTML **and** text, plus the layout call and the footer slots | | `layout.ts` | `MAIL_TOKENS` (light + dark), the 600px table shell, layout registry | | `driver.ts` | `MailDriver` + memory/log + `resultFor` + the `setMailDriver` seam | +| `driver-env.ts` | `selectMailDriver`: which transport an environment installs, and nothing else | | `driver-smtp.ts` | `createSmtpDriver`: `SMTP_URL` parsing, the pool ceiling, one send | | `driver-resend.ts` | `createResendDriver`: one `POST /emails`, status → retryable | | `smtp-client.ts` | the conversation: greeting → EHLO → STARTTLS → AUTH → envelope → DATA | @@ -40,6 +41,10 @@ never a `retryable` guess. `stage` is the `SendStage` union in `errors.ts`; a new step goes there first. The transient set is 4xx over SMTP, and 408/409/425/429 + 5xx over HTTP — that HTTP set lives in `RETRYABLE_STATUSES` (`driver-resend.ts`) and is edited there, never restated. +- A transport is selected from the environment by `selectMailDriver`, never from an `app.config.ts` + field — nothing loads that file's contents at runtime, so a `mail:` config block would be a + setting no boot could read. Two credentials at once is refused, not resolved: mail leaving by + the wrong provider is not a failure anyone sees. The credential never reaches a printed string. - `Bcc` is an envelope field. It reaches `RCPT TO` and Resend's body, never a header. - Recipient addresses stay out of logs and out of error text we write ourselves; the server's own reply is passed through verbatim, and that is where the refused address comes from. diff --git a/packages/mail/README.md b/packages/mail/README.md index 01c2d845..5e6d5c3e 100644 --- a/packages/mail/README.md +++ b/packages/mail/README.md @@ -47,6 +47,23 @@ delivers inline only when `{ sync: true }` is passed or no job driver is configu | `createSmtpDriver({ url, from })` | prod | real ESMTP over `Bun.connect`: STARTTLS, `AUTH PLAIN`/`LOGIN`, quoted-printable MIME | | `createResendDriver({ apiKey, from })` | prod | one `POST /emails`, `Idempotency-Key` on every request | +### Which one a boot installs + +`selectMailDriver(env)` is the one answer, and `x dev` calls it — an unset variable means the +embedded default, the same law the database, event bus and storage bindings follow. Nothing about +the app changes between environments; the credential does. + +| env | driver | +|---|---| +| *(nothing set)* | `createMemoryDriver()` — caught, never sent | +| `SMTP_URL` + `MAIL_FROM` | `createSmtpDriver(...)`, `MAIL_POOL_SIZE` optional | +| `RESEND_API_KEY` + `MAIL_FROM` | `createResendDriver(...)` | + +Both credentials at once is `X_CONFIG_INVALID` rather than a winner picked for you, and a +transport with no `MAIL_FROM` is refused at boot instead of on the first send. A host that is not +`x dev` calls `selectMailDriver` itself, or constructs a driver directly — `setMailDriver` is the +only seam either way. + ### SMTP `smtps://user:pass@host:465` is implicit TLS; `smtp://host:587` starts in the clear and upgrades diff --git a/packages/mail/src/driver-env.test.ts b/packages/mail/src/driver-env.test.ts new file mode 100644 index 00000000..b825519b --- /dev/null +++ b/packages/mail/src/driver-env.test.ts @@ -0,0 +1,243 @@ +// The selection seam: a credential picks its transport, two credentials are refused, and no +// credential catches in memory. These are the only tests that prove the shipped transports are +// reachable from a boot at all — everything else in the package tests them once constructed. + +import { describe, expect, test } from 'bun:test'; +import { UltimateError } from '@ultimat3/core'; +import type { MailDriver, MemoryMailDriver } from './driver'; +import { createMemoryDriver, isMemoryDriver } from './driver'; +import { MAIL_ENV_KEYS, selectMailDriver } from './driver-env'; +import { driverUnavailable } from './errors'; + +const FROM = 'Postly '; +const SMTP_URL = 'smtps://user:pass@mail.postly.test:465'; + +/** The thrown error itself, so a test can assert on `code`, `cause` and `fix` together. */ +const thrown = (run: () => unknown): UltimateError => { + try { + run(); + } catch (error) { + if (error instanceof UltimateError) return error; + } + // `expect.unreachable` fails through the runner, so the caller sees its own assertion rather + // than a stack from inside this helper — and a bare throw here would carry no code and no fix. + return expect.unreachable('expected selectMailDriver to refuse with an UltimateError'); +}; + +/** Every `MemoryMailDriver` member, optional and removable, so a case can drop exactly one. */ +type MemoryMembers = { -readonly [K in keyof MemoryMailDriver]?: MemoryMailDriver[K] }; + +/** + * A driver that answers to `memory` and holds exactly the members a case grants it. The refusal + * it sends is coded like any other driver refusal: nothing calls it, and a bare `Error` here would + * still be the one shape this package forbids. + */ +function memoryLike(members: MemoryMembers): MailDriver { + const driver: MailDriver & MemoryMembers = { + name: 'memory', + send: () => + Promise.reject(driverUnavailable('a look-alike driver in this test cannot deliver')), + ...members, + }; + return driver; +} + +describe('selectMailDriver', () => { + test('SMTP_URL selects the smtp transport', () => { + const selection = selectMailDriver({ SMTP_URL, MAIL_FROM: FROM }); + expect(selection.driver.name).toBe('smtp'); + expect(selection.detail).toBe('SMTP_URL'); + expect(isMemoryDriver(selection.driver)).toBe(false); + }); + + test('RESEND_API_KEY selects the resend transport', () => { + const selection = selectMailDriver({ RESEND_API_KEY: 're_test_key', MAIL_FROM: FROM }); + expect(selection.driver.name).toBe('resend'); + expect(selection.detail).toBe('RESEND_API_KEY'); + }); + + test('no credential catches in memory and says how to deliver', () => { + const selection = selectMailDriver({}); + expect(isMemoryDriver(selection.driver)).toBe(true); + expect(selection.detail).toContain('SMTP_URL'); + expect(selection.detail).toContain('RESEND_API_KEY'); + }); + + // A blank value in a `.env` file is the same as an unset one everywhere else in the framework; + // treating it as a credential would build a transport out of an empty string. + test('a blank credential is an unset one', () => { + expect(isMemoryDriver(selectMailDriver({ SMTP_URL: ' ' }).driver)).toBe(true); + expect(isMemoryDriver(selectMailDriver({ RESEND_API_KEY: '' }).driver)).toBe(true); + }); + + test('both credentials are refused rather than one silently winning', () => { + const error = thrown(() => + selectMailDriver({ SMTP_URL, RESEND_API_KEY: 're_test_key', MAIL_FROM: FROM }), + ); + expect(error.code).toBe('X_CONFIG_INVALID'); + expect(error.cause).toContain('SMTP_URL and RESEND_API_KEY are both set'); + expect(error.fix).toContain('unset one'); + }); + + test('a transport without MAIL_FROM names the missing key, not the driver internals', () => { + const error = thrown(() => selectMailDriver({ SMTP_URL })); + expect(error.code).toBe('X_CONFIG_INVALID'); + expect(error.cause).toContain('MAIL_FROM is unset'); + expect(error.fix).toContain('MAIL_FROM='); + }); + + test('resend without MAIL_FROM is refused the same way', () => { + const error = thrown(() => selectMailDriver({ RESEND_API_KEY: 're_test_key' })); + expect(error.cause).toContain('MAIL_FROM is unset'); + expect(error.cause).toContain('RESEND_API_KEY'); + }); + + test('MAIL_POOL_SIZE reaches the smtp driver', () => { + expect(selectMailDriver({ SMTP_URL, MAIL_FROM: FROM, MAIL_POOL_SIZE: '2' }).driver.name).toBe( + 'smtp', + ); + }); + + test.each([['nope'], ['0'], ['2.5'], ['-1']])( + 'MAIL_POOL_SIZE=%p is refused at the env boundary', + (raw) => { + const error = thrown(() => + selectMailDriver({ SMTP_URL, MAIL_FROM: FROM, MAIL_POOL_SIZE: raw }), + ); + expect(error.code).toBe('X_CONFIG_INVALID'); + expect(error.cause).toContain('MAIL_POOL_SIZE'); + // The operator set a string in a file; a cause reading "poolSize: NaN" names nothing they own. + expect(error.cause).not.toContain('NaN'); + }, + ); + + // The keys are documented in the wiki and read by the CLI's service resolution. A key added + // here without being added there is a key nothing tells an operator about. + test('the read keys are exactly the declared ones', () => { + expect([...MAIL_ENV_KEYS]).toEqual([ + 'SMTP_URL', + 'RESEND_API_KEY', + 'MAIL_FROM', + 'MAIL_POOL_SIZE', + ]); + }); + + test('a bad SMTP_URL still fails at selection, not at first send', () => { + expect(thrown(() => selectMailDriver({ SMTP_URL: 'https://nope', MAIL_FROM: FROM })).code).toBe( + 'X_CONFIG_INVALID', + ); + }); +}); + +describe('isMemoryDriver', () => { + // The guard decides whether a host reads `outbox()`. Narrowing on the name alone would let a + // transport that happens to be called "memory" reach a method it does not have. + test('a look-alike without an outbox is not a memory driver', () => { + expect(isMemoryDriver(memoryLike({}))).toBe(false); + }); + + // The predicate promises the whole `MemoryMailDriver` interface, so it has to check the whole + // interface: `outbox()` is what the `/_x` panel reaches first, not all it reaches. A partial + // look-alike that passed here would type-check its way into `sent`, `lastTo()` and `clear()`. + test('a look-alike with an outbox and nothing else is refused', () => { + expect(isMemoryDriver(memoryLike({ outbox: () => [] }))).toBe(false); + }); + + test.each(['sent', 'outbox', 'lastTo', 'clear'] as const)( + 'a memory driver missing only %s is refused', + (missing) => { + const members: MemoryMembers = { ...createMemoryDriver() }; + delete members[missing]; + expect(isMemoryDriver(memoryLike(members))).toBe(false); + }, + ); + + test('the real one is', () => { + expect(isMemoryDriver(createMemoryDriver())).toBe(true); + }); +}); + +/** + * The greeting and EHLO of a server that offers no STARTTLS, and nothing more — the conversation + * under test stops there on purpose. `Bun.listen` on loopback is allowed: the sealed test network + * covers `fetch` only, and a transport that has only ever met a fake stream has not been proven + * to dial anything. + */ +function startPlaintextSmtp(): { + readonly port: number; + readonly commands: string[]; + stop(): void; +} { + const commands: string[] = []; + const decoder = new TextDecoder(); + const buffers = new Map(); + const listener = Bun.listen({ + hostname: '127.0.0.1', + port: 0, + socket: { + open(socket) { + buffers.set(socket, ''); + socket.write('220 local.test ESMTP\r\n'); + }, + close(socket) { + buffers.delete(socket); + }, + data(socket, chunk) { + let buffer = (buffers.get(socket) ?? '') + decoder.decode(chunk); + for (;;) { + const eol = buffer.indexOf('\r\n'); + if (eol === -1) break; + const line = buffer.slice(0, eol); + buffer = buffer.slice(eol + 2); + commands.push(line); + // No STARTTLS in the capability list: the driver must refuse rather than downgrade. + if (line.startsWith('EHLO')) socket.write('250-local.test\r\n250 SIZE 20000000\r\n'); + else if (line === 'QUIT') { + socket.write('221 2.0.0 Bye\r\n'); + socket.end(); + } else socket.write('502 5.5.2 Command not implemented\r\n'); + } + buffers.set(socket, buffer); + }, + }, + }); + return { port: listener.port, commands, stop: () => listener.stop(true) }; +} + +// The proof that the selection is a transport and not a shape: it opens a socket to the address +// the env key named and speaks SMTP on it. Nothing else in the package covers env → the wire. +test('the env-selected transport dials for real and refuses to send in the clear', async () => { + const server = startPlaintextSmtp(); + try { + const selection = selectMailDriver({ + SMTP_URL: `smtp://127.0.0.1:${server.port}`, + MAIL_FROM: FROM, + }); + expect(selection.driver.name).toBe('smtp'); + + const failure = await selection.driver + .send({ + mailId: 'welcome', + to: ['ada@example.test'], + subject: 'Welcome', + html: '

hi

', + text: 'hi', + locale: 'en', + tz: 'UTC', + }) + .then( + () => undefined, + (error: unknown) => error as { code?: string; fix?: string }, + ); + + // It got far enough to greet the server and read its capabilities — a driver that never + // connected could not have produced this command list. + expect(server.commands.some((line) => line.startsWith('EHLO'))).toBe(true); + expect(failure?.code).toBe('X_MAIL_SEND_FAILED'); + // The fix names the env key the operator set, which is the thing they can actually change. + expect(failure?.fix).toContain('SMTP_URL'); + expect(failure?.fix).toContain('smtps://'); + } finally { + server.stop(); + } +}); diff --git a/packages/mail/src/driver-env.ts b/packages/mail/src/driver-env.ts new file mode 100644 index 00000000..ded7409f --- /dev/null +++ b/packages/mail/src/driver-env.ts @@ -0,0 +1,105 @@ +// Single responsibility: environment → transport. The one place that decides which `MailDriver` +// a boot installs, so `x dev`, a worker container and any custom host all resolve it identically. +// The two production transports are useless until something constructs them from a credential; +// this is that something, and it is keyed on env rather than a config field so the same image +// deploys to every environment. + +import { ConfigInvalidError } from '@ultimat3/core'; +import { createMemoryDriver, type MailDriver } from './driver'; +import { createResendDriver } from './driver-resend'; +import { createSmtpDriver } from './driver-smtp'; + +/** The keys read here, and nothing else. Named once so docs and tests cannot drift from the code. */ +export const MAIL_ENV_KEYS = ['SMTP_URL', 'RESEND_API_KEY', 'MAIL_FROM', 'MAIL_POOL_SIZE'] as const; + +export type MailEnvironment = Readonly>; + +export interface MailSelection { + readonly driver: MailDriver; + /** + * Why this driver, in one line: the env key that selected it, or what to set to change it. + * A boot prints it, so "which transport is this process using" is never a guess. + */ + readonly detail: string; +} + +const nonEmpty = (value: string | undefined): string | undefined => + value === undefined || value.trim().length === 0 ? undefined : value.trim(); + +/** + * Both transports put the address in the envelope and in `From:`, so neither can be built + * without it. Refused here rather than inside the driver: the cause names the env key that is + * missing, which is the thing an operator can actually set. + */ +function requireFrom(env: MailEnvironment, selectedBy: string): string { + const from = nonEmpty(env['MAIL_FROM']); + if (from === undefined) { + throw new ConfigInvalidError({ + cause: `${selectedBy} selects a mail transport, but MAIL_FROM is unset — no envelope sender`, + fix: 'set MAIL_FROM="App " in .env.production', + meta: { selectedBy, missing: 'MAIL_FROM' }, + }); + } + return from; +} + +/** + * `Number('abc')` is `NaN`, and `NaN` reaches the driver as "poolSize: NaN" — an accurate but + * useless cause, because the operator set a string in a file and the driver never saw the key. + * Parsed at the boundary so the error names `MAIL_POOL_SIZE` instead. + */ +function poolSizeFrom(env: MailEnvironment): number | undefined { + const raw = nonEmpty(env['MAIL_POOL_SIZE']); + if (raw === undefined) return undefined; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new ConfigInvalidError({ + cause: `MAIL_POOL_SIZE is "${raw}", which is not a whole number of connections >= 1`, + fix: 'set MAIL_POOL_SIZE=4 in .env.production, or unset it to keep the default', + meta: { MAIL_POOL_SIZE: raw }, + }); + } + return parsed; +} + +/** + * A credential selects its transport; no credential catches mail in memory. Two credentials is + * the one case that cannot be answered by picking a winner — whichever this chose would be the + * one an operator did not mean half the time, and mail would silently leave by the wrong path. + */ +export function selectMailDriver(env: MailEnvironment): MailSelection { + const smtpUrl = nonEmpty(env['SMTP_URL']); + const resendKey = nonEmpty(env['RESEND_API_KEY']); + + if (smtpUrl !== undefined && resendKey !== undefined) { + throw new ConfigInvalidError({ + cause: 'SMTP_URL and RESEND_API_KEY are both set — two transports claim the same mail', + fix: 'unset one of them in .env.production: a process delivers through exactly one transport', + meta: { selected: ['SMTP_URL', 'RESEND_API_KEY'] }, + }); + } + + if (smtpUrl !== undefined) { + const poolSize = poolSizeFrom(env); + return { + driver: createSmtpDriver({ + url: smtpUrl, + from: requireFrom(env, 'SMTP_URL'), + ...(poolSize === undefined ? {} : { poolSize }), + }), + detail: 'SMTP_URL', + }; + } + + if (resendKey !== undefined) { + return { + driver: createResendDriver({ apiKey: resendKey, from: requireFrom(env, 'RESEND_API_KEY') }), + detail: 'RESEND_API_KEY', + }; + } + + return { + driver: createMemoryDriver(), + detail: 'caught in memory — set SMTP_URL or RESEND_API_KEY to deliver', + }; +} diff --git a/packages/mail/src/driver.ts b/packages/mail/src/driver.ts index a7c36323..017ff1a3 100644 --- a/packages/mail/src/driver.ts +++ b/packages/mail/src/driver.ts @@ -112,6 +112,25 @@ export function createMemoryDriver(): MemoryMailDriver { }; } +/** + * Whether this driver caught the message instead of sending it. A host asks before reading + * `outbox()` — the `/_x` mail panel exists only when nothing was actually delivered, and a real + * transport has no record to show. Every member the interface promises is checked, not just the + * one a caller happens to reach first: the predicate hands back a `MemoryMailDriver`, so a + * look-alike that passed on `name` + `outbox()` alone would make `sent`, `lastTo()` and `clear()` + * a compile-time promise the object cannot keep. + */ +export function isMemoryDriver(driver: MailDriver): driver is MemoryMailDriver { + if (driver.name !== 'memory') return false; + const candidate = driver as MemoryMailDriver; + return ( + Array.isArray(candidate.sent) && + typeof candidate.outbox === 'function' && + typeof candidate.lastTo === 'function' && + typeof candidate.clear === 'function' + ); +} + /** * Structured log line per message through core's `logger` — the default for a worker that * has no credentials yet. Bodies are never logged; a mail body is user data. diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 55d0f017..a8be9b34 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -19,12 +19,15 @@ export { createLogDriver, createMemoryDriver, envelopeRecipients, + isMemoryDriver, mailDriver, messageHeaders, resetMailDriver, setMailDriver, tryMailDriver, } from './driver'; +export type { MailEnvironment, MailSelection } from './driver-env'; +export { MAIL_ENV_KEYS, selectMailDriver } from './driver-env'; export type { MailFetch, ResendDriverOptions } from './driver-resend'; export { createResendDriver, RESEND_BASE_URL } from './driver-resend'; export type { SmtpDriverOptions } from './driver-smtp'; diff --git a/packages/pwa/CLAUDE.md b/packages/pwa/CLAUDE.md index e1cbc18c..e8fabd2c 100644 --- a/packages/pwa/CLAUDE.md +++ b/packages/pwa/CLAUDE.md @@ -21,8 +21,10 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach | Push strings | i18n keys only, rendered per subscriber locale. Never a literal. | | Colours | token values passed in via `PwaConfig.tokens`. Never a hex literal in this package — a test fixture asserting the parser is the one exception. | | Icons | one source image → `BuiltinImagePipeline` → a square PNG per `ICON_MATRIX` entry, rendered by `@ultimat3/core`'s image pipeline. Never a second scaler, never `sharp`, never a vendor CDN. | +| Icon source | a **PNG** (core decodes PNG and JPEG only). `x new` scaffolds one; `@ultimat3/cli`'s `dev-assets.ts` reads it at `ICON_SOURCE` and serves every entry at its `outputPath`. This package renders bytes and mounts nothing. | | Icon background | `IconSourceConfig.background`, hex or `transparent` — core's grammar has no named colours. Default transparent. | | Errors | `errors.ts` subclasses only. `X_NOT_IMPLEMENTED` must carry a real `fix:`. | +| Errors in `sw.js` | no bundler there, so no `UltimateError` import — the generated source defines its own class (`SYNC_ERROR_CLASS`) carrying `code`, `cause`, `fix`, `docs`. Never emit a bare `throw new Error`; the code stays owned by `errors.ts`. | ``` bun test # from packages/pwa diff --git a/packages/pwa/src/background-sync.test.ts b/packages/pwa/src/background-sync.test.ts new file mode 100644 index 00000000..4e63bf88 --- /dev/null +++ b/packages/pwa/src/background-sync.test.ts @@ -0,0 +1,123 @@ +// The emitted background-sync block is code nobody type-checks: it leaves this package as a +// string and is next parsed by a browser. So these tests read it the way the browser will — +// parsing and running the class it defines — rather than trusting that it was spelled right. + +import { describe, expect, test } from 'bun:test'; +import { describeErrorCode } from '@ultimat3/core'; +import { + backgroundSyncSource, + DEFAULT_FLUSH_ENDPOINT, + DEFAULT_RETRY, + retryDelayMs, + SYNC_TAG, + shouldRetry, +} from './background-sync'; +import { PwaSyncFlushFailedError, PwaSyncIncompleteError } from './errors'; + +/** The fields the emitted class promises — the same four `UltimateError` exposes, plus `message`. */ +interface EmittedSyncError { + readonly code: string; + readonly cause: string; + readonly fix: string; + readonly docs: string; + readonly message: string; +} + +/** + * The emitted block, evaluated the way a browser evaluates `sw.js`: `self` and `BUILD_ID` are the + * two globals the service-worker realm supplies. Constructing the error is the only proof the + * emitted class works — a substring assertion passes just as happily on source that throws a + * `SyntaxError` on the first byte the browser reads. + */ +function emittedError(code: string): EmittedSyncError { + const build = new Function( + 'self', + 'BUILD_ID', + `${backgroundSyncSource()} +return new PwaSyncError(${JSON.stringify(code)},'the flush endpoint said no','run the fix command');`, + ); + return build({ addEventListener: () => undefined }, 'build-1') as EmittedSyncError; +} + +describe('backgroundSyncSource', () => { + test('throws no bare Error — every failure in the generated realm is coded', () => { + const source = backgroundSyncSource(); + + expect(source).not.toContain('new Error('); + expect([...source.matchAll(/throw new (\w+)\(/g)].map((match) => match[1])).toEqual([ + 'PwaSyncError', + 'PwaSyncError', + ]); + }); + + test('each failure throws the code errors.ts declares for it', () => { + const source = backgroundSyncSource(); + + const thrownWith = (code: string): string => `throw new PwaSyncError(${JSON.stringify(code)}`; + expect(source).toContain(thrownWith(PwaSyncFlushFailedError.code)); + expect(source).toContain(thrownWith(PwaSyncIncompleteError.code)); + }); + + test('each failure carries a fix an operator can run, not advice', () => { + const source = backgroundSyncSource(); + + // A rejected flush is reproducible against the endpoint the SW just called. + expect(source).toContain("'curl -i -X POST '+FLUSH_ENDPOINT"); + // A partial flush is the outbox worker's business, or the retry ceiling's. + expect(source).toContain('x dev --role sync'); + expect(source).toContain('pwa.backgroundSync.retry.maxAttempts in app.config.ts'); + }); + + test('the emitted class exposes code, cause, fix and docs, like every other Ultimate error', () => { + const error = emittedError(PwaSyncFlushFailedError.code); + + expect(error.code).toBe(PwaSyncFlushFailedError.code); + expect(error.cause).toBe('the flush endpoint said no'); + expect(error.fix).toBe('run the fix command'); + // The docs host the SW builds its URL from is the one the registry declares here. + expect(error.docs).toBe(describeErrorCode(PwaSyncFlushFailedError.code).docs); + }); + + test('the message alone still instructs — an uncaught waitUntil rejection prints nothing else', () => { + const message = emittedError(PwaSyncIncompleteError.code).message; + + expect(message).toContain(PwaSyncIncompleteError.code); + expect(message).toContain('the flush endpoint said no'); + expect(message).toContain('fix: run the fix command'); + expect(message).toContain(describeErrorCode(PwaSyncIncompleteError.code).docs); + }); + + test('the handler is keyed on this package own sync tag and the configured endpoint', () => { + const source = backgroundSyncSource({ flushEndpoint: '/custom/flush' }); + + expect(source).toContain(`const SYNC_TAG="${SYNC_TAG}"`); + expect(source).toContain('const FLUSH_ENDPOINT="/custom/flush"'); + expect(source).not.toContain(DEFAULT_FLUSH_ENDPOINT); + expect(source).toContain("addEventListener('sync'"); + }); + + test('is deterministic for identical input', () => { + expect(backgroundSyncSource()).toBe(backgroundSyncSource()); + expect(backgroundSyncSource()).not.toContain('Date.now()'); + }); +}); + +describe('retryDelayMs', () => { + test('doubles per attempt and stops at the ceiling', () => { + expect(retryDelayMs(1)).toBe(DEFAULT_RETRY.baseDelayMs); + expect(retryDelayMs(3)).toBe(DEFAULT_RETRY.baseDelayMs * 4); + expect(retryDelayMs(DEFAULT_RETRY.maxAttempts)).toBeLessThanOrEqual(DEFAULT_RETRY.maxDelayMs); + }); + + test('an attempt below one or past the ceiling is clamped, never negative or unbounded', () => { + expect(retryDelayMs(0)).toBe(DEFAULT_RETRY.baseDelayMs); + expect(retryDelayMs(99)).toBe(retryDelayMs(DEFAULT_RETRY.maxAttempts)); + }); +}); + +describe('shouldRetry', () => { + test('stops at the attempt ceiling', () => { + expect(shouldRetry(DEFAULT_RETRY.maxAttempts - 1)).toBe(true); + expect(shouldRetry(DEFAULT_RETRY.maxAttempts)).toBe(false); + }); +}); diff --git a/packages/pwa/src/background-sync.ts b/packages/pwa/src/background-sync.ts index eced86b8..780e2f25 100644 --- a/packages/pwa/src/background-sync.ts +++ b/packages/pwa/src/background-sync.ts @@ -5,8 +5,16 @@ * belongs to `@ultimat3/realtime`. This file owns only the browser-side trigger: register * a sync tag, and when the platform says connectivity is back, ask realtime to flush. * Nothing here knows what a mutation is, and it must stay that way. + * + * The two failures below (`X_PWA_SYNC_FLUSH_FAILED`, `X_PWA_SYNC_INCOMPLETE`, documented in + * `./errors.ts`) run inside the string emitted into `sw.js` — the browser's service-worker + * realm, which has no bundler and cannot import `@ultimat3/core`. What it *can* have is a class + * of its own, so `SYNC_ERROR_CLASS` emits one: a bare `Error` carries a message and nothing a + * caller can read, while the emitted class exposes the same `code`, `cause`, `fix` and `docs` an + * `UltimateError` does everywhere else in the framework. */ +import { PwaSyncFlushFailedError, PwaSyncIncompleteError } from './errors'; import { BUILD_ID_HEADER } from './version-skew'; export const SYNC_TAG = 'x-outbox'; @@ -48,6 +56,29 @@ export interface BackgroundSyncOptions { export const DEFAULT_FLUSH_ENDPOINT = '/_x/outbox/flush'; +/** + * Where the emitted class sends a reader. The same host `./errors.ts` documents these two codes + * at — retyped here because the SW builds its URL from the code at throw time, and + * `background-sync.test.ts` asserts the two halves still agree. + */ +const SYNC_DOCS_BASE = 'https://ultimate.dev/errors/'; + +/** + * The generated realm's own coded error, as source. Small on purpose — this ships in `sw.js` — and + * deliberately not a bare `Error`: `code` is what a reporting hook groups on, `fix` is what the + * developer in devtools acts on, and neither survives being flattened into a message alone. The + * message still renders the contract's own line shape, because an uncaught `waitUntil` rejection + * prints nothing else. + */ +const SYNC_ERROR_CLASS = ` +class PwaSyncError extends Error{ + constructor(code,cause,fix){ + const docs=${JSON.stringify(SYNC_DOCS_BASE)}+code; + super(code+': '+cause+'\\n fix: '+fix+'\\n docs: '+docs); + this.name='PwaSyncError';this.code=code;this.cause=cause;this.fix=fix;this.docs=docs; + } +}`.trim(); + /** * Emitted into `sw.js` only when the `backgroundSync` capability is on. The handler posts * to realtime's flush endpoint; a non-2xx keeps the sync registration alive so the @@ -60,11 +91,12 @@ export function backgroundSyncSource(options: BackgroundSyncOptions = {}): strin const SYNC_TAG=${JSON.stringify(SYNC_TAG)}; const FLUSH_ENDPOINT=${JSON.stringify(endpoint)}; const SYNC_MAX_ATTEMPTS=${retry.maxAttempts}; +${SYNC_ERROR_CLASS} async function flushOutbox(){ const res=await fetch(FLUSH_ENDPOINT,{method:'POST',headers:{${JSON.stringify(BUILD_ID_HEADER)}:BUILD_ID}}); - if(!res.ok)throw new Error('outbox flush failed: '+res.status); + if(!res.ok)throw new PwaSyncError(${JSON.stringify(PwaSyncFlushFailedError.code)},'outbox flush POST '+FLUSH_ENDPOINT+' returned '+res.status,'curl -i -X POST '+FLUSH_ENDPOINT+' — @ultimat3/realtime must mount it and answer 2xx'); const body=await res.json().catch(()=>({remaining:0})); - if(body.remaining>0)throw new Error('outbox partially flushed'); + if(body.remaining>0)throw new PwaSyncError(${JSON.stringify(PwaSyncIncompleteError.code)},'outbox flush at '+FLUSH_ENDPOINT+' left '+body.remaining+' mutation(s) queued','x dev --role sync # drain the outbox, or raise pwa.backgroundSync.retry.maxAttempts in app.config.ts'); } self.addEventListener('sync',(event)=>{ if(event.tag!==SYNC_TAG)return; diff --git a/packages/pwa/src/capabilities.ts b/packages/pwa/src/capabilities.ts index 626380bd..379f1945 100644 --- a/packages/pwa/src/capabilities.ts +++ b/packages/pwa/src/capabilities.ts @@ -53,11 +53,15 @@ export const CAPABILITY_MANIFEST_KEYS: Readonly> = Object.freeze( { push: ["addEventListener('push'", "addEventListener('notificationclick'"], - backgroundSync: ["addEventListener('sync'"], + backgroundSync: ["addEventListener('sync'", 'class PwaSyncError'], badging: ['navigator.setAppBadge'], shareTarget: ['/_x/share-target'], fileHandlers: [], diff --git a/packages/pwa/src/errors.ts b/packages/pwa/src/errors.ts index 1c6e4d94..e463a5fc 100644 --- a/packages/pwa/src/errors.ts +++ b/packages/pwa/src/errors.ts @@ -12,6 +12,9 @@ export const PWA_OWNED_ERROR_CODES = [ 'X_PWA_MANIFEST_INVALID', 'X_BUILD_ID_MISSING', 'X_SW_SCOPE_INVALID', + 'X_PWA_STRATEGY_EXHAUSTED', + 'X_PWA_SYNC_FLUSH_FAILED', + 'X_PWA_SYNC_INCOMPLETE', ] as const; /** @@ -32,6 +35,9 @@ export const PWA_ERROR_TITLES: Readonly> = { X_PWA_MANIFEST_INVALID: 'the generated web manifest failed validation', X_BUILD_ID_MISSING: 'no immutable build ID', X_SW_SCOPE_INVALID: 'the service-worker scope cannot serve the routes it precaches', + X_PWA_STRATEGY_EXHAUSTED: 'a caching strategy had no cache, no network and no fallback', + X_PWA_SYNC_FLUSH_FAILED: 'the background-sync outbox flush was rejected', + X_PWA_SYNC_INCOMPLETE: 'the background-sync outbox flush left mutations queued', }; // One unconditional call, so a second package claiming one of pwa's codes throws @@ -107,6 +113,55 @@ export class SwScopeInvalidError extends UltimateError { } } +/** + * A strategy exhausted the cache, the network, and any declared fallback. Runs in-process (the + * `STRATEGY_FNS` half of `strategies.ts`, tested for parity with the `STRATEGY_SOURCE` emitted + * into `sw.js`), so it can import core the way the generated bundle below cannot. + */ +export class PwaStrategyExhaustedError extends UltimateError { + static readonly code = 'X_PWA_STRATEGY_EXHAUSTED' as const; + constructor(input: { cacheName: string }) { + super({ + code: PwaStrategyExhaustedError.code, + cause: `no cached response and the network failed for "${input.cacheName}"`, + fix: 'pass options.fallback to staleWhileRevalidate(request, env, options), or set pwa.offline.fallback in app.config.ts', + docs: docsFor(PwaStrategyExhaustedError.code), + }); + } +} + +/** + * Titles one of the two failures `backgroundSyncSource()` emits into `sw.js`, and owns the `code` + * the emitted source throws. The generated code runs in the browser's service-worker realm, which + * has no bundler and no `@ultimat3/core` to import — so it defines a local `PwaSyncError` carrying + * this same code, cause, fix and docs rather than constructing this class. This class is what gives + * the code one title and one wiki row, the same as every other code in this file. + */ +export class PwaSyncFlushFailedError extends UltimateError { + static readonly code = 'X_PWA_SYNC_FLUSH_FAILED' as const; + constructor(cause: string, fix: string) { + super({ + code: PwaSyncFlushFailedError.code, + cause, + fix, + docs: docsFor(PwaSyncFlushFailedError.code), + }); + } +} + +/** Documented for the same reason as {@link PwaSyncFlushFailedError} — see its comment. */ +export class PwaSyncIncompleteError extends UltimateError { + static readonly code = 'X_PWA_SYNC_INCOMPLETE' as const; + constructor(cause: string, fix: string) { + super({ + code: PwaSyncIncompleteError.code, + cause, + fix, + docs: docsFor(PwaSyncIncompleteError.code), + }); + } +} + /** A driver whose interface exists and whose backing implementation does not, yet. */ export class NotImplementedError extends UltimateError { static readonly code = 'X_NOT_IMPLEMENTED' as const; diff --git a/packages/pwa/src/strategies.ts b/packages/pwa/src/strategies.ts index b9abf768..e0e98ab6 100644 --- a/packages/pwa/src/strategies.ts +++ b/packages/pwa/src/strategies.ts @@ -4,6 +4,8 @@ * its bytes have to be, so the mapping is derived and the override is the exception. */ +import { PwaStrategyExhaustedError } from './errors'; + export type StrategyName = | 'cache-first' | 'network-first' @@ -167,7 +169,7 @@ async function fetchAndStore( async function fallbackOrThrow(options: StrategyOptions): Promise { if (options.fallback !== undefined) return options.fallback(); - throw new TypeError(`no cached response and the network failed for ${options.cacheName}`); + throw new PwaStrategyExhaustedError({ cacheName: options.cacheName }); } /** diff --git a/packages/realtime/src/pg-replication.live.test.ts b/packages/realtime/src/pg-replication.live.test.ts index d745bfac..2d3302ef 100644 --- a/packages/realtime/src/pg-replication.live.test.ts +++ b/packages/realtime/src/pg-replication.live.test.ts @@ -19,7 +19,12 @@ const url = Bun.env['TEST_REPLICATION_URL'] ?? Bun.env['TEST_DATABASE_URL'] ?? Bun.env['DATABASE_URL']; const TABLE = 'x_live_posts'; +// One slot per case. A slot carries the previous case's position, and a feed started with no +// cursor resumes from it — so the decode case's last transaction, delivered but not yet confirmed +// when its feed stopped, is legitimately re-sent to whoever opens that slot next. Sharing one slot +// made the resume case assert "exactly two" against a stream that owes it three. const SLOT = 'x_live_slot'; +const RESUME_SLOT = 'x_live_resume_slot'; const PUBLICATION = 'x_live_pub'; /** The preload freezes the clock, so waiting is counted in polls rather than in elapsed time. */ @@ -73,12 +78,18 @@ const ready = url !== undefined && url !== '' && (await bounded(logicalWal())); describe.skipIf(!ready)('live · postgres logical replication', () => { let sql: PgConnection; + /** Both slots, dropped by one statement so neither case inherits the other's position. */ + const dropSlots = async (): Promise => { + await sql.query( + `SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots ` + + `WHERE slot_name IN ('${SLOT}', '${RESUME_SLOT}')`, + ); + }; + beforeAll(async () => { sql = await admin(); await sql.query(`DROP PUBLICATION IF EXISTS ${PUBLICATION}`); - await sql.query( - `SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = '${SLOT}'`, - ); + await dropSlots(); await sql.query(`DROP TABLE IF EXISTS ${TABLE}`); await sql.query( `CREATE TABLE ${TABLE} ( @@ -100,9 +111,7 @@ describe.skipIf(!ready)('live · postgres logical replication', () => { afterAll(async () => { if (sql === undefined) return; await sql.query(`DROP PUBLICATION IF EXISTS ${PUBLICATION}`); - await sql.query( - `SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = '${SLOT}'`, - ); + await dropSlots(); await sql.query(`DROP TABLE IF EXISTS ${TABLE}`); await sql.close(); }); @@ -172,19 +181,34 @@ describe.skipIf(!ready)('live · postgres logical replication', () => { await feed.stop(); + // `active` flips when the *server's* walsender exits, which it does after this process closed + // the socket — so the slot is released eventually, never synchronously with `stop()`. Polled + // rather than read once: a single read turns a loaded runner into a failing assertion. + const slotRow = async (): Promise => { + const [row] = await sql.query( + `SELECT confirmed_flush_lsn <> '0/0', active FROM pg_replication_slots WHERE slot_name = '${SLOT}'`, + ); + return row; + }; + let slot = await slotRow(); + for (let poll = 0; poll < 200 && slot?.[1] !== 'f'; poll += 1) { + await Bun.sleep(50); + slot = await slotRow(); + } + // The slot moved, which is what stops the WAL from growing without bound. - const [slot] = await sql.query( - `SELECT confirmed_flush_lsn <> '0/0', active FROM pg_replication_slots WHERE slot_name = '${SLOT}'`, - ); expect(slot?.[0]).toBe('t'); + // And it was released: a slot still held is one the next replicator cannot claim. expect(slot?.[1]).toBe('f'); }, 60_000); test('a resume delivers each change exactly once across a restart', async () => { const first: ChangeEvent[] = []; + // Its own slot, created by this `start`: what a resume drops must be what *this* feed already + // delivered, not whatever the previous case left unconfirmed on a shared one. const one = new PgLogicalReplicationFeed({ url: url ?? '', - slot: SLOT, + slot: RESUME_SLOT, publication: PUBLICATION, entities: [TABLE], statusIntervalMs: 250, @@ -201,7 +225,7 @@ describe.skipIf(!ready)('live · postgres logical replication', () => { const second: ChangeEvent[] = []; const two = new PgLogicalReplicationFeed({ url: url ?? '', - slot: SLOT, + slot: RESUME_SLOT, publication: PUBLICATION, entities: [TABLE], statusIntervalMs: 250, diff --git a/packages/seo/CLAUDE.md b/packages/seo/CLAUDE.md index 619bb10b..c4859090 100644 --- a/packages/seo/CLAUDE.md +++ b/packages/seo/CLAUDE.md @@ -19,6 +19,15 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi whether that string is a path, a storage key or a URL is the app's fact, not seo's — never add a filesystem fallback. Pixels come from `@ultimat3/core`'s pipeline; seo owns no second scaler, and the driver reports the size it probed off the output, never the size that was requested. +- **One spelling of the transform query keys.** `IMAGE_QUERY_KEYS` in `images.ts` is the only + place `w`/`f`/`q` are spelled; `defaultUrlFor` writes them and `parseImageQuery` is the only + reader — never hand-roll either half against a literal. A present-but-unusable `w` or `q` + (`?w=0`, `?q=150`, empty, negative, fractional, or so many digits that `parseInt` returns + `Infinity`) throws `X_IMAGE_QUERY_INVALID` instead of + falling back to the untransformed original — that silent fallback is the layout shift this + whole contract exists to prevent. `parseImageQuery` never validates `f` against real format + names; that refusal stays `image-driver.ts`'s `X_IMAGE_UNSUPPORTED`, so one bad URL never + carries two codes. - Only `site/` routes are SEO-checked — `app/` is behind auth and crawlers never authenticate. ## Files @@ -29,7 +38,7 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi | `meta.ts` | model + `renderMeta()`; the only place head tags are constructed | | `validate.ts` | the gate; `MetaIssue` is the serialisable projection of a `SeoError` | | `xml.ts` | all escaping. Never hand-roll an escape in another module | -| `images.ts` | what the markup promises: `srcset` widths, `` order, inlined dimensions. Decodes nothing | +| `images.ts` | what the markup promises: `srcset` widths, `` order, inlined dimensions — plus `IMAGE_QUERY_KEYS` and `parseImageQuery`, the contract that reads a minted URL back. Decodes nothing | | `image-driver.ts` | the bytes behind that promise: `ImageTransformDriver` + `builtinImageDriver({ read })` over core's pipeline — png/jpeg only | ## Commands diff --git a/packages/seo/README.md b/packages/seo/README.md index 9ba37c19..304ea99e 100644 --- a/packages/seo/README.md +++ b/packages/seo/README.md @@ -32,7 +32,7 @@ X_SEO_META_MISSING: a site/ route is missing required metadata | `sitemap.ts` | `buildSitemap()` from the route table + each route's `prerender()`, per-locale alternates, automatic index splitting past 50k | | `robots.ts` | `buildRobots()`, environment-aware and fail-closed | | `rss.ts` | `buildFeed()` → RSS 2.0 + Atom + JSON Feed from one item list | -| `images.ts` | `srcset` widths, AVIF → WebP → original, inlined intrinsic dimensions | +| `images.ts` | `srcset` widths, AVIF → WebP → original, inlined intrinsic dimensions, and `parseImageQuery()` — reads a minted URL back into a transform request | | `image-driver.ts` | `ImageTransformDriver` + `builtinImageDriver()`: the variant bytes and the blur placeholder | | `budgets.ts` | `checkBudgets()` / `assertBudgets()`, the CI gate | @@ -91,6 +91,38 @@ await images.blurPlaceholder('/img/hero.png'); browser reserves is the box the bytes fill. - `blurPlaceholder()` returns a 16px-wide PNG `data:` URI, ready for `ImageInput.blurDataUrl`. +### Reading the URL back + +`images.ts` mints `?w=&f=` query strings; `parseImageQuery()` is the only place that reads one +back, so a server route never hand-rolls its own parsing of what `responsiveImage()` wrote. + +```ts +const query = parseImageQuery(new URL(req.url).searchParams); +// null: none of w/f/q was present — a plain asset read, not a transform. +if (query !== null) { + await images.transform({ + src, + // `?f=webp` alone still needs a width, and the source's own is the only one that resizes + // nothing the caller did not ask to resize. + width: query.width ?? intrinsicWidth, + // Spread, not `format: query.format`: `TransformRequest` declares both keys optional and + // `exactOptionalPropertyTypes` refuses an explicit `undefined`. Forward all three or `?q=75` + // parses and is then silently dropped. + ...(query.format === undefined ? {} : { format: query.format }), + ...(query.quality === undefined ? {} : { quality: query.quality }), + }); +} +``` + +- **`null`** means no transform was asked for. A present-but-unusable `w` or `q` — empty, `0`, + negative, fractional, longer than an exact integer, or `q` over 100 — throws + `X_IMAGE_QUERY_INVALID` instead: serving the untransformed original against a `?w=320` URL is + the layout shift this contract exists to prevent. +- **`f` is not checked against real format names here.** `?f=potato` parses fine; `transform()` + is what refuses an unencodable format, with `X_IMAGE_UNSUPPORTED`. +- **`IMAGE_QUERY_KEYS`** (`{ width: 'w', format: 'f', quality: 'q' }`) is the one spelling of the + three keys — `defaultUrlFor` and `parseImageQuery` both read it, so the two can never drift. + ## Usage ```ts diff --git a/packages/seo/src/errors.ts b/packages/seo/src/errors.ts index 657ebdf1..f136e829 100644 --- a/packages/seo/src/errors.ts +++ b/packages/seo/src/errors.ts @@ -2,6 +2,11 @@ // are build errors, so every one names the exact route file and the exact fix. import { registerErrorCodes, UltimateError } from '@ultimat3/core'; +// errors.ts <-> images.ts: images.ts throws imageQueryInvalid() and this file spells its fix +// using images.ts's IMAGE_QUERY_KEYS. Safe like core's errors.ts <-> error-codes.ts cycle: +// nothing at this module's top level reads the import, only the factory body below does, and by +// the time that runs both modules have finished loading. +import { IMAGE_QUERY_KEYS } from './images'; export const SEO_ERROR_CODES = { metaMissing: 'X_SEO_META_MISSING', @@ -11,6 +16,7 @@ export const SEO_ERROR_CODES = { ldInvalid: 'X_LD_INVALID', budgetExceeded: 'X_SEO_BUDGET_EXCEEDED', sitemapTooLarge: 'X_SITEMAP_TOO_LARGE', + imageQueryInvalid: 'X_IMAGE_QUERY_INVALID', } as const; export type SeoErrorCode = (typeof SEO_ERROR_CODES)[keyof typeof SEO_ERROR_CODES]; @@ -30,6 +36,7 @@ registerErrorCodes({ X_LD_INVALID: { title: 'JSON-LD node is missing a required schema.org field' }, X_SEO_BUDGET_EXCEEDED: { title: 'route exceeded its performance budget' }, X_SITEMAP_TOO_LARGE: { title: 'sitemap exceeds the 50,000-entry protocol limit' }, + X_IMAGE_QUERY_INVALID: { title: 'an image transform query parameter is present but unusable' }, }); export interface SeoErrorInit { @@ -124,6 +131,31 @@ export function sitemapTooLarge(count: number, max: number): SeoError { }); } +/** + * `parseImageQuery`'s only refusal: a `?w=`/`?q=`/`?f=` value present but not usable — serving + * the untransformed original against a URL that asked for a size would be the layout shift this + * contract exists to prevent, so an unusable value throws instead of falling back silently. A + * format string naming no *real* encoder is a different failure (`image-driver.ts`'s + * `X_IMAGE_UNSUPPORTED`); this code never covers it. + * + * The `fix` is written as an inline ternary, not a helper call, so the `errors` gate step can + * still read each branch as a literal — a `fix` computed behind a function call has nothing for + * a static scan to check, and the gate would silently wave the whole thing through. + */ +export function imageQueryInvalid(param: string, value: string, reason: string): SeoError { + return new SeoError({ + code: SEO_ERROR_CODES.imageQueryInvalid, + cause: `?${param}=${value} is not usable: ${reason}`, + fix: + param === IMAGE_QUERY_KEYS.quality + ? `request ?${IMAGE_QUERY_KEYS.quality}=75 — a whole number from 1 to 100` + : param === IMAGE_QUERY_KEYS.format + ? `request ?${IMAGE_QUERY_KEYS.format}=webp — a non-empty format name` + : `request ?${IMAGE_QUERY_KEYS.width}=640 with a positive integer width`, + meta: { param, value, reason }, + }); +} + /** * The vocabulary a **user-supplied** `ImageTransformDriver` uses to report a capability it * does not implement — a CDN driver with no blur endpoint, say. `builtinImageDriver` needs it diff --git a/packages/seo/src/image-driver.test.ts b/packages/seo/src/image-driver.test.ts index 5f6041bc..6d25fdf3 100644 --- a/packages/seo/src/image-driver.test.ts +++ b/packages/seo/src/image-driver.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from 'bun:test'; import { createRaster, encodeImage, probeImage, type Raster } from '@ultimat3/core'; import { notImplementedDriver } from './errors'; -import { builtinImageDriver } from './image-driver'; +import { builtinImageDriver, type TransformedImage } from './image-driver'; +import { IMAGE_QUERY_KEYS, parseImageQuery } from './images'; /** A flat 64x48 PNG. `alpha: 255` is opaque; anything less makes the raster alpha-bearing. */ function pngSource(alpha: number): Uint8Array { @@ -99,6 +100,36 @@ describe('builtinImageDriver', () => { ).toMatchObject({ format: 'png', width: 16 }); }); + /** + * The README's route example, verbatim in shape: `parseImageQuery` → `transform`. It lives here + * because a snippet nothing compiles is a snippet that drifts — this one dropped `quality`, so a + * route copied from the docs answered `?q=40` with the default encode and no error anywhere. + */ + test('the documented route shape forwards every key parseImageQuery returns', async () => { + const driver = builtinImageDriver({ read: reader(OPAQUE).read }); + const encode = async (search: string): Promise => { + const query = parseImageQuery(new URL(`https://x.test/media/hero.png${search}`).searchParams); + if (query === null) return expect.unreachable('the URL carries a transform'); + return await driver.transform({ + src: '/img/hero.png', + width: query.width ?? 64, + ...(query.format === undefined ? {} : { format: query.format }), + ...(query.quality === undefined ? {} : { quality: query.quality }), + }); + }; + + const coarse = await encode( + `?${IMAGE_QUERY_KEYS.width}=64&${IMAGE_QUERY_KEYS.format}=jpeg&${IMAGE_QUERY_KEYS.quality}=20`, + ); + const fine = await encode( + `?${IMAGE_QUERY_KEYS.width}=64&${IMAGE_QUERY_KEYS.format}=jpeg&${IMAGE_QUERY_KEYS.quality}=95`, + ); + expect(coarse.contentType).toBe('image/jpeg'); + expect(coarse.width).toBe(64); + // A dropped `quality` makes these two identical, which is the failure the docs example had. + expect(coarse.bytes.length).toBeLessThan(fine.bytes.length); + }); + test('reads once per call, with the exact src it was handed', async () => { const io = reader(OPAQUE); const driver = builtinImageDriver({ read: io.read }); diff --git a/packages/seo/src/images.test.ts b/packages/seo/src/images.test.ts index d61ed9a5..0a7da1af 100644 --- a/packages/seo/src/images.test.ts +++ b/packages/seo/src/images.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { renderPicture, responsiveImage, usableWidths } from './images'; +import { + IMAGE_QUERY_KEYS, + parseImageQuery, + renderPicture, + responsiveImage, + usableWidths, +} from './images'; const INPUT = { src: '/img/hero.jpg', width: 1200, height: 630, alt: 'Ultimate dashboard' }; @@ -14,8 +20,8 @@ describe('responsiveImage', () => { test('offers AVIF before WebP before the original', () => { const image = responsiveImage(INPUT); expect(image.sources.map((source) => source.type)).toEqual(['image/avif', 'image/webp']); - expect(image.sources[0]?.srcset).toContain('f=avif'); - expect(image.img.srcset).not.toContain('f='); + expect(image.sources[0]?.srcset).toContain(`${IMAGE_QUERY_KEYS.format}=avif`); + expect(image.img.srcset).not.toContain(`${IMAGE_QUERY_KEYS.format}=`); }); test('never upscales past the intrinsic width', () => { @@ -44,3 +50,92 @@ describe('responsiveImage', () => { expect(html).toContain('alt="Ultimate dashboard"'); }); }); + +/** `toBeUltimateError` reads a value, and every rejection here throws synchronously. */ +function caught(fn: () => unknown): unknown { + try { + fn(); + return undefined; + } catch (error) { + return error; + } +} + +describe('parseImageQuery', () => { + test('round-trips the exact width and format a minted srcset URL carries', () => { + const image = responsiveImage(INPUT); + const firstEntry = image.sources[0]?.srcset.split(', ')[0]?.split(' ')[0] ?? ''; + const url = new URL(firstEntry, 'https://x.test'); + expect(parseImageQuery(url.searchParams)).toEqual({ width: 320, format: 'avif' }); + }); + + test('a URL with none of the three keys is a plain asset read, not a transform', () => { + expect(parseImageQuery(new URL('https://x.test/img/hero.jpg').searchParams)).toBeNull(); + }); + + test('accepts width, format and quality together', () => { + const params = new URLSearchParams({ + [IMAGE_QUERY_KEYS.width]: '640', + [IMAGE_QUERY_KEYS.format]: 'webp', + [IMAGE_QUERY_KEYS.quality]: '75', + }); + expect(parseImageQuery(params)).toEqual({ width: 640, format: 'webp', quality: 75 }); + }); + + test('an empty, non-numeric, zero, negative or fractional width throws', () => { + for (const bad of ['', 'abc', '0', '-5', '12.5']) { + const error = caught(() => + parseImageQuery(new URLSearchParams({ [IMAGE_QUERY_KEYS.width]: bad })), + ); + expect(error).toBeUltimateError('X_IMAGE_QUERY_INVALID'); + } + }); + + /** + * Digits all the way down still parse: `Number.parseInt('9'.repeat(400))` is `Infinity`, which + * satisfies every "is it a positive integer" test and then reaches the driver as a width no + * pipeline can allocate. The refusal has to be a range check, not a shape check. + */ + test('a width too long to be a number throws instead of parsing to Infinity', () => { + for (const key of [IMAGE_QUERY_KEYS.width, IMAGE_QUERY_KEYS.quality]) { + const error = caught(() => + parseImageQuery(new URLSearchParams({ [key]: '9'.repeat(400) })), + ) as { code?: string; meta?: Record }; + expect(error).toBeUltimateError('X_IMAGE_QUERY_INVALID'); + expect(error.meta?.['param']).toBe(key); + } + }); + + test('a quality above 100, or otherwise unusable, throws the same code', () => { + for (const bad of ['101', '1000', 'abc', '0', '-5']) { + const error = caught(() => + parseImageQuery(new URLSearchParams({ [IMAGE_QUERY_KEYS.quality]: bad })), + ); + expect(error).toBeUltimateError('X_IMAGE_QUERY_INVALID'); + } + }); + + test('a present but empty format throws', () => { + const error = caught(() => + parseImageQuery(new URLSearchParams({ [IMAGE_QUERY_KEYS.format]: '' })), + ); + expect(error).toBeUltimateError('X_IMAGE_QUERY_INVALID'); + }); + + test('the fix line names a usable value, not just the code', () => { + const error = caught(() => + parseImageQuery(new URLSearchParams({ [IMAGE_QUERY_KEYS.width]: '0' })), + ) as { + fix?: string; + cause?: string; + }; + expect(error.cause).toContain(`${IMAGE_QUERY_KEYS.width}=0`); + expect(error.fix).toContain(`${IMAGE_QUERY_KEYS.width}=640`); + }); + + test('a format naming no real format is not rejected here — the driver owns that refusal', () => { + expect(parseImageQuery(new URLSearchParams({ [IMAGE_QUERY_KEYS.format]: 'potato' }))).toEqual({ + format: 'potato', + }); + }); +}); diff --git a/packages/seo/src/images.ts b/packages/seo/src/images.ts index 47637444..d21090de 100644 --- a/packages/seo/src/images.ts +++ b/packages/seo/src/images.ts @@ -3,6 +3,7 @@ // so the browser reserves the box before the bytes arrive, keeping CLS at 0. Producing those // bytes is `image-driver.ts`; nothing here decodes a pixel. +import { imageQueryInvalid } from './errors'; import { attributes, escapeAttribute } from './xml'; /** Ordered widest-first is wrong for `srcset`; browsers want ascending. */ @@ -64,7 +65,7 @@ export interface ResponsiveImage { export interface ResponsiveImageOptions { widths?: readonly number[]; formats?: readonly ModernFormat[]; - /** Builds the URL for one variant. Defaults to `?w=&f=` query parameters. */ + /** Builds the URL for one variant. Defaults to `IMAGE_QUERY_KEYS` query parameters (`?w=&f=`). */ urlFor?: (src: string, width: number, format?: string) => string; } @@ -72,9 +73,80 @@ export function extensionOf(src: string): string { return (src.split('?')[0]?.split('.').pop() ?? '').toLowerCase(); } +/** + * The one spelling of the transform query keys. `defaultUrlFor` writes them and + * `parseImageQuery` reads them back — a literal `'w'` in one place and a literal `'w'` in the + * other is how a rename of one silently stops answering the other's URLs. + */ +export const IMAGE_QUERY_KEYS = { width: 'w', format: 'f', quality: 'q' } as const; + function defaultUrlFor(src: string, width: number, format?: string): string { const separator = src.includes('?') ? '&' : '?'; - return `${src}${separator}w=${width}${format === undefined ? '' : `&f=${format}`}`; + // Both keys read from IMAGE_QUERY_KEYS, never a literal 'w'/'f' — see the constant above. + const widthParam = `${IMAGE_QUERY_KEYS.width}=${width}`; + const formatParam = format === undefined ? '' : `&${IMAGE_QUERY_KEYS.format}=${format}`; + return `${src}${separator}${widthParam}${formatParam}`; +} + +export interface ImageQuery { + readonly width?: number | undefined; + readonly format?: string | undefined; + readonly quality?: number | undefined; +} + +/** + * `w` and `q` share one shape: digits only, so `/^[1-9]\d*$/` rejects an empty string, `"0"`, a + * negative sign and a fractional point in a single test instead of four checks that could each + * drift out of sync with the others. + * + * Digits alone are still not a number, which is why the range gate is here and not only in + * `parseQuality`: 400 of them parse to `Infinity`, and `Infinity > 0` passes every positive-integer + * test there is, so `?w=999…9` used to reach the driver as a width nothing can allocate. + */ +function parsePositiveInt(param: string, raw: string): number { + if (!/^[1-9]\d*$/.test(raw)) throw imageQueryInvalid(param, raw, 'must be a positive integer'); + const value = Number.parseInt(raw, 10); + if (!Number.isSafeInteger(value)) { + throw imageQueryInvalid(param, raw, 'is past the largest integer a pixel count can hold'); + } + return value; +} + +function parseQuality(raw: string): number { + const quality = parsePositiveInt(IMAGE_QUERY_KEYS.quality, raw); + if (quality > 100) throw imageQueryInvalid(IMAGE_QUERY_KEYS.quality, raw, 'must be 100 or less'); + return quality; +} + +/** + * Naming no *real* format is deliberately not refused here: `image-driver.ts`'s + * `requestedFormat` already owns "is this an encodable format", and throwing in two places + * would give one bad URL two different error codes depending on which module ran first. This + * only refuses the one thing that is unambiguously this module's fact — the key was present and + * empty. + */ +function parseFormat(raw: string): string { + if (raw === '') + throw imageQueryInvalid(IMAGE_QUERY_KEYS.format, raw, 'must be a non-empty string'); + return raw; +} + +/** + * `null` means no transform was asked for — a plain asset read, not a bad request. An + * asked-for-but-unusable value throws instead, because silently serving the full-size + * original against a `?w=320` URL is the layout shift this contract exists to prevent. + */ +export function parseImageQuery(params: URLSearchParams): ImageQuery | null { + const rawWidth = params.get(IMAGE_QUERY_KEYS.width); + const rawFormat = params.get(IMAGE_QUERY_KEYS.format); + const rawQuality = params.get(IMAGE_QUERY_KEYS.quality); + if (rawWidth === null && rawFormat === null && rawQuality === null) return null; + + return { + ...(rawWidth === null ? {} : { width: parsePositiveInt(IMAGE_QUERY_KEYS.width, rawWidth) }), + ...(rawFormat === null ? {} : { format: parseFormat(rawFormat) }), + ...(rawQuality === null ? {} : { quality: parseQuality(rawQuality) }), + }; } /** Never upscale: drop candidate widths above the intrinsic width. */ diff --git a/packages/seo/src/index.ts b/packages/seo/src/index.ts index 34dcebf4..6e08540a 100644 --- a/packages/seo/src/index.ts +++ b/packages/seo/src/index.ts @@ -7,6 +7,7 @@ export { budgetExceeded, canonicalMismatch, duplicateMeta, + imageQueryInvalid, ldInvalid, metaMissing, metaTooLong, @@ -24,6 +25,7 @@ export type { export { builtinImageDriver } from './image-driver'; export type { ImageInput, + ImageQuery, ImageSourceSet, ModernFormat, ResponsiveImage, @@ -33,8 +35,10 @@ export { DEFAULT_WIDTHS, extensionOf, FORMAT_ORDER, + IMAGE_QUERY_KEYS, inlineBlur, MIME_TYPES, + parseImageQuery, renderPicture, responsiveImage, srcsetFor, diff --git a/packages/storage/CLAUDE.md b/packages/storage/CLAUDE.md index 43be5a16..9661bb05 100644 --- a/packages/storage/CLAUDE.md +++ b/packages/storage/CLAUDE.md @@ -24,6 +24,7 @@ Tier 2. Object storage: named disks, safe keys, signed URLs, sniffed uploads. | `signed-url.ts` | HMAC over the constraint tuple, constant-time verify | | `upload.ts` | magic-byte sniff + size/allowlist/checksum policy | | `image.ts` | deterministic variant keys; byte path over core's pipeline (png/jpeg encode only) | +| | `variantKey` is the cache identity `@ultimat3/cli`'s `/media/*` route looks a variant up by — derived, never stored, so a request that misses transforms once and every later one is a disk read | | `storage.ts` | `defineStorage` + module-level `storage()` / `disk()` | ```bash diff --git a/packages/testing/src/errors.ts b/packages/testing/src/errors.ts index f780a967..795386b0 100644 --- a/packages/testing/src/errors.ts +++ b/packages/testing/src/errors.ts @@ -9,6 +9,10 @@ export const TESTING_ERROR_CODES = [ 'X_TEST_NONDETERMINISTIC', 'X_TEST_FIXTURE_UNKNOWN', 'X_TEST_FIXTURE_UNAVAILABLE', + 'X_TEST_EVAL_THRESHOLD', + 'X_TEST_SCHEMA_EXPECTED', + 'X_TEST_JOB_EXPECTED', + 'X_TEST_NETWORK_RACE', ] as const; export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number]; @@ -20,6 +24,10 @@ export const TESTING_ERROR_TITLES: Readonly> = X_TEST_NONDETERMINISTIC: 'a test read wall-clock time or unseeded randomness', X_TEST_FIXTURE_UNKNOWN: 'a test requested a fixture nobody registered', X_TEST_FIXTURE_UNAVAILABLE: 'a declared fixture has no driver in this process', + X_TEST_EVAL_THRESHOLD: 'an evalTest() score fell below its threshold', + X_TEST_SCHEMA_EXPECTED: 'a matcher expected a Standard Schema and got something else', + X_TEST_JOB_EXPECTED: 'a matcher expected a job declaration and got something else', + X_TEST_NETWORK_RACE: 'a request raced unsealNetwork() and lost the patched fetch', }; // Titles must be registered for `format()` to render the contract's first line. Every code above is @@ -137,3 +145,58 @@ export class NetworkOfflineError extends UltimateError { }); } } + +/** `evalTest()`'s score fell below its declared threshold. A test failure, not a warning. */ +export class TestEvalThresholdError extends UltimateError { + constructor(input: { name: string; threshold: number; detail: string }) { + super({ + code: 'X_TEST_EVAL_THRESHOLD', + cause: `eval "${input.name}" scored below ${input.threshold}: ${input.detail}`, + fix: 'improve the prompt under test, or lower the threshold passed to evalTest()', + docs: docsFor('X_TEST_EVAL_THRESHOLD'), + }); + } +} + +/** `toRejectInput`/`toAcceptInput` were handed something other than a Standard Schema. */ +export class TestSchemaExpectedError extends UltimateError { + constructor() { + super({ + code: 'X_TEST_SCHEMA_EXPECTED', + cause: 'toRejectInput/toAcceptInput expect a Standard Schema (`t`), not the action', + // Names the call, not the intent: "assert against action.input" left the reader to work out + // which call to edit, and a fix is only executable if it can be pasted over the failing line. + fix: 'call toRejectInput(action.input) — the schema, not toRejectInput(action) or the query', + docs: docsFor('X_TEST_SCHEMA_EXPECTED'), + }); + } +} + +/** `toEmitSteps`/`recordSteps` were handed something other than a job declaration. */ +export class TestJobExpectedError extends UltimateError { + constructor() { + super({ + code: 'X_TEST_JOB_EXPECTED', + cause: 'toEmitSteps expects a job declaration built with job(...)', + // Same rule as X_TEST_SCHEMA_EXPECTED's: the paste-able call, not a description of it. + fix: 'call toEmitSteps(myJob) with the job export, not toEmitSteps(myJob.run)', + docs: docsFor('X_TEST_JOB_EXPECTED'), + }); + } +} + +/** + * `sealNetwork()` always sets the original `fetch` before installing its patch, so this can only + * fire if `unsealNetwork()` ran concurrently with a request from the same seal — a race, not a + * reachable steady state. + */ +export class NetworkRaceError extends UltimateError { + constructor() { + super({ + code: 'X_TEST_NETWORK_RACE', + cause: 'sealed network lost its original fetch mid-request', + fix: 'do not call unsealNetwork() while a request from the same test is still in flight', + docs: docsFor('X_TEST_NETWORK_RACE'), + }); + } +} diff --git a/packages/testing/src/matchers.ts b/packages/testing/src/matchers.ts index 2f98a274..6c3fe4af 100644 --- a/packages/testing/src/matchers.ts +++ b/packages/testing/src/matchers.ts @@ -3,6 +3,7 @@ // a test ends up asserting on the wrong branch. import { expect } from 'bun:test'; +import { TestJobExpectedError, TestSchemaExpectedError } from './errors'; import type { OpenApiLike } from './test-types'; export interface MatcherResult { @@ -31,9 +32,7 @@ const isStandardSchema = (value: unknown): value is StandardSchema => async function hasIssues(schema: unknown, input: unknown): Promise { if (!isStandardSchema(schema)) { - throw new TypeError( - 'toRejectInput expects a Standard Schema (`t`) — pass action.input, not the action', - ); + throw new TestSchemaExpectedError(); } const result = await schema['~standard'].validate(input); const issues = result.issues; @@ -88,7 +87,7 @@ const isJob = (value: unknown): value is JobLike => /** Runs the job with a recording step API, so the assertion is on the sequence, not the effects. */ export async function recordSteps(job: unknown, input: unknown = {}): Promise { - if (!isJob(job)) throw new TypeError('toEmitSteps expects a job declaration'); + if (!isJob(job)) throw new TestJobExpectedError(); const names: string[] = []; const step: StepRecorder = { run: async (name, body) => { diff --git a/packages/testing/src/sealed-network.ts b/packages/testing/src/sealed-network.ts index 54d3eec4..208031f1 100644 --- a/packages/testing/src/sealed-network.ts +++ b/packages/testing/src/sealed-network.ts @@ -3,7 +3,7 @@ // for reasons nobody can reproduce — so the default is "nothing gets out". import { isSelfOrigin } from '@ultimat3/core'; -import { NetworkOfflineError, NetworkSealedError } from './errors'; +import { NetworkOfflineError, NetworkRaceError, NetworkSealedError } from './errors'; export type FetchLike = typeof globalThis.fetch; @@ -79,7 +79,7 @@ export function sealNetwork(): void { const host = safeHost(url); if (isSelfOrigin(url) || (host !== undefined && state.allowed.has(host))) { const original = state.original; - if (original === undefined) throw new TypeError('sealed network lost its original fetch'); + if (original === undefined) throw new NetworkRaceError(); return original(input, init); } throw new NetworkSealedError({ diff --git a/packages/testing/src/test-types.ts b/packages/testing/src/test-types.ts index 97c0f9c3..b3d58663 100644 --- a/packages/testing/src/test-types.ts +++ b/packages/testing/src/test-types.ts @@ -3,6 +3,7 @@ // failure line say which of the six steps it belongs to. See packages/cli/src/verify-tests.ts. import { test } from 'bun:test'; +import { TestEvalThresholdError } from './errors'; export const TEST_TYPES = ['unit', 'contract', 'live', 'job', 'e2e', 'eval'] as const; @@ -129,9 +130,7 @@ export function evalTest( const failures = scores.filter((entry) => entry.score < options.threshold); if (failures.length > 0) { const detail = failures.map((entry) => `${entry.name}=${entry.score.toFixed(2)}`).join(', '); - throw new Error( - `eval "${name}" scored below ${options.threshold}: ${detail} — improve the prompt or lower the declared tolerance`, - ); + throw new TestEvalThresholdError({ name, threshold: options.threshold, detail }); } }); } diff --git a/wiki/Caching-And-Invalidation.md b/wiki/Caching-And-Invalidation.md index 417bcd39..87752ed5 100644 --- a/wiki/Caching-And-Invalidation.md +++ b/wiki/Caching-And-Invalidation.md @@ -76,7 +76,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 | **independent path** — realtime does not depend on cache invalidation | Fanout is enqueued in the **same transaction** as the write — the transactional outbox from [Jobs and workflows](Jobs-And-Workflows). A rolled-back write never purges; a committed write always does. @@ -98,6 +98,28 @@ The bug is never "the cache is wrong". The bug is that invalidation is a *decisi Agents are measurably bad at *distant* invariants — "edit here, remember to also edit there" is where LLM-written code regresses most. Declaring `invalidates` at the write site is local, checkable, and typed. +## The CDN leg + +The CDN is the one tier Ultimate never reads back from, so the emitted header and the purge call +are the whole contract. `cacheHeaders()` writes the surrogate keys, and they are the tag strings +unchanged — `post`, `post:1` — which is what keeps an edge purge from ever meaning something +different than an `invalidates: [tag.post]`. + +| Driver | Purge | Purge all | Per call | +|---|---|---|---| +| `noopPurgeDriver()` | echoes the keys back | resolves | — | +| `fastlyPurgeDriver({ apiToken, serviceId })` | `POST /service//purge` with `surrogate_keys` | `POST /service//purge_all` | 256 keys | +| `cloudflarePurgeDriver({ apiToken, zoneId })` | `POST /zones//purge_cache` with `tags` | same call with `purge_everything` | 30 tags | + +Which one a process installs is decided from the environment — `FASTLY_API_TOKEN` + +`FASTLY_SERVICE_ID`, or `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ZONE_ID`. See +[Configuration → CDN purge](Configuration#cdn-purge). With neither pair set, nothing is purged and +no `cdn` line appears in the invalidation report: a tier that reported keys an edge that does not +exist had accepted would be worse than no tier at all. + +A refusal is `X_CACHE_PURGE_FAILED` with `meta.retryable`, collected into `report.errors` — a dead +CDN never fails the write that triggered the bust, and the entry expires by TTL instead. + ## Semantic cache for LLM calls Model calls are slow and metered; exact-match caching almost never hits because prompts differ by a word. @@ -142,7 +164,8 @@ Also cached exactly (tier 3, not semantic): embeddings themselves, keyed by cont | `X_CACHE_UNTAGGED_QUERY` | **reserved, nothing raises it** `As of 2026-08` — a query's tables are covered by no tag, so it could never be invalidated ([Error codes → Reserved codes](Error-Codes#reserved-codes)) | declare the entity tag, then `x manifest` | | `X_CACHE_TAG_UNKNOWN` | `tag "" is not declared by any entity` | `x manifest` | | `X_CACHE_TOO_LARGE` | `entry "" is B, over the budget of B` | `raise cache..maxBytes in app.config.ts, or cache a projection instead of the row` | -| `X_CACHE_DRIVER_UNAVAILABLE` | `cache tier "" is unavailable` — no Redis binding, no CDN token | the error carries the exact config or command to fix | +| `X_CACHE_DRIVER_UNAVAILABLE` | `cache tier "" is unavailable` — no Redis binding, or a purge driver built without its token | the error carries the exact config or command to fix | +| `X_CACHE_PURGE_FAILED` | ` refused the purge (HTTP )` — a wrong token, a zone without tag purge, a throttle, or a key a CDN would split | `meta.retryable === true` → the identical purge can land again; otherwise set the env key the `fix` names | Verbatim shapes: [`packages/cache/src/errors.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cache/src/errors.ts). Full index: [Error codes](Error-Codes). diff --git a/wiki/Configuration.md b/wiki/Configuration.md index ac67f680..0179d3e9 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -112,8 +112,36 @@ Tiers are read in fixed order `memo → lru → redis → cdn → origin`. See [ | `cache.ttl.lru` | duration | `'60s'` | | | `cache.ttl.redis` | duration | `'15m'` | | | `cache.ttl.cdn` | duration | `'1h'` | emitted as `Cache-Control` + `stale-while-revalidate` | -| `cache.cdn.purge.webhook` | `string` | — | the only CDN coupling. Tag-driven purge-by-URL | -| `cache.cdn.purge.secretEnv` | `string` | — | env key holding the signing secret | + +### CDN purge + +The purge driver is selected from the **environment**, not from a config field — the same law the +mail transports follow, and for the same reason: nothing loads `app.config.ts`'s contents at +runtime, so one image deploys to every environment. + +| Key | Selects | Notes | +|---|---|---| +| `FASTLY_API_TOKEN` + `FASTLY_SERVICE_ID` | Fastly | batch surrogate-key purge, 256 keys per call | +| `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ZONE_ID` | Cloudflare | cache-tag purge, 30 tags per call, Enterprise zones | + +The surrogate keys are the tags — `post`, `post:1` — so the edge purges exactly what +`invalidates: [tag.post]` busts. + +| Failure | Code | Raised by | Lands | +|---|---|---|---| +| both pairs set — two CDNs claim one purge | `X_CONFIG_INVALID` | `selectPurgeDriver` | boot | +| half a pair — a token with no id, or an id with no token | `X_CONFIG_INVALID` | `selectPurgeDriver` | boot | +| the provider refused — 401, 429, `success: false` | `X_CACHE_PURGE_FAILED` | the purge driver | `report.errors`, never the write | + +Half a pair is refused because "no CDN" is the one wrong reading — a deployment then ships +believing it purges. Both refusals name the keys that are actually set, never their values. + +`X_CONFIG_INVALID` covers a configuration that cannot boot, env **or** `app.config.ts` +([Env vars](#env-vars)). `X_CACHE_PURGE_FAILED` is provider refusal only — never a configuration +problem, and never fatal to the write that triggered the bust. + +`x dev` prints which one it installed — `cdn=none`, or `cdn=external(fastly via +FASTLY_API_TOKEN)`. The env **key** is reported, never its value. ## `pwa` @@ -158,13 +186,31 @@ Per surface, overridable per route via `budget` on `defineRoute`. | `budgets.app.cls` | number | `0.1` | | | `budgets.precache` | size | `'3mb'` | mirrors `pwa.precache.maxBytes` | -## `mail`, `storage`, `otel` +## `mail` + +Not an `app.config.ts` block. The transport is selected by **environment**, like every other +external service — an unset variable means the embedded default, so the same image deploys +everywhere and no credential is ever committed. + +| env key | selects | notes | +|---|---|---| +| *(none set)* | memory | caught, never sent; the `/_x` mail panel reads this outbox | +| `SMTP_URL` | SMTP | `smtps://user:pass@host:465`, or `smtp://host:587` for STARTTLS | +| `RESEND_API_KEY` | Resend | one `POST /emails` per message, with an `Idempotency-Key` | +| `MAIL_FROM` | — | required by both transports. `Name `; also the envelope sender and the `Message-ID` domain | +| `MAIL_POOL_SIZE` | — | SMTP connections open at once. Default `4`, whole number ≥ 1 | + +Setting `SMTP_URL` **and** `RESEND_API_KEY` is `X_CONFIG_INVALID`: a process delivers through +exactly one transport, and picking a winner would send half of an operator's mail the wrong way. +A transport without `MAIL_FROM` is refused at boot rather than on the first send. + +`x dev` prints which one it installed — `mail=embedded`, or `mail=external(smtp via SMTP_URL)`. +The env **key** is reported, never its value, because `SMTP_URL` carries a password. + +## `storage`, `otel` | field | type | default | notes | |---|---|---|---| -| `mail.from` | `string` | required if mail is used | `Name `; also the envelope sender and the `Message-ID` domain | -| `mail.driver` | `'smtp' \| 'resend' \| 'log' \| 'memory'` | `'log'` in dev, `'smtp'` otherwise | sends are always a `job` | -| `mail.url` | `string` | — | `SMTP_URL` from env: `smtps://user:pass@host:465`, or `smtp://host:587` for STARTTLS | | `storage.driver` | `'s3' \| 'local'` | `'local'` in dev, `'s3'` otherwise | `s3` is `Bun.s3`; `local` is a directory | | `storage.bucket` | `string` | — | required for `s3`; `local` uses `storage.dir`, default `'.x/storage'` | | `otel.endpoint` | `string` | — | OTLP collector. Absent = spans still recorded, exported nowhere | @@ -206,7 +252,8 @@ Rules: |---|---| | Secrets are env or a mounted file | the framework never talks to a vendor secret API ([axiom 7](Home)) | | `env.X` reads through `defineEnv`'s schema | a declared key that is missing or malformed is `X_ENV_MISSING` at boot, every offender in one error. A `process.env` read outside the schema is a lint error, never a runtime one | -| `X_CONFIG_INVALID` is `app.config.ts` only | it is what `defineConfig`'s own validation throws — a bad locale, an unknown time zone, `poolSize < 1`. Env failures never carry it | +| `X_CONFIG_INVALID` is env **and** `app.config.ts` | one code for a configuration that cannot boot: what `defineConfig`'s own validation throws — a bad locale, an unknown time zone, `poolSize < 1` — and any env **combination** no boot can resolve, thrown by the selector that reads it. Both CDN pairs or half a pair (`selectPurgeDriver`), `SMTP_URL` + `RESEND_API_KEY` or a transport with no `MAIL_FROM` (`selectMailDriver`) | +| `X_ENV_MISSING` is one key, `X_CONFIG_INVALID` is the shape | absent or malformed key → `X_ENV_MISSING` at the `defineEnv` gate. Keys that each parse but contradict each other → `X_CONFIG_INVALID`. The two never overlap | | No runtime mutation | config is frozen after `defineConfig`; there is no `setConfig` | | Same image, all environments | only env differs. That is what makes staging a real rehearsal ([Deployment](Deployment)) | diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index b13d6f27..77877303 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -60,7 +60,7 @@ One pipeline in `@ultimat3/core` serves `storage`, `seo` and `pwa`. It **decodes | Code | Means | Typical cause | Fix | |---|---|---|---| -| `X_CONFIG_INVALID` | `app.config.ts` is invalid | a field failed its schema, or a required field is missing | `x config show --json` and fix the named field; see [Configuration](Configuration) | +| `X_CONFIG_INVALID` | a configuration this process cannot boot on — env **or** `app.config.ts` | a field failed its schema, a required field is missing, or two keys that each parse contradict each other (`selectMailDriver`, `selectPurgeDriver`) | `x doctor --json` and fix the named key; see [Configuration](Configuration) | | `X_ENV_MISSING` | required environment variables are missing or invalid | a key absent at boot; validation runs before the server listens | `x env check --fix`, then set the keys it names | | `X_BUN_VERSION` | Bun is older than the framework floor | Bun < 1.3 | `bun upgrade` | | `X_NOT_IN_APP` | command must run inside an Ultimate app | no `app.config.ts` at or above the cwd | `x new myapp && cd myapp` | @@ -186,7 +186,8 @@ A denial is `X_FORBIDDEN`, above — `@ultimat3/policy` owns it and every surfac |---|---|---|---| | `X_CACHE_TAG_UNKNOWN` | a tag no entity declared | typo in `invalidates: [tag.pots]` | `x manifest` to regenerate the tag graph, then fix the tag | | `X_CACHE_TOO_LARGE` | one entry exceeds the tier's byte budget | caching a whole row set | raise `cache..maxBytes`, or cache a projection | -| `X_CACHE_DRIVER_UNAVAILABLE` | a tier's backing store is missing | no Redis binding, no CDN token | provision the tier, or drop it from `app.config.ts` | +| `X_CACHE_DRIVER_UNAVAILABLE` | a tier's backing store is missing | no Redis binding, or a purge driver built without `FASTLY_API_TOKEN` / `CLOUDFLARE_API_TOKEN` | provision the tier, or drop it from `app.config.ts` | +| `X_CACHE_PURGE_FAILED` | the CDN refused a purge | a wrong or unscoped API token, a zone without tag purge, a throttle, a key carrying whitespace or a comma | `meta.retryable === true` → the identical purge can land again; `false` → set the env key the `fix` names, then `x dev` | ## Storage @@ -231,6 +232,7 @@ A denial is `X_FORBIDDEN`, above — `@ultimat3/policy` owns it and every surfac | `X_LD_INVALID` | JSON-LD node is missing a required schema.org field | an `ld.*` helper called with a partial object | supply the field named in `cause` | | `X_SEO_BUDGET_EXCEEDED` | route exceeded its performance budget | a `js`/`css`/`lcp`/`cls`/`inp` budget broken in the SEO report | `x routes --json` for the route's budget, then cut the regression `cause` names | | `X_SITEMAP_TOO_LARGE` | sitemap exceeds the 50,000-entry limit | too many prerendered URLs in one file | enable sitemap index splitting in `app.config.ts` | +| `X_IMAGE_QUERY_INVALID` | a minted image URL's `?w=`/`?q=` value is present but unusable | `?w=0`, `?w=-5`, `?q=150` on a URL `responsiveImage()` minted | request a positive integer, e.g. `?w=640` (quality is `1`-`100`, e.g. `?q=75`) | ## PWA and build skew @@ -241,6 +243,9 @@ A denial is `X_FORBIDDEN`, above — `@ultimat3/policy` owns it and every surfac | `X_PWA_MANIFEST_INVALID` | the generated web manifest failed validation | a bad `start_url` or `scope` | fix the `pwa` block; `cause` names the field | | `X_SW_SCOPE_INVALID` | the service-worker scope cannot serve the routes it precaches | a scope narrower than the app | serve `sw.js` from the app root | | `X_BUILD_ID_MISSING` | no immutable build ID | a build produced outside `x build` | build with `x build`; never use a timestamp or `latest` | +| `X_PWA_STRATEGY_EXHAUSTED` | a caching strategy had no cache, no network and no fallback | `staleWhileRevalidate` with no `StrategyOptions.fallback` and the network failed | pass `fallback` to the strategy, or set `pwa.offline.fallback` | +| `X_PWA_SYNC_FLUSH_FAILED` | the background-sync outbox flush was rejected | `POST /_x/outbox/flush` returned a non-2xx | confirm `@ultimat3/realtime` mounts the flush endpoint and it returns 2xx on success | +| `X_PWA_SYNC_INCOMPLETE` | the background-sync outbox flush left mutations queued | the flush endpoint reported `remaining > 0` | check the realtime outbox worker is draining, or raise the sync retry ceiling | ## i18n, money, time @@ -306,6 +311,7 @@ A denial is `X_FORBIDDEN`, above — `@ultimat3/policy` owns it and every surfac | `X_EVAL_RECORDING` | the gate ran with baseline recording switched on | `ULTIMATE_EVAL_RECORD` was exported in the shell, or set on the CI job, that ran `x verify` | `env -u ULTIMATE_EVAL_RECORD x verify` — record with `ULTIMATE_EVAL_RECORD=1 x test eval` instead | | `X_VECTOR_DIM_MISMATCH` | embedding dimensions differ from the store | the embedder model changed | use the original embedder, or `x ai reindex` | | `X_VECTOR_SCOPE_WIDENED` | a derived vector scope tried to leave its tenant | a handler re-scoped the store it was handed | derive from the unscoped store: `vectorStore.scoped({ tenant })` | +| `X_AI_EMBEDDER_INVALID` | an `Embedder` returned fewer vectors than texts it was given | `embedOne` got an empty batch back from `embed([text])` | return one vector per input text from `embed()`, in the order the texts arrived | ## Admin and manifest @@ -334,6 +340,10 @@ A denial is `X_FORBIDDEN`, above — `@ultimat3/policy` owns it and every surfac | `X_TEST_NETWORK_OFFLINE` | the test network is offline | a request made after `network.offline()` or `network.drop()` | `network.online()` before the call — or assert the offline path instead of the request | | `X_TEST_NETWORK_SEALED` | a test tried to reach the network | an unmocked external call | `mockFetch('', …)`, or `allowHost('')` if it must be real | | `X_TEST_NONDETERMINISTIC` | a test read wall-clock time or unseeded randomness | `Date.now()` in the code under test | wrap in `frozenClock()` / `seededRandom()`, or remove the read | +| `X_TEST_EVAL_THRESHOLD` | an `evalTest()` score fell below its threshold | a prompt or output regressed against `options.threshold` | improve the prompt under test, or lower the threshold passed to `evalTest()` | +| `X_TEST_SCHEMA_EXPECTED` | a matcher expected a Standard Schema and got something else | `toRejectInput`/`toAcceptInput` called on an action instead of `action.input` | `call toRejectInput(action.input)` — the schema, not the action or the query | +| `X_TEST_JOB_EXPECTED` | a matcher expected a job declaration and got something else | `toEmitSteps`/`recordSteps` called on `job.run` or an unrelated value | `call toEmitSteps(myJob)` with the job export, not `toEmitSteps(myJob.run)` | +| `X_TEST_NETWORK_RACE` | a request raced `unsealNetwork()` and lost the patched fetch | `unsealNetwork()` called while a request from the same seal was still in flight | do not call `unsealNetwork()` while a request from the same test is still pending | ## UI