Lua script store - design v0.1 (2026-09-15)
Status: built 2026-09-15 - the user took all three recommendations in
section 6 (separate stack + public sign-up, install by share code, SVG path
data), and phases 1-3 shipped the same day: stack foogolf-extensions
(us-east-1, foogolf-scripts until the 2026-09-16 rename) + site
https://extensions.foogolf.com, fw 0.8.516 (staging)
with the Scripts row, Install by code / Remove, and canvas.path. Section 7
records what exists; phase 4 (boot-time update check, online-play version
match) and phase 5 are still to do. Companion to developer-guide.md
section 7 and foodoublebassesp32/docs/lua-runtime.md.
Renamed script -> extension, end to end (user decisions 2026-09-16, fw
0.8.548 wording + fw 0.8.549 wire): every surface says extension -
the device's Extensions row and Settings > Extensions settings,
https://extensions.foogolf.com, the admin dashboard's Extensions page -
and so does every machine name, because nothing had been published yet and
the user chose to get the terminology right from the beginning. The old
foogolf-scripts stack was deleted and foogolf-extensions created in
its place (its table, source bucket and Cognito pool were empty, so nothing
was migrated); scripts.foogolf.com was dropped, not redirected. What
moved: stack and every resource name, the routes (/extensions,
/extensions/{extension_id}, and the device's GET /e/{code} +
/e/{code}/version), the extension_id field, the X-Extension-* install
headers, the device's /lfs/extensions directory (0.8.549 deletes the old
/lfs/scripts once), the analytics game: "extension:<id>", the C++ names
ExtensionStore / ExtensionFetch / ExtensionsConfig.h, and the repo
directories extension-store/, lambdas/extensions_api/,
lambdas/dash_extensions/. Sections 1-6 below are the original design
and still say "script" - read it as the old word for the same thing;
section 7 records what is actually deployed. Record:
foodoublebassesp32/docs/menu-housekeeping-2026-09-16.md; current ids in
foogolf-ota-cloud/docs/operations.md.
1. Where scripts live today (fw 0.8.514)
- (Until fw 0.8.549 - section 9.) Two scripts,
lua/inside_three.luaandlua/mini_golf.lua, are compiled into the firmware image as null-terminated text (board_build.embed_txtfiles, symbols_binary_lua_<name>_lua_start,App.cppsetup). They are constants in.rodata: changing one means editing the file, rebuilding and cutting an OTA. Nothing on the device can edit or replace them. LuaGame::kMaxSlots = 2: two fixed hosts, gameMode 24 and 25, each a cell placed by hand in the Drills / Games row.LuaHostcompiles from a buffer (luaL_loadbuffer) and removesload,loadfile,dofile,loadstring,requirefrom the sandbox, so a script cannot pull in code from anywhere.- LittleFS has ~4 MB spare after the audio pack's install reserve
(
foodoublebassesp32/docs/storage-budget-2026-09-15.md); nothing reads a script from it yet (lua-runtime.md next step 5).
2. Assets: vector only, in the script text (decision proposed)
No per-script file uploads. Everything a script draws is described in its
own source: the canvas API is already a vector API (AA disc, ring, line,
dashed line, arc, rect, polygon, text) plus two built-in bitmaps
(putter_clubhead, ball).
"SVG" should mean SVG path data, not SVG files. A full SVG renderer on the device (parser, CSS, gradients, ThorVG-class rasteriser as in LVGL 9) is ~300 KB of flash and heavy at 240 MHz; LVGL 8 here has none. What fits:
canvas.path(d, {fill=color, stroke=color, width=1, opa=255,
x=0, y=0, scale=1, angle=0})
d is the SVG <path d="..."> mini-language (M L H V C S Q T A Z,
absolute + relative). The runtime flattens curves to line segments and
feeds the existing AA polygon / line fills, ~300 lines of C++. Authors can
paste path data straight out of Inkscape or Illustrator, and a whole
drawing stays a few KB of text inside the 256 KB script heap. This is the
one addition the API needs for "images"; raster uploads are out of scope.
Why not raster: the numbers do allow ~35-50 JPEGs of 800 x 600, but a JPEG decoder, a per-script asset quota, an asset pack installer with its tmp-beside-live flash reserve, and 0.2-1 MB of PSRAM per decoded image are a subsystem of their own, and the script sandbox has no file access by design. Vector costs none of that.
3. Cloud side (proposed)
A separate small SAM stack, foogolf-scripts, not the admin dashboard:
the dashboard's Cognito pool and pages show the whole fleet, so public
self-sign-up cannot be turned on there. Same region, same account, own
resources so it can be torn down or costed alone.
- Auth: own Cognito user pool with self-service sign-up (email + password, hosted UI). Authors only ever see their own scripts and the public listing.
- Tables:
foogolf-scripts(PKscript_id= manifestid+ owner;name,kinddrill|game,version,sha256,share_code(5 chars, the join-a-host alphabet),publicbool,author,about,ts,installs). Versions are immutable objects in S3foogolf-scripts/<script_id>/<version>.lua. - Lambdas:
publish_script(JWT; validates the manifest and compiles the chunk with real Lua 5.4 in the Lambda vialupa, so a syntax error never reaches a device; returns the share code),list_scripts(own + public),get_script(device or browser, by share code or id; no auth, rate-limited; returns source + sha + manifest),delete_script. - Spoken name:
spoken_namesynthesis at publish time is deferred (open in the API); v1 scripts open silently unless the vocabulary spells the name. - Site: a static React page (Vite, the dashboard's toolchain) at
scripts.foogolf.com: sign in, "New script" (paste or upload the .lua, manifest read back, errors inline), my scripts (version bump = publish again), public listing with each script's share code and About text. - Cost: KB-sized objects, a few requests per device per boot; rounds to zero against the US$4 per device per year budget.
4. Device side (proposed)
- Scripts install into LittleFS:
/lfs/scripts/<id>.luaplus/lfs/scripts/index.json([{id, version, name, kind, sha, code}]). Quota: 1 MB / 32 scripts (the storage budget's split). LuaGameslots become dynamic: the two embedded scripts stay as built-ins (they are the reference ports - until 0.8.549, section 9), installed scripts get a host each, placement-new'd in PSRAM at boot (~13 KB idle each, source resident). A new Scripts grid row lists installed scripts as cells; the last cell is Get scripts (Install by code on the on-screen keyboard, the Join-a-host flow; Update all; Remove).- Install by code: main-loop HTTPS GET
get_script?code=(a KB-sized response, the check-in's TLS pattern), sha checked, written to a tmp and renamed, host loaded, cell appears. A compile error on the device ends on the existing red Lua error page with the line. - Updates follow the OTA timing rule: the boot check-in body carries
scripts:[{id,v}]and the reply lists newer versions; they are fetched at that moment or from the explicit Update row. Never on a timer. - Trends / settings keep their
lua_<id>namespaces, so an update keeps the player's history. - Online play: an installed script must match version across host and
guests; the cfg carries
{id, v}and a guest without it shows "Install(code ABCDE)" instead of following (a later slice).
5. Phases
- Firmware: LittleFS script loading + dynamic slots + Scripts row + Install by code (stubbed against a static S3 object first).
- Cloud:
foogolf-scriptsstack + site, publish / list / get. canvas.path(SVG path data).- Boot-time update check, Remove, online-play version match.
- Later: browser simulator, spoken-name synthesis, account-linked push to own devices over the control channel.
7. As built (2026-09-15)
Cloud - foogolf-ota-cloud/script-store/ (own SAM project, stack
foogolf-extensions, us-east-1, review-first deploy; ops notes in
foogolf-ota-cloud/docs/operations.md):
- Cognito pool
foogolf-extensionswith self-service sign-up (e-mail + password, hosted UI), app client PKCE. - Table
foogolf-extensions(PKextension_id= first 8 chars of the owner's sub +-+ manifest id; GSIsby_code,by_owner), bucketfoogolf-extensions-<acct>with immutable<extension_id>/<version>.lua. - One Lambda
foogolf-extensions-api(lambdas/extensions_api/app.py) on an HTTP API:POST/GET /scripts,PATCH/DELETE /scripts/{id}(JWT),GET /public,GET /e/{code}(the device's install: source as text withX-Extension-Id/-Version/-Name/-Kind/-Sha256/-Code/-Authorheaders,installscounted whenX-FooGolf-Deviceis present),GET /e/{code}/version. Publishing compiles the chunk with Lua 5.4 throughlupa(layerfoogolf-lupa, built from the manylinux wheel byscripts/build-lupa-layer.py) inside a stub sandbox (luacheck.py) that records the manifest; syntax errors, runaway top levels (2 M instructions), bad ids and double manifests are refused with a message. Version = manifestversionif higher than the stored one, else stored + 1; the share code (5 chars, join-a-host alphabet) never changes. - Site
extensions.foogolf.com(ACM cert + CloudFront + Route 53 alias in the stack; Vite/React infrontend/, built with.envfrom the stack outputs, synced tofoogolf-extensions-site-<acct>): Browse (public list with the codes and About text), My scripts (publish by paste or file, public toggle, remove). - Smoke:
python scripts/store-smoke.py(throwaway user, publish, bad publish refused, device fetch + sha, version, public, delete) - PASS 2026-09-15.
Device (fw 0.8.516):
src/LUA/ExtensionStore.{h,cpp}:/lfs/extensions/<id>.lua+index.json, tmp + rename writes, 6 scripts x 64 KB.src/LUA/ExtensionFetch.{h,cpp}: one main-loop HTTPS GET (Amazon root CA,NetActivity::Scope, task watchdog paused), body streamed into PSRAM, sha256 checked.include/ExtensionsConfig.hholds the API base.LuaGame::kMaxSlots8 (2 built-in + 6 installed, gameModes 24..31; since 0.8.550 no built-ins, section 9),LuaGame::unload();App::reloadInstalledScripts()parks the active experience on Metrics only first if it is an installed script.- Menu: Scripts grid row (after Drills): a cell per installed script (Play|Resume+New / Settings / Trends for drills with a trend / About) and Get scripts > Install by code (keyboard on the last code, OK focused; "Fetching" page; result page names the script and version, or the reason and OK to retry), Remove a script (list, confirm, then back to Get scripts), About. A script the store accepted but this firmware cannot load is removed again with the runtime's error shown.
canvas.path(d, opts)inLuaCanvas(SVG path data, even-odd fill with two coverage sub-rows, AA stroke, 2048 points / 64 subpaths in PSRAM).- The per-slot menu row storage is PSRAM (
ensureLuaMenuStorage), so static DRAM did not grow.
8. Moderated public listing (2026-09-15, user request, deployed the same day)
An author can never make an extension public. Rules:
- Every publish carries a mandatory description (store field, 1000 chars max, separate from the manifest's About) - shown on the public page and to the support team.
listingon the row isnone|requested|public. The author's checkbox "Request a listing on the public page" (publish form, or Request / Withdraw / Make private on the card) moves betweennoneandrequestedonly (PATCH /extensions/{id} {request_listing}); a public extension the author withdraws goes private.- Support approves in the admin dashboard: an Extensions page
(
dashboard/frontend/src/ExtensionsView.jsx) lists the requests with name, kind, author, owner e-mail, version, share code, description and the About page; Approve ->public(recordsapproved_versionand the reviewer), Decline / Unlist ->none. Lambdafoogolf-dash-extensions(dashboard/lambdas/dash_extensions/,GET /extensions?state=,PATCH /extensions/{id} {listing}) writes the store's table by name; a public extension that was published again shows "approved vN" until re-approved. - The public list is
GET /public=listing == public, rendered by the Browse page of extensions.foogolf.com; the foogolf.com landing page (S3 bucketfoogolf.com, no stack) links to it ("Community drills and games"). - Discovery only:
GET /e/{code}serves any extension whatever its listing, so anyone who knows a code can install it.
Smoke (scripts/store-smoke.py, PASS 2026-09-16 against the renamed stack): publish without a
description -> 400; publish with a request -> requested, absent from
/public; the dashboard Lambda approves and lists it; then the usual
fetch / version / delete steps.
9. Nothing embedded - the store is the only source (fw 0.8.550, user decision 2026-09-16)
The user asked for Mini golf (Lua) to be published on the public site as a public extension "so that we can practice uploading it onto a machine", and for every Lua constant compiled into the ESP32 image to go, "so that the only source for extensions will now be the cloud". As built the same day:
- Firmware 0.8.550:
lua/deleted, noboard_build.embed_txtfiles,LuaGame::kBuiltinSlotsdeleted - slot i is installed extension i (gameMode 24 + i,kMaxSlotsstill 8,ExtensionStore::kMaxInstalledstill 6). The Games row's "Mini golf (Lua)" and the Drills row's "Inside three" cells are gone; an extension appears only in the Extensions row. A device with gameMode 24 / 25 saved from the built-ins boots into installed slot 0 / 1 if loaded, else Metrics only. Trend history and NVS settings are keyed by manifest id, so a re-installed Mini golf (Lua) keeps them. - Sources:
foogolf-ota-cloud/extension-store/extensions/(mini_golf.lua,inside_three.lua, README) - git keeps the reference ports, the store delivers them. - Publishing:
extension-store/scripts/publish-extension.py <file> --author <email> --description "..." [--request-listing | --approve]: the author's own Cognito account (SRP via pycognito, password fromFOOGOLF_EXTENSIONS_PASSWORD; a missing account is created with a random password the script never prints - the author sets theirs with "Forgot password" on the site, email pre-verified),POST /extensions, and with--approvethe dashboard's moderation Lambda invoked directly (the smoke test's trick). Re-publishing the same manifest id keeps the share code and bumps the version. - Mini golf (Lua) is live and public: share code
CFZW5, manifest idminigolf_l, version 14, owner account support@foogolf.com (first published under abraae@gmail.com, moved the same day withscripts/transfer-extension.py- user rules: every extension lives under exactly one account, no orphans, and FooGolf's own under the main support account);GET /e/CFZW5returns the file byte for byte with a matching sha header,/publiclists it, extensions.foogolf.com's Browse page shows it. Inside three is not published (the developer guide's example;store-smoke.py's throwaway, now read from theextensions/dir). - Store fix found on the way:
luacheck.py's manifest sandbox knew onlyfoogolf.drill/foogolf.game; mini_golf.lua's top level callsfoogolf.action{}(menu rows, fw 0.8.528, added after the store was written) and was refused with "attempt to call a nil value (field 'action')". Every otherfoogolf.*field is now the permissive stub, like the other modules; stackfoogolf-extensionsredeployed (review-first changeset, ApiFn + Api modified). Rule: an API surface a script may touch at load time must be stubbed in the checker.
10. Check for updates at boot + default extensions (fw 0.8.551 + cloud, user request 2026-09-16)
The request: a "check for updates" the device calls at startup after the audio pack, passing the ids of every Lua extension it uses; the server answers with version information, or the lack of the extension if it has been taken down; the device updates its local copies with progress ("1 of 3 extensions updated"). And a default flag managed through the support dashboard: every device installs a default extension, and default extensions cannot be removed from the device.
Wire (store API, open route like the install):
POST /e/check {"installed": [{"c": "CFZW5", "v": 14}, ...], "fw": "0.8.551", "dev": "<device id>"}
200 {"installed": [{"c": "CFZW5", "id": "minigolf_l", "v": 15, "name": ..., "kind": "game",
"sha256": ..., "default": false},
{"c": "ZZZZZ", "gone": true}],
"defaults": [{"c": ..., "id": ..., "v": ..., "name": ..., "kind": ..., "default": true}, ...]}
Codes, not manifest ids, identify an extension on the wire (a manifest id
is unique only per author; the code is global). defaults is every row
whose default flag is set - a table scan with a filter, fine for a table
of dozens; revisit with a GSI if the store ever holds thousands. The
device's re-fetches carry X-FooGolf-Update: 1 so they are not counted
as installs.
Default flag: the dashboard's Extensions page (ExtensionsView.jsx)
has a Default checkbox on every table (a confirm before ticking) and a
"Default extensions" table; foogolf-dash-extensions PATCH accepts
{default: bool} (alone or with listing) and GET /extensions?state=default.
The store API refuses an author's DELETE of a default extension (409
is_default) - support clears the flag first. A default extension need
not be publicly listed: the flag alone installs it everywhere.
Device (fw 0.8.551): src/LUA/ExtensionUpdate builds the job list
from the answer - gone -> remove, a different version -> fetch + install,
a changed flag alone -> ExtensionStore::setDefault (no download), every
cloud default the device lacks -> fetch + install flagged - and runs it
with "Updating|Installing|Removing N of M" progress; the summary reads
"Up to date" / "None installed" / "2 of 2 extensions updated" / "1
installed, 1 removed" / "1 of 3 failed". At boot it is the Extensions
highlight right after the audio pack (skipped under a pending firmware
offer, as the pack is; never a held page - a failure is a red line and the
next boot retries); the same run is Settings > Extensions settings >
Check for updates. After a change the Lua slots reload and the active
experience follows its extension by manifest id (App::applyExtensionChanges).
ExtensionStore::Entry::isDefault (index field d, kept across a manual
re-install) makes the Remove list say "NAME (default)" and its row show a
refusal page instead of the confirm.
Ownership: every extension lives under exactly one author account
(the row key is <sub8>-<manifest id>; there are no orphans), and
FooGolf's own extensions live under support@foogolf.com.
scripts/transfer-extension.py <CODE> --to <email> [--create] moves one
(new row + S3 copies, code / version / listing / default kept). Mini golf
(Lua) was moved there on 2026-09-16.
Deployed 2026-09-16: both stacks (review-first changesets: store ApiFn +
Api + a CheckUpdates permission; dashboard DashExtensionsFn + Api), the
dashboard frontend synced + invalidated; the flag toggle verified end to
end through the moderation Lambda and /e/check. Firmware 0.8.551 on
staging, bench verify pending.
11. Source editor on the site (2026-09-16, user request)
"There's no way for me to look at its source. I would like a big text
area that I can actually look at the source in and edit it and save my
changes; then any device will see those changes and update its version."
Built the same day on the My extensions page: View / edit the source
on every card opens the Lua in a 65 vh monospace editor (SourceEditor in
App.jsx; the text comes from GET /e/<code> without the device header,
so it is not counted as an install). Save as new version (or Ctrl+S)
POSTs the text through the ordinary publish route with the extension's
existing description - the store compiles it first and a syntax error
shows in the editor with nothing changed - and the row's version bumps
under the same share code, so every device that has it installed fetches
it at its next boot update check (section 10) or Check for updates.
Closing with unsaved changes asks first. The manifest version inside the
file is not touched by an edit; the row version is what devices follow.
12. The share code IS the id; every source is open (2026-09-16, user decisions)
What happened: the user copied Mini golf (Lua)'s source into Publish to
make a new extension and it came back with the ORIGINAL's share code - the
row key was <owner sub>-<manifest id>, so the same author publishing the
same id was, by design, "a new version". The user's rules:
- An extension's id is not written in the extension code. It is held by the store, allocated the first time the extension is saved / created, and can never be modified afterwards.
- The id and the share code are one thing: the five-character code.
- Every extension's source is visible wherever the extension is visible in the UI. There is never a secret extension code (for security reasons). Anyone can copy any extension and create their own version.
As built (cloud + site the same day, fw 0.8.553):
- Store API:
POST /extensions= CREATE (mints the code, which is the table PKextension_id; the row'sidfield equals it; v1 or the manifest version);PUT /extensions/{code}= a new version of the caller's own (description optional, listing / default kept); PATCH / DELETE unchanged.luacheck.pyignores a manifestid.X-Extension-Idand/e/check'sidcarry the code. Theby_codeGSI is now redundant and left in place. The one row was migrated by hand (new key, S3 copies, old row deleted) and re-published id-less as v16 - v15 had been the user's paste test ("Mongo"), which under the old scheme overwrote the original's name instead of creating anything. - Site: Browse cards get View the source (read-only editor) and Copy
to a new extension (the text goes to the Create form; a signed-out
user is sent to sign in and the draft survives in sessionStorage); My
extensions keeps the editor with Save as new version (now
PUT); "Publish" is now "Create" and its help says the code is allocated here and there is no id in the file. - Device:
LuaGame::load(..., storeId);App::reloadInstalledScriptspassesEntry::code;parseManifesttakes_idfrom it and ignores the manifest's (the old validation only runs when no store id is given).ExtensionStore::installmatches by code first and deletes the old<manifest id>.lua, so the boot update check migrates an installed copy in place when the next version lands. - Tools:
publish-extension.pycreates without--code, updates with it;store-smoke.pyalso checks PUT and that a re-POST of the same source is a NEW code. The reference files lost theiridlines.
Not changed: ownership (one account per extension, FooGolf's under support@foogolf.com), moderation, the default flag, the update check.
6. Decisions needed from the user (decided 2026-09-15: all three recommendations)
- Separate
foogolf-scriptsstack + site with public sign-up (recommended) versus a Scripts page inside the admin dashboard (would need per-user scoping of every existing view first). - Install by share code on the device (recommended for v1, no account linking needed) versus linking a device to an account and pushing from the web.
canvas.pathwith SVG path data as the image story (recommended) versus no vector import at all (authors draw with primitives only).