/**
 * Asset upload and serving API routes.
 *
 * POST /api/repos/:repo/entities/:type/:id/assets — multipart image upload
 * GET  /api/assets/*                               — serve local assets
 */
import { reqGitUser } from './graph-api-access.js';
import * as access from '../../services/access-control.js';
import { listRepoAssets, listWorkspaceAssets, clearLegacyBucketCache, isFreeStandingOwner } from '../../services/asset-listing.js';
import { createEntityFromAsset } from '../../services/asset-import-service.js';
import { assetDeleteDenial, assetDeleteDenialByAssetId } from '../../services/asset-delete-authz.js';
import { resolveWorkspaceAssetsRepo } from '../../services/workspace-assets-folder.js';
import { Workspace } from '../../core/workspace.js';
import { existsSync, createReadStream, statSync } from 'fs';
import { join, extname } from 'path';
import { artifactAssetKey } from '../../services/artifact-asset-token.js';
import { mintArtifactAssetTokenForText } from '../artifact-asset-mint.js';
import { encodeAssetFilename } from '../../core/artifact-asset-urls.js';
/** Max bytes proxied same-origin through the artifact-asset token path.
 *  `downloadAssetById` buffers, so this guard (checked BEFORE download against
 *  the index size) bounds memory. Images well under this; refuse unknown size. */
const ARTIFACT_PROXY_MAX_BYTES = 25 * 1024 * 1024;
/** Extensions the artifact-asset token proxy will serve — images + fonts only.
 *  An artifact embeds these; a same-repo doc/dataset/binary referenced in text
 *  must NOT become a 12h bearer URL. */
const PROXYABLE_ASSET_EXTS = new Set([
    '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.avif',
    '.woff', '.woff2', '.ttf', '.otf',
]);
/** Whether the same-origin proxy transport serves this file type (images +
 *  fonts — the subresources a CSP-pinned sandboxed iframe loads). The share
 *  asset route uses this to pick proxy vs the default serve for other types
 *  (e.g. a PDF download link on a shared doc keeps the cached R2 302). */
export function isProxyableAssetFilename(filename) {
    return PROXYABLE_ASSET_EXTS.has(extname(filename).toLowerCase());
}
const MIME_TYPES = {
    '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
    '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
    '.ico': 'image/x-icon', '.avif': 'image/avif',
    '.woff': 'font/woff', '.woff2': 'font/woff2',
    '.ttf': 'font/ttf', '.otf': 'font/otf',
};
function isFontExtension(filename) {
    return ['.woff', '.woff2', '.ttf', '.otf'].includes(extname(filename).toLowerCase());
}
function addFontResourceHeaders(reply, filename) {
    if (!isFontExtension(filename))
        return;
    reply.header('Access-Control-Allow-Origin', '*');
    reply.header('Cross-Origin-Resource-Policy', 'cross-origin');
}
function hasRepoAccess(req, repoName, workspaceManager, authService) {
    const user = req.user;
    return access.hasRepoAccess(user, repoName, workspaceManager, authService);
}
/**
 * Serve an immutable-key asset's bytes once authorization has already been
 * decided by the caller. This is the shared serving entry behind BOTH the
 * authed `/api/assets/:assetId/:filename` route (authz = hasRepoAccess) and the
 * public `/api/share/:token/asset/...` route (authz = valid token + repo scope).
 *
 * It does serving ONLY — R2 302-redirect vs local stream, path-segment
 * hardening, and cache headers. The index lookup, filename match, and the
 * access decision stay in each route so neither surface can drift into serving
 * a file the other wouldn't authorize.
 *
 * Mechanically this is a THIN WRAPPER over the generic `serveBackendObject`
 * primitive (the same primitive that backs conversation-scratch images), so
 * both surfaces share one serving codepath. Two asset-specific concerns stay
 * here as pre-steps rather than leaking into the generic primitive:
 *   1. The R2 FONT branch — fonts are proxied through the app origin (not 302'd)
 *      with cross-origin CORS headers, and HEAD is answered from the indexed
 *      size WITHOUT downloading the bytes. This is unique to the catalog.
 *   2. The catalog's stricter local segment hardening (rejects a `.`-prefixed
 *      filename, `/` or `\` in either segment), preserved verbatim.
 *
 * The owning `repo` must be resolved by the caller (from the asset index) so the
 * correct graph/backend is selected. Always fail-closed with 404 — never leak
 * existence.
 */
