Controls what happens when a user hasn't verified their domain.
What a new account can do before it has clicked the link in its verification email. Admin accounts are never locked out.
Allow non-logged-in users to build one URL-wrap app per IP/fingerprint before being required to sign in.
In addition to the existing free URL wrap. Each guest gets one Clone and one Describe build, tracked separately by IP/fingerprint.
Loading…
Controls the welcome-offer banner on the home page.
Optional things visitors see on the front page.
A one-time popup nudging new visitors toward the free URL tool, separate from the top banner.
Links to your Creative Digital Downloads store, opens in a new tab.
When on, new Generate builds bake in a flag that hides all in-app paywalls/upgrade prompts — required for Aptoide/Google Play review, which rejects apps with a visible IAP flow and no store billing SDK. Existing builds already downloaded are unaffected; toggle off and generate a new build to re-enable gating for that app.
How much of each app a free user gets.
Applies to new Generate builds only (baked in at build time, like the gating flag above). Export is always allowed; import from a JSON export is disabled so reinstalling the app does not bypass these caps.
Each row saves independently. These are the real Stripe charge amounts — the server always uses this value regardless of what the installed app sends.
Feature key is always pro_unlock — the app_slug must exactly match the slug the template's gateFeature() calls use.
See who's signed up, their plan, and send a one-off email update to your user base.
Loading...
Choosing "Custom list" reveals a box below to paste specific email addresses or upload a file, instead of picking from existing users.
Send to these specific people instead:
Warm-up sending (large lists — paced, resumable, safe for sender reputation)
Sends this many per day, in small paced batches. Re-sending the same campaign later automatically skips anyone already reached, so a large list gets worked through safely over several days.
Real customer/visitor request activity — separate from the raw worker debug log below. Auto-refreshes every 15s.
Loading…
Real-time worker activity — same as pm2 logs wrapapp_worker. Red lines are errors.
Old build files and generated sites are deleted automatically to reclaim disk space.
Adjust price and monthly app quota per plan — e.g. seasonal promos. Other plan features stay code-managed.
Every build across every user, live. Red rows have been in-progress 5+ minutes — likely stuck.
Adjust pack names, prices and credit amounts. Changes take effect on next purchase (new Stripe price created automatically).
All Android build types (URL wrap, Clone, Generate) — runs on our own server, no external billing.
Loading…
Minutes consumed by the Mac-runner iOS build pipeline.
Loading…
Who can build each ready-made app. Any plan = every plan that can generate. Pro and Elite = those two plans. Elite only = Elite. Others see an upgrade message. Admins are never blocked. The same choice is on each row of Description Templates.
Templates shown to users in the Generate tab. Add, edit, reorder, or deactivate. Changes are live immediately.
Tracks our own LLM calls against daily limits — catches quota exhaustion before customers hit it.
Active provider (switches immediately, no .env editing needed)
Ollama (self-hosted, free) — server & models
Top up / manage billing
🧪 Ollama Testing (dev only)
🚨 Emergency Mode
If this runs out, customers can't:
🧬 Clone an existing app (analysis + generation)
✨ Build a fresh app from a description
✓ Plain URL-to-APK, iOS builds, billing, and everything else keep working fine — only AI generation is affected.
Real daily quota usage across providers, pulled from actual API call logs (Gemini/Anthropic don't expose per-call dollar cost directly -- this tracks real call volume and quota-hit frequency against your configured daily limits, the leading indicator of real cost/reliability). Auto-refreshes every 30s.
Add, edit, and reorder OpenAI-compatible LLM providers. These are tried before the hardcoded Gemini/Anthropic/Ollama fallback chain.
Runs a real build against the CURRENT Ollama toggle settings above — use this periodically to confirm routing still works after any deploy.
URL-to-APK test
Description test
Clone test (real analyzed upload)
IPA analysis test (analysis only — tests Clone's understanding of an uploaded .ipa)
iOS build test (REAL GitHub Actions build — costs real minutes)
Track referral traffic and conversions, generate tracking links, and see who's earned what.
Generate a tracking link
Generate a tracking link for a specific app's share page
Paste an existing share link (or just its token) for a build you've already shared -- this adds tracking (using the Affiliate ID + Platform above) to that exact link, so visits/downloads for that specific app get attributed, not just homepage visits.
Leaderboard
Traffic Source — matches the range selected above
Generate a download-tracking link
For linking straight to a specific APK file in an ad (e.g. a YouTube video description) -- separate from the affiliate links above, since those track site visits, not direct file downloads.
Download Campaigns
Real customer activity vs. internal test accounts — test data is never hidden, only labeled.
Real builds by type
What people wrote on the public page /feedback (YouTube comments, app links). Reply opens your email app with their message quoted.
Architecture reference — not shown to regular users. Visible only because you're signed in with an admin account.
How it actually works: Every build request (Wrap, Clone, or Generate) does the same first step: INSERT INTO builds then buildQueue.add(...) — this pushes a job onto a single shared BullMQ queue backed by Redis.
WORKER_CONCURRENCY, hardcoded default). That means at most 2 builds run genuinely simultaneously, regardless of how many are queued.sandbox.js, which we tested and verified working on .21 earlier). Wrap and Generate builds run Gradle directly on the host — no Docker container per request. Docker only fires for Clone/analyze steps, and even then, it's a short-lived, --rm container that exits the moment analysis finishes — not a persistent session per user.
Concurrency = 2 was deliberately chosen (per the comment we saw in worker.js earlier tonight) because each Gradle build can peak at 1.5-2GB RAM, and the box also needs headroom for MariaDB (~1.9GB resident), Redis, PM2's other processes, and — when triggered — a Docker container capped at --memory 1g --cpus 1.
Rough math: 2 concurrent Gradle builds (up to ~4GB) + MySQL (~2GB) + everything else (~1-2GB) = comfortably within 10.7GB, with real margin. If a 3rd build happened to need Docker analysis simultaneously, that's another capped 1GB — still fits, but margins tighten.
Written after the Calorie Tracker PRO and Document QA PRO builds (Sept 2026), to prevent future templates from silently breaking each other. Real files, real roles, verified directly against the codebase -- not guessed.
Genuinely shared, by design (all templates use these):
src/routes/billing.js -- the generic, reusable app-feature paywall system (/api/billing/app-features/check + /checkout). Built once so any future template can gate a named feature (app_slug + feature_key) with zero new backend work. Checks the real subscription tier first (payg/starter/builder/pro/elite auto-unlock), then a one-time app_feature_purchases row.src/routes/auth.js + src/middleware/auth.js -- real WrapApp login/JWT, used wherever a template needs to know the real, authenticated user.src/worker/tools/templateResolver.js -- keyword-to-template routing for every local template. Runs inside the wrapapp_worker PM2 process specifically, not wrapapp_web -- a change here needs pm2 restart wrapapp_worker, confirmed the hard way after a resolver fix silently didn't take effect until the worker was restarted separately.src/worker/jobs/generateApp.js -- injects the real, fixed WrapApp branding bars (top+bottom) into every generated app at build time. Any new template's own header/footer needs real clearance (confirmed pattern: margin-top: 26px / matching bottom offset) or the fixed bars will visually cover it.Properly isolated per template (confirmed by direct grep, not assumed):
templates/calorie-meal-tracker-pro/, src/routes/nutrition.js, src/routes/analyzeMeal.js, src/routes/premium.js (favorites + herbal RAG Q&A).templates/document-qa-pro/, src/routes/documents.js.templates/reader/ only (fully offline, no route file of its own; Pro via the shared billing.js, slug reader). Picker row added by tools/seed_reader_description_template.py.nutrition.js was accidentally shared with Document QA PRO. Directly verified via grep against the real template file: false alarm -- Document QA PRO only has the word "search" in UI copy (Local vs AI search mode labels), it never calls any /api/nutrition/* endpoint. No cross-contamination found between these two templates at any point tonight.
Rules for every future local template:
templates/ and its own dedicated route file(s) under src/routes/ -- never reuse another template's route file for a different template's feature./api/billing/app-features/* endpoints with a real, unique app_slug -- don't build a second gating system.Written Sept 2026 after real bugs were found and fixed in Calorie Tracker PRO and Plant Genius PRO's gating (leftover premium gates that should have been free, shared free-limit keys between unrelated features, a missing Stripe webhook that let a real customer pay and stay locked out). This is the complete, current standard -- any AI session building a new WrapApp template should follow this exactly, not invent its own gating.
Two separate gating layers -- never confuse them:
WRAPAPP_FREE_LIMIT / WRAPAPP_HISTORY_CAP / WRAPAPP_DOC_IMPORT_CAP) injected by generateApp.js, admin-configurable via the usage-limits card above. Template helpers: wrapCheckFreeLimit(key), wrapGetCap(key), wrapShowLimitLock(label). Tracks a per-device monthly counter in localStorage -- soft, resets monthly, never touches the server or real money.The real IAP system -- use exactly this, no new backend work needed:
'pro_unlock'. Every premium action in a template gates through the same (app_slug, 'pro_unlock') pair -- one purchase unlocks every premium feature in that app. Do not create a separate key per feature (the old per-feature approach was deliberately reworked away from).gateFeature(appSlug, 'pro_unlock', 'Your App Name Pro', priceCents, onUnlocked, onDenied) function pattern at every premium action's entry point. It checks /api/billing/app-features/check first (auto-unlocks qualifying subscribers, then checks app_feature_purchases), and on deny shows a purchase prompt.confirm()/alert() for the purchase prompt. Build a showPurchaseModal(featureName, priceCents, onConfirm, onCancel) styled to the app's own brand colors (see Calorie Tracker/Plant Genius templates for the reference implementation) -- a real customer flagged the generic native dialog as looking broken/untrustworthy.check.priceCents), never hardcode it into the confirm/modal text -- the admin can change the price at any time via the "In-app purchase prices" card without an app rebuild, and a stale hardcoded price shown to the user is a real bug.window.location.href = data.url -- never window.open(data.url, '_blank'). The Android WebView shell has setSupportMultipleWindows(false) with no onCreateWindow override, so window.open() is a silent no-op -- confirmed the real cause of a "no payment link appears" bug. window.location.href triggers the existing shouldOverrideUrlLoading handoff to a real external browser correctly.window.WRAPAPP_HIDE_PREMIUM_GATING is true (Aptoide/Google Play builds with no visible IAP surface), a denied action must show wrapShowLimitLock() and stop -- never fall through to the real gateFeature()/checkout flow in that build. On allow, always call the pure underlying action directly (e.g. openGallery()), never a wrapped/original function that itself re-triggers the premium gate -- this exact mistake (calling origTapGallery() instead of openGallery()) caused a real "immediately re-locks after unlocking" bug in Plant Genius.
Mandatory checklist before shipping any new gated template:
(app_slug, 'pro_unlock') row to feature_prices via the admin "In-app purchase prices" card before the template ships -- tools/check_payment_gateway.js (ops menu 28) will fail loudly on a "feature-key drift" check if any template references a key with no matching DB row.Auth.checkFeature(appSlug, 'pro_unlock') call on load (for a logged-in user) -- never left permanently visible or only toggled client-side on the purchase click. A banner that doesn't hide itself for an already-paid user on a fresh login is a real, previously-shipped bug.node --check templates/<your-template>/index.html (or the project's syntax-check pattern) after every edit, before rebuilding.tools/check_payment_gateway.js (ops menu 28) after adding or changing any gate -- it checks auth is required on both billing endpoints and cross-references every gateFeature() call site in every template against the real feature_prices table.checkout.session.completed events had nowhere to deliver to. The webhook handler code itself was already correct; the endpoint registration was simply missing. Before assuming any future "payment succeeded but app still locked" report is a code bug, confirm first that a live webhook endpoint (stripe.webhookEndpoints.list()) actually points at this app's real public domain, and that STRIPE_WEBHOOK_SECRET in .env matches that endpoint's current signing secret.
Written after the real WrapApp.com Aptoide Connect submission (MD5 ac8d0a721c49dd594ff7996e99bcdc17, v1.1) was rejected: "has IAPs but does not have our billing solution implemented."
Why this happens:
Aptoide Connect and Google Play both require apps with visible in-app purchase flows to use their own respective billing SDK, regardless of what real payment processor actually powers it (Stripe, in our case). An app with a visible "Buy"/"Upgrade" button and no matching store SDK gets rejected on sight.
Two real options when this happens:
The real, live fix for URL-wrap builds (the "Wrap" tool):
Confirmed directly by extracting and inspecting the actual rejected APK: it is a thin WebView wrapper (com.wrapapp.template.MainActivity, the same real android-webview template used for every generated app) pointing at https://builder.creativetechniques.org via BuildConfig.TARGET_URL -- it has no bundled HTML of its own, so the real purchase UI lives entirely on the live site.
public/index.html now checks its own real query string on load. When ?aptoide=1 is present, it adds an aptoide-build class to <html>, and matching CSS hides every real purchase/upgrade entry point (#pricing, the nav's Billing/Pricing links, .upbtn, #bannerCta, #tailoredRedesignLocked) via display:none. Since the buttons that trigger /api/billing/checkout* are gone, those endpoints are never called from this build either -- genuinely no IAP surface for review to find. The plain URL with no query string, and every other existing build, is completely unaffected. To rebuild for Aptoide: point TARGET_URL at https://builder.creativetechniques.org/?aptoide=1 specifically.
Google Play: same real mechanism, live pre-emptively.
Google Play Billing has the identical real requirement for digital-goods IAPs. The same real check for ?googleplay=1 is already live in public/index.html alongside the Aptoide one, adding a googleplay-build class and reusing the same display:none CSS rules -- built pre-emptively, before any real Google Play rejection, in case one ever happens for the same reason. If a URL-wrap build needs it: point TARGET_URL at https://builder.creativetechniques.org/?googleplay=1 specifically.
Important: this does NOT cover the local, bundled-HTML templates.
index.html locally inside the APK, with their own separate real gating UI (the "Unlock for £0.99" gateFeature() buttons via /api/billing/app-features/*). There is no live URL to attach a query parameter to, so the ?aptoide=1 mechanism above does not apply to them at all. Publishing any of these specific generated apps to Aptoide or Google Play would hit the identical real rejection separately, and needs its own real, analogous fix: a build-time flag baked into that template's bundled HTML by generateApp.js for builds explicitly marked for that store, which the template's own JS checks to hide its own gating UI the same way. Not yet built -- only needed if/when a generated app is actually submitted to one of these stores.
Verified end-to-end on Women Cycle Genius Pro (com.WomenCycleGenius) on 2026-10-01, with a real completed test purchase (purchase_token + order_id recorded in app_feature_purchases). The real billing code (BillingBridge.kt playstore flavor, src/routes/billing.js verify-native) is shared infrastructure and already correct -- do not rebuild it. Only two Play Console steps are genuinely per-app:
1. Create the in-app product
Play Console → [App] → Monetize with Play → Products → One-time products → Create product. Product ID must exactly match the string that app's template passes to window.AndroidApp.requestPurchase(featureKey) (usually pro_unlock -- grep the template to confirm). Set price via "Set prices" (NOT the separate "App pricing" page -- that's the app's own install price). Must show Active. Can take minutes-to-hours to actually propagate even after showing Active.
2. App needs a release on a testing track
Internal or alpha is enough. A sideloaded APK with no real track release throws "product not found" even with the product correctly created. Check with the Play Developer API (edits.tracks.list) rather than assuming.
What does NOT need repeating per app
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON) and its permission grant -- grant "View financial data" once under Play Console → Users and permissions → [service account] → Account permissions tab (not per-app App permissions) so it auto-applies to every future app. Missing this throws "insufficient permissions" server-side on every verify-native call.verify-native route itself -- already does real independent server-side verification via androidpublisher.purchases.products.get, checks purchase state, re-acknowledges as fallback, blocks token replay via a UNIQUE constraint. Each template just needs its own correct appSlug.Concurrent processing: 2 is the actual, current ceiling — that's not a soft guideline, it's a hard code-level setting. A 3rd, 4th, 50th request doesn't fail, it just waits in the Redis queue until one of the 2 active slots frees up.
Queued/waiting requests: effectively unlimited — Redis can hold thousands of queued jobs without strain. The real-world limit here is how long someone's willing to wait, not a system failure point. If builds take ~1-2 minutes each (matching what we saw in tonight's real test builds), and 20 people all hit "Build" at once, the 20th person waits roughly 10 build-cycles behind, which at ~90 seconds per Gradle build could mean a genuinely long queue delay (~15 minutes) — not a crash, just a bad user experience.
The Generate card has two independent switches and a quick-setup box that fills both in and prints a plain-English summary. Full reference: docs/admin/BUILD_TARGETS_AND_DISTRIBUTION.md.
| Where it is going | Quick setup | Paywalls | Billing |
|---|---|---|---|
| Google Play, paid | Google Play — paid | Show | Google Play |
| Google Play, free | Google Play — free | Hide | Google Play |
| Aptoide, paid | Aptoide — paid | Show | Aptoide |
| Aptoide, free | Aptoide — free | Hide | Aptoide |
| Direct download, paid via Stripe | Direct download — paid with my Stripe | Show | Stripe |
| Your private copy (never publish) | Private copy | Hide | Web |
Why Stripe stopped working inside APKs (history):
22 Sept: the Web flavor got a placeholder billing bridge so the project compiled. 26 Sept: the templates started handing every buy tap to the bridge whenever it exists. Since the placeholder exists in every APK, Stripe was never reached. The new Stripe target has a bridge with no purchase function, so the page falls through to Stripe.
Package name rules:
WrapApp.com: change before publishing.DocumentUploadQnA.com ownership was rejected). Use a domain you own, one unique last word per app (e.g. com.wrapapp.biblegenius), and the same domain on the website field and privacy policy. Doc-QA Pro’s existing listing needs Update on its saved app.An iPhone only installs apps signed with an Apple certificate, so an IPA cannot be self-signed like an APK. The web app below needs no signing at all. Reference: docs/admin/BUILD_TARGETS_AND_DISTRIBUTION.md section 7.
Route A — Web app (works today, free, keeps offline):
bash tools/wrapapp-tool.sh → option 36 → 1 (Build). Choose the template, a folder name, what the app gives them (everything unlocked, or Pro paid) and how people get in: access codes (friends’ names), wide open (a promo), a free trial of N days per person, open until a date, or public for good. For anything but public you can also let people carry on with a free WrapApp account (sign in / create one) — a way to turn promo viewers into sign-ups.https://wrap-app.co.uk/apps/<folder>/ on a computer and check it loads and Settings shows “Offline on this device”.Route B — IPA from Option 1 “Wrap a URL” (exists, unsigned):
src/routes/ios.js dispatches ios-preview.yml in GitHub repo chaosnature/wrapapp-ios-template (Mac runner, 10–20+ min).scripts/fetch_ios_artifacts.js downloads the result into public/ios_builds/<jobId>/.Route C — TestFlight / App Store:
docs/admin/platform-comparison.md).
iOS builds are tracked separately in ios_jobs, dispatched via GitHub Actions on a Mac runner (see below).
All three share one worker pipeline: src/worker/jobs/{webToApk,cloneApp,generateApp}.js → fs.rm any stale per-build temp dir (retry safety) → fs.cp the template → optional custom icon (writeAppIcon(), gated on deviceConfig.iconPath) → buildAndSign() in android.js → Gradle build → sign → save to data/builds/{id}.apk.
Every build gets a fresh, unique keystore by default (generateKeystore() in android.js) — one build, one keystore, never shared. This is deliberate: a compromised key only ever affects one customer's one app. generateKeystore() also deletes any pre-existing keystore file at that build's path before calling keytool — without this, a build retried after a prior failure would hit a keystore-collision error every time (confirmed: build 65's real failure mode).
The saved_apps table is the exception: when a user explicitly opts in ("save signing key for updates" checkbox — wired generically across all three Android build types on the frontend), that build's keystore path + key password + alias get copied into a persistent row via POST /api/saved-apps. A future build can pass savedAppId in its request body, which makes the route reuse that saved app's exact package_id, and the worker look up the saved keystore/version_code and pass them into buildAndSign() — the only way Android will treat a new build as an update rather than a separate install. version_code increments automatically on each reuse, and saved_apps.current_version_code gets bumped to match.
All three build types now support this identically (as of tonight). Previously only cloneApp.js/POST /clone implemented it; webToApk.js/POST / and generateApp.js/POST /generate hardcoded versionCode=1 and existingKeystore=null unconditionally and never accepted savedAppId at all — silently making "save signing key" a no-op for URL and Generate builds (worse: the checkbox still created a saved_apps row, just with key_pass/alias left NULL, since those two workers' final UPDATE never saved those columns — a broken, unusable row, not an absent one). Fixed and verified for real: build 76 (v1, URL-wrap) → saved as saved_apps id 10 → build 77 targeting that savedAppId came back with an identical keystore_path, version_code incremented 1→2, and apksigner verify --print-certs showing byte-identical SHA-256 digests on both APKs.
Earlier Clone-only verification: matching SHA-256 digests across builds 21→24→25. Both verifications used real cert comparison, not just DB field matching.
src/routes/verify.js — POST /api/verify/start issues a one-time token per (user, domain) pair, stored in ownership_checks. POST /api/verify/check fetches https://{domain}/.well-known/wrapapp-verify.txt first, falls back to scanning the homepage HTML for a matching <meta name="wrapapp-verify"> tag. isDomainVerified() treats a domain as verified for 30 days after a successful check, then requires re-verification.
Was broken until tonight: express.static only served public/, but .well-known/ lives at the project root — so the file method 404'd unconditionally, for every domain, regardless of whether the customer created the file correctly. Confirmed via both real historical ownership_checks rows (ids 1 and 4) showing verified_at = NULL — every verification attempt on record had silently failed this exact way. Fixed by adding a dedicated static mount for /.well-known in server.js. First successful verification (real token, real HTTPS fetch, real match) confirmed working post-fix.
Policy is admin-controlled (card above): full blocks unverified builds outright, frontpage_only lets them through but the generated app is locked to the homepage at build time (baked into the APK, not re-checked live — see RESTRICT_TO_FRONTPAGE in MainActivity.kt), none disables the check entirely.
POST /api/builds/:id/retry — checks ownership + status='failed', maps the DB's long type (web_android/clone_android/generate_android) to the queue's short job name (web_android/clone/generate), resets the row, requeues. Relies on the keystore-collision and stale-temp-dir fixes above to actually succeed on a build that failed before. User-facing Retry button on failed cards in index.html; admin builds-monitor has a per-row Retry plus a manual retry-by-ID box. Note: the client's "Recent apps" list is localStorage-scoped per browser session, not DB-backed — a build retried via API/another session won't appear there even though it's genuinely retried; the admin monitor is the reliable live view.
POST /api/admin/test-suite/{url,generate,clone} + GET /api/admin/test-suite/status/:buildId — runs a real build against the CURRENT Ollama toggle settings, for periodically verifying routing after a deploy. Every test build gets a unique package_id (a stray digit-first random segment broke this once — Android package segments can't start with a digit; AAPT caught it on the very first live run). Clone test picks from real analyzed uploads via GET /api/admin/test-suite/uploads — a real clone build cannot be faked without an uploads row genuinely at status='analyzed'. Provider attribution in the results is a recent-activity feed (last 5 llm_usage_log rows), not tied precisely to one build — llm_usage_log has no build_id/user_id column at all, so any "most recent row" approach would misattribute under concurrent builds (worker concurrency=2, confirmed to happen in practice).
GET/PUT /api/admin/plans (admin) edits price_monthly/max_apps_month live per plan — e.g. seasonal promos, no deploy needed. Deliberately scoped to just those two fields; feature flags (can_ios, can_clone, etc.) stay code-managed. GET /api/admin/public-plans is the public unauthenticated counterpart the homepage pricing section actually reads from — added because the admin card previously changed the DB but nothing customer-facing ever reflected it.
Template only ships modern adaptive icons (mipmap-anydpi-v26 XML + vector drawables, no legacy per-density raster mipmaps). src/worker/tools/appIcon.js (uses sharp) generates real per-density foreground/background/legacy-launcher PNGs and rewrites ic_launcher.xml to reference them, gated on deviceConfig.iconPath. Real upload UI lives in the ⚙ device-settings modal (POST /api/devicecfg/icon-upload). Verified via aapt2 dump resources/xmltree on a real compiled APK.
wrapapp_worker, concurrency=2) — generating → building progress stages written directly to the builds row.LLM_PROVIDER in .env (anthropic/gemini), plus admin-toggleable Ollama routing (admin-only testing mode, and an emergency all-users fallback) — src/worker/tools/llm.js is the single call site. Raw LLM output is passed through stripMarkdownFence() in generate.js before being used as app HTML — Ollama models are notably more likely than the hosted providers to wrap output in a markdown code fence despite instructions not to (and to add leading whitespace before it), which silently broke the old naive strip regex and shipped a literal ```html marker as visible page content in at least one real build (id 66) before the fix.GITHUB_DISPATCH_PAT against GITHUB_REPO_OWNER/GITHUB_REPO_NAME, artifacts fetched by a 2-minute cron.main, fine-grained commits going forward. Previously no version control at all.cleanup_old_builds.js (hourly) — deletes expired build files/generated sites/raw uploads, logs to cleanup_runs.cleanup.js (every 6h) — Qwen's original cleanup script, may be redundant with the above, not yet deduplicated.fetch_ios_artifacts.js (every 2 min) — pulls completed iOS builds from GitHub Actions.public/index.html still contains the visible HTML markup for all 3 build tools (Wrap/Clone/Generate) and their <script src=...> tags — that never changed. What moved out is each tool's JavaScript logic, previously ~2000 lines tangled together in one file with no isolation between tools. Real incidents this caused before the split: a status element (generateStatus) got silently removed during a layout rebuild, crashing Describe/Generate for an unknown period; separately, the Update-mode dropdown/radio HTML was removed while its JS still referenced it.
public/js/shared.js (161 lines) — $, headers(), toast(), getFingerprint(), auth/polling helpers. Must load first — every other tool file calls these on page load.public/js/wrap-tool.js (113 lines) — Option 1, submitAndroidBuild().public/js/clone-tool.js (72 lines) — Option 2, uploadApk() + generateClone().public/js/generate-tool.js (55 lines) — Option 3, startGenerate().public/js/device-settings.js (5 lines) — the gear-modal saveDeviceSettings() handler, shared across all 3 tools.3 verification tools exist specifically for this surface area — run all 3 after touching any of the above:
node tools/check_tool_elements.js (ops menu 25) — confirms every HTML element id each tool's JS references still exists in the live page.node tools/smoke_test_frontend.js (ops menu 24) — real headless-browser click test; the only one of the three that actually executes the JS and catches runtime/syntax errors.node tools/verify_split.js — checks function names and API endpoint strings are present as text. Does not validate the JS actually parses — a real extraction bug (missing async keyword, orphaned braces) passed this check while completely breaking the page; only the Puppeteer-based smoke test caught it. Treat a pass here as necessary, not sufficient.Confirmed real versions from this exact production server (Sep 2026) — not assumed, all checked directly.
Core runtime
npm install from package.json): bullmq, express, mysql2, ioredis, sharp, sqlite3 (reads the bounce-webhook's known_dead.db), puppeteer (also needs its own Chrome binary — see below), stripe, nodemailer, jsonwebtoken, bcryptjs, multer, express-rate-limit.Android build pipeline (Wrap/Clone/Generate all need this)
/opt/android-sdk (cmdline-tools + platform-tools installed), referenced via templates/android-webview/local.properties → sdk.dir=/opt/android-sdk./gradlew wrapper — no separate system-wide Gradle install needed)Clone tool's sandboxed analysis
wrapapp-analyzer image (built from sandbox/Dockerfile, base image eclipse-temurin:17-jre-jammy) — extracts an uploaded APK/IPA's structure in an isolated, --network none container before any AI analysis happensTesting/verification tooling
npx puppeteer browsers install chrome after npm install. Real incident: this was missing entirely on DR (.21) during Sep 2026 testing, causing tools/smoke_test_frontend.js to fail with "Could not find Chrome" until installed — easy to forget on a fresh box since it's not in package.json's dependency list at all.Real, verified-on-device native bridges available to any generated app's JavaScript via window.WrapApp.*. All added Sep 2026, all built the same way: manifest permission + runtime request + WebView bridge, then verified with a real build and a real device test before being trusted.
Camera, microphone, geolocation
Standard web APIs (getUserMedia(), navigator.geolocation) now actually work in generated apps — previously the WebView silently blocked all three with no permission prompt at all, since WebChromeClient.onPermissionRequest() was never implemented. Manifest declares CAMERA/RECORD_AUDIO/ACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATION; runtime request fires on app launch.
Barcode & QR scanning — fully offline
Real ZXing-based decoding via window.WrapApp.scanBarcode(callbackName). Supports all standard formats (QR, EAN-13, UPC-A, Code 128, etc). Verified end-to-end with a real product scan against Open Food Facts.
Handwriting/print OCR — fully offline extraction
ML Kit Text Recognition (bundled model, ~contributes to the 42MB APK size) via window.WrapApp.scanHandwriting(callbackName). Extraction happens entirely on-device — works in airplane mode. Verified on a real device with real handwritten text.
What needs internet (by design, to avoid app bloat)
Quick way to verify the offline/online split yourself on a real device: airplane mode on → Scan Handwriting → extraction succeeds, translation area shows "unavailable". That's the exact, correct behavior.
iOS builds run through a genuinely separate GitHub repository (GITHUB_REPO_OWNER/GITHUB_REPO_NAME, dispatched via src/routes/ios.js and GITHUB_DISPATCH_PAT) on a Mac runner — not this codebase, and not something this project's own tooling (smoke tests, element checkers) covers. This table reflects what's confirmed from the dispatch parameters this repo actually sends; the iOS build repo's own internals are outside this project's visibility.
| Capability | Android | iOS |
|---|---|---|
| Wrap a Website | ✅ Yes | ✅ Yes |
| Clone an App | ✅ Yes | ❌ Not implemented (dispatcher explicitly throws "ios_export handler not implemented yet") |
| Describe Your Idea (Generate) | ✅ Yes | ❌ Not implemented |
| Camera / microphone / geolocation permission bridge | ✅ Yes (Sep 2026) | ❌ No Swift equivalent exists |
| Barcode / QR scanning (native, offline) | ✅ Yes (ZXing) | ❌ Not built |
| Handwriting/print OCR (native, offline) | ✅ Yes (ML Kit) | ❌ Not built |
| Device settings (icon, zoom, viewport, mobile-fix) | ✅ Yes | ⚠ Unconfirmed — ios.js only accepts target_url/app_name/bundle_id/icon_url |
| Google Play Bundle (.aab) equivalent | ✅ Yes, opt-in checkbox | ⚠ N/A — iOS uses a different signing/export process entirely (IPA), not directly comparable |
Coming soon for iOS (not started — not scoped in detail yet)
Known open issue (from earlier session): iOS billing API returns 404 for this GitHub account — not yet on Enhanced Billing Platform rollout. Check github.com/settings/billing manually until GitHub enables it.
Each pill reports the health of one tier in the 4-tier LLM fallback chain: Gemini Free → Gemini Paid → Gemini WrapApp → Anthropic → Ollama. Colours are computed from the last ~300 worker log lines, so a red badge will age back to green once failures scroll out of the window.
| Green | Healthy — no quota/credit errors seen in the window. |
| Amber | On shift — this tier got pulled in as fallback (the tier above it failed) and is itself working. |
| Red | Exhausted/failing — quota hit or credit-balance error; the chain is skipping this tier. |
| Purple | Ollama actively serving as the last-resort fallback. |
| Grey | At rest — either not needed (Ollama) or healthy standby (cloud tier not named in the pill). The iOS pill is grey when there are no iOS builds today; green when the most recent iOS build succeeded; red when it failed. |
| Dark pill | provider: X | jobs: N — X = tier that served the most recent LLM call; N = jobs the worker picked up in the window. When X matches a badge, that badge is actively serving; other green badges are healthy standbys. |
Caveats: Colours age out as log lines rotate, so for absolute truth run tool option 29 (check_llm_providers.js) which hits each API live. iOS runs through the same LLM chain but is packaged in GitHub Actions on the wrapapp-ios-template repo. The badge reads local builds rows first, then the GitHub Actions runs API (cached 60s): green = last run succeeded, amber = run in progress, red = last run failed, grey = no runs yet. iOS dollar cost lives only at github.com/settings/billing → Metered usage → wrapapp-ios-template, because the GitHub billing API returns 404 for this account type.
build-device.js vs devicecfg.js) still both live — the latter had a real IDOR (any authenticated user could overwrite any other user's build's device config by guessing the build ID) that's now fixed, but the duplication itself isn't consolidated.Last 5 min / Last hour / Today: real visitor requests only, excludes admin's own self-polling (fixed 31 Aug 2026).Unique visitors today: distinct IPs today.Guest requests today: genuine guest build submissions/polls only.Top endpoints (last hour): real visitor activity, most common paths:/track: fires on landing via a tracked affiliate link./public-plans: pricing data for homepage, fires on page load./public-settings: banner/popup/guest-tool settings, fires on page load./engage: fires after 5s dwell or first interaction - a loose "didn't bounce" signal, not proof of tool use./tool-focus: fires when a visitor actually focuses the URL box, picks a Clone file, or focuses the Describe textarea - the real "tried the tool" signal, tracked per-tool in affiliate_visits.Visits/Engaged/Focused [Tool] today: same definitions above, scoped per affiliate, per-tool since 31 Aug 2026.Total visits: all-time count.Conversions today: completed paid_conversion events.Active affiliates: distinct affiliate IDs with a visit in range.Copy this raw text to paste into any new AI session to ensure strict compliance with WrapApp's billing architecture.
# WrapApp Gating & Billing Standards (For AI & Devs) ## 1. The Two Gating Layers (Distinct & Separate) - **Layer 1: LocalStorage Free-Tier Caps**: Used for soft UX limits. Checked client-side. - **Layer 2: Real Stripe IAP (Server-Side)**: The actual paywall. Requires a valid `stripe_session_id` in `app_feature_purchases` or active `subscriptions`. Enforced server-side (402/403 if missing). ## 2. Store-Safe Branching (`WRAPAPP_HIDE_PREMIUM_GATING`) - Aptoide/Play Store will reject apps with visible Stripe "Upgrade" buttons if native billing isn't used. - **Rule**: All premium UI must be wrapped in a check for `WRAPAPP_HIDE_PREMIUM_GATING`. If true, "Upgrade/Premium" buttons must be completely hidden from the DOM. ## 3. Android WebView Bridge Requirement - When triggering a purchase or deep-link from the WebView, you MUST use `window.location.href = '...'` or the specific `@JavascriptInterface` bridge. - **NEVER use `window.open()`**, as Android WebViews often block it. ## 4. The Single `pro_unlock` Feature-Key Convention - For template-level premium unlocks, use the standardized feature key: `pro_unlock`. - Do not invent new keys unless it's a distinct micro-transaction. ## 5. Mandatory `feature_prices` Registration - Any new `feature_key` introduced MUST be registered in the `feature_prices` database table. - **Why**: The `check_js_integrity.js` drift-check tool verifies that all feature keys referenced in frontend HTML exist in the backend `feature_prices` table. ## 6. Post-Change Workflow (Mandatory) After modifying any gating logic, HTML, or JS: 1. Run `node --check` on the modified JS. 2. Run the maintenance tool: Option 26 (JS Integrity + Drift Check). 3. Rebuild the template APK.