hammerkit 1.23.0 → 1.25.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.
Files changed (52) hide show
  1. package/build.schema.json +10 -0
  2. package/dist/cli.js +6 -0
  3. package/dist/executer/execute-command.js +7 -1
  4. package/dist/optimizer/get-cache-directory.js +5 -0
  5. package/dist/planner/utils/append-work-task.js +1 -0
  6. package/dist/planner/work-secret.js +6 -2
  7. package/dist/program.js +6 -0
  8. package/dist/remote/bundle.js +59 -0
  9. package/dist/remote/collect-bundle.js +41 -0
  10. package/dist/remote/credentials.js +93 -0
  11. package/dist/remote/device-login.js +177 -0
  12. package/dist/remote/host-config.js +170 -0
  13. package/dist/remote/location.js +46 -0
  14. package/dist/remote/remote-commands.js +158 -0
  15. package/dist/remote/remote-work-commands.js +224 -0
  16. package/dist/remote/server-client.js +88 -0
  17. package/dist/remote/source-manifest.js +79 -0
  18. package/dist/remote/text-diff.js +89 -0
  19. package/dist/schema/build-file-base-task-schema.js +4 -0
  20. package/dist/schema/build-file-schema.js +3 -0
  21. package/dist/schema/reference-parser.js +9 -2
  22. package/dist/schema/runner-schema.js +13 -0
  23. package/dist/secrets/provider-values.js +4 -0
  24. package/dist/server/accounts-api.js +42 -0
  25. package/dist/server/approvals-api.js +104 -0
  26. package/dist/server/definitions-api.js +163 -0
  27. package/dist/server/definitions.js +160 -0
  28. package/dist/server/discovery.js +9 -0
  29. package/dist/server/executor.js +237 -0
  30. package/dist/server/git-checkout.js +115 -0
  31. package/dist/server/http.js +39 -0
  32. package/dist/server/oauth.js +226 -0
  33. package/dist/server/plan.js +296 -0
  34. package/dist/server/policy.js +138 -0
  35. package/dist/server/proposals-api.js +145 -0
  36. package/dist/server/proposals.js +173 -0
  37. package/dist/server/repo-access.js +127 -0
  38. package/dist/server/repo.js +94 -0
  39. package/dist/server/run-environment.js +20 -0
  40. package/dist/server/run-plan.js +44 -0
  41. package/dist/server/run-request.js +85 -0
  42. package/dist/server/run-store.js +61 -0
  43. package/dist/server/run-worker-command.js +17 -0
  44. package/dist/server/run-worker-main.js +7 -0
  45. package/dist/server/run-worker.js +179 -0
  46. package/dist/server/runs.js +432 -0
  47. package/dist/server/server-command.js +61 -0
  48. package/dist/server/server-config.js +394 -0
  49. package/dist/server/server.js +434 -0
  50. package/dist/server/sources-api.js +106 -0
  51. package/dist/server/sources.js +258 -0
  52. package/package.json +1 -1
