Automations and webhooks for the AT Protocol airglow.run

Autosave automation drafts #33

Closed tyb.there-hugo.dev
Closed opened by tyb.there-hugo.dev

Queued: the loop will work on this version of the issue.

loop:queued cid=bafyreigcsloxntokq3nhruzp7v433ygavbhkhxxgxffb5tc37h3u22fjou

Working on this, attempt 1.

loop:claim run=contribute-20261026-162100-3my32gozhgf22 branch=loop/3my32gozhgf22 attempt=1 issue=33

feat(automations): autosave the automation form draft in the browser

The automation form (new, duplicate and edit) now saves what you have typed to localStorage one second after each change. It also saves right away when the page is hidden or unloaded. The next visit to the same page restores that draft and says so at the top of the form, with a "Discard draft" link that returns the form to its starting state. Work now survives reloads, redirects, closed tabs and browser crashes, not just the scope-upgrade round trip (#6). A small "Draft autosaved in this browser on ..." line above the submit buttons shows that it is working.

How it works #

  • New module app/islands/form-autosave.ts. Each draft is keyed by pathname plus a hash of the form's existing draftContext (viewer handle + the page's seed). A draft therefore only restores for the same user and the same seed: another duplicate, another account, or an automation changed on the server since then starts clean. Drafts expire after 7 days, and expired ones are swept on read. It reuses hashContext from scope-upgrade-draft.ts, which is now exported.
  • The existing scope-upgrade stash in sessionStorage still wins when it is present, and keeps its own "before the permissions sign-in" notice.
  • A form that matches its starting state is not saved, and undoing every change removes the draft. A successful create or update clears the draft and stops autosave, so the pagehide flush cannot write it back while the page navigates away.

Verified #

  • pnpm run check: passes.
  • pnpm run test: 172 files and 2656 tests pass.
  • New tests in app/islands/form-autosave.test.ts: round trip, path and context isolation, expiry, sweep, clear.
  • New tests in the AutomationForm autosave block of app/islands/AutomationForm.test.tsx:
    • restore after a remount, with the same payload;
    • the pagehide flush;
    • an untouched duplicate, branched duplicate or edit is not saved;
    • undoing the changes drops the draft;
    • no restore into another duplicate, another viewer, or an edit page whose automation changed;
    • discard, and a cancelled discard;
    • the draft is cleared on a successful save and not written back on pagehide. I checked that this test fails without the stop flag.
  • Not tested by hand in a browser: the dev server needs the local PDS and Postgres.

Acceptance criteria #

Decisions the owner can reverse #

  • localStorage instead of sessionStorage. This makes drafts survive a closed tab or a crash without session restore, but they stay on the device for up to 7 days. A draft holds what the form holds, including a webhook's HMAC secret on edit pages and any raw header value the user typed. Drafts are not cleared on sign-out; another account on the same browser never sees them, because the viewer is part of the key. To keep drafts tab-scoped, switch storage() in form-autosave.ts to sessionStorage.
  • Change-debounced save, not a fixed interval. The delay is 1 s (AUTOSAVE_DELAY_MS).
  • The 7-day expiry (MAX_AGE_MS).
  • Discard asks for confirmation and resets the form in place to its seed instead of reloading.
  • Two tabs on the same page with the same seed share one draft, and the last write wins.

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/3my32gozhgf22

loop:pushed run=contribute-20261026-162100-3my32gozhgf22 sha=46312052f7fd2ec083ed5df2298d22b6da9c8c18

Don't merge this as is: on edit pages, the autosave draft stores secrets in the browser.

The draft saved to localStorage includes the webhook's HMAC secret and the raw header values, and it stays there for 7 days. Anyone using that browser, or any script running on the site, can read them. Please change three things:

  1. Leave the secret out of the draft. If the form is submitted without a secret, the server keeps the one it already has. So a restored draft that has no secret loses nothing.
  2. Leave the header values out too. Either drop headers from the draft completely, or save only their names, and let the server keep the values.
  3. Clear drafts on logout. When the user logs out, remove every autosave entry for them, so nothing is left behind on a shared computer.

Add a test for each:

  • a draft saved on an edit page contains no secret and no header values;
  • submitting a restored draft keeps the secret the server already has;
  • after logout, no draft is left.

Fix round 1: the owner commented.

loop:fix run=contribute-20261026-214042-3my32gozhgf22 round=1 issue=33

feat(automations): autosave the automation form draft in the browser

