Actaurus返回 Actaurus

Case Study · 2026

DailyMeal

A meal app organised around the fridge rather than the recipe. And, underneath it, a public demo that is the real product rather than a mock of it — running in the same deployment as data it cannot reach.

Inventory matching · Trust boundaries · State consistency

Cloudflare Workers · D1 · React 19 · Bilingual · Two hostnames, one deployment


The brief

Most recipe apps answer “what could I cook?”. The useful question is “what can I cook right now, with what is actually in this kitchen?”

A recipe library is a catalogue. A kitchen is an inventory. Almost every cooking app ships the first and leaves you to hold the second in your head — which is why the browsing ends in takeout roughly as often as it ends in dinner.

So the unit of this app is not the recipe. It is the match between a recipe and a shelf. Every dish carries its ingredient list; every ingredient is matched against real stock; and what the app shows you first is the fraction you already have.


Decisions

Product

Availability, not a list

A dish is not shown as available or unavailable. It is shown as a proportion — seven ingredients of eight, with the eighth named. That distinction turns out to carry the whole product: a dish at 7/8 is a dinner plus one item on a list, where a binary “unavailable” is a dead end.

Matching an ingredient to a shelf is the fussiest code in the repository, and deliberately so. It tries an exact match in either language, then a table of thirty-four domain aliases, then a substring fallback. Real pantries do not have primary keys.

Product

Cold storage, not delete

Eighty-five recipes, of which thirty-nine start archived rather than deleted. A dish you are bored of in March is a dish you want back in September, and a delete button is a decision you cannot undo six months later when you have forgotten the dish existed.

Product

Eating out is a first-class meal

There is a meal category for the place on the corner. It is always one hundred percent available, it logs its protein and calcium like anything else, and it pointedly does not deduct from the pantry.

An app that only understands cooking quietly records every takeaway as a gap in the data. This one just records dinner.

Product

One card at a time

The deciding surface is not a grid. You pick a mood — ready in the fridge, high protein, under five minutes, something warm, surprise me — and the app deals exactly one dish, with a button to deal another.

Eighty-five options presented at once is the state of indecision the app exists to end, not a feature.

Architecture

Two trust levels, one deployment

A public sandbox that lives entirely in the visitor's browser, and a signed-in workspace backed by Cloudflare D1. Same Worker, same build, same components — the difference is which side a request arrives on.

That is decided at the edge, from the hostname, and injected as a request header before anything renders. Every protected endpoint checks that header before it checks authentication, and before it touches the database. Any hostname the app does not recognise gets a 404 — it fails closed rather than open.

The protected routes answer the public host with 404, not 403. A 403 tells you there is something there.

Architecture

The demo is the product

The workspace is one component. What differs between the two surfaces is a configuration object — where state loads from, where it saves to, how a recipe's calcium is resolved. Protected state round-trips to D1 through an authenticated API; demo state round-trips to localStorage.

The alternative — a separate marketing mock — is a thing that starts accurate and silently stops being accurate. This one cannot drift, because there is nothing for it to drift from.

Security

The protected catalogue is absent, not hidden

The recipes and pantry on the signed-in side sit behind a server-only import and reach the browser solely through an authenticated bootstrap endpoint. They are not in the public bundle at all.

That claim is worth nothing unless something checks it, so a test does: it requests the portfolio page, walks every <script> the page actually loads, reads those files off disk, and asserts that specific protected dish names appear in none of them. A refactor that accidentally pulls the protected catalogue into the shared graph fails the build.

Security

The credential never reaches the client

Authentication is an HMAC-signed, HttpOnly, SameSite=Strict session cookie issued by the server and valid for thirty days. The signing key is a Worker secret — not in the bundle, not in a header, not in browser storage. Comparison is constant-time, and a failed attempt costs a fixed delay.

That boundary is asserted rather than assumed: a test reads every client chunk the build produces and fails if a credential identifier appears in any of them. A key that reaches a browser is a key you can no longer rotate out of the devices that already hold it — which makes it worth a test rather than a convention.


Under the hood

Protected state is a single JSON document in D1, read and written through one authenticated endpoint. Saves go through a sequential queue that always writes the newest snapshot, so a burst of quick edits cannot land out of order; a failed sync keeps a local copy and says so rather than pretending it saved.

