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.
- package/.github/workflows/main.yml +1 -1
- package/.github/workflows/publish.yml +8 -6
- package/.github/workflows/upgrade-jinaga.yml +3 -3
- package/dist/http/router.js +188 -57
- package/dist/http/router.js.map +1 -1
- package/package.json +2 -2
- package/src/http/router.ts +218 -60
- package/test/http/errorStatusMappingSpec.ts +351 -0
- package/test/postgres/userGivenFeedSqlSpec.ts +115 -0
package/src/http/router.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
228
|
-
|
|
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
|
|
241
|
-
if (!
|
|
242
|
-
|
|
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
|
|
265
|
-
if (!
|
|
266
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
//
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1010
|
-
`
|
|
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
|
+
"&": "&",
|
|
1160
|
+
"<": "<",
|
|
1161
|
+
">": ">"
|
|
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
|
|
1192
|
+
res.status(500).send("Internal server error");
|
|
1035
1193
|
}
|
|
1036
1194
|
next();
|
|
1037
1195
|
}
|