Series seed

Nothing to log into

The whole security model was one line, and the two things it still didn't cover.

5 min read

The parser worked and there was still nothing to look at. So the second half of the 19th was one decision, and I sat on it longer than the amount of code deserved.

Where does this thing actually live.

Every hosted CMS answers that the same way. The editor is a website. It runs somewhere, it has an address, and because it has an address it needs a door, and because it has a door it needs a lock, users, sessions, a way to reset a password I’d forget anyway. None of which has anything to do with writing. All of which I’d have to maintain forever, on a personal site, for exactly one user.

I really didn’t want to build any of that.

The whole thing is one line

The editor is an Astro integration. Integrations get a hook that runs when the config is set up, and that hook knows which command you ran.

'astro:config:setup': ({ command, injectRoute }) => {
  if (command !== 'dev') return;
  injectRoute({ pattern: '/_editor', entrypoint: './src/editor/routes/editor.astro' });
  injectRoute({ pattern: '/_editor/api/[...path]', entrypoint: './src/editor/routes/api.ts' });
},

That’s it. That’s the security model.

Under astro dev, the routes get injected and the editor exists. Under astro build, the function returns before it injects anything, so the routes are never registered, never rendered, never emitted. There is no /_editor on the deployed site to find. There’s nothing to brute force, nothing to rate limit, nothing to log into.

I like this more than I expected to. It isn’t security by obscurity, where the thing is there and you hope nobody looks. The thing is not there. You can’t misconfigure your way back into it, because the code that would have created it didn’t run.

And because a claim like that is worth exactly as much as the test behind it, there’s a test. It builds the site and then goes looking:

const leaks = readdirSync(dir)
  .filter((file) => file.endsWith('.js'))
  .filter((file) => /tiptap|EditorApp|_editor\/api/.test(readFileSync(join(dir, file), 'utf8')));
expect(leaks).toEqual([]);

It greps every built JavaScript bundle for Tiptap, for the editor app, for the api path. If any of it ever reaches dist/, the suite goes red. It has stayed green through everything since, including the parts of this series I haven’t written yet.

Two things “it doesn’t ship” doesn’t cover

Deleting the whole category of remote attacker is not the same as being careful, and there were two places where being careless would have hurt me on my own machine.

The first is paths. The api takes a file path from the browser, which is me, but “it’s me” is not a design. So every read and every write goes through one function:

const isContentFile = target.startsWith(`${contentRoot}${sep}`) && /\.mdx?$/.test(target);
if (isAbsolute(rel) || !isContentFile) throw new Error(`path outside src/content: ${rel}`);

Resolve it, check it actually landed under src/content, check it ends in .md or .mdx, refuse everything else. A path with enough ../ in it is just a typo away from the editor writing into src/components, and I’d rather find that as an error message than as a git diff.

The second is more interesting. The post settings panel needed to know what fields a post has, and I could have hardcoded them. Title, description, pubDate, tags, the ones I remember. Instead the fields are read off the real Zod schemas the site already validates content with, at runtime:

if (schema instanceof z.ZodString) { … }
if (schema instanceof z.ZodDate) return { type: 'date' };
if (schema instanceof z.ZodEnum) return { type: 'enum', options: schema._def.values };

It walks the schema, unwraps the optionals and defaults to work out what’s required, and turns each field into something the panel can render. A date field because the schema says date. A dropdown because the schema says enum, with the options taken from the schema’s own list.

Which means the editor cannot offer me a field the site would reject, and cannot quietly stop offering one I’ve added. When I change content.config.ts, the settings panel changes. I didn’t have to remember to update it, and more importantly, future me doesn’t have to remember either.

That’s the one decision from this phase I’d defend hardest. Everything else was cheap.

Proving it before planning it

One thing about how this got built: the risky part wasn’t written first, it was proved first.

Before any plan for this phase, the open question was whether a dev-server endpoint could write into src/content at all without Astro noticing the change and reloading the page out from under me. Which would be a fairly comprehensive way to lose whatever I’d just typed.

So, a throwaway integration at src/_probe, a throwaway note at zz-probe.md, create it, edit it, and watch what the page does.

{ same: true, alive: 'function' }
[glob-loader] Reloaded data from zz-probe.md

The content layer reloaded, and the page stayed put. Then the probe came out and the tree went back to clean. The same session also settled a smaller thing, that astro/zod resolves under Vitest, which is why the field test can import the site’s real schemas instead of a copy of them.

The read pass on this phase came back with five mismatches and five questions, all answerable from the repo. Fewer than the six and seven from the morning, which I chose to read as progress.

I want to be honest about that probe though, because it comes back. It proved the mechanism could work. It did not prove the mechanism always works, and the difference between those two turned out to be a narrow window at startup that a test found the next day. I’ll get to that.

Where it landed

By the end of the 19th there was a route, an api, a file store that refuses to leave src/content, settings fields derived from the site’s own schemas, and a test asserting none of it ships.

There was still no editor. Nothing to type in. You could open /_editor and get a page that listed posts and did approximately nothing with them.

And there was no authentication at all, anywhere, because the argument above says you don’t need any.

It held for four days.

Next part is the 20th, which is ten commits in one day and the point where it stopped being plumbing.

seed maturity Seed — this note will change as the strip does