Server
PremiumA template's sign-in page for the demo: the kit's SignIn, against the template's demo session route (demoSessionRoutes in ./candor-server.ts). It says plainly that no email is sent and which code to type, and after signing in it goes to next (with onSignedIn, through your router). In production the demo is off unless the app sets CANDOR_DEMO_AUTH=1 (demoAuthAllowed() in ./candor-server.ts), and the page says signing in isn't set up.
Replace it with your auth's sign-in page, or keep SignIn and point its onSendCode and onVerifyCode at your auth.
Version 5.2.0· other versions
This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).
Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1 and 2.6.2: the page came later, or had another name then.
Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.
Preview
It's types and helpers, listed below.
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/server
Brings in: locale, sign-in, tokens · npm: drizzle-orm@^0.45.4, postgres@^4.0.0
<DemoSignIn>
| Prop | Type | Default | Description |
|---|---|---|---|
| apirequired | string | — | The template's session route, such as "/approvals-inbox/api/session". |
| nextrequired | string | — | Where to go once signed in. |
| brand | React.ReactNode | undefined | — | The product's name or logo, shown above the form. |
| onSignedIn | ((next: string) => void) | undefined | — | Goes to `next` once signed in, such as the template's `go` or your router's push. Defaults to `window.location.assign(next)`, a page load. |
Functions
setDatabase(db: Database)Use your app's own Drizzle database for every block. Call it once at startup, before the first request.
database()The shared database: whatever setDatabase() was given, else a pool on DATABASE_URL, opened on first use.
demoViewer(request: Request)The demo's viewer: whoever the candor_demo_user cookie names. With no cookie it's Riley, so a block works from a clean download; after signing out (the cookie is empty) it's nobody. Not authentication: anyone can set a cookie.
setViewerSource(source: ViewerSource)Replace the demo cookie with your auth. Call it once at startup, before the first request.
crossSite(request: Request)Whether a page on another site made the browser send request. Every route in the blocks and templates that changes something (POST, PUT, PATCH, DELETE) asks this first and answers true with crossSiteRefused(). Safe methods (GET, HEAD, OPTIONS) are never refused.
How it decides. Browsers say where a request came from. Sec-Fetch-Site is trusted when it's there: "same-origin", or "none" (the person typed the address), may go on; "same-site" (a sibling subdomain) and "cross-site" may not. Without it, Origin must name this app's host (X-Forwarded-Host behind a proxy, else Host). An origin listed in CANDOR_TRUSTED_ORIGINS (comma-separated, e.g. https://admin.example.com; compared as origins, so case, a default port and a trailing slash don't matter) may always go on. A request with neither header didn't come from a browser page, and goes on.
What it protects against: cross-site request forgery, a page elsewhere making a signed-in person's browser send a form or a fetch here with their cookies, so that it acts as them.
What it doesn't: a script running on the app's own origin (cross-site scripting); a caller outside a browser, such as curl or another server, which sends whatever headers it likes and is held back only by your auth; a route that changes something on GET; and browsers too old to send either header. It says nothing about who sent the request: that is getViewer().
crossSiteRefused()What a route answers, with a 403, when crossSite() says another site's page sent the request.
demoAuthAllowed()Whether the demo cookie may say who is signed in: always outside production, and in production (NODE_ENV is "production") only when the app opts in with CANDOR_DEMO_AUTH=1. So a production deploy that never called setViewerSource() signs nobody in, rather than every visitor as the demo's first person.
getViewer(request: Request)Who sent request, by your auth; null when nobody is signed in. Until you set a source it's the demo cookie, except in production without CANDOR_DEMO_AUTH=1, where it's null for everyone (and the server log says why, once).
viewerName(request: Request)The signed-in person's name, which the blocks record as who did what; null when nobody is signed in.
signedOut()What a route answers when nobody is signed in.
demoSessionRoutes(people: DemoPerson[], options?: DemoSessionOptions | undefined)A template's sign-in route for the demo (api/session): GET says who is signed in and lists the demo's people, POST { email, code } signs that person in, DELETE signs out. Nothing is sent and nothing is checked beyond the code above; it sets the cookie demoViewer() reads (not HttpOnly, so the blocks' demo "signed in as" switches can still change it). In production without CANDOR_DEMO_AUTH=1 every method answers 403 with { error, demo: false } and lists nobody. options.allowed decides that instead of the environment.
Replace it with your auth's own sign-in (its route, or its hosted page) and call setViewerSource() with how your auth reads a request. The template's pages then need nothing else changed: they ask getViewer() through the API, and a 401 sends the person to the sign-in page.
errorText(error: unknown, sentence = "That couldn't be done because of an error on the server.")What a person is told when something failed, without telling them how. A Refusal was written for them, so its message comes back as it is. Anything else (a database error, a setting that's missing, a bug) is logged on the server with a short reference, and the person gets sentence with that reference, which they can quote to whoever reads the log. Use it wherever an error's words would reach the browser: a route's answer (see errorResponse()), an agent stream's onError, a tool's result.
errorResponse(error: unknown, sentence?: string)A route's answer to an error: a Refusal's own message with its status, or, for anything else, a 500 with sentence and a reference, the error itself going to the server log under the same reference (see errorText()).
guardTools(server: T)For a Claude Agent SDK tool server, createSdkMcpServer(guardTools({ name, tools })): an error a tool throws reaches the model, and the browser that shows the tool's result, as errorText() says it, a Refusal as written and anything else as a fixed sentence with a reference (the detail goes to the server log). Without it the SDK sends any error's message as the tool's result.
guardToolSet(tools: T)For a Vercel AI SDK tool set, streamText({ tools: guardToolSet({ … }) }): an error a tool throws reaches the model, and the browser that shows the tool's result, as errorText() says it, a Refusal as written and anything else as a fixed sentence with a reference (the detail goes to the server log). Without it the model reads any error's message, and can repeat it to the person. The tool still fails, so the call shows as failed, and the route's onError says the same sentence rather than logging it twice. Tools without execute are left as they are; so is an error a streaming tool throws once its first result is out.
approvalSigning()The AI SDK's approval signing, spread into streamText(): with CANDOR_APPROVAL_SECRET set, each approval request is signed, so a browser can't approve a call the server never asked about. Without it, nothing is signed.
Strings
The fixed words, in English by default. Change any of them under demoSignIn in <LocaleProvider strings={{ … }}>; the table is exported as demoSignInStrings.
| Key | English |
|---|---|
| hint | This is a demo with no real accounts. Sign in as {emails}. |
| codeSent | This demo sends no email. Type {code} to sign in as {email}. |
| unreachable | The sign-in couldn't be reached. Check your connection and try again. |
| failed | That didn't work. Try again. |
| off | Signing in isn't set up here. The demo sign-in is off in production, and this app hasn't connected its own sign-in yet. |
Types
- Database
- Any Drizzle Postgres database, whatever its driver; each block's tables are its own, so the handle has no schema.
PgDatabase<PgQueryResultHKT, any>- Viewer
- A person who is signed in, as your auth knows them. `name` is what the blocks record as who did what.
{ id: string; name: string }- ViewerSource
- Your auth's answer to "who sent this request?": the person, or null when nobody is signed in.
(request: Request) => Viewer | null | Promise<Viewer | null>- DemoPerson
- One of the demo's people, as its sign-in page lists them.
{ name: string; email: string }- DemoSessionOptions
- How demoSessionRoutes() decides whether it answers.
{ /** * Whether the demo sign-in answers, decided by the host rather than read from `process.env` on each request: true * where the demo is the point and there's no environment to read (a demo running in the browser), false to turn it * off. Defaults to demoAuthAllowed(). */ allowed?: boolean | undefined; }