hammerkit 1.24.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.
@@ -14,7 +14,12 @@ function getCacheStatsFile(cacheId) {
14
14
  return (0, path_1.join)(getCacheDirectory(cacheId), 'stats.json');
15
15
  }
16
16
  exports.getCacheStatsFile = getCacheStatsFile;
17
+ // `HAMMERKIT_DIR` puts hammerkit's directory (the cache) elsewhere than below the home directory;
18
+ // a server uses it to keep the caches of runs apart.
17
19
  function getHammerkitDirectory() {
20
+ if (process.env.HAMMERKIT_DIR) {
21
+ return process.env.HAMMERKIT_DIR;
22
+ }
18
23
  if (process.platform === 'win32') {
19
24
  return (0, path_1.join)(process.env.APPDATA, 'hammerkit');
20
25
  }
@@ -66,9 +66,13 @@ function parseSecretSource(secret, ref, cwd, envs, catalog, taskAccount, label)
66
66
  if (providerRef.length === 0 || providerRef.startsWith('-')) {
67
67
  throw new Error(`${label}: a reference for ${kind} must not be empty or start with -`);
68
68
  }
69
- const accountName = secret.account ?? taskAccount ?? Object.values(catalog.accounts).find((a) => a.default)?.name;
69
+ const accountName = catalog.explicitAccounts
70
+ ? secret.account
71
+ : secret.account ?? taskAccount ?? Object.values(catalog.accounts).find((a) => a.default)?.name;
70
72
  if (!accountName) {
71
- throw new Error(`${label}: ${kind}:${providerRef} needs an account, and none is named or marked default: true`);
73
+ throw new Error(catalog.explicitAccounts
74
+ ? `${label}: ${kind}:${providerRef} needs account: on the secret, a server uses neither a task's account nor a default`
75
+ : `${label}: ${kind}:${providerRef} needs an account, and none is named or marked default: true`);
72
76
  }
73
77
  const account = catalog.accounts[accountName];
74
78
  if (!account) {
package/dist/program.js CHANGED
@@ -27,6 +27,7 @@ const units_1 = require("./utils/units");
27
27
  const cache_inventory_1 = require("./cache/cache-inventory");
28
28
  const remote_commands_1 = require("./remote/remote-commands");
29
29
  const server_command_1 = require("./server/server-command");
30
+ const run_worker_command_1 = require("./server/run-worker-command");
30
31
  async function createCli(fileName, environment, workScope) {
31
32
  const { ctx, scope } = await (0, schema_parser_1.createParseContext)(fileName, environment);
32
33
  const referencedScope = await (0, reference_parser_1.parseReferences)(ctx, scope, environment);
@@ -105,6 +106,7 @@ function parseWorkLabelScope(options) {
105
106
  async function getProgram(environment, argv) {
106
107
  const program = new commander_1.Command();
107
108
  (0, server_command_1.registerServerCommand)(program, environment);
109
+ (0, run_worker_command_1.registerRunWorkerCommand)(program, environment);
108
110
  const parsed = (0, parse_set_arguments_1.parseSetArguments)(argv);
109
111
  const args = parsed.args;
110
112
  environment.processEnvs = { ...environment.processEnvs, ...parsed.values };
@@ -108,7 +108,7 @@ function registerRemoteCommands(program, environment, buildFile) {
108
108
  const me = await (0, server_client_1.getMe)(server, result.accessToken);
109
109
  environment.stdout.write(`signed in to ${name} as ${me.sub}\n`);
110
110
  environment.stdout.write(me.accounts.length > 0
111
- ? `service accounts: ${me.accounts.join(', ')}\n`
111
+ ? `service accounts: ${me.accounts.map((a) => `${(0, server_client_1.printable)(a.name)} (${a.level})`).join(', ')}\n`
112
112
  : 'you have no service accounts on this server\n');
113
113
  }
114
114
  catch (e) {
@@ -131,21 +131,25 @@ function registerRemoteCommands(program, environment, buildFile) {
131
131
  const chosen = await getServer(name);
132
132
  const token = await (0, credentials_1.getAccessToken)(environment, chosen.name, chosen.server);
133
133
  const me = await (0, server_client_1.getMe)(chosen.server, token);
134
- environment.stdout.write(`${chosen.name}: ${me.sub} (${me.iss})\n`);
135
- if (me.accounts.length === 0 && !(me.request && me.request.length > 0)) {
134
+ const { body } = await (0, server_client_1.callServer)(chosen.server, token, 'GET', '/v1/accounts');
135
+ environment.stdout.write(`${chosen.name}: ${(0, server_client_1.printable)(me.sub)} (${(0, server_client_1.printable)(me.iss)})\n`);
136
+ if (body.accounts.length === 0) {
136
137
  environment.stdout.write('no service accounts\n');
137
138
  }
138
- for (const account of me.accounts) {
139
- environment.stdout.write(`${account}${account === me.default ? colors_1.default.green(' (default)') : ''}\n`);
139
+ for (const account of body.accounts) {
140
+ const level = account.level === 'use' ? 'use' : 'request, somebody who uses it approves';
141
+ environment.stdout.write(`${colors_1.default.bold((0, server_client_1.printable)(account.name))} ${level}${account.description ? ` ${(0, server_client_1.printable)(account.description)}` : ''} from ${account.sources.map(server_client_1.printable).join(', ')}\n`);
140
142
  }
141
- if (me.publish && me.publish.length > 0) {
142
- environment.stdout.write(`may publish build files: ${me.publish.join(', ')}\n`);
143
+ if (me.builds) {
144
+ const may = {
145
+ run: 'run published build files',
146
+ propose: 'propose build files, which somebody who publishes approves',
147
+ publish: 'publish build files',
148
+ }[me.builds];
149
+ environment.stdout.write(`you may ${may}\n`);
143
150
  }
144
- if (me.propose && me.propose.length > 0) {
145
- environment.stdout.write(`may propose build files, which need an approval: ${me.propose.join(', ')}\n`);
146
- }
147
- if (me.request && me.request.length > 0) {
148
- environment.stdout.write(`may request runs, which an admin approves: ${me.request.join(', ')}\n`);
151
+ if (me.repos.length > 0) {
152
+ environment.stdout.write(`repositories: ${me.repos.map(server_client_1.printable).join(', ')}\n`);
149
153
  }
150
154
  }));
151
155
  (0, remote_work_commands_1.registerWorkCommands)(remote, { environment, buildFile, getServer, guarded });
@@ -28,11 +28,38 @@ function describe(run) {
28
28
  `${colors_1.default.bold((0, server_client_1.printable)(run.id))} requested by ${(0, server_client_1.printable)(run.requestedBy.sub)} (${(0, server_client_1.printable)(run.requestedBy.iss)})`,
29
29
  ` ${(0, server_client_1.printable)(run.definition.name)} ${(0, server_client_1.printable)(run.definition.hash)}`,
30
30
  ` tasks: ${run.tasks.length > 0 ? run.tasks.map(server_client_1.printable).join(', ') : 'selected by labels'}`,
31
- ` account ${run.account === null ? 'none' : (0, server_client_1.printable)(run.account)}, source ${(0, server_client_1.printable)(source)}${options ? `, ${(0, server_client_1.printable)(options)}` : ''}`,
32
- ` needs the approval of an admin of ${run.account === null ? 'none' : (0, server_client_1.printable)(run.account)}`,
33
- ` decide before ${(0, server_client_1.printable)(run.approval?.expiresAt ?? '-')}`,
31
+ ` accounts: ${run.accounts.length > 0 ? run.accounts.map(server_client_1.printable).join(', ') : 'none'}, source ${(0, server_client_1.printable)(source)}${options ? `, ${(0, server_client_1.printable)(options)}` : ''}`,
32
+ ...(run.approvals ?? []).map((approval) => ` ${(0, server_client_1.printable)(approval.account)}: ${approval.approvedBy
33
+ ? `approved by ${(0, server_client_1.printable)(approval.approvedBy.sub)}`
34
+ : 'waits for somebody who uses it to approve'}`),
35
+ ...flagLines(run),
36
+ ` decide before ${(0, server_client_1.printable)(run.approvals?.[0]?.expiresAt ?? '-')}`,
34
37
  ];
35
38
  }
39
+ // What to look at first, one line.
40
+ function flagLines(run) {
41
+ const flagged = Object.entries(run.plan ?? {}).filter(([, entry]) => entry.flags.length > 0);
42
+ return flagged.length > 0
43
+ ? [
44
+ ` look at: ${flagged
45
+ .map(([name, entry]) => `${(0, server_client_1.printable)(name)} (${entry.flags.map(server_client_1.printable).join(', ')})`)
46
+ .join(', ')}`,
47
+ ]
48
+ : [];
49
+ }
50
+ // The tasks and services the run executes and what each does, never a secret's value.
51
+ function describePlan(plan) {
52
+ return Object.entries(plan).flatMap(([name, entry]) => [
53
+ ` ${colors_1.default.bold((0, server_client_1.printable)(name))} ${entry.kind}, ${(0, server_client_1.printable)(entry.runtime)}${entry.image ? ` ${(0, server_client_1.printable)(entry.image)}` : ''}${entry.flags.length > 0 ? ` [${entry.flags.map(server_client_1.printable).join(', ')}]` : ''}`,
54
+ ...entry.cmds.map((cmd) => ` $ ${(0, server_client_1.printable)(cmd)}`),
55
+ ...(entry.deps.length > 0 ? [` after: ${entry.deps.map(server_client_1.printable).join(', ')}`] : []),
56
+ ...(entry.needs.length > 0 ? [` needs: ${entry.needs.map(server_client_1.printable).join(', ')}`] : []),
57
+ ...entry.secrets.map((secret) => ` secret ${(0, server_client_1.printable)(secret.from)}${secret.account ? ` as ${(0, server_client_1.printable)(secret.account)}` : ''} -> ${(0, server_client_1.printable)(secret.target)}`),
58
+ ...(entry.mounts.length > 0 ? [` mounts: ${entry.mounts.map(server_client_1.printable).join(', ')}`] : []),
59
+ ...(entry.ports.length > 0 ? [` ports: ${entry.ports.map(server_client_1.printable).join(', ')}`] : []),
60
+ ...(entry.generates.length > 0 ? [` generates: ${entry.generates.map(server_client_1.printable).join(', ')}`] : []),
61
+ ]);
62
+ }
36
63
  function registerWorkCommands(remote, context) {
37
64
  const { environment, buildFile, getServer, guarded } = context;
38
65
  const connect = async (name) => {
@@ -75,6 +102,20 @@ function registerWorkCommands(remote, context) {
75
102
  }
76
103
  }
77
104
  }));
105
+ remote
106
+ .command('show <run> [server]')
107
+ .description('show a run that waits for your approval in full: the commands, secrets and accounts of its tasks')
108
+ .action(guarded('Remote show was not successful', async (run, name) => {
109
+ if (!RUN_ID.test(run)) {
110
+ throw new Error(`${(0, server_client_1.printable)(run)} is not a run id, list them with: hammerkit remote approvals`);
111
+ }
112
+ const { server, token } = await connect(name);
113
+ const view = (await (0, server_client_1.callServer)(server, token, 'GET', `/v1/approvals/${run}`)).body;
114
+ for (const line of describe(view)) {
115
+ environment.stdout.write(`${line}\n`);
116
+ }
117
+ environment.stdout.write(view.plan ? `${describePlan(view.plan).join('\n')}\n` : 'the server did not record what its tasks do\n');
118
+ }));
78
119
  remote
79
120
  .command('proposals [server]')
80
121
  .description('list the proposed versions of build files you made or may approve')
@@ -92,6 +133,17 @@ function registerWorkCommands(remote, context) {
92
133
  environment.stdout.write(`${colors_1.default.bold(`${(0, server_client_1.printable)(proposal.name)}@${(0, server_client_1.printable)(proposal.hash)}`)} proposed by ${who}, ${state}\n`);
93
134
  const names = [...proposal.tasks, ...proposal.services];
94
135
  environment.stdout.write(` tasks and services: ${names.map(server_client_1.printable).join(', ') || 'none'}\n`);
136
+ if (proposal.accounts.length > 0) {
137
+ environment.stdout.write(` accounts: ${proposal.accounts.map(server_client_1.printable).join(', ')}${proposal.missing.length > 0
138
+ ? `, the proposer does not use ${proposal.missing.map(server_client_1.printable).join(', ')}`
139
+ : ''}\n`);
140
+ }
141
+ for (const line of flagLines(proposal)) {
142
+ environment.stdout.write(`${line}\n`);
143
+ }
144
+ if (proposal.stale) {
145
+ environment.stdout.write(` stale: ${(0, server_client_1.printable)(proposal.name)} has a newer version, reject it and propose again on top of it\n`);
146
+ }
95
147
  if (proposal.state === 'waiting') {
96
148
  environment.stdout.write(` decide before ${(0, server_client_1.printable)(proposal.expiresAt)}\n`);
97
149
  }
@@ -117,9 +169,29 @@ function registerWorkCommands(remote, context) {
117
169
  : undefined;
118
170
  environment.stdout.write(`${(0, server_client_1.printable)(proposal.name)}@${(0, server_client_1.printable)(proposal.hash)} on ${serverName}, proposed by ${(0, server_client_1.printable)(view.proposedBy.sub)} (${(0, server_client_1.printable)(view.proposedBy.iss)})\n`);
119
171
  environment.stdout.write(base ? `compared with ${(0, server_client_1.printable)(view.base)}\n` : 'no version is published yet, everything is new\n');
172
+ if (view.stale) {
173
+ environment.stdout.write(`warning: ${(0, server_client_1.printable)(proposal.name)} has a newer version (${(0, server_client_1.printable)(view.latest)}), this can only be rejected\n`);
174
+ }
120
175
  const lines = (0, text_diff_1.diffBundles)(base, proposed);
121
176
  environment.stdout.write(lines.length > 0 ? `${lines.join('\n')}\n` : 'the files are the same\n');
122
177
  }));
178
+ for (const action of ['yank', 'unyank']) {
179
+ remote
180
+ .command(`${action} <version> [server]`)
181
+ .description(action === 'yank'
182
+ ? 'pull a published version (name@hash) back: no run starts from it and it is not latest'
183
+ : 'let a yanked version (name@hash) go on')
184
+ .addOption(new commander_1.Option('--reason <text>', 'why, shown to whoever tries to run it').argParser((value) => value))
185
+ .action(guarded(`Remote ${action} was not successful`, async (target, name, options) => {
186
+ const version = parseProposal(target);
187
+ if (!version) {
188
+ throw new Error(`${(0, server_client_1.printable)(target)} is not a version, name it like payments@sha256:...`);
189
+ }
190
+ const { name: serverName, server, token } = await connect(name);
191
+ await (0, server_client_1.callServer)(server, token, 'POST', `/v1/definitions/${encodeURIComponent(version.name)}/${version.hash}/${action}`, options.reason ? { reason: options.reason } : undefined);
192
+ environment.stdout.write(`${action === 'yank' ? 'yanked' : 'let go on'} ${(0, server_client_1.printable)(target)} on ${serverName}\n`);
193
+ }));
194
+ }
123
195
  for (const action of ['approve', 'reject']) {
124
196
  remote
125
197
  .command(`${action} <target> [server]`)
@@ -26,8 +26,12 @@ async function parseReferences(ctx, mainScope, environment) {
26
26
  projectRoot: await (0, project_root_1.findProjectRoot)(mainScope.cwd, environment),
27
27
  };
28
28
  for (const file of Object.values(ctx.files)) {
29
+ if (environment.hosted && (file.schema.secretProviders || file.schema.secretAccounts)) {
30
+ throw new Error(`${file.fileName} declares secret providers or accounts, which a server defines in its own config`);
31
+ }
29
32
  if (!reference.envFiles[file.cwd]) {
30
- reference.envFiles[file.cwd] = await (0, read_env_file_1.readEnvFile)(file.cwd, environment);
33
+ // a server does not read the `.env` of a checkout: it is code of the project
34
+ reference.envFiles[file.cwd] = environment.hosted ? {} : await (0, read_env_file_1.readEnvFile)(file.cwd, environment);
31
35
  }
32
36
  const buildFileEnvs = file.schema.envs;
33
37
  if (file.schema.tasks) {
@@ -92,7 +96,9 @@ async function parseReferences(ctx, mainScope, environment) {
92
96
  resolveInit(reference, service);
93
97
  }
94
98
  reference.caches = (0, resolve_cache_1.withBuiltinCaches)(declaredCaches);
95
- reference.secrets = (0, secret_catalog_1.collectSecretCatalog)(Object.values(ctx.files), environment, reference);
99
+ reference.secrets = environment.hosted
100
+ ? { ...environment.hosted.catalog, explicitAccounts: true }
101
+ : (0, secret_catalog_1.collectSecretCatalog)(Object.values(ctx.files), environment, reference);
96
102
  return reference;
97
103
  }
98
104
  exports.parseReferences = parseReferences;
@@ -16,6 +16,10 @@ function keyOf(binding, ref) {
16
16
  return [binding.definition.name, binding.account.name, ref].join('\0');
17
17
  }
18
18
  function fetchProviderValue(binding, ref, environment) {
19
+ const allowed = environment.hosted?.accounts;
20
+ if (allowed && !allowed.includes(binding.account.name)) {
21
+ return Promise.reject(new Error(`the run may not read secrets as ${binding.account.name}`));
22
+ }
19
23
  let values = runs.get(environment);
20
24
  if (!values) {
21
25
  values = new Map();
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleAccounts = void 0;
4
+ const http_1 = require("./http");
5
+ const policy_1 = require("./policy");
6
+ /**
7
+ * Who the caller is and what they may do:
8
+ *
9
+ * GET /v1/me identity, accounts with their level, repositories and the builds level
10
+ * GET /v1/accounts the accounts the caller has a level on, with what the operator says about them
11
+ */
12
+ async function handleAccounts(config, log, principal, req, res, url) {
13
+ if (url.pathname !== '/v1/me' && url.pathname !== '/v1/accounts') {
14
+ return false;
15
+ }
16
+ if (req.method !== 'GET') {
17
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
18
+ return true;
19
+ }
20
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
21
+ const accounts = Object.entries(grant.accounts)
22
+ .sort(([a], [b]) => (a < b ? -1 : 1))
23
+ .map(([name, level]) => ({ name, level }));
24
+ if (url.pathname === '/v1/me') {
25
+ log(`grant iss=${principal.iss} sub=${principal.sub} accounts=[${accounts
26
+ .map((account) => `${account.name}:${account.level}`)
27
+ .join(',')}] entitlements=[${grant.matched.join(',')}]`);
28
+ (0, http_1.send)(res, 200, { iss: principal.iss, sub: principal.sub, accounts, repos: grant.repos, builds: grant.builds });
29
+ return true;
30
+ }
31
+ (0, http_1.send)(res, 200, {
32
+ accounts: accounts.map(({ name, level }) => ({
33
+ name,
34
+ ...(config.accounts[name]?.description ? { description: config.accounts[name].description } : {}),
35
+ level,
36
+ sources: config.accounts[name]?.source ?? ['upload', 'ref'],
37
+ })),
38
+ });
39
+ return true;
40
+ }
41
+ exports.handleAccounts = handleAccounts;
42
+ //# sourceMappingURL=accounts-api.js.map
@@ -17,15 +17,16 @@ function toApprovalView(run) {
17
17
  createdAt: run.createdAt,
18
18
  definition: run.definition,
19
19
  tasks: run.tasks,
20
- account: run.account,
20
+ accounts: run.accounts,
21
+ plan: run.plan,
21
22
  source: run.source,
22
23
  options: run.options,
23
- approval: run.approval,
24
+ approvals: run.approvals,
24
25
  };
25
26
  }
26
27
  /**
27
- * The runs waiting for an approval, which an admin of the service account they
28
- * use gives:
28
+ * The runs waiting for an approval, which somebody who uses the accounts they
29
+ * need gives (one person is enough when they use all of them):
29
30
  *
30
31
  * GET /v1/approvals the runs the caller may approve
31
32
  * GET /v1/approvals/<id> one of them
@@ -42,7 +43,7 @@ async function handleApprovals(config, registry, log, principal, req, res, url)
42
43
  }
43
44
  const [, id, action] = match;
44
45
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
45
- const canDecide = (account) => (0, policy_1.isAdmin)(grant, account);
46
+ const canDecide = (account) => (0, policy_1.canUse)(grant, account);
46
47
  if (!id) {
47
48
  if (req.method !== 'GET') {
48
49
  (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
@@ -50,7 +51,8 @@ async function handleApprovals(config, registry, log, principal, req, res, url)
50
51
  }
51
52
  const open = registry
52
53
  .waiting()
53
- .filter((run) => !(run.owner.iss === principal.iss && run.owner.sub === principal.sub) && canDecide(run.account));
54
+ .filter((run) => !(run.owner.iss === principal.iss && run.owner.sub === principal.sub) &&
55
+ (run.approvals ?? []).some((approval) => !approval.approvedBy && canDecide(approval.account)));
54
56
  (0, http_1.send)(res, 200, { approvals: open.map(toApprovalView) });
55
57
  return true;
56
58
  }
@@ -90,6 +92,9 @@ async function handleApprovals(config, registry, log, principal, req, res, url)
90
92
  case 'not-waiting':
91
93
  (0, http_1.send)(res, 409, { error: `the run is ${result.state}, it does not wait for an approval` });
92
94
  break;
95
+ case 'already-approved':
96
+ (0, http_1.send)(res, 409, { error: 'you approved all you can, the run waits for somebody who uses the other accounts' });
97
+ break;
93
98
  default:
94
99
  (0, http_1.send)(res, 404, { error: 'not found' });
95
100
  }
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.handleDefinitions = void 0;
4
+ const zod_1 = require("zod");
4
5
  const bundle_1 = require("../remote/bundle");
5
6
  const definitions_1 = require("./definitions");
6
7
  const http_1 = require("./http");
8
+ const oauth_1 = require("./oauth");
7
9
  const policy_1 = require("./policy");
8
10
  const proposals_1 = require("./proposals");
9
11
  const server_config_1 = require("./server-config");
@@ -18,13 +20,16 @@ const MAX_PUBLISH_BYTES = bundle_1.MAX_BUNDLE_BYTES * 2;
18
20
  * GET /v1/definitions/<name> the versions of one
19
21
  * GET /v1/definitions/<name>/<hash|latest> one version
20
22
  * GET /v1/definitions/<name>/<hash|latest>/files the files of one version
23
+ * POST /v1/definitions/<name>/<hash>/yank pull a version back (body: optional {reason})
24
+ * POST /v1/definitions/<name>/<hash>/unyank let it go on
21
25
  */
22
26
  async function handleDefinitions(config, store, proposals, log, principal, req, res, url) {
23
- const match = /^\/v1\/definitions(?:\/([^/]+)(?:\/([^/]+)(\/files)?)?)?$/.exec(url.pathname);
27
+ const match = /^\/v1\/definitions(?:\/([^/]+)(?:\/([^/]+)(\/files|\/yank|\/unyank)?)?)?$/.exec(url.pathname);
24
28
  if (!match) {
25
29
  return false;
26
30
  }
27
- const [, name, version, files] = match;
31
+ const [, name, version, suffix] = match;
32
+ const files = suffix === '/files';
28
33
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
29
34
  if (name !== undefined && !server_config_1.definitionName.safeParse(name).success) {
30
35
  (0, http_1.send)(res, 404, { error: 'not found' });
@@ -34,8 +39,14 @@ async function handleDefinitions(config, store, proposals, log, principal, req,
34
39
  await publish(config, store, proposals, log, principal, name, req, res);
35
40
  return true;
36
41
  }
42
+ if ((suffix === '/yank' || suffix === '/unyank') && req.method === 'POST') {
43
+ await yank(config, store, log, principal, name, version, suffix === '/unyank', req, res);
44
+ return true;
45
+ }
37
46
  if (req.method !== 'GET') {
38
- (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: name !== undefined && !version ? 'GET, PUT' : 'GET' });
47
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, {
48
+ allow: suffix === '/yank' || suffix === '/unyank' ? 'POST' : name !== undefined && !version ? 'GET, PUT' : 'GET',
49
+ });
39
50
  return true;
40
51
  }
41
52
  // what the caller may not see does not exist for them
@@ -71,12 +82,40 @@ async function handleDefinitions(config, store, proposals, log, principal, req,
71
82
  return true;
72
83
  }
73
84
  exports.handleDefinitions = handleDefinitions;
85
+ const yankSchema = zod_1.z.object({ reason: zod_1.z.string().max(500).optional() }).strict();
86
+ // Yanking is allowed to who publishes the build file or uses an account the version
87
+ // uses; letting a version go on again is the publisher's call.
88
+ async function yank(config, store, log, principal, name, hash, undo, req, res) {
89
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
90
+ const version = bundle_1.HASH_PATTERN.test(hash) ? store.find(name, hash) : undefined;
91
+ if (!version || !(0, policy_1.maySeeDefinitions)(grant)) {
92
+ (0, http_1.send)(res, 404, { error: 'not found' });
93
+ return;
94
+ }
95
+ if (undo ? !(0, policy_1.mayPublish)(grant) : !(0, policy_1.mayYank)(grant, version)) {
96
+ log(`refuse ${undo ? 'unyank' : 'yank'} ${name}@${hash} iss=${principal.iss} sub=${principal.sub}`);
97
+ (0, http_1.send)(res, 403, {
98
+ error: undo
99
+ ? 'a version is let go on by who publishes the build file'
100
+ : 'a version is yanked by who publishes the build file or uses an account it uses',
101
+ });
102
+ return;
103
+ }
104
+ const body = yankSchema.safeParse(req.headers['content-type'] ? await (0, http_1.readJson)(req) : {});
105
+ if (!body.success) {
106
+ const issue = body.error.issues[0];
107
+ (0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
108
+ return;
109
+ }
110
+ const changed = await store.yank(name, hash, principal, { reason: body.data.reason, undo });
111
+ log(`${undo ? 'unyank' : 'yank'} ${name}@${hash} iss=${principal.iss} sub=${principal.sub}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
112
+ (0, http_1.send)(res, 200, changed);
113
+ }
74
114
  async function publish(config, store, proposals, log, principal, name, req, res) {
75
115
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
76
- const direct = (0, policy_1.mayPublish)(grant, name);
77
- if (!direct && !(0, policy_1.mayPropose)(grant, name)) {
78
- log(`refuse publish iss=${principal.iss} sub=${principal.sub}: build file ${name} is not granted`);
79
- (0, http_1.send)(res, 403, { error: `you may not publish ${name}` });
116
+ if (!(0, policy_1.mayPublish)(grant) && !(0, policy_1.mayPropose)(grant)) {
117
+ log(`refuse publish iss=${principal.iss} sub=${principal.sub}: no builds permission`);
118
+ (0, http_1.send)(res, 403, { error: `you may not publish ${name}, it needs builds: propose or publish` });
80
119
  return;
81
120
  }
82
121
  const parsed = bundle_1.bundleSchema.safeParse(await (0, http_1.readJson)(req, MAX_PUBLISH_BYTES));
@@ -86,22 +125,27 @@ async function publish(config, store, proposals, log, principal, name, req, res)
86
125
  return;
87
126
  }
88
127
  try {
89
- if (!direct) {
90
- // a version can't run before it is approved, but it has to load, as it would
91
- // have when published, so the approver is not asked about a broken one
92
- const { version, existing } = await store.prepare(name, parsed.data, principal);
93
- if (existing) {
94
- (0, http_1.send)(res, 200, { ...existing, created: false });
95
- return;
96
- }
97
- const { proposal, created } = proposals.propose(version, parsed.data, principal);
98
- log(`propose ${name} ${proposal.hash} iss=${principal.iss} sub=${principal.sub} created=${created} tasks=${proposal.tasks.length}`);
128
+ // a version can't run before it is approved, but it has to load, as it would
129
+ // have when published, so the approver is not asked about a broken one
130
+ const { version, existing } = await store.prepare(name, parsed.data, principal, {
131
+ noRemote: !(0, policy_1.mayPublish)(grant),
132
+ });
133
+ if (existing) {
134
+ (0, http_1.send)(res, 200, { ...existing, created: false });
135
+ return;
136
+ }
137
+ // a version that names an account is published by somebody who uses it; anyone
138
+ // else proposes it, and somebody who does approves it (four eyes)
139
+ const missing = (version.accounts ?? []).filter((account) => !(0, policy_1.canUse)(grant, account));
140
+ if (!(0, policy_1.mayPublish)(grant) || missing.length > 0) {
141
+ const { proposal, created } = proposals.propose(version, parsed.data, principal, missing, store.find(name, 'latest')?.hash ?? null);
142
+ log(`propose ${name} ${proposal.hash} iss=${principal.iss} sub=${principal.sub} created=${created} tasks=${proposal.tasks.length} missing=[${missing.join(',')}]`);
99
143
  (0, http_1.send)(res, created ? 202 : 200, { ...proposal, created });
100
144
  return;
101
145
  }
102
- const { version, created } = await store.publish(name, parsed.data, principal);
103
- log(`publish ${name} ${version.hash} iss=${principal.iss} sub=${principal.sub} created=${created} tasks=${version.tasks.length}`);
104
- (0, http_1.send)(res, created ? 201 : 200, { ...version, created });
146
+ const { version: published, created } = await store.publish(name, parsed.data, principal);
147
+ log(`publish ${name} ${published.hash} iss=${principal.iss} sub=${principal.sub} created=${created} tasks=${published.tasks.length}`);
148
+ (0, http_1.send)(res, created ? 201 : 200, { ...published, created });
105
149
  }
106
150
  catch (e) {
107
151
  if (e instanceof definitions_1.DefinitionError) {
@@ -110,7 +154,7 @@ async function publish(config, store, proposals, log, principal, name, req, res)
110
154
  return;
111
155
  }
112
156
  if (e instanceof proposals_1.ProposalError) {
113
- (0, http_1.send)(res, 503, { error: e.message });
157
+ (0, http_1.send)(res, e.status, { error: e.message });
114
158
  return;
115
159
  }
116
160
  throw e;