@valbuild/server 0.121.0 → 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 +132 -0
- package/dist/declarations/src/ValOps.d.ts +67 -1
- package/dist/declarations/src/ValOpsFS.d.ts +14 -0
- package/dist/declarations/src/ValOpsHttp.d.ts +30 -0
- 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 +1417 -286
- package/dist/valbuild-server.cjs.prod.js +1417 -286
- package/dist/valbuild-server.esm.js +1414 -289
- package/package.json +3 -3
|
@@ -599,11 +599,13 @@ const patchValFile = async (id, rootDir, patch$1, sourceFileHandler) => {
|
|
|
599
599
|
// );
|
|
600
600
|
// sourceFileHandler.moveFile(tempFilePath, "." + filePath);
|
|
601
601
|
// TODO: ensure that directory exists
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
602
|
+
// NOTE: there used to be a branch here testing
|
|
603
|
+
// `content.startsWith("data:/image/svg+xml")` - with a stray slash after
|
|
604
|
+
// `data:`, so it could never match a real data URL and SVGs took the
|
|
605
|
+
// "binary" path anyway. Removed rather than fixed: the branch existed to
|
|
606
|
+
// write SVG as utf8, and writing the decoded bytes is correct for every
|
|
607
|
+
// type including SVG.
|
|
608
|
+
sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("binary"), "binary");
|
|
607
609
|
}
|
|
608
610
|
sourceFileHandler.writeSourceFile(newSourceFile.value);
|
|
609
611
|
// console.timeEnd("patchValFile" + timeId);
|
|
@@ -1952,6 +1954,183 @@ class Service {
|
|
|
1952
1954
|
}
|
|
1953
1955
|
}
|
|
1954
1956
|
|
|
1957
|
+
/**
|
|
1958
|
+
* The type surface of an external record's adapter.
|
|
1959
|
+
*
|
|
1960
|
+
* Nothing here runs: phase 0 is the contract, and the registry that executes it
|
|
1961
|
+
* arrives with the read endpoints. What the contract has to get right now is the
|
|
1962
|
+
* shape, because every adapter written against it is a compatibility promise.
|
|
1963
|
+
*
|
|
1964
|
+
* Four kinds of method, and each is required or not for one reason:
|
|
1965
|
+
*
|
|
1966
|
+
* - **Read** — `keys`, `get`. Cannot be built out of anything else, so always
|
|
1967
|
+
* required.
|
|
1968
|
+
* - **Write** — `put`, `delete`. Likewise, and they move together: required on a
|
|
1969
|
+
* writable record, forbidden on a `.readonly()` one.
|
|
1970
|
+
* - **Derived** — `count`, `search`. Computable from the read methods, so
|
|
1971
|
+
* omitting one costs performance, never capability. `false` declines the
|
|
1972
|
+
* fallback; that is the only thing `false` ever means here.
|
|
1973
|
+
* - **Media** — `putFile`, `getFile`. A pair, and required by the item SCHEMA
|
|
1974
|
+
* rather than by this type (see `hasMediaSchema`).
|
|
1975
|
+
*/
|
|
1976
|
+
|
|
1977
|
+
// #region result
|
|
1978
|
+
|
|
1979
|
+
/**
|
|
1980
|
+
* Brands the result envelope so a bare value can be accepted alongside it.
|
|
1981
|
+
*
|
|
1982
|
+
* A symbol rather than a property name because `get` returns
|
|
1983
|
+
* `Record<key, Item>` — a record that can perfectly well contain a key called
|
|
1984
|
+
* `kind`. The envelope never crosses the wire (it travels in-process between
|
|
1985
|
+
* adapter and Val), so there is no serialization cost to it.
|
|
1986
|
+
*/
|
|
1987
|
+
const EXTERNAL_RESULT = Symbol.for("@valbuild/server/ExternalResult");
|
|
1988
|
+
|
|
1989
|
+
/**
|
|
1990
|
+
* What every adapter method may return: the value on its own, or the envelope.
|
|
1991
|
+
*
|
|
1992
|
+
* A bare value means "ok, nothing to report", which keeps the common path one
|
|
1993
|
+
* line. Errors can be thrown instead of returned — the envelope is for a failure
|
|
1994
|
+
* worth classifying, or a success worth annotating.
|
|
1995
|
+
*/
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* Wrap a value as a successful result, optionally with warnings.
|
|
1999
|
+
*
|
|
2000
|
+
* `NoInfer` is load-bearing, not decoration. Without it `T` is inferred FROM the
|
|
2001
|
+
* argument, and an object literal that infers its own type is not contextually
|
|
2002
|
+
* typed by the adapter's contract — so `title` inside `ok({ a: { title } })` is a
|
|
2003
|
+
* fresh property that happens to be named `title`, with no declaration link back
|
|
2004
|
+
* to `title: s.string()` in the schema. "Find all references" on the schema field
|
|
2005
|
+
* then stops at the page that reads it and never reaches the adapter that
|
|
2006
|
+
* produces it. `NoInfer` blocks that inference, leaving the contextual return
|
|
2007
|
+
* type as the only source for `T`, which is what restores the link (and, as a
|
|
2008
|
+
* bonus, makes a typo report the offending property rather than the whole
|
|
2009
|
+
* return value).
|
|
2010
|
+
*
|
|
2011
|
+
* `externalNavigation.test.ts` guards this; nothing else would notice.
|
|
2012
|
+
*
|
|
2013
|
+
* The cost: `ok(...)` in a position with NO contextual type — a helper without a
|
|
2014
|
+
* return type annotation — infers `unknown` instead of the argument's type, and
|
|
2015
|
+
* fails where the helper is used rather than where it is written. Annotate the
|
|
2016
|
+
* helper's return type, or inline it.
|
|
2017
|
+
*/
|
|
2018
|
+
function ok$1(value, warnings) {
|
|
2019
|
+
return warnings && warnings.length > 0 ? {
|
|
2020
|
+
[EXTERNAL_RESULT]: true,
|
|
2021
|
+
kind: "ok",
|
|
2022
|
+
value,
|
|
2023
|
+
warnings
|
|
2024
|
+
} : {
|
|
2025
|
+
[EXTERNAL_RESULT]: true,
|
|
2026
|
+
kind: "ok",
|
|
2027
|
+
value
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
function err$1(issue) {
|
|
2031
|
+
return {
|
|
2032
|
+
[EXTERNAL_RESULT]: true,
|
|
2033
|
+
kind: "err",
|
|
2034
|
+
error: issue
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
function isExternalResult(value) {
|
|
2038
|
+
return typeof value === "object" && value !== null && value[EXTERNAL_RESULT] === true;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// #endregion
|
|
2042
|
+
|
|
2043
|
+
// #region context
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* What every adapter method is told about the call it is serving.
|
|
2047
|
+
*
|
|
2048
|
+
* `tx` is present only when the adapter declared an `around`; a store with no
|
|
2049
|
+
* transaction seam has nothing to put there and nothing to ignore.
|
|
2050
|
+
*/
|
|
2051
|
+
|
|
2052
|
+
// #endregion
|
|
2053
|
+
|
|
2054
|
+
// #region paging, sorting, searching
|
|
2055
|
+
|
|
2056
|
+
/**
|
|
2057
|
+
* How a page should be ordered.
|
|
2058
|
+
*
|
|
2059
|
+
* Records are unordered today and sorting is coming; the parameter lands now
|
|
2060
|
+
* because adding one to `keys` later would break every adapter that exists by
|
|
2061
|
+
* then. Val passes `undefined` until sorting ships, and an adapter may ignore it.
|
|
2062
|
+
*
|
|
2063
|
+
* Two rules for whoever implements it:
|
|
2064
|
+
*
|
|
2065
|
+
* - **A cursor is only valid for the sort that issued it.** Key-ordered paging
|
|
2066
|
+
* is `where key > cursor`; sorted paging is `where (field, key) > (…, …)`.
|
|
2067
|
+
* Val pairs the cursor with a hash of the sort and restarts rather than
|
|
2068
|
+
* replaying a mismatched one.
|
|
2069
|
+
* - **The key is always the last sort term.** Ordering by a non-unique field
|
|
2070
|
+
* without a tiebreaker lets rows shift between pages, so page 2 can skip or
|
|
2071
|
+
* repeat an entry while both pages look fine on their own.
|
|
2072
|
+
*/
|
|
2073
|
+
|
|
2074
|
+
// #endregion
|
|
2075
|
+
|
|
2076
|
+
// #region media
|
|
2077
|
+
|
|
2078
|
+
// #endregion
|
|
2079
|
+
|
|
2080
|
+
// #region the adapter
|
|
2081
|
+
|
|
2082
|
+
/** Read methods. Always required. */
|
|
2083
|
+
|
|
2084
|
+
/**
|
|
2085
|
+
* Write methods. Required together, and forbidden together on `.readonly()`.
|
|
2086
|
+
*
|
|
2087
|
+
* `put` must be an UPSERT keyed by the entry key and `delete` must tolerate an
|
|
2088
|
+
* absent key, because a publish may be replayed: retry re-runs the whole scope
|
|
2089
|
+
* where there is a transaction, and the individual call where there is not.
|
|
2090
|
+
*/
|
|
2091
|
+
|
|
2092
|
+
/**
|
|
2093
|
+
* Named so the compiler prints the reason. A bare `never` would report only
|
|
2094
|
+
* "not assignable to type 'undefined'", which tells nobody anything.
|
|
2095
|
+
*/
|
|
2096
|
+
|
|
2097
|
+
/** Derived and media methods, and the sort declaration. */
|
|
2098
|
+
|
|
2099
|
+
/** The item type an external module's entries hold, loosened as JSON allows. */
|
|
2100
|
+
|
|
2101
|
+
/**
|
|
2102
|
+
* The adapter a given external module needs.
|
|
2103
|
+
*
|
|
2104
|
+
* Writes are required or forbidden by the module's own `.readonly()`, read off
|
|
2105
|
+
* the source marker's phantom.
|
|
2106
|
+
*/
|
|
2107
|
+
|
|
2108
|
+
/** What `entry()` returns: a module and its adapter, checked against each other. */
|
|
2109
|
+
|
|
2110
|
+
/**
|
|
2111
|
+
* Declare the adapters for this project's external records.
|
|
2112
|
+
*
|
|
2113
|
+
* `Tx` is given explicitly: `around` offers the compiler no inference site,
|
|
2114
|
+
* since `run` is a callback you CALL rather than one whose signature you write.
|
|
2115
|
+
* One type argument, in one place, and every inline adapter then gets `tx`,
|
|
2116
|
+
* `cursor`, `limit` and the row shape correctly typed.
|
|
2117
|
+
*
|
|
2118
|
+
* @example
|
|
2119
|
+
* const { entry, modules } = defineExternal<typeof sql>({
|
|
2120
|
+
* around: (run) => sql.begin(run),
|
|
2121
|
+
* });
|
|
2122
|
+
*
|
|
2123
|
+
* export default modules({
|
|
2124
|
+
* posts: entry(postsVal, { keys, get, put, delete: del, search: false }),
|
|
2125
|
+
* });
|
|
2126
|
+
*
|
|
2127
|
+
* @example a store with no transaction
|
|
2128
|
+
* const { entry, modules } = defineExternal();
|
|
2129
|
+
*/
|
|
2130
|
+
function defineExternal(definition) {
|
|
2131
|
+
throw new Error("defineExternal is not implemented yet: phase 0 lands the contract, the registry that executes it arrives with the read endpoints.");
|
|
2132
|
+
}
|
|
2133
|
+
|
|
1955
2134
|
const JwtPayloadSchema = z.z.object({
|
|
1956
2135
|
sub: z.z.string(),
|
|
1957
2136
|
exp: z.z.number(),
|
|
@@ -3736,6 +3915,47 @@ class ValOps {
|
|
|
3736
3915
|
};
|
|
3737
3916
|
}
|
|
3738
3917
|
}));
|
|
3918
|
+
|
|
3919
|
+
/*
|
|
3920
|
+
* What each changed module IS after this commit, and the schema it is under.
|
|
3921
|
+
*
|
|
3922
|
+
* The data rather than the file's text, because that is the half git cannot
|
|
3923
|
+
* give back: a `.val.ts` in git is code, and turning code back into data
|
|
3924
|
+
* means parsing it, which is best-effort and rots across TypeScript,
|
|
3925
|
+
* runtime and Val versions. The schema comes along because a value on its
|
|
3926
|
+
* own cannot be RENDERED - showing a module as it was at a commit whose
|
|
3927
|
+
* schema has since changed needs the schema of that commit, and nothing in
|
|
3928
|
+
* the current checkout has it.
|
|
3929
|
+
*
|
|
3930
|
+
* Taken from `getSources(analysis)` rather than re-derived here so the data
|
|
3931
|
+
* stored is the same data the Studio shows, produced by the one
|
|
3932
|
+
* implementation of "apply these ops".
|
|
3933
|
+
*/
|
|
3934
|
+
const moduleVersions = {};
|
|
3935
|
+
const {
|
|
3936
|
+
sources: sourcesAfter
|
|
3937
|
+
} = await this.getSources(patchAnalysis);
|
|
3938
|
+
for (const path of Object.keys(patchesByModule)) {
|
|
3939
|
+
let serialized;
|
|
3940
|
+
try {
|
|
3941
|
+
var _schemas$path3;
|
|
3942
|
+
serialized = (_schemas$path3 = schemas[path]) === null || _schemas$path3 === void 0 ? void 0 : _schemas$path3["executeSerialize"]();
|
|
3943
|
+
} catch {
|
|
3944
|
+
// Same guard as above: one unserializable schema must not cost every
|
|
3945
|
+
// other module its history.
|
|
3946
|
+
serialized = undefined;
|
|
3947
|
+
}
|
|
3948
|
+
if (!serialized) {
|
|
3949
|
+
continue;
|
|
3950
|
+
}
|
|
3951
|
+
const source = sourcesAfter[path];
|
|
3952
|
+
moduleVersions[path] = {
|
|
3953
|
+
// `undefined` here means the module is gone, which is a state history
|
|
3954
|
+
// has to be able to show. `null` is how that travels over the wire.
|
|
3955
|
+
source: source === undefined ? null : source,
|
|
3956
|
+
schema: serialized
|
|
3957
|
+
};
|
|
3958
|
+
}
|
|
3739
3959
|
const res = {
|
|
3740
3960
|
hasErrors,
|
|
3741
3961
|
sourceFilePatchErrors,
|
|
@@ -3748,7 +3968,8 @@ class ValOps {
|
|
|
3748
3968
|
patchedBinaryFilesDescriptors,
|
|
3749
3969
|
appliedPatches,
|
|
3750
3970
|
skippedPatches,
|
|
3751
|
-
triedPatches
|
|
3971
|
+
triedPatches,
|
|
3972
|
+
moduleVersions
|
|
3752
3973
|
};
|
|
3753
3974
|
return res;
|
|
3754
3975
|
}
|
|
@@ -3805,6 +4026,66 @@ class ValOps {
|
|
|
3805
4026
|
}
|
|
3806
4027
|
|
|
3807
4028
|
// #region abstract ops
|
|
4029
|
+
|
|
4030
|
+
/**
|
|
4031
|
+
* Save a patch's binary file from a `data:...;base64,...` URL.
|
|
4032
|
+
*
|
|
4033
|
+
* The wire form: `FileReader.readAsDataURL` is what the browser produces, and
|
|
4034
|
+
* published `@valbuild/server` versions send it. Code that already HAS bytes
|
|
4035
|
+
* should call {@link saveBinaryFileFromPatch} instead of wrapping them in a
|
|
4036
|
+
* data URL just to have this unwrap them again.
|
|
4037
|
+
*
|
|
4038
|
+
* A `null` `data` records a DELETION, which is why this cannot simply be
|
|
4039
|
+
* replaced by the byte-taking sibling: there is nothing to hand it.
|
|
4040
|
+
*/
|
|
4041
|
+
|
|
4042
|
+
/**
|
|
4043
|
+
* The same, for a caller that already has the bytes.
|
|
4044
|
+
*
|
|
4045
|
+
* Default implementation wraps them back into a data URL so every backend
|
|
4046
|
+
* gets this for free; a backend that can take bytes straight through should
|
|
4047
|
+
* override it.
|
|
4048
|
+
*/
|
|
4049
|
+
async saveBinaryFileFromPatch(filePath, parentRef, patchId, bytes, mimeType, type, metadata) {
|
|
4050
|
+
return this.saveBase64EncodedBinaryFileFromPatch(filePath, parentRef, patchId, `data:${mimeType};base64,${bytes.toString("base64")}`, type, metadata);
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
// #region history
|
|
4054
|
+
//
|
|
4055
|
+
// Reading the past, as opposed to reading the present with pending patches
|
|
4056
|
+
// applied. Every one of these is Result-typed against `HistoryError`, because
|
|
4057
|
+
// the ways this can fail - an unreadable record, a source that no longer
|
|
4058
|
+
// parses, an op that will not replay, a schema that has moved on - are the
|
|
4059
|
+
// interesting part rather than an edge case, and a caller deciding whether to
|
|
4060
|
+
// offer a RESTORE has to know which one it hit.
|
|
4061
|
+
//
|
|
4062
|
+
// Only implemented where there is a service holding the history:
|
|
4063
|
+
// `ValOpsHttp`. `ValOpsFS` answers `not-supported-in-fs-mode`, because local
|
|
4064
|
+
// dev has git for this and no commit records of its own.
|
|
4065
|
+
|
|
4066
|
+
/** One page of a branch's commits, newest first. See history/listCommits. */
|
|
4067
|
+
|
|
4068
|
+
/** The patches that produced one commit, with their ops. */
|
|
4069
|
+
|
|
4070
|
+
/**
|
|
4071
|
+
* How each `.val.ts` the commit changed looked BEFORE it, keyed by module
|
|
4072
|
+
* file path. Empty for a commit made before this was recorded - which the
|
|
4073
|
+
* caller reports as `source-unavailable` rather than as an empty module.
|
|
4074
|
+
*/
|
|
4075
|
+
/**
|
|
4076
|
+
* Each module a commit changed: its data, and the schema it was under.
|
|
4077
|
+
*
|
|
4078
|
+
* `asOf` widens it from "what this commit changed" to "the whole project as
|
|
4079
|
+
* this commit left it", which is what reverting everything to a point in time
|
|
4080
|
+
* needs; `moduleFilePath` narrows it to one module, for navigating the
|
|
4081
|
+
* history pane off the changed set.
|
|
4082
|
+
*/
|
|
4083
|
+
|
|
4084
|
+
/** Which files the commit touched, and how. Names them; does not fetch them. */
|
|
4085
|
+
|
|
4086
|
+
/** One file's bytes as they were at one commit. */
|
|
4087
|
+
|
|
4088
|
+
// #endregion history
|
|
3808
4089
|
}
|
|
3809
4090
|
function isOnlyFileCheckValidationError(validationError) {
|
|
3810
4091
|
var _validationError$fixe9;
|
|
@@ -6410,6 +6691,46 @@ class ValOpsFS extends ValOps {
|
|
|
6410
6691
|
getPatchLockFile() {
|
|
6411
6692
|
return path__namespace["default"].join(this.rootDir, ValOpsFS.VAL_DIR, PATCH_LOCK_FILE_NAME);
|
|
6412
6693
|
}
|
|
6694
|
+
|
|
6695
|
+
// #region history
|
|
6696
|
+
//
|
|
6697
|
+
// Not available locally, and deliberately not faked from git.
|
|
6698
|
+
//
|
|
6699
|
+
// History is a record of what VAL did: which patches produced a commit, who
|
|
6700
|
+
// wrote them, and what each module looked like immediately before. Git has
|
|
6701
|
+
// the files but not that - it cannot say which of a commit's changes were one
|
|
6702
|
+
// editor's patch set, so a "restore this change" built on it would be
|
|
6703
|
+
// guesswork wearing the same UI.
|
|
6704
|
+
//
|
|
6705
|
+
// A single, honest error rather than five different ones: the caller's
|
|
6706
|
+
// question is "is history available here", and the answer is no.
|
|
6707
|
+
|
|
6708
|
+
async listCommits() {
|
|
6709
|
+
return fp.result.err({
|
|
6710
|
+
kind: "not-supported-in-fs-mode"
|
|
6711
|
+
});
|
|
6712
|
+
}
|
|
6713
|
+
async getCommitPatches() {
|
|
6714
|
+
return fp.result.err({
|
|
6715
|
+
kind: "not-supported-in-fs-mode"
|
|
6716
|
+
});
|
|
6717
|
+
}
|
|
6718
|
+
async getCommitModules() {
|
|
6719
|
+
return fp.result.err({
|
|
6720
|
+
kind: "not-supported-in-fs-mode"
|
|
6721
|
+
});
|
|
6722
|
+
}
|
|
6723
|
+
async getCommitAffectedFiles() {
|
|
6724
|
+
return fp.result.err({
|
|
6725
|
+
kind: "not-supported-in-fs-mode"
|
|
6726
|
+
});
|
|
6727
|
+
}
|
|
6728
|
+
async getFileAtCommit() {
|
|
6729
|
+
return fp.result.err({
|
|
6730
|
+
kind: "not-supported-in-fs-mode"
|
|
6731
|
+
});
|
|
6732
|
+
}
|
|
6733
|
+
// #endregion history
|
|
6413
6734
|
}
|
|
6414
6735
|
class FSOpsHost {
|
|
6415
6736
|
constructor() {}
|
|
@@ -6697,6 +7018,99 @@ const CommitResponse = z.z.object({
|
|
|
6697
7018
|
commit: CommitSha,
|
|
6698
7019
|
branch: z.z.string()
|
|
6699
7020
|
});
|
|
7021
|
+
// #region history wire schemas
|
|
7022
|
+
//
|
|
7023
|
+
// Validated on arrival rather than trusted: these come from a service that
|
|
7024
|
+
// versions separately, and a silently mis-shaped commit record reads as "this
|
|
7025
|
+
// commit changed nothing", which is indistinguishable from a real answer.
|
|
7026
|
+
const HistoricalCommitResponse = z.z.object({
|
|
7027
|
+
commitSha: z.z.string(),
|
|
7028
|
+
parentCommitSha: z.z.string(),
|
|
7029
|
+
clientCommitSha: z.z.string(),
|
|
7030
|
+
branch: z.z.string(),
|
|
7031
|
+
createdBranch: z.z.string().nullable(),
|
|
7032
|
+
creator: z.z.string().nullable(),
|
|
7033
|
+
message: z.z.string().nullable(),
|
|
7034
|
+
createdAt: z.z.string(),
|
|
7035
|
+
seqNum: z.z.string(),
|
|
7036
|
+
patchCount: z.z.number(),
|
|
7037
|
+
hasArchive: z.z.boolean()
|
|
7038
|
+
});
|
|
7039
|
+
const ListCommitsResponse = z.z.object({
|
|
7040
|
+
commits: z.z.array(HistoricalCommitResponse),
|
|
7041
|
+
nextCursor: z.z.string().nullable()
|
|
7042
|
+
});
|
|
7043
|
+
const CommitPatchesResponse = z.z.object({
|
|
7044
|
+
commitSha: z.z.string(),
|
|
7045
|
+
commit: z.z.object({
|
|
7046
|
+
commitSha: z.z.string(),
|
|
7047
|
+
parentCommitSha: z.z.string(),
|
|
7048
|
+
clientCommitSha: z.z.string(),
|
|
7049
|
+
branch: z.z.string(),
|
|
7050
|
+
createdBranch: z.z.string().nullable(),
|
|
7051
|
+
creator: z.z.string().nullable(),
|
|
7052
|
+
message: z.z.string().nullable(),
|
|
7053
|
+
createdAt: z.z.string(),
|
|
7054
|
+
seqNum: z.z.string(),
|
|
7055
|
+
hasArchive: z.z.boolean()
|
|
7056
|
+
}),
|
|
7057
|
+
patches: z.z.array(z.z.object({
|
|
7058
|
+
patchId: z.z.string(),
|
|
7059
|
+
path: z.z.string(),
|
|
7060
|
+
patch: z.z.unknown(),
|
|
7061
|
+
authorId: z.z.string().nullable(),
|
|
7062
|
+
createdAt: z.z.string(),
|
|
7063
|
+
baseSha: z.z.string(),
|
|
7064
|
+
coreVersion: z.z.string()
|
|
7065
|
+
}))
|
|
7066
|
+
});
|
|
7067
|
+
|
|
7068
|
+
/**
|
|
7069
|
+
* `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
|
|
7070
|
+
*
|
|
7071
|
+
* `schema` stays `unknown` here on purpose. The content service stores it
|
|
7072
|
+
* opaquely and cannot vouch for it, so validating it at the transport boundary
|
|
7073
|
+
* would turn "a schema written by a different version of Val" into a failed
|
|
7074
|
+
* REQUEST rather than one module that cannot be shown. It is checked in
|
|
7075
|
+
* `getHistoricalPatchSet`, per module, where a failure degrades that module and
|
|
7076
|
+
* leaves the commit readable.
|
|
7077
|
+
*/
|
|
7078
|
+
const CommitModulesResponse = z.z.object({
|
|
7079
|
+
commitSha: z.z.string(),
|
|
7080
|
+
parentCommitSha: z.z.string(),
|
|
7081
|
+
/**
|
|
7082
|
+
* Whether an `asOf` read covered the whole project.
|
|
7083
|
+
*
|
|
7084
|
+
* Optional so an older content server still parses. False means modules last
|
|
7085
|
+
* edited before history started being recorded are missing from the answer -
|
|
7086
|
+
* which a whole-project revert has to say out loud rather than silently skip.
|
|
7087
|
+
*/
|
|
7088
|
+
complete: z.z.boolean().optional(),
|
|
7089
|
+
modules: z.z.array(z.z.object({
|
|
7090
|
+
moduleFilePath: z.z.string(),
|
|
7091
|
+
commitSha: z.z.string(),
|
|
7092
|
+
sourceSha: z.z.string().nullable(),
|
|
7093
|
+
schemaSha: z.z.string(),
|
|
7094
|
+
// Validated, because a Source IS just JSON and this side knows that much.
|
|
7095
|
+
source: internal.JSONValue.nullable(),
|
|
7096
|
+
schema: z.z.unknown(),
|
|
7097
|
+
unavailable: z.z.boolean()
|
|
7098
|
+
}))
|
|
7099
|
+
});
|
|
7100
|
+
const CommitAffectedFilesResponse = z.z.object({
|
|
7101
|
+
commitSha: z.z.string(),
|
|
7102
|
+
files: z.z.array(z.z.union([z.z.object({
|
|
7103
|
+
kind: z.z.union([z.z.literal("module-source"), z.z.literal("json-entry"), z.z.literal("binary")]),
|
|
7104
|
+
gitPath: z.z.string(),
|
|
7105
|
+
change: z.z.union([z.z.literal("added"), z.z.literal("modified"), z.z.literal("deleted")])
|
|
7106
|
+
}), z.z.object({
|
|
7107
|
+
kind: z.z.literal("remote-binary"),
|
|
7108
|
+
ref: z.z.string(),
|
|
7109
|
+
change: z.z.union([z.z.literal("added"), z.z.literal("modified"), z.z.literal("deleted")])
|
|
7110
|
+
})]))
|
|
7111
|
+
});
|
|
7112
|
+
// #endregion history wire schemas
|
|
7113
|
+
|
|
6700
7114
|
/*
|
|
6701
7115
|
* The shared schema, not a copy of it.
|
|
6702
7116
|
*
|
|
@@ -7716,7 +8130,12 @@ class ValOpsHttp extends ValOps {
|
|
|
7716
8130
|
if (!file) {
|
|
7717
8131
|
return null;
|
|
7718
8132
|
}
|
|
7719
|
-
|
|
8133
|
+
// Plain base64, the same as the `repo` branch of getBinaryFile above.
|
|
8134
|
+
//
|
|
8135
|
+
// `value` used to be a data: URL here and plain base64 there - two
|
|
8136
|
+
// encodings in one field, told apart only by which branch produced them.
|
|
8137
|
+
// The content service answers base64 for both now.
|
|
8138
|
+
return Buffer.from(file.value, "base64");
|
|
7720
8139
|
}
|
|
7721
8140
|
async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId, remote) {
|
|
7722
8141
|
const params = new URLSearchParams();
|
|
@@ -7885,6 +8304,19 @@ class ValOpsHttp extends ValOps {
|
|
|
7885
8304
|
patchedSourceFiles: prepared.patchedSourceFiles,
|
|
7886
8305
|
patchedBinaryFilesDescriptors: prepared.patchedBinaryFilesDescriptors,
|
|
7887
8306
|
appliedPatches: prepared.appliedPatches,
|
|
8307
|
+
/*
|
|
8308
|
+
* What each changed module IS after this commit, as DATA, with the
|
|
8309
|
+
* schema it is under.
|
|
8310
|
+
*
|
|
8311
|
+
* The half git cannot give back. Git keeps the `.val.ts`, but that is
|
|
8312
|
+
* code: turning it back into data means parsing it, which is
|
|
8313
|
+
* best-effort and rots across TypeScript, runtime and Val versions -
|
|
8314
|
+
* so a commit that reads today can quietly stop reading later. And
|
|
8315
|
+
* git has no copy at all of the SCHEMA a commit was written under,
|
|
8316
|
+
* which is what showing a module as it was needs once the schema has
|
|
8317
|
+
* moved on.
|
|
8318
|
+
*/
|
|
8319
|
+
modules: prepared.moduleVersions,
|
|
7888
8320
|
commit: this.commitSha,
|
|
7889
8321
|
root: this.root,
|
|
7890
8322
|
filesDirectory,
|
|
@@ -7945,80 +8377,681 @@ class ValOpsHttp extends ValOps {
|
|
|
7945
8377
|
};
|
|
7946
8378
|
}
|
|
7947
8379
|
}
|
|
7948
|
-
}
|
|
7949
8380
|
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
8381
|
+
// #region history
|
|
8382
|
+
|
|
8383
|
+
/**
|
|
8384
|
+
* One GET against the content service, parsed and Result-typed.
|
|
8385
|
+
*
|
|
8386
|
+
* Every history read has the same three failure modes - could not reach the
|
|
8387
|
+
* service, the commit is not there, the answer was not what was expected -
|
|
8388
|
+
* and each of them means something different to a caller deciding whether to
|
|
8389
|
+
* offer a restore. Doing it once here is what keeps that consistent across
|
|
8390
|
+
* the five endpoints.
|
|
8391
|
+
*/
|
|
8392
|
+
async getHistory(path, schema, commitShaForErrors) {
|
|
8393
|
+
let res;
|
|
8394
|
+
try {
|
|
8395
|
+
res = await fetch(`${this.contentUrl}/v1/${this.project}${path}`, {
|
|
8396
|
+
headers: {
|
|
8397
|
+
...this.authHeaders,
|
|
8398
|
+
"Content-Type": "application/json"
|
|
8399
|
+
}
|
|
8400
|
+
});
|
|
8401
|
+
} catch (err) {
|
|
8402
|
+
return fp.result.err({
|
|
8403
|
+
kind: "transport",
|
|
8404
|
+
message: err instanceof Error ? err.message : String(err)
|
|
8405
|
+
});
|
|
8406
|
+
}
|
|
8407
|
+
if (res.status === 404) {
|
|
8408
|
+
return fp.result.err({
|
|
8409
|
+
kind: "commit-not-found",
|
|
8410
|
+
commitSha: commitShaForErrors
|
|
8411
|
+
});
|
|
8412
|
+
}
|
|
8413
|
+
if (!res.ok) {
|
|
8414
|
+
var _res$headers$get4;
|
|
8415
|
+
let message = `${res.status} ${res.statusText}`;
|
|
8416
|
+
if ((_res$headers$get4 = res.headers.get("Content-Type")) !== null && _res$headers$get4 !== void 0 && _res$headers$get4.includes("application/json")) {
|
|
8417
|
+
message = internal.getErrorMessageFromUnknownJson(await res.json(), message);
|
|
7966
8418
|
}
|
|
8419
|
+
// A 5xx from the service reading a record it says it has is a real
|
|
8420
|
+
// failure of that record, not a network problem - keep them apart.
|
|
8421
|
+
return fp.result.err({
|
|
8422
|
+
kind: "archive-unreadable",
|
|
8423
|
+
commitSha: commitShaForErrors,
|
|
8424
|
+
message
|
|
8425
|
+
});
|
|
8426
|
+
}
|
|
8427
|
+
// A 200 is not a promise of JSON: a proxy or gateway in front of the
|
|
8428
|
+
// service answers HTML, and an unguarded `.json()` would reject straight
|
|
8429
|
+
// out of this Result-typed API and 500 the route.
|
|
8430
|
+
let body;
|
|
8431
|
+
try {
|
|
8432
|
+
body = await res.json();
|
|
8433
|
+
} catch (err) {
|
|
8434
|
+
return fp.result.err({
|
|
8435
|
+
kind: "archive-unreadable",
|
|
8436
|
+
commitSha: commitShaForErrors,
|
|
8437
|
+
message: `response was not JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
8438
|
+
});
|
|
8439
|
+
}
|
|
8440
|
+
const parsed = schema.safeParse(body);
|
|
8441
|
+
if (!parsed.success) {
|
|
8442
|
+
return fp.result.err({
|
|
8443
|
+
kind: "archive-unreadable",
|
|
8444
|
+
commitSha: commitShaForErrors,
|
|
8445
|
+
message: `unexpected response shape: ${zodValidationError.fromError(parsed.error)}`
|
|
8446
|
+
});
|
|
8447
|
+
}
|
|
8448
|
+
return fp.result.ok(parsed.data);
|
|
8449
|
+
}
|
|
8450
|
+
async listCommits(branch, options) {
|
|
8451
|
+
const params = new URLSearchParams({
|
|
8452
|
+
branch
|
|
7967
8453
|
});
|
|
7968
|
-
if (
|
|
7969
|
-
|
|
7970
|
-
success: false,
|
|
7971
|
-
message: `Project '${projectName}' not found: verify that the name of the project is correct and that you have access to it.`
|
|
7972
|
-
};
|
|
8454
|
+
if ((options === null || options === void 0 ? void 0 : options.limit) !== undefined) {
|
|
8455
|
+
params.set("limit", String(options.limit));
|
|
7973
8456
|
}
|
|
7974
|
-
if (
|
|
7975
|
-
|
|
7976
|
-
success: false,
|
|
7977
|
-
message: `Failed to get project id: ${response.statusText}`
|
|
7978
|
-
};
|
|
8457
|
+
if ((options === null || options === void 0 ? void 0 : options.cursor) !== undefined) {
|
|
8458
|
+
params.set("cursor", options.cursor);
|
|
7979
8459
|
}
|
|
7980
|
-
const
|
|
7981
|
-
|
|
7982
|
-
|
|
7983
|
-
return {
|
|
7984
|
-
success: false,
|
|
7985
|
-
message: `Failed to parse settings data: ${parseRes.error.message}`
|
|
7986
|
-
};
|
|
8460
|
+
const res = await this.getHistory(`/commits?${params}`, ListCommitsResponse, branch);
|
|
8461
|
+
if (fp.result.isErr(res)) {
|
|
8462
|
+
return res;
|
|
7987
8463
|
}
|
|
7988
|
-
return {
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
};
|
|
7992
|
-
} catch {
|
|
7993
|
-
return {
|
|
7994
|
-
success: false,
|
|
7995
|
-
message: `Failed to get project id. Check network connection and try again.`
|
|
7996
|
-
};
|
|
8464
|
+
return fp.result.ok({
|
|
8465
|
+
commits: res.value.commits,
|
|
8466
|
+
nextCursor: res.value.nextCursor
|
|
8467
|
+
});
|
|
7997
8468
|
}
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
|
|
8001
|
-
|
|
8002
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
|
|
8007
|
-
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8469
|
+
async getCommitPatches(commitSha) {
|
|
8470
|
+
// One request. The endpoint returns the commit's own summary alongside its
|
|
8471
|
+
// patches, because it already has the row - this used to page the commit
|
|
8472
|
+
// LISTING until the sha turned up, which cost up to twenty requests to open
|
|
8473
|
+
// an old commit and could not find one on another branch at all.
|
|
8474
|
+
const patchesRes = await this.getHistory(`/commits/${commitSha}/patches`, CommitPatchesResponse, commitSha);
|
|
8475
|
+
if (fp.result.isErr(patchesRes)) {
|
|
8476
|
+
return patchesRes;
|
|
8477
|
+
}
|
|
8478
|
+
return fp.result.ok({
|
|
8479
|
+
commit: {
|
|
8480
|
+
...patchesRes.value.commit,
|
|
8481
|
+
patchCount: patchesRes.value.patches.length
|
|
8482
|
+
},
|
|
8483
|
+
patches: patchesRes.value.patches.map(patch => ({
|
|
8484
|
+
patchId: patch.patchId,
|
|
8485
|
+
moduleFilePath: patch.path,
|
|
8486
|
+
patch: patch.patch,
|
|
8487
|
+
authorId: patch.authorId,
|
|
8488
|
+
createdAt: patch.createdAt,
|
|
8489
|
+
baseSha: patch.baseSha,
|
|
8490
|
+
coreVersion: patch.coreVersion
|
|
8491
|
+
}))
|
|
8492
|
+
});
|
|
8493
|
+
}
|
|
8494
|
+
async getCommitModules(commitSha, options) {
|
|
8495
|
+
const query = new URLSearchParams();
|
|
8496
|
+
if (options !== null && options !== void 0 && options.asOf) {
|
|
8497
|
+
query.set("as_of", "1");
|
|
8498
|
+
}
|
|
8499
|
+
if (options !== null && options !== void 0 && options.moduleFilePath) {
|
|
8500
|
+
query.set("path", options.moduleFilePath);
|
|
8501
|
+
}
|
|
8502
|
+
const search = query.toString();
|
|
8503
|
+
const res = await this.getHistory(`/commits/${commitSha}/modules${search ? `?${search}` : ""}`, CommitModulesResponse, commitSha);
|
|
8504
|
+
if (fp.result.isErr(res)) {
|
|
8505
|
+
return res;
|
|
8506
|
+
}
|
|
8507
|
+
return fp.result.ok({
|
|
8508
|
+
modules: res.value.modules.map(module => ({
|
|
8509
|
+
...module,
|
|
8510
|
+
moduleFilePath: module.moduleFilePath
|
|
8511
|
+
})),
|
|
8512
|
+
// Absent from an older content server, which only ever answered "what
|
|
8513
|
+
// this commit changed" - and that answer is always whole.
|
|
8514
|
+
complete: res.value.complete ?? !(options !== null && options !== void 0 && options.asOf)
|
|
8515
|
+
});
|
|
8516
|
+
}
|
|
8517
|
+
async getCommitAffectedFiles(commitSha) {
|
|
8518
|
+
const res = await this.getHistory(`/commits/${commitSha}/affected-files`, CommitAffectedFilesResponse, commitSha);
|
|
8519
|
+
if (fp.result.isErr(res)) {
|
|
8520
|
+
return res;
|
|
8521
|
+
}
|
|
8522
|
+
return fp.result.ok(res.value.files);
|
|
8523
|
+
}
|
|
8524
|
+
async getFileAtCommit(commitSha, filePath, remote) {
|
|
8525
|
+
const params = new URLSearchParams({
|
|
8526
|
+
path: filePath
|
|
8527
|
+
});
|
|
8528
|
+
if (remote) {
|
|
8529
|
+
params.set("remote", "true");
|
|
8530
|
+
}
|
|
8531
|
+
let res;
|
|
8532
|
+
try {
|
|
8533
|
+
res = await fetch(`${this.contentUrl}/v1/${this.project}/commits/${commitSha}/file?${params}`, {
|
|
8534
|
+
headers: {
|
|
8535
|
+
...this.authHeaders
|
|
8536
|
+
}
|
|
8537
|
+
});
|
|
8538
|
+
} catch (err) {
|
|
8539
|
+
return fp.result.err({
|
|
8540
|
+
kind: "transport",
|
|
8541
|
+
message: err instanceof Error ? err.message : String(err)
|
|
8542
|
+
});
|
|
8543
|
+
}
|
|
8544
|
+
if (!res.ok) {
|
|
8545
|
+
// A missing file is about the FILE, not the commit: the commit may be
|
|
8546
|
+
// perfectly readable and this one blob gone. Saying "commit not found"
|
|
8547
|
+
// here would send a caller looking in the wrong place.
|
|
8548
|
+
return fp.result.err({
|
|
8549
|
+
kind: "file-unavailable",
|
|
8550
|
+
gitPath: filePath,
|
|
8551
|
+
message: `${res.status} ${res.statusText}`
|
|
8552
|
+
});
|
|
8553
|
+
}
|
|
8554
|
+
return fp.result.ok(Buffer.from(await res.arrayBuffer()));
|
|
8555
|
+
}
|
|
8556
|
+
// #endregion history
|
|
8557
|
+
}
|
|
8558
|
+
|
|
8559
|
+
/**
|
|
8560
|
+
* Everything that can go wrong reading, replaying or restoring history.
|
|
8561
|
+
*
|
|
8562
|
+
* There is a lot of it, which is the point of making it one closed union rather
|
|
8563
|
+
* than strings: reading a commit means reaching a content host, finding the
|
|
8564
|
+
* commit, reading what was recorded for each of its modules, and asking whether
|
|
8565
|
+
* this version of Val can make sense of the schema it was stored under. Each of
|
|
8566
|
+
* those fails differently, and a caller deciding what to SHOW needs to know
|
|
8567
|
+
* which.
|
|
8568
|
+
*
|
|
8569
|
+
* Every member has a producer. The union once carried five more - a module
|
|
8570
|
+
* removed from the project, an op that would not replay, a value that no longer
|
|
8571
|
+
* fits today's schema, a field the schema no longer defines, and ops from a
|
|
8572
|
+
* core version known to replay wrongly. All five belonged to reconstructing a
|
|
8573
|
+
* commit by replaying patches against a parsed source; storing the data ended
|
|
8574
|
+
* that, and a member nothing can produce is a state the Studio writes handling
|
|
8575
|
+
* for and never sees.
|
|
8576
|
+
*
|
|
8577
|
+
* ## The rule
|
|
8578
|
+
*
|
|
8579
|
+
* Whole-commit failures are the `err` channel. Per-module and per-patch
|
|
8580
|
+
* failures ride along inside the `ok` payload.
|
|
8581
|
+
*
|
|
8582
|
+
* A commit that cannot be found or read at all has nothing to show. But one
|
|
8583
|
+
* unparseable module out of ten does not make the other nine unreadable, and
|
|
8584
|
+
* collapsing the whole view because of it would hide exactly the information
|
|
8585
|
+
* someone needs to see - that this module is the broken one.
|
|
8586
|
+
*/
|
|
8587
|
+
|
|
8588
|
+
function historyErrorMessage(error) {
|
|
8589
|
+
switch (error.kind) {
|
|
8590
|
+
case "commit-not-found":
|
|
8591
|
+
return `No commit ${error.commitSha} created by Val in this project`;
|
|
8592
|
+
case "archive-unreadable":
|
|
8593
|
+
return `Could not read the stored record of commit ${error.commitSha}: ${error.message}`;
|
|
8594
|
+
case "source-unavailable":
|
|
8595
|
+
return `No stored source for ${error.moduleFilePath} at this commit (it predates history being recorded)`;
|
|
8596
|
+
case "schema-unreadable":
|
|
8597
|
+
return `The schema stored for ${error.moduleFilePath} at this commit is not one this version of Val can read: ${error.message}`;
|
|
8598
|
+
case "file-unavailable":
|
|
8599
|
+
return `Could not read ${error.gitPath} at this commit: ${error.message}`;
|
|
8600
|
+
case "not-supported-in-fs-mode":
|
|
8601
|
+
return "History is only available for projects connected to Val's content service";
|
|
8602
|
+
case "transport":
|
|
8603
|
+
return `Could not reach the content service: ${error.message}`;
|
|
8604
|
+
}
|
|
8605
|
+
}
|
|
8606
|
+
|
|
8607
|
+
/**
|
|
8608
|
+
* Everything the content service knows about one commit, in one step.
|
|
8609
|
+
*
|
|
8610
|
+
* Three endpoints, requested together rather than in sequence: they are
|
|
8611
|
+
* independent, they all resolve from the same stored record, and the round
|
|
8612
|
+
* trips are what a user waits through when opening a commit.
|
|
8613
|
+
*
|
|
8614
|
+
* The commit itself comes from the patches call, which is the one that fails
|
|
8615
|
+
* usefully when the commit does not exist.
|
|
8616
|
+
*/
|
|
8617
|
+
async function fetchCommitRecord(ops, commitSha) {
|
|
8618
|
+
const [patchesRes, modulesRes, filesRes] = await Promise.all([ops.getCommitPatches(commitSha), ops.getCommitModules(commitSha), ops.getCommitAffectedFiles(commitSha)]);
|
|
8619
|
+
if (fp.result.isErr(patchesRes)) {
|
|
8620
|
+
return patchesRes;
|
|
8621
|
+
}
|
|
8622
|
+
if (fp.result.isErr(modulesRes)) {
|
|
8623
|
+
return modulesRes;
|
|
8624
|
+
}
|
|
8625
|
+
if (fp.result.isErr(filesRes)) {
|
|
8626
|
+
return filesRes;
|
|
8627
|
+
}
|
|
8628
|
+
return fp.result.ok({
|
|
8629
|
+
commit: patchesRes.value.commit,
|
|
8630
|
+
patches: patchesRes.value.patches,
|
|
8631
|
+
modules: modulesRes.value.modules,
|
|
8632
|
+
affectedFiles: filesRes.value
|
|
8633
|
+
});
|
|
8634
|
+
}
|
|
8635
|
+
|
|
8636
|
+
/**
|
|
8637
|
+
* The `*.val.json` entries a commit touched, read from git at that commit.
|
|
8638
|
+
*
|
|
8639
|
+
* Not stored in the commit record, unlike the `.val.ts` sources: an entry is a
|
|
8640
|
+
* plain JSON file that git has at every commit, so a second copy could only
|
|
8641
|
+
* drift from the first. A `.val.ts` is different - it is what a restore
|
|
8642
|
+
* replays patches against, and reading it from git would make every look at
|
|
8643
|
+
* history depend on the repository still being there.
|
|
8644
|
+
*
|
|
8645
|
+
* Reads at the commit itself rather than its parent: an entry's content AT a
|
|
8646
|
+
* commit is what that commit produced, which is the "after" side. The "before"
|
|
8647
|
+
* side is the same file at the parent commit, and a caller that wants it asks
|
|
8648
|
+
* for the parent.
|
|
8649
|
+
*/
|
|
8650
|
+
async function resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles) {
|
|
8651
|
+
const entryPaths = affectedFiles.filter(file => file.kind === "json-entry")
|
|
8652
|
+
// A deleted entry has no content at this commit; asking for it would be a
|
|
8653
|
+
// guaranteed 404 reported as a failure, which is noise rather than news.
|
|
8654
|
+
.filter(file => file.change !== "deleted").map(file => file.gitPath);
|
|
8655
|
+
if (entryPaths.length === 0) {
|
|
8656
|
+
return fp.result.ok({
|
|
8657
|
+
entries: {},
|
|
8658
|
+
failures: []
|
|
8659
|
+
});
|
|
8660
|
+
}
|
|
8661
|
+
const entries = {};
|
|
8662
|
+
const failures = [];
|
|
8663
|
+
const fetched = await Promise.all(entryPaths.map(async gitPath => ({
|
|
8664
|
+
gitPath,
|
|
8665
|
+
res: await ops.getFileAtCommit(commitSha, gitPath, false)
|
|
8666
|
+
})));
|
|
8667
|
+
for (const {
|
|
8668
|
+
gitPath,
|
|
8669
|
+
res
|
|
8670
|
+
} of fetched) {
|
|
8671
|
+
if (fp.result.isErr(res)) {
|
|
8672
|
+
failures.push({
|
|
8673
|
+
kind: "file-unavailable",
|
|
8674
|
+
gitPath,
|
|
8675
|
+
message: res.error.kind === "file-unavailable" ? res.error.message : `could not read entry: ${res.error.kind}`
|
|
8676
|
+
});
|
|
8677
|
+
continue;
|
|
8678
|
+
}
|
|
8679
|
+
try {
|
|
8680
|
+
entries[gitPath] = JSON.parse(res.value.toString("utf-8"));
|
|
8681
|
+
} catch (err) {
|
|
8682
|
+
failures.push({
|
|
8683
|
+
kind: "file-unavailable",
|
|
8684
|
+
gitPath,
|
|
8685
|
+
message: `not valid JSON at this commit: ${err instanceof Error ? err.message : String(err)}`
|
|
8686
|
+
});
|
|
8687
|
+
}
|
|
8688
|
+
}
|
|
8689
|
+
return fp.result.ok({
|
|
8690
|
+
entries,
|
|
8691
|
+
failures
|
|
8692
|
+
});
|
|
8693
|
+
}
|
|
8694
|
+
|
|
8695
|
+
/**
|
|
8696
|
+
* Turn a commit's binary files into descriptors, WITHOUT fetching any of them.
|
|
8697
|
+
*
|
|
8698
|
+
* Pure, and that is the whole design. Showing that a commit changed six images
|
|
8699
|
+
* needs six rows, not six downloads; only an `<img>` that actually mounts pays
|
|
8700
|
+
* for its bytes. `url` points at the app's own history file route, which is
|
|
8701
|
+
* immutable for a given commit, so the browser's HTTP cache handles flipping
|
|
8702
|
+
* between commits with no JS cache at all.
|
|
8703
|
+
*/
|
|
8704
|
+
function describeBinaryFilesAtCommit(commitSha, affectedFiles, /** Where the app serves `/api/val` from, e.g. "/api/val". */
|
|
8705
|
+
apiBasePath) {
|
|
8706
|
+
const refs = [];
|
|
8707
|
+
for (const file of affectedFiles) {
|
|
8708
|
+
if (file.kind === "remote-binary") {
|
|
8709
|
+
refs.push({
|
|
8710
|
+
gitPath: file.ref,
|
|
8711
|
+
change: file.change,
|
|
8712
|
+
remote: true,
|
|
8713
|
+
url: historyFileUrl(apiBasePath, commitSha, file.ref, true)
|
|
8714
|
+
});
|
|
8715
|
+
continue;
|
|
8716
|
+
}
|
|
8717
|
+
if (file.kind !== "binary") {
|
|
8718
|
+
continue;
|
|
8719
|
+
}
|
|
8720
|
+
refs.push({
|
|
8721
|
+
gitPath: file.gitPath,
|
|
8722
|
+
change: file.change,
|
|
8723
|
+
remote: false,
|
|
8724
|
+
url: historyFileUrl(apiBasePath, commitSha, file.gitPath, false)
|
|
8725
|
+
});
|
|
8726
|
+
}
|
|
8727
|
+
return refs;
|
|
8728
|
+
}
|
|
8729
|
+
function historyFileUrl(apiBasePath, commitSha, filePath, remote) {
|
|
8730
|
+
const params = new URLSearchParams({
|
|
8731
|
+
commit_sha: commitSha,
|
|
8732
|
+
path: filePath
|
|
8733
|
+
});
|
|
8734
|
+
if (remote) {
|
|
8735
|
+
params.set("remote", "true");
|
|
8736
|
+
}
|
|
8737
|
+
return `${apiBasePath}/history/files?${params.toString()}`;
|
|
8738
|
+
}
|
|
8739
|
+
|
|
8740
|
+
/**
|
|
8741
|
+
* The paths a commit's ops touched, from the ops themselves.
|
|
8742
|
+
*
|
|
8743
|
+
* The alternative was reconstructing the module's previous state and diffing it
|
|
8744
|
+
* against the new one - which needed the pre-commit `.val.ts`, a static parse
|
|
8745
|
+
* of it, and a replay of every op, three fragile steps to rediscover something
|
|
8746
|
+
* the ops already say outright. An op carries the path it applies to. That is
|
|
8747
|
+
* the answer.
|
|
8748
|
+
*
|
|
8749
|
+
* Deduplicated and returned in first-touched order, because a module edited
|
|
8750
|
+
* eight times in one publish should list a field once, where it first changed.
|
|
8751
|
+
*/
|
|
8752
|
+
function changedPathsOf(moduleFilePath, patches) {
|
|
8753
|
+
const seen = new Set();
|
|
8754
|
+
const paths = [];
|
|
8755
|
+
for (const {
|
|
8756
|
+
patch
|
|
8757
|
+
} of patches) {
|
|
8758
|
+
for (const op of patch) {
|
|
8759
|
+
// A `file` op names a file, not a place in the source; the `replace` that
|
|
8760
|
+
// points the field at it is in the same patch and is the real change.
|
|
8761
|
+
if (op.op === "file") {
|
|
8762
|
+
continue;
|
|
8763
|
+
}
|
|
8764
|
+
/*
|
|
8765
|
+
* The canonical patch-path-to-source-path conversion, not a hand-rolled
|
|
8766
|
+
* join.
|
|
8767
|
+
*
|
|
8768
|
+
* `createValPathOfItem` JSON-quotes the ONE key it is handed, so joining
|
|
8769
|
+
* the segments with "." first produced `?p="teddy.name"` — a single
|
|
8770
|
+
* segment with a dot in its name — rather than `?p="teddy"."name"`. Only
|
|
8771
|
+
* top-level fields came out right, a record key containing a dot was
|
|
8772
|
+
* split in the other direction, and an op at the module root became
|
|
8773
|
+
* `?p=""`.
|
|
8774
|
+
*
|
|
8775
|
+
* `patchPathToModulePath` is what the rest of the Studio produces and
|
|
8776
|
+
* parses, integer segments left unquoted (`"items".2."label"`).
|
|
8777
|
+
*/
|
|
8778
|
+
const sourcePath = op.path.length === 0 ? moduleFilePath : core.Internal.joinModuleFilePathAndModulePath(moduleFilePath, core.Internal.patchPathToModulePath(op.path));
|
|
8779
|
+
if (!seen.has(sourcePath)) {
|
|
8780
|
+
seen.add(sourcePath);
|
|
8781
|
+
paths.push(sourcePath);
|
|
8782
|
+
}
|
|
8783
|
+
}
|
|
8784
|
+
}
|
|
8785
|
+
return paths;
|
|
8786
|
+
}
|
|
8787
|
+
|
|
8788
|
+
/**
|
|
8789
|
+
* Reconstruct one commit: how each module looked before it, and after it.
|
|
8790
|
+
*
|
|
8791
|
+
* Deliberately says NOTHING about the current source or the current schema. For
|
|
8792
|
+
* a given commit sha this can never change, which is what lets its result be
|
|
8793
|
+
* cached forever and what makes flipping between commits cheap. The comparison
|
|
8794
|
+
* against today is `compareWithCurrent`, and it is deliberately the cheap half.
|
|
8795
|
+
*
|
|
8796
|
+
* Nothing is parsed and nothing is replayed: the module's data was recorded at
|
|
8797
|
+
* the commit, so reading it back is a read. What IS done here is validating the
|
|
8798
|
+
* stored schema against this version of Val, because that is the one thing the
|
|
8799
|
+
* content service could not check for us.
|
|
8800
|
+
*
|
|
8801
|
+
* Failures are collected per module, not thrown - one module this Val cannot
|
|
8802
|
+
* read leaves the other nine readable, and knowing WHICH one is the thing
|
|
8803
|
+
* someone opening history actually needs. Only a commit that cannot be read at
|
|
8804
|
+
* all is an `err`.
|
|
8805
|
+
*/
|
|
8806
|
+
async function getHistoricalPatchSet(ops, commitSha, options) {
|
|
8807
|
+
const recordRes = await fetchCommitRecord(ops, commitSha);
|
|
8808
|
+
if (fp.result.isErr(recordRes)) {
|
|
8809
|
+
return recordRes;
|
|
8810
|
+
}
|
|
8811
|
+
const {
|
|
8812
|
+
commit,
|
|
8813
|
+
patches,
|
|
8814
|
+
modules: stored,
|
|
8815
|
+
affectedFiles
|
|
8816
|
+
} = recordRes.value;
|
|
8817
|
+
const warnings = [];
|
|
8818
|
+
|
|
8819
|
+
// Group the commit's patches by module. Not to replay them - the stored
|
|
8820
|
+
// source already IS the result - but because the ops name the paths this
|
|
8821
|
+
// commit touched, which is what the view highlights.
|
|
8822
|
+
const patchesByModule = new Map();
|
|
8823
|
+
for (const patch of patches) {
|
|
8824
|
+
const existing = patchesByModule.get(patch.moduleFilePath) ?? [];
|
|
8825
|
+
existing.push({
|
|
8826
|
+
patchId: patch.patchId,
|
|
8827
|
+
coreVersion: patch.coreVersion,
|
|
8828
|
+
// The wire type is `unknown` because the content service does not know
|
|
8829
|
+
// Val's patch type; it has been validated on the way out of the archive.
|
|
8830
|
+
patch: patch.patch
|
|
8831
|
+
});
|
|
8832
|
+
patchesByModule.set(patch.moduleFilePath, existing);
|
|
8833
|
+
}
|
|
8834
|
+
const storedByPath = new Map(stored.map(module => [module.moduleFilePath, module]));
|
|
8835
|
+
|
|
8836
|
+
// Every module this commit touched: one it patched, and one we hold a stored
|
|
8837
|
+
// version of. Usually the same set - but a commit made by a Val too old to
|
|
8838
|
+
// send its modules has the first and not the second, and that has to read as
|
|
8839
|
+
// `source-unavailable` rather than as "no modules changed".
|
|
8840
|
+
const touched = new Set([...patchesByModule.keys(), ...storedByPath.keys()]);
|
|
8841
|
+
const modules = {};
|
|
8842
|
+
for (const moduleFilePath of touched) {
|
|
8843
|
+
const failures = [];
|
|
8844
|
+
const modulePatches = patchesByModule.get(moduleFilePath) ?? [];
|
|
8845
|
+
const patchIds = modulePatches.map(patch => patch.patchId);
|
|
8846
|
+
const changedPaths = changedPathsOf(moduleFilePath, modulePatches);
|
|
8847
|
+
const version = storedByPath.get(moduleFilePath);
|
|
8848
|
+
if (version === undefined || version.unavailable) {
|
|
8849
|
+
failures.push({
|
|
8850
|
+
kind: "source-unavailable",
|
|
8851
|
+
moduleFilePath
|
|
8852
|
+
});
|
|
8853
|
+
modules[moduleFilePath] = {
|
|
8854
|
+
source: null,
|
|
8855
|
+
schema: null,
|
|
8856
|
+
patchIds,
|
|
8857
|
+
changedPaths,
|
|
8858
|
+
failures
|
|
8859
|
+
};
|
|
8860
|
+
continue;
|
|
8861
|
+
}
|
|
8862
|
+
|
|
8863
|
+
/*
|
|
8864
|
+
* The schema is validated here, and nowhere earlier.
|
|
8865
|
+
*
|
|
8866
|
+
* It was stored as written and served back opaquely, so this is the first
|
|
8867
|
+
* place that knows what a schema is supposed to look like. A failure is
|
|
8868
|
+
* NOT damage and not anyone's mistake - Val's schema format is allowed to
|
|
8869
|
+
* move, and an older project opened in a newer Val (or the reverse) lands
|
|
8870
|
+
* exactly here. The module reads as one this version cannot show, and the
|
|
8871
|
+
* rest of the commit is unaffected.
|
|
8872
|
+
*/
|
|
8873
|
+
const schemaRes = internal.SerializedSchema.safeParse(version.schema);
|
|
8874
|
+
if (!schemaRes.success) {
|
|
8875
|
+
failures.push({
|
|
8876
|
+
kind: "schema-unreadable",
|
|
8877
|
+
moduleFilePath,
|
|
8878
|
+
message: zodValidationError.fromError(schemaRes.error).toString()
|
|
8879
|
+
});
|
|
8880
|
+
modules[moduleFilePath] = {
|
|
8881
|
+
source: version.source,
|
|
8882
|
+
schema: null,
|
|
8883
|
+
patchIds,
|
|
8884
|
+
changedPaths,
|
|
8885
|
+
failures
|
|
8886
|
+
};
|
|
8887
|
+
continue;
|
|
8888
|
+
}
|
|
8889
|
+
modules[moduleFilePath] = {
|
|
8890
|
+
source: version.source,
|
|
8891
|
+
schema: schemaRes.data,
|
|
8892
|
+
patchIds,
|
|
8893
|
+
changedPaths,
|
|
8894
|
+
failures
|
|
8895
|
+
};
|
|
8896
|
+
}
|
|
8897
|
+
const entriesRes = await resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles);
|
|
8898
|
+
let jsonEntries = {};
|
|
8899
|
+
if (fp.result.isErr(entriesRes)) {
|
|
8900
|
+
// Entry contents are supporting detail, not the commit. Losing them should
|
|
8901
|
+
// narrow what can be shown, not hide the commit entirely.
|
|
8902
|
+
warnings.push(entriesRes.error);
|
|
8903
|
+
} else {
|
|
8904
|
+
jsonEntries = entriesRes.value.entries;
|
|
8905
|
+
warnings.push(...entriesRes.value.failures);
|
|
8906
|
+
}
|
|
8907
|
+
return fp.result.ok({
|
|
8908
|
+
commit,
|
|
8909
|
+
modules,
|
|
8910
|
+
patches,
|
|
8911
|
+
jsonEntries,
|
|
8912
|
+
binaryFiles: describeBinaryFilesAtCommit(commitSha, affectedFiles, (options === null || options === void 0 ? void 0 : options.apiBasePath) ?? "/api/val"),
|
|
8913
|
+
warnings
|
|
8914
|
+
});
|
|
8915
|
+
}
|
|
8916
|
+
|
|
8917
|
+
/**
|
|
8918
|
+
* One module, as of a commit — including a commit that never touched it.
|
|
8919
|
+
*
|
|
8920
|
+
* `getHistoricalPatchSet` answers "what did this commit change", which is what
|
|
8921
|
+
* a commit IS. This answers "how did this module look at that point", which is
|
|
8922
|
+
* what someone comparing two panes is asking as soon as they navigate off the
|
|
8923
|
+
* changed set — and without it the history pane can only show a handful of
|
|
8924
|
+
* modules per commit.
|
|
8925
|
+
*
|
|
8926
|
+
* `null` means history has no record at or before that commit: the module was
|
|
8927
|
+
* last edited before recording started, or never. That is deliberately NOT the
|
|
8928
|
+
* same as a module the commit deleted, which is a recorded fact with a source
|
|
8929
|
+
* of `null`, and not the same as an error.
|
|
8930
|
+
*/
|
|
8931
|
+
async function getModuleAtCommit(ops, commitSha, moduleFilePath) {
|
|
8932
|
+
const res = await ops.getCommitModules(commitSha, {
|
|
8933
|
+
asOf: true,
|
|
8934
|
+
moduleFilePath
|
|
8935
|
+
});
|
|
8936
|
+
if (fp.result.isErr(res)) {
|
|
8937
|
+
return res;
|
|
8938
|
+
}
|
|
8939
|
+
const version = res.value.modules.find(module => module.moduleFilePath === moduleFilePath);
|
|
8940
|
+
if (version === undefined) {
|
|
8941
|
+
return fp.result.ok(null);
|
|
8942
|
+
}
|
|
8943
|
+
if (version.unavailable) {
|
|
8944
|
+
return fp.result.ok({
|
|
8945
|
+
source: null,
|
|
8946
|
+
schema: null,
|
|
8947
|
+
patchIds: [],
|
|
8948
|
+
changedPaths: [],
|
|
8949
|
+
failures: [{
|
|
8950
|
+
kind: "source-unavailable",
|
|
8951
|
+
moduleFilePath
|
|
8952
|
+
}]
|
|
8953
|
+
});
|
|
8954
|
+
}
|
|
8955
|
+
// Validated here, not at the transport boundary, for the same reason as in
|
|
8956
|
+
// `getHistoricalPatchSet`: a schema this Val cannot read is one module it
|
|
8957
|
+
// cannot show, not a failed request.
|
|
8958
|
+
const schemaRes = internal.SerializedSchema.safeParse(version.schema);
|
|
8959
|
+
if (!schemaRes.success) {
|
|
8960
|
+
return fp.result.ok({
|
|
8961
|
+
source: version.source,
|
|
8962
|
+
schema: null,
|
|
8963
|
+
patchIds: [],
|
|
8964
|
+
changedPaths: [],
|
|
8965
|
+
failures: [{
|
|
8966
|
+
kind: "schema-unreadable",
|
|
8967
|
+
moduleFilePath,
|
|
8968
|
+
message: zodValidationError.fromError(schemaRes.error).toString()
|
|
8969
|
+
}]
|
|
8970
|
+
});
|
|
8971
|
+
}
|
|
8972
|
+
return fp.result.ok({
|
|
8973
|
+
source: version.source,
|
|
8974
|
+
schema: schemaRes.data,
|
|
8975
|
+
// Empty, and correct: this module may well have been changed by an EARLIER
|
|
8976
|
+
// commit than the one asked about, and those are that commit's patches.
|
|
8977
|
+
patchIds: [],
|
|
8978
|
+
changedPaths: [],
|
|
8979
|
+
failures: []
|
|
8980
|
+
});
|
|
8981
|
+
}
|
|
8982
|
+
|
|
8983
|
+
const host = process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
|
|
8984
|
+
const SettingsSchema = z.z.object({
|
|
8985
|
+
publicProjectId: z.z.string(),
|
|
8986
|
+
remoteFileBuckets: z.z.array(z.z.object({
|
|
8987
|
+
bucket: z.z.string()
|
|
8988
|
+
}))
|
|
8989
|
+
});
|
|
8990
|
+
async function getSettings(projectName, auth) {
|
|
8991
|
+
try {
|
|
8992
|
+
const response = await fetch(`${host}/v1/${projectName}/settings`, {
|
|
8993
|
+
headers: "pat" in auth ? {
|
|
8994
|
+
"x-val-pat": auth.pat,
|
|
8995
|
+
"Content-Type": "application/json"
|
|
8996
|
+
} : {
|
|
8997
|
+
Authorization: `Bearer ${auth.apiKey}`,
|
|
8998
|
+
"Content-Type": "application/json"
|
|
8999
|
+
}
|
|
9000
|
+
});
|
|
9001
|
+
if (response.status === 404) {
|
|
9002
|
+
return {
|
|
9003
|
+
success: false,
|
|
9004
|
+
message: `Project '${projectName}' not found: verify that the name of the project is correct and that you have access to it.`
|
|
9005
|
+
};
|
|
9006
|
+
}
|
|
9007
|
+
if (response.status !== 200) {
|
|
9008
|
+
return {
|
|
9009
|
+
success: false,
|
|
9010
|
+
message: `Failed to get project id: ${response.statusText}`
|
|
9011
|
+
};
|
|
9012
|
+
}
|
|
9013
|
+
const json = await response.json();
|
|
9014
|
+
const parseRes = SettingsSchema.safeParse(json);
|
|
9015
|
+
if (!parseRes.success) {
|
|
9016
|
+
return {
|
|
9017
|
+
success: false,
|
|
9018
|
+
message: `Failed to parse settings data: ${parseRes.error.message}`
|
|
9019
|
+
};
|
|
9020
|
+
}
|
|
9021
|
+
return {
|
|
9022
|
+
success: true,
|
|
9023
|
+
data: parseRes.data
|
|
9024
|
+
};
|
|
9025
|
+
} catch {
|
|
9026
|
+
return {
|
|
9027
|
+
success: false,
|
|
9028
|
+
message: `Failed to get project id. Check network connection and try again.`
|
|
9029
|
+
};
|
|
9030
|
+
}
|
|
9031
|
+
}
|
|
9032
|
+
|
|
9033
|
+
/**
|
|
9034
|
+
* Resolving how Val is configured, and building the data layer from it.
|
|
9035
|
+
*
|
|
9036
|
+
* Both live here rather than inside `createValApiRouter` because the MCP tool
|
|
9037
|
+
* registry needs exactly the same answers: which mode we are in, which
|
|
9038
|
+
* credential to use, and which `ValOps` implementation that implies. Two copies
|
|
9039
|
+
* of this would drift, and the failure would be quiet — a registry that decides
|
|
9040
|
+
* it is in fs mode while the Studio decides it is in proxy mode reads different
|
|
9041
|
+
* content from the same project.
|
|
9042
|
+
*
|
|
9043
|
+
* The type-only import from `./ValRouter` is deliberate: it keeps `ValApiOptions`
|
|
9044
|
+
* where its documentation lives without creating a runtime cycle.
|
|
9045
|
+
*
|
|
9046
|
+
* The credential-bearing URL check at the bottom of this file lives here for the
|
|
9047
|
+
* same reason: it is a check on the URLs `initHandlerOptions` resolves, and its
|
|
9048
|
+
* only caller is that function. Leaving it in `./ValRouter` would have meant
|
|
9049
|
+
* importing it back from there, which is the runtime cycle the paragraph above
|
|
9050
|
+
* exists to avoid.
|
|
9051
|
+
*/
|
|
9052
|
+
|
|
9053
|
+
const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
|
|
9054
|
+
|
|
8022
9055
|
/**
|
|
8023
9056
|
* Resolve options plus environment into a concrete {@link ValServerConfig}.
|
|
8024
9057
|
*
|
|
@@ -8102,31 +9135,39 @@ async function initHandlerOptions(route, opts, config) {
|
|
|
8102
9135
|
/**
|
|
8103
9136
|
* Build the data layer a {@link ValServerConfig} calls for.
|
|
8104
9137
|
*
|
|
8105
|
-
*
|
|
8106
|
-
*
|
|
8107
|
-
*
|
|
8108
|
-
*
|
|
9138
|
+
* The http backend always sees the app's own API key. That is what the Studio
|
|
9139
|
+
* wants — there the app has already verified a session cookie and is acting on
|
|
9140
|
+
* the user's behalf under its own authority — and it is now the only shape:
|
|
9141
|
+
* every caller that reaches here has been authenticated by the app itself, so
|
|
9142
|
+
* there is no request left on which the app is a pipe rather than an authority.
|
|
8109
9143
|
*
|
|
8110
|
-
*
|
|
8111
|
-
* user's personal access token
|
|
8112
|
-
* what the caller
|
|
8113
|
-
*
|
|
8114
|
-
*
|
|
8115
|
-
*
|
|
8116
|
-
*
|
|
9144
|
+
* This took a parameter for the other case: a caller acting for a user it had
|
|
9145
|
+
* *not* authenticated passed that user's personal access token, and the backend
|
|
9146
|
+
* decided what the caller could do. `ValOpsHttp` still accepts such a token —
|
|
9147
|
+
* the CLI's `debug` command uses the developer's own from `val login` — but no
|
|
9148
|
+
* server request builds one any more, because a request the app cannot
|
|
9149
|
+
* authenticate is now refused instead of relayed.
|
|
9150
|
+
*
|
|
9151
|
+
* What has not changed is why the API key must never stand in for a credential
|
|
9152
|
+
* that was merely *absent*: it works, and it works for every project the key
|
|
9153
|
+
* can reach, including the ones the caller cannot. Callers are refused for a
|
|
9154
|
+
* missing credential well before this point.
|
|
8117
9155
|
*/
|
|
8118
|
-
function createValOps(valModules, options
|
|
9156
|
+
function createValOps(valModules, options) {
|
|
8119
9157
|
if (options.mode === "fs") {
|
|
8120
9158
|
// No credential in fs mode: this reads and writes the developer's own
|
|
8121
|
-
// working tree, and there is no backend to authenticate to. A
|
|
8122
|
-
//
|
|
9159
|
+
// working tree, and there is no backend to authenticate to. A credential
|
|
9160
|
+
// that arrives for such a project is not ignored quietly — the caller is
|
|
9161
|
+
// told, by `createValTools` when the project is configured for oauth and by
|
|
9162
|
+
// `initValMcp` when it is not, since with no issuer there is no verified
|
|
9163
|
+
// credential left for the registry to see.
|
|
8123
9164
|
return new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
|
|
8124
9165
|
formatter: options.formatter,
|
|
8125
9166
|
config: options.config
|
|
8126
9167
|
});
|
|
8127
9168
|
}
|
|
8128
9169
|
if (options.mode === "http") {
|
|
8129
|
-
return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch,
|
|
9170
|
+
return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, {
|
|
8130
9171
|
apiKey: options.apiKey
|
|
8131
9172
|
}, valModules, {
|
|
8132
9173
|
formatter: options.formatter,
|
|
@@ -8748,90 +9789,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8748
9789
|
};
|
|
8749
9790
|
}
|
|
8750
9791
|
},
|
|
8751
|
-
"/session": {
|
|
8752
|
-
GET: async req => {
|
|
8753
|
-
const cookies = req.cookies;
|
|
8754
|
-
if (serverOps instanceof ValOpsFS) {
|
|
8755
|
-
return {
|
|
8756
|
-
status: 200,
|
|
8757
|
-
json: {
|
|
8758
|
-
mode: "local",
|
|
8759
|
-
enabled: await callbacks.isEnabled()
|
|
8760
|
-
}
|
|
8761
|
-
};
|
|
8762
|
-
}
|
|
8763
|
-
if (!options.project) {
|
|
8764
|
-
return {
|
|
8765
|
-
status: 500,
|
|
8766
|
-
json: {
|
|
8767
|
-
message: "Project is not set"
|
|
8768
|
-
}
|
|
8769
|
-
};
|
|
8770
|
-
}
|
|
8771
|
-
if (!options.valSecret) {
|
|
8772
|
-
return {
|
|
8773
|
-
status: 500,
|
|
8774
|
-
json: {
|
|
8775
|
-
message: "Secret is not set"
|
|
8776
|
-
}
|
|
8777
|
-
};
|
|
8778
|
-
}
|
|
8779
|
-
return withAuth(options.valSecret, cookies, "session", async data => {
|
|
8780
|
-
if (!options.valBuildUrl) {
|
|
8781
|
-
return {
|
|
8782
|
-
status: 500,
|
|
8783
|
-
json: {
|
|
8784
|
-
message: "Val is not correctly setup. Build url is missing"
|
|
8785
|
-
}
|
|
8786
|
-
};
|
|
8787
|
-
}
|
|
8788
|
-
const url = new URL(`/api/val/${options.project}/auth/session`, options.valBuildUrl);
|
|
8789
|
-
const fetchRes = await fetch(url, {
|
|
8790
|
-
headers: getAuthHeaders(data.token, "application/json")
|
|
8791
|
-
});
|
|
8792
|
-
if (fetchRes.status === 200) {
|
|
8793
|
-
const json = z.z.object({
|
|
8794
|
-
member_role: z.z.union([z.z.literal("owner"), z.z.literal("developer"), z.z.literal("editor")]).optional(),
|
|
8795
|
-
id: z.z.string(),
|
|
8796
|
-
full_name: z.z.string().optional(),
|
|
8797
|
-
username: z.z.string().optional(),
|
|
8798
|
-
avatar_url: z.z.string().optional()
|
|
8799
|
-
}).safeParse(await fetchRes.json());
|
|
8800
|
-
if (json.success) {
|
|
8801
|
-
return {
|
|
8802
|
-
status: fetchRes.status,
|
|
8803
|
-
json: {
|
|
8804
|
-
mode: "proxy",
|
|
8805
|
-
enabled: await callbacks.isEnabled(),
|
|
8806
|
-
...json.data
|
|
8807
|
-
}
|
|
8808
|
-
};
|
|
8809
|
-
} else {
|
|
8810
|
-
const message = internal.getErrorMessageFromUnknownJson(json, "Could not parse session response. Unexpected error (no error message). Status: " + fetchRes.status);
|
|
8811
|
-
return {
|
|
8812
|
-
status: 500,
|
|
8813
|
-
json: {
|
|
8814
|
-
message: message,
|
|
8815
|
-
...json
|
|
8816
|
-
}
|
|
8817
|
-
};
|
|
8818
|
-
}
|
|
8819
|
-
} else {
|
|
8820
|
-
const json = z.z.object({
|
|
8821
|
-
message: z.z.string()
|
|
8822
|
-
}).safeParse(await fetchRes.json());
|
|
8823
|
-
const message = internal.getErrorMessageFromUnknownJson(json, "Unknown error");
|
|
8824
|
-
return {
|
|
8825
|
-
status: fetchRes.status,
|
|
8826
|
-
json: {
|
|
8827
|
-
message: message,
|
|
8828
|
-
...json
|
|
8829
|
-
}
|
|
8830
|
-
};
|
|
8831
|
-
}
|
|
8832
|
-
});
|
|
8833
|
-
}
|
|
8834
|
-
},
|
|
8835
9792
|
"/logout": {
|
|
8836
9793
|
GET: async req => {
|
|
8837
9794
|
const query = req.query;
|
|
@@ -11045,11 +12002,13 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
11045
12002
|
}
|
|
11046
12003
|
};
|
|
11047
12004
|
}
|
|
11048
|
-
|
|
11049
|
-
|
|
11050
|
-
|
|
12005
|
+
// Bytes straight through. This used to base64 the buffer,
|
|
12006
|
+
// wrap it in a data: URL, and hand that to a method whose first
|
|
12007
|
+
// act was to unwrap it again - ceremony to satisfy a convention
|
|
12008
|
+
// that no longer exists.
|
|
12009
|
+
const bytes = Buffer.from(await binaryRes.arrayBuffer());
|
|
11051
12010
|
const type = file.metadata.mimeType.startsWith("image/") ? "image" : "file";
|
|
11052
|
-
const saveRes = await serverOps.
|
|
12011
|
+
const saveRes = await serverOps.saveBinaryFileFromPatch(file.filePath, req.body.parentRef, req.body.patchId, bytes, file.metadata.mimeType, type, file.metadata);
|
|
11053
12012
|
if (saveRes.error) {
|
|
11054
12013
|
return {
|
|
11055
12014
|
status: 500,
|
|
@@ -11177,6 +12136,137 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
11177
12136
|
}
|
|
11178
12137
|
},
|
|
11179
12138
|
//#region files
|
|
12139
|
+
// #region history
|
|
12140
|
+
"/history/commits": {
|
|
12141
|
+
GET: async req => {
|
|
12142
|
+
const auth = getAuth(req.cookies);
|
|
12143
|
+
if (auth.error) {
|
|
12144
|
+
return {
|
|
12145
|
+
status: 401,
|
|
12146
|
+
json: {
|
|
12147
|
+
message: auth.error
|
|
12148
|
+
}
|
|
12149
|
+
};
|
|
12150
|
+
}
|
|
12151
|
+
const res = await serverOps.listCommits(req.query.branch, {
|
|
12152
|
+
limit: req.query.limit,
|
|
12153
|
+
cursor: req.query.cursor
|
|
12154
|
+
});
|
|
12155
|
+
if (fp.result.isErr(res)) {
|
|
12156
|
+
return historyErrorResponse(res.error);
|
|
12157
|
+
}
|
|
12158
|
+
return {
|
|
12159
|
+
status: 200,
|
|
12160
|
+
json: res.value,
|
|
12161
|
+
// The head moves, so a listing is never reusable.
|
|
12162
|
+
headers: {
|
|
12163
|
+
"Cache-Control": "no-store"
|
|
12164
|
+
}
|
|
12165
|
+
};
|
|
12166
|
+
}
|
|
12167
|
+
},
|
|
12168
|
+
"/history/commit": {
|
|
12169
|
+
GET: async req => {
|
|
12170
|
+
const auth = getAuth(req.cookies);
|
|
12171
|
+
if (auth.error) {
|
|
12172
|
+
return {
|
|
12173
|
+
status: 401,
|
|
12174
|
+
json: {
|
|
12175
|
+
message: auth.error
|
|
12176
|
+
}
|
|
12177
|
+
};
|
|
12178
|
+
}
|
|
12179
|
+
const res = await getHistoricalPatchSet(serverOps, req.query.commit_sha);
|
|
12180
|
+
if (fp.result.isErr(res)) {
|
|
12181
|
+
return historyErrorResponse(res.error);
|
|
12182
|
+
}
|
|
12183
|
+
/*
|
|
12184
|
+
* Immutable ONLY when nothing in the answer was transient.
|
|
12185
|
+
*
|
|
12186
|
+
* What a commit recorded cannot change, so a clean answer is reusable
|
|
12187
|
+
* forever - that is what makes comparing many commits cheap. But a
|
|
12188
|
+
* module marked `unavailable` means a blob fetch failed, and a warning
|
|
12189
|
+
* means an entry read against GitHub did; both can succeed on the next
|
|
12190
|
+
* try. Cached for a year, one flaky read would become "nothing was
|
|
12191
|
+
* recorded for this module" for the rest of the session and beyond.
|
|
12192
|
+
*
|
|
12193
|
+
* `private`, not `public`: this route is behind the session cookie, and
|
|
12194
|
+
* a shared cache holding it would hand one project's module sources to
|
|
12195
|
+
* whoever asked next. The browser's own cache is where the reuse was
|
|
12196
|
+
* wanted anyway.
|
|
12197
|
+
*/
|
|
12198
|
+
const settled = res.value.warnings.length === 0 && Object.values(res.value.modules).every(module => module.failures.length === 0);
|
|
12199
|
+
return {
|
|
12200
|
+
status: 200,
|
|
12201
|
+
json: res.value,
|
|
12202
|
+
headers: {
|
|
12203
|
+
"Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
|
|
12204
|
+
}
|
|
12205
|
+
};
|
|
12206
|
+
}
|
|
12207
|
+
},
|
|
12208
|
+
"/history/module": {
|
|
12209
|
+
GET: async req => {
|
|
12210
|
+
const auth = getAuth(req.cookies);
|
|
12211
|
+
if (auth.error) {
|
|
12212
|
+
return {
|
|
12213
|
+
status: 401,
|
|
12214
|
+
json: {
|
|
12215
|
+
message: auth.error
|
|
12216
|
+
}
|
|
12217
|
+
};
|
|
12218
|
+
}
|
|
12219
|
+
const res = await getModuleAtCommit(serverOps, req.query.commit_sha, req.query.module_file_path);
|
|
12220
|
+
if (fp.result.isErr(res)) {
|
|
12221
|
+
return historyErrorResponse(res.error);
|
|
12222
|
+
}
|
|
12223
|
+
// Immutable only when the answer settled — a module reported
|
|
12224
|
+
// unavailable means a blob fetch failed and may succeed next time, and
|
|
12225
|
+
// caching that for a year turns one flaky read into a permanent gap.
|
|
12226
|
+
const settled = res.value === null || res.value.failures.length === 0;
|
|
12227
|
+
return {
|
|
12228
|
+
status: 200,
|
|
12229
|
+
json: {
|
|
12230
|
+
moduleFilePath: req.query.module_file_path,
|
|
12231
|
+
module: res.value
|
|
12232
|
+
},
|
|
12233
|
+
headers: {
|
|
12234
|
+
"Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
|
|
12235
|
+
}
|
|
12236
|
+
};
|
|
12237
|
+
}
|
|
12238
|
+
},
|
|
12239
|
+
"/history/files": {
|
|
12240
|
+
GET: async req => {
|
|
12241
|
+
// No auth, for the same reason /files has none: this is served to an
|
|
12242
|
+
// <img> that the app's own backend may fetch during image
|
|
12243
|
+
// optimisation, with no cookies. What it exposes is a file at a commit
|
|
12244
|
+
// that is already in the repository.
|
|
12245
|
+
const res = await serverOps.getFileAtCommit(req.query.commit_sha, req.query.path, req.query.remote === "true");
|
|
12246
|
+
if (fp.result.isErr(res)) {
|
|
12247
|
+
const response = historyErrorResponse(res.error);
|
|
12248
|
+
// The stream branch of this route's response union has no json, so
|
|
12249
|
+
// narrow to the shapes it does allow.
|
|
12250
|
+
if (response.status === 401 || response.status === 404) {
|
|
12251
|
+
return response;
|
|
12252
|
+
}
|
|
12253
|
+
return {
|
|
12254
|
+
status: 400,
|
|
12255
|
+
json: response.json
|
|
12256
|
+
};
|
|
12257
|
+
}
|
|
12258
|
+
return {
|
|
12259
|
+
status: 200,
|
|
12260
|
+
headers: {
|
|
12261
|
+
"Content-Type": guessMimeTypeFromPath(req.query.path) ?? "application/octet-stream",
|
|
12262
|
+
// A file at a fixed commit cannot change.
|
|
12263
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
12264
|
+
},
|
|
12265
|
+
body: bufferToReadableStream(res.value)
|
|
12266
|
+
};
|
|
12267
|
+
}
|
|
12268
|
+
},
|
|
12269
|
+
// #endregion history
|
|
11180
12270
|
"/files": {
|
|
11181
12271
|
GET: async req => {
|
|
11182
12272
|
const query = req.query;
|
|
@@ -11675,6 +12765,45 @@ const ENABLE_COOKIE_VALUE = {
|
|
|
11675
12765
|
}
|
|
11676
12766
|
};
|
|
11677
12767
|
const chunkSize = 1024 * 1024;
|
|
12768
|
+
|
|
12769
|
+
/**
|
|
12770
|
+
* Turn a HistoryError into the HTTP answer it deserves.
|
|
12771
|
+
*
|
|
12772
|
+
* The `kind` travels in the body alongside the message, because the Studio
|
|
12773
|
+
* decides what to OFFER from it - "cannot restore this field" and "cannot
|
|
12774
|
+
* restore this module at all" are different affordances, and a rendered string
|
|
12775
|
+
* cannot be told apart.
|
|
12776
|
+
*/
|
|
12777
|
+
function historyErrorResponse(error) {
|
|
12778
|
+
const message = historyErrorMessage(error);
|
|
12779
|
+
switch (error.kind) {
|
|
12780
|
+
case "commit-not-found":
|
|
12781
|
+
return {
|
|
12782
|
+
status: 404,
|
|
12783
|
+
json: {
|
|
12784
|
+
message
|
|
12785
|
+
}
|
|
12786
|
+
};
|
|
12787
|
+
case "not-supported-in-fs-mode":
|
|
12788
|
+
// Not an error in the request - this deployment simply has no history
|
|
12789
|
+
// service. 400 rather than 500 so it does not read as a bug.
|
|
12790
|
+
return {
|
|
12791
|
+
status: 400,
|
|
12792
|
+
json: {
|
|
12793
|
+
message,
|
|
12794
|
+
kind: error.kind
|
|
12795
|
+
}
|
|
12796
|
+
};
|
|
12797
|
+
default:
|
|
12798
|
+
return {
|
|
12799
|
+
status: 500,
|
|
12800
|
+
json: {
|
|
12801
|
+
message,
|
|
12802
|
+
kind: error.kind
|
|
12803
|
+
}
|
|
12804
|
+
};
|
|
12805
|
+
}
|
|
12806
|
+
}
|
|
11678
12807
|
function bufferToReadableStream(buffer) {
|
|
11679
12808
|
const stream = new ReadableStream({
|
|
11680
12809
|
start(controller) {
|
|
@@ -12755,21 +13884,22 @@ async function savePatch(deps, moduleFilePath, patch, onInvalid = "reject") {
|
|
|
12755
13884
|
}
|
|
12756
13885
|
|
|
12757
13886
|
/**
|
|
12758
|
-
*
|
|
13887
|
+
* The verified profile, or null when there was nothing to verify.
|
|
12759
13888
|
*
|
|
12760
|
-
*
|
|
12761
|
-
*
|
|
12762
|
-
* checked
|
|
12763
|
-
*
|
|
12764
|
-
*
|
|
13889
|
+
* An author is written only when somebody checked it. On the token path the
|
|
13890
|
+
* host verified a signature over a key it does not hold, so the profile is
|
|
13891
|
+
* checked rather than claimed, and the backend has no token of its own to
|
|
13892
|
+
* attribute from — the call reaches it under the app's API key. If this
|
|
13893
|
+
* stayed null there, every edit made through a signed-in editor's own session
|
|
13894
|
+
* would land with no author at all, which is worse than useless on a CMS
|
|
13895
|
+
* whose review screen is organised by who changed what.
|
|
12765
13896
|
*
|
|
12766
|
-
*
|
|
12767
|
-
*
|
|
12768
|
-
*
|
|
12769
|
-
*
|
|
12770
|
-
*
|
|
12771
|
-
*
|
|
12772
|
-
* review screen is organised by who changed what.
|
|
13897
|
+
* Null is what local filesystem mode gets, where there is no credential to
|
|
13898
|
+
* resolve, exactly as the Studio does locally. It is also what the removed
|
|
13899
|
+
* personal-access-token path got, and for a reason worth keeping in view: an
|
|
13900
|
+
* id derived from a credential the app cannot resolve is an unverified claim
|
|
13901
|
+
* dressed up as a checked one. Should another unverified credential ever
|
|
13902
|
+
* reach here, null remains its only honest author.
|
|
12773
13903
|
*/
|
|
12774
13904
|
const authorId = ((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) === "verified-profile" ? ctx.auth.profileId : null;
|
|
12775
13905
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -13027,14 +14157,17 @@ function rejectFileOps(patch) {
|
|
|
13027
14157
|
*/
|
|
13028
14158
|
|
|
13029
14159
|
/**
|
|
13030
|
-
* How the caller was established, and
|
|
13031
|
-
*
|
|
14160
|
+
* How the caller was established, and there is one acceptable answer: the host
|
|
14161
|
+
* **checked a signature**.
|
|
13032
14162
|
*
|
|
13033
|
-
*
|
|
13034
|
-
* backend unchecked,
|
|
13035
|
-
*
|
|
13036
|
-
*
|
|
13037
|
-
*
|
|
14163
|
+
* A union of one, deliberately. It carried a second variant — a personal access
|
|
14164
|
+
* token relayed to the backend unchecked, on the reasoning that the app cannot
|
|
14165
|
+
* resolve one and the backend can. The reasoning held; the shape did not. A
|
|
14166
|
+
* credential the host cannot check is one it also cannot refuse, so accepting
|
|
14167
|
+
* one made "a deployed endpoint that authenticates nobody" a supported
|
|
14168
|
+
* configuration, and it let a host serve these tools without ever being told
|
|
14169
|
+
* where callers should authorize. The discriminant stays so that adding a
|
|
14170
|
+
* second *verified* kind stays a one-line change at every call site.
|
|
13038
14171
|
*/
|
|
13039
14172
|
|
|
13040
14173
|
/**
|
|
@@ -13069,17 +14202,6 @@ const VAL_SCOPE_READ = "val:read";
|
|
|
13069
14202
|
/** Write access. Needed *in addition* by any tool not marked `readOnlyHint`. */
|
|
13070
14203
|
const VAL_SCOPE_WRITE = "val:write";
|
|
13071
14204
|
|
|
13072
|
-
/**
|
|
13073
|
-
* How many callers' data layers to keep around in proxy mode.
|
|
13074
|
-
*
|
|
13075
|
-
* Each entry holds one `ValOpsHttp`, and each of those caches the project's
|
|
13076
|
-
* evaluated modules once `initSources` has run — so this bounds memory, not just
|
|
13077
|
-
* entry count. Small on purpose: the cost of a miss is re-evaluating the
|
|
13078
|
-
* modules on the next call, which is what happened on *every* call before this
|
|
13079
|
-
* cache existed.
|
|
13080
|
-
*/
|
|
13081
|
-
const MAX_CACHED_OPS = 8;
|
|
13082
|
-
|
|
13083
14205
|
/**
|
|
13084
14206
|
* Val's server-side tool registry.
|
|
13085
14207
|
*
|
|
@@ -13179,17 +14301,25 @@ function createValTools(valModules, options) {
|
|
|
13179
14301
|
* Pick the data layer for a call, which in proxy mode means picking whose
|
|
13180
14302
|
* credential the backend will see.
|
|
13181
14303
|
*
|
|
13182
|
-
* This is the one place authorization is decided, and
|
|
13183
|
-
*
|
|
13184
|
-
*
|
|
13185
|
-
*
|
|
13186
|
-
*
|
|
14304
|
+
* This is the one place authorization is decided, and in proxy mode there is
|
|
14305
|
+
* exactly one credential it will act on: an access token whose signature,
|
|
14306
|
+
* issuer, audience and expiry the host verified against the authorization
|
|
14307
|
+
* server's published key. Anything less is refused here rather than forwarded.
|
|
14308
|
+
*
|
|
14309
|
+
* There used to be a second route — the caller's personal access token, passed
|
|
14310
|
+
* through unread on the reasoning that the backend, not the app, is the
|
|
14311
|
+
* authority on what it may do. That was true, and it was still the wrong shape:
|
|
14312
|
+
* it made an unauthenticated bearer token on a deployed endpoint a supported
|
|
14313
|
+
* configuration, and it meant `initValMcp` had a path where an app served MCP
|
|
14314
|
+
* without ever being told where to authorize. A host that has not verified
|
|
14315
|
+
* anything now gets a refusal that names the missing `oauth` config.
|
|
13187
14316
|
*
|
|
13188
|
-
*
|
|
13189
|
-
*
|
|
13190
|
-
*
|
|
13191
|
-
*
|
|
13192
|
-
*
|
|
14317
|
+
* What has *not* changed is why a verified token does not become the app's own
|
|
14318
|
+
* API key by some other name. The app authenticates to the backend with its own
|
|
14319
|
+
* key here, and who did what travels as the patch's `authorId` — so the
|
|
14320
|
+
* profile has to be one the host checked cryptographically, never one it was
|
|
14321
|
+
* handed. An `authenticate()` that decided a credential's rights inside the app
|
|
14322
|
+
* would make every bug in it full access to every project that key can reach.
|
|
13193
14323
|
*/
|
|
13194
14324
|
function createOpsResolver(valModules, options) {
|
|
13195
14325
|
if (options.mode === "fs") {
|
|
@@ -13203,18 +14333,17 @@ function createOpsResolver(valModules, options) {
|
|
|
13203
14333
|
// and the difference matters, because fs mode writes straight to disk
|
|
13204
14334
|
// with no backend permission check at all.
|
|
13205
14335
|
//
|
|
13206
|
-
//
|
|
13207
|
-
//
|
|
13208
|
-
//
|
|
13209
|
-
//
|
|
13210
|
-
//
|
|
13211
|
-
// difference between a two-minute fix and an afternoon.
|
|
14336
|
+
// A verified access token is not something the caller chose to send: it
|
|
14337
|
+
// only exists because this app advertised an authorization server, so
|
|
14338
|
+
// the developer seeing this did not do anything wrong — a config file
|
|
14339
|
+
// did, and naming it is the difference between a two-minute fix and an
|
|
14340
|
+
// afternoon.
|
|
13212
14341
|
return {
|
|
13213
14342
|
status: "error",
|
|
13214
14343
|
result: {
|
|
13215
14344
|
status: "error",
|
|
13216
14345
|
code: "unsupported",
|
|
13217
|
-
message:
|
|
14346
|
+
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."
|
|
13218
14347
|
}
|
|
13219
14348
|
};
|
|
13220
14349
|
}
|
|
@@ -13225,78 +14354,50 @@ function createOpsResolver(valModules, options) {
|
|
|
13225
14354
|
};
|
|
13226
14355
|
}
|
|
13227
14356
|
|
|
13228
|
-
// Keyed by a hash of the PAT, so the same caller reuses their own instance and
|
|
13229
|
-
// two callers can never share one. Hashing is not a security boundary — the
|
|
13230
|
-
// instance holds the token regardless — but it keeps credentials out of the
|
|
13231
|
-
// key set, which is the thing that ends up in a heap dump or an error dump.
|
|
13232
|
-
const byPatHash = new Map();
|
|
13233
14357
|
/**
|
|
13234
|
-
* One instance for every verified caller, and
|
|
13235
|
-
*
|
|
13236
|
-
*
|
|
13237
|
-
*
|
|
14358
|
+
* One instance for every verified caller, and that is correct rather than a
|
|
14359
|
+
* shortcut: this instance authenticates with the app's own API key, so there
|
|
14360
|
+
* is nothing per-caller in it to keep apart. Who did what travels as the
|
|
14361
|
+
* patch's `authorId` instead — see `writePath`.
|
|
14362
|
+
*
|
|
14363
|
+
* One instance is also all proxy mode keeps now. It could not share while a
|
|
14364
|
+
* personal access token reached this function: each token needed its own
|
|
14365
|
+
* `ValOpsHttp` to hold it, each of those cached the project's evaluated
|
|
14366
|
+
* modules, and the bounded cache that kept that memory in check turned an
|
|
14367
|
+
* eviction into a re-evaluation of every module on the next call.
|
|
13238
14368
|
*/
|
|
13239
14369
|
let sharedOps = null;
|
|
13240
14370
|
return ctx => {
|
|
13241
|
-
|
|
14371
|
+
var _ctx$auth;
|
|
14372
|
+
if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
|
|
13242
14373
|
return {
|
|
13243
14374
|
status: "error",
|
|
13244
14375
|
result: {
|
|
13245
14376
|
status: "error",
|
|
13246
14377
|
code: "forbidden",
|
|
13247
|
-
message: "This Val project talks to the Val content backend, so every call needs
|
|
14378
|
+
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."
|
|
13248
14379
|
}
|
|
13249
14380
|
};
|
|
13250
14381
|
}
|
|
13251
|
-
if (
|
|
13252
|
-
|
|
13253
|
-
|
|
13254
|
-
|
|
13255
|
-
// alternative to refusing is building ops with no credential at all.
|
|
13256
|
-
return {
|
|
13257
|
-
status: "error",
|
|
13258
|
-
result: {
|
|
13259
|
-
status: "error",
|
|
13260
|
-
code: "forbidden",
|
|
13261
|
-
message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
|
|
13262
|
-
}
|
|
13263
|
-
};
|
|
13264
|
-
}
|
|
13265
|
-
if (!sharedOps) {
|
|
13266
|
-
sharedOps = createValOps(valModules, options);
|
|
13267
|
-
}
|
|
13268
|
-
return {
|
|
13269
|
-
status: "ok",
|
|
13270
|
-
ops: sharedOps
|
|
13271
|
-
};
|
|
13272
|
-
}
|
|
13273
|
-
const key = node_crypto.createHash("sha256").update(ctx.auth.pat).digest("hex");
|
|
13274
|
-
const cached = byPatHash.get(key);
|
|
13275
|
-
if (cached) {
|
|
13276
|
-
// Re-inserted so eviction drops the least recently used rather than the
|
|
13277
|
-
// oldest — a long-running caller should not be evicted by a burst of
|
|
13278
|
-
// one-off ones.
|
|
13279
|
-
byPatHash.delete(key);
|
|
13280
|
-
byPatHash.set(key, cached);
|
|
14382
|
+
if (!options.apiKey) {
|
|
14383
|
+
// Proxy mode is inferred from the api key being present, so this is
|
|
14384
|
+
// unreachable through `initHandlerOptions`. It stays because the
|
|
14385
|
+
// alternative to refusing is building ops with no credential at all.
|
|
13281
14386
|
return {
|
|
13282
|
-
status: "
|
|
13283
|
-
|
|
14387
|
+
status: "error",
|
|
14388
|
+
result: {
|
|
14389
|
+
status: "error",
|
|
14390
|
+
code: "forbidden",
|
|
14391
|
+
message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
|
|
14392
|
+
}
|
|
13284
14393
|
};
|
|
13285
14394
|
}
|
|
13286
|
-
|
|
13287
|
-
|
|
13288
|
-
});
|
|
13289
|
-
byPatHash.set(key, ops);
|
|
13290
|
-
while (byPatHash.size > MAX_CACHED_OPS) {
|
|
13291
|
-
const oldest = byPatHash.keys().next();
|
|
13292
|
-
if (oldest.done) {
|
|
13293
|
-
break;
|
|
13294
|
-
}
|
|
13295
|
-
byPatHash.delete(oldest.value);
|
|
14395
|
+
if (!sharedOps) {
|
|
14396
|
+
sharedOps = createValOps(valModules, options);
|
|
13296
14397
|
}
|
|
13297
14398
|
return {
|
|
13298
14399
|
status: "ok",
|
|
13299
|
-
ops
|
|
14400
|
+
ops: sharedOps
|
|
13300
14401
|
};
|
|
13301
14402
|
};
|
|
13302
14403
|
}
|
|
@@ -13394,14 +14495,18 @@ function describeZodError(error) {
|
|
|
13394
14495
|
* the safe direction: a tool that forgets the hint is treated as a write and
|
|
13395
14496
|
* demands the wider scope, rather than a write slipping through as a read.
|
|
13396
14497
|
*
|
|
13397
|
-
*
|
|
13398
|
-
*
|
|
13399
|
-
*
|
|
13400
|
-
*
|
|
14498
|
+
* The early return is a call carrying no verified credential, and there is no
|
|
14499
|
+
* scope to check because nothing granted one. In Val's own host that means
|
|
14500
|
+
* local filesystem mode, where a project writing a developer's own working tree
|
|
14501
|
+
* has no wider authority to withhold. A host assembling its own context can
|
|
14502
|
+
* also reach it with an unauthenticated proxy-mode call — refused a few lines
|
|
14503
|
+
* later, by `resolveOps`, for the credential rather than the scope. Every other
|
|
14504
|
+
* caller arrives as a verified profile, carrying the scopes its token was
|
|
14505
|
+
* issued with.
|
|
13401
14506
|
*/
|
|
13402
14507
|
function refuseInsufficientScope(tool, ctx) {
|
|
13403
|
-
var _ctx$
|
|
13404
|
-
if (((_ctx$
|
|
14508
|
+
var _ctx$auth2, _tool$annotations;
|
|
14509
|
+
if (((_ctx$auth2 = ctx.auth) === null || _ctx$auth2 === void 0 ? void 0 : _ctx$auth2.type) !== "verified-profile") {
|
|
13405
14510
|
return null;
|
|
13406
14511
|
}
|
|
13407
14512
|
// Read is needed by every call, including the writes: a tool that changes
|
|
@@ -14930,6 +16035,25 @@ async function handleJsonValuesExtractEntry(ctx) {
|
|
|
14930
16035
|
};
|
|
14931
16036
|
}
|
|
14932
16037
|
|
|
16038
|
+
/**
|
|
16039
|
+
* `external:upload` under `val validate --fix`: refuse, and say what to run.
|
|
16040
|
+
*
|
|
16041
|
+
* Every other fix in this registry rewrites something inside the repository —
|
|
16042
|
+
* reversible, visible in a diff, wrong by at most one commit. This one would
|
|
16043
|
+
* write entries into a live external store: not in a diff, not undone by
|
|
16044
|
+
* `git revert`, and against production indistinguishable from an editor's
|
|
16045
|
+
* publish. So a blanket `--fix` must never apply it.
|
|
16046
|
+
*
|
|
16047
|
+
* `fixableErrorMessage` rather than a plain error, because the error IS fixable
|
|
16048
|
+
* — just not by this command.
|
|
16049
|
+
*/
|
|
16050
|
+
async function handleExternalUpload() {
|
|
16051
|
+
return {
|
|
16052
|
+
success: true,
|
|
16053
|
+
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."
|
|
16054
|
+
};
|
|
16055
|
+
}
|
|
16056
|
+
|
|
14933
16057
|
// Fix handler registry. `keyof:check-keys` and `router:check-route` are
|
|
14934
16058
|
// resolved upfront by the shared resolveSchemaSourceFixes — they never reach
|
|
14935
16059
|
// this registry, so they're excluded from the key set.
|
|
@@ -14952,7 +16076,8 @@ const currentFixHandlers = {
|
|
|
14952
16076
|
"files:check-unique-folder": handleUniqueFolderCheck,
|
|
14953
16077
|
"images:check-all-files": handleCheckAllFiles,
|
|
14954
16078
|
"files:check-all-files": handleCheckAllFiles,
|
|
14955
|
-
"jsonValues:extract-entry": handleJsonValuesExtractEntry
|
|
16079
|
+
"jsonValues:extract-entry": handleJsonValuesExtractEntry,
|
|
16080
|
+
"external:upload": handleExternalUpload
|
|
14956
16081
|
};
|
|
14957
16082
|
const deprecatedFixHandlers = {
|
|
14958
16083
|
"image:replace-metadata": handleFileMetadata
|
|
@@ -15608,6 +16733,7 @@ Object.defineProperty(exports, 'hasRemoteFileSchema', {
|
|
|
15608
16733
|
exports.DEFAULT_LOGIN_EXPIRES_IN_SECONDS = DEFAULT_LOGIN_EXPIRES_IN_SECONDS;
|
|
15609
16734
|
exports.DEFAULT_LOGIN_HOST = DEFAULT_LOGIN_HOST;
|
|
15610
16735
|
exports.DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = DEFAULT_LOGIN_POLL_INTERVAL_SECONDS;
|
|
16736
|
+
exports.EXTERNAL_RESULT = EXTERNAL_RESULT;
|
|
15611
16737
|
exports.Service = Service;
|
|
15612
16738
|
exports.VAL_SCOPE_READ = VAL_SCOPE_READ;
|
|
15613
16739
|
exports.VAL_SCOPE_WRITE = VAL_SCOPE_WRITE;
|
|
@@ -15635,9 +16761,11 @@ exports.createValServer = createValServer;
|
|
|
15635
16761
|
exports.createValTools = createValTools;
|
|
15636
16762
|
exports.currentFixHandlers = currentFixHandlers;
|
|
15637
16763
|
exports.decodeJwtWithoutVerifying = decodeJwtWithoutVerifying;
|
|
16764
|
+
exports.defineExternal = defineExternal;
|
|
15638
16765
|
exports.describePatchStoreProblems = describePatchStoreProblems;
|
|
15639
16766
|
exports.downloadFileFromRemote = downloadFileFromRemote;
|
|
15640
16767
|
exports.encodeJwt = encodeJwt;
|
|
16768
|
+
exports.err = err$1;
|
|
15641
16769
|
exports.evalValConfigFile = evalValConfigFile;
|
|
15642
16770
|
exports.extractFileMetadata = extractFileMetadata;
|
|
15643
16771
|
exports.extractImageMetadata = extractImageMetadata;
|
|
@@ -15657,6 +16785,7 @@ exports.getPersonalAccessTokenPath = getPersonalAccessTokenPath;
|
|
|
15657
16785
|
exports.getSettings = getSettings;
|
|
15658
16786
|
exports.getValidationErrorFileRef = getValidationErrorFileRef;
|
|
15659
16787
|
exports.handleCheckAllFiles = handleCheckAllFiles;
|
|
16788
|
+
exports.handleExternalUpload = handleExternalUpload;
|
|
15660
16789
|
exports.handleFileMetadata = handleFileMetadata;
|
|
15661
16790
|
exports.handleJsonValuesExtractEntry = handleJsonValuesExtractEntry;
|
|
15662
16791
|
exports.handleRemoteFileCheck = handleRemoteFileCheck;
|
|
@@ -15665,7 +16794,9 @@ exports.handleRemoteFileUpload = handleRemoteFileUpload;
|
|
|
15665
16794
|
exports.handleRemoteGalleryFileUpload = handleRemoteGalleryFileUpload;
|
|
15666
16795
|
exports.handleUniqueFolderCheck = handleUniqueFolderCheck;
|
|
15667
16796
|
exports.initHandlerOptions = initHandlerOptions;
|
|
16797
|
+
exports.isExternalResult = isExternalResult;
|
|
15668
16798
|
exports.loadValModules = loadValModules;
|
|
16799
|
+
exports.ok = ok$1;
|
|
15669
16800
|
exports.parsePersonalAccessTokenFile = parsePersonalAccessTokenFile;
|
|
15670
16801
|
exports.patchSourceFile = patchSourceFile;
|
|
15671
16802
|
exports.persistPersonalAccessToken = persistPersonalAccessToken;
|