@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.
- package/CHANGELOG.md +242 -0
- package/dist/declarations/src/Service.d.ts +15 -0
- package/dist/declarations/src/ValOps.d.ts +156 -4
- package/dist/declarations/src/ValOpsFS.d.ts +16 -2
- package/dist/declarations/src/ValOpsHttp.d.ts +186 -5
- package/dist/declarations/src/ValServer.d.ts +106 -1
- package/dist/declarations/src/externalRecords.d.ts +366 -0
- package/dist/declarations/src/fixHandlers.d.ts +13 -0
- package/dist/declarations/src/history/HistoryError.d.ts +90 -0
- package/dist/declarations/src/history/types.d.ts +126 -0
- package/dist/declarations/src/index.d.ts +3 -1
- package/dist/declarations/src/tools/types.d.ts +22 -31
- package/dist/declarations/src/valServerConfig.d.ts +17 -14
- package/dist/valbuild-server.cjs.dev.js +2704 -306
- package/dist/valbuild-server.cjs.prod.js +2704 -306
- package/dist/valbuild-server.esm.js +2701 -309
- package/package.json +4 -4
|
@@ -8,14 +8,14 @@ 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, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, 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';
|
|
15
15
|
import sizeOf from 'image-size';
|
|
16
16
|
import { fromError } from 'zod-validation-error';
|
|
17
17
|
import os from 'os';
|
|
18
|
-
import { randomUUID
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
19
|
import { transform } from 'sucrase';
|
|
20
20
|
import http from 'http';
|
|
21
21
|
import https from 'https';
|
|
@@ -565,11 +565,13 @@ const patchValFile = async (id, rootDir, patch, sourceFileHandler) => {
|
|
|
565
565
|
// );
|
|
566
566
|
// sourceFileHandler.moveFile(tempFilePath, "." + filePath);
|
|
567
567
|
// TODO: ensure that directory exists
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
568
|
+
// NOTE: there used to be a branch here testing
|
|
569
|
+
// `content.startsWith("data:/image/svg+xml")` - with a stray slash after
|
|
570
|
+
// `data:`, so it could never match a real data URL and SVGs took the
|
|
571
|
+
// "binary" path anyway. Removed rather than fixed: the branch existed to
|
|
572
|
+
// write SVG as utf8, and writing the decoded bytes is correct for every
|
|
573
|
+
// type including SVG.
|
|
574
|
+
sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("binary"), "binary");
|
|
573
575
|
}
|
|
574
576
|
sourceFileHandler.writeSourceFile(newSourceFile.value);
|
|
575
577
|
// console.timeEnd("patchValFile" + timeId);
|
|
@@ -1665,6 +1667,21 @@ class Service {
|
|
|
1665
1667
|
getModuleFilePaths() {
|
|
1666
1668
|
return Object.keys(this.extracted.sources);
|
|
1667
1669
|
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* Everything that is wrong with how the project's modules are DECLARED, as
|
|
1673
|
+
* opposed to what is in them.
|
|
1674
|
+
*
|
|
1675
|
+
* A module that failed to load is here, and so is a rule that spans the whole
|
|
1676
|
+
* set — "one settings module, at the root" cannot be checked while looking at
|
|
1677
|
+
* a single module, so `extractValModules` appends it with the offending path.
|
|
1678
|
+
* `get` only surfaces these when the module is missing entirely, which a
|
|
1679
|
+
* misplaced settings module is not: `val validate` reads them from here and
|
|
1680
|
+
* reports them against the file.
|
|
1681
|
+
*/
|
|
1682
|
+
getModuleErrors() {
|
|
1683
|
+
return this.extracted.moduleErrors;
|
|
1684
|
+
}
|
|
1668
1685
|
serializedSchemaOf(moduleFilePath) {
|
|
1669
1686
|
return this.extracted.serializedSchemas[moduleFilePath];
|
|
1670
1687
|
}
|
|
@@ -1903,6 +1920,183 @@ class Service {
|
|
|
1903
1920
|
}
|
|
1904
1921
|
}
|
|
1905
1922
|
|
|
1923
|
+
/**
|
|
1924
|
+
* The type surface of an external record's adapter.
|
|
1925
|
+
*
|
|
1926
|
+
* Nothing here runs: phase 0 is the contract, and the registry that executes it
|
|
1927
|
+
* arrives with the read endpoints. What the contract has to get right now is the
|
|
1928
|
+
* shape, because every adapter written against it is a compatibility promise.
|
|
1929
|
+
*
|
|
1930
|
+
* Four kinds of method, and each is required or not for one reason:
|
|
1931
|
+
*
|
|
1932
|
+
* - **Read** — `keys`, `get`. Cannot be built out of anything else, so always
|
|
1933
|
+
* required.
|
|
1934
|
+
* - **Write** — `put`, `delete`. Likewise, and they move together: required on a
|
|
1935
|
+
* writable record, forbidden on a `.readonly()` one.
|
|
1936
|
+
* - **Derived** — `count`, `search`. Computable from the read methods, so
|
|
1937
|
+
* omitting one costs performance, never capability. `false` declines the
|
|
1938
|
+
* fallback; that is the only thing `false` ever means here.
|
|
1939
|
+
* - **Media** — `putFile`, `getFile`. A pair, and required by the item SCHEMA
|
|
1940
|
+
* rather than by this type (see `hasMediaSchema`).
|
|
1941
|
+
*/
|
|
1942
|
+
|
|
1943
|
+
// #region result
|
|
1944
|
+
|
|
1945
|
+
/**
|
|
1946
|
+
* Brands the result envelope so a bare value can be accepted alongside it.
|
|
1947
|
+
*
|
|
1948
|
+
* A symbol rather than a property name because `get` returns
|
|
1949
|
+
* `Record<key, Item>` — a record that can perfectly well contain a key called
|
|
1950
|
+
* `kind`. The envelope never crosses the wire (it travels in-process between
|
|
1951
|
+
* adapter and Val), so there is no serialization cost to it.
|
|
1952
|
+
*/
|
|
1953
|
+
const EXTERNAL_RESULT = Symbol.for("@valbuild/server/ExternalResult");
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* What every adapter method may return: the value on its own, or the envelope.
|
|
1957
|
+
*
|
|
1958
|
+
* A bare value means "ok, nothing to report", which keeps the common path one
|
|
1959
|
+
* line. Errors can be thrown instead of returned — the envelope is for a failure
|
|
1960
|
+
* worth classifying, or a success worth annotating.
|
|
1961
|
+
*/
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* Wrap a value as a successful result, optionally with warnings.
|
|
1965
|
+
*
|
|
1966
|
+
* `NoInfer` is load-bearing, not decoration. Without it `T` is inferred FROM the
|
|
1967
|
+
* argument, and an object literal that infers its own type is not contextually
|
|
1968
|
+
* typed by the adapter's contract — so `title` inside `ok({ a: { title } })` is a
|
|
1969
|
+
* fresh property that happens to be named `title`, with no declaration link back
|
|
1970
|
+
* to `title: s.string()` in the schema. "Find all references" on the schema field
|
|
1971
|
+
* then stops at the page that reads it and never reaches the adapter that
|
|
1972
|
+
* produces it. `NoInfer` blocks that inference, leaving the contextual return
|
|
1973
|
+
* type as the only source for `T`, which is what restores the link (and, as a
|
|
1974
|
+
* bonus, makes a typo report the offending property rather than the whole
|
|
1975
|
+
* return value).
|
|
1976
|
+
*
|
|
1977
|
+
* `externalNavigation.test.ts` guards this; nothing else would notice.
|
|
1978
|
+
*
|
|
1979
|
+
* The cost: `ok(...)` in a position with NO contextual type — a helper without a
|
|
1980
|
+
* return type annotation — infers `unknown` instead of the argument's type, and
|
|
1981
|
+
* fails where the helper is used rather than where it is written. Annotate the
|
|
1982
|
+
* helper's return type, or inline it.
|
|
1983
|
+
*/
|
|
1984
|
+
function ok$1(value, warnings) {
|
|
1985
|
+
return warnings && warnings.length > 0 ? {
|
|
1986
|
+
[EXTERNAL_RESULT]: true,
|
|
1987
|
+
kind: "ok",
|
|
1988
|
+
value,
|
|
1989
|
+
warnings
|
|
1990
|
+
} : {
|
|
1991
|
+
[EXTERNAL_RESULT]: true,
|
|
1992
|
+
kind: "ok",
|
|
1993
|
+
value
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
function err$1(issue) {
|
|
1997
|
+
return {
|
|
1998
|
+
[EXTERNAL_RESULT]: true,
|
|
1999
|
+
kind: "err",
|
|
2000
|
+
error: issue
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
function isExternalResult(value) {
|
|
2004
|
+
return typeof value === "object" && value !== null && value[EXTERNAL_RESULT] === true;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// #endregion
|
|
2008
|
+
|
|
2009
|
+
// #region context
|
|
2010
|
+
|
|
2011
|
+
/**
|
|
2012
|
+
* What every adapter method is told about the call it is serving.
|
|
2013
|
+
*
|
|
2014
|
+
* `tx` is present only when the adapter declared an `around`; a store with no
|
|
2015
|
+
* transaction seam has nothing to put there and nothing to ignore.
|
|
2016
|
+
*/
|
|
2017
|
+
|
|
2018
|
+
// #endregion
|
|
2019
|
+
|
|
2020
|
+
// #region paging, sorting, searching
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* How a page should be ordered.
|
|
2024
|
+
*
|
|
2025
|
+
* Records are unordered today and sorting is coming; the parameter lands now
|
|
2026
|
+
* because adding one to `keys` later would break every adapter that exists by
|
|
2027
|
+
* then. Val passes `undefined` until sorting ships, and an adapter may ignore it.
|
|
2028
|
+
*
|
|
2029
|
+
* Two rules for whoever implements it:
|
|
2030
|
+
*
|
|
2031
|
+
* - **A cursor is only valid for the sort that issued it.** Key-ordered paging
|
|
2032
|
+
* is `where key > cursor`; sorted paging is `where (field, key) > (…, …)`.
|
|
2033
|
+
* Val pairs the cursor with a hash of the sort and restarts rather than
|
|
2034
|
+
* replaying a mismatched one.
|
|
2035
|
+
* - **The key is always the last sort term.** Ordering by a non-unique field
|
|
2036
|
+
* without a tiebreaker lets rows shift between pages, so page 2 can skip or
|
|
2037
|
+
* repeat an entry while both pages look fine on their own.
|
|
2038
|
+
*/
|
|
2039
|
+
|
|
2040
|
+
// #endregion
|
|
2041
|
+
|
|
2042
|
+
// #region media
|
|
2043
|
+
|
|
2044
|
+
// #endregion
|
|
2045
|
+
|
|
2046
|
+
// #region the adapter
|
|
2047
|
+
|
|
2048
|
+
/** Read methods. Always required. */
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Write methods. Required together, and forbidden together on `.readonly()`.
|
|
2052
|
+
*
|
|
2053
|
+
* `put` must be an UPSERT keyed by the entry key and `delete` must tolerate an
|
|
2054
|
+
* absent key, because a publish may be replayed: retry re-runs the whole scope
|
|
2055
|
+
* where there is a transaction, and the individual call where there is not.
|
|
2056
|
+
*/
|
|
2057
|
+
|
|
2058
|
+
/**
|
|
2059
|
+
* Named so the compiler prints the reason. A bare `never` would report only
|
|
2060
|
+
* "not assignable to type 'undefined'", which tells nobody anything.
|
|
2061
|
+
*/
|
|
2062
|
+
|
|
2063
|
+
/** Derived and media methods, and the sort declaration. */
|
|
2064
|
+
|
|
2065
|
+
/** The item type an external module's entries hold, loosened as JSON allows. */
|
|
2066
|
+
|
|
2067
|
+
/**
|
|
2068
|
+
* The adapter a given external module needs.
|
|
2069
|
+
*
|
|
2070
|
+
* Writes are required or forbidden by the module's own `.readonly()`, read off
|
|
2071
|
+
* the source marker's phantom.
|
|
2072
|
+
*/
|
|
2073
|
+
|
|
2074
|
+
/** What `entry()` returns: a module and its adapter, checked against each other. */
|
|
2075
|
+
|
|
2076
|
+
/**
|
|
2077
|
+
* Declare the adapters for this project's external records.
|
|
2078
|
+
*
|
|
2079
|
+
* `Tx` is given explicitly: `around` offers the compiler no inference site,
|
|
2080
|
+
* since `run` is a callback you CALL rather than one whose signature you write.
|
|
2081
|
+
* One type argument, in one place, and every inline adapter then gets `tx`,
|
|
2082
|
+
* `cursor`, `limit` and the row shape correctly typed.
|
|
2083
|
+
*
|
|
2084
|
+
* @example
|
|
2085
|
+
* const { entry, modules } = defineExternal<typeof sql>({
|
|
2086
|
+
* around: (run) => sql.begin(run),
|
|
2087
|
+
* });
|
|
2088
|
+
*
|
|
2089
|
+
* export default modules({
|
|
2090
|
+
* posts: entry(postsVal, { keys, get, put, delete: del, search: false }),
|
|
2091
|
+
* });
|
|
2092
|
+
*
|
|
2093
|
+
* @example a store with no transaction
|
|
2094
|
+
* const { entry, modules } = defineExternal();
|
|
2095
|
+
*/
|
|
2096
|
+
function defineExternal(definition) {
|
|
2097
|
+
throw new Error("defineExternal is not implemented yet: phase 0 lands the contract, the registry that executes it arrives with the read endpoints.");
|
|
2098
|
+
}
|
|
2099
|
+
|
|
1906
2100
|
const JwtPayloadSchema = z.object({
|
|
1907
2101
|
sub: z.string(),
|
|
1908
2102
|
exp: z.number(),
|
|
@@ -2362,7 +2556,8 @@ class ValOps {
|
|
|
2362
2556
|
* in-flight client patches the server has not seen) must pass
|
|
2363
2557
|
* `applyPatches: false` or the same edits would be applied twice.
|
|
2364
2558
|
*/
|
|
2365
|
-
async getJsonEntry(moduleFilePath, entryKey,
|
|
2559
|
+
async getJsonEntry(moduleFilePath, entryKey, /** Passed straight through — see {@link getJsonEntries}. */
|
|
2560
|
+
opts) {
|
|
2366
2561
|
const res = await this.getJsonEntries(moduleFilePath, {
|
|
2367
2562
|
keys: [entryKey]
|
|
2368
2563
|
}, opts);
|
|
@@ -2456,7 +2651,7 @@ class ValOps {
|
|
|
2456
2651
|
message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
|
|
2457
2652
|
};
|
|
2458
2653
|
}
|
|
2459
|
-
modulePatches = patchOps.patches
|
|
2654
|
+
modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds).map(p => ({
|
|
2460
2655
|
patchId: p.patchId,
|
|
2461
2656
|
patch: p.patch
|
|
2462
2657
|
}));
|
|
@@ -3686,6 +3881,47 @@ class ValOps {
|
|
|
3686
3881
|
};
|
|
3687
3882
|
}
|
|
3688
3883
|
}));
|
|
3884
|
+
|
|
3885
|
+
/*
|
|
3886
|
+
* What each changed module IS after this commit, and the schema it is under.
|
|
3887
|
+
*
|
|
3888
|
+
* The data rather than the file's text, because that is the half git cannot
|
|
3889
|
+
* give back: a `.val.ts` in git is code, and turning code back into data
|
|
3890
|
+
* means parsing it, which is best-effort and rots across TypeScript,
|
|
3891
|
+
* runtime and Val versions. The schema comes along because a value on its
|
|
3892
|
+
* own cannot be RENDERED - showing a module as it was at a commit whose
|
|
3893
|
+
* schema has since changed needs the schema of that commit, and nothing in
|
|
3894
|
+
* the current checkout has it.
|
|
3895
|
+
*
|
|
3896
|
+
* Taken from `getSources(analysis)` rather than re-derived here so the data
|
|
3897
|
+
* stored is the same data the Studio shows, produced by the one
|
|
3898
|
+
* implementation of "apply these ops".
|
|
3899
|
+
*/
|
|
3900
|
+
const moduleVersions = {};
|
|
3901
|
+
const {
|
|
3902
|
+
sources: sourcesAfter
|
|
3903
|
+
} = await this.getSources(patchAnalysis);
|
|
3904
|
+
for (const path of Object.keys(patchesByModule)) {
|
|
3905
|
+
let serialized;
|
|
3906
|
+
try {
|
|
3907
|
+
var _schemas$path3;
|
|
3908
|
+
serialized = (_schemas$path3 = schemas[path]) === null || _schemas$path3 === void 0 ? void 0 : _schemas$path3["executeSerialize"]();
|
|
3909
|
+
} catch {
|
|
3910
|
+
// Same guard as above: one unserializable schema must not cost every
|
|
3911
|
+
// other module its history.
|
|
3912
|
+
serialized = undefined;
|
|
3913
|
+
}
|
|
3914
|
+
if (!serialized) {
|
|
3915
|
+
continue;
|
|
3916
|
+
}
|
|
3917
|
+
const source = sourcesAfter[path];
|
|
3918
|
+
moduleVersions[path] = {
|
|
3919
|
+
// `undefined` here means the module is gone, which is a state history
|
|
3920
|
+
// has to be able to show. `null` is how that travels over the wire.
|
|
3921
|
+
source: source === undefined ? null : source,
|
|
3922
|
+
schema: serialized
|
|
3923
|
+
};
|
|
3924
|
+
}
|
|
3689
3925
|
const res = {
|
|
3690
3926
|
hasErrors,
|
|
3691
3927
|
sourceFilePatchErrors,
|
|
@@ -3698,7 +3934,8 @@ class ValOps {
|
|
|
3698
3934
|
patchedBinaryFilesDescriptors,
|
|
3699
3935
|
appliedPatches,
|
|
3700
3936
|
skippedPatches,
|
|
3701
|
-
triedPatches
|
|
3937
|
+
triedPatches,
|
|
3938
|
+
moduleVersions
|
|
3702
3939
|
};
|
|
3703
3940
|
return res;
|
|
3704
3941
|
}
|
|
@@ -3716,8 +3953,21 @@ class ValOps {
|
|
|
3716
3953
|
}
|
|
3717
3954
|
|
|
3718
3955
|
// #region createPatch
|
|
3719
|
-
async createPatch(path, patch, patchId, parentRef, sessionId, authorId
|
|
3720
|
-
|
|
3956
|
+
async createPatch(path, patch, patchId, parentRef, sessionId, authorId,
|
|
3957
|
+
/**
|
|
3958
|
+
* Which patch group this patch joins, recorded in the SAME request.
|
|
3959
|
+
*
|
|
3960
|
+
* Atomic on purpose. The content API runs every refusal before its insert,
|
|
3961
|
+
* so an invalid closure is a 400 with nothing written. Recording membership
|
|
3962
|
+
* in a second call would let a patch exist outside its author's group if
|
|
3963
|
+
* that call failed — and a patch outside your own group is one you cannot
|
|
3964
|
+
* publish until a repair puts it back.
|
|
3965
|
+
*
|
|
3966
|
+
* Optional: `fs` mode has no groups, and a client that predates them sends
|
|
3967
|
+
* nothing.
|
|
3968
|
+
*/
|
|
3969
|
+
patchGroup) {
|
|
3970
|
+
const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup);
|
|
3721
3971
|
if (result.isErr(saveRes)) {
|
|
3722
3972
|
console.error(`Could not save source patch at path: '${path}'. Error: ${saveRes.error.errorType === "other" ? saveRes.error.message : saveRes.error.errorType}`);
|
|
3723
3973
|
if (saveRes.error.errorType === "patch-head-conflict") {
|
|
@@ -3732,11 +3982,76 @@ class ValOps {
|
|
|
3732
3982
|
}
|
|
3733
3983
|
return result.ok({
|
|
3734
3984
|
patchId,
|
|
3735
|
-
createdAt: new Date().toISOString()
|
|
3985
|
+
createdAt: new Date().toISOString(),
|
|
3986
|
+
// Spread rather than assigned: absent has to stay distinguishable from a
|
|
3987
|
+
// group whose id is undefined, all the way to the client.
|
|
3988
|
+
...(saveRes.value.patchGroupId !== undefined ? {
|
|
3989
|
+
patchGroupId: saveRes.value.patchGroupId
|
|
3990
|
+
} : {})
|
|
3736
3991
|
});
|
|
3737
3992
|
}
|
|
3738
3993
|
|
|
3739
3994
|
// #region abstract ops
|
|
3995
|
+
|
|
3996
|
+
/**
|
|
3997
|
+
* Save a patch's binary file from a `data:...;base64,...` URL.
|
|
3998
|
+
*
|
|
3999
|
+
* The wire form: `FileReader.readAsDataURL` is what the browser produces, and
|
|
4000
|
+
* published `@valbuild/server` versions send it. Code that already HAS bytes
|
|
4001
|
+
* should call {@link saveBinaryFileFromPatch} instead of wrapping them in a
|
|
4002
|
+
* data URL just to have this unwrap them again.
|
|
4003
|
+
*
|
|
4004
|
+
* A `null` `data` records a DELETION, which is why this cannot simply be
|
|
4005
|
+
* replaced by the byte-taking sibling: there is nothing to hand it.
|
|
4006
|
+
*/
|
|
4007
|
+
|
|
4008
|
+
/**
|
|
4009
|
+
* The same, for a caller that already has the bytes.
|
|
4010
|
+
*
|
|
4011
|
+
* Default implementation wraps them back into a data URL so every backend
|
|
4012
|
+
* gets this for free; a backend that can take bytes straight through should
|
|
4013
|
+
* override it.
|
|
4014
|
+
*/
|
|
4015
|
+
async saveBinaryFileFromPatch(filePath, parentRef, patchId, bytes, mimeType, type, metadata) {
|
|
4016
|
+
return this.saveBase64EncodedBinaryFileFromPatch(filePath, parentRef, patchId, `data:${mimeType};base64,${bytes.toString("base64")}`, type, metadata);
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
// #region history
|
|
4020
|
+
//
|
|
4021
|
+
// Reading the past, as opposed to reading the present with pending patches
|
|
4022
|
+
// applied. Every one of these is Result-typed against `HistoryError`, because
|
|
4023
|
+
// the ways this can fail - an unreadable record, a source that no longer
|
|
4024
|
+
// parses, an op that will not replay, a schema that has moved on - are the
|
|
4025
|
+
// interesting part rather than an edge case, and a caller deciding whether to
|
|
4026
|
+
// offer a RESTORE has to know which one it hit.
|
|
4027
|
+
//
|
|
4028
|
+
// Only implemented where there is a service holding the history:
|
|
4029
|
+
// `ValOpsHttp`. `ValOpsFS` answers `not-supported-in-fs-mode`, because local
|
|
4030
|
+
// dev has git for this and no commit records of its own.
|
|
4031
|
+
|
|
4032
|
+
/** One page of a branch's commits, newest first. See history/listCommits. */
|
|
4033
|
+
|
|
4034
|
+
/** The patches that produced one commit, with their ops. */
|
|
4035
|
+
|
|
4036
|
+
/**
|
|
4037
|
+
* How each `.val.ts` the commit changed looked BEFORE it, keyed by module
|
|
4038
|
+
* file path. Empty for a commit made before this was recorded - which the
|
|
4039
|
+
* caller reports as `source-unavailable` rather than as an empty module.
|
|
4040
|
+
*/
|
|
4041
|
+
/**
|
|
4042
|
+
* Each module a commit changed: its data, and the schema it was under.
|
|
4043
|
+
*
|
|
4044
|
+
* `asOf` widens it from "what this commit changed" to "the whole project as
|
|
4045
|
+
* this commit left it", which is what reverting everything to a point in time
|
|
4046
|
+
* needs; `moduleFilePath` narrows it to one module, for navigating the
|
|
4047
|
+
* history pane off the changed set.
|
|
4048
|
+
*/
|
|
4049
|
+
|
|
4050
|
+
/** Which files the commit touched, and how. Names them; does not fetch them. */
|
|
4051
|
+
|
|
4052
|
+
/** One file's bytes as they were at one commit. */
|
|
4053
|
+
|
|
4054
|
+
// #endregion history
|
|
3740
4055
|
}
|
|
3741
4056
|
function isOnlyFileCheckValidationError(validationError) {
|
|
3742
4057
|
var _validationError$fixe9;
|
|
@@ -3755,6 +4070,21 @@ function isOnlyFileCheckValidationError(validationError) {
|
|
|
3755
4070
|
function isFileSource(value) {
|
|
3756
4071
|
return typeof value === "object" && value !== null && "path" in value && typeof value.path === "string";
|
|
3757
4072
|
}
|
|
4073
|
+
|
|
4074
|
+
/**
|
|
4075
|
+
* The patch group a newly created patch joins.
|
|
4076
|
+
*
|
|
4077
|
+
* `withPatchIds` is the CLOSURE the client computed — the patches that share
|
|
4078
|
+
* a patch set with this one and must move with it. It is not derived here and
|
|
4079
|
+
* must not be: the closure needs the content schema, and the service that
|
|
4080
|
+
* stores groups does not have it. One implementation of that rule, on the side
|
|
4081
|
+
* that can actually compute it.
|
|
4082
|
+
*
|
|
4083
|
+
* Membership rows are stamped with `coreVersion` on the content side, the same
|
|
4084
|
+
* stamp the patch row itself carries, so which client wrote a row stays legible
|
|
4085
|
+
* after the fact.
|
|
4086
|
+
*/
|
|
4087
|
+
|
|
3758
4088
|
function formatPatchSourceError(error) {
|
|
3759
4089
|
if ("message" in error) {
|
|
3760
4090
|
return error.message;
|
|
@@ -3765,6 +4095,33 @@ function formatPatchSourceError(error) {
|
|
|
3765
4095
|
return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
|
|
3766
4096
|
}
|
|
3767
4097
|
}
|
|
4098
|
+
/**
|
|
4099
|
+
* The patches a json entry render should apply, out of the whole chain.
|
|
4100
|
+
*
|
|
4101
|
+
* Three rules, and the second is the one that was missing. A draft page renders
|
|
4102
|
+
* `jsonValues` entries beside module content, and only the modules were scoped
|
|
4103
|
+
* — so one screen showed the caller's own view for its modules and base plus
|
|
4104
|
+
* EVERY pending patch on the branch for the entries beside them, including
|
|
4105
|
+
* another author's half-finished edit rendered as though it were live.
|
|
4106
|
+
*
|
|
4107
|
+
* 1. this module's, since the chain is branch-wide;
|
|
4108
|
+
* 2. this caller's, when they asked to be scoped. `undefined` is "everything",
|
|
4109
|
+
* which is what every unscoped caller gets and must keep getting;
|
|
4110
|
+
* 3. not already applied — a fact about this path rather than about scoping,
|
|
4111
|
+
* and true with or without a scope.
|
|
4112
|
+
*
|
|
4113
|
+
* Filtered here rather than by asking `fetchPatches` for a list, and that is
|
|
4114
|
+
* load-bearing: both implementations read an empty `patchIds` as "no filter"
|
|
4115
|
+
* and return the whole chain. That is the right default for a caller that
|
|
4116
|
+
* cannot mean "none", and the most dangerous possible reading of a group that
|
|
4117
|
+
* is genuinely empty — it would render every unpublished patch on the branch
|
|
4118
|
+
* instead of base. It costs no round trip either: the whole chain is what the
|
|
4119
|
+
* unscoped path fetches anyway.
|
|
4120
|
+
*/
|
|
4121
|
+
function scopedModulePatches(patches, moduleFilePath, patchIds) {
|
|
4122
|
+
const scope = patchIds && new Set(patchIds);
|
|
4123
|
+
return patches.filter(patch => patch.path === moduleFilePath && !patch.appliedAt && (scope === undefined || scope.has(patch.patchId)));
|
|
4124
|
+
}
|
|
3768
4125
|
function getFieldsForType(type) {
|
|
3769
4126
|
if (type === "file") {
|
|
3770
4127
|
return ["mimeType"];
|
|
@@ -5795,7 +6152,17 @@ class ValOpsFS extends ValOps {
|
|
|
5795
6152
|
};
|
|
5796
6153
|
}
|
|
5797
6154
|
}
|
|
5798
|
-
async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId
|
|
6155
|
+
async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId,
|
|
6156
|
+
/*
|
|
6157
|
+
* Named and ignored, rather than omitted from the signature.
|
|
6158
|
+
*
|
|
6159
|
+
* `fs` mode has no shared store and exactly one author, so there is nothing
|
|
6160
|
+
* for a group to separate — every pending patch is already this person's.
|
|
6161
|
+
* Declaring it makes that a decision a reader can see: TypeScript lets an
|
|
6162
|
+
* implementation take fewer parameters than the abstract, so leaving it off
|
|
6163
|
+
* would drop group membership silently and look identical to handling it.
|
|
6164
|
+
*/
|
|
6165
|
+
_patchGroup) {
|
|
5799
6166
|
const patchesDir = this.getPatchesDir();
|
|
5800
6167
|
const record = {
|
|
5801
6168
|
patch,
|
|
@@ -6290,6 +6657,46 @@ class ValOpsFS extends ValOps {
|
|
|
6290
6657
|
getPatchLockFile() {
|
|
6291
6658
|
return path__default.join(this.rootDir, ValOpsFS.VAL_DIR, PATCH_LOCK_FILE_NAME);
|
|
6292
6659
|
}
|
|
6660
|
+
|
|
6661
|
+
// #region history
|
|
6662
|
+
//
|
|
6663
|
+
// Not available locally, and deliberately not faked from git.
|
|
6664
|
+
//
|
|
6665
|
+
// History is a record of what VAL did: which patches produced a commit, who
|
|
6666
|
+
// wrote them, and what each module looked like immediately before. Git has
|
|
6667
|
+
// the files but not that - it cannot say which of a commit's changes were one
|
|
6668
|
+
// editor's patch set, so a "restore this change" built on it would be
|
|
6669
|
+
// guesswork wearing the same UI.
|
|
6670
|
+
//
|
|
6671
|
+
// A single, honest error rather than five different ones: the caller's
|
|
6672
|
+
// question is "is history available here", and the answer is no.
|
|
6673
|
+
|
|
6674
|
+
async listCommits() {
|
|
6675
|
+
return result.err({
|
|
6676
|
+
kind: "not-supported-in-fs-mode"
|
|
6677
|
+
});
|
|
6678
|
+
}
|
|
6679
|
+
async getCommitPatches() {
|
|
6680
|
+
return result.err({
|
|
6681
|
+
kind: "not-supported-in-fs-mode"
|
|
6682
|
+
});
|
|
6683
|
+
}
|
|
6684
|
+
async getCommitModules() {
|
|
6685
|
+
return result.err({
|
|
6686
|
+
kind: "not-supported-in-fs-mode"
|
|
6687
|
+
});
|
|
6688
|
+
}
|
|
6689
|
+
async getCommitAffectedFiles() {
|
|
6690
|
+
return result.err({
|
|
6691
|
+
kind: "not-supported-in-fs-mode"
|
|
6692
|
+
});
|
|
6693
|
+
}
|
|
6694
|
+
async getFileAtCommit() {
|
|
6695
|
+
return result.err({
|
|
6696
|
+
kind: "not-supported-in-fs-mode"
|
|
6697
|
+
});
|
|
6698
|
+
}
|
|
6699
|
+
// #endregion history
|
|
6293
6700
|
}
|
|
6294
6701
|
class FSOpsHost {
|
|
6295
6702
|
constructor() {}
|
|
@@ -6552,7 +6959,14 @@ const FilesResponse = z.object({
|
|
|
6552
6959
|
})])).optional()
|
|
6553
6960
|
});
|
|
6554
6961
|
const SavePatchResponse = z.object({
|
|
6555
|
-
patchId: PatchId
|
|
6962
|
+
patchId: PatchId,
|
|
6963
|
+
/**
|
|
6964
|
+
* Which group the content API put this patch in.
|
|
6965
|
+
*
|
|
6966
|
+
* Optional: a content API that predates patch groups does not send one, and
|
|
6967
|
+
* absence has to keep meaning "no groups here" rather than failing the save.
|
|
6968
|
+
*/
|
|
6969
|
+
patchGroupId: z.string().optional()
|
|
6556
6970
|
});
|
|
6557
6971
|
const DeletePatchesResponse = z.object({
|
|
6558
6972
|
deleted: z.array(PatchId),
|
|
@@ -6570,10 +6984,126 @@ const CommitResponse = z.object({
|
|
|
6570
6984
|
commit: CommitSha,
|
|
6571
6985
|
branch: z.string()
|
|
6572
6986
|
});
|
|
6987
|
+
// #region history wire schemas
|
|
6988
|
+
//
|
|
6989
|
+
// Validated on arrival rather than trusted: these come from a service that
|
|
6990
|
+
// versions separately, and a silently mis-shaped commit record reads as "this
|
|
6991
|
+
// commit changed nothing", which is indistinguishable from a real answer.
|
|
6992
|
+
const HistoricalCommitResponse = z.object({
|
|
6993
|
+
commitSha: z.string(),
|
|
6994
|
+
parentCommitSha: z.string(),
|
|
6995
|
+
clientCommitSha: z.string(),
|
|
6996
|
+
branch: z.string(),
|
|
6997
|
+
createdBranch: z.string().nullable(),
|
|
6998
|
+
creator: z.string().nullable(),
|
|
6999
|
+
message: z.string().nullable(),
|
|
7000
|
+
createdAt: z.string(),
|
|
7001
|
+
seqNum: z.string(),
|
|
7002
|
+
patchCount: z.number(),
|
|
7003
|
+
hasArchive: z.boolean()
|
|
7004
|
+
});
|
|
7005
|
+
const ListCommitsResponse = z.object({
|
|
7006
|
+
commits: z.array(HistoricalCommitResponse),
|
|
7007
|
+
nextCursor: z.string().nullable()
|
|
7008
|
+
});
|
|
7009
|
+
const CommitPatchesResponse = z.object({
|
|
7010
|
+
commitSha: z.string(),
|
|
7011
|
+
commit: z.object({
|
|
7012
|
+
commitSha: z.string(),
|
|
7013
|
+
parentCommitSha: z.string(),
|
|
7014
|
+
clientCommitSha: z.string(),
|
|
7015
|
+
branch: z.string(),
|
|
7016
|
+
createdBranch: z.string().nullable(),
|
|
7017
|
+
creator: z.string().nullable(),
|
|
7018
|
+
message: z.string().nullable(),
|
|
7019
|
+
createdAt: z.string(),
|
|
7020
|
+
seqNum: z.string(),
|
|
7021
|
+
hasArchive: z.boolean()
|
|
7022
|
+
}),
|
|
7023
|
+
patches: z.array(z.object({
|
|
7024
|
+
patchId: z.string(),
|
|
7025
|
+
path: z.string(),
|
|
7026
|
+
patch: z.unknown(),
|
|
7027
|
+
authorId: z.string().nullable(),
|
|
7028
|
+
createdAt: z.string(),
|
|
7029
|
+
baseSha: z.string(),
|
|
7030
|
+
coreVersion: z.string()
|
|
7031
|
+
}))
|
|
7032
|
+
});
|
|
7033
|
+
|
|
7034
|
+
/**
|
|
7035
|
+
* `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
|
|
7036
|
+
*
|
|
7037
|
+
* `schema` stays `unknown` here on purpose. The content service stores it
|
|
7038
|
+
* opaquely and cannot vouch for it, so validating it at the transport boundary
|
|
7039
|
+
* would turn "a schema written by a different version of Val" into a failed
|
|
7040
|
+
* REQUEST rather than one module that cannot be shown. It is checked in
|
|
7041
|
+
* `getHistoricalPatchSet`, per module, where a failure degrades that module and
|
|
7042
|
+
* leaves the commit readable.
|
|
7043
|
+
*/
|
|
7044
|
+
const CommitModulesResponse = z.object({
|
|
7045
|
+
commitSha: z.string(),
|
|
7046
|
+
parentCommitSha: z.string(),
|
|
7047
|
+
/**
|
|
7048
|
+
* Whether an `asOf` read covered the whole project.
|
|
7049
|
+
*
|
|
7050
|
+
* Optional so an older content server still parses. False means modules last
|
|
7051
|
+
* edited before history started being recorded are missing from the answer -
|
|
7052
|
+
* which a whole-project revert has to say out loud rather than silently skip.
|
|
7053
|
+
*/
|
|
7054
|
+
complete: z.boolean().optional(),
|
|
7055
|
+
modules: z.array(z.object({
|
|
7056
|
+
moduleFilePath: z.string(),
|
|
7057
|
+
commitSha: z.string(),
|
|
7058
|
+
sourceSha: z.string().nullable(),
|
|
7059
|
+
schemaSha: z.string(),
|
|
7060
|
+
// Validated, because a Source IS just JSON and this side knows that much.
|
|
7061
|
+
source: JSONValue.nullable(),
|
|
7062
|
+
schema: z.unknown(),
|
|
7063
|
+
unavailable: z.boolean()
|
|
7064
|
+
}))
|
|
7065
|
+
});
|
|
7066
|
+
const CommitAffectedFilesResponse = z.object({
|
|
7067
|
+
commitSha: z.string(),
|
|
7068
|
+
files: z.array(z.union([z.object({
|
|
7069
|
+
kind: z.union([z.literal("module-source"), z.literal("json-entry"), z.literal("binary")]),
|
|
7070
|
+
gitPath: z.string(),
|
|
7071
|
+
change: z.union([z.literal("added"), z.literal("modified"), z.literal("deleted")])
|
|
7072
|
+
}), z.object({
|
|
7073
|
+
kind: z.literal("remote-binary"),
|
|
7074
|
+
ref: z.string(),
|
|
7075
|
+
change: z.union([z.literal("added"), z.literal("modified"), z.literal("deleted")])
|
|
7076
|
+
})]))
|
|
7077
|
+
});
|
|
7078
|
+
// #endregion history wire schemas
|
|
7079
|
+
|
|
7080
|
+
/*
|
|
7081
|
+
* The shared schema, not a copy of it.
|
|
7082
|
+
*
|
|
7083
|
+
* This re-declared `PatchGroup` field for field while the file already imported
|
|
7084
|
+
* `PatchGroupT` from the same module — so a field added on one side and not the
|
|
7085
|
+
* other would have `getPatchGroups()` return a `PatchGroupT[]` silently missing
|
|
7086
|
+
* it, with no type error anywhere.
|
|
7087
|
+
*/
|
|
7088
|
+
const PatchGroupsResponse = z.object({
|
|
7089
|
+
patchGroups: z.array(PatchGroup)
|
|
7090
|
+
});
|
|
7091
|
+
const PatchGroupMutationResponse = z.object({
|
|
7092
|
+
patchGroupId: z.string(),
|
|
7093
|
+
patchIds: z.array(PatchId)
|
|
7094
|
+
});
|
|
6573
7095
|
const NonceResponse = z.object({
|
|
6574
7096
|
nonce: z.string(),
|
|
6575
7097
|
url: z.string()
|
|
6576
7098
|
});
|
|
7099
|
+
|
|
7100
|
+
/**
|
|
7101
|
+
* How long a patch-group lookup is reused. See `ValOpsHttp.patchGroupsCache`.
|
|
7102
|
+
*
|
|
7103
|
+
* Sized to cover one server render, not to be a cache: several `fetchVal` calls
|
|
7104
|
+
* in one request share an answer, and the next request asks again.
|
|
7105
|
+
*/
|
|
7106
|
+
const PATCH_GROUPS_CACHE_MS = 1000;
|
|
6577
7107
|
class ValOpsHttp extends ValOps {
|
|
6578
7108
|
constructor(contentUrl, project, commitSha,
|
|
6579
7109
|
// TODO: CommitSha
|
|
@@ -6733,8 +7263,26 @@ class ValOpsHttp extends ValOps {
|
|
|
6733
7263
|
}
|
|
6734
7264
|
}
|
|
6735
7265
|
const patches = [];
|
|
7266
|
+
/*
|
|
7267
|
+
* Which of them have SHIPPED, alongside which of them exist.
|
|
7268
|
+
*
|
|
7269
|
+
* A published patch stays in the chain with `appliedAt` set until the next
|
|
7270
|
+
* deployment moves the base, so "in the chain" and "has shipped" are
|
|
7271
|
+
* different questions — and the chain ids alone answer only the first. A
|
|
7272
|
+
* client that already holds a record never re-fetches it, so it never
|
|
7273
|
+
* learns the second: another author's publish left that patch in your scope
|
|
7274
|
+
* as pending, your prefix gate read a hole in front of it, and Publish
|
|
7275
|
+
* refused for a reason that had stopped being true.
|
|
7276
|
+
*
|
|
7277
|
+
* Sent as ids rather than folded into `patches`, so a client that ignores
|
|
7278
|
+
* it behaves exactly as before.
|
|
7279
|
+
*/
|
|
7280
|
+
const appliedPatches = [];
|
|
6736
7281
|
for (const patchData of allPatchData.patches) {
|
|
6737
7282
|
patches.push(patchData.patchId);
|
|
7283
|
+
if (patchData.appliedAt) {
|
|
7284
|
+
appliedPatches.push(patchData.patchId);
|
|
7285
|
+
}
|
|
6738
7286
|
}
|
|
6739
7287
|
const webSocketNonceRes = await this.getWebSocketNonce(params.profileId);
|
|
6740
7288
|
if (webSocketNonceRes.status === "error") {
|
|
@@ -6757,6 +7305,16 @@ class ValOpsHttp extends ValOps {
|
|
|
6757
7305
|
commits: allPatchData.commits || [],
|
|
6758
7306
|
deployments: allPatchData.deployments || [],
|
|
6759
7307
|
patches,
|
|
7308
|
+
appliedPatches,
|
|
7309
|
+
/*
|
|
7310
|
+
* The PUBLISH head, which is not `commitSha`.
|
|
7311
|
+
*
|
|
7312
|
+
* `commitSha` is the commit this deployment is serving and does not move
|
|
7313
|
+
* when somebody publishes — only when the new build lands. This does, so
|
|
7314
|
+
* it is what a client carries back to `/save` to say which world it
|
|
7315
|
+
* decided against.
|
|
7316
|
+
*/
|
|
7317
|
+
headCommitSha: newestCommitSha(allPatchData.commits) ?? undefined,
|
|
6760
7318
|
commitSha: this.commitSha
|
|
6761
7319
|
};
|
|
6762
7320
|
}
|
|
@@ -6837,6 +7395,26 @@ class ValOpsHttp extends ValOps {
|
|
|
6837
7395
|
}
|
|
6838
7396
|
const allPatches = [];
|
|
6839
7397
|
const allErrors = [];
|
|
7398
|
+
/*
|
|
7399
|
+
* The commits, which are a fact about the whole BRANCH, not about a chunk.
|
|
7400
|
+
*
|
|
7401
|
+
* This loop used to return only `patches` and `errors`, so a filtered fetch
|
|
7402
|
+
* silently answered with no commits at all. That is not cosmetic:
|
|
7403
|
+
* the publish-head guard in `ValServer` reads `newestCommitSha(commits)`,
|
|
7404
|
+
* got `undefined` for every publish (a publish always names patch ids, so
|
|
7405
|
+
* it always takes this branch), and skipped the check entirely. Two clients
|
|
7406
|
+
* could publish against the same head with neither told.
|
|
7407
|
+
*
|
|
7408
|
+
* Taken from the first chunk that carries them, which is sound for the
|
|
7409
|
+
* reason the dedupe below exists: each chunk's response describes the whole
|
|
7410
|
+
* chain regardless of which ids it asked about.
|
|
7411
|
+
*
|
|
7412
|
+
* `deployments` is not carried, because it is declared only on
|
|
7413
|
+
* `OrderedPatchesMetadata` and this is generic over both shapes. Nothing
|
|
7414
|
+
* reads it from a filtered fetch today; if something starts to, it needs
|
|
7415
|
+
* the same treatment and a home on `OrderedPatches` first.
|
|
7416
|
+
*/
|
|
7417
|
+
let commits;
|
|
6840
7418
|
if (patchIds === undefined || patchIds.length === 0) {
|
|
6841
7419
|
return this.fetchPatchesInternal({
|
|
6842
7420
|
patchIds: patchIds,
|
|
@@ -6854,6 +7432,9 @@ class ValOpsHttp extends ValOps {
|
|
|
6854
7432
|
if (res.errors) {
|
|
6855
7433
|
allErrors.push(...res.errors);
|
|
6856
7434
|
}
|
|
7435
|
+
if (commits === undefined && res.commits !== undefined) {
|
|
7436
|
+
commits = res.commits;
|
|
7437
|
+
}
|
|
6857
7438
|
}
|
|
6858
7439
|
// Chunking is a query-string-length workaround, NOT a filter: the content
|
|
6859
7440
|
// api returns every applicable patch per request regardless of which
|
|
@@ -6880,7 +7461,13 @@ class ValOpsHttp extends ValOps {
|
|
|
6880
7461
|
});
|
|
6881
7462
|
return {
|
|
6882
7463
|
patches,
|
|
6883
|
-
errors: Object.keys(allErrors).length > 0 ? allErrors : undefined
|
|
7464
|
+
errors: Object.keys(allErrors).length > 0 ? allErrors : undefined,
|
|
7465
|
+
// Spread rather than set to `undefined`, so a caller that distinguishes
|
|
7466
|
+
// "absent" from "empty" — `newestCommitSha` does not, but the annotation
|
|
7467
|
+
// readers do — sees the same shape the unchunked path gives it.
|
|
7468
|
+
...(commits !== undefined ? {
|
|
7469
|
+
commits
|
|
7470
|
+
} : {})
|
|
6884
7471
|
};
|
|
6885
7472
|
}
|
|
6886
7473
|
async fetchPatchesInternal(filters) {
|
|
@@ -6988,59 +7575,364 @@ class ValOpsHttp extends ValOps {
|
|
|
6988
7575
|
};
|
|
6989
7576
|
}
|
|
6990
7577
|
}
|
|
6991
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
7015
|
-
|
|
7016
|
-
|
|
7017
|
-
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
7578
|
+
|
|
7579
|
+
// #region patch groups
|
|
7580
|
+
/**
|
|
7581
|
+
* Add patches to a patch group.
|
|
7582
|
+
*
|
|
7583
|
+
* The set arrives closed by the client — `withPatchIds` is the prefix closure
|
|
7584
|
+
* over the patch sets the staged patches belong to. We forward it and do not
|
|
7585
|
+
* second-guess it: deriving the closure needs the content schema, which this
|
|
7586
|
+
* process does have but content.val.build does not, and having two
|
|
7587
|
+
* implementations of the rule would be worse than having one.
|
|
7588
|
+
*
|
|
7589
|
+
* Membership rows are stamped with `coreVersion` on the content side — the
|
|
7590
|
+
* same stamp the patch row carries — so which client wrote a row stays
|
|
7591
|
+
* legible after the fact.
|
|
7592
|
+
*/
|
|
7593
|
+
async stagePatches(patchGroupId, /** What the user asked to stage. */
|
|
7594
|
+
patchIds,
|
|
7595
|
+
/**
|
|
7596
|
+
* What has to come with it, because the staged patches are written on top
|
|
7597
|
+
* of it.
|
|
7598
|
+
*
|
|
7599
|
+
* The content API stores each membership row as `explicit` or `dependency`
|
|
7600
|
+
* and treats what it is not told about as a dependency. Folding the two
|
|
7601
|
+
* halves into `patchIds` therefore files the patch somebody clicked as one
|
|
7602
|
+
* the closure dragged in — the exact opposite of what happened, and the
|
|
7603
|
+
* only record anywhere of what the author chose.
|
|
7604
|
+
*/
|
|
7605
|
+
withPatchIds,
|
|
7606
|
+
/**
|
|
7607
|
+
* WHO is asking, so the content API can refuse a group that is not theirs.
|
|
7608
|
+
*
|
|
7609
|
+
* Every call from this class carries the app's API key, which says which
|
|
7610
|
+
* PROJECT is calling and nothing about which editor. Without this the
|
|
7611
|
+
* content API cannot tell one of a project's editors from another, so the
|
|
7612
|
+
* only check on stage and unstage is the one in `ValServer` — and anything
|
|
7613
|
+
* reaching the content API by another route (an API key, a PAT) has none at
|
|
7614
|
+
* all.
|
|
7615
|
+
*
|
|
7616
|
+
* `null` where there is no session. The content API refuses rather than
|
|
7617
|
+
* treating that as a match: a group written by an api key has a null author
|
|
7618
|
+
* too, and `null === null` must not read as ownership.
|
|
7619
|
+
*/
|
|
7620
|
+
authorId) {
|
|
7621
|
+
return this.mutatePatchGroup("POST",
|
|
7622
|
+
// Encoded: patchGroupId arrives in a request body, so an unencoded value
|
|
7623
|
+
// like "../../commit" would reach a different endpoint carrying this
|
|
7624
|
+
// project's auth headers.
|
|
7625
|
+
`patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
|
|
7626
|
+
patchIds,
|
|
7627
|
+
withPatchIds,
|
|
7628
|
+
coreVersion: Internal.VERSION.core
|
|
7629
|
+
}, authorId);
|
|
7630
|
+
}
|
|
7631
|
+
|
|
7632
|
+
/**
|
|
7633
|
+
* Remove patches from a patch group.
|
|
7634
|
+
*
|
|
7635
|
+
* The set arrives closed FORWARDS by the client: unstaging a patch also
|
|
7636
|
+
* unstages everything built on top of it within its patch sets, and that is
|
|
7637
|
+
* what `withPatchIds` carries.
|
|
7638
|
+
*/
|
|
7639
|
+
async unstagePatches(patchGroupId, /** What the user asked to unstage. */
|
|
7640
|
+
patchIds, /** What has to go with it: everything built on top of it. */
|
|
7641
|
+
withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
|
|
7642
|
+
authorId) {
|
|
7643
|
+
return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
|
|
7644
|
+
patchIds,
|
|
7645
|
+
withPatchIds
|
|
7646
|
+
}, authorId);
|
|
7647
|
+
}
|
|
7648
|
+
|
|
7649
|
+
/**
|
|
7650
|
+
* Every patch group on this branch, with what each holds.
|
|
7651
|
+
*
|
|
7652
|
+
* Read rather than mutated, and used to answer "which pending patches is THIS
|
|
7653
|
+
* person allowed to see". A draft render that skips this shows base + every
|
|
7654
|
+
* pending patch on the branch, including work other people have not
|
|
7655
|
+
* published — which is what independent publish exists to prevent.
|
|
7656
|
+
*
|
|
7657
|
+
* A failure is an empty list rather than a throw, and the caller decides what
|
|
7658
|
+
* that means. For a draft render the honest fallback is "show nothing
|
|
7659
|
+
* pending" rather than "show everything": being shown your own committed
|
|
7660
|
+
* content when the group lookup is down is a worse experience than being
|
|
7661
|
+
* shown somebody else's unpublished draft is a bug.
|
|
7662
|
+
*/
|
|
7663
|
+
/**
|
|
7664
|
+
* The last group lookup, and when it was made.
|
|
7665
|
+
*
|
|
7666
|
+
* A draft render calls `getPatchGroups` once per `fetchVal`, in series with
|
|
7667
|
+
* the whole-chain fetch, and a page that calls `fetchVal` several times pays
|
|
7668
|
+
* the round trip several times. Groups are per branch and change rarely, so a
|
|
7669
|
+
* short window removes the multiplier without letting a stage go unseen for
|
|
7670
|
+
* meaningfully longer than one render.
|
|
7671
|
+
*
|
|
7672
|
+
* Deliberately short. This is a read whose staleness decides whose draft
|
|
7673
|
+
* content someone sees, so it is a per-request de-duplication rather than a
|
|
7674
|
+
* cache: a second render a second later asks again.
|
|
7675
|
+
*/
|
|
7676
|
+
patchGroupsCache = null;
|
|
7677
|
+
async getPatchGroups(options) {
|
|
7678
|
+
const now = Date.now();
|
|
7679
|
+
if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
|
|
7680
|
+
return this.patchGroupsCache.res;
|
|
7681
|
+
}
|
|
7682
|
+
const res = await this.fetchPatchGroups();
|
|
7683
|
+
/*
|
|
7684
|
+
* ANSWERS are cached; failures are not.
|
|
7685
|
+
*
|
|
7686
|
+
* A transient failure held for a second is replayed to every caller in it,
|
|
7687
|
+
* and the callers are not equivalent: `refuseUnlessOwn` turns it into a 500
|
|
7688
|
+
* that refuses the stage, a scoped draft render falls back to base and
|
|
7689
|
+
* drops every pending patch on the page, and `GET /patches` omits the
|
|
7690
|
+
* annotation. One flaky request became a second of all three. The point of
|
|
7691
|
+
* this cache is to collapse the several `fetchVal` calls in one render into
|
|
7692
|
+
* one round trip, and an error is exactly the case worth retrying inside
|
|
7693
|
+
* that window rather than the case worth remembering.
|
|
7694
|
+
*
|
|
7695
|
+
* `unsupported` is cached with `ok` deliberately: it is a real answer about
|
|
7696
|
+
* the deployment — this content API predates patch groups — and it will not
|
|
7697
|
+
* change between two renders.
|
|
7698
|
+
*/
|
|
7699
|
+
if (res.status === "ok" || res.status === "unsupported") {
|
|
7700
|
+
this.patchGroupsCache = {
|
|
7701
|
+
at: now,
|
|
7702
|
+
res
|
|
7703
|
+
};
|
|
7704
|
+
}
|
|
7705
|
+
return res;
|
|
7706
|
+
}
|
|
7707
|
+
async fetchPatchGroups() {
|
|
7708
|
+
try {
|
|
7709
|
+
/*
|
|
7710
|
+
* `branch` is REQUIRED by the endpoint, which answers 400 without it.
|
|
7711
|
+
*
|
|
7712
|
+
* Same two params every other read here sends (`fetchPatchesInternal`,
|
|
7713
|
+
* `saveSourceFilePatch`): groups are per branch, so a request without one
|
|
7714
|
+
* is not merely under-specified, it is rejected.
|
|
7715
|
+
*/
|
|
7716
|
+
const params = new URLSearchParams([["branch", this.branch]]);
|
|
7717
|
+
const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
|
|
7718
|
+
headers: this.authHeaders
|
|
7719
|
+
});
|
|
7720
|
+
if (res.status === 404) {
|
|
7721
|
+
/*
|
|
7722
|
+
* The endpoint is not there, which is a content API that PREDATES patch
|
|
7723
|
+
* groups — not a failure.
|
|
7724
|
+
*
|
|
7725
|
+
* The distinction decides what a draft render shows, and collapsing it
|
|
7726
|
+
* into "error" is not a small mistake: a caller that reads an error as
|
|
7727
|
+
* "could not ask" renders BASE, so every existing http deployment would
|
|
7728
|
+
* silently drop all pending content from every draft preview. "There
|
|
7729
|
+
* are no groups here" has to mean unscoped, which is exactly the
|
|
7730
|
+
* behaviour those projects have today.
|
|
7731
|
+
*/
|
|
7732
|
+
return {
|
|
7733
|
+
status: "unsupported"
|
|
7734
|
+
};
|
|
7735
|
+
}
|
|
7736
|
+
if (!res.ok) {
|
|
7737
|
+
return {
|
|
7738
|
+
status: "error",
|
|
7739
|
+
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}`
|
|
7740
|
+
};
|
|
7741
|
+
}
|
|
7742
|
+
const parsed = PatchGroupsResponse.safeParse(await res.json());
|
|
7743
|
+
if (!parsed.success) {
|
|
7744
|
+
return {
|
|
7745
|
+
status: "error",
|
|
7746
|
+
message: `Could not parse patch groups response. Error: ${fromError(parsed.error)}`
|
|
7747
|
+
};
|
|
7748
|
+
}
|
|
7749
|
+
return {
|
|
7750
|
+
status: "ok",
|
|
7751
|
+
patchGroups: parsed.data.patchGroups
|
|
7752
|
+
};
|
|
7753
|
+
} catch (err) {
|
|
7754
|
+
return {
|
|
7755
|
+
status: "error",
|
|
7756
|
+
message: `Could not read patch groups. Error: ${err instanceof Error ? err.message : String(err)}`
|
|
7757
|
+
};
|
|
7758
|
+
}
|
|
7759
|
+
}
|
|
7760
|
+
async mutatePatchGroup(method, path, body,
|
|
7761
|
+
/**
|
|
7762
|
+
* WHO is asking. Sent as `x-val-profile-id`, which is what the content API
|
|
7763
|
+
* reads to decide whether this group is the caller's.
|
|
7764
|
+
*
|
|
7765
|
+
* `this.authHeaders` is the app's API key, and that names the PROJECT, not
|
|
7766
|
+
* the person — so without this the content API cannot resolve a profile
|
|
7767
|
+
* and refuses every stage and unstage with
|
|
7768
|
+
* "Cannot resolve the caller's profile". The group endpoints are the only
|
|
7769
|
+
* ones here that need it, because they are the only ones whose answer
|
|
7770
|
+
* depends on which of a project's editors is calling.
|
|
7771
|
+
*
|
|
7772
|
+
* Omitted when there is no session rather than sent empty: the content API
|
|
7773
|
+
* treats an unidentified caller as a refusal, which is what we want, and an
|
|
7774
|
+
* empty header would be a different and less obvious way to say it.
|
|
7775
|
+
*
|
|
7776
|
+
* A PAT already identifies a person, so `authHeaders` carries the identity
|
|
7777
|
+
* on its own there and this adds nothing.
|
|
7778
|
+
*/
|
|
7779
|
+
authorId) {
|
|
7780
|
+
try {
|
|
7781
|
+
const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
|
|
7782
|
+
method,
|
|
7783
|
+
headers: {
|
|
7784
|
+
...this.authHeaders,
|
|
7785
|
+
...(authorId !== null ? {
|
|
7786
|
+
"x-val-profile-id": authorId
|
|
7787
|
+
} : {}),
|
|
7788
|
+
"Content-Type": "application/json"
|
|
7789
|
+
},
|
|
7790
|
+
body: JSON.stringify(body)
|
|
7791
|
+
});
|
|
7792
|
+
if (res.ok) {
|
|
7793
|
+
const parsed = PatchGroupMutationResponse.safeParse(await res.json());
|
|
7794
|
+
if (parsed.success) {
|
|
7795
|
+
return {
|
|
7796
|
+
patchIds: parsed.data.patchIds
|
|
7797
|
+
};
|
|
7798
|
+
}
|
|
7799
|
+
return {
|
|
7800
|
+
status: 500,
|
|
7801
|
+
patchIds: [],
|
|
7802
|
+
error: {
|
|
7803
|
+
message: `Could not parse patch group response. Error: ${fromError(parsed.error)}`
|
|
7804
|
+
}
|
|
7805
|
+
};
|
|
7806
|
+
}
|
|
7807
|
+
// 403 (not your group) and 409 (already published) are meaningful to the
|
|
7808
|
+
// client, so they are passed through rather than flattened to a 500.
|
|
7809
|
+
if (res.status === 403 || res.status === 409) {
|
|
7810
|
+
// `home` answers these with a JSON body, so `res.text()` put the literal
|
|
7811
|
+
// `{"message":"..."}` in front of the user. Every other branch in this
|
|
7812
|
+
// class unwraps it; this one now does too.
|
|
7813
|
+
return {
|
|
7814
|
+
status: res.status,
|
|
7815
|
+
patchIds: [],
|
|
7816
|
+
error: {
|
|
7817
|
+
message: getErrorMessageFromUnknownJson(await res.json().catch(() => undefined), `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`)
|
|
7818
|
+
}
|
|
7819
|
+
};
|
|
7820
|
+
}
|
|
7821
|
+
// A 401 here is the app's own credentials failing, not the user's, so it gets
|
|
7822
|
+
// the same wording as every other call in this class rather than an opaque
|
|
7823
|
+
// 500 that sends the user looking at their own session.
|
|
7824
|
+
if (res.status === 401) {
|
|
7825
|
+
return {
|
|
7826
|
+
status: 500,
|
|
7827
|
+
patchIds: [],
|
|
7828
|
+
error: {
|
|
7829
|
+
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."
|
|
7830
|
+
}
|
|
7831
|
+
};
|
|
7832
|
+
}
|
|
7833
|
+
return {
|
|
7834
|
+
status: 500,
|
|
7835
|
+
patchIds: [],
|
|
7836
|
+
error: {
|
|
7837
|
+
message: `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`
|
|
7838
|
+
}
|
|
7839
|
+
};
|
|
7840
|
+
} catch (err) {
|
|
7841
|
+
return {
|
|
7842
|
+
status: 500,
|
|
7843
|
+
patchIds: [],
|
|
7844
|
+
error: {
|
|
7845
|
+
message: `Could not update patch group (connection error?): ${err instanceof Error ? err.message : JSON.stringify(err)}`
|
|
7846
|
+
}
|
|
7847
|
+
};
|
|
7848
|
+
}
|
|
7849
|
+
}
|
|
7850
|
+
// #endregion
|
|
7851
|
+
|
|
7852
|
+
async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup) {
|
|
7853
|
+
const baseSha = await this.getBaseSha();
|
|
7854
|
+
return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
|
|
7855
|
+
method: "POST",
|
|
7856
|
+
headers: {
|
|
7857
|
+
...this.authHeaders,
|
|
7858
|
+
"Content-Type": "application/json"
|
|
7859
|
+
},
|
|
7860
|
+
body: JSON.stringify({
|
|
7861
|
+
path,
|
|
7862
|
+
patch,
|
|
7863
|
+
authorId,
|
|
7864
|
+
sessionId,
|
|
7865
|
+
patchId,
|
|
7866
|
+
parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
|
|
7867
|
+
baseSha,
|
|
7868
|
+
commit: this.commitSha,
|
|
7869
|
+
branch: this.branch,
|
|
7870
|
+
coreVersion: Internal.VERSION.core,
|
|
7871
|
+
/*
|
|
7872
|
+
* Group membership in the SAME request as the patch.
|
|
7873
|
+
*
|
|
7874
|
+
* The content API runs every refusal before its insert, so an invalid
|
|
7875
|
+
* closure is a 400 with nothing written. Spread rather than sent as
|
|
7876
|
+
* nulls: a client with no group omits the fields entirely, which is
|
|
7877
|
+
* what an older content API expects to see.
|
|
7878
|
+
*/
|
|
7879
|
+
...(patchGroup ? {
|
|
7880
|
+
// Only when the caller named one. Omitted, the content API
|
|
7881
|
+
// resolves this author's open group and creates it if absent,
|
|
7882
|
+
// which is what every write wants.
|
|
7883
|
+
...(patchGroup.patchGroupId !== undefined ? {
|
|
7884
|
+
patchGroupId: patchGroup.patchGroupId
|
|
7885
|
+
} : {}),
|
|
7886
|
+
withPatchIds: patchGroup.withPatchIds
|
|
7887
|
+
} : {})
|
|
7888
|
+
})
|
|
7889
|
+
}).then(async res => {
|
|
7890
|
+
var _res$headers$get2;
|
|
7891
|
+
if (res.ok) {
|
|
7892
|
+
const parsed = SavePatchResponse.safeParse(await res.json());
|
|
7893
|
+
if (parsed.success) {
|
|
7894
|
+
return result.ok({
|
|
7895
|
+
patchId: parsed.data.patchId,
|
|
7896
|
+
/*
|
|
7897
|
+
* Passed back to the client, which cannot learn it any other way.
|
|
7898
|
+
*
|
|
7899
|
+
* A write names no group — the content API resolves this author's
|
|
7900
|
+
* open group and CREATES it if absent — so on a fresh branch the
|
|
7901
|
+
* group comes into existence here and nowhere else. The chain
|
|
7902
|
+
* annotation is only re-read when a fetch has missing ids to ask
|
|
7903
|
+
* for, and a patch this client made is never missing, so without
|
|
7904
|
+
* this the tab that bootstrapped the group would never learn its
|
|
7905
|
+
* id and every stage would be a no-op.
|
|
7906
|
+
*/
|
|
7907
|
+
...(parsed.data.patchGroupId !== undefined ? {
|
|
7908
|
+
patchGroupId: parsed.data.patchGroupId
|
|
7909
|
+
} : {})
|
|
7910
|
+
});
|
|
7911
|
+
}
|
|
7912
|
+
return result.err({
|
|
7913
|
+
errorType: "other",
|
|
7914
|
+
message: `Could not parse save patch response. Error: ${fromError(parsed.error)}`
|
|
7915
|
+
});
|
|
7916
|
+
}
|
|
7917
|
+
if (res.status === 409) {
|
|
7918
|
+
return result.err({
|
|
7919
|
+
errorType: "patch-head-conflict",
|
|
7920
|
+
message: "Conflict: " + (await res.text())
|
|
7921
|
+
});
|
|
7922
|
+
}
|
|
7923
|
+
if ((_res$headers$get2 = res.headers.get("Content-Type")) !== null && _res$headers$get2 !== void 0 && _res$headers$get2.includes("application/json")) {
|
|
7924
|
+
const json = await res.json();
|
|
7925
|
+
const message = getErrorMessageFromUnknownJson(json, "Unknown error");
|
|
7926
|
+
return result.err({
|
|
7927
|
+
errorType: "other",
|
|
7928
|
+
message
|
|
7929
|
+
});
|
|
7930
|
+
}
|
|
7931
|
+
return result.err({
|
|
7932
|
+
errorType: "other",
|
|
7933
|
+
message: "Could not save patch. HTTP error: " + res.status + " " + res.statusText
|
|
7934
|
+
});
|
|
7935
|
+
}).catch(e => {
|
|
7044
7936
|
return result.err({
|
|
7045
7937
|
errorType: "other",
|
|
7046
7938
|
message: `Could save source file patch (connection error?): ${e instanceof Error ? e.message : e.toString()}`
|
|
@@ -7204,7 +8096,12 @@ class ValOpsHttp extends ValOps {
|
|
|
7204
8096
|
if (!file) {
|
|
7205
8097
|
return null;
|
|
7206
8098
|
}
|
|
7207
|
-
|
|
8099
|
+
// Plain base64, the same as the `repo` branch of getBinaryFile above.
|
|
8100
|
+
//
|
|
8101
|
+
// `value` used to be a data: URL here and plain base64 there - two
|
|
8102
|
+
// encodings in one field, told apart only by which branch produced them.
|
|
8103
|
+
// The content service answers base64 for both now.
|
|
8104
|
+
return Buffer.from(file.value, "base64");
|
|
7208
8105
|
}
|
|
7209
8106
|
async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId, remote) {
|
|
7210
8107
|
const params = new URLSearchParams();
|
|
@@ -7264,7 +8161,17 @@ class ValOpsHttp extends ValOps {
|
|
|
7264
8161
|
}]
|
|
7265
8162
|
};
|
|
7266
8163
|
}
|
|
7267
|
-
async deletePatches(patchIds
|
|
8164
|
+
async deletePatches(patchIds,
|
|
8165
|
+
/**
|
|
8166
|
+
* Patches that are NOT deleted but must lose their group membership.
|
|
8167
|
+
*
|
|
8168
|
+
* Deleting a patch out of the middle of a patch set leaves every group
|
|
8169
|
+
* still holding the rest with a non-prefix intersection — the patches after
|
|
8170
|
+
* the hole were written against a view that had it. The content API cannot
|
|
8171
|
+
* work out which those are (it has no schema), so the client sends the
|
|
8172
|
+
* forward closure and it drops those memberships without deleting anything.
|
|
8173
|
+
*/
|
|
8174
|
+
unstagePatchIds) {
|
|
7268
8175
|
return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
|
|
7269
8176
|
method: "DELETE",
|
|
7270
8177
|
headers: {
|
|
@@ -7272,7 +8179,10 @@ class ValOpsHttp extends ValOps {
|
|
|
7272
8179
|
"Content-Type": "application/json"
|
|
7273
8180
|
},
|
|
7274
8181
|
body: JSON.stringify({
|
|
7275
|
-
patchIds
|
|
8182
|
+
patchIds,
|
|
8183
|
+
...(unstagePatchIds !== undefined && unstagePatchIds.length > 0 ? {
|
|
8184
|
+
unstagePatchIds
|
|
8185
|
+
} : {})
|
|
7276
8186
|
})
|
|
7277
8187
|
}).then(async res => {
|
|
7278
8188
|
if (res.ok) {
|
|
@@ -7311,7 +8221,22 @@ class ValOpsHttp extends ValOps {
|
|
|
7311
8221
|
};
|
|
7312
8222
|
});
|
|
7313
8223
|
}
|
|
7314
|
-
async commit(prepared, message, committer, filesDirectory, newBranch
|
|
8224
|
+
async commit(prepared, message, committer, filesDirectory, newBranch,
|
|
8225
|
+
/**
|
|
8226
|
+
* The patch group this commit EMPTIES, if it empties one.
|
|
8227
|
+
*
|
|
8228
|
+
* The content API closes the group it is given — and closes it WITHOUT
|
|
8229
|
+
* checking that the commit shipped all of it, so a caller that names a
|
|
8230
|
+
* group still holding work takes those patches out of every group and
|
|
8231
|
+
* leaves their author unable to publish them. The client therefore sends it
|
|
8232
|
+
* only when the publish accounts for everything the group still holds.
|
|
8233
|
+
*
|
|
8234
|
+
* Omitting it is not neutral: the commit still empties the group (the
|
|
8235
|
+
* content API drops applied ids from every group), but `published_at` is
|
|
8236
|
+
* never set, so the id is reused across publishes instead of a new group
|
|
8237
|
+
* per publish and the "already published" refusal can never fire.
|
|
8238
|
+
*/
|
|
8239
|
+
patchGroupId) {
|
|
7315
8240
|
try {
|
|
7316
8241
|
var _res$headers$get3;
|
|
7317
8242
|
const existingBranch = this.branch;
|
|
@@ -7319,12 +8244,45 @@ class ValOpsHttp extends ValOps {
|
|
|
7319
8244
|
method: "POST",
|
|
7320
8245
|
headers: {
|
|
7321
8246
|
...this.authHeaders,
|
|
8247
|
+
/*
|
|
8248
|
+
* WHO is publishing — the same `x-val-profile-id` stage and unstage
|
|
8249
|
+
* send, and for the same reason: `this.authHeaders` is the app's API
|
|
8250
|
+
* key, which names the PROJECT and not the person.
|
|
8251
|
+
*
|
|
8252
|
+
* Closing a group is an ownership decision, so the content API
|
|
8253
|
+
* refuses a commit that names a `patchGroupId` it cannot attribute:
|
|
8254
|
+
* without this header `profileId` is `undefined` there and every
|
|
8255
|
+
* group-closing publish is 403 "Cannot resolve the caller's profile".
|
|
8256
|
+
* That is the NORMAL full publish, not an edge — `publish` names the
|
|
8257
|
+
* group whenever the commit empties it.
|
|
8258
|
+
*
|
|
8259
|
+
* Sent on every commit rather than only when a group is named. The
|
|
8260
|
+
* committer is a person either way, `committer` in the body already
|
|
8261
|
+
* says so, and a header that appears only on some commits is one more
|
|
8262
|
+
* conditional for a reader of either repo to reconstruct. It changes
|
|
8263
|
+
* nothing else: the content API reads it as an identity claim beside
|
|
8264
|
+
* the app's key and derives no scope from it.
|
|
8265
|
+
*/
|
|
8266
|
+
"x-val-profile-id": committer,
|
|
7322
8267
|
"Content-Type": "application/json"
|
|
7323
8268
|
},
|
|
7324
8269
|
body: JSON.stringify({
|
|
7325
8270
|
patchedSourceFiles: prepared.patchedSourceFiles,
|
|
7326
8271
|
patchedBinaryFilesDescriptors: prepared.patchedBinaryFilesDescriptors,
|
|
7327
8272
|
appliedPatches: prepared.appliedPatches,
|
|
8273
|
+
/*
|
|
8274
|
+
* What each changed module IS after this commit, as DATA, with the
|
|
8275
|
+
* schema it is under.
|
|
8276
|
+
*
|
|
8277
|
+
* The half git cannot give back. Git keeps the `.val.ts`, but that is
|
|
8278
|
+
* code: turning it back into data means parsing it, which is
|
|
8279
|
+
* best-effort and rots across TypeScript, runtime and Val versions -
|
|
8280
|
+
* so a commit that reads today can quietly stop reading later. And
|
|
8281
|
+
* git has no copy at all of the SCHEMA a commit was written under,
|
|
8282
|
+
* which is what showing a module as it was needs once the schema has
|
|
8283
|
+
* moved on.
|
|
8284
|
+
*/
|
|
8285
|
+
modules: prepared.moduleVersions,
|
|
7328
8286
|
commit: this.commitSha,
|
|
7329
8287
|
root: this.root,
|
|
7330
8288
|
filesDirectory,
|
|
@@ -7332,7 +8290,10 @@ class ValOpsHttp extends ValOps {
|
|
|
7332
8290
|
committer,
|
|
7333
8291
|
message,
|
|
7334
8292
|
existingBranch,
|
|
7335
|
-
newBranch
|
|
8293
|
+
newBranch,
|
|
8294
|
+
...(patchGroupId !== undefined ? {
|
|
8295
|
+
patchGroupId
|
|
8296
|
+
} : {})
|
|
7336
8297
|
})
|
|
7337
8298
|
});
|
|
7338
8299
|
if (res.ok) {
|
|
@@ -7369,19 +8330,620 @@ class ValOpsHttp extends ValOps {
|
|
|
7369
8330
|
}
|
|
7370
8331
|
};
|
|
7371
8332
|
}
|
|
7372
|
-
return {
|
|
7373
|
-
error: {
|
|
7374
|
-
message: "Could not commit. HTTP error: " + res.status + " " + res.statusText
|
|
7375
|
-
}
|
|
8333
|
+
return {
|
|
8334
|
+
error: {
|
|
8335
|
+
message: "Could not commit. HTTP error: " + res.status + " " + res.statusText
|
|
8336
|
+
}
|
|
8337
|
+
};
|
|
8338
|
+
} catch (err) {
|
|
8339
|
+
return {
|
|
8340
|
+
error: {
|
|
8341
|
+
message: `Could not commit (connection error?): ${err instanceof Error ? err.message : (err === null || err === void 0 ? void 0 : err.toString()) || "unknown error"}`
|
|
8342
|
+
}
|
|
8343
|
+
};
|
|
8344
|
+
}
|
|
8345
|
+
}
|
|
8346
|
+
|
|
8347
|
+
// #region history
|
|
8348
|
+
|
|
8349
|
+
/**
|
|
8350
|
+
* One GET against the content service, parsed and Result-typed.
|
|
8351
|
+
*
|
|
8352
|
+
* Every history read has the same three failure modes - could not reach the
|
|
8353
|
+
* service, the commit is not there, the answer was not what was expected -
|
|
8354
|
+
* and each of them means something different to a caller deciding whether to
|
|
8355
|
+
* offer a restore. Doing it once here is what keeps that consistent across
|
|
8356
|
+
* the five endpoints.
|
|
8357
|
+
*/
|
|
8358
|
+
async getHistory(path, schema, commitShaForErrors) {
|
|
8359
|
+
let res;
|
|
8360
|
+
try {
|
|
8361
|
+
res = await fetch(`${this.contentUrl}/v1/${this.project}${path}`, {
|
|
8362
|
+
headers: {
|
|
8363
|
+
...this.authHeaders,
|
|
8364
|
+
"Content-Type": "application/json"
|
|
8365
|
+
}
|
|
8366
|
+
});
|
|
8367
|
+
} catch (err) {
|
|
8368
|
+
return result.err({
|
|
8369
|
+
kind: "transport",
|
|
8370
|
+
message: err instanceof Error ? err.message : String(err)
|
|
8371
|
+
});
|
|
8372
|
+
}
|
|
8373
|
+
if (res.status === 404) {
|
|
8374
|
+
return result.err({
|
|
8375
|
+
kind: "commit-not-found",
|
|
8376
|
+
commitSha: commitShaForErrors
|
|
8377
|
+
});
|
|
8378
|
+
}
|
|
8379
|
+
if (!res.ok) {
|
|
8380
|
+
var _res$headers$get4;
|
|
8381
|
+
let message = `${res.status} ${res.statusText}`;
|
|
8382
|
+
if ((_res$headers$get4 = res.headers.get("Content-Type")) !== null && _res$headers$get4 !== void 0 && _res$headers$get4.includes("application/json")) {
|
|
8383
|
+
message = getErrorMessageFromUnknownJson(await res.json(), message);
|
|
8384
|
+
}
|
|
8385
|
+
// A 5xx from the service reading a record it says it has is a real
|
|
8386
|
+
// failure of that record, not a network problem - keep them apart.
|
|
8387
|
+
return result.err({
|
|
8388
|
+
kind: "archive-unreadable",
|
|
8389
|
+
commitSha: commitShaForErrors,
|
|
8390
|
+
message
|
|
8391
|
+
});
|
|
8392
|
+
}
|
|
8393
|
+
// A 200 is not a promise of JSON: a proxy or gateway in front of the
|
|
8394
|
+
// service answers HTML, and an unguarded `.json()` would reject straight
|
|
8395
|
+
// out of this Result-typed API and 500 the route.
|
|
8396
|
+
let body;
|
|
8397
|
+
try {
|
|
8398
|
+
body = await res.json();
|
|
8399
|
+
} catch (err) {
|
|
8400
|
+
return result.err({
|
|
8401
|
+
kind: "archive-unreadable",
|
|
8402
|
+
commitSha: commitShaForErrors,
|
|
8403
|
+
message: `response was not JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
8404
|
+
});
|
|
8405
|
+
}
|
|
8406
|
+
const parsed = schema.safeParse(body);
|
|
8407
|
+
if (!parsed.success) {
|
|
8408
|
+
return result.err({
|
|
8409
|
+
kind: "archive-unreadable",
|
|
8410
|
+
commitSha: commitShaForErrors,
|
|
8411
|
+
message: `unexpected response shape: ${fromError(parsed.error)}`
|
|
8412
|
+
});
|
|
8413
|
+
}
|
|
8414
|
+
return result.ok(parsed.data);
|
|
8415
|
+
}
|
|
8416
|
+
async listCommits(branch, options) {
|
|
8417
|
+
const params = new URLSearchParams({
|
|
8418
|
+
branch
|
|
8419
|
+
});
|
|
8420
|
+
if ((options === null || options === void 0 ? void 0 : options.limit) !== undefined) {
|
|
8421
|
+
params.set("limit", String(options.limit));
|
|
8422
|
+
}
|
|
8423
|
+
if ((options === null || options === void 0 ? void 0 : options.cursor) !== undefined) {
|
|
8424
|
+
params.set("cursor", options.cursor);
|
|
8425
|
+
}
|
|
8426
|
+
const res = await this.getHistory(`/commits?${params}`, ListCommitsResponse, branch);
|
|
8427
|
+
if (result.isErr(res)) {
|
|
8428
|
+
return res;
|
|
8429
|
+
}
|
|
8430
|
+
return result.ok({
|
|
8431
|
+
commits: res.value.commits,
|
|
8432
|
+
nextCursor: res.value.nextCursor
|
|
8433
|
+
});
|
|
8434
|
+
}
|
|
8435
|
+
async getCommitPatches(commitSha) {
|
|
8436
|
+
// One request. The endpoint returns the commit's own summary alongside its
|
|
8437
|
+
// patches, because it already has the row - this used to page the commit
|
|
8438
|
+
// LISTING until the sha turned up, which cost up to twenty requests to open
|
|
8439
|
+
// an old commit and could not find one on another branch at all.
|
|
8440
|
+
const patchesRes = await this.getHistory(`/commits/${commitSha}/patches`, CommitPatchesResponse, commitSha);
|
|
8441
|
+
if (result.isErr(patchesRes)) {
|
|
8442
|
+
return patchesRes;
|
|
8443
|
+
}
|
|
8444
|
+
return result.ok({
|
|
8445
|
+
commit: {
|
|
8446
|
+
...patchesRes.value.commit,
|
|
8447
|
+
patchCount: patchesRes.value.patches.length
|
|
8448
|
+
},
|
|
8449
|
+
patches: patchesRes.value.patches.map(patch => ({
|
|
8450
|
+
patchId: patch.patchId,
|
|
8451
|
+
moduleFilePath: patch.path,
|
|
8452
|
+
patch: patch.patch,
|
|
8453
|
+
authorId: patch.authorId,
|
|
8454
|
+
createdAt: patch.createdAt,
|
|
8455
|
+
baseSha: patch.baseSha,
|
|
8456
|
+
coreVersion: patch.coreVersion
|
|
8457
|
+
}))
|
|
8458
|
+
});
|
|
8459
|
+
}
|
|
8460
|
+
async getCommitModules(commitSha, options) {
|
|
8461
|
+
const query = new URLSearchParams();
|
|
8462
|
+
if (options !== null && options !== void 0 && options.asOf) {
|
|
8463
|
+
query.set("as_of", "1");
|
|
8464
|
+
}
|
|
8465
|
+
if (options !== null && options !== void 0 && options.moduleFilePath) {
|
|
8466
|
+
query.set("path", options.moduleFilePath);
|
|
8467
|
+
}
|
|
8468
|
+
const search = query.toString();
|
|
8469
|
+
const res = await this.getHistory(`/commits/${commitSha}/modules${search ? `?${search}` : ""}`, CommitModulesResponse, commitSha);
|
|
8470
|
+
if (result.isErr(res)) {
|
|
8471
|
+
return res;
|
|
8472
|
+
}
|
|
8473
|
+
return result.ok({
|
|
8474
|
+
modules: res.value.modules.map(module => ({
|
|
8475
|
+
...module,
|
|
8476
|
+
moduleFilePath: module.moduleFilePath
|
|
8477
|
+
})),
|
|
8478
|
+
// Absent from an older content server, which only ever answered "what
|
|
8479
|
+
// this commit changed" - and that answer is always whole.
|
|
8480
|
+
complete: res.value.complete ?? !(options !== null && options !== void 0 && options.asOf)
|
|
8481
|
+
});
|
|
8482
|
+
}
|
|
8483
|
+
async getCommitAffectedFiles(commitSha) {
|
|
8484
|
+
const res = await this.getHistory(`/commits/${commitSha}/affected-files`, CommitAffectedFilesResponse, commitSha);
|
|
8485
|
+
if (result.isErr(res)) {
|
|
8486
|
+
return res;
|
|
8487
|
+
}
|
|
8488
|
+
return result.ok(res.value.files);
|
|
8489
|
+
}
|
|
8490
|
+
async getFileAtCommit(commitSha, filePath, remote) {
|
|
8491
|
+
const params = new URLSearchParams({
|
|
8492
|
+
path: filePath
|
|
8493
|
+
});
|
|
8494
|
+
if (remote) {
|
|
8495
|
+
params.set("remote", "true");
|
|
8496
|
+
}
|
|
8497
|
+
let res;
|
|
8498
|
+
try {
|
|
8499
|
+
res = await fetch(`${this.contentUrl}/v1/${this.project}/commits/${commitSha}/file?${params}`, {
|
|
8500
|
+
headers: {
|
|
8501
|
+
...this.authHeaders
|
|
8502
|
+
}
|
|
8503
|
+
});
|
|
8504
|
+
} catch (err) {
|
|
8505
|
+
return result.err({
|
|
8506
|
+
kind: "transport",
|
|
8507
|
+
message: err instanceof Error ? err.message : String(err)
|
|
8508
|
+
});
|
|
8509
|
+
}
|
|
8510
|
+
if (!res.ok) {
|
|
8511
|
+
// A missing file is about the FILE, not the commit: the commit may be
|
|
8512
|
+
// perfectly readable and this one blob gone. Saying "commit not found"
|
|
8513
|
+
// here would send a caller looking in the wrong place.
|
|
8514
|
+
return result.err({
|
|
8515
|
+
kind: "file-unavailable",
|
|
8516
|
+
gitPath: filePath,
|
|
8517
|
+
message: `${res.status} ${res.statusText}`
|
|
8518
|
+
});
|
|
8519
|
+
}
|
|
8520
|
+
return result.ok(Buffer.from(await res.arrayBuffer()));
|
|
8521
|
+
}
|
|
8522
|
+
// #endregion history
|
|
8523
|
+
}
|
|
8524
|
+
|
|
8525
|
+
/**
|
|
8526
|
+
* Everything that can go wrong reading, replaying or restoring history.
|
|
8527
|
+
*
|
|
8528
|
+
* There is a lot of it, which is the point of making it one closed union rather
|
|
8529
|
+
* than strings: reading a commit means reaching a content host, finding the
|
|
8530
|
+
* commit, reading what was recorded for each of its modules, and asking whether
|
|
8531
|
+
* this version of Val can make sense of the schema it was stored under. Each of
|
|
8532
|
+
* those fails differently, and a caller deciding what to SHOW needs to know
|
|
8533
|
+
* which.
|
|
8534
|
+
*
|
|
8535
|
+
* Every member has a producer. The union once carried five more - a module
|
|
8536
|
+
* removed from the project, an op that would not replay, a value that no longer
|
|
8537
|
+
* fits today's schema, a field the schema no longer defines, and ops from a
|
|
8538
|
+
* core version known to replay wrongly. All five belonged to reconstructing a
|
|
8539
|
+
* commit by replaying patches against a parsed source; storing the data ended
|
|
8540
|
+
* that, and a member nothing can produce is a state the Studio writes handling
|
|
8541
|
+
* for and never sees.
|
|
8542
|
+
*
|
|
8543
|
+
* ## The rule
|
|
8544
|
+
*
|
|
8545
|
+
* Whole-commit failures are the `err` channel. Per-module and per-patch
|
|
8546
|
+
* failures ride along inside the `ok` payload.
|
|
8547
|
+
*
|
|
8548
|
+
* A commit that cannot be found or read at all has nothing to show. But one
|
|
8549
|
+
* unparseable module out of ten does not make the other nine unreadable, and
|
|
8550
|
+
* collapsing the whole view because of it would hide exactly the information
|
|
8551
|
+
* someone needs to see - that this module is the broken one.
|
|
8552
|
+
*/
|
|
8553
|
+
|
|
8554
|
+
function historyErrorMessage(error) {
|
|
8555
|
+
switch (error.kind) {
|
|
8556
|
+
case "commit-not-found":
|
|
8557
|
+
return `No commit ${error.commitSha} created by Val in this project`;
|
|
8558
|
+
case "archive-unreadable":
|
|
8559
|
+
return `Could not read the stored record of commit ${error.commitSha}: ${error.message}`;
|
|
8560
|
+
case "source-unavailable":
|
|
8561
|
+
return `No stored source for ${error.moduleFilePath} at this commit (it predates history being recorded)`;
|
|
8562
|
+
case "schema-unreadable":
|
|
8563
|
+
return `The schema stored for ${error.moduleFilePath} at this commit is not one this version of Val can read: ${error.message}`;
|
|
8564
|
+
case "file-unavailable":
|
|
8565
|
+
return `Could not read ${error.gitPath} at this commit: ${error.message}`;
|
|
8566
|
+
case "not-supported-in-fs-mode":
|
|
8567
|
+
return "History is only available for projects connected to Val's content service";
|
|
8568
|
+
case "transport":
|
|
8569
|
+
return `Could not reach the content service: ${error.message}`;
|
|
8570
|
+
}
|
|
8571
|
+
}
|
|
8572
|
+
|
|
8573
|
+
/**
|
|
8574
|
+
* Everything the content service knows about one commit, in one step.
|
|
8575
|
+
*
|
|
8576
|
+
* Three endpoints, requested together rather than in sequence: they are
|
|
8577
|
+
* independent, they all resolve from the same stored record, and the round
|
|
8578
|
+
* trips are what a user waits through when opening a commit.
|
|
8579
|
+
*
|
|
8580
|
+
* The commit itself comes from the patches call, which is the one that fails
|
|
8581
|
+
* usefully when the commit does not exist.
|
|
8582
|
+
*/
|
|
8583
|
+
async function fetchCommitRecord(ops, commitSha) {
|
|
8584
|
+
const [patchesRes, modulesRes, filesRes] = await Promise.all([ops.getCommitPatches(commitSha), ops.getCommitModules(commitSha), ops.getCommitAffectedFiles(commitSha)]);
|
|
8585
|
+
if (result.isErr(patchesRes)) {
|
|
8586
|
+
return patchesRes;
|
|
8587
|
+
}
|
|
8588
|
+
if (result.isErr(modulesRes)) {
|
|
8589
|
+
return modulesRes;
|
|
8590
|
+
}
|
|
8591
|
+
if (result.isErr(filesRes)) {
|
|
8592
|
+
return filesRes;
|
|
8593
|
+
}
|
|
8594
|
+
return result.ok({
|
|
8595
|
+
commit: patchesRes.value.commit,
|
|
8596
|
+
patches: patchesRes.value.patches,
|
|
8597
|
+
modules: modulesRes.value.modules,
|
|
8598
|
+
affectedFiles: filesRes.value
|
|
8599
|
+
});
|
|
8600
|
+
}
|
|
8601
|
+
|
|
8602
|
+
/**
|
|
8603
|
+
* The `*.val.json` entries a commit touched, read from git at that commit.
|
|
8604
|
+
*
|
|
8605
|
+
* Not stored in the commit record, unlike the `.val.ts` sources: an entry is a
|
|
8606
|
+
* plain JSON file that git has at every commit, so a second copy could only
|
|
8607
|
+
* drift from the first. A `.val.ts` is different - it is what a restore
|
|
8608
|
+
* replays patches against, and reading it from git would make every look at
|
|
8609
|
+
* history depend on the repository still being there.
|
|
8610
|
+
*
|
|
8611
|
+
* Reads at the commit itself rather than its parent: an entry's content AT a
|
|
8612
|
+
* commit is what that commit produced, which is the "after" side. The "before"
|
|
8613
|
+
* side is the same file at the parent commit, and a caller that wants it asks
|
|
8614
|
+
* for the parent.
|
|
8615
|
+
*/
|
|
8616
|
+
async function resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles) {
|
|
8617
|
+
const entryPaths = affectedFiles.filter(file => file.kind === "json-entry")
|
|
8618
|
+
// A deleted entry has no content at this commit; asking for it would be a
|
|
8619
|
+
// guaranteed 404 reported as a failure, which is noise rather than news.
|
|
8620
|
+
.filter(file => file.change !== "deleted").map(file => file.gitPath);
|
|
8621
|
+
if (entryPaths.length === 0) {
|
|
8622
|
+
return result.ok({
|
|
8623
|
+
entries: {},
|
|
8624
|
+
failures: []
|
|
8625
|
+
});
|
|
8626
|
+
}
|
|
8627
|
+
const entries = {};
|
|
8628
|
+
const failures = [];
|
|
8629
|
+
const fetched = await Promise.all(entryPaths.map(async gitPath => ({
|
|
8630
|
+
gitPath,
|
|
8631
|
+
res: await ops.getFileAtCommit(commitSha, gitPath, false)
|
|
8632
|
+
})));
|
|
8633
|
+
for (const {
|
|
8634
|
+
gitPath,
|
|
8635
|
+
res
|
|
8636
|
+
} of fetched) {
|
|
8637
|
+
if (result.isErr(res)) {
|
|
8638
|
+
failures.push({
|
|
8639
|
+
kind: "file-unavailable",
|
|
8640
|
+
gitPath,
|
|
8641
|
+
message: res.error.kind === "file-unavailable" ? res.error.message : `could not read entry: ${res.error.kind}`
|
|
8642
|
+
});
|
|
8643
|
+
continue;
|
|
8644
|
+
}
|
|
8645
|
+
try {
|
|
8646
|
+
entries[gitPath] = JSON.parse(res.value.toString("utf-8"));
|
|
8647
|
+
} catch (err) {
|
|
8648
|
+
failures.push({
|
|
8649
|
+
kind: "file-unavailable",
|
|
8650
|
+
gitPath,
|
|
8651
|
+
message: `not valid JSON at this commit: ${err instanceof Error ? err.message : String(err)}`
|
|
8652
|
+
});
|
|
8653
|
+
}
|
|
8654
|
+
}
|
|
8655
|
+
return result.ok({
|
|
8656
|
+
entries,
|
|
8657
|
+
failures
|
|
8658
|
+
});
|
|
8659
|
+
}
|
|
8660
|
+
|
|
8661
|
+
/**
|
|
8662
|
+
* Turn a commit's binary files into descriptors, WITHOUT fetching any of them.
|
|
8663
|
+
*
|
|
8664
|
+
* Pure, and that is the whole design. Showing that a commit changed six images
|
|
8665
|
+
* needs six rows, not six downloads; only an `<img>` that actually mounts pays
|
|
8666
|
+
* for its bytes. `url` points at the app's own history file route, which is
|
|
8667
|
+
* immutable for a given commit, so the browser's HTTP cache handles flipping
|
|
8668
|
+
* between commits with no JS cache at all.
|
|
8669
|
+
*/
|
|
8670
|
+
function describeBinaryFilesAtCommit(commitSha, affectedFiles, /** Where the app serves `/api/val` from, e.g. "/api/val". */
|
|
8671
|
+
apiBasePath) {
|
|
8672
|
+
const refs = [];
|
|
8673
|
+
for (const file of affectedFiles) {
|
|
8674
|
+
if (file.kind === "remote-binary") {
|
|
8675
|
+
refs.push({
|
|
8676
|
+
gitPath: file.ref,
|
|
8677
|
+
change: file.change,
|
|
8678
|
+
remote: true,
|
|
8679
|
+
url: historyFileUrl(apiBasePath, commitSha, file.ref, true)
|
|
8680
|
+
});
|
|
8681
|
+
continue;
|
|
8682
|
+
}
|
|
8683
|
+
if (file.kind !== "binary") {
|
|
8684
|
+
continue;
|
|
8685
|
+
}
|
|
8686
|
+
refs.push({
|
|
8687
|
+
gitPath: file.gitPath,
|
|
8688
|
+
change: file.change,
|
|
8689
|
+
remote: false,
|
|
8690
|
+
url: historyFileUrl(apiBasePath, commitSha, file.gitPath, false)
|
|
8691
|
+
});
|
|
8692
|
+
}
|
|
8693
|
+
return refs;
|
|
8694
|
+
}
|
|
8695
|
+
function historyFileUrl(apiBasePath, commitSha, filePath, remote) {
|
|
8696
|
+
const params = new URLSearchParams({
|
|
8697
|
+
commit_sha: commitSha,
|
|
8698
|
+
path: filePath
|
|
8699
|
+
});
|
|
8700
|
+
if (remote) {
|
|
8701
|
+
params.set("remote", "true");
|
|
8702
|
+
}
|
|
8703
|
+
return `${apiBasePath}/history/files?${params.toString()}`;
|
|
8704
|
+
}
|
|
8705
|
+
|
|
8706
|
+
/**
|
|
8707
|
+
* The paths a commit's ops touched, from the ops themselves.
|
|
8708
|
+
*
|
|
8709
|
+
* The alternative was reconstructing the module's previous state and diffing it
|
|
8710
|
+
* against the new one - which needed the pre-commit `.val.ts`, a static parse
|
|
8711
|
+
* of it, and a replay of every op, three fragile steps to rediscover something
|
|
8712
|
+
* the ops already say outright. An op carries the path it applies to. That is
|
|
8713
|
+
* the answer.
|
|
8714
|
+
*
|
|
8715
|
+
* Deduplicated and returned in first-touched order, because a module edited
|
|
8716
|
+
* eight times in one publish should list a field once, where it first changed.
|
|
8717
|
+
*/
|
|
8718
|
+
function changedPathsOf(moduleFilePath, patches) {
|
|
8719
|
+
const seen = new Set();
|
|
8720
|
+
const paths = [];
|
|
8721
|
+
for (const {
|
|
8722
|
+
patch
|
|
8723
|
+
} of patches) {
|
|
8724
|
+
for (const op of patch) {
|
|
8725
|
+
// A `file` op names a file, not a place in the source; the `replace` that
|
|
8726
|
+
// points the field at it is in the same patch and is the real change.
|
|
8727
|
+
if (op.op === "file") {
|
|
8728
|
+
continue;
|
|
8729
|
+
}
|
|
8730
|
+
/*
|
|
8731
|
+
* The canonical patch-path-to-source-path conversion, not a hand-rolled
|
|
8732
|
+
* join.
|
|
8733
|
+
*
|
|
8734
|
+
* `createValPathOfItem` JSON-quotes the ONE key it is handed, so joining
|
|
8735
|
+
* the segments with "." first produced `?p="teddy.name"` — a single
|
|
8736
|
+
* segment with a dot in its name — rather than `?p="teddy"."name"`. Only
|
|
8737
|
+
* top-level fields came out right, a record key containing a dot was
|
|
8738
|
+
* split in the other direction, and an op at the module root became
|
|
8739
|
+
* `?p=""`.
|
|
8740
|
+
*
|
|
8741
|
+
* `patchPathToModulePath` is what the rest of the Studio produces and
|
|
8742
|
+
* parses, integer segments left unquoted (`"items".2."label"`).
|
|
8743
|
+
*/
|
|
8744
|
+
const sourcePath = op.path.length === 0 ? moduleFilePath : Internal.joinModuleFilePathAndModulePath(moduleFilePath, Internal.patchPathToModulePath(op.path));
|
|
8745
|
+
if (!seen.has(sourcePath)) {
|
|
8746
|
+
seen.add(sourcePath);
|
|
8747
|
+
paths.push(sourcePath);
|
|
8748
|
+
}
|
|
8749
|
+
}
|
|
8750
|
+
}
|
|
8751
|
+
return paths;
|
|
8752
|
+
}
|
|
8753
|
+
|
|
8754
|
+
/**
|
|
8755
|
+
* Reconstruct one commit: how each module looked before it, and after it.
|
|
8756
|
+
*
|
|
8757
|
+
* Deliberately says NOTHING about the current source or the current schema. For
|
|
8758
|
+
* a given commit sha this can never change, which is what lets its result be
|
|
8759
|
+
* cached forever and what makes flipping between commits cheap. The comparison
|
|
8760
|
+
* against today is `compareWithCurrent`, and it is deliberately the cheap half.
|
|
8761
|
+
*
|
|
8762
|
+
* Nothing is parsed and nothing is replayed: the module's data was recorded at
|
|
8763
|
+
* the commit, so reading it back is a read. What IS done here is validating the
|
|
8764
|
+
* stored schema against this version of Val, because that is the one thing the
|
|
8765
|
+
* content service could not check for us.
|
|
8766
|
+
*
|
|
8767
|
+
* Failures are collected per module, not thrown - one module this Val cannot
|
|
8768
|
+
* read leaves the other nine readable, and knowing WHICH one is the thing
|
|
8769
|
+
* someone opening history actually needs. Only a commit that cannot be read at
|
|
8770
|
+
* all is an `err`.
|
|
8771
|
+
*/
|
|
8772
|
+
async function getHistoricalPatchSet(ops, commitSha, options) {
|
|
8773
|
+
const recordRes = await fetchCommitRecord(ops, commitSha);
|
|
8774
|
+
if (result.isErr(recordRes)) {
|
|
8775
|
+
return recordRes;
|
|
8776
|
+
}
|
|
8777
|
+
const {
|
|
8778
|
+
commit,
|
|
8779
|
+
patches,
|
|
8780
|
+
modules: stored,
|
|
8781
|
+
affectedFiles
|
|
8782
|
+
} = recordRes.value;
|
|
8783
|
+
const warnings = [];
|
|
8784
|
+
|
|
8785
|
+
// Group the commit's patches by module. Not to replay them - the stored
|
|
8786
|
+
// source already IS the result - but because the ops name the paths this
|
|
8787
|
+
// commit touched, which is what the view highlights.
|
|
8788
|
+
const patchesByModule = new Map();
|
|
8789
|
+
for (const patch of patches) {
|
|
8790
|
+
const existing = patchesByModule.get(patch.moduleFilePath) ?? [];
|
|
8791
|
+
existing.push({
|
|
8792
|
+
patchId: patch.patchId,
|
|
8793
|
+
coreVersion: patch.coreVersion,
|
|
8794
|
+
// The wire type is `unknown` because the content service does not know
|
|
8795
|
+
// Val's patch type; it has been validated on the way out of the archive.
|
|
8796
|
+
patch: patch.patch
|
|
8797
|
+
});
|
|
8798
|
+
patchesByModule.set(patch.moduleFilePath, existing);
|
|
8799
|
+
}
|
|
8800
|
+
const storedByPath = new Map(stored.map(module => [module.moduleFilePath, module]));
|
|
8801
|
+
|
|
8802
|
+
// Every module this commit touched: one it patched, and one we hold a stored
|
|
8803
|
+
// version of. Usually the same set - but a commit made by a Val too old to
|
|
8804
|
+
// send its modules has the first and not the second, and that has to read as
|
|
8805
|
+
// `source-unavailable` rather than as "no modules changed".
|
|
8806
|
+
const touched = new Set([...patchesByModule.keys(), ...storedByPath.keys()]);
|
|
8807
|
+
const modules = {};
|
|
8808
|
+
for (const moduleFilePath of touched) {
|
|
8809
|
+
const failures = [];
|
|
8810
|
+
const modulePatches = patchesByModule.get(moduleFilePath) ?? [];
|
|
8811
|
+
const patchIds = modulePatches.map(patch => patch.patchId);
|
|
8812
|
+
const changedPaths = changedPathsOf(moduleFilePath, modulePatches);
|
|
8813
|
+
const version = storedByPath.get(moduleFilePath);
|
|
8814
|
+
if (version === undefined || version.unavailable) {
|
|
8815
|
+
failures.push({
|
|
8816
|
+
kind: "source-unavailable",
|
|
8817
|
+
moduleFilePath
|
|
8818
|
+
});
|
|
8819
|
+
modules[moduleFilePath] = {
|
|
8820
|
+
source: null,
|
|
8821
|
+
schema: null,
|
|
8822
|
+
patchIds,
|
|
8823
|
+
changedPaths,
|
|
8824
|
+
failures
|
|
7376
8825
|
};
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
|
|
8826
|
+
continue;
|
|
8827
|
+
}
|
|
8828
|
+
|
|
8829
|
+
/*
|
|
8830
|
+
* The schema is validated here, and nowhere earlier.
|
|
8831
|
+
*
|
|
8832
|
+
* It was stored as written and served back opaquely, so this is the first
|
|
8833
|
+
* place that knows what a schema is supposed to look like. A failure is
|
|
8834
|
+
* NOT damage and not anyone's mistake - Val's schema format is allowed to
|
|
8835
|
+
* move, and an older project opened in a newer Val (or the reverse) lands
|
|
8836
|
+
* exactly here. The module reads as one this version cannot show, and the
|
|
8837
|
+
* rest of the commit is unaffected.
|
|
8838
|
+
*/
|
|
8839
|
+
const schemaRes = SerializedSchema.safeParse(version.schema);
|
|
8840
|
+
if (!schemaRes.success) {
|
|
8841
|
+
failures.push({
|
|
8842
|
+
kind: "schema-unreadable",
|
|
8843
|
+
moduleFilePath,
|
|
8844
|
+
message: fromError(schemaRes.error).toString()
|
|
8845
|
+
});
|
|
8846
|
+
modules[moduleFilePath] = {
|
|
8847
|
+
source: version.source,
|
|
8848
|
+
schema: null,
|
|
8849
|
+
patchIds,
|
|
8850
|
+
changedPaths,
|
|
8851
|
+
failures
|
|
7382
8852
|
};
|
|
8853
|
+
continue;
|
|
7383
8854
|
}
|
|
8855
|
+
modules[moduleFilePath] = {
|
|
8856
|
+
source: version.source,
|
|
8857
|
+
schema: schemaRes.data,
|
|
8858
|
+
patchIds,
|
|
8859
|
+
changedPaths,
|
|
8860
|
+
failures
|
|
8861
|
+
};
|
|
8862
|
+
}
|
|
8863
|
+
const entriesRes = await resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles);
|
|
8864
|
+
let jsonEntries = {};
|
|
8865
|
+
if (result.isErr(entriesRes)) {
|
|
8866
|
+
// Entry contents are supporting detail, not the commit. Losing them should
|
|
8867
|
+
// narrow what can be shown, not hide the commit entirely.
|
|
8868
|
+
warnings.push(entriesRes.error);
|
|
8869
|
+
} else {
|
|
8870
|
+
jsonEntries = entriesRes.value.entries;
|
|
8871
|
+
warnings.push(...entriesRes.value.failures);
|
|
8872
|
+
}
|
|
8873
|
+
return result.ok({
|
|
8874
|
+
commit,
|
|
8875
|
+
modules,
|
|
8876
|
+
patches,
|
|
8877
|
+
jsonEntries,
|
|
8878
|
+
binaryFiles: describeBinaryFilesAtCommit(commitSha, affectedFiles, (options === null || options === void 0 ? void 0 : options.apiBasePath) ?? "/api/val"),
|
|
8879
|
+
warnings
|
|
8880
|
+
});
|
|
8881
|
+
}
|
|
8882
|
+
|
|
8883
|
+
/**
|
|
8884
|
+
* One module, as of a commit — including a commit that never touched it.
|
|
8885
|
+
*
|
|
8886
|
+
* `getHistoricalPatchSet` answers "what did this commit change", which is what
|
|
8887
|
+
* a commit IS. This answers "how did this module look at that point", which is
|
|
8888
|
+
* what someone comparing two panes is asking as soon as they navigate off the
|
|
8889
|
+
* changed set — and without it the history pane can only show a handful of
|
|
8890
|
+
* modules per commit.
|
|
8891
|
+
*
|
|
8892
|
+
* `null` means history has no record at or before that commit: the module was
|
|
8893
|
+
* last edited before recording started, or never. That is deliberately NOT the
|
|
8894
|
+
* same as a module the commit deleted, which is a recorded fact with a source
|
|
8895
|
+
* of `null`, and not the same as an error.
|
|
8896
|
+
*/
|
|
8897
|
+
async function getModuleAtCommit(ops, commitSha, moduleFilePath) {
|
|
8898
|
+
const res = await ops.getCommitModules(commitSha, {
|
|
8899
|
+
asOf: true,
|
|
8900
|
+
moduleFilePath
|
|
8901
|
+
});
|
|
8902
|
+
if (result.isErr(res)) {
|
|
8903
|
+
return res;
|
|
8904
|
+
}
|
|
8905
|
+
const version = res.value.modules.find(module => module.moduleFilePath === moduleFilePath);
|
|
8906
|
+
if (version === undefined) {
|
|
8907
|
+
return result.ok(null);
|
|
8908
|
+
}
|
|
8909
|
+
if (version.unavailable) {
|
|
8910
|
+
return result.ok({
|
|
8911
|
+
source: null,
|
|
8912
|
+
schema: null,
|
|
8913
|
+
patchIds: [],
|
|
8914
|
+
changedPaths: [],
|
|
8915
|
+
failures: [{
|
|
8916
|
+
kind: "source-unavailable",
|
|
8917
|
+
moduleFilePath
|
|
8918
|
+
}]
|
|
8919
|
+
});
|
|
8920
|
+
}
|
|
8921
|
+
// Validated here, not at the transport boundary, for the same reason as in
|
|
8922
|
+
// `getHistoricalPatchSet`: a schema this Val cannot read is one module it
|
|
8923
|
+
// cannot show, not a failed request.
|
|
8924
|
+
const schemaRes = SerializedSchema.safeParse(version.schema);
|
|
8925
|
+
if (!schemaRes.success) {
|
|
8926
|
+
return result.ok({
|
|
8927
|
+
source: version.source,
|
|
8928
|
+
schema: null,
|
|
8929
|
+
patchIds: [],
|
|
8930
|
+
changedPaths: [],
|
|
8931
|
+
failures: [{
|
|
8932
|
+
kind: "schema-unreadable",
|
|
8933
|
+
moduleFilePath,
|
|
8934
|
+
message: fromError(schemaRes.error).toString()
|
|
8935
|
+
}]
|
|
8936
|
+
});
|
|
7384
8937
|
}
|
|
8938
|
+
return result.ok({
|
|
8939
|
+
source: version.source,
|
|
8940
|
+
schema: schemaRes.data,
|
|
8941
|
+
// Empty, and correct: this module may well have been changed by an EARLIER
|
|
8942
|
+
// commit than the one asked about, and those are that commit's patches.
|
|
8943
|
+
patchIds: [],
|
|
8944
|
+
changedPaths: [],
|
|
8945
|
+
failures: []
|
|
8946
|
+
});
|
|
7385
8947
|
}
|
|
7386
8948
|
|
|
7387
8949
|
const host = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
|
|
@@ -7539,31 +9101,39 @@ async function initHandlerOptions(route, opts, config) {
|
|
|
7539
9101
|
/**
|
|
7540
9102
|
* Build the data layer a {@link ValServerConfig} calls for.
|
|
7541
9103
|
*
|
|
7542
|
-
*
|
|
7543
|
-
*
|
|
7544
|
-
*
|
|
7545
|
-
*
|
|
9104
|
+
* The http backend always sees the app's own API key. That is what the Studio
|
|
9105
|
+
* wants — there the app has already verified a session cookie and is acting on
|
|
9106
|
+
* the user's behalf under its own authority — and it is now the only shape:
|
|
9107
|
+
* every caller that reaches here has been authenticated by the app itself, so
|
|
9108
|
+
* there is no request left on which the app is a pipe rather than an authority.
|
|
9109
|
+
*
|
|
9110
|
+
* This took a parameter for the other case: a caller acting for a user it had
|
|
9111
|
+
* *not* authenticated passed that user's personal access token, and the backend
|
|
9112
|
+
* decided what the caller could do. `ValOpsHttp` still accepts such a token —
|
|
9113
|
+
* the CLI's `debug` command uses the developer's own from `val login` — but no
|
|
9114
|
+
* server request builds one any more, because a request the app cannot
|
|
9115
|
+
* authenticate is now refused instead of relayed.
|
|
7546
9116
|
*
|
|
7547
|
-
*
|
|
7548
|
-
*
|
|
7549
|
-
*
|
|
7550
|
-
*
|
|
7551
|
-
* key on such a request is the D.6 confused deputy, and it is worth being blunt
|
|
7552
|
-
* about why it is tempting — it works, and it works for every project the key
|
|
7553
|
-
* can reach, including the ones the caller cannot.
|
|
9117
|
+
* What has not changed is why the API key must never stand in for a credential
|
|
9118
|
+
* that was merely *absent*: it works, and it works for every project the key
|
|
9119
|
+
* can reach, including the ones the caller cannot. Callers are refused for a
|
|
9120
|
+
* missing credential well before this point.
|
|
7554
9121
|
*/
|
|
7555
|
-
function createValOps(valModules, options
|
|
9122
|
+
function createValOps(valModules, options) {
|
|
7556
9123
|
if (options.mode === "fs") {
|
|
7557
9124
|
// No credential in fs mode: this reads and writes the developer's own
|
|
7558
|
-
// working tree, and there is no backend to authenticate to. A
|
|
7559
|
-
//
|
|
9125
|
+
// working tree, and there is no backend to authenticate to. A credential
|
|
9126
|
+
// that arrives for such a project is not ignored quietly — the caller is
|
|
9127
|
+
// told, by `createValTools` when the project is configured for oauth and by
|
|
9128
|
+
// `initValMcp` when it is not, since with no issuer there is no verified
|
|
9129
|
+
// credential left for the registry to see.
|
|
7560
9130
|
return new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
|
|
7561
9131
|
formatter: options.formatter,
|
|
7562
9132
|
config: options.config
|
|
7563
9133
|
});
|
|
7564
9134
|
}
|
|
7565
9135
|
if (options.mode === "http") {
|
|
7566
|
-
return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch,
|
|
9136
|
+
return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, {
|
|
7567
9137
|
apiKey: options.apiKey
|
|
7568
9138
|
}, valModules, {
|
|
7569
9139
|
formatter: options.formatter,
|
|
@@ -8185,90 +9755,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8185
9755
|
};
|
|
8186
9756
|
}
|
|
8187
9757
|
},
|
|
8188
|
-
"/session": {
|
|
8189
|
-
GET: async req => {
|
|
8190
|
-
const cookies = req.cookies;
|
|
8191
|
-
if (serverOps instanceof ValOpsFS) {
|
|
8192
|
-
return {
|
|
8193
|
-
status: 200,
|
|
8194
|
-
json: {
|
|
8195
|
-
mode: "local",
|
|
8196
|
-
enabled: await callbacks.isEnabled()
|
|
8197
|
-
}
|
|
8198
|
-
};
|
|
8199
|
-
}
|
|
8200
|
-
if (!options.project) {
|
|
8201
|
-
return {
|
|
8202
|
-
status: 500,
|
|
8203
|
-
json: {
|
|
8204
|
-
message: "Project is not set"
|
|
8205
|
-
}
|
|
8206
|
-
};
|
|
8207
|
-
}
|
|
8208
|
-
if (!options.valSecret) {
|
|
8209
|
-
return {
|
|
8210
|
-
status: 500,
|
|
8211
|
-
json: {
|
|
8212
|
-
message: "Secret is not set"
|
|
8213
|
-
}
|
|
8214
|
-
};
|
|
8215
|
-
}
|
|
8216
|
-
return withAuth(options.valSecret, cookies, "session", async data => {
|
|
8217
|
-
if (!options.valBuildUrl) {
|
|
8218
|
-
return {
|
|
8219
|
-
status: 500,
|
|
8220
|
-
json: {
|
|
8221
|
-
message: "Val is not correctly setup. Build url is missing"
|
|
8222
|
-
}
|
|
8223
|
-
};
|
|
8224
|
-
}
|
|
8225
|
-
const url = new URL(`/api/val/${options.project}/auth/session`, options.valBuildUrl);
|
|
8226
|
-
const fetchRes = await fetch(url, {
|
|
8227
|
-
headers: getAuthHeaders(data.token, "application/json")
|
|
8228
|
-
});
|
|
8229
|
-
if (fetchRes.status === 200) {
|
|
8230
|
-
const json = z.object({
|
|
8231
|
-
member_role: z.union([z.literal("owner"), z.literal("developer"), z.literal("editor")]).optional(),
|
|
8232
|
-
id: z.string(),
|
|
8233
|
-
full_name: z.string().optional(),
|
|
8234
|
-
username: z.string().optional(),
|
|
8235
|
-
avatar_url: z.string().optional()
|
|
8236
|
-
}).safeParse(await fetchRes.json());
|
|
8237
|
-
if (json.success) {
|
|
8238
|
-
return {
|
|
8239
|
-
status: fetchRes.status,
|
|
8240
|
-
json: {
|
|
8241
|
-
mode: "proxy",
|
|
8242
|
-
enabled: await callbacks.isEnabled(),
|
|
8243
|
-
...json.data
|
|
8244
|
-
}
|
|
8245
|
-
};
|
|
8246
|
-
} else {
|
|
8247
|
-
const message = getErrorMessageFromUnknownJson(json, "Could not parse session response. Unexpected error (no error message). Status: " + fetchRes.status);
|
|
8248
|
-
return {
|
|
8249
|
-
status: 500,
|
|
8250
|
-
json: {
|
|
8251
|
-
message: message,
|
|
8252
|
-
...json
|
|
8253
|
-
}
|
|
8254
|
-
};
|
|
8255
|
-
}
|
|
8256
|
-
} else {
|
|
8257
|
-
const json = z.object({
|
|
8258
|
-
message: z.string()
|
|
8259
|
-
}).safeParse(await fetchRes.json());
|
|
8260
|
-
const message = getErrorMessageFromUnknownJson(json, "Unknown error");
|
|
8261
|
-
return {
|
|
8262
|
-
status: fetchRes.status,
|
|
8263
|
-
json: {
|
|
8264
|
-
message: message,
|
|
8265
|
-
...json
|
|
8266
|
-
}
|
|
8267
|
-
};
|
|
8268
|
-
}
|
|
8269
|
-
});
|
|
8270
|
-
}
|
|
8271
|
-
},
|
|
8272
9758
|
"/logout": {
|
|
8273
9759
|
GET: async req => {
|
|
8274
9760
|
const query = req.query;
|
|
@@ -8577,10 +10063,190 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8577
10063
|
};
|
|
8578
10064
|
}
|
|
8579
10065
|
},
|
|
10066
|
+
//#region patch groups
|
|
10067
|
+
// Staging and unstaging patches. Both take an already-closed set of patch ids:
|
|
10068
|
+
// the prefix closure (staging) or the forward closure (unstaging) is computed
|
|
10069
|
+
// on the client, which is the only side that has the schema needed to derive
|
|
10070
|
+
// patch sets. See `docs/independent-publish/PLAN.md`.
|
|
10071
|
+
//
|
|
10072
|
+
// In FS mode there is no shared store and exactly one author, so group
|
|
10073
|
+
// membership lives in the client and these handlers simply acknowledge. The
|
|
10074
|
+
// client already sends an explicit patch id list to `/save`, so a locally-held
|
|
10075
|
+
// group is enough to publish a subset correctly.
|
|
10076
|
+
"/patch-groups/~/patches": {
|
|
10077
|
+
PUT: async req => {
|
|
10078
|
+
const auth = getAuth(req.cookies);
|
|
10079
|
+
if (auth.error) {
|
|
10080
|
+
return {
|
|
10081
|
+
status: 401,
|
|
10082
|
+
json: {
|
|
10083
|
+
message: auth.error
|
|
10084
|
+
}
|
|
10085
|
+
};
|
|
10086
|
+
}
|
|
10087
|
+
const {
|
|
10088
|
+
patchGroupId,
|
|
10089
|
+
patchIds
|
|
10090
|
+
} = req.body;
|
|
10091
|
+
const withPatchIds = req.body.withPatchIds ?? [];
|
|
10092
|
+
if (serverOps instanceof ValOpsFS) {
|
|
10093
|
+
return {
|
|
10094
|
+
status: 200,
|
|
10095
|
+
json: {
|
|
10096
|
+
patchGroupId,
|
|
10097
|
+
patchIds: [...patchIds, ...withPatchIds]
|
|
10098
|
+
}
|
|
10099
|
+
};
|
|
10100
|
+
}
|
|
10101
|
+
if (!("id" in auth) || !auth.id) {
|
|
10102
|
+
return {
|
|
10103
|
+
status: 401,
|
|
10104
|
+
json: {
|
|
10105
|
+
message: "Unauthorized"
|
|
10106
|
+
}
|
|
10107
|
+
};
|
|
10108
|
+
}
|
|
10109
|
+
const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
|
|
10110
|
+
if (refusal !== null) {
|
|
10111
|
+
return {
|
|
10112
|
+
status: refusal.status,
|
|
10113
|
+
json: {
|
|
10114
|
+
message: refusal.message
|
|
10115
|
+
}
|
|
10116
|
+
};
|
|
10117
|
+
}
|
|
10118
|
+
const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
|
|
10119
|
+
// Forwarded so the content API can refuse independently. This server
|
|
10120
|
+
// has already refused a group that is not the caller's; sending the
|
|
10121
|
+
// author means the check also holds for anything reaching the content
|
|
10122
|
+
// API without coming through here.
|
|
10123
|
+
auth.id);
|
|
10124
|
+
if (res.error) {
|
|
10125
|
+
return {
|
|
10126
|
+
status: res.status,
|
|
10127
|
+
json: {
|
|
10128
|
+
message: res.error.message
|
|
10129
|
+
}
|
|
10130
|
+
};
|
|
10131
|
+
}
|
|
10132
|
+
return {
|
|
10133
|
+
status: 200,
|
|
10134
|
+
json: {
|
|
10135
|
+
patchGroupId,
|
|
10136
|
+
patchIds: res.patchIds
|
|
10137
|
+
}
|
|
10138
|
+
};
|
|
10139
|
+
},
|
|
10140
|
+
DELETE: async req => {
|
|
10141
|
+
const auth = getAuth(req.cookies);
|
|
10142
|
+
if (auth.error) {
|
|
10143
|
+
return {
|
|
10144
|
+
status: 401,
|
|
10145
|
+
json: {
|
|
10146
|
+
message: auth.error
|
|
10147
|
+
}
|
|
10148
|
+
};
|
|
10149
|
+
}
|
|
10150
|
+
const {
|
|
10151
|
+
patchGroupId,
|
|
10152
|
+
patchIds
|
|
10153
|
+
} = req.body;
|
|
10154
|
+
const withPatchIds = req.body.withPatchIds ?? [];
|
|
10155
|
+
if (serverOps instanceof ValOpsFS) {
|
|
10156
|
+
return {
|
|
10157
|
+
status: 200,
|
|
10158
|
+
json: {
|
|
10159
|
+
patchGroupId,
|
|
10160
|
+
patchIds: [...patchIds, ...withPatchIds]
|
|
10161
|
+
}
|
|
10162
|
+
};
|
|
10163
|
+
}
|
|
10164
|
+
if (!("id" in auth) || !auth.id) {
|
|
10165
|
+
return {
|
|
10166
|
+
status: 401,
|
|
10167
|
+
json: {
|
|
10168
|
+
message: "Unauthorized"
|
|
10169
|
+
}
|
|
10170
|
+
};
|
|
10171
|
+
}
|
|
10172
|
+
const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
|
|
10173
|
+
if (refusal !== null) {
|
|
10174
|
+
return {
|
|
10175
|
+
status: refusal.status,
|
|
10176
|
+
json: {
|
|
10177
|
+
message: refusal.message
|
|
10178
|
+
}
|
|
10179
|
+
};
|
|
10180
|
+
}
|
|
10181
|
+
const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
|
|
10182
|
+
if (res.error) {
|
|
10183
|
+
return {
|
|
10184
|
+
status: res.status,
|
|
10185
|
+
json: {
|
|
10186
|
+
message: res.error.message
|
|
10187
|
+
}
|
|
10188
|
+
};
|
|
10189
|
+
}
|
|
10190
|
+
return {
|
|
10191
|
+
status: 200,
|
|
10192
|
+
json: {
|
|
10193
|
+
patchGroupId,
|
|
10194
|
+
patchIds: res.patchIds
|
|
10195
|
+
}
|
|
10196
|
+
};
|
|
10197
|
+
}
|
|
10198
|
+
},
|
|
8580
10199
|
//#region patches
|
|
8581
10200
|
"/patches": {
|
|
8582
10201
|
PUT: async req => {
|
|
8583
10202
|
const cookies = req.cookies;
|
|
10203
|
+
|
|
10204
|
+
/**
|
|
10205
|
+
* Group membership travels WITH the patch, in one request.
|
|
10206
|
+
*
|
|
10207
|
+
* Atomic on purpose: the content API runs every refusal before its
|
|
10208
|
+
* insert, so an invalid closure is a 400 with nothing written. Recording
|
|
10209
|
+
* membership in a second call would let a patch exist outside its
|
|
10210
|
+
* author's group whenever that call failed — and a patch outside your
|
|
10211
|
+
* own group is one you cannot publish until a repair puts it back.
|
|
10212
|
+
*
|
|
10213
|
+
*/
|
|
10214
|
+
/*
|
|
10215
|
+
* A membership is present if EITHER field is.
|
|
10216
|
+
*
|
|
10217
|
+
* The common case names no group: the content API resolves the author's
|
|
10218
|
+
* open group, creating it if absent, so the client never has to hold an
|
|
10219
|
+
* id across publishes. It still sends the closure, which is the part it
|
|
10220
|
+
* alone can compute.
|
|
10221
|
+
*
|
|
10222
|
+
* `patchGroupId` is nullable, so an explicit `null` means "no group
|
|
10223
|
+
* named" exactly as omitting it does — it is passed through as
|
|
10224
|
+
* `undefined` rather than becoming a membership keyed by null.
|
|
10225
|
+
*/
|
|
10226
|
+
const requestedPatchGroupId = req.body.patchGroupId ?? undefined;
|
|
10227
|
+
const requestedWith = req.body.withPatchIds;
|
|
10228
|
+
const patchGroup = requestedPatchGroupId !== undefined || requestedWith !== undefined ? {
|
|
10229
|
+
...(requestedPatchGroupId !== undefined ? {
|
|
10230
|
+
patchGroupId: requestedPatchGroupId
|
|
10231
|
+
} : {}),
|
|
10232
|
+
withPatchIds: requestedWith ?? []
|
|
10233
|
+
} : undefined;
|
|
10234
|
+
if (patchGroup !== undefined && serverOps instanceof ValOpsFS) {
|
|
10235
|
+
/*
|
|
10236
|
+
* `fs` has no shared store and one author, so there is no group to
|
|
10237
|
+
* join. Refused rather than acknowledged: answering 200 would tell the
|
|
10238
|
+
* client its membership was recorded when it was dropped, and the
|
|
10239
|
+
* client would then believe a publish is scoped when it is not.
|
|
10240
|
+
*/
|
|
10241
|
+
return {
|
|
10242
|
+
status: 400,
|
|
10243
|
+
json: {
|
|
10244
|
+
type: "patch-error",
|
|
10245
|
+
message: "Patch groups are not available in fs mode. Omit the patch group fields.",
|
|
10246
|
+
errors: {}
|
|
10247
|
+
}
|
|
10248
|
+
};
|
|
10249
|
+
}
|
|
8584
10250
|
const auth = getAuth(cookies);
|
|
8585
10251
|
if (auth.error) {
|
|
8586
10252
|
return {
|
|
@@ -8603,8 +10269,19 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8603
10269
|
const sessionId = req.body.sessionId ?? null;
|
|
8604
10270
|
const authorId = "id" in auth ? auth.id : null;
|
|
8605
10271
|
const newPatchIds = [];
|
|
10272
|
+
/*
|
|
10273
|
+
* The group the content API put these patches in.
|
|
10274
|
+
*
|
|
10275
|
+
* Every patch in one request has the same author and the same
|
|
10276
|
+
* membership, so the last answer is the answer — they all land in the
|
|
10277
|
+
* same group. Reported back because the client cannot learn it any
|
|
10278
|
+
* other way: it names no group (the content API resolves the author's
|
|
10279
|
+
* open one, creating it if absent), and the chain annotation is only
|
|
10280
|
+
* re-read when a fetch has missing ids to ask for.
|
|
10281
|
+
*/
|
|
10282
|
+
let patchGroupIdFromStore;
|
|
8606
10283
|
for (const patch of patches) {
|
|
8607
|
-
const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId);
|
|
10284
|
+
const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId, patchGroup);
|
|
8608
10285
|
if (result.isErr(createPatchRes)) {
|
|
8609
10286
|
if (createPatchRes.error.errorType === "patch-head-conflict") {
|
|
8610
10287
|
return {
|
|
@@ -8636,13 +10313,22 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8636
10313
|
patchId: createPatchRes.value.patchId
|
|
8637
10314
|
};
|
|
8638
10315
|
newPatchIds.push(createPatchRes.value.patchId);
|
|
10316
|
+
if (createPatchRes.value.patchGroupId !== undefined) {
|
|
10317
|
+
patchGroupIdFromStore = createPatchRes.value.patchGroupId;
|
|
10318
|
+
}
|
|
8639
10319
|
}
|
|
8640
10320
|
}
|
|
8641
10321
|
return {
|
|
8642
10322
|
status: 200,
|
|
8643
10323
|
json: {
|
|
8644
10324
|
newPatchIds,
|
|
8645
|
-
parentRef
|
|
10325
|
+
parentRef,
|
|
10326
|
+
// Absent rather than null where there are no groups: `fs` mode and
|
|
10327
|
+
// a content API that predates them both answer without one, and the
|
|
10328
|
+
// client reads absence as "staging is not available here".
|
|
10329
|
+
...(patchGroupIdFromStore !== undefined ? {
|
|
10330
|
+
patchGroupId: patchGroupIdFromStore
|
|
10331
|
+
} : {})
|
|
8646
10332
|
}
|
|
8647
10333
|
};
|
|
8648
10334
|
},
|
|
@@ -8704,15 +10390,84 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8704
10390
|
}
|
|
8705
10391
|
// TODO: we should sort by parentRef instead:
|
|
8706
10392
|
patches.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
10393
|
+
/**
|
|
10394
|
+
* Patch groups, ANNOTATED onto the chain rather than filtering it.
|
|
10395
|
+
*
|
|
10396
|
+
* The client computes a new patch's parent as the last id in this
|
|
10397
|
+
* response, so a filtered chain would make every client name a parent
|
|
10398
|
+
* that is not the real head and `POST /patches` would answer 409
|
|
10399
|
+
* forever. Annotate, never filter.
|
|
10400
|
+
*
|
|
10401
|
+
* Absent — not empty — where there are no groups: `fs` mode, a content
|
|
10402
|
+
* API that predates them, or a failed lookup. The client reads absence
|
|
10403
|
+
* as "this deployment has no groups" and leaves staging off, which is
|
|
10404
|
+
* the behaviour every project has today. An empty array would say
|
|
10405
|
+
* "groups exist and hold nothing", which would turn the staging UI on
|
|
10406
|
+
* with everything held.
|
|
10407
|
+
*/
|
|
10408
|
+
let patchGroups;
|
|
10409
|
+
if (query.include_patch_groups === true && serverOps instanceof ValOpsHttp) {
|
|
10410
|
+
/*
|
|
10411
|
+
* FRESH, because this answer makes a CLOSING decision.
|
|
10412
|
+
*
|
|
10413
|
+
* The client adopts this annotation as its group membership and
|
|
10414
|
+
* `emptiesOwnPatchGroup` then decides from it whether a publish may
|
|
10415
|
+
* name the group — and the content API closes what it is named
|
|
10416
|
+
* without checking. A one-second-old list is enough to get that
|
|
10417
|
+
* wrong: the same author writing in a second tab joins the open
|
|
10418
|
+
* group, the websocket pushes the chain immediately, so this tab's
|
|
10419
|
+
* fetch for the missing patch lands well inside the cache window and
|
|
10420
|
+
* reads a membership that is one patch short. It then publishes,
|
|
10421
|
+
* names the group, and closes it with the other tab's work still in
|
|
10422
|
+
* it — which leaves that tab wedged on 409 until a reload.
|
|
10423
|
+
*
|
|
10424
|
+
* `resolveOwnPatchScope` keeps the cache deliberately: that read
|
|
10425
|
+
* decides what a draft render SHOWS, it is repeated once per
|
|
10426
|
+
* `fetchVal` in a single render, and being a second stale there costs
|
|
10427
|
+
* a patch appearing late rather than a group closing early.
|
|
10428
|
+
*/
|
|
10429
|
+
const groupsRes = await serverOps.getPatchGroups({
|
|
10430
|
+
fresh: true
|
|
10431
|
+
});
|
|
10432
|
+
if (groupsRes.status === "ok" && groupsRes.patchGroups.length > 0) {
|
|
10433
|
+
patchGroups = groupsRes.patchGroups;
|
|
10434
|
+
} else if (groupsRes.status === "error") {
|
|
10435
|
+
// Not fatal: the chain is what this endpoint is for, and staging
|
|
10436
|
+
// simply stays off for this read rather than the whole review
|
|
10437
|
+
// screen failing to load.
|
|
10438
|
+
console.error("Val: could not read patch groups", groupsRes.message);
|
|
10439
|
+
}
|
|
10440
|
+
}
|
|
10441
|
+
const groupIdsByPatchId = new Map();
|
|
10442
|
+
for (const group of patchGroups ?? []) {
|
|
10443
|
+
for (const patchId of group.patchIds) {
|
|
10444
|
+
const existing = groupIdsByPatchId.get(patchId);
|
|
10445
|
+
if (existing) {
|
|
10446
|
+
existing.push(group.patchGroupId);
|
|
10447
|
+
} else {
|
|
10448
|
+
groupIdsByPatchId.set(patchId, [group.patchGroupId]);
|
|
10449
|
+
}
|
|
10450
|
+
}
|
|
10451
|
+
}
|
|
8707
10452
|
return {
|
|
8708
10453
|
status: 200,
|
|
8709
10454
|
json: {
|
|
8710
|
-
patches: patches
|
|
10455
|
+
patches: patches.map(patch => {
|
|
10456
|
+
const patchGroupIds = groupIdsByPatchId.get(patch.patchId);
|
|
10457
|
+
return patchGroupIds ? {
|
|
10458
|
+
...patch,
|
|
10459
|
+
patchGroupIds
|
|
10460
|
+
} : patch;
|
|
10461
|
+
}),
|
|
10462
|
+
...(patchGroups ? {
|
|
10463
|
+
patchGroups
|
|
10464
|
+
} : {}),
|
|
8711
10465
|
baseSha: await serverOps.getBaseSha()
|
|
8712
10466
|
}
|
|
8713
10467
|
};
|
|
8714
10468
|
},
|
|
8715
10469
|
DELETE: async req => {
|
|
10470
|
+
var _req$body;
|
|
8716
10471
|
const query = req.query;
|
|
8717
10472
|
const cookies = req.cookies;
|
|
8718
10473
|
const auth = getAuth(cookies);
|
|
@@ -8732,8 +10487,26 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8732
10487
|
}
|
|
8733
10488
|
};
|
|
8734
10489
|
}
|
|
8735
|
-
const ids = query.id;
|
|
8736
|
-
|
|
10490
|
+
const ids = query.id;
|
|
10491
|
+
/*
|
|
10492
|
+
* Which OTHER patches lose their group membership because these are
|
|
10493
|
+
* going. Only the client can COMPUTE it — that needs the patch sets,
|
|
10494
|
+
* which need the schema — but it is bounded here rather than trusted,
|
|
10495
|
+
* because the content API strips those memberships from every group
|
|
10496
|
+
* without an ownership check. See `boundUnstageClosure`.
|
|
10497
|
+
*
|
|
10498
|
+
* Only in `http` mode: `ValOpsFS` has no groups and ignores it, and the
|
|
10499
|
+
* client does not send it there.
|
|
10500
|
+
*/
|
|
10501
|
+
let unstagePatchIds;
|
|
10502
|
+
const requestedUnstage = (_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.unstagePatchIds;
|
|
10503
|
+
if (serverOps instanceof ValOpsHttp && requestedUnstage !== undefined && requestedUnstage.length > 0) {
|
|
10504
|
+
const chain = await serverOps.fetchPatches({
|
|
10505
|
+
excludePatchOps: true
|
|
10506
|
+
});
|
|
10507
|
+
unstagePatchIds = boundUnstageClosure(chain.patches, ids, requestedUnstage);
|
|
10508
|
+
}
|
|
10509
|
+
const deleteRes = await serverOps.deletePatches(ids, unstagePatchIds);
|
|
8737
10510
|
if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
|
|
8738
10511
|
console.error("Val: Failed to delete patches", deleteRes.errors);
|
|
8739
10512
|
return {
|
|
@@ -8847,6 +10620,22 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8847
10620
|
} = req.query;
|
|
8848
10621
|
// Defaults to true, mirroring /sources/~. The Studio opts out.
|
|
8849
10622
|
const applyPatches = req.query.apply_patches !== false;
|
|
10623
|
+
/*
|
|
10624
|
+
* Whose pending work this render may see.
|
|
10625
|
+
*
|
|
10626
|
+
* The same resolution `/sources/~` runs, through the same function: a
|
|
10627
|
+
* draft page renders module content and `jsonValues` entries together,
|
|
10628
|
+
* and this route used to apply every pending patch on the branch while
|
|
10629
|
+
* the modules beside it were scoped — so one screen showed the caller's
|
|
10630
|
+
* view and everybody's unpublished work at once.
|
|
10631
|
+
*/
|
|
10632
|
+
const {
|
|
10633
|
+
ownPatchIds
|
|
10634
|
+
} = await resolveOwnPatchScope(serverOps, {
|
|
10635
|
+
explicitPatchIds: undefined,
|
|
10636
|
+
ownGroupsOnly: req.query.own_patch_groups_only === true,
|
|
10637
|
+
authorId: "id" in auth && auth.id || undefined
|
|
10638
|
+
});
|
|
8850
10639
|
const isWindow = offset !== undefined || limit !== undefined;
|
|
8851
10640
|
const shapes = [key !== undefined, keys !== undefined, isWindow].filter(Boolean).length;
|
|
8852
10641
|
if (shapes !== 1) {
|
|
@@ -8867,7 +10656,8 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8867
10656
|
}
|
|
8868
10657
|
if (key !== undefined) {
|
|
8869
10658
|
const res = await serverOps.getJsonEntry(moduleFilePath, key, {
|
|
8870
|
-
applyPatches
|
|
10659
|
+
applyPatches,
|
|
10660
|
+
patchIds: ownPatchIds
|
|
8871
10661
|
});
|
|
8872
10662
|
if (res.status === "unauthorized") {
|
|
8873
10663
|
return {
|
|
@@ -8908,7 +10698,8 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8908
10698
|
offset: offset,
|
|
8909
10699
|
limit: limit
|
|
8910
10700
|
}, {
|
|
8911
|
-
applyPatches
|
|
10701
|
+
applyPatches,
|
|
10702
|
+
patchIds: ownPatchIds
|
|
8912
10703
|
});
|
|
8913
10704
|
if (res.status === "unauthorized") {
|
|
8914
10705
|
return {
|
|
@@ -8993,10 +10784,87 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8993
10784
|
patches: []
|
|
8994
10785
|
};
|
|
8995
10786
|
if (query.exclude_patches !== true) {
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
|
|
10787
|
+
/**
|
|
10788
|
+
* The caller's own groups, resolved from their session.
|
|
10789
|
+
*
|
|
10790
|
+
* A draft render cannot name its own group ids — it has no client
|
|
10791
|
+
* state — so it asks for "mine" and the server works out which. Only
|
|
10792
|
+
* when `patch_id` is absent: an explicit list is a caller that already
|
|
10793
|
+
* knows what it wants.
|
|
10794
|
+
*
|
|
10795
|
+
* A group lookup that FAILS renders base rather than everything. Being
|
|
10796
|
+
* shown only committed content while the content API is unreachable is
|
|
10797
|
+
* a degraded preview; being shown another author's unpublished draft
|
|
10798
|
+
* because a lookup failed is the bug this feature exists to prevent,
|
|
10799
|
+
* and it would be silent.
|
|
10800
|
+
*/
|
|
10801
|
+
const {
|
|
10802
|
+
ownPatchIds,
|
|
10803
|
+
scopeAlsoIncludesApplied
|
|
10804
|
+
} = await resolveOwnPatchScope(serverOps, {
|
|
10805
|
+
explicitPatchIds: query.patch_id,
|
|
10806
|
+
ownGroupsOnly: query.own_patch_groups_only === true,
|
|
10807
|
+
authorId: "id" in auth && auth.id || undefined
|
|
8999
10808
|
});
|
|
10809
|
+
const requestedPatchIds = query.patch_id ?? ownPatchIds;
|
|
10810
|
+
if (scopeAlsoIncludesApplied) {
|
|
10811
|
+
/*
|
|
10812
|
+
* Scoped to this caller's groups, PLUS everything already
|
|
10813
|
+
* committed.
|
|
10814
|
+
*
|
|
10815
|
+
* Filtered here rather than through `patchIds`, because the set is
|
|
10816
|
+
* not knowable before the fetch: `appliedAt` lives on the patch,
|
|
10817
|
+
* not on the group. One request either way — the whole chain is
|
|
10818
|
+
* what the unscoped path fetches too — so this costs a filter, not
|
|
10819
|
+
* a round trip.
|
|
10820
|
+
*
|
|
10821
|
+
* This also subsumes the empty-group case below: a caller holding
|
|
10822
|
+
* nothing, on a branch with nothing applied, filters down to no
|
|
10823
|
+
* patches, which is base.
|
|
10824
|
+
*/
|
|
10825
|
+
const all = await serverOps.fetchPatches({
|
|
10826
|
+
patchIds: undefined,
|
|
10827
|
+
excludePatchOps: false
|
|
10828
|
+
});
|
|
10829
|
+
patchOps = {
|
|
10830
|
+
...all,
|
|
10831
|
+
patches: scopedPatches(all.patches, ownPatchIds)
|
|
10832
|
+
};
|
|
10833
|
+
} else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
|
|
10834
|
+
/*
|
|
10835
|
+
* A group that holds nothing renders base, and is handled HERE.
|
|
10836
|
+
*
|
|
10837
|
+
* `fetchPatches` cannot express it: both implementations read an
|
|
10838
|
+
* empty `patchIds` as "no filter" and return the whole chain
|
|
10839
|
+
* (`ValOpsFS`: `patchIds.length > 0 ? new Set(...) : null`;
|
|
10840
|
+
* `ValOpsHttp`: an explicit `length === 0` branch that fetches all).
|
|
10841
|
+
* That is the right default for every caller that has ever passed a
|
|
10842
|
+
* list, since none of them can mean "none" — but it is the most
|
|
10843
|
+
* dangerous possible reading of an EXPLICITLY empty group, which
|
|
10844
|
+
* would render every unpublished patch on the branch instead of
|
|
10845
|
+
* base.
|
|
10846
|
+
*
|
|
10847
|
+
* Answered before the call rather than by changing that shared
|
|
10848
|
+
* default, which seven other call sites rely on.
|
|
10849
|
+
*/
|
|
10850
|
+
patchOps = {
|
|
10851
|
+
patches: []
|
|
10852
|
+
};
|
|
10853
|
+
} else {
|
|
10854
|
+
patchOps = await serverOps.fetchPatches({
|
|
10855
|
+
/*
|
|
10856
|
+
* The caller's patch group, when it named one.
|
|
10857
|
+
*
|
|
10858
|
+
* `undefined` means every pending patch, which is what every
|
|
10859
|
+
* existing caller gets and has to keep getting. A draft-mode
|
|
10860
|
+
* render that names its group gets base + that group instead, so
|
|
10861
|
+
* a server-rendered preview shows the same thing the person
|
|
10862
|
+
* editing is looking at rather than everybody's unpublished work.
|
|
10863
|
+
*/
|
|
10864
|
+
patchIds: requestedPatchIds,
|
|
10865
|
+
excludePatchOps: false
|
|
10866
|
+
});
|
|
10867
|
+
}
|
|
9000
10868
|
}
|
|
9001
10869
|
// We check authorization here, because it is the first call to the backend
|
|
9002
10870
|
if (patchOps.error && patchOps.unauthorized) {
|
|
@@ -9253,6 +11121,43 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
9253
11121
|
* store wholesale.
|
|
9254
11122
|
*/
|
|
9255
11123
|
const consumed = patches.patches.map(patch => patch.patchId);
|
|
11124
|
+
/*
|
|
11125
|
+
* Has somebody else published since this was decided?
|
|
11126
|
+
*
|
|
11127
|
+
* The client names the newest commit it knew about; anything newer here
|
|
11128
|
+
* means the review screen it acted on described a world that has moved.
|
|
11129
|
+
* The answer is "look again" rather than "your commit was rejected".
|
|
11130
|
+
*
|
|
11131
|
+
* Git's own not-fast-forward guard cannot see this: the chain is
|
|
11132
|
+
* fetched and committed fresh at this point, so the parent commit sent
|
|
11133
|
+
* is always the server's current one.
|
|
11134
|
+
*
|
|
11135
|
+
* Checked HERE, before `analyzePatches` and `prepare`, rather than just
|
|
11136
|
+
* before the commit: a publish that is going to be refused should not
|
|
11137
|
+
* first pay to apply every patch in it. And compared against the
|
|
11138
|
+
* commits `fetchPatches` just returned — `applicable/patches` filters
|
|
11139
|
+
* its PATCHES by the requested ids and never its commits, so the second
|
|
11140
|
+
* whole-chain fetch this used to make asked for a list it already had.
|
|
11141
|
+
*
|
|
11142
|
+
* Only when the client sends a head. One that does not publishes
|
|
11143
|
+
* exactly as it did before, and this cannot start refusing publishes for
|
|
11144
|
+
* a field it never sets.
|
|
11145
|
+
*/
|
|
11146
|
+
if (serverOps instanceof ValOpsHttp) {
|
|
11147
|
+
const expectedHead = body.expectedHeadCommitSha;
|
|
11148
|
+
if (expectedHead !== undefined) {
|
|
11149
|
+
const serverHead = newestCommitSha(patches.commits);
|
|
11150
|
+
if (serverHead !== null && serverHead !== expectedHead) {
|
|
11151
|
+
return {
|
|
11152
|
+
status: 409,
|
|
11153
|
+
json: {
|
|
11154
|
+
message: "Someone else published while you were reviewing. Nothing was published — open Review again to see what changed.",
|
|
11155
|
+
headMoved: true
|
|
11156
|
+
}
|
|
11157
|
+
};
|
|
11158
|
+
}
|
|
11159
|
+
}
|
|
11160
|
+
}
|
|
9256
11161
|
const analysis = serverOps.analyzePatches(patches.patches, patches.commits, commit);
|
|
9257
11162
|
let preparedCommit = await serverOps.prepare({
|
|
9258
11163
|
...analysis,
|
|
@@ -9401,8 +11306,53 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
9401
11306
|
} else if (serverOps instanceof ValOpsHttp) {
|
|
9402
11307
|
if (auth.error === undefined && auth.id) {
|
|
9403
11308
|
var _options$config$files;
|
|
11309
|
+
/*
|
|
11310
|
+
* The group this commit CLOSES has to be the caller's.
|
|
11311
|
+
*
|
|
11312
|
+
* Stage and unstage go through `refuseUnlessOwn`; this route did
|
|
11313
|
+
* not, and the content API's `postCommit` marks the group published
|
|
11314
|
+
* on id alone with no author clause — so the id was trusted twice
|
|
11315
|
+
* and checked nowhere. Any logged-in editor could name a colleague's
|
|
11316
|
+
* open group and close it: their pending patches land in a closed
|
|
11317
|
+
* group and in no open one, so a scoped draft render shows base for
|
|
11318
|
+
* them, and their own tab still believes the group is open, so their
|
|
11319
|
+
* next stage is refused with 409.
|
|
11320
|
+
*
|
|
11321
|
+
* Same hole as the stage/unstage one this branch already closed, one
|
|
11322
|
+
* route over. The content API needs the same guard — this one is the
|
|
11323
|
+
* convenience, that one is what actually holds.
|
|
11324
|
+
*/
|
|
11325
|
+
if (body.patchGroupId !== undefined && body.patchGroupId !== null) {
|
|
11326
|
+
const refusal = await refuseUnlessOwn(serverOps, body.patchGroupId, auth.id);
|
|
11327
|
+
if (refusal !== null) {
|
|
11328
|
+
return {
|
|
11329
|
+
status: refusal.status,
|
|
11330
|
+
json: {
|
|
11331
|
+
message: refusal.message,
|
|
11332
|
+
/*
|
|
11333
|
+
* Flagged, because a bare 409 here reads as git refusing
|
|
11334
|
+
* the commit — which is retryable, and this is the
|
|
11335
|
+
* opposite. `refuseUnlessOwn` answers 409 for one reason
|
|
11336
|
+
* only: the group has already been published, so its id
|
|
11337
|
+
* will never be writable again and retrying reproduces
|
|
11338
|
+
* this forever. The client forgets the id instead.
|
|
11339
|
+
*/
|
|
11340
|
+
...(refusal.status === 409 ? {
|
|
11341
|
+
patchGroupPublished: true
|
|
11342
|
+
} : {})
|
|
11343
|
+
}
|
|
11344
|
+
};
|
|
11345
|
+
}
|
|
11346
|
+
}
|
|
9404
11347
|
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"
|
|
11348
|
+
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,
|
|
11349
|
+
/*
|
|
11350
|
+
* Forwarded verbatim, and only the client can decide it: the
|
|
11351
|
+
* content API closes the group it is named without checking that
|
|
11352
|
+
* the commit shipped all of it, and whether it did needs the
|
|
11353
|
+
* patch sets, which live in the browser.
|
|
11354
|
+
*/
|
|
11355
|
+
body.patchGroupId);
|
|
9406
11356
|
if (commitRes.error) {
|
|
9407
11357
|
console.error("Failed to commit", commitRes.error);
|
|
9408
11358
|
if ("isNotFastForward" in commitRes && commitRes.isNotFastForward) {
|
|
@@ -9423,9 +11373,20 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
9423
11373
|
};
|
|
9424
11374
|
}
|
|
9425
11375
|
// TODO: serverOps.markApplied(patchIds);
|
|
11376
|
+
/*
|
|
11377
|
+
* The new head, back to the client that made it.
|
|
11378
|
+
*
|
|
11379
|
+
* Nothing else tells it in time: `headCommitSha` moves on a `/stat`
|
|
11380
|
+
* response, so until the next poll the client still believes the
|
|
11381
|
+
* pre-publish head — and its next publish sent that as
|
|
11382
|
+
* `expectedHeadCommitSha`, hit the check above against the commit it
|
|
11383
|
+
* had itself just made, and was told somebody else had published.
|
|
11384
|
+
*/
|
|
9426
11385
|
return {
|
|
9427
11386
|
status: 200,
|
|
9428
|
-
json: {
|
|
11387
|
+
json: {
|
|
11388
|
+
commitSha: commitRes.commit
|
|
11389
|
+
}
|
|
9429
11390
|
};
|
|
9430
11391
|
}
|
|
9431
11392
|
return {
|
|
@@ -10007,11 +11968,13 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
10007
11968
|
}
|
|
10008
11969
|
};
|
|
10009
11970
|
}
|
|
10010
|
-
|
|
10011
|
-
|
|
10012
|
-
|
|
11971
|
+
// Bytes straight through. This used to base64 the buffer,
|
|
11972
|
+
// wrap it in a data: URL, and hand that to a method whose first
|
|
11973
|
+
// act was to unwrap it again - ceremony to satisfy a convention
|
|
11974
|
+
// that no longer exists.
|
|
11975
|
+
const bytes = Buffer.from(await binaryRes.arrayBuffer());
|
|
10013
11976
|
const type = file.metadata.mimeType.startsWith("image/") ? "image" : "file";
|
|
10014
|
-
const saveRes = await serverOps.
|
|
11977
|
+
const saveRes = await serverOps.saveBinaryFileFromPatch(file.filePath, req.body.parentRef, req.body.patchId, bytes, file.metadata.mimeType, type, file.metadata);
|
|
10015
11978
|
if (saveRes.error) {
|
|
10016
11979
|
return {
|
|
10017
11980
|
status: 500,
|
|
@@ -10139,6 +12102,137 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
10139
12102
|
}
|
|
10140
12103
|
},
|
|
10141
12104
|
//#region files
|
|
12105
|
+
// #region history
|
|
12106
|
+
"/history/commits": {
|
|
12107
|
+
GET: async req => {
|
|
12108
|
+
const auth = getAuth(req.cookies);
|
|
12109
|
+
if (auth.error) {
|
|
12110
|
+
return {
|
|
12111
|
+
status: 401,
|
|
12112
|
+
json: {
|
|
12113
|
+
message: auth.error
|
|
12114
|
+
}
|
|
12115
|
+
};
|
|
12116
|
+
}
|
|
12117
|
+
const res = await serverOps.listCommits(req.query.branch, {
|
|
12118
|
+
limit: req.query.limit,
|
|
12119
|
+
cursor: req.query.cursor
|
|
12120
|
+
});
|
|
12121
|
+
if (result.isErr(res)) {
|
|
12122
|
+
return historyErrorResponse(res.error);
|
|
12123
|
+
}
|
|
12124
|
+
return {
|
|
12125
|
+
status: 200,
|
|
12126
|
+
json: res.value,
|
|
12127
|
+
// The head moves, so a listing is never reusable.
|
|
12128
|
+
headers: {
|
|
12129
|
+
"Cache-Control": "no-store"
|
|
12130
|
+
}
|
|
12131
|
+
};
|
|
12132
|
+
}
|
|
12133
|
+
},
|
|
12134
|
+
"/history/commit": {
|
|
12135
|
+
GET: async req => {
|
|
12136
|
+
const auth = getAuth(req.cookies);
|
|
12137
|
+
if (auth.error) {
|
|
12138
|
+
return {
|
|
12139
|
+
status: 401,
|
|
12140
|
+
json: {
|
|
12141
|
+
message: auth.error
|
|
12142
|
+
}
|
|
12143
|
+
};
|
|
12144
|
+
}
|
|
12145
|
+
const res = await getHistoricalPatchSet(serverOps, req.query.commit_sha);
|
|
12146
|
+
if (result.isErr(res)) {
|
|
12147
|
+
return historyErrorResponse(res.error);
|
|
12148
|
+
}
|
|
12149
|
+
/*
|
|
12150
|
+
* Immutable ONLY when nothing in the answer was transient.
|
|
12151
|
+
*
|
|
12152
|
+
* What a commit recorded cannot change, so a clean answer is reusable
|
|
12153
|
+
* forever - that is what makes comparing many commits cheap. But a
|
|
12154
|
+
* module marked `unavailable` means a blob fetch failed, and a warning
|
|
12155
|
+
* means an entry read against GitHub did; both can succeed on the next
|
|
12156
|
+
* try. Cached for a year, one flaky read would become "nothing was
|
|
12157
|
+
* recorded for this module" for the rest of the session and beyond.
|
|
12158
|
+
*
|
|
12159
|
+
* `private`, not `public`: this route is behind the session cookie, and
|
|
12160
|
+
* a shared cache holding it would hand one project's module sources to
|
|
12161
|
+
* whoever asked next. The browser's own cache is where the reuse was
|
|
12162
|
+
* wanted anyway.
|
|
12163
|
+
*/
|
|
12164
|
+
const settled = res.value.warnings.length === 0 && Object.values(res.value.modules).every(module => module.failures.length === 0);
|
|
12165
|
+
return {
|
|
12166
|
+
status: 200,
|
|
12167
|
+
json: res.value,
|
|
12168
|
+
headers: {
|
|
12169
|
+
"Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
|
|
12170
|
+
}
|
|
12171
|
+
};
|
|
12172
|
+
}
|
|
12173
|
+
},
|
|
12174
|
+
"/history/module": {
|
|
12175
|
+
GET: async req => {
|
|
12176
|
+
const auth = getAuth(req.cookies);
|
|
12177
|
+
if (auth.error) {
|
|
12178
|
+
return {
|
|
12179
|
+
status: 401,
|
|
12180
|
+
json: {
|
|
12181
|
+
message: auth.error
|
|
12182
|
+
}
|
|
12183
|
+
};
|
|
12184
|
+
}
|
|
12185
|
+
const res = await getModuleAtCommit(serverOps, req.query.commit_sha, req.query.module_file_path);
|
|
12186
|
+
if (result.isErr(res)) {
|
|
12187
|
+
return historyErrorResponse(res.error);
|
|
12188
|
+
}
|
|
12189
|
+
// Immutable only when the answer settled — a module reported
|
|
12190
|
+
// unavailable means a blob fetch failed and may succeed next time, and
|
|
12191
|
+
// caching that for a year turns one flaky read into a permanent gap.
|
|
12192
|
+
const settled = res.value === null || res.value.failures.length === 0;
|
|
12193
|
+
return {
|
|
12194
|
+
status: 200,
|
|
12195
|
+
json: {
|
|
12196
|
+
moduleFilePath: req.query.module_file_path,
|
|
12197
|
+
module: res.value
|
|
12198
|
+
},
|
|
12199
|
+
headers: {
|
|
12200
|
+
"Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
|
|
12201
|
+
}
|
|
12202
|
+
};
|
|
12203
|
+
}
|
|
12204
|
+
},
|
|
12205
|
+
"/history/files": {
|
|
12206
|
+
GET: async req => {
|
|
12207
|
+
// No auth, for the same reason /files has none: this is served to an
|
|
12208
|
+
// <img> that the app's own backend may fetch during image
|
|
12209
|
+
// optimisation, with no cookies. What it exposes is a file at a commit
|
|
12210
|
+
// that is already in the repository.
|
|
12211
|
+
const res = await serverOps.getFileAtCommit(req.query.commit_sha, req.query.path, req.query.remote === "true");
|
|
12212
|
+
if (result.isErr(res)) {
|
|
12213
|
+
const response = historyErrorResponse(res.error);
|
|
12214
|
+
// The stream branch of this route's response union has no json, so
|
|
12215
|
+
// narrow to the shapes it does allow.
|
|
12216
|
+
if (response.status === 401 || response.status === 404) {
|
|
12217
|
+
return response;
|
|
12218
|
+
}
|
|
12219
|
+
return {
|
|
12220
|
+
status: 400,
|
|
12221
|
+
json: response.json
|
|
12222
|
+
};
|
|
12223
|
+
}
|
|
12224
|
+
return {
|
|
12225
|
+
status: 200,
|
|
12226
|
+
headers: {
|
|
12227
|
+
"Content-Type": guessMimeTypeFromPath(req.query.path) ?? "application/octet-stream",
|
|
12228
|
+
// A file at a fixed commit cannot change.
|
|
12229
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
12230
|
+
},
|
|
12231
|
+
body: bufferToReadableStream(res.value)
|
|
12232
|
+
};
|
|
12233
|
+
}
|
|
12234
|
+
},
|
|
12235
|
+
// #endregion history
|
|
10142
12236
|
"/files": {
|
|
10143
12237
|
GET: async req => {
|
|
10144
12238
|
const query = req.query;
|
|
@@ -10191,6 +12285,242 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
10191
12285
|
}
|
|
10192
12286
|
};
|
|
10193
12287
|
};
|
|
12288
|
+
/**
|
|
12289
|
+
* Refuse to touch a group that is not the caller's.
|
|
12290
|
+
*
|
|
12291
|
+
* Exported for `patchGroupOwnership.test.ts`: this is the whole of the
|
|
12292
|
+
* authorization for stage and unstage, and nothing else in the process checks
|
|
12293
|
+
* it, so it is worth testing as a policy rather than only through a route.
|
|
12294
|
+
*
|
|
12295
|
+
* `getAuth` only proves a session EXISTS; it says nothing about whose
|
|
12296
|
+
* group this is. And the content API cannot decide either — every call
|
|
12297
|
+
* from here carries the app's API key, not the editor's identity — so if
|
|
12298
|
+
* this does not check, nothing does.
|
|
12299
|
+
*
|
|
12300
|
+
* `GET /patches?include_patch_groups=true` hands every editor the id and
|
|
12301
|
+
* author of every open group on the branch, so without this any logged-in
|
|
12302
|
+
* editor can unstage another author's patches (their next publish
|
|
12303
|
+
* silently ships less) or stage into their group (it silently ships
|
|
12304
|
+
* more). The 403 declared for this route in `ApiRoutes.ts` was
|
|
12305
|
+
* unreachable.
|
|
12306
|
+
*
|
|
12307
|
+
* Fails CLOSED: if the groups cannot be read, the mutation is refused
|
|
12308
|
+
* rather than allowed unverified.
|
|
12309
|
+
*/
|
|
12310
|
+
|
|
12311
|
+
/**
|
|
12312
|
+
* The patches a scoped draft render should apply: the caller's own group, plus
|
|
12313
|
+
* everything already committed.
|
|
12314
|
+
*
|
|
12315
|
+
* Scoping is about PENDING work. A published patch stays in the chain with
|
|
12316
|
+
* `appliedAt` set until the next deployment moves the base, and it is part of
|
|
12317
|
+
* everyone's view in that window — the unscoped path applies it. Dropping it
|
|
12318
|
+
* meant the moment somebody published, their own draft preview reverted the
|
|
12319
|
+
* field they had just shipped, and nobody else saw it either until the deploy
|
|
12320
|
+
* landed; anything written on top in that window is authored against content
|
|
12321
|
+
* already stale on `main`.
|
|
12322
|
+
*
|
|
12323
|
+
* Keyed on `appliedAt` rather than on the group's `publishedAt`, because a
|
|
12324
|
+
* PARTIAL publish leaves the group open with only some of its patches applied.
|
|
12325
|
+
* Those are committed too, and no flag on the group names them.
|
|
12326
|
+
*
|
|
12327
|
+
* `undefined` scope is unscoped and never reaches here; an EMPTY scope is a
|
|
12328
|
+
* caller holding nothing, and on a branch with nothing applied it correctly
|
|
12329
|
+
* filters down to no patches, which renders base.
|
|
12330
|
+
*/
|
|
12331
|
+
function scopedPatches(patches, ownPatchIds) {
|
|
12332
|
+
const own = new Set(ownPatchIds ?? []);
|
|
12333
|
+
return patches.filter(patch => own.has(patch.patchId) || patch.appliedAt !== null);
|
|
12334
|
+
}
|
|
12335
|
+
|
|
12336
|
+
/**
|
|
12337
|
+
* Which of the client's `unstagePatchIds` this server is willing to forward.
|
|
12338
|
+
*
|
|
12339
|
+
* The forward closure of a discard is the client's to compute — it needs the
|
|
12340
|
+
* patch sets, which need the schema — and it was being forwarded verbatim. But
|
|
12341
|
+
* the content API removes those memberships from EVERY group with no ownership
|
|
12342
|
+
* check, so any logged-in editor could strip arbitrary patches out of any other
|
|
12343
|
+
* author's group by attaching them to a delete of one of their own throwaway
|
|
12344
|
+
* patches. That is the outcome the 403 on `/patch-groups` exists to prevent,
|
|
12345
|
+
* reached by a different door: their next publish silently ships less.
|
|
12346
|
+
*
|
|
12347
|
+
* Neither server can compute the true closure, but this one can BOUND it. A
|
|
12348
|
+
* patch can only be invalidated by a delete if it was written after that delete
|
|
12349
|
+
* — its paths were chosen against a view that had it — and if it is in the same
|
|
12350
|
+
* module, since a patch set never spans two. Anything outside those bounds was
|
|
12351
|
+
* not in the closure whatever the client says, so it is dropped rather than
|
|
12352
|
+
* refused: the delete is still correct, and refusing the whole request over an
|
|
12353
|
+
* over-broad extra would turn a discard into an error the user cannot act on.
|
|
12354
|
+
*
|
|
12355
|
+
* Exported for the test. Pure, and given the chain rather than fetching it, so
|
|
12356
|
+
* the ordering it depends on is visible in the test rather than mocked.
|
|
12357
|
+
*/
|
|
12358
|
+
function boundUnstageClosure(/** The pending chain, in chain order, as `fetchPatches` returns it. */
|
|
12359
|
+
chain, deleted, requested) {
|
|
12360
|
+
if (requested.length === 0) {
|
|
12361
|
+
return [];
|
|
12362
|
+
}
|
|
12363
|
+
const positionOf = new Map();
|
|
12364
|
+
const moduleOf = new Map();
|
|
12365
|
+
chain.forEach((entry, index) => {
|
|
12366
|
+
positionOf.set(entry.patchId, index);
|
|
12367
|
+
moduleOf.set(entry.patchId, entry.path);
|
|
12368
|
+
});
|
|
12369
|
+
const doomed = new Set(deleted);
|
|
12370
|
+
return requested.filter(patchId => {
|
|
12371
|
+
// A patch being deleted anyway does not need its membership stripped
|
|
12372
|
+
// separately, and naming one is how an over-broad list hides.
|
|
12373
|
+
if (doomed.has(patchId)) return false;
|
|
12374
|
+
const position = positionOf.get(patchId);
|
|
12375
|
+
const moduleFilePath = moduleOf.get(patchId);
|
|
12376
|
+
if (position === undefined || moduleFilePath === undefined) return false;
|
|
12377
|
+
return deleted.some(deletedId => {
|
|
12378
|
+
const deletedPosition = positionOf.get(deletedId);
|
|
12379
|
+
if (deletedPosition === undefined) return false;
|
|
12380
|
+
return position > deletedPosition && moduleOf.get(deletedId) === moduleFilePath;
|
|
12381
|
+
});
|
|
12382
|
+
});
|
|
12383
|
+
}
|
|
12384
|
+
|
|
12385
|
+
/**
|
|
12386
|
+
* Which pending patches this caller may see, when they asked for "only mine".
|
|
12387
|
+
*
|
|
12388
|
+
* Shared by `/sources/~` and `/json`, and it has to be: a draft page renders
|
|
12389
|
+
* both, so two answers to "whose work is this" put one person's half-finished
|
|
12390
|
+
* edit on another person's preview through whichever route was not scoped. That
|
|
12391
|
+
* is exactly what happened — `/json` applied every pending patch on the branch
|
|
12392
|
+
* while the module content beside it was scoped.
|
|
12393
|
+
*
|
|
12394
|
+
* `undefined` means "apply everything", which is what every caller that does
|
|
12395
|
+
* not ask for scoping gets and must keep getting.
|
|
12396
|
+
*/
|
|
12397
|
+
async function resolveOwnPatchScope(serverOps, opts) {
|
|
12398
|
+
let ownPatchIds;
|
|
12399
|
+
/** See where this is set: committed work is nobody's to hold back. */
|
|
12400
|
+
let scopeAlsoIncludesApplied = false;
|
|
12401
|
+
if (opts.explicitPatchIds === undefined && opts.ownGroupsOnly) {
|
|
12402
|
+
if (serverOps instanceof ValOpsHttp && opts.authorId) {
|
|
12403
|
+
const groupsRes = await serverOps.getPatchGroups();
|
|
12404
|
+
if (groupsRes.status === "unsupported") {
|
|
12405
|
+
/*
|
|
12406
|
+
* A content API that PREDATES patch groups — the endpoint 404s.
|
|
12407
|
+
*
|
|
12408
|
+
* Unscoped, which is what those deployments do today and must
|
|
12409
|
+
* keep doing. Reading this as a failure and rendering base
|
|
12410
|
+
* would silently drop every pending patch from every draft
|
|
12411
|
+
* preview on every existing http project — the exact opposite
|
|
12412
|
+
* of "keeps working unchanged", and invisible to the reader.
|
|
12413
|
+
*/
|
|
12414
|
+
ownPatchIds = undefined;
|
|
12415
|
+
} else if (groupsRes.status === "error") {
|
|
12416
|
+
/*
|
|
12417
|
+
* We could not ask, and this deployment DOES have the endpoint.
|
|
12418
|
+
* Render base rather than everything: a degraded preview is
|
|
12419
|
+
* recoverable, showing another author's unpublished draft is
|
|
12420
|
+
* not, and it would be silent.
|
|
12421
|
+
*/
|
|
12422
|
+
ownPatchIds = [];
|
|
12423
|
+
} else if (groupsRes.patchGroups.length === 0) {
|
|
12424
|
+
/*
|
|
12425
|
+
* The branch has no groups AT ALL, so this deployment is not
|
|
12426
|
+
* using them — a content API that predates patch groups, or a
|
|
12427
|
+
* project where nobody has staged anything since they existed.
|
|
12428
|
+
*
|
|
12429
|
+
* Unscoped, which is the behaviour every such project has
|
|
12430
|
+
* today. Collapsing this into "your group is empty" would make
|
|
12431
|
+
* every draft render base and silently drop all pending
|
|
12432
|
+
* content, which is what happened before this branch: nothing
|
|
12433
|
+
* writes a group yet, so EVERY project is in this state right
|
|
12434
|
+
* now.
|
|
12435
|
+
*/
|
|
12436
|
+
ownPatchIds = undefined;
|
|
12437
|
+
} else {
|
|
12438
|
+
/*
|
|
12439
|
+
* Groups exist and none are this person's: they have staged
|
|
12440
|
+
* nothing, and base is the honest answer. Distinct from the
|
|
12441
|
+
* case above, which is why the two are not one expression.
|
|
12442
|
+
*/
|
|
12443
|
+
ownPatchIds = groupsRes.patchGroups.filter(group => group.publishedAt === null && group.authorId === opts.authorId).flatMap(group => group.patchIds);
|
|
12444
|
+
/*
|
|
12445
|
+
* Scoping applies to PENDING work only. Anything already
|
|
12446
|
+
* committed is part of everyone's view.
|
|
12447
|
+
*
|
|
12448
|
+
* A published patch stays in the chain with `appliedAt` set
|
|
12449
|
+
* until the next deployment moves the base, and the unscoped
|
|
12450
|
+
* path applies it. Filtering to open groups dropped it — so the
|
|
12451
|
+
* moment someone published, their own draft preview reverted
|
|
12452
|
+
* the field they had just shipped, and nobody else saw it
|
|
12453
|
+
* either until the deploy landed. Anything written on top in
|
|
12454
|
+
* that window is authored against content that is already
|
|
12455
|
+
* stale on `main`.
|
|
12456
|
+
*
|
|
12457
|
+
* Unioned by `appliedAt` rather than by pulling in groups with
|
|
12458
|
+
* a `publishedAt`, because a partial publish leaves the group
|
|
12459
|
+
* OPEN with some of its patches applied — those are committed
|
|
12460
|
+
* too, and no group flag names them.
|
|
12461
|
+
*/
|
|
12462
|
+
scopeAlsoIncludesApplied = true;
|
|
12463
|
+
}
|
|
12464
|
+
} else {
|
|
12465
|
+
/*
|
|
12466
|
+
* fs mode, or a server with no groups: there is nothing to scope
|
|
12467
|
+
* BY, and every pending patch is this one person's anyway. Left
|
|
12468
|
+
* `undefined` so the existing "apply everything" path runs.
|
|
12469
|
+
*/
|
|
12470
|
+
ownPatchIds = undefined;
|
|
12471
|
+
}
|
|
12472
|
+
}
|
|
12473
|
+
return {
|
|
12474
|
+
ownPatchIds,
|
|
12475
|
+
scopeAlsoIncludesApplied
|
|
12476
|
+
};
|
|
12477
|
+
}
|
|
12478
|
+
async function refuseUnlessOwn(ops, patchGroupId, authorId) {
|
|
12479
|
+
/*
|
|
12480
|
+
* Never from the cache. This answers "is this group yours", and a group is at
|
|
12481
|
+
* its youngest exactly when the question is asked — the first write creates
|
|
12482
|
+
* it and the shell flushes its queued stages the moment the save response
|
|
12483
|
+
* names it. A cached list fetched a few hundred milliseconds earlier does not
|
|
12484
|
+
* contain it, and every one of those stages was refused and dropped.
|
|
12485
|
+
*/
|
|
12486
|
+
const groupsRes = await ops.getPatchGroups({
|
|
12487
|
+
fresh: true
|
|
12488
|
+
});
|
|
12489
|
+
if (groupsRes.status !== "ok") {
|
|
12490
|
+
return {
|
|
12491
|
+
status: 500,
|
|
12492
|
+
message: "Could not verify that this patch group is yours, so it was not changed."
|
|
12493
|
+
};
|
|
12494
|
+
}
|
|
12495
|
+
const group = groupsRes.patchGroups.find(candidate => candidate.patchGroupId === patchGroupId);
|
|
12496
|
+
if (group === undefined || group.authorId === null || group.authorId !== authorId) {
|
|
12497
|
+
/*
|
|
12498
|
+
* "Not found" and "not yours" are the SAME refusal on purpose: the route
|
|
12499
|
+
* schema has no 404, and `GET /patches` already lists every group on the
|
|
12500
|
+
* branch, so distinguishing them hides nothing and only adds a second
|
|
12501
|
+
* message to keep consistent.
|
|
12502
|
+
*
|
|
12503
|
+
* A null author is a group written by an api key or a PAT. Nobody owns it,
|
|
12504
|
+
* so nobody may stage into it — `null === null` must not read as a match.
|
|
12505
|
+
*/
|
|
12506
|
+
return {
|
|
12507
|
+
status: 403,
|
|
12508
|
+
message: "You can only change your own patch group"
|
|
12509
|
+
};
|
|
12510
|
+
}
|
|
12511
|
+
if (group.publishedAt !== null) {
|
|
12512
|
+
/*
|
|
12513
|
+
* Already shipped, so it can never be written again. The content API
|
|
12514
|
+
* answers this too; refusing here saves the round trip and keeps the
|
|
12515
|
+
* wording the same as every other refusal on this route.
|
|
12516
|
+
*/
|
|
12517
|
+
return {
|
|
12518
|
+
status: 409,
|
|
12519
|
+
message: "Patch group is already published"
|
|
12520
|
+
};
|
|
12521
|
+
}
|
|
12522
|
+
return null;
|
|
12523
|
+
}
|
|
10194
12524
|
function verifyCallbackReq(stateCookie, queryParams) {
|
|
10195
12525
|
if (typeof stateCookie !== "string") {
|
|
10196
12526
|
return {
|
|
@@ -10401,6 +12731,45 @@ const ENABLE_COOKIE_VALUE = {
|
|
|
10401
12731
|
}
|
|
10402
12732
|
};
|
|
10403
12733
|
const chunkSize = 1024 * 1024;
|
|
12734
|
+
|
|
12735
|
+
/**
|
|
12736
|
+
* Turn a HistoryError into the HTTP answer it deserves.
|
|
12737
|
+
*
|
|
12738
|
+
* The `kind` travels in the body alongside the message, because the Studio
|
|
12739
|
+
* decides what to OFFER from it - "cannot restore this field" and "cannot
|
|
12740
|
+
* restore this module at all" are different affordances, and a rendered string
|
|
12741
|
+
* cannot be told apart.
|
|
12742
|
+
*/
|
|
12743
|
+
function historyErrorResponse(error) {
|
|
12744
|
+
const message = historyErrorMessage(error);
|
|
12745
|
+
switch (error.kind) {
|
|
12746
|
+
case "commit-not-found":
|
|
12747
|
+
return {
|
|
12748
|
+
status: 404,
|
|
12749
|
+
json: {
|
|
12750
|
+
message
|
|
12751
|
+
}
|
|
12752
|
+
};
|
|
12753
|
+
case "not-supported-in-fs-mode":
|
|
12754
|
+
// Not an error in the request - this deployment simply has no history
|
|
12755
|
+
// service. 400 rather than 500 so it does not read as a bug.
|
|
12756
|
+
return {
|
|
12757
|
+
status: 400,
|
|
12758
|
+
json: {
|
|
12759
|
+
message,
|
|
12760
|
+
kind: error.kind
|
|
12761
|
+
}
|
|
12762
|
+
};
|
|
12763
|
+
default:
|
|
12764
|
+
return {
|
|
12765
|
+
status: 500,
|
|
12766
|
+
json: {
|
|
12767
|
+
message,
|
|
12768
|
+
kind: error.kind
|
|
12769
|
+
}
|
|
12770
|
+
};
|
|
12771
|
+
}
|
|
12772
|
+
}
|
|
10404
12773
|
function bufferToReadableStream(buffer) {
|
|
10405
12774
|
const stream = new ReadableStream({
|
|
10406
12775
|
start(controller) {
|
|
@@ -10722,7 +13091,7 @@ function createValApiRouter(route, valServerPromise, convert) {
|
|
|
10722
13091
|
}
|
|
10723
13092
|
let bodyRes;
|
|
10724
13093
|
try {
|
|
10725
|
-
bodyRes = reqDefinition.body ? reqDefinition.body.safeParse(await req
|
|
13094
|
+
bodyRes = reqDefinition.body ? reqDefinition.body.safeParse(await readJsonBody(req)) : {
|
|
10726
13095
|
success: true,
|
|
10727
13096
|
data: {}
|
|
10728
13097
|
};
|
|
@@ -10801,6 +13170,33 @@ function formatZodErrorString(error) {
|
|
|
10801
13170
|
const errors = fromError(error).toString();
|
|
10802
13171
|
return errors.length > 640 ? `${errors.slice(0, 640)}...` : errors;
|
|
10803
13172
|
}
|
|
13173
|
+
|
|
13174
|
+
/**
|
|
13175
|
+
* The request's JSON body, or `undefined` when it has none.
|
|
13176
|
+
*
|
|
13177
|
+
* `req.json()` THROWS on an empty body, and the router used to call it
|
|
13178
|
+
* unconditionally for any route that declares a body — so the moment
|
|
13179
|
+
* `DELETE /patches` gained an optional body, every caller that sent none got
|
|
13180
|
+
* `400 Could not parse request body`. Declaring the schema `.optional()` did
|
|
13181
|
+
* not help: the throw happens before zod is ever consulted. Six e2e tests went
|
|
13182
|
+
* red on a helper doing exactly what the route still permits.
|
|
13183
|
+
*
|
|
13184
|
+
* Told apart by the request rather than by catching, so a body that IS sent and
|
|
13185
|
+
* is malformed still fails: absent means no content type and nothing to read,
|
|
13186
|
+
* and anything else is parsed and allowed to throw. A route whose schema
|
|
13187
|
+
* requires a body is unaffected — it gets `undefined` and zod refuses it, with
|
|
13188
|
+
* the same 400 as before, now naming the field.
|
|
13189
|
+
*/
|
|
13190
|
+
async function readJsonBody(req) {
|
|
13191
|
+
if (req.headers.get("content-length") === "0") {
|
|
13192
|
+
return undefined;
|
|
13193
|
+
}
|
|
13194
|
+
const text = await req.text();
|
|
13195
|
+
if (text.length === 0) {
|
|
13196
|
+
return undefined;
|
|
13197
|
+
}
|
|
13198
|
+
return JSON.parse(text);
|
|
13199
|
+
}
|
|
10804
13200
|
function zodErrorResult(error, message) {
|
|
10805
13201
|
return {
|
|
10806
13202
|
status: 400,
|
|
@@ -11454,21 +13850,22 @@ async function savePatch(deps, moduleFilePath, patch, onInvalid = "reject") {
|
|
|
11454
13850
|
}
|
|
11455
13851
|
|
|
11456
13852
|
/**
|
|
11457
|
-
*
|
|
13853
|
+
* The verified profile, or null when there was nothing to verify.
|
|
11458
13854
|
*
|
|
11459
|
-
*
|
|
11460
|
-
*
|
|
11461
|
-
* checked
|
|
11462
|
-
*
|
|
11463
|
-
*
|
|
13855
|
+
* An author is written only when somebody checked it. On the token path the
|
|
13856
|
+
* host verified a signature over a key it does not hold, so the profile is
|
|
13857
|
+
* checked rather than claimed, and the backend has no token of its own to
|
|
13858
|
+
* attribute from — the call reaches it under the app's API key. If this
|
|
13859
|
+
* stayed null there, every edit made through a signed-in editor's own session
|
|
13860
|
+
* would land with no author at all, which is worse than useless on a CMS
|
|
13861
|
+
* whose review screen is organised by who changed what.
|
|
11464
13862
|
*
|
|
11465
|
-
*
|
|
11466
|
-
*
|
|
11467
|
-
*
|
|
11468
|
-
*
|
|
11469
|
-
*
|
|
11470
|
-
*
|
|
11471
|
-
* review screen is organised by who changed what.
|
|
13863
|
+
* Null is what local filesystem mode gets, where there is no credential to
|
|
13864
|
+
* resolve, exactly as the Studio does locally. It is also what the removed
|
|
13865
|
+
* personal-access-token path got, and for a reason worth keeping in view: an
|
|
13866
|
+
* id derived from a credential the app cannot resolve is an unverified claim
|
|
13867
|
+
* dressed up as a checked one. Should another unverified credential ever
|
|
13868
|
+
* reach here, null remains its only honest author.
|
|
11472
13869
|
*/
|
|
11473
13870
|
const authorId = ((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) === "verified-profile" ? ctx.auth.profileId : null;
|
|
11474
13871
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -11726,14 +14123,17 @@ function rejectFileOps(patch) {
|
|
|
11726
14123
|
*/
|
|
11727
14124
|
|
|
11728
14125
|
/**
|
|
11729
|
-
* How the caller was established, and
|
|
11730
|
-
*
|
|
14126
|
+
* How the caller was established, and there is one acceptable answer: the host
|
|
14127
|
+
* **checked a signature**.
|
|
11731
14128
|
*
|
|
11732
|
-
*
|
|
11733
|
-
* backend unchecked,
|
|
11734
|
-
*
|
|
11735
|
-
*
|
|
11736
|
-
*
|
|
14129
|
+
* A union of one, deliberately. It carried a second variant — a personal access
|
|
14130
|
+
* token relayed to the backend unchecked, on the reasoning that the app cannot
|
|
14131
|
+
* resolve one and the backend can. The reasoning held; the shape did not. A
|
|
14132
|
+
* credential the host cannot check is one it also cannot refuse, so accepting
|
|
14133
|
+
* one made "a deployed endpoint that authenticates nobody" a supported
|
|
14134
|
+
* configuration, and it let a host serve these tools without ever being told
|
|
14135
|
+
* where callers should authorize. The discriminant stays so that adding a
|
|
14136
|
+
* second *verified* kind stays a one-line change at every call site.
|
|
11737
14137
|
*/
|
|
11738
14138
|
|
|
11739
14139
|
/**
|
|
@@ -11768,17 +14168,6 @@ const VAL_SCOPE_READ = "val:read";
|
|
|
11768
14168
|
/** Write access. Needed *in addition* by any tool not marked `readOnlyHint`. */
|
|
11769
14169
|
const VAL_SCOPE_WRITE = "val:write";
|
|
11770
14170
|
|
|
11771
|
-
/**
|
|
11772
|
-
* How many callers' data layers to keep around in proxy mode.
|
|
11773
|
-
*
|
|
11774
|
-
* Each entry holds one `ValOpsHttp`, and each of those caches the project's
|
|
11775
|
-
* evaluated modules once `initSources` has run — so this bounds memory, not just
|
|
11776
|
-
* entry count. Small on purpose: the cost of a miss is re-evaluating the
|
|
11777
|
-
* modules on the next call, which is what happened on *every* call before this
|
|
11778
|
-
* cache existed.
|
|
11779
|
-
*/
|
|
11780
|
-
const MAX_CACHED_OPS = 8;
|
|
11781
|
-
|
|
11782
14171
|
/**
|
|
11783
14172
|
* Val's server-side tool registry.
|
|
11784
14173
|
*
|
|
@@ -11878,17 +14267,25 @@ function createValTools(valModules, options) {
|
|
|
11878
14267
|
* Pick the data layer for a call, which in proxy mode means picking whose
|
|
11879
14268
|
* credential the backend will see.
|
|
11880
14269
|
*
|
|
11881
|
-
* This is the one place authorization is decided, and
|
|
11882
|
-
*
|
|
11883
|
-
*
|
|
11884
|
-
*
|
|
11885
|
-
*
|
|
14270
|
+
* This is the one place authorization is decided, and in proxy mode there is
|
|
14271
|
+
* exactly one credential it will act on: an access token whose signature,
|
|
14272
|
+
* issuer, audience and expiry the host verified against the authorization
|
|
14273
|
+
* server's published key. Anything less is refused here rather than forwarded.
|
|
14274
|
+
*
|
|
14275
|
+
* There used to be a second route — the caller's personal access token, passed
|
|
14276
|
+
* through unread on the reasoning that the backend, not the app, is the
|
|
14277
|
+
* authority on what it may do. That was true, and it was still the wrong shape:
|
|
14278
|
+
* it made an unauthenticated bearer token on a deployed endpoint a supported
|
|
14279
|
+
* configuration, and it meant `initValMcp` had a path where an app served MCP
|
|
14280
|
+
* without ever being told where to authorize. A host that has not verified
|
|
14281
|
+
* anything now gets a refusal that names the missing `oauth` config.
|
|
11886
14282
|
*
|
|
11887
|
-
*
|
|
11888
|
-
*
|
|
11889
|
-
*
|
|
11890
|
-
*
|
|
11891
|
-
*
|
|
14283
|
+
* What has *not* changed is why a verified token does not become the app's own
|
|
14284
|
+
* API key by some other name. The app authenticates to the backend with its own
|
|
14285
|
+
* key here, and who did what travels as the patch's `authorId` — so the
|
|
14286
|
+
* profile has to be one the host checked cryptographically, never one it was
|
|
14287
|
+
* handed. An `authenticate()` that decided a credential's rights inside the app
|
|
14288
|
+
* would make every bug in it full access to every project that key can reach.
|
|
11892
14289
|
*/
|
|
11893
14290
|
function createOpsResolver(valModules, options) {
|
|
11894
14291
|
if (options.mode === "fs") {
|
|
@@ -11902,18 +14299,17 @@ function createOpsResolver(valModules, options) {
|
|
|
11902
14299
|
// and the difference matters, because fs mode writes straight to disk
|
|
11903
14300
|
// with no backend permission check at all.
|
|
11904
14301
|
//
|
|
11905
|
-
//
|
|
11906
|
-
//
|
|
11907
|
-
//
|
|
11908
|
-
//
|
|
11909
|
-
//
|
|
11910
|
-
// difference between a two-minute fix and an afternoon.
|
|
14302
|
+
// A verified access token is not something the caller chose to send: it
|
|
14303
|
+
// only exists because this app advertised an authorization server, so
|
|
14304
|
+
// the developer seeing this did not do anything wrong — a config file
|
|
14305
|
+
// did, and naming it is the difference between a two-minute fix and an
|
|
14306
|
+
// afternoon.
|
|
11911
14307
|
return {
|
|
11912
14308
|
status: "error",
|
|
11913
14309
|
result: {
|
|
11914
14310
|
status: "error",
|
|
11915
14311
|
code: "unsupported",
|
|
11916
|
-
message:
|
|
14312
|
+
message: "This Val project is running in local filesystem mode, so there is nothing to authenticate against, but it is configured with an `oauth` issuer and is therefore asking clients for an access token it cannot use. Remove the `oauth` config (or `VAL_OAUTH_ISSUER` from your local `.env`) for local development."
|
|
11917
14313
|
}
|
|
11918
14314
|
};
|
|
11919
14315
|
}
|
|
@@ -11924,78 +14320,50 @@ function createOpsResolver(valModules, options) {
|
|
|
11924
14320
|
};
|
|
11925
14321
|
}
|
|
11926
14322
|
|
|
11927
|
-
// Keyed by a hash of the PAT, so the same caller reuses their own instance and
|
|
11928
|
-
// two callers can never share one. Hashing is not a security boundary — the
|
|
11929
|
-
// instance holds the token regardless — but it keeps credentials out of the
|
|
11930
|
-
// key set, which is the thing that ends up in a heap dump or an error dump.
|
|
11931
|
-
const byPatHash = new Map();
|
|
11932
14323
|
/**
|
|
11933
|
-
* One instance for every verified caller, and
|
|
11934
|
-
*
|
|
11935
|
-
*
|
|
11936
|
-
*
|
|
14324
|
+
* One instance for every verified caller, and that is correct rather than a
|
|
14325
|
+
* shortcut: this instance authenticates with the app's own API key, so there
|
|
14326
|
+
* is nothing per-caller in it to keep apart. Who did what travels as the
|
|
14327
|
+
* patch's `authorId` instead — see `writePath`.
|
|
14328
|
+
*
|
|
14329
|
+
* One instance is also all proxy mode keeps now. It could not share while a
|
|
14330
|
+
* personal access token reached this function: each token needed its own
|
|
14331
|
+
* `ValOpsHttp` to hold it, each of those cached the project's evaluated
|
|
14332
|
+
* modules, and the bounded cache that kept that memory in check turned an
|
|
14333
|
+
* eviction into a re-evaluation of every module on the next call.
|
|
11937
14334
|
*/
|
|
11938
14335
|
let sharedOps = null;
|
|
11939
14336
|
return ctx => {
|
|
11940
|
-
|
|
14337
|
+
var _ctx$auth;
|
|
14338
|
+
if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
|
|
11941
14339
|
return {
|
|
11942
14340
|
status: "error",
|
|
11943
14341
|
result: {
|
|
11944
14342
|
status: "error",
|
|
11945
14343
|
code: "forbidden",
|
|
11946
|
-
message: "This Val project talks to the Val content backend, so every call needs
|
|
14344
|
+
message: "This Val project talks to the Val content backend, so every call needs an access token from the Val authorization server. If this endpoint is not asking clients to authorize, it has no `oauth` config — give `initValMcp` one, or run the project in local filesystem mode for development."
|
|
11947
14345
|
}
|
|
11948
14346
|
};
|
|
11949
14347
|
}
|
|
11950
|
-
if (
|
|
11951
|
-
|
|
11952
|
-
|
|
11953
|
-
|
|
11954
|
-
// alternative to refusing is building ops with no credential at all.
|
|
11955
|
-
return {
|
|
11956
|
-
status: "error",
|
|
11957
|
-
result: {
|
|
11958
|
-
status: "error",
|
|
11959
|
-
code: "forbidden",
|
|
11960
|
-
message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
|
|
11961
|
-
}
|
|
11962
|
-
};
|
|
11963
|
-
}
|
|
11964
|
-
if (!sharedOps) {
|
|
11965
|
-
sharedOps = createValOps(valModules, options);
|
|
11966
|
-
}
|
|
11967
|
-
return {
|
|
11968
|
-
status: "ok",
|
|
11969
|
-
ops: sharedOps
|
|
11970
|
-
};
|
|
11971
|
-
}
|
|
11972
|
-
const key = createHash("sha256").update(ctx.auth.pat).digest("hex");
|
|
11973
|
-
const cached = byPatHash.get(key);
|
|
11974
|
-
if (cached) {
|
|
11975
|
-
// Re-inserted so eviction drops the least recently used rather than the
|
|
11976
|
-
// oldest — a long-running caller should not be evicted by a burst of
|
|
11977
|
-
// one-off ones.
|
|
11978
|
-
byPatHash.delete(key);
|
|
11979
|
-
byPatHash.set(key, cached);
|
|
14348
|
+
if (!options.apiKey) {
|
|
14349
|
+
// Proxy mode is inferred from the api key being present, so this is
|
|
14350
|
+
// unreachable through `initHandlerOptions`. It stays because the
|
|
14351
|
+
// alternative to refusing is building ops with no credential at all.
|
|
11980
14352
|
return {
|
|
11981
|
-
status: "
|
|
11982
|
-
|
|
14353
|
+
status: "error",
|
|
14354
|
+
result: {
|
|
14355
|
+
status: "error",
|
|
14356
|
+
code: "forbidden",
|
|
14357
|
+
message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
|
|
14358
|
+
}
|
|
11983
14359
|
};
|
|
11984
14360
|
}
|
|
11985
|
-
|
|
11986
|
-
|
|
11987
|
-
});
|
|
11988
|
-
byPatHash.set(key, ops);
|
|
11989
|
-
while (byPatHash.size > MAX_CACHED_OPS) {
|
|
11990
|
-
const oldest = byPatHash.keys().next();
|
|
11991
|
-
if (oldest.done) {
|
|
11992
|
-
break;
|
|
11993
|
-
}
|
|
11994
|
-
byPatHash.delete(oldest.value);
|
|
14361
|
+
if (!sharedOps) {
|
|
14362
|
+
sharedOps = createValOps(valModules, options);
|
|
11995
14363
|
}
|
|
11996
14364
|
return {
|
|
11997
14365
|
status: "ok",
|
|
11998
|
-
ops
|
|
14366
|
+
ops: sharedOps
|
|
11999
14367
|
};
|
|
12000
14368
|
};
|
|
12001
14369
|
}
|
|
@@ -12093,14 +14461,18 @@ function describeZodError(error) {
|
|
|
12093
14461
|
* the safe direction: a tool that forgets the hint is treated as a write and
|
|
12094
14462
|
* demands the wider scope, rather than a write slipping through as a read.
|
|
12095
14463
|
*
|
|
12096
|
-
*
|
|
12097
|
-
*
|
|
12098
|
-
*
|
|
12099
|
-
*
|
|
14464
|
+
* The early return is a call carrying no verified credential, and there is no
|
|
14465
|
+
* scope to check because nothing granted one. In Val's own host that means
|
|
14466
|
+
* local filesystem mode, where a project writing a developer's own working tree
|
|
14467
|
+
* has no wider authority to withhold. A host assembling its own context can
|
|
14468
|
+
* also reach it with an unauthenticated proxy-mode call — refused a few lines
|
|
14469
|
+
* later, by `resolveOps`, for the credential rather than the scope. Every other
|
|
14470
|
+
* caller arrives as a verified profile, carrying the scopes its token was
|
|
14471
|
+
* issued with.
|
|
12100
14472
|
*/
|
|
12101
14473
|
function refuseInsufficientScope(tool, ctx) {
|
|
12102
|
-
var _ctx$
|
|
12103
|
-
if (((_ctx$
|
|
14474
|
+
var _ctx$auth2, _tool$annotations;
|
|
14475
|
+
if (((_ctx$auth2 = ctx.auth) === null || _ctx$auth2 === void 0 ? void 0 : _ctx$auth2.type) !== "verified-profile") {
|
|
12104
14476
|
return null;
|
|
12105
14477
|
}
|
|
12106
14478
|
// Read is needed by every call, including the writes: a tool that changes
|
|
@@ -13629,6 +16001,25 @@ async function handleJsonValuesExtractEntry(ctx) {
|
|
|
13629
16001
|
};
|
|
13630
16002
|
}
|
|
13631
16003
|
|
|
16004
|
+
/**
|
|
16005
|
+
* `external:upload` under `val validate --fix`: refuse, and say what to run.
|
|
16006
|
+
*
|
|
16007
|
+
* Every other fix in this registry rewrites something inside the repository —
|
|
16008
|
+
* reversible, visible in a diff, wrong by at most one commit. This one would
|
|
16009
|
+
* write entries into a live external store: not in a diff, not undone by
|
|
16010
|
+
* `git revert`, and against production indistinguishable from an editor's
|
|
16011
|
+
* publish. So a blanket `--fix` must never apply it.
|
|
16012
|
+
*
|
|
16013
|
+
* `fixableErrorMessage` rather than a plain error, because the error IS fixable
|
|
16014
|
+
* — just not by this command.
|
|
16015
|
+
*/
|
|
16016
|
+
async function handleExternalUpload() {
|
|
16017
|
+
return {
|
|
16018
|
+
success: true,
|
|
16019
|
+
fixableErrorMessage: "This entry is written inline but its record is .external(). " + "Run 'val external upload' to move it into the store — " + "'val validate --fix' will not write to a live store."
|
|
16020
|
+
};
|
|
16021
|
+
}
|
|
16022
|
+
|
|
13632
16023
|
// Fix handler registry. `keyof:check-keys` and `router:check-route` are
|
|
13633
16024
|
// resolved upfront by the shared resolveSchemaSourceFixes — they never reach
|
|
13634
16025
|
// this registry, so they're excluded from the key set.
|
|
@@ -13651,7 +16042,8 @@ const currentFixHandlers = {
|
|
|
13651
16042
|
"files:check-unique-folder": handleUniqueFolderCheck,
|
|
13652
16043
|
"images:check-all-files": handleCheckAllFiles,
|
|
13653
16044
|
"files:check-all-files": handleCheckAllFiles,
|
|
13654
|
-
"jsonValues:extract-entry": handleJsonValuesExtractEntry
|
|
16045
|
+
"jsonValues:extract-entry": handleJsonValuesExtractEntry,
|
|
16046
|
+
"external:upload": handleExternalUpload
|
|
13655
16047
|
};
|
|
13656
16048
|
const deprecatedFixHandlers = {
|
|
13657
16049
|
"image:replace-metadata": handleFileMetadata
|
|
@@ -14300,4 +16692,4 @@ function readCapturedReport(snapshotDir) {
|
|
|
14300
16692
|
return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
|
|
14301
16693
|
}
|
|
14302
16694
|
|
|
14303
|
-
export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, Service, VAL_SCOPE_READ, VAL_SCOPE_WRITE, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, authorIdFromVerifiedSubject, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, createValTools, currentFixHandlers, decodeJwtWithoutVerifying, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, initHandlerOptions, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
|
|
16695
|
+
export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, EXTERNAL_RESULT, Service, VAL_SCOPE_READ, VAL_SCOPE_WRITE, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, authorIdFromVerifiedSubject, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, createValTools, currentFixHandlers, decodeJwtWithoutVerifying, defineExternal, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, err$1 as err, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleExternalUpload, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, initHandlerOptions, isExternalResult, loadValModules, ok$1 as ok, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
|