Repository navigation
Expand file tree
/
Copy pathapiOnlyService.ts
More file actions
128 lines (113 loc) · 3.6 KB
/
Copy pathapiOnlyService.ts
File metadata and controls
128 lines (113 loc) · 3.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
import type { RequestHandler } from "express";
import { getRouterPath, pathHasPrefix, sanitizeHttpUrl } from "./sanitizeHttpUrl";
const MACHINE_PATH_PREFIXES = [
"/api",
"/engine",
"/realtime",
"/otel",
"/webhooks/v1/ingest",
"/admin/api",
"/healthcheck",
"/metrics",
];
const MACHINE_PATH_PATTERNS = [
/^\/webhooks\/v1\/accounts\/?$/,
/^\/projects\/v3\/[^/]+\/metrics\/?$/,
];
export type ApiOnlyServiceMode = "report" | "enforce";
export type ApiOnlyServiceDecision =
| { action: "allow" }
| { action: "redirect"; location: string }
| { action: "notFound" };
export function parseApiOnlyServiceMode(value: string | undefined): ApiOnlyServiceMode | undefined {
return value === "report" || value === "enforce" ? value : undefined;
}
/**
* Whether the server should mount its static file middleware. An enforcing
* API-only service serves no dashboard assets at all: `express.static` decodes
* the path after this gate has matched it, so an encoded separator
* (`/api/..%2fassets/x.js`) would otherwise reach a build file through an
* allowed prefix.
*/
export function servesStaticFiles(mode: ApiOnlyServiceMode | undefined): boolean {
return mode !== "enforce";
}
/**
* Decides what a service that should only serve the machine APIs (SDK, CLI,
* workers, OTLP, webhook ingress, the admin API, the accounts webhook and the
* project metrics scrape endpoint) does with a request. Machine paths are allowed. Anything
* else is a dashboard, login or resource route: a `GET`/`HEAD` is sent to the
* same path on `appOrigin`, where the dashboard lives, and other methods get a
* 404. Without an `appOrigin` everything outside the machine paths is a 404.
*/
export function decideApiOnlyServiceRequest({
method,
pathname,
originalUrl,
appOrigin,
}: {
method: string;
pathname: string | undefined;
originalUrl: string;
appOrigin: string | undefined;
}): ApiOnlyServiceDecision {
if (
pathname &&
(MACHINE_PATH_PREFIXES.some((prefix) => pathHasPrefix(pathname, prefix)) ||
MACHINE_PATH_PATTERNS.some((pattern) => pattern.test(pathname)))
) {
return { action: "allow" };
}
if (appOrigin && (method === "GET" || method === "HEAD")) {
const origin = new URL(appOrigin).origin;
const path = originalUrl.startsWith("/") ? originalUrl : `/${originalUrl}`;
return { action: "redirect", location: `${origin}${path}` };
}
return { action: "notFound" };
}
/**
* The Express middleware for `API_ONLY_SERVICE_MODE`. It logs every request
* it would act on, in both modes, because the access log is mounted after it.
* `report` then serves the request as before; `enforce` redirects or 404s it.
*/
export function createApiOnlyServiceMiddleware({
mode,
appOrigin,
log = (line) => console.log(line),
}: {
mode: ApiOnlyServiceMode;
appOrigin: string | undefined;
log?: (line: string) => void;
}): RequestHandler {
return (req, res, next) => {
const decision = decideApiOnlyServiceRequest({
method: req.method,
pathname: getRouterPath(req),
originalUrl: req.originalUrl,
appOrigin,
});
if (decision.action === "allow") {
next();
return;
}
const enforced = mode === "enforce";
log(
JSON.stringify({
message: "Dashboard route on an API-only service",
method: req.method,
path: sanitizeHttpUrl(req.originalUrl),
decision: decision.action,
enforced,
})
);
if (!enforced) {
next();
return;
}
if (decision.action === "redirect") {
res.redirect(302, decision.location);
return;
}
res.status(404).send("Not Found");
};
}