← Ambasdr Docs
Mobile · Store Readiness

App Store & Play Store Report Card

A pre-submission audit of the Ambasdr mobile app (Expo / React Native), graded against the live Apple App Store Review Guidelines and Google Play policies fetched 2026-09-29. Every finding was verified against the current code (mobile, backend, and web, re-checked 2026-09-29) and routed to its owner — one pack claim was corrected in the process. Ambasdr has already been rejected once by both stores — this is the map back out.

Overall: NOT READY — 15 blockers, 6 fixes, 3 Play content-policy findings

The single most important thing to hold: most blockers are backend, not mobile. Wiring up the six mobile-owned items does not clear the gate — the app cannot truthfully submit until the backend and disclosure blockers below are resolved too. The plan of record: fix the blockers first, publish each disclosure before switching on the thing it discloses, then enter the store forms and resubmit.

The 15 blockers and 6 fixes are carried and re-verified from the disclosure pack. The three G-findings below are added by this audit's own pass against the Google Play Developer Content Policy hub, which the pack did not cover — the largest is Google Play Billing, which Apple's US external-purchase carve-out does not extend to.

01

The report card

Grades are A–F by submission category. Each maps to a specific guideline section (fetched live) and the evidence that sets the grade. Anything below C is a category with at least one hard rejection risk.

CategoryGradeBasis — guideline → evidence
App Completeness F Apple 2.1. B14 — the in-app visitor chat renders a live-looking send button whose onSend is an empty stub (PublicPageView.tsx:144), and Voice Mode is a timer-driven animation with no microphone or audio (VoiceMode/useVoiceMode.ts:85, demo=true). B8 — the three legal links on the sign-in screen are dead stubs (login.tsx:199). Both are first-two-minutes rejections.
Account Deletion F Apple 5.1.1(v) and Play account-deletion policy. B2 — in-app delete sets deleted_at and stops; the Auth0 account, Postgres rows, Neo4j graph and Spaces files all survive, and no job ever purges them (user.go:170). Both stores mandate real in-app deletion; this contradicts the "removed within 30 days" promise already published.
UGC Safety & Moderation F Apple 1.2 (filter + report + block + published contact) and Play UGC + Generative-AI policies. B13 — no report / flag / block mechanism anywhere in the repo. B12 — no in-product "AI-generated" label and no way to report offensive AI output; the only AI disclosure lives inside the legal pages, which Play does not accept.
Privacy Disclosure & Consent D Apple 5.1.1(i), 5.1.2(i) (name third-party AI and get explicit permission — the 13 Nov 2025 change). No consent gate exists in the app, and the policy names no AI vendor. Both halves resolved. The app has a member consent screen at mobile/app/(onboarding)/consent.tsx, and the published policy names its AI processors individually rather than generically. The live policy also states falsehoods the code contradicts (B1, B0a). F6 — no record of policy/terms acceptance is stored anywhere.
Data Handling & Retention D B9 visitor IP, bearer tokens and request/response bodies sit in Loki for 30 days. B3 full visitor transcripts go to DeepSeek in mainland China today (insights, live). B7 retention vars unset = keep forever. B0 login email reachable via search_knowledge. B10 no per-conversation delete. B0a uploads are public-by-default (a backend publish toggle now exists, but no client surfaces it).
Store Labels / Data Safety C Apple App Privacy and Play Data safety. The disclosure pack supplies correct, ready-to-type tables — but they are only truthful once B3/B9 resolve, and they have not yet been entered in either console.
Age & Target Audience D Apple age rating and Play target-audience/Families. F8 — no age gate anywhere; a public profile has no sign-in at all, so a child can chat with an ambasdr. The minimum age is stated as 13 in the Terms but is inconsistent across policy and the two store declarations.
SDK & Tracking Hygiene A- Genuinely clean. No analytics, ads, crash, or attribution SDK; no IDFA/GAID; ITSAppUsesNonExemptEncryption:false is correct. Minus for F5 (the react-native-auth0 JS package ships no privacy manifest and app.json has no ios.privacyManifests) and F2 (a dead Google Gemini client + key wiring still sits in the web bundle).
Payments / Monetization D Apple 3.1.1(a)/3.1.3 — Stripe Checkout in the system browser is a permitted external purchase in the US storefront (B on Apple alone). Google Play is stricter (G1): digital subscriptions consumed in-app must use Google Play's billing system, and "apps may not lead users to a payment method other than Google Play's billing system." The app leads users to external Stripe (POST /v1/billing/checkout) — a likely Play rejection. Grade reflects the Play risk, not the Apple one.
02

