初回コミット: 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
+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"]
}