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.lua and lua/mini_golf.lua, are compiled into the firmware image as null-terminated text (board_build.embed_txtfiles, symbols _binary_lua_<name>_lua_start, App.cpp setup). 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.
  • LuaHost compiles from a buffer (luaL_loadbuffer) and removes load, loadfile, dofile, loadstring, require from 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 (PK script_id = manifest id + owner; name, kind drill|game, version, sha256, share_code (5 chars, the join-a-host alphabet), public bool, author, about, ts, installs). Versions are immutable objects in S3 foogolf-scripts/<script_id>/<version>.lua.
  • Lambdas: publish_script (JWT; validates the manifest and compiles the chunk with real Lua 5.4 in the Lambda via lupa, 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_name synthesis 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>.lua plus /lfs/scripts/index.json ([{id, version, name, kind, sha, code}]). Quota: 1 MB / 32 scripts (the storage budget’s split).
  • LuaGame slots 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

  1. Firmware: LittleFS script loading + dynamic slots + Scripts row + Install by code (stubbed against a static S3 object first).
  2. Cloud: foogolf-scripts stack + site, publish / list / get.
  3. canvas.path (SVG path data).
  4. Boot-time update check, Remove, online-play version match.
  5. 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-extensions with self-service sign-up (e-mail + password, hosted UI), app client PKCE.
  • Table foogolf-extensions (PK extension_id = first 8 chars of the owner’s sub + - + manifest id; GSIs by_code, by_owner), bucket foogolf-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 with X-Extension-Id/-Version/-Name/-Kind/-Sha256/-Code/-Author headers, installs counted when X-FooGolf-Device is present), GET /e/{code}/version. Publishing compiles the chunk with Lua 5.4 through lupa (layer foogolf-lupa, built from the manylinux wheel by scripts/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 = manifest version if 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 in frontend/, built with .env from the stack outputs, synced to foogolf-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.h holds the API base.
  • LuaGame::kMaxSlots 8 (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) in LuaCanvas (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.
  • listing on the row is none | requested | public. The author’s checkbox “Request a listing on the public page” (publish form, or Request / Withdraw / Make private on the card) moves between none and requested only (PATCH /extensions/{id} {request_listing}); a public extension the author withdraws goes private.
  • (Since 2026-10-08 this is the website’s Manage apps, for the forum’s Ariki badge - section 15; the dashboard page below is gone.) 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 (records approved_version and the reviewer), Decline / Unlist -> none. Lambda foogolf-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 bucket foogolf.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, no board_build.embed_txtfiles, LuaGame::kBuiltinSlots deleted - slot i is installed extension i (gameMode 24 + i, kMaxSlots still 8, ExtensionStore::kMaxInstalled still 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 from FOOGOLF_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 --approve the 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 id minigolf_l, version 14, owner account support@foogolf.com (first published under abraae@gmail.com, moved the same day with scripts/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/CFZW5 returns the file byte for byte with a matching sha header, /public lists 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 the extensions/ dir).
  • Store fix found on the way: luacheck.py’s manifest sandbox knew only foogolf.drill / foogolf.game; mini_golf.lua’s top level calls foogolf.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 other foogolf.* field is now the permissive stub, like the other modules; stack foogolf-extensions redeployed (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. (Changed in fw 0.8.632, user request 2026-10-01: the owner MAY remove a default; it is then declined and the check leaves it out until the owner installs it again - see foodoublebassesp32/docs/lua-runtime.md, the 0.8.632 section.)

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 PK extension_id; the row’s id field 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.py ignores a manifest id. X-Extension-Id and /e/check’s id carry the code. The by_code GSI 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::reloadInstalledScripts passes Entry::code; parseManifest takes _id from it and ignores the manifest’s (the old validation only runs when no store id is given). ExtensionStore::install matches 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.py creates without --code, updates with it; store-smoke.py also checks PUT and that a re-POST of the same source is a NEW code. The reference files lost their id lines.

Not changed: ownership (one account per extension, FooGolf’s under support@foogolf.com), moderation, the default flag, the update check.

13. Kind web - Browser sim games + the web runtime (cloud built 2026-10-01)

The Browser sim (golfclaude/docs/browser-sim-design.md - the contract; its “Store: kind web” section is canonical) makes a browser game a store extension like any other. As built in the store (not yet deployed):

  • One HTML file is the source. webcheck.py beside luacheck.py reads the <script type="application/json" id="foogolf-manifest"> block: exactly one, valid JSON, kind web, api 1, a name (1..31 chars, the Lua drill / game limit), a description (<= 2000, it becomes the About description); optional version / author / spoken_name; a manifest id is ignored. Source cap 512 KB (Lua stays 64 KB). Same error shape as luacheck (422 extension_error with the message).
  • The checker is picked by content (a <script ... id="foogolf-manifest"> tag = web) on both POST and PUT; a PUT that would turn a Lua extension into a web one or back is refused (409 kind_changed - a device holds the two completely differently). S3 <code>/<version>.html (Lua stays .lua); codes, versions, sha256, listing, approval, defaults, /public, /e/check unchanged.
  • Downloads. Plain GET /e/<CODE> is the HTML as text/plain (View source on the site - never rendered). GET /e/<CODE>?form=gz is the device’s: gzip.compress(src, 9, mtime=0) (deterministic), application/gzip, base64 body, the usual X-Extension-* headers plus X-Extension-Body-Sha256 (sha of the gzip bytes; X-Extension-Sha256 stays the source’s). A Lua extension ignores form. The HTTP API’s CORS exposes every X-Extension-* / X-Runtime-* header.
  • The web runtime (three.js r170 three.module.min.js served as three.module.js, and foogolf.js; source extension-store/web-runtime/ with VERSION and three’s LICENSE) is FooGolf’s alone: runtime/web/<v>/<name>.gz in the source bucket plus ONE pointer, runtime/web/current.json ({version, files: [{name, sha256, size}]} of the gz bytes, written after the files). GET /runtime/web returns it, GET /runtime/web/<name> the gzip bytes, POST /e/check adds "web_runtime": N (0 = none). POST /runtime/web (JWT, only the RUNTIME_PUBLISHERS account = support@foogolf.com, versions only go up) is what scripts/publish-web-runtime.py calls with the publisher password from Parameter Store. foogolf.com routes /runtime/* to the store API so the emulator (foogolf.com/emulator/) reads it same-origin.
  • Site: kind web shows as “Browser sim game”; the Create form takes .html; View source / Copy to a new extension / the editor handle the HTML as plain text.
  • Tests: extension-store/tests/test_web_kind.py (webcheck, form=gz round trip + body sha, runtime routes, web_runtime, and foogolf.js on the BroadcastChannel transport under node).
  • Runtime v6 (2026-10-12): the HUD is the library’s. foogolf.js draws the device’s combined shot widget, the Menu (the device’s rows), the full-screen control and the taken notice inside every game, unprompted; a game draws none of them. The author page is Browser sim games; the design is golfclaude/docs/browser-sim-design.md, “As built: api 6”.

14. Apps on the website: owner_username, /e/{code}/info, screenshots (2026-10-08, user request)

The user: “extensions become apps” - foogolf.com gets an Apps menu item, a gallery of every public app by type (Games / Drills / Launch monitors / In-browser simulator = the store’s four kinds) and an app page with a screenshot captured on the emulator, Play it, the source, and the author’s username linking to their forum profile. “App” is the WEBSITE’s word; the API, the wire and the device keep “extension” (nothing existing was renamed or removed - the /extensions pages stay). The site: golfclaude/docs/website-design.md, “Apps”; the emulator: foodoublebassesp32/docs/web-build.md, “Play it + Capture screenshot”. What the store gained (deployed 2026-10-08, review-first changeset: ApiFn, Api, UserPoolClient in place):

  • owner_username on every row: the token’s preferred_username (the one username, the forum’s name), stamped by create and update (both already pass _needs_username); scripts/backfill-owner-username.py stamped the rows published before (13 rows -> FooGolf; one row under an account with no username yet is left alone - its next publish stamps it). The manifest’s free-text author stays. _summary carries it; the site links community.foogolf.com/u/<owner_username> and falls back to author as plain text.
  • GET /e/{code}/info (open): one extension’s public shape - _summary minus extension_id / sha256, the same strip /public applies (now _public(row)) - whatever its listing, exactly as GET /e/{code} serves any code. The app page works for a private app by its link; the gallery lists public ones only. Cache-Control: no-store.
  • The screenshot: ONE S3 object <code>/screenshot (type on the row: screenshot = {v: <epoch>, type: png|jpeg}, null in the public shape until set). PUT /extensions/{code}/screenshot (JWT + username, the owner, or since 2026-10-10 a holder of the forum’s Ariki badge - the badge read from Discourse as the moderation routes do; 404 otherwise, like update): {image: <base64>, type: image/png | image/jpeg}, the bytes checked against the type’s magic, 1 MB decoded at most (400 bad_image / too_large); a new capture replaces the old, there is no delete (the extension’s delete takes it with the sources). GET /e/{code}/screenshot (open): the bytes, the right Content-Type, Cache-Control: public, max-age=3600 (the site asks with ?v=<screenshot.v> so a new capture shows at once; the front door does not cache /e/* itself). A private app’s screenshot is as open as its source. scripts/set-screenshot.py <CODE> <file> sets one from a file with the owner account (FooGolf’s own: the support account).
  • Emulator installs are not counted: X-FooGolf-Emulator: 1 on the fetch is treated like X-FooGolf-Update (no installs++).
  • CORS + the pool’s web client (as first built, 2026-10-08 - both removed 2026-10-10 when the emulator moved to foogolf.com/emulator/ and started reading the site’s own login): AllowOrigins gained https://dev.foogolf.com, https://dev.foogolf.nz, http://localhost:8765, http://127.0.0.1:8765 (the emulator uploads straight to the front door with the site’s token); CallbackURLs gained the four emulator pages (the emulator page is its own OIDC callback; no logout URL - it never logs out). Both reverted 2026-10-10: the emulator moved to foogolf.com/emulator/ (the site’s origin, already allowed) and reads the site’s login on the same origin - it has no callback or CORS origin of its own any more (golfclaude/docs/website-design.md, “The emulator lives on foogolf.com”).
  • Tests: tests/test_apps.py (plain python, the fakes of test_web_kind.py, whose ev() now carries preferred_username and whose FakeTable.update_item applies SET / ADD - the username gate had left that file red).

15. Moderation moves to the website: Manage apps, the Ariki badge (2026-10-08, user request)

Moderation (sections 8 and 10, and the Primary flag of fw 0.8.645) moved from the support dashboard to foogolf.com/apps/manage, open to a signed-in holder of the forum’s Ariki badge. The store API gained GET /extensions/admin?state=requested|public|default|none|all and PATCH /extensions/admin/{code} ({listing: public|none}, {default}, {primary} - the dashboard Lambda’s rules, with the 409 is_primary refusals now checked before anything is written), both behind the JWT authorizer and then _admin(), which reads the person’s forum badges (_forum_badges, the staging-mode lookup) and answers 403 not_moderator without ADMIN_BADGE (template: Ariki). GET /account answers admin / admin_badge. reviewed_by is the moderator’s username. The public shapes now carry primary too - and, since 2026-10-09, featured (user request: the strip of featured apps on foogolf.com’s front page): {featured: bool} on the same PATCH, ?state=featured on the GET, refused 409 not_public unless the row is public after the patch, cleared by every path out of public (a moderator’s unlist / decline, the owner’s withdrawal) so that featured always implies public. The dashboard’s ExtensionsView.jsx, foogolf-dash-extensions and the dashboard’s ExtensionsTable parameter were removed; publish-extension.py --approve and store-smoke.py write the approval to the table directly (the smoke also checks that a badge-less user gets 403 from both admin routes). Tests: tests/test_moderation.py. Website side: docs/website-design.md “Manage apps”.

16. Per-account limits and the store busy alarm (2026-10-18, user request)

The cost review of the editor’s save path (the user: “I’m very nervous about cloud resources being used unexpectedly”). What it found: iterating in the emulator stores nothing in the cloud (Play it fetches the app once per page load; installs live in the browser); only the website’s explicit “Save as new version” writes - one Lambda call, one new S3 object (<code>/<version>.lua or .html, 64 KB / 512 KB), one row put - and nothing bounded it per account: no cap on extensions created, no cap on save rate below the stage throttle’s 50/s, and every version kept in S3 for ever. One free self-service account could cost about US$45 a day plus 2 TB a day of unpruned storage, with the US$100 budget mail the first signal a day or two in. The table’s default-extension scan on every boot check also grows with every junk row.

Standing rules (built and deployed 2026-10-18, app.py):

  • An account owns at most 50 extensions (MAX_PER_OWNER): the 51st POST /extensions is 409 too_many; a delete frees a slot. FooGolf’s own support account is under the same cap (16 of 50 at the time).
  • One save per account every 3 seconds (MIN_SAVE_GAP), across create AND update and across all the account’s extensions (the newest updated of its rows): a save inside the gap is 429 too_fast with retry_after in the body and a Retry-After header, and writes nothing. The website shows the message; publish-extension.py and store-smoke.py sleep the header and retry once. Nothing needs to save faster (user).
  • Only the newest 3 source objects of an extension are kept (KEEP_VERSIONS): the PUT deletes the older <code>/<n>.lua|html objects after the new one is stored; the current version is never deleted whatever its number (a manifest version jump), <code>/screenshot is never touched. Older versions were never read back - a device fetches the current version only.
  • The store busy alarm (template.yaml StoreInvocationsAlarm): more than 2,000 invocations of foogolf-extensions-api in an hour mails support@foogolf.com through SNS topic foogolf-store-alarms, within the hour. Raise the threshold only when the fleet’s real hourly count nears it.

Tests: extension-store/tests/test_limits.py (the fakes’ fresh() turns the save gap OFF by default so the other tests can save back to back; a test asks for it with fresh(save_gap=3)). Ops table: foogolf-ota-cloud/docs/operations.md, the emulator-is-public section.

2026-10-19 (tier two): the scans are gone - by_listing (listing) serves /public and the moderation lists, the sparse by_def index (is_default = “1”, kept beside the default flag by admin_patch) serves the boot check’s defaults, both behind a 30 s warm-container cache (_cached), and /public answers Cache-Control: public, max-age=60; the redundant by_code index is removed. Usernames: foogolf-usernames (PK lname, the lowercase name) is the registry _username_taken claims against with one conditional put, released if the pool write fails - the page through every pool user is gone.

6. Decisions needed from the user (decided 2026-09-15: all three recommendations)

  1. Separate foogolf-scripts stack + site with public sign-up (recommended) versus a Scripts page inside the admin dashboard (would need per-user scoping of every existing view first).
  2. 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.
  3. canvas.path with SVG path data as the image story (recommended) versus no vector import at all (authors draw with primitives only).

Rendered from golfclaude/docs/lua/script-store-design.md at 8ef01dd, 2026-10-26. The Markdown in the repository is the source; this page follows it on every push.