@valbuild/server 0.120.4 → 0.122.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,25 @@
1
1
  import { type PatchId, type ModuleFilePath, ValModules } from "@valbuild/core";
2
2
  import type { Patch as PatchT, ParentRef as ParentRefT } from "@valbuild/core/patch";
3
- import { type AuthorId, type BaseSha, BinaryFileType, type CommitSha, GenericErrorMessage, MetadataOfType, OpsMetadata, PreparedCommit, ValOps, ValOpsOptions, WithGenericError, SaveSourceFilePatchResult, SchemaSha, OrderedPatchesMetadata, OrderedPatches, SourcesSha } from "./ValOps.js";
3
+ import { type AuthorId, type BaseSha, BinaryFileType, type CommitSha, GenericErrorMessage, MetadataOfType, OpsMetadata, PreparedCommit, ValOps, ValOpsOptions, WithGenericError, SaveSourceFilePatchResult, type PatchGroupMembership, SchemaSha, OrderedPatchesMetadata, OrderedPatches, SourcesSha } from "./ValOps.js";
4
4
  import { z } from "zod";
5
- import { ParentRef, ValCommit, ValDeployment } from "@valbuild/shared/internal";
5
+ import type { HistoryError } from "./history/HistoryError.js";
6
+ import type { AffectedFile, StoredModuleVersion, CommitPage, CommitPatch, HistoricalCommit } from "./history/types.js";
7
+ import { ParentRef, ValCommit, ValDeployment, type PatchGroupT } from "@valbuild/shared/internal";
8
+ import { result } from "@valbuild/core/fp";
6
9
  declare const PatchId: z.ZodString & z.ZodType<PatchId, string, z.core.$ZodTypeInternals<PatchId, string>>;
7
10
  declare const CommitSha: z.ZodString & z.ZodType<CommitSha, string, z.core.$ZodTypeInternals<CommitSha, string>>;
8
11
  declare const BaseSha: z.ZodString & z.ZodType<BaseSha, string, z.core.$ZodTypeInternals<BaseSha, string>>;
9
12
  declare const AuthorId: z.ZodString & z.ZodType<AuthorId, string, z.core.$ZodTypeInternals<AuthorId, string>>;
10
13
  declare const ModuleFilePath: z.ZodString & z.ZodType<ModuleFilePath, string, z.core.$ZodTypeInternals<ModuleFilePath, string>>;
