Controls what happens when a user hasn't verified their domain.
Controls the welcome-offer banner on the home 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.
In addition to the existing free URL wrap. Each guest gets one Clone and one Describe build, tracked separately by IP/fingerprint.
Allow non-logged-in users to build one URL-wrap app per IP/fingerprint before being required to sign in.
Loading…
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…
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.
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
Leaderboard
Traffic Source — matches the range selected above
Real customer activity vs. internal test accounts — test data is never hidden, only labeled.
Real builds by type
Architecture reference — not shown to regular users. Visible only because you're signed in with an admin account.
Build types
web_android — wraps a user-submitted URL directly, no AI involved.
clone_android — sandboxed decompile of an uploaded APK/IPA → LLM produces an abstract functional description (never raw content) → LLM generates a fresh app from that description → same build/sign pipeline as web_android.
generate_android — same generation step as clone, but the spec comes directly from the customer's own typed description, no upload/sandbox involved.
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.
Signing & the update-in-place system
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.
Domain verification
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.
Build retry
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.
Admin Test Suite
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).
Plan pricing
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.
Custom app icons
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.
Key infrastructure
Server: aapanel22 (192.168.1.22), reverse-proxied through .73 (UFW rule required for that hop specifically — see port 3002 allowlist).
Queue: BullMQ over Redis, single worker process (wrapapp_worker, concurrency=2) — generating → building progress stages written directly to the builds row.
LLM provider: switchable via 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.
iOS: GitHub Actions on a Mac runner, triggered via GITHUB_DISPATCH_PAT against GITHUB_REPO_OWNER/GITHUB_REPO_NAME, artifacts fetched by a 2-minute cron.
Version control: git repo at the project root, branch main, fine-grained commits going forward. Previously no version control at all.
Cron jobs currently active
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.
Known open items
• Device config (UA/viewport/zoom) not yet wired into clone/generate handlers — only plain URL builds honor it.
• Rate limiting installed in the wrong directory, not yet wired to auth routes.
• Two overlapping device-config route files (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.
• Credential rotation still pending: DB password, ADMIN_TOKEN, GitHub PAT, Stripe test keys — all have appeared in plaintext during development and should be rotated before any public/production launch.
• Stripe currency configuration not yet confirmed to match the £ display now shown on pricing — display-only change so far.
• 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.
Traffic & funnel metrics - what each one means
Live Traffic & Load card:
- 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.
Affiliate Marketing card:
- 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.
Guest Trials card:
- Real individual guest build attempts. URL shows null for Clone/Describe since those aren't URL-based.