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.
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.
| Category | Grade | Basis — 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). 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. |
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.
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.
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.
The app declares Implemented, then deliberately reversed — read both halves before acting.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".
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.
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.
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.
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.
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.
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.
| ID | What must change |
|---|---|
| B2 BE | In-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 BE | Uploads 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 BE | Security-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 BE | The 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 BE | Add "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 BE | Label 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 BE | The published policy claims "insights are summaries, not raw transcripts" — false; members read every message. Publish the replacement policy (pack Part 9). |
| B0 BE | A 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 BE | No 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 BE | Code 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. |
| ID | What to do |
|---|---|
| B4 BE | Read 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 BE | 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 BE | Set a Langfuse retention period; add a deploy guard that fails if LANGFUSE_SEND_CONTENT is ever true in prod. |
| B11 BE | Establish whether the Slack alert webhook is set; if so, strip account/Auth0 identifiers from the payload or add Slack to the register. |
| F2 BE | Remove 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 BE | Move 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 BE | When 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. |
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.
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.
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.
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.
Outside the codebase. The disclosure pack supplies the exact tables to type into each; these are the actions, not the content.
https://ambasdr.com/account-deletion (in-app path and web URL are both required).https://ambasdr.com/privacy, naming the AI sub-processors.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.
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 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.
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".
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).
| Aspect | Method |
|---|---|
| Live guidelines | Apple 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 findings | B14, 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 findings | Re-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 build | Target 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 check | No 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.
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 item | State | Evidence / note |
|---|---|---|---|
| 1 | Secure your API keys | GAP (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). |
| 2 | Hide all .env files | PRESENT | Only .env.example tracked; .env gitignored across every service. |
| 3 | Never hardcode secrets | PRESENT | No literal secrets in source; all env/config-driven. |
| 4 | Add authentication | PRESENT | Auth0 JWT + M2M + agent API-key + admin API-key, all constant-time compare (router.go:262,278,482,523). |
| 5 | Verify permissions server-side | PRESENT | Ownership checked before every op (page.go:48-64, document.go:126-141). |
| 6 | Don't trust frontend user IDs | PRESENT | Identity comes from the JWT context, never a request body/param. |
| 7 | Isolate user data | PRESENT | Queries scoped to user.ID (page.go:106); no IDOR found. |
| 8 | Lock down your database | PRESENT | goqu parameterized throughout; managed Postgres on a private VPC. |
| 9 | Secure Firebase, Supabase, and storage | N-A / PRESENT | No 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). |
| 10 | Protect admin routes | PRESENT | Admin API key + Cloudflare Access + email allowlist + audit log; dev bypass gated to ENVIRONMENT=development (router.go:521-555). |
| 11 | Disable production debug mode | GAP | GIN_MODE defaults to debug (config.go:694); prod must set release, and nothing enforces it at build. |
| 12 | Hide detailed errors | PARTIAL | Client-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). |
| 13 | Validate inputs server-side | PRESENT | Handlers are the live validation (OpenAPI middleware not wired); upload validation is thorough. |
| 14 | Sanitize user content | PARTIAL | Stored 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). |
| 15 | Secure file uploads | PRESENT | Size caps + magic-byte sniffing + image re-encode (strips EXIF/GPS) + filename sanitization (upload.go:80-181). Caveat: B0a default-public visibility. |
| 16 | Prevent SQL and NoSQL injection | PRESENT | goqu parameterized; zero fmt.Sprintf-into-SQL; no NoSQL store. |
| 17 | Rate-limit login and signup | GAP | Public chat is rate-limited (ratelimit.go:65), but /v1/waitlist is unauthenticated with no limit; login relies on Auth0. |
| 18 | Check Git history for secrets | PRESENT (done) | History scanned this pass — no real secret ever committed (only placeholders in docs/tests). |
| 19 | Add security headers and restrict CORS | PARTIAL | CORS 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. |
| 20 | Test as an untrusted user | PROCESS | No systematic adversarial / IDOR pass has been run; this is an activity, not a code state. |
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.
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
Cons
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
Cons
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
Cons
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.