/**
 * Public read-only share links.
 *
 * The app's first anonymous surface. Three routes:
 *
 *   POST /api/repos/:repo/entities/:type/:id/share-link   (AUTHED)  — mint/reuse
 *   GET  /api/share/:token                                (PUBLIC)  — read body
 *   GET  /api/share/:token/asset/:assetId/:filename       (PUBLIC)  — embedded asset
 *
 * Authorization model:
 *  - Mint is write-gated (mirrors revoke). Personal folders ARE publishable by
 *    their owner — the publish right is orthogonal to teammate visibility, and
 *    the single mint caller is already owner-gated. Today read ⟺ write for
 *    shared folders, so write-gating is a no-op now but keeps publish/unpublish
 *    coherent and pre-empts a future read-only role from minting a link it
 *    couldn't revoke.
 *  - The two PUBLIC routes are bypassed in the global auth hook; the opaque
 *    token (looked up in share_links, fail-closed on missing/revoked) IS the
 *    authorization. A revoked token stops resolving for BOTH the body and its
 *    assets immediately.
 *  - The public read returns ONLY { title, entityType, content } — frontmatter
 *    and metadata never cross the boundary (test-enforced). Asset URLs in the
 *    body are rewritten to token-scoped /api/share/:token/asset/... links.
 *  - A public asset must be EMBEDDED in the shared entry and owned by the
 *    entry's repo OR a workspace-wide shared store (same scope as the
 *    artifact-asset token path — font kits/shared images live there), so a
 *    crafted body can't republish another entry's, a private repo's, or an
 *    un-embedded asset.
 */
import * as access from '../../services/access-control.js';
import { rewriteImmutableAssetUrls, extractImmutableAssetRefs } from '../../core/embeds.js';
import { encodeAssetFilename } from '../../services/assets/index.js';
import { serveIndexedAsset, isProxyableAssetFilename } from './asset-api.js';
/**
 * Whether a folder may be published at all. Personal folders ARE publishable:
 * the owner's right to publish their own content externally is orthogonal to
 * teammate visibility, and the single `mint()` caller is already owner-gated
 * (a non-owner 404s on a personal folder before reaching here). The gate now
 * degrades to a repo-existence check, but it stays a named gate — and keeps its
 * four call sites — because it's where a future non-publishable repo class
 * (e.g. a `system` / workspace-wide asset store added to RepoConfig) would be
 * rejected. Read the flag off the repo config directly — `isRestrictedPersonalFolder`
 * in access-control is unexported and user-scoped (a per-user access check),
 * whereas this is a publish gate.
 */
function isPublishableRepo(workspaceManager, repo) {
    const config = workspaceManager.getRepoConfig(repo);
    if (!config)
        return false;
    return true;
}
function entityTitle(data, fallbackId) {
    const name = data?.name ?? data?.title;
    return typeof name === 'string' && name.trim() ? name : fallbackId;
}
/** The entry's stable creation timestamp, used to detect a delete+recreate at
 *  the same coords. Returns null when absent (then identity isn't enforced). */
function entityTimestamp(entity) {
    const ts = entity?.data?.created_at;
    return typeof ts === 'string' && ts ? ts : null;
}
/**
 * Resolve the entry a live share link points at, applying the existence +
 * entity-identity guards both public routes share: the repo + entry must still
 * exist, and when both the link and the current entry carry a created_at they
 * must match (else the original entry was deleted and a new one took its coords
 * — fail closed so the old token can't serve unrelated content). Returns the
 * entity, or null when any guard trips. Callers have already validated the
 * token + publishable gate.
 */
