filegrc 0.12.2 → 0.12.4
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/package.json +1 -1
- package/src/git.js +677 -106
- package/src/reconciliation.js +616 -154
- package/src/server.js +356 -52
- package/src/state.js +18 -10
- package/src/validate.js +112 -15
- package/src/web.js +75 -9
- package/src/workflow-history-integrity.js +19 -9
- package/src/workflow.js +7 -2
- package/src/workspace.js +3 -0
package/src/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createServer as createHttpServer } from "node:http";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { extname, join, resolve } from "node:path";
|
|
@@ -26,11 +27,14 @@ import {
|
|
|
26
27
|
getBrowserRepositoryState,
|
|
27
28
|
getFileHistory,
|
|
28
29
|
getRepositorySnapshot,
|
|
30
|
+
getRepositoryStateSignature,
|
|
29
31
|
prefetchBrowserRemote,
|
|
30
32
|
pullWorkspace,
|
|
31
33
|
pushWorkspace,
|
|
32
34
|
retryBrowserSync,
|
|
33
|
-
runBrowserMutation
|
|
35
|
+
runBrowserMutation,
|
|
36
|
+
withGitCommandCache,
|
|
37
|
+
withGitCommandDeadline
|
|
34
38
|
} from "./git.js";
|
|
35
39
|
import { normalizeResourceMutation, serializeWorkspaceMutation } from "./mutation.js";
|
|
36
40
|
import {
|
|
@@ -57,11 +61,13 @@ import {
|
|
|
57
61
|
} from "./external-reviewer.js";
|
|
58
62
|
import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
|
|
59
63
|
import { activatePolicies } from "./policy-activation.js";
|
|
64
|
+
import { resolveProgram } from "./program.js";
|
|
60
65
|
import { applyReconciliation, dismissReconciliation, planReconciliation } from "./reconciliation.js";
|
|
61
66
|
import { resourceReviewRevisions } from "./retention.js";
|
|
62
67
|
import { createAppBootstrap, createAppState, createAppStateSection, createResourceDetail } from "./state.js";
|
|
63
68
|
import { setupWorkspace } from "./setup.js";
|
|
64
69
|
import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
|
|
70
|
+
import { fingerprintWorkspace } from "./validate.js";
|
|
65
71
|
import {
|
|
66
72
|
assessWorkflow,
|
|
67
73
|
buildWorkflowDelta,
|
|
@@ -71,9 +77,15 @@ import {
|
|
|
71
77
|
import { loadWorkspace } from "./workspace.js";
|
|
72
78
|
import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
|
|
73
79
|
|
|
80
|
+
const STATE_SESSION_MAX_AGE_MS = 5 * 60_000;
|
|
81
|
+
const MAX_STATE_SESSIONS = 8;
|
|
82
|
+
const MAX_STATE_SESSION_PROMISES = 64;
|
|
83
|
+
const RESOURCE_DETAIL_GIT_DEADLINE_MS = 10_000;
|
|
84
|
+
const STATE_SECTION_GIT_DEADLINE_MS = 10_000;
|
|
85
|
+
|
|
74
86
|
export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
75
87
|
const stateSessions = new Map();
|
|
76
|
-
|
|
88
|
+
const fileDigestCache = new Map();
|
|
77
89
|
return createHttpServer(async (request, response) => {
|
|
78
90
|
const requestStarted = performance.now();
|
|
79
91
|
if (timingEnabled()) {
|
|
@@ -92,7 +104,11 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
92
104
|
return json(response, 403, { error: "The request host is not allowed." });
|
|
93
105
|
}
|
|
94
106
|
const url = new URL(request.url, "http://localhost");
|
|
95
|
-
const requestOptions = {
|
|
107
|
+
const requestOptions = {
|
|
108
|
+
...options,
|
|
109
|
+
programId: url.searchParams.get("programId") || undefined,
|
|
110
|
+
invalidateStateSessions: () => invalidateStateSessions(stateSessions)
|
|
111
|
+
};
|
|
96
112
|
if (["POST", "PUT", "DELETE"].includes(request.method) && !sameOrigin(request)) {
|
|
97
113
|
return json(response, 403, { error: "Cross-origin writes are not allowed." });
|
|
98
114
|
}
|
|
@@ -113,15 +129,43 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
113
129
|
}));
|
|
114
130
|
}
|
|
115
131
|
if (request.method === "GET" && url.pathname === "/api/state/bootstrap") {
|
|
116
|
-
|
|
117
|
-
|
|
132
|
+
if (!sameOriginBrowserRead(request)) {
|
|
133
|
+
return json(response, 403, { error: "Cross-origin state requests are not allowed." });
|
|
134
|
+
}
|
|
135
|
+
const deadlineAt = performance.now() + STATE_SECTION_GIT_DEADLINE_MS;
|
|
136
|
+
const [snapshot, repositorySignature] = await withGitCommandDeadline(deadlineAt, () => (
|
|
137
|
+
serializeWorkspaceMutation(input, (root) => Promise.all([
|
|
138
|
+
fingerprintWorkspace(root, {
|
|
139
|
+
fileDigestCache,
|
|
140
|
+
deadlineAt
|
|
141
|
+
}),
|
|
142
|
+
getRepositoryStateSignature(root, {
|
|
143
|
+
timeoutMs: Math.max(1, Math.ceil(deadlineAt - performance.now()))
|
|
144
|
+
})
|
|
145
|
+
]))
|
|
146
|
+
));
|
|
147
|
+
const loaded = snapshot.loaded;
|
|
148
|
+
const token = randomUUID();
|
|
118
149
|
const session = {
|
|
119
150
|
loaded,
|
|
151
|
+
fingerprint: snapshot.fingerprint,
|
|
152
|
+
repositorySignature,
|
|
153
|
+
fileDigestCache,
|
|
120
154
|
generatedAt: new Date().toISOString(),
|
|
121
|
-
|
|
155
|
+
expiresAt: Date.now() + STATE_SESSION_MAX_AGE_MS,
|
|
156
|
+
revoked: false,
|
|
157
|
+
promises: new Map(),
|
|
158
|
+
verificationPromise: null,
|
|
159
|
+
gitCommandCache: new Map()
|
|
122
160
|
};
|
|
161
|
+
pruneStateSessions(stateSessions);
|
|
123
162
|
stateSessions.set(token, session);
|
|
124
|
-
while (stateSessions.size >
|
|
163
|
+
while (stateSessions.size > MAX_STATE_SESSIONS) {
|
|
164
|
+
const oldestToken = stateSessions.keys().next().value;
|
|
165
|
+
const oldestSession = stateSessions.get(oldestToken);
|
|
166
|
+
if (oldestSession) oldestSession.revoked = true;
|
|
167
|
+
stateSessions.delete(oldestToken);
|
|
168
|
+
}
|
|
125
169
|
const state = await createAppBootstrap(loaded, {
|
|
126
170
|
generatedAt: session.generatedAt,
|
|
127
171
|
programId: url.searchParams.get("programId") || undefined
|
|
@@ -135,9 +179,17 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
135
179
|
return json(response, 404, { error: "Unknown app-state section." });
|
|
136
180
|
}
|
|
137
181
|
const token = url.searchParams.get("token");
|
|
182
|
+
pruneStateSessions(stateSessions);
|
|
138
183
|
const session = stateSessions.get(token);
|
|
139
184
|
if (!session) return json(response, 409, { error: "The workspace state expired. Reload it and try again." });
|
|
140
|
-
const
|
|
185
|
+
const sectionDeadlineMs = Number.isFinite(options.stateSectionDeadlineMs)
|
|
186
|
+
? Math.max(0, options.stateSectionDeadlineMs)
|
|
187
|
+
: STATE_SECTION_GIT_DEADLINE_MS;
|
|
188
|
+
const deadlineAt = performance.now() + sectionDeadlineMs;
|
|
189
|
+
const state = await withGitCommandDeadline(deadlineAt, () => (
|
|
190
|
+
loadStateSessionSection(session, section, options, url.searchParams.get("programId") || undefined, null, deadlineAt)
|
|
191
|
+
));
|
|
192
|
+
assertCurrentStateSession(session);
|
|
141
193
|
return json(response, 200, { stateToken: token, section, state });
|
|
142
194
|
}
|
|
143
195
|
if (request.method === "GET" && url.pathname === "/api/history") {
|
|
@@ -514,9 +566,14 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
514
566
|
return json(response, 200, await manualGitResultWithState(input, requestOptions, () => pushWorkspace(input)));
|
|
515
567
|
}
|
|
516
568
|
if (request.method === "POST" && url.pathname === "/api/git/retry-sync") {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
569
|
+
let result;
|
|
570
|
+
try {
|
|
571
|
+
result = await retryBrowserSync(input, {
|
|
572
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
573
|
+
});
|
|
574
|
+
} finally {
|
|
575
|
+
requestOptions.invalidateStateSessions();
|
|
576
|
+
}
|
|
520
577
|
const state = await createAppState(input, {
|
|
521
578
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
522
579
|
includeDetails: false
|
|
@@ -562,10 +619,17 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
562
619
|
const id = decodeURIComponent(match[2]);
|
|
563
620
|
if (!safeSegment(type) || !safeSegment(id)) return json(response, 400, { error: "Unsafe resource identifier." });
|
|
564
621
|
if (request.method === "GET") {
|
|
565
|
-
const
|
|
622
|
+
const token = url.searchParams.get("token");
|
|
623
|
+
pruneStateSessions(stateSessions);
|
|
624
|
+
const session = token ? stateSessions.get(token) : null;
|
|
625
|
+
if (token && !session) return json(response, 409, { error: "The workspace state expired. Reload it and try again." });
|
|
626
|
+
const includeWorkflow = url.searchParams.get("workflow") === "true";
|
|
627
|
+
const entry = session
|
|
628
|
+
? await loadStateSessionResource(session, token, type, id, options, requestOptions.programId, includeWorkflow)
|
|
629
|
+
: await createResourceDetail(input, type, id);
|
|
566
630
|
if (!entry) return json(response, 404, { error: "Resource not found." });
|
|
567
|
-
if (
|
|
568
|
-
const workflow = await assessWorkflow(input);
|
|
631
|
+
if (includeWorkflow && !session) {
|
|
632
|
+
const workflow = await assessWorkflow(input, { programId: requestOptions.programId });
|
|
569
633
|
entry.workflow = workflowForResource(workflow, type, id);
|
|
570
634
|
}
|
|
571
635
|
return json(response, 200, entry);
|
|
@@ -651,7 +715,9 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
651
715
|
const server = createFilegrcServer(loaded.root, {
|
|
652
716
|
allowedHosts: [host],
|
|
653
717
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
|
|
654
|
-
backgroundPushDelayMs: options.backgroundPushDelayMs
|
|
718
|
+
backgroundPushDelayMs: options.backgroundPushDelayMs,
|
|
719
|
+
stateSectionDeadlineMs: options.stateSectionDeadlineMs,
|
|
720
|
+
resourceDetailDeadlineMs: options.resourceDetailDeadlineMs
|
|
655
721
|
});
|
|
656
722
|
let usedFallbackPort = false;
|
|
657
723
|
try {
|
|
@@ -699,12 +765,17 @@ function browserMutation(input, requestOptions, mutationOptions, task) {
|
|
|
699
765
|
const workflowBefore = fastResponse
|
|
700
766
|
? null
|
|
701
767
|
: await measureTiming("workflow-before", () => assessWorkflow(root, { programId: requestOptions.programId }));
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
768
|
+
let result;
|
|
769
|
+
try {
|
|
770
|
+
result = await measureTiming("mutation", () => runBrowserMutation(root, {
|
|
771
|
+
...mutationOptions,
|
|
772
|
+
allowNonAuthoritativeWrites: requestOptions.allowNonAuthoritativeWrites === true,
|
|
773
|
+
backgroundPushDelayMs: requestOptions.backgroundPushDelayMs,
|
|
774
|
+
includeValidationProof: !fastResponse
|
|
775
|
+
}, task));
|
|
776
|
+
} finally {
|
|
777
|
+
requestOptions.invalidateStateSessions?.();
|
|
778
|
+
}
|
|
708
779
|
if (fastResponse) {
|
|
709
780
|
return {
|
|
710
781
|
...result,
|
|
@@ -752,40 +823,112 @@ export function reconcileMutationSynchronization(synchronization, repository) {
|
|
|
752
823
|
};
|
|
753
824
|
}
|
|
754
825
|
|
|
755
|
-
function loadStateSessionSection(session, section, serverOptions, programId) {
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
const
|
|
759
|
-
?
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
826
|
+
async function loadStateSessionSection(session, section, serverOptions, programId, verificationContext = null, deadlineAt) {
|
|
827
|
+
const requestStartedAt = performance.now();
|
|
828
|
+
assertCurrentStateSession(session);
|
|
829
|
+
const selectedProgramId = section === "repository"
|
|
830
|
+
? null
|
|
831
|
+
: resolveProgram(session.loaded, programId).id;
|
|
832
|
+
const cacheKey = section === "repository" ? section : `${section}:${selectedProgramId}`;
|
|
833
|
+
const ownsVerification = verificationContext === null;
|
|
834
|
+
const sharedVerification = verificationContext || {};
|
|
835
|
+
let calculationEntry = session.promises.get(cacheKey);
|
|
836
|
+
if (!calculationEntry) {
|
|
837
|
+
makeStateSessionCalculationRoom(session);
|
|
838
|
+
const dependencies = section === "workflow"
|
|
839
|
+
? Promise.all([
|
|
840
|
+
loadStateSessionSection(session, "repository", serverOptions, programId, sharedVerification, deadlineAt),
|
|
841
|
+
loadStateSessionSection(session, "program", serverOptions, programId, sharedVerification, deadlineAt),
|
|
842
|
+
loadStateSessionSection(session, "obligations", serverOptions, programId, sharedVerification, deadlineAt),
|
|
843
|
+
loadStateSessionSection(session, "audits", serverOptions, programId, sharedVerification, deadlineAt)
|
|
844
|
+
])
|
|
845
|
+
: section === "audits"
|
|
846
|
+
? Promise.all([loadStateSessionSection(session, "program", serverOptions, programId, sharedVerification, deadlineAt)])
|
|
847
|
+
: Promise.resolve([]);
|
|
848
|
+
calculationEntry = { promise: null, completedAt: null, deadlineAt };
|
|
849
|
+
calculationEntry.promise = dependencies.then((results) => {
|
|
850
|
+
const repository = section === "workflow" ? results[0] : null;
|
|
851
|
+
const program = section === "workflow" ? results[1] : section === "audits" ? results[0] : null;
|
|
852
|
+
const obligations = section === "workflow" ? results[2] : null;
|
|
853
|
+
const audits = section === "workflow" ? results[3] : null;
|
|
854
|
+
return withGitCommandCache(session.gitCommandCache, () => createAppStateSection(session.loaded, section, {
|
|
855
|
+
allowNonAuthoritativeWrites: serverOptions.allowNonAuthoritativeWrites,
|
|
856
|
+
generatedAt: session.generatedAt,
|
|
857
|
+
programReadiness: program?.programReadiness,
|
|
858
|
+
auditPreparations: audits?.auditPreparations,
|
|
859
|
+
obligations: obligations?.obligations,
|
|
860
|
+
git: repository?.git,
|
|
861
|
+
validation: repository?.validation,
|
|
862
|
+
strictHistory: repository?.git?.available === true,
|
|
863
|
+
programId: selectedProgramId || programId
|
|
864
|
+
}));
|
|
865
|
+
}).then((state) => {
|
|
866
|
+
calculationEntry.completedAt = performance.now();
|
|
867
|
+
return state;
|
|
868
|
+
}).catch((error) => {
|
|
869
|
+
if (session.promises.get(cacheKey) === calculationEntry) session.promises.delete(cacheKey);
|
|
870
|
+
throw error;
|
|
782
871
|
});
|
|
783
|
-
|
|
872
|
+
session.promises.set(cacheKey, calculationEntry);
|
|
873
|
+
} else if (calculationEntry.completedAt !== null) {
|
|
784
874
|
session.promises.delete(cacheKey);
|
|
875
|
+
session.promises.set(cacheKey, calculationEntry);
|
|
876
|
+
}
|
|
877
|
+
let state;
|
|
878
|
+
try {
|
|
879
|
+
state = await awaitWithinDeadline(calculationEntry.promise, deadlineAt);
|
|
880
|
+
} catch (error) {
|
|
881
|
+
if (error?.code === "FILEGRC_GIT_DEADLINE"
|
|
882
|
+
&& calculationEntry.deadlineAt !== deadlineAt
|
|
883
|
+
&& deadlineAt !== undefined
|
|
884
|
+
&& performance.now() < deadlineAt) {
|
|
885
|
+
return loadStateSessionSection(
|
|
886
|
+
session,
|
|
887
|
+
section,
|
|
888
|
+
serverOptions,
|
|
889
|
+
programId,
|
|
890
|
+
verificationContext,
|
|
891
|
+
deadlineAt
|
|
892
|
+
);
|
|
893
|
+
}
|
|
785
894
|
throw error;
|
|
895
|
+
}
|
|
896
|
+
if (ownsVerification) {
|
|
897
|
+
await verifyStateSessionSnapshot(session, Math.max(requestStartedAt, calculationEntry.completedAt || 0), deadlineAt);
|
|
898
|
+
}
|
|
899
|
+
return state;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function loadStateSessionResource(session, token, type, id, serverOptions, programId, includeWorkflow) {
|
|
903
|
+
assertCurrentStateSession(session);
|
|
904
|
+
const detailDeadlineMs = Number.isFinite(serverOptions.resourceDetailDeadlineMs)
|
|
905
|
+
? Math.max(0, serverOptions.resourceDetailDeadlineMs)
|
|
906
|
+
: RESOURCE_DETAIL_GIT_DEADLINE_MS;
|
|
907
|
+
const deadlineAt = performance.now() + detailDeadlineMs;
|
|
908
|
+
return withGitCommandDeadline(deadlineAt, async () => {
|
|
909
|
+
const detail = await withGitCommandCache(session.gitCommandCache, () => createResourceDetail(session.loaded, type, id, {
|
|
910
|
+
historyDeadlineAt: deadlineAt
|
|
911
|
+
}));
|
|
912
|
+
if (detail && includeWorkflow) {
|
|
913
|
+
const repository = await loadStateSessionSection(
|
|
914
|
+
session,
|
|
915
|
+
"repository",
|
|
916
|
+
serverOptions,
|
|
917
|
+
programId,
|
|
918
|
+
{},
|
|
919
|
+
deadlineAt
|
|
920
|
+
);
|
|
921
|
+
const workflow = await withGitCommandCache(session.gitCommandCache, () => assessWorkflow(session.loaded, {
|
|
922
|
+
programId,
|
|
923
|
+
historyDeadlineAt: deadlineAt,
|
|
924
|
+
strictHistory: repository?.git?.available === true
|
|
925
|
+
}));
|
|
926
|
+
detail.workflow = workflowForResource(workflow, type, id);
|
|
927
|
+
}
|
|
928
|
+
await verifyStateSessionSnapshot(session, performance.now(), deadlineAt);
|
|
929
|
+
assertCurrentStateSession(session);
|
|
930
|
+
return detail ? { ...detail, stateToken: token } : null;
|
|
786
931
|
});
|
|
787
|
-
session.promises.set(cacheKey, promise);
|
|
788
|
-
return promise;
|
|
789
932
|
}
|
|
790
933
|
|
|
791
934
|
function prefersFastMutation(request) {
|
|
@@ -804,7 +947,12 @@ async function requireManualBrowserGit(input, options) {
|
|
|
804
947
|
}
|
|
805
948
|
|
|
806
949
|
async function manualGitResultWithState(input, requestOptions, task) {
|
|
807
|
-
|
|
950
|
+
let result;
|
|
951
|
+
try {
|
|
952
|
+
result = await task();
|
|
953
|
+
} finally {
|
|
954
|
+
requestOptions.invalidateStateSessions?.();
|
|
955
|
+
}
|
|
808
956
|
const state = await createAppState(input, {
|
|
809
957
|
allowNonAuthoritativeWrites: requestOptions.allowNonAuthoritativeWrites,
|
|
810
958
|
programId: requestOptions.programId,
|
|
@@ -813,6 +961,141 @@ async function manualGitResultWithState(input, requestOptions, task) {
|
|
|
813
961
|
return { ...result, state };
|
|
814
962
|
}
|
|
815
963
|
|
|
964
|
+
function pruneStateSessions(stateSessions, now = Date.now()) {
|
|
965
|
+
for (const [token, session] of stateSessions) {
|
|
966
|
+
if (session.revoked || session.expiresAt <= now) {
|
|
967
|
+
session.revoked = true;
|
|
968
|
+
stateSessions.delete(token);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function invalidateStateSessions(stateSessions) {
|
|
974
|
+
for (const session of stateSessions.values()) session.revoked = true;
|
|
975
|
+
stateSessions.clear();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function assertCurrentStateSession(session) {
|
|
979
|
+
if (session.revoked || session.expiresAt <= Date.now()) throw stateSessionExpiredError();
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function stateSessionExpiredError() {
|
|
983
|
+
const error = new Error("The workspace state expired. Reload it and try again.");
|
|
984
|
+
error.code = "FILEGRC_STATE_EXPIRED";
|
|
985
|
+
return error;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function stateSessionCapacityError() {
|
|
989
|
+
const error = new Error("The workspace state has too many calculations in progress. Reload it and try again.");
|
|
990
|
+
error.code = "FILEGRC_STATE_CAPACITY";
|
|
991
|
+
return error;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function makeStateSessionCalculationRoom(session) {
|
|
995
|
+
const pending = [...session.promises.values()].filter(({ completedAt }) => completedAt === null).length;
|
|
996
|
+
if (pending >= MAX_STATE_SESSION_PROMISES) throw stateSessionCapacityError();
|
|
997
|
+
while (session.promises.size >= MAX_STATE_SESSION_PROMISES) {
|
|
998
|
+
const completedKey = [...session.promises].find(([, entry]) => entry.completedAt !== null)?.[0];
|
|
999
|
+
if (completedKey === undefined) throw stateSessionCapacityError();
|
|
1000
|
+
session.promises.delete(completedKey);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function assertStateSessionFingerprint(session, fingerprint) {
|
|
1005
|
+
if (fingerprint === session.fingerprint) return;
|
|
1006
|
+
session.revoked = true;
|
|
1007
|
+
throw stateSessionExpiredError();
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async function verifyStateSessionSnapshot(session, notBefore = 0, deadlineAt) {
|
|
1011
|
+
assertCurrentStateSession(session);
|
|
1012
|
+
while (session.verificationPromise) {
|
|
1013
|
+
const current = session.verificationPromise;
|
|
1014
|
+
if (current.startedAt >= notBefore) {
|
|
1015
|
+
try {
|
|
1016
|
+
return await awaitWithinDeadline(current.promise, deadlineAt);
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
if (!replaceableVerificationError(error)
|
|
1019
|
+
|| error.source === "caller-deadline"
|
|
1020
|
+
|| current.deadlineAt === deadlineAt
|
|
1021
|
+
|| deadlineAt === undefined
|
|
1022
|
+
|| performance.now() >= deadlineAt) throw error;
|
|
1023
|
+
if (session.verificationPromise === current) session.verificationPromise = null;
|
|
1024
|
+
assertCurrentStateSession(session);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
await awaitWithinDeadline(current.promise, deadlineAt);
|
|
1030
|
+
assertCurrentStateSession(session);
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
if (!replaceableVerificationError(error)
|
|
1033
|
+
|| error.source === "caller-deadline"
|
|
1034
|
+
|| current.deadlineAt === deadlineAt
|
|
1035
|
+
|| deadlineAt === undefined
|
|
1036
|
+
|| performance.now() >= deadlineAt) throw error;
|
|
1037
|
+
if (session.verificationPromise === current) session.verificationPromise = null;
|
|
1038
|
+
assertCurrentStateSession(session);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
const verification = { startedAt: Number.POSITIVE_INFINITY, promise: null, deadlineAt };
|
|
1043
|
+
verification.promise = (async () => {
|
|
1044
|
+
// Let concurrent section requests join the same future verification. Once
|
|
1045
|
+
// file reads begin, callers whose calculation finishes later must wait for
|
|
1046
|
+
// a new pass.
|
|
1047
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1048
|
+
verification.startedAt = performance.now();
|
|
1049
|
+
return Promise.all([
|
|
1050
|
+
fingerprintWorkspace(session.loaded.root, {
|
|
1051
|
+
fileDigestCache: session.fileDigestCache,
|
|
1052
|
+
deadlineAt
|
|
1053
|
+
}),
|
|
1054
|
+
getRepositoryStateSignature(session.loaded.root, {
|
|
1055
|
+
timeoutMs: deadlineAt === undefined ? undefined : Math.max(1, Math.ceil(deadlineAt - performance.now()))
|
|
1056
|
+
})
|
|
1057
|
+
]);
|
|
1058
|
+
})().then(([snapshot, repositorySignature]) => {
|
|
1059
|
+
assertStateSessionFingerprint(session, snapshot.fingerprint);
|
|
1060
|
+
if (repositorySignature !== session.repositorySignature) {
|
|
1061
|
+
session.revoked = true;
|
|
1062
|
+
throw stateSessionExpiredError();
|
|
1063
|
+
}
|
|
1064
|
+
assertCurrentStateSession(session);
|
|
1065
|
+
return { ...snapshot, repositorySignature };
|
|
1066
|
+
}).finally(() => {
|
|
1067
|
+
if (session.verificationPromise === verification) session.verificationPromise = null;
|
|
1068
|
+
});
|
|
1069
|
+
session.verificationPromise = verification;
|
|
1070
|
+
return awaitWithinDeadline(verification.promise, deadlineAt);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function awaitWithinDeadline(promise, deadlineAt) {
|
|
1074
|
+
if (deadlineAt === undefined) return promise;
|
|
1075
|
+
const remaining = Math.ceil(deadlineAt - performance.now());
|
|
1076
|
+
if (remaining <= 0) {
|
|
1077
|
+
promise.catch(() => {});
|
|
1078
|
+
return Promise.reject(gitDeadlineError());
|
|
1079
|
+
}
|
|
1080
|
+
let timer;
|
|
1081
|
+
const timeout = new Promise((resolve, reject) => {
|
|
1082
|
+
timer = setTimeout(() => reject(gitDeadlineError()), remaining);
|
|
1083
|
+
timer.unref?.();
|
|
1084
|
+
});
|
|
1085
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function gitDeadlineError() {
|
|
1089
|
+
const error = new Error("The shared Git request deadline expired.");
|
|
1090
|
+
error.code = "FILEGRC_GIT_DEADLINE";
|
|
1091
|
+
error.source = "caller-deadline";
|
|
1092
|
+
return error;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function replaceableVerificationError(error) {
|
|
1096
|
+
return ["FILEGRC_GIT_DEADLINE", "FILEGRC_FINGERPRINT_BUDGET"].includes(error?.code);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
816
1099
|
function resourceTypeLabel(value) {
|
|
817
1100
|
return String(value || "record").replaceAll("-", " ");
|
|
818
1101
|
}
|
|
@@ -897,6 +1180,9 @@ function requireRevision(value, target) {
|
|
|
897
1180
|
}
|
|
898
1181
|
|
|
899
1182
|
function sameOrigin(request) {
|
|
1183
|
+
const fetchSite = String(request.headers["sec-fetch-site"] || "").toLowerCase();
|
|
1184
|
+
if (fetchSite === "same-origin") return true;
|
|
1185
|
+
if (fetchSite && fetchSite !== "none") return false;
|
|
900
1186
|
const origin = request.headers.origin;
|
|
901
1187
|
if (!origin) return true;
|
|
902
1188
|
try {
|
|
@@ -907,6 +1193,19 @@ function sameOrigin(request) {
|
|
|
907
1193
|
}
|
|
908
1194
|
}
|
|
909
1195
|
|
|
1196
|
+
function sameOriginBrowserRead(request) {
|
|
1197
|
+
const fetchSite = String(request.headers["sec-fetch-site"] || "").toLowerCase();
|
|
1198
|
+
if (fetchSite) return ["same-origin", "none"].includes(fetchSite);
|
|
1199
|
+
if (!sameOrigin(request)) return false;
|
|
1200
|
+
const referrer = request.headers.referer;
|
|
1201
|
+
if (!referrer) return true;
|
|
1202
|
+
try {
|
|
1203
|
+
return new URL(referrer).host === request.headers.host;
|
|
1204
|
+
} catch {
|
|
1205
|
+
return false;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
910
1209
|
function expectedHost(request, allowedHosts = []) {
|
|
911
1210
|
const host = request.headers.host;
|
|
912
1211
|
const localAddress = normalizeAddress(request.socket.localAddress);
|
|
@@ -937,6 +1236,11 @@ function urlHost(host) {
|
|
|
937
1236
|
}
|
|
938
1237
|
|
|
939
1238
|
function statusFor(error) {
|
|
1239
|
+
if (error?.code === "FILEGRC_STATE_EXPIRED") return 409;
|
|
1240
|
+
if (error?.code === "FILEGRC_STATE_CAPACITY") return 429;
|
|
1241
|
+
if (error?.code === "FILEGRC_FINGERPRINT_BUDGET") return 503;
|
|
1242
|
+
if (error?.code === "FILEGRC_GIT_HISTORY_UNAVAILABLE") return 503;
|
|
1243
|
+
if (error?.code === "FILEGRC_GIT_DEADLINE") return 503;
|
|
940
1244
|
if (error instanceof SyntaxError || error instanceof URIError) return 400;
|
|
941
1245
|
if (/exceeds 2 MB/i.test(error.message)) return 413;
|
|
942
1246
|
if (/exceeds 25 MB/i.test(error.message)) return 413;
|
package/src/state.js
CHANGED
|
@@ -218,7 +218,8 @@ export async function createAppStateSection(input, section, options = {}) {
|
|
|
218
218
|
auditPreparations: options.auditPreparations,
|
|
219
219
|
obligations: options.obligations,
|
|
220
220
|
git: options.git,
|
|
221
|
-
validation: options.validation
|
|
221
|
+
validation: options.validation,
|
|
222
|
+
strictHistory: options.strictHistory
|
|
222
223
|
})
|
|
223
224
|
};
|
|
224
225
|
}
|
|
@@ -355,17 +356,24 @@ async function createAppStateUnlocked(input, options) {
|
|
|
355
356
|
};
|
|
356
357
|
}
|
|
357
358
|
|
|
358
|
-
export async function createResourceDetail(input, type, id) {
|
|
359
|
+
export async function createResourceDetail(input, type, id, options = {}) {
|
|
360
|
+
if (input?.entries && input?.root) return createResourceDetailFromLoaded(input, type, id, options);
|
|
359
361
|
return serializeWorkspaceMutation(input, async (root) => {
|
|
360
362
|
const validation = await validateWorkspace(root);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
363
|
+
return createResourceDetailFromLoaded(validation.loaded, type, id, options);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function createResourceDetailFromLoaded(loaded, type, id, options) {
|
|
368
|
+
const entry = loaded.entries.find(({ record }) => record.type === type && record.id === id);
|
|
369
|
+
if (!entry) return null;
|
|
370
|
+
const relativePath = `data/${entry.relativePath}`;
|
|
371
|
+
const histories = getWorkspaceHistories(loaded.root, [relativePath], 12, {
|
|
372
|
+
deadlineAt: options.historyDeadlineAt
|
|
373
|
+
});
|
|
374
|
+
return createStateEntry(loaded, entry, {
|
|
375
|
+
includeDetails: true,
|
|
376
|
+
history: histories.get(relativePath) ?? []
|
|
369
377
|
});
|
|
370
378
|
}
|
|
371
379
|
|