hammerkit 1.27.0-next.9 → 2.0.0-next.2

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.
Files changed (122) hide show
  1. package/build.schema.json +176 -6
  2. package/dist/cache/backends/local-cache-backend.js +6 -0
  3. package/dist/cache/backends/registry/registry-client.js +1 -0
  4. package/dist/cache/cache-sync.js +1 -1
  5. package/dist/cache/explain.js +17 -0
  6. package/dist/cli.js +2 -0
  7. package/dist/dashboard/assets/index-CjnKm5-3.css +2 -0
  8. package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
  9. package/dist/dashboard/favicon.svg +1 -0
  10. package/dist/dashboard/index.html +15 -0
  11. package/dist/docker/item-network.js +13 -5
  12. package/dist/executer/archive-task-entry.js +4 -2
  13. package/dist/executer/deadline.js +17 -3
  14. package/dist/executer/deferred-restore.js +36 -0
  15. package/dist/executer/docker-service.js +30 -8
  16. package/dist/executer/docker-task.js +21 -1
  17. package/dist/executer/execute-work-service.js +2 -0
  18. package/dist/executer/execute-work-task.js +11 -4
  19. package/dist/executer/execution-steps.js +25 -4
  20. package/dist/executer/reset-work-tree.js +4 -0
  21. package/dist/executer/scheduler/enqueue-next.js +57 -6
  22. package/dist/executer/scheduler/state-key.js +42 -5
  23. package/dist/executer/watch-loop.js +6 -3
  24. package/dist/harness/connect.js +20 -6
  25. package/dist/harness/mcp-command.js +22 -6
  26. package/dist/harness/mcp.js +63 -114
  27. package/dist/harness/opencode.js +158 -50
  28. package/dist/harness/pi.js +29 -0
  29. package/dist/harness/sandbox-client.js +194 -23
  30. package/dist/harness/service-tools.js +141 -0
  31. package/dist/harness/task-tools.js +221 -0
  32. package/dist/kubernetes/apply.js +49 -1
  33. package/dist/kubernetes/await-running-state.js +172 -13
  34. package/dist/kubernetes/cluster-state.js +245 -0
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +133 -9
  36. package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
  37. package/dist/kubernetes/ensure-persistent-data.js +211 -6
  38. package/dist/kubernetes/follow-pod-logs.js +182 -0
  39. package/dist/kubernetes/job-placement.js +36 -12
  40. package/dist/kubernetes/kubernetes-instance.js +1 -0
  41. package/dist/kubernetes/network-policy.js +2 -1
  42. package/dist/kubernetes/remove-stale-items.js +46 -0
  43. package/dist/kubernetes/restore-kubernetes-data.js +9 -5
  44. package/dist/kubernetes/sandbox-runner.js +628 -0
  45. package/dist/kubernetes/service-rollouts.js +74 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +5 -2
  47. package/dist/kubernetes/volumes.js +94 -9
  48. package/dist/main.js +2 -0
  49. package/dist/optimizer/calculate-checksum.js +3 -2
  50. package/dist/optimizer/check-key.js +94 -0
  51. package/dist/optimizer/check-keys.js +64 -0
  52. package/dist/optimizer/get-work-cache-stats.js +17 -3
  53. package/dist/optimizer/work-task-cache-description.js +49 -1
  54. package/dist/planner/service-definition.js +9 -4
  55. package/dist/planner/utils/append-work-dependencies.js +5 -3
  56. package/dist/planner/utils/append-work-service.js +18 -1
  57. package/dist/planner/utils/append-work-task.js +13 -3
  58. package/dist/planner/utils/get-work-runtime.js +6 -1
  59. package/dist/planner/utils/normalize-path.js +19 -1
  60. package/dist/planner/utils/parse-work-mount.js +7 -1
  61. package/dist/planner/utils/parse-work-port.js +4 -1
  62. package/dist/planner/validate.js +22 -0
  63. package/dist/planner/work-network.js +3 -1
  64. package/dist/planner/work-runtime-aggregate.js +32 -0
  65. package/dist/planner/work-runtime-kubernetes.js +136 -13
  66. package/dist/planner/work-task.js +6 -1
  67. package/dist/program.js +27 -9
  68. package/dist/remote/device-login.js +7 -2
  69. package/dist/remote/host-config.js +69 -3
  70. package/dist/remote/remote-commands.js +33 -7
  71. package/dist/remote/remote-status.js +112 -0
  72. package/dist/remote/remote-work-commands.js +562 -45
  73. package/dist/remote/server-client.js +15 -2
  74. package/dist/remote/token-source.js +64 -26
  75. package/dist/run-program.js +9 -0
  76. package/dist/schema/build-file-base-task-schema.js +3 -3
  77. package/dist/schema/build-file-container-service-schema.js +10 -0
  78. package/dist/schema/build-file-container-task-schema.js +16 -1
  79. package/dist/schema/build-file-include-schema.js +18 -4
  80. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  81. package/dist/schema/build-file-task-schema.js +5 -1
  82. package/dist/schema/capabilities-schema.js +56 -0
  83. package/dist/schema/reference-parser.js +119 -11
  84. package/dist/schema/removed-properties.js +20 -0
  85. package/dist/schema/schema-parser.js +94 -41
  86. package/dist/schema/work-scope-parser.js +33 -10
  87. package/dist/secrets/command-secret-provider.js +3 -1
  88. package/dist/secrets/github-app-token-command.js +3 -1
  89. package/dist/secrets/secret-catalog.js +43 -19
  90. package/dist/server/accounts-api.js +4 -1
  91. package/dist/server/approvals-api.js +1 -1
  92. package/dist/server/capacity-format.js +36 -0
  93. package/dist/server/capacity.js +300 -0
  94. package/dist/server/checks-api.js +83 -0
  95. package/dist/server/checks.js +116 -0
  96. package/dist/server/dashboard.js +196 -0
  97. package/dist/server/definitions-api.js +66 -3
  98. package/dist/server/endpoints.js +794 -0
  99. package/dist/server/executor.js +164 -9
  100. package/dist/server/kubernetes-backend.js +415 -48
  101. package/dist/server/kubernetes-sandbox-storage.js +46 -2
  102. package/dist/server/overview.js +271 -0
  103. package/dist/server/plan.js +170 -121
  104. package/dist/server/policy.js +34 -2
  105. package/dist/server/proposals-api.js +17 -8
  106. package/dist/server/repo-access.js +5 -0
  107. package/dist/server/run-logs.js +4 -0
  108. package/dist/server/run-plan.js +38 -4
  109. package/dist/server/run-request.js +58 -3
  110. package/dist/server/run-worker.js +262 -13
  111. package/dist/server/runs.js +962 -36
  112. package/dist/server/sandbox-pod.js +28 -0
  113. package/dist/server/sandbox-storage.js +23 -0
  114. package/dist/server/sandboxes.js +8 -0
  115. package/dist/server/server-config.js +246 -10
  116. package/dist/server/server.js +429 -32
  117. package/dist/server/service-runs.js +82 -0
  118. package/dist/server/timings.js +132 -0
  119. package/dist/server/wait-reason.js +21 -0
  120. package/dist/utils/tar.js +28 -1
  121. package/dist/utils/terminal-prompt.js +53 -0
  122. package/package.json +8 -2
