hammerkit 1.27.0-next.13 → 1.27.0-next.15

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.
@@ -0,0 +1,612 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ownPath = 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
+ // The cookie of a sign-in at one endpoint host. `__Host-` keeps a page on another host of the
14
+ // domain from setting or overwriting it; it needs https, so plain http (on the loopback
15
+ // interface, for testing) goes without the prefix.
16
+ const SESSION_COOKIE = '__Host-hammerkit-session';
17
+ const PLAIN_SESSION_COOKIE = 'hammerkit-session';
18
+ // the host of the domain that signs people in, the one redirect URI registered at the issuer
19
+ exports.LOGIN_HOST = 'login';
20
+ // the paths of an endpoint host that are the server's, never the app's
21
+ const OWN_PATH = '/.hammerkit/';
22
+ // how long a sign-in may take at the issuer, and how long its one-time code holds
23
+ const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
24
+ const CODE_TIMEOUT_MS = 60 * 1000;
25
+ const MAX_PENDING_LOGINS = 1000;
26
+ const DISCOVERY_TTL_MS = 10 * 60 * 1000;
27
+ const FETCH_TIMEOUT_MS = 10000;
28
+ /**
29
+ * Publishes the endpoints of service runs: every host `<label>.<domain>` is answered here
30
+ * and nowhere else. A caller is known by a session cookie of that host, which the sign-in
31
+ * at `login.<domain>` gives a person, or by a token in `Hammerkit-Authorization`. Who may
32
+ * open it (the run's owner, or an `open` level for its repository) is decided with the
33
+ * entitlements of the moment on every request, then the request goes to the service's port.
34
+ */
35
+ class EndpointGateway {
36
+ constructor(config, registry, options) {
37
+ this.config = config;
38
+ this.registry = registry;
39
+ this.options = options;
40
+ this.pending = new Map();
41
+ this.codes = new Map();
42
+ this.base = { scheme: 'https', port: '' };
43
+ this.fetcher = options.fetch ?? fetch;
44
+ this.now = options.now ?? Date.now;
45
+ const login = config.endpoints?.login;
46
+ const issuer = login ? config.issuers.find((candidate) => candidate.issuer === login.issuer) : undefined;
47
+ if (login && issuer) {
48
+ // an ID token is meant for the client that signed in, not for the API
49
+ this.loginVerifier = new oauth_1.TokenVerifier([{ ...issuer, audience: login.clientId }], {
50
+ ...(options.fetch ? { fetch: options.fetch } : {}),
51
+ ...(options.now ? { now: options.now } : {}),
52
+ });
53
+ }
54
+ }
55
+ async load() {
56
+ try {
57
+ const key = await (0, promises_1.readFile)(this.options.keyFile);
58
+ if (key.length >= 32) {
59
+ this.key = key;
60
+ return;
61
+ }
62
+ }
63
+ catch {
64
+ // made below
65
+ }
66
+ this.key = (0, crypto_1.randomBytes)(32);
67
+ await (0, promises_1.mkdir)((0, path_1.dirname)(this.options.keyFile), { recursive: true, mode: 0o700 });
68
+ await (0, promises_1.writeFile)(this.options.keyFile, this.key, { mode: 0o600 });
69
+ }
70
+ // Where the server listens, for the addresses of endpoints: https unless it serves plain
71
+ // http on the loopback interface; no port behind a proxy or on the default one.
72
+ listening(port, secure) {
73
+ const scheme = secure || this.config.behindTlsProxy ? 'https' : 'http';
74
+ const standard = this.config.behindTlsProxy || (scheme === 'https' ? port === 443 : port === 80);
75
+ this.base = { scheme, port: standard ? '' : `:${port}` };
76
+ }
77
+ url(host, path = '/') {
78
+ return `${this.base.scheme}://${host}.${this.config.endpoints?.domain}${this.base.port}${path}`;
79
+ }
80
+ // The label of an endpoint host the request is for, or null when it is for the API.
81
+ hostOf(req) {
82
+ const domain = this.config.endpoints?.domain;
83
+ const host = (req.headers.host ?? '').toLowerCase().replace(/:\d+$/, '');
84
+ if (!domain || !host.endsWith(`.${domain}`)) {
85
+ return null;
86
+ }
87
+ return host.slice(0, -domain.length - 1);
88
+ }
89
+ async handle(label, req, res) {
90
+ const url = new URL(req.url ?? '/', 'http://endpoint');
91
+ if (label === exports.LOGIN_HOST) {
92
+ return this.handleLogin(req, res, url);
93
+ }
94
+ const found = /^[a-z0-9-]+$/.test(label) ? this.registry.endpoint(label) : undefined;
95
+ if (url.pathname === `${OWN_PATH}session`) {
96
+ return this.takeCode(label, req, res, url);
97
+ }
98
+ if (url.pathname === `${OWN_PATH}logout`) {
99
+ res.writeHead(303, { location: '/', 'set-cookie': this.clearCookie(), 'cache-control': 'no-store' });
100
+ res.end();
101
+ return;
102
+ }
103
+ if (!found) {
104
+ return page(res, 404, 'Not found', '<p>There is no endpoint at this address.</p>');
105
+ }
106
+ const principal = await this.caller(label, req);
107
+ if (!principal) {
108
+ if (this.loginVerifier && req.method === 'GET' && acceptsHtml(req.headers)) {
109
+ const start = new URL(this.url(exports.LOGIN_HOST, '/start'));
110
+ start.searchParams.set('host', label);
111
+ start.searchParams.set('path', url.pathname + url.search);
112
+ res.writeHead(302, { location: start.toString(), 'cache-control': 'no-store' });
113
+ res.end();
114
+ return;
115
+ }
116
+ res.writeHead(401, {
117
+ 'content-type': 'text/plain; charset=utf-8',
118
+ 'www-authenticate': 'Bearer realm="hammerkit"',
119
+ 'cache-control': 'no-store',
120
+ });
121
+ res.end('sign in: send a token in Hammerkit-Authorization: Bearer <token>\n');
122
+ return;
123
+ }
124
+ if (!this.mayOpen(principal, found.run)) {
125
+ this.options.log(`deny endpoint ${label} iss=${principal.iss} sub=${principal.sub}`);
126
+ 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>`);
127
+ }
128
+ if (url.pathname === `${OWN_PATH}status` || url.pathname === `${OWN_PATH}extend`) {
129
+ return this.status(label, found.run, found.endpoint, principal, req, res, url);
130
+ }
131
+ if (url.pathname.startsWith(OWN_PATH)) {
132
+ return page(res, 404, 'Not found', '<p>There is no such page.</p>');
133
+ }
134
+ // a request keeps the services up, or wakes them
135
+ const refused = this.registry.visit(found.run.id, this.options.mayWake);
136
+ if (!found.target) {
137
+ return this.notUp(res, this.registry.endpoint(label)?.run ?? found.run, refused);
138
+ }
139
+ this.forward(found.target, principal, req, res);
140
+ }
141
+ // A WebSocket (or any upgrade) to an endpoint, after the same checks as a request.
142
+ async upgrade(label, req, socket, head) {
143
+ const refuse = (status, text) => {
144
+ socket.end(`HTTP/1.1 ${status} ${text}\r\nconnection: close\r\ncontent-length: 0\r\n\r\n`);
145
+ };
146
+ const found = label !== exports.LOGIN_HOST && /^[a-z0-9-]+$/.test(label) ? this.registry.endpoint(label) : undefined;
147
+ if (!found) {
148
+ return refuse(404, 'Not Found');
149
+ }
150
+ const principal = await this.caller(label, req);
151
+ if (!principal) {
152
+ return refuse(401, 'Unauthorized');
153
+ }
154
+ if (!this.mayOpen(principal, found.run)) {
155
+ this.options.log(`deny endpoint ${label} iss=${principal.iss} sub=${principal.sub}`);
156
+ return refuse(403, 'Forbidden');
157
+ }
158
+ if (new URL(req.url ?? '/', 'http://endpoint').pathname.startsWith(OWN_PATH)) {
159
+ return refuse(404, 'Not Found');
160
+ }
161
+ this.registry.visit(found.run.id, this.options.mayWake);
162
+ const target = found.target;
163
+ if (!target) {
164
+ return refuse(503, 'Service Unavailable');
165
+ }
166
+ const upstream = (0, net_1.connect)(target.address.port, target.address.host);
167
+ const headers = this.forwardedHeaders(req, principal);
168
+ upstream.once('connect', () => {
169
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
170
+ for (const [name, value] of Object.entries(headers)) {
171
+ for (const item of Array.isArray(value) ? value : [value]) {
172
+ if (item !== undefined) {
173
+ lines.push(`${name}: ${item}`);
174
+ }
175
+ }
176
+ }
177
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
178
+ if (head.length > 0) {
179
+ upstream.write(head);
180
+ }
181
+ upstream.pipe(socket);
182
+ socket.pipe(upstream);
183
+ });
184
+ upstream.on('error', () => socket.destroy());
185
+ socket.on('error', () => upstream.destroy());
186
+ socket.on('close', () => upstream.destroy());
187
+ upstream.on('close', () => socket.destroy());
188
+ }
189
+ // The owner of the run, or who has an `open` level for its repository, with the
190
+ // entitlements as they are now.
191
+ mayOpen(principal, run) {
192
+ if (run.owner.iss === principal.iss && run.owner.sub === principal.sub) {
193
+ return true;
194
+ }
195
+ return (0, service_runs_1.mayOpenEndpoints)((0, policy_1.getGrant)(this.config.entitlements, principal), run);
196
+ }
197
+ async caller(label, req) {
198
+ const header = req.headers['hammerkit-authorization'];
199
+ const token = typeof header === 'string' ? /^Bearer ([A-Za-z0-9._~+/-]+=*)$/.exec(header)?.[1] : undefined;
200
+ if (token) {
201
+ try {
202
+ return await this.options.verifier.verify(token);
203
+ }
204
+ catch (e) {
205
+ this.options.log(`deny endpoint ${label}: ${e instanceof oauth_1.TokenError ? e.message : 'token could not be checked'}`);
206
+ return null;
207
+ }
208
+ }
209
+ // the cookie is sent along when a page of another endpoint asks: the endpoints share
210
+ // one site, and the app of one is not to act as its visitor on another
211
+ if (this.fromElsewhere(label, req)) {
212
+ return null;
213
+ }
214
+ const session = this.readSession(label, req.headers.cookie);
215
+ return session ? { iss: session.iss, sub: session.sub, groups: session.groups, claims: session.claims } : null;
216
+ }
217
+ // Whether the browser sends the request for another page than the endpoint's own: an
218
+ // upgrade from another origin, or a request a page of another site makes (a frame too),
219
+ // but a link followed to the endpoint. Without Sec-Fetch-Site, by its Origin.
220
+ fromElsewhere(label, req) {
221
+ const origin = req.headers.origin;
222
+ const own = this.url(label, '/').slice(0, -1);
223
+ if (req.headers.upgrade) {
224
+ return origin !== own;
225
+ }
226
+ const site = req.headers['sec-fetch-site'];
227
+ if (site === 'same-site' || site === 'cross-site') {
228
+ // a page of its own the person goes to, not one framed by another site
229
+ const dest = req.headers['sec-fetch-dest'];
230
+ return !(req.headers['sec-fetch-mode'] === 'navigate' &&
231
+ (dest === undefined || dest === 'document') &&
232
+ (req.method === 'GET' || req.method === 'HEAD'));
233
+ }
234
+ return site === undefined && origin !== undefined && origin !== own;
235
+ }
236
+ forward(target, principal, req, res) {
237
+ const upstream = (0, http_1.request)({
238
+ host: target.address.host,
239
+ port: target.address.port,
240
+ method: req.method,
241
+ path: req.url,
242
+ headers: this.forwardedHeaders(req, principal),
243
+ }, (answer) => {
244
+ const headers = { ...answer.headers, 'x-robots-tag': 'noindex' };
245
+ res.writeHead(answer.statusCode ?? 502, answer.statusMessage, headers);
246
+ answer.pipe(res);
247
+ });
248
+ upstream.on('error', (e) => {
249
+ this.options.log(`endpoint ${target.service}:${target.port}: ${e.message}`);
250
+ if (!res.headersSent) {
251
+ page(res, 502, 'Not reachable', `<p>${escape(target.service)} did not answer on port ${target.port}.</p>`);
252
+ }
253
+ else {
254
+ res.destroy();
255
+ }
256
+ });
257
+ res.on('close', () => upstream.destroy());
258
+ req.pipe(upstream);
259
+ }
260
+ // What reaches the app: the request without the server's cookie and headers, with who asked.
261
+ forwardedHeaders(req, principal) {
262
+ const headers = {};
263
+ for (const [name, value] of Object.entries(req.headers)) {
264
+ if (name.startsWith('hammerkit-') || name.startsWith('x-hammerkit-') || name.startsWith('x-forwarded-')) {
265
+ continue;
266
+ }
267
+ headers[name] = value;
268
+ }
269
+ const cookies = (req.headers.cookie ?? '')
270
+ .split(/;\s*/)
271
+ .filter((cookie) => cookie && !cookie.startsWith(`${SESSION_COOKIE}=`) && !cookie.startsWith(`${PLAIN_SESSION_COOKIE}=`));
272
+ if (cookies.length > 0) {
273
+ headers.cookie = cookies.join('; ');
274
+ }
275
+ else {
276
+ delete headers.cookie;
277
+ }
278
+ headers['x-forwarded-proto'] = this.base.scheme;
279
+ headers['x-forwarded-host'] = req.headers.host ?? '';
280
+ headers['x-forwarded-for'] = req.socket.remoteAddress ?? '';
281
+ headers['x-hammerkit-caller'] = `${principal.iss} ${principal.sub}`;
282
+ return headers;
283
+ }
284
+ notUp(res, run, refused) {
285
+ if (run.state === 'queued' || run.state === 'running' || run.state === 'awaiting-approval') {
286
+ res.setHeader('refresh', '5');
287
+ res.setHeader('retry-after', '5');
288
+ return page(res, 503, 'Starting', `<p>The services of this endpoint are ${run.wakes ? 'waking up' : 'starting'} (${escape(run.state)}), this page reloads until they are up.</p>`);
289
+ }
290
+ if (run.state === 'asleep') {
291
+ return page(res, 503, 'Asleep', `<p>The services of this endpoint sleep and can not wake now: ${escape(refused ?? 'try again')}.</p>`);
292
+ }
293
+ const why = run.stoppedBy === 'ttl'
294
+ ? 'its time ran out'
295
+ : run.stoppedBy === 'replaced'
296
+ ? 'a newer run of the same name replaced it'
297
+ : run.stoppedBy
298
+ ? `${escape(run.stoppedBy.sub)} stopped it`
299
+ : escape(run.error ?? run.state);
300
+ 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>`);
301
+ }
302
+ async status(label, run, endpoint, principal, req, res, url) {
303
+ const csrf = this.sign(`csrf\0${label}\0${principal.iss}\0${principal.sub}`);
304
+ if (url.pathname === `${OWN_PATH}extend`) {
305
+ if (req.method !== 'POST') {
306
+ return page(res, 405, 'Not allowed', '<p>Extend with the form of the status page.</p>');
307
+ }
308
+ const form = new URLSearchParams(await readText(req, 4096));
309
+ if (!safeEqual(form.get('csrf') ?? '', csrf)) {
310
+ return page(res, 403, 'Not allowed', '<p>The form is out of date, open the status page again.</p>');
311
+ }
312
+ let ttl;
313
+ try {
314
+ ttl = (0, units_1.parseDuration)(form.get('ttl') ?? '');
315
+ if (!(ttl > 0)) {
316
+ throw new Error('no time');
317
+ }
318
+ }
319
+ catch {
320
+ return page(res, 400, 'Not a duration', '<p>Pick how long it should stay.</p>');
321
+ }
322
+ const result = this.registry.extend(principal, run.id, ttl, () => this.mayOpen(principal, run));
323
+ this.options.log(`extend iss=${principal.iss} sub=${principal.sub} run=${run.id} result=${result.status}`);
324
+ res.writeHead(303, { location: `${OWN_PATH}status`, 'cache-control': 'no-store' });
325
+ res.end();
326
+ return;
327
+ }
328
+ const source = run.source.type === 'ref'
329
+ ? `${run.source.repo} ${run.source.ref} at ${run.source.commit.slice(0, 12)}`
330
+ : run.source.type === 'sandbox'
331
+ ? `sandbox ${run.source.id} of ${run.source.repo} at ${run.source.commit.slice(0, 12)}${run.source.clean === false ? ' with changes' : ''}`
332
+ : 'an upload';
333
+ const options = ['1h', '8h', '24h', '72h']
334
+ .map((ttl) => `<option value="${ttl}"${ttl === '8h' ? ' selected' : ''}>${ttl}</option>`)
335
+ .join('');
336
+ return page(res, 200, run.up?.name ?? 'Endpoint', `<dl>
337
+ <dt>Service</dt><dd>${escape(endpoint.service)}, port ${endpoint.port}</dd>
338
+ <dt>Run</dt><dd><code>${escape(run.id)}</code> of <code>${escape(run.owner.sub)}</code>, ${escape(run.state)}</dd>
339
+ <dt>Source</dt><dd>${escape(source)}</dd>
340
+ <dt>Ends</dt><dd>${escape(run.expiresAt ?? 'when it is up and its time ran out')}</dd>
341
+ ${run.up?.sleepTtl
342
+ ? `<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`
343
+ : ''}<dt>Signed in</dt><dd><code>${escape(principal.sub)}</code></dd>
344
+ </dl>
345
+ ${run.state === 'up'
346
+ ? `<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>`
347
+ : ''}
348
+ <p><a href="/">Open the app</a> · <a href="${OWN_PATH}logout">Sign out</a></p>`);
349
+ }
350
+ // ---- sign-in, on login.<domain> ----
351
+ async handleLogin(req, res, url) {
352
+ const login = this.config.endpoints?.login;
353
+ if (!login || !this.loginVerifier || req.method !== 'GET') {
354
+ return page(res, 404, 'Not found', '<p>There is nothing here.</p>');
355
+ }
356
+ if (url.pathname === '/start') {
357
+ const host = url.searchParams.get('host') ?? '';
358
+ const path = url.searchParams.get('path') ?? '/';
359
+ if (!/^[a-z0-9-]+$/.test(host) || host === exports.LOGIN_HOST || !this.registry.endpoint(host)) {
360
+ return page(res, 404, 'Not found', '<p>There is no endpoint at this address.</p>');
361
+ }
362
+ const endpoints = await this.endpointsOf(login.issuer);
363
+ this.sweep();
364
+ if (this.pending.size >= MAX_PENDING_LOGINS) {
365
+ return page(res, 503, 'Busy', '<p>Too many sign-ins at once, try again in a minute.</p>');
366
+ }
367
+ const state = (0, crypto_1.randomBytes)(24).toString('base64url');
368
+ const verifier = (0, crypto_1.randomBytes)(32).toString('base64url');
369
+ const nonce = (0, crypto_1.randomBytes)(24).toString('base64url');
370
+ this.pending.set(state, {
371
+ host,
372
+ path: ownPath(path),
373
+ verifier,
374
+ nonce,
375
+ expires: this.now() + LOGIN_TIMEOUT_MS,
376
+ });
377
+ const authorize = new URL(endpoints.authorize);
378
+ authorize.searchParams.set('response_type', 'code');
379
+ authorize.searchParams.set('client_id', login.clientId);
380
+ authorize.searchParams.set('redirect_uri', this.url(exports.LOGIN_HOST, '/callback'));
381
+ authorize.searchParams.set('scope', login.scope);
382
+ authorize.searchParams.set('state', state);
383
+ authorize.searchParams.set('nonce', nonce);
384
+ authorize.searchParams.set('code_challenge', (0, crypto_1.createHash)('sha256').update(verifier).digest('base64url'));
385
+ authorize.searchParams.set('code_challenge_method', 'S256');
386
+ res.writeHead(302, { location: authorize.toString(), 'cache-control': 'no-store' });
387
+ res.end();
388
+ return;
389
+ }
390
+ if (url.pathname === '/callback') {
391
+ const state = url.searchParams.get('state') ?? '';
392
+ const pending = this.pending.get(state);
393
+ this.pending.delete(state);
394
+ if (!pending || pending.expires < this.now()) {
395
+ return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
396
+ }
397
+ const code = url.searchParams.get('code');
398
+ if (!code) {
399
+ const error = url.searchParams.get('error') ?? 'no code';
400
+ return page(res, 400, 'Not signed in', `<p>The sign-in did not succeed: ${escape(error.slice(0, 200))}.</p>`);
401
+ }
402
+ let principal;
403
+ try {
404
+ principal = await this.exchange(code, pending);
405
+ }
406
+ catch (e) {
407
+ this.options.log(`endpoint sign-in failed: ${e instanceof Error ? e.message : String(e)}`);
408
+ return page(res, 400, 'Not signed in', '<p>The sign-in could not be checked, open the endpoint again.</p>');
409
+ }
410
+ const found = this.registry.endpoint(pending.host);
411
+ if (!found || !this.mayOpen(principal, found.run)) {
412
+ this.options.log(`deny endpoint ${pending.host} iss=${principal.iss} sub=${principal.sub}`);
413
+ return page(res, 403, 'Not allowed', `<p>You signed in as <code>${escape(principal.sub)}</code>, which may not open this endpoint.</p>`);
414
+ }
415
+ const issued = (0, crypto_1.randomBytes)(24).toString('base64url');
416
+ this.codes.set(issued, {
417
+ host: pending.host,
418
+ path: pending.path,
419
+ principal,
420
+ expires: this.now() + CODE_TIMEOUT_MS,
421
+ });
422
+ const target = new URL(this.url(pending.host, `${OWN_PATH}session`));
423
+ target.searchParams.set('code', issued);
424
+ this.options.log(`endpoint sign-in ${pending.host} iss=${principal.iss} sub=${principal.sub}`);
425
+ res.writeHead(302, { location: target.toString(), 'cache-control': 'no-store' });
426
+ res.end();
427
+ return;
428
+ }
429
+ return page(res, 404, 'Not found', '<p>There is nothing here.</p>');
430
+ }
431
+ // The code of the issuer for its tokens, and the ID token for who signed in.
432
+ async exchange(code, pending) {
433
+ const login = this.config.endpoints?.login;
434
+ if (!login || !this.loginVerifier) {
435
+ throw new Error('no sign-in configured');
436
+ }
437
+ const endpoints = await this.endpointsOf(login.issuer);
438
+ const body = new URLSearchParams({
439
+ grant_type: 'authorization_code',
440
+ code,
441
+ redirect_uri: this.url(exports.LOGIN_HOST, '/callback'),
442
+ client_id: login.clientId,
443
+ code_verifier: pending.verifier,
444
+ });
445
+ if (this.options.clientSecret) {
446
+ body.set('client_secret', await this.options.clientSecret());
447
+ }
448
+ const response = await this.fetcher(endpoints.token, {
449
+ method: 'POST',
450
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
451
+ body: body.toString(),
452
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
453
+ });
454
+ const answer = (await response.json().catch(() => ({})));
455
+ if (!response.ok || typeof answer.id_token !== 'string') {
456
+ throw new Error(`the token endpoint answered ${response.status}${answer.error ? ` ${String(answer.error)}` : ''}`);
457
+ }
458
+ const principal = await this.loginVerifier.verify(answer.id_token);
459
+ if (principal.claims.nonce !== pending.nonce) {
460
+ throw new oauth_1.TokenError('the ID token is not of this sign-in (nonce)');
461
+ }
462
+ return principal;
463
+ }
464
+ async endpointsOf(issuer) {
465
+ if (this.discovery && this.now() - this.discovery.at < DISCOVERY_TTL_MS) {
466
+ return this.discovery;
467
+ }
468
+ const response = await this.fetcher(`${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`, {
469
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
470
+ });
471
+ const metadata = (await response.json());
472
+ if (typeof metadata.authorization_endpoint !== 'string' || typeof metadata.token_endpoint !== 'string') {
473
+ throw new Error(`${issuer} names no authorization or token endpoint`);
474
+ }
475
+ this.discovery = { at: this.now(), authorize: metadata.authorization_endpoint, token: metadata.token_endpoint };
476
+ return this.discovery;
477
+ }
478
+ // `/.hammerkit/session?code=` on the endpoint host: the one-time code of the sign-in
479
+ // becomes the cookie of this host.
480
+ takeCode(label, req, res, url) {
481
+ const code = url.searchParams.get('code') ?? '';
482
+ const issued = this.codes.get(code);
483
+ this.codes.delete(code);
484
+ if (!issued || issued.host !== label || issued.expires < this.now()) {
485
+ return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
486
+ }
487
+ const found = this.registry.endpoint(label);
488
+ const ends = Date.parse(found?.run.expiresAt ?? '');
489
+ const session = this.now() + (0, units_1.parseDuration)(this.config.endpoints?.session ?? '8h');
490
+ const exp = Number.isFinite(ends) ? Math.min(session, Math.max(ends, this.now() + LOGIN_TIMEOUT_MS)) : session;
491
+ res.writeHead(303, {
492
+ location: issued.path,
493
+ 'set-cookie': this.sessionCookie({
494
+ host: label,
495
+ iss: issued.principal.iss,
496
+ sub: issued.principal.sub,
497
+ groups: issued.principal.groups,
498
+ claims: this.matchedClaims(issued.principal),
499
+ exp,
500
+ }),
501
+ 'cache-control': 'no-store',
502
+ });
503
+ res.end();
504
+ }
505
+ // Only the claims an entitlement matches on go into the cookie, which stays small.
506
+ matchedClaims(principal) {
507
+ const names = new Set(this.config.entitlements.flatMap((entitlement) => Object.keys(entitlement.claims?.match ?? {})));
508
+ return Object.fromEntries(Object.entries(principal.claims).filter(([name]) => names.has(name)));
509
+ }
510
+ cookieName() {
511
+ return this.base.scheme === 'https' ? SESSION_COOKIE : PLAIN_SESSION_COOKIE;
512
+ }
513
+ sessionCookie(session) {
514
+ const payload = Buffer.from(JSON.stringify(session)).toString('base64url');
515
+ const value = `${payload}.${this.sign(payload)}`;
516
+ const maxAge = Math.max(0, Math.floor((session.exp - this.now()) / 1000));
517
+ return `${this.cookieName()}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${this.base.scheme === 'https' ? '; Secure' : ''}`;
518
+ }
519
+ clearCookie() {
520
+ return `${this.cookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${this.base.scheme === 'https' ? '; Secure' : ''}`;
521
+ }
522
+ readSession(label, header) {
523
+ const name = this.cookieName();
524
+ const value = (header ?? '')
525
+ .split(/;\s*/)
526
+ .find((cookie) => cookie.startsWith(`${name}=`))
527
+ ?.slice(name.length + 1);
528
+ const [payload, signature] = (value ?? '').split('.');
529
+ if (!payload || !signature || !safeEqual(signature, this.sign(payload))) {
530
+ return null;
531
+ }
532
+ try {
533
+ const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
534
+ if (session.host !== label || !(session.exp > this.now()) || !session.iss || !session.sub) {
535
+ return null;
536
+ }
537
+ return {
538
+ ...session,
539
+ groups: Array.isArray(session.groups) ? session.groups : [],
540
+ claims: session.claims ?? {},
541
+ };
542
+ }
543
+ catch {
544
+ return null;
545
+ }
546
+ }
547
+ sign(value) {
548
+ if (!this.key) {
549
+ throw new Error('the endpoints were not loaded');
550
+ }
551
+ return (0, crypto_1.createHmac)('sha256', this.key).update(value).digest('base64url');
552
+ }
553
+ sweep() {
554
+ const now = this.now();
555
+ for (const [state, login] of this.pending) {
556
+ if (login.expires < now) {
557
+ this.pending.delete(state);
558
+ }
559
+ }
560
+ for (const [code, issued] of this.codes) {
561
+ if (issued.expires < now) {
562
+ this.codes.delete(code);
563
+ }
564
+ }
565
+ }
566
+ }
567
+ exports.EndpointGateway = EndpointGateway;
568
+ function safeEqual(a, b) {
569
+ const left = Buffer.from(a);
570
+ const right = Buffer.from(b);
571
+ return left.length === right.length && (0, crypto_1.timingSafeEqual)(left, right);
572
+ }
573
+ function acceptsHtml(headers) {
574
+ return (headers.accept ?? '').includes('text/html');
575
+ }
576
+ function escape(text) {
577
+ return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
578
+ }
579
+ async function readText(req, limit) {
580
+ let text = '';
581
+ for await (const chunk of req) {
582
+ text += chunk.toString('utf8');
583
+ if (text.length > limit) {
584
+ break;
585
+ }
586
+ }
587
+ return text.slice(0, limit);
588
+ }
589
+ // A page of the server's own on an endpoint host: nothing of it is the app's.
590
+ function page(res, status, title, body) {
591
+ res.writeHead(status, {
592
+ 'content-type': 'text/html; charset=utf-8',
593
+ 'cache-control': 'no-store',
594
+ 'x-robots-tag': 'noindex',
595
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
596
+ 'x-frame-options': 'DENY',
597
+ });
598
+ 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>`);
599
+ }
600
+ // Where on its own host a person goes back to after the sign-in: a path, never an address a
601
+ // browser reads as another host (`//x`, `/\\x`, control characters).
602
+ function ownPath(path) {
603
+ if (!path.startsWith('/') ||
604
+ [...path].some((char) => char === '\\' || char.charCodeAt(0) < 0x20 || char === '\u007f')) {
605
+ return '/';
606
+ }
607
+ const base = 'http://endpoint.invalid';
608
+ const target = new URL(path, base);
609
+ return target.origin === base ? target.pathname + target.search : '/';
610
+ }
611
+ exports.ownPath = ownPath;
612
+ //# sourceMappingURL=endpoints.js.map
@@ -127,7 +127,9 @@ class HostExecutor {
127
127
  const credentials = await repos.credentials(repo);
128
128
  let commit;
129
129
  try {
130
- commit = await (0, git_checkout_1.checkoutRef)(repos.url(repo), ref, cwd, credentials.env, abortCtrl.signal, pinned);
130
+ // a woken service run starts the commit it started first, where the ref is now or not
131
+ const fetched = run.wakes ? pinned : ref;
132
+ commit = await (0, git_checkout_1.checkoutRef)(repos.url(repo), fetched, cwd, credentials.env, abortCtrl.signal, pinned);
131
133
  }
132
134
  catch (e) {
133
135
  throw repos.scrub(e, credentials.values);
@@ -189,6 +191,14 @@ class HostExecutor {
189
191
  accounts: run.accounts,
190
192
  volumeScope: volumeScope(run),
191
193
  catalog: planning.environment.hosted?.catalog,
194
+ ...(run.up
195
+ ? {
196
+ up: {
197
+ services: run.up.services,
198
+ endpoints: run.up.endpoints.map(({ service, port }) => ({ service, port })),
199
+ },
200
+ }
201
+ : {}),
192
202
  ...(placed ? { kubernetes: placed.kubernetes } : {}),
193
203
  }, cwd, { ...this.processEnvironment(home, cache), ...placed?.env }, context, abortCtrl.signal);
194
204
  }
@@ -282,6 +292,9 @@ class HostExecutor {
282
292
  if (message.kind === 'event') {
283
293
  context.emit(message.event);
284
294
  }
295
+ else if (message.kind === 'up') {
296
+ context.up?.(message.targets);
297
+ }
285
298
  else if (message.kind === 'result') {
286
299
  result = message;
287
300
  // nothing left to tell the process