SaaS Kit · Test run · run 2026-11-05
SaaS Kit Test run, as shipped in the download
703 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-11-05 with php tests/run.php. Run it yourself after you download.
== 1. Subdomain → tenant resolution (the isolation boundary) ==
PASS valid single-label subdomain (got 'alpha')
PASS valid with hyphen (got 'acme-corp')
PASS strips port (got 'alpha')
PASS case-insensitive (got 'alpha')
PASS strips trailing FQDN dot (got 'alpha')
PASS strips userinfo smuggling (got 'alpha')
PASS extra reserved: an operator label is not a tenant (got NULL)
PASS extra reserved: normalized (case + whitespace) (got NULL)
PASS extra reserved: blanks are dropped, not reserved (got 'alpha')
PASS baseline still applies alongside the extras (got NULL)
PASS reserved() merges baseline + extras without duplicates
PASS cleared: a former extra is a tenant again (got 'invora')
PASS FORGED foreign domain → null (got NULL)
PASS SUFFIX-SPOOF → null (got NULL)
PASS bare app domain → null (got NULL)
PASS multi-label subdomain → null (got NULL)
PASS lookalike prefix (xapp) → null (got NULL)
PASS empty host → null (got NULL)
PASS null host → null (got NULL)
PASS IPv6 literal → null (got NULL)
PASS reserved www → null (got NULL)
PASS reserved admin → null (got NULL)
PASS reserved api → null (got NULL)
PASS SQLi in label → null (got NULL)
PASS space in label → null (got NULL)
PASS leading hyphen → null (got NULL)
PASS trailing hyphen → null (got NULL)
PASS underscore (not a DNS label) → null (got NULL)
PASS resolve valid → tenant row
PASS resolve FORGED → null
PASS resolve FORGED never hit registry
PASS resolve UNKNOWN subdomain → null
PASS resolve UNKNOWN did consult registry (well-formed)
PASS active tenant is servable
PASS suspended tenant NOT servable
PASS canceled tenant NOT servable
== 2. Plan-limit quotas ==
PASS planLimit reads cap (got 3)
PASS planLimit 0 → unlimited (null) (got NULL)
PASS planLimit unknown key → unlimited (got NULL)
PASS planLimit parses JSON limits (got 2)
PASS withinLimit under cap allows
PASS withinLimit at cap denies
PASS withinLimit unlimited always ok
PASS allowCreate room → true
PASS allowCreate at cap → false
PASS allowCreate unlimited plan → true
== 3. Subscription state machine + precedence ==
PASS trial + activate → active (got 'active')
PASS trial + payment_failed → past_due (got 'past_due')
PASS active + payment_failed → past_due (got 'past_due')
PASS past_due + payment_succeeded → active (got 'active')
PASS past_due + payment_failed stays past_due (got 'past_due')
PASS past_due + suspend → suspended (got 'suspended')
PASS suspended + resume → active (got 'active')
PASS suspended + activate → active (got 'active')
PASS active + cancel → canceled (got 'cancelled')
PASS canceled + activate stays canceled (terminal) (got 'cancelled')
PASS canceled + resume stays canceled (got 'cancelled')
PASS canceled + payment_succeeded stays canceled (got 'cancelled')
PASS unknown event = safe no-op (got 'active')
PASS unknown current normalizes to trial→active (got 'active')
PASS webhook map: checkout → checkout_completed (got 'checkout_completed')
PASS webhook map: invoice.payment_failed → payment_failed (got 'payment_failed')
PASS webhook map: subscription.deleted → cancel (got 'cancel')
PASS webhook map: irrelevant → null (got NULL)
== 4. Webhook signature verification (fail closed) ==
PASS stripe: valid signature verifies
PASS demo: valid signature verifies
PASS tampered payload rejected
PASS wrong secret rejected
PASS missing signature header rejected
PASS empty configured secret rejects all
PASS stale timestamp rejected (>5min)
PASS fresh timestamp within tolerance
PASS parseEvent extracts id/type/subdomain
== 4b. Lemon Squeezy dialect ==
PASS ls: valid X-Signature verifies
PASS ls: header case-insensitive
PASS ls: tampered payload rejected
PASS ls: wrong secret rejected
PASS ls: malformed signature rejected
PASS ls: empty secret rejects all
PASS ls parse: id prefixed + type + subdomain + amount
PASS ls parse: no custom_data -> empty subdomain, subtotal fallback
PASS ls map: subscription_created -> checkout_completed (got 'checkout_completed')
PASS ls map: payment_success -> payment_succeeded (got 'payment_succeeded')
PASS ls map: payment_failed -> payment_failed (got 'payment_failed')
PASS ls map: paused -> suspend (got 'suspend')
PASS ls map: unpaused -> resume (got 'resume')
PASS ls map: canceled -> cancel (got 'cancel')
PASS ls map: expired -> cancel (got 'cancel')
PASS ls map: subscription_updated -> null (no lifecycle) (got NULL)
PASS ls checkout: stamps subdomain into custom_data
PASS ls checkout: prefills email
PASS ls checkout: preserves existing query
PASS ls checkout: merges extra custom keys
PASS ls checkout: empty base → empty
PASS ls checkout: stamped subdomain round-trips through parseEvent
PASS checkoutUrlFor: non-LS provider → null
PASS checkoutUrlFor: no urls configured → null
PASS checkoutUrlFor: exact plan-code URL wins
PASS checkoutUrlFor: falls back to default when plan has no URL
PASS parseEvent garbage → null
== 9. Exporter — leave-with-everything archive ==
Warning: Undefined array key "db_name" in src/Provisioner.php on line 40
Warning: Undefined array key "db_name" in src/Provisioner.php on line 40
PASS export: produces a tar file on disk
PASS export: includes README-FIRST.txt
PASS export: includes the tenant database file
PASS export: includes the app source
PASS export: SKIPS runtime data/ from source
PASS export: README names the business + ownership
PASS export: bundled DB actually contains the data
PASS export: rejects a bad subdomain
== 5. DB-backed: webhook replay ledger + provisioning idempotency (throwaway SQLite) ==
PASS control schema installs
PASS re-running install is idempotent
PASS default plans seeded (3)
PASS seedDefaultPlans idempotent
PASS event not seen initially
PASS event seen after record
PASS duplicate record is a no-op
PASS tenant A and B get DISTINCT db paths
PASS tenant A path contains its subdomain
PASS provision ran installer twice
PASS boot never requires src/Totp.php — a wrapped product owns that name
PASS boot never requires src/Database.php — a wrapped product owns that name
PASS boot never requires src/AuditLog.php — a wrapped product owns that name
PASS boot never requires src/console_ip.php — a wrapped product owns that name
PASS …yet exactly ONE admin exists (idempotent)
PASS provision stamped provisioned_at
== 6. Hardening: CSRF empty-token guard, clip-to-column, CSV formula guard ==
PASS csrf: empty stored + empty given REJECTED
PASS csrf: empty stored + any given REJECTED
PASS csrf: token vs empty REJECTED
PASS csrf: wrong token REJECTED
PASS csrf: matching token accepted
PASS clip: 9000 chars → 190
PASS clip: short string untouched
PASS clip: trims whitespace
PASS createTenant clips subdomain to 63
PASS createTenant clips business_name to 190
PASS createTenant clips app_secret to 64
PASS createTenant clips status to 16
PASS recordWebhook clips + webhookSeen agrees
PASS csv: =formula neutralized (got '\'=SUM(A1:A9)')
PASS csv: @DDE neutralized (got '\'@cmd')
PASS csv: +payload neutralized (got '\'+1|calc')
PASS csv: -payload neutralized (got '\'-2+3+cmd')
PASS csv: negative money UNTOUCHED (got '-45.00')
PASS csv: plain number untouched (got '123')
PASS csv: comma gets RFC quoting (got '"a,b"')
PASS csv: quote doubled (got '"say ""hi"""')
PASS csv: formula+comma quoted after prefix (got '"\'=1,2"')
== 7. ProductRegistry: graceful on missing adapters / missing apps ==
PASS missing adapter file → null (no fatal)
PASS missing adapter excluded from all()
PASS adapter loads even when its app dir is absent
PASS …and the absent app is detectable (no fatal)
== 8. Dashboard KPI queries ==
PASS tenantCountsBy(status) counts rows
PASS tenantCountsBy whitelists columns
PASS tenants table survived the injection attempt
PASS launcher: dashboard has the "Your apps" tile grid
PASS launcher: each tile posts to the single-use open-admin action
PASS launcher: canceled tenants are excluded from the launcher
PASS sso: authentic token verifies and returns the admin uid
PASS sso: REPLAYING the same token is rejected (nonce burned in control DB)
PASS sso: expired token is rejected even with a valid signature
PASS sso: token signed with a DIFFERENT tenant secret is rejected
PASS sso: tampering with the uid breaks the signature
PASS sso: legacy 3-part (pre-nonce) token format is rejected outright
PASS sso: without a control PDO verification still validates sig+exp (offline mode)
== PWA ==
PASS manifest.json is valid JSON
PASS manifest has required fields
PASS manifest URLs are RELATIVE (subpath-install safe)
PASS manifest icons exist on disk (192 + 512 + apple-touch)
PASS sw.js never caches non-GET
PASS sw.js never caches page navigations
PASS sw.js cache versioned + purges old caches
PASS offline fallback ships
PASS all three console layouts link the manifest + register the SW
----------------------------------------
== v3 ==
PASS v3: reference copy absent (buyer machine) — byte-identity skipped
PASS v3: audit row written via the Control-backed Database facade
PASS v3: audit redacts secret-named keys
PASS v3: tenant lifecycle audited at the apply_event choke point
PASS v3: logins audited on BOTH paths (plain + after 2FA)
PASS v3: backup download + settings save audited
PASS v3: healthz + backup stamped with the one version (3.5.2), no typed version left
PASS v3: backup redaction covers token_hash/secret/api_key
PASS v3: sw cache is static-v3.5.2 and names no other version
PASS v3: audit viewer + CSV routes registered
PASS v3: README documents New in 3.0 + the deliberate opt-outs
PASS ip: a direct visitor's forged headers are ignored
PASS ip: behind a trusted proxy the forwarded visitor is used
PASS ip: CF-Connecting-IP wins over the X-Forwarded-For chain
PASS ip: the chain is read right-to-left, past trusted hops
PASS ip: a Cloudflare edge is not a trusted proxy by default
PASS ip: CIDR maths (v4 boundaries + v6, no cross-family match)
== 9. Own It 3.0: ControlApi keys + the MCP agent door ==
PASS mint returns a ck_-prefixed token
PASS plaintext is never stored
PASS auth accepts the minted token
PASS auth is null on empty
PASS auth is null on garbage
PASS auth is null on wrong secret, right id
PASS auth is null on truncated token
PASS auth is null on 1-char tamper
PASS revoke succeeds once
PASS revoke is idempotent-false the 2nd time
PASS REVOKED key never authenticates again
PASS label hard-capped at 120 chars
PASS tools/list names tenants_list
PASS tools/list carries readOnly annotation
PASS tools/call reaches the handler
PASS tools/call returns the rows
PASS unknown tool is an error, not a crash
PASS unknown ARGUMENT is refused by name
PASS nested argument refused unless allowNested
PASS broken JSON is a JSON-RPC error
PASS GET is 405 (POST-only transport)
PASS write-shaped tools DO NOT EXIST in the kit spec (read-only by design)
== C. Cloud build: pay first, provision on the webhook ==
PASS C1: migrate adds reservations + account_links tables and the new tenant/subscription columns
PASS C1: every catalog app with an adapter seeds a hosted plan (47; "The Cloud offers every app", 2026-09-25) (got 47)
PASS C1: no plan carries a trial (trial_days 0 everywhere) (got 0)
PASS C1: the $19 plans are exactly the apps whose one-time price is $119 or more; every other hosted plan is $12 (got array (
0 => 4,
1 => 43,
))
PASS C1: the launch twelve still lead, in launch order, so their plans keep their positions
PASS C1: an app with no adapter (the flagship, not PHP; the kit itself) is not hosted
PASS C1: the catalog lists all 49 products (got 49)
PASS C1: live = an active hosted plan AND a checkout URL in the current mode (slotly only in this fixture) (got 1)
PASS C1: the rest read coming-soon and link to the store product page
PASS C2: a valid signup returns a reservation + the provider checkout URL, and writes NO tenant
PASS C2: the checkout URL is the TEST variant (test_mode on) and carries the reservation, subdomain and email
PASS C2: the password is stored encrypted, never plain
PASS C2: a pending reservation holds the subdomain
PASS C2: an app without a checkout URL in the current mode is refused before any reservation
PASS C2: a reservation older than 48 h no longer holds the name
PASS C3: a test-mode event signed with the LIVE secret is refused (got 400)
PASS C3: signed with the TEST secret it is accepted (got 200)
PASS C3: the tenant now exists, ACTIVE, flagged is_test, provisioned, with the plan and the buyer email
PASS C3: the product installer ran exactly once (got 1)
PASS C3: the installer got the decrypted password, name and timezone
PASS C3: the subscription row carries the LS ids, the portal URL and the renewal date
PASS C3: the reservation is marked provisioned and its secret is wiped
PASS C3: the welcome mail went to the buyer with the instance URL and the login page, and NO password
PASS C3: the same event again is a no-op (replay ledger) — one installer run, one tenant
PASS C3: a second event for a provisioned reservation (order_created) provisions nothing twice
PASS C3: a LIVE event (test_mode false) verifies with the live secret and provisions a NON-test tenant
PASS C3: a webhook without a reservation for an unknown subdomain provisions nothing
PASS C4: subscription_cancelled → tenant canceled with ends_at recorded; still servable until then
PASS C4: past ends_at the instance is NOT servable (the wall)
PASS C4: a canceled tenant with no ends_at is walled immediately
PASS C4: subscription_expired → walled now
PASS C4: the old provision-first path is dead: console_signup no longer calls Provisioner or createTenant
PASS C5: a magic link is a 40-hex token that resolves to the email once and expires
PASS C5: an expired link is refused
PASS C5: the account lists the instances of that email with status, plan and portal URL
PASS C5: export builds a tar with the tenant database for the owner — and refuses another email
PASS C5: delete removes the rows and the database file (allowed once cancelled/expired)
PASS C5: an ACTIVE instance cannot be deleted from the account (cancel at the provider first)
PASS C6: an unknown subdomain is a 404, not the marketing page
PASS C6: the console owner without 2FA is sent to enroll (every path but security/logout)
PASS DEMO 2026-09-25: the public demo seats the visitor without a 2FA wall (it opened on the enrollment page), and nothing else changes
PASS WELCOME 2026-09-25: the provision-first /welcome/{sub} page hands the view its tenant as the reservation (it showed "Nothing to show here yet" after every demo/Stripe signup)
PASS C6: the header menu and the footer columns come from config brand (neutral kit; the plane config carries the store, legal and contact links)
PASS C6: the landing types no trial or free-days words of its own (D-7: they come from cloud_offer_line, for the offer the plane is on)
PASS C6: the landing takes its offer lines from cloud_offer_line() (D-7: one place, both offers), and the pay-first line still says so
PASS C6: the phone clip is fixed in BOTH stylesheets (no horizontal overflow at 390)
PASS C6: the operator console can delete a tenant (route + handler)
PASS C6: routes: /apps, /account, /account/{token}, /account/export, /account/delete, /welcome exist
PASS G2: the flagship app is marked flagship in the SAME card loop as every other app (not lifted out)
PASS G2: the flagship treatment is exactly one property, on the coming-soon card
PASS G2: the flagship card does NOT borrow the live treatment (no glow, no inset ring)
PASS G2: only ONE app can be flagship
PASS G2: the header nav color rule exempts .btn (the Start pill keeps --btnink)
PASS G2: --btnink is white, so an exempted .btn is legible on the gradient
PASS CLOUDAPI: the variant id is read from the map already in use, so there is one map not two
PASS CLOUDAPI: an explicit ls_variants entry wins over the ?enabled= id
PASS CLOUDAPI: a plan with no variant anywhere yields no id rather than a warning
PASS CLOUDAPI: the returned checkout url is the one LS handed back
PASS CLOUDAPI: it POSTs to the v1 checkouts endpoint
PASS CLOUDAPI: enabled_variants pins exactly the chosen variant, so the picker hides
PASS CLOUDAPI: custom carries the subdomain the webhook will match on
PASS CLOUDAPI: custom carries the plan code
PASS CLOUDAPI: custom still carries the reservation token, so BOTH lookups can find it
PASS CLOUDAPI: the checkout expires with the reservation, not on its own clock
PASS CLOUDAPI: store and variant relationships are both set
PASS CLOUDAPI: the key travels in the Authorization header and is read from the file
PASS CLOUDAPI: a missing key file refuses rather than sending an empty Bearer
PASS CLOUDAPI: no store id refuses
PASS CLOUDAPI: an HTTP error refuses instead of returning a non-url
PASS CLOUDAPI: a 200 with no url in it refuses
PASS CLOUDAPI: a thrown transport error refuses and never surfaces the exception
PASS CLOUDAPI: when the API cannot be used the buyer still gets the static checkout
PASS CLOUDAPI: when the API works the buyer gets the API checkout, not the static one
PASS AFF: a click id is accepted as given (got '01HZX4-ab_c.9')
PASS AFF: junk, empty, arrays and over-long values are refused
PASS AFF: an API checkout URL (already signed) gets &aff_ref (got 'https://ownware.lemonsqueezy.com/checkout/custom/6acd00cc?signature=abc&aff_ref=clk_1')
PASS AFF: a URL with no query gets ?aff_ref, and a fragment stays last (got 'https://shop.example/buy/x?aff_ref=clk_1#top')
PASS AFF: nothing to add leaves the URL alone (empty ref, invalid ref, empty URL, one already there)
PASS AFF: the cookie (latest click) wins over the session, and a bad cookie falls back to the session
PASS AFF: a referred sign-up hands the buyer a checkout that names the click (static map, test mode)
PASS AFF: an unreferred sign-up carries no aff_ref
PASS AFF: the pay-first sign-up reads the click from the cookie or the session and passes it to reserve()
PASS AFF: only the public landing pages (/ and /apps) bounce an ?aff= link; sign-in and account links keep their address
PASS AFF: the console takes the click in before it dispatches (index.php)
PASS CLOUDAPI: a pending reservation is found by its exact subdomain
PASS CLOUDAPI: a PREFIX of the subdomain finds nothing — a near-match must never provision
PASS CLOUDAPI: the subdomain with a suffix finds nothing either
PASS CLOUDAPI: the webhook reads the subdomain out of meta.custom_data
PASS CLOUDAPI: an API checkout carries no 40-hex reservation token, and that is not an error
PASS CLOUDAPI: the numeric order id is still captured for the [order_id] welcome recovery leg
== coming-soon intent capture ==
PASS intent: the capture form exists and posts to the plane, not across origins to the store
PASS intent: it is CSRF-protected and carries a honeypot
PASS intent: the form is inside the coming-soon branch only — a live card keeps its Start button
PASS intent: the coming-soon card still quotes NO price — the plane has none to quote
PASS intent: every card is addressable, so the redirect lands on the card that was used
PASS intent: the page states the consent terms in plain words next to the grid
PASS intent: the handler checks CSRF before anything else
PASS intent: a forged slug for a LIVE app is refused — the field only exists on coming-soon cards
PASS intent: an unreachable store is reported to the visitor, never thanked away
PASS intent: no secret, no request — the forward refuses rather than posting unsigned
PASS intent: the store recomputes exactly the signature the plane sent
PASS intent: one byte changed in the body and the signature no longer matches
PASS intent: the wrong secret does not verify
== C9. terms pages and the sign-up agreement (3.3.0) ==
PASS C9: pages() lists the configured pages whose file exists (a missing file is not listed) (got array (
'terms' => 'Terms of Service',
'dpa' => 'Data Processing Agreement',
))
PASS C9: a page renders its headings, bold, table and list, with the version
PASS C9: rendering is escape-first: raw HTML is shown as text, only https:// and / links become links
PASS C9: an unknown or missing page is null
PASS C9: the box is required when the sentence, the version and every page it names are there
PASS C9: the sentence links each named page by its title
PASS C9: a sign-up without the box ticked is refused, and nothing is reserved
PASS C9: ticked, the reservation records the terms version and the time
PASS C9: provisioning carries the agreement to the tenant (version and time, unchanged)
PASS C9: without a legal config the sign-up needs no box (the earlier sign-ups above), and the tenant records none
PASS C9: /legal and /legal/{slug} are routed; the form shows the box only when it is required; the legacy sign-up checks it too
PASS C9: request logs are capped at 30 days in the image (access and error log through rotatelogs -n 30, not stdout)
PASS C9: footer links can name the pages as @legal and @legal/<slug>; anything else is left as written
== C10. apps with no adapter are a one-time purchase, not coming soon (3.3.0) ==
PASS C10: the flagship and the SaaS Kit have no adapter and are marked not hosted; a hosted app is marked hosted
PASS C10: on /apps the two cards read "Buy once · run it yourself", link to the store, and offer no "tell me when it is on Cloud" form
PASS C10: a hosted app that is not wired yet still reads "Coming soon" with the notify form
PASS C10: the page counts them apart: a "Run it yourself · 2" filter beside Live and Coming soon
PASS C10: llms.txt, agents.md and agent.json count the not-hosted apps apart and never call them coming soon
PASS C10: nothing still says "coming soon" once no hostable app waits: the /apps title, agents.md and the home fine print name only the states the list has
PASS C10: the "tell me when" post and a forged sign-up refuse an app with no adapter, and say what it is
PASS C10: a forged sign-up for the flagship is refused as a one-time purchase, before any reservation
PASS C10: with nothing coming soon, the fine print drops "coming soon", "not one yet" and the notify promise, and says where the run-it-yourself price is
== C11. crawlable public pages, canonical links, a labeled sign-up form (3.3.1) ==
PASS AFF: an ?aff= link goes to the bounce page with the code and the product (got 'https://store.example.test/go/cloud?aff=Abc12&product=invora')
PASS AFF: no bounce page, a plain-http one, a bad code, an array, or a link already tracked: no redirect
PASS AFF: a bounce URL with its own query gets &aff (got 'https://s.example.test/go?x=1&aff=Abc12')
PASS AFF: ?aff_ref= is kept for the session; a bad one never replaces it; a POST never sets it
PASS C11: robots.txt opens the landing page, /apps, /legal and its pages, the assets and the agent files
PASS C11: ...and closes everything else (account, sign-up, checkout, welcome, admin): one "Disallow: /", no Allow for them
PASS C11: robots.txt names the sitemap on the console's own address, not the tenant domain
PASS C11: the sitemap lists /, /apps, /legal and each terms page on the console's address
PASS C11: with no base_url the sitemap takes the scheme and host the request arrived on
PASS C11: each public page names itself once as canonical; the 404 page names none
PASS C11: every sign-up field has a label bound to it (for/id), the two selects included
PASS C11: the name, email and password fields say what they hold, and the password asks for the 8 characters the server requires
PASS C11: the time zone keeps America/New_York as the server default and the page preselects the browser's own zone when it is on the list
PASS C11: the fine print (--faint) reads at 4.5:1 or better on every page ground (WCAG AA, small text); lowest 4.8
PASS C11: the header button names where it goes ("Get started"), not a bare "Start"
PASS C11: the terms list gives every link a tap target of 24 px or more (WCAG 2.5.8; Lighthouse target-size)
== C12. the v2 face: grouped apps, own lines, icons, prices from the plans, every public page (3.4.0) ==
PASS C12: with design.default v2 the landing page is the v2 face (its own layout, stylesheet and sections)
PASS C12: one card per hostable app (47), each with its own line: never one description repeated on every card
PASS C12: a configured line wins over the catalog tagline; an app with no line keeps its tagline
PASS C12: the apps sit in the configured job groups in order, and an app no group names lands in "More apps" (nothing dropped)
PASS C12: each card shows its icon from design.icon_url, the live state, the monthly price from its plan and both links
PASS C12: the four $19 apps come from their plans, and the price line is computed ("$12 a month for most apps, $19 for the four biggest.")
PASS C12: the never-hosted apps are a "Run it yourself" strip with the platform's note and their buy-once price from the catalog
PASS C12: what every plan includes is said once, with the trial fact from the plane's own trial days
PASS C12: the header carries the platform's strip back to its wider site, its menu, the account link and Start free; a menu for phones
PASS C12: the sign-up form is the same shared form (labels, consent box, time-zone preselect) and says the hold sentence once
PASS C12: /apps, the terms pages and the account page all render in the v2 frame, with their content
PASS C12: canonical links stay on the clean address in the v2 face too
PASS C12: with v1 the default, ?design=v2 previews v2 and shows the way back; with v2 the default there is no preview pill
PASS C12: v1 stays v1 when nothing asks for v2 (the live face is unchanged for visitors)
PASS C12: with no design config the kit is neutral: one "All apps" group, letter tiles, catalog taglines, no outside strip
PASS C12: the switch: config default, then a preview cookie (v1 or v2 only), then ?design=; anything else falls back to the default
PASS C12: a console link may carry a fragment (@home#apps), resolved on the console's own address
PASS C12: the v2 views type no trial or free-days words of their own (D-7: cloud_offer_line, for the offer the plane is on)
PASS C12: in the v2 face a link inside running text is underlined, never told apart by color alone (Lighthouse link-in-text-block)
== OW3-CDOC: the tenant help page's promises, bound to the code ==
PASS CDOC: every hosted app (47) is a launch app for the help page
PASS CDOC: every launch app resolves to a help page ()
PASS CDOC: a slug we do not publish gets no page
PASS CDOC: every help page price is the catalog rule applied, not a literal ()
PASS CDOC: with no scheduler known, the page does not claim one
PASS CDOC: retention matches bin/backup-tenants.php itself
PASS CDOC: bin/backup-tenants.php still skips the mysql driver (the page says so)
PASS CDOC: on a mysql plane the page states the snapshots do NOT cover the tenant
PASS CDOC: the export still bundles the source tree
PASS CDOC: the export still carries README-FIRST.txt
PASS CDOC: sqlite export = the database file; mysql export = dump instructions
PASS CDOC: the run-it-yourself step is driver-aware too (it assumed a database FILE)
PASS CDOC: the export README no longer promises the whole database unconditionally
PASS CDOC: 'cancelled' is still a status CloudAccount::deleteInstance accepts
PASS CDOC: 'suspended' is still a status CloudAccount::deleteInstance accepts
PASS CDOC: 'expired' is still a status CloudAccount::deleteInstance accepts
PASS CDOC: deletion still refuses while the subscription is live
PASS CDOC: 'trial' is still served by Tenant::isServable
PASS CDOC: 'active' is still served by Tenant::isServable
PASS CDOC: 'past_due' is still served by Tenant::isServable
PASS CDOC: a canceled instance still serves until its paid period ends
PASS CDOC: the sign-in link TTL is read from CloudAccount::LINK_TTL, not typed
PASS CDOC: the instance address is built from the configured app domain
PASS CDOC: the perpetual license vests after twelve paid months
PASS CDOC: there is still NO paid-month counter in the control schema, so the page shows none
PASS CDOC: the impersonate link writes a tenant.support_login audit row
PASS CDOC: the one-click open writes a tenant.support_login audit row
PASS CDOC: the page covers 'Signing in'
PASS CDOC: the page covers 'Where your data lives'
PASS CDOC: the page covers 'Backups'
PASS CDOC: the page covers 'Taking everything with you'
PASS CDOC: the page covers 'Leaving'
PASS CDOC: the page covers '-month license'
PASS CDOC: the page states who can get into the instance, including us
PASS CDOC: the view types no price, no retention window and no TTL of its own
== B13. the MCP door states its method without a key ==
PASS B13: /mcp is routed on the path, not only when the method is already POST
PASS B13: a non-POST /mcp answers 405
PASS B13: …and names the method it takes, as the vet page promises
PASS B13: the method answer comes BEFORE the key check — a keyless GET must still learn it
PASS B13: real MCP work stays POST-only behind a key
== OW3-VET. the plane's agent surfaces are wired ==
PASS VET: /llms.txt is routed to console_llms
PASS VET: /agents.md is routed to console_agents_md
PASS VET: /.well-known/agent.json is routed to console_agent_json
PASS VET: /.well-known/security.txt is routed to console_security_txt
PASS VET: /security.txt is routed to console_security_txt
PASS VET: /agent.json is routed to console_agent_json
PASS VET: console_llms() is defined
PASS VET: console_agents_md() is defined
PASS VET: console_agent_json() is defined
PASS VET: console_security_txt() is defined
PASS VET: console_vet_facts() is defined
PASS VET: console_vet_surfaces() is defined
PASS VET: console_crawler_cache() is defined
PASS VET: llms.txt, agents.md and the agent card all set a crawler cache header
PASS VET: security.txt carries the three fields RFC 9116 requires
PASS VET: the sitemap lists /apps and not only the root (3.3.1: from console_public_paths(), which C11 runs)
PASS VET: the landing page and /apps both pass structured data to the layout
PASS VET: the layout emits ld+json only when a handler supplies it
PASS VET: exactly one ld+json block in the layout, never a duplicate
PASS VET: the /apps ItemList is built from the LIVE apps only
PASS QAS: console_base() derives the console host, not the tenant domain
PASS QAS: console_base() prefers an explicitly configured URL first
== OW3B-B9: tenant lifecycle emails — armed OFF, and honest ==
PASS B9: mail is NOT armed on a config that says nothing about it
PASS B9: a truthy-but-not-true flag does NOT arm it (arming is a decision, not a typo)
PASS B9: only an explicit boolean true arms it
PASS B9: send() REFUSES while disarmed
PASS B9: …and says why, rather than dropping the message silently
PASS B9: …and still composed the subject, so a dry run shows what would have gone
PASS B9: a tenant with no admin_email is refused on the address, not on the flag
PASS B9: 'building' composes with a subject and a body
PASS B9: 'welcome' composes with a subject and a body
PASS B9: 'cancelled' composes with a subject and a body
PASS B9: 'expiring' composes with a subject and a body
PASS B9: 'deletion' composes with a subject and a body
PASS B9: an unknown kind composes nothing
PASS B9: a tenant with no subdomain composes nothing
PASS B9: no email says 'free trial' — CloudCatalog records "No trial anywhere" and the ruling was pay-first
PASS B9: no email says '14-day' — the specific claim ownware_send_welcome_email used to make
PASS B9: no email says 'unlimited' — nothing here is unlimited and the plans say so
PASS B9: no email says 'nightly backup' — no scheduler in this repo installs bin/backup-tenants.php (OW3-CDOC)
PASS B9: no email says 'bank-grade' — marketing security language we cannot evidence
PASS B9: no email says 'money-back' — no refund policy exists in this codebase to point at
PASS B9: the copy is US English (license, never licence)
PASS B9: the welcome states the license term TenantDoc holds, not a typed number
PASS B9: the canceled + expiring notes carry the tenant row's OWN end date
PASS B9: "expiring" REFUSES to compose when the record holds no end date (no invented deadline)
PASS B9: "cancelled" still composes without an end date, and drops the date sentence
PASS B9: canceled really is terminal in the status machine
PASS B9: no email tells a canceled customer they can restart it themselves
PASS B9: the expiring note says plainly that a person has to do it
PASS B9: 'building' links the account page and this app's help page
PASS B9: 'welcome' links the account page and this app's help page
PASS B9: 'cancelled' links the account page and this app's help page
PASS B9: 'expiring' links the account page and this app's help page
PASS B9: 'deletion' links the account page and this app's help page
PASS B9: the welcome names the instance address built from the configured domain
PASS B9: ownware_send_welcome_email no longer promises a 14-day free trial (comments stripped)
PASS B9: …and spells the noun the US way
PASS B9: the comment stripper actually removed the quoted sentence from the scanned text
PASS B9: past_due and suspended are named as deliberately unsent, with reasons
PASS B3: API.md ships
PASS B3: API.md carries the MCP section under the fleet heading
PASS B3: API.md carries no copy-paste artifacts from another app
PASS B3: every tool the code serves is in API.md, and every tool API.md names is served
PASS B3: API.md does not promise a REST API the kit does not have
PASS B3: the kit still serves a read-only tool set, as API.md states
PASS F2: the Plans page claims enforcement only because the plane enforces (PlanGuard wired before the app runs)
PASS F2: the README names both billing providers the code runs, and the true adapter count (48)
PASS F2: the README carries the ownware.io license and support route, not marketplace-era text
PASS F2: the demo console explains "Open admin" once above the grid, not once per card
PASS S6 forms: every POST form on a console screen reaches a registered route (29 checked)
PASS S6 backups: nobody but the operator gets the plane backup (anonymous, a signed-in customer, a signup in flight)
PASS S6 backup JSON: the operator gets a backup, with every credential column planted (10)
PASS S6 backup JSON: it carries none of them — no tenant key, no password, no sign-in link
== RELEASE 2026-09-24: the CLI scripts reach Control without CloudSignup ==
PASS CLI: Control::subdomainTaken() runs with only helpers.php + Control.php loaded (bin/demo.php's order)
== P. Plane ops: the hand patches as kit code ==
PASS P1: on an empty plans table every hosted plan is an insert (got array (
0 => 'insert',
))
PASS P1: the dry run writes nothing (got 0)
PASS P1: after seeding, every row reads unchanged (got 0)
PASS P1: a price that drifted shows old -> new (the catalog's $19 rule, never invented) (got array (
0 => 'price 1200 -> 1900',
))
PASS P1: an inactive row with a trial shows both moves (got array (
0 => 'reactivate',
1 => 'trial 14 -> 0',
))
PASS P1: an active hosted plan outside the set shows deactivate (got array (
0 => 'deactivate',
))
PASS P1: seeding settles all three (got 0)
PASS P2: the control DB runs in WAL mode (why copy() was not a backup) (got 'wal')
PASS P2: the snapshot holds the latest write, still in the WAL (got 1)
PASS P2: a snapshot never overwrites an existing file
PASS P3: a whole live wiring has no problems (each plan its own variant link and id) (got array (
))
PASS P3: the 09-13 "live shape" (one PRODUCT link for every plan, distinct ?enabled=) is refused: rendered, it charged the first variant, $19, no app named (2026-09-26)
PASS P3: a hosted plan with no URL yet is coming soon, not a fault (every app is wired one by one)
PASS P3: ... and it is counted as unwired (got array (
0 => 'gymora',
))
PASS P3: --require-all makes it a fault, named (the check before a flip)
PASS P3: an unwired app with no plan row and no adapter cannot be bought, so it is not half-built (no fault)
PASS P3: ... --require-all names both, because a flip would sell it
PASS P3: a URL for a plan outside the hosted set is named
PASS P3: a URL that is not https /checkout/buy/<uuid> is refused
PASS P3: a URL with no ?enabled= variant is refused (the page would show a picker)
PASS P3: two plans on one variant are refused (one would sell the other)
PASS P3: the 09-06 broken test-map shape (every id but one selecting nothing) refuses every one of them (got 46)
PASS P3: a hosted app with no active plan row is named, with the fix
PASS P3: a hosted app whose adapter file is missing is named
PASS P3: a hosted app whose own code is not on the box is named (a buyer would pay for an instance that cannot be built)
PASS P3: live with no mail is refused (a buyer would pay and hear nothing)
PASS P3: ... unless --no-smtp says so out loud
PASS P3: live with no https base_url is refused
PASS P3: live with console 2FA off is refused
PASS P3: live on the sample signing secret is refused
PASS P3: test mode with no test secret is refused
PASS P3: the mode defaults to the config's own (test here: live-only rules do not apply)
PASS P3: an API key path with no store id is refused
PASS P3: an API key path that does not exist is refused (silent fallback to the static map)
PASS P3: an empty API key file is refused
PASS P3: a key file group/other can read is refused
PASS P3: a 600 key file with a store id is whole (web-user check skipped) (got array (
))
PASS P3: a root-owned 600 key file the web user cannot read is refused (measured on the plane 09-13)
PASS P4: wire(test) switches to test mode, fills the test map, keeps the live map
PASS P4: wire(live) switches to live, fills the live map, leaves the test map as the record
PASS P4: no secret given = the secret is untouched
PASS P4: the API key goes in as a PATH, never a key
PASS P4: switch=false STAGES the test map on a live plane: the plane stays live, the live map is untouched
PASS P4: an unknown mode throws
PASS P5: a secret change with nothing pending is allowed (got NULL)
PASS P5: a secret change with a pending reservation is refused, even an old one (the webhook finds it by token, at any age)
PASS P5: no secret change = nothing to refuse (got NULL)
PASS P6: the rendered config reads back as the same array, and a */ in the note cannot end the comment early
PASS P7: cloud-config refuses to run on a DB with no schema
PASS P7: seed-plans on a DB with no schema says so and exits 2 (it used to die in a stack trace)
PASS P7: --register-adapters --dry names the 42 missing adapters and writes nothing
PASS P7: --register-adapters backs up, writes, reads back: every hosted app registered
PASS P7: a second --register-adapters has nothing to write
PASS P7: back-to-back writes keep two backups, and one of them is the config from before both
PASS P7: seed-plans --dry lists every hosted plan as an insert and writes nothing
PASS P7: seed-plans snapshots first, then writes and reads back (47 active)
PASS P7: a second run finds nothing to write and takes no second snapshot
PASS P7: --check on a live config with no URLs yet is whole, and counts every app as coming soon
PASS P7: --check --require-all exits 1 and names the unwired
PASS P7: --mode=live --dry checks the result whole and writes nothing
PASS P7: --mode=live backs up, writes, and reads back
PASS P7: the summary never prints a secret
PASS P7: --mode=test --stage fills the test map and the plane stays LIVE
PASS P7: --check --mode=test --require-all checks the staged test map whole
PASS P7: --merge sets the platform's face (brand, support_email) and reads it back
PASS P7: --merge refuses a key with its own gate (billing), and writes nothing
PASS P7: --check --require-all is now whole
PASS P7: a map that shares a variant is refused and config.php is untouched
PASS P7: a secret change while a reservation is pending is refused (P5, through the CLI)
PASS P8: console_intent_on() needs both the store URL and the shared secret
PASS P8: forward not configured: no address field on the coming-soon card (got 0)
PASS P8: ... and no consent sentence for a list the visitor cannot join
PASS P8: ... the card keeps its way to buy outright
PASS P8: forward configured: the field is on the coming-soon card only (got 1)
PASS P8: ... with its consent sentence
PASS P9: a tenant session dir is created 0700 for a valid subdomain
PASS P9: a subdomain that is not a clean label gets no dir (no path tricks)
PASS P9: A's session id opened on B finds nothing (was: B's admin) [B empty]
PASS P9: ... while A still has its own session [A sees uid 1]
PASS P9: ... and the product's own session_save_path() did not move it: the file is in A's store only
PASS P9: the plane installs the tenant store BEFORE it requires the product (whose boot starts the session)
PASS P9: no private store = the request is refused, never served from the shared one
PASS P10: the copy takes the code and the data/ skeleton, nothing else (got array (
0 => 'data',
1 => 'data/.htaccess',
2 => 'data/imports',
3 => 'data/uploads',
4 => 'data/uploads/.gitkeep',
5 => 'index.php',
6 => 'src',
7 => 'src/a.php',
8 => 'src/config.php',
9 => 'views',
10 => 'views/old.php',
))
PASS P10: a subdomain that is not a clean label gets no copy
PASS P10: ensure() builds the tenant copy and answers its path (got '/tmp/sk-planeops-b39580fce3/tenant-code/alpha/app')
PASS P10: ... with the code, and none of the demo/store data (no DB, no WAL, no visitor upload, no local config)
PASS P10: a tenant's upload lands in its own data/, and the other tenant does not have it
PASS P10: files() lists the uploads, not the stubs, DBs or sessions (got array (
0 => 'uploads/receipt.pdf',
))
PASS P10: within the check interval the copy is not re-read (no per-request walk)
PASS P10: after it, a changed file is brought in line
PASS P10: ... a file the new version dropped is removed
PASS P10: ... and the tenant's files are untouched
Warning: Undefined array key "db_name" in src/Provisioner.php on line 40
Warning: Undefined array key "db_name" in src/Provisioner.php on line 40
PASS P10: the export carries the uploads under files/
PASS P10: ... the source is the tenant's copy (v2), and still no data/ in source/
PASS P10: ... and its README says where the files go
PASS P10: deleting an instance removes its copy and every file it held
PASS P10: the plane runs the tenant's copy, not the shared folder, and refuses when there is none
PASS P10: the nightly backup takes each instance's files too, on the same rotation
PASS P11: no page, sheet or script loads a font from Google ()
PASS P11: the three families the Cloud face uses are declared locally, and each file is a real woff2
PASS P11: each font ships with its OFL license
PASS P12: trial days come from billing.trial_days, 0 by default, capped
PASS P12: with no trial every offer line says pay first, and no line mentions a trial
PASS P12: with 14 days every line says so, the card and the one-per-email rule included
PASS P12: case and a +tag do not make a new address (got 'jo.smith@example.com')
PASS P12: ... so both hash to the same trial
PASS P12: a subscription that starts on_trial makes a trial tenant (got 'trial')
PASS P12: ... that serves
PASS P12: ... and the address is recorded as having had its trial
PASS P12: a second trial for the same address (other case, other +tag) is refused at signup
Warning: Undefined array key "id" in src/CloudWebhook.php on line 87
PASS P12: subscription_updated to active (the first paid renewal) makes the trial tenant active (got 'active')
PASS P12: subscription_payment_success does the same (got 'active')
PASS P12: the $0 invoice that starts a trial leaves the tenant on trial (got 'trial')
PASS P12: the first real charge makes it active (got 'active')
PASS P12: subscription_updated still on_trial changes nothing (got NULL)
Warning: Undefined array key "id" in src/CloudWebhook.php on line 87
PASS P12: canceled during the trial: status canceled, still serving until the trial ends
PASS P12: ... and walled once it has ended
PASS P12: the signup form itself refuses a second trial for that address, and reserves nothing
PASS P12: a new address gets through the same form
PASS P12: ... and that address, still in flight, cannot start a second trial meanwhile
PASS P12: with no trial on offer, the one-per-email gate does not apply (pay-first signups are not limited)
PASS P12: the building email says the trial started and when the card is charged
PASS P12: with no trial it still says the payment is confirmed
PASS P12: llms.txt states the trial in one machine-readable line, for the store's offering sync
PASS P12: past_due (LS still retrying) keeps the instance serving (got 'past_due')
PASS P12: unpaid (the last retry failed) walls it: suspended, not served
PASS P12: ... and a recovered payment resumes it (got 'active')
PASS P12: plans carry the trial days they are seeded with (got 14)
PASS P13: with no brand config the pages name no maker (no "ownware" anywhere)
PASS P13: ... and link to no outside site ()
PASS P13: ... the name is the install's own domain, and the menu reaches its own apps and account
PASS P13: a brand config sets the wordmark, the menu (with its classes), the footer columns and the footnote
PASS P13: the brand name is escaped (no markup from config)
PASS P13: support links go to the brand's page, else the customer's account page (got array (
0 => 'https://acme.example/help',
1 => 'https://c.example/account',
))
PASS P13: with no catalog file, /apps sells the install's OWN registered products, not the maker's catalog
PASS P13: naming the bundled catalog (as the Ownware plane does) brings the 49 back
PASS P13: no shipped file says "Ownware Cloud" or "ownware.io" outside the documented example ()
PASS P14: off by default: nothing is sent or deleted
PASS P14: a dry run names who would be told and who deleted, and changes nothing
PASS P14: the owner is told once; the run came 6 days before the date, so the date moves out to a full week after the notice (got array (
0 =>
array (
0 => 'deletion',
1 => 'rnotice',
2 => '2026-09-28',
),
))
PASS P14: ... and the notice is recorded (not sent twice)
PASS P14: past its date and told a week ago: deleted (row, database, app copy and files)
PASS P14: ... the audit keeps the fact, and the control DB was snapshotted first
PASS P14: past its date but told only 2 days ago (unpaid, walled since suspended_at): waits for the full week
PASS P14: served and recently walled instances are untouched
PASS P14: the deletion email names the date and the export
PASS P14: without a date it says nothing
PASS P14: the cancellation wording follows the setting: deletion in N days when on, "not deleted" when off
PASS P14: the help page says the same
PASS P15: /t/<tenant>/ is routed ONLY in the public demo (behind the .demo-mode marker, never in a customer zip)
PASS P15: the tenant answers with /t/<sub> as its base path (products route and build URLs from base_url)
PASS P15: an unknown demo tenant path is a 404, not the console
PASS P15: the demo's Open admin signs the visitor into that tenant's own instance under /t/<sub>/ with the single-use token
PASS P15: the hourly demo reset also removes each tenant's app copy (and anything a visitor uploaded)
PASS P16: a tenant's own CSS, nested CSS and service worker are served from its own folder, with their types
PASS P16: never PHP, config, data/, code folders, hidden files or a path that climbs out
PASS P16: a path that is not a file falls through to the app's routing
PASS P16: the console serves only its own listed paths
PASS P16: If-Modified-Since at or after the file answers 304 (got 304)
PASS P16: the tenant dispatch serves the tenant's own files from its copy before the app runs
PASS P16: .htaccess no longer lets Apache hand out the plane's real files: everything but the installer reaches index.php
PASS P16: the copy walker skips a folder it cannot read instead of failing the tenant
PASS P16: Message-ID and EHLO use the sender address's domain (got array (
0 => 'acme.example',
1 => 'acme.example',
2 => 'localhost',
))
PASS P16: no mail names the maker's store app
PASS P17: with no landing config the page makes none of the maker's promises (no license, no twelve months, no maker name)
PASS P17: ... and no promise strip at all
SKIP P17: the Ownware plane's landing (deploy/vps/plane-brand.php is not next to this copy)
PASS P17: on a trial the signup section, its fine print and its button say trial, not "pay for the first month"
PASS P17: the count of apps is computed, not typed
PASS P18: a hostname is normalized (case, port, trailing dot) (got 'booking.brighthair.com')
PASS P18: not a hostname: IPs, single labels, junk
PASS P18: a tenant cannot claim the app domain or anything under it (those are tenants)
PASS P18: verified by a CNAME to the tenant's own address
PASS P18: ... or by the same A records (an apex that cannot CNAME)
PASS P18: a CNAME to ANOTHER tenant, or no record at all, verifies nothing
PASS P18: an UNVERIFIED claim serves nothing (a Host header proves nothing)
PASS P18: once verified, the domain resolves to its tenant
PASS P18: the tenant's emails give its verified custom address
PASS P18: requests resolve a custom domain only outside the app domain, and only a verified one
PASS P18: the TLS ask approves a verified custom domain and nothing unverified
PASS P18: setting and verifying a domain are owner-only, CSRF-checked and audited
PASS P19: at the staff limit, the request that adds one is refused with the numbers
PASS P19: under the limit it goes through
PASS P19: other requests (editing, reading, a GET) are never blocked, even over the limit
PASS P19: no limit (0 or absent) = unlimited: a plane whose plans meter nothing is unchanged
PASS P19: upload sizes are summed for single and multi-file fields (got 20)
PASS P19: an upload that would pass the storage limit is refused; a smaller one goes through
PASS P19: the counter counts in the tenant's own database (got 2)
PASS P19: a counter that cannot count never locks a tenant out (got 0)
PASS P19: Invora's adapter declares its limits (staff on invite, clients on create)
PASS P19: the plane checks the limits before the app runs, and only when the plan sets some
== 4.0: the Update button is wired (console only) ==
PASS updates: index.php names Updater/BackupRestore only on the console path, after every tenant dispatch (the Totp lesson)
PASS updates: the updater and its backup load beside Database and AuditLog, with require (never on a tenant path)
PASS updates: Control::migrate() never touches the updater (it runs before tenant dispatch)
PASS updates: the boot migrate waits while an update is in flight (a plain file test, no updater class)
PASS updates: a hosted instance answers 503 "back in a minute" while the kit is being swapped
PASS updates: bin/update.php settles a pending finish before it migrates
PASS updates: stale compiled code is cleared right after the updater loads
PASS updates: the table is ensured beside the audit table, and an update is finished or rolled back before any console page
PASS updates: the resume hook migrates the control database and checks the owners table
PASS updates: the controller loads after the console controller and before dispatch
PASS updates: route get /admin/updates is console_updates()
PASS updates: console_updates() opens with the owner gate
PASS updates: route post /admin/updates/key is console_updates_key()
PASS updates: console_updates_key() opens with the owner gate and the CSRF check
PASS updates: route post /admin/updates/check is console_updates_check()
PASS updates: console_updates_check() opens with the owner gate and the CSRF check
PASS updates: route post /admin/updates/apply is console_updates_apply()
PASS updates: console_updates_apply() opens with the owner gate and the CSRF check
PASS updates: every POST is audited
PASS updates: the page is told why updates are off here, the deploy-plane.sh console included (F5 A1 pass)
PASS updates: the admin menu links to the page
PASS updates: .plane-managed turns the button off (enabled() also refuses a Cloud tenant and the public demo)
PASS updates: Check and Apply refuse on a console where updates are off, before any store contact
PASS updates: bin/update.php refuses on a deploy-plane.sh console, before loading anything
PASS updates: bin/update.php refuses the web and finishes in a fresh process
PASS updates: the release public key is pinned
PASS updates: the kit carries the backup core the updater needs (BackupRestore::writeScheduled)
703 passed, 0 failed
ALL GREEN