๐ Access-Control Audit
Scope. Every public-addressable surface across the AceSense platform โ landing site, admin panel, REST/MCP API, docs hub, launchpad, mobile/web client, and Cloud Functions. For every route, page, well-known endpoint, and callable, this audit records the intended audience, the gate that is currently in place, and any leak or contradiction discovered.
Sister documents: Upload Threat Model, DPIA, ADR-0007 Agent Commerce API.
Summaryโ
Seven distinct hosting surfaces were audited end-to-end against source: acesense.io (landing, 153 routes/files), admin.acesense.io (12 routes, all gated), api.acesense.io (5 REST + 1 MCP endpoint), docs.acesense.io (~110 docs pages, admin-gated as of 2026-06-21), acesense-launchpad.web.app (19 routes, allowlist gated), the Flutter app (12 screens, auth gate at root), and Cloud Functions (4 onRequest + 13 onCall + 2 auth-trigger surfaces). Total addressable pages/routes counted: ~310. No customer-data leaks found on a public surface โ Firestore rules + Storage rules + admin allowlist all align. One cross-cutting issue remains: launchpad allowlist plus admin allowlist are duplicated in multiple places that drift independently. Historical docs-public findings below are marked resolved where fixed.
Surface 1: Landing (acesense.io) โ acesense-landing/โ
React-router routes (rendered from dist/index.html SPA fallback)โ
Source of truth: acesense-landing/src/AppRouter.tsx.
| Route | Audience | Gate | Notes |
|---|---|---|---|
/ | Public | None | Homepage with App Store CTA and sample-report CTA; no browser Firestore write path |
/blog | Public | None | Blog index |
/blog/:slug | Public | None | 50 prerendered posts in dist/blog/ |
/compare | Public | None | Hub: 6 entries |
/compare/:slug | Public | None | swingvision, pb-vision, baseline-vision, playsight, onform, topcourt, pricing-grid |
/alternatives | Public | None | Hub |
/alternatives/:slug | Public | None | swingvision, onform, topcourt |
/use-cases | Public | None | Hub |
/use-cases/:slug | Public | None | adult-returners, club-players, coaches-async-review, junior-coaches, parents-junior-tennis |
/features | Public | None | Hub |
/features/:slug | Public | None | ball-tracking, coaching-report, court-heatmap, shot-detection, stroke-quality |
/how-to | Public | None | Hub |
/how-to/:slug | Public | None | film-your-tennis-match, record-your-serve, share-report-with-coach |
/how-it-works, /accuracy, /pricing, /faq, /changelog, /android, /ios, /examples, /about, /biomechanics, /sweden, /agents | Public | None | Static markdown-backed marketing pages |
* | Public | None | NotFoundPage; renders a real 404 (no Helmet duplicate) |
Firebase Hosting rewrites + raw HTMLโ
Source: acesense-landing/firebase.json.
| Path | Audience | Gate | Notes |
|---|---|---|---|
/privacy โ /privacy.html | Public | None | GDPR/CCPA-compliant policy |
/terms โ /terms.html | Public | None | |
/contact โ /contact.html | Public | None | Static contact form |
/support โ /contact.html | Public | None | Alias |
/accessibility โ /accessibility.html | Public | None | EAA/WCAG statement |
Markdown twins + machine-readable surfacesโ
| Path | Audience | Gate | Notes |
|---|---|---|---|
/<route>.md (every content page) | Public | None | Content-Type: text/markdown, X-Robots-Tag: index, follow (firebase.json:78-82) |
/llms.txt | Public | None | text/plain, indexed |
/robots.txt | Public | None | Allow-all (intentional per ADR-0006) |
/sitemap.xml | Public | None | |
/openapi.json | Public | None | OAS 3.1 for the agent API; CORS * |
/.well-known/* (RFC 8615 + agent-discovery)โ
| Path | Audience | Gate | Notes |
|---|---|---|---|
/.well-known/api-catalog | Public | None | RFC 9727 linkset |
/.well-known/agent-skills/index.json | Public | None | Cloudflare Agent Skills RFC v0.2.0 |
/.well-known/mcp/server-card.json | Public | None | Points to api.acesense.io/mcp |
/.well-known/mcp.json, /.well-known/acp.json, /.well-known/mpp.json | Public | None | Discovery cards |
/.well-known/oauth-authorization-server | Public | None | OAuth 2.0 metadata |
/.well-known/oauth-protected-resource | Public | None | RFC 9728 |
/.well-known/openid-configuration | Public | None | OIDC metadata |
/.well-known/http-message-signatures-directory | Public | None | RFC 9421 dir |
/.well-known/ucp | Public | None | Universal Content Profile |
/.well-known/x402 | Public | None | 402-payment discovery |
Audit findingsโ
- OK โ All routes are public-by-design and contain only marketing/discovery material. No PII or customer data is rendered.
- OK โ The landing page no longer writes waitlist documents from the browser; it renders public marketing/discovery pages only.
- OK โ
robots.txt(acesense-landing/public/robots.txt:5-7) explicitly notes that authed surfaces live on separate domains, matching what we found. - RESOLVED 2026-06-21 โ
llms.txtline 78 said "Internal documentation atdocs.acesense.io" while docs were public. The docs host now has an admin sign-in gate.
Surface 2: Admin (admin.acesense.io) โ acesense-admin/โ
React-router routesโ
Source: acesense-admin/src/App.tsx.
| Route | Audience | Gate | Notes |
|---|---|---|---|
/ | Admin | App.tsx:113 if (!user) return <LoginPage /> | Dashboard |
/users | Admin | Auth gate | User dashboard |
/jobs | Admin | Auth gate | Job dashboard (Firestore read goes through isAdmin() rule, see below) |
/analytics | Admin | Auth gate | |
/storage | Admin | Auth gate | |
/research-decisions | Admin | Auth gate | |
/research-decisions/:id | Admin | Auth gate | |
/developer | Admin | Auth gate | |
/operations | Admin | Auth gate | |
/audit-log | Admin | Auth gate | Reads admin_audit/* |
/admin-api-keys | Admin | Auth gate | Manages api_keys/* (root-level, requires isAdmin() to read all keys) |
/settings | Admin | Auth gate |
Hostingโ
acesense-admin/firebase.json โ single rewrite ** โ /index.html. No public assets, no robots.txt, no noindex meta tag in index.html.
Allowlistโ
The Firestore-rules allowlist that backs every admin Firestore read is at acesense-frontend/firestore.rules:18-25:
function isAdmin() {
return isSignedIn() && (
request.auth.token.admin == true ||
request.auth.token.email == 'admin@acesense.io' ||
request.auth.token.email == 'akshay.sarode@anilata.com' ||
request.auth.token.email == 'akshay.sarode18@gmail.com'
);
}
Compare to the Cloud Functions allowlist at acesense-auth-function/shared/admin-auth.ts:32-34:
export const ADMIN_EMAIL_ALLOWLIST: ReadonlyArray<string> = [
"admin@acesense.io",
];
Audit findingsโ
- OK โ Root auth gate at
App.tsx:113-115is correct; without a signed-in Firebase user, only<LoginPage />renders. Routes are only mounted under<AuthenticatedApp />. - OK โ Even if a route were brute-forced via deep-link, every Firestore read in admin pages is bound by the
isAdmin()rule above, so a signed-in non-admin would only see empty results / permission-denied. - RECOMMEND (drift) โ The Firestore-rules allowlist lists three emails (
admin@acesense.io,akshay.sarode@anilata.com,akshay.sarode18@gmail.com); the Cloud Functions allowlist lists only one (admin@acesense.io). That means the two non-canonical emails can read admin Firestore data but cannot invokeadminRetryJob/adminSetUserPlan/etc. The discrepancy is a P2 footgun โ admin actions silently 403 for users who can otherwise see the dashboard. - RECOMMEND โ
acesense-admin/index.htmlhas no<meta name="robots" content="noindex, nofollow">and there's noX-Robots-Tagheader infirebase.json. Search engines cannot crawl past the auth gate, but they can index the login page itself. Addnoindexto be explicit.
Surface 3: API (api.acesense.io) โ acesense-auth-function/โ
REST apiServer โ acesense-auth-function/api/index.tsโ
| Endpoint | Auth | Rate-limit | Audience | Notes |
|---|---|---|---|---|
GET /v1/healthz | None | No | Public | Liveness (api/index.ts:227-235) |
GET /healthz | None | No | Public | Alias |
GET /v1/pricing | None | No | Public | Rate card (api/index.ts:237-240) |
POST /v1/jobs | Bearer phk_... | Yes (per-key sliding window) | Paying agent caller | Creates job + signed upload URL (api/index.ts:598-603) |
GET /v1/jobs/:jobId | Bearer phk_... + ownership (apiKeyId === key.keyId) | Yes | Paying agent caller | 404 (not 403) if not owner โ by design, no enumeration (api/index.ts:453) |
GET /v1/jobs/:jobId/result | Bearer phk_... + ownership | Yes | Paying agent caller | Streams result JSON from Storage |
GET /v1/usage | Bearer phk_... | Yes | Paying agent caller | Returns balance + counts |
OPTIONS * | None | No | Browsers (CORS preflight) | Universal allow-list for safe methods only |
* (404) | None | No | Anything else | Generic 404 envelope |
CORS:
GETisAccess-Control-Allow-Origin: *(read-only and per-key authed โ safe).POSTrequires either noOrigin(server-side caller) or anOriginlisted onapiKey.allowedOrigins(api/index.ts:147-165). Origin is echoed only after a match โ never reflected.
MCP mcpServer โ acesense-auth-function/mcp/index.tsโ
| Method | Path | Auth | Rate-limit | Notes |
|---|---|---|---|---|
OPTIONS | /mcp | None | No | Preflight |
GET | /mcp | None | No | Tiny health JSON pointing at server-card; intentionally no internals (mcp/index.ts:107-117) |
POST | /mcp | Bearer phk_... | Yes | JSON-RPC dispatch (analyze_tennis_video, get_job_status, get_analysis_result, get_pricing, list_my_jobs) |
| Other methods | /mcp | None | No | 405 with JSON-RPC error envelope |
Other public Cloud Run / Functionsโ
| Function | Source | Auth | Notes |
|---|---|---|---|
health | index.ts:80-87 | invoker: public (default โ no auth required at IAM level) | Returns {status: "ok", region, timestamp, version} โ no PII |
apiServer | api/index.ts:666-679 | Per-route (see above) | invoker: public, max 50 instances |
mcpServer | mcp/index.ts:88-200 | Per-route (see above) | invoker: public, max 50 instances |
Audit findingsโ
- OK โ Every authed endpoint returns a 404 (not 403) on cross-tenant access (
api/index.ts:453,api/index.ts:495) so a stolen API key cannot enumerate other tenants' jobIds. - OK โ Rate-limit is fail-open with structured logging (
api/index.ts:212-220). The billing meter (per-job spend deduction inquoteAndReserve) remains authoritative even when the rate limiter is wobbling. - OK โ
GET /v1/pricingand/healthzreturn only static config โ no secret material. - RECOMMEND โ
healthCloud Function (index.ts:80-87) has no rate-limiting and apublicinvoker. Acceptable for an uptime probe, but consider a 1 req/s/IP ceiling so it can't be used as a 200-OK amplifier.
Surface 4: Docs (docs.acesense.io) โ acesense-docs/โ
Hostingโ
acesense-docs/firebase.json โ single rewrite ** โ functions/docsServer, with a global X-Robots-Tag: noindex, nofollow, noarchive header. functions/docsServer verifies an admin Firebase session cookie before serving the Docusaurus HTML or assets. Docusaurus sitemap generation and local search indexing are disabled.
Remaining caveat: Firebase Hosting is static, so hashed JavaScript chunks are still served. This gate blocks normal browsing/indexing; strict byte-level privacy needs Cloudflare Access or an authenticated server rewrite.
Published doc tree (selected directories that matter for access control)โ
| Path | Audience as of source | Currently | Notes |
|---|---|---|---|
/intro | "internal documentation" (intro.md:22) | Admin-gated | Sets the entire framing |
/architecture/* | Engineering | Admin-gated | Includes architecture/api-reference, deployment docs |
/compliance/* | Compliance officer / DPO | Admin-gated | DPIA, threat model, SOC2 readiness, breach response, AI model card, eu-uk-representative |
/compliance/dpas/* | Compliance officer | Admin-gated | Firebase DPA, RunPod DPA, Resend DPA notes |
/compliance/incidents/* | Compliance officer | Admin-gated | 2026-Q2-drill-1.md โ internal drill log |
/postmortems/* | Engineering | Admin-gated | 2026-03-14-runpod-image-regression.md โ full incident timeline |
/runbooks/* | On-call engineer | Admin-gated | admin-signin.md, runpod-errors.md, functions-deploy-failed.md, job-stuck.md |
/decisions/* | Engineering | Admin-gated | 7 ADRs |
/marketing-videos/* | Marketing | Admin-gated | Includes a teleprompter.html standalone tool |
/admin/*, /frontend/*, /gpu-backend/*, /firebase-functions/*, /annotate/*, /landing/*, /designs/*, /help/*, /get-started/*, /reference/* | Engineering | Admin-gated | Service-internal docs |
Total addressable doc pages from website/build/: roughly 110.
Configuration footgun (resolved 2026-06-21)โ
tagline: 'Architecture, API, and operator docs for the AceSense platform',
Welcome to the AceSense internal documentation hub.
The docs site used to be on the public web with no auth, a generated public sitemap, and a generated public search index. It now has a Firebase Auth admin gate, global noindex, no sitemap, and no local-search index plugin.
Audit findingsโ
- RESOLVED โ The site now self-describes as operator docs and requires admin sign-in before rendering.
- RESOLVED โ
compliance/incidents/,postmortems/, andrunbooks/are behind the same docs-wide gate and the host sendsX-Robots-Tag: noindex, nofollow, noarchive. - OK โ The gpu-backend
agent.mdhandoff log lives atacesense-gpu-backend/agent.mdand is not included in the docs build (verified โ noagent.mdfile underacesense-docs/). The Docusauruspresets.docs.path: '..'config only walksacesense-docs/, so the sibling repo's handoff is not exposed.
Surface 5: Launchpad (acesense-launchpad.web.app) โ acesense-launchpad/โ
React-router routesโ
Source: acesense-launchpad/src/App.tsx.
Every route below is wrapped in <AuthGate> (acesense-launchpad/src/App.tsx:25).
| Route | Audience | Gate | Notes |
|---|---|---|---|
/ | Allowlist (3 emails) | AuthGate | Dashboard |
/market | Allowlist | AuthGate | TAM/SAM/SOM data |
/pain-points | Allowlist | AuthGate | |
/competitors | Allowlist | AuthGate | |
/accelerators | Allowlist | AuthGate | |
/grants | Allowlist | AuthGate | |
/vcs | Allowlist | AuthGate | VC contact list โ most sensitive |
/phases | Allowlist | AuthGate | |
/pitch | Allowlist | AuthGate | Full pitch deck |
/strategy | Allowlist | AuthGate | |
/tools | Allowlist | AuthGate | |
/timeline | Allowlist | AuthGate | |
/repo-map | Allowlist | AuthGate | |
/brand-hub | Allowlist | AuthGate | |
/operations | Allowlist | AuthGate | |
/architecture | Allowlist | AuthGate | Company architecture โ overlaps docs hub |
/os-doc | Allowlist | AuthGate | Operating doc |
/playbooks | Allowlist | AuthGate | |
/playbooks/:id | Allowlist | AuthGate |
Allowlist sourceโ
acesense-launchpad/src/config/firebase.ts:22-26:
export const ALLOWED_EMAILS: ReadonlySet<string> = new Set([
'akshay.sarode18@gmail.com',
'admin@acesense.io',
'akshay@akshaysarode.com',
]);
Login UIโ
acesense-launchpad/src/pages/Login.tsx โ when a non-allowlisted user signs in, the UI shows:
"
{notAllowedEmail}isn't authorised. The launchpad is a personal fundraising tool restricted to a 3-account list. Sign in with a different account, or contact the founder."
Audit findingsโ
- OK โ
AuthGateis wired at the router root and ladders through three states: loading โ no-user (Login) โ user-not-allowed (Login withnotAllowedEmail) โ app shell. Routes only render in the final state. - OK โ The exact email list is not rendered in the unauth UI (only the count "3-account list" is mentioned). The allowlist constants live in the bundled JS, but a determined inspector reading the bundle still cannot impersonate those accounts without their Firebase credentials. This is the deliberate trade-off documented in
config/firebase.ts:16-21. - RECOMMEND โ The launchpad has no Firestore data layer (data is bundled at build time as
src/data/*.ts), so there are no rules to back the allowlist server-side. If the project ever grows to write data (e.g. CRM-style notes), this becomes a hard requirement; document it in the launchpad's ownREADME.mdso it isn't forgotten. - RECOMMEND (drift) โ Three different allowlists now exist:
- Firestore rules:
admin@acesense.io,akshay.sarode@anilata.com,akshay.sarode18@gmail.com(firestore.rules:18-25) - Cloud Functions admin:
admin@acesense.ioonly (shared/admin-auth.ts:32-34) - Launchpad:
admin@acesense.io,akshay.sarode18@gmail.com,akshay@akshaysarode.com(config/firebase.ts:22-26) - Pick one source of truth (custom claim
admin === true) and have all three reference it.
- Firestore rules:
Surface 6: Flutter app (app.acesense.io + iOS + Android) โ acesense-frontend/โ
Auth gateโ
The Flutter app gates authenticated screens on Firebase Auth state before rendering the app shell:
nulluser โLoginScreen- signed-in +
onboarding.completed != trueโOnboardingFlow - signed-in + onboarded โ
MainNavigation
Deep-link routes (/login, /session/new, /session/:jobId) all pass through the same auth-state checks โ there is no path that bypasses Firebase Auth state.
Screensโ
| Screen | Auth required | Source |
|---|---|---|
LoginScreen | No | lib/screens/login_screen.dart |
OnboardingFlow | Yes | lib/screens/onboarding/onboarding_flow.dart |
HomeScreen | Yes | lib/screens/home_screen.dart |
SessionsScreen | Yes | lib/screens/sessions_screen.dart |
PracticeScreen | Yes | lib/screens/practice_screen.dart |
ProfileScreen | Yes | lib/screens/profile_screen.dart |
SettingsScreen | Yes | lib/screens/settings_screen.dart |
StartSessionScreen | Yes | lib/screens/start_session_screen.dart |
JobStatusScreen | Yes | lib/screens/job_status_screen.dart |
AnalysisScreen | Yes | lib/screens/analysis_screen.dart |
UpgradeScreen | Yes | lib/screens/upgrade_screen.dart |
HowItWorksScreen | Yes | lib/screens/how_it_works_screen.dart |
PrivacyPolicyScreen, TermsOfServiceScreen | Yes (presented from inside the app) | Public-content screens shown in-app |
Server-side enforcementโ
- Firestore rules:
acesense-frontend/firestore.rulesโjobs(owner read/delete + create with own uid + restricted update whitelist),users(owner-only or admin),sessions(owner-only),api_keys(owner/admin read, function-owned balance fields), andapi_usage(admin-only). - Storage rules:
acesense-frontend/storage.rulesโvideos/{userId}/{sessionId}/{file}owner R/W with 2GB cap;results/{userId}/{sessionId}/{file}owner read, no client write (Admin SDK only); legacy paths implicitly denied.
Audit findingsโ
- OK โ Every authenticated screen depends on Firebase Auth state, and every server-side read/write is bound by Firestore + Storage rules that key on
request.auth.uid. Thejobsrules even enforce ahasOnly([...presentation fields...])whitelist on client-side updates (firestore.rules:53-58) so a compromised client cannot forgestatus,resultPath, oruserId. - OK โ Storage rules deny client writes to
results/*so the GPU backend's outputs are tamper-proof from any client. - RECOMMEND โ The
userscollection rule grants the ownerwriteaccess (firestore.rules:67) without a field whitelist. That means a savvy user can mutateplan,trialEndsAt,welcomeEmailSent, etc. from the client. Tighten tohasOnly([...presentation fields...])matching thejobspattern.
Surface 7: Cloud Functions โ acesense-auth-function/โ
Auth-triggered (no callable surface)โ
| Function | Trigger | Source |
|---|---|---|
createUserDocument (createUser) | auth.user().onCreate | user/index.ts:77 |
deleteUserDocument (deleteUser) | auth.user().onDelete | user/index.ts:159 |
Both fire on Firebase platform events. There is no HTTP surface to abuse.
Storage-triggeredโ
| Function | Trigger | Source |
|---|---|---|
processVideoOnUpload | onObjectFinalized on videos/* | video/index.ts:185 |
onResultUploaded | onObjectFinalized on output/* | video/index.ts:575 |
Trigger via Storage event only โ not callable from the client.
Callables (Firebase auth context)โ
| Callable | Auth | Owner-check | Source |
|---|---|---|---|
requestUploadPath | requireAuth (shared/auth.ts) | uses request.auth.uid for path | video/index.ts:726 |
mergeChunkResults | requireAuth | reads job by sessionId, asserts ownership | video/index.ts:869 |
markEmailVerified | requireAuth | self-only | user/index.ts:201 |
sendPasswordResetCustom | requireAuth | self-only | user/index.ts:253 |
exportUserData | requireAuth | self-only (GDPR portability) | user/data.ts:49 |
requestAccountDeletion | requireAuth | self-only (GDPR erasure) | user/data.ts:248 |
createApiKey | requireAuth + max-10-keys/user cap | self-only | api/keys.ts:49 |
listApiKeys | requireAuth | self-only | api/keys.ts:96 |
revokeApiKey | requireAuth + ownership | self-only | api/keys.ts:118 |
adminRetryJob | requireAdmin (shared/admin-auth.ts) | admin | admin/index.ts:100 |
adminSetUserPlan | requireAdmin | admin | admin/index.ts:243 |
adminSetUserSuspended | requireAdmin | admin | admin/index.ts:347 |
adminAdjustApiKeyBalance | requireAdmin | admin | admin/index.ts:437 |
adminRevokeSignedUrl | requireAdmin | admin | admin/index.ts:550 |
Public-invokable Cloud Run / onRequestโ
Already covered in Surface 3 (apiServer, mcpServer, health). Each handles its own auth.
Audit findingsโ
- OK โ Every callable starts with
requireAuthorrequireAdminas the first line. The only paths past auth are unauth health/pricing. - OK โ Admin callables write to
admin_audit/{autoid}(admin/index.ts:60-76) before performing the privileged action. The audit row is the only audit trail;CONTRIBUTING.mdandadmin/index.ts:1-12both call out that adding a new admin callable without an audit row is a contract violation. - OK โ
processVideoOnUploadenforces prefix + extension filter;onResultUploadedfilters by output prefix. Neither is reachable from a client request. - OK โ
requireAdminand the firestore-rulesisAdmin()are both keyed onrequest.auth.token.email, which is set by Firebase Auth and not user-mutable.
Cross-cutting findingsโ
Ordered by severity:
- Allowlist drift across 3 files (P2 โ drift footgun). Firestore rules, Cloud Functions admin, and Launchpad each maintain their own admin/allowlist. Drift means the same user can do A but not B. Single source: a custom claim
admin === trueset via Firebase Admin SDK. - RESOLVED 2026-06-21 โ Docs site labelled "internal" but published publicly. The site now has an admin sign-in gate.
- RESOLVED 2026-06-21 โ Operational/incident docs were public on
docs.acesense.io. The whole docs host is admin-gated and noindexed. users/{userId}Firestore rule lets owner write any field (P3 โ privilege creep).firestore.rules:65-68. A user can alter their ownplan,trialEndsAt, etc. Tighten to a presentation-fields whitelist asjobsalready does.- Admin login page indexable by search engines (P4 โ minor info leak). No
noindexonacesense-admin/index.htmland noX-Robots-Taginacesense-admin/firebase.json. The login form itself isn't sensitive, but indexing it surfaces the brand name pattern unnecessarily. - RESOLVED 2026-06-21 โ
llms.txtcalled docs internal while they were public. The docs site is now private.
Recommendationsโ
Numbered. Severity in brackets matches the cross-cutting list.
-
[P2] Unify admin allowlist behind a custom claim. Add a Cloud Function (admin-only) that calls
admin.auth().setCustomUserClaims(uid, { admin: true }). Updatefirestore.rules:18-25to only checkrequest.auth.token.admin == true. Updateshared/admin-auth.ts:32to drop the email allowlist and rely ontoken.admin === true. Update launchpad allowlist to read the same claim (sign-in flow already gives Firebase a JWT; checktoken.admin === trueinstead of an email set). Once shipped, delete all three hard-coded lists. -
[P3] RESOLVED โ Auth-gate
docs.acesense.io. The whole site now uses Firebase Auth, serves the Docusaurus bundle and assets through thedocsServerfunction after admin session-cookie verification, removes the public local-search index, and sends a globalnoindexheader. -
[P3] Tighten
users/{userId}Firestore rule. Apply theaffectedKeys().hasOnly([...])pattern that already exists forjobs(firestore.rules:53-58) to theuserscollection. Allow onlydisplayName,photoURL,preferences,onboarding,updatedAt, etc. โ neverplan,trialEndsAt,welcomeEmailSent,subscription, oremailVerified. Server-owned fields stay in the Cloud Functions Admin SDK path. -
[P4] Add
X-Robots-Tag: noindex, nofollowto the admin host. Updateacesense-admin/firebase.jsonhosting.headerswith a wildcard rule for**setting the header. Mirror inacesense-launchpad/firebase.json. -
[P4] Rate-limit the
healthCloud Function. Add a 1 req/s/IP cap at the function level (index.ts:80-87) so it cannot be co-opted as a 200-OK amplifier. -
[P4] Fix
llms.txtline 78. Either change "Internal documentation atdocs.acesense.io" to "Public engineering documentation atdocs.acesense.io" (Option A in #2) or actually move the docs behind auth.
Appendix: count of addressable itemsโ
| Surface | Routes / pages / endpoints |
|---|---|
| Landing routes (React) | 22 |
| Landing prerendered slugs | 86 (49 blog + 7 compare + 3 alternatives + 5 use-cases + 5 features + 3 how-to + 14 static-marketing) |
| Landing markdown twins | ~85 |
Landing /.well-known/* | 11 |
Landing other public files (robots.txt, sitemap.xml, llms.txt, openapi.json, etc.) | 6 |
| Admin routes | 12 |
| API REST endpoints | 8 |
| API MCP endpoint (POST + GET + OPTIONS) | 3 |
| Other Cloud Run / onRequest functions | 1 (health) |
| Docs pages (built) | ~110 |
| Launchpad routes | 19 |
| Flutter screens | 13 |
| Cloud Functions callables | 15 |
| Auth + Storage triggers | 4 |
| Total | โ383 |
(The 310 figure in the executive summary excludes prerendered blog/marketing slug variants when collapsing identical content under one route; the 383 figure here counts each addressable URL.)