Schema changes are handled by a migration that runs on load: it matches old pantry items to new ones by name, then by identifier, then by a handful of domain-specific rules, and carries the quantities across. Upgrading the recipe library does not cost you your fridge.

Loaded state is sanitised field by field before it is trusted. The current date is re-checked on a timer and on window focus; if Safari private mode rejects localStorage, storage falls back to memory instead of crashing.

Lint, types, a build, and the isolation tests run on every push. The isolation tests assert against the built output, which means they can only run after a real build — so the build is part of the check, not a separate step somebody might skip.


How this was built

I defined the product brief, data model, trust boundaries, matching behavior, edge cases, and acceptance criteria, and directed the work across 175 commits. An AI coding agent produced the implementation; I reviewed system behavior, tested failure cases, and drove revisions. A second AI reviewed the finished repository, and several findings were incorporated into the final implementation.

Half the repository is not code. Eighty-five recipes and a full pantry — every ingredient with its weight, its protein, its calcium, its fibre, in two languages — outweigh the logic that reads them by an order of magnitude. The matching engine is a few hundred lines. What makes its answer useful is the accuracy of the shelf behind it, not the cleverness of the search.

Where it is now

The link above is the public sandbox. It seeds a plausible day and a half of meals so the page has something to show, keeps everything in your own browser, and resets on request — it is the application itself, not a walkthrough of it.

The other half of the deployment is the signed-in workspace on D1. Nothing it holds is reachable from the public surface — not the data, and not the code paths that could reach it. That is what the tests are there to keep true.

Case Study · 2026

DailyMeal

一个围绕冰箱、而不是围绕菜谱组织起来的做饭 app。以及它底下的一件事:公开演示就是产品本身,而不是产品的仿制品——它和它够不到的数据跑在同一个部署里。

库存匹配 · 信任边界 · 状态一致性

Cloudflare Workers · D1 · React 19 · 中英双语 · 两个域名,一个部署


命题

大部分菜谱 app 回答的是「我可以做什么」。真正有用的问题是「我现在、用这个厨房里真有的东西,能做什么」。

菜谱库是一本目录,厨房是一份库存。几乎所有做饭 app 都只给你前者,后者要你自己在脑子里记着——所以翻了半天最后点外卖的概率,和真的开火差不多。

所以这个 app 的基本单位不是菜谱,是「菜谱和货架之间的匹配」。每道菜带着它的配料表,每样配料去真实库存里比对,最先呈现给你的,是你已经有了多少。


取舍

产品

给「齐了多少」,不给「能不能做」

一道菜不是显示为「可做 / 不可做」,而是显示为一个比例——八样里有七样,并且把缺的那一样点名。这个区别几乎撑起了整个产品:7/8 是「一顿饭 + 清单上一项」,而二元的「不可做」是一条死路。

把配料对上货架,是整个仓库里最琐碎的一段代码,而且是故意琐碎的。先两种语言精确匹配,再走三十四条领域别名表,最后子串兜底。真实的冰箱是没有主键的。

产品

冷藏室,而不是删除

八十五道菜,其中三十九道默认就在冷藏室里——归档,不是删掉。三月吃腻的菜,九月会想再吃;而删除是一个六个月后你撤销不了的决定,那时你已经忘了它存在过。

产品

外食是一级餐别

街角食堂是一个正式的餐别。它永远 100% 可做,它像别的菜一样记录蛋白和钙,并且明确地不扣冰箱库存。

一个只理解「做饭」的 app,会把每一次外食悄悄记成数据上的一个空洞。这个只是记下:今天晚饭吃了。

产品

一次只发一张牌

做决定的界面不是一个网格。你先选一种心情——冰箱即做、猛补蛋白、五分钟快手、暖胃锅物、随便抽——然后 app 只发给你一道菜,旁边一个按钮可以再抽。

把八十五个选项一次摊开,正是这个 app 想终结的那种纠结,不是一个功能。

架构

两个信任级别,一个部署

