Home›SaaS Kit›Test run
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

← Back to SaaS Kit · Quickstart · API