@valbuild/server 0.120.3 → 0.121.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.
- package/CHANGELOG.md +117 -0
- package/dist/declarations/src/Service.d.ts +15 -0
- package/dist/declarations/src/ValOps.d.ts +89 -3
- package/dist/declarations/src/ValOpsFS.d.ts +2 -2
- package/dist/declarations/src/ValOpsHttp.d.ts +156 -5
- package/dist/declarations/src/ValServer.d.ts +106 -1
- package/dist/valbuild-server.cjs.dev.js +1294 -27
- package/dist/valbuild-server.cjs.prod.js +1294 -27
- package/dist/valbuild-server.esm.js +1295 -28
- package/package.json +4 -4
|
@@ -8,7 +8,7 @@ import path__default from 'path';
|
|
|
8
8
|
import fs, { promises } from 'fs';
|
|
9
9
|
import vm from 'node:vm';
|
|
10
10
|
import { Module, createRequire } from 'node:module';
|
|
11
|
-
import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api, filterBlockingValidationErrors, describeContainerAtPath, safeParsePatch, buildDuplicatePatch, buildEmptyAtPathPatch, buildRemoveImageGalleryEntryPatch } from '@valbuild/shared/internal';
|
|
11
|
+
import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, PatchGroup, newestCommitSha, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api, filterBlockingValidationErrors, describeContainerAtPath, safeParsePatch, buildDuplicatePatch, buildEmptyAtPathPatch, buildRemoveImageGalleryEntryPatch } from '@valbuild/shared/internal';
|
|
12
12
|
import { createUIRequestHandler } from '@valbuild/ui/server';
|
|
13
13
|
import crypto$1 from 'crypto';
|
|
14
14
|
import z$1, { z } from 'zod';
|
|
@@ -1665,6 +1665,21 @@ class Service {
|
|
|
1665
1665
|
getModuleFilePaths() {
|
|
1666
1666
|
return Object.keys(this.extracted.sources);
|
|
1667
1667
|
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Everything that is wrong with how the project's modules are DECLARED, as
|
|
1671
|
+
* opposed to what is in them.
|
|
1672
|
+
*
|
|
1673
|
+
* A module that failed to load is here, and so is a rule that spans the whole
|
|
1674
|
+
* set — "one settings module, at the root" cannot be checked while looking at
|
|
1675
|
+
* a single module, so `extractValModules` appends it with the offending path.
|
|
1676
|
+
* `get` only surfaces these when the module is missing entirely, which a
|
|
1677
|
+
* misplaced settings module is not: `val validate` reads them from here and
|
|
1678
|
+
* reports them against the file.
|
|
1679
|
+
*/
|
|
1680
|
+
getModuleErrors() {
|
|
1681
|
+
return this.extracted.moduleErrors;
|
|
1682
|
+
}
|
|
1668
1683
|
serializedSchemaOf(moduleFilePath) {
|
|
1669
1684
|
return this.extracted.serializedSchemas[moduleFilePath];
|
|
1670
1685
|
}
|
|
@@ -2362,7 +2377,8 @@ class ValOps {
|
|
|
2362
2377
|
* in-flight client patches the server has not seen) must pass
|
|
2363
2378
|
* `applyPatches: false` or the same edits would be applied twice.
|
|
2364
2379
|
*/
|
|
2365
|
-
async getJsonEntry(moduleFilePath, entryKey,
|
|
2380
|
+
async getJsonEntry(moduleFilePath, entryKey, /** Passed straight through — see {@link getJsonEntries}. */
|
|
2381
|
+
opts) {
|
|
2366
2382
|
const res = await this.getJsonEntries(moduleFilePath, {
|
|
2367
2383
|
keys: [entryKey]
|
|
2368
2384
|
}, opts);
|
|
@@ -2456,7 +2472,7 @@ class ValOps {
|
|
|
2456
2472
|
message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
|
|
2457
2473
|
};
|
|
2458
2474
|
}
|
|
2459
|
-
modulePatches = patchOps.patches
|
|
2475
|
+
modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds).map(p => ({
|
|
2460
2476
|
patchId: p.patchId,
|
|
2461
2477
|
patch: p.patch
|
|
2462
2478
|
}));
|
|
@@ -3716,8 +3732,21 @@ class ValOps {
|
|
|
3716
3732
|
}
|
|
3717
3733
|
|
|
3718
3734
|
// #region createPatch
|
|
3719
|
-
async createPatch(path, patch, patchId, parentRef, sessionId, authorId
|
|
3720
|
-
|
|
3735
|
+
async createPatch(path, patch, patchId, parentRef, sessionId, authorId,
|
|
3736
|
+
/**
|
|
3737
|
+
* Which patch group this patch joins, recorded in the SAME request.
|
|
3738
|
+
*
|
|
3739
|
+
* Atomic on purpose. The content API runs every refusal before its insert,
|
|
3740
|
+
* so an invalid closure is a 400 with nothing written. Recording membership
|
|
3741
|
+
* in a second call would let a patch exist outside its author's group if
|
|
3742
|
+
* that call failed — and a patch outside your own group is one you cannot
|
|
3743
|
+
* publish until a repair puts it back.
|
|
3744
|
+
*
|
|
3745
|
+
* Optional: `fs` mode has no groups, and a client that predates them sends
|
|
3746
|
+
* nothing.
|
|
3747
|
+
*/
|
|
3748
|
+
patchGroup) {
|
|
3749
|
+
const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup);
|
|
3721
3750
|
if (result.isErr(saveRes)) {
|
|
3722
3751
|
console.error(`Could not save source patch at path: '${path}'. Error: ${saveRes.error.errorType === "other" ? saveRes.error.message : saveRes.error.errorType}`);
|
|
3723
3752
|
if (saveRes.error.errorType === "patch-head-conflict") {
|
|
@@ -3732,7 +3761,12 @@ class ValOps {
|
|
|
3732
3761
|
}
|
|
3733
3762
|
return result.ok({
|
|
3734
3763
|
patchId,
|
|
3735
|
-
createdAt: new Date().toISOString()
|
|
3764
|
+
createdAt: new Date().toISOString(),
|
|
3765
|
+
// Spread rather than assigned: absent has to stay distinguishable from a
|
|
3766
|
+
// group whose id is undefined, all the way to the client.
|
|
3767
|
+
...(saveRes.value.patchGroupId !== undefined ? {
|
|
3768
|
+
patchGroupId: saveRes.value.patchGroupId
|
|
3769
|
+
} : {})
|
|
3736
3770
|
});
|
|
3737
3771
|
}
|
|
3738
3772
|
|
|
@@ -3755,6 +3789,21 @@ function isOnlyFileCheckValidationError(validationError) {
|
|
|
3755
3789
|
function isFileSource(value) {
|
|
3756
3790
|
return typeof value === "object" && value !== null && "path" in value && typeof value.path === "string";
|
|
3757
3791
|
}
|
|
3792
|
+
|
|
3793
|
+
/**
|
|
3794
|
+
* The patch group a newly created patch joins.
|
|
3795
|
+
*
|
|
3796
|
+
* `withPatchIds` is the CLOSURE the client computed — the patches that share
|
|
3797
|
+
* a patch set with this one and must move with it. It is not derived here and
|
|
3798
|
+
* must not be: the closure needs the content schema, and the service that
|
|
3799
|
+
* stores groups does not have it. One implementation of that rule, on the side
|
|
3800
|
+
* that can actually compute it.
|
|
3801
|
+
*
|
|
3802
|
+
* Membership rows are stamped with `coreVersion` on the content side, the same
|
|
3803
|
+
* stamp the patch row itself carries, so which client wrote a row stays legible
|
|
3804
|
+
* after the fact.
|
|
3805
|
+
*/
|
|
3806
|
+
|
|
3758
3807
|
function formatPatchSourceError(error) {
|
|
3759
3808
|
if ("message" in error) {
|
|
3760
3809
|
return error.message;
|
|
@@ -3765,6 +3814,33 @@ function formatPatchSourceError(error) {
|
|
|
3765
3814
|
return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
|
|
3766
3815
|
}
|
|
3767
3816
|
}
|
|
3817
|
+
/**
|
|
3818
|
+
* The patches a json entry render should apply, out of the whole chain.
|
|
3819
|
+
*
|
|
3820
|
+
* Three rules, and the second is the one that was missing. A draft page renders
|
|
3821
|
+
* `jsonValues` entries beside module content, and only the modules were scoped
|
|
3822
|
+
* — so one screen showed the caller's own view for its modules and base plus
|
|
3823
|
+
* EVERY pending patch on the branch for the entries beside them, including
|
|
3824
|
+
* another author's half-finished edit rendered as though it were live.
|
|
3825
|
+
*
|
|
3826
|
+
* 1. this module's, since the chain is branch-wide;
|
|
3827
|
+
* 2. this caller's, when they asked to be scoped. `undefined` is "everything",
|
|
3828
|
+
* which is what every unscoped caller gets and must keep getting;
|
|
3829
|
+
* 3. not already applied — a fact about this path rather than about scoping,
|
|
3830
|
+
* and true with or without a scope.
|
|
3831
|
+
*
|
|
3832
|
+
* Filtered here rather than by asking `fetchPatches` for a list, and that is
|
|
3833
|
+
* load-bearing: both implementations read an empty `patchIds` as "no filter"
|
|
3834
|
+
* and return the whole chain. That is the right default for a caller that
|
|
3835
|
+
* cannot mean "none", and the most dangerous possible reading of a group that
|
|
3836
|
+
* is genuinely empty — it would render every unpublished patch on the branch
|
|
3837
|
+
* instead of base. It costs no round trip either: the whole chain is what the
|
|
3838
|
+
* unscoped path fetches anyway.
|
|
3839
|
+
*/
|
|
3840
|
+
function scopedModulePatches(patches, moduleFilePath, patchIds) {
|
|
3841
|
+
const scope = patchIds && new Set(patchIds);
|
|
3842
|
+
return patches.filter(patch => patch.path === moduleFilePath && !patch.appliedAt && (scope === undefined || scope.has(patch.patchId)));
|
|
3843
|
+
}
|
|
3768
3844
|
function getFieldsForType(type) {
|
|
3769
3845
|
if (type === "file") {
|
|
3770
3846
|
return ["mimeType"];
|
|
@@ -5795,7 +5871,17 @@ class ValOpsFS extends ValOps {
|
|
|
5795
5871
|
};
|
|
5796
5872
|
}
|
|
5797
5873
|
}
|
|
5798
|
-
async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId
|
|
5874
|
+
async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId,
|
|
5875
|
+
/*
|
|
5876
|
+
* Named and ignored, rather than omitted from the signature.
|
|
5877
|
+
*
|
|
5878
|
+
* `fs` mode has no shared store and exactly one author, so there is nothing
|
|
5879
|
+
* for a group to separate — every pending patch is already this person's.
|
|
5880
|
+
* Declaring it makes that a decision a reader can see: TypeScript lets an
|
|
5881
|
+
* implementation take fewer parameters than the abstract, so leaving it off
|
|
5882
|
+
* would drop group membership silently and look identical to handling it.
|
|
5883
|
+
*/
|
|
5884
|
+
_patchGroup) {
|
|
5799
5885
|
const patchesDir = this.getPatchesDir();
|
|
5800
5886
|
const record = {
|
|
5801
5887
|
patch,
|
|
@@ -6552,7 +6638,14 @@ const FilesResponse = z.object({
|
|
|
6552
6638
|
})])).optional()
|
|
6553
6639
|
});
|
|
6554
6640
|
const SavePatchResponse = z.object({
|
|
6555
|
-
patchId: PatchId
|
|
6641
|
+
patchId: PatchId,
|
|
6642
|
+
/**
|
|
6643
|
+
* Which group the content API put this patch in.
|
|
6644
|
+
*
|
|
6645
|
+
* Optional: a content API that predates patch groups does not send one, and
|
|
6646
|
+
* absence has to keep meaning "no groups here" rather than failing the save.
|
|
6647
|
+
*/
|
|
6648
|
+
patchGroupId: z.string().optional()
|
|
6556
6649
|
});
|
|
6557
6650
|
const DeletePatchesResponse = z.object({
|
|
6558
6651
|
deleted: z.array(PatchId),
|
|
@@ -6570,10 +6663,33 @@ const CommitResponse = z.object({
|
|
|
6570
6663
|
commit: CommitSha,
|
|
6571
6664
|
branch: z.string()
|
|
6572
6665
|
});
|
|
6666
|
+
/*
|
|
6667
|
+
* The shared schema, not a copy of it.
|
|
6668
|
+
*
|
|
6669
|
+
* This re-declared `PatchGroup` field for field while the file already imported
|
|
6670
|
+
* `PatchGroupT` from the same module — so a field added on one side and not the
|
|
6671
|
+
* other would have `getPatchGroups()` return a `PatchGroupT[]` silently missing
|
|
6672
|
+
* it, with no type error anywhere.
|
|
6673
|
+
*/
|
|
6674
|
+
const PatchGroupsResponse = z.object({
|
|
6675
|
+
patchGroups: z.array(PatchGroup)
|
|
6676
|
+
});
|
|
6677
|
+
const PatchGroupMutationResponse = z.object({
|
|
6678
|
+
patchGroupId: z.string(),
|
|
6679
|
+
patchIds: z.array(PatchId)
|
|
6680
|
+
});
|
|
6573
6681
|
const NonceResponse = z.object({
|
|
6574
6682
|
nonce: z.string(),
|
|
6575
6683
|
url: z.string()
|
|
6576
6684
|
});
|
|
6685
|
+
|
|
6686
|
+
/**
|
|
6687
|
+
* How long a patch-group lookup is reused. See `ValOpsHttp.patchGroupsCache`.
|
|
6688
|
+
*
|
|
6689
|
+
* Sized to cover one server render, not to be a cache: several `fetchVal` calls
|
|
6690
|
+
* in one request share an answer, and the next request asks again.
|
|
6691
|
+
*/
|
|
6692
|
+
const PATCH_GROUPS_CACHE_MS = 1000;
|
|
6577
6693
|
class ValOpsHttp extends ValOps {
|
|
6578
6694
|
constructor(contentUrl, project, commitSha,
|
|
6579
6695
|
// TODO: CommitSha
|
|
@@ -6733,8 +6849,26 @@ class ValOpsHttp extends ValOps {
|
|
|
6733
6849
|
}
|
|
6734
6850
|
}
|
|
6735
6851
|
const patches = [];
|
|
6852
|
+
/*
|
|
6853
|
+
* Which of them have SHIPPED, alongside which of them exist.
|
|
6854
|
+
*
|
|
6855
|
+
* A published patch stays in the chain with `appliedAt` set until the next
|
|
6856
|
+
* deployment moves the base, so "in the chain" and "has shipped" are
|
|
6857
|
+
* different questions — and the chain ids alone answer only the first. A
|
|
6858
|
+
* client that already holds a record never re-fetches it, so it never
|
|
6859
|
+
* learns the second: another author's publish left that patch in your scope
|
|
6860
|
+
* as pending, your prefix gate read a hole in front of it, and Publish
|
|
6861
|
+
* refused for a reason that had stopped being true.
|
|
6862
|
+
*
|
|
6863
|
+
* Sent as ids rather than folded into `patches`, so a client that ignores
|
|
6864
|
+
* it behaves exactly as before.
|
|
6865
|
+
*/
|
|
6866
|
+
const appliedPatches = [];
|
|
6736
6867
|
for (const patchData of allPatchData.patches) {
|
|
6737
6868
|
patches.push(patchData.patchId);
|
|
6869
|
+
if (patchData.appliedAt) {
|
|
6870
|
+
appliedPatches.push(patchData.patchId);
|
|
6871
|
+
}
|
|
6738
6872
|
}
|
|
6739
6873
|
const webSocketNonceRes = await this.getWebSocketNonce(params.profileId);
|
|
6740
6874
|
if (webSocketNonceRes.status === "error") {
|
|
@@ -6757,6 +6891,16 @@ class ValOpsHttp extends ValOps {
|
|
|
6757
6891
|
commits: allPatchData.commits || [],
|
|
6758
6892
|
deployments: allPatchData.deployments || [],
|
|
6759
6893
|
patches,
|
|
6894
|
+
appliedPatches,
|
|
6895
|
+
/*
|
|
6896
|
+
* The PUBLISH head, which is not `commitSha`.
|
|
6897
|
+
*
|
|
6898
|
+
* `commitSha` is the commit this deployment is serving and does not move
|
|
6899
|
+
* when somebody publishes — only when the new build lands. This does, so
|
|
6900
|
+
* it is what a client carries back to `/save` to say which world it
|
|
6901
|
+
* decided against.
|
|
6902
|
+
*/
|
|
6903
|
+
headCommitSha: newestCommitSha(allPatchData.commits) ?? undefined,
|
|
6760
6904
|
commitSha: this.commitSha
|
|
6761
6905
|
};
|
|
6762
6906
|
}
|
|
@@ -6837,6 +6981,26 @@ class ValOpsHttp extends ValOps {
|
|
|
6837
6981
|
}
|
|
6838
6982
|
const allPatches = [];
|
|
6839
6983
|
const allErrors = [];
|
|
6984
|
+
/*
|
|
6985
|
+
* The commits, which are a fact about the whole BRANCH, not about a chunk.
|
|
6986
|
+
*
|
|
6987
|
+
* This loop used to return only `patches` and `errors`, so a filtered fetch
|
|
6988
|
+
* silently answered with no commits at all. That is not cosmetic:
|
|
6989
|
+
* the publish-head guard in `ValServer` reads `newestCommitSha(commits)`,
|
|
6990
|
+
* got `undefined` for every publish (a publish always names patch ids, so
|
|
6991
|
+
* it always takes this branch), and skipped the check entirely. Two clients
|
|
6992
|
+
* could publish against the same head with neither told.
|
|
6993
|
+
*
|
|
6994
|
+
* Taken from the first chunk that carries them, which is sound for the
|
|
6995
|
+
* reason the dedupe below exists: each chunk's response describes the whole
|
|
6996
|
+
* chain regardless of which ids it asked about.
|
|
6997
|
+
*
|
|
6998
|
+
* `deployments` is not carried, because it is declared only on
|
|
6999
|
+
* `OrderedPatchesMetadata` and this is generic over both shapes. Nothing
|
|
7000
|
+
* reads it from a filtered fetch today; if something starts to, it needs
|
|
7001
|
+
* the same treatment and a home on `OrderedPatches` first.
|
|
7002
|
+
*/
|
|
7003
|
+
let commits;
|
|
6840
7004
|
if (patchIds === undefined || patchIds.length === 0) {
|
|
6841
7005
|
return this.fetchPatchesInternal({
|
|
6842
7006
|
patchIds: patchIds,
|
|
@@ -6854,6 +7018,9 @@ class ValOpsHttp extends ValOps {
|
|
|
6854
7018
|
if (res.errors) {
|
|
6855
7019
|
allErrors.push(...res.errors);
|
|
6856
7020
|
}
|
|
7021
|
+
if (commits === undefined && res.commits !== undefined) {
|
|
7022
|
+
commits = res.commits;
|
|
7023
|
+
}
|
|
6857
7024
|
}
|
|
6858
7025
|
// Chunking is a query-string-length workaround, NOT a filter: the content
|
|
6859
7026
|
// api returns every applicable patch per request regardless of which
|
|
@@ -6880,7 +7047,13 @@ class ValOpsHttp extends ValOps {
|
|
|
6880
7047
|
});
|
|
6881
7048
|
return {
|
|
6882
7049
|
patches,
|
|
6883
|
-
errors: Object.keys(allErrors).length > 0 ? allErrors : undefined
|
|
7050
|
+
errors: Object.keys(allErrors).length > 0 ? allErrors : undefined,
|
|
7051
|
+
// Spread rather than set to `undefined`, so a caller that distinguishes
|
|
7052
|
+
// "absent" from "empty" — `newestCommitSha` does not, but the annotation
|
|
7053
|
+
// readers do — sees the same shape the unchunked path gives it.
|
|
7054
|
+
...(commits !== undefined ? {
|
|
7055
|
+
commits
|
|
7056
|
+
} : {})
|
|
6884
7057
|
};
|
|
6885
7058
|
}
|
|
6886
7059
|
async fetchPatchesInternal(filters) {
|
|
@@ -6988,7 +7161,281 @@ class ValOpsHttp extends ValOps {
|
|
|
6988
7161
|
};
|
|
6989
7162
|
}
|
|
6990
7163
|
}
|
|
6991
|
-
|
|
7164
|
+
|
|
7165
|
+
// #region patch groups
|
|
7166
|
+
/**
|
|
7167
|
+
* Add patches to a patch group.
|
|
7168
|
+
*
|
|
7169
|
+
* The set arrives closed by the client — `withPatchIds` is the prefix closure
|
|
7170
|
+
* over the patch sets the staged patches belong to. We forward it and do not
|
|
7171
|
+
* second-guess it: deriving the closure needs the content schema, which this
|
|
7172
|
+
* process does have but content.val.build does not, and having two
|
|
7173
|
+
* implementations of the rule would be worse than having one.
|
|
7174
|
+
*
|
|
7175
|
+
* Membership rows are stamped with `coreVersion` on the content side — the
|
|
7176
|
+
* same stamp the patch row carries — so which client wrote a row stays
|
|
7177
|
+
* legible after the fact.
|
|
7178
|
+
*/
|
|
7179
|
+
async stagePatches(patchGroupId, /** What the user asked to stage. */
|
|
7180
|
+
patchIds,
|
|
7181
|
+
/**
|
|
7182
|
+
* What has to come with it, because the staged patches are written on top
|
|
7183
|
+
* of it.
|
|
7184
|
+
*
|
|
7185
|
+
* The content API stores each membership row as `explicit` or `dependency`
|
|
7186
|
+
* and treats what it is not told about as a dependency. Folding the two
|
|
7187
|
+
* halves into `patchIds` therefore files the patch somebody clicked as one
|
|
7188
|
+
* the closure dragged in — the exact opposite of what happened, and the
|
|
7189
|
+
* only record anywhere of what the author chose.
|
|
7190
|
+
*/
|
|
7191
|
+
withPatchIds,
|
|
7192
|
+
/**
|
|
7193
|
+
* WHO is asking, so the content API can refuse a group that is not theirs.
|
|
7194
|
+
*
|
|
7195
|
+
* Every call from this class carries the app's API key, which says which
|
|
7196
|
+
* PROJECT is calling and nothing about which editor. Without this the
|
|
7197
|
+
* content API cannot tell one of a project's editors from another, so the
|
|
7198
|
+
* only check on stage and unstage is the one in `ValServer` — and anything
|
|
7199
|
+
* reaching the content API by another route (an API key, a PAT) has none at
|
|
7200
|
+
* all.
|
|
7201
|
+
*
|
|
7202
|
+
* `null` where there is no session. The content API refuses rather than
|
|
7203
|
+
* treating that as a match: a group written by an api key has a null author
|
|
7204
|
+
* too, and `null === null` must not read as ownership.
|
|
7205
|
+
*/
|
|
7206
|
+
authorId) {
|
|
7207
|
+
return this.mutatePatchGroup("POST",
|
|
7208
|
+
// Encoded: patchGroupId arrives in a request body, so an unencoded value
|
|
7209
|
+
// like "../../commit" would reach a different endpoint carrying this
|
|
7210
|
+
// project's auth headers.
|
|
7211
|
+
`patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
|
|
7212
|
+
patchIds,
|
|
7213
|
+
withPatchIds,
|
|
7214
|
+
coreVersion: Internal.VERSION.core
|
|
7215
|
+
}, authorId);
|
|
7216
|
+
}
|
|
7217
|
+
|
|
7218
|
+
/**
|
|
7219
|
+
* Remove patches from a patch group.
|
|
7220
|
+
*
|
|
7221
|
+
* The set arrives closed FORWARDS by the client: unstaging a patch also
|
|
7222
|
+
* unstages everything built on top of it within its patch sets, and that is
|
|
7223
|
+
* what `withPatchIds` carries.
|
|
7224
|
+
*/
|
|
7225
|
+
async unstagePatches(patchGroupId, /** What the user asked to unstage. */
|
|
7226
|
+
patchIds, /** What has to go with it: everything built on top of it. */
|
|
7227
|
+
withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
|
|
7228
|
+
authorId) {
|
|
7229
|
+
return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
|
|
7230
|
+
patchIds,
|
|
7231
|
+
withPatchIds
|
|
7232
|
+
}, authorId);
|
|
7233
|
+
}
|
|
7234
|
+
|
|
7235
|
+
/**
|
|
7236
|
+
* Every patch group on this branch, with what each holds.
|
|
7237
|
+
*
|
|
7238
|
+
* Read rather than mutated, and used to answer "which pending patches is THIS
|
|
7239
|
+
* person allowed to see". A draft render that skips this shows base + every
|
|
7240
|
+
* pending patch on the branch, including work other people have not
|
|
7241
|
+
* published — which is what independent publish exists to prevent.
|
|
7242
|
+
*
|
|
7243
|
+
* A failure is an empty list rather than a throw, and the caller decides what
|
|
7244
|
+
* that means. For a draft render the honest fallback is "show nothing
|
|
7245
|
+
* pending" rather than "show everything": being shown your own committed
|
|
7246
|
+
* content when the group lookup is down is a worse experience than being
|
|
7247
|
+
* shown somebody else's unpublished draft is a bug.
|
|
7248
|
+
*/
|
|
7249
|
+
/**
|
|
7250
|
+
* The last group lookup, and when it was made.
|
|
7251
|
+
*
|
|
7252
|
+
* A draft render calls `getPatchGroups` once per `fetchVal`, in series with
|
|
7253
|
+
* the whole-chain fetch, and a page that calls `fetchVal` several times pays
|
|
7254
|
+
* the round trip several times. Groups are per branch and change rarely, so a
|
|
7255
|
+
* short window removes the multiplier without letting a stage go unseen for
|
|
7256
|
+
* meaningfully longer than one render.
|
|
7257
|
+
*
|
|
7258
|
+
* Deliberately short. This is a read whose staleness decides whose draft
|
|
7259
|
+
* content someone sees, so it is a per-request de-duplication rather than a
|
|
7260
|
+
* cache: a second render a second later asks again.
|
|
7261
|
+
*/
|
|
7262
|
+
patchGroupsCache = null;
|
|
7263
|
+
async getPatchGroups(options) {
|
|
7264
|
+
const now = Date.now();
|
|
7265
|
+
if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
|
|
7266
|
+
return this.patchGroupsCache.res;
|
|
7267
|
+
}
|
|
7268
|
+
const res = await this.fetchPatchGroups();
|
|
7269
|
+
/*
|
|
7270
|
+
* ANSWERS are cached; failures are not.
|
|
7271
|
+
*
|
|
7272
|
+
* A transient failure held for a second is replayed to every caller in it,
|
|
7273
|
+
* and the callers are not equivalent: `refuseUnlessOwn` turns it into a 500
|
|
7274
|
+
* that refuses the stage, a scoped draft render falls back to base and
|
|
7275
|
+
* drops every pending patch on the page, and `GET /patches` omits the
|
|
7276
|
+
* annotation. One flaky request became a second of all three. The point of
|
|
7277
|
+
* this cache is to collapse the several `fetchVal` calls in one render into
|
|
7278
|
+
* one round trip, and an error is exactly the case worth retrying inside
|
|
7279
|
+
* that window rather than the case worth remembering.
|
|
7280
|
+
*
|
|
7281
|
+
* `unsupported` is cached with `ok` deliberately: it is a real answer about
|
|
7282
|
+
* the deployment — this content API predates patch groups — and it will not
|
|
7283
|
+
* change between two renders.
|
|
7284
|
+
*/
|
|
7285
|
+
if (res.status === "ok" || res.status === "unsupported") {
|
|
7286
|
+
this.patchGroupsCache = {
|
|
7287
|
+
at: now,
|
|
7288
|
+
res
|
|
7289
|
+
};
|
|
7290
|
+
}
|
|
7291
|
+
return res;
|
|
7292
|
+
}
|
|
7293
|
+
async fetchPatchGroups() {
|
|
7294
|
+
try {
|
|
7295
|
+
/*
|
|
7296
|
+
* `branch` is REQUIRED by the endpoint, which answers 400 without it.
|
|
7297
|
+
*
|
|
7298
|
+
* Same two params every other read here sends (`fetchPatchesInternal`,
|
|
7299
|
+
* `saveSourceFilePatch`): groups are per branch, so a request without one
|
|
7300
|
+
* is not merely under-specified, it is rejected.
|
|
7301
|
+
*/
|
|
7302
|
+
const params = new URLSearchParams([["branch", this.branch]]);
|
|
7303
|
+
const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
|
|
7304
|
+
headers: this.authHeaders
|
|
7305
|
+
});
|
|
7306
|
+
if (res.status === 404) {
|
|
7307
|
+
/*
|
|
7308
|
+
* The endpoint is not there, which is a content API that PREDATES patch
|
|
7309
|
+
* groups — not a failure.
|
|
7310
|
+
*
|
|
7311
|
+
* The distinction decides what a draft render shows, and collapsing it
|
|
7312
|
+
* into "error" is not a small mistake: a caller that reads an error as
|
|
7313
|
+
* "could not ask" renders BASE, so every existing http deployment would
|
|
7314
|
+
* silently drop all pending content from every draft preview. "There
|
|
7315
|
+
* are no groups here" has to mean unscoped, which is exactly the
|
|
7316
|
+
* behaviour those projects have today.
|
|
7317
|
+
*/
|
|
7318
|
+
return {
|
|
7319
|
+
status: "unsupported"
|
|
7320
|
+
};
|
|
7321
|
+
}
|
|
7322
|
+
if (!res.ok) {
|
|
7323
|
+
return {
|
|
7324
|
+
status: "error",
|
|
7325
|
+
message: res.status === 401 ? "Could not read patch groups: unauthorized. Verify that the val api keys are correct." : `Could not read patch groups. HTTP error: ${res.status} ${res.statusText}`
|
|
7326
|
+
};
|
|
7327
|
+
}
|
|
7328
|
+
const parsed = PatchGroupsResponse.safeParse(await res.json());
|
|
7329
|
+
if (!parsed.success) {
|
|
7330
|
+
return {
|
|
7331
|
+
status: "error",
|
|
7332
|
+
message: `Could not parse patch groups response. Error: ${fromError(parsed.error)}`
|
|
7333
|
+
};
|
|
7334
|
+
}
|
|
7335
|
+
return {
|
|
7336
|
+
status: "ok",
|
|
7337
|
+
patchGroups: parsed.data.patchGroups
|
|
7338
|
+
};
|
|
7339
|
+
} catch (err) {
|
|
7340
|
+
return {
|
|
7341
|
+
status: "error",
|
|
7342
|
+
message: `Could not read patch groups. Error: ${err instanceof Error ? err.message : String(err)}`
|
|
7343
|
+
};
|
|
7344
|
+
}
|
|
7345
|
+
}
|
|
7346
|
+
async mutatePatchGroup(method, path, body,
|
|
7347
|
+
/**
|
|
7348
|
+
* WHO is asking. Sent as `x-val-profile-id`, which is what the content API
|
|
7349
|
+
* reads to decide whether this group is the caller's.
|
|
7350
|
+
*
|
|
7351
|
+
* `this.authHeaders` is the app's API key, and that names the PROJECT, not
|
|
7352
|
+
* the person — so without this the content API cannot resolve a profile
|
|
7353
|
+
* and refuses every stage and unstage with
|
|
7354
|
+
* "Cannot resolve the caller's profile". The group endpoints are the only
|
|
7355
|
+
* ones here that need it, because they are the only ones whose answer
|
|
7356
|
+
* depends on which of a project's editors is calling.
|
|
7357
|
+
*
|
|
7358
|
+
* Omitted when there is no session rather than sent empty: the content API
|
|
7359
|
+
* treats an unidentified caller as a refusal, which is what we want, and an
|
|
7360
|
+
* empty header would be a different and less obvious way to say it.
|
|
7361
|
+
*
|
|
7362
|
+
* A PAT already identifies a person, so `authHeaders` carries the identity
|
|
7363
|
+
* on its own there and this adds nothing.
|
|
7364
|
+
*/
|
|
7365
|
+
authorId) {
|
|
7366
|
+
try {
|
|
7367
|
+
const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
|
|
7368
|
+
method,
|
|
7369
|
+
headers: {
|
|
7370
|
+
...this.authHeaders,
|
|
7371
|
+
...(authorId !== null ? {
|
|
7372
|
+
"x-val-profile-id": authorId
|
|
7373
|
+
} : {}),
|
|
7374
|
+
"Content-Type": "application/json"
|
|
7375
|
+
},
|
|
7376
|
+
body: JSON.stringify(body)
|
|
7377
|
+
});
|
|
7378
|
+
if (res.ok) {
|
|
7379
|
+
const parsed = PatchGroupMutationResponse.safeParse(await res.json());
|
|
7380
|
+
if (parsed.success) {
|
|
7381
|
+
return {
|
|
7382
|
+
patchIds: parsed.data.patchIds
|
|
7383
|
+
};
|
|
7384
|
+
}
|
|
7385
|
+
return {
|
|
7386
|
+
status: 500,
|
|
7387
|
+
patchIds: [],
|
|
7388
|
+
error: {
|
|
7389
|
+
message: `Could not parse patch group response. Error: ${fromError(parsed.error)}`
|
|
7390
|
+
}
|
|
7391
|
+
};
|
|
7392
|
+
}
|
|
7393
|
+
// 403 (not your group) and 409 (already published) are meaningful to the
|
|
7394
|
+
// client, so they are passed through rather than flattened to a 500.
|
|
7395
|
+
if (res.status === 403 || res.status === 409) {
|
|
7396
|
+
// `home` answers these with a JSON body, so `res.text()` put the literal
|
|
7397
|
+
// `{"message":"..."}` in front of the user. Every other branch in this
|
|
7398
|
+
// class unwraps it; this one now does too.
|
|
7399
|
+
return {
|
|
7400
|
+
status: res.status,
|
|
7401
|
+
patchIds: [],
|
|
7402
|
+
error: {
|
|
7403
|
+
message: getErrorMessageFromUnknownJson(await res.json().catch(() => undefined), `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`)
|
|
7404
|
+
}
|
|
7405
|
+
};
|
|
7406
|
+
}
|
|
7407
|
+
// A 401 here is the app's own credentials failing, not the user's, so it gets
|
|
7408
|
+
// the same wording as every other call in this class rather than an opaque
|
|
7409
|
+
// 500 that sends the user looking at their own session.
|
|
7410
|
+
if (res.status === 401) {
|
|
7411
|
+
return {
|
|
7412
|
+
status: 500,
|
|
7413
|
+
patchIds: [],
|
|
7414
|
+
error: {
|
|
7415
|
+
message: "Although your user is authorized, the application has authorization issues. Contact the developers on your team and ask them to verify the api keys."
|
|
7416
|
+
}
|
|
7417
|
+
};
|
|
7418
|
+
}
|
|
7419
|
+
return {
|
|
7420
|
+
status: 500,
|
|
7421
|
+
patchIds: [],
|
|
7422
|
+
error: {
|
|
7423
|
+
message: `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`
|
|
7424
|
+
}
|
|
7425
|
+
};
|
|
7426
|
+
} catch (err) {
|
|
7427
|
+
return {
|
|
7428
|
+
status: 500,
|
|
7429
|
+
patchIds: [],
|
|
7430
|
+
error: {
|
|
7431
|
+
message: `Could not update patch group (connection error?): ${err instanceof Error ? err.message : JSON.stringify(err)}`
|
|
7432
|
+
}
|
|
7433
|
+
};
|
|
7434
|
+
}
|
|
7435
|
+
}
|
|
7436
|
+
// #endregion
|
|
7437
|
+
|
|
7438
|
+
async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup) {
|
|
6992
7439
|
const baseSha = await this.getBaseSha();
|
|
6993
7440
|
return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
|
|
6994
7441
|
method: "POST",
|
|
@@ -7006,7 +7453,24 @@ class ValOpsHttp extends ValOps {
|
|
|
7006
7453
|
baseSha,
|
|
7007
7454
|
commit: this.commitSha,
|
|
7008
7455
|
branch: this.branch,
|
|
7009
|
-
coreVersion: Internal.VERSION.core
|
|
7456
|
+
coreVersion: Internal.VERSION.core,
|
|
7457
|
+
/*
|
|
7458
|
+
* Group membership in the SAME request as the patch.
|
|
7459
|
+
*
|
|
7460
|
+
* The content API runs every refusal before its insert, so an invalid
|
|
7461
|
+
* closure is a 400 with nothing written. Spread rather than sent as
|
|
7462
|
+
* nulls: a client with no group omits the fields entirely, which is
|
|
7463
|
+
* what an older content API expects to see.
|
|
7464
|
+
*/
|
|
7465
|
+
...(patchGroup ? {
|
|
7466
|
+
// Only when the caller named one. Omitted, the content API
|
|
7467
|
+
// resolves this author's open group and creates it if absent,
|
|
7468
|
+
// which is what every write wants.
|
|
7469
|
+
...(patchGroup.patchGroupId !== undefined ? {
|
|
7470
|
+
patchGroupId: patchGroup.patchGroupId
|
|
7471
|
+
} : {}),
|
|
7472
|
+
withPatchIds: patchGroup.withPatchIds
|
|
7473
|
+
} : {})
|
|
7010
7474
|
})
|
|
7011
7475
|
}).then(async res => {
|
|
7012
7476
|
var _res$headers$get2;
|
|
@@ -7014,7 +7478,21 @@ class ValOpsHttp extends ValOps {
|
|
|
7014
7478
|
const parsed = SavePatchResponse.safeParse(await res.json());
|
|
7015
7479
|
if (parsed.success) {
|
|
7016
7480
|
return result.ok({
|
|
7017
|
-
patchId: parsed.data.patchId
|
|
7481
|
+
patchId: parsed.data.patchId,
|
|
7482
|
+
/*
|
|
7483
|
+
* Passed back to the client, which cannot learn it any other way.
|
|
7484
|
+
*
|
|
7485
|
+
* A write names no group — the content API resolves this author's
|
|
7486
|
+
* open group and CREATES it if absent — so on a fresh branch the
|
|
7487
|
+
* group comes into existence here and nowhere else. The chain
|
|
7488
|
+
* annotation is only re-read when a fetch has missing ids to ask
|
|
7489
|
+
* for, and a patch this client made is never missing, so without
|
|
7490
|
+
* this the tab that bootstrapped the group would never learn its
|
|
7491
|
+
* id and every stage would be a no-op.
|
|
7492
|
+
*/
|
|
7493
|
+
...(parsed.data.patchGroupId !== undefined ? {
|
|
7494
|
+
patchGroupId: parsed.data.patchGroupId
|
|
7495
|
+
} : {})
|
|
7018
7496
|
});
|
|
7019
7497
|
}
|
|
7020
7498
|
return result.err({
|
|
@@ -7264,7 +7742,17 @@ class ValOpsHttp extends ValOps {
|
|
|
7264
7742
|
}]
|
|
7265
7743
|
};
|
|
7266
7744
|
}
|
|
7267
|
-
async deletePatches(patchIds
|
|
7745
|
+
async deletePatches(patchIds,
|
|
7746
|
+
/**
|
|
7747
|
+
* Patches that are NOT deleted but must lose their group membership.
|
|
7748
|
+
*
|
|
7749
|
+
* Deleting a patch out of the middle of a patch set leaves every group
|
|
7750
|
+
* still holding the rest with a non-prefix intersection — the patches after
|
|
7751
|
+
* the hole were written against a view that had it. The content API cannot
|
|
7752
|
+
* work out which those are (it has no schema), so the client sends the
|
|
7753
|
+
* forward closure and it drops those memberships without deleting anything.
|
|
7754
|
+
*/
|
|
7755
|
+
unstagePatchIds) {
|
|
7268
7756
|
return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
|
|
7269
7757
|
method: "DELETE",
|
|
7270
7758
|
headers: {
|
|
@@ -7272,7 +7760,10 @@ class ValOpsHttp extends ValOps {
|
|
|
7272
7760
|
"Content-Type": "application/json"
|
|
7273
7761
|
},
|
|
7274
7762
|
body: JSON.stringify({
|
|
7275
|
-
patchIds
|
|
7763
|
+
patchIds,
|
|
7764
|
+
...(unstagePatchIds !== undefined && unstagePatchIds.length > 0 ? {
|
|
7765
|
+
unstagePatchIds
|
|
7766
|
+
} : {})
|
|
7276
7767
|
})
|
|
7277
7768
|
}).then(async res => {
|
|
7278
7769
|
if (res.ok) {
|
|
@@ -7311,7 +7802,22 @@ class ValOpsHttp extends ValOps {
|
|
|
7311
7802
|
};
|
|
7312
7803
|
});
|
|
7313
7804
|
}
|
|
7314
|
-
async commit(prepared, message, committer, filesDirectory, newBranch
|
|
7805
|
+
async commit(prepared, message, committer, filesDirectory, newBranch,
|
|
7806
|
+
/**
|
|
7807
|
+
* The patch group this commit EMPTIES, if it empties one.
|
|
7808
|
+
*
|
|
7809
|
+
* The content API closes the group it is given — and closes it WITHOUT
|
|
7810
|
+
* checking that the commit shipped all of it, so a caller that names a
|
|
7811
|
+
* group still holding work takes those patches out of every group and
|
|
7812
|
+
* leaves their author unable to publish them. The client therefore sends it
|
|
7813
|
+
* only when the publish accounts for everything the group still holds.
|
|
7814
|
+
*
|
|
7815
|
+
* Omitting it is not neutral: the commit still empties the group (the
|
|
7816
|
+
* content API drops applied ids from every group), but `published_at` is
|
|
7817
|
+
* never set, so the id is reused across publishes instead of a new group
|
|
7818
|
+
* per publish and the "already published" refusal can never fire.
|
|
7819
|
+
*/
|
|
7820
|
+
patchGroupId) {
|
|
7315
7821
|
try {
|
|
7316
7822
|
var _res$headers$get3;
|
|
7317
7823
|
const existingBranch = this.branch;
|
|
@@ -7319,6 +7825,26 @@ class ValOpsHttp extends ValOps {
|
|
|
7319
7825
|
method: "POST",
|
|
7320
7826
|
headers: {
|
|
7321
7827
|
...this.authHeaders,
|
|
7828
|
+
/*
|
|
7829
|
+
* WHO is publishing — the same `x-val-profile-id` stage and unstage
|
|
7830
|
+
* send, and for the same reason: `this.authHeaders` is the app's API
|
|
7831
|
+
* key, which names the PROJECT and not the person.
|
|
7832
|
+
*
|
|
7833
|
+
* Closing a group is an ownership decision, so the content API
|
|
7834
|
+
* refuses a commit that names a `patchGroupId` it cannot attribute:
|
|
7835
|
+
* without this header `profileId` is `undefined` there and every
|
|
7836
|
+
* group-closing publish is 403 "Cannot resolve the caller's profile".
|
|
7837
|
+
* That is the NORMAL full publish, not an edge — `publish` names the
|
|
7838
|
+
* group whenever the commit empties it.
|
|
7839
|
+
*
|
|
7840
|
+
* Sent on every commit rather than only when a group is named. The
|
|
7841
|
+
* committer is a person either way, `committer` in the body already
|
|
7842
|
+
* says so, and a header that appears only on some commits is one more
|
|
7843
|
+
* conditional for a reader of either repo to reconstruct. It changes
|
|
7844
|
+
* nothing else: the content API reads it as an identity claim beside
|
|
7845
|
+
* the app's key and derives no scope from it.
|
|
7846
|
+
*/
|
|
7847
|
+
"x-val-profile-id": committer,
|
|
7322
7848
|
"Content-Type": "application/json"
|
|
7323
7849
|
},
|
|
7324
7850
|
body: JSON.stringify({
|
|
@@ -7332,7 +7858,10 @@ class ValOpsHttp extends ValOps {
|
|
|
7332
7858
|
committer,
|
|
7333
7859
|
message,
|
|
7334
7860
|
existingBranch,
|
|
7335
|
-
newBranch
|
|
7861
|
+
newBranch,
|
|
7862
|
+
...(patchGroupId !== undefined ? {
|
|
7863
|
+
patchGroupId
|
|
7864
|
+
} : {})
|
|
7336
7865
|
})
|
|
7337
7866
|
});
|
|
7338
7867
|
if (res.ok) {
|
|
@@ -8577,10 +9106,190 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8577
9106
|
};
|
|
8578
9107
|
}
|
|
8579
9108
|
},
|
|
9109
|
+
//#region patch groups
|
|
9110
|
+
// Staging and unstaging patches. Both take an already-closed set of patch ids:
|
|
9111
|
+
// the prefix closure (staging) or the forward closure (unstaging) is computed
|
|
9112
|
+
// on the client, which is the only side that has the schema needed to derive
|
|
9113
|
+
// patch sets. See `docs/independent-publish/PLAN.md`.
|
|
9114
|
+
//
|
|
9115
|
+
// In FS mode there is no shared store and exactly one author, so group
|
|
9116
|
+
// membership lives in the client and these handlers simply acknowledge. The
|
|
9117
|
+
// client already sends an explicit patch id list to `/save`, so a locally-held
|
|
9118
|
+
// group is enough to publish a subset correctly.
|
|
9119
|
+
"/patch-groups/~/patches": {
|
|
9120
|
+
PUT: async req => {
|
|
9121
|
+
const auth = getAuth(req.cookies);
|
|
9122
|
+
if (auth.error) {
|
|
9123
|
+
return {
|
|
9124
|
+
status: 401,
|
|
9125
|
+
json: {
|
|
9126
|
+
message: auth.error
|
|
9127
|
+
}
|
|
9128
|
+
};
|
|
9129
|
+
}
|
|
9130
|
+
const {
|
|
9131
|
+
patchGroupId,
|
|
9132
|
+
patchIds
|
|
9133
|
+
} = req.body;
|
|
9134
|
+
const withPatchIds = req.body.withPatchIds ?? [];
|
|
9135
|
+
if (serverOps instanceof ValOpsFS) {
|
|
9136
|
+
return {
|
|
9137
|
+
status: 200,
|
|
9138
|
+
json: {
|
|
9139
|
+
patchGroupId,
|
|
9140
|
+
patchIds: [...patchIds, ...withPatchIds]
|
|
9141
|
+
}
|
|
9142
|
+
};
|
|
9143
|
+
}
|
|
9144
|
+
if (!("id" in auth) || !auth.id) {
|
|
9145
|
+
return {
|
|
9146
|
+
status: 401,
|
|
9147
|
+
json: {
|
|
9148
|
+
message: "Unauthorized"
|
|
9149
|
+
}
|
|
9150
|
+
};
|
|
9151
|
+
}
|
|
9152
|
+
const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
|
|
9153
|
+
if (refusal !== null) {
|
|
9154
|
+
return {
|
|
9155
|
+
status: refusal.status,
|
|
9156
|
+
json: {
|
|
9157
|
+
message: refusal.message
|
|
9158
|
+
}
|
|
9159
|
+
};
|
|
9160
|
+
}
|
|
9161
|
+
const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
|
|
9162
|
+
// Forwarded so the content API can refuse independently. This server
|
|
9163
|
+
// has already refused a group that is not the caller's; sending the
|
|
9164
|
+
// author means the check also holds for anything reaching the content
|
|
9165
|
+
// API without coming through here.
|
|
9166
|
+
auth.id);
|
|
9167
|
+
if (res.error) {
|
|
9168
|
+
return {
|
|
9169
|
+
status: res.status,
|
|
9170
|
+
json: {
|
|
9171
|
+
message: res.error.message
|
|
9172
|
+
}
|
|
9173
|
+
};
|
|
9174
|
+
}
|
|
9175
|
+
return {
|
|
9176
|
+
status: 200,
|
|
9177
|
+
json: {
|
|
9178
|
+
patchGroupId,
|
|
9179
|
+
patchIds: res.patchIds
|
|
9180
|
+
}
|
|
9181
|
+
};
|
|
9182
|
+
},
|
|
9183
|
+
DELETE: async req => {
|
|
9184
|
+
const auth = getAuth(req.cookies);
|
|
9185
|
+
if (auth.error) {
|
|
9186
|
+
return {
|
|
9187
|
+
status: 401,
|
|
9188
|
+
json: {
|
|
9189
|
+
message: auth.error
|
|
9190
|
+
}
|
|
9191
|
+
};
|
|
9192
|
+
}
|
|
9193
|
+
const {
|
|
9194
|
+
patchGroupId,
|
|
9195
|
+
patchIds
|
|
9196
|
+
} = req.body;
|
|
9197
|
+
const withPatchIds = req.body.withPatchIds ?? [];
|
|
9198
|
+
if (serverOps instanceof ValOpsFS) {
|
|
9199
|
+
return {
|
|
9200
|
+
status: 200,
|
|
9201
|
+
json: {
|
|
9202
|
+
patchGroupId,
|
|
9203
|
+
patchIds: [...patchIds, ...withPatchIds]
|
|
9204
|
+
}
|
|
9205
|
+
};
|
|
9206
|
+
}
|
|
9207
|
+
if (!("id" in auth) || !auth.id) {
|
|
9208
|
+
return {
|
|
9209
|
+
status: 401,
|
|
9210
|
+
json: {
|
|
9211
|
+
message: "Unauthorized"
|
|
9212
|
+
}
|
|
9213
|
+
};
|
|
9214
|
+
}
|
|
9215
|
+
const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
|
|
9216
|
+
if (refusal !== null) {
|
|
9217
|
+
return {
|
|
9218
|
+
status: refusal.status,
|
|
9219
|
+
json: {
|
|
9220
|
+
message: refusal.message
|
|
9221
|
+
}
|
|
9222
|
+
};
|
|
9223
|
+
}
|
|
9224
|
+
const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
|
|
9225
|
+
if (res.error) {
|
|
9226
|
+
return {
|
|
9227
|
+
status: res.status,
|
|
9228
|
+
json: {
|
|
9229
|
+
message: res.error.message
|
|
9230
|
+
}
|
|
9231
|
+
};
|
|
9232
|
+
}
|
|
9233
|
+
return {
|
|
9234
|
+
status: 200,
|
|
9235
|
+
json: {
|
|
9236
|
+
patchGroupId,
|
|
9237
|
+
patchIds: res.patchIds
|
|
9238
|
+
}
|
|
9239
|
+
};
|
|
9240
|
+
}
|
|
9241
|
+
},
|
|
8580
9242
|
//#region patches
|
|
8581
9243
|
"/patches": {
|
|
8582
9244
|
PUT: async req => {
|
|
8583
9245
|
const cookies = req.cookies;
|
|
9246
|
+
|
|
9247
|
+
/**
|
|
9248
|
+
* Group membership travels WITH the patch, in one request.
|
|
9249
|
+
*
|
|
9250
|
+
* Atomic on purpose: the content API runs every refusal before its
|
|
9251
|
+
* insert, so an invalid closure is a 400 with nothing written. Recording
|
|
9252
|
+
* membership in a second call would let a patch exist outside its
|
|
9253
|
+
* author's group whenever that call failed — and a patch outside your
|
|
9254
|
+
* own group is one you cannot publish until a repair puts it back.
|
|
9255
|
+
*
|
|
9256
|
+
*/
|
|
9257
|
+
/*
|
|
9258
|
+
* A membership is present if EITHER field is.
|
|
9259
|
+
*
|
|
9260
|
+
* The common case names no group: the content API resolves the author's
|
|
9261
|
+
* open group, creating it if absent, so the client never has to hold an
|
|
9262
|
+
* id across publishes. It still sends the closure, which is the part it
|
|
9263
|
+
* alone can compute.
|
|
9264
|
+
*
|
|
9265
|
+
* `patchGroupId` is nullable, so an explicit `null` means "no group
|
|
9266
|
+
* named" exactly as omitting it does — it is passed through as
|
|
9267
|
+
* `undefined` rather than becoming a membership keyed by null.
|
|
9268
|
+
*/
|
|
9269
|
+
const requestedPatchGroupId = req.body.patchGroupId ?? undefined;
|
|
9270
|
+
const requestedWith = req.body.withPatchIds;
|
|
9271
|
+
const patchGroup = requestedPatchGroupId !== undefined || requestedWith !== undefined ? {
|
|
9272
|
+
...(requestedPatchGroupId !== undefined ? {
|
|
9273
|
+
patchGroupId: requestedPatchGroupId
|
|
9274
|
+
} : {}),
|
|
9275
|
+
withPatchIds: requestedWith ?? []
|
|
9276
|
+
} : undefined;
|
|
9277
|
+
if (patchGroup !== undefined && serverOps instanceof ValOpsFS) {
|
|
9278
|
+
/*
|
|
9279
|
+
* `fs` has no shared store and one author, so there is no group to
|
|
9280
|
+
* join. Refused rather than acknowledged: answering 200 would tell the
|
|
9281
|
+
* client its membership was recorded when it was dropped, and the
|
|
9282
|
+
* client would then believe a publish is scoped when it is not.
|
|
9283
|
+
*/
|
|
9284
|
+
return {
|
|
9285
|
+
status: 400,
|
|
9286
|
+
json: {
|
|
9287
|
+
type: "patch-error",
|
|
9288
|
+
message: "Patch groups are not available in fs mode. Omit the patch group fields.",
|
|
9289
|
+
errors: {}
|
|
9290
|
+
}
|
|
9291
|
+
};
|
|
9292
|
+
}
|
|
8584
9293
|
const auth = getAuth(cookies);
|
|
8585
9294
|
if (auth.error) {
|
|
8586
9295
|
return {
|
|
@@ -8603,8 +9312,19 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8603
9312
|
const sessionId = req.body.sessionId ?? null;
|
|
8604
9313
|
const authorId = "id" in auth ? auth.id : null;
|
|
8605
9314
|
const newPatchIds = [];
|
|
9315
|
+
/*
|
|
9316
|
+
* The group the content API put these patches in.
|
|
9317
|
+
*
|
|
9318
|
+
* Every patch in one request has the same author and the same
|
|
9319
|
+
* membership, so the last answer is the answer — they all land in the
|
|
9320
|
+
* same group. Reported back because the client cannot learn it any
|
|
9321
|
+
* other way: it names no group (the content API resolves the author's
|
|
9322
|
+
* open one, creating it if absent), and the chain annotation is only
|
|
9323
|
+
* re-read when a fetch has missing ids to ask for.
|
|
9324
|
+
*/
|
|
9325
|
+
let patchGroupIdFromStore;
|
|
8606
9326
|
for (const patch of patches) {
|
|
8607
|
-
const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId);
|
|
9327
|
+
const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId, patchGroup);
|
|
8608
9328
|
if (result.isErr(createPatchRes)) {
|
|
8609
9329
|
if (createPatchRes.error.errorType === "patch-head-conflict") {
|
|
8610
9330
|
return {
|
|
@@ -8636,13 +9356,22 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8636
9356
|
patchId: createPatchRes.value.patchId
|
|
8637
9357
|
};
|
|
8638
9358
|
newPatchIds.push(createPatchRes.value.patchId);
|
|
9359
|
+
if (createPatchRes.value.patchGroupId !== undefined) {
|
|
9360
|
+
patchGroupIdFromStore = createPatchRes.value.patchGroupId;
|
|
9361
|
+
}
|
|
8639
9362
|
}
|
|
8640
9363
|
}
|
|
8641
9364
|
return {
|
|
8642
9365
|
status: 200,
|
|
8643
9366
|
json: {
|
|
8644
9367
|
newPatchIds,
|
|
8645
|
-
parentRef
|
|
9368
|
+
parentRef,
|
|
9369
|
+
// Absent rather than null where there are no groups: `fs` mode and
|
|
9370
|
+
// a content API that predates them both answer without one, and the
|
|
9371
|
+
// client reads absence as "staging is not available here".
|
|
9372
|
+
...(patchGroupIdFromStore !== undefined ? {
|
|
9373
|
+
patchGroupId: patchGroupIdFromStore
|
|
9374
|
+
} : {})
|
|
8646
9375
|
}
|
|
8647
9376
|
};
|
|
8648
9377
|
},
|
|
@@ -8704,15 +9433,84 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8704
9433
|
}
|
|
8705
9434
|
// TODO: we should sort by parentRef instead:
|
|
8706
9435
|
patches.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
9436
|
+
/**
|
|
9437
|
+
* Patch groups, ANNOTATED onto the chain rather than filtering it.
|
|
9438
|
+
*
|
|
9439
|
+
* The client computes a new patch's parent as the last id in this
|
|
9440
|
+
* response, so a filtered chain would make every client name a parent
|
|
9441
|
+
* that is not the real head and `POST /patches` would answer 409
|
|
9442
|
+
* forever. Annotate, never filter.
|
|
9443
|
+
*
|
|
9444
|
+
* Absent — not empty — where there are no groups: `fs` mode, a content
|
|
9445
|
+
* API that predates them, or a failed lookup. The client reads absence
|
|
9446
|
+
* as "this deployment has no groups" and leaves staging off, which is
|
|
9447
|
+
* the behaviour every project has today. An empty array would say
|
|
9448
|
+
* "groups exist and hold nothing", which would turn the staging UI on
|
|
9449
|
+
* with everything held.
|
|
9450
|
+
*/
|
|
9451
|
+
let patchGroups;
|
|
9452
|
+
if (query.include_patch_groups === true && serverOps instanceof ValOpsHttp) {
|
|
9453
|
+
/*
|
|
9454
|
+
* FRESH, because this answer makes a CLOSING decision.
|
|
9455
|
+
*
|
|
9456
|
+
* The client adopts this annotation as its group membership and
|
|
9457
|
+
* `emptiesOwnPatchGroup` then decides from it whether a publish may
|
|
9458
|
+
* name the group — and the content API closes what it is named
|
|
9459
|
+
* without checking. A one-second-old list is enough to get that
|
|
9460
|
+
* wrong: the same author writing in a second tab joins the open
|
|
9461
|
+
* group, the websocket pushes the chain immediately, so this tab's
|
|
9462
|
+
* fetch for the missing patch lands well inside the cache window and
|
|
9463
|
+
* reads a membership that is one patch short. It then publishes,
|
|
9464
|
+
* names the group, and closes it with the other tab's work still in
|
|
9465
|
+
* it — which leaves that tab wedged on 409 until a reload.
|
|
9466
|
+
*
|
|
9467
|
+
* `resolveOwnPatchScope` keeps the cache deliberately: that read
|
|
9468
|
+
* decides what a draft render SHOWS, it is repeated once per
|
|
9469
|
+
* `fetchVal` in a single render, and being a second stale there costs
|
|
9470
|
+
* a patch appearing late rather than a group closing early.
|
|
9471
|
+
*/
|
|
9472
|
+
const groupsRes = await serverOps.getPatchGroups({
|
|
9473
|
+
fresh: true
|
|
9474
|
+
});
|
|
9475
|
+
if (groupsRes.status === "ok" && groupsRes.patchGroups.length > 0) {
|
|
9476
|
+
patchGroups = groupsRes.patchGroups;
|
|
9477
|
+
} else if (groupsRes.status === "error") {
|
|
9478
|
+
// Not fatal: the chain is what this endpoint is for, and staging
|
|
9479
|
+
// simply stays off for this read rather than the whole review
|
|
9480
|
+
// screen failing to load.
|
|
9481
|
+
console.error("Val: could not read patch groups", groupsRes.message);
|
|
9482
|
+
}
|
|
9483
|
+
}
|
|
9484
|
+
const groupIdsByPatchId = new Map();
|
|
9485
|
+
for (const group of patchGroups ?? []) {
|
|
9486
|
+
for (const patchId of group.patchIds) {
|
|
9487
|
+
const existing = groupIdsByPatchId.get(patchId);
|
|
9488
|
+
if (existing) {
|
|
9489
|
+
existing.push(group.patchGroupId);
|
|
9490
|
+
} else {
|
|
9491
|
+
groupIdsByPatchId.set(patchId, [group.patchGroupId]);
|
|
9492
|
+
}
|
|
9493
|
+
}
|
|
9494
|
+
}
|
|
8707
9495
|
return {
|
|
8708
9496
|
status: 200,
|
|
8709
9497
|
json: {
|
|
8710
|
-
patches: patches
|
|
9498
|
+
patches: patches.map(patch => {
|
|
9499
|
+
const patchGroupIds = groupIdsByPatchId.get(patch.patchId);
|
|
9500
|
+
return patchGroupIds ? {
|
|
9501
|
+
...patch,
|
|
9502
|
+
patchGroupIds
|
|
9503
|
+
} : patch;
|
|
9504
|
+
}),
|
|
9505
|
+
...(patchGroups ? {
|
|
9506
|
+
patchGroups
|
|
9507
|
+
} : {}),
|
|
8711
9508
|
baseSha: await serverOps.getBaseSha()
|
|
8712
9509
|
}
|
|
8713
9510
|
};
|
|
8714
9511
|
},
|
|
8715
9512
|
DELETE: async req => {
|
|
9513
|
+
var _req$body;
|
|
8716
9514
|
const query = req.query;
|
|
8717
9515
|
const cookies = req.cookies;
|
|
8718
9516
|
const auth = getAuth(cookies);
|
|
@@ -8733,7 +9531,25 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8733
9531
|
};
|
|
8734
9532
|
}
|
|
8735
9533
|
const ids = query.id;
|
|
8736
|
-
|
|
9534
|
+
/*
|
|
9535
|
+
* Which OTHER patches lose their group membership because these are
|
|
9536
|
+
* going. Only the client can COMPUTE it — that needs the patch sets,
|
|
9537
|
+
* which need the schema — but it is bounded here rather than trusted,
|
|
9538
|
+
* because the content API strips those memberships from every group
|
|
9539
|
+
* without an ownership check. See `boundUnstageClosure`.
|
|
9540
|
+
*
|
|
9541
|
+
* Only in `http` mode: `ValOpsFS` has no groups and ignores it, and the
|
|
9542
|
+
* client does not send it there.
|
|
9543
|
+
*/
|
|
9544
|
+
let unstagePatchIds;
|
|
9545
|
+
const requestedUnstage = (_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.unstagePatchIds;
|
|
9546
|
+
if (serverOps instanceof ValOpsHttp && requestedUnstage !== undefined && requestedUnstage.length > 0) {
|
|
9547
|
+
const chain = await serverOps.fetchPatches({
|
|
9548
|
+
excludePatchOps: true
|
|
9549
|
+
});
|
|
9550
|
+
unstagePatchIds = boundUnstageClosure(chain.patches, ids, requestedUnstage);
|
|
9551
|
+
}
|
|
9552
|
+
const deleteRes = await serverOps.deletePatches(ids, unstagePatchIds);
|
|
8737
9553
|
if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
|
|
8738
9554
|
console.error("Val: Failed to delete patches", deleteRes.errors);
|
|
8739
9555
|
return {
|
|
@@ -8847,6 +9663,22 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8847
9663
|
} = req.query;
|
|
8848
9664
|
// Defaults to true, mirroring /sources/~. The Studio opts out.
|
|
8849
9665
|
const applyPatches = req.query.apply_patches !== false;
|
|
9666
|
+
/*
|
|
9667
|
+
* Whose pending work this render may see.
|
|
9668
|
+
*
|
|
9669
|
+
* The same resolution `/sources/~` runs, through the same function: a
|
|
9670
|
+
* draft page renders module content and `jsonValues` entries together,
|
|
9671
|
+
* and this route used to apply every pending patch on the branch while
|
|
9672
|
+
* the modules beside it were scoped — so one screen showed the caller's
|
|
9673
|
+
* view and everybody's unpublished work at once.
|
|
9674
|
+
*/
|
|
9675
|
+
const {
|
|
9676
|
+
ownPatchIds
|
|
9677
|
+
} = await resolveOwnPatchScope(serverOps, {
|
|
9678
|
+
explicitPatchIds: undefined,
|
|
9679
|
+
ownGroupsOnly: req.query.own_patch_groups_only === true,
|
|
9680
|
+
authorId: "id" in auth && auth.id || undefined
|
|
9681
|
+
});
|
|
8850
9682
|
const isWindow = offset !== undefined || limit !== undefined;
|
|
8851
9683
|
const shapes = [key !== undefined, keys !== undefined, isWindow].filter(Boolean).length;
|
|
8852
9684
|
if (shapes !== 1) {
|
|
@@ -8867,7 +9699,8 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8867
9699
|
}
|
|
8868
9700
|
if (key !== undefined) {
|
|
8869
9701
|
const res = await serverOps.getJsonEntry(moduleFilePath, key, {
|
|
8870
|
-
applyPatches
|
|
9702
|
+
applyPatches,
|
|
9703
|
+
patchIds: ownPatchIds
|
|
8871
9704
|
});
|
|
8872
9705
|
if (res.status === "unauthorized") {
|
|
8873
9706
|
return {
|
|
@@ -8908,7 +9741,8 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8908
9741
|
offset: offset,
|
|
8909
9742
|
limit: limit
|
|
8910
9743
|
}, {
|
|
8911
|
-
applyPatches
|
|
9744
|
+
applyPatches,
|
|
9745
|
+
patchIds: ownPatchIds
|
|
8912
9746
|
});
|
|
8913
9747
|
if (res.status === "unauthorized") {
|
|
8914
9748
|
return {
|
|
@@ -8993,10 +9827,87 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8993
9827
|
patches: []
|
|
8994
9828
|
};
|
|
8995
9829
|
if (query.exclude_patches !== true) {
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
|
|
9830
|
+
/**
|
|
9831
|
+
* The caller's own groups, resolved from their session.
|
|
9832
|
+
*
|
|
9833
|
+
* A draft render cannot name its own group ids — it has no client
|
|
9834
|
+
* state — so it asks for "mine" and the server works out which. Only
|
|
9835
|
+
* when `patch_id` is absent: an explicit list is a caller that already
|
|
9836
|
+
* knows what it wants.
|
|
9837
|
+
*
|
|
9838
|
+
* A group lookup that FAILS renders base rather than everything. Being
|
|
9839
|
+
* shown only committed content while the content API is unreachable is
|
|
9840
|
+
* a degraded preview; being shown another author's unpublished draft
|
|
9841
|
+
* because a lookup failed is the bug this feature exists to prevent,
|
|
9842
|
+
* and it would be silent.
|
|
9843
|
+
*/
|
|
9844
|
+
const {
|
|
9845
|
+
ownPatchIds,
|
|
9846
|
+
scopeAlsoIncludesApplied
|
|
9847
|
+
} = await resolveOwnPatchScope(serverOps, {
|
|
9848
|
+
explicitPatchIds: query.patch_id,
|
|
9849
|
+
ownGroupsOnly: query.own_patch_groups_only === true,
|
|
9850
|
+
authorId: "id" in auth && auth.id || undefined
|
|
8999
9851
|
});
|
|
9852
|
+
const requestedPatchIds = query.patch_id ?? ownPatchIds;
|
|
9853
|
+
if (scopeAlsoIncludesApplied) {
|
|
9854
|
+
/*
|
|
9855
|
+
* Scoped to this caller's groups, PLUS everything already
|
|
9856
|
+
* committed.
|
|
9857
|
+
*
|
|
9858
|
+
* Filtered here rather than through `patchIds`, because the set is
|
|
9859
|
+
* not knowable before the fetch: `appliedAt` lives on the patch,
|
|
9860
|
+
* not on the group. One request either way — the whole chain is
|
|
9861
|
+
* what the unscoped path fetches too — so this costs a filter, not
|
|
9862
|
+
* a round trip.
|
|
9863
|
+
*
|
|
9864
|
+
* This also subsumes the empty-group case below: a caller holding
|
|
9865
|
+
* nothing, on a branch with nothing applied, filters down to no
|
|
9866
|
+
* patches, which is base.
|
|
9867
|
+
*/
|
|
9868
|
+
const all = await serverOps.fetchPatches({
|
|
9869
|
+
patchIds: undefined,
|
|
9870
|
+
excludePatchOps: false
|
|
9871
|
+
});
|
|
9872
|
+
patchOps = {
|
|
9873
|
+
...all,
|
|
9874
|
+
patches: scopedPatches(all.patches, ownPatchIds)
|
|
9875
|
+
};
|
|
9876
|
+
} else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
|
|
9877
|
+
/*
|
|
9878
|
+
* A group that holds nothing renders base, and is handled HERE.
|
|
9879
|
+
*
|
|
9880
|
+
* `fetchPatches` cannot express it: both implementations read an
|
|
9881
|
+
* empty `patchIds` as "no filter" and return the whole chain
|
|
9882
|
+
* (`ValOpsFS`: `patchIds.length > 0 ? new Set(...) : null`;
|
|
9883
|
+
* `ValOpsHttp`: an explicit `length === 0` branch that fetches all).
|
|
9884
|
+
* That is the right default for every caller that has ever passed a
|
|
9885
|
+
* list, since none of them can mean "none" — but it is the most
|
|
9886
|
+
* dangerous possible reading of an EXPLICITLY empty group, which
|
|
9887
|
+
* would render every unpublished patch on the branch instead of
|
|
9888
|
+
* base.
|
|
9889
|
+
*
|
|
9890
|
+
* Answered before the call rather than by changing that shared
|
|
9891
|
+
* default, which seven other call sites rely on.
|
|
9892
|
+
*/
|
|
9893
|
+
patchOps = {
|
|
9894
|
+
patches: []
|
|
9895
|
+
};
|
|
9896
|
+
} else {
|
|
9897
|
+
patchOps = await serverOps.fetchPatches({
|
|
9898
|
+
/*
|
|
9899
|
+
* The caller's patch group, when it named one.
|
|
9900
|
+
*
|
|
9901
|
+
* `undefined` means every pending patch, which is what every
|
|
9902
|
+
* existing caller gets and has to keep getting. A draft-mode
|
|
9903
|
+
* render that names its group gets base + that group instead, so
|
|
9904
|
+
* a server-rendered preview shows the same thing the person
|
|
9905
|
+
* editing is looking at rather than everybody's unpublished work.
|
|
9906
|
+
*/
|
|
9907
|
+
patchIds: requestedPatchIds,
|
|
9908
|
+
excludePatchOps: false
|
|
9909
|
+
});
|
|
9910
|
+
}
|
|
9000
9911
|
}
|
|
9001
9912
|
// We check authorization here, because it is the first call to the backend
|
|
9002
9913
|
if (patchOps.error && patchOps.unauthorized) {
|
|
@@ -9253,6 +10164,43 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
9253
10164
|
* store wholesale.
|
|
9254
10165
|
*/
|
|
9255
10166
|
const consumed = patches.patches.map(patch => patch.patchId);
|
|
10167
|
+
/*
|
|
10168
|
+
* Has somebody else published since this was decided?
|
|
10169
|
+
*
|
|
10170
|
+
* The client names the newest commit it knew about; anything newer here
|
|
10171
|
+
* means the review screen it acted on described a world that has moved.
|
|
10172
|
+
* The answer is "look again" rather than "your commit was rejected".
|
|
10173
|
+
*
|
|
10174
|
+
* Git's own not-fast-forward guard cannot see this: the chain is
|
|
10175
|
+
* fetched and committed fresh at this point, so the parent commit sent
|
|
10176
|
+
* is always the server's current one.
|
|
10177
|
+
*
|
|
10178
|
+
* Checked HERE, before `analyzePatches` and `prepare`, rather than just
|
|
10179
|
+
* before the commit: a publish that is going to be refused should not
|
|
10180
|
+
* first pay to apply every patch in it. And compared against the
|
|
10181
|
+
* commits `fetchPatches` just returned — `applicable/patches` filters
|
|
10182
|
+
* its PATCHES by the requested ids and never its commits, so the second
|
|
10183
|
+
* whole-chain fetch this used to make asked for a list it already had.
|
|
10184
|
+
*
|
|
10185
|
+
* Only when the client sends a head. One that does not publishes
|
|
10186
|
+
* exactly as it did before, and this cannot start refusing publishes for
|
|
10187
|
+
* a field it never sets.
|
|
10188
|
+
*/
|
|
10189
|
+
if (serverOps instanceof ValOpsHttp) {
|
|
10190
|
+
const expectedHead = body.expectedHeadCommitSha;
|
|
10191
|
+
if (expectedHead !== undefined) {
|
|
10192
|
+
const serverHead = newestCommitSha(patches.commits);
|
|
10193
|
+
if (serverHead !== null && serverHead !== expectedHead) {
|
|
10194
|
+
return {
|
|
10195
|
+
status: 409,
|
|
10196
|
+
json: {
|
|
10197
|
+
message: "Someone else published while you were reviewing. Nothing was published — open Review again to see what changed.",
|
|
10198
|
+
headMoved: true
|
|
10199
|
+
}
|
|
10200
|
+
};
|
|
10201
|
+
}
|
|
10202
|
+
}
|
|
10203
|
+
}
|
|
9256
10204
|
const analysis = serverOps.analyzePatches(patches.patches, patches.commits, commit);
|
|
9257
10205
|
let preparedCommit = await serverOps.prepare({
|
|
9258
10206
|
...analysis,
|
|
@@ -9401,8 +10349,53 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
9401
10349
|
} else if (serverOps instanceof ValOpsHttp) {
|
|
9402
10350
|
if (auth.error === undefined && auth.id) {
|
|
9403
10351
|
var _options$config$files;
|
|
10352
|
+
/*
|
|
10353
|
+
* The group this commit CLOSES has to be the caller's.
|
|
10354
|
+
*
|
|
10355
|
+
* Stage and unstage go through `refuseUnlessOwn`; this route did
|
|
10356
|
+
* not, and the content API's `postCommit` marks the group published
|
|
10357
|
+
* on id alone with no author clause — so the id was trusted twice
|
|
10358
|
+
* and checked nowhere. Any logged-in editor could name a colleague's
|
|
10359
|
+
* open group and close it: their pending patches land in a closed
|
|
10360
|
+
* group and in no open one, so a scoped draft render shows base for
|
|
10361
|
+
* them, and their own tab still believes the group is open, so their
|
|
10362
|
+
* next stage is refused with 409.
|
|
10363
|
+
*
|
|
10364
|
+
* Same hole as the stage/unstage one this branch already closed, one
|
|
10365
|
+
* route over. The content API needs the same guard — this one is the
|
|
10366
|
+
* convenience, that one is what actually holds.
|
|
10367
|
+
*/
|
|
10368
|
+
if (body.patchGroupId !== undefined && body.patchGroupId !== null) {
|
|
10369
|
+
const refusal = await refuseUnlessOwn(serverOps, body.patchGroupId, auth.id);
|
|
10370
|
+
if (refusal !== null) {
|
|
10371
|
+
return {
|
|
10372
|
+
status: refusal.status,
|
|
10373
|
+
json: {
|
|
10374
|
+
message: refusal.message,
|
|
10375
|
+
/*
|
|
10376
|
+
* Flagged, because a bare 409 here reads as git refusing
|
|
10377
|
+
* the commit — which is retryable, and this is the
|
|
10378
|
+
* opposite. `refuseUnlessOwn` answers 409 for one reason
|
|
10379
|
+
* only: the group has already been published, so its id
|
|
10380
|
+
* will never be writable again and retrying reproduces
|
|
10381
|
+
* this forever. The client forgets the id instead.
|
|
10382
|
+
*/
|
|
10383
|
+
...(refusal.status === 409 ? {
|
|
10384
|
+
patchGroupPublished: true
|
|
10385
|
+
} : {})
|
|
10386
|
+
}
|
|
10387
|
+
};
|
|
10388
|
+
}
|
|
10389
|
+
}
|
|
9404
10390
|
const message = body.message || "Val CMS update (" + Object.keys(analysis.patchesByModule).length + " files changed)";
|
|
9405
|
-
const commitRes = await serverOps.commit(preparedCommit, message, auth.id, ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val"
|
|
10391
|
+
const commitRes = await serverOps.commit(preparedCommit, message, auth.id, ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val", undefined,
|
|
10392
|
+
/*
|
|
10393
|
+
* Forwarded verbatim, and only the client can decide it: the
|
|
10394
|
+
* content API closes the group it is named without checking that
|
|
10395
|
+
* the commit shipped all of it, and whether it did needs the
|
|
10396
|
+
* patch sets, which live in the browser.
|
|
10397
|
+
*/
|
|
10398
|
+
body.patchGroupId);
|
|
9406
10399
|
if (commitRes.error) {
|
|
9407
10400
|
console.error("Failed to commit", commitRes.error);
|
|
9408
10401
|
if ("isNotFastForward" in commitRes && commitRes.isNotFastForward) {
|
|
@@ -9423,9 +10416,20 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
9423
10416
|
};
|
|
9424
10417
|
}
|
|
9425
10418
|
// TODO: serverOps.markApplied(patchIds);
|
|
10419
|
+
/*
|
|
10420
|
+
* The new head, back to the client that made it.
|
|
10421
|
+
*
|
|
10422
|
+
* Nothing else tells it in time: `headCommitSha` moves on a `/stat`
|
|
10423
|
+
* response, so until the next poll the client still believes the
|
|
10424
|
+
* pre-publish head — and its next publish sent that as
|
|
10425
|
+
* `expectedHeadCommitSha`, hit the check above against the commit it
|
|
10426
|
+
* had itself just made, and was told somebody else had published.
|
|
10427
|
+
*/
|
|
9426
10428
|
return {
|
|
9427
10429
|
status: 200,
|
|
9428
|
-
json: {
|
|
10430
|
+
json: {
|
|
10431
|
+
commitSha: commitRes.commit
|
|
10432
|
+
}
|
|
9429
10433
|
};
|
|
9430
10434
|
}
|
|
9431
10435
|
return {
|
|
@@ -10191,6 +11195,242 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
10191
11195
|
}
|
|
10192
11196
|
};
|
|
10193
11197
|
};
|
|
11198
|
+
/**
|
|
11199
|
+
* Refuse to touch a group that is not the caller's.
|
|
11200
|
+
*
|
|
11201
|
+
* Exported for `patchGroupOwnership.test.ts`: this is the whole of the
|
|
11202
|
+
* authorization for stage and unstage, and nothing else in the process checks
|
|
11203
|
+
* it, so it is worth testing as a policy rather than only through a route.
|
|
11204
|
+
*
|
|
11205
|
+
* `getAuth` only proves a session EXISTS; it says nothing about whose
|
|
11206
|
+
* group this is. And the content API cannot decide either — every call
|
|
11207
|
+
* from here carries the app's API key, not the editor's identity — so if
|
|
11208
|
+
* this does not check, nothing does.
|
|
11209
|
+
*
|
|
11210
|
+
* `GET /patches?include_patch_groups=true` hands every editor the id and
|
|
11211
|
+
* author of every open group on the branch, so without this any logged-in
|
|
11212
|
+
* editor can unstage another author's patches (their next publish
|
|
11213
|
+
* silently ships less) or stage into their group (it silently ships
|
|
11214
|
+
* more). The 403 declared for this route in `ApiRoutes.ts` was
|
|
11215
|
+
* unreachable.
|
|
11216
|
+
*
|
|
11217
|
+
* Fails CLOSED: if the groups cannot be read, the mutation is refused
|
|
11218
|
+
* rather than allowed unverified.
|
|
11219
|
+
*/
|
|
11220
|
+
|
|
11221
|
+
/**
|
|
11222
|
+
* The patches a scoped draft render should apply: the caller's own group, plus
|
|
11223
|
+
* everything already committed.
|
|
11224
|
+
*
|
|
11225
|
+
* Scoping is about PENDING work. A published patch stays in the chain with
|
|
11226
|
+
* `appliedAt` set until the next deployment moves the base, and it is part of
|
|
11227
|
+
* everyone's view in that window — the unscoped path applies it. Dropping it
|
|
11228
|
+
* meant the moment somebody published, their own draft preview reverted the
|
|
11229
|
+
* field they had just shipped, and nobody else saw it either until the deploy
|
|
11230
|
+
* landed; anything written on top in that window is authored against content
|
|
11231
|
+
* already stale on `main`.
|
|
11232
|
+
*
|
|
11233
|
+
* Keyed on `appliedAt` rather than on the group's `publishedAt`, because a
|
|
11234
|
+
* PARTIAL publish leaves the group open with only some of its patches applied.
|
|
11235
|
+
* Those are committed too, and no flag on the group names them.
|
|
11236
|
+
*
|
|
11237
|
+
* `undefined` scope is unscoped and never reaches here; an EMPTY scope is a
|
|
11238
|
+
* caller holding nothing, and on a branch with nothing applied it correctly
|
|
11239
|
+
* filters down to no patches, which renders base.
|
|
11240
|
+
*/
|
|
11241
|
+
function scopedPatches(patches, ownPatchIds) {
|
|
11242
|
+
const own = new Set(ownPatchIds ?? []);
|
|
11243
|
+
return patches.filter(patch => own.has(patch.patchId) || patch.appliedAt !== null);
|
|
11244
|
+
}
|
|
11245
|
+
|
|
11246
|
+
/**
|
|
11247
|
+
* Which of the client's `unstagePatchIds` this server is willing to forward.
|
|
11248
|
+
*
|
|
11249
|
+
* The forward closure of a discard is the client's to compute — it needs the
|
|
11250
|
+
* patch sets, which need the schema — and it was being forwarded verbatim. But
|
|
11251
|
+
* the content API removes those memberships from EVERY group with no ownership
|
|
11252
|
+
* check, so any logged-in editor could strip arbitrary patches out of any other
|
|
11253
|
+
* author's group by attaching them to a delete of one of their own throwaway
|
|
11254
|
+
* patches. That is the outcome the 403 on `/patch-groups` exists to prevent,
|
|
11255
|
+
* reached by a different door: their next publish silently ships less.
|
|
11256
|
+
*
|
|
11257
|
+
* Neither server can compute the true closure, but this one can BOUND it. A
|
|
11258
|
+
* patch can only be invalidated by a delete if it was written after that delete
|
|
11259
|
+
* — its paths were chosen against a view that had it — and if it is in the same
|
|
11260
|
+
* module, since a patch set never spans two. Anything outside those bounds was
|
|
11261
|
+
* not in the closure whatever the client says, so it is dropped rather than
|
|
11262
|
+
* refused: the delete is still correct, and refusing the whole request over an
|
|
11263
|
+
* over-broad extra would turn a discard into an error the user cannot act on.
|
|
11264
|
+
*
|
|
11265
|
+
* Exported for the test. Pure, and given the chain rather than fetching it, so
|
|
11266
|
+
* the ordering it depends on is visible in the test rather than mocked.
|
|
11267
|
+
*/
|
|
11268
|
+
function boundUnstageClosure(/** The pending chain, in chain order, as `fetchPatches` returns it. */
|
|
11269
|
+
chain, deleted, requested) {
|
|
11270
|
+
if (requested.length === 0) {
|
|
11271
|
+
return [];
|
|
11272
|
+
}
|
|
11273
|
+
const positionOf = new Map();
|
|
11274
|
+
const moduleOf = new Map();
|
|
11275
|
+
chain.forEach((entry, index) => {
|
|
11276
|
+
positionOf.set(entry.patchId, index);
|
|
11277
|
+
moduleOf.set(entry.patchId, entry.path);
|
|
11278
|
+
});
|
|
11279
|
+
const doomed = new Set(deleted);
|
|
11280
|
+
return requested.filter(patchId => {
|
|
11281
|
+
// A patch being deleted anyway does not need its membership stripped
|
|
11282
|
+
// separately, and naming one is how an over-broad list hides.
|
|
11283
|
+
if (doomed.has(patchId)) return false;
|
|
11284
|
+
const position = positionOf.get(patchId);
|
|
11285
|
+
const moduleFilePath = moduleOf.get(patchId);
|
|
11286
|
+
if (position === undefined || moduleFilePath === undefined) return false;
|
|
11287
|
+
return deleted.some(deletedId => {
|
|
11288
|
+
const deletedPosition = positionOf.get(deletedId);
|
|
11289
|
+
if (deletedPosition === undefined) return false;
|
|
11290
|
+
return position > deletedPosition && moduleOf.get(deletedId) === moduleFilePath;
|
|
11291
|
+
});
|
|
11292
|
+
});
|
|
11293
|
+
}
|
|
11294
|
+
|
|
11295
|
+
/**
|
|
11296
|
+
* Which pending patches this caller may see, when they asked for "only mine".
|
|
11297
|
+
*
|
|
11298
|
+
* Shared by `/sources/~` and `/json`, and it has to be: a draft page renders
|
|
11299
|
+
* both, so two answers to "whose work is this" put one person's half-finished
|
|
11300
|
+
* edit on another person's preview through whichever route was not scoped. That
|
|
11301
|
+
* is exactly what happened — `/json` applied every pending patch on the branch
|
|
11302
|
+
* while the module content beside it was scoped.
|
|
11303
|
+
*
|
|
11304
|
+
* `undefined` means "apply everything", which is what every caller that does
|
|
11305
|
+
* not ask for scoping gets and must keep getting.
|
|
11306
|
+
*/
|
|
11307
|
+
async function resolveOwnPatchScope(serverOps, opts) {
|
|
11308
|
+
let ownPatchIds;
|
|
11309
|
+
/** See where this is set: committed work is nobody's to hold back. */
|
|
11310
|
+
let scopeAlsoIncludesApplied = false;
|
|
11311
|
+
if (opts.explicitPatchIds === undefined && opts.ownGroupsOnly) {
|
|
11312
|
+
if (serverOps instanceof ValOpsHttp && opts.authorId) {
|
|
11313
|
+
const groupsRes = await serverOps.getPatchGroups();
|
|
11314
|
+
if (groupsRes.status === "unsupported") {
|
|
11315
|
+
/*
|
|
11316
|
+
* A content API that PREDATES patch groups — the endpoint 404s.
|
|
11317
|
+
*
|
|
11318
|
+
* Unscoped, which is what those deployments do today and must
|
|
11319
|
+
* keep doing. Reading this as a failure and rendering base
|
|
11320
|
+
* would silently drop every pending patch from every draft
|
|
11321
|
+
* preview on every existing http project — the exact opposite
|
|
11322
|
+
* of "keeps working unchanged", and invisible to the reader.
|
|
11323
|
+
*/
|
|
11324
|
+
ownPatchIds = undefined;
|
|
11325
|
+
} else if (groupsRes.status === "error") {
|
|
11326
|
+
/*
|
|
11327
|
+
* We could not ask, and this deployment DOES have the endpoint.
|
|
11328
|
+
* Render base rather than everything: a degraded preview is
|
|
11329
|
+
* recoverable, showing another author's unpublished draft is
|
|
11330
|
+
* not, and it would be silent.
|
|
11331
|
+
*/
|
|
11332
|
+
ownPatchIds = [];
|
|
11333
|
+
} else if (groupsRes.patchGroups.length === 0) {
|
|
11334
|
+
/*
|
|
11335
|
+
* The branch has no groups AT ALL, so this deployment is not
|
|
11336
|
+
* using them — a content API that predates patch groups, or a
|
|
11337
|
+
* project where nobody has staged anything since they existed.
|
|
11338
|
+
*
|
|
11339
|
+
* Unscoped, which is the behaviour every such project has
|
|
11340
|
+
* today. Collapsing this into "your group is empty" would make
|
|
11341
|
+
* every draft render base and silently drop all pending
|
|
11342
|
+
* content, which is what happened before this branch: nothing
|
|
11343
|
+
* writes a group yet, so EVERY project is in this state right
|
|
11344
|
+
* now.
|
|
11345
|
+
*/
|
|
11346
|
+
ownPatchIds = undefined;
|
|
11347
|
+
} else {
|
|
11348
|
+
/*
|
|
11349
|
+
* Groups exist and none are this person's: they have staged
|
|
11350
|
+
* nothing, and base is the honest answer. Distinct from the
|
|
11351
|
+
* case above, which is why the two are not one expression.
|
|
11352
|
+
*/
|
|
11353
|
+
ownPatchIds = groupsRes.patchGroups.filter(group => group.publishedAt === null && group.authorId === opts.authorId).flatMap(group => group.patchIds);
|
|
11354
|
+
/*
|
|
11355
|
+
* Scoping applies to PENDING work only. Anything already
|
|
11356
|
+
* committed is part of everyone's view.
|
|
11357
|
+
*
|
|
11358
|
+
* A published patch stays in the chain with `appliedAt` set
|
|
11359
|
+
* until the next deployment moves the base, and the unscoped
|
|
11360
|
+
* path applies it. Filtering to open groups dropped it — so the
|
|
11361
|
+
* moment someone published, their own draft preview reverted
|
|
11362
|
+
* the field they had just shipped, and nobody else saw it
|
|
11363
|
+
* either until the deploy landed. Anything written on top in
|
|
11364
|
+
* that window is authored against content that is already
|
|
11365
|
+
* stale on `main`.
|
|
11366
|
+
*
|
|
11367
|
+
* Unioned by `appliedAt` rather than by pulling in groups with
|
|
11368
|
+
* a `publishedAt`, because a partial publish leaves the group
|
|
11369
|
+
* OPEN with some of its patches applied — those are committed
|
|
11370
|
+
* too, and no group flag names them.
|
|
11371
|
+
*/
|
|
11372
|
+
scopeAlsoIncludesApplied = true;
|
|
11373
|
+
}
|
|
11374
|
+
} else {
|
|
11375
|
+
/*
|
|
11376
|
+
* fs mode, or a server with no groups: there is nothing to scope
|
|
11377
|
+
* BY, and every pending patch is this one person's anyway. Left
|
|
11378
|
+
* `undefined` so the existing "apply everything" path runs.
|
|
11379
|
+
*/
|
|
11380
|
+
ownPatchIds = undefined;
|
|
11381
|
+
}
|
|
11382
|
+
}
|
|
11383
|
+
return {
|
|
11384
|
+
ownPatchIds,
|
|
11385
|
+
scopeAlsoIncludesApplied
|
|
11386
|
+
};
|
|
11387
|
+
}
|
|
11388
|
+
async function refuseUnlessOwn(ops, patchGroupId, authorId) {
|
|
11389
|
+
/*
|
|
11390
|
+
* Never from the cache. This answers "is this group yours", and a group is at
|
|
11391
|
+
* its youngest exactly when the question is asked — the first write creates
|
|
11392
|
+
* it and the shell flushes its queued stages the moment the save response
|
|
11393
|
+
* names it. A cached list fetched a few hundred milliseconds earlier does not
|
|
11394
|
+
* contain it, and every one of those stages was refused and dropped.
|
|
11395
|
+
*/
|
|
11396
|
+
const groupsRes = await ops.getPatchGroups({
|
|
11397
|
+
fresh: true
|
|
11398
|
+
});
|
|
11399
|
+
if (groupsRes.status !== "ok") {
|
|
11400
|
+
return {
|
|
11401
|
+
status: 500,
|
|
11402
|
+
message: "Could not verify that this patch group is yours, so it was not changed."
|
|
11403
|
+
};
|
|
11404
|
+
}
|
|
11405
|
+
const group = groupsRes.patchGroups.find(candidate => candidate.patchGroupId === patchGroupId);
|
|
11406
|
+
if (group === undefined || group.authorId === null || group.authorId !== authorId) {
|
|
11407
|
+
/*
|
|
11408
|
+
* "Not found" and "not yours" are the SAME refusal on purpose: the route
|
|
11409
|
+
* schema has no 404, and `GET /patches` already lists every group on the
|
|
11410
|
+
* branch, so distinguishing them hides nothing and only adds a second
|
|
11411
|
+
* message to keep consistent.
|
|
11412
|
+
*
|
|
11413
|
+
* A null author is a group written by an api key or a PAT. Nobody owns it,
|
|
11414
|
+
* so nobody may stage into it — `null === null` must not read as a match.
|
|
11415
|
+
*/
|
|
11416
|
+
return {
|
|
11417
|
+
status: 403,
|
|
11418
|
+
message: "You can only change your own patch group"
|
|
11419
|
+
};
|
|
11420
|
+
}
|
|
11421
|
+
if (group.publishedAt !== null) {
|
|
11422
|
+
/*
|
|
11423
|
+
* Already shipped, so it can never be written again. The content API
|
|
11424
|
+
* answers this too; refusing here saves the round trip and keeps the
|
|
11425
|
+
* wording the same as every other refusal on this route.
|
|
11426
|
+
*/
|
|
11427
|
+
return {
|
|
11428
|
+
status: 409,
|
|
11429
|
+
message: "Patch group is already published"
|
|
11430
|
+
};
|
|
11431
|
+
}
|
|
11432
|
+
return null;
|
|
11433
|
+
}
|
|
10194
11434
|
function verifyCallbackReq(stateCookie, queryParams) {
|
|
10195
11435
|
if (typeof stateCookie !== "string") {
|
|
10196
11436
|
return {
|
|
@@ -10722,7 +11962,7 @@ function createValApiRouter(route, valServerPromise, convert) {
|
|
|
10722
11962
|
}
|
|
10723
11963
|
let bodyRes;
|
|
10724
11964
|
try {
|
|
10725
|
-
bodyRes = reqDefinition.body ? reqDefinition.body.safeParse(await req
|
|
11965
|
+
bodyRes = reqDefinition.body ? reqDefinition.body.safeParse(await readJsonBody(req)) : {
|
|
10726
11966
|
success: true,
|
|
10727
11967
|
data: {}
|
|
10728
11968
|
};
|
|
@@ -10801,6 +12041,33 @@ function formatZodErrorString(error) {
|
|
|
10801
12041
|
const errors = fromError(error).toString();
|
|
10802
12042
|
return errors.length > 640 ? `${errors.slice(0, 640)}...` : errors;
|
|
10803
12043
|
}
|
|
12044
|
+
|
|
12045
|
+
/**
|
|
12046
|
+
* The request's JSON body, or `undefined` when it has none.
|
|
12047
|
+
*
|
|
12048
|
+
* `req.json()` THROWS on an empty body, and the router used to call it
|
|
12049
|
+
* unconditionally for any route that declares a body — so the moment
|
|
12050
|
+
* `DELETE /patches` gained an optional body, every caller that sent none got
|
|
12051
|
+
* `400 Could not parse request body`. Declaring the schema `.optional()` did
|
|
12052
|
+
* not help: the throw happens before zod is ever consulted. Six e2e tests went
|
|
12053
|
+
* red on a helper doing exactly what the route still permits.
|
|
12054
|
+
*
|
|
12055
|
+
* Told apart by the request rather than by catching, so a body that IS sent and
|
|
12056
|
+
* is malformed still fails: absent means no content type and nothing to read,
|
|
12057
|
+
* and anything else is parsed and allowed to throw. A route whose schema
|
|
12058
|
+
* requires a body is unaffected — it gets `undefined` and zod refuses it, with
|
|
12059
|
+
* the same 400 as before, now naming the field.
|
|
12060
|
+
*/
|
|
12061
|
+
async function readJsonBody(req) {
|
|
12062
|
+
if (req.headers.get("content-length") === "0") {
|
|
12063
|
+
return undefined;
|
|
12064
|
+
}
|
|
12065
|
+
const text = await req.text();
|
|
12066
|
+
if (text.length === 0) {
|
|
12067
|
+
return undefined;
|
|
12068
|
+
}
|
|
12069
|
+
return JSON.parse(text);
|
|
12070
|
+
}
|
|
10804
12071
|
function zodErrorResult(error, message) {
|
|
10805
12072
|
return {
|
|
10806
12073
|
status: 400,
|