@@ -0,0 +1,794 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ownPath = exports.returnPath = exports.EndpointGateway = exports.LOGIN_HOST = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const promises_1 = require("fs/promises");
6
+ const http_1 = require("http");
7
+ const net_1 = require("net");
8
+ const path_1 = require("path");
9
+ const oauth_1 = require("./oauth");
10
+ const policy_1 = require("./policy");
11
+ const service_runs_1 = require("./service-runs");
12
+ const units_1 = require("../utils/units");
13
+ const dashboard_1 = require("./dashboard");
14
+ // The cookie of a sign-in at one endpoint host. `__Host-` keeps a page on another host of the
15
+ // domain from setting or overwriting it; it needs https, so plain http (on the loopback
16
+ // interface, for testing) goes without the prefix.
17
+ const SESSION_COOKIE = '__Host-hammerkit-session';
18
+ const PLAIN_SESSION_COOKIE = 'hammerkit-session';
19
+ // The cookie of a sign-in under way, on the login host, one per sign-in (by its state): the
20
+ // browser keeps what the callback needs, so a caller who is not signed in fills no table of
21
+ // the server's.
22
+ const LOGIN_COOKIE = '__Host-hammerkit-login-';
23
+ const PLAIN_LOGIN_COOKIE = 'hammerkit-login-';
24
+ // The cookie of the endpoint host that a sign-in started from, which the one-time code that
25
+ // comes back has to match: a code is for the browser that asked for it, not one it is sent to.
26
+ const BIND_COOKIE = '__Host-hammerkit-bind-';
27
+ const PLAIN_BIND_COOKIE = 'hammerkit-bind-';
28
+ // the most of the page to go back to that a sign-in keeps: its cookie stays below what a
29
+ // browser takes (4096 bytes for name and value)
30
+ const MAX_RETURN_PATH = 1024;
31
+ // the host of the domain that signs people in, the one redirect URI registered at the issuer
32
+ exports.LOGIN_HOST = 'login';
33
+ // the paths of an endpoint host that are the server's, never the app's
34
+ const OWN_PATH = '/.hammerkit/';
35
+ // how long a sign-in may take at the issuer, and how long its one-time code holds
36
+ const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
37
+ const CODE_TIMEOUT_MS = 60 * 1000;
38
+ // the states of sign-ins that came back, so one is taken once; the oldest goes first when
39
+ // there are more, then only the issuer's one-time code holds
40
+ const MAX_SPENT_LOGINS = 10000;
41
+ const DISCOVERY_TTL_MS = 10 * 60 * 1000;
42
+ const FETCH_TIMEOUT_MS = 10000;
43
+ /**
44
+ * Publishes the endpoints of service runs: every host `<label>.<domain>` is answered here
45
+ * and nowhere else. A caller is known by a session cookie of that host, which the sign-in
46
+ * at `login.<domain>` gives a person, or by a token in `Hammerkit-Authorization`. Who may
47
+ * open it (the run's owner, or an `open` level for its repository) is decided with the
48
+ * entitlements of the moment on every request, then the request goes to the service's port.
49
+ */
50
+ class EndpointGateway {
51
+ constructor(config, registry, options) {
52
+ this.config = config;
53
+ this.registry = registry;
54
+ this.options = options;
55
+ // the state of each sign-in that came back, until it would have expired
56
+ this.spent = new Map();
57
+ this.codes = new Map();
58
+ this.base = { scheme: 'https', port: '' };
59
+ this.fetcher = options.fetch ?? fetch;
60
+ this.now = options.now ?? Date.now;
61
+ const login = config.endpoints?.login;
62
+ const issuer = login ? config.issuers.find((candidate) => candidate.issuer === login.issuer) : undefined;
63
+ if (login && issuer) {
64
+ // an ID token is meant for the client that signed in, not for the API
65
+ this.loginVerifier = new oauth_1.TokenVerifier([{ ...issuer, audience: login.clientId }], {
66
+ ...(options.fetch ? { fetch: options.fetch } : {}),
67
+ ...(options.now ? { now: options.now } : {}),
68
+ });
69
+ }
70
+ }
71
+ async load() {
72
+ try {
73
+ const key = await (0, promises_1.readFile)(this.options.keyFile);
74
+ if (key.length >= 32) {
75
+ this.key = key;
76
+ return;
77
+ }
78
+ }
79
+ catch {
80
+ // made below
81
+ }
82
+ this.key = (0, crypto_1.randomBytes)(32);
83
+ await (0, promises_1.mkdir)((0, path_1.dirname)(this.options.keyFile), { recursive: true, mode: 0o700 });
84
+ await (0, promises_1.writeFile)(this.options.keyFile, this.key, { mode: 0o600 });
85
+ }
86
+ // Where the server listens, for the addresses of endpoints: https unless it serves plain
87
+ // http on the loopback interface; no port behind a proxy or on the default one.
88
+ listening(port, secure) {
89
+ const scheme = secure || this.config.behindTlsProxy ? 'https' : 'http';
90
+ const standard = this.config.behindTlsProxy || (scheme === 'https' ? port === 443 : port === 80);
91
+ this.base = { scheme, port: standard ? '' : `:${port}` };
92
+ }
93
+ url(host, path = '/') {
94
+ return `${this.base.scheme}://${host}.${this.config.endpoints?.domain}${this.base.port}${path}`;
95
+ }
96
+ // The label of an endpoint host the request is for, or null when it is for the API.
97
+ hostOf(req) {
98
+ const domain = this.config.endpoints?.domain;
99
+ const host = (req.headers.host ?? '').toLowerCase().replace(/:\d+$/, '');
100
+ if (!domain || !host.endsWith(`.${domain}`)) {
101
+ return null;
102
+ }
103
+ return host.slice(0, -domain.length - 1);
104
+ }
105
+ async handle(label, req, res) {
106
+ const url = new URL(req.url ?? '/', 'http://endpoint');
107
+ if (label === exports.LOGIN_HOST) {
108
+ return this.handleLogin(req, res, url);
109
+ }
110
+ const found = /^[a-z0-9-]+$/.test(label) ? this.registry.endpoint(label) : undefined;
111
+ if (url.pathname === `${OWN_PATH}session`) {
112
+ return this.takeCode(label, req, res, url);
113
+ }
114
+ if (url.pathname === `${OWN_PATH}logout`) {
115
+ res.writeHead(303, { location: '/', 'set-cookie': this.clearCookie(), 'cache-control': 'no-store' });
116
+ res.end();
117
+ return;
118
+ }
119
+ if (label === this.dashboardLabel() && url.pathname === `${OWN_PATH}signin`) {
120
+ // sign in again, with the groups and claims of now, and go back to a page of the dashboard
121
+ const back = url.searchParams.get('path') ?? '/';
122
+ const path = /^\/[A-Za-z0-9_\-/?=&.,]*$/.test(back) && !back.startsWith('//') ? back : '/';
123
+ return this.signIn(label, req, res, new URL(path, 'http://endpoint'));
124
+ }
125
+ if (label === this.dashboardLabel()) {
126
+ const principal = await this.caller(label, req);
127
+ if (!principal) {
128
+ return this.signIn(label, req, res, url);
129
+ }
130
+ const dashboard = this.options.dashboard;
131
+ if (!dashboard) {
132
+ return page(res, 404, 'Not found', '<p>This server has no dashboard.</p>');
133
+ }
134
+ // the token of what the app sends holds for this sign-in only: it dies with its session
135
+ const session = this.readSession(label, req.headers.cookie);
136
+ return (0, dashboard_1.handleDashboard)({
137
+ config: this.config,
138
+ registry: this.registry,
139
+ sign: (value) => this.sign(value),
140
+ log: this.options.log,
141
+ ...(this.options.now ? { now: this.options.now } : {}),
142
+ ...dashboard,
143
+ session: session ? `session:${session.exp}` : 'token',
144
+ // a cookie of before the sign-in time was kept counts as an old sign-in
145
+ ...(session ? { signedInAt: session.at ?? 0 } : {}),
146
+ }, principal, req, res, url);
147
+ }
148
+ if (!found) {
149
+ return page(res, 404, 'Not found', '<p>There is no endpoint at this address.</p>');
150
+ }
151
+ const principal = await this.caller(label, req);
152
+ if (!principal) {
153
+ return this.signIn(label, req, res, url);
154
+ }
155
+ if (!this.mayOpen(principal, found.run)) {
156
+ this.options.log(`deny endpoint ${label} iss=${principal.iss} sub=${principal.sub}`);
157
+ return page(res, 403, 'Not allowed', `<p>You are signed in as <code>${escape(principal.sub)}</code> of <code>${escape(principal.iss)}</code>, which may not open this endpoint.</p><p><a href="${OWN_PATH}logout">Sign out</a></p>`);
158
+ }
159
+ if (url.pathname === `${OWN_PATH}status` || url.pathname === `${OWN_PATH}extend`) {
160
+ return this.status(label, found.run, found.endpoint, principal, req, res, url);
161
+ }
162
+ if (url.pathname.startsWith(OWN_PATH)) {
163
+ return page(res, 404, 'Not found', '<p>There is no such page.</p>');
164
+ }
165
+ // a request keeps the services up, or wakes them
166
+ const refused = this.registry.visit(found.run.id, this.options.mayWake);
167
+ if (!found.target) {
168
+ return this.notUp(res, this.registry.endpoint(label)?.run ?? found.run, refused);
169
+ }
170
+ this.forward(found.target, principal, req, res);
171
+ }
172
+ // A WebSocket (or any upgrade) to an endpoint, after the same checks as a request.
173
+ async upgrade(label, req, socket, head) {
174
+ const refuse = (status, text) => {
175
+ socket.end(`HTTP/1.1 ${status} ${text}\r\nconnection: close\r\ncontent-length: 0\r\n\r\n`);
176
+ };
177
+ const found = label !== exports.LOGIN_HOST && /^[a-z0-9-]+$/.test(label) ? this.registry.endpoint(label) : undefined;
178
+ if (!found) {
179
+ return refuse(404, 'Not Found');
180
+ }
181
+ const principal = await this.caller(label, req);
182
+ if (!principal) {
183
+ return refuse(401, 'Unauthorized');
184
+ }
185
+ if (!this.mayOpen(principal, found.run)) {
186
+ this.options.log(`deny endpoint ${label} iss=${principal.iss} sub=${principal.sub}`);
187
+ return refuse(403, 'Forbidden');
188
+ }
189
+ if (new URL(req.url ?? '/', 'http://endpoint').pathname.startsWith(OWN_PATH)) {
190
+ return refuse(404, 'Not Found');
191
+ }
192
+ this.registry.visit(found.run.id, this.options.mayWake);
193
+ const target = found.target;
194
+ if (!target) {
195
+ return refuse(503, 'Service Unavailable');
196
+ }
197
+ const upstream = (0, net_1.connect)(target.address.port, target.address.host);
198
+ const headers = this.forwardedHeaders(req, principal);
199
+ upstream.once('connect', () => {
200
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
201
+ for (const [name, value] of Object.entries(headers)) {
202
+ for (const item of Array.isArray(value) ? value : [value]) {
203
+ if (item !== undefined) {
204
+ lines.push(`${name}: ${item}`);
205
+ }
206
+ }
207
+ }
208
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
209
+ if (head.length > 0) {
210
+ upstream.write(head);
211
+ }
212
+ upstream.pipe(socket);
213
+ socket.pipe(upstream);
214
+ });
215
+ upstream.on('error', () => socket.destroy());
216
+ socket.on('error', () => upstream.destroy());
217
+ socket.on('close', () => upstream.destroy());
218
+ upstream.on('close', () => socket.destroy());
219
+ }
220
+ // The host of the dashboard, when there is one: it needs the sign-in of a browser.
221
+ dashboardLabel() {
222
+ const label = this.config.endpoints?.dashboard;
223
+ return label && this.loginVerifier ? label : undefined;
224
+ }
225
+ // The address of the dashboard, for a browser that opens the address of the API.
226
+ dashboardUrl() {
227
+ const label = this.dashboardLabel();
228
+ return label ? this.url(label, '/') : undefined;
229
+ }
230
+ // A person in a browser is sent to sign in, anything else is told how to.
231
+ signIn(label, req, res, url) {
232
+ if (this.loginVerifier && req.method === 'GET' && acceptsHtml(req.headers) && isPageLoad(req.headers)) {
233
+ const bind = (0, crypto_1.randomBytes)(18).toString('base64url');
234
+ const start = new URL(this.url(exports.LOGIN_HOST, '/start'));
235
+ start.searchParams.set('host', label);
236
+ start.searchParams.set('path', url.pathname + url.search);
237
+ start.searchParams.set('bind', bind);
238
+ res.writeHead(302, {
239
+ location: start.toString(),
240
+ 'set-cookie': this.cookie(this.bindCookieName(bind), bind, LOGIN_TIMEOUT_MS),
241
+ 'cache-control': 'no-store',
242
+ });
243
+ res.end();
244
+ return;
245
+ }
246
+ res.writeHead(401, {
247
+ 'content-type': 'text/plain; charset=utf-8',
248
+ 'www-authenticate': 'Bearer realm="hammerkit"',
249
+ 'cache-control': 'no-store',
250
+ });
251
+ res.end('sign in: send a token in Hammerkit-Authorization: Bearer <token>\n');
252
+ }
253
+ // Who may come back from the sign-in to a host: to an endpoint who may open it, to the
254
+ // dashboard anybody with an entitlement.
255
+ mayEnter(principal, host) {
256
+ if (host === this.dashboardLabel()) {
257
+ return (0, policy_1.getGrant)(this.config.entitlements, principal).matched.length > 0;
258
+ }
259
+ const found = this.registry.endpoint(host);
260
+ return !!found && this.mayOpen(principal, found.run);
261
+ }
262
+ // The owner of the run, or who has an `open` level for its repository, with the
263
+ // entitlements as they are now.
264
+ mayOpen(principal, run) {
265
+ if (run.owner.iss === principal.iss && run.owner.sub === principal.sub) {
266
+ return true;
267
+ }
268
+ return (0, service_runs_1.mayOpenEndpoints)((0, policy_1.getGrant)(this.config.entitlements, principal), run);
269
+ }
270
+ async caller(label, req) {
271
+ const header = req.headers['hammerkit-authorization'];
272
+ const token = typeof header === 'string' ? /^Bearer ([A-Za-z0-9._~+/-]+=*)$/.exec(header)?.[1] : undefined;
273
+ if (token) {
274
+ try {
275
+ return await this.options.verifier.verify(token);
276
+ }
277
+ catch (e) {
278
+ this.options.log(`deny endpoint ${label}: ${e instanceof oauth_1.TokenError ? e.message : 'token could not be checked'}`);
279
+ return null;
280
+ }
281
+ }
282
+ // the cookie is sent along when a page of another endpoint asks: the endpoints share
283
+ // one site, and the app of one is not to act as its visitor on another
284
+ if (this.fromElsewhere(label, req)) {
285
+ return null;
286
+ }
287
+ const session = this.readSession(label, req.headers.cookie);
288
+ return session ? { iss: session.iss, sub: session.sub, groups: session.groups, claims: session.claims } : null;
289
+ }
290
+ // Whether the browser sends the request for another page than the endpoint's own: an
291
+ // upgrade from another origin, or a request a page of another site makes (a frame too),
292
+ // but a link followed to the endpoint. Without Sec-Fetch-Site, by its Origin.
293
+ fromElsewhere(label, req) {
294
+ const origin = req.headers.origin;
295
+ const own = this.url(label, '/').slice(0, -1);
296
+ if (req.headers.upgrade) {
297
+ return origin !== own;
298
+ }
299
+ const site = req.headers['sec-fetch-site'];
300
+ if (site === 'same-site' || site === 'cross-site') {
301
+ // a page of its own the person goes to, not one framed by another site
302
+ const dest = req.headers['sec-fetch-dest'];
303
+ return !(req.headers['sec-fetch-mode'] === 'navigate' &&
304
+ (dest === undefined || dest === 'document') &&
305
+ (req.method === 'GET' || req.method === 'HEAD'));
306
+ }
307
+ return site === undefined && origin !== undefined && origin !== own;
308
+ }
309
+ forward(target, principal, req, res) {
310
+ const upstream = (0, http_1.request)({
311
+ host: target.address.host,
312
+ port: target.address.port,
313
+ method: req.method,
314
+ path: req.url,
315
+ headers: this.forwardedHeaders(req, principal),
316
+ }, (answer) => {
317
+ const headers = { ...answer.headers, 'x-robots-tag': 'noindex' };
318
+ res.writeHead(answer.statusCode ?? 502, answer.statusMessage, headers);
319
+ answer.pipe(res);
320
+ });
321
+ upstream.on('error', (e) => {
322
+ this.options.log(`endpoint ${target.service}:${target.port}: ${e.message}`);
323
+ if (!res.headersSent) {
324
+ page(res, 502, 'Not reachable', `<p>${escape(target.service)} did not answer on port ${target.port}.</p>`);
325
+ }
326
+ else {
327
+ res.destroy();
328
+ }
329
+ });
330
+ res.on('close', () => upstream.destroy());
331
+ req.pipe(upstream);
332
+ }
333
+ // What reaches the app: the request without the server's cookie and headers, with who asked.
334
+ forwardedHeaders(req, principal) {
335
+ const headers = {};
336
+ for (const [name, value] of Object.entries(req.headers)) {
337
+ if (name.startsWith('hammerkit-') || name.startsWith('x-hammerkit-') || name.startsWith('x-forwarded-')) {
338
+ continue;
339
+ }
340
+ headers[name] = value;
341
+ }
342
+ const cookies = (req.headers.cookie ?? '')
343
+ .split(/;\s*/)
344
+ .filter((cookie) => cookie && !cookie.startsWith(`${SESSION_COOKIE}=`) && !cookie.startsWith(`${PLAIN_SESSION_COOKIE}=`));
345
+ if (cookies.length > 0) {
346
+ headers.cookie = cookies.join('; ');
347
+ }
348
+ else {
349
+ delete headers.cookie;
350
+ }
351
+ headers['x-forwarded-proto'] = this.base.scheme;
352
+ headers['x-forwarded-host'] = req.headers.host ?? '';
353
+ headers['x-forwarded-for'] = req.socket.remoteAddress ?? '';
354
+ headers['x-hammerkit-caller'] = `${principal.iss} ${principal.sub}`;
355
+ return headers;
356
+ }
357
+ notUp(res, run, refused) {
358
+ if (run.state === 'queued' || run.state === 'running' || run.state === 'awaiting-approval') {
359
+ res.setHeader('refresh', '5');
360
+ res.setHeader('retry-after', '5');
361
+ return page(res, 503, 'Starting', `<p>The services of this endpoint are ${run.wakes ? `waking up${run.volumesKept ? ' with the volumes they kept' : ''}` : 'starting'} (${escape(run.state)}), this page reloads until they are up.</p>`);
362
+ }
363
+ if (run.state === 'asleep') {
364
+ // it may wake on a later request, once there is a place for it
365
+ res.setHeader('refresh', '30');
366
+ res.setHeader('retry-after', '30');
367
+ return page(res, 503, 'Asleep', `<p>The services of this endpoint sleep and can not wake now: ${escape(refused ?? 'try again')}.</p>`);
368
+ }
369
+ const why = run.stoppedBy === 'ttl'
370
+ ? 'its time ran out'
371
+ : run.stoppedBy === 'replaced'
372
+ ? 'a newer run of the same name replaced it'
373
+ : run.stoppedBy
374
+ ? `${escape(run.stoppedBy.sub)} stopped it`
375
+ : escape(run.error ?? run.state);
376
+ return page(res, 410, 'Ended', `<p>This endpoint ended${run.finishedAt ? ` at ${escape(run.finishedAt)}` : ''}: ${why}.</p><p>Ask <code>${escape(run.owner.sub)}</code> to start it again.</p>`);
377
+ }
378
+ async status(label, run, endpoint, principal, req, res, url) {
379
+ const csrf = this.sign(`csrf\0${label}\0${principal.iss}\0${principal.sub}`);
380
+ if (url.pathname === `${OWN_PATH}extend`) {
381
+ if (req.method !== 'POST') {
382
+ return page(res, 405, 'Not allowed', '<p>Extend with the form of the status page.</p>');
383
+ }
384
+ const form = new URLSearchParams(await readText(req, 4096));
385
+ if (!safeEqual(form.get('csrf') ?? '', csrf)) {
386
+ return page(res, 403, 'Not allowed', '<p>The form is out of date, open the status page again.</p>');
387
+ }
388
+ let ttl;
389
+ try {
390
+ ttl = (0, units_1.parseDuration)(form.get('ttl') ?? '');
391
+ if (!(ttl > 0)) {
392
+ throw new Error('no time');
393
+ }
394
+ }
395
+ catch {
396
+ return page(res, 400, 'Not a duration', '<p>Pick how long it should stay.</p>');
397
+ }
398
+ const result = this.registry.extend(principal, run.id, ttl, () => this.mayOpen(principal, run));
399
+ this.options.log(`extend iss=${principal.iss} sub=${principal.sub} run=${run.id} result=${result.status}`);
400
+ res.writeHead(303, { location: `${OWN_PATH}status`, 'cache-control': 'no-store' });
401
+ res.end();
402
+ return;
403
+ }
404
+ const source = run.source.type === 'ref'
405
+ ? `${run.source.repo} ${run.source.ref} at ${run.source.commit.slice(0, 12)}`
406
+ : run.source.type === 'sandbox'
407
+ ? `sandbox ${run.source.id} of ${run.source.repo} at ${run.source.commit.slice(0, 12)}${run.source.clean === false ? ' with changes' : ''}`
408
+ : 'an upload';
409
+ const moving = run.moving ? `, moving to ${run.moving.commit.slice(0, 12)}` : '';
410
+ const options = ['1h', '8h', '24h', '72h']
411
+ .map((ttl) => `<option value="${ttl}"${ttl === '8h' ? ' selected' : ''}>${ttl}</option>`)
412
+ .join('');
413
+ return page(res, 200, run.up?.name ?? 'Endpoint', `<dl>
414
+ <dt>Service</dt><dd>${escape(endpoint.service)}, port ${endpoint.port}</dd>
415
+ <dt>Run</dt><dd><code>${escape(run.id)}</code> of <code>${escape(run.owner.sub)}</code>, ${escape(run.state)}${run.state === 'asleep' ? (run.volumesKept ? ' with its volumes kept' : ', its services start from nothing') : ''}</dd>
416
+ <dt>Source</dt><dd>${escape(source + moving)}</dd>
417
+ <dt>Ends</dt><dd>${escape(run.expiresAt ?? 'when it is up and its time ran out')}</dd>
418
+ ${run.up?.sleepTtl
419
+ ? `<dt>Sleeps</dt><dd>after ${escape((0, units_1.formatDuration)(run.up.sleepTtl))} without a request${run.sleepsAt ? `, next at ${escape(run.sleepsAt)}` : ''}; a request wakes it</dd>\n`
420
+ : ''}<dt>Signed in</dt><dd><code>${escape(principal.sub)}</code></dd>
421
+ </dl>
422
+ ${run.state === 'up'
423
+ ? `<form method="post" action="${OWN_PATH}extend"><input type="hidden" name="csrf" value="${csrf}"><label>Keep it for <select name="ttl">${options}</select> from now</label> <button>Extend</button></form>`
424
+ : ''}
425
+ <p><a href="/">Open the app</a> · <a href="${OWN_PATH}logout">Sign out</a></p>`);
426
+ }
427
+ // ---- sign-in, on login.<domain> ----
428
+ async handleLogin(req, res, url) {
429
+ const login = this.config.endpoints?.login;
430
+ if (!login || !this.loginVerifier || req.method !== 'GET') {
431
+ return page(res, 404, 'Not found', '<p>There is nothing here.</p>');
432
+ }
433
+ if (url.pathname === '/start') {
434
+ const host = url.searchParams.get('host') ?? '';
435
+ const path = url.searchParams.get('path') ?? '/';
436
+ const bind = url.searchParams.get('bind') ?? '';
437
+ if (!/^[a-z0-9-]+$/.test(host) ||
438
+ host === exports.LOGIN_HOST ||
439
+ (host !== this.dashboardLabel() && !this.registry.endpoint(host))) {
440
+ return page(res, 404, 'Not found', '<p>There is no endpoint at this address.</p>');
441
+ }
442
+ if (!BIND_PATTERN.test(bind)) {
443
+ return page(res, 400, 'Not signed in', '<p>Open the endpoint to sign in.</p>');
444
+ }
445
+ // a sign-in starts when a person goes to it, not from a request of a page, which could
446
+ // pile up cookies of sign-ins in their browser
447
+ if (req.headers['sec-fetch-mode'] !== undefined && req.headers['sec-fetch-mode'] !== 'navigate') {
448
+ return page(res, 403, 'Not allowed', '<p>Open the endpoint to sign in.</p>');
449
+ }
450
+ const endpoints = await this.endpointsOf(login.issuer);
451
+ const state = (0, crypto_1.randomBytes)(24).toString('base64url');
452
+ const verifier = (0, crypto_1.randomBytes)(32).toString('base64url');
453
+ const nonce = (0, crypto_1.randomBytes)(24).toString('base64url');
454
+ const pending = {
455
+ state,
456
+ bind,
457
+ host,
458
+ path: returnPath(path),
459
+ verifier,
460
+ nonce,
461
+ expires: this.now() + LOGIN_TIMEOUT_MS,
462
+ };
463
+ const authorize = new URL(endpoints.authorize);
464
+ authorize.searchParams.set('response_type', 'code');
465
+ authorize.searchParams.set('client_id', login.clientId);
466
+ authorize.searchParams.set('redirect_uri', this.url(exports.LOGIN_HOST, '/callback'));
467
+ authorize.searchParams.set('scope', login.scope);
468
+ authorize.searchParams.set('state', state);
469
+ authorize.searchParams.set('nonce', nonce);
470
+ authorize.searchParams.set('code_challenge', (0, crypto_1.createHash)('sha256').update(verifier).digest('base64url'));
471
+ authorize.searchParams.set('code_challenge_method', 'S256');
472
+ res.writeHead(302, {
473
+ location: authorize.toString(),
474
+ 'set-cookie': this.loginCookie(pending),
475
+ 'cache-control': 'no-store',
476
+ });
477
+ res.end();
478
+ return;
479
+ }
480
+ if (url.pathname === '/callback') {
481
+ const state = url.searchParams.get('state') ?? '';
482
+ const pending = this.readLogin(state, req.headers.cookie);
483
+ if (pending) {
484
+ res.setHeader('set-cookie', this.clearLoginCookie(state));
485
+ }
486
+ const code = url.searchParams.get('code');
487
+ if (pending && !code) {
488
+ const error = url.searchParams.get('error') ?? 'no code';
489
+ return page(res, 400, 'Not signed in', `<p>The sign-in did not succeed: ${escape(error.slice(0, 200))}.</p>`);
490
+ }
491
+ if (!pending || !code || !this.spend(pending)) {
492
+ return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
493
+ }
494
+ let principal;
495
+ try {
496
+ principal = await this.exchange(code, pending);
497
+ }
498
+ catch (e) {
499
+ this.options.log(`endpoint sign-in failed: ${e instanceof Error ? e.message : String(e)}`);
500
+ return page(res, 400, 'Not signed in', '<p>The sign-in could not be checked, open the endpoint again.</p>');
501
+ }
502
+ if (!this.mayEnter(principal, pending.host)) {
503
+ this.options.log(`deny endpoint ${pending.host} iss=${principal.iss} sub=${principal.sub}`);
504
+ return page(res, 403, 'Not allowed', `<p>You signed in as <code>${escape(principal.sub)}</code>, which may not open ${pending.host === this.dashboardLabel() ? 'the dashboard of this server' : 'this endpoint'}.</p>`);
505
+ }
506
+ const issued = (0, crypto_1.randomBytes)(24).toString('base64url');
507
+ this.codes.set(issued, {
508
+ bind: pending.bind,
509
+ host: pending.host,
510
+ path: pending.path,
511
+ principal,
512
+ expires: this.now() + CODE_TIMEOUT_MS,
513
+ });
514
+ const target = new URL(this.url(pending.host, `${OWN_PATH}session`));
515
+ target.searchParams.set('code', issued);
516
+ this.options.log(`endpoint sign-in ${pending.host} iss=${principal.iss} sub=${principal.sub}`);
517
+ res.writeHead(302, { location: target.toString(), 'cache-control': 'no-store' });
518
+ res.end();
519
+ return;
520
+ }
521
+ return page(res, 404, 'Not found', '<p>There is nothing here.</p>');
522
+ }
523
+ // The code of the issuer for its tokens, and the ID token for who signed in.
524
+ // A sign-in comes back once: its state is kept as spent until it would have expired. Past
525
+ // MAX_SPENT_LOGINS the oldest are forgotten, which anybody can bring about with sign-ins of
526
+ // their own; a state forgotten that way still needs its cookie and a code the issuer takes
527
+ // a second time, which it doesn't (codes of the authorization code flow are one-time).
528
+ spend(pending) {
529
+ const now = this.now();
530
+ if (pending.expires < now || this.spent.has(pending.state)) {
531
+ return false;
532
+ }
533
+ this.sweep();
534
+ while (this.spent.size >= MAX_SPENT_LOGINS) {
535
+ const oldest = this.spent.keys().next().value;
536
+ this.spent.delete(oldest);
537
+ }
538
+ this.spent.set(pending.state, pending.expires);
539
+ return true;
540
+ }
541
+ loginCookieName(state) {
542
+ // the state is base64url, which a cookie name may carry
543
+ return `${this.base.scheme === 'https' ? LOGIN_COOKIE : PLAIN_LOGIN_COOKIE}${state.slice(0, 16)}`;
544
+ }
545
+ bindCookieName(bind) {
546
+ return `${this.base.scheme === 'https' ? BIND_COOKIE : PLAIN_BIND_COOKIE}${bind.slice(0, 16)}`;
547
+ }
548
+ // A cookie of the host the request is for (no Domain), for so long; 0 clears it.
549
+ cookie(name, value, ms) {
550
+ return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.max(0, Math.floor(ms / 1000))}${this.base.scheme === 'https' ? '; Secure' : ''}`;
551
+ }
552
+ loginCookie(pending) {
553
+ const payload = Buffer.from(JSON.stringify(pending)).toString('base64url');
554
+ return this.cookie(this.loginCookieName(pending.state), `${payload}.${this.sign(`login\0${payload}`)}`, LOGIN_TIMEOUT_MS);
555
+ }
556
+ clearLoginCookie(state) {
557
+ return this.cookie(this.loginCookieName(state), '', 0);
558
+ }
559
+ // The sign-in of this state, from its cookie, when the server signed it.
560
+ readLogin(state, header) {
561
+ if (!/^[A-Za-z0-9_-]{32}$/.test(state)) {
562
+ return null;
563
+ }
564
+ const value = readCookie(header, this.loginCookieName(state));
565
+ const [payload, signature] = (value ?? '').split('.');
566
+ if (!payload || !signature || !safeEqual(signature, this.sign(`login\0${payload}`))) {
567
+ return null;
568
+ }
569
+ try {
570
+ const pending = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
571
+ return pending.state === state && typeof pending.verifier === 'string' && typeof pending.bind === 'string'
572
+ ? pending
573
+ : null;
574
+ }
575
+ catch {
576
+ return null;
577
+ }
578
+ }
579
+ async exchange(code, pending) {
580
+ const login = this.config.endpoints?.login;
581
+ if (!login || !this.loginVerifier) {
582
+ throw new Error('no sign-in configured');
583
+ }
584
+ const endpoints = await this.endpointsOf(login.issuer);
585
+ const body = new URLSearchParams({
586
+ grant_type: 'authorization_code',
587
+ code,
588
+ redirect_uri: this.url(exports.LOGIN_HOST, '/callback'),
589
+ client_id: login.clientId,
590
+ code_verifier: pending.verifier,
591
+ });
592
+ if (this.options.clientSecret) {
593
+ body.set('client_secret', await this.options.clientSecret());
594
+ }
595
+ const response = await this.fetcher(endpoints.token, {
596
+ method: 'POST',
597
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
598
+ body: body.toString(),
599
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
600
+ });
601
+ const answer = (await response.json().catch(() => ({})));
602
+ if (!response.ok || typeof answer.id_token !== 'string') {
603
+ throw new Error(`the token endpoint answered ${response.status}${answer.error ? ` ${String(answer.error)}` : ''}`);
604
+ }
605
+ const principal = await this.loginVerifier.verify(answer.id_token);
606
+ if (principal.claims.nonce !== pending.nonce) {
607
+ throw new oauth_1.TokenError('the ID token is not of this sign-in (nonce)');
608
+ }
609
+ return principal;
610
+ }
611
+ async endpointsOf(issuer) {
612
+ if (this.discovery && this.now() - this.discovery.at < DISCOVERY_TTL_MS) {
613
+ return this.discovery;
614
+ }
615
+ const response = await this.fetcher(`${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`, {
616
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
617
+ });
618
+ const metadata = (await response.json());
619
+ if (typeof metadata.authorization_endpoint !== 'string' || typeof metadata.token_endpoint !== 'string') {
620
+ throw new Error(`${issuer} names no authorization or token endpoint`);
621
+ }
622
+ this.discovery = { at: this.now(), authorize: metadata.authorization_endpoint, token: metadata.token_endpoint };
623
+ return this.discovery;
624
+ }
625
+ // `/.hammerkit/session?code=` on the endpoint host: the one-time code of the sign-in
626
+ // becomes the cookie of this host.
627
+ takeCode(label, req, res, url) {
628
+ const code = url.searchParams.get('code') ?? '';
629
+ const issued = this.codes.get(code);
630
+ this.codes.delete(code);
631
+ const bind = issued ? readCookie(req.headers.cookie, this.bindCookieName(issued.bind)) : undefined;
632
+ if (issued) {
633
+ res.setHeader('set-cookie', this.cookie(this.bindCookieName(issued.bind), '', 0));
634
+ }
635
+ if (!issued ||
636
+ issued.host !== label ||
637
+ issued.expires < this.now() ||
638
+ // the browser that started the sign-in at this host, no other one the link was sent to
639
+ !bind ||
640
+ !safeEqual(bind, issued.bind)) {
641
+ return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
642
+ }
643
+ const found = this.registry.endpoint(label);
644
+ const ends = Date.parse(found?.run.expiresAt ?? '');
645
+ const session = this.now() + (0, units_1.parseDuration)(this.config.endpoints?.session ?? '1h');
646
+ const exp = Number.isFinite(ends) ? Math.min(session, Math.max(ends, this.now() + LOGIN_TIMEOUT_MS)) : session;
647
+ res.writeHead(303, {
648
+ location: issued.path,
649
+ 'set-cookie': [
650
+ this.cookie(this.bindCookieName(issued.bind), '', 0),
651
+ this.sessionCookie({
652
+ host: label,
653
+ iss: issued.principal.iss,
654
+ sub: issued.principal.sub,
655
+ groups: issued.principal.groups,
656
+ claims: this.matchedClaims(issued.principal),
657
+ exp,
658
+ at: this.now(),
659
+ }),
660
+ ],
661
+ 'cache-control': 'no-store',
662
+ });
663
+ res.end();
664
+ }
665
+ // Only the claims an entitlement matches on go into the cookie, which stays small.
666
+ matchedClaims(principal) {
667
+ const names = new Set(this.config.entitlements.flatMap((entitlement) => Object.keys(entitlement.claims?.match ?? {})));
668
+ return Object.fromEntries(Object.entries(principal.claims).filter(([name]) => names.has(name)));
669
+ }
670
+ cookieName() {
671
+ return this.base.scheme === 'https' ? SESSION_COOKIE : PLAIN_SESSION_COOKIE;
672
+ }
673
+ sessionCookie(session) {
674
+ const payload = Buffer.from(JSON.stringify(session)).toString('base64url');
675
+ const value = `${payload}.${this.sign(payload)}`;
676
+ const maxAge = Math.max(0, Math.floor((session.exp - this.now()) / 1000));
677
+ return `${this.cookieName()}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${this.base.scheme === 'https' ? '; Secure' : ''}`;
678
+ }
679
+ clearCookie() {
680
+ return `${this.cookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${this.base.scheme === 'https' ? '; Secure' : ''}`;
681
+ }
682
+ readSession(label, header) {
683
+ const value = readCookie(header, this.cookieName());
684
+ const [payload, signature] = (value ?? '').split('.');
685
+ if (!payload || !signature || !safeEqual(signature, this.sign(payload))) {
686
+ return null;
687
+ }
688
+ try {
689
+ const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
690
+ if (session.host !== label || !(session.exp > this.now()) || !session.iss || !session.sub) {
691
+ return null;
692
+ }
693
+ return {
694
+ ...session,
695
+ groups: Array.isArray(session.groups) ? session.groups : [],
696
+ claims: session.claims ?? {},
697
+ };
698
+ }
699
+ catch {
700
+ return null;
701
+ }
702
+ }
703
+ sign(value) {
704
+ if (!this.key) {
705
+ throw new Error('the endpoints were not loaded');
706
+ }
707
+ return (0, crypto_1.createHmac)('sha256', this.key).update(value).digest('base64url');
708
+ }
709
+ sweep() {
710
+ const now = this.now();
711
+ for (const [state, expires] of this.spent) {
712
+ if (expires < now) {
713
+ this.spent.delete(state);
714
+ }
715
+ }
716
+ for (const [code, issued] of this.codes) {
717
+ if (issued.expires < now) {
718
+ this.codes.delete(code);
719
+ }
720
+ }
721
+ }
722
+ }
723
+ exports.EndpointGateway = EndpointGateway;
724
+ const BIND_PATTERN = /^[A-Za-z0-9_-]{24}$/;
725
+ // The page a sign-in goes back to: a path of its own host, and only the path when the query
726
+ // is too long for the cookie of the sign-in.
727
+ function returnPath(path) {
728
+ const own = ownPath(path);
729
+ if (own.length <= MAX_RETURN_PATH) {
730
+ return own;
731
+ }
732
+ const pathname = own.split('?')[0];
733
+ return pathname.length <= MAX_RETURN_PATH ? pathname : '/';
734
+ }
735
+ exports.returnPath = returnPath;
736
+ function readCookie(header, name) {
737
+ return (header ?? '')
738
+ .split(/;\s*/)
739
+ .find((cookie) => cookie.startsWith(`${name}=`))
740
+ ?.slice(name.length + 1);
741
+ }
742
+ function safeEqual(a, b) {
743
+ const left = Buffer.from(a);
744
+ const right = Buffer.from(b);
745
+ return left.length === right.length && (0, crypto_1.timingSafeEqual)(left, right);
746
+ }
747
+ // Whether the browser loads a page of its own, which a sign-in can go through: not a request
748
+ // of a page (fetch, htmx, a frame), which would only pile up cookies of sign-ins. Without
749
+ // Sec-Fetch-*, an older browser or another client, by its Accept alone.
750
+ function isPageLoad(headers) {
751
+ const mode = headers['sec-fetch-mode'];
752
+ const dest = headers['sec-fetch-dest'];
753
+ return (mode === undefined || mode === 'navigate') && (dest === undefined || dest === 'document');
754
+ }
755
+ function acceptsHtml(headers) {
756
+ return (headers.accept ?? '').includes('text/html');
757
+ }
758
+ function escape(text) {
759
+ return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
760
+ }
761
+ async function readText(req, limit) {
762
+ let text = '';
763
+ for await (const chunk of req) {
764
+ text += chunk.toString('utf8');
765
+ if (text.length > limit) {
766
+ break;
767
+ }
768
+ }
769
+ return text.slice(0, limit);
770
+ }
771
+ // A page of the server's own on an endpoint host: nothing of it is the app's.
772
+ function page(res, status, title, body) {
773
+ res.writeHead(status, {
774
+ 'content-type': 'text/html; charset=utf-8',
775
+ 'cache-control': 'no-store',
776
+ 'x-robots-tag': 'noindex',
777
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
778
+ 'x-frame-options': 'DENY',
779
+ });
780
+ res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem;color:#222}code{background:#f2f2f2;padding:0 .2em}dt{font-weight:600}dd{margin:0 0 .5rem}</style></head><body><h1>${escape(title)}</h1>${body}<p><small>hammerkit</small></p></body></html>`);
781
+ }
782
+ // Where on its own host a person goes back to after the sign-in: a path, never an address a
783
+ // browser reads as another host (`//x`, `/\\x`, control characters).
784
+ function ownPath(path) {
785
+ if (!path.startsWith('/') ||
786
+ [...path].some((char) => char === '\\' || char.charCodeAt(0) < 0x20 || char === '\u007f')) {
787
+ return '/';
788
+ }
789
+ const base = 'http://endpoint.invalid';
790
+ const target = new URL(path, base);
791
+ return target.origin === base ? target.pathname + target.search : '/';
792
+ }
793
+ exports.ownPath = ownPath;
794
+ //# sourceMappingURL=endpoints.js.map