Clearer path to open existing records for a new key-holder
- Granting a colleague access to protected data is two steps: Encryption Access on the form (new submissions then seal to them automatically), and - for EXISTING records - a current key-holder unlocking on Your encryption key and clicking Grant next to their name, which re-seals the history in one audited click. The second step was invisible; the submission page's "Requires Encryption Access" message now spells out both.
- Verified end to end: a freshly enrolled user saw no-access on an old record, the owner granted from the key page, and the user then revealed the old card with their own passphrase.
Old submissions can be unlocked again
- Submissions captured before the staff-key encryption shipped had no key packets, so the panel showed the masked card with no Reveal button and no explanation. A one-click, one-time Maintenance conversion decrypts each legacy card server-side (the server held those old keys all along) and re-seals it to the form's current key-holders - after that, Reveal + passphrase works on the backlog exactly like on new submissions, and the server can no longer read them.
- Old stored CVVs are deleted outright, never carried over. Cards on forms with no enrolled key-holder are skipped untouched and convert on a later run. Every conversion is audit-logged; re-running is safe.
- Unconverted rows now explain themselves on the submission page instead of showing nothing.
Dragging a field no longer scrambles the order
- The drag library physically moves the row and the builder then updated the schema - but the canvas renderer re-applied the same move on top of the already-moved row, so what you saw and what was saved disagreed from the first drag on, and every follow-up drag compounded the scramble. The handler now restores the row first and lets the renderer perform the real move from the updated schema.
- Verified: drags land exactly as dropped, canvas and saved schema match after every drag and after repeated drags, and the up/down buttons stay in lockstep. A form already saved scrambled just needs a one-time re-arrange - what you arrange is now exactly what saves.
Hotfix: dropdown options editor typable again
- The per-option color picker (v3.5.61) grabbed the whole row's width in the narrow field-settings sidebar and crushed the label and value boxes to invisible slivers. The option row is a grid now: label and value share the first line at full typing width, the color picker sits on its own line underneath (wearing its chosen color), the remove button stays on the right.
Background image for the public form
- Form settings gain a Background image upload (PNG/JPG/WebP/SVG, up to 4 MB): it fills the page behind the form, scaled to cover, with the background color painting underneath. The form card stays white. Same hardened pipeline as the logo (type whitelist, SVG script scan, gated serving route).
- FIX: saving the settings page on a form whose "accepting submissions" toggle was never touched silently closed it - the never-set state (which counts as accepting) was coerced to an explicit "closed". Never-set now stays accepting.
Styling batch: headers, background, colored dropdowns
- Amex is out of the Payment type dropdown (Visa, Mastercard, Discover offered). Prefix detection, stored records and PDF display names still understand Amex - it is just not offered.
- Section headers style per header: alignment (left / center / right, hairlines follow), title size (12-60px) and font face (Georgia, Times, Palatino, Arial, Helvetica, Trebuchet, Courier) in the field settings. Form settings gain a Page background color for the public form.
- Dropdown options can be color-coded (preset pastels): the open list shows each option on its color, the closed select tints to the picked option, and the submission page's inline editor shows the same coding. Also fixed: the card block printed its label twice.
Blank optional card block validates
- A card block left entirely empty on an optional field was rejected with "Card number must be 13-19 digits". The capture path has always treated an all-blank block as "no card"; the validator now agrees. A blank block on a required card field still asks for the card.
- Also verified the plan-cap override end to end: on /admin/billing a super-admin sets explicit workspace caps; a blank cap means "plan default", an explicit number wins immediately - no restart, no reset date.
Card and encrypted comments editable after the fact
- On the submission's Edit fields page the protected values work replace-or-keep (they can never be prefilled - the server cannot decrypt them; blank means keep). A full new card number replaces the card and re-seals it to the form's current key-holders exactly like a new submission; name, expiry or payment type can be corrected on their own without re-keying. New comment text re-seals; stored data keeps only the [encrypted] mask.
- Every replacement is audit-logged (fields and editor, never values), a validation error leaves stored values untouched, and writing needs no unlock - sealing uses public keys, same as first capture. Reading stays behind Reveal.
- Also fixed: saving any edit on a form with a card block could fail validation on unrelated changes (the empty card shape used to overwrite the stored summary).
Reveal errors tell the truth
- When the Reveal or Unlock call got back anything that was not JSON (a redirect, an HTML error, an expired session) the panel said "Network error - try again", sending people to check their wifi while the server had refused the request. The two failures now read differently: a refused request says so, with the HTTP status and what to do; a genuinely dropped connection still reads as a network problem.
- Wrong-passphrase and permission messages are unchanged. Verified in the browser: forced non-JSON shows the self-diagnosing message, the normal reveal flow still works end to end.
Base Level staff can unlock protected data
- The Reveal endpoint carried a blanket "not for Base Level" block, so the exact role that processes payments could see the Protected data panel but never unlock it. Removed.
- Everything that actually guards the data is unchanged: form invite + submission-view permission, Encryption Access + an enrolled key (no sealed packet, no reveal - the boundary is the encryption itself), audit-logged reveals, 30-minute inactivity expiry.
- Base Level stays Base Level everywhere else: no card deletion, no workflow actions, no editor access. Verified end to end as a real Base Level account.
Contract preview removed from the public form
- Nobody filling a form sees the contract any more: the success overlay's "Preview the filled PDF" button, the thanks page's preview link, and the footer's editor-scoped "Preview PDF" button are all removed.
- The anonymous sample endpoint (/f/{slug}/pdf-preview) is deleted outright and now 404s; the submit response no longer hands out a per-submission PDF link.
- Staff still preview and download the stamped contract from the submission page and the PDF editor exactly as before.
The card block is vertical
- On the public form the card block stacks one box under the other, each full width: Payment type, Name on card, Card number, Expiry (MM/YY). Same layout on every screen size.
- Nothing else changed: the POST shape, validation, vault capture, PDF stamping and the editor's live preview all keep working exactly as before - verified with a real submission through the new layout.
Live preview follows the payment type you enter
- The PDF editor's live preview always showed "Visa" for a payment-type placement regardless of what was picked or typed. It now mirrors the real stamping order: the dropdown pick wins, otherwise the brand is read off the typed number's prefix (the same detection the capture path runs), and "Visa" remains the neutral stand-in only when nothing is entered.
- Verified in the editor: a 5555... number previews "Mastercard", an explicit Amex pick overrides it, a 6011... number previews "Discover".
Name on card is back
- v3.5.49 removed the cardholder-name box along with the CVV by mistake. The card block is four fields on one row again: Card number, Name on card, MM/YY, Payment type. The name is collected, stored with the card summary, shown as the Cardholder line on the submission page, and stamps on the contract. The CVV stays gone - never asked, never stored.
- "Split into 4 placements" in the PDF editor turns a whole-card placement into card number, name on card, expiry and payment type - one per pre-printed line of the payment section, all selected and ready to slide into place.
- The name is still never demanded (an old page mid-flight that posts without it submits fine), and a posted name is capped at 120 characters.
Protected download follows the Reveal button
- The "Download filled PDF" button starts inert and is armed only by the in-page Reveal data action. The vault unlock lasts 30 minutes of inactivity, so the download could previously be a step ahead of a freshly loaded page that showed everything masked. A refresh re-masks the values and re-disarms the download together. The endpoint keeps its own server-side key check.
- The "Encrypted with staff keys" hint sits on one clean line with its lock icon, on its own row with proper clearance from the buttons.
- The gap between the Protected data panel, the Submitted data block, and the right-hand column is the same 20px everywhere.
Protected data panel restyle, filled PDF first, console typography
- The protected data panel on the submission page is a proper definition list: every label - including the comments box - sits in front of its value in a fixed uppercase label column. Values render bigger (card number 16px with letter spacing) so revealed data reads at a glance; stacks label-above-value on phones.
- Download buttons swapped: when a submission carries protected data and it is unlocked, "Download filled PDF" (with the protected data) is the primary button and the clean copy becomes the secondary "Customer copy (no protected data)". Submissions without protected data keep the single clean download.
- The operator console (/admin overview and Maintenance) gets real typography: content capped at a readable width, larger headings with divider rules, uppercase eyebrow sub-headings, padded cards and a comfortable line length for prose.
Payment type is a placeable card part
- The Card part dropdown in the PDF placement inspector gains "Payment type only (Visa, Amex, ...)" - place, size and align it like any other part; it prints the brand's display name.
- "Split into 3 placements" now follows the live field: card number, expiry and payment type, matching the three boxes the form shows since v3.5.49. The "Name on card" part remains available for older submissions that carry one.
- The live preview shows the brand in place (sample "Visa" when previewing without data). Verified against a stamped PDF: masked number, expiry and "Visa" each on their own placed line.
Card block: three fields on one row
- The card block is now Card number + Expiry + Payment type on a single row (stacking cleanly on phones). The CVV box is gone entirely - not an option any more, simply not asked; it was never stored on any path. The cardholder-name box is gone too: every real form collects the person's name as a field of its own.
- Each removed input also stopped being required by the capture pipeline: a card without those keys is still recognised, still sealed to staff keys, still masked everywhere. Old forms mid-fill still submit fine; a holder or CVV posted by an old page is handled exactly as before.
- The submission page hides the Cardholder line unless an older record actually has one.
Submission section headings: centered and bigger
- Section headings on the submission review page are now centered, 17px bold, with hairlines running to both edges - nothing else on the page looks like that, so a heading cannot be mistaken for the data under it. The section description sits centered beneath in muted ink.
- Lands on top of the last two releases' work on the same page (proper borders on every field, tighter spacing) and matches the public form's centered style, one size up.
Submission fields look like fields
- Every control on the submission review page - text inputs, dropdowns, textareas - now has a proper visible border on a white field, matching inputs everywhere else in the app. They used to be transparent until hovered, with dropdowns floating as bare text next to a chevron ("reads like data" by design, read as broken in practice).
- Hover darkens the border, focus shows the blue ring, and a disabled field sits flat on a muted background instead of dissolving into the page. No behaviour changed: values still save on blur, dropdowns still come from the form's own options, protected fields keep their locked stubs.
Card options and a round of requested polish
- The credit-card field has a new "Collect CVV" switch: turn it off and the CVV box disappears from the public form, for workflows that never need the code. The code was never stored on any path either way. Existing forms keep the box until the switch is turned off.
- A "Card type" dropdown on the card block (Visa / Mastercard / American Express / Discover). If the agent skips it, the type is derived from the number's prefix. It shows on the Protected data panel, is stored with the card summary, and an Amex now masks in its own 4-6-5 grouping on the printed contract regardless of the form-level setting.
- The public form no longer fades fields in as you scroll - everything is simply visible. This also fixes forms rendering blank when scripts were blocked or broken.
- The thank-you page's checkmark is centred in its circle instead of hanging off the top-left edge, and button icons line up with their text.
- The submission review page is readable again: compact left-aligned section headings with a clean underline replace the centred captions, with less vertical air between sections. The public form keeps its centred style.
Hardening the staff-held-keys release
- An adversarial review of v3.5.44 surfaced four gaps. None let anyone read a protected value - the sealed-packet arithmetic held everywhere - but all four deserved closing. All four are fixed.
- The consolidated reveal endpoint now honours the per-user Submissions switch, matching the gate the old card reveal had. A user whose Submissions access was switched off could previously still call the endpoint directly (their own key and a sealed packet were still required - the encryption held).
- Encryption Access can only be granted to a member of the workspace. The request previously accepted any user id, which would have sealed future submissions to a keypair outside the workspace - unreachable through the app, but packets for outsiders should not exist at all.
- Closed a redirect trick: the unlock form's return parameter accepted /\evil.com, which browsers read as a link to another site. CSRF-gated, so not externally exploitable - closed anyway.
- The Protected data admin page is scoped per organization: a regular admin sees only their own organization's key-holders and forms; the cross-organization view remains for super-admins, matching the other operator pages.
Staff-held keys: the server can no longer read protected data
- Card numbers and the new Protected text field (internal staff comments on the public form) are now encrypted to keys held by STAFF. The server stores public keys only: it can seal a value when a form is submitted with nobody logged in, and is mathematically unable to open one. A stolen database, backup, or disk yields junk.
- Each authorized person generates their own key once (Account -> Your encryption key), locked by a vault passphrase that is never stored, with a recovery code shown exactly once. An admin cannot reset the passphrase - by design - and a passphrase change or recovery never orphans data.
- One Protected data panel on the submission page, one Reveal data click for the card and the comments together. First reveal of the session asks for the passphrase; the unlock lasts 30 minutes of inactivity; revealed values live only on that page - a refresh re-hides them. Every reveal is audit-logged with the kinds opened.
- Access is per form: an Encryption Access checkbox on the invite, a grant/revoke toggle in the Team section, and the owner always has access to their own form. The boundary is cryptographic - revoking deletes the sealed packets, so old records go dark immediately - and someone enrolled later is granted history by any key-holder in one click.
- The customer's contract never carries the comments. A separate "Download with internal comments" button prints them, only for an unlocked key-holder; share links, signature copies and public previews always get the clean version.
- Fail-closed everywhere: a form collecting protected data with zero enrolled readers refuses those submissions, and the builder, the Settings tools card and the repurposed Protected data admin page all warn before a customer hits it. Also fixed: autosaved drafts no longer capture typed card numbers in plaintext, and inserts into tables whose name ends in "values" no longer generate broken SQL.
The Card vault is in Settings, where you look for it
- The Card vault page shipped last release but nothing linked to it. The only ways in were typing the URL or catching the warning banner in the form editor — which disappears once the problem is fixed. So the page an admin needs was, in practice, not there.
- Settings now opens with a Tools section, listed at the top of the page and in the sticky sidebar: Card vault, Database migrations, and the IRW contract form.
- Each row carries its live state. The card vault reads "Encryption keys installed", or in amber "No keys — 2 live forms cannot take a card", so the situation worth shouting about is visible on arrival instead of three clicks away. Migrations read "Up to date" or "3 pending".
- All three pages already required exactly the permission that gets you onto Settings, so nobody gains access they did not have. They are not settings — they do a job rather than store a value — so they sit in their own section and stay out of the settings count.
- Reading the status is cheap in the normal case: the key check is one cached read, and the full scan of every published form (the one that can say how many forms are affected) only runs once that check has already failed.
Security: the card security code is no longer stored
- A CVV/CV2 must never be retained after authorization — not in plain text, not encrypted. Every card taken through the credit-card field was having its code sealed into the vault.
- The violation had been fixed once: migration 066 in v3.4.3 scrubbed stored codes and stopped the separate card fields writing them. But the composite credit-card field's capture path was written afterwards, in v3.5.4, and stored one on every submission for eight releases — with two tests actively requiring it to, which is how it survived.
- That path no longer stores a code. Only the card number, name and expiry are kept, all encrypted.
- Migration 070 scrubs every code already stored, whichever path wrote it, and runs automatically on update.
- The two tests that required storage are inverted, and a new test asserts no code is retained by either capture path — verified by reintroducing the bug and confirming the test catches it.
- Separately: the card field always rendered a security-code box, but the row holding the name, expiry and code had no styling on the public form, so they stacked as three full-width boxes. The row lays out properly now and stacks on a phone.
See why a card was refused, and fix it from the browser
- Card capture fails closed: with no encryption keys on the server a card submission is refused rather than stored in the clear. Correct, but invisible — the customer got the same message a mistyped card number produces, and the real cause went only to the PHP error log.
- New page at Settings → Card vault: whether this server can accept a card, and if not exactly which thing is wrong (no key file, unreadable, or invalid), which live forms are turning cards away, how many cards are stored, and whether the key file permissions are too loose.
- The keys can be created from that page for a server with no shell access, or with the two commands it shows. It refuses to overwrite an existing key file, and if cards are already stored while the key file is missing it stops and says to restore the backup — new keys cannot open cards sealed with the old ones.
- The form editor now warns while you are building: a form that collects a card on a server that cannot store one shows a warning with a link to the fix, instead of letting a customer discover it.
- Fixed: a card submitted through the separate card fields (cardholder name / number / expiration) on a keyless server returned a 500 and left a submission saved with no card attached — the vault write happened after the insert and threw there. The check now runs before anything is written, and a vault failure rolls the submission back rather than keeping a card-less one.
- Fixed: alert-danger was used in five views but only alert-error was defined in the stylesheet, so those alerts — including role-creation errors — rendered as plain boxes instead of red.
Each part of a credit card, placed separately
- A card field printed as one 2x2 block — number, name and expiry together with their own "Card:" / "Holder:" / "Exp:" captions. On a payment section that already prints Card Number:, Name on Card:, Expiration Date: and CV2: on separate lines and in two columns, that lands as a clump between the labels with the captions doubled up.
- New "Card part" control in the placement inspector: whole block, card number only, name on card only, or expiration date only. A single part stamps with no caption, at its own position, width, font size and alignment.
- "Split into 3 placements" turns one block into three in a click — one per part, stacked a line apart and all selected, ready to slide into the payment section and nudge onto their lines.
- Markers name their part ("Credit card · card no.", "· name", "· expiry"), so three placements on one field are not three identical boxes.
- The security code is never stamped: a CV2 slot can be placed and stays empty by design, because no security code is retained.
- Fixed, and the reason none of the card options ever stuck: saving a placement erased them. The save path rebuilds each placement from a fixed list of allowed keys, and cc_card_format, grid_rows, grid_cols, grid_inner_borders and grid_cell_font_sizes were not in it — the inspector set them, the stamper honoured them, and a save dropped them silently.
One masked card, and no CVV on the contract
- A card that prints on a contract was formatted in three different places, differently: a real submission printed "xxxx-xxxx-xxxx-4242", the credit-card block printed bullets, and a third helper (never called) printed bullets too. Every surface that can reach a PDF now uses one place and prints **** **** **** 4242 — or **** ***** 4242 for a 15-digit Amex, following the form's setting.
- The old "xxxx-xxxx" mask read like a placeholder nobody filled in, sitting next to real typed values in the contract's own typeface.
- Fixed: the card block stamped "CVV: ****" whenever a card was captured, which states on a signed contract that a security code is on file. None is retained — PCI-DSS forbids it, no code path writes one, and the column was scrubbed in v3.4.3 — so that cell is blank now.
- Fixed: the live preview echoed a typed card number in full while the print masks to the last four. It masks now, matching the print, and a CVV previews as blank because that is what prints.
- Checked and confirmed unchanged: the full card number never reaches the submission record or the printed page (only the last four, from the vault), and a card submission leaves no CVV anywhere, encrypted or otherwise.
Micro-adjustment for placed labels
- Arrow keys now move a placement by a real distance in points — 1pt by default — instead of 0.5% of the page, which was 3.96pt down but 3.06pt across: too far for lining a value up against printed text, and a different distance on each axis.
- A step picker in the Move group offers 0.25 / 0.5 / 1 / 2 / 5 / 10 pt, drives both the arrow keys and the Move buttons, and is remembered between sessions. Shift+arrow is 10x for covering ground; Alt+arrow is a quarter step for the last hair.
- The Move buttons appear as soon as anything is selected, not only with two or more.
- Position and size are shown in points now, not a percentage of the page — the unit the contract is measured in, so a position read off the PDF can be typed straight in. Position & size is also the first section in the panel instead of below the font controls.
- A bulk selection gets a "Move all" box: with 24 placements selected there is no single x/y to type, but "everything down 1.5pt" is exactly the adjustment being made — and it applies in one undoable step.
- Font size showed the raw stored fraction ("0.009") under a label calling it a percentage. It now shows the point size that will actually print, and steps by whole points. Letter spacing is in points of extra space per character.
- Fixed: with a bulk selection the delete button said "Delete this placement" while 24 were selected and removed one without saying which. It now names it — Delete "Owner Name" (1 of 24 selected) — and says the rest stay put.
The live preview now matches the printed PDF
- Both live previews — PDF Label placement and the side-by-side fill view — now use the stamping code's own layout maths and the PDF font's own character widths, instead of a second copy of the maths that had drifted from it.
- The preview used to size text from the on-screen canvas in pixels with an 8-pixel floor, so it grew as you zoomed out: a 7pt stamp previewed near 10.5pt at 50% zoom. Font size now comes from the page in whole points and does not move with zoom.
- Shrink-to-fit, truncation, centring and baselines all follow the stamper exactly, and the preview shows characters the contract cannot print the way they will actually come out. Measured against a stamped contract, the preview was running up to 18 pixels long on a value at 50% zoom; it now agrees to within a pixel at any zoom.
- Fixed: the advertising fee on page 2 printed about 2.3 inches right of the "$" it belongs after, at 7pt beside 11pt print. Its position is now measured off the contract — one space after the "$", same baseline, same size — and existing forms are corrected on update unless the placement has been moved by hand.
- Fixed: a value containing line breaks printed as one run of text; multi-line values now stamp one line per row.
- Fixed: masked card numbers printed as "???? ???? ???? 1234" because the PDF fonts have no bullet character. They print as asterisks now.
- Fixed: a credit-card placement made the live preview throw before drawing anything, so that page previewed blank.
Duplicating a form now brings the contract with it
- Duplicating a form copies its PDF templates, the contract file itself, and every field placement — so "build the contract form once, then duplicate it per office" works without redoing the placement work.
- The copy also gets its own logo file, instead of a logo URL pointing back at the original form (which broke as soon as the original was archived, deleted, or in another organization).
- The duplicate confirmation now says what came across — "Copied 1 PDF template with 24 field placements and the logo" — and names anything it could not copy.
- Fixed: that confirmation was never visible. The duplicate button loaded the new form twice, and the first invisible load consumed the one-time message.
- Fixed: the sticky page-jump chips in the PDF placement editor were deleted while the canvas loaded, so they never appeared on a multi-page contract. The two console errors that came from the same cause are gone too.
Preview PDF shows the contract instead of saving it
- Fixed: "Preview PDF" on a submission behaved exactly like "Download filled PDF" — it saved the file instead of showing it. The button pointed at the download endpoint, which sends Content-Disposition: attachment, so the browser had no choice.
- There is now a separate preview endpoint that serves the same bytes with Content-Disposition: inline, and both Preview buttons (on the submission page and the edit page) point at it. Download is untouched and still saves the file.
- The two entry points share one method, differing only in that header, so preview and download cannot drift apart on permissions, stamping or error handling.
Dropdowns stay dropdowns when you open a submission; centred section dividers
- Fixed: opening a submission turned every dropdown into a free-text box. Reviewing and correcting a submission is the normal workflow, so a field that is meant to be one of a fixed set could be typed into freely. Choice fields now render as a real dropdown built from the form's own option list, and picking a value saves it the same way typing did.
- Date, number, email and phone fields get their matching input types on that page too, instead of all being plain text. A stored value that is not in the option list is preserved as its own option rather than being silently rewritten.
- Section dividers are now centred with a rule running out to both sides — on the public form the rep fills in, in the builder preview, and on the submission page the admin reviews, so a section reads the same way everywhere.
- Multi-select checkbox fields still render as text on the submission page: their values are stored comma-joined, and swapping in real checkboxes risks rewriting that on save. That one is still to do.
Editor visual polish — premium markers, gradient buttons, smooth toasts
- Premium placement markers. The default marker now has a soft drop shadow + subtle inner highlight so it floats above the page. On hover, the marker lifts (-1px translateY) and brightens the shadow. On selection, the marker gets a 3px ring + gradient fill + a 220ms pop animation that feels premium.
- Toolbar buttons get richer treatment. The Save button now has a brand-color gradient fill + soft glow when enabled, deepens on hover. The Live preview toggle, when active, gets a vivid sky-to-indigo gradient. All buttons get a 1px lift on hover and a press-down on click.
- Page indicator fade animation. The "Page N of M" indicator on each PDF page fades in with a 4px translateY as the page enters view, giving a subtle scroll-into-view feel.
- Premium toast notifications. The bottom-right toast gets a 240ms slide-in (translateY + scale) animation, gradient backgrounds by type (green for success, red for error), and a slide-out animation when dismissed. Max-width 360px with proper line-height.
- 2x2 grid preview in inspector. A small live mockup of the CC field layout appears in the placement inspector when the placement's field key matches a CC field type. Shows the grid layout in real time as the user changes placement.grid_rows / grid_cols. Placeholder cells show what values will land where.
- Field picker item hover polish. Hovering a field in the left picker now nudges the item 2px to the right with a soft background, making the hover state feel responsive without being noisy.
- Placement list row polish. Hover state on a placement list row softens the background and tightens the border. Selected rows get a brand-tint gradient and a 3px brand-color left rail so the active row is unmistakable.
- Auto-busy button state. The toolbar buttons (Auto-detect labels, Save, etc.) show a spinning loader automatically when they have the .is-busy class. The native spinner is hidden via the .is-busy class on the button.
Create the IRW contract form from the admin area
- New page at /admin/irw-form. It says whether the IRW intake form exists, which organization it is in, who owns it, whether it is published and how many placements it has — reading across every organization, not just the one you are viewing — and gives you a button to create it. No shell access needed.
- It exists because a form can be present and still invisible: the forms list only ever shows the organization you are currently in, and a user without form.edit.any sees only forms they own. Both look exactly like "the form was never created". The page names which case it is, and can put a copy in the organization you are actually looking at.
- The automatic seed now picks its organization better too: it prefers the one that already holds IRW forms (where the work has been happening) over the first organization the admin happens to belong to, and it says which rule it used.
- Clicking Create when the form already exists does nothing, so the button is safe to press twice.
A diagnostic for the IRW form, and control over where it is seeded
- New: php bin/irw_doctor.php reports, in one pass, everything that decides whether the IRW form exists and whether you can see it — the deployed version, whether the seeder code is present, what the migration bookkeeping says, every tenant, any matching form in ANY tenant with its owner/workspace/published state, and where a seed would land. Report-only by default; --seed creates the form.
- It exists because a form can be present and still invisible: the forms list is always tenant-scoped, so a form in one tenant cannot be seen from another, and a user without form.edit.any only sees forms they own. Those two cases look identical to "the form was never created", and the doctor tells them apart.
- The seeder now takes --tenant and --owner overrides, so a form that landed in the wrong tenant or under the wrong owner can be placed where you actually look, without SQL.
- The doctor fails with one clear line if the database is unreachable, rather than printing the styled HTML error page at a terminal.
A seed that failed once can now heal itself on the next update
- Fixed the reason the IRW form still did not appear after v3.5.29. A PHP migration is recorded as applied as soon as it returns, whether or not the work inside it succeeded — both the in-app updater and the shell updater mark it right after including the file. Migration 069 ran under v3.5.26, failed to write (super-admin mode), and was marked applied anyway. Every later update then skipped it, so the v3.5.29 fix never got a chance to run.
- The seed now runs as a post-migration step on both update paths instead of relying on a one-shot migration marker, so each update retries it until it succeeds. It is cheap when the form already exists (one indexed lookup) and never fatal — a failure is recorded and the update continues.
- The in-app updater records what the seed did in its audit entry, so the outcome is visible in /admin/audit rather than only in shell output nobody sees during a click-to-update.
The IRW form seed now works when you update from the admin UI
- Fixed: updating from the admin subdomain runs in super-admin mode, where writes to tenant-owned tables are refused by design ("Refusing to insert into forms in super-admin mode"). The v3.5.26 IRW form seed hit exactly that and reported "could not create form" instead of seeding.
- The seeder now binds the target tenant for the duration of its work and puts the previous binding back afterwards — super-admin mode included — so the rest of the update request is unaffected. It also reports which tenant and user it seeded into, so a form landing in an unexpected tenant is visible in the update log rather than a mystery.
- The rebind is a new TenantContext::withTenant() helper. Neither existing setter could do this: setBySubdomain() returns early once the context is locked (which it always is inside a request), and setById() can rebind but has no way back to super-admin mode, so doing it by hand left the rest of the request wrongly tenant-scoped. The new helper restores on the way out even if the work throws.
A real footer, and two fixes on the settings page
- The in-app footer was a single thin strip with three links. It is now a proper four-column footer — Product, Account, Help and About — carrying the things people go to the bottom of a page for: where to go next, how to reach support, what changed, and the legal pages. Every product link repeats the topbar's permission check, so the footer never offers a page you would get a 403 from, and the columns stack cleanly on a phone.
- Fixed on /admin/settings: an empty green alert bar rendered on every visit. The controller passes a null flash when there is nothing to report, and the view tested it against an empty string — null is not an empty string, so the bar always drew.
- Fixed on /admin/settings: every setting row showed a bare empty circle where its info affordance should be. The view asked for an "info" icon that was never added to the icon set, and the helper returns nothing for an unknown name. The icon now exists, and the Identity section has a real glyph instead of a placeholder outline.
- Sign out in the footer posts a CSRF-checked form rather than linking to /logout, which is POST-only — a GET logout would be forgeable, and the link 404d.
Data migrations now apply when you update from the admin UI
- Fixed: PHP data/seed migrations were only ever run by the shell updater. The installer and the in-app updater discovered .sql files only, so anyone updating from the admin UI silently never got them — including the v3.5.26 IRW form seed and the v3.5.21 payment help_text rewrite. They were not marked as applied either, so a later shell update would still pick them up; they simply never ran in the meantime.
- A PHP migration now opts in by declaring @web-safe-migration in its header, which is a promise that it contains no exit()/die(), guards every prerequisite, and is idempotent. The v3.5.26 IRW form seed declares it, so updating from the admin UI now creates the form. The older PHP seeds deliberately stay excluded: several call exit(), which would kill the update request half-way through.
The IRW contract intake form, ready to use out of the update
- New form: "IRW Marketing Contract (Final)", created and published by the update. It mirrors the IRW intake form reps have been filling on JotForm field-for-field — the same 24 fields in the same order, the same labels, and the same dropdown option lists for Rep Name, Rep License Number, Bedrooms, Bathrooms and Usage.
- Every field on it is already placed on the IRW contract, so a submission downloads as a filled contract with no placement work. The contract PDF is attached automatically when a copy is already on the server; otherwise the form still arrives published and one command finishes it (see bin/seed_irw_final_form.php). Existing forms are not touched — this adds one form.
- Its dropdowns open on an empty "Please Select" instead of their first real option, so an untouched dropdown submits nothing rather than quietly putting the first rep's name on a contract.
- Fixed: two section headers in a row rendered side by side on one line instead of stacking (and a header after a half-width field slid up beside it). A section header is a divider, so it now always takes its own row, on every form.
CC field grid options: variable rows/cols, per-cell fonts, form-level format, auto-fit warning
- Variable row/col counts via placement.grid_rows / placement.grid_cols. Default 2x2 for CC fields (backward compatible). For general composite fields (e.g. address: 4 rows x 1 col, or 2 rows x 2 cols), the operator sets the layout per-placement. Both the server-side stamper (PhpStamperService.php) and the front-end live preview canvas (pdf-mode.js) honor the placement config.
- Per-cell font size overrides via placement.grid_cell_font_sizes. Format: 2D array of fractional sizes (e.g. 0.012 = 1.2% of page height). Cells without an override inherit the placement's font_size. This lets the operator set a smaller font for the Card cell (\u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 1234 is wide) and a larger font for the Holder cell (\u201cJohn Doe\u201d is short) -- each cell scales to its own width and uses the operator's override if set.
- Form-level card format fallback. placement.cc_card_format > form.settings.cc_card_format > default '16'. Operators can set the format once on the form (e.g. \u201call CC fields in this form are 15-digit Amex\u201d) and every placement on the form inherits it. The form setting is emitted into __PDF_MODE_CONFIG__.formSettings for the live preview canvas; the stamper reads it directly from the forms table via TenantDb::fetch.
- Auto-fit detection. If a cell's text overflows the cell width even at the 8px floor, it's truncated with \u2026. The canvas adds .pdf-grid-cell-truncated to the page wrap and a data-truncated-cells attribute (CSS gives a thin dashed red ring). The stamper logs Audit::log('pdf_stamp.truncated', ...) with the field name + truncated-cell count. Operator sees the warning and enlarges the placement to fit.
- Both the server-side stamper and the front-end canvas implement the same logic. The live preview and the actual stamped PDF render the same grid layout.
CC field grid polish: per-cell font sizing, configurable card format, optional inner-borders frame
- Per-cell font sizing on the 2x2 grid. The previous v3.5.22 shared a single font size across all 4 cells (sized to fit the widest). This made the Card cell (with "**** **** **** 1234") shrink the Holder cell unnecessarily. Each cell now scales its own font to fit its own width, so "Card: **** **** **** 1234" can be smaller than "Holder: John Doe" when both need to fit comfortably.
- Configurable card format via placement config. placement.cc_card_format = '16' (default) stamps "**** **** **** 1234" (Visa/MC/Discover style). placement.cc_card_format = '15' stamps "**** ***** 1234" (Amex 15-digit format). The vault stores only last4 so the PAN length is assumed; the operator picks the format string per-placement. Both fpdf stamper and the live preview canvas respect this config.
- Optional grid frame. placement.grid_inner_borders = true draws a hairline border around the 2x2 grid AND inner dividers between cells, so the 2x2 reads as a printable card-detail block on the contract. FPDF \$pdf->Rect + \$pdf->Line for the stamper; ctx.strokeRect + ctx.strokePath for the canvas. The frame color defaults to the placement's border_color, falling back to #d0d4dc (muted line) if not set.
- Optional per-cell backgrounds via placement.grid_cell_backgrounds (2D array of color strings). Cells with a background color get a per-cell fill (e.g. a soft tint behind the value to make the grid look like a "card details" card).
- Auto-fit detection: per-cell font size shrinks from the placement's base size down to the 8px floor to fit the cell width. Cells that still overflow at 8px get truncated with ellipsis (…).
- Both the server-side stamper (app/Builder/PdfStamperService.php) and the front-end live preview canvas (public/assets/builder/pdf-mode.js) implement the same v3.5.23 logic. The live preview and the actual stamped PDF render the same grid layout.
CC field stamps as a 2x2 grid with full PAN masking (**** **** **** 1234)
- The credit-card field now stamps as a 2x2 grid on the contract PDF: top-left Card, top-right Holder, bottom-left Expiry, bottom-right CVV. Previously the four pieces were stacked vertically on separate rows; the 2x2 grid matches the IRW contract layout (Card Number / Name on Card on the top row, Expiration Date / CV2 on the bottom row).
- Card number is now formatted with all-but-last-4 masking: \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 1234 (16-digit default; the vault stores only last4 so the PAN length is assumed to be 16). The previous format was just \u2022\u2022\u2022\u2022 1234 which lost the printed-card feel.
- CVV is still rendered as \u2022\u2022\u2022\u2022 (PCI-DSS Req 3.2: never stamp the actual CVV digits). The bullet marker confirms a CVV was on file without exposing the value.
- Both the server-side stamper (PdfStamperService.php) and the front-end live preview canvas (pdf-mode.js) use the same 2x2 grid logic. _resolvePreviewValue returns a 2D array for cc fields; _drawPreviewGrid renders each cell separately with its own position. The font size is sized to fit the WIDEST line across the entire grid so all cells share the same font size and the grid looks uniform. Per-cell text-decoration (underline / line-through) is honored per cell.
- If only some of the 4 pieces are present (e.g. user only typed card number but not holder), the corresponding cells are blank. The grid is still 2x2; only the populated cells draw text.
Migration: rewrites stale "Card data is tokenized via Stripe..." help_text on existing payment fields
- The v3.5.20 default-helper-text fix (commit 20db8b8) only affects payment fields created AFTER that release. Existing payment fields on every install have their own stored help_text in form_versions.schema_json, and that stored value wins over the source-code default. So operators still saw the misleading "Card data is tokenized via Stripe; the server never sees your card." text on the live form.
- This migration (db/migrations/068_payment_field_stripe_help_text.php) walks every form_versions row, finds payment-type fields whose help_text starts with "Card data is tokenized via Stripe", and rewrites it to the honest text: "This field renders a 'Pay now' button. No payment processing is wired up yet -- operator must set help_text and wire the button to a payment processor."
- Idempotent: re-running the migration on already-updated rows is a no-op (the WHERE clause excludes rows that already have the new text).
- Operator runs `php bin/migrate.php` (or in-app update, which runs the same migration runner) to apply. After this, the misleading Stripe text is gone from the live form for ALL existing payment fields.
Stripped the false 'Stripe' claim from PaymentField + version bump for the in-app updater
- PaymentField default help_text no longer claims card data is tokenized via Stripe. The actual code has no Stripe SDK, no real payment processor, and no JS handler for the data-payment-button click -- the original "tokenized via Stripe" text was misleading. The default is now honest: "This field renders a 'Pay now' button. No payment processing is wired up yet -- operator must set help_text and wire the button to a payment processor.".
- PaymentField class docblock + the render() docblock + the inline "JS after Stripe.js confirms" comment were rewritten with a v3.5.19+ NOTE explaining the missing integration. The operator can wire a real payment processor in via public/assets/ in a future commit -- until then, the button is UI scaffolding only.
- VERSION bumped 3.5.18 -> 3.5.20 (was 3.5.19 prior to revert e2dd2db). The intermediate 3.5.19 had added form-side CC styling that the user reported as never asked for; that commit was reverted in e2dd2db and the same v3.5.20 release picks up the cleaned PaymentField text. v3.5.18 's multi-line CC-field stamp on the live preview canvas + the server-side stamper remains unchanged.
- This version is a marker bump so the /admin/updates page on live installs sees "v3.5.20 available" and the in-app updater fires. The source code under v3.5.20 is functionally equivalent to v3.5.18 + the PaymentField docblock/help_text fix.
CC field now stamps all 4 inputs on the contract PDF (number, holder, expiry, CVV-marker)
- CC field placements on the PDF editor's live preview canvas now render all 4 inputs as separate lines: Card: •••• 4242, Holder: John Doe, Exp: 12/30, CVV: ••••. Previously only the masked card number was visible.
- Server-side stamper (PdfStamperService::stampWithRaster) detects the post-vault CC-field shape {vault_id, last4, holder, expiry, cvv_on_file} and stamps each line on its own row of the placement box. The vault_id is internal and is omitted; the CVV is rendered as a bullet (PCI-DSS Req 3.2 forbids any post-auth CVV representation).
- Canvas preview supports multi-line draw via ctx.fillText over a vertical "blockTop" -- the block of lines is centered / top-aligned / bottom-aligned as a unit, with per-line fontPx shrink + ellipsis truncation matching the existing single-line behavior.
- 52 tests pass, 0 regressions. pdf_live_preview_test is pre-existing-stale (38/41 fail without any changes -- the v3.5.12 rewrite renamed pdf-live-preview to pdf-live-form, the test was never updated). Update pdf_live_preview_test to track the new class name in a follow-up.
Removing a placement can remove the field from the form
- The form exists to fill the PDF, so a field whose last placement you delete has nothing left to fill. On Save, the PDF editor now lists any such fields and offers to remove them from the form in one step — closing the loop that previously only worked in the other direction (deleting a field already removed its placements).
- It asks before removing, because a removed field stops being collected and values already submitted for it stop showing on existing submissions. Cancel keeps them. Only fields you unplaced during that editing session are ever offered — fields that were already unplaced are left alone — and undoing the delete, or re-placing the field, before saving quietly withdraws it. Fields still placed on another PDF template of the same form are refused server-side.
- Fixed: deleting a placement left the selection pointing at the wrong markers, because the remaining selected indexes were not shifted down after the delete. A bulk style edit straight after a delete could change neighbouring placements instead of the highlighted ones.
- Fixed: three JavaScript errors in the delete/deselect path — the inspector evaluated its position and size fields against no placement whenever the selection was cleared, the placement-list rows evaluated against a just-deleted row, and arming a field in the picker left the inspector open on a stale selection.
PDF editor opens fitted, with the placement list collapsed
- The PDF placement editor now opens in the roomiest sensible view: the right placement list starts collapsed and the page is fitted to the pane width. You land on a large, fully-visible page ready to place on, instead of a small one you had to widen and fit by hand on every visit.
- Nothing is taken away — "Show list" brings the list back, and the zoom controls (or "Fit") still own the page size. The fit runs once on open, so any zoom you set afterwards stays put.
- On narrow screens the placement list stays visible: the layout is a single stack there, so collapsing a column would hide the placements without giving the PDF any extra width.
PDF editor: select-all + bulk move, rename placed-field labels, drag-resize the live preview
- Select all placed fields and move them as one. A new "Select all" button (or Ctrl/Cmd+A) selects every placement across all pages, then a "Move" group (◀ ▶ ▲ ▼) in the alignment row shifts the whole selection 1% at a time. The arrow keys now nudge the entire selection too — 0.5%, or 2% with Shift — instead of only the last-clicked marker. Each move is one undo step.
- Rename a placed field's label right from the inspector. Selecting a placement shows an editable "Field label" box; renaming updates the name everywhere it appears (marker, picker, placement list, and the public form) and saves to the draft. The internal field key stays fixed, so no submitted data is affected and every placement keeps resolving.
- The live-preview panel is now resizable — in Live preview, drag the handle on its right edge to make it wider or narrower relative to the PDF, and the chosen width is remembered next time.
PDF editor: zoom, hide the list, go wide; form builder: up/down field reorder
- PDF placement editor gains real viewport controls. Zoom (−/percentage/+, click the percentage to reset) plus a "Fit" that fits the page width to the pane; the page scales with its markers, grid and live-preview all staying aligned, so placement is easier at any size.
- "Hide list" collapses the right placement-list column to give that width back to the PDF, and "Wide" breaks the editor out to the full window width — combine them (and zoom) for a much larger canvas. A zoomed or wide page stays scrollable to its left edge instead of being centre-clipped.
- Form builder: every field in the list now has ↑/↓ buttons to nudge it one position at a time — a click/keyboard alternative to dragging the handle, disabled at the ends. It edits the same field order everywhere (public form, live preview, placements) and autosaves.
PDF editor live-preview panel: polished into a proper card
- The live-preview panel is now designed to match the editor's other side panels instead of floating bare on the page: a card with a "LIVE PREVIEW" header (eye icon + a Clear button), a brand accent bar so it reads as the preview surface, a sticky header, and compact, comfortable inputs in place of the public form's full page size.
- Single-page forms no longer show a stray "Page 1" label; multi-page forms show it as a quiet section divider. Composite fields (credit card, address) lay out tidily in the narrow column, and the panel adapts cleanly on small screens.
- Purely visual — the fill-the-form-and-watch-the-contract behavior from v3.5.12 is unchanged (re-verified 18/18).
PDF editor live preview: fill the whole form, watch the whole contract fill in
- The placement editor's live preview is now the full "both side by side" experience. The left panel is a fillable copy of the PUBLIC form — every field, including credit-card and address, rendered as the exact inputs a visitor sees (via Registry::renderAll) — and the whole contract updates live as you type. Each value is stamped where its placement sits, at that placement's font / size / alignment, matching the final PDF 1:1 (it reuses the real stamper's coordinate + baseline math).
- Works in normal AND fullscreen mode, with a "Live preview" toolbar toggle and a "Clear values" reset. Edit markers and the grid hide while previewing so the page reads like the finished contract; toggling off restores the editor untouched.
- This replaces the earlier live preview (v3.5.7-v3.5.11), which was a fullscreen-only box that edited one placement's value at a time. The change is confined to the three editor files; the real stamping path, persistence, and the v3.5.6 credit-card input validation are untouched.
- Also fixed a pre-existing "formId is not defined" console error that fired on every load of the placement editor.
Live preview input is now visually obvious (UX bug fix: v3.5.7 made it look like static text)
- UX bug fix: v3.5.7-style borderless input was INVISIBLE — the operator could not tell that the placeholder/value text inside the Live preview paper was an editable form field. The input rendered with border:none, outline:none, background:transparent and only showed a focus ring when actively clicked.
- v3.5.11 adds a subtle dotted underline (border-bottom: 1.5px dotted var(--c-line-3)) so the field reads as a form input, not static text. The placement's own color flows through inline via :style so the underline does not compete with the placement's style.
- Hover state solidifies the underline (border-bottom-style: solid) and colors it with the brand blue (var(--c-brand, #0066cc)). Cursor is text (I-beam) so the operator sees the I-beam when their mouse enters the field. Transition is a quick 80ms ease so the visual feedback feels alive.
- The placement's font-size, font-family, font-weight, color, padding, and background-color are STILL inherited from the placement's :style binding (livePreviewStyle). The new dotted underline is the ONLY visible addition -- the input still renders as the placement's exact final-PDF stamp.
- pdf_live_preview_test grew from 39 to 41 checks, pinning the v3.5.11 fix: input has a visible affordance (the dotted underline), cursor is text, hover rule solidifies the underline.
Re-publish v3.5.9 source tree (deploy-fresh trigger)
- VERSION bumped from 3.5.9 to 3.5.10 and DOCS_REVIEWED_VERSION bumped to 3.5.10. No code changes.
- This release exists so the operator-side Updates page (which compares the running VERSION against the latest GitHub release tag) sees a fresh release. Run the in-app Update flow to pull v3.5.10 and re-issue the v3.5.9 PDF editor asset files (pdf-mode.js, pdf-mode.css) onto the live install, replacing the stale v3.5.7/v3.5.8 copies currently being served.
- After Update runs: hard refresh the browser so the new ?v= cache-buster loads the fresh asset files. If the asset files still appear stale, purge the Cloudflare edge cache for /assets/builder/pdf-mode.{js,css}.
Live preview is now genuinely live -- auto-focus + pulse + 30ms typing
- Live preview on the LEFT of fullscreen now genuinely reacts to placement events. When the operator places a marker (draw-to-place, click-to-place, stamp-to-all-pages, stamp-selected-to-other-pages, or stamp-field-to-all-pages), the preview input auto-focuses so the operator can immediately type the placement's stamped value without clicking.
- The preview panel pulses briefly (400ms scale 1.025x with deepened shadow) when a new placement lands, so the operator has unmistakable visual feedback that the preview just updated. CSS-only animation -- no JS-driven re-paints.
- Typing in the Live preview input is now 30ms-debounced (was 150ms). The marker on the PDF updates within one frame of the operator pausing their typing, so the "live preview" reads as live rather than "delayed preview".
- New focusLivePreview() method on pdfMode() sets selection to the END of the value (not start) so the operator can keep typing without first clicking at the end of the input. This matches the form-input UX the operator already knows from the rest of the system.
- New pulseLivePreview() method toggles an is-just-updated class on the panel for 400ms, force-reflowing before re-adding the class so back-to-back placement additions all animate (no animation collision).
- Single-source-of-truth helper _onPlacementAdded() fires both focus + pulse via Alpine $nextTick so reactivity applies the new value to the input's :value binding BEFORE focus lands. Without $nextTick, focus would land on a stale input with the old value. Wired into all 5 placement-creation paths so the UX is consistent across draw-to-place, completeDraw, stampToAllPages, stampSelectedToOtherPages, and stampFieldToAllPages.
- pdf_live_preview_test grew from 30 to 39 checks, now also pinning: focusLivePreview() / pulseLivePreview() / _onPlacementAdded() exist on pdfMode(); focus sets selection to end-of-value (not start); pulse toggles is-just-updated; _onPlacementAdded defers via $nextTick; exactly 5 calls to _onPlacementAdded() at the 5 placement-creation entry points; @keyframes pdf-live-preview-pulse is defined; .is-just-updated selector applies the pulse animation in fullscreen.
Live preview is now an editable form: type to fill the placement, panel always visible
- PDF editor fullscreen Live preview is now an editable FORM, not just a read-only display. The operator can type the placement's stamped value directly in the left-side panel and the marker on the PDF updates live within the same Alpine tick. The panel is always present in fullscreen -- no need to click a marker first.
- The preview auto-populates as you place new markers. The new `latestPlacement` getter returns the most-recently-added placement (placements[length-1]) so the form re-fills with each new marker. No more selecting a marker first; the panel tracks the latest edit target automatically.
- The stamp is now an <input> (not a static <div>) so the operator can type freely. The input inherits font-size, font-family, font-weight, font-style, text-decoration, color, background-color, and padding from the placement's :style binding, so what you see in the preview is exactly what the stamper will draw on the final PDF at the same position.
- Debounced @input handler (150ms) batches typing so a fast typist triggers a single renderAllMarkerPages() per pause, not per keystroke. Esc blurs the input. The input's on-screen appearance matches the placement's actual font/color/padding so the operator can read what they're typing in the rendered style.
- When the form has no placements yet, the preview shows an empty-state hint: "Place a marker on the PDF -- this panel fills in as you go." Once a marker is placed, the panel populates with the value, font, color, x, y, w, h, size, family, and weight -- all editable in the future for finer control (only the value field is editable today; the meta rows remain read-only and are edited via the right-side inspector).
- New updateLivePreviewValue() method on pdfMode() persists the input value into the placement. One undoable step (pushUndo) per typing pause. Marker render re-reads placement.value on the next tick so the PDF shows the new text immediately.
- pdf_live_preview_test grew from 23 to 30 checks, now pinning: the always-visible aside (no x-show gate), the latestPlacement() helper, the editable <input> shape, the @input.debounce.150ms handler, the updateLivePreviewValue() method that mutates p.value, and the empty-state hint gated on !livePreviewHeader().
PDF editor: live preview on left side of fullscreen + top-of-page no longer cut off
- PDF editor fullscreen placement mode no longer clips the top of the page. The fixed toolbar (top:0, ~56px tall) used to overlap placements near y=0%. The .pdf-page-wrap now gets padding-top:80px specifically when body.pdf-fullscreen is active, so a marker at the very top of the page is fully visible. Normal-mode PDF editing is unchanged (the 3-col grid has no top clipping because the toolbar is not fixed there).
- New Live preview panel on the LEFT side of the fullscreen view. Hidden in normal mode (the field-picker is already on the left there). When a placement is selected, the panel renders a paper-style stage with the stamp text in the actual font, size, color, padding, weight, and style the stamper will draw. Updates live as the operator types in the right-side inspector -- x, y, w, h, font_size, font_family, font_weight, color, padding, value all re-render the preview within the same Alpine tick.
- The Live preview value source follows the same precedence the actual PDF stamper uses: explicit value override on the placement, then sample value from the field schema, then field label, then field key. So the preview shows exactly what the stamper will draw at the same position on the final PDF.
- Style + value + header helpers (livePreviewStyle / livePreviewValue / livePreviewHeader) live on the pdfMode() Alpine component so the template stays declarative. The earlier inline IIFEs (font-size * 1100, font-family stack object literal, value-source ternary) are now a single set of named methods, with a regression test pinning each one.
- New pdf_live_preview_test (23 checks) pins: the live-preview aside exists and is hidden in normal mode; the fullscreen CSS positions it fixed at top:64, left:var(--s-4), width:320px; the bug fix (padding-top:80px on .pdf-page-wrap) is in; the helper methods exist on pdfMode() and derive font_size from the same 1100 multiplier the marker render uses; the meta rows display x, y, w, h in % and font size in px.
CC field input validation: maxlength + pattern + auto-format
- Credit-card field now enforces maxlength on the input as the user types: card number capped at 19 digits, expiry at 5 (MM/YY), CVV at 4 (3-4 digits), holder at 100. The browser blocks over-length values BEFORE the form submits, so the server-side validator never sees a malformed card.
- New HTML5 pattern attributes gate each input: card number must be 13-19 digits, expiry must match MM/YY with month 01-12, CVV must be 3 or 4 digits. Browser form-validation surfaces these to the visitor; the existing server-side validator (PAN length, Luhn, MM/YY regex, expiry-not-in-past) enforces the same rules again with stronger checks.
- Mobile keyboards now land on the right layout: number, expiry and CVV get inputmode="numeric" with the correct autocomplete hint (cc-number, cc-exp, cc-csc) so iOS/Android/Wallet can autofill from a saved card. Cardholder name uses autocomplete="cc-name". Each input has a non-empty aria-label for screen readers.
- New cc-field.js (loaded on every public form via defer) auto-formats the card number as the visitor types — digits only, capped at 19, re-grouped into 4-4-4-4 chunks for readability. Expiry auto-inserts the slash after MM (positions 1-2). CVV strips non-digits and caps at 4. The browser-side formatting is display-only; the value submitted to the server is always digits-only and matches the validator rules.
- New cc_field_maxlength_test pins every render-time attribute (maxlength, pattern, inputmode, autocomplete, aria-label, data-cc-input hook) and the companion JS file's presence. 26 checks; runs in the existing test harness with no new dependencies.
Security: the 3.5.4 CC field could store card data in plaintext — now fails closed
- SECURITY -- the credit-card field added in 3.5.4 failed OPEN. When card data could not be securely captured (most importantly on a host missing card-vault-keys.bin), the pipeline swallowed the failure and wrote the raw card number and CVV in plaintext into the submission record, while reporting success to the submitter. It now fails CLOSED in three independent layers: capture throws instead of falling back to raw data; the submission is rejected outright (nothing stored, a safe message shown, the event audited with no card data in it); and a final guard right before the database write refuses to store anything still shaped like a raw card, so no future change upstream can reintroduce the leak. A blank optional card field still submits normally. Reproduced end-to-end before the fix and confirmed gone after, with a new regression test pinning the behavior. Anyone running the 3.5.4 CC field should update.
- Card values now render masked (XXXX XXXX XXXX 1234) in the submissions inbox, matching the detail panel, share page and PDF.
- Operator documentation pages (/admin, /admin/maintenance) center their content column on wide monitors instead of hugging the left edge with a dead right half.
CC field type: card data encrypted at rest, masked in the readout, reveal-audited, 14-day TTL
- New CC field type -- the form-builder gains a generic "Credit card" field. The public form renders three plain inputs (number, holder, expiry, CVV); the values land in the existing card_vault table (envelope-encrypted under the three independent KEKs already in place) with source="cc_field". submissions.data stores ONLY {vault_id, last4, holder, expiry} -- never PAN, CVV, or full expiry. The fill-page readout, the share page, the submission detail panel, and the PDF stamper all render the masked ".... .... .... 1234" shape; the existing reveal endpoint (with its 30-second cooldown and audit-logged reveals) handles staff-side access, since anyone with view access to the submission can also reveal its card data by operator policy.
- CC field audit trail -- every capture writes a cc_field.captured event and every reveal writes a cc_field.revealed event. Both events carry vault_id, field_key, last4, source, by_user_id, ip, and expires_at -- never PAN, CVV, holder name, or full expiry. The audit log is the only accountability control for the reveal endpoint (since reveal is gated on view access, not on a separate capability), so the log shape is the part of the contract that matters.
- No third-party service -- the CC field path does not touch the PaymentProcessorMock or any processor tokenization. The user policy is "no third-party service" and the CC field is operator-stored only. The legacy PaymentField path is untouched and continues to use the processor (mock by default); migration 066 had scrubbed the CVV columns on the legacy path, but the new CC field writes CVV (with explicit user approval per the storage decision) -- the ciphertext is the same shape under the same three KEKs.
- Migration 067 -- card_vault.source ENUM column distinguishes CC-field rows from legacy PaymentField rows. The DEFAULT is "payment_field" so all existing rows are tagged correctly; new CC-field rows are explicitly "cc_field". A "manual" value is reserved for future staff-entered rows. The migration is idempotent on a re-run.
- Two new tests -- tests/cc_field_test.php (40+ assertions covering registry, render, capture, encrypted storage, reveal, cooldown, audit metadata, submissions.data never contains PAN/CVV) and tests/cc_field_audit_no_pii_test.php (paranoid source-grep that asserts no plaintext PAN/CVV/holder can land in any of the audit log, error log, var_export, or SQL params on the CC path).
Mobile: the app works properly on a phone
- Top bar on phones -- the menu lived in a ~140px sliver between the brand and the account cluster: items clipped mid-letter, the strip scrolled with a hidden scrollbar and no hint, and the Super admin link was hidden entirely, leaving an operator no path into the console from a phone. The bar now wraps into rows (brand + account on top, the menu as a full-width swipe strip with a right-edge fade as the cue), the Super admin link is back, and the bar scrolls away instead of pinning a fifth of the screen.
- Forms list on phones -- the table collapses to cards, but the cards dropped their column headers, leaving bare values with no context ("v2", "published", a lone "1"). Every value now carries a small label.
- Tables that scroll sideways on phones (users, audit log, tenants) looked cut off; they and the operator console's own menu strip now show a right-edge fade so it is clear there is more to swipe to.
- Public "form not found" pages -- nine bare-text 404s on public form routes (the links form-fillers actually open on phones) now render the branded 404 page.
- Verified, not assumed: 26 pages x 3 widths (320/375/1440) swept for content cut off, unreachable, or outside screen bounds; every flag was individually scrolled-to and measured -- all content reachable, no text truncated anywhere, popovers fit the viewport.
PDF values land on the printed line at the printed size; button hover fix; readable operator docs
- PDF stamping -- values stamped onto a contract rendered visibly BELOW the line they were placed on, and smaller than the contract's own print. Three renderers disagreed: auto-detect boxes hung all their breathing room below the printed line, the stamper and the fill-page preview anchored the default vertical alignment to the box BOTTOM (an old fudge for exactly those boxes), and the placement editor centered its markers -- while the detector's measured print size was thrown away entirely, so every value fell back to a smaller default font. All surfaces now share one geometry: boxes centered on the printed line, true-middle baseline math in the stamper and preview, editor markers that reflect the placement's real alignment, and auto-detect now carries the contract's own print size into each placement. Measured on a rasterized test contract: misalignment dropped from ~4px to ~1px at 150dpi with exactly matching glyph sizes. Existing placements improve immediately; re-run auto-detect on a template to snap its placements to the exact print size.
- Button labels vanished on hover -- the global link-hover rule outranked the button label color, and on primary buttons the hover background is the same blue the label was flipped to ("Download filled PDF" went blank on hover). Buttons are now excluded from the link-hover rule.
- /admin and /admin/maintenance get a reading measure: page capped at a document width with a consistent 78ch line length, instead of viewport-wide prose next to a narrow list. Data pages (tenants, audit log) stay full width.
- builder PDF pages linked three assets with hardcoded 3.01.x cache busters that the 3.5.1 sweep missed -- now mtime-busted like everything else.
The 3.5.0 UI actually shows up: stale-CSS cache busting; tenant vs workspace terminology
- Asset cache busting -- the 3.5.0 operator console rendered unstyled (the left nav as a plain bulleted list above full-width content) because layouts/app.php linked the stylesheet with a HAND-MAINTAINED buster (app.css?v=3.4.0-6) that was never bumped for 3.5.0: browsers and the host page cache kept serving the 3.4.x CSS under the new markup. Every stylesheet and script under /assets is now linked through Version::asset(), which appends the file's mtime -- the URL changes exactly when the bytes do, including on updates, so this class of bug cannot recur. Also converted the version-less app.css links on the public pages (guest/minimal layouts, share pages, thanks page) and two public-form scripts pinned at 2.99.x.
- Operator docs -- the /admin documentation page claimed "tenant and workspace mean the same thing." They do not: a workspace is a folder INSIDE one tenant (the Workspaces item in the top bar) that groups forms and controls which members see them. The docs now use "organization" as the plain word for tenant, gained a "Workspace -- not the same thing as an organization" section explaining the hierarchy, and the same wording was fixed across the console blurbs, the Super admin link tooltip, the maintenance page, settings scope badges, the impersonation picker and both billing pages.
Critical: autosave dead for every tenant, blank-card 500; operator console, audit export, footer redesign
- CRITICAL -- app/Core/TenantDb.php: the INSERT rewriter located the VALUES list with a regex that stopped at the first closing parenthesis, so any tenant-owned INSERT whose VALUES contained a function call -- NOW(), UUID(), anything -- was rewritten into invalid SQL ("VALUES (:fid, :fvid, :d, :s, NOW(, :tenant_id), NOW())"). Form autosave, which writes exactly such an INSERT, failed with a MariaDB syntax error for EVERY tenant. Replaced with a paren-depth scanner that finds the true end of the list.
- CRITICAL -- app/Builder/CardVaultService.php: a closure declared ": string" returned null for a blank card field, so any form with an optional Payment (card) field threw a TypeError on submit whenever the visitor left the card blank -- a public 500 after the submission row was already written. Declared ": ?string".
- app/Views/builder/settings.php + app/Views/submissions/global.php: $h() around json_encode() inside <script> emitted HTML entities into JavaScript source and silently killed both blocks (entities are not decoded in script content). $h() stays for attribute contexts; raw JSON with the standard hex-escape flags in script contexts.
- Operator console (super admin): a real shell. Two-column layout built once in the layout when a controller opts in; left nav lists the seven operator surfaces only (Overview, Tenants, Maintenance, Updates, Audit log, Impersonation log, Operator settings) and no longer duplicates tenant pages; /admin -- which the nav linked and 404d -- is now the Overview: a plain-language documentation index whose roles / capabilities / settings sections render live from the same sources the application reads, with a stale-notice when the app version passes the docs review version. The maintenance switch moved off the tenants list onto its own /admin/maintenance page with a "what this actually does" explainer. Entry point: a "Super admin" link in the account cluster, visible only to super admins.
- /admin/audit-log: Export CSV / Export JSON honouring the exact filter set on screen, plus new from/to date filters. Exports snapshot MAX(id) so the file is consistent while the log keeps writing; every export is itself an audit event (who, when, which filters, how many rows) written before streaming starts; CSV ships with the formula-injection guard, BOM and streaming writes. Also fixed the "61 rows total s." stray text.
- /admin/updates: "Re-check now" bypasses the 6-hour check cache so a release cut minutes ago is installable immediately; the page shows when the last real check happened. Found while wiring it: UpdateService::getConfig() treated values echoed back from the live config.php as operator overrides because Settings::get() never returns null for a whitelisted key -- it now probes for an actual settings row before honouring an override.
- Footer redesign: the in-app footer is one slim quiet line (identity, a few capability-gated links, support, version) instead of a four-column sitemap duplicating the top bar; the marketing footer (the one on the 404 page) keeps its sitemap but gets a gradient seam, muted column titles, brand-blue hovers and a mono version pill; the marketing top nav -- which had no responsive handling and overflowed every phone -- wraps at 760px and collapses to brand + Log in at 480px.
- The "+ New form" plus sign hugging the left edge of its button: an old divider reset zeroed the button's own left padding (and left border) for every primary CTA in a page header. The reset now scopes to the action-group separators it was written for, and the button uses the shared icon() helper like every other button.
- Tenant detail (/admin/tenants/{id}): cards no longer overlap at narrow widths (grid columns can now actually shrink; tables scroll inside their cards).
- Housekeeping: three raw-Db reads with explicit tenant scoping annotated for the isolation lint (it was red on main); the pre-3.5.0 shell assertions in three test suites updated to the new layout contract; +4 updater tests for the re-check path.
Critical: signup bypassed email verification; security and correctness set
- CRITICAL -- app/Controllers/LandingController.php::submit() called createTenantWithOwner() about 24 lines BEFORE it read feature.require_email_verification. With that setting ON the tenant and user were created and the user row inserted with is_active = 1 before any OTP was issued, so the submitter could sign in without ever verifying their address -- the gate had no effect. The verify handler then called createTenantWithOwner() a second time, hit the uniqueness SELECTs and returned "Failed to create account", so a legitimate OTP could not complete a signup either. The call now sits below the gate, which is what the surrounding comment and the setting's own help text already described. Also replaced the hardcoded https://example.test/signup/verify link in the OTP email with a helper built from app.url -- no recipient could previously reach the verify page from the email.
- app/Builder/PaymentService.php sent "Authorization: *** " to Stripe instead of the Bearer scheme -- the literal write-tool corruption pattern that bin/pre-commit Pattern 1 exists to catch. That guard never ran: the hook reset FAIL=0 unconditionally after detecting failures, discarding every finding above that line including the raw-Db tenant-write lint. Both fixed.
- app/Controllers/PaymentController.php::submit() authorised against the route's form id but loaded the submission and its pricing schema from the submission's own form with no check that they match, so a public token for form A plus a guessed submission id from form B in the same tenant was accepted and form B's config drove the charge. Now 404s unless the ids agree.
- app/Builder/WebhookNotifier.php built CURLOPT_RESOLVE by repeating "host:port:" before every address inside a single entry, so libcurl read the second "host:port:ip" as a literal address and the DNS-rebinding pin silently failed for any destination with two or more A records. Single-address output is unchanged.
- app/Controllers/SettingsController.php accepted IPv6 addresses, IPv6 CIDRs and prefixes such as /999 in the admin IP allowlist, but the runtime matcher is ip2long-based and bounded to 0-32. Since a non-empty allowlist with no match denies, saving one of those locked the operator out of admin with no way back through the UI. Validation now requires IPv4 and bounds the prefix.
- app/Controllers/PublicFormController.php::saveDraft() took form_id unvalidated on a public unauthenticated endpoint (form_drafts has no foreign key, so any integer created orphan rows), let the caller supply draft_token -- the other half of the unique key, so row count was unbounded -- and had no payload cap, leaving post_max_size at 8M as the only ceiling. Unknown or inactive forms now 404 and payloads over 256KB now 413. Per-caller rate limiting still needs a chosen threshold.
- .gitignore now covers storage/updates/ and storage/backups/, the two directories UpdateService writes to. Opening /admin/updates previously left an untracked cache file, and applying an update left two complete untracked copies of the application.
Critical: Publish confirm modal opened at 0x0
- app/Views/partials/confirm_modal.php: openConfirm() set modal.hidden = false but never added the .is-open class that the CSS needs to flip .modal-shade from display:none to display:flex. The modal rendered at 0x0 with no error logged, so Publish appeared to do nothing. Fix: paired classList.add(\"is-open\") with the existing modal.hidden = false (and .remove with modal.hidden = true). Mirror of typed_confirm_modal.php:206/214 which already does this correctly. The class drives the visible state (and the pop-in animation); the attribute keeps the modal out of the a11y tree when closed and is what the Escape handler below queries.
Patch: auto-detect post-colon gap widened to 10pt
- app/Builder/PdfLabelDetector.php:155: $gapPt 6.0 -> 10.0. The auto-detect placement now sits clearly on the blank to the right of each label rather than just past the colon. Single-constant change in the file that owns the concept; the controller is unchanged (the 3.4.4 revert at commit 6c03fe3 keeps $x = $clamp($d["x"] ?? 0) and there is no autodetectOffset / page-width arithmetic in PdfController.php). After re-running auto-detect on the IRW marketing contract, the operator should re-render to confirm the wider gap is correct.
Operator console at <base>/admin (F8)
- app/Core/TenantResolver.php: the operator console is now reachable at <APP_BASE_DOMAIN>/admin/... as well as on admin.<APP_BASE_DOMAIN>, so operators do not need a cPanel subdomain. The PATH NEVER GRANTS PRIVILEGE -- it only selects which context shape to build (TenantContext::setSuperAdmin(), i.e. tenantId=null); the actual gate remains users.is_super_admin=1, identical to the admin-host branch.
- New OPERATOR_PATHS constant lists the six routes whose controller calls SuperAdminController::requireSuperAdmin(): /admin/tenants, /admin/impersonation-log, /admin/audit-log, /admin/maintenance, /admin/updates, /admin/caps. Everything else under /admin (/admin/users, /admin/roles, /admin/settings, /admin/migrate, /admin/billing) is TENANT-scoped and must not appear on the list -- setSuperAdmin() nulls the tenant id and would break those routes.
- New requestPath() helper mirrors Router::compilePath() exactly -- "/" . ltrim(parse_url(\$uri, PHP_URL_PATH)) -- so the resolver and the router can never disagree about which route a request is for. isOperatorPath() matches on a SEGMENT boundary (so /admin/tenantsXYZ does NOT match).
- app/Controllers/SuperAdminController.php: 404 text and class docblock updated to reflect that the console is reachable at <base>/admin/... or on admin.<base>; the gate is users.is_super_admin=1 in both cases.
- app/Controllers/LoginController.php: a zero-membership super-admin logging in on the APEX (not on admin.<base>) now lands on /admin/tenants instead of the /no-tenant holding page. The 3.4.5 hoisted check fires on admin host OR when the user has zero tenants. A super-admin who IS a member of a tenant still lands on the apex dashboard -- on the apex they are acting as a normal user of that tenant.
- tests/f8_apex_operator_path_test.php: 31 assertions across 10 cases + 2 bonus cases. Proves cases 1-3 engage super-admin mode on apex, cases 4-5 fall through to default tenant when not super, cases 6-9 prove tenant-scoped /admin routes (e.g. /admin/users, /admin/migrate) do NOT engage super-admin mode, case 10 proves tenant isolation does not regress (super-admin on tenant subdomain resolves to that tenant, isSuperAdmin()==false).
- tests/f7_admin_host_login_test.php: 15/15 still -- admin.<base> behaviour unchanged. This is purely additive.
Patch: admin-host post-login redirect
- LoginController::submit() (3.4.4) consumed the admin-host redirect inside the count($tenants) === 1 branch. A zero-membership super-admin (the normal shape) hit the /no-tenant holding page; a multi-tenant super-admin hit /pick-tenant. Only a single-tenant super-admin ever reached /admin/tenants. Hoisted the check above all three tenant-count branches. The single-tenant active_tenant_id side effect is preserved so a later visit to the apex still has an active tenant. The check stays host-scoped: a super-admin logging in on the apex is unaffected.
Corrective: auto-detect placement revert; admin-host login
- REVERT (P0): auto-detect offset moved placements 35% of the page width right because $d[\"w\"] is the placement-box width (0.35 of page), not the label width. The 6pt post-colon gap was already applied in app/Builder/PdfLabelDetector.php:155. Reverted the v3.4.3 commit 24b57d1 and pointed the next reader at PdfLabelDetector::$gapPt as the correct lever if values still hug their labels.
- F7: admin.<APP_BASE_DOMAIN> now renders /login and /logout for anonymous visitors, so a super-admin who lands on the admin host logged out can log in without first hitting the apex. Every other path on the admin host with no session still returns 404. Cookie domain unchanged (already spans subdomains via Session.php). Post-login redirect on the admin host now lands on /admin/tenants. See docs/UPDATING.md for the cPanel subdomain setup (no wildcard cert required).
Security: stored XSS via unescaped JSON in quoted HTML attributes; inherited security hardening set
- SECURITY (L-90): user-controlled form title broke out of the @click attribute on /forms via bare json_encode() inside a quoted HTML attribute. 13 sites across 6 files wrapped in $h() and verified with a byte-level test. Decrypted card data (submissions/card_reveal.php) was among the affected sites.
- Inherited from claude/codebase-audit-1wjy7o and claude/production-readiness-audit-pv4fub (full provenance in docs/AUDIT_3.4.0_STATUS.md, docs/UPDATING.md, and the commit log of those branches): the PDF stamper no longer emits a full card number or CVV (65eedab), the auto-detect can create fields and place them, and the security hardening set the operator carried over from the 5-round audit. See those docs for the per-finding detail; the source-of-truth is the commit log.
- fputcsv: every call now passes all 5 args explicitly (PHP 8.4 deprecation corrupts exports when display_errors is on)
- Marketing footers: source the version from App\Core\Version::current() instead of the stale Settings::get("feature.version", "2.99") default — operators were seeing "v2.99" on the live site while running 3.4.x
- Error pages (404/500) and the marketing footer: suppress the "Email us at" clause when no address is configured (was rendering "Email us at ." on empty)
- Google Fonts cross-origin links removed from /form/{id} — the app's CSP was blocking them anyway. Form uses the system font stack deliberately
- PDF generation: "no PDF template attached" is now a 404, not a 500. The two PDF buttons (Download + Preview) gate on PdfPlacementService::getDefaultForForm. Edit fields is no longer hidden by the gate (regression fixed in bb65dfe)
- Publish uses the app's new openConfirm() modal, not window.confirm()
- Auto-detect: detected placements offset 0.01 (≈ 6 PDF points) right of the label so values land on the blank, not on the label text
- create_user: --tenant, --tenant-role, --can-view-submissions, --form-grant, --form-grant-role flags. Base Level users get grants on every active form in the tenant by default; loud NEXT STEP if no tenant is given
- Topbar nav scrolls on mobile (overflow-x: auto + min-width: 0) — every page that overflowed at 375px (previously 9 pages) now fits
- More action button (⋯) aligns with the other four action buttons on every row (.table td.actions > * vertical-align: middle)
- Data tables: the existing card-collapse responsive pattern is extended to the inbox (.inbox-table-wrap) and global submissions table (.submissions-global). The 720px breakpoint now collapses thead, blocks table/tbody/tr/td, and borders each row as a card
- Capability matrix (.cap-grid) is now horizontally scrollable rather than overflowing to ~800px on mobile/tablet
- Topbar identity (.user-menu): user-name truncates with ellipsis (min-width: 0 + text-overflow: ellipsis) so Sign out stays on one line at 375px
Visual: Submission statuses card padding, actions cell no longer flexed out of the table
- app/Views/builder/settings.php: the Submission statuses card (x-data on the <section>) now wraps its contents in <div class="card-pad-lg"> like its five sibling cards on lines 41, 271, 313, 359, 421; the heading, help text, STATUS MODE legend and the three radio options now sit at the same edge as the rest of the page
- app.css:649 .table td.actions: removed display:flex + gap + align-items + flex-wrap + justify-content. The cell is a <td> in a real <table>; display:flex was taking it out of the row layout and the column grid (exposed after 3.4.1 scoped the 360px width to .forms-list). Spacing now comes from the margin-right rule on form/a/button children; added vertical-align:middle so the buttons sit centred in the row
Hardening: zip-slip guard, multi-level subdomain fail-closed, Stripe tenant binding, redacted email bodies, WCAG AA text
- L-58: UpdateService::extractZip validates every zip entry before extracting; rejects absolute paths, "..", Windows drive letters, and out-of-target landings
- L-50: TenantResolver::resolve() returns false for malformed/multi-level subdomains (was returning the default tenant)
- L-22 RESIDUAL: BillingController::returnUrl binds the verified Stripe session to the caller's tenant; a session from another tenant is rejected
- L-54 RESIDUAL: SubmissionPipeline::updateField validates pipeline_stage_id belongs to the submission's form before writing
- M-09: SubmissionService::record logs DB exception detail server-side; the public caller gets a generic "We could not record your submission"
- .gitattributes keeps spike/ + tests/ out of the release zip (export-ignore); docs/ retained
- Visual: link/subtle/placeholder/select text now meets WCAG AA (>= 4.5:1) via --c-brand-btn and --c-ink-3; --c-ink-4 is disabled-only
- Visual: 360px actions column scoped to .forms-list so admin tables do not force mobile overflow; .table-wrap added
- Visual: maintenance banner stacks below the topbar; PDF placement inspector anchored to the grid; kanban card name/body wrap
- redact-after-send: email_messages.body + .subject are NULL'd on status="sent" for signup_otp / tenant_invite / draft_resume_link; the redact UPDATE has its own try/catch
- Mailer::devLogPath derives the dev mail log path from the repo root; the previous hardcoded /Users/ahmad/Forum Builder path is gone
- VERSION::current(?string $root) accepts an explicit root; update_service_test reaches 0 failures for the first time
- 29 test files: /Users/ahmad/... roots replaced with __DIR__-relative resolution; git grep -c /Users/ahmad returns 0 in production code
Architecture: Phase 7 signup-OTP, Phase 8 reCAPTCHA, 7-theme refactor, 5-round security audit
- Phase 7: signup-OTP flow (signup_otps + signup_payloads tables) with 15-min TTL, single-use, attempt cap, per-IP rate limit, keyed HMAC storage
- Phase 7: registration_mode WHITELIST (invite_only / open_signup) with a Settings::set arming guard
- Phase 8: reCAPTCHA v2 with fail-closed verifier (5s timeout, no cross-host secret leakage, audit-logged outcomes)
- Phase 8: public-form gates run maintenance -> form-lookup -> CSRF -> captcha in that order (cheap local checks first, network call last)
- Phase 7 step 3: stash the password digest in the signup payload so OTP verify completes without a re-prompt; createTenantWithOwner has a single $precomputedHash path
- 5-round security audit (3.3.x): every finding from that audit is fixed on main, plus 4 ambient vulnerabilities found during the sweep. See docs/AUDIT_3.4.0_STATUS.md for the items still open in 3.4.0.
- 7-theme architectural refactor: Capabilities unification, Authorizer helper, SystemConfig split, TenantDbQuery (Phase 4), SubmissionPipeline (Phase 6), FieldView (Phase 6)
- Self-update: admin-panel updater is canonical for production; bin/update.sh is dev-only; UPDATING.md reconciles both
- Docs: docs/UPDATING.md, docs/SECURITY_BRIEF_AUDIT.md, docs/DEPLOY_CPANEL.md, docs/RELEASING.md, docs/AUDIT_3.4.0_STATUS.md
Security: impersonation CSRF, exit-impersonation audit, domain IDOR binding, webhook SSRF guard, app.secret default check, open-redirect allowlist
- exitImpersonation now requires CSRF + audits the tenant_id (was undefined)
- Tenant domain verify/remove require the {did} to belong to the URL's {id}
- Webhook SSRF guard: rejects private/loopback/link-local IPs + DNS-pins cURL
- Public-form CSRF fails closed when app.secret is the placeholder
- redirect_url now restricted to same-host or path-relative
- App exception handler returns generic "internal error" in production (no SQL leak)
- SMTP STARTTLS validates the 220 response and the TLS upgrade return value
- CSV export neutralizes formula prefixes (=, +, -, @, tab, CR) in attacker-supplied cells
- Router now fails closed on unresolved middleware (was fail-open)
Security: PCI-DSS card data, payment cluster, upload preview IDOR, /sub/{sid}/pdf IDOR
- card_vault.card_number and card_vault.card_cvv columns dropped; PAN + CVV never stored
- /sub/{sid}/pdf requires ?token= (no more public PII walk)
- Stripe webhook now verifies the signature in mock mode (was unguarded)
- Payment submit reads price server-side from the form's payment-field config
- Upload preview requires a per-upload random token (no more integer walk)
Security: tenant/super-admin takeover (X-Tenant / ?tenant=)
- TenantResolver no longer trusts X-Tenant header or ?tenant= query param
- Super-admin requires users.is_super_admin=1 (new column, seeded from role_id=1)
Public forms, multi-tenant SaaS, conditional logic, PDF stamping, e-signature
- Multi-tenant architecture (apex + subdomains + custom domains)
- Form builder with conditional logic, file uploads, payment fields
- PDF stamping with raster + FPDF overlay (per-placement styling)
- E-signature via Adobe Sign (mock-by-default)
- Webhook notifications (Slack, Discord, generic)
Versioning policy
We follow semantic versioning:
major versions break compatibility, minor versions add
features, patch versions fix bugs. Every commit on
main is deployable.
Security fixes ship immediately and are tagged in the
changelog with the security badge. They are
backported to the previous major version for 6 months.