初回コミット: AltStore PAL向けADPホスティングSaaS

- Fastify API・Node Worker・PostgreSQL・S3互換ストレージ構成
- ADP ZIPのマルチパートアップロードとManifest検証(パストラバーサル・ZIP爆弾等を拒否)
- manifest.json/signatureは再シリアライズせず元バイト列を保持
- Cloudflare風の運用向け管理ダッシュボード(shadcn/Radix・日本語UI・4ルート)
  概要/Sources/アプリ/リリース + ルートランディングページ
- 汎用OIDC SSO・Workspace単位の認可・demo mode
- 匿名配布: /sources/{slug}/source.json, /artifacts/{releaseId}/manifest.json
- Docker Compose対応
This commit is contained in:
amania-jailbreak
2026-08-05 10:09:58 +09:00
commit 93825ebc64
59 changed files with 16065 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
NODE_ENV=development
PORT=4000
PUBLIC_BASE_URL=http://localhost:4000
WEB_ORIGIN=http://localhost:3000
NEXT_PUBLIC_API_BASE_URL=http://localhost:4000
NEXT_PUBLIC_AUTH_MODE=demo
# Use PostgreSQL + MinIO in Docker Compose. Leave DATABASE_URL empty for memory mode.
DATABASE_URL=postgres://altdock:altdock@localhost:5432/altdock
STORAGE_MODE=s3
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_BUCKET=altdock
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_FORCE_PATH_STYLE=true
# demo works without an identity provider; production should use generic OIDC.
AUTH_MODE=demo
SESSION_SECRET=replace-with-at-least-32-random-characters
OIDC_ISSUER_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=http://localhost:4000/auth/callback
MAX_UPLOAD_BYTES=5368709120
MAX_ARCHIVE_ENTRIES=2048
MAX_EXPANDED_BYTES=8589934592
MAX_SOURCES_PER_WORKSPACE=3
MAX_APPS_PER_SOURCE=20
MAX_STORAGE_BYTES=21474836480
PROCESS_INLINE=true
+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
next-env.d.ts
/dist/
*.tsbuildinfo
/.wrangler/
/outputs/
/work/
/.data/
+4
View File
@@ -0,0 +1,4 @@
{
"d1": null,
"r2": null
}
+56
View File
@@ -0,0 +1,56 @@
# AltDock
AltDock is a self-hostable, multi-tenant hosting service for AltStore PAL
Alternative Distribution Packages (ADPs). Upload the original ADP ZIP, validate
its manifest and asset set, then publish a PAL-compatible Source JSON URL.
## Run locally without Docker
```bash
npm install
cp .env.example .env
npm run dev:api
```
With no `DATABASE_URL`, the API uses an in-memory store and local files under
`.data/storage`. Open the web app in another terminal:
```bash
npm run dev
```
The dashboard is at `http://localhost:3000` and the API is at
`http://localhost:4000`.
## Run the full stack
```bash
docker compose up
```
This starts PostgreSQL, MinIO, the API, the ADP worker, and the web app. The
MinIO console is available at `http://localhost:9001` with the credentials in
`docker-compose.yml`.
## Production identity
Set `AUTH_MODE=oidc` and configure `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`,
`OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI`, and a random `SESSION_SECRET`. The
demo mode is intentionally enabled by default for local development only.
## ADP workflow
1. Submit the app for Notarization and download the completed ADP ZIP.
2. Upload the original ZIP to AltDock; do not prettify or rewrite its files.
3. Review the parsed bundle ID, Apple Item ID, version, build, and asset paths.
4. Publish the release and copy the Source URL into AltStore PAL.
Published artifacts are served at `/artifacts/{releaseId}/{relativePath}` and
the Source JSON is served at `/sources/{slug}/source.json`.
## Checks
```bash
npm run lint
npm test
```
+86
View File
@@ -0,0 +1,86 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
displayName: string;
email: string;
fullName: string | null;
};
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { DashboardView } from "@/components/altdock/dashboard-view";
export default function AppsPage() {
return <DashboardView view="apps" />;
}
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import { DashboardProvider } from "@/components/altdock/dashboard-provider";
import { AdminShell } from "@/components/altdock/admin-shell";
export default function DashboardLayout({ children }: { children: ReactNode }) {
return <DashboardProvider><AdminShell>{children}</AdminShell></DashboardProvider>;
}
+6
View File
@@ -0,0 +1,6 @@
import { DashboardView } from "@/components/altdock/dashboard-view";
export default function DashboardPage() {
return <DashboardView view="overview" />;
}
+6
View File
@@ -0,0 +1,6 @@
import { DashboardView } from "@/components/altdock/dashboard-view";
export default function ReleasesPage() {
return <DashboardView view="releases" />;
}
+7
View File
@@ -0,0 +1,7 @@
import Link from "next/link";
import { ArrowUpRight, KeyRound, ShieldCheck, SlidersHorizontal } from "lucide-react";
export default function SettingsPage() {
return <><div className="admin-page-heading"><div><div className="admin-eyebrow">WORKSPACE SETTINGS</div><h1></h1><p></p></div></div><section className="admin-section-card settings-placeholder"><div className="settings-placeholder-icon"><SlidersHorizontal size={20} /></div><h2></h2><p>SSO</p><div className="settings-placeholder-grid"><div><ShieldCheck size={17} /><strong></strong><span>OIDC / demo mode</span></div><div><KeyRound size={17} /><strong></strong><span>Workspace単位の認可</span></div></div><Link className="admin-link" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank">PALドキュメントを開く <ArrowUpRight size={14} /></Link></section></>;
}
+6
View File
@@ -0,0 +1,6 @@
import { DashboardView } from "@/components/altdock/dashboard-view";
export default function SourcesPage() {
return <DashboardView view="sources" />;
}
+136
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = {
title: "AltDock — Workspace dashboard",
description: "Manage AltStore PAL Sources, apps, and notarized ADP releases.",
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html lang="ja"><body className={`${geistSans.variable} ${geistMono.variable}`}>{children}</body></html>;
}
+105
View File
@@ -0,0 +1,105 @@
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
export type DashboardSource = {
source: SourceRecord;
apps: AppRecord[];
releases: ReleaseRecord[];
};
export type DashboardData = {
workspace: {
name: string;
slug: string;
};
sources: DashboardSource[];
usage: {
storageBytes: number;
sourceCount: number;
};
limits: {
maxSources: number;
maxAppsPerSource: number;
maxStorageBytes: number;
maxUploadBytes: number;
};
};
export type Notice = {
tone: "success" | "error" | "info";
text: string;
};
export type Visibility = SourceRecord["visibility"];
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:4000";
export const AUTH_MODE = process.env.NEXT_PUBLIC_AUTH_MODE || "demo";
export const DEMO_HEADERS = { "x-demo-user": "demo@altdock.local" };
export async function api<T>(path: string, init: RequestInit = {}) {
const headers = new Headers(init.headers);
headers.set("accept", "application/json");
if (init.body) headers.set("content-type", "application/json");
Object.entries(DEMO_HEADERS).forEach(([key, value]) => headers.set(key, value));
const response = await fetch(`${API_BASE}${path}`, { ...init, headers, credentials: "include" });
const body = (await response.json().catch(() => ({}))) as T & { error?: string; detail?: string };
if (!response.ok) throw new Error(body.detail || body.error || `Request failed (${response.status})`);
return body;
}
export function formatBytes(value: number) {
if (!value) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
export function formatDate(value: string) {
return new Intl.DateTimeFormat("ja-JP", { year: "numeric", month: "short", day: "numeric" }).format(new Date(value));
}
export function formatDateTime(value: string) {
return new Intl.DateTimeFormat("ja-JP", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
export function statusLabel(status: ReleaseRecord["status"]) {
return {
uploaded: "アップロード済み",
processing: "処理中",
ready: "公開準備完了",
rejected: "要修正",
published: "公開中",
archived: "アーカイブ",
}[status];
}
export function statusTone(status: ReleaseRecord["status"]) {
return {
uploaded: "neutral",
processing: "info",
ready: "warning",
rejected: "danger",
published: "success",
archived: "neutral",
}[status] as "neutral" | "info" | "warning" | "danger" | "success";
}
export function visibilityLabel(visibility: Visibility) {
return { draft: "下書き", unlisted: "限定公開", public: "公開" }[visibility];
}
export function visibilityTone(visibility: Visibility) {
return { draft: "neutral", unlisted: "warning", public: "success" }[visibility] as "neutral" | "warning" | "success";
}
export function flattenSources(dashboard: DashboardData) {
return dashboard.sources;
}
export function flattenApps(dashboard: DashboardData) {
return dashboard.sources.flatMap((entry) => entry.apps.map((app) => ({ app, source: entry.source, releases: entry.releases.filter((release) => release.appId === app.id) })));
}
export function flattenReleases(dashboard: DashboardData) {
return dashboard.sources.flatMap((entry) => entry.releases.map((release) => ({ release, source: entry.source, app: entry.apps.find((app) => app.id === release.appId) })));
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { ArrowRight, Boxes, FileArchive, Gauge, KeyRound, ShieldCheck, UploadCloud } from "lucide-react";
import { AUTH_MODE, API_BASE } from "@/app/lib/dashboard";
export default function Home() {
return <main className="landing">
<header className="landing-header">
<Link className="landing-brand" href="/"><span className="admin-brand-mark">A</span><span>AltDock</span><span className="landing-beta">BETA</span></Link>
<nav className="landing-nav">
<a href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer">PALドキュメント</a>
<a href="https://developer.apple.com/documentation/marketplacekit/ingesting-an-alternative-distribution-package" target="_blank" rel="noreferrer">ADP仕様</a>
{AUTH_MODE === "oidc" ? <a className="landing-signin" href={`${API_BASE}/auth/login`}>SSOでログイン</a> : <Link className="landing-signin" href="/dashboard"> <ArrowRight size={14} /></Link>}
</nav>
</header>
<section className="landing-hero">
<div className="landing-hero-copy">
<div className="landing-eyebrow">ALTSTORE PAL · ADP HOSTING</div>
<h1>Notarization済みのADPを<br />PALへ届ける</h1>
<p>Appleの検証チェーンを壊さずADP ZIPをアップロードするだけでAltStore PAL互換のSourceを公開できるホスティング基盤ですManifestとsignatureは再シリアライズせず</p>
<div className="landing-hero-actions">
<Link className="ui-button ui-button-primary" href="/dashboard"> <ArrowRight size={15} /></Link>
<a className="ui-button ui-button-outline" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer"></a>
</div>
<div className="landing-hero-meta"><Gauge size={14} />Demo mode · API <code>{API_BASE}</code></div>
</div>
<aside className="landing-hero-card" aria-hidden="true">
<div className="landing-flow-step"><span className="landing-flow-num">1</span><span><UploadCloud size={16} />ADP ZIPをアップロード</span></div>
<div className="landing-flow-arrow" />
<div className="landing-flow-step"><span className="landing-flow-num">2</span><span><ShieldCheck size={16} />Manifest</span></div>
<div className="landing-flow-arrow" />
<div className="landing-flow-step done"><span className="landing-flow-num">3</span><span><Boxes size={16} />PAL Sourceを公開</span></div>
</aside>
</section>
<section className="landing-features">
<article className="landing-feature"><FileArchive size={18} /><h2></h2><p>ADPの階層manifest.jsonsignatureを一切書き換えませんAppleの検証チェーンがそのまま通ります</p></article>
<article className="landing-feature"><ShieldCheck size={18} /><h2></h2><p>ZIP爆弾Workerで検査し</p></article>
<article className="landing-feature"><KeyRound size={18} /><h2>Workspace単位の認可</h2><p>OIDC SSOでユーザーを識別しWorkspaceごとにSource</p></article>
</section>
<footer className="landing-footer">
<span>AltDock · AltStore PAL向け ADP </span>
<div className="landing-footer-links">
<Link href="/dashboard"></Link>
<a href="https://faq.altstore.io/developers/make-a-source" target="_blank" rel="noreferrer">Source仕様</a>
<a href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">APIステータス</a>
</div>
</footer>
</main>;
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
type SourceDocument = {
name: string;
subtitle?: string;
description?: string;
iconURL?: string;
tintColor?: string;
apps: Array<{ name: string; bundleIdentifier: string; developerName: string; subtitle?: string; localizedDescription: string; iconURL?: string; category: string; versions: Array<{ version: string; buildVersion: string; date: string; downloadURL: string; size: number }> }>;
};
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:4000";
function formatBytes(value: number) {
if (!value) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
export default function PublicSourceView({ slug }: { slug: string }) {
const [document, setDocument] = useState<SourceDocument | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
fetch(`${API_BASE}/sources/${slug}/source.json`)
.then((response) => response.ok ? response.json() : Promise.reject(new Error("not found")))
.then(setDocument)
.catch(() => setError(true));
}, [slug]);
if (error) return <main className="public-source-error"><span className="brand-mark">A</span><h1>Source not found</h1><p>This Source is still a draft or the URL is incorrect.</p><Link href="/">Open AltDock</Link></main>;
if (!document) return <main className="public-source-loading"><span className="brand-mark">A</span><p>Loading Source</p></main>;
return <main className="public-source-page" style={{ "--source-tint": document.tintColor || "#E9694B" } as React.CSSProperties}>
<header className="public-source-header"><Link className="public-brand" href="/"><span className="brand-mark">A</span><span>AltDock</span></Link><span className="public-badge">ALTSTORE PAL SOURCE</span></header>
<section className="public-source-hero"><div className="public-source-avatar">{document.iconURL ? <img src={document.iconURL} alt="" /> : document.name.slice(0, 1)}</div><div><div className="section-kicker">SOURCE / {slug}</div><h1>{document.name}</h1><p>{document.subtitle}</p></div><button className="button primary" onClick={() => navigator.clipboard?.writeText(`${API_BASE}/sources/${slug}/source.json`)}>Copy Source URL</button></section>
{document.description && <p className="public-source-description">{document.description}</p>}
<section className="public-app-grid">{document.apps.length ? document.apps.map((app) => { const release = app.versions[0]; return <article className="public-app-card" key={app.bundleIdentifier}><div className="public-app-card-top"><span className="public-app-icon" style={{ background: document.tintColor || "#E9694B" }}>{app.iconURL ? <img src={app.iconURL} alt="" /> : app.name.slice(0, 1)}</span><span className="public-category">{app.category}</span></div><h2>{app.name}</h2><p className="public-developer">{app.developerName} · {app.bundleIdentifier}</p><p className="public-app-description">{app.localizedDescription || app.subtitle || "A PAL-ready iOS app."}</p>{release ? <div className="public-release"><span>Latest release</span><strong>v{release.version} <small>build {release.buildVersion}</small></strong><span>{formatBytes(release.size)}</span></div> : <div className="public-release"><span>No published release yet</span></div>}</article>; }) : <div className="public-no-apps"><strong>No published apps yet.</strong><p>Come back when the developer ships their first release.</p></div>}</section>
<footer className="public-source-footer"><span>Powered by AltDock</span><span>Source JSON is available for AltStore PAL.</span></footer>
</main>;
}
+6
View File
@@ -0,0 +1,6 @@
import PublicSourceView from "./PublicSourceView";
export default async function PublicSourcePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return <PublicSourceView slug={slug} />;
}
+126
View File
@@ -0,0 +1,126 @@
import { createHash, randomBytes } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose";
import type { Identity } from "../../../packages/core/src/index";
import type { AppConfig } from "./config";
const stateCookie = "altdock_oidc_state";
const verifierCookie = "altdock_oidc_verifier";
const nonceCookie = "altdock_oidc_nonce";
const sessionCookie = "altdock_session";
function base64Url(value: Buffer) {
return value.toString("base64url");
}
function pkceChallenge(verifier: string) {
return createHash("sha256").update(verifier).digest("base64url");
}
export async function createSession(identity: Identity, appConfig: AppConfig) {
return new SignJWT({ ...identity })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(new TextEncoder().encode(appConfig.SESSION_SECRET));
}
export async function readSession(token: string | undefined, appConfig: AppConfig): Promise<Identity | null> {
if (!token) return null;
try {
const result = await jwtVerify(token, new TextEncoder().encode(appConfig.SESSION_SECRET), { algorithms: ["HS256"] });
const payload = result.payload;
if (typeof payload.subject !== "string" || typeof payload.email !== "string" || typeof payload.displayName !== "string") return null;
return { subject: payload.subject, email: payload.email, displayName: payload.displayName };
} catch {
return null;
}
}
export async function getIdentity(request: FastifyRequest, appConfig: AppConfig): Promise<Identity | null> {
if (appConfig.AUTH_MODE === "demo") {
const email = String(request.headers["x-demo-user"] || "demo@altdock.local");
return { subject: `demo:${email}`, email, displayName: email.split("@")[0] || "Demo Developer" };
}
return readSession(request.cookies[sessionCookie], appConfig);
}
function setSession(reply: FastifyReply, token: string) {
reply.setCookie(sessionCookie, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
}
async function oidcDiscovery(appConfig: AppConfig) {
if (!appConfig.OIDC_ISSUER_URL) throw new Error("OIDC_ISSUER_URL_REQUIRED");
const response = await fetch(`${appConfig.OIDC_ISSUER_URL.replace(/\/$/, "")}/.well-known/openid-configuration`);
if (!response.ok) throw new Error(`OIDC_DISCOVERY_FAILED:${response.status}`);
return response.json() as Promise<{ authorization_endpoint: string; token_endpoint: string; jwks_uri: string; issuer: string }>;
}
export async function registerAuthRoutes(app: FastifyInstance, appConfig: AppConfig) {
app.get("/auth/login", async (_request, reply) => {
if (appConfig.AUTH_MODE === "demo") return reply.redirect(appConfig.WEB_ORIGIN);
if (!appConfig.OIDC_CLIENT_ID || !appConfig.OIDC_REDIRECT_URI) throw new Error("OIDC_CONFIGURATION_REQUIRED");
const provider = await oidcDiscovery(appConfig);
const state = base64Url(randomBytes(24));
const verifier = base64Url(randomBytes(32));
const nonce = base64Url(randomBytes(24));
const params = new URLSearchParams({
client_id: appConfig.OIDC_CLIENT_ID,
redirect_uri: appConfig.OIDC_REDIRECT_URI,
response_type: "code",
scope: "openid profile email",
state,
nonce,
code_challenge: pkceChallenge(verifier),
code_challenge_method: "S256",
});
reply.setCookie(stateCookie, state, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/auth" });
reply.setCookie(verifierCookie, verifier, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/auth" });
reply.setCookie(nonceCookie, nonce, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/auth" });
return reply.redirect(`${provider.authorization_endpoint}?${params.toString()}`);
});
app.get("/auth/callback", async (request, reply) => {
if (appConfig.AUTH_MODE === "demo") return reply.redirect(appConfig.WEB_ORIGIN);
if (!appConfig.OIDC_CLIENT_ID || !appConfig.OIDC_REDIRECT_URI || !appConfig.OIDC_CLIENT_SECRET) throw new Error("OIDC_CONFIGURATION_REQUIRED");
const query = request.query as { code?: string; state?: string; error?: string };
if (query.error) return reply.code(400).send({ error: "OIDC_LOGIN_FAILED", detail: query.error });
if (!query.code || query.state !== request.cookies[stateCookie]) return reply.code(400).send({ error: "OIDC_STATE_INVALID" });
const verifier = request.cookies[verifierCookie];
const nonce = request.cookies[nonceCookie];
if (!verifier || !nonce) return reply.code(400).send({ error: "OIDC_VERIFIER_MISSING" });
const provider = await oidcDiscovery(appConfig);
const tokenResponse = await fetch(provider.token_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "authorization_code", code: query.code, redirect_uri: appConfig.OIDC_REDIRECT_URI, client_id: appConfig.OIDC_CLIENT_ID, client_secret: appConfig.OIDC_CLIENT_SECRET, code_verifier: verifier }),
});
if (!tokenResponse.ok) return reply.code(400).send({ error: "OIDC_TOKEN_EXCHANGE_FAILED" });
const tokens = await tokenResponse.json() as { id_token?: string; access_token?: string };
if (!tokens.id_token) return reply.code(400).send({ error: "OIDC_ID_TOKEN_MISSING" });
const jwks = createRemoteJWKSet(new URL(provider.jwks_uri));
const verified = await jwtVerify(tokens.id_token, jwks, { issuer: provider.issuer, audience: appConfig.OIDC_CLIENT_ID });
const claims = verified.payload;
if (claims.nonce !== nonce) return reply.code(400).send({ error: "OIDC_NONCE_INVALID" });
const subject = String(claims.sub || "");
if (!subject) return reply.code(400).send({ error: "OIDC_SUBJECT_MISSING" });
const email = String(claims.email || `${subject}@oidc.local`);
const displayName = String(claims.name || claims.preferred_username || email.split("@")[0] || "Developer");
setSession(reply, await createSession({ subject, email, displayName }, appConfig));
reply.clearCookie(stateCookie, { path: "/auth" });
reply.clearCookie(verifierCookie, { path: "/auth" });
reply.clearCookie(nonceCookie, { path: "/auth" });
return reply.redirect(appConfig.WEB_ORIGIN);
});
app.get("/auth/logout", async (_request, reply) => {
reply.clearCookie(sessionCookie, { path: "/" });
return reply.redirect(appConfig.WEB_ORIGIN);
});
}
+49
View File
@@ -0,0 +1,49 @@
import { z } from "zod";
const booleanFromEnv = z.preprocess((value) => {
if (typeof value !== "string") return value;
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
}, z.boolean());
const numberFromEnv = z.preprocess(
(value) => (typeof value === "string" ? Number(value) : value),
z.number().finite(),
);
const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
HOST: z.string().default("0.0.0.0"),
PORT: numberFromEnv.default(4000),
PUBLIC_BASE_URL: z.string().url().default("http://localhost:4000"),
WEB_ORIGIN: z.string().url().default("http://localhost:3000"),
DATABASE_URL: z.string().optional(),
STORAGE_MODE: z.enum(["local", "s3"]).default("local"),
LOCAL_STORAGE_DIR: z.string().default(".data/storage"),
S3_ENDPOINT: z.string().url().optional(),
S3_REGION: z.string().default("auto"),
S3_BUCKET: z.string().default("altdock"),
S3_ACCESS_KEY_ID: z.string().optional(),
S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_FORCE_PATH_STYLE: booleanFromEnv.default(true),
AUTH_MODE: z.enum(["demo", "oidc"]).default("demo"),
SESSION_SECRET: z.string().min(32).default("altdock-local-session-secret-change-me-123456"),
OIDC_ISSUER_URL: z.string().url().optional(),
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
OIDC_REDIRECT_URI: z.string().url().optional(),
MAX_UPLOAD_BYTES: numberFromEnv.default(5 * 1024 * 1024 * 1024),
MAX_ARCHIVE_ENTRIES: numberFromEnv.default(2048),
MAX_EXPANDED_BYTES: numberFromEnv.default(8 * 1024 * 1024 * 1024),
MAX_SOURCES_PER_WORKSPACE: numberFromEnv.default(3),
MAX_APPS_PER_SOURCE: numberFromEnv.default(20),
MAX_STORAGE_BYTES: numberFromEnv.default(20 * 1024 * 1024 * 1024),
PROCESS_INLINE: booleanFromEnv.default(true),
});
export type AppConfig = z.infer<typeof schema>;
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
return schema.parse(env);
}
export const config = loadConfig();
+183
View File
@@ -0,0 +1,183 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createHash } from "node:crypto";
import { posix } from "node:path";
import { Transform } from "node:stream";
import unzipper from "unzipper";
import type { ManifestSummary, ReleaseAsset } from "../../../packages/core/src/index";
import type { StorageAdapter } from "./storage";
interface ZipEntryLike {
path: string;
type: string;
uncompressedSize?: number;
buffer(): Promise<Buffer>;
stream(): NodeJS.ReadableStream;
}
export interface AdpInspection {
manifestBytes: Buffer;
signatureBytes: Buffer;
manifest: Record<string, any>;
summary: ManifestSummary;
expectedPaths: string[];
entries: Map<string, ZipEntryLike>;
archiveSizeBytes: number;
}
function safeZipPath(value: string) {
const normalized = value.replaceAll("\\", "/");
if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) {
throw new Error("ADP_PATH_INVALID");
}
const clean = posix.normalize(normalized);
if (clean === "." || clean.startsWith("../") || clean.includes("/../") || clean === "..") {
throw new Error("ADP_PATH_TRAVERSAL");
}
return clean;
}
function collectAssetPaths(value: unknown, output: Set<string>) {
if (!value || typeof value !== "object") return;
if (Array.isArray(value)) {
for (const item of value) collectAssetPaths(item, output);
return;
}
for (const [key, child] of Object.entries(value)) {
if (["assetPath", "sourcePath", "deltaPath"].includes(key) && typeof child === "string") {
output.add(safeZipPath(child));
}
collectAssetPaths(child, output);
}
}
function stringRecord(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).filter(([, item]) => typeof item === "string")) as Record<string, string>;
}
function summaryFromManifest(manifest: Record<string, any>): ManifestSummary {
const variantPaths = new Set<string>();
const deltaPaths = new Set<string>();
collectAssetPaths(manifest.variants, variantPaths);
collectAssetPaths(manifest.deltas, deltaPaths);
return {
distributionPackageRevision: typeof manifest.distributionPackageRevision === "number" ? manifest.distributionPackageRevision : undefined,
appleItemId: String(manifest.appleItemId || ""),
bundleId: String(manifest.bundleId || ""),
shortVersionString: String(manifest.shortVersionString || ""),
bundleVersion: String(manifest.bundleVersion || ""),
appleVersionId: manifest.appleVersionId ? String(manifest.appleVersionId) : undefined,
platforms: Array.isArray(manifest.platforms) ? manifest.platforms.map(String) : [],
minimumSystemVersions: stringRecord(manifest.minimumSystemVersions),
variantPaths: [...variantPaths],
deltaPaths: [...deltaPaths],
};
}
export async function inspectAdpArchive(
archivePath: string,
maxEntries: number,
maxExpandedBytes: number,
): Promise<AdpInspection> {
const directory = await unzipper.Open.file(archivePath);
if (directory.files.length > maxEntries) throw new Error("ADP_TOO_MANY_FILES");
const entries = new Map<string, ZipEntryLike>();
let expandedBytes = 0;
for (const rawEntry of directory.files as unknown as ZipEntryLike[]) {
const path = safeZipPath(rawEntry.path);
if (rawEntry.type !== "File" && rawEntry.type !== "Directory") throw new Error("ADP_LINK_ENTRY");
if (rawEntry.type === "Directory" || path.endsWith("/")) continue;
expandedBytes += Number(rawEntry.uncompressedSize || 0);
if (expandedBytes > maxExpandedBytes) throw new Error("ADP_EXPANDED_SIZE_LIMIT");
if (entries.has(path)) throw new Error("ADP_DUPLICATE_PATH");
entries.set(path, rawEntry);
}
const manifestEntry = entries.get("manifest.json");
const signatureEntry = entries.get("signature");
if (!manifestEntry || !signatureEntry) throw new Error("ADP_REQUIRED_FILE_MISSING");
const manifestBytes = await manifestEntry.buffer();
let manifest: Record<string, any>;
try {
manifest = JSON.parse(manifestBytes.toString("utf8"));
} catch {
throw new Error("ADP_MANIFEST_INVALID_JSON");
}
const assetPaths = new Set<string>();
collectAssetPaths(manifest.variants, assetPaths);
collectAssetPaths(manifest.deltas, assetPaths);
const expectedPaths = ["manifest.json", "signature", ...assetPaths].sort();
const actualPaths = [...entries.keys()].sort();
if (expectedPaths.join("\n") !== actualPaths.join("\n")) {
const missing = expectedPaths.filter((path) => !entries.has(path));
const extra = actualPaths.filter((path) => !expectedPaths.includes(path));
const reason = missing.length ? `missing:${missing.join(",")}` : `extra:${extra.join(",")}`;
throw new Error(`ADP_FILE_SET_MISMATCH:${reason}`);
}
const summary = summaryFromManifest(manifest);
if (!summary.appleItemId || !summary.bundleId || !summary.shortVersionString || !summary.bundleVersion) {
throw new Error("ADP_MANIFEST_METADATA_MISSING");
}
return {
manifestBytes,
signatureBytes: await signatureEntry.buffer(),
manifest,
summary,
expectedPaths,
entries,
archiveSizeBytes: 0,
};
}
function contentTypeFor(path: string) {
if (path === "manifest.json") return "application/json; charset=utf-8";
if (path.endsWith(".ipa")) return "application/octet-stream";
return "application/octet-stream";
}
class HashTransform extends Transform {
readonly hash = createHash("sha256");
sizeBytes = 0;
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: Buffer) => void) {
this.hash.update(chunk);
this.sizeBytes += chunk.length;
callback(null, chunk);
}
}
export async function storeAdpAssets(
archivePath: string,
releaseId: string,
inspection: AdpInspection,
storage: StorageAdapter,
): Promise<{ assets: ReleaseAsset[]; totalBytes: number }> {
const assets: ReleaseAsset[] = [];
const freshDirectory = await unzipper.Open.file(archivePath);
const freshEntries = new Map<string, ZipEntryLike>();
for (const rawEntry of freshDirectory.files as unknown as ZipEntryLike[]) {
if (rawEntry.type !== "File" && rawEntry.type !== "Directory") throw new Error("ADP_LINK_ENTRY");
if (rawEntry.type === "Directory" || rawEntry.path.endsWith("/")) continue;
freshEntries.set(safeZipPath(rawEntry.path), rawEntry);
}
for (const path of inspection.expectedPaths) {
const entry = freshEntries.get(path);
if (!entry) throw new Error("ADP_REQUIRED_FILE_MISSING");
const objectKey = `artifacts/${releaseId}/${path}`;
if (path === "manifest.json") {
await storage.writeObject(objectKey, inspection.manifestBytes, "application/json; charset=utf-8");
assets.push({ releaseId, path, objectKey, sizeBytes: inspection.manifestBytes.length, sha256: createHash("sha256").update(inspection.manifestBytes).digest("hex"), contentType: contentTypeFor(path) });
continue;
}
if (path === "signature") {
await storage.writeObject(objectKey, inspection.signatureBytes, "application/octet-stream");
assets.push({ releaseId, path, objectKey, sizeBytes: inspection.signatureBytes.length, sha256: createHash("sha256").update(inspection.signatureBytes).digest("hex"), contentType: contentTypeFor(path) });
continue;
}
const transform = new HashTransform();
await storage.writeStream(objectKey, transform, contentTypeFor(path), Number(entry.uncompressedSize || 0), entry.stream());
assets.push({ releaseId, path, objectKey, sizeBytes: transform.sizeBytes, sha256: transform.hash.digest("hex"), contentType: contentTypeFor(path) });
}
return { assets, totalBytes: assets.reduce((sum, asset) => sum + asset.sizeBytes, 0) };
}
+11
View File
@@ -0,0 +1,11 @@
import { loadConfig } from "./config";
import { createRuntime } from "./runtime";
const appConfig = loadConfig();
if (!appConfig.DATABASE_URL) {
console.log("DATABASE_URL is not configured; memory mode does not need a migration.");
process.exit(0);
}
const runtime = await createRuntime(appConfig);
await runtime.pool?.end();
console.log("AltDock database schema is ready.");
+66
View File
@@ -0,0 +1,66 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { AppMetadata } from "../../../packages/core/src/index";
import { inspectAdpArchive, storeAdpAssets } from "./manifest";
import type { AppConfig } from "./config";
import type { Store } from "./store";
import type { StorageAdapter } from "./storage";
export async function processUpload(uploadId: string, store: Store, storage: StorageAdapter, appConfig: AppConfig) {
const upload = await store.getUpload(uploadId);
if (!upload) throw new Error("UPLOAD_NOT_FOUND");
await store.updateUpload(uploadId, { status: "processing", errorCode: undefined, errorMessage: undefined });
const workDir = await mkdtemp(join(tmpdir(), "altdock-upload-"));
const archivePath = join(workDir, "package.zip");
try {
await storage.downloadToFile(upload.objectKey, archivePath);
const inspection = await inspectAdpArchive(archivePath, appConfig.MAX_ARCHIVE_ENTRIES, appConfig.MAX_EXPANDED_BYTES);
const source = await store.getSource(upload.sourceId);
if (!source) throw new Error("SOURCE_NOT_FOUND");
const existingApp = upload.appId ? await store.getApp(upload.appId) : null;
const metadata: Partial<AppMetadata> = existingApp || {
name: source.name,
developerName: "AltDock Developer",
localizedDescription: `Release ${inspection.summary.shortVersionString} (${inspection.summary.bundleVersion})`,
subtitle: "",
tintColor: source.tintColor,
category: "other",
screenshots: [],
appPermissions: { entitlements: [], privacy: {} },
};
const app = await store.upsertApp(upload.sourceId, {
bundleIdentifier: inspection.summary.bundleId,
marketplaceID: inspection.summary.appleItemId,
metadata,
});
await store.updateUpload(uploadId, { appId: app.id });
const releaseId = randomUUID();
const release = await store.createRelease({
id: releaseId,
appId: app.id,
uploadId,
version: inspection.summary.shortVersionString,
buildVersion: inspection.summary.bundleVersion,
appleItemId: inspection.summary.appleItemId,
date: new Date().toISOString(),
localizedDescription: metadata.localizedDescription || "",
minOSVersion: inspection.summary.minimumSystemVersions.ios,
sizeBytes: 0,
manifest: inspection.summary,
});
const result = await storeAdpAssets(archivePath, release.id, inspection, storage);
await store.addReleaseAssets(result.assets);
const completed = await store.updateRelease(release.id, { sizeBytes: result.totalBytes, status: "ready" });
await store.updateUpload(uploadId, { status: "completed", receivedSize: upload.receivedSize || upload.expectedSize });
return completed;
} catch (error) {
const message = error instanceof Error ? error.message : "ADP_PROCESSING_FAILED";
console.error(`[altdock] upload ${uploadId} failed: ${message}`);
await store.updateUpload(uploadId, { status: "failed", errorCode: message.split(":")[0], errorMessage: message });
throw error;
} finally {
await rm(workDir, { recursive: true, force: true });
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { AppConfig } from "./config";
import { createStore } from "./store";
import { createStorage } from "./storage";
export async function createRuntime(appConfig: AppConfig) {
const { store, pool } = await createStore(appConfig);
await store.init();
return { store, storage: createStorage(appConfig), pool };
}
+115
View File
@@ -0,0 +1,115 @@
export const schemaSql = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
display_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
icon_url TEXT,
header_url TEXT,
website TEXT,
tint_color TEXT NOT NULL DEFAULT '#E9694B',
visibility TEXT NOT NULL DEFAULT 'draft' CHECK (visibility IN ('draft', 'unlisted', 'public')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS apps (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
name TEXT NOT NULL,
bundle_identifier TEXT NOT NULL,
marketplace_id TEXT NOT NULL,
developer_name TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
localized_description TEXT NOT NULL DEFAULT '',
icon_url TEXT,
tint_color TEXT NOT NULL DEFAULT '#E9694B',
category TEXT NOT NULL DEFAULT 'other',
screenshots JSONB NOT NULL DEFAULT '[]'::jsonb,
app_permissions JSONB NOT NULL DEFAULT '{"entitlements":[],"privacy":{}}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(source_id, bundle_identifier)
);
CREATE TABLE IF NOT EXISTS uploads (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
app_id TEXT REFERENCES apps(id) ON DELETE SET NULL,
object_key TEXT NOT NULL UNIQUE,
multipart_upload_id TEXT,
original_filename TEXT NOT NULL,
expected_size BIGINT NOT NULL,
received_size BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'created',
error_code TEXT,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS releases (
id TEXT PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
upload_id TEXT NOT NULL REFERENCES uploads(id) ON DELETE CASCADE,
version TEXT NOT NULL,
build_version TEXT NOT NULL,
apple_item_id TEXT NOT NULL,
date TIMESTAMPTZ NOT NULL,
localized_description TEXT NOT NULL DEFAULT '',
min_os_version TEXT,
size_bytes BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'processing',
error_code TEXT,
error_message TEXT,
manifest JSONB NOT NULL,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(app_id, version, build_version)
);
CREATE TABLE IF NOT EXISTS release_assets (
id TEXT PRIMARY KEY,
release_id TEXT NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
path TEXT NOT NULL,
object_key TEXT NOT NULL UNIQUE,
size_bytes BIGINT NOT NULL,
sha256 TEXT NOT NULL,
content_type TEXT NOT NULL,
UNIQUE(release_id, path)
);
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS sources_workspace_idx ON sources(workspace_id);
ALTER TABLE uploads ADD COLUMN IF NOT EXISTS multipart_upload_id TEXT;
CREATE INDEX IF NOT EXISTS apps_source_idx ON apps(source_id);
CREATE INDEX IF NOT EXISTS releases_app_idx ON releases(app_id);
CREATE INDEX IF NOT EXISTS uploads_queue_idx ON uploads(status, created_at);
`;
+301
View File
@@ -0,0 +1,301 @@
import { randomUUID } from "node:crypto";
import { posix } from "node:path";
import { Readable } from "node:stream";
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import { z } from "zod";
import { buildSourceDocument } from "../../../packages/core/src/index";
import type { Identity } from "../../../packages/core/src/index";
import { loadConfig } from "./config";
import { getIdentity, registerAuthRoutes } from "./auth";
import { processUpload } from "./processor";
import { createRuntime } from "./runtime";
const sourceInput = z.object({
name: z.string().trim().min(1).max(80),
subtitle: z.string().max(160).default(""),
description: z.string().max(4000).default(""),
visibility: z.enum(["draft", "unlisted", "public"]).default("draft"),
iconURL: z.string().url().optional(),
headerURL: z.string().url().optional(),
website: z.string().url().optional(),
tintColor: z.string().regex(/^#?[0-9a-f]{6}$/i).default("#E9694B"),
});
const uploadInput = z.object({
sourceId: z.string().min(1),
appId: z.string().optional(),
filename: z.string().trim().min(1).max(180),
sizeBytes: z.number().int().positive(),
});
const appInput = z.object({
sourceId: z.string().min(1),
name: z.string().trim().min(1).max(80).optional(),
developerName: z.string().max(120).optional(),
subtitle: z.string().max(160).optional(),
localizedDescription: z.string().max(4000).optional(),
iconURL: z.string().url().optional(),
tintColor: z.string().regex(/^#?[0-9a-f]{6}$/i).optional(),
category: z.string().max(32).optional(),
screenshots: z.array(z.object({ imageURL: z.string().url(), width: z.number().int().positive().optional(), height: z.number().int().positive().optional() })).max(12).optional(),
appPermissions: z.object({ entitlements: z.array(z.string()).max(200), privacy: z.record(z.string(), z.string()).default({}) }).optional(),
});
const multipartPartsInput = z.object({
parts: z.array(z.object({ partNumber: z.number().int().positive(), etag: z.string().min(1) })).max(10000).optional(),
}).default({});
function safePath(value: string) {
const decoded = decodeURIComponent(value).replaceAll("\\", "/");
const normalized = posix.normalize(decoded);
if (!normalized || normalized.startsWith("/") || normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) return null;
return normalized;
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "UNKNOWN_ERROR";
}
async function requireIdentity(request: FastifyRequest, reply: FastifyReply, appConfig: ReturnType<typeof loadConfig>) {
const identity = await getIdentity(request, appConfig);
if (!identity) {
await reply.code(401).send({ error: "AUTHENTICATION_REQUIRED" });
return null;
}
return identity;
}
async function requireOwnedSource(id: string, identity: Identity, runtime: Awaited<ReturnType<typeof createRuntime>>, appConfig: ReturnType<typeof loadConfig>) {
const workspace = await runtime.store.getWorkspace(identity);
const source = await runtime.store.getSource(id, workspace.id);
if (!source) throw new Error("SOURCE_NOT_FOUND");
const usage = await runtime.store.getUsage(workspace.id);
return { workspace, source, usage, appConfig };
}
export async function createServer() {
const appConfig = loadConfig();
const runtime = await createRuntime(appConfig);
const app = Fastify({ logger: appConfig.NODE_ENV !== "test", bodyLimit: appConfig.MAX_UPLOAD_BYTES });
app.addContentTypeParser(["application/zip", "application/octet-stream"], { parseAs: "buffer" }, (_request, payload, done) => done(null, payload));
await app.register(cookie);
// The control plane (web :3000) and API (:4000) are cross-origin, so allow
// the write methods the dashboard uses; @fastify/cors otherwise defaults to
// GET/HEAD/POST and blocks PATCH/PUT in the browser.
await app.register(cors, { origin: true, credentials: true, methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] });
await registerAuthRoutes(app, appConfig);
app.get("/healthz", async () => ({ ok: true, service: "altdock-api", storageMode: appConfig.STORAGE_MODE }));
app.get("/api/v1/me", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig);
if (!identity) return;
const workspace = await runtime.store.getWorkspace(identity);
return { identity, workspace };
});
app.get("/api/v1/dashboard", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig);
if (!identity) return;
const workspace = await runtime.store.getWorkspace(identity);
let sources = await runtime.store.listSources(workspace.id);
if (appConfig.AUTH_MODE === "demo" && sources.length === 0) {
await runtime.store.createSource(workspace.id, { name: "Demo Source", subtitle: "A PAL-ready app shelf", description: "Upload a notarized ADP ZIP to publish your first app.", visibility: "draft", tintColor: "#E9694B" });
sources = await runtime.store.listSources(workspace.id);
}
const enriched = await Promise.all(sources.map(async (source) => ({ source, apps: await runtime.store.listApps(source.id), releases: await runtime.store.listReleasesForSource(source.id) })));
return { workspace, sources: enriched, usage: await runtime.store.getUsage(workspace.id), limits: { maxSources: appConfig.MAX_SOURCES_PER_WORKSPACE, maxAppsPerSource: appConfig.MAX_APPS_PER_SOURCE, maxStorageBytes: appConfig.MAX_STORAGE_BYTES, maxUploadBytes: appConfig.MAX_UPLOAD_BYTES } };
});
app.post("/api/v1/sources", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig);
if (!identity) return;
const input = sourceInput.parse(request.body);
const workspace = await runtime.store.getWorkspace(identity);
const existing = await runtime.store.listSources(workspace.id);
if (existing.length >= appConfig.MAX_SOURCES_PER_WORKSPACE) return reply.code(409).send({ error: "SOURCE_QUOTA_REACHED" });
const source = await runtime.store.createSource(workspace.id, input);
return reply.code(201).send({ source });
});
app.patch("/api/v1/sources/:id", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig);
if (!identity) return;
const input = sourceInput.partial().parse(request.body);
const sourceId = (request.params as { id: string }).id;
const workspace = await runtime.store.getWorkspace(identity);
const source = await runtime.store.updateSource(sourceId, workspace.id, input);
if (!source) return reply.code(404).send({ error: "SOURCE_NOT_FOUND" });
return { source };
});
app.post("/api/v1/apps", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig);
if (!identity) return;
const input = appInput.parse(request.body);
const { source } = await requireOwnedSource(input.sourceId, identity, runtime, appConfig);
const apps = await runtime.store.listApps(source.id);
if (apps.length >= appConfig.MAX_APPS_PER_SOURCE) return reply.code(409).send({ error: "APP_QUOTA_REACHED" });
if (!input.name) return reply.code(400).send({ error: "APP_NAME_REQUIRED_FOR_MANUAL_APP" });
const appRecord = await runtime.store.upsertApp(source.id, { bundleIdentifier: `manual.${source.slug}.${Date.now()}`, marketplaceID: "", metadata: input });
return reply.code(201).send({ app: appRecord });
});
app.patch("/api/v1/apps/:id", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig);
if (!identity) return;
const input = appInput.omit({ sourceId: true }).partial().parse(request.body);
const appId = (request.params as { id: string }).id;
const appRecord = await runtime.store.getApp(appId);
if (!appRecord) return reply.code(404).send({ error: "APP_NOT_FOUND" });
await requireOwnedSource(appRecord.sourceId, identity, runtime, appConfig);
const updated = await runtime.store.updateApp(appId, input);
return { app: updated };
});
app.post("/api/v1/uploads", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig);
if (!identity) return;
const input = uploadInput.parse(request.body);
if (input.sizeBytes > appConfig.MAX_UPLOAD_BYTES) return reply.code(413).send({ error: "UPLOAD_SIZE_LIMIT", limitBytes: appConfig.MAX_UPLOAD_BYTES });
const { workspace, source } = await requireOwnedSource(input.sourceId, identity, runtime, appConfig);
const usage = await runtime.store.getUsage(workspace.id);
if (usage.storageBytes + input.sizeBytes > appConfig.MAX_STORAGE_BYTES) return reply.code(413).send({ error: "STORAGE_QUOTA_REACHED" });
if (input.appId) {
const appRecord = await runtime.store.getApp(input.appId);
if (!appRecord || appRecord.sourceId !== source.id) return reply.code(400).send({ error: "APP_NOT_IN_SOURCE" });
}
const uploadId = randomUUID();
const safeFilename = input.filename.replace(/[^a-zA-Z0-9._-]/g, "-");
const objectKey = `uploads/${workspace.id}/${uploadId}/${safeFilename}`;
const uploadPlan = await runtime.storage.createUploadPlan(objectKey, "application/zip", input.sizeBytes);
const upload = await runtime.store.createUpload({ id: uploadId, workspaceId: workspace.id, sourceId: source.id, appId: input.appId, objectKey, multipartUploadId: uploadPlan.mode === "multipart" ? uploadPlan.uploadId : undefined, originalFilename: input.filename, expectedSize: input.sizeBytes });
if (uploadPlan.mode === "single" && uploadPlan.uploadUrl.startsWith("/")) uploadPlan.uploadUrl = `${appConfig.PUBLIC_BASE_URL}${uploadPlan.uploadUrl}`;
return reply.code(201).send({ upload, uploadPlan, mode: appConfig.STORAGE_MODE });
});
app.put("/api/v1/uploads/content/:encodedKey", async (request, reply) => {
if (appConfig.STORAGE_MODE !== "local") return reply.code(404).send({ error: "DIRECT_UPLOAD_USES_STORAGE_URL" });
const key = decodeURIComponent((request.params as { encodedKey: string }).encodedKey);
if (!key.startsWith("uploads/") || key.includes("..")) return reply.code(400).send({ error: "UPLOAD_KEY_INVALID" });
const uploadId = key.split("/")[2];
const upload = await runtime.store.getUpload(uploadId);
if (!upload || upload.objectKey !== key) return reply.code(404).send({ error: "UPLOAD_NOT_FOUND" });
const body = Buffer.isBuffer(request.body) ? Readable.from(request.body) : request.body as NodeJS.ReadableStream;
const receivedSize = await runtime.storage.writeUploadFromStream(key, body);
if (receivedSize !== upload.expectedSize) return reply.code(400).send({ error: "UPLOAD_SIZE_MISMATCH", expectedSize: upload.expectedSize, receivedSize });
await runtime.store.updateUpload(upload.id, { receivedSize, status: "uploaded" });
return { ok: true, receivedSize };
});
app.post("/api/v1/uploads/:id/complete", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig);
if (!identity) return;
const uploadId = (request.params as { id: string }).id;
const workspace = await runtime.store.getWorkspace(identity);
const upload = await runtime.store.getUpload(uploadId, workspace.id);
if (!upload) return reply.code(404).send({ error: "UPLOAD_NOT_FOUND" });
const completeInput = multipartPartsInput.parse(request.body);
if (upload.multipartUploadId) await runtime.storage.completeMultipartUpload(upload.objectKey, upload.multipartUploadId, completeInput.parts);
const head = await runtime.storage.headObject(upload.objectKey);
if (!head || head.sizeBytes !== upload.expectedSize) return reply.code(400).send({ error: "UPLOAD_INCOMPLETE", expectedSize: upload.expectedSize, receivedSize: head?.sizeBytes || 0 });
await runtime.store.updateUpload(upload.id, { receivedSize: head.sizeBytes, status: "queued" });
if (appConfig.PROCESS_INLINE) {
try {
await processUpload(upload.id, runtime.store, runtime.storage, appConfig);
} catch {
// The failed upload and user-facing error are stored by the processor.
}
}
return reply.code(202).send({ upload: await runtime.store.getUpload(upload.id), release: (await runtime.store.listReleasesForSource(upload.sourceId)).find((release) => release.uploadId === upload.id) || null });
});
app.get("/api/v1/uploads/:id", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig);
if (!identity) return;
const workspace = await runtime.store.getWorkspace(identity);
const upload = await runtime.store.getUpload((request.params as { id: string }).id, workspace.id);
if (!upload) return reply.code(404).send({ error: "UPLOAD_NOT_FOUND" });
const release = (await runtime.store.listReleasesForSource(upload.sourceId)).find((item) => item.uploadId === upload.id) || null;
return { upload, release };
});
app.get("/api/v1/releases/:id", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig);
if (!identity) return;
const release = await runtime.store.getRelease((request.params as { id: string }).id);
if (!release) return reply.code(404).send({ error: "RELEASE_NOT_FOUND" });
const appRecord = await runtime.store.getApp(release.appId);
if (!appRecord) return reply.code(404).send({ error: "APP_NOT_FOUND" });
await requireOwnedSource(appRecord.sourceId, identity, runtime, appConfig);
return { release, assets: await runtime.store.listReleaseAssets(release.id) };
});
app.post("/api/v1/releases/:id/publish", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig);
if (!identity) return;
const release = await runtime.store.getRelease((request.params as { id: string }).id);
if (!release) return reply.code(404).send({ error: "RELEASE_NOT_FOUND" });
const appRecord = await runtime.store.getApp(release.appId);
if (!appRecord) return reply.code(404).send({ error: "APP_NOT_FOUND" });
await requireOwnedSource(appRecord.sourceId, identity, runtime, appConfig);
if (release.status !== "ready" && release.status !== "published") return reply.code(409).send({ error: "RELEASE_NOT_READY", status: release.status, detail: release.errorMessage });
const updated = await runtime.store.updateRelease(release.id, { status: "published", publishedAt: new Date().toISOString() });
return { release: updated };
});
app.get("/sources/:slug/source.json", async (request, reply) => {
const source = await runtime.store.getSourceBySlug((request.params as { slug: string }).slug);
if (!source || source.visibility === "draft") return reply.code(404).send({ error: "SOURCE_NOT_FOUND" });
const apps = await runtime.store.listApps(source.id);
const releases = await runtime.store.listReleasesForSource(source.id);
const document = buildSourceDocument(source, apps, releases, appConfig.PUBLIC_BASE_URL);
return reply.header("cache-control", "public, max-age=60, must-revalidate").header("access-control-allow-origin", "*").type("application/json").send(document);
});
app.get("/sources/:slug", async (request, reply) => {
const source = await runtime.store.getSourceBySlug((request.params as { slug: string }).slug);
if (!source || source.visibility === "draft") return reply.code(404).send({ error: "SOURCE_NOT_FOUND" });
return { source, apps: await runtime.store.listApps(source.id), releases: (await runtime.store.listReleasesForSource(source.id)).filter((release) => release.status === "published") };
});
app.route({ method: ["GET", "HEAD"], url: "/artifacts/:releaseId/*", handler: async (request, reply) => {
const params = request.params as { releaseId: string; "*": string };
const release = await runtime.store.getRelease(params.releaseId);
if (!release || release.status !== "published") return reply.code(404).send({ error: "ARTIFACT_NOT_FOUND" });
const appRecord = await runtime.store.getApp(release.appId);
if (!appRecord) return reply.code(404).send({ error: "ARTIFACT_NOT_FOUND" });
const source = await runtime.store.getSource(appRecord.sourceId);
if (!source || source.visibility === "draft") return reply.code(404).send({ error: "ARTIFACT_NOT_FOUND" });
const assetPath = safePath(params["*"]);
if (!assetPath) return reply.code(400).send({ error: "ASSET_PATH_INVALID" });
const asset = await runtime.store.getReleaseAsset(release.id, assetPath);
if (!asset) return reply.code(404).send({ error: "ASSET_NOT_FOUND" });
reply.header("content-type", asset.contentType).header("content-length", String(asset.sizeBytes)).header("cache-control", "public, max-age=31536000, immutable").header("etag", `\"${asset.sha256}\"`).header("accept-ranges", "bytes").header("access-control-allow-origin", "*");
if (request.method === "HEAD") return reply.code(200).send();
const object = await runtime.storage.getObject(asset.objectKey);
if (!object) return reply.code(404).send({ error: "ASSET_NOT_FOUND" });
return reply.send(object.body);
}});
app.setErrorHandler((error, request, reply) => {
const message = errorMessage(error);
request.log.error({ err: error }, "request failed");
const status = error instanceof z.ZodError ? 400 : (error as { statusCode?: number }).statusCode || (message.includes("_NOT_FOUND") ? 404 : 500);
return reply.code(status).send({ error: status === 500 ? "INTERNAL_ERROR" : message, detail: status === 500 ? undefined : message });
});
return { app, runtime, appConfig };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { app, runtime, appConfig } = await createServer();
await app.listen({ host: appConfig.HOST, port: appConfig.PORT });
const shutdown = async () => {
await app.close();
await runtime.pool?.end();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
}
+191
View File
@@ -0,0 +1,191 @@
import { createReadStream, createWriteStream } from "node:fs";
import { mkdir, stat, writeFile } from "node:fs/promises";
/* eslint-disable @typescript-eslint/no-explicit-any */
import { dirname, normalize, relative, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import { Readable } from "node:stream";
import {
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { AppConfig } from "./config";
export interface StoredObject {
body: Readable;
sizeBytes: number;
contentType: string;
}
export type UploadPlan =
| { mode: "single"; uploadUrl: string }
| { mode: "multipart"; uploadId: string; partSizeBytes: number; parts: Array<{ partNumber: number; uploadUrl: string }> };
export interface StorageAdapter {
createUploadPlan(key: string, contentType: string, sizeBytes: number): Promise<UploadPlan>;
completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined): Promise<void>;
writeUploadFromStream(key: string, stream: NodeJS.ReadableStream): Promise<number>;
writeObject(key: string, body: Buffer, contentType: string): Promise<void>;
writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream): Promise<void>;
downloadToFile(key: string, filePath: string): Promise<void>;
getObject(key: string): Promise<StoredObject | null>;
headObject(key: string): Promise<{ sizeBytes: number; contentType: string } | null>;
}
function contentTypeForPath(path: string) {
if (path.endsWith(".json")) return "application/json; charset=utf-8";
if (path.endsWith(".ipa")) return "application/octet-stream";
return "application/octet-stream";
}
export class LocalStorage implements StorageAdapter {
private readonly root: string;
constructor(root: string) {
this.root = resolve(root);
}
private pathFor(key: string) {
const candidate = resolve(this.root, normalize(key));
const relativePath = relative(this.root, candidate);
if (!relativePath || relativePath.startsWith("..") || relativePath.includes("..")) {
throw new Error("STORAGE_PATH_INVALID");
}
return candidate;
}
async createUploadPlan(key: string) {
return { mode: "single" as const, uploadUrl: `/api/v1/uploads/content/${encodeURIComponent(key)}` };
}
async completeMultipartUpload() {
return;
}
async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream) {
const filePath = this.pathFor(key);
await mkdir(dirname(filePath), { recursive: true });
await pipeline(stream, createWriteStream(filePath, { flags: "w" }));
return Number((await stat(filePath)).size);
}
async writeObject(key: string, body: Buffer) {
const filePath = this.pathFor(key);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, body);
}
async writeStream(key: string, transform: NodeJS.ReadWriteStream, _contentType: string, _sizeBytes: number, source: NodeJS.ReadableStream) {
const filePath = this.pathFor(key);
await mkdir(dirname(filePath), { recursive: true });
await pipeline(source, transform, createWriteStream(filePath, { flags: "w" }));
}
async downloadToFile(key: string, filePath: string) {
await mkdir(dirname(filePath), { recursive: true });
await pipeline(createReadStream(this.pathFor(key)), createWriteStream(filePath, { flags: "w" }));
}
async getObject(key: string) {
const filePath = this.pathFor(key);
try {
const fileStat = await stat(filePath);
return { body: createReadStream(filePath), sizeBytes: Number(fileStat.size), contentType: contentTypeForPath(key) };
} catch {
return null;
}
}
async headObject(key: string) {
try {
const fileStat = await stat(this.pathFor(key));
return { sizeBytes: Number(fileStat.size), contentType: contentTypeForPath(key) };
} catch {
return null;
}
}
}
export class S3Storage implements StorageAdapter {
private readonly client: S3Client;
constructor(private readonly appConfig: AppConfig) {
this.client = new S3Client({
region: appConfig.S3_REGION,
endpoint: appConfig.S3_ENDPOINT,
forcePathStyle: appConfig.S3_FORCE_PATH_STYLE,
credentials:
appConfig.S3_ACCESS_KEY_ID && appConfig.S3_SECRET_ACCESS_KEY
? { accessKeyId: appConfig.S3_ACCESS_KEY_ID, secretAccessKey: appConfig.S3_SECRET_ACCESS_KEY }
: undefined,
});
}
async createUploadPlan(key: string, contentType: string, sizeBytes: number) {
const partSizeBytes = 16 * 1024 * 1024;
const partCount = Math.ceil(sizeBytes / partSizeBytes);
if (partCount > 10000) throw new Error("UPLOAD_PART_COUNT_LIMIT");
const multipart = await this.client.send(new CreateMultipartUploadCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, ContentType: contentType }));
if (!multipart.UploadId) throw new Error("MULTIPART_UPLOAD_ID_MISSING");
const parts = await Promise.all(Array.from({ length: partCount }, async (_, index) => ({ partNumber: index + 1, uploadUrl: await getSignedUrl(this.client, new UploadPartCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, UploadId: multipart.UploadId, PartNumber: index + 1 }), { expiresIn: 900 }) })));
return { mode: "multipart" as const, uploadId: multipart.UploadId, partSizeBytes, parts };
}
async completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined) {
if (!uploadId || !parts?.length) throw new Error("MULTIPART_PARTS_REQUIRED");
await this.client.send(new CompleteMultipartUploadCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts.sort((a, b) => a.partNumber - b.partNumber).map((part) => ({ PartNumber: part.partNumber, ETag: part.etag })) } }));
}
async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream) {
await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: stream as any }));
const metadata = await this.headObject(key);
return metadata?.sizeBytes || 0;
}
async writeObject(key: string, body: Buffer, contentType: string) {
await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: body, ContentType: contentType }));
}
async writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream) {
const upload = this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: transform as any, ContentType: contentType, ContentLength: sizeBytes || undefined }));
await pipeline(source, transform);
await upload;
}
async downloadToFile(key: string, filePath: string) {
const response = await this.client.send(new GetObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
if (!response.Body) throw new Error("STORAGE_OBJECT_EMPTY");
await mkdir(dirname(filePath), { recursive: true });
const body = response.Body instanceof Readable ? response.Body : Readable.fromWeb(response.Body as any);
await pipeline(body, createWriteStream(filePath, { flags: "w" }));
}
async getObject(key: string) {
try {
const response = await this.client.send(new GetObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
if (!response.Body) return null;
const body = response.Body instanceof Readable ? response.Body : Readable.fromWeb(response.Body as any);
return { body, sizeBytes: Number(response.ContentLength || 0), contentType: response.ContentType || contentTypeForPath(key) };
} catch {
return null;
}
}
async headObject(key: string) {
try {
const response = await this.client.send(new HeadObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
return { sizeBytes: Number(response.ContentLength || 0), contentType: response.ContentType || contentTypeForPath(key) };
} catch {
return null;
}
}
}
export function createStorage(appConfig: AppConfig): StorageAdapter {
return appConfig.STORAGE_MODE === "s3" ? new S3Storage(appConfig) : new LocalStorage(appConfig.LOCAL_STORAGE_DIR);
}
+492
View File
@@ -0,0 +1,492 @@
import { randomUUID } from "node:crypto";
import { Pool } from "pg";
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
AppMetadata,
AppRecord,
Identity,
ReleaseAsset,
ReleaseRecord,
SourceRecord,
UploadRecord,
WorkspaceRecord,
} from "../../../packages/core/src/index";
import { schemaSql } from "./schema";
import type { AppConfig } from "./config";
export interface Store {
init(): Promise<void>;
getWorkspace(identity: Identity): Promise<WorkspaceRecord>;
listSources(workspaceId: string): Promise<SourceRecord[]>;
getSource(id: string, workspaceId?: string): Promise<SourceRecord | null>;
getSourceBySlug(slug: string): Promise<SourceRecord | null>;
createSource(
workspaceId: string,
input: Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility"> &
Partial<Pick<SourceRecord, "iconURL" | "headerURL" | "website" | "tintColor">>,
): Promise<SourceRecord>;
updateSource(
id: string,
workspaceId: string,
patch: Partial<Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility" | "iconURL" | "headerURL" | "website" | "tintColor">>,
): Promise<SourceRecord | null>;
listApps(sourceId: string): Promise<AppRecord[]>;
getApp(id: string): Promise<AppRecord | null>;
upsertApp(
sourceId: string,
input: {
bundleIdentifier: string;
marketplaceID: string;
metadata: Partial<AppMetadata>;
},
): Promise<AppRecord>;
updateApp(
id: string,
patch: Partial<AppMetadata>,
): Promise<AppRecord | null>;
createUpload(input: Omit<UploadRecord, "status" | "receivedSize" | "createdAt">): Promise<UploadRecord>;
getUpload(id: string, workspaceId?: string): Promise<UploadRecord | null>;
updateUpload(id: string, patch: Partial<UploadRecord>): Promise<UploadRecord | null>;
createRelease(input: Omit<ReleaseRecord, "createdAt" | "status">): Promise<ReleaseRecord>;
getRelease(id: string): Promise<ReleaseRecord | null>;
updateRelease(id: string, patch: Partial<ReleaseRecord>): Promise<ReleaseRecord | null>;
listReleasesForSource(sourceId: string): Promise<ReleaseRecord[]>;
addReleaseAssets(assets: ReleaseAsset[]): Promise<void>;
listReleaseAssets(releaseId: string): Promise<ReleaseAsset[]>;
getReleaseAsset(releaseId: string, path: string): Promise<ReleaseAsset | null>;
getUsage(workspaceId: string): Promise<{ storageBytes: number; sourceCount: number }>;
listQueuedUploads(limit: number): Promise<UploadRecord[]>;
}
function now() {
return new Date().toISOString();
}
function slugify(value: string) {
const result = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 48);
return result || `source-${randomUUID().slice(0, 8)}`;
}
function defaultMetadata(metadata: Partial<AppMetadata>): AppMetadata {
return {
name: metadata.name || "Untitled App",
developerName: metadata.developerName || "AltDock Developer",
subtitle: metadata.subtitle || "",
localizedDescription: metadata.localizedDescription || "",
iconURL: metadata.iconURL,
tintColor: metadata.tintColor || "#E9694B",
category: metadata.category || "other",
screenshots: metadata.screenshots || [],
appPermissions: metadata.appPermissions || { entitlements: [], privacy: {} },
};
}
export class MemoryStore implements Store {
private readonly workspaces = new Map<string, WorkspaceRecord>();
private readonly workspaceBySubject = new Map<string, string>();
private readonly sources = new Map<string, SourceRecord>();
private readonly apps = new Map<string, AppRecord>();
private readonly uploads = new Map<string, UploadRecord>();
private readonly releases = new Map<string, ReleaseRecord>();
private readonly assets = new Map<string, ReleaseAsset>();
async init() {}
async getWorkspace(identity: Identity) {
const existingId = this.workspaceBySubject.get(identity.subject);
if (existingId) return this.workspaces.get(existingId)!;
const id = randomUUID();
const workspace: WorkspaceRecord = {
id,
name: `${identity.displayName || "Developer"}'s Workspace`,
slug: slugify(identity.displayName || identity.email),
ownerSubject: identity.subject,
ownerEmail: identity.email,
createdAt: now(),
};
this.workspaces.set(id, workspace);
this.workspaceBySubject.set(identity.subject, id);
return workspace;
}
async listSources(workspaceId: string) {
return [...this.sources.values()]
.filter((source) => source.workspaceId === workspaceId)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
async getSource(id: string, workspaceId?: string) {
const source = this.sources.get(id);
return source && (!workspaceId || source.workspaceId === workspaceId) ? source : null;
}
async getSourceBySlug(slug: string) {
return [...this.sources.values()].find((source) => source.slug === slug) || null;
}
async createSource(workspaceId: string, input: Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility"> & Partial<Pick<SourceRecord, "iconURL" | "headerURL" | "website" | "tintColor">>) {
const createdAt = now();
const source: SourceRecord = {
id: randomUUID(),
workspaceId,
slug: `${slugify(input.name)}-${randomUUID().slice(0, 6)}`,
name: input.name,
subtitle: input.subtitle || "",
description: input.description || "",
iconURL: input.iconURL,
headerURL: input.headerURL,
website: input.website,
tintColor: input.tintColor || "#E9694B",
visibility: input.visibility,
createdAt,
updatedAt: createdAt,
};
this.sources.set(source.id, source);
return source;
}
async updateSource(id: string, workspaceId: string, patch: Partial<Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility" | "iconURL" | "headerURL" | "website" | "tintColor">>) {
const source = await this.getSource(id, workspaceId);
if (!source) return null;
Object.assign(source, patch, { updatedAt: now() });
return source;
}
async listApps(sourceId: string) {
return [...this.apps.values()].filter((app) => app.sourceId === sourceId);
}
async getApp(id: string) {
return this.apps.get(id) || null;
}
async upsertApp(sourceId: string, input: { bundleIdentifier: string; marketplaceID: string; metadata: Partial<AppMetadata> }) {
const existing = [...this.apps.values()].find((app) => app.sourceId === sourceId && app.bundleIdentifier === input.bundleIdentifier);
const metadata = defaultMetadata(input.metadata);
if (existing) {
Object.assign(existing, metadata, { marketplaceID: input.marketplaceID || existing.marketplaceID, updatedAt: now() });
return existing;
}
const app: AppRecord = {
id: randomUUID(),
sourceId,
bundleIdentifier: input.bundleIdentifier,
marketplaceID: input.marketplaceID,
...metadata,
createdAt: now(),
updatedAt: now(),
};
this.apps.set(app.id, app);
return app;
}
async updateApp(id: string, patch: Partial<AppMetadata>) {
const app = this.apps.get(id);
if (!app) return null;
Object.assign(app, patch, { updatedAt: now() });
return app;
}
async createUpload(input: Omit<UploadRecord, "status" | "receivedSize" | "createdAt">) {
const upload: UploadRecord = { ...input, status: "created", receivedSize: 0, createdAt: now() };
this.uploads.set(upload.id, upload);
return upload;
}
async getUpload(id: string, workspaceId?: string) {
const upload = this.uploads.get(id);
return upload && (!workspaceId || upload.workspaceId === workspaceId) ? upload : null;
}
async updateUpload(id: string, patch: Partial<UploadRecord>) {
const upload = this.uploads.get(id);
if (!upload) return null;
Object.assign(upload, patch);
return upload;
}
async createRelease(input: Omit<ReleaseRecord, "createdAt" | "status">) {
const release: ReleaseRecord = { ...input, status: "processing", createdAt: now() };
this.releases.set(release.id, release);
return release;
}
async getRelease(id: string) {
return this.releases.get(id) || null;
}
async updateRelease(id: string, patch: Partial<ReleaseRecord>) {
const release = this.releases.get(id);
if (!release) return null;
Object.assign(release, patch);
return release;
}
async listReleasesForSource(sourceId: string) {
const appIds = new Set((await this.listApps(sourceId)).map((app) => app.id));
return [...this.releases.values()].filter((release) => appIds.has(release.appId));
}
async addReleaseAssets(assets: ReleaseAsset[]) {
for (const asset of assets) this.assets.set(`${asset.releaseId}:${asset.path}`, asset);
}
async listReleaseAssets(releaseId: string) {
return [...this.assets.values()].filter((asset) => asset.releaseId === releaseId);
}
async getReleaseAsset(releaseId: string, path: string) {
return this.assets.get(`${releaseId}:${path}`) || null;
}
async getUsage(workspaceId: string) {
const sourceCount = (await this.listSources(workspaceId)).length;
const sourceIds = new Set((await this.listSources(workspaceId)).map((source) => source.id));
const appIds = new Set([...this.apps.values()].filter((app) => sourceIds.has(app.sourceId)).map((app) => app.id));
const releaseIds = new Set([...this.releases.values()].filter((release) => appIds.has(release.appId)).map((release) => release.id));
const storageBytes = [...this.assets.values()].filter((asset) => releaseIds.has(asset.releaseId)).reduce((sum, asset) => sum + asset.sizeBytes, 0);
return { storageBytes, sourceCount };
}
async listQueuedUploads(limit: number) {
return [...this.uploads.values()].filter((upload) => upload.status === "queued").slice(0, limit);
}
}
type Queryable = Pick<Pool, "query">;
function mapSource(row: any): SourceRecord {
return {
id: row.id,
workspaceId: row.workspace_id,
slug: row.slug,
name: row.name,
subtitle: row.subtitle,
description: row.description,
iconURL: row.icon_url || undefined,
headerURL: row.header_url || undefined,
website: row.website || undefined,
tintColor: row.tint_color,
visibility: row.visibility,
createdAt: new Date(row.created_at).toISOString(),
updatedAt: new Date(row.updated_at).toISOString(),
};
}
function mapApp(row: any): AppRecord {
return {
id: row.id,
sourceId: row.source_id,
name: row.name,
bundleIdentifier: row.bundle_identifier,
marketplaceID: row.marketplace_id,
developerName: row.developer_name,
subtitle: row.subtitle,
localizedDescription: row.localized_description,
iconURL: row.icon_url || undefined,
tintColor: row.tint_color,
category: row.category,
screenshots: row.screenshots || [],
appPermissions: row.app_permissions || { entitlements: [], privacy: {} },
createdAt: new Date(row.created_at).toISOString(),
updatedAt: new Date(row.updated_at).toISOString(),
};
}
function mapUpload(row: any): UploadRecord {
return {
id: row.id,
workspaceId: row.workspace_id,
sourceId: row.source_id,
appId: row.app_id || undefined,
objectKey: row.object_key,
multipartUploadId: row.multipart_upload_id || undefined,
originalFilename: row.original_filename,
expectedSize: Number(row.expected_size),
receivedSize: Number(row.received_size),
status: row.status,
errorCode: row.error_code || undefined,
errorMessage: row.error_message || undefined,
createdAt: new Date(row.created_at).toISOString(),
};
}
function mapRelease(row: any): ReleaseRecord {
return {
id: row.id,
appId: row.app_id,
uploadId: row.upload_id,
version: row.version,
buildVersion: row.build_version,
appleItemId: row.apple_item_id,
date: new Date(row.date).toISOString(),
localizedDescription: row.localized_description,
minOSVersion: row.min_os_version || undefined,
sizeBytes: Number(row.size_bytes),
status: row.status,
errorCode: row.error_code || undefined,
errorMessage: row.error_message || undefined,
manifest: row.manifest,
createdAt: new Date(row.created_at).toISOString(),
publishedAt: row.published_at ? new Date(row.published_at).toISOString() : undefined,
};
}
export class PostgresStore implements Store {
constructor(private readonly pool: Queryable) {}
async init() {
await this.pool.query(schemaSql);
}
async getWorkspace(identity: Identity) {
const user = await this.pool.query<any>(
`INSERT INTO users (id, subject, email, display_name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (subject) DO UPDATE SET email = EXCLUDED.email, display_name = EXCLUDED.display_name
RETURNING *`,
[randomUUID(), identity.subject, identity.email, identity.displayName],
);
const userRow = user.rows[0];
const workspace = await this.pool.query<any>(
`INSERT INTO workspaces (id, owner_user_id, name, slug)
VALUES ($1, $2, $3, $4)
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING *`,
[randomUUID(), userRow.id, `${identity.displayName || "Developer"}'s Workspace`, `${slugify(identity.displayName || identity.email)}-${userRow.id.slice(0, 6)}`],
);
const row = workspace.rows[0];
return { id: row.id, name: row.name, slug: row.slug, ownerSubject: identity.subject, ownerEmail: identity.email, createdAt: new Date(row.created_at).toISOString() };
}
async listSources(workspaceId: string) {
const result = await this.pool.query(`SELECT * FROM sources WHERE workspace_id = $1 ORDER BY updated_at DESC`, [workspaceId]);
return result.rows.map(mapSource);
}
async getSource(id: string, workspaceId?: string) {
const result = await this.pool.query(`SELECT * FROM sources WHERE id = $1 ${workspaceId ? "AND workspace_id = $2" : ""}`, workspaceId ? [id, workspaceId] : [id]);
return result.rows[0] ? mapSource(result.rows[0]) : null;
}
async getSourceBySlug(slug: string) {
const result = await this.pool.query(`SELECT * FROM sources WHERE slug = $1`, [slug]);
return result.rows[0] ? mapSource(result.rows[0]) : null;
}
async createSource(workspaceId: string, input: Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility"> & Partial<Pick<SourceRecord, "iconURL" | "headerURL" | "website" | "tintColor">>) {
const result = await this.pool.query(`INSERT INTO sources (id, workspace_id, slug, name, subtitle, description, icon_url, header_url, website, tint_color, visibility) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, [randomUUID(), workspaceId, `${slugify(input.name)}-${randomUUID().slice(0, 6)}`, input.name, input.subtitle || "", input.description || "", input.iconURL || null, input.headerURL || null, input.website || null, input.tintColor || "#E9694B", input.visibility]);
return mapSource(result.rows[0]);
}
async updateSource(id: string, workspaceId: string, patch: Partial<Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility" | "iconURL" | "headerURL" | "website" | "tintColor">>) {
const source = await this.getSource(id, workspaceId);
if (!source) return null;
const next = { ...source, ...patch };
const result = await this.pool.query(`UPDATE sources SET name=$1, subtitle=$2, description=$3, icon_url=$4, header_url=$5, website=$6, tint_color=$7, visibility=$8, updated_at=NOW() WHERE id=$9 AND workspace_id=$10 RETURNING *`, [next.name, next.subtitle, next.description, next.iconURL || null, next.headerURL || null, next.website || null, next.tintColor, next.visibility, id, workspaceId]);
return result.rows[0] ? mapSource(result.rows[0]) : null;
}
async listApps(sourceId: string) {
const result = await this.pool.query(`SELECT * FROM apps WHERE source_id = $1 ORDER BY created_at ASC`, [sourceId]);
return result.rows.map(mapApp);
}
async getApp(id: string) {
const result = await this.pool.query(`SELECT * FROM apps WHERE id = $1`, [id]);
return result.rows[0] ? mapApp(result.rows[0]) : null;
}
async upsertApp(sourceId: string, input: { bundleIdentifier: string; marketplaceID: string; metadata: Partial<AppMetadata> }) {
const metadata = defaultMetadata(input.metadata);
const result = await this.pool.query(`INSERT INTO apps (id, source_id, name, bundle_identifier, marketplace_id, developer_name, subtitle, localized_description, icon_url, tint_color, category, screenshots, app_permissions) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (source_id, bundle_identifier) DO UPDATE SET marketplace_id=EXCLUDED.marketplace_id, name=EXCLUDED.name, developer_name=EXCLUDED.developer_name, subtitle=EXCLUDED.subtitle, localized_description=EXCLUDED.localized_description, icon_url=EXCLUDED.icon_url, tint_color=EXCLUDED.tint_color, category=EXCLUDED.category, screenshots=EXCLUDED.screenshots, app_permissions=EXCLUDED.app_permissions, updated_at=NOW() RETURNING *`, [randomUUID(), sourceId, metadata.name, input.bundleIdentifier, input.marketplaceID, metadata.developerName, metadata.subtitle, metadata.localizedDescription, metadata.iconURL || null, metadata.tintColor, metadata.category, JSON.stringify(metadata.screenshots), JSON.stringify(metadata.appPermissions)]);
return mapApp(result.rows[0]);
}
async updateApp(id: string, patch: Partial<AppMetadata>) {
const app = await this.getApp(id);
if (!app) return null;
const next = { ...app, ...patch };
const result = await this.pool.query(`UPDATE apps SET name=$1, developer_name=$2, subtitle=$3, localized_description=$4, icon_url=$5, tint_color=$6, category=$7, screenshots=$8, app_permissions=$9, updated_at=NOW() WHERE id=$10 RETURNING *`, [next.name, next.developerName, next.subtitle, next.localizedDescription, next.iconURL || null, next.tintColor, next.category, JSON.stringify(next.screenshots), JSON.stringify(next.appPermissions), id]);
return result.rows[0] ? mapApp(result.rows[0]) : null;
}
async createUpload(input: Omit<UploadRecord, "status" | "receivedSize" | "createdAt">) {
const result = await this.pool.query(`INSERT INTO uploads (id, workspace_id, source_id, app_id, object_key, multipart_upload_id, original_filename, expected_size) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, [input.id, input.workspaceId, input.sourceId, input.appId || null, input.objectKey, input.multipartUploadId || null, input.originalFilename, input.expectedSize]);
return mapUpload(result.rows[0]);
}
async getUpload(id: string, workspaceId?: string) {
const result = await this.pool.query(`SELECT * FROM uploads WHERE id=$1 ${workspaceId ? "AND workspace_id=$2" : ""}`, workspaceId ? [id, workspaceId] : [id]);
return result.rows[0] ? mapUpload(result.rows[0]) : null;
}
async updateUpload(id: string, patch: Partial<UploadRecord>) {
const current = await this.getUpload(id);
if (!current) return null;
const next = { ...current, ...patch };
const result = await this.pool.query(`UPDATE uploads SET app_id=$1, multipart_upload_id=$2, received_size=$3, status=$4, error_code=$5, error_message=$6 WHERE id=$7 RETURNING *`, [next.appId || null, next.multipartUploadId || null, next.receivedSize, next.status, next.errorCode || null, next.errorMessage || null, id]);
return result.rows[0] ? mapUpload(result.rows[0]) : null;
}
async createRelease(input: Omit<ReleaseRecord, "createdAt" | "status">) {
const result = await this.pool.query(`INSERT INTO releases (id, app_id, upload_id, version, build_version, apple_item_id, date, localized_description, min_os_version, size_bytes, status, manifest) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'processing',$11) RETURNING *`, [input.id, input.appId, input.uploadId, input.version, input.buildVersion, input.appleItemId, input.date, input.localizedDescription, input.minOSVersion || null, input.sizeBytes, JSON.stringify(input.manifest)]);
return mapRelease(result.rows[0]);
}
async getRelease(id: string) {
const result = await this.pool.query(`SELECT * FROM releases WHERE id=$1`, [id]);
return result.rows[0] ? mapRelease(result.rows[0]) : null;
}
async updateRelease(id: string, patch: Partial<ReleaseRecord>) {
const current = await this.getRelease(id);
if (!current) return null;
const next = { ...current, ...patch };
const result = await this.pool.query(`UPDATE releases SET status=$1, error_code=$2, error_message=$3, size_bytes=$4, localized_description=$5, published_at=$6 WHERE id=$7 RETURNING *`, [next.status, next.errorCode || null, next.errorMessage || null, next.sizeBytes, next.localizedDescription, next.publishedAt || null, id]);
return result.rows[0] ? mapRelease(result.rows[0]) : null;
}
async listReleasesForSource(sourceId: string) {
const result = await this.pool.query(`SELECT releases.* FROM releases JOIN apps ON apps.id = releases.app_id WHERE apps.source_id=$1 ORDER BY releases.date DESC, releases.build_version DESC`, [sourceId]);
return result.rows.map(mapRelease);
}
async addReleaseAssets(assets: ReleaseAsset[]) {
for (const asset of assets) {
await this.pool.query(`INSERT INTO release_assets (id, release_id, path, object_key, size_bytes, sha256, content_type) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (release_id, path) DO UPDATE SET object_key=EXCLUDED.object_key, size_bytes=EXCLUDED.size_bytes, sha256=EXCLUDED.sha256, content_type=EXCLUDED.content_type`, [randomUUID(), asset.releaseId, asset.path, asset.objectKey, asset.sizeBytes, asset.sha256, asset.contentType]);
}
}
async listReleaseAssets(releaseId: string) {
const result = await this.pool.query(`SELECT release_id, path, object_key, size_bytes, sha256, content_type FROM release_assets WHERE release_id=$1 ORDER BY path`, [releaseId]);
return result.rows.map((row) => ({ releaseId: row.release_id, path: row.path, objectKey: row.object_key, sizeBytes: Number(row.size_bytes), sha256: row.sha256, contentType: row.content_type }));
}
async getReleaseAsset(releaseId: string, path: string) {
const result = await this.pool.query(`SELECT release_id, path, object_key, size_bytes, sha256, content_type FROM release_assets WHERE release_id=$1 AND path=$2`, [releaseId, path]);
const row = result.rows[0];
return row ? { releaseId: row.release_id, path: row.path, objectKey: row.object_key, sizeBytes: Number(row.size_bytes), sha256: row.sha256, contentType: row.content_type } : null;
}
async getUsage(workspaceId: string) {
const result = await this.pool.query(`SELECT COUNT(DISTINCT s.id)::int AS source_count, COALESCE(SUM(u.received_size),0)::bigint AS storage_bytes FROM sources s LEFT JOIN uploads u ON u.workspace_id=s.workspace_id WHERE s.workspace_id=$1`, [workspaceId]);
return { storageBytes: Number(result.rows[0]?.storage_bytes || 0), sourceCount: Number(result.rows[0]?.source_count || 0) };
}
async listQueuedUploads(limit: number) {
const result = await this.pool.query(`SELECT * FROM uploads WHERE status='queued' ORDER BY created_at LIMIT $1`, [limit]);
return result.rows.map(mapUpload);
}
}
export async function createStore(appConfig: AppConfig): Promise<{ store: Store; pool?: Pool }> {
if (!appConfig.DATABASE_URL) return { store: new MemoryStore() };
const pool = new Pool({ connectionString: appConfig.DATABASE_URL });
return { store: new PostgresStore(pool), pool };
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+30
View File
@@ -0,0 +1,30 @@
import { loadConfig } from "../../api/src/config";
import { processUpload } from "../../api/src/processor";
import { createRuntime } from "../../api/src/runtime";
const appConfig = loadConfig();
const runtime = await createRuntime(appConfig);
async function tick() {
const uploads = await runtime.store.listQueuedUploads(2);
for (const upload of uploads) {
try {
await processUpload(upload.id, runtime.store, runtime.storage, appConfig);
console.log(`[worker] processed ${upload.id}`);
} catch (error) {
console.error(`[worker] failed ${upload.id}`, error);
}
}
}
console.log(`AltDock worker listening for queued uploads (${appConfig.STORAGE_MODE} storage).`);
await tick();
const interval = setInterval(() => void tick(), 2500);
const shutdown = async () => {
clearInterval(interval);
await runtime.pool?.end();
process.exit(0);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+45
View File
@@ -0,0 +1,45 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import * as Toast from "@radix-ui/react-toast";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import { useEffect, useMemo, useState, type ComponentType, type PropsWithChildren } from "react";
import { AppWindow, BookOpen, Boxes, ChevronDown, ExternalLink, FileArchive, Gauge, Menu, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings2, UploadCloud, X } from "lucide-react";
import { API_BASE, AUTH_MODE, formatBytes } from "@/app/lib/dashboard";
import { useDashboard } from "./dashboard-provider";
type NavItem = { href: string; label: string; icon: ComponentType<{ size?: number; strokeWidth?: number }> };
const primaryNav: NavItem[] = [
{ href: "/dashboard", label: "概要", icon: Gauge },
{ href: "/dashboard/sources", label: "Sources", icon: Boxes },
{ href: "/dashboard/apps", label: "アプリ", icon: AppWindow },
{ href: "/dashboard/releases", label: "リリース", icon: FileArchive },
];
const secondaryNav: NavItem[] = [
{ href: "https://faq.altstore.io/developers/distribute-with-altstore-pal", label: "PALドキュメント", icon: BookOpen },
{ href: "/dashboard/settings", label: "設定", icon: Settings2 },
];
function isActive(pathname: string, href: string) {
if (href === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(href);
}
function SideNavigation({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
const pathname = usePathname();
const router = useRouter();
const { dashboard } = useDashboard();
const renderItem = (item: NavItem) => {
const external = item.href.startsWith("http");
const Icon = item.icon;
const className = `admin-nav-item${isActive(pathname, item.href) ? " active" : ""}`;
const content = <><Icon size={16} strokeWidth={1.8} /><span>{item.label}</span>{external && <ExternalLink className="admin-nav-external" size={12} />}</>;
if (external) return <a key={item.href} className={className} href={item.href} target="_blank" rel="noreferrer" onClick={onNavigate}>{content}</a>;
return <Link key={item.href} className={className} href={item.href} onClick={onNavigate}>{content}</Link>;
};
const storagePercent = dashboard ? Math.min(100, (dashboard.usage.storageBytes / Math.max(1, dashboard.limits.maxStorageBytes)) * 100) : 0;
return <div className="admin-sidebar-inner">
<button className="admin-brand" onClick={() => router.push("/dashboard")} aria-label="AltDock 概要へ移動"><span className="admin-brand-mark">A</span><span className={collapsed ? "sr-only" : "admin-brand-name"}>AltDock</span><span className={collapsed ? "sr-only" : "admin-brand-beta"}>BETA</span></button>
{!collapsed && <div className="admin-workspace-switcher"><span className="admin-workspace-avatar">{dashboard?.workspace.name.slice(0, 1).toUpperCase() || "D"}</span><span className="admin-workspace-copy"><small>WORKSPACE</small><strong>{dashboard?.workspace.name || "Developer Workspace"}</strong></span><ChevronDown size={14} /></div>}
<div className={collapsed ? "sr-only" : "admin-nav-label"}></div>
<nav className="admin-nav" aria-label="管理画面ナビゲーション">{primaryNav.map(renderItem)}</nav>
<div className={collapsed ? "sr-only" : "admin-nav-label admin-nav-label-secondary"}></div>
<nav className="admin-nav admin-nav-secondary" aria-label="その他のナビゲーション">{secondaryNav.map(renderItem)}</nav>
<div className="admin-sidebar-spacer" />
{!collapsed && <div className="admin-storage-card"><div className="admin-storage-row"><span></span><strong>{formatBytes(dashboard?.usage.storageBytes || 0)} / {formatBytes(dashboard?.limits.maxStorageBytes || 0)}</strong></div><div className="admin-progress"><span style={{ width: `${storagePercent}%` }} /></div><small>Free workspace plan</small></div>}
{!collapsed && <div className="admin-user-card"><span className="admin-user-avatar">D</span><span><strong>Demo Developer</strong><small>demo@altdock.local</small></span><span className="admin-user-menu">···</span></div>}
</div>;
}
function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const router = useRouter();
const commands = useMemo(() => primaryNav.map((item) => ({ ...item, action: () => router.push(item.href) })), [router]);
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="command-dialog"><DialogTitle className="sr-only"></DialogTitle><DialogDescription className="sr-only"></DialogDescription><Command label="画面を検索"><CommandInput placeholder="画面や操作を検索…" autoFocus /><CommandList><CommandEmpty></CommandEmpty><CommandGroup heading="移動"><CommandItem onSelect={() => { commands[0].action(); onOpenChange(false); }}><Gauge size={15} /><span className="command-shortcut">G O</span></CommandItem><CommandItem onSelect={() => { commands[1].action(); onOpenChange(false); }}><Boxes size={15} />Sources<span className="command-shortcut">G S</span></CommandItem><CommandItem onSelect={() => { commands[2].action(); onOpenChange(false); }}><AppWindow size={15} /><span className="command-shortcut">G A</span></CommandItem><CommandItem onSelect={() => { commands[3].action(); onOpenChange(false); }}><FileArchive size={15} /><span className="command-shortcut">G R</span></CommandItem></CommandGroup><CommandGroup heading="操作"><CommandItem onSelect={() => { router.push("/dashboard/releases?upload=1"); onOpenChange(false); }}><UploadCloud size={15} />ADPをアップロード</CommandItem></CommandGroup></CommandList></Command></DialogContent></Dialog>;
}
export function AdminShell({ children }: PropsWithChildren) {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [commandOpen, setCommandOpen] = useState(false);
const pathname = usePathname();
const { notice, dismissNotice, dashboard } = useDashboard();
useEffect(() => {
const handleShortcut = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); setCommandOpen(true); }
};
window.addEventListener("keydown", handleShortcut);
return () => window.removeEventListener("keydown", handleShortcut);
}, []);
const pageLabel = pathname === "/dashboard" ? "概要" : pathname.includes("/sources") ? "Sources" : pathname.includes("/apps") ? "アプリ" : pathname.includes("/releases") ? "リリース" : "設定";
return <Toast.Provider swipeDirection="right">
<div className="admin-shell">
<aside className={`admin-sidebar ${collapsed ? "is-collapsed" : ""}`}><SideNavigation collapsed={collapsed} /></aside>
<Dialog open={mobileOpen} onOpenChange={setMobileOpen}><DialogContent className="mobile-nav-dialog"><DialogTitle className="sr-only"></DialogTitle><DialogDescription className="sr-only"></DialogDescription><button className="mobile-nav-close" onClick={() => setMobileOpen(false)} aria-label="ナビゲーションを閉じる"><X size={17} /></button><SideNavigation onNavigate={() => setMobileOpen(false)} /></DialogContent></Dialog>
<section className="admin-main-column">
<header className="admin-topbar"><div className="admin-topbar-left"><Button className="mobile-menu-button" variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="ナビゲーションを開く"><Menu size={18} /></Button><div className="admin-breadcrumb"><span>Workspace</span><span>/</span><strong>{pageLabel}</strong></div></div><div className="admin-topbar-actions"><button className="admin-search-trigger" onClick={() => setCommandOpen(true)}><Search size={15} /><span></span><kbd> K</kbd></button><Link className="admin-topbar-action" href="/dashboard/releases?upload=1"><Plus size={15} /></Link>{AUTH_MODE === "oidc" ? <a className="admin-auth-link" href={`${API_BASE}/auth/login`}>SSOでログイン</a> : <a className="admin-auth-link" href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">API status</a>}</div></header>
<div className="admin-content">{children}</div>
</section>
<button className="admin-collapse-toggle" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? "サイドバーを展開" : "サイドバーを折りたたむ"}>{collapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}</button>
</div>
{notice && <Toast.Root open onOpenChange={(open) => { if (!open) dismissNotice(); }} className={`admin-toast toast-${notice.tone}`}><Toast.Title>{notice.tone === "error" ? "処理に失敗しました" : notice.tone === "info" ? "処理中" : "完了"}</Toast.Title><Toast.Description>{notice.text}</Toast.Description><Toast.Close aria-label="通知を閉じる">×</Toast.Close></Toast.Root>}
<Toast.Viewport className="admin-toast-viewport" />
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
</Toast.Provider>;
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type PropsWithChildren } from "react";
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
import { API_BASE, api, type DashboardData, type Notice, type Visibility } from "@/app/lib/dashboard";
type SourceInput = Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility"> & Partial<Pick<SourceRecord, "iconURL" | "headerURL" | "website" | "tintColor">>;
type UploadPlan =
| { mode: "single"; uploadUrl: string }
| { mode: "multipart"; uploadId: string; partSizeBytes: number; parts: Array<{ partNumber: number; uploadUrl: string }> };
type AppPatch = Partial<Pick<AppRecord, "name" | "developerName" | "subtitle" | "localizedDescription" | "iconURL" | "category" | "appPermissions">>;
type DashboardContextValue = {
dashboard: DashboardData | null;
loading: boolean;
busy: boolean;
notice: Notice | null;
refresh: () => Promise<void>;
dismissNotice: () => void;
createSource: (input: SourceInput) => Promise<void>;
updateSource: (id: string, input: Partial<SourceInput>) => Promise<void>;
changeVisibility: (id: string, visibility: Visibility) => Promise<void>;
uploadRelease: (sourceId: string, file: File, appId?: string) => Promise<ReleaseRecord | null>;
saveApp: (id: string, patch: AppPatch) => Promise<void>;
publishRelease: (id: string) => Promise<void>;
copySourceUrl: (source: SourceRecord) => Promise<void>;
};
const DashboardContext = createContext<DashboardContextValue | null>(null);
function messageFor(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
export function DashboardProvider({ children }: PropsWithChildren) {
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
setDashboard(await api<DashboardData>("/api/v1/dashboard"));
} catch (error) {
setNotice({ tone: "error", text: `APIに接続できません。${messageFor(error, "APIを起動してください。")}` });
} finally {
setLoading(false);
}
}, []);
// The provider owns the external dashboard request and refreshes it on mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { void refresh(); }, [refresh]);
const withBusy = useCallback(async (work: () => Promise<void>, success: string, failure: string) => {
setBusy(true);
try {
await work();
setNotice({ tone: "success", text: success });
await refresh();
} catch (error) {
setNotice({ tone: "error", text: messageFor(error, failure) });
throw error;
} finally {
setBusy(false);
}
}, [refresh]);
const createSource = useCallback(async (input: SourceInput) => {
await withBusy(
async () => { await api("/api/v1/sources", { method: "POST", body: JSON.stringify(input) }); },
"Sourceを作成しました。",
"Sourceを作成できませんでした。",
);
}, [withBusy]);
const updateSource = useCallback(async (id: string, input: Partial<SourceInput>) => {
await withBusy(
async () => { await api(`/api/v1/sources/${id}`, { method: "PATCH", body: JSON.stringify(input) }); },
"Sourceを更新しました。",
"Sourceを更新できませんでした。",
);
}, [withBusy]);
const changeVisibility = useCallback(async (id: string, visibility: Visibility) => {
await withBusy(
async () => { await api(`/api/v1/sources/${id}`, { method: "PATCH", body: JSON.stringify({ visibility }) }); },
"Sourceの公開状態を更新しました。",
"公開状態を変更できませんでした。",
);
}, [withBusy]);
const uploadRelease = useCallback(async (sourceId: string, file: File, appId?: string) => {
setBusy(true);
setNotice({ tone: "info", text: "ADPをアップロードし、Manifestとアセットを検証しています。" });
try {
const init = await api<{ upload: { id: string }; uploadPlan: UploadPlan }>("/api/v1/uploads", { method: "POST", body: JSON.stringify({ sourceId, appId, filename: file.name, sizeBytes: file.size }) });
const uploadedParts: Array<{ partNumber: number; etag: string }> = [];
if (init.uploadPlan.mode === "multipart") {
for (const part of init.uploadPlan.parts) {
const start = (part.partNumber - 1) * init.uploadPlan.partSizeBytes;
const response = await fetch(part.uploadUrl, { method: "PUT", body: file.slice(start, Math.min(file.size, start + init.uploadPlan.partSizeBytes)) });
if (!response.ok) throw new Error(`ADPパート${part.partNumber}の転送に失敗しました。`);
const etag = response.headers.get("etag");
if (!etag) throw new Error(`ADPパート${part.partNumber}のETagを取得できませんでした。`);
uploadedParts.push({ partNumber: part.partNumber, etag });
}
} else {
const response = await fetch(init.uploadPlan.uploadUrl, { method: "PUT", headers: { "content-type": "application/zip" }, body: file });
if (!response.ok) throw new Error("ADPファイルの転送に失敗しました。");
}
let complete = await api<{ release?: ReleaseRecord }>(`/api/v1/uploads/${init.upload.id}/complete`, { method: "POST", body: JSON.stringify({ parts: uploadedParts.length ? uploadedParts : undefined }) });
if (!complete.release) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1200));
const status = await api<{ release?: ReleaseRecord }>(`/api/v1/uploads/${init.upload.id}`);
if (status.release) { complete = status; break; }
}
}
setNotice({ tone: complete.release?.status === "ready" ? "success" : "info", text: complete.release ? `v${complete.release.version}build ${complete.release.buildVersion})の検証が完了しました。` : "アップロードをキューに追加しました。処理完了後に更新してください。" });
await refresh();
return complete.release || null;
} catch (error) {
setNotice({ tone: "error", text: messageFor(error, "ADPを処理できませんでした。") });
throw error;
} finally {
setBusy(false);
}
}, [refresh]);
const saveApp = useCallback(async (id: string, patch: AppPatch) => {
await withBusy(
async () => { await api(`/api/v1/apps/${id}`, { method: "PATCH", body: JSON.stringify(patch) }); },
"アプリのSourceメタデータを更新しました。",
"アプリ情報を更新できませんでした。",
);
}, [withBusy]);
const publishRelease = useCallback(async (id: string) => {
await withBusy(
async () => { await api(`/api/v1/releases/${id}/publish`, { method: "POST", body: "{}" }); },
"リリースを公開しました。",
"リリースを公開できませんでした。",
);
}, [withBusy]);
const copySourceUrl = useCallback(async (source: SourceRecord) => {
const url = `${API_BASE.replace(/\/$/, "")}/sources/${source.slug}/source.json`;
await navigator.clipboard?.writeText(url);
setNotice({ tone: "success", text: "Source URLをコピーしました。AltStore PALのSource追加画面に貼り付けられます。" });
}, []);
const value = useMemo<DashboardContextValue>(() => ({ dashboard, loading, busy, notice, refresh, dismissNotice: () => setNotice(null), createSource, updateSource, changeVisibility, uploadRelease, saveApp, publishRelease, copySourceUrl }), [dashboard, loading, busy, notice, refresh, createSource, updateSource, changeVisibility, uploadRelease, saveApp, publishRelease, copySourceUrl]);
return <DashboardContext.Provider value={value}>{children}</DashboardContext.Provider>;
}
export function useDashboard() {
const context = useContext(DashboardContext);
if (!context) throw new Error("useDashboard must be used inside DashboardProvider");
return context;
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Activity, ArrowUpRight, ChevronRight, Clipboard, Code2, Copy, Database, ExternalLink, Filter, HardDrive, Pencil, Plus, RefreshCw, Search, ShieldCheck, UploadCloud, XCircle, type LucideIcon } from "lucide-react";
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
import { API_BASE, flattenApps, flattenReleases, formatBytes, formatDate, formatDateTime, statusLabel, statusTone, visibilityLabel, visibilityTone, type DashboardData, type DashboardSource, type Visibility } from "@/app/lib/dashboard";
import { useDashboard } from "./dashboard-provider";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogForm, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type ViewName = "overview" | "sources" | "apps" | "releases";
function PageHeading({ eyebrow, title, description, actions }: { eyebrow?: string; title: string; description: string; actions?: ReactNode }) {
return <div className="admin-page-heading"><div><div className="admin-eyebrow">{eyebrow || "ALTDock WORKSPACE"}</div><h1>{title}</h1><p>{description}</p></div><div className="admin-page-actions">{actions}</div></div>;
}
function MetricCard({ label, value, detail, icon: Icon, tone = "neutral" }: { label: string; value: string | number; detail: string; icon: LucideIcon; tone?: "neutral" | "orange" | "blue" | "green" }) {
return <article className={`admin-metric-card metric-${tone}`}><div className="admin-metric-top"><span>{label}</span><Icon size={16} strokeWidth={1.8} /></div><strong>{value}</strong><small>{detail}</small></article>;
}
function SectionCard({ title, description, action, children, className = "" }: { title: string; description?: string; action?: ReactNode; children: ReactNode; className?: string }) {
return <section className={`admin-section-card ${className}`}><div className="admin-section-card-header"><div><h2>{title}</h2>{description && <p>{description}</p>}</div>{action}</div>{children}</section>;
}
function EmptyState({ icon: Icon, title, description, action }: { icon: LucideIcon; title: string; description: string; action?: ReactNode }) {
return <div className="admin-empty-state"><span className="admin-empty-icon"><Icon size={20} /></span><strong>{title}</strong><p>{description}</p>{action}</div>;
}
function LoadingState() {
return <div className="admin-loading-state"><span className="admin-spinner" /><strong>Workspaceを読み込んでいます</strong><p>APIから最新の配布状況を取得中です</p></div>;
}
function InlineStatus({ status }: { status: ReleaseRecord["status"] }) {
return <Badge tone={statusTone(status)}><span className="admin-status-dot-small" />{statusLabel(status)}</Badge>;
}
function InlineVisibility({ visibility }: { visibility: Visibility }) {
return <Badge tone={visibilityTone(visibility)}><span className="admin-status-dot-small" />{visibilityLabel(visibility)}</Badge>;
}
function SourceDialog({ open, onOpenChange, source, busy, onSubmit }: { open: boolean; onOpenChange: (open: boolean) => void; source?: SourceRecord; busy: boolean; onSubmit: (input: { name: string; subtitle: string; description: string; visibility: Visibility }) => Promise<void> }) {
const [form, setForm] = useState({ name: source?.name || "", subtitle: source?.subtitle || "", description: source?.description || "", visibility: source?.visibility || "draft" as Visibility });
const submit = async (event: FormEvent) => { event.preventDefault(); await onSubmit(form); onOpenChange(false); };
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><div className="dialog-kicker">{source ? "SOURCE SETTINGS" : "NEW SOURCE"}</div><DialogTitle>{source ? "Sourceを編集" : "Sourceを作成"}</DialogTitle><DialogDescription>AltStore PALに表示する配布面の基本情報を設定します</DialogDescription></DialogHeader><DialogForm onSubmit={submit}><label className="admin-field">Source名<input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="My Apps" /></label><label className="admin-field">Subtitle<input value={form.subtitle} onChange={(event) => setForm({ ...form, subtitle: event.target.value })} placeholder="iOSアプリの配布ページ" /></label><label className="admin-field"><textarea rows={4} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="このSourceについて説明します。" /></label><label className="admin-field"><select value={form.visibility} onChange={(event) => setForm({ ...form, visibility: event.target.value as Visibility })}><option value="draft"> </option><option value="unlisted"> URLを知っている人だけ</option><option value="public"> </option></select></label><DialogFooter><Button type="button" variant="ghost" onClick={() => onOpenChange(false)}></Button><Button disabled={busy}>{busy ? "保存中…" : source ? "変更を保存" : "Sourceを作成"}</Button></DialogFooter></DialogForm></DialogContent></Dialog>;
}
function UploadDialog({ open, onOpenChange, sources, busy, maxUploadBytes, onSubmit }: { open: boolean; onOpenChange: (open: boolean) => void; sources: DashboardSource[]; busy: boolean; maxUploadBytes: number; onSubmit: (sourceId: string, file: File, appId?: string) => Promise<void> }) {
const firstSource = sources[0];
const [sourceId, setSourceId] = useState(firstSource?.source.id || "");
const [appId, setAppId] = useState("");
const [file, setFile] = useState<File | null>(null);
const selectedSource = sources.find((entry) => entry.source.id === sourceId) || firstSource;
const submit = async (event: FormEvent) => { event.preventDefault(); if (!selectedSource || !file) return; await onSubmit(selectedSource.source.id, file, appId || undefined); setFile(null); setAppId(""); onOpenChange(false); };
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><div className="dialog-kicker">NEW RELEASE</div><DialogTitle>ADPをアップロード</DialogTitle><DialogDescription>Notarization済みのADP ZIPをそのまま選択してくださいManifestとsignatureは書き換えません</DialogDescription></DialogHeader><DialogForm onSubmit={submit}><label className="admin-field">Source<select value={sourceId} onChange={(event) => { setSourceId(event.target.value); setAppId(""); }}>{sources.map((entry) => <option key={entry.source.id} value={entry.source.id}>{entry.source.name}</option>)}</select></label>{selectedSource && selectedSource.apps.length > 0 && <label className="admin-field"><span className="admin-field-hint">Manifestから新しいアプリを作成</span><select value={appId} onChange={(event) => setAppId(event.target.value)}><option value=""></option>{selectedSource.apps.map((app) => <option key={app.id} value={app.id}>{app.name} · {app.bundleIdentifier}</option>)}</select></label>}<div className="admin-dropzone"><input id="adp-file-new" type="file" accept=".zip,application/zip" onChange={(event) => setFile(event.target.files?.[0] || null)} /><label htmlFor="adp-file-new"><span className="admin-dropzone-icon"><UploadCloud size={22} /></span><strong>{file?.name || "ADP ZIPを選択"}</strong><small>{file ? formatBytes(file.size) : `最大 ${formatBytes(maxUploadBytes)} · manifest.json + signature required`}</small></label></div><div className="admin-info-callout"><ShieldCheck size={16} /><span>ZIP爆弾Workerで検査します</span></div><DialogFooter><Button type="button" variant="ghost" onClick={() => onOpenChange(false)}></Button><Button disabled={!file || !selectedSource || busy}>{busy ? "処理中…" : "アップロードして検証"}</Button></DialogFooter></DialogForm></DialogContent></Dialog>;
}
function AppDialog({ open, onOpenChange, app, busy, onSubmit }: { open: boolean; onOpenChange: (open: boolean) => void; app: AppRecord | null; busy: boolean; onSubmit: (id: string, patch: Partial<AppRecord>) => Promise<void> }) {
const [form, setForm] = useState({ name: app?.name || "", developerName: app?.developerName || "", subtitle: app?.subtitle || "", localizedDescription: app?.localizedDescription || "", iconURL: app?.iconURL || "", category: app?.category || "other", entitlements: app?.appPermissions?.entitlements.join("\n") || "", privacy: JSON.stringify(app?.appPermissions?.privacy || {}, null, 2) });
const [error, setError] = useState("");
if (!app) return null;
const submit = async (event: FormEvent) => { event.preventDefault(); setError(""); try { const privacy = JSON.parse(form.privacy) as Record<string, string>; await onSubmit(app.id, { name: form.name, developerName: form.developerName, subtitle: form.subtitle, localizedDescription: form.localizedDescription, iconURL: form.iconURL || undefined, category: form.category, appPermissions: { entitlements: form.entitlements.split("\n").map((value) => value.trim()).filter(Boolean), privacy } }); onOpenChange(false); } catch (reason) { setError(reason instanceof Error ? reason.message : "JSON形式を確認してください。"); } };
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="wide-dialog"><DialogHeader><div className="dialog-kicker">APP METADATA</div><DialogTitle></DialogTitle><DialogDescription>Source JSONに出力する表示情報と権限情報を確認します</DialogDescription></DialogHeader><DialogForm onSubmit={submit}><div className="admin-form-grid"><label className="admin-field"><input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></label><label className="admin-field">Developer<input value={form.developerName} onChange={(event) => setForm({ ...form, developerName: event.target.value })} /></label></div><div className="admin-form-grid"><label className="admin-field">Subtitle<input value={form.subtitle} onChange={(event) => setForm({ ...form, subtitle: event.target.value })} /></label><label className="admin-field"><select value={form.category} onChange={(event) => setForm({ ...form, category: event.target.value })}><option value="developer">Developer</option><option value="entertainment">Entertainment</option><option value="games">Games</option><option value="lifestyle">Lifestyle</option><option value="other">Other</option><option value="photo-video">Photo & Video</option><option value="social">Social</option><option value="utilities">Utilities</option></select></label></div><label className="admin-field"><textarea rows={4} value={form.localizedDescription} onChange={(event) => setForm({ ...form, localizedDescription: event.target.value })} /></label><label className="admin-field">Icon URL<input type="url" value={form.iconURL} onChange={(event) => setForm({ ...form, iconURL: event.target.value })} placeholder="https://…" /></label><div className="admin-form-grid"><label className="admin-field">Bundle ID<input readOnly value={app.bundleIdentifier} /></label><label className="admin-field">Apple Item ID<input readOnly value={app.marketplaceID || "—"} /></label></div><label className="admin-field">Entitlements<span className="admin-field-hint">11</span><textarea rows={3} value={form.entitlements} onChange={(event) => setForm({ ...form, entitlements: event.target.value })} /></label><label className="admin-field">Privacy UsageDescription JSON<textarea rows={4} value={form.privacy} onChange={(event) => setForm({ ...form, privacy: event.target.value })} /></label>{error && <div className="admin-form-error">{error}</div>}<DialogFooter><Button type="button" variant="ghost" onClick={() => onOpenChange(false)}></Button><Button disabled={busy}>{busy ? "保存中…" : "変更を保存"}</Button></DialogFooter></DialogForm></DialogContent></Dialog>;
}
function JsonPreviewDialog({ source, open, onOpenChange }: { source: SourceRecord | null; open: boolean; onOpenChange: (open: boolean) => void }) {
const [payload, setPayload] = useState<string>("");
const [error, setError] = useState("");
useEffect(() => {
if (!open || !source) return;
setPayload(""); setError("");
fetch(`${API_BASE}/sources/${source.slug}/source.json`).then(async (response) => { if (!response.ok) throw new Error("公開済みSourceだけがJSONを取得できます。"); return response.json(); }).then((value) => setPayload(JSON.stringify(value, null, 2))).catch((reason) => setError(reason instanceof Error ? reason.message : "Source JSONを取得できませんでした。"));
}, [open, source]);
if (!source) return null;
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="wide-dialog"><DialogHeader><div className="dialog-kicker">SOURCE JSON</div><DialogTitle>{source.name}</DialogTitle><DialogDescription>{source.visibility === "draft" ? "下書きのため匿名Source JSONはまだ取得できません。" : `${API_BASE}/sources/${source.slug}/source.json`}</DialogDescription></DialogHeader><Tabs defaultValue="summary"><TabsList><TabsTrigger value="summary"></TabsTrigger><TabsTrigger value="json">JSONプレビュー</TabsTrigger></TabsList><TabsContent value="summary"><div className="json-summary-grid"><div><span></span><strong><InlineVisibility visibility={source.visibility} /></strong></div><div><span>Source slug</span><code>{source.slug}</code></div><div><span>URL</span><code>{`${API_BASE}/sources/${source.slug}/source.json`}</code></div></div></TabsContent><TabsContent value="json">{error ? <div className="admin-form-error">{error}</div> : payload ? <pre className="json-preview">{payload}</pre> : <div className="admin-loading-inline"><span className="admin-spinner" />JSONを取得しています</div>}</TabsContent></Tabs><DialogFooter><Button variant="ghost" onClick={() => onOpenChange(false)}></Button></DialogFooter></DialogContent></Dialog>;
}
function ReleaseDetailsDialog({ item, open, onOpenChange }: { item: { release: ReleaseRecord; app?: AppRecord; source: SourceRecord } | null; open: boolean; onOpenChange: (open: boolean) => void }) {
if (!item) return null;
const { release, app, source } = item;
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="wide-dialog"><DialogHeader><div className="dialog-kicker">RELEASE DETAILS</div><DialogTitle>{app?.name || "アプリリリース"} <span className="admin-title-muted">v{release.version}</span></DialogTitle><DialogDescription>{source.name} · build {release.buildVersion} · {formatDateTime(release.date)}</DialogDescription></DialogHeader><div className="release-detail-grid"><div><span></span><strong><InlineStatus status={release.status} /></strong></div><div><span></span><strong>{formatBytes(release.sizeBytes)}</strong></div><div><span>Bundle ID</span><code>{release.manifest.bundleId}</code></div><div><span>Apple Item ID</span><code>{release.manifest.appleItemId}</code></div><div><span>OS</span><strong>{release.minOSVersion || "—"}</strong></div><div><span></span><strong>{release.publishedAt ? formatDateTime(release.publishedAt) : "未公開"}</strong></div></div>{release.errorMessage && <div className="admin-form-error">{release.errorMessage}</div>}<div className="asset-path-panel"><h3>Manifest参照パス</h3><p>Manifestに記載されたassetPath / sourcePath / deltaPathを確認できます</p>{release.manifest.variantPaths.length || release.manifest.deltaPaths.length ? <ul>{[...release.manifest.variantPaths.map((path) => `variant · ${path}`), ...release.manifest.deltaPaths.map((path) => `delta · ${path}`)].map((path) => <li key={path}><code>{path}</code></li>)}</ul> : <div className="admin-table-empty"></div>}</div><DialogFooter><Button variant="ghost" onClick={() => onOpenChange(false)}></Button></DialogFooter></DialogContent></Dialog>;
}
function Overview({ dashboard, onCreateSource, onUpload, onOpenSource, onOpenRelease }: { dashboard: DashboardData; onCreateSource: () => void; onUpload: () => void; onOpenSource: (source: SourceRecord) => void; onOpenRelease: (item: { release: ReleaseRecord; app?: AppRecord; source: SourceRecord }) => void }) {
const flattened = flattenReleases(dashboard).sort((a, b) => b.release.date.localeCompare(a.release.date));
const appCount = flattenApps(dashboard).length;
const published = flattened.filter((item) => item.release.status === "published").length;
const processing = flattened.filter((item) => item.release.status === "processing").length;
const needsAttention = flattened.filter((item) => item.release.status === "rejected").length;
const usagePercent = Math.min(100, dashboard.usage.storageBytes / Math.max(1, dashboard.limits.maxStorageBytes) * 100);
return <>
<PageHeading eyebrow="WORKSPACE OVERVIEW" title="概要" description="Source、アプリ、リリースの現在の状態を確認します。" actions={<><Button variant="outline" onClick={onUpload}><UploadCloud size={15} />ADPをアップロード</Button><Button onClick={onCreateSource}><Plus size={15} />Sourceを作成</Button></>} />
<div className="admin-metric-grid"><MetricCard label="SOURCES" value={dashboard.usage.sourceCount} detail={`${dashboard.limits.maxSources}件中`} icon={Database} tone="orange" /><MetricCard label="アプリ" value={appCount} detail={`${published}件の公開リリース`} icon={Code2} tone="blue" /><MetricCard label="保存容量" value={formatBytes(dashboard.usage.storageBytes)} detail={`${formatBytes(dashboard.limits.maxStorageBytes)}まで`} icon={HardDrive} tone="green" /><MetricCard label="要確認" value={needsAttention} detail={processing ? `${processing}件を処理中` : "現在なし"} icon={Activity} tone={needsAttention ? "orange" : "neutral"} /></div>
<div className="admin-overview-grid"><SectionCard title="配布状態" description="Workspace全体のリリース状態"><div className="health-list"><div><span className="health-label"><i className="health-dot success" /></span><strong>{published}</strong></div><div><span className="health-label"><i className="health-dot info" /></span><strong>{processing}</strong></div><div><span className="health-label"><i className="health-dot warning" /></span><strong>{flattened.filter((item) => item.release.status === "ready").length}</strong></div><div><span className="health-label"><i className="health-dot danger" /></span><strong>{needsAttention}</strong></div></div><div className="quota-summary"><div><span>Storage quota</span><strong>{usagePercent.toFixed(0)}%</strong></div><div className="admin-progress"><span style={{ width: `${usagePercent}%` }} /></div></div></SectionCard><SectionCard title="クイック操作" description="よく使う管理操作"><button className="admin-action-row" onClick={onUpload}><span className="admin-action-icon orange"><UploadCloud size={17} /></span><span><strong>ADPをアップロード</strong><small>Manifestを検証してリリースを作成</small></span><ChevronRight size={16} /></button><button className="admin-action-row" onClick={onCreateSource}><span className="admin-action-icon blue"><Plus size={17} /></span><span><strong>Sourceを作成</strong><small>PALへ公開する配布面を追加</small></span><ChevronRight size={16} /></button><a className="admin-action-row" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer"><span className="admin-action-icon neutral"><Clipboard size={17} /></span><span><strong>PALの配布要件を確認</strong><small>AltStore公式ドキュメント</small></span><ExternalLink size={15} /></a></SectionCard></div>
<SectionCard title="Sources" description="公開面ごとの状態とアプリ数" action={<a className="admin-link" href="/dashboard/sources"> <ArrowUpRight size={14} /></a>}><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Source</th><th></th><th></th><th></th><th /></tr></thead><tbody>{dashboard.sources.length ? dashboard.sources.map((entry) => <tr key={entry.source.id}><td><button className="table-primary-link" onClick={() => onOpenSource(entry.source)}><span className="table-avatar" style={{ background: entry.source.tintColor }}>{entry.source.name.slice(0, 1).toUpperCase()}</span><span><strong>{entry.source.name}</strong><small>{entry.source.slug}</small></span></button></td><td><InlineVisibility visibility={entry.source.visibility} /></td><td>{entry.apps.length}</td><td>{formatDate(entry.source.updatedAt)}</td><td><ChevronRight size={15} className="table-chevron" /></td></tr>) : <tr><td colSpan={5}><EmptyState icon={Boxes} title="Sourceがありません" description="最初の配布面を作成してください。" action={<Button size="sm" onClick={onCreateSource}>Sourceを作成</Button>} /></td></tr>}</tbody></table></div></SectionCard>
<SectionCard title="最近のリリース" description="最新5件の処理結果" action={<a className="admin-link" href="/dashboard/releases"> <ArrowUpRight size={14} /></a>}><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th> / Version</th><th>Source</th><th></th><th></th><th></th></tr></thead><tbody>{flattened.slice(0, 5).map((item) => <tr key={item.release.id} onClick={() => onOpenRelease(item)} className="admin-clickable-row"><td><span className="table-primary-link"><span className="table-avatar table-avatar-small" style={{ background: item.app?.tintColor || item.source.tintColor }}>{item.app?.name.slice(0, 1).toUpperCase() || "A"}</span><span><strong>{item.app?.name || "App release"}</strong><small>v{item.release.version} · build {item.release.buildVersion}</small></span></span></td><td>{item.source.name}</td><td><InlineStatus status={item.release.status} /></td><td>{formatBytes(item.release.sizeBytes)}</td><td>{formatDate(item.release.date)}</td></tr>)}{!flattened.length && <tr><td colSpan={5}><div className="admin-table-empty"></div></td></tr>}</tbody></table></div></SectionCard>
</>;
}
function SourcesView({ dashboard, onCreateSource, onEditSource, onOpenJson, onCopyUrl }: { dashboard: DashboardData; onCreateSource: () => void; onEditSource: (source: SourceRecord) => void; onOpenJson: (source: SourceRecord) => void; onCopyUrl: (source: SourceRecord) => void }) {
const [query, setQuery] = useState("");
const rows = dashboard.sources.filter((entry) => `${entry.source.name} ${entry.source.slug} ${entry.source.subtitle}`.toLowerCase().includes(query.toLowerCase()));
return <><PageHeading eyebrow="DISTRIBUTION SURFACES" title="Sources" description="AltStore PALへ公開する配布面を管理します。" actions={<Button onClick={onCreateSource}><Plus size={15} />Sourceを作成</Button>} /><SectionCard title="Source一覧" description={`${rows.length} / ${dashboard.sources.length}`}><div className="admin-toolbar"><label className="admin-search-field"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Source名・slugを検索" /></label></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Source</th><th></th><th></th><th></th><th></th></tr></thead><tbody>{rows.map((entry) => <tr key={entry.source.id}><td><div className="table-primary-link"><span className="table-avatar" style={{ background: entry.source.tintColor }}>{entry.source.name.slice(0, 1).toUpperCase()}</span><span><strong>{entry.source.name}</strong><small>{entry.source.subtitle || entry.source.slug}</small></span></div></td><td><InlineVisibility visibility={entry.source.visibility} /></td><td>{entry.apps.length}</td><td>{formatDate(entry.source.updatedAt)}</td><td><div className="table-actions"><Button size="icon" variant="ghost" onClick={() => onOpenJson(entry.source)} aria-label={`${entry.source.name}のJSONをプレビュー`}><Code2 size={15} /></Button><Button size="icon" variant="ghost" onClick={() => onCopyUrl(entry.source)} aria-label={`${entry.source.name}のURLをコピー`}><Copy size={15} /></Button><Button size="icon" variant="ghost" onClick={() => onEditSource(entry.source)} aria-label={`${entry.source.name}を編集`}><Pencil size={15} /></Button></div></td></tr>)}{!rows.length && <tr><td colSpan={5}><div className="admin-table-empty">Sourceがありません</div></td></tr>}</tbody></table></div></SectionCard></>;
}
function AppsView({ dashboard, onEditApp }: { dashboard: DashboardData; onEditApp: (app: AppRecord) => void }) {
const [query, setQuery] = useState("");
const rows = flattenApps(dashboard).filter(({ app, source }) => `${app.name} ${app.bundleIdentifier} ${source.name}`.toLowerCase().includes(query.toLowerCase()));
return <><PageHeading eyebrow="APP CATALOG" title="アプリ" description="Manifestから抽出した識別子と、Sourceに表示するメタデータを管理します。" /><SectionCard title="アプリ一覧" description={`${rows.length}`}><div className="admin-toolbar"><label className="admin-search-field"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="アプリ名・Bundle IDを検索" /></label></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th></th><th>Bundle ID</th><th>Source</th><th></th><th></th><th></th></tr></thead><tbody>{rows.map(({ app, source, releases }) => { const latest = releases.slice().sort((a, b) => b.date.localeCompare(a.date))[0]; return <tr key={app.id}><td><div className="table-primary-link"><span className="table-avatar" style={{ background: app.tintColor || source.tintColor }}>{app.name.slice(0, 1).toUpperCase()}</span><span><strong>{app.name}</strong><small>{app.developerName || "Developer未設定"}</small></span></div></td><td><code className="table-code">{app.bundleIdentifier}</code></td><td>{source.name}</td><td>{latest ? <span className="version-cell"><strong>v{latest.version}</strong><small>build {latest.buildVersion}</small></span> : "—"}</td><td><span className="category-label">{app.category}</span></td><td><Button size="icon" variant="ghost" onClick={() => onEditApp(app)} aria-label={`${app.name}を編集`}><Pencil size={15} /></Button></td></tr>; })}{!rows.length && <tr><td colSpan={6}><EmptyState icon={Code2} title="アプリがありません" description="ADPをアップロードするとManifestからアプリが作成されます。" /></td></tr>}</tbody></table></div></SectionCard></>;
}
function ReleasesView({ dashboard, onUpload, onOpenRelease, onPublish }: { dashboard: DashboardData; onUpload: () => void; onOpenRelease: (item: { release: ReleaseRecord; app?: AppRecord; source: SourceRecord }) => void; onPublish: (id: string) => Promise<void> }) {
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const rows = flattenReleases(dashboard).filter(({ release, app, source }) => { const matchesQuery = `${app?.name || ""} ${source.name} ${release.version} ${release.buildVersion} ${release.manifest.bundleId}`.toLowerCase().includes(query.toLowerCase()); return matchesQuery && (statusFilter === "all" || release.status === statusFilter); }).sort((a, b) => b.release.date.localeCompare(a.release.date));
return <><PageHeading eyebrow="PACKAGE PIPELINE" title="リリース" description="ADPの検証結果、Manifest参照パス、公開状態を確認します。" actions={<Button onClick={onUpload}><UploadCloud size={15} />ADPをアップロード</Button>} /><SectionCard title="リリース一覧" description={`${rows.length}`}><div className="admin-toolbar"><label className="admin-search-field"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="アプリ名・version・Bundle IDを検索" /></label><label className="admin-filter-field"><Filter size={14} /><select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}><option value="all"></option><option value="processing"></option><option value="ready"></option><option value="published"></option><option value="rejected"></option><option value="archived"></option></select></label><Button variant="outline" size="sm" onClick={() => window.location.reload()}><RefreshCw size={14} /></Button></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th> / Version</th><th>Source</th><th></th><th>Manifest</th><th></th><th></th><th /></tr></thead><tbody>{rows.map((item) => <tr key={item.release.id} onClick={() => onOpenRelease(item)} className="admin-clickable-row"><td><div className="table-primary-link"><span className="table-avatar table-avatar-small" style={{ background: item.app?.tintColor || item.source.tintColor }}>{item.app?.name.slice(0, 1).toUpperCase() || "A"}</span><span><strong>{item.app?.name || "App release"}</strong><small>v{item.release.version} · build {item.release.buildVersion}</small></span></div></td><td>{item.source.name}</td><td><InlineStatus status={item.release.status} /></td><td><code className="table-code">{item.release.manifest.bundleId}</code><small className="table-subline">{item.release.manifest.variantPaths.length} variant · {item.release.manifest.deltaPaths.length} delta</small></td><td>{formatBytes(item.release.sizeBytes)}</td><td>{formatDate(item.release.date)}</td><td>{item.release.status === "ready" ? <Button size="sm" onClick={(event) => { event.stopPropagation(); void onPublish(item.release.id); }}></Button> : <ChevronRight size={15} className="table-chevron" />}</td></tr>)}{!rows.length && <tr><td colSpan={7}><div className="admin-table-empty"></div></td></tr>}</tbody></table></div></SectionCard></>;
}
export function DashboardView({ view }: { view: ViewName }) {
const { dashboard, loading, busy, createSource, updateSource, uploadRelease, saveApp, publishRelease, copySourceUrl } = useDashboard();
const router = useRouter();
const searchParams = useSearchParams();
const [sourceDialogOpen, setSourceDialogOpen] = useState(false);
const [editingSource, setEditingSource] = useState<SourceRecord | undefined>();
const [uploadOpen, setUploadOpen] = useState(false);
const [editingApp, setEditingApp] = useState<AppRecord | null>(null);
const [jsonSource, setJsonSource] = useState<SourceRecord | null>(null);
const [releaseItem, setReleaseItem] = useState<{ release: ReleaseRecord; app?: AppRecord; source: SourceRecord } | null>(null);
useEffect(() => {
if (view === "releases" && searchParams.get("upload") === "1") {
setUploadOpen(true);
router.replace("/dashboard/releases");
}
}, [router, searchParams, view]);
if (loading && !dashboard) return <LoadingState />;
if (!dashboard) return <div className="admin-error-state"><XCircle size={22} /><strong>Workspaceを読み込めませんでした</strong><p>APIが起動しているか確認して</p></div>;
const openCreateSource = () => { setEditingSource(undefined); setSourceDialogOpen(true); };
const openEditSource = (source: SourceRecord) => { setEditingSource(source); setSourceDialogOpen(true); };
const submitSource = async (input: { name: string; subtitle: string; description: string; visibility: Visibility }) => { if (editingSource) await updateSource(editingSource.id, input); else await createSource(input); };
const handleUpload = async (sourceId: string, file: File, appId?: string) => { await uploadRelease(sourceId, file, appId); };
const handleAppSave = async (id: string, patch: Partial<AppRecord>) => { await saveApp(id, { name: patch.name, developerName: patch.developerName, subtitle: patch.subtitle, localizedDescription: patch.localizedDescription, iconURL: patch.iconURL, category: patch.category, appPermissions: patch.appPermissions }); };
return <>
{view === "overview" && <Overview dashboard={dashboard} onCreateSource={openCreateSource} onUpload={() => setUploadOpen(true)} onOpenSource={openEditSource} onOpenRelease={setReleaseItem} />}
{view === "sources" && <SourcesView dashboard={dashboard} onCreateSource={openCreateSource} onEditSource={openEditSource} onOpenJson={setJsonSource} onCopyUrl={(source) => void copySourceUrl(source)} />}
{view === "apps" && <AppsView dashboard={dashboard} onEditApp={setEditingApp} />}
{view === "releases" && <ReleasesView dashboard={dashboard} onUpload={() => setUploadOpen(true)} onOpenRelease={setReleaseItem} onPublish={publishRelease} />}
<SourceDialog key={editingSource?.id || "new-source"} open={sourceDialogOpen} onOpenChange={setSourceDialogOpen} source={editingSource} busy={busy} onSubmit={submitSource} />
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} sources={dashboard.sources} busy={busy} maxUploadBytes={dashboard.limits.maxUploadBytes} onSubmit={handleUpload} />
<AppDialog key={editingApp?.id || "app-editor"} open={Boolean(editingApp)} onOpenChange={(open) => { if (!open) setEditingApp(null); }} app={editingApp} busy={busy} onSubmit={handleAppSave} />
<JsonPreviewDialog source={jsonSource} open={Boolean(jsonSource)} onOpenChange={(open) => { if (!open) setJsonSource(null); }} />
<ReleaseDetailsDialog item={releaseItem} open={Boolean(releaseItem)} onOpenChange={(open) => { if (!open) setReleaseItem(null); }} />
</>;
}
+7
View File
@@ -0,0 +1,7 @@
import type { HTMLAttributes } from "react";
import { cn } from "./utils";
export function Badge({ className, tone = "neutral", ...props }: HTMLAttributes<HTMLSpanElement> & { tone?: "neutral" | "info" | "warning" | "danger" | "success" }) {
return <span className={cn("ui-badge", `ui-badge-${tone}`, className)} {...props} />;
}
+9
View File
@@ -0,0 +1,9 @@
import type { ButtonHTMLAttributes } from "react";
import { cn } from "./utils";
type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
export function Button({ className, variant = "primary", size = "default", ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; size?: "sm" | "default" | "icon" }) {
return <button className={cn("ui-button", `ui-button-${variant}`, `ui-button-${size}`, className)} {...props} />;
}
+28
View File
@@ -0,0 +1,28 @@
import { Command as CommandPrimitive } from "cmdk";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "./utils";
export function Command({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive>) {
return <CommandPrimitive className={cn("ui-command", className)} {...props} />;
}
export function CommandInput({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Input>) {
return <CommandPrimitive.Input className={cn("ui-command-input", className)} {...props} />;
}
export function CommandList({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.List>) {
return <CommandPrimitive.List className={cn("ui-command-list", className)} {...props} />;
}
export function CommandEmpty({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty className={cn("ui-command-empty", className)} {...props} />;
}
export function CommandGroup({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Group>) {
return <CommandPrimitive.Group className={cn("ui-command-group", className)} {...props} />;
}
export function CommandItem({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Item>) {
return <CommandPrimitive.Item className={cn("ui-command-item", className)} {...props} />;
}
+33
View File
@@ -0,0 +1,33 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import type { ComponentPropsWithoutRef, PropsWithChildren } from "react";
import { cn } from "./utils";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
export function DialogContent({ className, children, ...props }: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
return <DialogPrimitive.Portal><DialogPrimitive.Overlay className="ui-dialog-overlay" /><DialogPrimitive.Content className={cn("ui-dialog-content", className)} {...props}>{children}<DialogPrimitive.Close className="ui-dialog-close" aria-label="閉じる"><X size={16} /></DialogPrimitive.Close></DialogPrimitive.Content></DialogPrimitive.Portal>;
}
export function DialogHeader({ className, ...props }: ComponentPropsWithoutRef<"div">) {
return <div className={cn("ui-dialog-header", className)} {...props} />;
}
export function DialogTitle({ className, ...props }: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
return <DialogPrimitive.Title className={cn("ui-dialog-title", className)} {...props} />;
}
export function DialogDescription({ className, ...props }: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
return <DialogPrimitive.Description className={cn("ui-dialog-description", className)} {...props} />;
}
export function DialogFooter({ className, ...props }: ComponentPropsWithoutRef<"div">) {
return <div className={cn("ui-dialog-footer", className)} {...props} />;
}
export function DialogForm({ children, ...props }: PropsWithChildren<ComponentPropsWithoutRef<"form">>) {
return <form className="ui-dialog-form" {...props}>{children}</form>;
}
+19
View File
@@ -0,0 +1,19 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "./utils";
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export function DropdownMenuContent({ className, sideOffset = 6, ...props }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>) {
return <DropdownMenuPrimitive.Portal><DropdownMenuPrimitive.Content sideOffset={sideOffset} className={cn("ui-dropdown-content", className)} {...props} /></DropdownMenuPrimitive.Portal>;
}
export function DropdownMenuItem({ className, ...props }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>) {
return <DropdownMenuPrimitive.Item className={cn("ui-dropdown-item", className)} {...props} />;
}
export function DropdownMenuSeparator({ className, ...props }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>) {
return <DropdownMenuPrimitive.Separator className={cn("ui-dropdown-separator", className)} {...props} />;
}
+18
View File
@@ -0,0 +1,18 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "./utils";
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {
return <TabsPrimitive.List className={cn("ui-tabs-list", className)} {...props} />;
}
export function TabsTrigger({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {
return <TabsPrimitive.Trigger className={cn("ui-tabs-trigger", className)} {...props} />;
}
export function TabsContent({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content className={cn("ui-tabs-content", className)} {...props} />;
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+105
View File
@@ -0,0 +1,105 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: altdock
POSTGRES_USER: altdock
POSTGRES_PASSWORD: altdock
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U altdock -d altdock"]
interval: 5s
timeout: 5s
retries: 10
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio-data:/data
minio-init:
image: minio/mc:latest
depends_on:
- minio
entrypoint: ["/bin/sh", "-c"]
command: >-
"until mc alias set local http://minio:9000 minioadmin minioadmin; do sleep 1; done;
mc mb --ignore-existing local/altdock"
api:
image: node:22-bookworm
working_dir: /workspace
command: sh -c "npm install --no-audit --no-fund && npm run dev:api"
environment:
NODE_ENV: development
PORT: 4000
PUBLIC_BASE_URL: http://localhost:4000
WEB_ORIGIN: http://localhost:3000
DATABASE_URL: postgres://altdock:altdock@postgres:5432/altdock
STORAGE_MODE: s3
S3_ENDPOINT: http://minio:9000
S3_REGION: us-east-1
S3_BUCKET: altdock
S3_ACCESS_KEY_ID: minioadmin
S3_SECRET_ACCESS_KEY: minioadmin
S3_FORCE_PATH_STYLE: "true"
AUTH_MODE: demo
PROCESS_INLINE: "false"
ports:
- "4000:4000"
volumes:
- .:/workspace
depends_on:
postgres:
condition: service_healthy
minio-init:
condition: service_completed_successfully
worker:
image: node:22-bookworm
working_dir: /workspace
command: sh -c "npm install --no-audit --no-fund && npm run dev:worker"
environment:
NODE_ENV: development
DATABASE_URL: postgres://altdock:altdock@postgres:5432/altdock
STORAGE_MODE: s3
S3_ENDPOINT: http://minio:9000
S3_REGION: us-east-1
S3_BUCKET: altdock
S3_ACCESS_KEY_ID: minioadmin
S3_SECRET_ACCESS_KEY: minioadmin
S3_FORCE_PATH_STYLE: "true"
AUTH_MODE: demo
PROCESS_INLINE: "false"
volumes:
- .:/workspace
depends_on:
api:
condition: service_started
web:
image: node:22-bookworm
working_dir: /workspace
command: sh -c "npm install --no-audit --no-fund && npm run dev -- --host 0.0.0.0"
environment:
NEXT_PUBLIC_API_BASE_URL: http://localhost:4000
ports:
- "3000:3000"
volumes:
- .:/workspace
depends_on:
- api
volumes:
postgres-data:
minio-data:
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+12538
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
{
"name": "altdock",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
"dev:api": "tsx apps/api/src/server.ts",
"dev:worker": "tsx apps/worker/src/worker.ts",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"build:api": "tsc -p apps/api/tsconfig.json",
"build:worker": "tsc -p apps/worker/tsconfig.json",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
"start:api": "node dist/apps/api/src/server.js",
"start:worker": "node dist/apps/worker/src/worker.js",
"test": "npm run build && npm run build:api && npm run build:worker && node --import tsx --test tests/*.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:migrate": "tsx apps/api/src/migrate.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1101.0",
"@aws-sdk/s3-request-presigner": "^3.1101.0",
"@fastify/cookie": "^11.1.2",
"@fastify/cors": "^11.3.0",
"@fastify/multipart": "^10.1.0",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toast": "^1.2.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"fastify": "^5.11.0",
"jose": "^6.2.7",
"lucide-react": "^1.28.0",
"next": "16.2.6",
"pg": "^8.22.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"tailwind-merge": "^3.6.0",
"unzipper": "^0.12.5",
"zod": "^4.4.3"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.37.1",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
"@types/pg": "^8.20.3",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/unzipper": "^0.10.11",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"eslint": "9.39.4",
"eslint-config-next": "16.2.6",
"react-server-dom-webpack": "19.2.6",
"tailwindcss": "4.2.1",
"tsx": "^4.23.4",
"typescript": "5.9.3",
"vinext": "0.0.50",
"vite": "8.0.13",
"wrangler": "4.92.0"
},
"type": "module"
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./source";
export * from "./types";
+102
View File
@@ -0,0 +1,102 @@
import type {
AppRecord,
ReleaseRecord,
SourceRecord,
} from "./types";
export interface AltStoreSourceDocument {
name: string;
subtitle?: string;
description?: string;
iconURL?: string;
headerURL?: string;
website?: string;
tintColor?: string;
featuredApps: string[];
apps: Array<{
name: string;
bundleIdentifier: string;
marketplaceID: string;
developerName: string;
subtitle?: string;
localizedDescription: string;
iconURL?: string;
tintColor?: string;
category: string;
screenshots: Array<{ imageURL: string; width?: number; height?: number }>;
versions: Array<{
version: string;
buildVersion: string;
date: string;
localizedDescription: string;
downloadURL: string;
size: number;
minOSVersion?: string;
}>;
appPermissions: {
entitlements: string[];
privacy: Record<string, string>;
};
}>;
news: [];
}
export function buildSourceDocument(
source: SourceRecord,
apps: AppRecord[],
releases: ReleaseRecord[],
publicBaseUrl: string,
): AltStoreSourceDocument {
const sourceApps = apps
.map((app) => {
const appReleases = releases
.filter(
(release) =>
release.appId === app.id && release.status === "published",
)
.sort((a, b) => {
const dateOrder = b.date.localeCompare(a.date);
if (dateOrder !== 0) return dateOrder;
return b.buildVersion.localeCompare(a.buildVersion, undefined, {
numeric: true,
});
});
return {
name: app.name,
bundleIdentifier: app.bundleIdentifier,
marketplaceID: app.marketplaceID,
developerName: app.developerName,
subtitle: app.subtitle || undefined,
localizedDescription: app.localizedDescription,
iconURL: app.iconURL,
tintColor: app.tintColor,
category: app.category,
screenshots: app.screenshots,
appPermissions: app.appPermissions,
versions: appReleases.map((release) => ({
version: release.version,
buildVersion: release.buildVersion,
date: release.date,
localizedDescription: release.localizedDescription,
downloadURL: `${publicBaseUrl.replace(/\/$/, "")}/artifacts/${release.id}/manifest.json`,
size: release.sizeBytes,
minOSVersion: release.minOSVersion,
})),
};
})
.filter((app) => app.versions.length > 0);
return {
name: source.name,
subtitle: source.subtitle || undefined,
description: source.description || undefined,
iconURL: source.iconURL || sourceApps[0]?.iconURL,
headerURL: source.headerURL,
website: source.website,
tintColor: source.tintColor,
featuredApps: sourceApps.slice(0, 5).map((app) => app.bundleIdentifier),
apps: sourceApps,
news: [],
};
}
+121
View File
@@ -0,0 +1,121 @@
export type Visibility = "draft" | "unlisted" | "public";
export type ReleaseStatus =
| "uploaded"
| "processing"
| "ready"
| "rejected"
| "published"
| "archived";
export interface WorkspaceRecord {
id: string;
name: string;
slug: string;
ownerSubject: string;
ownerEmail: string;
createdAt: string;
}
export interface SourceRecord {
id: string;
workspaceId: string;
slug: string;
name: string;
subtitle: string;
description: string;
iconURL?: string;
headerURL?: string;
website?: string;
tintColor: string;
visibility: Visibility;
createdAt: string;
updatedAt: string;
}
export interface AppMetadata {
name: string;
developerName: string;
subtitle: string;
localizedDescription: string;
iconURL?: string;
tintColor: string;
category: string;
screenshots: Array<{ imageURL: string; width?: number; height?: number }>;
appPermissions: {
entitlements: string[];
privacy: Record<string, string>;
};
}
export interface AppRecord extends AppMetadata {
id: string;
sourceId: string;
bundleIdentifier: string;
marketplaceID: string;
createdAt: string;
updatedAt: string;
}
export interface ManifestSummary {
distributionPackageRevision?: number;
appleItemId: string;
bundleId: string;
shortVersionString: string;
bundleVersion: string;
appleVersionId?: string;
platforms: string[];
minimumSystemVersions: Record<string, string>;
variantPaths: string[];
deltaPaths: string[];
}
export interface ReleaseRecord {
id: string;
appId: string;
uploadId: string;
version: string;
buildVersion: string;
appleItemId: string;
date: string;
localizedDescription: string;
minOSVersion?: string;
sizeBytes: number;
status: ReleaseStatus;
errorCode?: string;
errorMessage?: string;
manifest: ManifestSummary;
createdAt: string;
publishedAt?: string;
}
export interface UploadRecord {
id: string;
workspaceId: string;
sourceId: string;
appId?: string;
objectKey: string;
multipartUploadId?: string;
originalFilename: string;
expectedSize: number;
receivedSize: number;
status: "created" | "uploaded" | "queued" | "processing" | "completed" | "failed";
errorCode?: string;
errorMessage?: string;
createdAt: string;
}
export interface ReleaseAsset {
releaseId: string;
path: string;
objectKey: string;
sizeBytes: number;
sha256: string;
contentType: string;
}
export interface Identity {
subject: string;
email: string;
displayName: string;
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+4
View File
@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="10" fill="#172321"/>
<path d="M8 23.5L14.2 8.5H18.1L24 23.5H20.2L18.9 20H13.1L11.8 23.5H8ZM14.2 17.1H17.9L16.1 12.2L14.2 17.1Z" fill="#E9694B"/>
</svg>

After

Width:  |  Height:  |  Size: 285 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+92
View File
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import test from "node:test";
process.env.AUTH_MODE = "demo";
process.env.STORAGE_MODE = "local";
process.env.PUBLIC_BASE_URL = "http://localhost:4000";
process.env.WEB_ORIGIN = "http://localhost:3000";
process.env.LOCAL_STORAGE_DIR = ".data/test-storage";
process.env.PROCESS_INLINE = "true";
delete process.env.DATABASE_URL;
const { createServer } = await import("../apps/api/src/server.ts");
const runtime = await createServer();
const app = runtime.app;
test.after(async () => {
await app.close();
});
test("creates a source and returns a dashboard in demo mode", async () => {
const sourceResponse = await app.inject({
method: "POST",
url: "/api/v1/sources",
headers: { "x-demo-user": "test@example.com", "content-type": "application/json" },
payload: { name: "Test Source", subtitle: "Test apps", visibility: "draft" },
});
assert.equal(sourceResponse.statusCode, 201);
const source = sourceResponse.json().source;
assert.equal(source.visibility, "draft");
const dashboardResponse = await app.inject({ method: "GET", url: "/api/v1/dashboard", headers: { "x-demo-user": "test@example.com" } });
assert.equal(dashboardResponse.statusCode, 200);
const dashboard = dashboardResponse.json();
assert.ok(dashboard.sources.some((entry) => entry.source.id === source.id));
});
test("does not expose draft sources publicly", async () => {
const response = await app.inject({ method: "GET", url: "/sources/test-source/source.json" });
assert.equal(response.statusCode, 404);
});
test("validates, stores, publishes, and serves an ADP package", async () => {
const root = await mkdtemp(join(tmpdir(), "altdock-test-adp-"));
const fixtureRoot = join(root, "fixture");
await (await import("node:fs/promises")).mkdir(join(fixtureRoot, "variant"), { recursive: true });
const manifest = Buffer.from(JSON.stringify({
distributionPackageRevision: 1,
appleItemId: "123456789",
bundleId: "com.example.fixture",
shortVersionString: "1.0",
bundleVersion: "1",
platforms: ["ios"],
minimumSystemVersions: { ios: "17.4" },
variants: [{ assetPath: "variant/fixture.ipa", installTargets: [] }],
deltas: [],
}));
const signature = Buffer.from("signed-fixture");
const ipa = Buffer.from("not-an-installable-ipa-fixture");
await writeFile(join(fixtureRoot, "manifest.json"), manifest);
await writeFile(join(fixtureRoot, "signature"), signature);
await writeFile(join(fixtureRoot, "variant/fixture.ipa"), ipa);
const archivePath = join(root, "fixture.zip");
await promisify(execFile)("zip", ["-q", "-r", archivePath, "manifest.json", "signature", "variant"], { cwd: fixtureRoot });
const archive = await readFile(archivePath);
const sourceResponse = await app.inject({ method: "POST", url: "/api/v1/sources", headers: { "x-demo-user": "fixture@example.com", "content-type": "application/json" }, payload: { name: "Fixture Source", visibility: "public" } });
const source = sourceResponse.json().source;
const uploadResponse = await app.inject({ method: "POST", url: "/api/v1/uploads", headers: { "x-demo-user": "fixture@example.com", "content-type": "application/json" }, payload: { sourceId: source.id, filename: "fixture.zip", sizeBytes: archive.length } });
assert.equal(uploadResponse.statusCode, 201);
const upload = uploadResponse.json();
const putResponse = await app.inject({ method: "PUT", url: new URL(upload.uploadPlan.uploadUrl).pathname, headers: { "content-type": "application/zip" }, payload: archive });
assert.equal(putResponse.statusCode, 200, putResponse.body);
const completeResponse = await app.inject({ method: "POST", url: `/api/v1/uploads/${upload.upload.id}/complete`, headers: { "x-demo-user": "fixture@example.com", "content-type": "application/json" }, payload: {} });
assert.equal(completeResponse.statusCode, 202, completeResponse.body);
const release = completeResponse.json().release;
assert.equal(release.status, "ready");
const publishResponse = await app.inject({ method: "POST", url: `/api/v1/releases/${release.id}/publish`, headers: { "x-demo-user": "fixture@example.com" } });
assert.equal(publishResponse.statusCode, 200, publishResponse.body);
const sourceJsonResponse = await app.inject({ method: "GET", url: `/sources/${source.slug}/source.json` });
assert.equal(sourceJsonResponse.statusCode, 200, sourceJsonResponse.body);
const sourceJson = sourceJsonResponse.json();
assert.equal(sourceJson.apps[0].bundleIdentifier, "com.example.fixture");
assert.match(sourceJson.apps[0].versions[0].downloadURL, new RegExp(`/artifacts/${release.id}/manifest\\.json$`));
const artifactResponse = await app.inject({ method: "GET", url: `/artifacts/${release.id}/manifest.json` });
assert.equal(artifactResponse.statusCode, 200, artifactResponse.body);
assert.deepEqual(artifactResponse.rawPayload, manifest);
await rm(root, { recursive: true, force: true });
});
+54
View File
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildSourceDocument } from "../packages/core/src/index.ts";
test("builds a PAL source with published releases only", () => {
const source = {
id: "source-1",
workspaceId: "workspace-1",
slug: "my-apps",
name: "My Apps",
subtitle: "Small iOS experiments",
description: "A test source",
tintColor: "#E9694B",
visibility: "public",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const app = {
id: "app-1",
sourceId: "source-1",
name: "Orbit",
bundleIdentifier: "com.example.orbit",
marketplaceID: "123",
developerName: "Example",
subtitle: "",
localizedDescription: "An orbit app",
tintColor: "#E9694B",
category: "utilities",
screenshots: [],
appPermissions: { entitlements: [], privacy: {} },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const published = {
id: "release-1",
appId: "app-1",
uploadId: "upload-1",
version: "1.0",
buildVersion: "1",
appleItemId: "123",
date: "2026-02-01T00:00:00.000Z",
localizedDescription: "First release",
sizeBytes: 1024,
status: "published",
manifest: { bundleId: "com.example.orbit", appleItemId: "123", shortVersionString: "1.0", bundleVersion: "1", variantPaths: [], deltaPaths: [], platforms: [], minimumSystemVersions: {} },
createdAt: "2026-02-01T00:00:00.000Z",
};
const draft = { ...published, id: "release-2", version: "2.0", status: "ready" };
const document = buildSourceDocument(source, [app], [published, draft], "https://example.test");
assert.equal(document.apps.length, 1);
assert.equal(document.apps[0].versions.length, 1);
assert.equal(document.apps[0].versions[0].downloadURL, "https://example.test/artifacts/release-1/manifest.json");
assert.deepEqual(document.featuredApps, ["com.example.orbit"]);
});
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+59
View File
@@ -0,0 +1,59 @@
import vinext from "vinext";
import { defineConfig } from "vite";
import hostingConfig from "./.openai/hosting.json";
import { sites } from "./build/sites-vite-plugin";
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
"00000000-0000-4000-8000-000000000000";
const { d1, r2 } = hostingConfig;
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
d1_databases: d1
? [
{
binding: d1,
database_name: "site-creator-d1",
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
},
]
: [],
r2_buckets: r2
? [
{
binding: r2,
bucket_name: "site-creator-r2",
},
]
: [],
};
export default defineConfig(async () => {
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
// settings; application environment belongs in ignored `.env*` files.
process.env.WRANGLER_WRITE_LOGS ??= "false";
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin");
return {
server: isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: undefined,
plugins: [
vinext(),
sites(),
cloudflare({
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
config: localBindingConfig,
}),
],
};
});
+47
View File
@@ -0,0 +1,47 @@
/** Cloudflare Worker entry point for the vinext-starter template. */
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
DB: D1Database;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
};
};
};
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(request, {
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}
return handler.fetch(request, env, ctx);
},
};
export default worker;