@@ -0,0 +1,434 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startServer = exports.getDiscoveryDocument = exports.SERVER_PROTOCOL = exports.DISCOVERY_PATH = void 0;
4
+ const http_1 = require("http");
5
+ const https_1 = require("https");
6
+ const version_1 = require("../version");
7
+ const server_config_1 = require("./server-config");
8
+ const discovery_1 = require("./discovery");
9
+ const oauth_1 = require("./oauth");
10
+ const policy_1 = require("./policy");
11
+ const run_store_1 = require("./run-store");
12
+ const runs_1 = require("./runs");
13
+ const git_checkout_1 = require("./git-checkout");
14
+ const repo_access_1 = require("./repo-access");
15
+ const executor_1 = require("./executor");
16
+ const run_plan_1 = require("./run-plan");
17
+ const run_environment_1 = require("./run-environment");
18
+ const accounts_api_1 = require("./accounts-api");
19
+ const run_request_1 = require("./run-request");
20
+ const http_2 = require("./http");
21
+ const definitions_1 = require("./definitions");
22
+ const definitions_api_1 = require("./definitions-api");
23
+ const approvals_api_1 = require("./approvals-api");
24
+ const proposals_api_1 = require("./proposals-api");
25
+ const proposals_1 = require("./proposals");
26
+ const sources_1 = require("./sources");
27
+ const sources_api_1 = require("./sources-api");
28
+ const units_1 = require("../utils/units");
29
+ const get_cache_directory_1 = require("../optimizer/get-cache-directory");
30
+ const path_1 = require("path");
31
+ var discovery_2 = require("./discovery");
32
+ Object.defineProperty(exports, "DISCOVERY_PATH", { enumerable: true, get: function () { return discovery_2.DISCOVERY_PATH; } });
33
+ Object.defineProperty(exports, "SERVER_PROTOCOL", { enumerable: true, get: function () { return discovery_2.SERVER_PROTOCOL; } });
34
+ function getDiscoveryDocument(config) {
35
+ return {
36
+ name: 'hammerkit',
37
+ version: (0, version_1.getVersion)(),
38
+ protocol: discovery_1.SERVER_PROTOCOL,
39
+ issuers: config.issuers.map((i) => ({
40
+ issuer: i.issuer,
41
+ audience: i.audience,
42
+ ...(i.clientId ? { clientId: i.clientId } : {}),
43
+ scope: i.scope,
44
+ })),
45
+ backend: { type: config.backend.type, platform: config.backend.platform ?? (0, server_config_1.detectPlatform)() },
46
+ };
47
+ }
48
+ exports.getDiscoveryDocument = getDiscoveryDocument;
49
+ const HEARTBEAT_MS = 15000;
50
+ // Server-sent events: `id` is the sequence number, so a client that lost the
51
+ // connection continues with Last-Event-ID (or ?after=) and misses nothing.
52
+ function streamEvents(registry, config, principal, id, req, res, url) {
53
+ const after = Number(req.headers['last-event-id'] ?? url.searchParams.get('after') ?? 0);
54
+ const write = (event) => res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
55
+ const watch = registry.watch(principal, id, Number.isFinite(after) && after > 0 ? after : 0, (event) => {
56
+ write(event);
57
+ if (event.type === 'state' && (0, runs_1.isFinal)(event.state)) {
58
+ end();
59
+ }
60
+ }, accountsOf(config, principal));
61
+ if (!watch) {
62
+ (0, http_2.send)(res, 404, { error: 'not found' });
63
+ return;
64
+ }
65
+ const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), HEARTBEAT_MS);
66
+ const end = () => {
67
+ clearInterval(heartbeat);
68
+ watch.stop();
69
+ res.end();
70
+ };
71
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
72
+ watch.events.forEach(write);
73
+ if (watch.finished) {
74
+ end();
75
+ return;
76
+ }
77
+ res.on('close', () => {
78
+ clearInterval(heartbeat);
79
+ watch.stop();
80
+ });
81
+ }
82
+ async function handleRuns(config, registry, definitions, sources, planning, repos, log, principal, req, res, url) {
83
+ const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events))?)?$/.exec(url.pathname);
84
+ if (!match) {
85
+ return false;
86
+ }
87
+ const [, id, action] = match;
88
+ const method = req.method;
89
+ if (!id) {
90
+ if (method === 'GET') {
91
+ (0, http_2.send)(res, 200, { runs: registry.list(principal).map(toRunResponse) });
92
+ }
93
+ else if (method === 'POST') {
94
+ await submitRun(config, registry, definitions, sources, planning, repos, log, principal, req, res);
95
+ }
96
+ else {
97
+ (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST' });
98
+ }
99
+ return true;
100
+ }
101
+ if (!runs_1.RUN_ID_PATTERN.test(id)) {
102
+ (0, http_2.send)(res, 404, { error: 'not found' });
103
+ }
104
+ else if (action === 'cancel') {
105
+ if (method !== 'POST') {
106
+ (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
107
+ return true;
108
+ }
109
+ const run = registry.cancel(principal, id, accountsOf(config, principal));
110
+ log(`cancel iss=${principal.iss} sub=${principal.sub} run=${id} found=${Boolean(run)}`);
111
+ run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
112
+ }
113
+ else if (method !== 'GET') {
114
+ (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
115
+ }
116
+ else if (action === 'events') {
117
+ streamEvents(registry, config, principal, id, req, res, url);
118
+ }
119
+ else {
120
+ const run = registry.get(principal, id, accountsOf(config, principal));
121
+ run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
122
+ }
123
+ return true;
124
+ }
125
+ // The accounts the caller uses: who approves a run on them follows and stops it.
126
+ function accountsOf(config, principal) {
127
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
128
+ return (account) => (0, policy_1.canUse)(grant, account);
129
+ }
130
+ function toRunResponse(run) {
131
+ const { owner, ...visible } = run;
132
+ void owner;
133
+ return visible;
134
+ }
135
+ async function submitRun(config, registry, definitions, sources, planning, repos, log, principal, req, res) {
136
+ const parsed = run_request_1.runRequestSchema.safeParse(await (0, http_2.readJson)(req));
137
+ if (!parsed.success) {
138
+ const issue = parsed.error.issues[0];
139
+ (0, http_2.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
140
+ return;
141
+ }
142
+ const request = parsed.data;
143
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
144
+ // what the caller may not do is said plainly: they already know who they are
145
+ if (!(0, policy_1.mayRun)(grant)) {
146
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: no entitlement`);
147
+ (0, http_2.send)(res, 403, { error: 'you are not entitled to run anything on this server' });
148
+ return;
149
+ }
150
+ const version = definitions.find(request.definition.name, request.definition.hash);
151
+ if (!version) {
152
+ (0, http_2.send)(res, 400, { error: `there is no build file ${request.definition.name} at ${request.definition.hash}` });
153
+ return;
154
+ }
155
+ if (version.yanked) {
156
+ (0, http_2.send)(res, 409, {
157
+ error: `${version.name} at ${version.hash} was yanked${version.yanked.reason ? `: ${version.yanked.reason}` : ''}`,
158
+ });
159
+ return;
160
+ }
161
+ const known = [...version.tasks, ...version.services];
162
+ const unknown = request.tasks.filter((task) => !known.includes(task));
163
+ if (unknown.length > 0) {
164
+ (0, http_2.send)(res, 400, { error: `${version.name} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
165
+ return;
166
+ }
167
+ if (request.source.type === 'upload') {
168
+ // a run starts from files the server has all of, never from a half-sent tree
169
+ const tree = await sources.get(principal, request.source.snapshot);
170
+ if (!tree) {
171
+ (0, http_2.send)(res, 400, {
172
+ error: `the files ${request.source.snapshot} are not known, announce them with POST /v1/sources`,
173
+ });
174
+ return;
175
+ }
176
+ if (tree.missing.length > 0) {
177
+ (0, http_2.send)(res, 400, {
178
+ error: `the files ${tree.snapshot} are not complete, ${tree.missing.length} of them still have to be sent`,
179
+ });
180
+ return;
181
+ }
182
+ }
183
+ // the accounts of a run are read from the files it is pinned to, with the engine
184
+ const bundle = version.plan ? await definitions.files(version) : undefined;
185
+ if (!version.plan || !bundle) {
186
+ (0, http_2.send)(res, 409, {
187
+ error: `${version.name} at ${version.hash} was published before accounts were recorded, publish it again`,
188
+ });
189
+ return;
190
+ }
191
+ let planned;
192
+ try {
193
+ planned = await (0, run_plan_1.planRun)(bundle, request, planning.environment, planning.policy);
194
+ }
195
+ catch (e) {
196
+ (0, http_2.send)(res, 400, { error: `the run does not load: ${e instanceof Error ? e.message : String(e)}` });
197
+ return;
198
+ }
199
+ const decision = (0, policy_1.decideRun)({
200
+ grant,
201
+ accounts: planned.accounts,
202
+ catalog: config.accounts,
203
+ source: request.source.type === 'ref'
204
+ ? { type: 'ref', repo: request.source.repo, ref: request.source.ref }
205
+ : { type: 'upload' },
206
+ approvable: (account) => config.entitlements.some((entitlement) => entitlement.accounts[account] === 'use'),
207
+ readable: (repo) => repos.readable(repo),
208
+ });
209
+ if (decision.type === 'refuse') {
210
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${decision.error}`);
211
+ (0, http_2.send)(res, decision.status, { error: decision.error });
212
+ return;
213
+ }
214
+ const approvals = decision.type === 'approval' ? decision.accounts : [];
215
+ // the ref is pinned now: the approver sees the commit, and the run executes that one
216
+ let source;
217
+ if (request.source.type === 'ref') {
218
+ try {
219
+ const commit = await repos.resolve(request.source.repo, request.source.ref);
220
+ source = { type: 'ref', repo: request.source.repo, ref: request.source.ref, commit };
221
+ }
222
+ catch (e) {
223
+ const message = e instanceof Error ? e.message : String(e);
224
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${message}`);
225
+ (0, http_2.send)(res, e instanceof git_checkout_1.RefError ? 400 : 502, {
226
+ error: e instanceof git_checkout_1.RefError ? message : `could not resolve ${request.source.ref}: ${message}`,
227
+ });
228
+ return;
229
+ }
230
+ }
231
+ else {
232
+ source = request.source;
233
+ }
234
+ try {
235
+ const run = registry.submit(principal, {
236
+ definition: { name: version.name, hash: version.hash },
237
+ tasks: request.tasks,
238
+ accounts: planned.accounts,
239
+ plan: planned.plan,
240
+ source,
241
+ options: request.options,
242
+ }, approvals);
243
+ log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} accounts=[${planned.accounts.join(',')}] source=${run.source.type} tasks=${run.tasks.length}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
244
+ (0, http_2.send)(res, 202, toRunResponse(run));
245
+ }
246
+ catch (e) {
247
+ if (e instanceof runs_1.RunError) {
248
+ (0, http_2.send)(res, e.status, { error: e.message });
249
+ return;
250
+ }
251
+ throw e;
252
+ }
253
+ }
254
+ function bearerToken(req) {
255
+ const match = /^Bearer ([A-Za-z0-9._~+/-]+=*)$/.exec(req.headers.authorization ?? '');
256
+ return match ? match[1] : null;
257
+ }
258
+ // Callers are known by their validated token alone. The reason a token was
259
+ // refused goes to the log, the caller only learns that it was.
260
+ async function authenticate(verifier, log, req, res) {
261
+ const token = bearerToken(req);
262
+ try {
263
+ if (!token) {
264
+ throw new oauth_1.TokenError('no bearer token');
265
+ }
266
+ return await verifier.verify(token);
267
+ }
268
+ catch (e) {
269
+ log(`deny ${req.method} ${req.url}: ${e instanceof oauth_1.TokenError ? e.message : 'token could not be checked'}`);
270
+ (0, http_2.send)(res, 401, { error: 'invalid token' }, { 'www-authenticate': 'Bearer error="invalid_token"' });
271
+ return null;
272
+ }
273
+ }
274
+ async function handle(config, registry, definitions, proposals, sources, planning, repos, verifier, log, req, res) {
275
+ const url = new URL(req.url ?? '/', 'http://localhost');
276
+ const path = url.pathname;
277
+ if (path === discovery_1.DISCOVERY_PATH || path === '/healthz') {
278
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
279
+ (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, HEAD' });
280
+ return;
281
+ }
282
+ (0, http_2.send)(res, 200, path === '/healthz' ? { status: 'ok' } : getDiscoveryDocument(config));
283
+ return;
284
+ }
285
+ const principal = await authenticate(verifier, log, req, res);
286
+ if (!principal) {
287
+ return;
288
+ }
289
+ if (await (0, accounts_api_1.handleAccounts)(config, log, principal, req, res, url)) {
290
+ return;
291
+ }
292
+ if (await (0, definitions_api_1.handleDefinitions)(config, definitions, proposals, log, principal, req, res, url)) {
293
+ return;
294
+ }
295
+ if (await (0, sources_api_1.handleSources)(config, sources, log, principal, req, res, url)) {
296
+ return;
297
+ }
298
+ if (await handleRuns(config, registry, definitions, sources, planning, repos, log, principal, req, res, url)) {
299
+ return;
300
+ }
301
+ if (await (0, approvals_api_1.handleApprovals)(config, registry, log, principal, req, res, url)) {
302
+ return;
303
+ }
304
+ if (await (0, proposals_api_1.handleProposals)(config, definitions, proposals, log, principal, req, res, url)) {
305
+ return;
306
+ }
307
+ (0, http_2.send)(res, 404, { error: 'not found' });
308
+ }
309
+ async function startServer(config, environment, options = {}) {
310
+ // tasks find their tools through PATH, a server that hides it would fail every run
311
+ if (!config.runs.env.allow.some((name) => name.toUpperCase() === 'PATH') && !('PATH' in config.runs.env.set)) {
312
+ throw new Error('runs.env.allow has no PATH, tasks could not find their tools: add PATH (and on Windows SystemRoot, ComSpec, PATHEXT and TEMP)');
313
+ }
314
+ const verifier = new oauth_1.TokenVerifier(config.issuers, options);
315
+ const write = options.log ?? (() => undefined);
316
+ // request urls and subjects come from callers: no control characters, so no forged log lines
317
+ const log = (line) => write(line.replace(/\p{Cc}/gu, '?').slice(0, 1000));
318
+ if (config.runtimes.includes('local')) {
319
+ log('warning: the local runtime is allowed, local tasks run as the user of this server and read all it can read; ' +
320
+ 'an account is then an approval class, not a credential boundary');
321
+ }
322
+ // build files are read, planned and run as the server's runs see the world: the
323
+ // operator's accounts and the allowlisted environment, nothing of the server's own
324
+ const planning = {
325
+ environment: {
326
+ ...environment,
327
+ processEnvs: (0, run_environment_1.runEnvironment)(config, environment.processEnvs),
328
+ hosted: { catalog: (0, server_config_1.getSecretCatalog)(config) },
329
+ },
330
+ policy: { envs: config.runs.env.allow, reserved: (0, server_config_1.getSourceAccounts)(config), runtimes: config.runtimes },
331
+ };
332
+ const storage = config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server');
333
+ const definitions = new definitions_1.DefinitionStore((0, path_1.join)(storage, 'definitions'), planning.environment, {
334
+ policy: planning.policy,
335
+ });
336
+ await definitions.load();
337
+ const repos = new repo_access_1.RepoAccess(config, planning.environment, options.repos);
338
+ const executor = options.executor ??
339
+ new executor_1.HostExecutor({
340
+ definitions,
341
+ planning,
342
+ repos,
343
+ directory: (0, path_1.join)(storage, 'runs'),
344
+ cache: (0, path_1.join)(storage, 'cache'),
345
+ worker: options.worker,
346
+ });
347
+ const sources = new sources_1.SourceStore((0, path_1.join)(storage, 'sources'));
348
+ const registry = new runs_1.RunRegistry({
349
+ executor,
350
+ maxConcurrent: config.runs.maxConcurrent,
351
+ keep: config.runs.keep,
352
+ maxWaiting: config.approvals.maxWaiting,
353
+ approvalTimeout: (0, units_1.parseDuration)(config.approvals.timeout),
354
+ limits: config.runs.limits,
355
+ maxDuration: (0, units_1.parseDuration)(config.runs.maxDuration),
356
+ maxLogBytes: config.runs.maxLogBytes,
357
+ store: new run_store_1.FileRunStore((0, path_1.join)(storage, 'records')),
358
+ log,
359
+ });
360
+ await registry.restore();
361
+ const proposals = new proposals_1.ProposalRegistry({
362
+ maxWaiting: config.approvals.maxWaiting,
363
+ timeout: (0, units_1.parseDuration)(config.approvals.timeout),
364
+ cooldown: (0, units_1.parseDuration)(config.approvals.cooldown),
365
+ });
366
+ const listener = (req, res) => {
367
+ handle(config, registry, definitions, proposals, sources, planning, repos, verifier, log, req, res).catch((e) => {
368
+ if (e instanceof http_2.HttpError) {
369
+ (0, http_2.send)(res, e.status, { error: e.message });
370
+ return;
371
+ }
372
+ log(`error ${req.method} ${req.url}: ${e instanceof Error ? e.message : String(e)}`);
373
+ if (!res.headersSent) {
374
+ (0, http_2.send)(res, 500, { error: 'internal error' });
375
+ }
376
+ else {
377
+ res.end();
378
+ }
379
+ });
380
+ };
381
+ let server;
382
+ if (config.tls) {
383
+ const [cert, key] = await Promise.all([
384
+ environment.file.read(config.tls.cert),
385
+ environment.file.read(config.tls.key),
386
+ ]);
387
+ server = (0, https_1.createServer)({ cert, key }, listener);
388
+ }
389
+ else {
390
+ server = (0, http_1.createServer)(listener);
391
+ }
392
+ await new Promise((resolve, reject) => {
393
+ server.once('error', reject);
394
+ server.listen(config.listen.port, config.listen.host, () => {
395
+ server.off('error', reject);
396
+ resolve();
397
+ });
398
+ });
399
+ const address = server.address();
400
+ const host = address.family === 'IPv6' ? `[${address.address}]` : address.address;
401
+ return {
402
+ url: `${config.tls ? 'https' : 'http'}://${host}:${address.port}`,
403
+ reload: (next) => {
404
+ const readers = (0, server_config_1.getSourceAccounts)(config);
405
+ next.entitlements.forEach((entitlement, index) => {
406
+ const iss = (entitlement.subject ?? entitlement.group ?? entitlement.claims)?.iss;
407
+ if (iss && !config.issuers.some((issuer) => issuer.issuer === iss)) {
408
+ throw new Error(`entitlements.${index}: issuer ${iss} is not accepted by the running server`);
409
+ }
410
+ for (const account of Object.keys(entitlement.accounts)) {
411
+ if (!config.accounts[account] || readers.includes(account)) {
412
+ throw new Error(`entitlements.${index}: the running server has no account ${account} to give`);
413
+ }
414
+ }
415
+ });
416
+ const restart = Object.keys(next).filter((key) => key !== 'entitlements' &&
417
+ JSON.stringify(next[key]) !== JSON.stringify(config[key]));
418
+ // every request reads the entitlements of this object
419
+ config.entitlements = next.entitlements;
420
+ log(`reloaded ${next.entitlements.length} entitlements${restart.length > 0 ? `, restart for: ${restart.join(', ')}` : ''}`);
421
+ return { restart };
422
+ },
423
+ close: async () => {
424
+ proposals.close();
425
+ await registry.close();
426
+ await new Promise((resolve, reject) => {
427
+ server.close((err) => (err ? reject(err) : resolve()));
428
+ server.closeAllConnections();
429
+ });
430
+ },
431
+ };
432
+ }
433
+ exports.startServer = startServer;
434
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleSources = void 0;
4
+ const bundle_1 = require("../remote/bundle");
5
+ const source_manifest_1 = require("../remote/source-manifest");
6
+ const http_1 = require("./http");
7
+ const policy_1 = require("./policy");
8
+ const sources_1 = require("./sources");
9
+ /**
10
+ * The project files of a caller, in two steps so that only what the server
11
+ * lacks is sent:
12
+ *
13
+ * POST /v1/sources announce a tree (a manifest of paths and digests);
14
+ * answers its name and the digests still missing
15
+ * PUT /v1/sources/blobs/<sha256> send the content of one file
16
+ * GET /v1/sources the caller's trees and what they take
17
+ * GET /v1/sources/<hash> one tree and what is still missing
18
+ * DELETE /v1/sources/<hash> forget a tree and the content nothing else needs
19
+ *
20
+ * Everything is the caller's own: another caller's tree is `404`, and content is
21
+ * never shared between callers.
22
+ */
23
+ async function handleSources(config, store, log, principal, req, res, url) {
24
+ const match = /^\/v1\/sources(?:\/(blobs)\/([^/]+)|\/([^/]+))?$/.exec(url.pathname);
25
+ if (!match) {
26
+ return false;
27
+ }
28
+ const [, blobs, digest, rawSnapshot] = match;
29
+ const method = req.method;
30
+ try {
31
+ if (blobs) {
32
+ if (method !== 'PUT') {
33
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'PUT' });
34
+ return true;
35
+ }
36
+ mustMayUpload(config, principal);
37
+ const result = await store.put(principal, digest, req);
38
+ (0, http_1.send)(res, result === 'created' ? 201 : 200, { stored: digest });
39
+ return true;
40
+ }
41
+ if (rawSnapshot === undefined) {
42
+ if (method === 'GET') {
43
+ (0, http_1.send)(res, 200, await store.list(principal));
44
+ }
45
+ else if (method === 'POST') {
46
+ mustMayUpload(config, principal);
47
+ const parsed = source_manifest_1.sourceManifestSchema.safeParse(await (0, http_1.readJson)(req, source_manifest_1.MAX_MANIFEST_BODY_BYTES));
48
+ if (!parsed.success) {
49
+ const issue = parsed.error.issues[0];
50
+ (0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
51
+ return true;
52
+ }
53
+ const { status, created } = await store.announce(principal, parsed.data);
54
+ log(`source ${status.snapshot} iss=${principal.iss} sub=${principal.sub} files=${status.files} missing=${status.missing.length}`);
55
+ (0, http_1.send)(res, created ? 201 : 200, { ...status, complete: status.missing.length === 0 });
56
+ }
57
+ else {
58
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST' });
59
+ }
60
+ return true;
61
+ }
62
+ const snapshot = decodeSnapshot(rawSnapshot);
63
+ if (method === 'GET') {
64
+ const status = await store.get(principal, snapshot);
65
+ status ? (0, http_1.send)(res, 200, { ...status, complete: status.missing.length === 0 }) : notFound(res);
66
+ }
67
+ else if (method === 'DELETE') {
68
+ const removed = await store.remove(principal, snapshot);
69
+ log(`source remove ${snapshot} iss=${principal.iss} sub=${principal.sub} found=${removed}`);
70
+ removed ? (0, http_1.send)(res, 200, { removed: snapshot }) : notFound(res);
71
+ }
72
+ else {
73
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, DELETE' });
74
+ }
75
+ return true;
76
+ }
77
+ catch (e) {
78
+ if (e instanceof sources_1.SourceError) {
79
+ (0, http_1.send)(res, e.status, { error: e.message });
80
+ return true;
81
+ }
82
+ throw e;
83
+ }
84
+ }
85
+ exports.handleSources = handleSources;
86
+ // The colon of `sha256:<hex>` may arrive escaped.
87
+ function decodeSnapshot(raw) {
88
+ try {
89
+ const value = decodeURIComponent(raw);
90
+ return bundle_1.HASH_PATTERN.test(value) ? value : '';
91
+ }
92
+ catch {
93
+ return '';
94
+ }
95
+ }
96
+ function notFound(res) {
97
+ (0, http_1.send)(res, 404, { error: 'not found' });
98
+ }
99
+ // Sending files is only for callers who may run something from an upload.
100
+ function mustMayUpload(config, principal) {
101
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
102
+ if (!(0, policy_1.mayRun)(grant)) {
103
+ throw new sources_1.SourceError(403, 'uploads are not allowed for you');
104
+ }
105
+ }
106
+ //# sourceMappingURL=sources-api.js.map