export async function serveIndexedAsset(workspaceManager, reply, opts) {
    const { assetId, filename, repo, isHead, size } = opts;
    let graph;
    try {
        graph = workspaceManager.getGraph(repo);
    }
    catch {
        return reply.status(404).send({ error: 'Asset not found' });
    }
    const storage = graph.getAssetStorage();
    // Artifact-asset token path: uniform same-origin serve for BOTH backends,
    // with private/no-cache + a strong (content-immutable) ETag so revocation is
    // enforced on every revalidation while a 304 avoids re-download. Restricted
    // to images + fonts (no Range/video this pass).
    if (opts.proxyR2) {
        const ext = extname(filename).toLowerCase();
        // Token proxy is restricted to images + fonts (the only types an artifact
        // legitimately embeds). Refuse anything else so a same-repo doc/dataset/etc.
        // referenced in artifact text can't be turned into a bearer URL.
        if (!PROXYABLE_ASSET_EXTS.has(ext)) {
            reply.log?.warn?.({ assetId, filename, ext }, 'artifact-asset proxy refused (non-image/font)');
            return reply.status(404).send({ error: 'Asset not found' });
        }
        // ETag content must be ASCII-safe: the RAW stored filename can carry
        // non-ASCII (e.g. `Söhne-Halbfett.woff2`), and an invalid char in a header
        // value makes Node throw ERR_INVALID_CHAR at send time — which surfaced as
        // an unhandledRejection that took the whole server down the first time a
        // share-page iframe requested such a font. Use the percent-encoded segment
        // (same encoder as asset URLs); the If-None-Match comparison below sees the
        // client echo our own emitted string, so the round-trip is unaffected.
        const etag = `"${assetId}/${encodeAssetFilename(filename)}"`;
        reply.header('ETag', etag);
        reply.header('Cache-Control', 'private, no-cache');
        reply.header('X-Content-Type-Options', 'nosniff');
        // Neutralize active content (legacy/migrated SVGs) served same-origin.
        reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
        reply.header('Content-Type', MIME_TYPES[ext] || 'application/octet-stream');
        addFontResourceHeaders(reply, filename);
        if (opts.ifNoneMatch === etag)
            return reply.status(304).send();
        try {
            if (graph.getAssetStorage() === 'r2') {
                // The index `size` is advisory and often null (migrate-assets/sweep
                // never set it), so DON'T fail-closed on unknown size — the normal
                // serve path doesn't either. Pre-reject only a KNOWN-oversize asset;
                // for HEAD with a known size, answer without downloading.
                if (typeof size === 'number' && size > ARTIFACT_PROXY_MAX_BYTES) {
                    reply.log?.warn?.({ assetId, filename, size }, 'artifact-asset proxy refused (oversized)');
                    return reply.status(404).send({ error: 'Asset not found' });
                }
                if (isHead) {
                    if (typeof size === 'number' && size > 0)
                        reply.header('Content-Length', size);
                    return reply.send();
                }
                const bytes = await graph.downloadAssetById(assetId, filename);
                // Post-download cap bounds what we serve when size was unknown.
                if (bytes.length > ARTIFACT_PROXY_MAX_BYTES) {
                    reply.log?.warn?.({ assetId, filename, len: bytes.length }, 'artifact-asset proxy refused (oversized after download)');
                    return reply.status(404).send({ error: 'Asset not found' });
                }
                reply.header('Content-Length', bytes.length);
                return reply.send(bytes);
            }
            // Local backend: stream from disk (same path-hardening as below).
            if (filename.includes('..') || filename.includes('/') || filename.includes('\\') || filename.startsWith('.') ||
                assetId.includes('..') || assetId.includes('/') || assetId.includes('\\')) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            const base = workspaceManager.workspacePath ?? process.cwd();
            const fp = join(base, '.studiograph', 'assets', assetId, filename);
            if (!existsSync(fp))
                return reply.status(404).send({ error: 'Asset not found' });
            reply.header('Content-Length', statSync(fp).size);
            if (isHead)
                return reply.send();
            return reply.send(createReadStream(fp));
        }
        catch (err) {
            reply.log?.warn?.({ err, assetId, filename }, 'artifact-asset proxy serve failed');
            return reply.status(404).send({ error: 'Asset not found' });
        }
    }
    // (1) R2 font pre-step — catalog-only CORS proxy + HEAD-from-size optimization.
    if (storage === 'r2' && isFontExtension(filename)) {
        try {
            const ext = extname(filename).toLowerCase();
            if (isHead && typeof size === 'number') {
                reply.header('Content-Type', MIME_TYPES[ext] || 'application/octet-stream');
                reply.header('Content-Length', size);
                reply.header('Cache-Control', 'private, max-age=14400');
                reply.header('X-Content-Type-Options', 'nosniff');
                addFontResourceHeaders(reply, filename);
                return reply.send();
            }
            const bytes = await graph.downloadAssetById(assetId, filename);
            reply.header('Content-Type', MIME_TYPES[ext] || 'application/octet-stream');
            reply.header('Content-Length', bytes.length);
            reply.header('Cache-Control', 'private, max-age=14400');
            reply.header('X-Content-Type-Options', 'nosniff');
            addFontResourceHeaders(reply, filename);
            if (isHead)
                return reply.send();
            return reply.send(bytes);
        }
        catch (err) {
            reply.log?.warn?.({ err, assetId, filename }, 'asset r2 signed-url generation failed');
            return reply.status(404).send({ error: 'Asset not found' });
        }
    }
    // (2) Local-backend catalog segment hardening (stricter than the generic
    // primitive: also rejects a `.`-prefixed filename). Applied before delegation
    // so observable behavior is identical to the pre-unification serve path.
    if (storage !== 'r2') {
        if (filename.includes('..') || filename.includes('/') || filename.includes('\\') || filename.startsWith('.') ||
            assetId.includes('..') || assetId.includes('/') || assetId.includes('\\')) {
            return reply.status(404).send({ error: 'Asset not found' });
        }
    }
    // Delegate to the shared primitive. The catalog key is `{assetId}/{filename}`,
    // per-backend cache differs (R2 private/4h vs local immutable/1y), fonts on the
    // local path keep their CORS headers, and the R2 signer stays the catalog one.
    return serveBackendObject(workspaceManager, reply, {
        repo,
        key: `${assetId}/${filename}`,
        filename,
        isHead,
        cacheControl: { r2: 'private, max-age=14400', local: 'public, max-age=31536000, immutable' },
        fontHeaders: true,
        notFoundError: 'Asset not found',
        signR2Url: (method) => graph.getAssetSignedUrlByAssetId(assetId, filename, method),
    });
}
/**
 * Generic backend-object serving primitive (composer-vision-attach / E2a). Serves
 * bytes for a RAW backend key once the caller has decided authorization: R2 → 302
 * to a method-bound signed URL; local → stream from `.studiograph/assets/{key}`
 * with a sandbox CSP + nosniff. The R2 signer is INJECTED so the caller controls
 * which keyspace is signed (catalog asset vs conversation-scratch image), and the
 * cache header may be a single value or a per-backend pair. Always fail-closed 404.
 *
 * This is the single serving codepath behind BOTH `serveIndexedAsset` (the
 * catalog, which layers an R2 font pre-step + stricter segment hardening on top)
 * and the conversation-scratch image route.
 */