function resolveSharedEntity(workspaceManager, link) {
    let graph;
    try {
        graph = workspaceManager.getGraph(link.repo);
    }
    catch {
        return null;
    }
    let entity;
    try {
        entity = graph.get(link.entryType, link.entryId);
    }
    catch {
        return null;
    }
    if (!entity)
        return null;
    const currentTs = entityTimestamp(entity);
    if (link.entityCreatedAt && currentTs && currentTs !== link.entityCreatedAt)
        return null;
    return entity;
}
export async function registerShareApiRoutes(fastify, workspaceManager, authService, shareLinkService) {
    // POST /api/repos/:repo/entities/:type/:id/share-link — mint or reuse the
    // active public link for an entry. Authed (not bypassed in the auth hook).
    fastify.post('/api/repos/:repo/entities/:type/:id/share-link', async (req, reply) => {
        const user = req.user;
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const { repo, type, id } = req.params;
        if (!access.hasRepoAccess(user, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        // Write-gate publishing to match revoke (DELETE below). Today read ⟺ write
        // for shared folders, so this is a no-op, but it keeps the two halves of
        // the feature coherent and pre-empts a future read-only/viewer role from
        // being able to mint a permanent public link it couldn't revoke.
        if (!access.canWriteRepo(user, repo, workspaceManager, authService)) {
            return reply.status(403).send({ error: 'You do not have write access to this folder' });
        }
        if (!isPublishableRepo(workspaceManager, repo)) {
            return reply.status(403).send({ error: 'This folder cannot be shared publicly.' });
        }
        // Confirm the entity exists before minting, and capture its created_at so
        // a later delete+recreate at the same coords can't inherit this token.
        let graph;
        try {
            graph = workspaceManager.getGraph(repo);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message });
        }
        let entityCreatedAt = null;
        try {
            const entity = graph.get(type, id);
            if (!entity)
                return reply.status(404).send({ error: `Entity not found: ${type}/${id}` });
            entityCreatedAt = entityTimestamp(entity);
        }
        catch (err) {
            return reply.status(404).send({ error: err.message ?? `Entity not found: ${type}/${id}` });
        }
        const link = shareLinkService.mint(repo, type, id, user.id, entityCreatedAt);
        return reply.send({ path: `/share/${link.token}`, token: link.token });
    });
    // GET /api/repos/:repo/entities/:type/:id/share-link — share status for an
    // entry, so the entry view can show "Unshare" only when a live link exists.
    // Read-gated (authed). Returns the active link, or nulls when not shared.
    fastify.get('/api/repos/:repo/entities/:type/:id/share-link', async (req, reply) => {
        const user = req.user;
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const { repo, type, id } = req.params;
        if (!access.hasRepoAccess(user, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        // Defense in depth against a future non-publishable repo class: report
        // not-shared rather than handing out a token that the read path would
        // refuse to resolve anyway.
        if (!isPublishableRepo(workspaceManager, repo)) {
            return reply.send({ path: null, token: null });
        }
        const link = shareLinkService.getActiveByEntry(repo, type, id);
        return reply.send(link ? { path: `/share/${link.token}`, token: link.token } : { path: null, token: null });
    });
    // DELETE /api/repos/:repo/entities/:type/:id/share-link — revoke the entry's
    // active public link (the per-entry "Unshare" action). Write-gated: anyone
    // who can edit the entry can take it offline (publicness is a property of the
    // entry, not of whoever first shared it). Idempotent — revoked:false when the
    // entry wasn't shared.
    fastify.delete('/api/repos/:repo/entities/:type/:id/share-link', async (req, reply) => {
        const user = req.user;
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const { repo, type, id } = req.params;
        if (!access.hasRepoAccess(user, repo, workspaceManager, authService)) {
            return reply.status(404).send({ error: `Folder "${repo}" not found` });
        }
        if (!access.canWriteRepo(user, repo, workspaceManager, authService)) {
            return reply.status(403).send({ error: 'You do not have write access to this folder' });
        }
        return reply.send({ revoked: shareLinkService.revokeByEntry(repo, type, id) });
    });
    // GET /api/share/:token — PUBLIC read. Returns title + body only.
    fastify.get('/api/share/:token', async (req, reply) => {
        const link = shareLinkService.resolve(req.params.token);
        // Missing OR revoked → 404 (fail-closed, no existence oracle).
        if (!link)
            return reply.status(404).send({ error: 'Share link not found' });
        // Defense in depth: even a valid token never serves a now-unpublishable
        // folder (a future non-publishable repo class).
        if (!isPublishableRepo(workspaceManager, link.repo)) {
            return reply.status(404).send({ error: 'Share link not found' });
        }
        // Existence + entity-identity guard (a delete+recreate at the same coords
        // fails closed). Shared with the asset route below.
        const entity = resolveSharedEntity(workspaceManager, link);
        if (!entity)
            return reply.status(404).send({ error: 'Share link not found' });
        const rawContent = entity.document?.content ?? '';
        // Mutable + security-sensitive: never let a proxy/CDN cache it, so edits
        // and a revoke take effect immediately (D4).
        reply.header('Cache-Control', 'no-store');
        // SECURITY BOUNDARY: title + body + type ONLY. Frontmatter / metadata must
        // never cross to a guest (test-enforced in share-api.test.ts).
        return reply.send({
            title: entityTitle(entity.data, link.entryId),
            entityType: entity.entityType ?? link.entryType,
            // D5: the rewriter hands back the DECODED raw filename; the emitted
            // URL re-encodes it so spaces/parens can't break markdown/HTML parsing
            // (the serve route's Fastify-decoded param compares raw again).
            content: rewriteImmutableAssetUrls(rawContent, (assetId, filename) => `/api/share/${link.token}/asset/${assetId}/${encodeAssetFilename(filename)}`),
        });
    });
    // GET / HEAD /api/share/:token/asset/:assetId/:filename — PUBLIC asset.
    fastify.route({
        method: ['GET', 'HEAD'],
        url: '/api/share/:token/asset/:assetId/:filename',
        handler: async (req, reply) => {
            const { token, assetId, filename } = req.params;
            const isHead = req.method === 'HEAD';
            const link = shareLinkService.resolve(token);
            if (!link)
                return reply.status(404).send({ error: 'Asset not found' });
            if (!isPublishableRepo(workspaceManager, link.repo)) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            const row = workspaceManager.getAssetIndex().get(assetId);
            // Unknown id and filename mismatch fail closed. Repo scope mirrors the
            // artifact-asset token path: the asset must be owned by the shared
            // entry's repo OR live in a workspace-wide shared store (font kits and
            // shared images live in `workspace-assets`; every member can already
            // read them, and the embed check below still requires the SHARER to
            // have referenced the asset in the published body — so sharing an entry
            // publishes exactly what it embeds, nothing else). Other cross-repo
            // assets stay 404.
            const repoAllowed = !!row && (row.repo === link.repo || access.isWorkspaceWide(row.repo, workspaceManager));
            if (!row || row.filename !== filename || !repoAllowed) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            // The asset must be EMBEDDED in the shared entry — not merely a same-repo
            // sibling. Re-resolve the entry through the same existence + identity
            // guard the body read uses (so an asset 404s after a delete+recreate),
            // then confirm the body references this immutable asset URL. Uses the
            // same grammar `rewriteImmutableAssetUrls` rewrote it from on the read.
            // Both sides of the compare are the RAW identity (D5): the refs decode
            // the body's percent-encoded segment; `filename` is Fastify-decoded.
            const entity = resolveSharedEntity(workspaceManager, link);
            if (!entity)
                return reply.status(404).send({ error: 'Asset not found' });
            const embedded = extractImmutableAssetRefs(entity.document?.content ?? '');
            if (!embedded.some((ref) => ref.assetId === assetId && ref.filename === filename)) {
                return reply.status(404).send({ error: 'Asset not found' });
            }
            // TRANSPORT: images + fonts stream same-origin (proxyR2) instead of the
            // default R2 302 — a sandboxed share-page artifact iframe pins its CSP
            // to this route's path, and WebKit (and the CSP spec for redirects)
            // blocks the cross-origin bucket hop, so fixing the share CSP alone
            // would still show broken images (the Phase 0 Safari class). Other
            // types a shared doc may link (e.g. PDFs) load top-level, where the
            // redirect is fine — they keep the cached 302 / direct stream rather
            // than inheriting the proxy's image/font-only gate.
            return serveIndexedAsset(workspaceManager, reply, {
                assetId, filename, repo: row.repo, isHead, size: row.size,
                ...(isProxyableAssetFilename(filename)
                    ? { proxyR2: true, ifNoneMatch: req.headers['if-none-match'] }
                    : {}),
            });
        },
    });
}
//# sourceMappingURL=share-api.js.map