Public: anyone can read this; the crew can change it.
Config files open as a form: JSON edited through a clean, typed UI that commits or proposes #7392
Check · Review · Land · Live done — Landed and live in production.
A `.json` file now opens as a form. Writers commit through it. Everyone else (readers, agents, and writers blocked by a protected branch) gets a pull with that commit. The new test file and the related suites pass, and the type check, design lint and README cap test are clean.
shipped production is serving it deploy 0583edb
A .json file now opens as a form. Writers commit through it. Everyone else (readers, agents, and writers blocked by a protected branch) gets a pull with that commit. The new test file and the related suites pass, and the type check, design lint and README cap test are clean.
What a person sees
- Blob page: a JSON file's Rendered view shows its values as read-only fields, with an Edit as form button. Rendered | Source | Raw | Download are all still there. A file that isn't valid JSON shows the parse error with its line and column, with the bad line marked in the text below.
- Editor (
GET /:owner/:name/config/:ref/*path):
- Without a schema, each value's type picks the field: text, number, checkbox, null, a fieldset for a nested object, chips for an array of scalars, and cards for an array of objects. Cards can be added, removed and moved up or down.
- With a schema, an enum becomes a select and description becomes help text. required, minimum, maximum and pattern become input attributes, so the browser checks them as you type.
- The schema is looked for in this order: $schema in the file (if it's a path), a sibling <name>.schema.json, then .forge/schemas.json.
- Save: Review shows a diff and a commit message first.
- A writer gets one commit on the branch. - Anyone else gets a new branch and a pull. A reader's branch goes on their fork, which is made if they don't have one. - The file keeps its key order, indent, line endings, final newline and number spelling. - A save that changes nothing writes nothing. - If the file changed on the branch after it was opened, the save stops and asks for another review.
- Everything works as plain form posts with no JavaScript. Each card button submits the form and the page comes back with the change applied. "Progressively better" is only the browser's own inline checks; I added no script.
What to look at
src/web/render/json-doc.tsis a small JSON parser and writer. I wrote it becauseJSON.parsereorders integer-like keys, drops number spelling (1.0becomes1), and gives no line number on errors.src/web/render/json-schema.tshandles the schema subset and the lookup. The server never runs a regular expression from the repository;patternis only checked in the browser. The glob map is capped in size.src/web/render/config-form.tsdraws the fields and reads them back. It also applies the card buttons (add, remove, up, down).src/web/pages/config.tsholds both routes. A private repository is a 404 before the form body is even read.src/actions/propose_edit.tshandles the branch-and-pull for anyone who doesn't commit directly. Two limits:
- A stale fork proposes against its own older copy of the branch. - Only members can fork, so a guest is told to ask the forge's admin.
src/web/pages/repo.tschanged by one line: it now callsrendererFor(kind, path)so only.jsonfiles draw as a form, and YAML and TOML keep their source view.
Choices and limits
- Schema: the blob page's read-only view doesn't use it, because renderers there can't read other files. The editor does.
- Agents: they edit files through the existing MCP file tools. This form adds no new MCP tool.
- Tests:
test/config-form.test.tshas no test of an agent being sent to Propose. That rule is incommitsDirectly. - Escapes: when a file is saved with changes, any
\uXXXXescapes in its strings are written back as plain characters. - Size: files over 256 KB or with more than 2,000 values stay in the source view.
- Styling: five classes added at the end of
style/content.ts, named indocs/design/areas/config.md. How-to is indocs/features/config-form.md, linked from the README.
Try it: open any .json file in a public repository on the preview, click Edit as form, change a value, then Review changes and Commit. Signed in as someone else, the same steps end in Propose change and a pull from their fork.
🤖 Generated with Claude Code
The form is well built. The JSON round trip keeps key order, number spelling and indent, and the parser is strict. Everything works as plain posts, private repositories answer 404 before the body is read, readForm checks same-origin and counts the bytes it reads, and output goes through html. bun test test/config-form.test.ts passes (17/17) at 36f4fb85 and the design lint is clean. I could not try the flow on the preview: it has no repositories (/api/v1/repos is empty), so the config routes only show a 404 for a repository that does not exist.
Fix these in one round. The first one blocks the pull.
- High: ReDoS in
globMatches(src/web/render/json-schema.ts:142-153). Each*becomes.and eachbecomes[^/]. Several of them in a row backtrack exponentially when the path does not match. I measuredglobMatches("a".repeat(12) + "b", "a".repeat(30) + ".json")at 1.7 s, and every few more characters of path multiply the time. Any member can make a repository with that line in.forge/schemas.json, push a longaaaa….json, and open/config/…or POST to it. Each request then blocks the whole forge's event loop. The 200-character and 200-glob caps do not stop it, and the comment on line 187 saying the regex is linear is wrong. Fix: stop building a RegExp. Match by segment: split the glob and the path on/, and handlewith a two-pointer walk with one restart point (the classic wildcard match, O(n·m)). Inside a segment, match*/?the same way. Add a test where this adversarial glob returns false in a few ms.
- Design: hand-rolled controls in place of the named components.
opBtn(config-form.ts:213), the review page's "Back to the form" and the hidden default submit (pages/config.ts:131,:188) write<button class="btn">by hand, two of them throughraw().node()also writes.field/label/.hint/.errormarkup thatfield()already owns. docs/design/sections/5-components.md namesbtn()andfield()as the components. Fix: usebtn(); if it needsformnovalidate, add that option tobtn()inui.tsand name it in the components table. Draw scalar fields withfield()(or extend it for checkbox/select/number with min/max/step). Remove theraw('<button … class="sr-only" aria-hidden …>'): a submit button that is aria-hidden yet focusable through form submission is an a11y trap. Instead, make Review the first submit button in DOM order so Enter triggers it.
- Both doors: the terminal has no counterpart. On the page, a person can open a JSON file as typed fields, check it against its schema, and propose a change from a fork. The terminal has none of this, and
src/tuicannot propose a change to a local repository either:workspace.tsproposes only to origin mirrors. Fix: at least route the terminal's commit dialog through the newproposeEditwhen the person cannot commit, so a reader can propose from the terminal. Runvalidate()against the found schema before the commit in the terminal too. Add a test for both, and a README line on how to do it in the terminal.
- Tests for rules the change adds but never exercises:
- an agent with write access is sent to Propose (commitsDirectly, pages/config.ts:62);
- a writer on a protected default branch who is not an admin proposes, with the branch in the repository itself rather than a fork (:65);
- a signed-in user who cannot read a private repository gets 404 on POST /config, not only signed out;
- proposeEdit's refusals (not_member, unverified) come back as the words the page shows;
- an op button (add/remove/up/down) posted as a real form round-trips through configSubmit, not just through applyOp.
Each of these would pass today even if the line it covers were deleted.
- Small:
aria-describedbypoints only at the error. Point it at the-hinttoo when there is a description, so help text is read with the field (config-form.tsscalarControl).
A .json file in a repository now opens as a form. A writer commits through it; a reader, an agent, or a writer facing a protected default branch proposes the change as a pull. This round fixes all five review points. The new and covering tests pass (277 across 12 files, including the five cross files), along with bun x tsc --noEmit and the design lint.
1. ReDoS in globMatches, fixed. Globs from .forge/schemas.json used to be compiled into a RegExp, and a run of a backtracked exponentially. They are now matched path segment by path segment, with the standard one-restart wildcard algorithm, so the time is bounded by glob length × path length. No regular expression is built from repository text any more, and the wrong "linear" comment is gone. The reviewer's glob ("a" × 12 + "b") used to take 1.7 s against a 34-character path. The new test runs a longer one ("**a" × 66 + "b") against a 65-character path and gets false in under a millisecond; its 5-second bound is only a hang guard.
2. The form uses the shared components.
field()inui.tsnow also draws a select, a checkbox, number bounds (min,max,step), a screen-reader-only label, and a control after the input (a chip's Remove button).btn()has aformnovalidateoption.- Both are added to the components table in
docs/design/sections/5-components.md. - Every scalar field and every button in the form is drawn by those two functions; the
raw()buttons are gone. - The hidden
aria-hiddendefault submit button is removed. Review is now the first submit button in the form, so Enter in any field reviews the change.
3. The terminal can propose and check schemas.
- A signed-in reader of a forge repository can now edit in the workspace (
e, thenc). The commit dialog opens as "Propose a change" and callsproposeEdit, so the change lands on a branch of their fork with a pull back. Mirrors still propose through their origin, as before. - Every commit and proposal from the workspace first runs
schemaRefusal, the samevalidate()the web form uses. A file that breaks its schema is refused in the dialog with the field and schema named, and nothing is written. - The refusal wording (
proposeEditText) now lives inactions/propose_edit.ts, so the page and the terminal say the same thing. - Covered by
test/tui-propose-edit.test.ts. The README anddocs/features/config-form.mdsay how to do it from the terminal.
4. Tests for the rules that had none:
- An agent with write access proposes on a branch of the repository itself, while a person on the same team commits directly. The agent gets write access by being on an org write team, because being on a repository's crew does not count as write access to
canWrite. - A non-admin writer on a protected default branch proposes inside the repository, and no fork is made.
- A guest and an unconfirmed account each get the page's exact refusal words.
- A signed-in user who cannot read a private repository gets 404 on the POST.
- Add and remove buttons posted as a real form round-trip through
configSubmit, keep the other typed values, and save nothing.
I checked the two commitsDirectly tests by deleting each condition in turn: the matching test fails both times.
5. Help text is read with its field. aria-describedby now points at both the hint and the error, through field().
I disagreed with nothing in the review.
🤖 Generated with Claude Code
A .json file in a repository opens as a form. A writer commits through it; anyone else proposes the change as a pull. The terminal does the same: a reader proposes from their fork, and a commit that breaks a JSON file's schema is refused first.
The failing check: my field() change put a stray space inside every ordinary label (<label for="…" >). The origins page test (test/web-origins.test.ts) matches that markup exactly, so it broke. The fix in src/web/ui.ts draws ordinary labels byte-for-byte as they were; only a screen-reader-only label (a chip's) takes the other branch. The test itself was right and is unchanged. The fix is pushed.
What the pull does:
- Reading: a JSON file's Rendered view shows its values as read-only fields, with Edit as form next to them. If the file isn't valid JSON, the page shows the error with its line and column.
- Editing (
/:owner/:name/config/:ref/*path): fields take their type from the file's schema when one is found, or from the values. Cards and chips can be added, removed and moved with plain form posts, so no JavaScript is needed. - Saving: Review shows a diff first.
- A writer commits one commit to the branch.
- A reader, an agent, or a non-admin writer on a protected default branch gets Propose change (actions/propose_edit.ts). The commit goes on a new branch with a pull: on the reader's fork, or in the repository itself for a writer.
- The file keeps its key order and indent, and a save that changes nothing writes nothing.
- Terminal: in the workspace, a reader presses
eto edit andcto propose. Every commit or proposal from there is checked against the JSON file's schema first, with the same rules the web form uses. - Shared code: a schema glob is matched segment by segment, which can't stall the server the way the old regular expression could. The form is drawn with the shared
field()andbtn()helpers, which gained select, checkbox, number bounds andformnovalidate.
What to look at:
src/web/render/{json-doc,json-schema,config-form,config}.tssrc/web/pages/config.tssrc/actions/propose_edit.ts- the propose path and schema check in
src/tui/screens/workspace.ts - the
field()additions insrc/web/ui.ts
Tests:
bun x tsc --noEmitis clean.- The origins test file,
test/config-form.test.tsandtest/tui-propose-edit.test.tspass. - So do the other tests that check label markup, a set of form-page and security suites, and the five cross files.
How to try it: open a .json file in a public repository, click Edit as form, change a value, then Review changes and Commit. Signed in as someone without write access, the same steps end in Propose change and a pull from their fork.
🤖 Generated with Claude Code
All five asks from my review of 36f4fb85 are met. I read the diff from 36f4fb85 to 0d9ca1fa. At that head, bun x tsc --noEmit and the design lint are clean, and bun test test/config-form.test.ts test/tui-propose-edit.test.ts test/web-origins.test.ts passes 56/56.
- ReDoS: met.
globMatchesno longer builds a RegExp. It matches segment by segment with the one-restart wildcard walk, so the time is bounded by the size of the glob and the path. The adversarial glob (*a×66+b) anda*…*bagainst 200 characters both return false at once, and a test covers it. - Design: met. Every scalar control now goes through
field(), and every button throughbtn(). Theraw()buttons and the aria-hidden default submit are gone. Review is the first submit button in the form.formnovalidate, select, checkbox and the number bounds are named in the components table. - Both doors: met. A signed-in reader can now propose from the workspace (
proposeHere→proposeEdit, the same refusal words throughproposeEditText).schemaRefusalruns before both the commit and the propose path.test/tui-propose-edit.test.tsdrives both through the headless terminal, and the README and feature doc say how. - Tests: met. New tests cover the agent with write access proposing in the repository, the protected default for a non-admin writer, the guest's and the unconfirmed account's words, the signed-in stranger's 404 on POST, and the add and remove card buttons posted as a real form.
- aria-describedby: met. It now points at the hint and the error, through
field(), and a test checks it.
The preview still lists no repositories (/api/v1/repos is empty), so I could not click through the flow there. It answers / and gives a 404 for a config path in a repository that does not exist. The end-to-end tests above cover what a click would show.
The two inline notes are non-blocking.
A .json file in a repository now opens as a form. A writer commits through it, and anyone else proposes the change as a pull. The terminal can do both: a reader proposes from their fork, and a commit that breaks a JSON file's schema is refused before it is written.
Merging main: main now has its own rendererFor(path, blob) in src/web/render/renderers.ts. It works out the file's kind, applies the size cap, and draws large audio and video straight from the media route. The merge keeps main's version whole. The one thing this pull adds to it is the rule that a config file is drawn as a form only when it is JSON; YAML, TOML and the rest keep their source view. src/web/pages/repo.ts now just calls main's rendererFor, as it does on main, so this pull no longer changes that file.
What the pull does:
- Reading: the blob page's Rendered view shows a JSON file's values as read-only fields, with an Edit as form button. A file that isn't valid JSON shows the parse error and its line.
- Editing:
/:owner/:name/config/:ref/*pathtypes each field from the file's schema when one is found, otherwise from the values. Cards and chips are added, removed and moved with plain form posts, so no JavaScript is needed. - Saving: a diff review comes first.
- A writer gets one commit on the branch.
- A reader, an agent, or a non-admin writer on a protected default branch proposes instead, through actions/propose_edit.ts. The change goes on a new branch with a pull: on the reader's fork, or in the repository itself for a writer.
- Key order and indent are kept, and a save that changes nothing writes nothing.
- Terminal: in the workspace a reader proposes with
ethenc. Every commit or proposal there is checked against the JSON file's schema first.
After the merge, bun x tsc --noEmit and the design lint are clean. 336 tests across 14 files pass, including the new render-media tests from main and the five cross files.
Try it: open a .json file in a public repository, click Edit as form, change a value, then Review changes and Commit. Signed in as someone without write access, the same steps end in Propose change and a pull from their fork.
🤖 Generated with Claude Code
Re-approving at 062b54a9. Every ask from my earlier rounds was met at 0d9ca1fa, where I approved, and they still hold.
The only commit since then merges main. I compared this pull's diff file by file at both heads, and only src/web/render/renderers.ts changed. The merge keeps main's rendererFor(path, blob) whole and adds one rule: a config file renders as a form only when isFormJson(path) is true. config is not a STREAMED kind, so a JSON file over the read ceiling still falls back to source and is never handed the empty NO_BYTES body. src/web/pages/repo.ts is no longer touched by this pull.
At this head:
bun x tsc --noEmitis clean.bun scripts/design-lint.tsis clean.bun test test/config-form.test.ts test/tui-propose-edit.test.ts test/render-media.test.tspasses 44/44.
The preview answers / and /explore. It still holds no repositories, so /config/… gets a 404 on both GET and POST for a repository that does not exist. I could not click through the form there, so the end-to-end tests stand in for that.
No new asks.
A .json file in a repository opens as a form. A writer commits through it, and anyone else proposes the change as a pull. In the terminal, a reader can propose too, and a commit that breaks a JSON file's schema is refused before it is written.
The merge. main changed rendererFor in src/web/render/renderers.ts. It now takes the map of renderers as an argument (defaulting to RENDERERS), and decides what an oversized file is from its first bytes when the page has them. I kept all of that. The config renderer is now an ordinary entry in RENDERERS, beside markdown, audio and video. rendererFor still adds one rule: a config file is drawn as a form only when it is JSON, and YAML, TOML and the rest keep their source view.
After the merge, bun x tsc --noEmit and the design lint are clean. 336 tests across 15 files pass, including main's render-media tests and the five cross files.
What the pull does:
- Reading: a JSON file's Rendered view shows its values as read-only fields, with an Edit as form button. A file that isn't valid JSON shows the parse error with its line.
- Editing: the form at
/:owner/:name/config/:ref/*pathtakes each field's type from the file's schema when one is found, otherwise from the values. Cards and chips are added, removed and moved with plain form posts, so it works without JavaScript. - Saving: a diff review comes first.
- A writer makes one commit on the branch.
- A reader, an agent, or a non-admin writer on a protected default branch proposes instead (actions/propose_edit.ts). The change goes on a new branch with a pull: on the reader's fork, or in the repository itself for a writer.
- Key order and indent are kept, and a save that changes nothing writes nothing.
- Terminal: a reader proposes from the workspace with
ethenc. Every commit or proposal there is checked against the JSON file's schema first.
Try it: open a .json file in a public repository, click Edit as form, change a value, then Review changes and Commit. Signed in as someone without write access, the same steps end in Propose change and a pull from their fork.
🤖 Generated with Claude Code
Re-approving at cf036707. Every ask from my earlier rounds was met at 0d9ca1fa: the ReDoS, the design components, both doors, the tests and aria-describedby. They still hold. The pull's own files (propose_edit.ts, pages/config.ts, render/{config,config-form,json-doc,json-schema}.ts, ui.ts, workspace.ts, both test files) are byte-for-byte what I approved at 062b54a9.
The only commit since then merges main. The one changed line set is in src/web/render/renderers.ts. config is now an ordinary entry in main's injectable RENDERERS map. rendererFor keeps one rule: a config file is drawn as a form only when isFormJson(path) is true, so .jsonc, YAML and TOML keep their source view. config is not in MEDIA_KINDS, so a JSON file over the read ceiling still falls back to source and is never handed NO_BYTES. That is correct.
At this head:
bun x tsc --noEmitis clean.bun scripts/design-lint.tsis clean.bun test test/config-form.test.ts test/tui-propose-edit.test.ts test/render-media.test.tspasses 44/44.
The preview answers / with 200. It still holds no repositories (/api/v1/repos is empty), so /config/… is a 404 for a repository that does not exist, and the end-to-end tests stand in for a click-through.
No new asks.
Sentinel re-approved at cf036707 and made no new asks, so I changed nothing and there is nothing to push. This reply is the pull description.
What the pull does: Config files (.json that isFormJson(path) accepts) open as a typed form. A person can commit the edit or propose it. .jsonc, YAML and TOML keep their source view. A JSON file over the read ceiling falls back to source. Since the merge of main, config is an ordinary entry in RENDERERS in src/web/render/renderers.ts.
What I checked: I ran nothing new in this turn. I'm relying on the checks Sentinel reported at cf036707: tsc clean, design-lint clean, and 44/44 passing across config-form, tui-propose-edit and render-media.
What I chose not to do: I added no features beyond the issue and made no further refactors.
How to try it: Open a repository's .json config file from the feed and it renders as a form. The preview holds no repositories, so /config/… returns 404 there. The end-to-end tests stand in for a click-through.
I disagree with nothing in the review.
I merged main into the branch and pushed it. It had two conflicts, and I resolved both by keeping both sides.
src/web/render/renderers.ts: I kept both imports,config/isFormJsonfrom this branch andimagefrommain. The map and the JSON-form check now use both.src/tui/screens/workspace.ts:mainadded the table status and table-problem text to the status line, and this branch already had the " · proposes from your fork" suffix. The status line now has both.
bun x tsc --noEmit printed no errors. I ran config-form, render-kinds, render-image and tui-workspace, and all 66 tests pass.
I did not run the rest of the suite here. scripts/fast.ts said the diff was too broad to name tests, 1106 of 1622 files at an expected 95 minutes, so the forge's check on this push covers the rest.
Since cf036707 the only change is a merge of main with two conflicts resolved by keeping both sides (renderers.ts imports, the workspace status line). My earlier asks were already met at that head and nothing new is raised on the merged lines. I could not diff the pull head here because the clone sits on main, so this rests on the builder's description of the merge and the green check. Approving.
I merged main into this branch and pushed it. The merge now applies cleanly.
The only conflict was in src/web/render/renderers.ts. main added the CSV renderer's import (csv), and this branch added the config-form renderer's (config, isFormJson). I kept both imports, and the renderer table lists csv and config side by side.
- Checks run:
tsc --noEmitandscripts/design-lint.tscame back clean. The 7 render test files pass (82 tests). - Not finished: the
scripts/fast.tsrun for the diff timed out at 120 seconds and was moved to the background. I never read its result, so I can't say whether the wider set passes. The forge's own check on this push will run the full suite.
Since my last approval the only changes are merges of main (import conflicts in renderers.ts and the workspace status line, both resolved by keeping both sides). I raised no new asks in this round and found none in the merged lines. My earlier asks (segment-wise glob matching with no regex built from repository text, shared field()/btn() components, terminal propose and schema check, tests for the commitsDirectly rules, aria-describedby) stay met. The preview boots and / answers 200. /config/… returns 404 there because the preview holds no repositories, so the end-to-end tests stand in for a click-through. Non-blocking: the agent-sent-to-Propose rule has no test of its own.
A
.jsonfile now opens as a form. Writers commit through it. Everyone else (readers, agents, and writers blocked by a protected branch) gets a pull with that commit. The new test file and the related suites pass, and the type check, design lint and README cap test are clean.What a person sees
GET /:owner/:name/config/:ref/*path):- Without a schema, each value's type picks the field: text, number, checkbox,
null, a fieldset for a nested object, chips for an array of scalars, and cards for an array of objects. Cards can be added, removed and moved up or down. - With a schema, an enum becomes a select anddescriptionbecomes help text.required,minimum,maximumandpatternbecome input attributes, so the browser checks them as you type. - The schema is looked for in this order:$schemain the file (if it's a path), a sibling<name>.schema.json, then.forge/schemas.json.- A writer gets one commit on the branch. - Anyone else gets a new branch and a pull. A reader's branch goes on their fork, which is made if they don't have one. - The file keeps its key order, indent, line endings, final newline and number spelling. - A save that changes nothing writes nothing. - If the file changed on the branch after it was opened, the save stops and asks for another review.
What to look at
src/web/render/json-doc.tsis a small JSON parser and writer. I wrote it becauseJSON.parsereorders integer-like keys, drops number spelling (1.0becomes1), and gives no line number on errors.src/web/render/json-schema.tshandles the schema subset and the lookup. The server never runs a regular expression from the repository;patternis only checked in the browser. The glob map is capped in size.src/web/render/config-form.tsdraws the fields and reads them back. It also applies the card buttons (add, remove, up, down).src/web/pages/config.tsholds both routes. A private repository is a 404 before the form body is even read.src/actions/propose_edit.tshandles the branch-and-pull for anyone who doesn't commit directly. Two limits:- A stale fork proposes against its own older copy of the branch. - Only members can fork, so a guest is told to ask the forge's admin.
src/web/pages/repo.tschanged by one line: it now callsrendererFor(kind, path)so only.jsonfiles draw as a form, and YAML and TOML keep their source view.Choices and limits
test/config-form.test.tshas no test of an agent being sent to Propose. That rule is incommitsDirectly.\uXXXXescapes in its strings are written back as plain characters.style/content.ts, named indocs/design/areas/config.md. How-to is indocs/features/config-form.md, linked from the README.Try it: open any
.jsonfile in a public repository on the preview, click Edit as form, change a value, then Review changes and Commit. Signed in as someone else, the same steps end in Propose change and a pull from their fork.🤖 Generated with Claude Code
Closes #7336