source for getorbyt.com getorbyt.com/
client bsky orbytapp app orbyt bluesky getorbyt orbytvideo atproto video
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add dynamic AltStore source management with admin UI

- source.json is now served dynamically from R2 (no redeploy needed to publish releases)
- POST /api/admin/altstore/release publishes a new version (bearer token or session cookie auth)
- GET /admin/altstore is a protected admin UI: shows current release, publish form, verify/federate buttons
- altstore-r2.sh gains a `release` command that uploads ADP to R2 then calls the admin API in one step
- astro.config.mjs updated to route /altstore/source.json, /api/admin/*, /admin/* through the Worker

Setup required: run `wrangler secret put ADMIN_SECRET` then open /admin/altstore?token=<secret>

https://claude.ai/code/session_01RFjX945cF6zS1xdEJAEDM3

Claude 6ea5faa5 55c01fba

+642 -49
+1 -1
astro.config.mjs
··· 15 15 }, 16 16 routes: { 17 17 strategy: 'include', 18 - include: ['/@*', '/@*/*'], 18 + include: ['/@*', '/@*/*', '/altstore/source.json', '/api/admin/*', '/admin/*'], 19 19 }, 20 20 }), 21 21 });
+2 -1
package.json
··· 10 10 "check": "astro check", 11 11 "preview": "astro preview", 12 12 "astro": "astro", 13 - "altstore:r2": "bash ./scripts/altstore-r2.sh" 13 + "altstore:r2": "bash ./scripts/altstore-r2.sh", 14 + "altstore:release": "bash ./scripts/altstore-r2.sh release" 14 15 }, 15 16 "repository": { 16 17 "type": "git",
-47
public/altstore/source.json
··· 1 - { 2 - "name": "Orbyt", 3 - "subtitle": "Video app for Bluesky", 4 - "description": "Orbyt is a video-first social app for the Bluesky network, built on the AT Protocol. Available for EU and Japan via AltStore PAL.", 5 - "iconURL": "https://getorbyt.com/altstore/orbyt-icon.png", 6 - "headerURL": "https://getorbyt.com/images/orbyt-logotype.png", 7 - "website": "https://getorbyt.com", 8 - "tintColor": "#9B59B6", 9 - "fediUsername": "orbyt", 10 - "featuredApps": [ 11 - "com.getorbyt.app" 12 - ], 13 - "apps": [ 14 - { 15 - "name": "Orbyt", 16 - "bundleIdentifier": "com.getorbyt.app", 17 - "marketplaceID": "6751679299", 18 - "developerName": "Orbyt", 19 - "subtitle": "Video app for Bluesky", 20 - "localizedDescription": "Orbyt is a video-first social app for the Bluesky network.\n\n• Browse video feeds from Bluesky\n• Create and share short-form videos\n• Connect with the AT Protocol community\n• Built with React Native and Expo", 21 - "iconURL": "https://getorbyt.com/altstore/orbyt-icon.png", 22 - "tintColor": "#9B59B6", 23 - "category": "social", 24 - "screenshots": [], 25 - "versions": [ 26 - { 27 - "version": "1.1.2", 28 - "buildVersion": "62", 29 - "date": "2026-03-12", 30 - "localizedDescription": "Latest release.", 31 - "downloadURL": "https://downloads.getorbyt.com/manifest.json", 32 - "size": 70307293, 33 - "minOSVersion": "16.4" 34 - } 35 - ], 36 - "appPermissions": { 37 - "privacy": { 38 - "NSCameraUsageDescription": "We need camera access to record videos for posts and take profile photos.", 39 - "NSMicrophoneUsageDescription": "We need microphone access to record audio with your videos.", 40 - "NSPhotoLibraryUsageDescription": "We need access to your photo library to select videos for posts and photos for your profile.", 41 - "NSPhotoLibraryAddUsageDescription": "We need permission to save your videos to the camera roll." 42 - } 43 - } 44 - } 45 - ], 46 - "news": [] 47 - }
+65
scripts/altstore-r2.sh
··· 12 12 Usage: 13 13 ./scripts/altstore-r2.sh setup <bucket> [adp_dir] 14 14 ./scripts/altstore-r2.sh upload <bucket> [adp_dir] 15 + ./scripts/altstore-r2.sh release <bucket> <version> <build> <date> <size> [adp_dir] 15 16 ./scripts/altstore-r2.sh set-source-url <manifest_url> 16 17 ./scripts/altstore-r2.sh check <manifest_url> 17 18 ··· 19 20 setup Create the R2 bucket if needed, enable the public r2.dev URL, 20 21 upload the ADP directory, and update public/altstore/source.json. 21 22 upload Upload the ADP directory to an existing R2 bucket. 23 + release Upload ADP to R2 then call the admin API to publish a new version. 24 + Requires ADMIN_SECRET env var. After this, open the admin UI to confirm. 22 25 set-source-url Update public/altstore/source.json to point at a manifest URL. 23 26 check Fetch a manifest URL and print the HTTP status. 24 27 ··· 28 31 ALTSTORE_CUSTOM_MANIFEST_URL 29 32 Preferred manifest URL after upload. Defaults to 30 33 https://downloads.getorbyt.com/manifest.json. 34 + ADMIN_SECRET Required for the release command. Bearer token for the admin API. 35 + ADMIN_API_URL Override the admin API URL (default: https://getorbyt.com/api/admin/altstore/release). 36 + ALTSTORE_DESCRIPTION Override the changelog text for the release command. 37 + ALTSTORE_MIN_OS Override the minimum iOS version (default: 16.4). 31 38 EOF 32 39 } 33 40 ··· 199 206 echo "Next: deploy the site so https://getorbyt.com/altstore/source.json serves the updated metadata." 200 207 } 201 208 209 + publish_release() { 210 + local bucket="$1" 211 + local version="$2" 212 + local build="$3" 213 + local date="$4" 214 + local size="$5" 215 + local adp_dir="${6:-$DEFAULT_ADP_DIR}" 216 + local api_url="${ADMIN_API_URL:-https://getorbyt.com/api/admin/altstore/release}" 217 + local description="${ALTSTORE_DESCRIPTION:-}" 218 + local min_os="${ALTSTORE_MIN_OS:-16.4}" 219 + 220 + [[ -n "${ADMIN_SECRET:-}" ]] || fail "ADMIN_SECRET env var is required for the release command" 221 + 222 + echo "==> Uploading ADP to R2..." 223 + upload_adp "$bucket" "$adp_dir" 224 + 225 + echo 226 + echo "==> Publishing release v${version} (build ${build}) to admin API..." 227 + 228 + local payload 229 + payload="$(node --input-type=commonjs - <<NODE 230 + const body = { 231 + version: ${version@Q}, 232 + buildVersion: ${build@Q}, 233 + date: ${date@Q}, 234 + size: ${size}, 235 + downloadURL: "${DEFAULT_CUSTOM_MANIFEST_URL}", 236 + minOSVersion: ${min_os@Q}, 237 + }; 238 + if (${description@Q}) body.localizedDescription = ${description@Q}; 239 + process.stdout.write(JSON.stringify(body)); 240 + NODE 241 + )" 242 + 243 + local http_code 244 + http_code="$(curl -sS -o /tmp/altstore_release_response.json -w '%{http_code}' \ 245 + -X POST \ 246 + -H "Content-Type: application/json" \ 247 + -H "Authorization: Bearer ${ADMIN_SECRET}" \ 248 + -d "$payload" \ 249 + "$api_url")" 250 + 251 + if [[ "$http_code" == "200" ]]; then 252 + echo "Release published successfully." 253 + echo 254 + echo "Next: open https://getorbyt.com/admin/altstore to confirm and verify the release." 255 + else 256 + echo "Admin API returned HTTP ${http_code}:" 257 + cat /tmp/altstore_release_response.json 2>/dev/null || true 258 + echo 259 + fail "Release publish failed (HTTP ${http_code})" 260 + fi 261 + } 262 + 202 263 main() { 203 264 local command="${1:-}" 204 265 ··· 211 272 [[ $# -ge 2 ]] || fail "upload requires a bucket name" 212 273 ensure_adp_dir "${3:-$DEFAULT_ADP_DIR}" 213 274 upload_adp "$2" "${3:-$DEFAULT_ADP_DIR}" 275 + ;; 276 + release) 277 + [[ $# -ge 6 ]] || fail "release requires: <bucket> <version> <build> <date> <size> [adp_dir]" 278 + publish_release "$2" "$3" "$4" "$5" "$6" "${7:-$DEFAULT_ADP_DIR}" 214 279 ;; 215 280 set-source-url) 216 281 [[ $# -eq 2 ]] || fail "set-source-url requires a manifest URL"
+7
src/env.d.ts
··· 1 1 /// <reference path="../worker-configuration.d.ts" /> 2 2 3 + declare namespace Cloudflare { 4 + interface Env { 5 + orbyt_altstore_adp: R2Bucket; 6 + ADMIN_SECRET: string; 7 + } 8 + } 9 + 3 10 declare module 'cloudflare:workers' { 4 11 export const env: Env; 5 12 }
+353
src/pages/admin/altstore.astro
··· 1 + --- 2 + export const prerender = false; 3 + 4 + import { env } from 'cloudflare:workers'; 5 + import { readSource } from '../../utils/altstore-source'; 6 + 7 + const COOKIE_NAME = 'altstore_admin'; 8 + const SOURCE_URL = 'https://getorbyt.com/altstore/source.json'; 9 + const MANIFEST_URL = 'https://downloads.getorbyt.com/manifest.json'; 10 + const RELEASE_API = '/api/admin/altstore/release'; 11 + 12 + // --- Auth --- 13 + const secret = env.ADMIN_SECRET; 14 + 15 + // ?token=... in URL → set cookie + redirect clean 16 + const urlToken = Astro.url.searchParams.get('token'); 17 + if (urlToken && urlToken === secret) { 18 + Astro.cookies.set(COOKIE_NAME, secret, { 19 + httpOnly: true, 20 + secure: true, 21 + sameSite: 'lax', 22 + maxAge: 60 * 60 * 24 * 30, 23 + path: '/', 24 + }); 25 + return Astro.redirect('/admin/altstore'); 26 + } 27 + 28 + const cookieToken = Astro.cookies.get(COOKIE_NAME)?.value; 29 + const isAuthed = cookieToken === secret; 30 + 31 + // POST from login form 32 + if (Astro.request.method === 'POST' && !isAuthed) { 33 + const form = await Astro.request.formData(); 34 + const submitted = form.get('token'); 35 + if (submitted === secret) { 36 + Astro.cookies.set(COOKIE_NAME, secret, { 37 + httpOnly: true, 38 + secure: true, 39 + sameSite: 'lax', 40 + maxAge: 60 * 60 * 24 * 30, 41 + path: '/', 42 + }); 43 + return Astro.redirect('/admin/altstore'); 44 + } 45 + } 46 + 47 + // Read current source for display 48 + let currentVersion = null as null | { version: string; buildVersion: string; date: string; localizedDescription?: string; downloadURL: string; size: number }; 49 + let versionHistory: typeof currentVersion[] = []; 50 + 51 + if (isAuthed) { 52 + try { 53 + const source = await readSource(env.orbyt_altstore_adp); 54 + const versions = source.apps[0]?.versions ?? []; 55 + currentVersion = versions[0] ?? null; 56 + versionHistory = versions.slice(1, 5); 57 + } catch { 58 + // continue with null 59 + } 60 + } 61 + 62 + const today = new Date().toISOString().split('T')[0]; 63 + --- 64 + 65 + <!DOCTYPE html> 66 + <html lang="en"> 67 + <head> 68 + <meta charset="UTF-8" /> 69 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 70 + <title>AltStore Admin · Orbyt</title> 71 + <style> 72 + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 73 + body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f13; color: #e0e0e8; min-height: 100vh; padding: 2rem 1rem; } 74 + .container { max-width: 720px; margin: 0 auto; } 75 + h1 { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.25rem; } 76 + .subtitle { color: #888; font-size: 0.85rem; margin-bottom: 2rem; } 77 + .card { background: #1a1a22; border: 1px solid #2a2a36; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; } 78 + .card h2 { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: #9B59B6; margin-bottom: 1rem; } 79 + .meta-row { display: flex; gap: 0.5rem; align-items: baseline; margin-bottom: 0.4rem; font-size: 0.9rem; } 80 + .meta-label { color: #888; min-width: 100px; font-size: 0.8rem; } 81 + .meta-value { color: #e0e0e8; font-family: monospace; font-size: 0.85rem; word-break: break-all; } 82 + .badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.75rem; font-weight: 600; background: #9B59B6; color: #fff; margin-left: 0.5rem; } 83 + .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } 84 + .form-group { display: flex; flex-direction: column; gap: 0.35rem; } 85 + .form-group.full { grid-column: 1 / -1; } 86 + label { font-size: 0.78rem; color: #888; font-weight: 500; } 87 + input, textarea { background: #0f0f13; border: 1px solid #2a2a36; border-radius: 8px; color: #e0e0e8; font-size: 0.9rem; padding: 0.6rem 0.75rem; width: 100%; font-family: inherit; } 88 + input:focus, textarea:focus { outline: none; border-color: #9B59B6; } 89 + textarea { resize: vertical; min-height: 80px; } 90 + .btn { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.6rem 1.2rem; border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.15s; } 91 + .btn:hover { opacity: 0.85; } 92 + .btn:disabled { opacity: 0.4; cursor: not-allowed; } 93 + .btn-primary { background: #9B59B6; color: #fff; } 94 + .btn-secondary { background: #2a2a36; color: #e0e0e8; } 95 + .btn-danger { background: #c0392b; color: #fff; } 96 + .btn-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 1rem; } 97 + .status-line { font-size: 0.8rem; margin-top: 0.75rem; min-height: 1.2em; } 98 + .ok { color: #2ecc71; } 99 + .err { color: #e74c3c; } 100 + .warn { color: #f39c12; } 101 + .history-item { padding: 0.6rem 0; border-bottom: 1px solid #2a2a36; font-size: 0.82rem; color: #888; } 102 + .history-item:last-child { border-bottom: none; } 103 + .history-item strong { color: #ccc; } 104 + .link { color: #9B59B6; text-decoration: none; font-size: 0.82rem; } 105 + .link:hover { text-decoration: underline; } 106 + .login-wrap { max-width: 360px; margin: 8vh auto; } 107 + .login-wrap h1 { margin-bottom: 1.5rem; } 108 + .divider { height: 1px; background: #2a2a36; margin: 0.5rem 0 1rem; } 109 + @media (max-width: 500px) { .form-grid { grid-template-columns: 1fr; } } 110 + </style> 111 + </head> 112 + <body> 113 + <div class="container"> 114 + 115 + {!isAuthed ? ( 116 + <div class="login-wrap"> 117 + <h1>AltStore Admin</h1> 118 + <div class="card"> 119 + <h2>Sign in</h2> 120 + <form method="POST"> 121 + <div class="form-group" style="margin-bottom:1rem"> 122 + <label for="token">Admin secret</label> 123 + <input id="token" name="token" type="password" placeholder="Enter ADMIN_SECRET" required autofocus /> 124 + </div> 125 + <button type="submit" class="btn btn-primary" style="width:100%">Sign in</button> 126 + </form> 127 + </div> 128 + </div> 129 + ) : ( 130 + <> 131 + <h1>AltStore Admin <span style="color:#9B59B6">· Orbyt</span></h1> 132 + <p class="subtitle"> 133 + Source: <a class="link" href={SOURCE_URL} target="_blank">{SOURCE_URL}</a> 134 + </p> 135 + 136 + <!-- Current Release --> 137 + <div class="card"> 138 + <h2>Current release</h2> 139 + {currentVersion ? ( 140 + <> 141 + <div class="meta-row"> 142 + <span class="meta-label">Version</span> 143 + <span class="meta-value">{currentVersion.version} <span class="badge">build {currentVersion.buildVersion}</span></span> 144 + </div> 145 + <div class="meta-row"> 146 + <span class="meta-label">Date</span> 147 + <span class="meta-value">{currentVersion.date}</span> 148 + </div> 149 + <div class="meta-row"> 150 + <span class="meta-label">Size</span> 151 + <span class="meta-value">{(currentVersion.size / 1_000_000).toFixed(1)} MB</span> 152 + </div> 153 + <div class="meta-row"> 154 + <span class="meta-label">Changelog</span> 155 + <span class="meta-value">{currentVersion.localizedDescription ?? '—'}</span> 156 + </div> 157 + <div class="meta-row"> 158 + <span class="meta-label">Download</span> 159 + <span class="meta-value">{currentVersion.downloadURL}</span> 160 + </div> 161 + </> 162 + ) : ( 163 + <p style="color:#888;font-size:0.9rem">No releases published yet. Add one below.</p> 164 + )} 165 + 166 + {versionHistory.length > 0 && ( 167 + <> 168 + <div class="divider" style="margin-top:1rem"></div> 169 + <p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Previous releases</p> 170 + {versionHistory.map((v) => v && ( 171 + <div class="history-item"> 172 + <strong>{v.version}</strong> (build {v.buildVersion}) · {v.date} 173 + </div> 174 + ))} 175 + </> 176 + )} 177 + </div> 178 + 179 + <!-- Publish New Release --> 180 + <div class="card"> 181 + <h2>Publish new release</h2> 182 + <form id="release-form"> 183 + <div class="form-grid"> 184 + <div class="form-group"> 185 + <label for="version">Version *</label> 186 + <input id="version" name="version" placeholder="1.1.3" required /> 187 + </div> 188 + <div class="form-group"> 189 + <label for="buildVersion">Build * </label> 190 + <input id="buildVersion" name="buildVersion" placeholder="63" required /> 191 + </div> 192 + <div class="form-group"> 193 + <label for="date">Date *</label> 194 + <input id="date" name="date" type="date" value={today} required /> 195 + </div> 196 + <div class="form-group"> 197 + <label for="size">Size (bytes) *</label> 198 + <input id="size" name="size" type="number" placeholder="70307293" required /> 199 + </div> 200 + <div class="form-group full"> 201 + <label for="localizedDescription">Changelog</label> 202 + <textarea id="localizedDescription" name="localizedDescription" placeholder="What's new in this release…"></textarea> 203 + </div> 204 + <div class="form-group full"> 205 + <label for="downloadURL">Download URL (manifest.json)</label> 206 + <input id="downloadURL" name="downloadURL" value="https://downloads.getorbyt.com/manifest.json" /> 207 + </div> 208 + <div class="form-group"> 209 + <label for="minOSVersion">Min iOS version</label> 210 + <input id="minOSVersion" name="minOSVersion" value="16.4" /> 211 + </div> 212 + </div> 213 + <div class="btn-row"> 214 + <button type="submit" class="btn btn-primary" id="publish-btn">Publish release</button> 215 + </div> 216 + <p class="status-line" id="release-status"></p> 217 + </form> 218 + </div> 219 + 220 + <!-- Quick Actions --> 221 + <div class="card"> 222 + <h2>Quick actions</h2> 223 + <div class="btn-row"> 224 + <button class="btn btn-secondary" id="check-source-btn">Verify source.json</button> 225 + <button class="btn btn-secondary" id="check-manifest-btn">Verify manifest.json</button> 226 + <button class="btn btn-secondary" id="federate-btn">Federate with AltStore</button> 227 + </div> 228 + <p class="status-line" id="action-status"></p> 229 + </div> 230 + 231 + <!-- CLI Release Checklist --> 232 + <div class="card"> 233 + <h2>CLI release checklist</h2> 234 + <p style="font-size:0.82rem;color:#888;margin-bottom:1rem">Run these steps before publishing above. The <code style="color:#ccc">release</code> command handles steps 1–2 automatically.</p> 235 + <pre style="background:#0f0f13;border:1px solid #2a2a36;border-radius:8px;padding:1rem;font-size:0.78rem;overflow-x:auto;color:#b0b0c0;line-height:1.7"># 1) In orbyt-app — download + extract the new ADP from App Store Connect 236 + ./scripts/altstore-pal.sh download &lt;ADP_ID&gt; 237 + unzip orbyt-adp-&lt;ADP_ID&gt;.zip -d adp-extracted 238 + 239 + # 2) In orbyt-site — upload ADP to R2 (one command) 240 + ADMIN_SECRET=... npm run altstore:release -- orbyt-altstore-adp 1.1.3 63 2026-04-18 70307293 ../orbyt-app/adp-extracted 241 + 242 + # 3) Fill in the form above and click "Publish release" 243 + 244 + # 4) Optionally redeploy if you changed any other site files 245 + npm run build &amp;&amp; npx wrangler deploy</pre> 246 + </div> 247 + </> 248 + )} 249 + 250 + </div> 251 + 252 + {isAuthed && ( 253 + <script define:vars={{ RELEASE_API, SOURCE_URL, MANIFEST_URL }}> 254 + const releaseForm = document.getElementById('release-form'); 255 + const releaseStatus = document.getElementById('release-status'); 256 + const publishBtn = document.getElementById('publish-btn'); 257 + const actionStatus = document.getElementById('action-status'); 258 + 259 + releaseForm.addEventListener('submit', async (e) => { 260 + e.preventDefault(); 261 + publishBtn.disabled = true; 262 + releaseStatus.textContent = 'Publishing…'; 263 + releaseStatus.className = 'status-line warn'; 264 + 265 + const fd = new FormData(releaseForm); 266 + const body = { 267 + version: fd.get('version'), 268 + buildVersion: fd.get('buildVersion'), 269 + date: fd.get('date'), 270 + size: Number(fd.get('size')), 271 + localizedDescription: fd.get('localizedDescription') || undefined, 272 + downloadURL: fd.get('downloadURL') || undefined, 273 + minOSVersion: fd.get('minOSVersion') || undefined, 274 + }; 275 + 276 + try { 277 + const res = await fetch(RELEASE_API, { 278 + method: 'POST', 279 + credentials: 'include', 280 + headers: { 'Content-Type': 'application/json' }, 281 + body: JSON.stringify(body), 282 + }); 283 + const data = await res.json(); 284 + if (res.ok) { 285 + releaseStatus.textContent = `✓ Published ${data.version.version} (build ${data.version.buildVersion})`; 286 + releaseStatus.className = 'status-line ok'; 287 + setTimeout(() => location.reload(), 1500); 288 + } else { 289 + releaseStatus.textContent = `✗ Error: ${data.error}`; 290 + releaseStatus.className = 'status-line err'; 291 + } 292 + } catch (err) { 293 + releaseStatus.textContent = `✗ Network error: ${err.message}`; 294 + releaseStatus.className = 'status-line err'; 295 + } finally { 296 + publishBtn.disabled = false; 297 + } 298 + }); 299 + 300 + document.getElementById('check-source-btn').addEventListener('click', async () => { 301 + actionStatus.textContent = 'Checking source.json…'; 302 + actionStatus.className = 'status-line warn'; 303 + try { 304 + const res = await fetch(SOURCE_URL, { cache: 'no-store' }); 305 + actionStatus.textContent = res.ok 306 + ? `✓ source.json → ${res.status} OK` 307 + : `✗ source.json → ${res.status}`; 308 + actionStatus.className = res.ok ? 'status-line ok' : 'status-line err'; 309 + } catch (err) { 310 + actionStatus.textContent = `✗ ${err.message}`; 311 + actionStatus.className = 'status-line err'; 312 + } 313 + }); 314 + 315 + document.getElementById('check-manifest-btn').addEventListener('click', async () => { 316 + actionStatus.textContent = 'Checking manifest.json…'; 317 + actionStatus.className = 'status-line warn'; 318 + try { 319 + const res = await fetch(MANIFEST_URL, { cache: 'no-store' }); 320 + actionStatus.textContent = res.ok 321 + ? `✓ manifest.json → ${res.status} OK` 322 + : `✗ manifest.json → ${res.status}`; 323 + actionStatus.className = res.ok ? 'status-line ok' : 'status-line err'; 324 + } catch (err) { 325 + actionStatus.textContent = `✗ ${err.message}`; 326 + actionStatus.className = 'status-line err'; 327 + } 328 + }); 329 + 330 + document.getElementById('federate-btn').addEventListener('click', async () => { 331 + if (!confirm('Federate your source with AltStore? Only needed once (or after URL changes).')) return; 332 + actionStatus.textContent = 'Federating…'; 333 + actionStatus.className = 'status-line warn'; 334 + try { 335 + const res = await fetch('https://api.altstore.io/federate', { 336 + method: 'POST', 337 + headers: { 'Content-Type': 'application/json' }, 338 + body: JSON.stringify({ source: SOURCE_URL }), 339 + }); 340 + const text = await res.text(); 341 + actionStatus.textContent = res.ok 342 + ? `✓ Federated successfully` 343 + : `ℹ Federation response ${res.status}: ${text.slice(0, 120)}`; 344 + actionStatus.className = res.ok ? 'status-line ok' : 'status-line warn'; 345 + } catch (err) { 346 + actionStatus.textContent = `✗ ${err.message}`; 347 + actionStatus.className = 'status-line err'; 348 + } 349 + }); 350 + </script> 351 + )} 352 + </body> 353 + </html>
+16
src/pages/altstore/source.json.ts
··· 1 + import type { APIRoute } from 'astro'; 2 + import { env } from 'cloudflare:workers'; 3 + import { readSource } from '../../utils/altstore-source'; 4 + 5 + export const prerender = false; 6 + 7 + export const GET: APIRoute = async () => { 8 + const source = await readSource(env.orbyt_altstore_adp); 9 + 10 + return new Response(JSON.stringify(source, null, 2), { 11 + headers: { 12 + 'Content-Type': 'application/json', 13 + 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', 14 + }, 15 + }); 16 + };
+79
src/pages/api/admin/altstore/release.ts
··· 1 + import type { APIRoute } from 'astro'; 2 + import { env } from 'cloudflare:workers'; 3 + import { 4 + readSource, 5 + writeSource, 6 + checkBearerAuth, 7 + DEFAULT_DOWNLOAD_URL, 8 + DEFAULT_MIN_OS, 9 + } from '../../../../utils/altstore-source'; 10 + 11 + export const prerender = false; 12 + 13 + const COOKIE_NAME = 'altstore_admin'; 14 + 15 + function isAuthorized(request: Request, cookies: { get(name: string): { value: string } | undefined }): boolean { 16 + if (checkBearerAuth(request, env.ADMIN_SECRET)) return true; 17 + return cookies.get(COOKIE_NAME)?.value === env.ADMIN_SECRET; 18 + } 19 + 20 + export const POST: APIRoute = async ({ request, cookies }) => { 21 + if (!isAuthorized(request, cookies)) { 22 + return new Response(JSON.stringify({ error: 'Unauthorized' }), { 23 + status: 401, 24 + headers: { 'Content-Type': 'application/json' }, 25 + }); 26 + } 27 + 28 + let body: Record<string, unknown>; 29 + try { 30 + body = await request.json(); 31 + } catch { 32 + return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { 33 + status: 400, 34 + headers: { 'Content-Type': 'application/json' }, 35 + }); 36 + } 37 + 38 + const { version, buildVersion, date, localizedDescription, size, downloadURL, minOSVersion } = 39 + body as Record<string, unknown>; 40 + 41 + if (!version || !buildVersion || !date || size === undefined) { 42 + return new Response( 43 + JSON.stringify({ error: 'Missing required fields: version, buildVersion, date, size' }), 44 + { status: 400, headers: { 'Content-Type': 'application/json' } } 45 + ); 46 + } 47 + 48 + const newVersion = { 49 + version: String(version), 50 + buildVersion: String(buildVersion), 51 + date: String(date), 52 + ...(localizedDescription ? { localizedDescription: String(localizedDescription) } : {}), 53 + downloadURL: downloadURL ? String(downloadURL) : DEFAULT_DOWNLOAD_URL, 54 + size: Number(size), 55 + minOSVersion: minOSVersion ? String(minOSVersion) : DEFAULT_MIN_OS, 56 + }; 57 + 58 + const source = await readSource(env.orbyt_altstore_adp); 59 + source.apps[0].versions.unshift(newVersion); 60 + await writeSource(env.orbyt_altstore_adp, source); 61 + 62 + return new Response(JSON.stringify({ success: true, version: newVersion }), { 63 + headers: { 'Content-Type': 'application/json' }, 64 + }); 65 + }; 66 + 67 + export const GET: APIRoute = async ({ request, cookies }) => { 68 + if (!isAuthorized(request, cookies)) { 69 + return new Response(JSON.stringify({ error: 'Unauthorized' }), { 70 + status: 401, 71 + headers: { 'Content-Type': 'application/json' }, 72 + }); 73 + } 74 + 75 + const source = await readSource(env.orbyt_altstore_adp); 76 + return new Response(JSON.stringify(source, null, 2), { 77 + headers: { 'Content-Type': 'application/json' }, 78 + }); 79 + };
+119
src/utils/altstore-source.ts
··· 1 + export interface AppVersion { 2 + version: string; 3 + buildVersion: string; 4 + date: string; 5 + localizedDescription?: string; 6 + downloadURL: string; 7 + size: number; 8 + minOSVersion?: string; 9 + } 10 + 11 + export interface AltStoreSource { 12 + name: string; 13 + subtitle: string; 14 + description: string; 15 + iconURL: string; 16 + headerURL: string; 17 + website: string; 18 + tintColor: string; 19 + fediUsername: string; 20 + featuredApps: string[]; 21 + apps: Array<{ 22 + name: string; 23 + bundleIdentifier: string; 24 + marketplaceID: string; 25 + developerName: string; 26 + subtitle: string; 27 + localizedDescription: string; 28 + iconURL: string; 29 + tintColor: string; 30 + category: string; 31 + screenshots: unknown[]; 32 + versions: AppVersion[]; 33 + appPermissions: { 34 + privacy: Record<string, string>; 35 + }; 36 + }>; 37 + news: unknown[]; 38 + } 39 + 40 + export const SOURCE_KEY = 'altstore-source.json'; 41 + export const DEFAULT_DOWNLOAD_URL = 'https://downloads.getorbyt.com/manifest.json'; 42 + export const DEFAULT_MIN_OS = '16.4'; 43 + 44 + export const BASE_SOURCE: AltStoreSource = { 45 + name: 'Orbyt', 46 + subtitle: 'Video app for Bluesky', 47 + description: 48 + 'Orbyt is a video-first social app for the Bluesky network, built on the AT Protocol. Available for EU and Japan via AltStore PAL.', 49 + iconURL: 'https://getorbyt.com/altstore/orbyt-icon.png', 50 + headerURL: 'https://getorbyt.com/images/orbyt-logotype.png', 51 + website: 'https://getorbyt.com', 52 + tintColor: '#9B59B6', 53 + fediUsername: 'orbyt', 54 + featuredApps: ['com.getorbyt.app'], 55 + apps: [ 56 + { 57 + name: 'Orbyt', 58 + bundleIdentifier: 'com.getorbyt.app', 59 + marketplaceID: '6751679299', 60 + developerName: 'Orbyt', 61 + subtitle: 'Video app for Bluesky', 62 + localizedDescription: 63 + 'Orbyt is a video-first social app for the Bluesky network.\n\n• Browse video feeds from Bluesky\n• Create and share short-form videos\n• Connect with the AT Protocol community\n• Built with React Native and Expo', 64 + iconURL: 'https://getorbyt.com/altstore/orbyt-icon.png', 65 + tintColor: '#9B59B6', 66 + category: 'social', 67 + screenshots: [], 68 + versions: [ 69 + { 70 + version: '1.1.2', 71 + buildVersion: '62', 72 + date: '2026-03-12', 73 + localizedDescription: 'Latest release.', 74 + downloadURL: DEFAULT_DOWNLOAD_URL, 75 + size: 70307293, 76 + minOSVersion: DEFAULT_MIN_OS, 77 + }, 78 + ], 79 + appPermissions: { 80 + privacy: { 81 + NSCameraUsageDescription: 82 + 'We need camera access to record videos for posts and take profile photos.', 83 + NSMicrophoneUsageDescription: 84 + 'We need microphone access to record audio with your videos.', 85 + NSPhotoLibraryUsageDescription: 86 + 'We need access to your photo library to select videos for posts and photos for your profile.', 87 + NSPhotoLibraryAddUsageDescription: 88 + 'We need permission to save your videos to the camera roll.', 89 + }, 90 + }, 91 + }, 92 + ], 93 + news: [], 94 + }; 95 + 96 + export async function readSource(bucket: R2Bucket): Promise<AltStoreSource> { 97 + try { 98 + const obj = await bucket.get(SOURCE_KEY); 99 + if (obj) { 100 + const text = await obj.text(); 101 + return JSON.parse(text) as AltStoreSource; 102 + } 103 + } catch { 104 + // Fall through to base source 105 + } 106 + return structuredClone(BASE_SOURCE); 107 + } 108 + 109 + export async function writeSource(bucket: R2Bucket, source: AltStoreSource): Promise<void> { 110 + await bucket.put(SOURCE_KEY, JSON.stringify(source, null, 2), { 111 + httpMetadata: { contentType: 'application/json' }, 112 + }); 113 + } 114 + 115 + export function checkBearerAuth(request: Request, secret: string): boolean { 116 + const auth = request.headers.get('Authorization'); 117 + if (!auth?.startsWith('Bearer ')) return false; 118 + return auth.slice(7) === secret; 119 + }