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

← Back to SaaS Kit · Quickstart · API

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →