jinaga-server 3.7.4 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/degrees-of-freedom/.openskills.json +7 -0
- package/.claude/skills/degrees-of-freedom/SKILL.md +18 -0
- package/.claude/skills/degrees-of-freedom/degrees-of-freedom-constitution.md +142 -0
- package/.claude/skills/night-shift-setup/.openskills.json +7 -0
- package/.claude/skills/night-shift-setup/SKILL.md +95 -0
- package/.claude/skills/night-shift-setup/configuring-a-repository.md +111 -0
- package/.claude/skills/night-shift-setup/creating-the-log.md +59 -0
- package/.claude/skills/night-shift-setup/creating-the-routines.md +80 -0
- package/.claude/skills/night-shift-setup/log-model.factual +971 -0
- package/.claude/skills/night-shift-worker/.openskills.json +7 -0
- package/.claude/skills/night-shift-worker/SKILL.md +199 -0
- package/.claude/skills/night-shift-worker/night-shift-log.md +72 -0
- package/.claude/skills/night-shift-worker/scripts/register-stack.sh +173 -0
- package/.claude/skills/night-shift-worker/stacking.md +57 -0
- package/.claude/skills/night-shift-worker/verifying-a-merged-fix.md +16 -0
- package/.claude/skills/refining-issues/.openskills.json +7 -0
- package/.claude/skills/refining-issues/SKILL.md +327 -0
- package/.claude/skills/refining-issues/references/worked-example.md +234 -0
- package/.github/workflows/main.yml +2 -2
- package/.night-shift/config.md +84 -0
- package/.night-shift/skills.md +17 -0
- package/README.md +21 -0
- package/dist/http/router.js +207 -80
- package/dist/http/router.js.map +1 -1
- package/dist/http/stream.d.ts +0 -1
- package/dist/http/stream.js +6 -17
- package/dist/http/stream.js.map +1 -1
- package/dist/jinaga-server.js +74 -7
- package/dist/jinaga-server.js.map +1 -1
- package/integration-test/jinaga-test/package-lock.json +4 -4
- package/integration-test/jinaga-test/package.json +1 -1
- package/package.json +2 -2
- package/src/http/router.ts +249 -99
- package/src/http/stream.ts +21 -36
- package/src/jinaga-server.ts +92 -4
- package/test/http/errorStatusMappingSpec.ts +351 -0
- package/test/http/streamLogVolumeSpec.ts +284 -0
- package/test/jinaga-server/poolConfigurationSpec.ts +147 -0
- package/test/jinaga-server/tracePoolSpec.ts +368 -0
package/src/jinaga-server.ts
CHANGED
|
@@ -253,22 +253,82 @@ function getPool(uriOrPool: string | Pool, pools: { [uri: string]: Pool; }): Poo
|
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
type PoolSettingDeclaration = {
|
|
257
|
+
variable: string,
|
|
258
|
+
defaultValue: number,
|
|
259
|
+
minimum: number
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// The settings of a pool that this server builds for itself, each named by the
|
|
263
|
+
// environment variable that sets it. A pool the caller supplies is used as
|
|
264
|
+
// given, so none of these applies to it.
|
|
265
|
+
const poolSettingDeclarations = {
|
|
266
|
+
// A pool of zero connections can serve nothing, so one is the minimum.
|
|
267
|
+
max: { variable: "POSTGRES_POOL_MAX", defaultValue: 10, minimum: 1 },
|
|
268
|
+
idleTimeoutMillis: { variable: "POSTGRES_IDLE_TIMEOUT_MILLIS", defaultValue: 30000, minimum: 0 },
|
|
269
|
+
// Zero means wait forever for a connection to come free.
|
|
270
|
+
connectionTimeoutMillis: { variable: "POSTGRES_CONNECTION_TIMEOUT_MILLIS", defaultValue: 0, minimum: 0 }
|
|
271
|
+
} satisfies { [setting: string]: PoolSettingDeclaration };
|
|
272
|
+
|
|
273
|
+
type PoolSettings = {
|
|
274
|
+
[setting in keyof typeof poolSettingDeclarations]: number
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// The one rule by which every pool setting is read from the environment. An
|
|
278
|
+
// absent or empty variable takes the default; anything that is not an integer
|
|
279
|
+
// at or above the setting's minimum is refused rather than resolved, so the
|
|
280
|
+
// environment either names a pool that can be built or no pool at all.
|
|
281
|
+
function readPoolSetting({ variable, defaultValue, minimum }: PoolSettingDeclaration): number {
|
|
282
|
+
const value = process.env[variable];
|
|
283
|
+
if (value === undefined || value.trim() === '') {
|
|
284
|
+
return defaultValue;
|
|
285
|
+
}
|
|
286
|
+
const parsed = /^\d+$/.test(value.trim()) ? parseInt(value.trim(), 10) : NaN;
|
|
287
|
+
if (isNaN(parsed) || parsed < minimum) {
|
|
288
|
+
throw new Error(`The environment variable ${variable} is set to ${JSON.stringify(value)}, which is not an integer of at least ${minimum}.`);
|
|
289
|
+
}
|
|
290
|
+
return parsed;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function readPoolSettings(): PoolSettings {
|
|
294
|
+
return {
|
|
295
|
+
max: readPoolSetting(poolSettingDeclarations.max),
|
|
296
|
+
idleTimeoutMillis: readPoolSetting(poolSettingDeclarations.idleTimeoutMillis),
|
|
297
|
+
connectionTimeoutMillis: readPoolSetting(poolSettingDeclarations.connectionTimeoutMillis)
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
256
301
|
function createPool(postgresUri: string): Pool {
|
|
302
|
+
const settings = readPoolSettings();
|
|
257
303
|
const postgresPool = new Pool({
|
|
258
304
|
connectionString: postgresUri,
|
|
259
|
-
|
|
260
|
-
parseInt(process.env.POSTGRES_IDLE_TIMEOUT_MILLIS) :
|
|
261
|
-
30000,
|
|
305
|
+
...settings
|
|
262
306
|
});
|
|
263
307
|
|
|
308
|
+
Trace.info(`Postgres pool created with max ${settings.max}, idleTimeoutMillis ${settings.idleTimeoutMillis}, connectionTimeoutMillis ${settings.connectionTimeoutMillis}`);
|
|
309
|
+
|
|
264
310
|
tracePool(postgresPool);
|
|
265
311
|
|
|
266
312
|
return postgresPool;
|
|
267
313
|
}
|
|
268
314
|
|
|
315
|
+
const poolWindowMilliseconds = 10000;
|
|
316
|
+
|
|
269
317
|
export function tracePool(postgresPool: Pool) {
|
|
318
|
+
// Pool pressure is reported once per window of time rather than once per
|
|
319
|
+
// checkout, so the output rate no longer follows the acquire rate and no
|
|
320
|
+
// longer peaks when the pool is saturated. The window carries the largest
|
|
321
|
+
// waiting count it saw, so a spike that comes and goes inside one window
|
|
322
|
+
// still appears in the log.
|
|
323
|
+
let acquiredInWindow = false;
|
|
324
|
+
let peakWaiting = 0;
|
|
325
|
+
|
|
270
326
|
postgresPool.on('acquire', (client) => {
|
|
271
|
-
|
|
327
|
+
acquiredInWindow = true;
|
|
328
|
+
observeWaiting();
|
|
329
|
+
});
|
|
330
|
+
postgresPool.on('release', (err, client) => {
|
|
331
|
+
observeWaiting();
|
|
272
332
|
});
|
|
273
333
|
postgresPool.on('connect', (client) => {
|
|
274
334
|
Trace.metric('Postgres connected', poolMetrics());
|
|
@@ -280,6 +340,34 @@ export function tracePool(postgresPool: Pool) {
|
|
|
280
340
|
Trace.error(err);
|
|
281
341
|
});
|
|
282
342
|
|
|
343
|
+
// The window closes on a timer so that a storm which stops in the middle
|
|
344
|
+
// of a window still reports its peak. The timer is unreferenced, so it
|
|
345
|
+
// does not hold the process open, and it stops once the pool has ended.
|
|
346
|
+
const windowTimer = setInterval(closeWindow, poolWindowMilliseconds);
|
|
347
|
+
windowTimer.unref?.();
|
|
348
|
+
|
|
349
|
+
function observeWaiting() {
|
|
350
|
+
if (postgresPool.waitingCount > peakWaiting) {
|
|
351
|
+
peakWaiting = postgresPool.waitingCount;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function closeWindow() {
|
|
356
|
+
if (postgresPool.ended) {
|
|
357
|
+
clearInterval(windowTimer);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (acquiredInWindow) {
|
|
361
|
+
const metrics = poolMetrics();
|
|
362
|
+
Trace.metric('Postgres pool', { ...metrics, peakWaiting });
|
|
363
|
+
if (peakWaiting > 0) {
|
|
364
|
+
Trace.warn(`Postgres pool saturated: ${peakWaiting} waiting at peak, ${metrics.waiting} waiting of ${metrics.total} at window close.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
acquiredInWindow = false;
|
|
368
|
+
peakWaiting = 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
283
371
|
function poolMetrics() {
|
|
284
372
|
return {
|
|
285
373
|
total: postgresPool.totalCount,
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { AddressInfo } from "net";
|
|
3
|
+
import { Server } from "http";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Authorization,
|
|
7
|
+
AuthorizationRules,
|
|
8
|
+
buildModel,
|
|
9
|
+
dehydrateFact,
|
|
10
|
+
DistributionRules,
|
|
11
|
+
FactEnvelope,
|
|
12
|
+
FactManager,
|
|
13
|
+
FactReference,
|
|
14
|
+
FeedCache,
|
|
15
|
+
Invalid,
|
|
16
|
+
MemoryStore,
|
|
17
|
+
NetworkNoOp,
|
|
18
|
+
ObservableSource,
|
|
19
|
+
PassThroughFork,
|
|
20
|
+
PredecessorNotResolvedError,
|
|
21
|
+
User
|
|
22
|
+
} from "jinaga";
|
|
23
|
+
|
|
24
|
+
import { AuthorizationKeystore, SubscriptionAuthorizer } from "../../src/authorization/authorization-keystore";
|
|
25
|
+
import { HttpRouter, RequestUser } from "../../src/http/router";
|
|
26
|
+
import { MemoryKeystore } from "../../src/memory/memory-keystore";
|
|
27
|
+
|
|
28
|
+
class Workspace {
|
|
29
|
+
static Type = "ErrorStatus.Workspace" as const;
|
|
30
|
+
public type = Workspace.Type;
|
|
31
|
+
constructor(public owner: User, public identifier: string) { }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class Application {
|
|
35
|
+
static Type = "ErrorStatus.Application" as const;
|
|
36
|
+
public type = Application.Type;
|
|
37
|
+
constructor(public workspace: Workspace, public name: string) { }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const model = buildModel(b => b
|
|
41
|
+
.type(User)
|
|
42
|
+
.type(Workspace, w => w.predecessor("owner", User))
|
|
43
|
+
.type(Application, a => a.predecessor("workspace", Workspace))
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const ownerIdentity = { provider: "mock", id: "owner" };
|
|
47
|
+
|
|
48
|
+
const requestUser: RequestUser = {
|
|
49
|
+
provider: ownerIdentity.provider,
|
|
50
|
+
id: ownerIdentity.id,
|
|
51
|
+
profile: {} as any
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
async function createHarness() {
|
|
55
|
+
const store = new MemoryStore();
|
|
56
|
+
const keystore = new MemoryKeystore();
|
|
57
|
+
const ownerFact = await keystore.getOrCreateUserFact(ownerIdentity);
|
|
58
|
+
const owner = new User(ownerFact.fields.publicKey);
|
|
59
|
+
|
|
60
|
+
const factManager = new FactManager(
|
|
61
|
+
new PassThroughFork(store),
|
|
62
|
+
new ObservableSource(store),
|
|
63
|
+
store,
|
|
64
|
+
new NetworkNoOp(),
|
|
65
|
+
[]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const authorizationRules = new AuthorizationRules(model)
|
|
69
|
+
.any(User.Type)
|
|
70
|
+
.any(Workspace.Type)
|
|
71
|
+
.type(Application, app => app.workspace.owner);
|
|
72
|
+
const authorization = new AuthorizationKeystore(
|
|
73
|
+
factManager, store, keystore, authorizationRules, new DistributionRules([]));
|
|
74
|
+
const router = new HttpRouter(factManager, authorization, new FeedCache(), "*");
|
|
75
|
+
|
|
76
|
+
return { store, authorization, router, owner };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A batch holding a successor whose predecessors are in neither the batch nor
|
|
80
|
+
// storage. jinaga's AuthorizationEngine detects this in its topological sort
|
|
81
|
+
// and reports it as a plain Error.
|
|
82
|
+
function unclosedBatch(owner: User): FactEnvelope[] {
|
|
83
|
+
const records = dehydrateFact(new Application(new Workspace(owner, "workspace-1"), "application-1"));
|
|
84
|
+
return records
|
|
85
|
+
.filter(fact => fact.type === Application.Type)
|
|
86
|
+
.map(fact => ({ fact, signatures: [] }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function graphSourceOf(envelopes: FactEnvelope[]) {
|
|
90
|
+
return {
|
|
91
|
+
read: async (onEnvelopes: (envelopes: FactEnvelope[]) => Promise<void>) => {
|
|
92
|
+
await onEnvelopes(envelopes);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A router whose only job is to reject the authorization call with a chosen
|
|
98
|
+
// error, so the translation can be exercised for a route whose input grammar
|
|
99
|
+
// cannot provoke the error on its own.
|
|
100
|
+
function createRouterWithFailingSave(error: Error): HttpRouter {
|
|
101
|
+
const store = new MemoryStore();
|
|
102
|
+
const factManager = new FactManager(
|
|
103
|
+
new PassThroughFork(store), new ObservableSource(store), store, new NetworkNoOp(), []);
|
|
104
|
+
const keystore = new MemoryKeystore();
|
|
105
|
+
const authorization = {
|
|
106
|
+
getOrCreateUserFact: (identity: any) => keystore.getOrCreateUserFact(identity),
|
|
107
|
+
save: () => Promise.reject(error)
|
|
108
|
+
} as unknown as Authorization & SubscriptionAuthorizer;
|
|
109
|
+
return new HttpRouter(factManager, authorization, new FeedCache(), "*");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function captureError(action: () => Promise<unknown>): Promise<any> {
|
|
113
|
+
try {
|
|
114
|
+
await action();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return error;
|
|
117
|
+
}
|
|
118
|
+
throw new Error("Expected the operation to fail, but it succeeded.");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Issue #182 findings 1 and 2. These drive the real AuthorizationEngine rather
|
|
122
|
+
// than a hand-written message, so the classification is pinned to what jinaga
|
|
123
|
+
// actually throws. That mattered concretely: the original version of this
|
|
124
|
+
// translation matched the engine's prose, and 6.11.3 reworded it one release
|
|
125
|
+
// later — these tests failed rather than letting the 400 regress to a 500 in
|
|
126
|
+
// production. The unresolved-predecessor path is now an instanceof check on
|
|
127
|
+
// the typed error jinaga.js#234 introduced.
|
|
128
|
+
describe("save authorization error translation", () => {
|
|
129
|
+
it("reports an unresolved predecessor from the real engine as a typed error", async () => {
|
|
130
|
+
const { authorization, owner } = await createHarness();
|
|
131
|
+
|
|
132
|
+
const error = await captureError(() =>
|
|
133
|
+
authorization.save(ownerIdentity, unclosedBatch(owner)));
|
|
134
|
+
|
|
135
|
+
// The untranslated baseline: still not an Invalid, so handleError alone
|
|
136
|
+
// would map it to 500 — but now it carries the structured detail the
|
|
137
|
+
// translation reads instead of parsing prose.
|
|
138
|
+
expect(error).toBeInstanceOf(PredecessorNotResolvedError);
|
|
139
|
+
expect(error).not.toBeInstanceOf(Invalid);
|
|
140
|
+
expect(error.missingPredecessors.map((r: FactReference) => r.type)).toContain(Workspace.Type);
|
|
141
|
+
expect(error.unresolvedFacts.map((r: FactReference) => r.type)).toContain(Application.Type);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("translates the unresolved predecessor to Invalid on /save", async () => {
|
|
145
|
+
const { router, owner } = await createHarness();
|
|
146
|
+
|
|
147
|
+
const error = await captureError(() =>
|
|
148
|
+
(router as any).save(requestUser, graphSourceOf(unclosedBatch(owner))));
|
|
149
|
+
|
|
150
|
+
expect(error).toBeInstanceOf(Invalid);
|
|
151
|
+
// Names the predecessor that is actually missing — the structured
|
|
152
|
+
// fields make this possible, where the prose match could only report
|
|
153
|
+
// the successors that failed to sort.
|
|
154
|
+
expect(error.message).toContain(Workspace.Type);
|
|
155
|
+
expect(error.message).toContain("was not found in storage and was not included in the request");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("translates the unresolved predecessor to Invalid on /write", async () => {
|
|
159
|
+
// The declaration grammar /write accepts cannot express a dangling
|
|
160
|
+
// predecessor, so the engine error is captured from the reachable
|
|
161
|
+
// /save path and replayed here. The route still needs the translation:
|
|
162
|
+
// it makes the same authorization call, and this keeps both routes
|
|
163
|
+
// reading one upstream error type.
|
|
164
|
+
const { authorization, owner } = await createHarness();
|
|
165
|
+
const engineError = await captureError(() =>
|
|
166
|
+
authorization.save(ownerIdentity, unclosedBatch(owner)));
|
|
167
|
+
|
|
168
|
+
const router = createRouterWithFailingSave(engineError);
|
|
169
|
+
const declaration =
|
|
170
|
+
`let workspace: ${Workspace.Type} = { owner: me, identifier: "workspace-1" }\n` +
|
|
171
|
+
`let application: ${Application.Type} = { workspace: workspace, name: "application-1" }\n`;
|
|
172
|
+
|
|
173
|
+
const error = await captureError(() => (router as any).write(requestUser, declaration));
|
|
174
|
+
|
|
175
|
+
expect(error).toBeInstanceOf(Invalid);
|
|
176
|
+
expect(error.message).toContain("was not found in storage and was not included in the request");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("still translates the authorization-rule shape fixed by issue #175", async () => {
|
|
180
|
+
const router = createRouterWithFailingSave(
|
|
181
|
+
new Error("The fact ErrorStatus.Workspace:abc123 is not defined."));
|
|
182
|
+
|
|
183
|
+
const error = await captureError(() =>
|
|
184
|
+
(router as any).save(requestUser, graphSourceOf([])));
|
|
185
|
+
|
|
186
|
+
expect(error).toBeInstanceOf(Invalid);
|
|
187
|
+
expect(error.message).toContain("ErrorStatus.Workspace:abc123 is required to authorize this save");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Issue #182 findings 3 and 4, exercised over real HTTP so the assertions are
|
|
192
|
+
// on the status line a client actually receives.
|
|
193
|
+
describe("HTTP status mapping", () => {
|
|
194
|
+
let server: Server;
|
|
195
|
+
let baseUrl: string;
|
|
196
|
+
|
|
197
|
+
async function listen(app: express.Express) {
|
|
198
|
+
server = app.listen(0);
|
|
199
|
+
await new Promise<void>(resolve => server.once("listening", () => resolve()));
|
|
200
|
+
const port = (server.address() as AddressInfo).port;
|
|
201
|
+
baseUrl = `http://127.0.0.1:${port}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
afterEach(async () => {
|
|
205
|
+
if (server) {
|
|
206
|
+
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("with no body parser registered", () => {
|
|
211
|
+
beforeEach(async () => {
|
|
212
|
+
const { router } = await createHarness();
|
|
213
|
+
const app = express();
|
|
214
|
+
app.use(router.handler);
|
|
215
|
+
await listen(app);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Each of these parses the body synchronously in the handler. Express
|
|
219
|
+
// used to hand the throw to its default error handler, reporting the
|
|
220
|
+
// client's malformed request as a 500.
|
|
221
|
+
it.each([
|
|
222
|
+
["/write", "text/plain"],
|
|
223
|
+
["/read", "text/plain"],
|
|
224
|
+
["/feeds", "text/plain"],
|
|
225
|
+
["/load", "application/json"]
|
|
226
|
+
])("answers 400 for %s when the body cannot be read", async (path, contentType) => {
|
|
227
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: { "Content-Type": contentType },
|
|
230
|
+
body: contentType === "application/json" ? "{}" : "anything"
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(response.status).toBe(400);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// The text/plain-only routes name the parser the caller is missing,
|
|
237
|
+
// rather than falling back to parseString's generic content-type
|
|
238
|
+
// message.
|
|
239
|
+
it.each(["/write", "/read"])("names express.text() in the 400 body for %s", async (path) => {
|
|
240
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "Content-Type": "text/plain" },
|
|
243
|
+
body: "anything"
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(await response.text()).toContain("express.text()");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Saying what arrived tells the caller which end is misconfigured.
|
|
250
|
+
it("names the Content-Type that was received", async () => {
|
|
251
|
+
const response = await fetch(`${baseUrl}/write`, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: { "Content-Type": "application/json" },
|
|
254
|
+
body: "{}"
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(await response.text()).toContain("Received Content-Type: application/json");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("says so when no Content-Type was received", async () => {
|
|
261
|
+
const response = await fetch(`${baseUrl}/write`, {
|
|
262
|
+
method: "POST",
|
|
263
|
+
body: new Blob(["anything"])
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(await response.text()).toContain("No Content-Type header was received");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// The echoed Content-Type is client-supplied, so it must not be able to
|
|
270
|
+
// carry markup into the body.
|
|
271
|
+
it("neutralizes markup in the echoed Content-Type", async () => {
|
|
272
|
+
const response = await fetch(`${baseUrl}/write`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: { "Content-Type": "text/plain<script>alert(1)</script>" },
|
|
275
|
+
body: "anything"
|
|
276
|
+
});
|
|
277
|
+
const body = await response.text();
|
|
278
|
+
|
|
279
|
+
expect(response.status).toBe(400);
|
|
280
|
+
expect(body).not.toContain("<script>");
|
|
281
|
+
expect(body).toContain("<script>");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("caps the length of the echoed Content-Type", async () => {
|
|
285
|
+
const response = await fetch(`${baseUrl}/write`, {
|
|
286
|
+
method: "POST",
|
|
287
|
+
headers: { "Content-Type": `text/plain;padding=${"x".repeat(500)}` },
|
|
288
|
+
body: "anything"
|
|
289
|
+
});
|
|
290
|
+
const body = await response.text();
|
|
291
|
+
|
|
292
|
+
expect(response.status).toBe(400);
|
|
293
|
+
expect(body).toContain("…");
|
|
294
|
+
expect(body.length).toBeLessThan(300);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("marks error responses nosniff", async () => {
|
|
298
|
+
const response = await fetch(`${baseUrl}/write`, {
|
|
299
|
+
method: "POST",
|
|
300
|
+
headers: { "Content-Type": "text/plain" },
|
|
301
|
+
body: "anything"
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
expect(response.status).toBe(400);
|
|
305
|
+
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("answers 500 without echoing the internal error message", async () => {
|
|
310
|
+
const store = new MemoryStore();
|
|
311
|
+
const factManager = new FactManager(
|
|
312
|
+
new PassThroughFork(store), new ObservableSource(store), store, new NetworkNoOp(), []);
|
|
313
|
+
const secret = "connection to postgres://internal-host:5432 refused";
|
|
314
|
+
const failingAuthorization = {
|
|
315
|
+
load: () => Promise.reject(new Error(secret))
|
|
316
|
+
} as unknown as Authorization & SubscriptionAuthorizer;
|
|
317
|
+
const router = new HttpRouter(factManager, failingAuthorization, new FeedCache(), "*");
|
|
318
|
+
|
|
319
|
+
const app = express();
|
|
320
|
+
app.use(express.json());
|
|
321
|
+
app.use(router.handler);
|
|
322
|
+
await listen(app);
|
|
323
|
+
|
|
324
|
+
const response = await fetch(`${baseUrl}/load`, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: { "Content-Type": "application/json" },
|
|
327
|
+
body: JSON.stringify({ references: [] })
|
|
328
|
+
});
|
|
329
|
+
const body = await response.text();
|
|
330
|
+
|
|
331
|
+
expect(response.status).toBe(500);
|
|
332
|
+
expect(body).toBe("Internal server error");
|
|
333
|
+
expect(body).not.toContain("internal-host");
|
|
334
|
+
// A plain-text body labelled text/html invites a browser to render it.
|
|
335
|
+
expect(response.headers.get("content-type")).toContain("text/plain");
|
|
336
|
+
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("answers 404 with a distinguishable body for a feed route miss", async () => {
|
|
340
|
+
const { router } = await createHarness();
|
|
341
|
+
const app = express();
|
|
342
|
+
app.use(router.handler);
|
|
343
|
+
await listen(app);
|
|
344
|
+
|
|
345
|
+
// A hash the feed cache has never seen resolves to FeedNotFound, which
|
|
346
|
+
// is a different 404 from the route miss below.
|
|
347
|
+
const cacheMiss = await fetch(`${baseUrl}/feeds/unknownhash`);
|
|
348
|
+
expect(cacheMiss.status).toBe(404);
|
|
349
|
+
expect(await cacheMiss.text()).toBe("feed_not_found");
|
|
350
|
+
});
|
|
351
|
+
});
|