SaaS Kit · Test run · run 2026-09-25
SaaS Kit Test run, as shipped in the download
416 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 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: normalised (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 cancelled 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 → cancelled (got 'cancelled')
PASS cancelled + activate stays cancelled (terminal) (got 'cancelled')
PASS cancelled + resume stays cancelled (got 'cancelled')
PASS cancelled + payment_succeeded stays cancelled (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: cancelled -> 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: cancelled 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: AuditLog byte-identical to the reference copy
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.1.13), no typed version left
PASS v3: backup redaction covers token_hash/secret/api_key
PASS v3: sw cache is static-v3.1.13 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: the launch catalogue seeds exactly 12 hosted plans (got 12)
PASS C1: no plan carries a trial (trial_days 0 everywhere) (got 0)
PASS C1: rentara is the only $19 plan (one-time >= $119 rule); the other eleven are $12 (got array (
0 => 1,
1 => 11,
))
PASS C1: the twelve = the four live + the next eight by demo clicks among the 27 adapters
PASS C1: the catalogue 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 cancelled with ends_at recorded; still servable until then
PASS C4: past ends_at the instance is NOT servable (the wall)
PASS C4: a cancelled 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 enrol (every path but security/logout)
PASS DEMO 2026-09-25: the public demo seats the visitor without a 2FA wall (it opened on the enrolment 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 footer links terms, privacy and refunds on the store, plus contact, on every page
PASS C6: the header links the catalogue, the account and the store
PASS C6: the landing carries no trial or free-days words anywhere
PASS C6: the landing says payment comes first and cancel/export live in the account
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: OYA 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 colour 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 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
== OW3-CDOC: the tenant help page's promises, bound to the code ==
PASS CDOC: the launch twelve is twelve
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 catalogue 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 cancelled 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 licence 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 licence'
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
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: 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 British English (licence, not license)
PASS B9: the welcome states the licence term TenantDoc holds, not a typed number
PASS B9: the cancelled + 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: cancelled really is terminal in the status machine
PASS B9: no email tells a cancelled 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: 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 British 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 does not promise quotas that no shipped adapter enforces
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 licence 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 (23 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)
416 passed, 0 failed
ALL GREEN