Immediate action list — mobile-owned

The six items you can fix directly in mobile/. Every one was verified still-present at current HEAD. Branch fixes from origin/main — local main is 16 commits behind, and the pack's commit sits ahead of the local checkout.

B14 — Remove the stub chat + Voice Mode from the in-app profile MOBILE

The safer, smaller change for this release: drop the visitor chat tab and the Voice Mode microphone button from the in-app profile view and point the route at the web profile, rather than finishing two unbuilt features. Resolved on main by #272 (2026-09-28). The visitor-shaped screen and its route were deleted. What remains, PublicPagePreview, is the member's own onboarding preview: it is passed no send handler, nothing in the app passes enableVoiceMode, and PublicPageInputBar sets disabled, focusable={false} and importantForAccessibility="no-hide-descendants" when no handler is present, so no dead control is offered to a sighted user or to VoiceOver. That commit also corrected the framing this page had wrong: the app is member-only and never faces a stranger.

B8 — Wire the sign-in legal links MOBILE

Route Terms, Privacy Policy and Privacy FAQ through Linking.openURL, reusing the working openExternal helper the Account screen already has (account.tsx:102-110,212). Either build the /privacy-faq destination or drop that third link — it currently falls through to a missing public profile. login.tsx:199-201,302,311,321.

F1 — Resolve the microphone declaration MOBILE

The app declares NSMicrophoneUsageDescription for a Voice Mode that captures nothing. Recommended: delete the string, and if the preview WebView stays near any voice surface set mediaCapturePermissionGrantType="deny". Implemented, then deliberately reversed — read both halves before acting.

The recommendation above shipped (JUE-541). NSMicrophoneUsageDescription is absent from mobile/app.json, the preview WebView sets mediaCapturePermissionGrantType="deny", and three tests hold that pair in place — __tests__/config/iosPrivacyDeclarations.test.ts asserts the string stays absent, and __tests__/preview/ProfilePreviewView.test.tsx asserts capture is denied and not left at the library default. They are held together on purpose: iOS terminates an app that reaches the microphone with no usage string.

The consequence nobody intended: a member cannot test their own voice agent at all. The preview WebView loads the real web profile, whose Voice Mode works — and the app denies it the microphone before it can start.

Reversed on 8 October 2026. The reasoning behind "deny" was that an owner has no reason to hold a voice conversation on their own page. That is wrong: the voice agent's instructions are composed by a different backend path from the text agent's — a dedicated GET /v1/agent/sessions/{page_id}/voice-prompt endpoint, plus a voiceResponseScope component and a voice addendum to the operating contract in service/voice_prompt_components.go — so the two can and do deviate. It has already cost a defect: service/operating_contract_test.go records a guardrail against inventing appointment times that was attached to the booking clause, which GetSessionConfig never composes on the voice path, so every voice visitor talked to an agent without it while text visitors had it. A member must be able to hear what the spoken version of them says.

So the open work is now the opposite, and it must be atomic: re-add the usage string (worded for a member testing their own ambassador, not the old "speak with the visitor's ambasdr", which has no referent in a member-only app), allow media capture in the preview WebView, add RECORD_AUDIO to Android, invert the three tests above with their reasoning rewritten, declare Audio Data = Yes on both store forms, and add Deepgram, ElevenLabs and LiveKit to the member consent screen as 5.1.2(i) requires once a member's voice reaches them through the app. Splitting those across releases leaves a build iOS can terminate.

F5 — Add the iOS privacy manifest entry MOBILE

Add an ios.privacyManifests block in app.json covering react-native-auth0's required-reason APIs (its JS package ships no PrivacyInfo.xcprivacy), and confirm the existing hand-authored ios/ambasdr/PrivacyInfo.xcprivacy covers the app-level reasons. Apple warns or rejects on upload without it.

F6 / F8 — Consent step + minimum-age statement MOBILE