The automation form (new, duplicate and edit) now saves what you have typed to localStorage one second after each change. It also saves right away when the page is hidden or unloaded. The next visit to the same page restores that draft and says so at the top of the form, with a "Discard draft" link that returns the form to its starting state. Work now survives reloads, redirects, closed tabs and browser crashes, not just the scope-upgrade round trip (#6). A small "Draft autosaved in this browser on ..." line above the submit buttons shows that it is working. Drafts never hold a webhook secret or a header value, and signing out removes all of them.

This round (owner's review): webhook header values are left out of the saved draft, which keeps only the header names. A restored draft gets each value back from the page's seed, which is what the server has. The draft never held the HMAC secret, because the form never has one, and a test now checks that. Signing out, and "Revoke all sessions", now remove every autosaved draft in the browser.

How it works #

  • New module app/islands/form-autosave.ts. Each draft is keyed by pathname plus a hash of the form's existing draftContext (viewer handle + the page's seed). A draft therefore only restores for the same user and the same seed: another duplicate, another account, or an automation changed on the server since then starts clean. Drafts expire after 7 days, and expired ones are swept on read. It reuses hashContext from scope-upgrade-draft.ts, which is now exported.
  • No secrets in the draft. The form state has no webhook secret: the edit page's seed has one, but the step drafts never copy it, and the server keeps the stored secret when a webhook comes back with the same callback URL and no secret. Before saving, withoutHeaderValues (new, app/islands/step-drafts/header-values.ts) blanks every webhook header value at any depth and keeps the names. On restore, withSeedHeaderValues fills each blank value from the seed's webhook. It matches the same step id first, then the same callback URL, because duplicates mint new ids on each load. A header the seed does not have comes back with a name and an empty value. When the restored draft has headers, the restore notice says that header values are not saved and asks the user to re-enter any they changed.
  • Cleared on sign-out. The settings sign-out button is now a small island, app/islands/SignOutForm.tsx, with the same markup and styles (the styles moved from app/routes/settings/account.css.ts to app/islands/SignOutForm.css.ts). Before the form posts, it calls clearAllAutosavedDrafts(), which removes every airglow:automation-draft: key for every page and viewer. RevokeSessions calls the same function before it sends the user to /auth/login. Other keys, such as theme, stay.
  • The existing scope-upgrade stash in sessionStorage still wins when it is present, and keeps its own "before the permissions sign-in" notice.
  • A form that matches its starting state is not saved, and undoing every change removes the draft. A successful create or update clears the draft and stops autosave, so the pagehide flush cannot write it back while the page navigates away.

Verified #

  • pnpm run check: passes.
  • pnpm run test: 174 files and 2666 tests pass.
  • The owner's three requested tests:
    • A draft saved on an edit page has no secret and no header values. AutomationForm.test.tsx, "keeps the secret and the header values out of a draft saved on an edit page": the seed has an HMAC secret and an Authorization header, and the user adds a header with a typed value. The stored string has neither secret nor values, and does have the header names.
    • Submitting a restored draft keeps the server's secret. AutomationForm.test.tsx, "submits a restored edit draft with the server's header values and no secret", checks that the PATCH body carries the same callback URL, the server's header value and no secret. app/routes/api/automations/[rkey].test.ts, "keeps the webhook secret and headers when a restored draft is submitted", sends that same body to the PATCH handler and checks that the stored secret and headers are unchanged.
    • No draft is left after logout. app/islands/SignOutForm.test.tsx seeds drafts for two pages and two viewers, then checks that submitting the sign-out form, or revoking every session, leaves no draft key and keeps theme. I checked that the sign-out test fails when the clear call is removed.
  • Also new: step-drafts/header-values.test.ts (strip and refill at any depth, matching by id and by URL), form-autosave.test.ts (clearAllAutosavedDrafts), and a duplicate-page restore that refills by callback URL.
  • Earlier tests in form-autosave.test.ts and the AutomationForm autosave block: round trip, path and context isolation, expiry, sweep, the pagehide flush, untouched forms not saved, undo drops the draft, no restore into another duplicate, viewer or changed automation, discard and cancelled discard, and the draft cleared on a successful save.
  • Not tested by hand in a browser: the dev server needs the local PDS and Postgres.

Acceptance criteria #

Decisions the owner can reverse #

  • Header names are kept and values refilled from the page's seed, rather than dropping headers entirely. The values come back on the client from the seed, which matches the server because a draft only restores when the seed is unchanged. The server API is unchanged. One trade-off: if the user changed the value of an existing header, the restore shows the server's value again, and the notice asks them to re-enter it. A newly added header comes back with an empty value, and if it is not filled in, submitting drops it, as the form already does for any header with an empty value.
  • Sign-out clears every autosaved draft in the browser, including another account's. The key holds only a hash of the viewer, so drafts cannot be told apart by user, and clearing all of them is the safer choice on a shared computer. The sign-out clear runs in the browser: a sign-out before the island hydrates clears nothing, but no draft can exist without JavaScript either.
  • The scope-upgrade stash is not cleared on sign-out. It lives in sessionStorage for at most 30 minutes, belongs to one tab, holds the full form including header values, and is on main already. I left it out because the review asked about autosave entries. Clearing it too is a one-line addition in SignOutForm.
  • localStorage instead of sessionStorage, so drafts survive a closed tab or a crash. To keep drafts tab-scoped, switch storage() in form-autosave.ts to sessionStorage.
  • Change-debounced save, not a fixed interval. The delay is 1 s (AUTOSAVE_DELAY_MS).
  • The 7-day expiry (MAX_AGE_MS).
  • Discard asks for confirmation and resets the form in place to its seed instead of reloading.
  • Two tabs on the same page with the same seed share one draft, and the last write wins.

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/3my32gozhgf22

loop:pushed run=contribute-20261026-214042-3my32gozhgf22 sha=c1ab0d2e63057368a012e72369d70f2d3921a2ab

Verdict: changes-requested. The branch autosaves the automation form and does all three things the owner asked for. The draft has no webhook secret (WebhookDraft has no secret field). Header values are blanked at any depth before saving, and filled back from the seed when the draft is restored. Sign-out and "Revoke all sessions" clear every airglow:automation-draft: key. pnpm run check and pnpm run test pass (174 files, 2666 tests), and the branch merges cleanly with the current main (841cfb7). One real-use bug is left, on the most common way into the new-automation page, plus a keyboard-focus gap on the new "Discard draft" control.

Changes:

  1. A draft from one lexicon is restored when the user asks for another. On /automations/new the autosave key is the pathname plus a hash of [viewerHandle, null]. The ?lexicon= query is ignored, and form-autosave.test.ts even tests that it is ignored. Here is what happens:

    • The user starts a form from /automations/new?lexicon=app.bsky.feed.post, which app/routes/lexicons/[nsid].tsx links to.
    • Later they follow /automations/new?lexicon=app.bsky.graph.follow.
    • They get the old app.bsky.feed.post draft back, with "We restored the unsaved draft...".

    I reproduced this with a throwaway test: the restored form shows lexicon=app.bsky.feed.post on the follow URL. Fix it by adding the URL's lexicon param to the autosave context when there is no initial. For example, pass JSON.stringify([viewerHandle, initial ?? null, initial ? null : getInitialParam("lexicon")]) to readAutosavedDraft, saveAutosavedDraft and clearAutosavedDraft. Leave the scope-upgrade draftContext as it is. Then:

    • Plain /automations/new and each ?lexicon=X get their own draft.
    • Add a test in AutomationForm.test.tsx showing that a draft saved under one ?lexicon= does not restore under another, and still restores under the same one.
  2. Focus is lost after "Discard draft". When the user confirms, the notice that holds the button unmounts. document.activeElement then becomes <body>, which I checked in happy-dom, so a keyboard or screen-reader user is dropped at the top of the document. In discardAutosave, after the reset, move focus to something sensible, such as the #sub-name input (for example requestAnimationFrame(() => document.getElementById("sub-name")?.focus())). Add an assertion on document.activeElement to the existing "discards a restored draft back to the page's own seed" test.

What I checked and how:

  • Owner's point 1, the secret: the edit seed's secret never reaches WebhookDraft (see fromAction in webhook.tsx). The server keeps the stored secret on a PATCH without one, and the new [rkey].test.ts case covers that. The secret only enters the 32-bit FNV hash of the context, never the stored value.
  • Owner's point 2, header values: only webhook actions have HeaderDrafts. mapWebhookDrafts walks the action, condition (then/else) and loop step kinds, which are all the container kinds in tree.ts. The refill matches by step id first, then by callback URL. The tests check the raw stored string.
  • Owner's point 3, logout: /auth/signout is posted only from the settings form, which is now the SignOutForm island. The revoke-sessions path clears too. SignOutForm.test.tsx covers both, and keeps unrelated keys such as theme.
  • Other paths: a successful save clears the draft and stops the pagehide write-back. Undoing every change drops the draft. Duplicates, other viewers and changed seeds stay isolated. Expiry and sweep are covered.
  • Not in scope, and not blocking: callback URLs such as Discord or Slack webhook URLs carry their token in the URL, and they are still stored in the draft. The owner may want to decide whether that counts as a secret too.

loop:verdict run=review-20261026-215428-3my32gozhgf22 sha=c1ab0d2e63057368a012e72369d70f2d3921a2ab verdict=changes-requested

Fix round 2: the review asked for changes.

loop:fix run=contribute-20261026-215817-3my32gozhgf22 round=2 issue=33

feat(automations): autosave the automation form draft in the browser

The automation form (new, duplicate and edit) now saves what you have typed to localStorage one second after each change. It also saves right away when the page is hidden or unloaded. The next visit to the same page restores that draft and says so at the top of the form, with a "Discard draft" link that returns the form to its starting state. Work now survives reloads, redirects, closed tabs and browser crashes, not just the scope-upgrade round trip (#6). A small "Draft autosaved in this browser on ..." line above the submit buttons shows that it is working. Drafts never hold a webhook secret or a header value, and signing out removes all of them.

This round (second review): on /automations/new, the autosave key now includes the ?lexicon= link the blank form was opened from. Before, a draft started from ?lexicon=app.bsky.feed.post came back on ?lexicon=app.bsky.graph.follow. Now plain /automations/new and each ?lexicon=X keep their own draft. Also, after "Discard draft", focus moves to the name input, so keyboard and screen-reader users are no longer dropped at the top of the page when the notice unmounts.

Previous round (owner's review): webhook header values are left out of the saved draft, which keeps only the header names. A restored draft gets each value back from the page's seed, which is what the server has. The draft never held the HMAC secret, because the form never has one, and a test now checks that. Signing out, and "Revoke all sessions", now remove every autosaved draft in the browser.

How it works #

  • New module app/islands/form-autosave.ts. Each draft is keyed by pathname plus a hash of an autosaveContext: the viewer handle, the page's seed and, for a blank form with no seed, the URL's lexicon param. A draft therefore only restores for the same user, the same seed and the same ?lexicon= link: another duplicate, another account, or an automation changed on the server since then starts clean. Drafts expire after 7 days, and expired ones are swept on read. It reuses hashContext from scope-upgrade-draft.ts, which is now exported.
  • No secrets in the draft. The form state has no webhook secret: the edit page's seed has one, but the step drafts never copy it, and the server keeps the stored secret when a webhook comes back with the same callback URL and no secret. Before saving, withoutHeaderValues (new, app/islands/step-drafts/header-values.ts) blanks every webhook header value at any depth and keeps the names. On restore, withSeedHeaderValues fills each blank value from the seed's webhook. It matches the same step id first, then the same callback URL, because duplicates mint new ids on each load. A header the seed does not have comes back with a name and an empty value. When the restored draft has headers, the restore notice says that header values are not saved and asks the user to re-enter any they changed.
  • Cleared on sign-out. The settings sign-out button is now a small island, app/islands/SignOutForm.tsx, with the same markup and styles (the styles moved from app/routes/settings/account.css.ts to app/islands/SignOutForm.css.ts). Before the form posts, it calls clearAllAutosavedDrafts(), which removes every airglow:automation-draft: key for every page and viewer. RevokeSessions calls the same function before it sends the user to /auth/login. Other keys, such as theme, stay.
  • The existing scope-upgrade stash in sessionStorage still wins when it is present, and keeps its own "before the permissions sign-in" notice.
  • A form that matches its starting state is not saved, and undoing every change removes the draft. A successful create or update clears the draft and stops autosave, so the pagehide flush cannot write it back while the page navigates away.

Verified #

  • pnpm run check: passes.
  • pnpm run test: 174 files and 2667 tests pass.
  • This round: AutomationForm.test.tsx, "keeps a blank form's draft to the ?lexicon= it was started from", saves a draft on ?lexicon=app.bsky.feed.post. It checks that the draft does not restore on ?lexicon=app.bsky.graph.follow (which shows its own lexicon) or on plain /automations/new, and that it does restore on the same ?lexicon=. The "discards a restored draft back to the page's own seed" test now checks that document.activeElement is the name input. I checked that both tests fail without the change to AutomationForm.tsx.
  • The owner's three requested tests:
    • A draft saved on an edit page has no secret and no header values. AutomationForm.test.tsx, "keeps the secret and the header values out of a draft saved on an edit page": the seed has an HMAC secret and an Authorization header, and the user adds a header with a typed value. The stored string has neither secret nor values, and does have the header names.
    • Submitting a restored draft keeps the server's secret. AutomationForm.test.tsx, "submits a restored edit draft with the server's header values and no secret", checks that the PATCH body carries the same callback URL, the server's header value and no secret. app/routes/api/automations/[rkey].test.ts, "keeps the webhook secret and headers when a restored draft is submitted", sends that same body to the PATCH handler and checks that the stored secret and headers are unchanged.
    • No draft is left after logout. app/islands/SignOutForm.test.tsx seeds drafts for two pages and two viewers, then checks that submitting the sign-out form, or revoking every session, leaves no draft key and keeps theme. I checked that the sign-out test fails when the clear call is removed.
  • Also new: step-drafts/header-values.test.ts (strip and refill at any depth, matching by id and by URL), form-autosave.test.ts (clearAllAutosavedDrafts), and a duplicate-page restore that refills by callback URL.
  • Earlier tests in form-autosave.test.ts and the AutomationForm autosave block: round trip, path and context isolation, expiry, sweep, the pagehide flush, untouched forms not saved, undo drops the draft, no restore into another duplicate, viewer or changed automation, discard and cancelled discard, and the draft cleared on a successful save.
  • Not tested by hand in a browser: the dev server needs the local PDS and Postgres.

Acceptance criteria #

Decisions the owner can reverse #

  • Header names are kept and values refilled from the page's seed, rather than dropping headers entirely. The values come back on the client from the seed, which matches the server because a draft only restores when the seed is unchanged. The server API is unchanged. One trade-off: if the user changed the value of an existing header, the restore shows the server's value again, and the notice asks them to re-enter it. A newly added header comes back with an empty value, and if it is not filled in, submitting drops it, as the form already does for any header with an empty value.
  • Sign-out clears every autosaved draft in the browser, including another account's. The key holds only a hash of the viewer, so drafts cannot be told apart by user, and clearing all of them is the safer choice on a shared computer. The sign-out clear runs in the browser: a sign-out before the island hydrates clears nothing, but no draft can exist without JavaScript either.
  • The scope-upgrade stash is not cleared on sign-out. It lives in sessionStorage for at most 30 minutes, belongs to one tab, holds the full form including header values, and is on main already. I left it out because the review asked about autosave entries. Clearing it too is a one-line addition in SignOutForm.
  • localStorage instead of sessionStorage, so drafts survive a closed tab or a crash. To keep drafts tab-scoped, switch storage() in form-autosave.ts to sessionStorage.
  • Change-debounced save, not a fixed interval. The delay is 1 s (AUTOSAVE_DELAY_MS).
  • The 7-day expiry (MAX_AGE_MS).
  • Discard asks for confirmation and resets the form in place to its seed instead of reloading. Focus then moves to the name input (#sub-name).
  • The scope-upgrade stash keeps its old context (viewer + seed, no ?lexicon=), as the review asked. It is one-shot and lives for one tab, so it cannot come back on another lexicon's page later.
  • Two tabs on the same page with the same seed share one draft, and the last write wins.

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/3my32gozhgf22

loop:pushed run=contribute-20261026-215817-3my32gozhgf22 sha=27ce27e06705b43ead45952899afeeaa60dc0088

Verdict: changes-requested. The branch does what the owner's review asked: no secret in the draft, header names only, drafts cleared on sign-out and on "Revoke all sessions", and a test for each. pnpm run check and pnpm run test (174 files, 2667 tests) pass. But the blank form's autosave key moves whenever the user picks a lexicon, because the form rewrites ?lexicon= with history.replaceState and autosaveContext reads the live URL on every render. In the most common flow this leaves an orphan draft that comes back for an automation that has already been created.

What I checked and how:

  • I read every changed line in form-autosave.ts, header-values.ts, AutomationForm.tsx, SignOutForm.tsx, RevokeSessions.tsx and the tests. WebhookDraft has no secret field, so the form state never holds one. withoutHeaderValues blanks the values at any depth. /auth/signout is only posted from the new SignOutForm island. No other action draft holds headers or credentials.
  • I checked the owner's three requested tests: the edit-page draft has no secret and no header values, the PATCH of a restored draft keeps the stored secret (both in the form test and in the route test), and no draft is left after sign-out or revoke.
  • "Discard draft" is a native button with a distinct name, and focus moves to #sub-name after it runs.
  • Real-use probe, as a throwaway test in /work (since removed): open plain /automations/new, type a name, wait past the debounce, type app.bsky.feed.post into the lexicon field, wait, then create.
    • After the name, there is one key, .../automations/new:1tundme. After the lexicon, the URL is ?lexicon=app.bsky.feed.post and there are two keys, the old one plus svy8lg.
    • After a successful create, only svy8lg is cleared.
    • The next visit to plain /automations/new shows "We restored the unsaved draft..." with name "My automation", the automation the user just created.
    • Typing the NSID slowly leaves one extra key for each pause longer than 1 s. "Undo drops the draft" also fails across a key change.

Changes requested:

  1. In app/islands/AutomationForm.tsx, stop the blank form leaving drafts under an old key when ?lexicon= changes. One approach that works (I tried it: with it, the probe leaves no key after create, nothing restores on the next plain visit, and all 81 AutomationForm.test.tsx tests still pass): keep a lastAutosaveContextRef initialised to autosaveContext. In writeAutosave, if it differs from the current autosaveContext, call clearAutosavedDraft(lastAutosaveContextRef.current) and update the ref before saving. The draft then follows the URL, and a reload on the rewritten ?lexicon= URL still restores it. Do not just freeze the context at mount: after a lexicon pick, the reload URL carries the new ?lexicon=, so a frozen key would stop restoring on reload.
  2. Add a test in the AutomationForm autosave block for that flow. On plain /automations/new: type a name, settle, set #lexicon, settle, and check that only one draft key exists. Then a successful create leaves localStorage empty, and remounting on plain /automations/new restores nothing.

Note for the owner, not a required change: the draft keeps each webhook's callbackUrl in localStorage. Users who paste a raw URL with a token in it (such as a Discord webhook URL) instead of {{secret:...}} will have that token stored for up to 7 days. The owner's review did not ask for this, and the public view already strips callback URLs for the same reason.

No migration and no followup.md in this branch.

loop:verdict run=review-20261026-220739-3my32gozhgf22 sha=27ce27e06705b43ead45952899afeeaa60dc0088 verdict=changes-requested

Fix round 3: the review asked for changes.

loop:fix run=contribute-20261026-221157-3my32gozhgf22 round=3 issue=33

feat(automations): autosave the automation form draft in the browser

The automation form (new, duplicate and edit) now saves what you have typed to localStorage one second after each change. It also saves right away when the page is hidden or unloaded. The next visit to the same page restores that draft and says so at the top of the form, with a "Discard draft" link that returns the form to its starting state. Work now survives reloads, redirects, closed tabs and browser crashes, not just the scope-upgrade round trip (#6). A small "Draft autosaved in this browser on ..." line above the submit buttons shows that it is working. Drafts never hold a webhook secret or a header value, and signing out removes all of them.

This round (third review): picking a lexicon on a blank /automations/new rewrites the URL's ?lexicon=, which changes the autosave key. Before, the draft saved under the old key stayed behind, and after a create the next plain visit restored the automation that had just been created. Now the draft moves with the URL: when the key changes, the next autosave removes the draft under the old key before it writes the new one. A successful create, and "Discard draft", also clear both keys. A reload on the rewritten ?lexicon= URL still restores the draft.

Second review: on /automations/new, the autosave key now includes the ?lexicon= link the blank form was opened from. Before, a draft started from ?lexicon=app.bsky.feed.post came back on ?lexicon=app.bsky.graph.follow. Now plain /automations/new and each ?lexicon=X keep their own draft. Also, after "Discard draft", focus moves to the name input, so keyboard and screen-reader users are no longer dropped at the top of the page when the notice unmounts.

Previous round (owner's review): webhook header values are left out of the saved draft, which keeps only the header names. A restored draft gets each value back from the page's seed, which is what the server has. The draft never held the HMAC secret, because the form never has one, and a test now checks that. Signing out, and "Revoke all sessions", now remove every autosaved draft in the browser.

How it works #

  • New module app/islands/form-autosave.ts. Each draft is keyed by pathname plus a hash of an autosaveContext: the viewer handle, the page's seed and, for a blank form with no seed, the URL's lexicon param. A draft therefore only restores for the same user, the same seed and the same ?lexicon= link: another duplicate, another account, or an automation changed on the server since then starts clean. Drafts expire after 7 days, and expired ones are swept on read. It reuses hashContext from scope-upgrade-draft.ts, which is now exported.
  • No secrets in the draft. The form state has no webhook secret: the edit page's seed has one, but the step drafts never copy it, and the server keeps the stored secret when a webhook comes back with the same callback URL and no secret. Before saving, withoutHeaderValues (new, app/islands/step-drafts/header-values.ts) blanks every webhook header value at any depth and keeps the names. On restore, withSeedHeaderValues fills each blank value from the seed's webhook. It matches the same step id first, then the same callback URL, because duplicates mint new ids on each load. A header the seed does not have comes back with a name and an empty value. When the restored draft has headers, the restore notice says that header values are not saved and asks the user to re-enter any they changed.
  • Cleared on sign-out. The settings sign-out button is now a small island, app/islands/SignOutForm.tsx, with the same markup and styles (the styles moved from app/routes/settings/account.css.ts to app/islands/SignOutForm.css.ts). Before the form posts, it calls clearAllAutosavedDrafts(), which removes every airglow:automation-draft: key for every page and viewer. RevokeSessions calls the same function before it sends the user to /auth/login. Other keys, such as theme, stay.
  • The existing scope-upgrade stash in sessionStorage still wins when it is present, and keeps its own "before the permissions sign-in" notice.
  • A form that matches its starting state is not saved, and undoing every change removes the draft. A successful create or update clears the draft and stops autosave, so the pagehide flush cannot write it back while the page navigates away.

Verified #

  • pnpm run check: passes.
  • pnpm run test: 174 files and 2668 tests pass.
  • This round: AutomationForm.test.tsx, "moves a blank form's draft along when picking a lexicon rewrites ?lexicon=". On plain /automations/new it types a name, lets the autosave run, sets #lexicon to app.bsky.feed.post and lets it run again. It checks that the URL is ?lexicon=app.bsky.feed.post and that only one draft key exists. A successful create then leaves localStorage empty, and remounting on plain /automations/new restores nothing. Without the change to AutomationForm.tsx, this test fails with 2 keys instead of 1.
  • Second review: AutomationForm.test.tsx, "keeps a blank form's draft to the ?lexicon= it was started from", saves a draft on ?lexicon=app.bsky.feed.post. It checks that the draft does not restore on ?lexicon=app.bsky.graph.follow (which shows its own lexicon) or on plain /automations/new, and that it does restore on the same ?lexicon=. The "discards a restored draft back to the page's own seed" test now checks that document.activeElement is the name input. I checked that both tests fail without the change to AutomationForm.tsx.
  • The owner's three requested tests:
    • A draft saved on an edit page has no secret and no header values. AutomationForm.test.tsx, "keeps the secret and the header values out of a draft saved on an edit page": the seed has an HMAC secret and an Authorization header, and the user adds a header with a typed value. The stored string has neither secret nor values, and does have the header names.
    • Submitting a restored draft keeps the server's secret. AutomationForm.test.tsx, "submits a restored edit draft with the server's header values and no secret", checks that the PATCH body carries the same callback URL, the server's header value and no secret. app/routes/api/automations/[rkey].test.ts, "keeps the webhook secret and headers when a restored draft is submitted", sends that same body to the PATCH handler and checks that the stored secret and headers are unchanged.
    • No draft is left after logout. app/islands/SignOutForm.test.tsx seeds drafts for two pages and two viewers, then checks that submitting the sign-out form, or revoking every session, leaves no draft key and keeps theme. I checked that the sign-out test fails when the clear call is removed.
  • Also new: step-drafts/header-values.test.ts (strip and refill at any depth, matching by id and by URL), form-autosave.test.ts (clearAllAutosavedDrafts), and a duplicate-page restore that refills by callback URL.
  • Earlier tests in form-autosave.test.ts and the AutomationForm autosave block: round trip, path and context isolation, expiry, sweep, the pagehide flush, untouched forms not saved, undo drops the draft, no restore into another duplicate, viewer or changed automation, discard and cancelled discard, and the draft cleared on a successful save.
  • Not tested by hand in a browser: the dev server needs the local PDS and Postgres.

Acceptance criteria #

Decisions the owner can reverse #

  • Header names are kept and values refilled from the page's seed, rather than dropping headers entirely. The values come back on the client from the seed, which matches the server because a draft only restores when the seed is unchanged. The server API is unchanged. One trade-off: if the user changed the value of an existing header, the restore shows the server's value again, and the notice asks them to re-enter it. A newly added header comes back with an empty value, and if it is not filled in, submitting drops it, as the form already does for any header with an empty value.
  • Sign-out clears every autosaved draft in the browser, including another account's. The key holds only a hash of the viewer, so drafts cannot be told apart by user, and clearing all of them is the safer choice on a shared computer. The sign-out clear runs in the browser: a sign-out before the island hydrates clears nothing, but no draft can exist without JavaScript either.
  • The scope-upgrade stash is not cleared on sign-out. It lives in sessionStorage for at most 30 minutes, belongs to one tab, holds the full form including header values, and is on main already. I left it out because the review asked about autosave entries. Clearing it too is a one-line addition in SignOutForm.
  • localStorage instead of sessionStorage, so drafts survive a closed tab or a crash. To keep drafts tab-scoped, switch storage() in form-autosave.ts to sessionStorage.
  • Change-debounced save, not a fixed interval. The delay is 1 s (AUTOSAVE_DELAY_MS).
  • The 7-day expiry (MAX_AGE_MS).
  • Discard asks for confirmation and resets the form in place to its seed instead of reloading. Focus then moves to the name input (#sub-name).
  • The scope-upgrade stash keeps its old context (viewer + seed, no ?lexicon=), as the review asked. It is one-shot and lives for one tab, so it cannot come back on another lexicon's page later.
  • The blank-form draft follows the URL when ?lexicon= changes, as the review suggested. The key is not frozen at mount, because the reload URL carries the new ?lexicon=. The cost: a draft started on ?lexicon=A and then switched to B no longer restores on ?lexicon=A.
  • Callback URLs stay in the draft. As the review noted, a webhook URL with a token in it (such as a Discord webhook URL pasted raw, not as {{secret:...}}) is kept in localStorage for up to 7 days. This branch leaves it as is because no review asked for a change. Dropping or masking callbackUrl in withoutHeaderValues would fix that, but then restoring a duplicate's webhook by URL would no longer work.
  • Two tabs on the same page with the same seed share one draft, and the last write wins.

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/3my32gozhgf22

loop:pushed run=contribute-20261026-221157-3my32gozhgf22 sha=02a338a4d4d66939e0d494325451de33c378a60f

Verdict: merge. The branch autosaves the automation form (new, duplicate and edit) to localStorage one second after each change and on pagehide, and restores it on the next visit to the same page, so work survives reloads, redirects and crashes as the issue asks. It also covers all three points of the owner's review. The draft never holds the webhook HMAC secret, because the form state has no secret field and the server keeps the one it has. Header values are blanked before the draft is saved and refilled from the page's seed when it is restored. Signing out, and "Revoke all sessions", clear every airglow:automation-draft: key. pnpm run check and pnpm run test both pass (174 files, 2668 tests).

What I checked and how:

  • Checks and tests: I ran both commands from run.json. Both pass.
  • The secret: WebhookDraft has no secret field, and fromAction never copies one. The edit-page test seeds an HMAC secret and asserts that the stored string has neither the secret nor the word "secret". A new PATCH-handler test in [rkey].test.ts sends the shape a restored draft submits and asserts that the stored secret and headers are unchanged.
  • Header values: withoutHeaderValues blanks header values at any depth, inside conditions and loops, and keeps the names. withSeedHeaderValues refills them by step id first, then by callback URL. When a restored draft has headers, the notice tells the user to re-enter any values they changed. Webhook is the only action type with headers.
  • Sign-out: the settings form is the only place in app/ that posts to /auth/signout, and it is now the SignOutForm island, which clears drafts in onSubmit without blocking the post. RevokeSessions clears drafts on success. SignOutForm.test.tsx covers both paths and checks that theme is kept. The owner's three requested tests are all there.
  • Draft keys: the key is the pathname plus a hash of the viewer, the seed and, for a blank form, ?lexicon=. When picking a lexicon rewrites the URL, the draft moves to the new key and the old one is dropped. A successful create or update, and "Discard draft", clear both keys and stop the pagehide flush.
  • "Discard draft": it is a real <button> with a distinct name. It asks for confirmation, and focus then moves to #sub-name.
  • Untouched forms: I added a throwaway test, now reverted. An untouched blank form on /automations/new?lexicon=app.bsky.feed.post and on plain /automations/new writes no draft.
  • Edge case, not blocking: an edit seed with operations: ["create"] and dedupeBySource: false does autosave while untouched, because the existing snap effect flips dedupeBySource. Both API routes force dedupeBySource to true when the operations do not include update, so the server should never store that state.
  • No migration, and no followup.md to judge.

Accepted trade-offs, listed in the PR: callback URLs, which may embed tokens, stay in the draft, and the sessionStorage scope-upgrade stash is not cleared on sign-out. The owner can revisit either one.

loop:verdict run=review-20261026-222730-3my32gozhgf22 sha=02a338a4d4d66939e0d494325451de33c378a60f verdict=merge

Merged main into the branch: 1629edbe4f756ab2688e0e779e6b02a6e8955372.

loop:carry from=02a338a4d4d66939e0d494325451de33c378a60f sha=1629edbe4f756ab2688e0e779e6b02a6e8955372

Merged onto main as 5e1491f7bf6b256e8d8b956a6be633a7423862f8.

loop:merged sha=5e1491f7bf6b256e8d8b956a6be633a7423862f8 head=1629edbe4f756ab2688e0e779e6b02a6e8955372

Sign up or Sign in to add to the discussion.