Appearance
Deploying
The app is a Nuxt SPA built for Cloudflare Pages (nitro.preset: "cloudflare_pages") and backed by Firebase (Auth + Firestore). A deploy has two independent halves:
- Firestore rules + indexes → the tenant's Firebase project (
firebase deploy). - App bundle → the tenant's Cloudflare Pages project (
wrangler pages deploy).
Always deploy (1) before (2) when firestore.rules changed, otherwise the new app runs against the old rules and gets permission-denied. The exception is PII lockdown phase 2: app first, then rules (see "Phase 2 deploy order"). The staging CI always deploys the app first.
Environments
Every tenant has its own Firebase project and its own Pages project. The Firebase config is read from a .env* file at build time and baked into dist/_worker.js, so one build targets exactly one Firebase project.
| Env | Env file | Firebase project | Pages project | URL |
|---|---|---|---|---|
| Staging (PO review) | .env | multi-tenant-roulette | minigame-saas | https://minigame-saas.pages.dev |
Add a row here when you set up another environment. The name in wrangler.toml (gomini-minigame) doesn't match any Pages project, so always pass --project-name.
CI (GitHub Actions)
Two workflows in .github/workflows/. Only staging and the docs site are automated. Tenant and demo deployments stay manual (the sections below).
| Workflow | Runs on | What it does |
|---|---|---|
staging.yml | every PR into main, every push to main, manual | verify: unit tests, rules tests (Firestore emulator), a build. On push to main or manual run, then app: build against the staging Firebase config, check the build targets multi-tenant-roulette, deploy to minigame-saas. Then rules: deploy Firestore rules + indexes to multi-tenant-roulette. |
docs.yml | PRs and pushes to main that touch docs/**, manual | Builds the docs site (fails on dead links). On push to main, deploys it to minigame-docs. A manual run from another branch deploys a preview at https://<branch>.minigame-docs.pages.dev. |
Order: app, then rules. The PII phase 2 rules lock the rewards collection, so the app that no longer needs it must be live first (see "Phase 2 deploy order"). Phase 1 rules are additive, so an app that runs briefly on the old rules is fine on staging. If the app deploy fails, the rules job is skipped. A deploy that fails half way shows in the run summary: the app and rules jobs each report their own result.
A merge to main overwrites a manual staging deploy. Deploy a branch to staging by hand only while you accept that the next merge replaces it.
Re-run: Actions → staging (or docs) → Run workflow.
Secrets and variables
Add these under GitHub → Settings → Secrets and variables → Actions. A deploy job checks them first and fails naming the ones that are missing.
| Name | Kind | Used by | Where to get it |
|---|---|---|---|
CLOUDFLARE_API_TOKEN | secret | staging, docs | Cloudflare → My Profile → API Tokens → Account → Cloudflare Pages → Edit. It covers every Pages project in the account (accepted). |
CLOUDFLARE_ACCOUNT_ID | secret | staging, docs | Cloudflare dashboard, right sidebar. Currently 3063aaeb627e5984e754f39742697d0f. |
FIREBASE_SERVICE_ACCOUNT_STAGING | secret | staging (rules) | The whole JSON key of a service account in multi-tenant-roulette with Firebase Rules Admin and Cloud Datastore Index Admin (plus Service Usage Consumer if the deploy asks for it). |
NUXT_PUBLIC_VUEFIRE_CONFIG_API_KEY, _AUTH_DOMAIN, _PROJECT_ID, _STORAGE_BUCKET, _MESSAGING_SENDER_ID, _APP_ID | secrets | staging (build) | The staging .env: Firebase console → Project settings → Your apps. |
CLOUDINARY_CLOUD_NAME, CLOUDINARY_UPLOAD_PRESET | secrets | staging (build) | The staging .env. |
CLOUDINARY_ROOT_FOLDER | variable, optional | staging (build) | Only if staging uses a Cloudinary root folder. |
Pull requests from forks get no secrets: the verify build uses placeholder config, and the deploy jobs don't run.
Docs site
docs/ is a VitePress site with Mermaid support. It has its own docs/package.json and lockfile, separate from the app's. Local: bun run docs:dev (live reload) or bun run docs:build. Production is the Pages project minigame-docs (https://minigame-docs.pages.dev), created once with wrangler pages project create minigame-docs --production-branch main. Deploy by hand: bun run docs:build && bun run deploy:docs.
The docs are public. Don't put credentials or private customer data in docs/. Markdown files under docs/superpowers/ are agent plans and specs and are left out of the site.
Prerequisites
- Accounts. Log both CLIs in as an account that can reach the target projects (staging:
vunguyen4920@gmail.com):bashbunx wrangler login bunx wrangler whoami bun --bun firebase login:add # or: bun --bun firebase login --reauth bun --bun firebase projects:list # target project must be listed - Toolchain. bun and Node from
.tool-versions(bun installfirst). Build, dev, tests and firebase-tools run on the bun runtime; wrangler runs on Node (bunx wrangleruses its shebang). See the README setup section. - Firebase Auth authorized domain. In the Firebase console → Authentication → Settings → Authorized domains, add the Pages domain (e.g.
minigame-saas.pages.dev). Without it, admin sign-in fails on that URL.
1. Verify
bash
bun run test
bun run test:rules2. Deploy Firestore rules + indexes
bash
bun deploy:firestore --project multi-tenant-rouletteAbout firestore.indexes.json:
- It is the source of truth for composite indexes. Deploying creates any index in the file that the project lacks.
- Indexes that exist in the project but not in the file trigger a delete prompt (skipped under
--non-interactive; deleted with--force). Answer No unless you mean it. - Before deploying to a project for the first time, diff its live indexes against the file:bashand merge anything missing into the file first.
bun --bun firebase firestore:indexes --project <project-id> - New indexes take a few minutes to build. Queries that need them fail with
failed-preconditionuntil they're ready (status in the console → Firestore → Indexes). - Adding a query that combines
whereon one field withorderByon another (e.g.rewards:claimed ==+timestamp desc) needs a new entry here.
3. Build + deploy the app
bash
# Build against the env's Firebase project. `.env` is the default;
# for another env use: bun run build --dotenv .env.<name>
bun run build
# Confirm the right project got baked in
grep -l "multi-tenant-roulette" dist/_worker.js/chunks/nitro/nitro.mjs
# Upload. --branch main = the Pages project's production branch.
bunx wrangler pages deploy dist --project-name minigame-saas --branch main --commit-dirty=trueOr: bun deploy:pages --project-name minigame-saas --branch main --commit-dirty=true.
Runtime caveat: NUXT_PUBLIC_VUEFIRE_CONFIG_* variables set on the Pages project (dashboard → Settings → Variables) override the baked-in config at runtime. Keep them unset unless you intend that.
To create a new Pages project for a new environment:
bash
bunx wrangler pages project create <name> --production-branch main4. Smoke test
/loads the game with content from Firestore./loginshows the admin login;/adminredirects there when signed out.- Sign in with an admin account, open the admin pages touched by the release.
- Browser console has no
permission-denied/failed-preconditionerrors.
Grant or change a role on the target project (needs that project's service-account key path in FIREBASE_SERVICE_ACCOUNT in the env file). The script reads only the chosen env file (.env by default) and ignores whatever bun auto-loaded into process.env:
bash
bun superadmin <email> [--role owner|superadmin] [--dotenv .env.<name>] [--revoke]Rolling out tiers to an existing site
Warning: keep Email/Password sign-up disabled while
multiClientis off. WithmultiClientoff, the rules treat every signed-in user as an admin. The login page deletes an uninvited account right after "Tạo tài khoản", but that is only a convenience, not a security boundary: with sign-up enabled in Firebase Auth, anyone can create an account by calling the Identity Toolkitaccounts:signUpREST endpoint directly and get admin access. In the Firebase console (Authentication → Settings → User actions), keep account creation disabled. Enable it only after turning on "Nhiều khách hàng" (step 4), when users without a roles doc no longer have admin access.
Follow these steps in order. The owner role must exist in Firestore before the new rules are deployed, otherwise the owner loses access to config/license during the window between deploy and first login.
Set the owner. Run the script against the target project and confirm the result in the Firestore console (
roles/{uid}.role == "owner"). Do not deploy the rules before this step.bashbun superadmin <email> --role owner --dotenv <env file>Disable account creation. In the Firebase console (Authentication → Settings → User actions), turn off Email/Password account creation (see the warning above). Check it before deploying the app. Do not deploy the app before this step.
Deploy rules and app. Run
bun deploy:firestore --project <id>, then build and deploy the app as in sections 2–3 above. Nothing changes for players or admins (multiClientstays off). Existingsuperadminusers become agency staff: they are bound by the license window and can no longer editconfig/licenseorconfig/entitlements. To keep what they manage today, the owner grants them the matching entitlements on the Nhà cung cấp page:analyticsfor Thống kê / store-click stats;affiliatesfor affiliate links ("Quản lý liên kết tiếp thị");houseAdsfor house ads;customPoweredBy, if they use their own Powered by logo.
Then open Nhà cung cấp and save the entitlements once, even if nothing changed. An entitlements doc written before this release has no
ruleWindows, so the rules ignore any rule-gated key (affiliates,houseAds,customPoweredBy,analytics,campaigns) that it grants only through a dated grant or package. The app still shows the feature, but the writes are refused until the doc is saved again.Optional, per site: enable multi-client mode. Do this only for a site that will have more than one client, or more than one campaign for its client.
- Turn it on. Sign in as the owner and open Nhà cung cấp in the admin sidebar (
/admin/owner). On the Nền tảng card, turn on the "Nhiều khách hàng" switch and confirm ("Khi bật, chỉ người dùng đã được mời mới vào được trang quản trị. Tiếp tục?"). From now on, users without a roles doc lose admin access. The switch can be turned off again only while no client other thandefaultand no campaign other than the root one exists ("Không thể tắt khi đã có khách hàng hoặc chương trình khác."). - Enable sign-up. Only now enable Email/Password account creation in Firebase Auth, so invitees can use "Tạo tài khoản" (see the warning above).
- Re-admit the existing admins. On a legacy site an admin usually has no roles doc (any signed-in user was an admin), and in multi-client mode an
adminroles doc also needs aclientId. Without one, the login shows "Tài khoản chưa được cấp quyền. Liên hệ agency của bạn.". If an admin has an oldadminroles doc without aclientId, first delete that doc in the Firebase console (Firestore →roles→ the user's uid): redeeming an invite creates the roles doc and is refused while one exists. Then invite each existing admin on Khách hàng → "Khách hàng mặc định" → Người dùng của khách hàng (step 4). They sign in with their existing password (not "Tạo tài khoản"), verify their email if it isn't verified yet, and the invite is redeemed. Owner and agency (superadmin) logins keep working as they are. - Invite users. Agency staff are invited on the Nhà cung cấp page (Người dùng agency → Email → "Mời"). Client admins are invited by the owner or agency on the client's page (Khách hàng → the client → Người dùng của khách hàng). Emails are stored lowercased.
- No invitation email is sent. The inviter tells the person to open
/login, choose "Tạo tài khoản" and sign up with the invited email. - The email must be verified. Firebase sends a verification email at sign-up. The person opens the link, then presses "Tôi đã xác minh". Only then is the invite redeemed and the role created. "Gửi lại email" sends the link again. A sign-up without an invite is refused ("Email này chưa được mời.") and the account is deleted.
- "Đang chờ xác nhận" marks an invite that hasn't been redeemed yet. "Thu hồi" withdraws it.
- No invitation email is sent. The inviter tells the person to open
- Sell. In direct sales the owner also acts as the agency. To let client
defaultrun more than its root campaign, grant "Nhiều chương trình" (campaigns) to the agency on the Nhà cung cấp page and to the client on its page, and raise "Số chương trình tối đa". To sell through a partner agency, invite its staff (step 4) and set the agency license and limits on the Nhà cung cấp page. The agency then creates its clients on Khách hàng.
Legacy sites (multi-client off). Account creation stays disabled and the login page hides "Tạo tài khoản", so an invitee can't sign up. The operator creates the account in the Firebase console (Authentication → Users → Add user) with the invited email. The person signs in with it, verifies the email ("Gửi lại email" sends the link) and the invite is redeemed.
Removing a user on a legacy site. With multi-client off, the rules treat every signed-in user as an admin of the default campaign. "Xóa quyền" deletes the roles doc, but the person keeps full admin access. To really remove them, also disable or delete the account in the Firebase console (Authentication → Users). The "Xóa quyền" dialog says this too.
- Turn it on. Sign in as the owner and open Nhà cung cấp in the admin sidebar (
PII lockdown (2 phases)
Phase 1 deploy
Deploy rules then app, as in sections 2–3. Phase 1 is additive: old bundles keep working.
- The indexes deploy asks to delete the
surveyResponsesuserInfo.emailindex: answer Yes. - After deploy, each admin opens Quản lý phần thưởng and presses "Đồng bộ ngay" on each campaign, or the owner uses the gate card (below).
Per-tenant gate (operator checklist, before any phase 2 deploy)
The owner signs in on the tenant and opens Nhà cung cấp → the "Khóa dữ liệu cá nhân" card.
- Phase 1 has been live for at least 24 hours (rules and app). This lets players with open tabs on the old bundle reload before phase 2 locks the rewards collection.
- Re-run the backfill on every campaign, after step 1. Press "Chạy lại" on each row, archived and license-expired ones included. Every row then shows a stamp date later than the 24-hour mark. This catches rewards that old bundles wrote without projections.
- Admins with no roles doc. Compare the card's roles list with Firebase console → Authentication → Users. Every account that should stay an admin either signs in once (on a legacy site that self-provisions an
admin/defaultdoc) or is invited on/admin/clients/default→ "Người dùng của khách hàng". The operator creates the Auth account in the console for an invitee, as described above for legacy sites. - Self-provisioned docs. For each row under "Tự cấp quyền" that isn't a known admin, press "Thu hồi" and disable the account in Authentication → Users. While phase 1 is live, a revoked but still enabled account self-provisions again on its next sign-in.
- "Sẽ mất quyền ở giai đoạn 2" is empty, or every row in it is someone who should lose access. A legacy
admindoc withoutclientIdgets patched when its user signs in. Otherwise fix it in the console. - The card shows "Sẵn sàng cho giai đoạn 2".
- Record the commit of the phase 1
firestore.rulesthat is live on this tenant, for rollback.
Phase 2 deploy order
Deploy the app first, then the rules. The staging CI already deploys the app first, then the rules, in one run. Tenants are deployed by hand: run the two halves in that order. Legacy single-shop sites: the same steps, per tenant. Phase 1 must already be deployed there, the admin must have signed in and backfilled the root campaign, and the gate above must have passed.
After deploying the phase 2 rules:
- Re-open the gate card and re-check "Tự cấp quyền". Any row whose createdAt (shown as date + time) is after your earlier review (gate step 4) is suspect — press "Thu hồi" and disable that account in Authentication → Users. The uid shown on each row lets you find the account in Firebase console → Authentication → Users.
- Smoke test (sections 2–3 above apply). Confirm there are no
permission-deniederrors in the browser console for the game or admin pages you expect to work.
Legacy single-shop sites
Deploy phase 1; the admin signs in (self-provision) and backfills the root campaign; pass the gate; deploy phase 2.
Rollback (PII lockdown)
- Phase 2 rules: redeploy the phase 1
firestore.rules(record its commit before phase 2 — gate step 7 above). - Phase 1 app rollback is safe, but rewards written by the old app have no projections: re-run the backfill after rolling forward.
Rollback
- App: Cloudflare dashboard → Pages → project → Deployments → pick the previous deployment → Rollback to this deployment.
- Rules: Firebase console → Firestore → Rules → history → restore the previous version, or check out the old
firestore.rulesand redeploy step 3.The old rules accept only role
superadmin, notowner. Set the owner back tosuperadminwhen you roll back the rules, or the owner can no longer editconfig/licenseandconfig/entitlements:bashbun superadmin <email> --role superadmin --dotenv <env file>To roll forward again, set
--role ownerfirst (step 1), then redeploy the rules.
Demo deployment
A demo deployment is a standalone Cloudflare Pages + Firebase Spark project used to show prospects a pre-seeded copy of the app. It runs with demo mode on (the owner card and demo API are active). It is completely separate from staging and tenant projects.
Add a row to the Environments table above when you create one (env file .env.demo, Firebase project id, Pages project name, URL).
Steps
Create the Firebase project. In the Firebase console, create a new project (Spark plan is enough). Enable Email/Password sign-in under Authentication → Sign-in method. Keep account creation enabled so the PO can register.
Create
.env.demo. Copy.env.demo.exampleto.env.demoand fill in the real Firebase config values, the service-account key path, and optionallyDEMO_PARTNER_NAME. ConfirmNUXT_PUBLIC_DEMO_MODE=trueis set..env.demois gitignored and must never be committed.Deploy Firestore rules and indexes.
bashbun deploy:firestore --project <firebase-project-id>Create the Pages project.
bashbunx wrangler pages project create <pages-project-name> --production-branch main(
bunx wrangleruses its own Node shebang — do not replace withbun --bun.)Set the
NUXT_SERVICE_ACCOUNTPages secret. This is the service-account JSON (the whole file contents, not the path). The demo API uses it on the Worker to sign Firestore/Identity Toolkit calls. Without it, demo mode won't activate even ifNUXT_PUBLIC_DEMO_MODE=trueis set.bashbunx wrangler pages secret put NUXT_SERVICE_ACCOUNT --project-name <pages-project-name> # Paste the full JSON when prompted, then press Enter.Set this on the production environment in the Pages dashboard (Settings → Environment variables) as well if you use the dashboard.
Add the Pages domain to Firebase Auth authorized domains. Firebase console → Authentication → Settings → Authorized domains → Add domain. Without this, admin sign-in fails on the Pages URL.
Build the app against the demo env.
bashbun run build --dotenv .env.demoDeploy to Pages.
bashbunx wrangler pages deploy dist --project-name <pages-project-name> --branch main --commit-dirty=trueRun the setup script. The PO must have signed up on the demo site first (step 6 must already be done). The script writes the platform-level Firestore docs and grants the owner role. It is idempotent and safe to re-run.
bashbun scripts/demo-setup.mjs --dotenv .env.demo --owner <PO email>Create the sample agency. Sign in as owner, open
/admin/demo, press Tạo agency mẫu and store the three tour passwords in the team password manager (they are shown once).
When the demoTour Firestore rule changes (it is owner-read, server-write only), redeploy rules with bun deploy:firestore --project <firebase-project-id> before deploying the Pages build.
For the sample-agency tour, the demo story and how to create/reset/delete copies for prospects, see section 10 of docs/PRODUCT-OVERVIEW.md.