一个完全活在访客浏览器里的公开沙盒,和一个由 Cloudflare D1 支撑、需要登录的工作区。同一个 Worker,同一次构建,同一批组件——区别只在于请求落在哪一侧。

落在哪一侧,在边缘就从域名解析出来,作为请求头注入,早于任何渲染。每一个受保护的接口都是先查这个头,再查登录,最后才碰数据库。任何它不认识的域名直接 404——默认关闭,不是默认打开。

受保护的接口对公开域返回的是 404,不是 403。403 等于告诉对方:这儿有东西。

架构

演示就是产品

工作区是同一个组件。两个入口之间的全部差别,是一个配置对象:状态从哪儿读、存到哪儿去、一道菜的钙怎么算。受保护的状态经认证接口往返 D1,演示状态往返localStorage。

另一条路——单独做一个展示用的假界面——是那种一开始准确、然后悄悄不准确的东西。这个不会跑偏,因为它根本没有可以跑偏的对象。

安全

受保护的目录不是被藏起来,是根本不在里面

登录那一侧的菜谱和库存在一个 server-only 的模块后面,只能通过一个需要登录的 bootstrap 接口到达浏览器。它们完全不在公开包里。

但这句话没人查就不值钱,所以有一条测试在查:请求 portfolio 页面,把页面真正加载的每一个 <script> 都走一遍,从磁盘读出来,断言某几个受保护的菜名一个都不出现。哪天一次重构不小心把它们拖进了共享依赖,构建直接失败。

安全

凭证从不进入客户端

登录用的是服务端签发的 HMAC 签名 HttpOnly SameSite=Strict 会话 cookie,有效期三十天。签名密钥是 Worker secret——不在 bundle 里,不在请求头里,也不在浏览器存储里。比对走恒定时间,失败固定付出一段延迟。

这条边界是被断言的,不是被假定的:一条测试会读遍构建产出的每一个客户端 chunk,只要里面出现任何凭证标识就让构建失败。一把到过浏览器的钥匙,是一把你再也没法从那些已经拿到它的设备上换掉的钥匙——所以它值得一条测试,而不是一条约定。


技术细节

受保护的状态是 D1 里的一份 JSON 文档,通过一个需要认证的接口读写。保存走一条串行队列,每次都写入最新的快照,所以连续快速编辑不会乱序落库;同步失败时保留本地副本并明说,而不是假装存上了。

结构变更由一个加载时运行的迁移处理:把旧的库存条目先按名字、再按标识、最后按几条领域规则对上新的,并把数量带过去。升级菜谱库,不需要拿你的冰箱来换。

读进来的状态会逐字段清洗后才被信任。当前日期会定时、以及在窗口重新获得焦点时重新检查;如果 Safari 无痕模式拒绝 localStorage,存储就降级到内存而不是崩掉。

每次 push 都跑 lint、类型、构建,以及那几条隔离测试。隔离测试断言的是构建产物,也就是说它们只能在真的构建之后运行——于是构建本身成了检查的一部分,而不是一个可能被谁跳过的独立步骤。


这是怎么做出来的

我定义了产品命题、数据模型、信任边界、匹配逻辑、边界情况和验收标准,并在 175 个 commit 中主导整个过程。代码实现由 AI 编码 agent 生成;我审查系统行为、测试失败情况并推动修订。第二个 AI 对完成的仓库进行了审查,其中若干发现被纳入最终实现。

仓库里有一半不是代码。八十五道菜谱和一整份库存——每样配料的重量、蛋白、钙、纤维,两种语言各一份——体量比读它们的那些逻辑大一个数量级。匹配引擎只有几百行。让它给出的答案有用的,是它背后那份货架的准确度,不是搜索本身有多聪明。

现状

上面那个链接就是公开沙盒。它会种下一天半左右看起来合理的用餐记录,好让页面有东西可看,一切都留在你自己的浏览器里,随时可以重置——它就是这个应用本身,不是它的演示版。

部署的另一半,是跑在 D1 上、需要登录的工作区。它持有的任何东西都无法从公开这一面够到——数据够不到,能够到数据的代码路径也不在。那几条测试,就是为了让这句话一直成立。

Built by Emilie ZhangBuilt by Emilie Zhang actaurus.com