Add the member consent step before the first upload, storing policy_version + an accepted-at timestamp, and state the minimum age at sign-up. Blocked on the B3 vendor decision — the consent copy has to name the actual AI vendors (OpenAI, Anthropic, and DeepSeek-in-China if it stays), so settle B3 before building the screen.

Why the mobile fixes are necessary but not sufficient

B14 and B8 are what an App Review tester hits in the first two minutes, so they must land — but a clean mobile build still fails review on B2 (deletion), B12/B13 (UGC + AI safety) and the disclosure blockers, all of which live in the backend, the web client, or the store consoles.

Android build specifics — verified 2026-09-29

Target API: API 35 (Android 15) via Expo SDK 54, which meets Play's current target-level requirement. The android/ folder is gitignored and regenerated by expo prebuild, so the value is set at build time — confirm it on the actual artifact. Merged-manifest permissions (from the generated manifest): RECORD_AUDIO — the mic issue (F1) is not iOS-only, remove it here too; SYSTEM_ALERT_WINDOW ("draw over other apps") — Play-sensitive and usually React Native's debug overlay, so confirm it is stripped from the release build; and legacy READ_EXTERNAL_STORAGE / WRITE_EXTERNAL_STORAGE — Play scrutinizes these, so confirm they are scoped or maxSdkVersion-capped.

03

Backend & cross-cutting — decision notes

