From b67085f5aefd46eeeca0741195ee9a626b368fad Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Wed, 2 Sep 2026 14:02:12 -0400 Subject: [PATCH] docs: align guidance with published contracts --- public/skills/icons/references/logos.md | 2 +- .../references/application-structure.md | 5 ++-- .../references/states-and-consistency.md | 19 ++++++++----- src/pages/docs/catalog.ts | 9 +++++++ src/pages/docs/component-props.ts | 9 +++++++ src/pages/docs/content-overrides.ts | 8 +++--- src/pages/docs/usage-guide.ts | 2 +- src/pages/marketing/application-model.tsx | 8 +++--- src/pages/marketing/platform.tsx | 5 ++++ tests/docs-catalog.test.ts | 27 +++++++++++++++++++ 10 files changed, 75 insertions(+), 19 deletions(-) diff --git a/public/skills/icons/references/logos.md b/public/skills/icons/references/logos.md index 50f21ef..6a145ee 100644 --- a/public/skills/icons/references/logos.md +++ b/public/skills/icons/references/logos.md @@ -11,7 +11,7 @@ Use `@askrjs/logos` for an authorized brand mark, not for general interface concepts. The installed package currently determines which marks are available. -At version 0.2.0 it exports Apple, Facebook, GitHub, Google, and Microsoft logo +At version 0.2.1 it exports Apple, Facebook, GitHub, Google, and Microsoft logo components. Verify the installed declarations rather than assuming another brand is present. diff --git a/public/skills/project-structures/references/application-structure.md b/public/skills/project-structures/references/application-structure.md index 3c5fcd2..4a5a44d 100644 --- a/public/skills/project-structures/references/application-structure.md +++ b/public/skills/project-structures/references/application-structure.md @@ -45,7 +45,7 @@ src/ components/ project-card.tsx main.tsx - server.ts +server.ts ``` Keep a small application smaller. Retain `pages/_routes.tsx` and @@ -53,7 +53,8 @@ Keep a small application smaller. Retain `pages/_routes.tsx` and Add a nested page group only when it needs its own route prefix, layout, access boundary, or navigation context. -The browser entry owns mounting or hydration. A server entry owns server +The browser entry (`src/main.tsx`) owns mounting or hydration. The server +entry (`server.ts`, at the project root beside `package.json`) owns server composition. Neither should become a dumping ground for feature behavior. ## Keep pages thin diff --git a/public/skills/queries-and-mutations/references/states-and-consistency.md b/public/skills/queries-and-mutations/references/states-and-consistency.md index df1f045..f9377da 100644 --- a/public/skills/queries-and-mutations/references/states-and-consistency.md +++ b/public/skills/queries-and-mutations/references/states-and-consistency.md @@ -9,15 +9,20 @@ ## Render the state contract -Read the discriminated fields directly: +A query exposes `data`, `error`, the booleans `loading`, `refreshing`, and +`stale`, plus a `consistency` field and a `staleReason`. Read them directly +rather than inferring semantics from another query library: - `loading` means the first request has not produced data; `data` is `null`. -- `fresh` means committed data is current. -- `refreshing` keeps previous data while a confirming fetch runs. -- `pending-write` keeps previous data while a successful write is being - confirmed. -- `stale` is settled but not current, with reason `inconsistent`, `aborted`, or - `error`. +- `consistency === 'fresh'` means committed data is current. +- `refreshing` (and `consistency === 'refreshing'`) keeps previous data while + a confirming fetch runs. +- `consistency === 'pending-write'` keeps previous data while a successful + write is being confirmed. +- `stale` means the query is not fresh; it can also be `true` while refreshing + or confirming a pending write, when `staleReason` is `null`. +- `consistency === 'stale'` is settled but not current, with `staleReason` of + `inconsistent`, `aborted`, or `error`. Use `refresh()` for explicit retry or user refresh. Concurrent manual refresh calls coalesce while work is pending. diff --git a/src/pages/docs/catalog.ts b/src/pages/docs/catalog.ts index d8af487..44c7f37 100644 --- a/src/pages/docs/catalog.ts +++ b/src/pages/docs/catalog.ts @@ -537,6 +537,10 @@ const rendering = sectionPages('Rendering', 'Rendering', 'rendering', [ }, { title: 'Selective Hydration', + // @askrjs/askr/boot only exports createIsland/createIslands/createSPA/ + // hydrateSPA — there's no boundary-selection or priority API to hydrate + // part of a page before the rest. This page describes the pattern you'd + // want, not something the runtime ships today. status: 'limited', headings: [ 'Boundary selection', @@ -1065,6 +1069,11 @@ const overlays = componentPages('Overlays', [ }, { title: 'Drawer and Sheet', + // Not primitive-less like the experimentalControls set above — both are + // straight aliases of @askrjs/ui/dialog (askr-themes/src/entries/ + // {drawer,sheet}.ts). Sheet gets real data-side positioning CSS; Drawer + // has none at all — neither has distinct gesture or animation behavior + // of its own beyond what Dialog already provides. status: 'experimental', ui: [], themes: ['drawer', 'sheet'], diff --git a/src/pages/docs/component-props.ts b/src/pages/docs/component-props.ts index da99f72..4b940fe 100644 --- a/src/pages/docs/component-props.ts +++ b/src/pages/docs/component-props.ts @@ -51,6 +51,15 @@ export function componentPropReferences( ]) ); members.delete('maxWidth'); + // ContainerProps is `Omit`, and BlockDivProps + // fixes `as` to the literal "div" (intersecting BlockOwnProps's generic + // `as?: BlockElement` with BlockElementProps<"div">'s `as?: "div"`). + // Inheriting BlockOwnProps's own `as` member here would wrongly imply + // Container accepts any BlockElement tag, so narrow it back down. + const asMember = members.get('as'); + if (asMember) { + members.set('as', { ...asMember, signature: 'as?: "div" | undefined;' }); + } byName.set('ContainerProps', { ...container, members: [...members.values()].sort((left, right) => diff --git a/src/pages/docs/content-overrides.ts b/src/pages/docs/content-overrides.ts index 97557ce..7d9524f 100644 --- a/src/pages/docs/content-overrides.ts +++ b/src/pages/docs/content-overrides.ts @@ -18,7 +18,7 @@ export const headingOverrides: Readonly< 'browser-and-api-clients': "Because AuthOptions can hold both a sessionCookie-based session lookup and a jwt or jwtCookie validator at the same time, one createAuth() call can serve cookie-carrying browsers and bearer-token API clients without standing up two separate resolvers. @askrjs/server exposes the resolved value as ctx.auth on every request, so route code written against ctx.auth.principal doesn't need to know which path produced it. That's also what lets the same requirePermission()/requireUser() checks apply uniformly to both kinds of caller.", 'failure-behavior': - "A lookup that fails to identify anyone resolves to authenticated: false with principal and session set to null rather than throwing, so handlers can inspect ctx.auth without try/catch. Turning that into an actual accept/reject decision is the job of the requirement functions covered under Authorization Requirements, which map it to an AuthDecision of {allowed: false, reason: 'unauthenticated' | 'forbidden' | 'already_authenticated'}. A malformed bearer-header JWT does throw, surfacing as a JwtValidationError from the JWT validator — but a malformed JWT cookie is caught internally and resolution falls through to an anonymous context instead, so the two malformed-token paths don't behave the same way.", + "A lookup that fails to identify anyone resolves to authenticated: false with principal and session set to null rather than throwing, so handlers can inspect ctx.auth without try/catch. Turning that into an actual accept/reject decision is the job of the requirement functions covered under Authorization Requirements, which map it to an AuthDecision of {allowed: false, reason: 'unauthenticated' | 'forbidden' | 'already_authenticated'}. A malformed bearer-header JWT and a malformed JWT cookie behave the same way: both go through the JWT validator, and a JwtValidationError from either path is caught internally, so resolution falls through to an anonymous context instead of throwing out to your route.", 'identity-model': "A Principal (id, subject?, roles?, permissions?) and an AuthSession (id, subject, expiresAt?, revokedAt?) are separate types in @askrjs/auth, both extending a Claim base of Record so you can attach whatever custom fields your provider sends. createAuth(options) builds an AuthResolver whose resolve(request) returns one AuthContext per request, bundling authenticated, principal, session, tenant, and an optional scopes list into a single object. Route handlers and requirement functions both read from that same AuthContext, so there's one shape to reason about regardless of how the principal was established.", 'session-boundary': @@ -1402,7 +1402,7 @@ export const headingOverrides: Readonly< }, '/docs/data/queries-and-consistency': { 'consistency-modes': - "Instead of a single isLoading flag, a Query's consistency field is one of 'fresh', 'stale', 'refreshing', or 'pending-write', and each value comes with a matching shape for data, error, and stale flags — a refreshing query still has its previous data available, for instance, while a stale query with an error has both data: null and staleReason: 'error'. Reading consistency first tells you which of those shapes you're dealing with before you touch data or error directly.", + "Instead of a single isLoading flag, a Query's consistency field is one of 'fresh', 'stale', 'refreshing', or 'pending-write', and each value comes with a matching shape for data, error, and stale flags — a refreshing query still has its previous data available, for instance, while a stale query with an error has staleReason: 'error' and either retains previous data or has data: null when no successful value exists. Reading consistency first tells you which of those shapes you're dealing with before you touch data or error directly.", 'define-a-query': "defineQuery() takes a QueryDefinition — a key(input) function that derives a cache key, and a fetch(context) function that does the actual request — and hands back a reusable definition you can pass to createQuery(), serveQuery(), or prefetchQuery(). createQuery() also accepts a plain QueryOptions object directly if you don't need the input/key(input) split. Optional isConsistent() and reconcile() functions on the definition don't gate whether fetched data reaches the cache — freshly fetched data is written to the query's state either way. What they control is what happens next: isConsistent() returning false marks the result stale (consistency: 'stale', staleReason: 'inconsistent') and triggers reconcile(), whose return value decides whether the query automatically retries.", 'query-scopes': @@ -1553,7 +1553,7 @@ export const headingOverrides: Readonly< implementation: "On the server, attach `auth: requirePermission('reports:read')` to individual routes and read `ctx.auth.principal` in the handler; `createJwtValidator()` or a JWKS-backed validator plugs into `AuthOptions.jwt` if you're validating bearer tokens rather than cookies. On the client, `route(path, Component, { policies: [...] })` — plural, an array of requirement functions, not a single `policy` field — with `allow()`/`deny()`/`redirect()` decisions from your policy functions keeps navigation from ever rendering a page the user shouldn't see, and `currentAuth()` gives components access to the resolved identity for the route currently rendering.", verification: - 'Test all three identity states explicitly: anonymous, authenticated without the required role, and authenticated with it — each should reach a different, correct outcome on both server routes and client-rendered pages. Confirm a token that fails `JwtValidator` verification is rejected with a clear error, not silently treated as anonymous. Finally verify that server-side auth decisions and client-side route policies agree; a route hidden in the nav but reachable directly by URL because only the client checked permissions is a real vulnerability, not just a UX gap.', + 'Test all three identity states explicitly: anonymous, authenticated without the required role, and authenticated with it — each should reach a different, correct outcome on both server routes and client-rendered pages. Confirm a token that fails `JwtValidator` verification resolves to an unauthenticated context and is then rejected by the route requirement with a clear 401 or redirect, without leaking validation details. Finally verify that server-side auth decisions and client-side route policies agree; a route hidden in the nav but reachable directly by URL because only the client checked permissions is a real vulnerability, not just a UX gap.', }, '/docs/guides/build-an-mcp-server': { 'failure-states': @@ -2181,7 +2181,7 @@ export const headingOverrides: Readonly< 'context-first-handlers': 'Every handler and every Middleware receives a single ServerContext argument instead of separate request/response objects — request, url, params, headers, query, state, auth, and an AbortSignal all live on ctx. Dependencies like a database client are deliberately not part of that context; the README shows creating them at your composition root and passing them into route registration functions, keeping ctx reserved for request-scoped data. Because the context also carries every response helper (ctx.ok, ctx.notFound, and so on), a handler can build its whole response without importing anything else.', 'production-boundary': - "ServerAppOptions accepts an onError(error, context) hook that runs for an unhandled exception a handler or middleware throws, so you control what most errors turn into instead of leaking a stack trace to the client — but it's the fallback, not the only path: a few framework-recognized failures (an oversized request body, a malformed path parameter, a ctx.bind() failure) are converted to a fixed problem+json response before onError is ever consulted, so don't rely on it to customize those specific cases. The OpenAPI layer defaults to validateResponses: false and only turns response validation on when you explicitly opt in outside production, because checking every response against its schema costs real time on every request. The package is published in the 0.0.x line, so pin versions and review release notes before moving a production app between versions.", + "ServerAppOptions accepts an onError(error, context) hook that runs for an unhandled exception a handler or middleware throws, so you control what most errors turn into instead of leaking a stack trace to the client — but it's the fallback, not the only path: a few framework-recognized failures (an oversized request body, a malformed path parameter, a ctx.bind() failure) are converted to a fixed problem+json response before onError is ever consulted, so don't rely on it to customize those specific cases. The OpenAPI layer defaults to validateResponses: false and only turns response validation on when you explicitly opt in outside production, because checking every response against its schema costs real time on every request. The package is still published pre-1.0 (0.2.x), so pin versions and review release notes before moving a production app between versions.", 'response-helpers': 'ctx.ok(), ctx.notFound(), ctx.created(), ctx.problem(), and the rest of the status-code helpers are methods on ServerContext, so a handler returns a Response by calling ctx.(value) rather than constructing new Response(...) by hand. The same functions — json, text, redirect, setCookie, clearCookie, challenge, and friends — are also exported standalone from @askrjs/server for use outside a request context, such as inside an onError handler. setCookie() and clearCookie() take a Response and return a modified one; per the README they only touch response headers and have no opinion on session storage or credential validation.', }, diff --git a/src/pages/docs/usage-guide.ts b/src/pages/docs/usage-guide.ts index 8849c08..aa6f94b 100644 --- a/src/pages/docs/usage-guide.ts +++ b/src/pages/docs/usage-guide.ts @@ -1093,7 +1093,7 @@ route('/projects/{projectId}', ProjectPage, { 'Return an explicit allow, redirect, unauthorized, or forbidden decision from the route policy and repeat the enforcement on server APIs.', `route('/admin', AdminPage, { policies: [({ auth }) => - auth.permissions.includes('admin') ? allow() : forbidden()], + auth.principal?.permissions?.includes('admin') ? allow() : forbidden()], });`, ], [ diff --git a/src/pages/marketing/application-model.tsx b/src/pages/marketing/application-model.tsx index 1460b2d..3873a2f 100644 --- a/src/pages/marketing/application-model.tsx +++ b/src/pages/marketing/application-model.tsx @@ -26,8 +26,8 @@ const ownership: readonly SequenceItem[] = [ }, { title: 'Scopes', - description: 'End effects and resources with their lexical owner.', - meta: 'own · dispose', + description: 'Provide a typed value down the tree without a global store.', + meta: 'provide · read', }, ]; @@ -43,8 +43,8 @@ export function ApplicationModelPage() {

Four things, and where each one lives

State lives with the component that changes it. Derived values - recompute instead of drifting out of sync. When the owning scope - goes away, its resources are cleaned up automatically. + recompute instead of drifting out of sync. When the owning component + unmounts, its resources are cleaned up automatically.

+

+ All of them are pre-1.0 (0.2.x) — the API surface is still moving, + and askr upgrade exists because breaking changes are + expected between minor versions, not just major ones. +

diff --git a/tests/docs-catalog.test.ts b/tests/docs-catalog.test.ts index dcca965..b898c3b 100644 --- a/tests/docs-catalog.test.ts +++ b/tests/docs-catalog.test.ts @@ -268,6 +268,33 @@ describe('documentation catalog', () => { expect(prose).not.toContain('manifest-only'); }); + it('should keep query and authentication guidance aligned with installed contracts', () => { + const prose = JSON.stringify(headingOverrides); + const symbols = Object.values(apiSymbolSets).flat(); + const query = symbols.find( + (symbol) => + symbol.name === 'Query' && + symbol.signature.includes('QueryStaleErrorWithValue') + ); + const authResolver = symbols.find( + (symbol) => symbol.name === 'AuthResolver' + ); + const resolveMember = authResolver?.members?.find( + (member) => member.name === 'resolve' + ); + + expect(query?.signature).toContain('QueryStaleErrorWithValue'); + expect(query?.signature).toContain('QueryStaleError'); + expect(prose).toContain('either retains previous data or has data: null'); + expect(prose).not.toContain( + 'stale query with an error has both data: null' + ); + + expect(resolveMember?.summary).toContain('fall through as unauthenticated'); + expect(prose).toContain('resolves to an unauthenticated context'); + expect(prose).not.toContain('not silently treated as anonymous'); + }); + it('should keep hand-written CLI guidance aligned with generated behavior', () => { const prose = JSON.stringify(headingOverrides); expect(prose).not.toContain('nine subcommands');