import { ValModules, PatchId, ModuleFilePath, ValConfig } from "@valbuild/core"; import { Api, ServerOf } from "@valbuild/shared/internal"; import { z } from "zod"; import { AuthorId, CommitSha, ValOps, type GenericErrorMessage, type PreparedCommit } from "./ValOps.js"; import { ValOpsHttp } from "./ValOpsHttp.js"; import { type ValPatchStore } from "./ValOpsMemory.js"; export type ValServerOptions = { route: string; valEnableRedirectUrl?: string; valDisableRedirectUrl?: string; formatter?: (code: string, filePath: string) => string | Promise; valBuildUrl?: string; valContentUrl: string; valSecret?: string; apiKey?: string; project?: string; config: ValConfig; /** * Called after a save has applied its patches, with the files it produced. * * EXPERIMENTAL. The seam a host needs when "commit" does not mean "write to * the working tree and let git take it from here". `patchedSourceFiles` is * already path -> content (null = delete), which is what such a host * publishes, so this hands over the thing that already exists rather than * inventing a format. * * It runs AFTER the files are saved, not instead: the save is what makes the * patches consumed, and a host that also wants them elsewhere is adding a * destination, not replacing one. Throwing here fails the save. */ commitPrepared?: (commit: { patchedSourceFiles: Record; }) => Promise; /** * What a publish DOES, in `http` mode. * * EXPERIMENTAL. By default a publish there is a git commit through the * content API — the content service holds the patches, and publishing means * turning them into a commit on the project's repository. A host whose * "publish" is something else entirely (this one builds the site and flips a * pointer) has no way to say so. * * The default is handed over as {@link CommitContext.commitToGit} rather than * simply skipped, and that is the whole point: it lets a host REPLACE the * commit (never call it) or ADD to it (call it, then do its own work with the * files). Those are two genuinely different products — one where the * repository is the source of truth and one where the build is — and this * seam should not decide which. * * Return what the route should report. Throwing fails the publish, with the * patches left where they were. */ publishOverride?: (context: CommitContext) => Promise; }; /** What {@link ValServerOptions.publishOverride} is given. */ export type CommitContext = { /** The files this commit produced: `path -> content`, `null` = delete. */ patchedSourceFiles: Record; /** Everything else the commit knows, for a host that needs more. */ preparedCommit: PreparedCommit; message: string; authorId: AuthorId; /** The patch group this commit empties, if it empties one. */ patchGroupId?: string; /** * The default: a git commit through the content API. * * Call it to publish to the repository as well, or leave it alone to replace * that step. Not calling it means the patches are NOT marked published by the * content API, so a host that replaces the commit owns that too. */ commitToGit: () => Promise; }; /** What a publish reports back, in either shape. */ export type CommitResult = { isNotFastForward?: boolean; updatedFiles: string[]; commit: CommitSha; /** * The commit this one was built on. * * Optional because it is only as available as the content service is * willing to say: a service that predates this field sends nothing, and * `undefined` there means "not reported", never "this commit has no * parent". A host that needs it has to handle its absence rather than * treat it as a root commit. * * What it is FOR: a host that keeps its own record of what each commit * changed -- a build cache, an incremental publisher -- can only tell * whether its record is complete by chaining the commits it holds back to * the one it last built. Without a parent the record is a set of * snapshots with no way to notice a gap, and a commit made by somebody * else in between is silently absent rather than detected. * * Not `CommitSha`-typed for the same reason it is optional: it is * reported by a remote service and validated on arrival, and a branded * type here would suggest this end had checked it. */ parent?: string; /** * The git tree this commit points at. * * Optional for the same reason as {@link parent}. A tree hash identifies * the CONTENT of a commit rather than the commit itself, so two commits * with the same tree are the same source -- which is what lets a host * recognise that a commit it is being asked to build is one it has built * already, under a different sha, and skip the work. */ tree?: string; branch: string; error?: undefined; } | { isNotFastForward?: boolean; error: GenericErrorMessage; }; export type ValServerConfig = ValServerOptions & ({ mode: "fs"; cwd: string; config: ValConfig; } | { mode: "http"; apiKey: string; project: string; /** * The repository this project's commits are mirrored into, if any. * * Absent is a project whose content service is the store of record -- * which is every project that has not attached a repository, and the * normal case. See `git` on {@link ValApiOptions}. */ git?: { commit: string; branch: string; }; root?: string; config: ValConfig; } /** * EXPERIMENTAL -- a host that holds the project's source itself. * * Neither of the other two fits a host that builds and publishes its own * output: `fs` assumes a working tree it can watch and write, and `http` * assumes Val's content service owns the patch chain and that a commit is a * git commit. See {@link ValOpsMemory}, and `commitPrepared` above for * where the publish goes. */ | { mode: "memory"; /** The project's source, by path. */ sourceFiles: Record; /** Where pending patches live. Defaults to memory; see ValPatchStore. */ patchStore?: ValPatchStore; /** See ValServerOverrides. Off by default; memory mode authenticates. */ unsafelyAllowUnauthenticated?: boolean; config: ValConfig; }); export type ValServer = ServerOf; export declare const ValServer: (valModules: ValModules, options: ValServerConfig, callbacks: ValServerCallbacks) => ServerOf; export type ValServerCallbacks = { isEnabled: () => Promise; onEnable: (success: boolean) => Promise; onDisable: (success: boolean) => Promise; }; /** * Refuse to touch a group that is not the caller's. * * Exported for `patchGroupOwnership.test.ts`: this is the whole of the * authorization for stage and unstage, and nothing else in the process checks * it, so it is worth testing as a policy rather than only through a route. * * `getAuth` only proves a session EXISTS; it says nothing about whose * group this is. And the content API cannot decide either — every call * from here carries the app's API key, not the editor's identity — so if * this does not check, nothing does. * * `GET /patches?include_patch_groups=true` hands every editor the id and * author of every open group on the branch, so without this any logged-in * editor can unstage another author's patches (their next publish * silently ships less) or stage into their group (it silently ships * more). The 403 declared for this route in `ApiRoutes.ts` was * unreachable. * * Fails CLOSED: if the groups cannot be read, the mutation is refused * rather than allowed unverified. */ /** * The patches a scoped draft render should apply: the caller's own group, plus * everything already committed. * * Scoping is about PENDING work. A published patch stays in the chain with * `appliedAt` set until the next deployment moves the base, and it is part of * everyone's view in that window — the unscoped path applies it. Dropping it * meant the moment somebody published, their own draft preview reverted the * field they had just shipped, and nobody else saw it either until the deploy * landed; anything written on top in that window is authored against content * already stale on `main`. * * Keyed on `appliedAt` rather than on the group's `publishedAt`, because a * PARTIAL publish leaves the group open with only some of its patches applied. * Those are committed too, and no flag on the group names them. * * `undefined` scope is unscoped and never reaches here; an EMPTY scope is a * caller holding nothing, and on a branch with nothing applied it correctly * filters down to no patches, which renders base. */ export declare function scopedPatches(patches: T[], ownPatchIds: PatchId[] | undefined): T[]; /** * Which of the client's `unstagePatchIds` this server is willing to forward. * * The forward closure of a discard is the client's to compute — it needs the * patch sets, which need the schema — and it was being forwarded verbatim. But * the content API removes those memberships from EVERY group with no ownership * check, so any logged-in editor could strip arbitrary patches out of any other * author's group by attaching them to a delete of one of their own throwaway * patches. That is the outcome the 403 on `/patch-groups` exists to prevent, * reached by a different door: their next publish silently ships less. * * Neither server can compute the true closure, but this one can BOUND it. A * patch can only be invalidated by a delete if it was written after that delete * — its paths were chosen against a view that had it — and if it is in the same * module, since a patch set never spans two. Anything outside those bounds was * not in the closure whatever the client says, so it is dropped rather than * refused: the delete is still correct, and refusing the whole request over an * over-broad extra would turn a discard into an error the user cannot act on. * * Exported for the test. Pure, and given the chain rather than fetching it, so * the ordering it depends on is visible in the test rather than mocked. */ export declare function boundUnstageClosure( /** The pending chain, in chain order, as `fetchPatches` returns it. */ chain: readonly { patchId: PatchId; path: ModuleFilePath; }[], deleted: readonly PatchId[], requested: readonly PatchId[]): PatchId[]; /** * Which pending patches this caller may see, when they asked for "only mine". * * Shared by `/sources/~` and `/json`, and it has to be: a draft page renders * both, so two answers to "whose work is this" put one person's half-finished * edit on another person's preview through whichever route was not scoped. That * is exactly what happened — `/json` applied every pending patch on the branch * while the module content beside it was scoped. * * `undefined` means "apply everything", which is what every caller that does * not ask for scoping gets and must keep getting. */ export declare function resolveOwnPatchScope(serverOps: ValOps, opts: { /** A caller that named a list already knows what it wants. */ explicitPatchIds: PatchId[] | undefined; ownGroupsOnly: boolean; /** `undefined` where there is no session to have one. */ authorId: string | undefined; }): Promise<{ ownPatchIds: PatchId[] | undefined; scopeAlsoIncludesApplied: boolean; }>; export declare function refuseUnlessOwn(ops: ValOpsHttp, patchGroupId: string, authorId: string): Promise<{ status: 403 | 409 | 500; message: string; } | null>; declare const IntegratedServerJwtPayload: z.ZodObject<{ sub: z.ZodString; exp: z.ZodNumber; token: z.ZodString; org: z.ZodString; project: z.ZodString; }, z.core.$strip>; export type IntegratedServerJwtPayload = z.infer; export declare const ENABLE_COOKIE_VALUE: { readonly value: "true"; readonly options: { readonly httpOnly: false; readonly sameSite: "lax"; }; }; export declare function bufferToReadableStream(buffer: Buffer): ReadableStream; export declare function getRedirectUrl(query: { redirect_to?: string | undefined; }, overrideHost: string | undefined): string | { status: 400; json: { message: string; }; }; export declare function guessMimeTypeFromPath(filePath: string): string | null; export {};