Changelog
Every change in every release, newest first, except dependency-only updates.
All Graft packages release together under one version number, so each release below covers all of them. Each change names the packages it touched and links to its commit. To move a project across a breaking change, follow Upgrading, which says what to change for each one.
There are 13 releases and 130 changes on this page.
1.0.0-beta.9
Released November 1, 2026.
Breaking changes
-
BREAKING:
graftexits with a code that says what kind of failure it was, and refuses a flag the command does not take.Before, every failure exited 1, and a flag meant for another command was silently ignored:
graft init --applysucceeded and did nothing, which is how a dry run gets mistaken for a real one. Now:Exit code Meaning 0 It worked 1 It failed; the error says why and what to do 2 The command was called wrong: an unknown command or flag, or a missing argument. Nothing ran 3 A person has to approve the action first ( DESTRUCTIVE_OP_REQUIRES_APPROVAL)4 The credential is missing, wrong, or lacks the access this needs ( UNAUTHORIZED,TOKEN_INVALID)A script that checks for exit code 1 after a usage mistake must check for 2. An unknown command no longer prints the whole help on stdout; it prints the closest command on stderr:
graft: unknown command "complie". Did you mean `graft compile`? Run `graft --help` to list the commands.Every command that prints data now takes
--json:init,doctor,add,compile,check,content,compilations,asset put,studio invite,content import,pull,push,mirror,db migrate,migrate,harden,approvals,approve,deny,branch,branch create,branch drop,merge,ci changesandci skip-build. It prints one JSON object on stdout and sends every message to stderr. The object is indented on a terminal and on one line when piped, sograft doctor --jsonpiped to a file is now one line. A command run with--jsonthat fails prints one line of JSON on stderr:{"error", "message", "fix", "details"}, the shape every Graft error has.graft check --jsonlists each problem with its code and fix.Also new:
graft help <command>andgraft help <topic>(exit-codes,json,environment).graft commands --jsondescribes every command, its arguments and flags, so an agent can learn the CLI in one call.graft addwith no item lists the primitives you can add, instead of failing withREGISTRY_ITEM_NOT_FOUND.graft db --help,graft ci --helpand the other families print help for every member.--no-inputongraft createandgraft db claim, orGRAFT_PROMPT_DISABLED=1for every command, never asks a question.- Color follows
NO_COLOR,FORCE_COLOR,CLICOLOR,CLICOLOR_FORCEandTERM=dumb. - An unexpected error prints one line instead of a stack trace;
GRAFT_DEBUG=1prints the trace. - The CLI turns on Node's compile cache, which makes
graft checkabout 8% faster on a 48-document project.
Two error codes are added for these, with entries in the error reference and in
explain_error:USAGE_INVALIDandINTERNAL_ERROR.cli,contracts,mcp· 9a17a95 -
Read and write content from the terminal. The new commands run the same MCP tools an agent calls, in the CLI's own process, so they check the schema, compile, and file approvals exactly as the agent's call would. With
--json, each prints exactly what the tool returns.Command Tool graft content list [collection]list_collections,list_contentgraft content get <collection>/<slug>get_content, printed as the MDX filegraft content search <query>search_contentgraft content write <collection>/<slug>write_content, from--fileor--datagraft content set <collection> <slug...> --field --valuewrite_contentfor each documentgraft content delete <collection>/<slug>delete_contentgraft drafts,drafts publish,drafts discardlist_drafts,publish_drafts,discard_draftsgraft schemadescribe_schemagraft fn,fn describe,fn runlist_functions,describe_function,run_functiongraft explain <code>explain_errorgraft mcp tools,graft mcp call <tool>any tool, by name graft content get pages/home > home.mdxandgraft content write pages/home --file home.mdxround-trip a document. A delete, or a destructive function, exits 3 and itsfixnames the exact command to repeat with--approval <id>once a person has approved it.BREAKING:
graft contentnow lists the documents from the content files (or from the database, where content is kept there) instead of reading the Postgres index. It works on the static engine with noDATABASE_URL, and--jsonprints{ branch, collections: [{ collection, documents }] }.@usegraft/mcpexports the client the CLI uses:connectInProcess(server)andconnectClient(transport)return aToolClientthat calls tools by name and rejects with the GraftError a tool failed with (toolResultToGraftError).cli,mcp· 1a627ef -
BREAKING:
@usegraft/sdk-nextno longer supports Next.js 14. Its peer range isnext@^15.0.0 || ^16.0.0, and CI no longer builds a Next.js 14 app. Next.js 14 gets no security fixes, and its last release, 14.2.35, has two critical remote code execution advisories that will not be fixed on 14.The adapter still runs on 14.2, so a project that cannot upgrade yet keeps working. pnpm and bun print a peer warning on install, and npm refuses unless you pass
--legacy-peer-deps.withGraftnow says that 14 is not supported and links to "Run Graft on Next.js 14" in the Next.js guide, which lists what differs on 14. Upgrade to 15.5.24 or 16.3.3 or later.The MCP
list_packagestool says@usegraft/sdk-nextis for Next.js 15 or 16.mcp,sdk-next· 1deb1a7
Features
-
graft api --paginate --slurpprints every page as one JSON array once the last one arrives.graft api -X DELETEnow asks before it sends anything when run on a terminal; with--no-input, or with no terminal, it still needs--yes.graft help authexplainsgraft login, saved tokens,--remote,GRAFT_URLandGRAFT_TOKEN.cli· 42213aa -
Work with a hosted site from the terminal.
graft login <url>checks a token made in Studio under Developer, Agents, and saves it in~/.config/graft/hosts.json(mode 600). The token comes from stdin (--with-token) or a prompt that does not echo, never from an argument.printf %s "$TOKEN" | graft login https://cms.example.com --with-token graft content list --remote graft api entries -f collection=posts --paginate--remoteongraft content,graft drafts,graft schema,graft fn,graft mcp tools,graft mcp callandgraft approvalsruns the same MCP tools on the site's/api/mcp.GRAFT_URLandGRAFT_TOKENin the environment do the same without the flag, and--localoverrides them. Neither is read from.env.graft api <path>calls any Studio API route with the saved token:-X,-f/-Ffields,-H,--input,--includeand--paginate. It reaches only the logged-in site, follows no redirects, and needs--yesfor-X DELETE.graft auth statuschecks each saved token against its site,graft auth tokenprints one for piping, andgraft logoutforgets one.- A refused token exits 4 with
TOKEN_INVALID; an unreachable site is the newREMOTE_UNREACHABLE. No token can approve an action, so a remote delete still exits 3 until a person approves it.
@usegraft/mcpaddsconnectRemote({ origin, token }), aToolClientover HTTP to a hosted/api/mcp.cli,contracts,mcp· acb4a84 -
graftnow tells you when a newer Graft is on npm. After a command finishes, it prints the new version, the version you have, and a link to the upgrade guide on stderr. On a beta the notice is yellow, because beta releases often include security fixes. On a stable release it is cyan.The CLI asks the registry at most once a day and caches the answer under
~/.cache/graft(orXDG_CACHE_HOME). It never fails a command, and waits at most 1.5 seconds for the registry. The check is off underCI, forgraft mcp, and whenGRAFT_NO_UPDATE_CHECK=1is set in the environment or the project's.env.NO_COLORturns the color off.cli· 1deb1a7 -
graft checkandgraft compileskip the executable-MDX scan for a body they have already scanned. Results are kept under$XDG_CACHE_HOME/graft/mdx-safety(by default~/.cache/graft/mdx-safety), outside the project, and are keyed on the body and on the version of every package the scanner runs, down its whole dependency tree. A repeatgraft checkon a 50-page site went from about 1.7 s to about 0.3 s.@usegraft/mdx-safetyaddsscanMdx, theMdxScanCacheinterface,mdxScanVersion,mdxScanPackagesandscanVersionOf, and the compiler'svalidateDocuments,compileandcompileStatictake an optionalmdxScanCache. Studio saves and MCP writes do not pass one, so they scan every body every time.cli,compiler,mdx-safety· 42213aa -
Static projects no longer load the Postgres code.
@usegraft/db/staticis a new entry with the SQLite content index, the content diff, the search query check and branch scopes, and no drizzle-orm or Postgres driver behind it. Core, the compiler and the MCP server load the rest of@usegraft/dbonly inside the functions that talk to Postgres. On a 50-page static site,graft content getwent from about 950 ms to about 480 ms. Everything in@usegraft/db/staticis still exported from@usegraft/db.cli,compiler,core,db,mcp· 42213aa
Fixes
-
These packages now need zod 4.6.7 or newer. Earlier 4.x releases can run out of memory while validating a very large array or set that has a size limit.
contracts,core,mcp,registry· 3374aab
1.0.0-beta.8
Released October 19, 2026.
Breaking changes
-
BREAKING:
graft compilenow stops on afield.reference({ to })that names a document that doesn't exist, withCONTENT_REFERENCE_NOT_FOUND. Until now a reference was checked only bygraft check, so a product whose category had been deleted compiled, and the site found the gap at render time. A project that compiled with a broken reference fails now. Rungraft checkto list every one, then fix them or create the missing documents.The error names each file, field and missing slug in
details.references:Error CONTENT_REFERENCE_NOT_FOUND: 1 reference(s) name a document that doesn't exist: products/wool-hat.mdx "category" points at categories/hats.Both content sources make the check, because both go through
validateDocuments. In a database-sourced project, a publish refuses a draft whose reference isn't live, and refuses to publish the deletion (or a new slug) of a document that live documents still point at. Publish them together, changed to point elsewhere.A local Studio checks the same way: a save of an entry whose reference isn't in the index is refused, and so is deleting an entry other entries point at, before its file is removed.
checkRemovalin@usegraft/compileris that second check, andcompileDocumentsmakes the first.validateDocumentstakes a newreferencesoption."files"checks targets among the files given, which is right for a whole tree, andreadDocsuses it by default. A set ofcollection/slugkeys also counts those as existing, for a caller checking part of a larger whole. Without the option, references are not checked.liveDocumentsin@usegraft/dblists a branch's live documents for that set.cli,compiler,db,mcp· b56d6b1 -
BREAKING: frontmatter is read as YAML or JSON only. gray-matter runs a
---jsfrontmatter block througheval, so a contributor's Studio save, an agent'swrite_content, or a pull request built bygraft compilecould run code on the machine reading it. Every read now goes throughreadFrontmatterfrom the new@usegraft/mdx-safety/frontmatterentry, which refuses any other language withINPUT_VALIDATION_FAILEDbefore parsing, whatevermdxTrustis. A lint rule stops gray-matter being imported anywhere else.BREAKING:
POST /api/studio/v1/changes/commitandPOST /api/studio/v1/compilations/:id/revertneedstudio:publish. They asked forstudio:write, so a contributor could commit to the checkout, or roll it back, around the check Studio's Commit button makes.Also fixed:
graft studiowithGRAFT_DEV_TOKENgives the operator every admin scope, as its documentation says. Since contributors were stopped from committing locally, the token's missingstudio:publishstopped the operator committing too.- Without a database, a media upload or delete is refused before the bucket is touched. Both used to change the bucket, then answer
NEEDS_DATABASE.
cli,compiler,content-migrations,mcp,mdx-safety,studio· 70321c7
Features
-
graft studiostarts without a database. Until now it stopped withENV_VAR_MISSING: DATABASE_URL is not set, so a static project (export const index = "static") could not use Studio at all, and a Postgres project could not open it to fix a typo without a running database.Without
DATABASE_URLyou can find, edit, create, delete and commit content as before. In a static project each save also updates.graft/index.db, and Developer, Health compares the files with it; a static project never uses a database even when one is set, as withgraft mcp. The views whose data lives in Postgres (approvals, the compile history, branches, people, agents, the media library, site settings) say they need a database and how to add one, and their API routes answerNEEDS_DATABASE(HTTP 503) with the same steps.For
createStudioHandler,dbis now optional, andstaticIndexPathpoints a Studio at the static index:createStudioHandler({ collections, contentDir, staticIndexPath: ".graft/index.db", });GET /workspacereportsdatabaseandindex("postgres","static"or"none"), andGET /treereports which index its states are measured against.Sign-in is unchanged. A
graft studioon127.0.0.1asks nobody to sign in, with or without a database. Studio accounts and their sessions are kept in Postgres, so they still need one, andgraft serve --studiostill requiresDATABASE_URL.cli,studio· f1ed5d2 -
References are checked before a write, not after it. A local Studio save, bulk edit, delete or discard, and MCP's
write_contentanddelete_content, used to write or delete the file and then check references while updating the index. A refusal said nothing was saved while the file had changed, and the index no longer matched it. Now a refused write leaves every file as it was.checkChangein@usegraft/compilerchecks a change to a few files before it is written: the checks a compile makes of them, references included, and that nothing left as it is points at a document the change deletes or renames. It checks against the Postgres index, or the files when there is none.- A stale index no longer refuses a good save. A reference may name a file in the content folder the index hasn't caught up with, such as one a
git pullbrought in. - On the static index or with none, deletes and discards are checked too, and a reference already broken somewhere else no longer refuses an unrelated save.
graft compilestill reports it. checkRemovaltakescontentDir, and checks against the files there when it is given nodb.- Publishing in a database-sourced project checks references once it holds the content lock. Two publishes at once, one deleting a category and one pointing a product at it, could each pass against the state before the other.
publishDraftsin@usegraft/dbaccepts the index rows as a function of the live documents for this. - A bulk edit is checked as a batch before anything is written, and sets a field on at most 500 entries at once. A file that fails to write partway is reported with the others, and the ones written still reach the index.
compiler,db,mcp,studio· 36472f3 -
A delete or a slug change checks references against the files, not an index that is behind them. After a
git pullwith no compile, a Studio or MCP delete could remove a document a pulled one pointed at, and the nextgraft compilefailed. It could also refuse a delete over a document already gone from the files. Ordinary saves still check against the index, which keeps them fast.checkRemovalfrom@usegraft/compilerreads only the files: passcontentDirand dropdbandbranchId.Studio fixes from the review:
- Set a field opens ready to set a value. Removing a field from every selected entry is a separate checkbox, so an empty value no longer removes it on one Enter.
- The back link from an entry returns to the list as you left it: search, filters and sort.
- A filter that came from a link, on a field with no filter menu, shows as a chip you can remove. It used to narrow the list with nothing on screen saying so.
- A disabled Commit button says why on hover, on keyboard focus and to a screen reader, not only in a tooltip that never showed.
- Home folds repeated saves into one row only within an hour of each other, so Monday's edits don't vanish under Friday's.
- After a partial bulk failure, the selection keeps only rows the list still shows, so its count matches what the next action does.
- A refresh that fails says the rows on screen are from before.
- Opening a list, or Health and Settings, reads it once, not twice.
compiler,mcp,studio· e61014f -
A local Studio save now updates the index for the file it wrote, instead of recompiling the whole content tree. On a 2,000-product catalog a save took 1.6 seconds and now takes about 100 milliseconds.
The save checks the file with the same rules as
graft compile(the schema, MDX safety, unique slugs) and records a compilation with the commit it was read at, so the compile history still lists it. Other files are left as they are: a file changed outside Studio reaches the index on the nextgraft compile. One broken file elsewhere in the tree no longer makes every save fail.Two functions make this available outside Studio:
import { compileDocuments } from "@usegraft/compiler"; // Read, validate and project two files; a missing one comes off the index. await compileDocuments({ contentDir, collections, db, paths: ["products/wool-hat.mdx"], });projectDocumentsin@usegraft/dbis the projection underneath, and it is the one a database-sourced publish already used. It changes only the rows it is given, refuses a slug another live file holds withSLUG_NOT_UNIQUE, and removes a row whose file no longer produces it.compiler,db,studio· 41c371f -
autoJoinAccountcreates a Studio account for an email that has never had one, with a given role. It returns null for an email that has or had an account, so someone an admin removed is not brought back, and null before the owner exists, so the first visit can still claim the Studio.db· d854102 -
acceptInviteForEmailuses the newest open Studio invitation for an email, for someone who proved they own the address another way, such as a verified email from GitHub, Google or single sign-on. It only creates or restores an account: for someone who can already sign in, an invitation is a password reset and still takes its link. An account made that way storesNO_PASSWORD_HASH, andStudioAccount.hasPasswordsays whether an account has a password.db· ef9af08 -
@usegraft/sdk-nextruns on Next.js 14.2 and later, and in the Pages Router as well as the App Router. The peer ranges are nownext@^14.2.0 || ^15.0.0 || ^16.0.0andreact@^18.2.0 || ^19.0.0.- The Pages Router. Read in
getStaticPropsorgetServerSidePropsas before.prepareMdx(body)compiles a body there, with the same trust rule asMdxBody, andMdxContentrenders the result in the server render and in the browser. It comes from a new entry,@usegraft/sdk-next/mdx, that carries no server code. A body that usesimportis refused withINPUT_VALIDATION_FAILED, since the browser has no bundler to resolve it. - React 18.
createGraftusesReact.cachewhere React has it, and reads straight through where it does not, as in the Pages Router on Next.js 14. - Next.js 14.
withGraftwritesexperimental.serverComponentsExternalPackagesthere, the name 14 reads, andserverExternalPackageson 15 and later. The adapter importsnext/cache.js, which Node's own loader can resolve when the Pages Router loads it unbundled. - Advisories. Releases with open critical Next.js advisories are no longer refused at install.
withGraftprints one warning naming the release to upgrade to: any 14, 15 before 15.5.24, or 16 before 16.3.3. Next.js 14 gets no more security fixes. Its last release, 14.2.35, is affected by 23 published advisories, two of them critical remote code execution, and each is fixed only in 15 and 16. Support for 14.2 is there so you can adopt Graft before you upgrade.
CI now builds and serves a real Next.js 14.2.35 app beside 15 and 16, and on each checks a Pages Router page and that none of the scripts it sends to the browser carries the database, the index or the MDX compiler. The Next.js guide has a version table and a section on the Pages Router.
mcp,sdk-next· d06bbc3 - The Pages Router. Read in
-
GRAFT_STUDIO_AUTO_JOINlets a whole email domain join a hosted Studio without invitations:GRAFT_STUDIO_AUTO_JOIN="shop.example.com=contributor". The first time someone signs in with GitHub, Google or single sign-on and has a verified email at the domain, Studio makes them an account with the domain's role (contributor by default), shown in Settings, People, where an admin can change or remove it. Someone removed stays out. Admin is never given this way, free mail providers such as gmail.com are refused, and nobody joins before the owner exists. People lists the domains that join on their own.studio· d854102 -
A hosted Studio can sign people in with Google, and with any other OpenID Connect provider: Okta, Entra ID, Keycloak, your app's own Better Auth (with
@better-auth/oauth-provider) and others. SetGRAFT_GOOGLE_CLIENT_IDandGRAFT_GOOGLE_CLIENT_SECRETfor "Continue with Google", orGRAFT_OIDC_ISSUER,GRAFT_OIDC_CLIENT_IDandGRAFT_OIDC_CLIENT_SECRET(andGRAFT_OIDC_NAMEfor the button) for another provider. Studio runs the authorization code flow with PKCE and checks the ID token's signature, issuer, audience, expiry and nonce, and only a verified email counts.Settings, People is now the one place to add someone, however they sign in. A person invited there signs in with GitHub, Google or single sign-on as their Studio account, and their first sign-in uses the invitation: no link to pass on, no password to choose. GitHub matches any verified address on the person's account. Someone signed in as an account with no password sees no password to change in Settings.
studio· ef9af08 -
Set one field on many entries at once. Select entries in a collection's list and choose Set a field: pick the field and its value with the same control the entry editor uses, and Studio saves each entry as a draft. Nothing goes live until it is published. Entries that can't be saved are listed in the dialog with the reason and stay selected; the rest are saved.
The API behind it is
POST /api/studio/v1/entries/field:{ "collection": "products", "slugs": ["wool-hat", "linen-shirt"], "field": "status", "value": "archived" }Each entry is validated and written exactly as
PUT /entrywould, against the version read just before the write."value": nullremoves an optional field. A local Studio updates the index once for the whole batch. The answer listssavedentries with their new versions andfailedentries with their error code, message and fix.On a phone, the bulk bar's buttons now wrap instead of running off the screen.
studio· 6161f0e
Fixes
-
tools/listno longer fails with "Cannot read properties of undefined (reading 'push')" when the MCP SDK resolves an older zod than Graft's. The SDK takes any zod from 3.25 on, so a project whose own dependencies bring zod 4.2 to 4.4 handed the SDK a converter that crashed on Graft's schemas. Calling a tool by name still worked, but standard clients list tools first, so none of them could connect. The listing is now converted with the zod that built the schemas, and is otherwise the SDK's own, field for field.serverInforeports the Graft release instead of0.0.0.Token messages speak to whoever holds the token. A revoked or unknown Studio token is told to get a live one from Developer, Agents, not to configure
createActorResolver. A token missing a scope is told which access level carries it. An anonymous caller is told where tokens come from.graft servebeyond loopback no longer warns that MCP will refuse every caller: tokens made in Studio always work. It now says how callers get in.On
graft studio, the Agents page no longer shows an MCP endpoint on this computer, which answered 404. It showsnpx graft mcpfor an agent on this machine, and/api/mcpwherevergraft serveruns.auth,cli,mcp,studio· c9868b0 -
Studio no longer loops silently when opened over plain http on an address other than localhost. The session cookie is
Securethere, so the browser dropped it and sign-in landed back on the form with no message. Studio now says sign-in needs https, or localhost, instead of showing a form that can't work, and an invite link stays unused until it can sign someone in.graft doctorandsetup_statusno longer make a missingGRAFT_STUDIO_SECRETthe next step: it only matters once the site is online. That check now has its own id,studio-secret. With nothing else to do, doctor says the project is ready to work on and that the!lines are for going online. Once the secret is set, creating the owner account is still the next step.graft --helplistsgraft studio setup-codeandgraft studio invite.cli,compiler,studio· 9d26068 -
Studio's Publish button no longer says "Publish" for a moment before it says Commit or Submit for review. It now reads what it does from the workspace, which answers at once, instead of guessing until the drafts load.
A collection's search, filters and sort are kept in the address, so a reload, the back button or a shared link shows the same rows. Its count, and Home's, no longer include entries deleted in a draft: they stay listed as Deleted until the deletion is published.
Home's recent activity folds a run of the same person changing the same entries into one row ("Kyrus, 3 times"). Health and Settings read the content again after every save, publish, discard or delete, so their counts are never from before the last change. Studio has a favicon, so browsers stop asking the server for
/favicon.ico.An agent's
publish_draftsthat commits to the production branch on GitHub now calls the site's deploy hooks undergraft serve, as a database publish does.createGraftMcptakes anonPublishedcallback for this. A hook that fails never fails the publish.cli,mcp,studio· 9571702 -
A save keeps the comment after an edited value. Changing
title: "Old" # shown in the tabin Studio or over MCP used to writetitle: "New"and lose the note; it now writestitle: "New" # shown in the tab. This holds for quoted and plain one-line values. A value that becomes a list or a map drops the comment, because it no longer has one line to sit on.compiler· d016963 -
A save no longer turns a one-line string into a folded
>-block. js-yaml wrapped any string longer than 80 columns, so editing a longdescriptionrewrote it across three lines. Strings now stay on one line, and an edited one-line string keeps the quotes it had ("…"or'…').A frontmatter that opens with a comment is now patched key by key like any other, instead of being rewritten whole, which dropped the comment, re-quoted every string and lost the blank line after
---. Comments and blank lines between keys stay put when the key above them changes.compiler· de7e5d6 -
Requires
@modelcontextprotocol/sdk1.31 or newer. Older versions could send an OAuth client's credentials to the wrong authorization server, and pulled inproxy-addr,honoandip-addressversions with known advisories.mcp· 5553879 -
An agent's
write_contentanddelete_contentnow update the index for the file they wrote, instead of recompiling the whole content tree, as a Studio save does. On a 2,000-product catalog awrite_contentcall took about 1.5 seconds and now takes about 110 milliseconds. The result'schangeslists that one document. The static index is still rebuilt whole, and content kept in the database is unchanged.mcp· 41c371f -
Fixes from a review of the Next.js 14 and Pages Router support.
- Unsafe URLs are dropped from restricted MDX.
MdxBodyandprepareMdxremove any URL attribute whose scheme isn'thttp,https,mailtoortel, on Markdown links and on components. A[Read more](javascript:…)link rendered as a live link on React 18, which the Pages Router on Next.js 14 uses. prepareMdxreads imports from the syntax tree. A code span or fenced block that only mentionsawait import(is no longer refused. Undertrust: "full",import()andimport.metaare refused too, instead of compiling to code that always throws.withGraftmerges both externals settings into the one this Next.js reads. On 15 and later, a leftoverexperimental.serverComponentsExternalPackagesno longer replacesserverExternalPackagesand drops@usegraft/registry. On 14, a top-levelserverExternalPackagesis moved where 14 reads it.withGraftfinds Next.js from a monorepo's root. When nonextis installed beside the config, it uses the one this package resolves.- A prerelease of a patched release counts as before it, so
16.3.3-canary.1gets the upgrade warning. - The
FRAMEWORK_VERSION_UNSUPPORTEDfix saysrevalidateContentworks on Next.js 14, 15 and 16. The Next.js guide saysMdxContentneeds'unsafe-eval'in a Content Security Policy.
sdk-next· 334deef - Unsafe URLs are dropped from restricted MDX.
-
Lists can be walked from the keyboard. In a collection, j and k move through the entries from anywhere on the page (not while typing), and the arrow keys, Home and End once focus is in the list; Enter opens one. The list beside an open entry takes j and k too.
Forms fit what they hold:
- A string field that holds an address or a path (
url,githubUrl,thumbnail,emailand the like, or one with an^https?pattern) is a one-line input even when its limit is long. A URL allowed 2,048 characters used to open as a tall textarea. - The create dialog asks for the title first, then the rest in the schema's order.
- A required group whose fields are all optional starts empty in the create dialog, so it no longer blocks creating an entry with "Links is required." until one of its optional fields is filled.
On a phone, an entry's bar shows whether the save landed, and drops its own Commit button (the top bar's commits this entry with the rest) and the title, which the heading below shows in full.
studio· 48e07b9 - A string field that holds an address or a path (
-
A contributor on a local Studio can no longer commit. Locally there is no review step, so a commit landed on the checkout at once, whatever the role. The contributor's drafts now wait for an editor: the Commit buttons are off with the reason, and the API refuses with
UNAUTHORIZEDanddetails.required: "studio:publish".A local commit is now authored by the signed-in person instead of whoever runs Studio, who stays the committer. With no message, it names the entries by title (
Update “Wool Hat”) instead of by path, and the Publish sheet's placeholder shows that message.A refusal now speaks to whoever was refused. A person is told their role can't do it and to ask an admin. A token is told the scope it lacks and where to make one that has it. Studio shows these messages instead of "You're signed out".
studio· 8a5c753 -
Studio loads its editors when an entry opens, not with the app. Milkdown and CodeMirror were about 2.7 MB of the 3.3 MB every Studio page downloaded before it could show the home screen or a list. Now the first page loads 0.66 MB (201 kB gzipped), and opening an entry adds the editor (1.66 MB, 532 kB gzipped); the languages a code block highlights load only when one asks for them. The same split applies to
@usegraft/studio/panels, whose bundle goes from 4.8 MB to 0.5 MB with the editor in a chunk of its own.studio· 118a339 -
Settings, Keyboard lists the list shortcuts: J and K move down and up a collection's entries, the arrow keys, Home and End move through a list once it has focus, and Enter opens the selected entry.
studio· 0263253 -
An agent's request in the Inbox is titled with what it would do, such as "Delete projects/welup-digital", instead of the tool's name, and Approve asks once more before the agent may run it. The embedded
ApprovalQueuePanelbrings its own confirmation dialog.Deploy hooks are now called after Studio publishes to the production branch on GitHub, as they already were after a publish of content kept in the database. A commit on your own machine reaches no site and still calls none, and Settings now says so.
Wording that didn't match what happens:
- Deleting on your own machine says the file is removed now and the deployed site keeps it until you commit and push, instead of "stays live until you commit".
- An entry nobody has edited since its last commit is dated by that commit, not by its file's modification time, which a clone or checkout resets.
- The empty Drafts tab says there are no drafts instead of "Nothing matches."
- Shortcuts in Settings and tooltips show Ctrl off a Mac, as the top bar already did.
- The entry's details no longer show a "Version" that was the file's content hash.
- An asset field's placeholder and an asset key error no longer name the shop example, and a missing key says it is missing instead of
"null" is not an asset key.
studio· db2d4b6 -
Studio fixes from a review of the last release's UI work.
- The editor's menus and inline code look like Studio again. Since the editor started loading on demand, Milkdown's stylesheet arrived after Studio's and won ties: the slash menu had no background or shadow, and inline code lost its font and fill. It now sits in a cascade layer under Studio's own styles.
- The publish sheet no longer re-renders without end while drafts load. On a slow GitHub workspace it spun until the drafts answered, and forever if they never did. It shows a placeholder, or the error with Try again, and names the action from the workspace as the top bar does.
- A failed workspace request shows the error and Try again in Agents, Branches, History, Media and the Inbox, instead of a placeholder that never ends.
- Deleting several entries goes past one that is refused, says how many were deleted, keeps the refused ones selected, and refreshes the list either way.
- A bulk edit cut off partway refreshes the list and says some entries may have been saved.
- If the editor's code fails to load, only the editor says so, with Reload, and the entry's fields keep working.
- Keyboard: j and k no longer move focus behind a confirm dialog. Arrow keys from a row's checkbox move from that row, not from the first.
- Words:
- Errors read as two plain sentences, with no em dash.
- The "add a database" steps include removing
index = "static". - Health shows no drift columns when there is no index.
- Media doesn't offer uploads without a database.
- Discard on entries with no drafts says there is nothing to discard.
studio· 22c6e47
1.0.0-beta.7
Released October 13, 2026.
Breaking changes
-
Studio now uses the docs' type, color and icons. IBM Plex Sans and Plex Mono replace Geist and Instrument Serif, and mono is kept for identifiers (a slug, a path, a field name) instead of labels. Labels are sentence case with no letter-spacing, nothing is under 13px, and weights are 400, 500 and 600.
Color follows the docs' rule, monochrome chrome and colorful content: states are a mark and a word on the neutral ramp, amber is kept for something waiting on a person, red for errors and deletes, and diffs keep their code colors. Collections are marked by an icon in the text color, not a tinted tile.
Icons are Lucide, at 16px and a 1.75 stroke, as in the docs.
BREAKING (panels embed only): if you embed
@usegraft/studio/panels, installlucide-reactin place of@phosphor-icons/react. It is the optional peer the panels now import.graft studioandgraft serve --studioneed nothing.studio· 132107f
Features
-
Studio gets a media library, agent tokens, and the site's settings with deploy hooks.
- Media. A Media page lists the files in the asset store that Studio
uploaded or an entry uses. Upload, search, write alt text, see which entries
use a file, and delete one nothing uses. Image fields have Choose…, and a gallery's Add image, which pick from the same library and take the file's alt text when the field has none. New routes under
/api/studio/v1/assets. - Agents. Admins make a token per agent under Developer, Agents, with
Read, Write drafts or Publish access. The token is shown once and only its
hash is stored.
graft serveaccepts it on/api/mcp, the Studio API and functions, and revoking it takes effect on the next request. No token can decide approvals.@usegraft/auth's resolver gains atokensoption for this. - Site and deploy hooks. Settings has the site's name and address, and
deploy hooks: URLs called with a
POSTafter every publish of database-sourced content, with an optional bearer secret. Each hook shows what it answered last, and Send a test calls it now. A failing hook never undoes a publish.@usegraft/compilerexportsfireDeployHooks. - New scope
studio:settings, held by admins. - A Studio route that needs a table the database doesn't have answers
MIGRATION_REQUIRED(503) with the fix, instead of failing the request.
Run
graft db migrate(migration 0013 addsassets,agent_tokens,site_settingsanddeploy_hooks). Rungraft harden <role>again if you use a runtime role.auth,cli,compiler,db,studio· b086a62 - Media. A Media page lists the files in the asset store that Studio
uploaded or an entry uses. Upload, search, write alt text, see which entries
use a file, and delete one nothing uses. Image fields have Choose…, and a gallery's Add image, which pick from the same library and take the file's alt text when the field has none. New routes under
-
Content can now live in your Postgres database instead of in git. Add
export const content = { source: "database" }tograft.config.tsand rungraft content importto load your files once.Studio and agents then save drafts to the database, and publishing puts a change on the live site straight away, with no rebuild and no GitHub setup. A hosted Studio needs only
DATABASE_URL. Every publish is checked exactly asgraft compilechecks a file (the schema, slug uniqueness and MDX safety), and a draft that would fail compile never goes live.graft compilereads the published versions, never the files.There is one draft per document, shared by everyone who edits it. Two people or an agent saving the same document from the same version can't overwrite each other: the second save gets
CONTENT_CONFLICT. Someone without publish rights submits for review instead, and the request closes once everything in it is live.Git stays the default and works as before. A project uses one source or the other, never both. Reverting to a commit,
graft mergeand content migrations work on files, so in a database-sourced project they refuse with the newCONTENT_SOURCE_MISMATCH.graft db migrateadds three tables (content_revisions,content_heads,content_reviews), andgraft hardengrants the runtime role access to them.For code that reads review requests from a content store:
ReviewRequestnow has anid, and itsnumberandurlare optional, since a review in the database is not a pull request. Thereviewthat MCPlist_draftsandpublish_draftsreturn follows the same shape.cli,compiler,contracts,db,mcp,studio· c997e35 -
A new site in one command, and a checklist that says what to do next.
npm create graft(the newcreate-graftpackage) runsgraft create: it asks what kind of site (blog, docs, shop, portfolio or blank), how people will edit it (online in Studio, or as files in git) and which database to use, then writes the site with a Next.js app, installs it, migrates, loads the example content and compiles. Every question has a flag, and--yestakes the defaults, so an agent can run it with no prompts.--database newmakes a free Postgres with no account. It lasts 72 hours unless claimed:graft db claimprints the link, waits for the claim, and saves the new connection string to.env. Any Postgres 13 or newer works instead.graft doctorand the MCP toolsetup_statuscheck the database and its version, the schema, a database waiting to be claimed, content, the index and Studio sign-in, and end with the one thing to do next.- A new site gets an
AGENTS.md, and the docs serve a setup guide for agents at/start.md.
cli,compiler,create-graft,mcp· 95ecc3e -
Moving content between the database, your files and git.
graft pullwrites what is published into your files, andgraft pushpublishes the files you changed. Graft remembers what each file was pulled at (.graft/sync.json), so a pull never overwrites your changes, and a push is refused withCONTENT_CONFLICTif someone published that document since.--forcesettles a conflict either way.graft devtells you when someone publishes, so you know your files are behind. Agents get the same answer from the new MCP toolsync_status.content.mirrorcopies what is published into GitHub: in batches fromgraft serve(mode: "batch", everyeveryMinutes), or after every publish (mode: "each").graft mirrordoes one run. A batch is one commit, or one pull request merged straight away when the branch allows it. A pull request that waits on checks or a review stays the only one: later runs add to it until it merges.graft checkruns every check a publish makes on the files, with no database, and now also finds references to documents that don't exist.graft ci changes,graft ci skip-buildandgraft ci initkeep CI cheap on content-only changes.skip-buildcompares with the previous deploy, and builds when it can't tell which that was.
For git-sourced projects hosted on GitHub, some publishes now open a pull request even for an editor: deletions and renames, and changes over 200 lines. Studio says why. Change the rule with
content.review(deletes,collections,overLines), or turn it off withreview: false.graft db migrateadds thecontent_mirrortable.cli,compiler,db,mcp,studio· a761a13 -
Studio accounts: people sign in to a hosted Studio with an email and a password.
- The first visit creates the owner account. On a server anyone can reach,
it asks for a setup code first, which
graft serveprints when it starts andgraft studio setup-codeprints from the site's settings. - Admins invite people from Settings, People: an email and a role make a link to pass on. The same list changes roles, makes a link to set a new password, and removes access, which signs that person out at once.
- Account sessions live in the database, so signing out, removing someone or changing their role takes effect on their next request. Changing your password signs out your other browsers. Five wrong passwords in a row lock an account for 15 minutes.
- Admins get the new
studio:peoplescope. GitHub sign-in andgraft studio invitelinks work as before. - New tables
studio_users,studio_sessionsandstudio_invites: rungraft db migrate, andgraft harden <role>again if you use a runtime role.
cli,db,studio· 2d97fda - The first visit creates the owner account. On a server anyone can reach,
it asks for a setup code first, which
-
Studio keeps each entry's history, puts every decision in one inbox, and shows what was published lately.
- History. The entry's panel has a History tab. It lists every published
version with who made it and when. Open one to see how the entry differs
from it now, then restore it as a draft. History comes from the published
revisions when content is in the database, the production branch's commits
on GitHub, and the checkout's commits locally. New API routes:
GET /entry/historyandGET /entry/version. - Inbox. Changes sent for review and agents asking before a destructive
call share one inbox, with a count in the sidebar. When content is in the
database, someone who can publish publishes a review from there. Approvals
move out of the Developer views, and old
#/dev/approvalslinks open the inbox. - Home. A "Recently published" feed lists who published what, with
links to the entries (
GET /activity). - Content stores gain optional
history,readVersionandactivitymethods.DatabaseStoreandGitHubStoreimplement them, and@usegraft/dbaddspublishedHistory,recentlyPublishedandreadPublishedRevision.
compiler,db,studio· e8b55ac - History. The entry's panel has a History tab. It lists every published
version with who made it and when. Open one to see how the entry differs
from it now, then restore it as a draft. History comes from the published
revisions when content is in the database, the production branch's commits
on GitHub, and the checkout's commits locally. New API routes:
-
Studio's developer views follow the docs. Health leads with its numbers without boxes or a decorative chart, lists collections, recent compiles, approvals and branches as plain tables, and has a Compile button again, so out-of-date content is fixed where Studio shows it. Schema shows types as plain identifiers and "Required" as a word instead of a pill, and the sign-in page uses the
graft. / Studiowordmark. States read "Out of date", "Never compiled" and "File missing" in place of "Drifted", "Not indexed" and "Orphaned".studio· 21b232a -
Studio's editor screens follow the docs. Home opens with what is waiting for you (drafts, an agent waiting on an approval, a pull request in review) and a plain table of collections, in place of the printed-paper front page. The collection list marks drafts with a dot and a word instead of a pill. The entry page sits in the docs' column at the docs' reading size, with no card around it.
One word for one state: an edit not yet committed or published is a Draft everywhere, and every sentence uses the workspace's own verb, so a Studio on a laptop says "commit" and never "publish".
studio· fe7d03b -
Studio's frame and popups now match the docs. A 56px top bar runs across the page with the
graft. / Studiowordmark and the branch on the left, search centred, and Commit (or Publish) and the account menu on the right. The sidebar sits under it on the page colour. Below 768px the sidebar opens from a menu button.Popups share one material: the elevated surface and a 10px radius, opening in 160ms and closing in 100ms. Dialogs and sheets add a dimmed backdrop with no blur and a visible close button; the command palette opens instantly and closes on Escape or a choice. Deleting or discarding asks in a confirm dialog that starts on Cancel and names the action ("Delete post") in place of the browser's
confirm(). The command palette shows its keys, menus carry icons, toasts come one at a time, and committing ends with a plain check instead of an animation.@usegraft/tokensadds--scrim, the colour behind a dialog or sheet.studio,tokens· b146045 -
Studio opens an entry as panes. The collection's list stays beside the document, with the open entry marked, so the next entry is one click away and the arrow keys move through the list. A new inspector beside the document shows the entry's status, where it is saved, its file and version, and what changed; ⌘\ or Ctrl+\ hides it for a full-width page. Under 1280px it opens over the document instead, and the list gives way under 1024px. Every pane header is the same height, and all of Studio's spacing now comes from the
--space-*scale.studio· cafd545 -
The light scheme moves from ivory to chalk paper.
--paper-0is nowoklch(0.992 0.002 95)(about#fdfcfb) and--paper-2isoklch(0.966 0.003 95)(about#f4f4f1), on the same warm hue as the ink, so light mode reads white without going cold. Light borders are lighter to match (--border0.11,--border-strong0.24). Dark mode does not change. Anything that reads--paper,--backgroundor--border, including Studio's light preview, picks this up with no code change.New type-role tokens set size, leading and tracking together:
--type-title,--type-section,--type-subsection,--type-ledeand--type-meta, each with its--leading-*and, for headings,--tracking-*.--weight-regular,--weight-mediumand--weight-semiboldname the only three weights in use.--type-wordmarkand--tracking-wordmarkset the docs top-bar "graft." wordmark. The landing page keeps its own wordmark for now.tokens· f8c14f4
Fixes
-
Opening the account, sort or filter menu no longer turns Studio into a blank page. Each menu put its label outside a group, which the menu library refuses, and nothing caught the error. Menu labels now always come with their group, menu items sit on one line, and a rendering error shows what went wrong with a Reload button instead of a white page.
studio· d6aec19
1.0.0-beta.6
Released October 11, 2026.
Fixes
-
One loopback list decides every "https unless loopback" question:
@usegraft/compilerexportsLOOPBACK_HOSTSandisLoopbackHost, and the revalidate webhook, Studio'sSecuresession cookie andgraft studio inviteall use them, so the invite check and the cookie can never disagree about a host. Paths under a regular file now read as absent instead of failing withENOTDIR, and deleting a document that is already gone succeeds.cli,compiler,studio· 2386e74 -
graft studio inviterefuses a plainhttp://address unless it islocalhost,127.0.0.1or[::1]. The link carries a sign-in token, so plain http sent it unencrypted, and the session cookie isSecureoff loopback, so the sign-in failed anyway. The usage message for a missing email now lists--nameand--daystoo.cli· 2386e74 -
Two error-handling fixes.
GRAFT_REVALIDATE_URLwith auser:password@part is refused at startup without repeating the credentials, and a network error that quotes the webhook URL has its query stripped, so a token in the URL no longer reaches logs, agents or Studio toasts. Creating a GitHub draft branch now reads only GitHub's "already exists" answer as a race: any other 422, such as a missing commit, fails withREMOTE_STORE_FAILEDand GitHub's reason, where it used to retry to exhaustion and report the document busy.compiler· 2386e74 -
Paths inside the content directory now refuse a dangling symbolic link. The check followed the link, so a link whose target did not exist read as absent, and a Studio or MCP write then created the target outside the content directory. A delete on a read-only filesystem now fails with
CONTENT_TREE_READ_ONLY, the same error a save gives, instead of a rawEROFS.compiler· 2386e74 -
A save to a GitHub draft can no longer be lost while the draft is being emptied. Publishing everything or discarding the last change used to read the draft branch and then delete it, and a save that landed in between was deleted with it after reporting success. The draft is now emptied with a fast-forward that fails if the branch moved, so the branch stays, holding no changes, and the next save reuses it.
Invite-link editors get new draft branch names. Their key joins the hash with
--, which a GitHub login cannot produce, so a login shaped like an email's key (ana-lima-shop-test-0123456789) no longer shares that person's draft. A draft saved under the old name before upgrading stays on GitHub as a branch: publish or discard it first, or merge it by hand.compiler,mcp,studio· 2386e74 -
GITHUB_STORE_ENVnow lists the host commit variables the store reads (VERCEL_GIT_COMMIT_SHA,RENDER_GIT_COMMIT,RAILWAY_GIT_COMMIT_SHA,COMMIT_REF,GITHUB_SHA), exported on their own asDEPLOYED_SHA_ENV. A server that allowlisted its environment from the old list dropped them, so Studio could not tell which commit was deployed.compiler· 2386e74 -
Field descriptors now match what the validator checks. A pattern's flags are kept in the new
constraints.patternFlags, so Studio checks/^[a-z]+$/icase-insensitively like the server does instead of flagging valid values. Constraints are listed only for the field types that enforce them:field.string({ min: 10 })validates nothing, sodescribe_schemano longer advertises a minimum for it.contracts,core,studio· 2386e74 -
explain_errorforREMOTE_STORE_FAILEDno longer promises that a failed save did not happen. A refusal means that one request was not applied, but a publish can move production before a later step is refused, and a network error can arrive after GitHub applied the request, so agents are told to check the branches before retrying. A 404 now also names its other cause: a private repository the token or GitHub App cannot see.mcp· 2386e74 -
A hosted Studio's change diff keeps its memory bounded. It kept a copy of the whole search frontier for every changed line, so a large rewrite (two 10,000-line versions with nothing in common) needed gigabytes and could take the server down. It now skips the unchanged start and end, keeps only the part of the frontier each step uses, and past 2,000 changed lines falls back to every old line removed and every new line added. As before, the drawer renders at most 600 lines and marks a longer diff as truncated. A hunk whose old or new side is empty now starts at the line before it, as git does (
-0,0for a new file).studio· 2386e74 -
@usegraft/studioalso exportsContentChangeNoticeandReviewRequest, the two types its editor DTOs refer to, so typing a response needs one import instead of a second one from@usegraft/compiler.studio· 2386e74 -
Studio fixes from review:
- Discarding a local file that was staged as new and then deleted works, including in a repository with no commits yet. It used to try to restore it from HEAD, where it never was.
- Local drafts report each changed file's version, as GitHub drafts do. Only a deletion has none.
- A failed GitHub sign-in returns to the page the person started from, branch and hash kept, instead of Studio's front page. That needs the sign-in's own state cookie: a callback without a valid one still lands on the front page.
- Slugs drop every combining mark, not only those in U+0300 to U+036F, so a title with one no longer gains a hyphen mid-word.
- "News", "series", "species", "media" and "data" keep their form in the singular ("New news" became "New new").
ContentTreePanelfollows a change to itsbranchprop. It kept showing the first branch.- The first render after a view starts loading shows the loading state, not an empty page.
- The vermilion scale uses the same lightness steps as the other hues, so unpublished-state colour reads at the same brightness.
WorkspaceDto.sessionsis documented as what it is: whether the request carried a session cookie.
studio· 2386e74
1.0.0-beta.5
Released October 10, 2026.
Breaking changes
-
Breaking:
graft serve --studiono longer servesGETandPUT /api/studio/v1/document. UseGETandPUT /api/studio/v1/entry, which read and save the same files and carry a version for conflict detection. See Upgrading.cli· ab7cca3 -
Breaking: remove
GETandPUT /api/studio/v1/documentand theDocumentDtotype. UseGETandPUT /api/studio/v1/entry, which read and save the same files, carry a version for conflict detection, and work against a GitHub store too. An entry deleted in a draft now stays in/entriesasstatus: "deleted", andGET /entryreturns its published copy so it can be read or restored.studio· ab7cca3
Features
-
MCP writes through the content store. On a
graft servethat writes to GitHub (GRAFT_GITHUB_REPOset),write_contentanddelete_contentcommit to the connection's own draft branch instead of the read-only files, and three tools appear:list_drafts,publish_drafts(a pull request, or a commit to the production branch with the newcontent:publishscope) anddiscard_drafts. Agents and Studio editors share one draft model.cli,mcp· ab7cca3 -
Studio has an editor API on top of the content store.
GET /api/studio/v1/entriesreturns a page of a collection searched, filtered, sorted and faceted on the server, from a listing that re-parses only files whose size or mtime changed, so a catalog of thousands stays fast.GET/PUT/POST/DELETE /api/studio/v1/entryandPOST /api/studio/v1/entry/duplicateread, save, create (slug from the title), delete and copy entries; a save that sends the version it read asbaseVersionis refused withCONTENT_CONFLICTwhen that version is stale (a save without one is unconditional)./api/studio/v1/drafts,/drafts/diff,/drafts/publishand/drafts/discardlist unpublished changes, diff them, publish them and throw them away: a commit in your checkout locally (you push it yourself), a commit on the production branch or a pull request on GitHub.GET /api/studio/v1/workspacesays where saves land and what Publish does for the caller.graft serve --studiowrites through GitHub whenGRAFT_GITHUB_REPOis set along with a credential (GRAFT_GITHUB_TOKEN, or a GitHub App). Locally, every save, create, delete and discard compiles, then callsonContentChangewhen one is configured and the content changed, so the app refreshes the way it does for the old routes; a failed refresh comes back asrefresh: { ok: false }and Studio warns.cli,studio· ab7cca3 -
A hosted Studio now has sign-in. Set
GRAFT_STUDIO_SECRETand people sign in with GitHub (GRAFT_GITHUB_CLIENT_IDandGRAFT_GITHUB_CLIENT_SECRET) or with an invite link fromgraft studio invite <email> --role editor, into a signed, HTTP-only session cookie.GRAFT_STUDIO_EDITORSlists who may sign in with GitHub and with which role; without it, anyone with write access to the content repository can. An invite link signs its holder in with the role it was made with, whether or not they are on that list. Roles map to scopes:viewer,contributor(drafts, publishing opens a pull request),editor(adds the newstudio:publishscope) andadmin(addsapprovals:decide). Bearer tokens keep working beside sessions.cli,studio· ab7cca3 -
Add the content store: where authored content is read from and written to, behind one interface.
FilesystemStoreis today's behaviour.GitHubStorewrites through the GitHub REST API, so a hosted surface on a read-only filesystem can save: each editor drafts on their own branch, and publishing lands one commit on the production branch authored by the editor, or opens a pull request. Every write can carry the version it was read at and is refused with the newCONTENT_CONFLICTcode when the document changed underneath it, which covers a second tab or an agent writing under the same identity. Editors draft apart, so between two editors the guard is at publish: it refuses to overwrite a document production changed since the draft began until someone chooses to keep the draft or take the published version. Credentials are a fine-grained token (GRAFT_GITHUB_TOKEN) or a GitHub App (GRAFT_GITHUB_APP_ID,GRAFT_GITHUB_APP_PRIVATE_KEY);githubStoreFromEnvreads the configuration. GitHub failures surface asREMOTE_STORE_FAILED: a refusal carries GitHub's own message and status, and an unreachable GitHub says so.@usegraft/compiler/testingexports an in-memory GitHub for tests.compiler,contracts,mcp· ab7cca3 -
Fields can say how an editor should see them.
field.select({ options })holds one value from a fixed list,field.reference({ to })holds the slug of a document in another collection, every field takes alabel, andfield.number({ format: "money" })declares an amount in the smallest currency unit.describe_schemanow includes each field's label, limits (min,max,int,maxLength,pattern,maxItems), options and reference target, so Studio and agents see the rules the validator applies.contracts,core· ab7cca3 -
Studio is rebuilt for editors. Content comes first: a home page with what is waiting to publish and the collections as a table of contents, a table per collection searched, filtered, sorted and paged on the server with bulk publish, discard and delete, and an entry editor that shows the title in the site's display face, every field with its label, limits and help, and the content in the rich editor. Fields edit in place, including choices, money, references with a search picker, tags, image galleries, nested groups and repeatable lists such as product variants; "Edit in Raw MDX" is gone as the only way to change them. Problems are reported on the field in plain words before a save, and a change made elsewhere since the entry was opened is reported instead of overwritten. Publishing happens in one sheet that names what will happen (publish, submit for review, or commit), lists the changes as entries with their changed fields ("Price: $139.00 → $145.50"), resolves conflicts, and discards. New entries ask only for required fields. A hosted Studio shows a sign-in screen. Index health, schema, history, branches, approvals and settings move under Developer.
ContentTreePanelnow renders the full editor.studio· ab7cca3
Fixes
-
graft servepasses everySet-Cookieheader through. Its Node adapter copied response headers one value per name, so a response setting two cookies arrived with one, and a hosted Studio's sign-in lost the session cookie.cli· ab7cca3 -
Committing a staged rename from Studio works: the old path is recorded as removed instead of passed to
git add, which refused it because the file is gone.studio· ab7cca3 -
@usegraft/studioexports the types of the editor API:EntryDto(the replacement for the removedDocumentDto),EntryList,EntrySummary,SaveEntryResult,WorkspaceDto,DraftsDto,DraftDiffDto,PublishResultDtoand the types they use. Code that calls/api/studio/v1/entrycan type its responses without copying them.studio· 3b396c0
1.0.0-beta.4
Released October 9, 2026.
Features
-
Graft now tells your app when content changes, so its cache refreshes without a manual call.
Before this, an agent's
write_contentor a Studio save updated the index and returned the ChangeSet to the caller. The app kept serving its cached pages until someone called its revalidate route by hand.Webhook. Set
GRAFT_REVALIDATE_URLandGRAFT_WEBHOOK_SECRETwheregraft serve,graft studio,graft mcp,graft mergeorgraft migrateruns. After each local write that changes the index, Graft POSTs{ branch, gitSha, changes }withAuthorization: Bearer <secret>, the body the documented revalidate route already reads. The URL must use https, except on loopback (localhost,127.0.0.1,[::1]), and redirects are refused. A URL without a secret stops the command before it connects to anything.graft compiledoes not call the route, because a deploy compiles before the new version is live. A write to a GitHub-backed store is a draft and calls nothing: the app refreshes when a publish reaches the production branch and the host rebuilds.Hook.
createGraftMcp,createGraftMcpHandler,createStudioApiHandlerandcreateStudioHandlertakeonContentChange(event). An app that mounts the MCP endpoint itself refreshes in-process:createGraftMcpHandler({ // … onContentChange: ({ branch, changes }) => { revalidateContent(branch, changes); }, });@usegraft/compilerexports the pieces:notifyContentChange,createRevalidateWebhook,revalidateWebhookFromEnvand theContentChangeEvent/ContentChangeListenertypes.A failed refresh never fails the write. The write already landed. The server logs the new
REVALIDATE_FAILEDerror,write_contentanddelete_contentreturnrefresh: { ok: false, error, message, fix }, and Studio shows a warning toast. Writes that change nothing do not call the listener.Also fixed: saving a document with an empty body a second time appended a newline to the file, so it projected as a change.
composeDocumentnow leaves a body-less document byte-identical.cli,compiler,contracts,mcp,studio· 4831a77
1.0.0-beta.3
Released October 8, 2026.
Breaking changes
-
graft asset putrefuses a key that already holds a file, likeput_asset.The MCP
put_assettool has refused a taken key withASSET_EXISTSunless the caller passedoverwrite: true. The CLI did not check: it replaced whatever was stored under the key. The store keeps no version history, so the old file was gone, and every page pointing at the key showed the new one.The CLI now checks first and fails with the same
ASSET_EXISTSerror. Pass--overwriteto replace the file on purpose.Both also make the upload itself conditional.
Storage.putfrom@usegraft/assetstakes{ ifAbsent: true }, which sendsIf-None-Match: *, and throwsAssetKeyTakenErrorwhen the store answers 412. So the store refuses a key that a concurrent upload took between the check and the write, or that a credential allowed to upload but not read cannot see. A store that ignores the header writes as before, behind the existence check.Breaking: a script that re-uploads to the same key now fails until it adds
--overwriteor picks a new key.assets,cli,mcp· 56c0957 -
@usegraft/sdk-nextnow works on Next.js 15 as well as 16, and needs a release with the 2026 security fixes. The peer ranges arenext@^15.5.24 || ^16.3.3andreact@^19.0.0. CI builds and serves a real app on each major, type-checks the Next.js guide's examples against it, and checks reads, MDX, targeted refresh andupdateContentin a real Server Action.The package used to declare Next.js 15 or newer while calling APIs that only Next.js 16 has. What changed for you:
- Breaking: the peer range now starts at 15.5.24 and 16.3.3, the first releases that fix the critical Next.js advisories published in 2026. Upgrade Next.js if you are on an older 15.x. Next.js 14 is not supported: it no longer gets security fixes, and those advisories are still open on it.
createGraft,MdxBodyandrevalidateContentwork on 15 and 16. On Next.js 15, tag your reads withunstable_cache(fn, keys, { tags: tagsFor(...) }). The Next.js guide shows both paths.updateContentstill needs Next.js 16. On 15 it now throws aGraftErrorwith codeFRAMEWORK_VERSION_UNSUPPORTEDwhosefixnamesrevalidateContent. Before, the package importedupdateTagby name, and Next.js 15 does not export it.@usegraft/sdk-next/config,@usegraft/sdk-core/dband@usegraft/studio/panelsnow resolve their types undermoduleResolution: "node", the setting Next.js 15 writes into a new tsconfig. Before, those imports had no types there, and reads came back typed asunknown.
FRAMEWORK_VERSION_UNSUPPORTEDis a new error code, with an entry in the error reference and inexplain_error.contracts,mcp,sdk-core,sdk-next,studio· f92b97c
Features
-
Add
graft compile --json. It prints one JSON object on stdout and nothing else:{ "branch", "gitSha", "changes" }, wherechangesis the compile's ChangeSet. The summary line and any other log output go to stderr. It works on the static and Postgres engines.Until now the only ways to get a ChangeSet were an agent's
write_contentresult or callingcompile()from@usegraft/compiler. The new output is the body a revalidate route reads, so a deploy script can pipe it on:graft compile --json | curl --fail-with-body -X POST -H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" -H "content-type: application/json" --data-binary @- https://example.com/api/revalidate--fail-with-bodymakes curl exit non-zero on an error response while still printing its message and fix. It needs curl 7.76.0 or newer. On an older curl, use--fail, which fails the same way but drops the body.postgres-js prints server notices with
console.log, so while a--jsoncompile runs,console.logis routed to stderr. Nothing a dependency logs can corrupt the JSON.cli· f92b97c -
graft servecan check the token audience and record trusted callers as people.GRAFT_TRUSTED_ISSUERSbuilt each issuer with only its URL. The token audience was never checked, so a token the provider minted for another of your apps was accepted, and every caller was recorded in the audit log as an agent.Two variables now apply to every listed issuer:
GRAFT_TRUSTED_AUDIENCE: the acceptedaudvalues, comma-separated. When issuers are set without it,graft serveprints a warning at startup.GRAFT_TRUSTED_ACTOR_KIND:agent(the default) orhuman. Any other value stops the server from starting withINPUT_VALIDATION_FAILED, before it opens a database connection.
cli· 56c0957 -
Concurrent
graft migrate --applyruns apply each migration once.The ledger was read before a run and written after it, so two runs started together both saw a migration as pending and both applied it. The second then failed on the ledger's unique index, after its content rewrite and data updates had already run.
@usegraft/dbexportswithMigrationLock(db, branchId, work). It holds a transaction-scoped Postgres advisory lock for the branch whileworkruns.graft migrate --applyreads the ledger only once it holds the lock, so a second run waits for the first, then skips what the first applied.graft merge --applytakes the same lock on both branches, in a fixed order: it writes the target's ledger, and reads the source's, so no migration can land on the source while its rows move.The lock is transaction-scoped so it works behind a transaction-mode pooler such as Neon's PgBouncer. A crashed run drops its connection, which ends the transaction and frees the lock. There is no stale claim to clear.
The lock's transaction pings every 10 seconds while the run works, so an idle-in-transaction timeout longer than that does not end it early. It needs a pool of at least two connections, one for the lock and one for the work, and refuses a smaller pool up front instead of waiting on itself.
cli,db· 56c0957
Fixes
-
graft <command> --helpprints that command's usage and exits 0.Before,
--helpor-hafter a command name failed withunknown option "--help". Every command now has its own usage block: the usage line, what it does, and only the flags it reads. Help is answered before the other arguments are parsed, sograft merge --into --helpstill prints the merge usage. An unknown command with--helpstill reports the unknown command.cli· 56c0957 -
Clearer wording for two error explanations.
NEEDS_DATABASEnow says the project runs on the static storage engine and the feature needs the Postgres engine, instead of "static index mode" and "Postgres-tier". The registry entry now says "core registry" instead of "Tier-1 registry".@usegraft/tokensadds a--measuretoken for the reading width of prose. It also defines--space-5, which eight declarations already used while it was undefined, so callouts and other spaced elements lost their padding. Callouts get their own note and warning hues instead of reusing the accent, and--n-10names pure white for text on the primary color in dark mode.mcp,tokens· f92b97c -
run_functionnow setsopenWorldHint: true. It runs the project's own functions, and a function can call any external service, so it cannot promise the closed world every other tool does. Every other tool keepsopenWorldHint: false.mcp· f92b97c -
Fix the caching note in each adapter's JSDoc. It said to purge
tagsForChanges(branch, changeSet)"from your compile webhook", but Graft sends no webhook. It now says to purge from your own route, called with a compile's change list, for example the output ofgraft compile --json.sdk-astro,sdk-react-router,sdk-sveltekit,sdk-tanstack-start· f92b97c -
Keep every cache tag within 256 characters. Next.js skips a longer tag with a warning instead of an error, and a skipped tag is never invalidated, so a page read under a long slug stayed stale after its content changed.
documentTagandcollectionTagnow return a tag longer than 256 characters as its first part plus a digest of the whole. Reads andtagsForChangesbuild tags the same way, so they still match. Tags of 256 characters or fewer are unchanged.sdk-core· f92b97c -
useContentSearchandgraft.searchContentnow treat an empty or whitespace-only query as a search with no results. They return[]without sending a request, so a search box that starts empty no longer shows an error before anyone types.All three hooks also stop showing the previous answer for one render when their arguments change.
useContentwith a new slug, oruseContentSearchwith a new query, now reports loading in that same render instead of briefly rendering the old document or hits.sdk-react· f92b97c
1.0.0-beta.2
Released September 7, 2026.
Fixes
-
Clear leftover CodeQL findings: no ReDoS regex on storage URLs, no exception text on
/healthzor Studio asset-url failures, andgraft studioonly opens a validated loopback URL.assets,cli,studio· 98ff4ba
1.0.0-beta.1
Released September 2, 2026.
Fixes
-
Install commands drop
@beta. A plain install is now the right install.Every README said
npm i @usegraft/<pkg>@beta, becauselatestpointed at0.2.0while the docs described1.0.0-beta.x. Writing the tag into 22 files treated the symptom. The defect was the dist-tag:latestis what a bare install resolves, and it resolved to somewhere nobody should land.latestnow points at the prerelease across all 21 published packages, and the0.xline is deprecated, so the tag has nothing left to do.install-tag.mjsinverts with it — it strips tags instead of adding them, and CI fails if one comes back.The half a script cannot check is the registry. Publishing a prerelease while
latestsits on something older reopens the original bug and nothing in the repo will notice. That property is kept by moving the tag at release, and it is written down in the script rather than assumed.assets,auth,cli,compiler,content-api,content-migrations,contracts,core,db,mcp,mdx-safety,registry,sdk-astro,sdk-core,sdk-next,sdk-react,sdk-react-router,sdk-sveltekit,sdk-tanstack-start,studio,tokens· 52fc3e6 -
Dependency floors raised to versions without known advisories.
mainwas carrying 34 Dependabot alerts, and a range-respecting update across the workspace clears 23 of them:next16.2.9 → 16.3.3 (nine alerts on its own),hono4.12.27 → 4.13.5,postcss8.4.31 → 8.5.23, plusastro,tar,sharp,svgo,@astrojs/verceland@hono/node-server.Declared ranges move with the lockfile, which is what makes this a change worth a version rather than a lockfile refresh:
zod^4.1.0→^4.5.4,jose^6.0.0→^6.2.10,jiti^2.4.0→^2.7.0and the React type packages. Every one is a floor raised inside the same major. No peer range changed —sdk-nextstill peersnext >=15.0.0,sdk-reactstill peersreact >=18.0.0— so nothing a consumer already resolves stops resolving.Eleven alerts survive this and are deliberately not addressed here. Two are
vitest, pinned at^2.1.9against a fix in3.2.6: a major upgrade of the test framework is its own change, not a line in a dependency bump. The other nine are transitive versions their parents pin —esbuildnow resolves0.28.1and0.28.2alongside0.18.20,0.21.5,0.25.12and0.27.7, and onlypnpm.overridesdislodges those. Each override is a compatibility bet and belongs where it can be argued one at a time.auth,cli,contracts,core,mcp,registry,sdk-next,sdk-react,studio· 27b8468 -
Point the getting-started install at
@beta, because the docs already describe it.latestis0.2.0and these docs are written against1.0.0-beta.x. That gap is not cosmetic:approvalPolicymoved out ofGRAFT_APPROVAL_POLICYand intograft.config.ts, so someone following the page on a default install configures a policy their build ignores. The relative-endpoint fix and the database leaving the browser dependency graph are also beta-only.Every install command on the page now carries
@beta, with a note saying it is a channel rather than a snapshot — subscribe once, upgrade normally, drop the tag when 1.0 ships.cli· 1f23844 -
Move the docs site to the static tier. It needs no database now.
The docs are MDX in git. The only things forcing Postgres were a
submissionscollection and two functions —docStatsandsubmitContact— that no page insrc/referenced. They were declared to exercise the Postgres tier and never rendered, while the landing page carries that demo properly: its ownsubmitContact, wired to a<ContactForm />a visitor can actually post.So documentation was paying a database's availability, cost and cold starts to demonstrate something it did not show.
graft compilewrites.graft/index.dband 36 pages prerender from it./mcpstill serves agents — the docs MCP handler takesstaticIndexPath, so it reads the same artifact./api/searchstays on-demand for the same reason. The two routes that genuinely needed Postgres,/api/fn/[name]and the authenticated/api/mcp, are gone; both were already local-only, the latter by its own comment ("never set it on a deployed instance").The build is
graft compile && astro build, and the Vercel adapter is told to include the artifact — Vercel traces imports, not data files, so the on-demand routes would otherwise deploy without the thing they read.cli· db5abe6 -
Mark a grouped release as a prerelease when the version has one.
v1.0.0-beta.0was created unmarked, which leaves GitHub free to show it as the repository's Latest release — telling every visitor the beta is the current version, the exact confusion a beta channel exists to prevent. changesets marks its per-package releases; the grouped one now does too.cli· 411e9f0 -
Keep every documented install command on the channel we actually ship.
While the repo is in beta,
latestis an older version than the docs describe. A README sayingnpm i @usegraft/clisends a reader to a build that does not match the page they read it from — during this beta that meansapprovalPolicyingraft.config.tssilently doing nothing, because0.2.0still reads an environment variable.scripts/install-tag.mjsderives the tag from.changeset/pre.jsoninstead of anyone remembering: pre mode means@beta, stable means no tag.release:beta-enterandrelease:beta-exitboth run it, so entering and leaving the channel rewrites 22 READMEs and the docs with it, in both directions. CI checks it, because the failure is silent otherwise.CHANGELOGs are deliberately untouched. They narrate what happened at a version — "
npm i @usegraft/sdk-reactinstalled postgres and drizzle-orm" is a statement about the past, and rewriting it would falsify the record.cli· 0e537ee -
npx @usegraft/cli initin the README could run a copy from weeks ago.npx,pnpm dlxandbunxfetch a package, run it, discard the process — and keep the tarball. So a bare runner command re-runs whatever it cached last, without saying so. That is the failure the dist-tag work was meant to close, arriving by another road: the registry is right and the machine is stale.@latestis what tells them to check, and it is whynpx create-next-app@latestis written that way everywhere. The reason is the cache, not decoration.npm i -D @usegraft/clistays bare. A bare install already resolveslatest— that is what it means — so the tag would add a word that changes nothing, and a tag that is load-bearing on some lines and noise on others teaches a reader to skim past tags where it matters.scripts/install-tag.mjsnow enforces both halves:@lateston the ephemeral three, absent on the installers.cli· 94c8909
1.0.0-beta.0
Released September 1, 2026.
Breaking changes
-
BREAKING: approval policy moves from
GRAFT_APPROVAL_POLICYtoapprovalPolicyingraft.config.ts. The env var is ignored, andgraft servewarns once at boot if it is still set.createFunctionsHandlerhas documented this setting as config-owned all along: "deliberately a value the operator writes in config rather than an env var, because turning off the gate on irreversible work should appear in a diff and a review." The CLI was the piece still reading an env var, so the rationale was written down and not enforced. This is the setting that letsdeleteRecordhard-delete rows with no human in the loop, and a hosting dashboard is where that decision goes unreviewed. It is parsed likemdxTrust: an unknown value is refused rather than defaulted, so a typo cannot silently pick a weaker policy.// graft.config.ts export const approvalPolicy = "unattended";An approval presented to an ungated call is now spent. Under
"unattended"the gate is skipped, and the whole block went with it, includingapprovals.consume. A granted row stayedapprovedand replayable: tighten the policy later and that row still authorized a destructive call nobody re-reviewed. One-shot has to survive a policy change, which is exactly when the stale row is dangerous. Consuming is best-effort here, because an ungated call must not fail on the approval store.run_functionover MCP gets the same rate-limit backstop asPOST /api/fn.graft servepassed{ limit: 60, windowSeconds: 60 }to the functions handler and nothing to the MCP handler, so a function with no per-functionrateLimitwas capped on one transport and uncapped on the other.tools/functions.tsclaims both surfaces apply rate limits identically; now they do.assertSafeMdxreports unparseable MDX asINPUT_VALIDATION_FAILED.UncheckableMdxErrorescaped it raw, sowrite_contentand a Studio save returned a bareErrorwhere every other rejection on that path is a structuredGraftError— a client could not tell malformed input from a transport fault.graft compilecatches the raw error itself and is unaffected.All four found by cubic on the pull request.
cli,core,mdx-safety· 2561b47 -
BREAKING:
writeDocumentFile(root, sourcePath, raw)replaceswriteDocumentFile(fullPath, raw), and contains the path itself. It returns the resolved path..greptile/rules.mdtold reviewers that "every filesystem sink in@usegraft/compilerperforms path containment with symlink refusal". That was false.writeDocumentFiletook an already-resolved path and wrote it. Studio resolved carefully before calling — its source even carries a comment about"../../../../tmp/pwn"arriving as a clean-looking"pwn"— while MCP'swrite_contentdid not, which is how a traversal shipped in a package whose own review rules said it could not.Containment that lives in each caller is not an invariant, because the next caller does not inherit it. The sink no longer trusts its input, so the rule is now true rather than aspirational. The rule text was corrected too, and says what went wrong, since a rule that has been false once is worth annotating.
Raised by cubic on the pull request, against the rules file rather than the code.
compiler,mcp,studio· a6b7ddf -
BREAKING:
createClienttakesindexand no longer takesdb. Pass a Postgres handle tocreateDbClientfrom the new@usegraft/sdk-core/dbentry point instead. Every framework adapter'screateGraftstill takes either shape, so app code that used an adapter is unaffected.npm i @usegraft/sdk-reactinstalledpostgresanddrizzle-orm. cubic flagged it on the pull request and it was worse than a stray manifest entry:sdk-core/src/client.tsimportedcreateDbIndexReaderfrom@usegraft/dbfor its value, so the database package was reachable code, not a tree-shakeable type import — in a package whose stated premise is that a database handle never reaches the browser. The README and/docs/sdk-referenceboth made that claim while the dependency graph contradicted it.The runtime edge now lives in
@usegraft/sdk-core/db, and@usegraft/dbis an optional peer dependency ofsdk-core. A server adapter declares it outright and resolves it; a browser install never pulls it. The five server adapters gained it as a direct dependency, which is honest — they always did need it.The type edge had to move too, or the fix would only have been half true.
ClientOptionsreferencedDatabase, andContentRowwastypeof contentIndex.$inferSelect— derived from a Drizzle table — so any package that merely wanted to describe a row had to install a database driver to name the type.ContentRow,ContentIndexReader,ReaderReadOptions,ReaderSearchOptions,ContentSearchHitandChangeSetnow live in@usegraft/contracts, the layer every package already shares.@usegraft/dbre-exports all six, so existing imports keep resolving, and its table now proves it still matches the published contract instead of defining it — a compile-time assignment that fails if a column changes type. The dependency runs the right way round now: the seam owns the shape and the implementation conforms to it.@usegraft/content-apidrops@usegraft/dbentirely; its imports were always type-only.Two smaller fixes fall out of the same review.
@usegraft/sdk-reactnow refuses a per-readbranchon an endpoint-backed handle, not just one passed to the constructor: the content API pins its branch server-side and rejects the query param, sogetContent(c, s, { branch })silently read main while the caller believed they were reading a preview. And the three read helpers areasync, so that refusal arrives as a rejection rather than a synchronous throw from a function declared to return a promise — the kind a caller handling errors with.catch()never sees. An index-backed handle is unaffected, which is tested, because that is the one configuration where a branch is meaningful.content-api,contracts,db,sdk-astro,sdk-core,sdk-next,sdk-react,sdk-react-router,sdk-sveltekit,sdk-tanstack-start· e2829b4 -
BREAKING:
@usegraft/dbis an optional peer dependency of@usegraft/corerather than a dependency.db-out-of-the-browsermoved the database off sdk-core's direct dependencies, but only half the graph went with it.@usegraft/corestayed a hard dependency of sdk-core and kept its own hard dependency on@usegraft/db, so the chain survived one hop further out:sdk-react -> sdk-core -> core -> db -> postgres, drizzle-orm
Bundling was already safe, because sdk-core imports core with
import typeand those erase. The install was not:npm i @usegraft/sdk-reactstill downloadedpostgresanddrizzle-orm, which is the exact complaint that fix opens with. cubic kept the thread open on the pull request and was right to.npm i @usegraft/sdk-reactnow pulls one external package,zod.Every package that reaches core's database-backed modules at runtime already declares
@usegraft/dbdirectly (cli,compiler,mcp,studio, and the five server adapters), so nothing needs a new dependency. The packages that depend on core without it —auth,content-migrations,sdk-react— import only types. If you depend on@usegraft/coredirectly and calldefineDataMigration,records, or the functions handler, add@usegraft/dbto your own dependencies.core,sdk-core,sdk-react· 655e4d1
Features
-
Let the content API be read from a browser on another origin.
@usegraft/sdk-reactreads over HTTP from a browser, and the handler sent no CORS headers at all — so unless the app and the content API shared an origin, every read was blocked by the browser. That is the ordinary deployment for the browser client, which made it largely unusable as shipped. cubic raised it on the pull request.createContentApiHandlertakesallowedOrigins: a list, or"*". Omitted means no CORS headers and same-origin only, which stays the default because publishing an endpoint to other origins is the deployer's decision rather than a library's.graft servereadsGRAFT_CONTENT_ALLOWED_ORIGINS(comma separated, or*).Details that are easy to get wrong and are tested:
- An allowed origin is echoed with
Vary: Origin. WithoutVary, a shared cache can hand one origin the response it stored for another and the allowlist stops meaning anything. - A disallowed origin gets an ordinary response with no CORS header, not a refusal. The browser enforces it; answering differently would turn the allowlist into an origin oracle for non-browser callers, who are not bound by CORS in the first place.
OPTIONSpreflight is answered before method validation and before the rate limiter, so a browser asking permission is neither a405nor a charge against the budget for the read it precedes.- Error responses carry the headers too, or the browser hides the body and a
developer debugging a
400sees an opaque network failure instead of thefixthis API took care to send.
cli,content-api· d5f5567 - An allowed origin is echoed with
-
Add
list_packages, so an agent can answer "what do I install?"The tool surface could already introspect a project that exists — its collections, functions, errors, owned primitives. Nothing said which
@usegraft/*package to reach for, so a user on SvelteKit could only be told about@usegraft/sdk-sveltekitif the model happened to have read the docs. That is the first question anyone asks, and it was the one question with no surface.Filter by
frameworkand you get that adapter plus the packages every project needs, never a competing one. Filter bytierand a static project is never told to install something that cannot work without Postgres. It is registered on the public documentation mount too: it carries nothing about the project, only which of Graft's own packages exists, which is documentation in the plainest sense.PACKAGE_KNOWLEDGEis held in lockstep with what actually ships by a test, the same wayERROR_KNOWLEDGEis withErrorCodes— a package added without an entry is a package no agent will ever suggest. It caught@usegraft/tokensmissing on its first run.cli,mcp· 76baf51 -
Let
endpointbe a same-origin path, which is what the docs already tell you to write.createGraft({ endpoint: "/api/content/v1" })threwTypeError: Invalid URLbefore a single read.normalizeEndpointcalled barenew URL(endpoint)with no base, and a relative path has no origin to parse against.That form is not a stray idea — it is the example in
/docs/reading-contentand in thecreateGraftJSDoc that ships inside@usegraft/sdk-react. So the first thing a browser reader copied out of the documentation crashed at construction. cubic raised it on the pull request.A string endpoint now resolves against
location.hrefwhen one exists. An absolute endpoint is unchanged, and aURLinstance is unchanged. Outside a browser there is no origin to resolve against, and guessing one would send content reads somewhere arbitrary, so a relative path there is refused asCONFIG_INVALIDwith that reason rather than a bareTypeError.content-api,sdk-react· 655e4d1
Fixes
-
Rate-limit the content API, and give
graft servethe same backstop there it already gave functions.graft servepassedrateLimit: { limit: 60, windowSeconds: 60 }tocreateFunctionsHandlerand nothing tocreateContentApiHandler, which had no such option to pass. Greptile demonstrated the consequence on the pull request by running it: 61 requests to/api/content/v1/documents, 61 responses of200, noRetry-Afteron the last one. These routes authenticate nobody and run database listings and full-text searches, so one anonymous caller could keep the index busy for as long as it liked.createContentApiHandlernow takesrateLimitandtrustedProxyHops. OmittingrateLimitmeans unlimited, which stays correct for a handler mounted behind something that already limits.graft servepasses the same 60 per 60 seconds it gives functions.The counter is in memory rather than in the audit table. The function limiter counts rows it is already writing; these are reads that write nothing, and adding a write per read to enforce a read limit inverts the cost of the thing being protected. Two honest consequences, both documented: it is per process, so N replicas allow N times the limit and a restart resets it; and it is a fixed window where functions get a rolling one. A deployment needing an exact global limit puts it in the proxy already terminating TLS.
The check runs before the index is touched, and after method and route validation. Both halves are tested: a limit that fires once the query has already run protects nothing, and charging a
405to the bucket would let someone probing with the wrong method spend the shared anonymous budget.The caller-identity rule is now shared rather than copied.
rateIdentityand the peer registry move from@usegraft/coreto@usegraft/contracts, which both packages already depend on, and@usegraft/corere-exports them sosetRequestPeerkeeps resolving where callers already import it. That rule — never readx-forwarded-forunless the deployment declares its proxy depth, then count from the right — is listed in.greptile/rules.mdas a security invariant precisely because the obvious implementation is the wrong one, and two copies of it is how a future fix lands in only one.Also documents what was true before this change and unwritten: the content API performs no authentication at all, so its
collectionslist is a security boundary rather than a convenience, andgraft serveregisters every collection in the config.cli,content-api,contracts,core· 15568eb -
Add a versioned, read-only authored-content HTTP API and a remote
ContentIndexReader.graft servenow mounts document reads and search at/api/content/v1, fixed to the server's resolved branch.cli,content-api,mcp· 1139a88 -
Let a local MCP server ask its operator to decide a destructive call in-band, instead of failing with an id for them to run
graft approveon.Opt-in and off by default:
approvalElicitation: { decider }oncreateGraftMcp, orgraft mcp --elicit-approvals, which records the decision as the OS user — exactly whograft approverecords. A remote or public mount must never enable it: there is nobody at the other end to ask.What changes is how the human is reached. What does not change is anything underneath: the decision is still a row in
approvals, still one-shot, still bound to the exact function and canonical input, still stamped withdecided_role = current_user, and still refused by Postgres when the decider is the requester.decideris configured rather than taken from the connection precisely because that predicate lives in the UPDATE's ownWHERE.A client that never declared the elicitation capability falls back to the id-and-retry flow. Dismissing the prompt leaves the approval pending — only an explicit no records a denial.
delete_content,run_functionand the DESTRUCTIVE_OP_REQUIRES_APPROVAL explanation all now say the in-band path exists, so an agent seeing either outcome knows both are ordinary.cli,mcp· d3c51d1 -
Add a real release channel switch, and one GitHub Release per version.
Canary was doing a job it cannot do. A snapshot is
0.0.0-canary-<timestamp>forever: it sorts below every real release, npm never offers it as an upgrade, and there is no path from it to a stable version — you hand someone a build and they pin a timestamp. It answers "does this commit work?", not "is the next version ready?"Beta is changesets prerelease mode:
1.0.0-beta.0→1.0.0-beta.1→1.0.0. A tester runsnpm i @usegraft/cli@betaonce and gets each new beta as an ordinary upgrade, whilelateststays where it is.pnpm release:beta-enter # commit .changeset/pre.json pnpm release:beta-exit # graduate to stable pnpm release:channel # which channel am I on?release.yml's own comment named the reason pre mode was avoided: the state is committed, feat/core takes every ordinary push, and nothing would remind you to leave. So the release job now prints the active channel before anything publishes, and the canary path refuses to run in pre mode and says why. Being in beta is something the log tells you, not something you remember.
One release per version.
fixedmakes all 21 packages one product on one version line, but changesets/action tags and releases each separately — 21 tags and 21 GitHub Releases per version, with one arbitrarily flagged "Latest" because GitHub picks by recency. Tags fragmented the history instead of grouping it. Av<version>release now collects every package's changelog entry. The per-package tags stay, because npm and provenance point at them.cli· 76baf51 -
CI asserts that
.github/rulesets/*.jsonstill matches the branch protection GitHub is actually enforcing.Those files are a record, not a mechanism — a rule relaxed in the web UI leaves no diff anywhere, which is why the directory exists and also its weakness. The README carried "checked 2026-08-31", a claim with a shelf life.
pnpm check:ruleset-driftturns it into a job.It is deliberately one-directional: it never applies the file, because a commit that could rewrite branch protection is a commit that could remove it. The record can fail the build without being able to weaken the branch.
Reading bypass actors needs
administration: read, which the built-inGITHUB_TOKENcannot hold — that permission is not one a workflow may request for it. The job reads aRULESETS_TOKENsecret (a fine-grained PAT scoped to this repository,Administration: Read-only). Until it exists the job skips and says why rather than passing: withoutbypass_actorsGitHub answers200withrulesalone, and checking the rules while never checking who can ignore them prints a green tick nobody should trust. A fork's pull request sees the same skip, so outside contributors are not blocked. It runs as its own job rather than insideverify, keeping that token away from the runner that executes every installed dependency.cli· 4ac881c -
Fix the install command on every package page.
npx graftresolves to an unrelated package on npm, so the documented way to run the CLI without installing fetched the wrong thing. It isnpx @usegraft/clieverywhere now, with a note saying why the scoped name is needed.Fix the
graftRouteexample in@usegraft/sdk-astro. It showed a config object, butgraftRoutetakes the handler, so the snippet did not compile.Document the static index in the SDK READMEs.
createClientandcreateGraftboth acceptindexfromopenStaticIndex(".graft/index.db"), which is whatgraft initscaffolds by default, and none of the READMEs mentioned it. Every read example now shows where itsdborindexcomes from instead of leaving the handle undefined.@usegraft/sdk-sveltekitgets the same treatment, and its README ships in this release. It wasprivateand so could not be named in a changeset at all. It is public now, at the same version as the rest of the workspace.cli,sdk-astro,sdk-core,sdk-next· 36d6045 -
Publish
@usegraft/sdk-sveltekit. It sat at0.1.0andprivatewhile every other package in the workspace was at0.2.0, andprivateis what kept it there. The release config puts every@usegraft/*package in onefixedgroup, and changesets counts a private package as ignored, so naming this one beside any other failed the mixed-changeset check and the release plan refused to build. That left no way to ship a fix for it at all, and it fell one version further behind on each release. It is public at0.2.0now and versions with everything else.Nothing in the package itself changed. The README, the adapter and its tests were already written and are untouched.
src/still tracks@usegraft/sdk-astro: the samecreateGraftandgraftRoute, differing only in doc comments, thecontexttoeventrename that SvelteKit'sRequestEventasks for, and the URL one test uses as a fixture. That adapter is exercised byexamples/docs-site, so this code path has been covered all along and only the packaging held it back.Pin the MinIO images in the self-host Dockerfile to digests.
minio/minioandminio/mcwere both onlatest, which MinIO moves on every release, so the image the README tells people todocker runcould change from one build to the next while nothing in this repo did, and a bad upstream release would arrive with no way to tell what had shifted. Every GitHub Action here is already SHA-pinned for that reason.cli,sdk-sveltekit· 3bb7f7b -
Add
approvalPolicy: "unattended", so a caller with no human behind it can run a destructive function."none"and"human"both had no answer for a scheduled job or a CI migration. The destructive arm of the gate had no off switch at all, which is the absence of a policy rather than a policy: those callers could never invoke a destructive function, ever."unattended"turns the gate off entirely. Everything else is unchanged — every invocation still writes its audit row with actor, correlation id and git SHA, and access rules and rate limits still apply. What is given up is the waiting, not the accounting.It is deliberately not on the MCP surface.
GraftMcpOptionsaccepts only"none"and"human", so there is no server setting that makesrun_functionstop asking before a destructive call. The policy exists for a caller with no human behind it; an MCP mount exists because an agent is calling it, and the agent is precisely the party the gate is there to stop. Ongraft servethat split is observable — the env var lifts the gate onPOST /api/fnand leavesPOST /api/mcpgated.graft servereads it fromGRAFT_APPROVAL_POLICY=unattendedand warns on every boot while it is on, because an env var is one line in a dashboard and the log is where a mistake gets noticed.Worth being explicit about the trade: git restores authored content, so a deleted document comes back, but it does not restore operational data.
deleteRecordremoves rows outright and the asset store keeps no history.cli,core,mcp· a442299 -
Refuse elicited approvals over HTTP, and stop a failed prompt breaking the call.
Three findings from cubic's review of the pull request, all on
approvalElicitation.createGraftMcpHandlernow throwsCONFIG_INVALIDif givenapprovalElicitation. It was documented as "a remote or public mount must never set it" and enforced nowhere. A documented caution is the wrong shape for this one, because getting it wrong inverts the gate rather than weakening it: over HTTP the client being asked to approve is the agent that made the call, whiledecideris configured server-side — so an accepted prompt is self-approval recorded under the operator's name.requested_by_id <> decided_bylives in the UPDATE's ownWHEREprecisely so that cannot happen, and asking the requester's own client walks around it.createGraftMcp(stdio, operator at the machine) still accepts it.A failed elicitation falls back instead of failing the tool call. The capability check covered a client that never declared it; a client that declares it and then fails the request — older SDK, a schema it will not render, a transport that times out with the dialog open — let the error escape as a raw
McpError. It now falls through to the id-and-retry path, the same answer an undeclared capability gets. Nothing is decided, so the row stays pending for a human to find.The prompt no longer truncates silently. Inputs were cut at 300 characters with an ellipsis; the limit is now 1000 and the message says it truncated, gives the full length, and points at
graft approvals. A consent dialog that shows part of what is being consented to without saying so is worse than no preview, because the reader believes they have seen the call.mcp· 18ac061 -
Add
createDocsMcp/createDocsMcpHandler: a public, read-only documentation MCP server, for/mcpon a docs domain.It follows what docs platforms already publish. Mintlify generates a docs MCP at
/mcpfor every site it hosts — public, unauthenticated, strictly read-only, offering search plus navigating and reading the docs filesystem. Cloudflare runs a documentation server separately from its authenticated API server. Agents arrive expecting this shape.The surface is
list_collections,list_content,get_content,search_content,explain_error, and the document resources. What is missing is mostly not about writes:describe_schemacarries the project's functions,list_registryits owned primitives, and the branch, compilation and approval listings its operations. Those are all reads, which is why "read-only" is the wrong test for what belongs on a public endpoint.It is a separate factory rather than a flag on
createGraftMcp, so the authenticated endpoint gains no new way to be opened.DocsMcpOptionsomitsfunctions,actor, storage and approval elicitation outright, andcreateDocsMcpHandlerhas noallowAnonymousescape because it has nothing to authenticate.Internally this splits the content tools into read and write registrations, the introspection tools into collection and function halves, and the resources into documents and schema. A mount whose whole purpose is public should not be one
ifaway fromwrite_content.mcp· ffd5a07 -
Declare
outputSchemaon the 16 tools whose answer has a fixed shape, sostructuredContentis a validated contract rather than a convenience.The SDK checks every result against the schema before it leaves the server, so a tool that quietly changes shape now fails at its own boundary instead of in whatever the agent tried to do with the answer. Where a contract already existed in
@usegraft/contractsit is reused rather than restated:describe_schema,describe_functionanddescribe_itemwere already published shapes with a drift test, and this attaches them to the wire.run_functionanddelete_contentdeliberately keep no schema. Both return whatever the project's own function returns, so a declared shape would promise nothing and cost a validation pass. They are an explicit exemption in the test rather than an omission that looks like an oversight.mcp· e36c09b -
Security:
write_contentcould write outsidecontentDir.The
slugargument wasz.string()with no shape constraint, and the path was built asjoin(contentDir, collection, slug + ".mdx").parseDocument, which runs afterwards, is not a guard against this: it validatesbasename(sourcePath), so a slug of"../../escaped"parses as the entirely legal slug"escaped"while the join walks two directories up. Confirmed by running it before the fix — the file landed outside the content tree and the call returned success, not an error.Any caller holding
content:writecould therefore write an arbitrary file anywhere the server process could. That is most of the point of the scope on a local stdio server, and not at all the point on an HTTP mount.Two independent defences, each verified to hold on its own:
assertSlugShaperejects a non-kebab-case slug in the handler withINVALID_SLUGand afix. Deliberately not a.regex()on the input schema — zod rejects before the tool body runs and the SDK surfaces that as a bare protocol error with nofix, which is the self-teaching an agent most needs when the correct slug is one edit away.resolveContaineddecides where bytes actually land, so the guarantee does not rest on the check above still being there.
delete_contentgets the same slug check, ahead offindDocand ahead of the approval, so a malformed slug never reaches a human as a pending decision.The document resource read and the delete now resolve through
resolveContainedtoo. Those follow asourcePathproduced by a directory scan, which a caller cannot inject — but the scan lists a symlink like any other entry, and following one would serve or unlink a file outside the content tree. That matters most on the read, because the read-only documentation server is an unauthenticated mount. Raised by cubic on the pull request; the write traversal above was found by pulling on it.mcp· 04f7576 -
Add prompts and argument completion.
Four prompts, each filled in from the live project:
author-documentandplan-migrationcarry the collection's actual field list,revise-documentcarries the document's resource URI, andfix-errorresolves the recovery text from this build's own error knowledge. A prompt that only said "author a document nicely" would be a sentence the user could have typed, and would carry no reason to live on the server.They also encode the order of operations an agent has no way to infer:
write_contentvalidates before it writes,write_contentreplaces rather than patches so untouched frontmatter must come back byte-identical, migrations are reviewable commits, andgraft migrate --applyis the operator's consent to propose rather than to run.Prompt arguments and resource-template variables autocomplete from what exists: collection names, document slugs narrowed to the collection already chosen, and error codes matched case-insensitively. Picking a collection that is not registered is a mistake the server can prevent rather than diagnose.
mcp· 23b73fa -
Return
resource_linkblocks from the content tools, so an answer that names documents also says where they are.list_content,get_content,search_contentandwrite_contentnow carry a link per document alongside their text. A tool answering "here are eleven documents" and leaving the client to reconstruct eleven URIs was asking it to know the scheme; a search hit already knows its collection and slug, so the link saves aget_contentround trip to reach the document itself.The URI scheme lives in one module now, used by both the links and the resource registration. Two spellings of it would be two things to keep in step, and the failure would be silent — a link pointing at a URI nothing serves reads as a broken client rather than a server that disagrees with itself. The test follows a link through
readResourcerather than comparing strings.The text block stays first, so a client that ignores links loses nothing, and a failure emits no links at all.
mcp· 9859435 -
Serve authored documents and the project schema as MCP resources.
A tool call is a request to do something; a resource is a thing that exists, addressed by URI, which a client can list and attach to a conversation without spending a turn deciding to. Graft's documents were reachable only as tool output, so attaching one meant an agent calling
get_contentand pasting the answer — and they are files with stable paths, the most resource-shaped thing in the product.URIs are
graft://<branch>/<collection>/<slug>, with the branch baked into the template rather than left as a variable a server would only have to refuse. Reads come from the authored files, not the index, so a resource reflects the working tree rather than the last compile, and works on a static project with no database.graft://<branch>/schemaserves the same payload asdescribe_schema, for attaching once instead of fetching each time.The template's URI variables autocomplete from what exists, and the slug list narrows to the collection already chosen.
Also adds
guardedResource, so a failed resource read carries itsfix. A tool failure is a value that can hold the code, the fix and the recovery text; a resource read has no such envelope, and a GraftError escaping raw arrived with its fix stripped off.mcp· 0db04f7 -
Declare what every MCP tool does to the world, and answer with data as well as prose.
All 18 tools now carry
ToolAnnotations— the hints the protocol has had since 2025-03-26 and Graft shipped none of. Every tool looked identical to a client, sosearch_contentanddelete_contentwere offered on the same terms, and a client that asks a human before a destructive call had nothing to key on. Reads arereadOnlyHint,delete_content/put_asset/decide_approval/run_functionaredestructiveHint,write_contentis a non-destructive change wherecontentDiris a git work tree, which is the ordinary case and the one the hint is set for — outside one an overwrite has no undo, and the hint is then optimistic. Nothing claims an open world: the domain is the collections a project declares.These are hints and not a boundary, exactly as the spec says. Graft's real gates are unchanged — the scope check, the one-shot input-bound approval, the Postgres role separation. This makes an honest client's UX correct; it does not make a dishonest one safe.
Tool results also carry
structuredContent(MCP 2025-06-18) whenever the payload is an object. Every tool already built a JS object and serialised it, so each caller parsed the prose back into the shape it started as. The text block is byte-identical, so a client that predates the field sees no change.@modelcontextprotocol/sdkmoves to ^1.30.0.mcp· fe8b02f -
Add
@usegraft/sdk-tanstack-start. SamecreateGraftsurface as every other adapter, sogetContent("docs", slug)returns the type yourdefineCollectiondeclared and an unknown collection name is a compile error, plusgraftRoutefor mounting the functions and MCP handlers on a server route. Typed structurally, so the package depends on no TanStack code and there is no peer version to keep in step.The framework-specific warning is about loaders. TanStack Start route loaders are isomorphic — they run on the server for the first paint and in the browser on client-side navigation — so a handle holding a database connection belongs in a server function or a server route, never in a loader. Astro frontmatter is the only adapter here that is server-side unconditionally; SvelteKit has the same split under different names, where a
loadin+page.server.tsis server-only and a universalloadin+page.tsalso runs in the browser.Add
@usegraft/sdk-react-routerfor React Router v7 framework mode, which absorbs the Remix path. Same surface again, with one difference worth knowing before you mount anything: React Router splits a route by method into two exports instead of naming the method, so a functions endpoint exports bothaction(POST) andloader(GET). Theloaderline looks redundant and is not — without it a GET gets React Router's own "no loader" error rather than Graft's 405 with anAllowheader and a fix.Its
graftRoutetest builds a typedLoaderFunctionArgs-shaped value rather than passing an object literal inline. TypeScript's excess property check rejects the literal, which would have made the test assert the opposite of what it claims: that the mount accepts only{ request }, when the point is that it accepts the larger object React Router actually passes.Add
@usegraft/sdk-react, the browser client, which only became coherent once@usegraft/content-apishipped. The open question was types across the wire, and the answer is that they do not cross it: the app imports its owncollectionsfromgraft.config.tsat compile time exactly as a server adapter does, andcreateContentApiReadersupplies data over HTTP. No codegen, no generated client, no schema fetched at runtime — the wire carries documents.createGraft({ endpoint, collections })returns the same three reads as every other adapter. On top of that sitcreateGraftHooks(graft)and three hooks that run those reads and report{ data, error, loading, refresh }. The hooks come out of a factory rather than being importable directly because that is what keeps them typed: a hook reading an untyped context could only promiseDocument<AnyCollection>, which makesdata.titleunknown and a misspelled collection name a runtime surprise.They are a binding, not a cache. No deduplication, no retries, no stale-while-revalidate, and
dataanswers the current arguments or is undefined. Apps have TanStack Query or SWR for that andgraft.getContentis a plain async function that composes with either; a worse copy of a query client inside an SDK is the wrong trade.Two things this turned up.
branchis silently dropped over the wire — the content API pins its branch server-side and rejects a branch query param, so a client that set one would read main while believing it read a preview. Passingbranchwithendpointis now aCONFIG_INVALIDat construction that says to point at the preview's own endpoint. Andpackages/sdk-reactcarries the only jsdom config inpackages/:useEffectnever runs without a DOM, so a Node-environment test of these hooks would have asserted the loading state and nothing else — precisely the part that cannot break.sdk-react,sdk-react-router,sdk-tanstack-start· fcfa66f
0.2.0
Released August 27, 2026.
Breaking changes
-
Approval decisions are attributed to a verified identity, never to caller input.
decideApprovaltook adecidedBy: stringthat every surface let the caller supply. That string is the entire separation-of-duties control — the UPDATE's WHERE clause compares it againstrequested_by_id— so anyone who could name it could approve their own destructive operation by naming somebody else. The guard was decorative.Breaking:
decideApproval(db, id, decision, decidedBy)now takes anApprovalDecider({ kind, id }) instead of a string.- The MCP
decide_approvaltool no longer accepts adecidedByargument. It attributes the decision to the identity the connection authenticated as, via the newconnectionActoroption, and refuses an unauthenticated connection. POST /api/studio/v1/approvals/{id}/decideignoresdecidedByin the body; the Studio'sdecidedBymount option is nowdecider: ApprovalDecider.- An approval whose requester has no stable id is undecidable
(
APPROVAL_UNATTRIBUTED). The oldrequested_by_id IS NULLarm made those approvable by anyone, including whoever filed them. - A human-gated function called by an actor with no stable id is refused with
UNAUTHORIZEDinstead of filing an approval nobody can be accountable for.
Adds a
decided_by_kindcolumn (migration0008) so attribution records what kind of actor decided, matchingrequested_by_kind.cli,contracts,core,db,mcp,studio· 61b9ac4 -
graft compilerefuses executable MDX, and the project declares its own trust.MdxBodyrefuses{…}expressions andimportat render by default, and the write paths refuse them on the way in.graft compilechecked nothing, on the reasoning that content already in git arrived through code review.That left the compiler and the renderer disagreeing. A git-authored expression body compiled, indexed, and then failed at render, per request, on the page, in production. Compile now checks every authored body and reports every offending document at once, so the failure lands at build time.
export const mdxTrust = "full"ingraft.config.tsis the escape, for the case ADR 0004 names: every author has commit access, so code review really is the control. It defaults to"restricted", and an unrecognised value is refused rather than defaulted.MdxBody'strustprop is unchanged. The two settings have to agree, so compile's error names both.Breaking:
graft compilefails on authored MDX containing{…}expressions,import,exportor spread attributes, unless the project setsmdxTrust = "full". Evidence the break is narrow: all 28 authored.mdxfiles across both examples compile unchanged.- MDX the checker cannot parse is refused rather than indexed.
New:
readDocstakes a third options argument;CompileOptions,CompileStaticOptions,GraftMcpOptionsandStudioApiOptionsgain an optionalmdxTrust. All default to"restricted", so a call site that omits it is safe rather than permissive.MdxTrustis declared in@usegraft/mdx-safetyand re-exported from@usegraft/sdk-next, which used to declare its own copy of the same union. Same name, same shape, so nothing importing it has to change.See
docs/adr/0006-compile-refuses-executable-mdx.md.cli,compiler,mcp,mdx-safety,sdk-next,studio· 52d7488 -
Host validation and CSRF protection for the local Studio.
A loopback Studio has no authentication by design, so anything that can reach
127.0.0.1can act. Browsers let a page do exactly that: every Studio mutation is a plain POST/PUT parsed withrequest.json(), which ignores Content-Type — so a cross-origin "simple request" carryingtext/plainexecuted with no CORS preflight. The attacker cannot read the response, but the side effect already happened: an approval decided, a document overwritten, a commit made.Breaking:
createNodeListener(handler, { allowedHosts })refuses a request whoseHostis not one it answers to, with 400.graft serveandgraft studioderive the list from their bind address. Without this an attacker-chosen Host flowed into every handler, and a browser resolving any name to127.0.0.1is exactly how DNS rebinding reaches a loopback bind.- The Studio API refuses state-changing requests whose
Originis cross-origin, and requiresContent-Type: application/jsonon them — which forces a preflight for anything that omits Origin. Reads are unaffected.
The shell redirect is now
Cache-Control: no-store: it is built from the request's own Host and fires before any authorization runs, so a 302 cached by path alone would outlive the Host check.The Vite dev proxy rewrites
Originto the API's origin, since in development the browser's origin is the Vite server rather than the Studio.cli,studio· 4bde361 -
MCP over HTTP fails closed. Authentication is no longer opt-in.
createGraftMcpHandlerhadrequireActor?: booleandefaulting to off, on a handler whose own docs advertise embedding it in "a Next.js route, a self-host container, Vercel Fluid, or a Worker". Forgetting it publishedwrite_content,put_asset,delete_contentanddecide_approvalto anyone who found the URL — and unlikegraft serve, a library embedding got no warning at all.Breaking:
requireActoris replaced byallowAnonymous, which defaults tofalse.- Constructing a handler with neither an
actorresolver norallowAnonymous: truenow throws (CONFIG_INVALID). A deployer who forgets gets a startup failure with a fix line instead of an open endpoint. graft servederives the default from the bind host: anonymous MCP is served on loopback (zero-config local dev) and refused anywhere else.GRAFT_MCP_REQUIRE_AUTHis retired — its secure value (1) is now the default, so deployments that set it are unaffected. Off loopback,GRAFT_MCP_ALLOW_ANONYMOUS=1is a deliberate, warned-about opt-in for operators fronting the server with their own auth proxy.- The insecure-bind warning now tests what is enforced. It previously treated
"a dev token exists" as sufficient, so setting
GRAFT_DEV_TOKENsilenced the warning while anonymous callers kept reachingdecide_approval.
cli,mcp· 2eb24ed -
One path-containment helper, and it refuses symlinks.
resolveContained(root, path)(new, in@usegraft/compiler) checks the bytes and the filesystem. Lexical containment —resolveplus a prefix check — only answers "does this string stay under the root", which a symlink inside the root silently defeats:docs/notes.mdx -> ~/.ssh/id_rsapasses every string test andreadFileSyncthen follows it. Git can commit symlinks, so a cloned template can plant one.Breaking:
- MCP
put_assetno longer reads arbitrary server paths. Itspathargument requires the newlocalUploadRootoption, which onlygraft mcpsets (to the project directory) — every remote mount refuses it. Previously the raw string went toreadFileSync, the bytes were stored under a caller-chosen key, and the response included a fetchable URL, so one call read.envoff the server. - Studio
writeDocumentvalidates the slug againstSLUG_REand contains the resulting path.parseDocument's existing check did not help: it validatesbasename(sourcePath), which strips exactly the..segments that make a path dangerous. loadItemvalidates the item name before joining it onto the registry root.describe_itempassed a raw MCP argument through, and the three error branches were distinguishable — a filesystem existence oracle.safeContentPathnow refuses symlinks, so a hostile repository can no longer leak files through the changes-diff endpoint.
Also fixes
looksBinary, which read an entire file into memory to inspect its first 8KB — one large file made every diff render allocate all of it.SLUG_REis now exported from@usegraft/compiler.cli,compiler,mcp,registry,studio· 92fe85e - MCP
-
Rate limits key on the real peer address, and concurrency can no longer outrun them.
Every rate limit in the product was bypassable with a header.
clientIpreadx-forwarded-for.split(",")[0]— the leftmost entry, which under XFF's append semantics is whatever the original client wrote. Rotating the header minted a fresh bucket per request, defeating per-function limits, the handler-wide backstop, and the anti-brute-force property they exist for.Separately the limiter counted prior audit rows, ran the handler, and recorded its row afterwards — a window spanning the entire invocation. N concurrent requests all read the same count, all saw room, and all ran.
Breaking:
AuditStore.record(entry)is replaced byreserve(entry) => idandsettle(id, outcome). The row is inserted before the call is admitted, so the counter and the evidence are the same row. A row leftin_flightis a crashed or still-running invocation, which is worth being able to see.FunctionsHandlerOptions.trustedProxyHops(default0) controls whetherx-forwarded-foris read at all. At0it is ignored entirely. Atn, the nth entry from the right is used — the address your own nearest proxy observed, which a client cannot forge past. Set it to the number of proxies you run.runtimeRoleGrantsSqlgrantsUPDATE (status, duration_ms) ON audit_log. Column-scoped deliberately: the runtime may record how a call ended, never rewrite who made it or what it counted against.
PEER_HEADER(x-graft-peer) is exported from@usegraft/contracts. Graft's Node adapter strips any inbound copy and sets it from the socket, so unlikex-forwarded-forit cannot be written by a client.cli,contracts,core,db· ed103a8 -
Authorization is per-route and scope-based. Being authenticated is no longer enough to reach operator-only surfaces.
The Studio's
authorizewas(request) => boolean— an interface too narrow to express "may this actor do this particular thing", so callers invented their own policy. Whatgraft serveinvented wasactor.kind !== "anonymous", which admits every agent:GRAFT_DEV_TOKENmints{ kind: "agent" }and OIDC issuers default to the same. Any agent holding a normal runtime token could approve destructive operations, write documents, commit, and revert.MCP had the mirror problem: scopes were consulted only inside
run_function's access rules, sowrite_content,put_asset,delete_contentanddecide_approvalwere available to any authenticated caller whatever their token permitted.Breaking:
StudioApiOptions.authorizeis replaced byauthenticate, which returns aStudioPrincipal({ kind, id, scopes }) ornullto refuse.- Studio routes require scopes:
studio:readfor reads,studio:writefor mutations,approvals:decidefor the approval decision. The requirement is computed in one place, so a new route cannot be added without one. - MCP
write_content,put_assetanddelete_contentrequirecontent:write;decide_approvalrequiresapprovals:decide. - Approval decisions made through an authenticated Studio are attributed to the caller, not the mount-time identity.
graft mcpgrantscontent:writelocally (it runs on the operator's own machine) but deliberately notapprovals:decide, so the CLI requesting andgraft approvedeciding remain different identities.- The landing-page example stops granting every self-registered account
submissions:read commerce:orders:read commerce:orders:write. Scopes now come from aGRAFT_OPERATOR_EMAILSallowlist; one free signup previously dumped every contact submission and could mark arbitrary orders paid.
cli,mcp,studio· 6ebfe18 -
Field builders can bound what they accept, and query limits are clamped server-side.
FieldOptionscarried onlyoptionalanddescription, so every authored string and every public form input compiled to a barez.string()and was written verbatim into an unbounded jsonb column. A single anonymous request could store megabytes; an unbounded quantity multiplied by a price silently exceededNumber.MAX_SAFE_INTEGERand stored a wrong total rather than being rejected.New options:
maxLength(string/text),min/max/int(number),pattern(string/text), andmaxItemsonfield.array.Breaking:
listRecordsclampslimittoMAX_RECORD_LIMIT(500) and coerces nonsense values to the default. It previously passed a caller-supplied number straight toLIMIT, so a public query could ask for a billion rows — or a negative one, which made Postgres error.listRecordsgainsmatch, which filters ondatafields in SQL. Filtering after the row cap is a correctness bug, not just a slow path: non-matching rows still consume the window.listCommentsfilteredapproved && pageSlugin JavaScript afterwards, so posting enough unapproved comments emptied every approved comment on every page, silently.
The bundled
commentsandcommerceprimitives now bound every input,placeOrdercapsitemsat 100, andloadProductsbatches the catalog lookup into oneinArrayquery instead of one round-trip per slug — that loop ran before unknown slugs were rejected, so a request full of bogus slugs held a pooled connection for thousands of serial queries and only then failed validation.products.currencyis constrained to three letters, so one malformed product can no longer take down the catalog page viaIntl.NumberFormat.core,registry· e0d4eda -
The hardened runtime role can project content, and the container hardens by default.
graft hardendenied the runtime roleINSERT/UPDATEoncontent_indexandcompilations, so hardening cost a deployment its MCP content writes. That made the second layer of the approval gate something you traded a working feature for, which is why it was opt-in and applied to nothing.The denial was not buying what it looked like.
write_contentwrites the MDX file and then compiles, and compile is the step that reaches Postgres, so whoever holds the runtime credential could already project content through the application. Withholding the grant removed a feature, not a capability.The property worth enforcing is narrower and untouched: no
UPDATEonapprovals, sopending → approvedstays unreachable for the runtime even with raw SQL. Removals are a soft delete, soDELETEoncontent_indexstays ungranted, andmigrations_appliedstays operator-only.Security fix, found reviewing the above. The
approvalsINSERT grant was table-level, andstatusis plain text with aDEFAULTrather than aCHECK. Postgres lets a table-levelINSERTgrantee name every column, so the runtime credential never needed to flip a pending row: it could file one that was already'approved'and consume it, anddecideApproval(with its separation-of-duties predicate) would never run. WithholdingUPDATEalone was not the control it read as. The grant is now column-scoped to the seven columns an approval request actually writes, sostatus,decided_by,decided_atanddecided_rolefall back to their defaults.This predates the changes above, but shipped dormant behind an opt-in nobody ran. Turning hardening on by default is what would have made it live.
Breaking:
runtimeRoleGrantsSqlemits two moreGRANTstatements. Re-rungraft harden <role>; existing hardened roles keep working with the old, narrower grants until you do.- The all-in-one container serves under the hardened runtime role by default.
Set
GRAFT_HARDEN=0for the previous behaviour.GRAFT_MODE=servestays opt-in, because there the database is yours rather than the container's.
See
docs/adr/0005-hardening-is-the-default-where-the-container-owns-the-database.md.db· 52d7488 -
Authored MDX no longer executes as JavaScript by default.
MdxBodycompiles withoutputFormat: "function-body"and evaluates viarun(), which isnew Functionin the host runtime — so{expr}andimportin a stored body are arbitrary server-side JavaScript withprocess,fetchand dynamicimport(). For content the operator wrote and reviewed in git, that is the feature. It stops being one as soon as an author is not the operator: a hosted Studio, or one a user runs for their own writers, makes "can write content" mean "can execute code on the render host" — and on shared infrastructure, on other tenants' hosts too.New package
@usegraft/mdx-safety. It removes the executable surface rather than trying to contain it:node:vmis explicitly not a security boundary, and a worker thread cannot return React elements without breaking component identity under RSC. Prose, GFM and components with literal attributes all survive;{…}expressions,import,export, expression-valued attributes and{...spread}attributes are refused.Breaking:
MdxBodygainstrust?: "restricted" | "full", defaulting to"restricted". Pass"full"only for bodies you know came from your own repository.- MCP
write_contentand Studio document saves refuse executable MDX.
Checked at render as well as at write, because content can also arrive through a direct database write with the runtime credential — a path no write-side guard sees. All 28 authored files across both examples pass unchanged.
mcp,mdx-safety,sdk-next,studio· 1aea0da
Features
-
Every package ships a README, a description, keywords and a LICENSE.
0.1.1published sixteen packages with no README and, for fourteen of them, nodescriptioneither. On npm that renders as a blank page and an unsearchable listing:descriptionis the line npm search shows, and without keywords the packages are findable only by exact name.Each README says what the package is, how to install it, and shows one real example using its actual exports. The security-relevant ones state their defaults plainly, because "MdxBody refuses executable MDX by default" is something a reader should not have to find in an ADR.
LICENSEis now copied into each package.files: ["dist"]does not excludeREADME.mdorLICENSE(npm always packs those), but a licence file only ships if it exists in the package directory, and the root one does not count.assets,auth,cli,compiler,content-migrations,contracts,core,db,mcp,mdx-safety,registry,sdk-astro,sdk-core,sdk-next,studio,tokens· f423a6e -
Thirteen self-contained defects, led by a data-loss bug in the editor.
- The editor wrote one document's content into another document's file.
persisttookcollection/slugfrom the current route closure while taking the bytes from a ref. On an A → B navigation React re-renders with route=B before the pending flush runs, so A's edits were written to B's path, destroying it — and the toast said "Saved B/B". Identity now comes from the same snapshot as the content, via a newbuildSavePayloadthat takes it as an argument, which makes the mismatch unrepresentable. Document loads are sequenced so out-of-order responses cannot show one document under another's route. updateRecordread, merged and wrote with no lock, so two concurrent callers both merged over the same baseline and the second silently erased the first. Now one transaction withSELECT … FOR UPDATE, and the "unreachable"throw new Erroris a realDOCUMENT_NOT_FOUND.- A malformed URL escape white-screened the Studio (
parseHashruns in auseStateinitialiser, so theURIErrorthrew during first render) and turned Studio asset and route-id requests into misleading 500s. - The compiled static index briefly did not exist:
rmSyncthenrenameSyncleft a window where readers gotSTATIC_INDEX_NOT_FOUND, and a crash between them destroyed the artifact. Now an atomic rename, falling back only on Windows. - Data migrations persisted the raw transform output instead of the validated
result, so Zod defaults and coercions never reached the stored rows, and
compared with key-order-sensitive
JSON.stringifyagainst jsonb that normalises key order — rewriting identical rows. - The generated
graft/index.tsbarrel emitted duplicate imports when two filenames collapsed onto one identifier (my-mod.tsandmyMod.ts), in a file marked "do not edit". It now refuses and names both offenders. graft devkept watching the original content directory after a config reload moved it, so every later save was invisible.- OIDC tokens without a
subclaim authenticated as an actor with no id.requiredClaims: ["sub"]now rejects them. applyPlantrusted a conflict snapshot taken when the plan was built; it now re-checks disk immediately before each write.- A
??chain in the pages-description migration short-circuited on an empty string, so the documented title fallback was never reached. - All four GitHub Actions are pinned to commit SHAs, and the release workflow's
id-token: writeis scoped to the publish job rather than the whole file.
auth,core,db,registry,studio· d6cbc3d - The editor wrote one document's content into another document's file.
-
Fixes found by independent review of the hardening work itself.
- The rate-limit peer is no longer a header.
x-graft-peerwas stripped and re-set by Graft's Node adapter, which is sound forgraft serveand worthless in a Next.js or Astro route that passes the browser's Request through untouched — a client could send the header and choose its own bucket. That is thex-forwarded-forbug, relocated to a header nobody knew they had to strip. The peer is now registered against the Request object in-process (setRequestPeer/getRequestPeer), which nothing over the wire can forge.PEER_HEADERis removed. Deployments with no adapter share oneunknownbucket unless they declaretrustedProxyHops; both examples now do. @usegraft/mdx-safetyparses what the renderer parses, and fails closed. The checker usedremark-parse+remark-mdxwhileMdxBodycompiles withremark-gfm— so source that failed to parse here but compiled there was waved through by the old "unparseable means nothing to execute" shortcut. GFM is now enabled on both sides, and unparseable source throwsUncheckableMdxErrorinstead of returning[].- Scripting elements and inline event handlers are refused.
<script>alert(1)</script>and<img onerror="…">contain no{}expression, so the expression checks never saw them. The module now documents that it is not a general HTML sanitiser. createGraftMcpfails closed whenactoris set withoutconnectionActor. That combination silently disabled every MCP write-tool scope check, and it shipped in one of our own example scripts.
cli,contracts,core,mcp,mdx-safety· 301c817 - The rate-limit peer is no longer a header.
-
An approval is filed pending by construction.
Migration
0009_pending_by_constructionadds aCHECKonapprovals.statusand aBEFORE INSERTtrigger that refuses any insert naming a status other than'pending', or carrying a decision indecided_by,decided_at,decided_roleordecided_by_kind.The column-scoped INSERT grant already stopped a hardened runtime role from filing an approved approval. That is a grant list, and a grant list is a thing someone edits. This puts the same rule in the table, so it holds for every role including the owner, and no future change to
runtimeRoleGrantsSqlcan quietly reopen it.The trigger raises rather than coercing the row to
'pending'. A caller trying to file a decision has a bug or is an attacker, and silently rewriting the row would hide both.Deciding is an
UPDATEand is untouched.Requires a migration. Run
graft db migrate(ornode packages/db/scripts/migrate.mjs) before serving on an existing database.db· 02690dd
0.1.1
Released August 25, 2026.
Fixes
-
graft --versionreported0.0.0instead of the released version.The version was a hardcoded constant that changesets never touched, and the test asserted
toContain("0.0.0")— so it passed because of the bug. The version is now read from the manifest at runtime, and the test asserts against that value rather than a literal.cli· 6737b5b
0.1.0
Released August 25, 2026.
Features
-
Initial public release of the Graft packages: contracts, core, db, assets, compiler, content-migrations, auth, sdk-core, sdk-next, sdk-astro, sdk-sveltekit, cli, mcp, registry, studio, tokens. All packages version together (fixed group) while pre-1.0.
core· 8d8eda0