14
+ export type PatchGroupMutationResult = {
15
+ patchIds: PatchId[];
16
+ status?: undefined;
17
+ error?: undefined;
18
+ } | {
19
+ patchIds: PatchId[];
20
+ status: 403 | 409 | 500;
21
+ error: GenericErrorMessage;
22
+ };
11
23
  export declare class ValOpsHttp extends ValOps {
12
24
  private readonly contentUrl;
13
25
  private readonly project;
@@ -68,6 +80,10 @@ export declare class ValOpsHttp extends ValOps {
68
80
  commits: ValCommit[];
69
81
  deployments: ValDeployment[];
70
82
  patches: PatchId[];
83
+ /** Of `patches`, the ones that have shipped. See the implementation. */
84
+ appliedPatches: PatchId[];
85
+ /** The newest commit, which is the publish head. */
86
+ headCommitSha?: string;
71
87
  } | {
72
88
  type: "error";
73
89
  error: GenericErrorMessage;
@@ -92,7 +108,120 @@ export declare class ValOpsHttp extends ValOps {
92
108
  patchIds?: PatchId[];
93
109
  excludePatchOps: ExcludePatchOps;
94
110
  }): Promise<ExcludePatchOps extends true ? OrderedPatchesMetadata : OrderedPatches>;
95
- protected saveSourceFilePatch(path: ModuleFilePath, patch: PatchT, patchId: PatchId, parentRef: ParentRefT, authorId: AuthorId | null, sessionId: string | null): Promise<SaveSourceFilePatchResult>;
111
+ /**
112
+ * Add patches to a patch group.
113
+ *
114
+ * The set arrives closed by the client — `withPatchIds` is the prefix closure
115
+ * over the patch sets the staged patches belong to. We forward it and do not
116
+ * second-guess it: deriving the closure needs the content schema, which this
117
+ * process does have but content.val.build does not, and having two
118
+ * implementations of the rule would be worse than having one.
119
+ *
120
+ * Membership rows are stamped with `coreVersion` on the content side — the
121
+ * same stamp the patch row carries — so which client wrote a row stays
122
+ * legible after the fact.
123
+ */
124
+ stagePatches(patchGroupId: string,
125
+ /** What the user asked to stage. */
126
+ patchIds: PatchId[],
127
+ /**
128
+ * What has to come with it, because the staged patches are written on top
129
+ * of it.
130
+ *
131
+ * The content API stores each membership row as `explicit` or `dependency`
132
+ * and treats what it is not told about as a dependency. Folding the two
133
+ * halves into `patchIds` therefore files the patch somebody clicked as one
134
+ * the closure dragged in — the exact opposite of what happened, and the
135
+ * only record anywhere of what the author chose.
136
+ */
137
+ withPatchIds: PatchId[],
138
+ /**
139
+ * WHO is asking, so the content API can refuse a group that is not theirs.
140
+ *
141
+ * Every call from this class carries the app's API key, which says which
142
+ * PROJECT is calling and nothing about which editor. Without this the
143
+ * content API cannot tell one of a project's editors from another, so the
144
+ * only check on stage and unstage is the one in `ValServer` — and anything
145
+ * reaching the content API by another route (an API key, a PAT) has none at
146
+ * all.
147
+ *
148
+ * `null` where there is no session. The content API refuses rather than
149
+ * treating that as a match: a group written by an api key has a null author
150
+ * too, and `null === null` must not read as ownership.
151
+ */
152
+ authorId: AuthorId | null): Promise<PatchGroupMutationResult>;
153
+ /**
154
+ * Remove patches from a patch group.
155
+ *
156
+ * The set arrives closed FORWARDS by the client: unstaging a patch also
157
+ * unstages everything built on top of it within its patch sets, and that is
158
+ * what `withPatchIds` carries.
159
+ */
160
+ unstagePatches(patchGroupId: string,
161
+ /** What the user asked to unstage. */
162
+ patchIds: PatchId[],
163
+ /** What has to go with it: everything built on top of it. */
164
+ withPatchIds: PatchId[],
165
+ /** See {@link stagePatches} — the content API's half of the ownership check. */
166
+ authorId: AuthorId | null): Promise<PatchGroupMutationResult>;
167
+ /**
168
+ * Every patch group on this branch, with what each holds.
169
+ *
170
+ * Read rather than mutated, and used to answer "which pending patches is THIS
171
+ * person allowed to see". A draft render that skips this shows base + every
172
+ * pending patch on the branch, including work other people have not
173
+ * published — which is what independent publish exists to prevent.
174
+ *
175
+ * A failure is an empty list rather than a throw, and the caller decides what
176
+ * that means. For a draft render the honest fallback is "show nothing
177
+ * pending" rather than "show everything": being shown your own committed
178
+ * content when the group lookup is down is a worse experience than being
179
+ * shown somebody else's unpublished draft is a bug.
180
+ */
181
+ /**
182
+ * The last group lookup, and when it was made.
183
+ *
184
+ * A draft render calls `getPatchGroups` once per `fetchVal`, in series with
185
+ * the whole-chain fetch, and a page that calls `fetchVal` several times pays
186
+ * the round trip several times. Groups are per branch and change rarely, so a
187
+ * short window removes the multiplier without letting a stage go unseen for
188
+ * meaningfully longer than one render.
189
+ *
190
+ * Deliberately short. This is a read whose staleness decides whose draft
191
+ * content someone sees, so it is a per-request de-duplication rather than a
192
+ * cache: a second render a second later asks again.
193
+ */
194
+ private patchGroupsCache;
195
+ getPatchGroups(options?: {
196
+ /**
197
+ * Ask the content API even if a recent answer is remembered.
198
+ *
199
+ * For the checks that DECIDE something rather than render something.
200
+ * `refuseUnlessOwn` reads this list to say whether a group is yours, and a
201
+ * group is at its youngest exactly when that matters: the first write
202
+ * creates it, the save response names it, and the shell flushes its queued
203
+ * stages immediately — well inside the cache window. Served from a list
204
+ * fetched before the group existed, every one of those was refused 403 and
205
+ * dropped, so the queue that exists to survive the post-publish window
206
+ * persisted nothing in the flow it was built for.
207
+ *
208
+ * Not solved by shortening the window: the cache sits on this instance,
209
+ * which outlives the request, so "recent" is recent for the whole server
210
+ * and not for one render.
211
+ */
212
+ fresh?: boolean;
213
+ }): Promise<{
214
+ status: "ok";
215
+ patchGroups: PatchGroupT[];
216
+ } | {
217
+ status: "unsupported";
218
+ } | {
219
+ status: "error";
220
+ message: string;
221
+ }>;
222
+ private fetchPatchGroups;
223
+ private mutatePatchGroup;
224
+ protected saveSourceFilePatch(path: ModuleFilePath, patch: PatchT, patchId: PatchId, parentRef: ParentRefT, authorId: AuthorId | null, sessionId: string | null, patchGroup?: PatchGroupMembership): Promise<SaveSourceFilePatchResult>;
96
225
  /**
97
226
  * @deprecated For HTTP ops use direct upload instead (i.e. client should upload the files directly) since hosting platforms (Vercel) might have low limits on the size of the request body.
98
227
  */
@@ -108,7 +237,17 @@ export declare class ValOpsHttp extends ValOps {
108
237
  getBase64EncodedBinaryFileFromPatch(filePath: string, patchId: PatchId, remote: boolean): Promise<Buffer | null>;
109
238
  protected getBase64EncodedBinaryFileMetadataFromPatch<T extends "file" | "image">(filePath: string, type: T, patchId: PatchId, remote: boolean): Promise<OpsMetadata<T>>;
110
239
  protected getBinaryFileMetadata<T extends "file" | "image">(filePath: string, type: T): Promise<OpsMetadata<T>>;
111
- deletePatches(patchIds: PatchId[]): Promise<{
240
+ deletePatches(patchIds: PatchId[],
241
+ /**
242
+ * Patches that are NOT deleted but must lose their group membership.
243
+ *
244
+ * Deleting a patch out of the middle of a patch set leaves every group
245
+ * still holding the rest with a non-prefix intersection — the patches after
246
+ * the hole were written against a view that had it. The content API cannot
247
+ * work out which those are (it has no schema), so the client sends the
248
+ * forward closure and it drops those memberships without deleting anything.
249
+ */
250
+ unstagePatchIds?: PatchId[]): Promise<{
112
251
  deleted: PatchId[];
113
252
  errors?: undefined;
114
253
  error?: undefined;
@@ -120,7 +259,22 @@ export declare class ValOpsHttp extends ValOps {
120
259
  errors?: undefined;
121
260
  deleted?: undefined;
122
261
  }>;
123
- commit(prepared: PreparedCommit, message: string, committer: AuthorId, filesDirectory: string, newBranch?: string): Promise<{
262
+ commit(prepared: PreparedCommit, message: string, committer: AuthorId, filesDirectory: string, newBranch?: string,
263
+ /**
264
+ * The patch group this commit EMPTIES, if it empties one.
265
+ *
266
+ * The content API closes the group it is given — and closes it WITHOUT
267
+ * checking that the commit shipped all of it, so a caller that names a
268
+ * group still holding work takes those patches out of every group and
269
+ * leaves their author unable to publish them. The client therefore sends it
270
+ * only when the publish accounts for everything the group still holds.
271
+ *
272
+ * Omitting it is not neutral: the commit still empties the group (the
273
+ * content API drops applied ids from every group), but `published_at` is
274
+ * never set, so the id is reused across publishes instead of a new group
275
+ * per publish and the "already published" refusal can never fire.
276
+ */
277
+ patchGroupId?: string): Promise<{
124
278
  isNotFastForward?: boolean;
125
279
  updatedFiles: string[];
126
280
  commit: CommitSha;
@@ -130,5 +284,32 @@ export declare class ValOpsHttp extends ValOps {
130
284
  isNotFastForward?: boolean;
131
285
  error: GenericErrorMessage;
132
286
  }>;
287
+ /**
288
+ * One GET against the content service, parsed and Result-typed.
289
+ *
290
+ * Every history read has the same three failure modes - could not reach the
291
+ * service, the commit is not there, the answer was not what was expected -
292
+ * and each of them means something different to a caller deciding whether to
293
+ * offer a restore. Doing it once here is what keeps that consistent across
294
+ * the five endpoints.
295
+ */
296
+ private getHistory;
297
+ listCommits(branch: string, options?: {
298
+ limit?: number;
299
+ cursor?: string;
300
+ }): Promise<result.Result<CommitPage, HistoryError>>;
301
+ getCommitPatches(commitSha: string): Promise<result.Result<{
302
+ commit: HistoricalCommit;
303
+ patches: CommitPatch[];
304
+ }, HistoryError>>;
305
+ getCommitModules(commitSha: string, options?: {
306
+ asOf?: boolean;
307
+ moduleFilePath?: ModuleFilePath;
308
+ }): Promise<result.Result<{
309
+ modules: StoredModuleVersion[];
310
+ complete: boolean;
311
+ }, HistoryError>>;
312
+ getCommitAffectedFiles(commitSha: string): Promise<result.Result<AffectedFile[], HistoryError>>;
313
+ getFileAtCommit(commitSha: string, filePath: string, remote: boolean): Promise<result.Result<Buffer, HistoryError>>;
133
314
  }
134
315
  export {};
@@ -1,6 +1,9 @@
1
- import { ValModules, ValConfig } from "@valbuild/core";
1
+ import { ValModules, PatchId, ModuleFilePath, ValConfig } from "@valbuild/core";
2
2
  import { Api, ServerOf } from "@valbuild/shared/internal";
3
3
  import { z } from "zod";
4
+ import { ValOpsFS } from "./ValOpsFS.js";
5
+ import { CommitSha } from "./ValOps.js";
6
+ import { ValOpsHttp } from "./ValOpsHttp.js";
4
7
  export type ValServerOptions = {
5
8
  route: string;
6
9
  valEnableRedirectUrl?: string;
@@ -33,6 +36,108 @@ export type ValServerCallbacks = {
33
36
  onEnable: (success: boolean) => Promise<void>;
34
37
  onDisable: (success: boolean) => Promise<void>;
35
38
  };
39
+ /**
40
+ * Refuse to touch a group that is not the caller's.
41
+ *
42
+ * Exported for `patchGroupOwnership.test.ts`: this is the whole of the
43
+ * authorization for stage and unstage, and nothing else in the process checks
44
+ * it, so it is worth testing as a policy rather than only through a route.
45
+ *
46
+ * `getAuth` only proves a session EXISTS; it says nothing about whose
47
+ * group this is. And the content API cannot decide either — every call
48
+ * from here carries the app's API key, not the editor's identity — so if
49
+ * this does not check, nothing does.
50
+ *
51
+ * `GET /patches?include_patch_groups=true` hands every editor the id and
52
+ * author of every open group on the branch, so without this any logged-in
53
+ * editor can unstage another author's patches (their next publish
54
+ * silently ships less) or stage into their group (it silently ships
55
+ * more). The 403 declared for this route in `ApiRoutes.ts` was
56
+ * unreachable.
57
+ *
58
+ * Fails CLOSED: if the groups cannot be read, the mutation is refused
59
+ * rather than allowed unverified.
60
+ */
61
+ /**
62
+ * The patches a scoped draft render should apply: the caller's own group, plus
63
+ * everything already committed.
64
+ *
65
+ * Scoping is about PENDING work. A published patch stays in the chain with
66
+ * `appliedAt` set until the next deployment moves the base, and it is part of
67
+ * everyone's view in that window — the unscoped path applies it. Dropping it
68
+ * meant the moment somebody published, their own draft preview reverted the
69
+ * field they had just shipped, and nobody else saw it either until the deploy
70
+ * landed; anything written on top in that window is authored against content
71
+ * already stale on `main`.
72
+ *
73
+ * Keyed on `appliedAt` rather than on the group's `publishedAt`, because a
74
+ * PARTIAL publish leaves the group open with only some of its patches applied.
75
+ * Those are committed too, and no flag on the group names them.
76
+ *
77
+ * `undefined` scope is unscoped and never reaches here; an EMPTY scope is a
78
+ * caller holding nothing, and on a branch with nothing applied it correctly
79
+ * filters down to no patches, which renders base.
80
+ */
81
+ export declare function scopedPatches<T extends {
82
+ patchId: PatchId;
83
+ appliedAt: {
84
+ commitSha: CommitSha;
85
+ } | null;
86
+ }>(patches: T[], ownPatchIds: PatchId[] | undefined): T[];
87
+ /**
88
+ * Which of the client's `unstagePatchIds` this server is willing to forward.
89
+ *
90
+ * The forward closure of a discard is the client's to compute — it needs the
91
+ * patch sets, which need the schema — and it was being forwarded verbatim. But
92
+ * the content API removes those memberships from EVERY group with no ownership
93
+ * check, so any logged-in editor could strip arbitrary patches out of any other
94
+ * author's group by attaching them to a delete of one of their own throwaway
95
+ * patches. That is the outcome the 403 on `/patch-groups` exists to prevent,
96
+ * reached by a different door: their next publish silently ships less.
97
+ *
98
+ * Neither server can compute the true closure, but this one can BOUND it. A
99
+ * patch can only be invalidated by a delete if it was written after that delete
100
+ * — its paths were chosen against a view that had it — and if it is in the same
101
+ * module, since a patch set never spans two. Anything outside those bounds was
102
+ * not in the closure whatever the client says, so it is dropped rather than
103
+ * refused: the delete is still correct, and refusing the whole request over an
104
+ * over-broad extra would turn a discard into an error the user cannot act on.
105
+ *
106
+ * Exported for the test. Pure, and given the chain rather than fetching it, so
107
+ * the ordering it depends on is visible in the test rather than mocked.
108
+ */
109
+ export declare function boundUnstageClosure(
110
+ /** The pending chain, in chain order, as `fetchPatches` returns it. */
111
+ chain: readonly {
112
+ patchId: PatchId;
113
+ path: ModuleFilePath;
114
+ }[], deleted: readonly PatchId[], requested: readonly PatchId[]): PatchId[];
115
+ /**
116
+ * Which pending patches this caller may see, when they asked for "only mine".
117
+ *
118
+ * Shared by `/sources/~` and `/json`, and it has to be: a draft page renders
119
+ * both, so two answers to "whose work is this" put one person's half-finished
120
+ * edit on another person's preview through whichever route was not scoped. That
121
+ * is exactly what happened — `/json` applied every pending patch on the branch
122
+ * while the module content beside it was scoped.
123
+ *
124
+ * `undefined` means "apply everything", which is what every caller that does
125
+ * not ask for scoping gets and must keep getting.
126
+ */
127
+ export declare function resolveOwnPatchScope(serverOps: ValOpsFS | ValOpsHttp, opts: {
128
+ /** A caller that named a list already knows what it wants. */
129
+ explicitPatchIds: PatchId[] | undefined;
130
+ ownGroupsOnly: boolean;
131
+ /** `undefined` where there is no session to have one. */
132
+ authorId: string | undefined;
133
+ }): Promise<{
134
+ ownPatchIds: PatchId[] | undefined;
135
+ scopeAlsoIncludesApplied: boolean;
136
+ }>;
137
+ export declare function refuseUnlessOwn(ops: ValOpsHttp, patchGroupId: string, authorId: string): Promise<{
138
+ status: 403 | 409 | 500;
139
+ message: string;
140
+ } | null>;
36
141
  declare const IntegratedServerJwtPayload: z.ZodObject<{
37
142
  sub: z.ZodString;
38
143
  exp: z.ZodNumber;