初回コミット: 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:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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.");
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
`;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user