jinaga-server 3.7.3 → 3.7.5

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.
@@ -22,6 +22,7 @@ import {
22
22
  LoadResponse,
23
23
  parseLoadMessage,
24
24
  parseSaveMessage,
25
+ PredecessorNotResolvedError,
25
26
  ProfileMessage,
26
27
  ProjectedResult,
27
28
  ReferencesByName,
@@ -58,7 +59,7 @@ function getOrStream<U>(
58
59
  .then(response => {
59
60
  if (!response) {
60
61
  console.log(`[HttpConnection:${connectionId}] Stream method returned null - sending 404`);
61
- res.sendStatus(404);
62
+ sendRouteMiss(res);
62
63
  next();
63
64
  }
64
65
  else {
@@ -156,7 +157,7 @@ function getOrStream<U>(
156
157
  .then(response => {
157
158
  if (!response) {
158
159
  console.log(`[HttpConnection:${connectionId}] Get method returned null - sending 404`);
159
- res.sendStatus(404);
160
+ sendRouteMiss(res);
160
161
  next();
161
162
  }
162
163
  else {
@@ -199,9 +200,14 @@ function post<T, U>(
199
200
  ): Handler {
200
201
  return (req, res, next) => {
201
202
  const user = <RequestUser>(req as any).user;
202
- const message = parse(req.body);
203
+ const parsed = parseRequestInput(() => parse(req.body), req, res, next);
204
+ if (!parsed.ok) {
205
+ return;
206
+ }
207
+ const message = parsed.value;
203
208
  if (!message) {
204
- throw new Error('Ensure that you have called app.use(express.json()).');
209
+ handleError(new Invalid(`${MISSING_BODY} ${describeContentType(req)}`), req, res, next);
210
+ return;
205
211
  }
206
212
  method(user, message, req.params)
207
213
  .then(response => {
@@ -224,8 +230,11 @@ function postCreate<T>(
224
230
  ): Handler {
225
231
  return (req, res, next) => {
226
232
  const user = <RequestUser>(req as any).user;
227
- const message = parse(req);
228
- method(user, message)
233
+ const parsed = parseRequestInput(() => parse(req), req, res, next);
234
+ if (!parsed.ok) {
235
+ return;
236
+ }
237
+ method(user, parsed.value)
229
238
  .then(_ => {
230
239
  res.sendStatus(201);
231
240
  next();
@@ -237,19 +246,16 @@ function postCreate<T>(
237
246
  function postStringCreate(method: (user: RequestUser, message: string) => Promise<void>): Handler {
238
247
  return (req, res, next) => {
239
248
  const user = <RequestUser>(req as any).user;
240
- const input = parseString(req.body);
241
- if (!input || typeof (input) !== 'string') {
242
- res.type("text");
243
- res.status(500).send('Expected Content-Type text/plain. Ensure that you have called app.use(express.text()).');
244
- }
245
- else {
246
- method(user, input)
247
- .then(_ => {
248
- res.sendStatus(201);
249
- next();
250
- })
251
- .catch(error => handleError(error, req, res, next));
249
+ const parsed = parseRequestInput(() => parseTextBody(req), req, res, next);
250
+ if (!parsed.ok) {
251
+ return;
252
252
  }
253
+ method(user, parsed.value)
254
+ .then(_ => {
255
+ res.sendStatus(201);
256
+ next();
257
+ })
258
+ .catch(error => handleError(error, req, res, next));
253
259
  };
254
260
  }
255
261
 
@@ -261,29 +267,27 @@ function postReadWithStreaming(
261
267
  ): Handler {
262
268
  return (req, res, next) => {
263
269
  const user = <RequestUser>(req as any).user;
264
- const input = parseString(req.body);
265
- if (!input || typeof (input) !== 'string') {
266
- res.type("text");
267
- res.status(500).send('Expected Content-Type text/plain. Ensure that you have called app.use(express.text()).');
270
+ const parsed = parseRequestInput(() => parseTextBody(req), req, res, next);
271
+ if (!parsed.ok) {
272
+ return;
268
273
  }
269
- else {
270
- // Check if Accept header explicitly prefers a specific format
271
- const acceptHeader = req.get('Accept');
272
- let acceptType: string = 'text/plain'; // Default for backward compatibility
273
-
274
- if (acceptHeader && acceptHeader !== '*/*') {
275
- // Only use req.accepts() when there's a specific preference
276
- const preferredType = req.accepts(['text/csv', 'application/x-ndjson', 'application/json', 'text/plain']);
277
- acceptType = preferredType ? String(preferredType) : 'text/plain';
278
- }
279
-
280
- method(user, input, acceptType)
281
- .then(({resultStream, csvMetadata}) => {
282
- outputReadResults(resultStream, res, acceptType, csvMetadata);
283
- next();
284
- })
285
- .catch(error => handleError(error, req, res, next));
274
+
275
+ // Check if Accept header explicitly prefers a specific format
276
+ const acceptHeader = req.get('Accept');
277
+ let acceptType: string = 'text/plain'; // Default for backward compatibility
278
+
279
+ if (acceptHeader && acceptHeader !== '*/*') {
280
+ // Only use req.accepts() when there's a specific preference
281
+ const preferredType = req.accepts(['text/csv', 'application/x-ndjson', 'application/json', 'text/plain']);
282
+ acceptType = preferredType ? String(preferredType) : 'text/plain';
286
283
  }
284
+
285
+ method(user, parsed.value, acceptType)
286
+ .then(({resultStream, csvMetadata}) => {
287
+ outputReadResults(resultStream, res, acceptType, csvMetadata);
288
+ next();
289
+ })
290
+ .catch(error => handleError(error, req, res, next));
287
291
  };
288
292
  }
289
293
 
@@ -313,7 +317,7 @@ function inputSaveMessage(req: Request): GraphSource {
313
317
  read: async (onEnvelopes) => {
314
318
  const message = parseSaveMessage(req.body);
315
319
  if (!message) {
316
- throw new Error('Ensure that you have called app.use(express.json()).');
320
+ throw new Invalid(MISSING_JSON_BODY);
317
321
  }
318
322
  await onEnvelopes(message.facts.map(fact => ({
319
323
  fact: fact,
@@ -358,7 +362,9 @@ function outputReadResults(
358
362
  .catch(error => {
359
363
  console.error('Error in outputReadResults:', error);
360
364
  if (!res.headersSent) {
361
- res.status(500).send('Internal server error');
365
+ // Without an explicit type, res.send of a string makes Express
366
+ // infer text/html and label an error body as markup.
367
+ res.type("text").status(500).send('Internal server error');
362
368
  }
363
369
  });
364
370
  }
@@ -550,11 +556,19 @@ export class HttpRouter {
550
556
  }
551
557
 
552
558
  const userIdentity = serializeUserIdentity(user);
553
- await this.authorization.save(userIdentity, factRecords
559
+ const envelopes: FactEnvelope[] = factRecords
554
560
  .map(fact => ({
555
561
  fact: fact,
556
562
  signatures: []
557
- })));
563
+ }));
564
+ try {
565
+ await this.authorization.save(userIdentity, envelopes);
566
+ } catch (error) {
567
+ // /write reaches the same authorization path as /save, so it
568
+ // needs the same translation of predecessor-resolution
569
+ // failures into a diagnosable 4xx (issue #182 finding 2).
570
+ throw toSaveAuthorizationError(error, envelopes);
571
+ }
558
572
  });
559
573
  }
560
574
 
@@ -988,34 +1002,174 @@ export class FeedNotFound extends Error {
988
1002
  }
989
1003
  }
990
1004
 
1005
+ // Both message shapes below are matched by prose, because jinaga reports a
1006
+ // missing predecessor as a plain Error. That is fragile: an upstream wording
1007
+ // change silently turns these back into 500s (issue #182 finding 5). The
1008
+ // end-to-end cases in test/http/errorStatusMappingSpec.ts drive the real
1009
+ // AuthorizationEngine so that drift fails the build.
1010
+ //
1011
+ // jinaga 6.11.3 landed the typed errors of jinaga.js#234, so the topological
1012
+ // sort's failure is now an instanceof check below. One prose pattern remains:
1013
+ // specification-runner still reports its missing fact as a plain Error.
1014
+
991
1015
  // Authorization rule evaluation reports a missing predecessor as a plain
992
- // Error of this shape (see authorizationRules.ts / specification-runner.ts
993
- // in jinaga). It surfaces when the client's save request didn't include the
994
- // full closure of facts a rule needs to walk. Map it to a diagnosable 4xx
995
- // instead of an opaque 500 (issue #175).
1016
+ // Error of this shape (specification-runner.ts in jinaga, still untyped as of
1017
+ // 6.11.3). It surfaces when the client's save request didn't include the full
1018
+ // closure of facts a rule needs to walk. Map it to a diagnosable 4xx instead
1019
+ // of an opaque 500 (issue #175).
996
1020
  const FACT_NOT_DEFINED_PATTERN = /^The fact (\S+):(\S+) is not defined\.$/;
997
1021
 
998
1022
  function toSaveAuthorizationError(error: any, envelopes: FactEnvelope[]): any {
999
- if (error instanceof Error) {
1000
- const match = FACT_NOT_DEFINED_PATTERN.exec(error.message);
1001
- if (match) {
1002
- const [, factType, factHash] = match;
1003
- const factTypesInBatch = Array.from(new Set(envelopes.map(e => e.fact.type))).join(", ");
1004
- Trace.warn(
1005
- `Save authorization could not resolve predecessor ${factType}:${factHash} ` +
1006
- `among ${envelopes.length} fact(s) in the request (types: ${factTypesInBatch}).`
1023
+ if (!(error instanceof Error)) {
1024
+ return error;
1025
+ }
1026
+
1027
+ const describeBatch = () => {
1028
+ const factTypesInBatch = Array.from(new Set(envelopes.map(e => e.fact.type))).join(", ");
1029
+ return `${envelopes.length} fact(s) in the request (types: ${factTypesInBatch})`;
1030
+ };
1031
+
1032
+ // The batch named a predecessor that is in neither the batch nor storage,
1033
+ // so the topological sort could never reach the facts that depend on it.
1034
+ // A client data problem, not a server fault (issue #182 finding 1).
1035
+ if (error instanceof PredecessorNotResolvedError) {
1036
+ const missing = error.missingPredecessors.map(r => `${r.type}:${r.hash}`);
1037
+ const unresolved = error.unresolvedFacts.map(r => `${r.type}:${r.hash}`);
1038
+ Trace.warn(
1039
+ `Save authorization could not resolve ${missing.length} predecessor(s) [${missing.join(", ")}] ` +
1040
+ `needed by ${unresolved.length} fact(s) [${unresolved.join(", ")}] among ${describeBatch()}.`
1041
+ );
1042
+ const advice = `Send the full closure of predecessors together, or commit the predecessors first.`;
1043
+ if (missing.length === 1) {
1044
+ return new Invalid(
1045
+ `The fact ${missing[0]} is named as a predecessor, but it was not found in storage ` +
1046
+ `and was not included in the request. ${advice}`
1007
1047
  );
1048
+ }
1049
+ if (missing.length > 1) {
1008
1050
  return new Invalid(
1009
- `The fact ${factType}:${factHash} is required to authorize this save but was not included in the request. ` +
1010
- `Ensure the full closure of predecessors needed by your authorization rules is sent together.`
1051
+ `${missing.length} facts are named as predecessors, but they were not found in storage ` +
1052
+ `and were not included in the request: ${missing.join(", ")}. ${advice}`
1011
1053
  );
1012
1054
  }
1055
+ // The engine could not attribute the failure to specific references.
1056
+ return new Invalid(
1057
+ `${unresolved.length} fact(s) could not be authorized because one or more predecessors ` +
1058
+ `were not found in storage and were not included in the request. ${advice}`
1059
+ );
1013
1060
  }
1061
+
1062
+ const notDefined = FACT_NOT_DEFINED_PATTERN.exec(error.message);
1063
+ if (notDefined) {
1064
+ const [, factType, factHash] = notDefined;
1065
+ Trace.warn(
1066
+ `Save authorization could not resolve predecessor ${factType}:${factHash} ` +
1067
+ `among ${describeBatch()}.`
1068
+ );
1069
+ return new Invalid(
1070
+ `The fact ${factType}:${factHash} is required to authorize this save but was not included in the request. ` +
1071
+ `Ensure the full closure of predecessors needed by your authorization rules is sent together.`
1072
+ );
1073
+ }
1074
+
1014
1075
  return error;
1015
1076
  }
1016
1077
 
1078
+ // post() serves both a JSON route (/load) and a text route (/feeds), so its
1079
+ // message names no particular parser; the single-content-type entry points
1080
+ // keep the specific hint they have always given.
1081
+ const MISSING_BODY = 'The request body is empty or could not be read. Ensure that the body parser for this Content-Type is registered.';
1082
+ const MISSING_JSON_BODY = 'Ensure that you have called app.use(express.json()).';
1083
+ const MISSING_TEXT_BODY = 'Expected Content-Type text/plain. Ensure that you have called app.use(express.text()).';
1084
+
1085
+ // A request body that fails to parse is a client error. Both this module's
1086
+ // parseString and jinaga's message parsers signal that by throwing
1087
+ // synchronously from the handler, where Express hands it to its default error
1088
+ // handler and reports 500. Classify parse failures as Invalid so handleError
1089
+ // renders a 400 (issue #182 finding 3).
1090
+ function toInvalidInput(error: any): Invalid {
1091
+ if (error instanceof Invalid) {
1092
+ return error;
1093
+ }
1094
+ return new Invalid(error instanceof Error ? error.message : String(error));
1095
+ }
1096
+
1097
+ // /read and /write accept text/plain only, so every unreadable body there has
1098
+ // the same cause and deserves the same actionable hint. Checking req.body here
1099
+ // rather than deferring to parseString keeps a missing body parser — the common
1100
+ // misconfiguration — from being reported with parseString's generic
1101
+ // content-type message.
1102
+ function parseTextBody(req: Request): string {
1103
+ const body = req.body;
1104
+ if (typeof body !== 'string' || !body) {
1105
+ throw new Invalid(`${MISSING_TEXT_BODY} ${describeContentType(req)}`);
1106
+ }
1107
+ return body;
1108
+ }
1109
+
1110
+ // Naming what arrived turns "check your setup" into something the caller can
1111
+ // act on without guessing which end is misconfigured. The value is echoed from
1112
+ // the request, so cap it: a real content type is short, and the body should
1113
+ // not grow with whatever the caller chose to send.
1114
+ const MAX_ECHOED_CONTENT_TYPE = 80;
1115
+
1116
+ function describeContentType(req: Request): string {
1117
+ const contentType = req.get('Content-Type');
1118
+ if (!contentType) {
1119
+ return `No Content-Type header was received.`;
1120
+ }
1121
+ const echoed = contentType.length > MAX_ECHOED_CONTENT_TYPE
1122
+ ? `${contentType.substring(0, MAX_ECHOED_CONTENT_TYPE)}…`
1123
+ : contentType;
1124
+ return `Received Content-Type: ${echoed}.`;
1125
+ }
1126
+
1127
+ function parseRequestInput<T>(
1128
+ parse: () => T,
1129
+ req: Request,
1130
+ res: Response,
1131
+ next: NextFunction
1132
+ ): { ok: true, value: T } | { ok: false } {
1133
+ try {
1134
+ return { ok: true, value: parse() };
1135
+ } catch (error) {
1136
+ handleError(toInvalidInput(error), req, res, next);
1137
+ return { ok: false };
1138
+ }
1139
+ }
1140
+
1141
+ // A route miss (no :hash in the URL) is a 404 just like a feed-cache miss,
1142
+ // but for a different reason. Send a machine-readable body so a client can
1143
+ // always tell "wrong URL" from "re-subscribe via POST /feeds" (issue #182
1144
+ // finding 6); handleError sends "feed_not_found" for the latter.
1145
+ function sendRouteMiss(res: Response) {
1146
+ res.type("text");
1147
+ res.status(404).send("not_found");
1148
+ }
1149
+
1150
+ // These bodies are diagnostics, not markup, but they quote client-supplied
1151
+ // text: a Content-Type header, a fact type named in a parse failure. The
1152
+ // response is text/plain and marked nosniff, yet that is the content type
1153
+ // saying so — neutralize the characters that could start markup here, at the
1154
+ // one place every error body is written, so the guarantee does not rest on a
1155
+ // header a proxy or a later edit could change. Quotes are left alone: they
1156
+ // only matter inside a tag, which escaping '<' already prevents, and messages
1157
+ // quote identifiers routinely.
1158
+ const HTML_META_CHARACTERS: { [character: string]: string } = {
1159
+ "&": "&amp;",
1160
+ "<": "&lt;",
1161
+ ">": "&gt;"
1162
+ };
1163
+
1164
+ function sanitizeErrorBody(message: string): string {
1165
+ return message.replace(/[&<>]/g, character => HTML_META_CHARACTERS[character]);
1166
+ }
1167
+
1017
1168
  function handleError(error: any, req: Request, res: Response, next: NextFunction) {
1018
1169
  const requestPath = req.path;
1170
+ // Error bodies are plain text built partly from client-supplied input, so
1171
+ // stop a browser from sniffing one as markup.
1172
+ res.set("X-Content-Type-Options", "nosniff");
1019
1173
  if (error instanceof FeedNotFound) {
1020
1174
  Trace.warn(`Feed not found: ${error.feedHash} (Path: ${requestPath})`);
1021
1175
  res.type("text");
@@ -1023,15 +1177,19 @@ function handleError(error: any, req: Request, res: Response, next: NextFunction
1023
1177
  } else if (error instanceof Forbidden) {
1024
1178
  Trace.warn(`Forbidden: ${error.message} (Path: ${requestPath})`);
1025
1179
  res.type("text");
1026
- res.status(403).send(error.message);
1180
+ res.status(403).send(sanitizeErrorBody(error.message));
1027
1181
  } else if (error instanceof Invalid) {
1028
1182
  Trace.warn(`Invalid: ${error.message} (Path: ${requestPath})`);
1029
1183
  res.type("text");
1030
- res.status(400).send(error.message);
1184
+ res.status(400).send(sanitizeErrorBody(error.message));
1031
1185
  } else {
1186
+ // Classification above is an allow-list, so anything reaching here is
1187
+ // unclassified — including genuine internal failures whose message can
1188
+ // name internal hosts or credentials. Keep the detail in the trace and
1189
+ // send a generic body to the caller (issue #182 finding 4).
1032
1190
  Trace.error(`Error: ${error.message} (Path: ${requestPath})`);
1033
1191
  res.type("text");
1034
- res.status(500).send(error.message);
1192
+ res.status(500).send("Internal server error");
1035
1193
  }
1036
1194
  next();
1037
1195
  }