All Phase 2 tickets verified on beta
Every ticket in the Phase 2 stack behaves as specified on the beta environment. Twenty-four automated checks passed. One minor pre-existing bug and one infrastructure finding were recorded, together with three observations. Neither finding blocks the phase.
What was verified
One row per ticket in the Phase 2 stacked chain. Each result links to the evidence section below.
| Ticket | Scope | Result | Evidence |
|---|---|---|---|
| SP-609 | De-branded tenant shell — one static bundle serves every tenant (PR #288) | PASS | Section 4.1 |
| SP-557 | Banner management API and admin surface (PR #289) | PASS | Section 4.2 |
| SP-610 | Landing strategy resolved from tenant configuration (PR #290) | PASS 1 OBSERVATION | Section 4.3 |
| SP-611 | Shared landing template for non-default tenants (PR #291) | PASS | Section 4.4 |
| SP-476 | Managed subdomain activation gate (PR #292) | PASS | Section 4.5 |
| SP-612 | Staff invitations backend — issue, revoke, accept (PR #293) | PASS | Section 4.6 |
| SP-613 | Staff invitations user interface — tenant admin, platform admin, accept page (PR #294) | PASS | Section 4.7 |
Check breakdown
The figure of 24 is the count of discrete automated checks. It is not inflated: each row below is a distinct assertion against a live beta endpoint or a distinct verified browser state.
| Area | Checks |
|---|---|
| Deployment currency (backend run, frontend deployment) | 2 |
| Tenant shell and branding | 2 |
| Banners (public endpoint, admin endpoint) | 2 |
| Bootstrap and landing strategy | 4 |
| Activation gate | 3 |
| Invitations API | 7 |
| Invitations user interface and browser states (one per screenshot-verified state) | 4 |
| Total | 24 |
The database and log audits described in section 5 are reported separately and are not counted in the 24.
Deployment currency
Before any behavioural check, the beta environment was confirmed to be running the head of main.
Beta runs merge commit 83eecba1
- Check 1. Backend deployment for
83eecba1succeeded and is the active Cloud Run revision. - Check 2. Frontend deployment for the same commit is ACTIVE with all five components healthy.
Evidence by ticket
Each card records the checks executed, the observed responses, and the supporting screenshots.
SP-609 — De-branded tenant shell
A single static bundle must serve every tenant, with no SkillPixel branding baked into the served HTML.
-
Check 3.
curl https://historyclass.beta.skillpixel.vn/returns the tenant-neutral static shell. The served document carries the HTML comment “Tenant-neutral: one bundle serves every tenant…” and contains zero case-insensitive matches for “SkillPixel”. -
Check 4. The runbook browser console check on the rendered page returns tenant-specific
metadata with no SkillPixel body text:
{ "title": "HistoryClass - Nền tảng học tập", "description": "Khám phá các khoá học được xuất bản bởi HistoryClass.", "ogSiteName": "HistoryClass", "hasSkillPixelBodyText": false }
SP-557 — Banner management
- Check 5.
GET /api/v1/bannerswith headerX-Tenant-Host: beta.skillpixel.vnreturns 200. - Check 6.
GET /api/v1/banners/adminwith a platform-admin token returns 200. -
The admin surface at
/admin/marketingrenders the banner list in a clean browser: three banners with active and default badges, together with the add and reorder controls. The screen is also reachable on the HistoryClass host.
/admin/marketing in a clean browser. Banner list with three banners, active and default badges, and the add and reorder controls. The stale-cache variant of this route is documented in INFRA-1.SP-610 — Landing strategy from tenant configuration
-
Check 7.
GET /api/v1/tenant/bootstrapwithX-Tenant-Host: historyclass.beta.skillpixel.vnreturns 200:{ "slug": "historyclass", "is_default": false, "landing_strategy": null, "seo.site_name": "HistoryClass" } - Check 8. Default-tenant bootstrap with
X-Tenant-Host: beta.skillpixel.vnreturns 200 withis_default: trueand site name SkillPixel. - Check 9. An unknown host returns 400.
- Check 10. The default landing page is unchanged.
Observation O1. No landing.strategy platform-config entry exists on beta for
HistoryClass: GET /platform/config/effective returns null. The frontend therefore uses its
deterministic fallback, under which non-default tenants render shared-template. This behaviour is
correct by design. The product decision — a bespoke historyclass page versus the shared template —
remains open, and either outcome requires an explicit configuration row.
beta.skillpixel.vn, unchanged. The middle sections appear blank only because lazy-loaded sections do not render in a full-page capture without scrolling. The footer copyright line shows BUG-1.SP-611 — Shared landing template
- The HistoryClass landing page renders the hero with the title “HistoryClass” and the call to action “Khám phá khoá học”, a featured-courses section containing three courses, a tenant-branded header and footer, and the brown theme colours drawn from the tenant branding record. Desktop and mobile evidence appear in figures 01 and 02.
-
The announcement block is not configured on beta — the
tenants.landingJSON contains no announcement — so no announcement is shown. This is the expected result.
SP-476 — Managed subdomain activation gate
- Check 11. A throwaway tenant
gate-check-beta-verifywas created viaPOST /platform/tenants/→ 201, status PROVISIONED. -
Check 12.
PATCHof the status to ACTIVE returns 409 and enumerates every missing prerequisite rather than only the first:Tenant cannot be activated: an active or verified managed subdomain is required; a tenant admin membership is required - Check 13. The throwaway tenant was deleted afterwards via
DELETEwith the confirmation slug → 204. -
HistoryClass itself has been ACTIVE since 2026-07-16, from the earlier rehearsal, with an ACTIVE and verified
primary managed subdomain
historyclass.beta.skillpixel.vn. The domain serves correctly over HTTPS through Cloudflare.
SP-612 — Staff invitations backend
All checks were executed against tenant HistoryClass, 7c1e9f42-3b6a-4d58-9e10-2f4c8a7b5d31.
- Check 14.
POSTinvitation fornpkhang99@gmail.comwith role INSTRUCTOR returns 201, status PENDING, expiry 2026-08-29 (seven days). The response carries the one-time token. - Check 15. A duplicate invitation for the same address returns 409, “A pending invitation already exists for this email”.
- Check 16.
POSTinvitation forcuroa99@gmail.comwith role STUDENT returns 201. - Check 17. Revoking that invitation returns 200 with status REVOKED.
- Check 18. Accepting with a bogus token returns 404, “Invitation not found”.
- Check 19. Accepting the revoked token returns 409, “Invitation has been revoked”.
- Check 20. Accepting the valid token while signed in as the invitee
npkhang99@gmail.comreturns 200 with status ACCEPTED. The platform membership list then showsnpkhang99@gmail.comwith exactly["INSTRUCTOR"], and/users/meon the HistoryClass host returnstenant_roles: ["INSTRUCTOR"]. - Invitation e-mail delivery is fire-and-forget after commit, with failures logged. Missing e-mail infrastructure — Phase 3, SP-614 — therefore cannot fail the API. This matches the design.
SP-613 — Staff invitations user interface
- Check 21. The tenant-admin screen
/admin/invitationson HistoryClass shows the sidebar entry “Lời mời nhân sự”, the invite form with e-mail, role and send, and the table listingcuroa99as REVOKED andnpkhang99as PENDING with resend and revoke actions. - Check 22. The platform-admin surface — lms-admin staging on Vercel — shows an Invitations tab for tenant HistoryClass listing both invitations with role, status, expiry and inviter, plus a send-invite form.
- Check 23. The accept page in the signed-out state presents a card asking the visitor to sign in, “Đăng nhập để nhận lời mời”.
- Check 24. The accept page, opened while signed in as a different account, shows “Sai tài khoản”, names the signed-in e-mail address, and offers a sign-out-and-switch action.
- Both accept-page states are fully tenant-branded.
/admin/invitations on HistoryClass. Sidebar entry “Lời mời nhân sự”, invite form, and the invitation table showing one REVOKED and one PENDING row.
Database and log audit
Reported separately from the 24 checks. The purpose is to confirm that tenant isolation held during and after the deployment.
No cross-tenant leakage, no errors since the deployment
- Hot tables —
courses,chapters,lessons,questions— partition cleanly across the default tenant and HistoryClass. Zero default-tenant rows were created after the deployment cutoff of 2026-08-22T17:30Z. tenant_default_fallback_writehas zero occurrences in Cloud Run logs over the last seven days. This is the SP-478 Gate C metric. The gate's own seven-day window started 2026-08-20, so the gate completes on 2026-08-27.tenant_non_default_requestmarkers are present with the correct tenant identifier and hostname for HistoryClass traffic, on both bootstrap and courses requests.- Zero Cloud Run log entries at severity ERROR or above since the deployment.
Tenant and domain table state
| Tenant | Tenant status | Primary domain | Domain state |
|---|---|---|---|
skillpixel | ACTIVE | beta.skillpixel.vn | ACTIVE, verified |
historyclass | ACTIVE | historyclass.beta.skillpixel.vn | ACTIVE, verified |
tenant-a-rehearsal | ACTIVE | tenanta.beta.skillpixel.vn | ACTIVE, never verified — see O2 |
Findings
One bug, one infrastructure finding and three observations. Jira keys for the two items requiring tickets are placeholders until the tickets are filed.
BUG-1 — Footer copyright year is hardcoded
The footer copyright year is hardcoded as “© 2025” in
apps/frontend/src/components/layout/Footer.tsx at line 126. Every tenant therefore displays
“© 2025 <site name>” during 2026. The defect predates Phase 2 and affects the default tenant as well.
INFRA-1 — CDN may serve a pre-deployment index.html for up to 24 hours
The beta frontend serves index.html with
cache-control: public, max-age=10, s-maxage=86400. The DigitalOcean CDN may therefore serve a
pre-deployment index.html, and consequently an old bundle, for up to 24 hours after a release.
This was observed in practice. Approximately 25 minutes after the deployment, a warm real-Chrome profile was
redirected to /not-found on /admin/marketing twice, while a clean headless browser
rendered the same route correctly.
s-maxage for index.html. Jira: SP-624
O1 — landing.strategy configuration entry absent on beta
No landing.strategy entry exists for HistoryClass on beta, so the frontend falls back to
shared-template for non-default tenants. The behaviour is correct. The product decision between a
bespoke historyclass page and the shared template is still open, and either outcome requires an
explicit configuration row. See section 4.3.
O2 — Leftover rehearsal tenant tenant-a-rehearsal
tenant-a-rehearsal is a leftover Phase 1 rehearsal tenant. It is ACTIVE with a domain that was
activated but never verified, and it has no DNS. The state is harmless on beta. It should be cleaned up before
any production rehearsal parity check.
O3 — Non-default tenants inherit the generic platform tagline
Confirmed during this verification: non-default tenants still inherit the generic platform tagline copy in the footer — “Nền tảng giáo dục AI kết nối tri thức Việt” appears beneath the HistoryClass wordmark.
Test residue and cleanup
All residue on beta is intentional and recorded here so that a later reader does not mistake it for real data.
Two invitation history rows; everything else restored
- HistoryClass invitations.
npkhang99@gmail.comremains as an ACCEPTED history row andcuroa99@gmail.comas a REVOKED history row. The membership created by the accepted invitation was deleted afterwards, and memberships were restored to the original two administrators. - Throwaway tenant.
gate-check-beta-verifywas created for the activation-gate check and deleted afterwards. - Untouched. No configuration, branding, banner or domain rows were changed.
Method
Checks were API-driven: curl against the beta Cloud Run API using a platform-admin Firebase token.
The database audit was performed with psql against the beta Supabase database. The log audit was
performed with gcloud. Screenshots come from a real Chrome walkthrough together with a clean
headless Chromium session, the latter used to separate cached-bundle behaviour from application behaviour.
Verified by Claude (Fable 5) on 2026-08-23, ICT.