hammerkit 1.24.0 → 1.26.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 (43) hide show
  1. package/dist/executer/container-task-state.js +8 -6
  2. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +1 -1
  3. package/dist/optimizer/get-cache-directory.js +5 -0
  4. package/dist/planner/service-definition.js +3 -2
  5. package/dist/planner/utils/task-arguments.js +155 -0
  6. package/dist/planner/work-cache-id.js +15 -1
  7. package/dist/planner/work-runtime-docker.js +2 -0
  8. package/dist/planner/work-runtime-kubernetes.js +2 -1
  9. package/dist/planner/work-runtime-local.js +16 -8
  10. package/dist/planner/work-secret.js +6 -2
  11. package/dist/planner/work-service.js +7 -3
  12. package/dist/planner/work-state-record.js +30 -0
  13. package/dist/program.js +61 -7
  14. package/dist/remote/remote-commands.js +16 -12
  15. package/dist/remote/remote-work-commands.js +75 -3
  16. package/dist/schema/reference-parser.js +8 -2
  17. package/dist/schema/work-scope-parser.js +8 -0
  18. package/dist/secrets/provider-values.js +4 -0
  19. package/dist/server/accounts-api.js +42 -0
  20. package/dist/server/approvals-api.js +11 -6
  21. package/dist/server/definitions-api.js +65 -21
  22. package/dist/server/definitions.js +54 -142
  23. package/dist/server/executor.js +180 -181
  24. package/dist/server/git-checkout.js +55 -3
  25. package/dist/server/plan.js +296 -0
  26. package/dist/server/policy.js +98 -50
  27. package/dist/server/proposals-api.js +30 -8
  28. package/dist/server/proposals.js +50 -3
  29. package/dist/server/repo-access.js +127 -0
  30. package/dist/server/repo.js +94 -0
  31. package/dist/server/run-environment.js +20 -0
  32. package/dist/server/run-plan.js +44 -0
  33. package/dist/server/run-request.js +23 -5
  34. package/dist/server/run-store.js +61 -0
  35. package/dist/server/run-worker-command.js +17 -0
  36. package/dist/server/run-worker-main.js +7 -0
  37. package/dist/server/run-worker.js +179 -0
  38. package/dist/server/runs.js +189 -36
  39. package/dist/server/server-command.js +18 -0
  40. package/dist/server/server-config.js +203 -42
  41. package/dist/server/server.js +148 -52
  42. package/dist/server/sources-api.js +1 -1
  43. package/package.json +1 -1
@@ -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;
@@ -6,6 +6,7 @@ const label_values_1 = require("../executer/label-values");
6
6
  const append_work_service_1 = require("../planner/utils/append-work-service");
7
7
  const append_work_task_1 = require("../planner/utils/append-work-task");
8
8
  const append_work_environment_1 = require("../planner/utils/append-work-environment");
9
+ const task_arguments_1 = require("../planner/utils/task-arguments");
9
10
  function defaultEnvironment(context) {
10
11
  if ('default' in context.environments) {
11
12
  const defaultEnv = context.environments['default'];
@@ -41,17 +42,24 @@ function getWorkContext(context, scope, environment) {
41
42
  // its init task's need on it, which narrowing follows
42
43
  const tasks = Object.values(context.tasks).map((task) => (0, append_work_task_1.appendWorkTask)(workTree, task.cwd, task, environment, context));
43
44
  const services = Object.values(context.services).map((service) => (0, append_work_service_1.appendWorkService)(workTree, service, environment, context));
45
+ const selectedTasks = [];
46
+ const selectedServices = [];
44
47
  for (const item of tasks) {
45
48
  if (matchesTaskWorkScope(item, scope)) {
46
49
  requested.push(item.name);
50
+ selectedTasks.push(item.data);
47
51
  applyTask(filteredWorkTree, item);
48
52
  }
49
53
  }
50
54
  for (const item of services) {
51
55
  if (matchesServiceWorkScope(item.data, scope)) {
56
+ selectedServices.push(item.data);
52
57
  applyService(filteredWorkTree, item);
53
58
  }
54
59
  }
60
+ if (scope.args) {
61
+ (0, task_arguments_1.applyArguments)({ tasks: selectedTasks, services: scope.args.services ? selectedServices : [] }, scope.args.values, scope.args.cache);
62
+ }
55
63
  keepRequirersInScope(filteredWorkTree);
56
64
  return filteredWorkTree;
57
65
  }
@@ -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;