These are not for local patching (backend is Gerald's territory). They are surfaced as decisions and work items because they block the same submission. Every item below was re-verified against current HEAD on 2026-09-29 by reading the cited code directly — not carried from the pack — with current line numbers. The one claim that no longer holds is corrected inline at B0a.

Hard blockers

IDWhat must change
B2 BEIn-app delete purges nothing — call the real purge from the member-facing delete path, or add a job that hard-purges soft-deleted accounts after a grace period. user.go:170, user_purge.go.
B0a BEUploads default to visibility='public' + is_published=true (document.go:280, migration 000006) while the live policy says the opposite. Correction vs the pack: a backend toggle now exists — PATCH /v1/pages/{id}/documents/{id} accepts visibility/is_published (document.go:220, router.go:351) — but the default is still public and no client surfaces the control. Fix: default to private with deliberate publish, or expose the per-file toggle + a picker warning.
B9 BESecurity-first: bearer tokens, forwarded visitor IPs and request/response bodies land in Loki for 30 days. Strip request headers and stop logging bodies on the chat and profile routes. router.go:115, middleware_response_log.go.
B3 BEThe pivotal disclosure decision. DeepSeek (mainland China) reads full visitor transcripts today for insights; PR #273 would additionally point live visitor chat at it. Choose: (a) switch insights to OpenAI and drop DeepSeek from the register; (b) keep DeepSeek for insights with a written no-training assurance and name it in every disclosure; or (c) also move the chat — the most expensive to disclose honestly.
B13 BEAdd "report this profile / response", a published in-app support address, and an operator path to suspend a page. Needs a backend endpoint plus mobile + web UI. Apple 1.2 and Play UGC.
B12 BELabel the chat surface as AI ("answers can be wrong") on the public profile in both clients, and add a "report this response" action. Play's Generative-AI policy requires this in-product.
B1 BEThe published policy claims "insights are summaries, not raw transcripts" — false; members read every message. Publish the replacement policy (pack Part 9).
B0 BEA member's sign-in email is written into the knowledge graph and can be handed to a visitor by search_knowledge. Remove it or gate on contact_public; add a test. knowledge.go:981.
B10 BENo way to delete one conversation. Add DELETE /v1/pages/{page_id}/conversations/{id} that hard-deletes the thread and cascades its digest, turn signals and decision records.
B7 BECode done; deploy and policy wording outstanding. Both windows now default to 365 days instead of 0, both deploy workflows render them with a ${VAR:-365} fallback so an unset Environment variable does not switch retention off, and the insights sweep moved out of the INSIGHTS_ENABLED-gated digest job so deletion no longer rides on a model-spend flag. Remaining: deploy the backend and confirm the values reached the container, and rewrite policy §9 with the numbers — the exact facts are in the 2.3 table of docs/app-store-privacy-disclosure.md.

Confirmations & cheap fixes

IDWhat to do
B4 BERead and record live decision_vendor / insights_vendor, and turn off OpenRouter's train-on-data routing. The OpenRouter half is now pinned in code (2026-10-06): every request sends provider.data_collection=deny, so the account toggle is belt-and-braces rather than the only safeguard. Still to do: read decision_vendor and insights_vendor in the panel — no code can establish those.
B5 BEConfirm Deepgram / ElevenLabs retention terms, or soften the policy sentence to only the vendors you have confirmed. Largely resolved in code, 2026-10-06 (JUE-553). The voice agent now sends mip_opt_out=True to Deepgram and enable_logging=False to ElevenLabs instead of relying on either permissive default, and the policy describes those settings rather than asserting a blanket no-training term. Residual: a written retention period from each vendor, which only they can supply.
B6 BESet a Langfuse retention period; add a deploy guard that fails if LANGFUSE_SEND_CONTENT is ever true in prod.
B11 BEEstablish whether the Slack alert webhook is set; if so, strip account/Auth0 identifiers from the payload or add Slack to the register.
F2 BERemove the dead Gemini client and its two key-wiring paths from the web bundle — a reviewer greps the bundle, and a future engineer setting the var would ship a key to every visitor.
F7 BEMove link-preview resolution behind the API so the device only ever talks to api.ambasdr.com — removes four uncontracted recipients (YouTube/Vimeo/TikTok/Pinterest/Microlink) from the register.
F9 BEWhen the policy page is replaced, update its test + Storybook story in the same change (the test pins the effective date and section numbers), and assert every named sub-processor still appears.
04

Google Play content-policy findings

Added by this audit's pass against the Play Developer Content Policy hub (the pack did not cover it). G1 and G2 are verified against Play's Payments and Child Safety Standards policies; G3 is flagged for confirmation.

G1 — Google Play Billing, not external Stripe BE / PRODUCT

Play requires its own billing system for digital subscriptions consumed in-app, and states "apps may not lead users to a payment method other than Google Play's billing system." Apple's US external-purchase carve-out does not extend to Play, so the current Stripe-in-browser flow (POST /v1/billing/checkout) is a likely Play rejection. Decision needed: integrate Google Play Billing for the Android client, or confirm eligibility for one of Play's alternative-billing programs. This is a product/billing decision, not a mobile-only edit.

G2 — Play Child Safety Standards policy applies CROSS-CUTTING

Play's Child Safety Standards policy names anonymous / random-chat and social apps, and applies "regardless of whether child users are present." An AI that chats with anonymous strangers on public profiles is in scope. It requires: (1) published CSAE standards, (2) an in-app mechanism to report CSAE without leaving the app (overlaps B13), (3) a designated point of contact, and (4) the corresponding Play Console declaration. None exist today. Fold the reporting mechanism into B13 and publish the standards + contact separately.

G3 — Impersonation risk of the persona feature PRODUCT / VERIFY

A member can build an ambasdr that speaks as a named person. Play's Impersonation policy and Apple both restrict misleading users about identity, and this compounds B12 (the visitor must know they are talking to AI, not the real person). Flagged rather than graded — confirm against the exact Impersonation policy wording and decide whether name/likeness verification is needed before it becomes a defined action item.

05

Store-console & human tasks

Outside the codebase. The disclosure pack supplies the exact tables to type into each; these are the actions, not the content.

The one ordering rule that overrides the list

Publish each disclosure before switching on the thing it discloses — never after. This binds hardest on B3: setting CHAT_MODEL / CHAT_BASE_URL / CHAT_API_KEY (PR #273) points live visitor chat at DeepSeek the moment they are set. Enabling that before the matching consent screen and policy text are live reproduces the original rejection, this time with a written record that you knew.

06

What's already clean

Verified against the current code, so credit is due where it is earned — this is the part of the submission you do not have to fight.

No tracking

Zero ad / analytics SDKs

No Firebase, Sentry, Amplitude, Segment, Mixpanel, Bugsnag, Datadog, AppsFlyer, Branch or Google Mobile Ads. No IDFA/GAID is read, so the Apple "tracking" answer is honestly No and no ATT prompt is needed.

Payments

External purchase, done right

Stripe Checkout opens in the system browser, never an in-app web view. No card data ever reaches Ambasdr — Payment Info is honestly declared "Not collected".

Config

Encryption flag correct

ITSAppUsesNonExemptEncryption:false is right for an app whose only crypto is HTTPS and the platform keychain. Photo and Documents usage strings are present and describe real behaviour.

The device motion sensor (expo-sensors) drives one decorative Settings animation and its reading never leaves the phone; Auth0 tokens live in the Keychain / Keystore. The account-screen legal links already work — only the pre-auth sign-in screen's are dead (B8).

07

How this was checked

AspectMethod
Live guidelinesApple App Store Review Guidelines and Google Play policies fetched 2026-09-29 — Apple 2.1, 1.2, 5.1.1(i)/(v), 5.1.2(i) (the 13 Nov 2025 third-party-AI change), 3.1.1/3.1.3, 4.8; Google Play Developer Content Policy hub plus the Data-safety, account-deletion, Generative-AI-content, Payments, and Child Safety Standards pages. Requirements confirmed against the pages themselves, not memory.
Mobile findingsB14, B8, F1, F5, F6, F8 independently re-verified against the current tree with exact file:line evidence and the app's config (app.json, eas.json, package.json).
Backend & web findingsRe-verified against current HEAD on 2026-09-29 by reading the cited Go and React code directly. All confirmed except B0a's "no publish control", which no longer holds — a PATCH toggle now exists. Line numbers updated where they drifted. Backend stays Gerald's to fix; verification is read-only.
Android buildTarget API 35 (Android 15) via Expo SDK 54 — meets Play's requirement (generated at build; confirm on the artifact). Merged manifest declares RECORD_AUDIO, SYSTEM_ALERT_WINDOW and legacy storage permissions.
Currency checkNo legal or consent surface changed since the pack; local main is 16 commits behind origin/main, so fixes should branch from origin/main.

Sources: internal Ambasdr Privacy Disclosure Pack (2026-09-28); Apple App Store Review Guidelines and Google Play Developer Program Policy hub (fetched 2026-09-29); structure adapted from the store-readiness-audit and in-repo app-store-compliance skills. App under audit: Ambasdr iOS com.juelz.ambasdr (ASC 6773923666) / Android com.ambasdr.app, version 0.1.1.

08

Security posture — the 20-item checklist

Proposal — not yet actioned

Everything from here down is a proposal. It audits the codebase against the 20-point security checklist and lays out three ways to close the gaps — no fixes have been made. The audit is grounded: two read-only passes over the Go backend and the repo/git-history on 2026-09-29, with file:line evidence.

The honest headline: the posture is already strong on about thirteen of the twenty items. This is "close a named list," not "you're wide open." States: PRESENT, PARTIAL, GAP, PROCESS, N-A.

#Checklist itemStateEvidence / note
1Secure your API keysGAP (latent)Env-only, none hardcoded — but the dead VITE_GEMINI_API_KEY would ship to the browser bundle if a key were ever set (currently unset). Remove the wiring (F2, vite.config.ts:11).
2Hide all .env filesPRESENTOnly .env.example tracked; .env gitignored across every service.
3Never hardcode secretsPRESENTNo literal secrets in source; all env/config-driven.
4Add authenticationPRESENTAuth0 JWT + M2M + agent API-key + admin API-key, all constant-time compare (router.go:262,278,482,523).
5Verify permissions server-sidePRESENTOwnership checked before every op (page.go:48-64, document.go:126-141).
6Don't trust frontend user IDsPRESENTIdentity comes from the JWT context, never a request body/param.
7Isolate user dataPRESENTQueries scoped to user.ID (page.go:106); no IDOR found.
8Lock down your databasePRESENTgoqu parameterized throughout; managed Postgres on a private VPC.
9Secure Firebase, Supabase, and storageN-A / PRESENTNo Firebase or Supabase in the stack. Storage (DO Spaces) is ACL:private (spaces.go:112), served via authed proxies that re-check visibility && is_published per request (document_proxy.go:74).
10Protect admin routesPRESENTAdmin API key + Cloudflare Access + email allowlist + audit log; dev bypass gated to ENVIRONMENT=development (router.go:521-555).
11Disable production debug modeGAPGIN_MODE defaults to debug (config.go:694); prod must set release, and nothing enforces it at build.
12Hide detailed errorsPARTIALClient-facing is safe (commonerrors + publicDetail(), no raw err.Error()) — but B9 logs full request/response bodies + headers into Loki for 30 days (router.go:115).
13Validate inputs server-sidePRESENTHandlers are the live validation (OpenAPI middleware not wired); upload validation is thorough.
14Sanitize user contentPARTIALStored raw; React escapes on render and the vCard export escapes — but no server-side sanitization, and the LLM receives raw text (a prompt-injection surface).
15Secure file uploadsPRESENTSize caps + magic-byte sniffing + image re-encode (strips EXIF/GPS) + filename sanitization (upload.go:80-181). Caveat: B0a default-public visibility.
16Prevent SQL and NoSQL injectionPRESENTgoqu parameterized; zero fmt.Sprintf-into-SQL; no NoSQL store.
17Rate-limit login and signupGAPPublic chat is rate-limited (ratelimit.go:65), but /v1/waitlist is unauthenticated with no limit; login relies on Auth0.
18Check Git history for secretsPRESENT (done)History scanned this pass — no real secret ever committed (only placeholders in docs/tests).
19Add security headers and restrict CORSPARTIALCORS is allowlisted, not * (good) — but HSTS / CSP / X-Frame-Options are not in the Go layer and are unconfirmed in deploy/caddy/Caddyfile; verify at the edge.
20Test as an untrusted userPROCESSNo systematic adversarial / IDOR pass has been run; this is an activity, not a code state.
09

Three ways to fulfill the checklist

Three genuinely different strategies — fix the list, automate the list, or attack the list. Each with its trade-offs. Most of the work is backend (Gerald's territory); the mobile-owned slice is confirming EXPO_PUBLIC_* stays clean.

Option A

Close the named gaps (targeted)

Open a focused ticket per gap: a GIN_MODE=release guard, verify/add the Caddy security headers, rate-limit /v1/waitlist + add Auth0 login protections, delete the dead Gemini wiring, make a content-sanitization decision, and B9. Each ships as a small PR with a test.

Pros

  • Fast, cheap, low-risk — matches reality (posture is already strong).
  • Every fix is small and testable; no backend re-architecture.
  • Ships alongside the store-submission fixes.

Cons

  • No standing guard against regression — a later change can quietly reopen a gap.
  • "Test as an untrusted user" stays ad-hoc.
Option B

Bake guardrails into CI (systematize)

Do Option A's fixes and make them non-regressing: a secret-scanning CI step (gitleaks/trufflehog), a deploy guard that fails the workflow if GIN_MODE != release in prod (mirroring the existing LANGFUSE_SEND_CONTENT-guard idea), a security-headers + CORS integration test, and an authz/IDOR suite that encodes "test as an untrusted user" on the existing testcontainers infra.

Pros

  • Durable — the checklist becomes enforced contracts that can't silently regress.
  • Fits the repo's "test the promise" + real-Postgres testing culture.
  • Strongest posture for a rejection-averse, pre-launch product.

Cons

  • More work, and more billed CI minutes — CI cost is explicitly tracked in this repo.
  • Needs Gerald for the backend test additions; longer to land.
Option C

Adversarial pass first (assurance-first)

Elevate item 20 to the driving method: run a structured "untrusted user" attack pass — a workflow/agent or an external pen-test — that actually attempts IDOR, authz bypass, injection, rate-limit abuse and secret exposure against a running stack with seeded test tenants, produce a severity-ranked findings report, then remediate worst-first.

Pros

  • Highest assurance — finds unknown-unknowns static review can't.
  • Directly satisfies "test as an untrusted user"; produces evidence for store/legal/investors.

Cons

  • Most expensive and slowest; needs a running stack + seeded tenants.
  • Findings likely land in the backend; overkill if the named gaps are truly the whole story.
10

Recommendation

A blended path (proposal — your call)

A now → B next → C as a pre-launch gate. Option A closes the named gaps cheaply and can ride alongside the store-submission fixes. Option B then locks them so they can't regress — worth the CI minutes for a product that has already been rejected once. Option C is the assurance gate to run before public launch, not a day-one requirement. Ownership routes to Gerald for the backend items per the never-touch-backend rule; the mobile-owned piece is small (keep EXPO_PUBLIC_* clean, and the dead Gemini wiring is web, not mobile). This is a proposal — pick the option or the mix you want and it becomes the work plan.