filegrc 0.12.3 → 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.
@@ -819,7 +819,8 @@ function readRevisionRecord(root, revision, path) {
819
819
  if (!path) return null;
820
820
  try {
821
821
  return JSON.parse(readRevisionSource(root, revision, path));
822
- } catch {
822
+ } catch (error) {
823
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
823
824
  return null;
824
825
  }
825
826
  }
@@ -832,7 +833,8 @@ function readRevisionSource(root, revision, path, historyIndex = null) {
832
833
  ? historyIndex.parentsByCommit.get(parentMatch[1])?.[0] || null
833
834
  : /^[a-f0-9]{40}$/i.test(String(revision)) ? String(revision) : runGit(root, ["rev-parse", `${revision}^{commit}`]);
834
835
  return commit ? getFileAtRevision(root, commit, path) || "" : "";
835
- } catch {
836
+ } catch (error) {
837
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
836
838
  return "";
837
839
  }
838
840
  }
@@ -1128,7 +1130,8 @@ function readHeadRecord(root, path, revision = null) {
1128
1130
  if (!path) return null;
1129
1131
  try {
1130
1132
  return JSON.parse(readHeadSource(root, path, revision));
1131
- } catch {
1133
+ } catch (error) {
1134
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1132
1135
  return null;
1133
1136
  }
1134
1137
  }
@@ -1137,7 +1140,8 @@ function readHeadSource(root, path, revision = null) {
1137
1140
  try {
1138
1141
  const commit = revision || gitRevision(root);
1139
1142
  return commit ? getFileAtRevision(root, commit, path) || "" : "";
1140
- } catch {
1143
+ } catch (error) {
1144
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1141
1145
  return "";
1142
1146
  }
1143
1147
  }
@@ -1152,6 +1156,7 @@ function runGit(root, args, deadline) {
1152
1156
  if (deadline && timeoutMs <= 0) throw new Error("Git reconciliation history exceeded its cumulative deadline.");
1153
1157
  return runGitCommandSync(root, args, timeoutMs ? { timeoutMs } : {});
1154
1158
  } catch (error) {
1159
+ if (error?.code === "FILEGRC_GIT_DEADLINE") throw error;
1155
1160
  if (deadline && performance.now() >= deadline) {
1156
1161
  const timeoutError = new Error("Git reconciliation history exceeded its cumulative deadline.", { cause: error });
1157
1162
  timeoutError.code = "FILEGRC_HISTORY_DEADLINE";
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
- let nextStateSession = 0;
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 = { ...options, programId: url.searchParams.get("programId") || undefined };
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
- const loaded = await serializeWorkspaceMutation(input, (root) => loadWorkspace(root));
117
- const token = `${Date.now().toString(36)}-${(++nextStateSession).toString(36)}`;
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
- promises: new Map()
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 > 8) stateSessions.delete(stateSessions.keys().next().value);
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 state = await loadStateSessionSection(session, section, options, url.searchParams.get("programId") || undefined);
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
- const result = await retryBrowserSync(input, {
518
- allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
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 entry = await createResourceDetail(input, type, id);
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 (url.searchParams.get("workflow") === "true") {
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
- const result = await measureTiming("mutation", () => runBrowserMutation(root, {
703
- ...mutationOptions,
704
- allowNonAuthoritativeWrites: requestOptions.allowNonAuthoritativeWrites === true,
705
- backgroundPushDelayMs: requestOptions.backgroundPushDelayMs,
706
- includeValidationProof: !fastResponse
707
- }, task));
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 cacheKey = `${section}:${programId || "default"}`;
757
- if (session.promises.has(cacheKey)) return session.promises.get(cacheKey);
758
- const dependencies = section === "workflow"
759
- ? Promise.all([
760
- loadStateSessionSection(session, "repository", serverOptions, programId),
761
- loadStateSessionSection(session, "program", serverOptions, programId),
762
- loadStateSessionSection(session, "obligations", serverOptions, programId),
763
- loadStateSessionSection(session, "audits", serverOptions, programId)
764
- ])
765
- : section === "audits"
766
- ? Promise.all([loadStateSessionSection(session, "program", serverOptions, programId)])
767
- : Promise.resolve([]);
768
- const promise = dependencies.then((results) => {
769
- const repository = section === "workflow" ? results[0] : null;
770
- const program = section === "workflow" ? results[1] : section === "audits" ? results[0] : null;
771
- const obligations = section === "workflow" ? results[2] : null;
772
- const audits = section === "workflow" ? results[3] : null;
773
- return createAppStateSection(session.loaded, section, {
774
- allowNonAuthoritativeWrites: serverOptions.allowNonAuthoritativeWrites,
775
- generatedAt: session.generatedAt,
776
- programReadiness: program?.programReadiness,
777
- auditPreparations: audits?.auditPreparations,
778
- obligations: obligations?.obligations,
779
- git: repository?.git,
780
- validation: repository?.validation,
781
- programId
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
- }).catch((error) => {
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
- const result = await task();
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
- const entry = validation.loaded.entries.find(({ record }) => record.type === type && record.id === id);
362
- if (!entry) return null;
363
- const relativePath = `data/${entry.relativePath}`;
364
- const histories = getWorkspaceHistories(validation.loaded.root, [relativePath], 12);
365
- return createStateEntry(validation.loaded, entry, {
366
- includeDetails: true,
367
- history: histories.get(relativePath) ?? []
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