export async function serveBackendObject(workspaceManager, reply, opts) {
    const { repo, key, filename, contentType, isHead, cacheControl, fontHeaders, signR2Url } = opts;
    const notFound = opts.notFoundError ?? 'Not found';
    const r2Cache = typeof cacheControl === 'string' ? cacheControl : cacheControl.r2;
    const localCache = typeof cacheControl === 'string' ? cacheControl : cacheControl.local;
    let graph;
    try {
        graph = workspaceManager.getGraph(repo);
    }
    catch {
        return reply.status(404).send({ error: notFound });
    }
    if (graph.getAssetStorage() === 'r2') {
        try {
            const signed = await signR2Url(isHead ? 'HEAD' : 'GET');
            reply.header('Cache-Control', r2Cache);
            return reply.redirect(signed, 302);
        }
        catch (err) {
            reply.log?.warn?.({ err, key }, 'backend object r2 signed-url generation failed');
            return reply.status(404).send({ error: notFound });
        }
    }
    // Local backend: stream from .studiograph/assets/{key}. Defense-in-depth
    // segment validation (the key is stored server-side, but never trust it).
    for (const seg of key.split('/')) {
        if (seg === '' || seg === '.' || seg === '..' || seg.includes('\\')) {
            return reply.status(404).send({ error: notFound });
        }
    }
    const localBase = workspaceManager.workspacePath ?? process.cwd();
    const filePath = join(localBase, '.studiograph', 'assets', key);
    if (!existsSync(filePath)) {
        return reply.status(404).send({ error: notFound });
    }
    const ext = extname(filename).toLowerCase();
    reply.header('Content-Type', contentType || MIME_TYPES[ext] || 'application/octet-stream');
    reply.header('Content-Length', statSync(filePath).size);
    reply.header('Cache-Control', localCache);
    if (fontHeaders)
        addFontResourceHeaders(reply, filename);
    // Neutralize active content served from the app origin. SVG uploads are
    // blocked, but legacy/migrated/indexed SVGs would otherwise run script when
    // opened directly as image/svg+xml — now reachable anonymously via the share
    // route. A sandbox CSP blocks script + plugins on direct navigation (img
    // embeds still render, since <img> never executes SVG script), and nosniff
    // stops a mislabeled asset from being re-interpreted as HTML.
    reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
    reply.header('X-Content-Type-Options', 'nosniff');
    if (isHead)
        return reply.send();
    return reply.send(createReadStream(filePath));
}
export async function registerAssetApiRoutes(fastify, workspaceManager, authService) {
    // POST /api/repos/:repo/entities/:type/:id/assets — upload an image
    fastify.post('/api/repos/:repo/entities/:type/:id/assets', async (req, reply) => {
        const { repo, type, id } = req.params;
        if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        let graph;
        try {
            graph = workspaceManager.getGraph(repo);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
        const file = await req.file();
        if (!file)
            return reply.status(400).send({ error: 'No file uploaded' });
        if (!file.mimetype.startsWith('image/')) {
            return reply.status(400).send({ error: 'Only image files are supported' });
        }
        // Block SVG: stored XSS — SVG can contain <script> and the GET route
        // serves it inline as image/svg+xml, executing in the app origin.
        if (file.mimetype === 'image/svg+xml' || file.filename?.toLowerCase().endsWith('.svg')) {
            return reply.status(415).send({ error: 'SVG uploads are not supported (stored XSS risk).' });
        }
        const buffer = await file.toBuffer();
        const filename = `${Date.now()}-${file.filename.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
        const uploader = req.user;
        try {
            const asset = await graph.uploadAsset(type, id, filename, buffer, undefined, {
                uploaderUserId: uploader?.id != null ? String(uploader.id) : null,
            });
            // uploadAsset returns the canonical public URL (immutable-key scheme
            // when the index is wired, legacy otherwise) — don't re-derive it.
            return reply.send({ url: asset.publicUrl, filename: asset.filename, size: asset.size });
        }
        catch (err) {
            if (err.message?.includes('not configured')) {
                return reply.status(501).send({ error: 'Image uploads are not available. Configure R2 or local storage.' });
            }
            return reply.status(400).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/entities/artifact/:id/asset-token — mint a short-lived
    // (12h), ARTIFACT-SCOPED capability token so a sandboxed iframe (opaque origin,
    // no cookie) can load the exact images this artifact embeds. Authz: a normal
    // session with repo access. The allowlist is the set of indexed assets the
    // bundle references that actually live in THIS repo — cross-repo / unknown /
    // filename-mismatched refs are dropped (fail-closed).
    fastify.post('/api/repos/:repo/entities/artifact/:id/asset-token', async (req, reply) => {
        const { repo, id } = req.params;
        const user = req.user;
        if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        let graph;
        try {
            graph = workspaceManager.getGraph(repo);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
        // Allowlist source = the EXACT version being rendered. For a history
        // preview the client passes the version's commit; we derive the allowlist
        // from the artifact body AT THAT COMMIT (server-trusted, not client refs),
        // so a preview token authorizes the previewed version's assets — not the
        // current ones. Otherwise use the live body.
        const commit = typeof req.query.commit === 'string' && /^[0-9a-f]{7,40}$/i.test(req.query.commit)
            ? req.query.commit : null;
        let text;
        if (commit) {
            const atCommit = graph.getEntityAtCommit('artifact', id, commit);
            if (!atCommit)
                return reply.status(404).send({ error: 'Artifact version not found' });
            text = atCommit.content;
        }
        else {
            let entity;
            try {
                entity = graph.get('artifact', id);
            }
            catch {
                return reply.status(404).send({ error: 'Artifact not found' });
            }
            if (!entity)
                return reply.status(404).send({ error: 'Artifact not found' });
            text = entity.document?.content ?? '';
        }
        return reply.send(mintArtifactAssetTokenForText(workspaceManager, authService, repo, id, text, user ? String(user.id) : '0'));
    });
    // GET / HEAD /api/assets/:assetId/:filename — IMMUTABLE-KEY serve route.
    //
    // New scheme: assets are keyed `{assetId}/{filename}` (assetId = UUIDv4) and
    // ownership/authz lives in the AssetIndexService, not in the URL. Two path
    // segments after /api/assets/ vs three for the legacy route below — Fastify
    // disambiguates by arity, so both coexist during the migration cutover.
    //
    // Authz: index.get(assetId) → repo → hasRepoAccess. Fail-closed on a miss.
    // The filename in the URL MUST match the indexed filename, otherwise a valid
    // assetId would authorize arbitrary keys under that UUID directory.
    fastify.route({
        method: ['GET', 'HEAD'],
        url: '/api/assets/:assetId/:filename',
        handler: async (req, reply) => {
            const { assetId, filename } = req.params;
            const isHead = req.method === 'HEAD';
            const ifNoneMatch = req.headers['if-none-match'];
            const row = workspaceManager.getAssetIndex().get(assetId);
            // Fail-closed: unknown id, or a filename that doesn't match the indexed
            // one, both 404 (never leak existence; never authorize a sibling key).
            if (!row || row.filename !== filename) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            // (No anonymous public-fonts exception here anymore: fonts resolve
            // through the same contract as images — token allowlist below, the
            // share-token route, or export bundling. Fail closed without one.)
            const user = req.user;
            if (!user && authService.hasUsers()) {
                // No session cookie/Bearer (e.g. a sandboxed artifact iframe). Try the
                // artifact-asset capability token before failing. The preHandler only
                // verified the signature + 2-seg shape and stashed the payload as a
                // CANDIDATE — the authoritative checks happen HERE against the resolved
                // row: repo match + exact-asset allowlist + the token's user STILL has
                // repo access (revocation enforced per-serve). Serve same-origin.
                const tok = req.artifactAssetToken;
                if (tok) {
                    const tokenUser = authService.findUserById(Number(tok.userId));
                    // Token is minted for ONE artifact (in tok.repo). It may load assets
                    // owned by that repo, OR assets in a workspace-wide shared store
                    // (workspace-assets) — those are cross-repo by design and readable by
                    // every member, so an artifact in repo A may embed a shared-store
                    // image. The exact-asset allowlist still scopes what THIS token unlocks,
                    // and hasRepoAccess re-checks the token user against the owning repo.
                    const repoMatch = tok.repo === row.repo || access.isWorkspaceWide(row.repo, workspaceManager);
                    if (repoMatch &&
                        tok.assets.includes(artifactAssetKey(assetId, filename)) &&
                        tokenUser &&
                        access.hasRepoAccess(tokenUser, row.repo, workspaceManager, authService)) {
                        return serveIndexedAsset(workspaceManager, reply, {
                            assetId, filename, repo: row.repo, isHead, size: row.size, proxyR2: true, ifNoneMatch,
                        });
                    }
                }
                return reply.status(404).send({ error: 'Asset not found' });
            }
            if (!hasRepoAccess(req, row.repo, workspaceManager, authService)) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            // Authz decided above (folder access); shared helper does the serving.
            //
            // TRANSPORT exception: WebKit sends the session cookie from sandboxed
            // (opaque-origin) artifact iframes — Chromium doesn't — so a Safari/iOS
            // iframe image request lands HERE on session authz even though it also
            // carries a verified `?at=` candidate. The session stays the authz
            // authority (the token never overrides it), but the default R2 serve
            // answers 302-to-bucket-origin, which the iframe's CSP rightly blocks
            // ("Redirect was not allowed" — images broken in Safari, fine in
            // Chrome). A verified token candidate is the fingerprint of that
            // sandboxed caller, so serve it same-origin via the token proxy path
            // (image/font-only + size caps + revalidate ETag) instead of redirecting.
            // Grants nothing new: this session could fetch the same bytes through
            // the redirect; only the transport changes. Scoped to the R2 backend —
            // local storage streams directly (no redirect to block), and routing it
            // through the proxy would impose the token path's image/font-only gate
            // on session-authorized types it previously served (e.g. video).
            let r2Backed = false;
            if (req.artifactAssetToken) {
                try {
                    r2Backed = workspaceManager.getGraph(row.repo).getAssetStorage() === 'r2';
                }
                catch { /* served 404 below */ }
            }
            return serveIndexedAsset(workspaceManager, reply, {
                assetId, filename, repo: row.repo, isHead, size: row.size,
                ...(r2Backed ? { proxyR2: true, ifNoneMatch } : {}),
            });
        },
    });
    // GET / HEAD /api/assets/:type/:id/:filename — LEGACY serve route (kept for
    // the migration cutover window; removed once legacy-hit logs drain to 0).
    //
    // Per-repo authz: the asset storage layout is workspace-global
    // (`.studiograph/assets/{type}/{id}/{filename}` for local;
    // `{type}/{id}/{filename}` keys for R2) — the path doesn't carry repo
    // information. To enforce that a user with access to folder A can't
    // read assets owned by folder B, we resolve the owning repo from the
    // entity ID via workspaceManager.findEntityById (the same primitive
    // the agent's create_entity uses for cross-repo dedup checks), then
    // run hasRepoAccess against that repo. Both miss and forbid return 404
    // — never leak entity IDs as an existence oracle.
    //
    // Stage 2 (this commit): the route is now the single chokepoint for
    // both backends. Local files stream directly. R2-backed assets get a
    // 302 redirect to a short-lived pre-signed URL, so the underlying
    // bucket can be flipped to private without breaking the workflow.
    // Both GET and HEAD are registered — link unfurlers, OpenGraph
    // crawlers, and CDN healthchecks issue HEAD against this URL and
    // expect the same 302 (signed for HEAD) or local headers.
    fastify.route({
        method: ['GET', 'HEAD'],
        url: '/api/assets/:type/:id/:filename',
        handler: async (req, reply) => {
            const { type, id, filename } = req.params;
            const isHead = req.method === 'HEAD';
            // Legacy-scheme hit. Monitored during cutover: once this drains to 0
            // (all bodies migrated to /api/assets/{assetId}/...), this route is
            // removed. Tagged so it's greppable in logs / metrics.
            req.log?.info?.({ type, id, filename }, 'asset legacy-scheme route hit');
            // Legacy keys carry NO repo identity, and entity IDs are unique within
            // (repo, type) but NOT across the workspace — a case-study in `portfolio`
            // and a reference in `analogous-experiences` can share an id. The old
            // first-match `findEntityByTypeAndId` would authorize against an arbitrary
            // owner, so on a slug collision a request could be authorized via an
            // accessible repo yet serve a *different* repo's blob (codex P1). FAIL
            // CLOSED: resolve every repo that owns (type,id); serve only when exactly
            // one repo owns it AND that repo is accessible. (Immutable assets use the
            // /api/assets/{assetId}/... route and are unaffected.)
            const owners = [];
            for (const cfg of workspaceManager.getAllRepoConfigs()) {
                let g;
                try {
                    g = workspaceManager.getGraph(cfg.name);
                }
                catch {
                    continue;
                }
                if (g.getEntityFilePath(type, id))
                    owners.push(cfg.name);
            }
            if (owners.length !== 1) {
                return reply.status(404).send({ error: 'Asset not found' }); // ambiguous / none → fail closed
            }
            const repoName = owners[0];
            if (!hasRepoAccess(req, repoName, workspaceManager, authService)) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            let graph;
            try {
                graph = workspaceManager.getGraph(repoName);
            }
            catch {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            // R2 backend: 302 to a short-lived pre-signed URL. Cache the 302
            // for the lifetime of the signed URL (4 hours) — this is the
            // load-bearing perf optimization. Signed URLs are unique per
            // request (each carries its own X-Amz-Signature), so without
            // caching the redirect, every <img> tag fires a fresh proxy
            // roundtrip AND a fresh R2 download (the signed URL is different
            // each time, so the browser's image cache miss-matches and
            // re-downloads bytes it already has). Caching the 302 means
            // subsequent requests in the same 4-hour window resolve to the
            // SAME signed URL → cached image bytes → zero network. Single
            // line, multi-second page-load improvement.
            //
            // Trade-off: a signed URL cached in the browser keeps working
            // for up to 4 hours after the user loses folder access. Same
            // threat model the 4-hour signed-URL TTL already accepted —
            // fresh URLs lived 4 hours, now cached URLs do too. `private`
            // keeps shared caches (CDNs, corporate proxies) from holding
            // the 302; only the originating browser does.
            //
            // Pre-signed URLs are method-bound, so HEAD requests need a
            // HEAD-signed URL.
            if (graph.getAssetStorage() === 'r2') {
                try {
                    const signed = await graph.getAssetSignedUrl(type, id, filename, undefined, undefined, isHead ? 'HEAD' : 'GET');
                    reply.header('Cache-Control', 'private, max-age=14400');
                    return reply.redirect(signed, 302);
                }
                catch (err) {
                    // Surface SDK / network failures so credential rotation issues,
                    // R2 outages, etc. aren't silently presented to users as
                    // 'Asset not found'. The user-facing response stays 404 so
                    // entity IDs aren't leaked as an existence oracle.
                    req.log?.warn?.({ err, type, id, filename }, 'asset r2 signed-url generation failed');
                    return reply.status(404).send({ error: 'Asset not found' });
                }
            }
            // Local backend: stream the file directly. Defense-in-depth — the
            // R2 branch already validates filename inside AssetService, so
            // hold the local branch to the same standard. Fastify's segment
            // matcher blocks `/` in path params, but a literal `..` or hidden
            // dotfile would otherwise be passed through to `join` here.
            if (filename.includes('..') || filename.includes('/') || filename.includes('\\') || filename.startsWith('.') ||
                id.includes('..') || id.includes('/') || id.includes('\\') ||
                type.includes('..') || type.includes('/') || type.includes('\\')) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            const workspacePath = workspaceManager.workspacePath ?? process.cwd();
            const filePath = join(workspacePath, '.studiograph', 'assets', type, id, filename);
            if (!existsSync(filePath)) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            const ext = extname(filename).toLowerCase();
            const contentType = MIME_TYPES[ext] || 'application/octet-stream';
            const stats = statSync(filePath);
            reply.header('Content-Type', contentType);
            reply.header('Content-Length', stats.size);
            reply.header('Cache-Control', 'public, max-age=31536000, immutable');
            // HEAD: send headers without a body. Fastify auto-strips body on
            // HEAD, but being explicit keeps the file handle from opening.
            if (isHead)
                return reply.send();
            return reply.send(createReadStream(filePath));
        },
    });
    // GET /api/repos/:repo/assets — list all assets in a folder. The listing
    // logic (backend walk + immutable-index merge + name resolution) lives in
    // listRepoAssets() so the MCP `list_assets` tool returns an identical shape.
    fastify.get('/api/repos/:repo/assets', async (req, reply) => {
        const { repo } = req.params;
        if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        // Verify the repo exists before delegating; listRepoAssets swallows
        // missing-graph errors as [], which is right for the bulk endpoint but
        // wrong for the single-repo one (we want a 404).
        try {
            workspaceManager.getGraph(repo);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
        return { assets: await listRepoAssets(repo, workspaceManager) };
    });
    // GET /api/workspace/assets — bulk asset lists for ALL accessible repos in
    // one round-trip. Replaces 15+ parallel /api/repos/:repo/assets calls so
    // GalleryView's thumbnail effect doesn't saturate the browser HTTP/1.1
    // 6-slot queue and block other navigation fetches.
    // Response shape: `{ [repoName]: RepoAsset[] }`.
    fastify.get('/api/workspace/assets', async (req, reply) => {
        const user = req.user;
        // Use the shared access-control helper so personal-folder restrictions
        // and workspace-owner-on-shared-folder semantics apply consistently.
        const accessible = access.getAccessibleRepos(user, workspaceManager, authService).map(r => r.name);
        // One bucket walk for the whole workspace (see listWorkspaceAssets) rather
        // than a per-repo listAll fan-out. Seed every accessible repo so folders
        // with no assets still appear as empty arrays (shape-stable for callers).
        const result = {};
        for (const repo of accessible)
            result[repo] = [];
        const flat = await listWorkspaceAssets(accessible, workspaceManager);
        for (const { repo, ...asset } of flat)
            (result[repo] ??= []).push(asset);
        return reply.send(result);
    });
    // GET /api/workspace/assets/manager — the dedicated asset-manager view.
    //
    // Distinct from /api/workspace/assets (kept shape-stable for GalleryView's
    // thumbnail effect). This composes the SAME primitives (getAccessibleRepos +
    // listRepoAssets + assetDeleteDenial) into the shape the /assets UI needs:
    //   - `folders`: every accessible folder's metadata (display name +
    //     workspace_wide + system flags) so the client can render the folder
    //     filter, group headers, the Workspace/folder-scoped visibility badge,
    //     and pin the shared store first — the system store is excluded from
    //     /api/repos so the client can't get this elsewhere.
    //   - `assets`: a FLAT list annotated with the owning `repo` and a per-asset
    //     `canDelete` decision (uploader/owner gate, via the same authz primitive
    //     the DELETE route enforces) so the UI can disable delete with a reason
    //     instead of letting the user hit a 403.
    fastify.get('/api/workspace/assets/manager', async (req, reply) => {
        const user = req.user;
        workspaceManager.reloadConfig();
        const accessible = access.getAccessibleRepos(user, workspaceManager, authService);
        const index = workspaceManager.getAssetIndex?.();
        const folders = accessible.map(r => ({
            repo: r.name,
            displayName: r.display_name ?? null,
            workspaceWide: r.workspace_wide === true,
            system: r.system === true,
        }));
        const configByRepo = new Map(accessible.map(r => [r.name, r]));
        // listWorkspaceAssets does ONE bucket walk for the whole workspace (instead
        // of one per folder) and already dedupes legacy/immutable pairs per repo.
        // Here we just layer on the per-asset delete decision so the UI can disable
        // delete with a reason instead of letting the user hit a 403.
        const flat = await listWorkspaceAssets(accessible.map(r => r.name), workspaceManager);
        const assets = flat.map((a) => {
            const r = configByRepo.get(a.repo);
            // Index-tracked assets (incl. free-standing folder/workspace-owned ones)
            // carry an assetId — gate per-asset on that id. Legacy un-indexed blobs
            // fall back to the entity-coordinate gate, keyed to the exact filename.
            const denial = a.assetId
                ? assetDeleteDenialByAssetId(index, r, user, a.assetId)
                : assetDeleteDenial(index, r, user, a.repo, a.entityType, a.entityId, a.filename);
            return { ...a, canDelete: denial === null, denialReason: denial };
        });
        return reply.send({ folders, assets });
    });
    // GET /api/workspace/assets/:assetId/references — entries whose markdown
    // body (or frontmatter) references this asset's URL. Scans accessible
    // repos via graph.search, which already includes body-content scanning,
    // so no schema change is needed. Filters self-reference: a STANDALONE
    // asset's own wrapper entity has the URL in its `asset_url` frontmatter
    // and would self-match — drop it. Embedded assets keep their parent
    // entity in results (the parent IS the only "use", and clicking it is
    // the user's path to managing the image's context).
    fastify.get('/api/workspace/assets/:assetId/references', async (req, reply) => {
        const { assetId } = req.params;
        const user = req.user;
        const index = workspaceManager.getAssetIndex?.();
        const owner = index?.get(assetId) ?? null;
        const accessible = access.getAccessibleRepos(user, workspaceManager, authService);
        // The immutable-asset URL pattern. assetId is a UUID so this string
        // is unique enough that a substring match across markdown bodies is
        // a reliable "this entry references the asset" signal.
        const pattern = `/api/assets/${assetId}/`;
        const references = [];
        for (const repo of accessible) {
            let graph;
            try {
                graph = workspaceManager.getGraph(repo.name);
            }
            catch {
                continue;
            }
            const matches = graph.search({ query: pattern });
            for (const e of matches) {
                // Skip self: a standalone asset's own entity holds the URL in
                // its `asset_url` frontmatter so it always matches its own
                // search. Embedded assets fall through (their owner is the
                // parent entry, which IS a legitimate reference to show).
                if (owner &&
                    owner.ownerType === 'asset' &&
                    owner.repo === repo.name &&
                    e.id === owner.ownerId)
                    continue;
                const data = e.data;
                const title = typeof data.name === 'string' && data.name.trim() !== ''
                    ? data.name
                    : e.id;
                references.push({
                    repo: repo.name,
                    entityType: e.entityType,
                    entityId: e.id,
                    title,
                });
            }
        }
        return reply.send({ references });
    });
    // GET /api/workspace/assets/store — resolve (lazy-create) the reserved
    // Workspace Assets store and return its repo name. The manager's upload
    // affordance calls this, then POSTs the file to /api/repos/<repo>/assets.
    // This is how an existing workspace backfills the store on first use (D3 /
    // resolveWorkspaceAssetsRepo) — never a boot sweep. Authed users only; the
    // store is workspace_wide so any member can upload once it exists.
    fastify.get('/api/workspace/assets/store', async (_req, reply) => {
        // Match the upload route's auth posture: open-mode (no user) is allowed.
        const workspace = new Workspace(workspaceManager.getWorkspacePath());
        const repo = await resolveWorkspaceAssetsRepo(workspace, workspaceManager);
        return reply.send({ repo });
    });
    // DELETE /api/repos/:repo/entities/:type/:id/assets/:filename — delete an asset
    fastify.delete('/api/repos/:repo/entities/:type/:id/assets/:filename', async (req, reply) => {
        const { repo, type, id, filename } = req.params;
        if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        // Workspace Assets store delete gate (codex P1 / D2): workspace_wide grants
        // every member write, so this filename delete needs the same uploader/owner
        // gate the entity-delete route applies — otherwise a direct API call deletes
        // a shared/legacy file asset the manager already marks canDelete:false.
        const denial = assetDeleteDenial(workspaceManager.getAssetIndex?.(), workspaceManager.getRepoConfig(repo), req.user, repo, type, id, filename);
        if (denial)
            return reply.status(403).send({ error: denial });
        let graph;
        try {
            graph = workspaceManager.getGraph(repo);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
        try {
            await graph.deleteAsset(type, id, filename);
            // A legacy blob just disappeared — drop the cached bucket walk so the
            // manager stops listing it before the next process restart (P2).
            clearLegacyBucketCache();
            return { success: true };
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
    });
    // DELETE /api/repos/:repo/assets/:assetId/:filename — delete a free-standing
    // (folder/workspace-owned) asset by its immutable id. Folder-owned assets
    // have no owning entity, so the entity-coordinate route above can't address
    // them (it rejects 'folder' as an EntityType). This removes the blob + index
    // row in one step.
    fastify.delete('/api/repos/:repo/assets/:assetId/:filename', async (req, reply) => {
        const { repo, assetId, filename } = req.params;
        if (!hasRepoAccess(req, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        // Confirm the asset exists and actually belongs to this folder — don't let
        // access to one folder delete another folder's asset by id.
        const index = workspaceManager.getAssetIndex?.();
        const row = index?.get(assetId);
        if (!row || row.repo !== repo) {
            return reply.status(404).send({ error: 'Asset not found' });
        }
        // Same uploader/owner gate as the filename route, keyed on the asset row.
        const denial = assetDeleteDenialByAssetId(index, workspaceManager.getRepoConfig(repo), req.user, assetId);
        if (denial)
            return reply.status(403).send({ error: denial });
        // Filename must match the stored row — the key is `{assetId}/{filename}`.
        if (row.filename !== filename) {
            return reply.status(404).send({ error: 'Asset not found' });
        }
        let graph;
        try {
            graph = workspaceManager.getGraph(repo);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
        try {
            await graph.deleteAssetById(assetId, filename);
            return { success: true };
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
    });
    // POST /api/repos/:repo/assets/:assetId/create-entry — the second import
    // "door": create a typed entity (auto-derived type, re-extracted body) that
    // embeds an EXISTING free-standing asset, in the asset's own folder. Makes
    // deletion reversible — a surviving asset can be re-embedded into a new entry.
    fastify.post('/api/repos/:repo/assets/:assetId/create-entry', async (req, reply) => {
        const { repo, assetId } = req.params;
        const user = req.user;
        // Creating an entry is a write — require folder write access.
        if (!access.canWriteRepo(user, repo, workspaceManager, authService)) {
            return reply.status(403).send({ error: 'Editor access required to create an entry here.' });
        }
        const index = workspaceManager.getAssetIndex?.();
        const row = index?.get(assetId);
        if (!row || row.repo !== repo) {
            return reply.status(404).send({ error: 'Asset not found' });
        }
        // Only free-standing assets get the second import door (parity with the
        // delete-by-id + move routes). Re-embedding an entity-OWNED asset would
        // leave the new entry's embed deletable by the owning entity's
        // cleanupOwnedAssets — so refuse it here, not just in the UI.
        if (!isFreeStandingOwner(row.ownerType)) {
            return reply.status(400).send({ error: 'Only free-standing assets can be made into a new entry.' });
        }
        let graph;
        try {
            graph = workspaceManager.getGraph(repo);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
        try {
            const result = await createEntityFromAsset({
                graph,
                assetId,
                name: req.body?.name,
                user: reqGitUser(req),
            });
            return { entities: result.entities, assetId: result.assetId, asset_url: result.asset_url };
        }
        catch (err) {
            return reply.status(400).send({ error: err.message ?? 'Could not create entry from asset' });
        }
    });
    // POST /api/repos/:repo/assets/:assetId/move — relocate a free-standing asset
    // to another folder. Index-only (the blob key is repo-independent). Requires
    // write access to BOTH the source and target folders; only free-standing
    // (folder/workspace-owned) assets can move — entity-embedded assets move with
    // their owning entry.
    fastify.post('/api/repos/:repo/assets/:assetId/move', async (req, reply) => {
        const { repo, assetId } = req.params;
        const targetRepo = req.body?.targetRepo;
        const user = req.user;
        if (!targetRepo)
            return reply.status(400).send({ error: 'targetRepo is required' });
        if (!access.canWriteRepo(user, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        if (!access.canWriteRepo(user, targetRepo, workspaceManager, authService)) {
            return reply.status(403).send({ error: `Editor access required to move into "${targetRepo}".` });
        }
        const index = workspaceManager.getAssetIndex?.();
        const row = index?.get(assetId);
        if (!index || !row || row.repo !== repo) {
            return reply.status(404).send({ error: 'Asset not found' });
        }
        if (!isFreeStandingOwner(row.ownerType)) {
            return reply.status(400).send({ error: 'Only free-standing assets can be moved on their own.' });
        }
        // Moving OUT of a workspace_wide store reassigns the asset's repo, after
        // which the tighter uploader/owner delete rule no longer applies — so the
        // move itself must honor that rule (mirrors the entity-move guard in
        // graph-api). A no-op for non-workspace_wide sources, so normal
        // folder→folder moves are unaffected.
        const denial = assetDeleteDenialByAssetId(index, workspaceManager.getRepoConfig(repo), user, assetId);
        if (denial)
            return reply.status(403).send({ error: denial });
        if (targetRepo === repo)
            return { success: true }; // no-op
        // Honor the result — a concurrent delete/move could have removed the row
        // between the get() above and here; reporting success then would make the
        // asset appear moved when it wasn't.
        if (!index.moveByAssetId(assetId, targetRepo)) {
            return reply.status(404).send({ error: 'Asset not found' });
        }
        return { success: true };
    });
}
//# sourceMappingURL=asset-api.js.map