hammerkit 1.23.0 → 1.24.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 (39) 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/planner/utils/append-work-task.js +1 -0
  5. package/dist/program.js +4 -0
  6. package/dist/remote/bundle.js +59 -0
  7. package/dist/remote/collect-bundle.js +41 -0
  8. package/dist/remote/credentials.js +93 -0
  9. package/dist/remote/device-login.js +177 -0
  10. package/dist/remote/host-config.js +170 -0
  11. package/dist/remote/location.js +46 -0
  12. package/dist/remote/remote-commands.js +154 -0
  13. package/dist/remote/remote-work-commands.js +152 -0
  14. package/dist/remote/server-client.js +88 -0
  15. package/dist/remote/source-manifest.js +79 -0
  16. package/dist/remote/text-diff.js +89 -0
  17. package/dist/schema/build-file-base-task-schema.js +4 -0
  18. package/dist/schema/build-file-schema.js +3 -0
  19. package/dist/schema/reference-parser.js +1 -0
  20. package/dist/schema/runner-schema.js +13 -0
  21. package/dist/server/approvals-api.js +99 -0
  22. package/dist/server/definitions-api.js +119 -0
  23. package/dist/server/definitions.js +248 -0
  24. package/dist/server/discovery.js +9 -0
  25. package/dist/server/executor.js +238 -0
  26. package/dist/server/git-checkout.js +63 -0
  27. package/dist/server/http.js +39 -0
  28. package/dist/server/oauth.js +226 -0
  29. package/dist/server/policy.js +90 -0
  30. package/dist/server/proposals-api.js +123 -0
  31. package/dist/server/proposals.js +126 -0
  32. package/dist/server/run-request.js +67 -0
  33. package/dist/server/runs.js +279 -0
  34. package/dist/server/server-command.js +43 -0
  35. package/dist/server/server-config.js +233 -0
  36. package/dist/server/server.js +338 -0
  37. package/dist/server/sources-api.js +106 -0
  38. package/dist/server/sources.js +258 -0
  39. package/package.json +1 -1
@@ -0,0 +1,238 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HostExecutor = exports.passedEnvironment = void 0;
4
+ const promises_1 = require("fs/promises");
5
+ const path_1 = require("path");
6
+ const cli_1 = require("../cli");
7
+ const get_file_context_1 = require("../file/get-file-context");
8
+ const log_1 = require("../log");
9
+ const resolve_git_source_1 = require("../includes/resolve-git-source");
10
+ const parse_label_arguments_1 = require("../parser/parse-label-arguments");
11
+ const schema_parser_1 = require("../schema/schema-parser");
12
+ const reference_parser_1 = require("../schema/reference-parser");
13
+ const work_scope_parser_1 = require("../schema/work-scope-parser");
14
+ const work_item_status_1 = require("../planner/work-item-status");
15
+ const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
16
+ const redact_1 = require("../utils/redact");
17
+ const empty_writable_1 = require("../utils/empty-writable");
18
+ const units_1 = require("../utils/units");
19
+ const git_checkout_1 = require("./git-checkout");
20
+ // What a task on the server sees of the server's own environment: the tools it
21
+ // needs to find and reach docker, nothing the operator keeps there for the server.
22
+ const PASSED_ENVS = [
23
+ 'PATH',
24
+ 'HOME',
25
+ 'LANG',
26
+ 'LC_ALL',
27
+ 'TMPDIR',
28
+ 'DOCKER_HOST',
29
+ 'DOCKER_CONFIG',
30
+ 'SSL_CERT_FILE',
31
+ 'HTTP_PROXY',
32
+ 'HTTPS_PROXY',
33
+ 'NO_PROXY',
34
+ 'http_proxy',
35
+ 'https_proxy',
36
+ 'no_proxy',
37
+ 'SSH_AUTH_SOCK',
38
+ // Windows: without these powershell and git can not find their profile
39
+ 'SystemRoot',
40
+ 'SystemDrive',
41
+ 'windir',
42
+ 'ComSpec',
43
+ 'PATHEXT',
44
+ 'USERPROFILE',
45
+ 'APPDATA',
46
+ 'LOCALAPPDATA',
47
+ 'TEMP',
48
+ 'TMP',
49
+ 'ProgramData',
50
+ 'ProgramFiles',
51
+ 'ProgramFiles(x86)',
52
+ // powershell is very slow to start without it
53
+ 'PSModulePath',
54
+ ];
55
+ // one log line of a task is cut here, the registry caps how many events a run keeps
56
+ const MAX_LOG_TEXT = 8 * 1024;
57
+ function passedEnvironment(envs) {
58
+ const passed = {};
59
+ for (const name of PASSED_ENVS) {
60
+ if (envs[name] !== undefined) {
61
+ passed[name] = envs[name];
62
+ }
63
+ }
64
+ return passed;
65
+ }
66
+ exports.passedEnvironment = passedEnvironment;
67
+ /**
68
+ * Runs the tasks of a run on this machine with the engine of `hammerkit run`.
69
+ *
70
+ * The working directory is made for the run and removed afterwards. Its build
71
+ * file is the published version the run is pinned to, written over the code of the
72
+ * source, so the commands are the published ones whatever the source says. The
73
+ * local cache is shared by all runs, a task is keyed by its definition and
74
+ * sources and not by where they are.
75
+ */
76
+ class HostExecutor {
77
+ constructor(options) {
78
+ this.options = options;
79
+ }
80
+ async execute(run, context) {
81
+ const { definitions, config, environment } = this.options;
82
+ const version = definitions.find(run.definition.name, run.definition.hash);
83
+ const bundle = version ? await definitions.files(version) : undefined;
84
+ if (!version || !bundle) {
85
+ throw new Error(`the build file ${run.definition.name} at ${run.definition.hash} is no longer on this server`);
86
+ }
87
+ if (run.source.type !== 'ref') {
88
+ throw new Error('runs from an upload are not supported by this server yet, use a git ref');
89
+ }
90
+ const repository = config.sources[run.definition.name]?.git;
91
+ if (!repository) {
92
+ throw new Error(`this server has no repository for ${run.definition.name} to run ${run.source.ref} from, ` +
93
+ `the operator sets sources.${run.definition.name}.git`);
94
+ }
95
+ const abortCtrl = new AbortController();
96
+ const abort = () => abortCtrl.abort();
97
+ if (context.signal.aborted) {
98
+ abort();
99
+ }
100
+ context.signal.addEventListener('abort', abort, { once: true });
101
+ const root = (0, path_1.join)(this.options.directory, run.id);
102
+ const checkout = (0, path_1.join)(root, 'source');
103
+ try {
104
+ const passed = passedEnvironment(environment.processEnvs);
105
+ const commit = await (0, git_checkout_1.checkoutRef)(repository, run.source.ref, checkout, passed, abortCtrl.signal);
106
+ context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `checked out ${commit}` });
107
+ await this.writeBundle(checkout, bundle.files);
108
+ await this.runTasks(run, context, (0, path_1.join)(checkout, version.entry), checkout, passed, abortCtrl);
109
+ }
110
+ finally {
111
+ context.signal.removeEventListener('abort', abort);
112
+ await (0, promises_1.rm)(root, { recursive: true, force: true });
113
+ }
114
+ }
115
+ // The published files go over the checkout; nothing may land outside of it or in
116
+ // its git directory.
117
+ async writeBundle(directory, files) {
118
+ for (const [path, content] of Object.entries(files)) {
119
+ const target = (0, path_1.resolve)(directory, path);
120
+ if (!(0, resolve_git_source_1.isWithin)(directory, target) || /^\.git(\/|$)/.test(path)) {
121
+ throw new Error(`the published file ${JSON.stringify(path)} can not be written into the source`);
122
+ }
123
+ await (0, promises_1.mkdir)((0, path_1.dirname)(target), { recursive: true });
124
+ await (0, promises_1.writeFile)(target, content);
125
+ }
126
+ }
127
+ async runTasks(run, context, entry, cwd, passed, abortCtrl) {
128
+ const secrets = (0, redact_1.createSecretRegistry)();
129
+ const status = (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets);
130
+ // what the engine would print to a terminal is not wanted, its status console
131
+ // is what is streamed: it is redacted with the secrets of this run
132
+ const environment = {
133
+ processEnvs: passed,
134
+ abortCtrl,
135
+ cwd,
136
+ file: (0, get_file_context_1.getFileContext)(cwd),
137
+ console: (0, log_1.consoleContext)((0, empty_writable_1.emptyWritable)()),
138
+ status,
139
+ secrets,
140
+ stdout: (0, empty_writable_1.emptyWritable)(),
141
+ stderr: (0, empty_writable_1.emptyWritable)(),
142
+ stdoutColumns: 120,
143
+ };
144
+ const listener = status.on((message) => {
145
+ if (message.type === 'console') {
146
+ context.emit({
147
+ type: 'log',
148
+ task: message.context.name,
149
+ stream: message.console,
150
+ text: message.message.slice(0, MAX_LOG_TEXT),
151
+ });
152
+ }
153
+ else if (message.level === 'error' || message.level === 'warn') {
154
+ context.emit({
155
+ type: 'log',
156
+ task: message.context.name,
157
+ stream: 'stderr',
158
+ text: message.message.slice(0, MAX_LOG_TEXT),
159
+ });
160
+ }
161
+ });
162
+ try {
163
+ const options = run.options;
164
+ const execOptions = {
165
+ logMode: 'live',
166
+ cacheDefault: options.cache ?? 'checksum',
167
+ cacheReadOnly: options.cacheReadOnly ?? false,
168
+ timeout: options.timeout ? (0, units_1.parseDuration)(options.timeout) : null,
169
+ skipDeps: options.skipDeps !== false,
170
+ };
171
+ // the tasks in the order they were named; by labels when none is
172
+ const scopes = run.tasks.length > 0
173
+ ? run.tasks.map((task) => ({
174
+ name: task,
175
+ scope: { taskName: task, environmentName: options.env ?? null },
176
+ }))
177
+ : [
178
+ {
179
+ name: 'labels',
180
+ scope: {
181
+ filterLabels: labels(options.filter),
182
+ excludeLabels: labels(options.exclude),
183
+ environmentName: options.env ?? null,
184
+ },
185
+ },
186
+ ];
187
+ for (const { name, scope } of scopes) {
188
+ const { ctx, scope: parsed } = await (0, schema_parser_1.createParseContext)(entry, environment);
189
+ const workTree = (0, work_scope_parser_1.getWorkContext)(await (0, reference_parser_1.parseReferences)(ctx, parsed, environment), scope, environment);
190
+ const cli = (0, cli_1.getCli)(workTree, environment);
191
+ if (cli.tasks().length === 0) {
192
+ throw new Error(`no tasks found for ${name}`);
193
+ }
194
+ const execution = cli.exec({ ...execOptions, workers: cli.defaultWorkers() });
195
+ const seen = new Map();
196
+ const watch = execution.state.on('run-events', () => reportTasks(context, execution.state.current, seen));
197
+ try {
198
+ const result = await execution.start();
199
+ reportTasks(context, execution.state.current, seen);
200
+ if (!result.success) {
201
+ throw new Error(context.signal.aborted ? 'the run was cancelled' : `${name} failed`);
202
+ }
203
+ }
204
+ finally {
205
+ watch.close();
206
+ }
207
+ }
208
+ }
209
+ finally {
210
+ listener.close();
211
+ }
212
+ }
213
+ }
214
+ exports.HostExecutor = HostExecutor;
215
+ function labels(values) {
216
+ return values && values.length > 0 ? (0, parse_label_arguments_1.parseLabelArguments)(values) : undefined;
217
+ }
218
+ // A task event when a task has moved on since the last look.
219
+ function reportTasks(context, workTree, seen) {
220
+ for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
221
+ const state = task.state.current;
222
+ let event = null;
223
+ if (state.type === 'running') {
224
+ event = 'running';
225
+ }
226
+ else if (state.type === 'completed') {
227
+ event = state.skipped ? null : state.cached ? 'cached' : 'succeeded';
228
+ }
229
+ else if (state.type === 'error' || state.type === 'crash') {
230
+ event = 'failed';
231
+ }
232
+ if (event && seen.get(task.name) !== event) {
233
+ seen.set(task.name, event);
234
+ context.emit({ type: 'task', task: task.name, state: event });
235
+ }
236
+ }
237
+ }
238
+ //# sourceMappingURL=executor.js.map
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkoutRef = exports.isValidRef = void 0;
4
+ const child_process_1 = require("child_process");
5
+ const promises_1 = require("fs/promises");
6
+ const util_1 = require("util");
7
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
8
+ // Anything but plain network and local transports is refused: `ext::` runs a command.
9
+ const ALLOWED_PROTOCOLS = 'file:git:http:https:ssh';
10
+ // A branch, a tag or a commit. It is passed after `--` as well, but a ref that
11
+ // looks like an option, has a `..` or odd characters is not one of the three.
12
+ const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
13
+ function isValidRef(ref) {
14
+ return (REF_PATTERN.test(ref) && !ref.includes('..') && !ref.includes('//') && !ref.endsWith('/') && !ref.endsWith('.lock'));
15
+ }
16
+ exports.isValidRef = isValidRef;
17
+ /**
18
+ * Fetches one commit of a repository into `directory` and checks it out, and
19
+ * answers which commit that is. The repository is the operator's, the ref is the
20
+ * caller's. Credentials come from the git configuration of the server, there is no
21
+ * prompt a run could hang on.
22
+ */
23
+ async function checkoutRef(repository, ref, directory, env, signal) {
24
+ if (!isValidRef(ref)) {
25
+ throw new Error(`${JSON.stringify(ref.slice(0, 80))} is not a branch, tag or commit`);
26
+ }
27
+ await (0, promises_1.mkdir)(directory, { recursive: true });
28
+ const run = (args) => git(args, env, signal, `${repository} at ${ref}`);
29
+ await run(['init', '--quiet', directory]);
30
+ await run(['-C', directory, 'fetch', '--quiet', '--depth', '1', '--', repository, ref]);
31
+ await run(['-C', directory, '-c', 'advice.detachedHead=false', 'checkout', '--quiet', 'FETCH_HEAD']);
32
+ return (await run(['-C', directory, 'rev-parse', 'HEAD'])).trim();
33
+ }
34
+ exports.checkoutRef = checkoutRef;
35
+ async function git(args, baseEnv, signal, what) {
36
+ const env = {};
37
+ for (const [key, value] of Object.entries(baseEnv)) {
38
+ if (value !== undefined) {
39
+ env[key] = value;
40
+ }
41
+ }
42
+ env.GIT_TERMINAL_PROMPT = '0';
43
+ env.GIT_ALLOW_PROTOCOL = ALLOWED_PROTOCOLS;
44
+ try {
45
+ return (await execFileAsync('git', args, { env, signal })).stdout;
46
+ }
47
+ catch (e) {
48
+ const error = e;
49
+ if (error.code === 'ABORT_ERR') {
50
+ throw new Error(`fetching ${what} was cancelled`);
51
+ }
52
+ if (error.code === 'ENOENT') {
53
+ throw new Error(`unable to fetch ${what}: git is not installed`);
54
+ }
55
+ const reason = (error.stderr ?? '')
56
+ .split('\n')
57
+ .map((line) => line.trim())
58
+ .filter((line) => line.length > 0)
59
+ .pop() ?? error.message;
60
+ throw new Error(`unable to fetch ${what}: ${reason}`);
61
+ }
62
+ }
63
+ //# sourceMappingURL=git-checkout.js.map
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readJson = exports.MAX_BODY_BYTES = exports.HttpError = exports.send = void 0;
4
+ function send(res, status, body, headers = {}) {
5
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers });
6
+ res.end(JSON.stringify(body));
7
+ }
8
+ exports.send = send;
9
+ // An answer to the caller, thrown from wherever the request turns out to be wrong.
10
+ class HttpError extends Error {
11
+ constructor(status, message) {
12
+ super(message);
13
+ this.status = status;
14
+ }
15
+ }
16
+ exports.HttpError = HttpError;
17
+ exports.MAX_BODY_BYTES = 64 * 1024;
18
+ async function readJson(req, limit = exports.MAX_BODY_BYTES) {
19
+ if (!/^application\/json(;|$)/i.test(req.headers['content-type'] ?? '')) {
20
+ throw new HttpError(415, 'send application/json');
21
+ }
22
+ const chunks = [];
23
+ let size = 0;
24
+ for await (const chunk of req) {
25
+ size += chunk.length;
26
+ if (size > limit) {
27
+ throw new HttpError(413, `the request is larger than ${limit} bytes`);
28
+ }
29
+ chunks.push(chunk);
30
+ }
31
+ try {
32
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
33
+ }
34
+ catch {
35
+ throw new HttpError(400, 'the body is not valid JSON');
36
+ }
37
+ }
38
+ exports.readJson = readJson;
39
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,226 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TokenVerifier = exports.quote = exports.TokenError = void 0;
4
+ const crypto_1 = require("crypto");
5
+ // The reason is for the operator's log, never for the caller.
6
+ class TokenError extends Error {
7
+ }
8
+ exports.TokenError = TokenError;
9
+ // Values from a token are written to the operator's log before the signature
10
+ // is checked, so they are quoted and cut: they can't start a log line of their own.
11
+ function quote(value) {
12
+ return JSON.stringify(String(value).slice(0, 80));
13
+ }
14
+ exports.quote = quote;
15
+ // Asymmetric algorithms only: there is no shared secret, and `none` and HS*
16
+ // are never accepted.
17
+ const ALGORITHMS = {
18
+ RS256: { hash: 'sha256', kty: 'RSA' },
19
+ RS384: { hash: 'sha384', kty: 'RSA' },
20
+ RS512: { hash: 'sha512', kty: 'RSA' },
21
+ PS256: { hash: 'sha256', kty: 'RSA', pss: true },
22
+ ES256: { hash: 'sha256', kty: 'EC', crv: 'P-256' },
23
+ ES384: { hash: 'sha384', kty: 'EC', crv: 'P-384' },
24
+ ES512: { hash: 'sha512', kty: 'EC', crv: 'P-521' },
25
+ };
26
+ const CLOCK_SKEW_SECONDS = 60;
27
+ const KEYS_TTL_MS = 10 * 60 * 1000;
28
+ // an unknown kid refreshes the keys, but not more often than this
29
+ const REFRESH_COOLDOWN_MS = 60 * 1000;
30
+ const FETCH_TIMEOUT_MS = 5000;
31
+ // after a failed load the issuer is left alone for this long, instead of being hit by every request
32
+ const FAILURE_BACKOFF_MS = 10 * 1000;
33
+ // keys that could not be refreshed are still used for this long past their TTL, so a short IdP outage does not lock everyone out
34
+ const STALE_KEYS_MS = 60 * 60 * 1000;
35
+ class TokenVerifier {
36
+ constructor(issuers, options = {}) {
37
+ this.issuers = issuers;
38
+ this.keys = new Map();
39
+ this.loading = new Map();
40
+ this.failures = new Map();
41
+ const fetcher = options.fetch ?? fetch;
42
+ this.now = options.now ?? Date.now;
43
+ this.fetchJson = async (url) => {
44
+ const response = await fetcher(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
45
+ if (!response.ok) {
46
+ throw new TokenError(`${url} answered ${response.status}`);
47
+ }
48
+ return response.json();
49
+ };
50
+ }
51
+ async verify(token) {
52
+ const parts = token.split('.');
53
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
54
+ throw new TokenError('not a signed JWT');
55
+ }
56
+ const header = decodeObject(parts[0], 'header');
57
+ const payload = decodeObject(parts[1], 'payload');
58
+ const algorithm = typeof header.alg === 'string' ? ALGORITHMS[header.alg] : undefined;
59
+ if (!algorithm) {
60
+ throw new TokenError(`algorithm ${quote(header.alg)} is not accepted`);
61
+ }
62
+ const issuer = this.issuers.find((i) => i.issuer === payload.iss);
63
+ if (!issuer) {
64
+ throw new TokenError(`issuer ${quote(payload.iss)} is not accepted`);
65
+ }
66
+ const jwk = await this.findKey(issuer, algorithm, typeof header.kid === 'string' ? header.kid : undefined);
67
+ const signature = Buffer.from(parts[2], 'base64url');
68
+ const data = Buffer.from(`${parts[0]}.${parts[1]}`);
69
+ const key = (0, crypto_1.createPublicKey)({ key: jwk, format: 'jwk' });
70
+ if (!verifySignature(algorithm, data, key, signature)) {
71
+ throw new TokenError('signature does not match');
72
+ }
73
+ return this.readClaims(issuer, payload);
74
+ }
75
+ readClaims(issuer, payload) {
76
+ const now = this.now() / 1000;
77
+ if (typeof payload.exp !== 'number') {
78
+ throw new TokenError('token has no expiry');
79
+ }
80
+ if (payload.exp + CLOCK_SKEW_SECONDS < now) {
81
+ throw new TokenError('token expired');
82
+ }
83
+ if (typeof payload.nbf === 'number' && payload.nbf - CLOCK_SKEW_SECONDS > now) {
84
+ throw new TokenError('token not valid yet');
85
+ }
86
+ const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
87
+ if (!audiences.includes(issuer.audience)) {
88
+ throw new TokenError(`token is not meant for ${issuer.audience}`);
89
+ }
90
+ if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
91
+ throw new TokenError('token has no subject');
92
+ }
93
+ const groups = payload[issuer.groupsClaim];
94
+ const claims = {};
95
+ for (const [name, value] of Object.entries(payload)) {
96
+ if (typeof value === 'string') {
97
+ claims[name] = value;
98
+ }
99
+ }
100
+ return {
101
+ iss: issuer.issuer,
102
+ sub: payload.sub,
103
+ groups: Array.isArray(groups)
104
+ ? groups.filter((g) => typeof g === 'string')
105
+ : typeof groups === 'string'
106
+ ? [groups]
107
+ : [],
108
+ claims,
109
+ };
110
+ }
111
+ async findKey(issuer, algorithm, kid) {
112
+ const known = this.keys.get(issuer.issuer);
113
+ const stale = !known || this.now() - known.loadedAt > KEYS_TTL_MS;
114
+ let loaded;
115
+ if (stale) {
116
+ try {
117
+ loaded = await this.loadKeys(issuer);
118
+ }
119
+ catch (e) {
120
+ if (!known || this.now() - known.loadedAt > KEYS_TTL_MS + STALE_KEYS_MS) {
121
+ throw e;
122
+ }
123
+ loaded = known;
124
+ }
125
+ }
126
+ else {
127
+ loaded = known;
128
+ }
129
+ let jwk = pickKey(loaded.keys, algorithm, kid);
130
+ if (!jwk && !stale && this.now() - loaded.loadedAt > REFRESH_COOLDOWN_MS) {
131
+ // the issuer may have rotated its keys since they were cached
132
+ try {
133
+ loaded = await this.loadKeys(issuer);
134
+ jwk = pickKey(loaded.keys, algorithm, kid);
135
+ }
136
+ catch {
137
+ // keep the cached keys: the token simply has no key we know
138
+ }
139
+ }
140
+ if (!jwk) {
141
+ throw new TokenError(`issuer has no ${algorithm.kty} key${kid ? ` ${quote(kid)}` : ''}`);
142
+ }
143
+ return jwk;
144
+ }
145
+ // One load per issuer at a time, and none right after a failed one.
146
+ loadKeys(issuer) {
147
+ const running = this.loading.get(issuer.issuer);
148
+ if (running) {
149
+ return running;
150
+ }
151
+ const failure = this.failures.get(issuer.issuer);
152
+ if (failure && this.now() - failure.at < FAILURE_BACKOFF_MS) {
153
+ return Promise.reject(failure.error);
154
+ }
155
+ const load = this.fetchKeys(issuer)
156
+ .then((loaded) => {
157
+ this.failures.delete(issuer.issuer);
158
+ return loaded;
159
+ })
160
+ .catch((error) => {
161
+ this.failures.set(issuer.issuer, { at: this.now(), error });
162
+ throw error;
163
+ })
164
+ .finally(() => this.loading.delete(issuer.issuer));
165
+ this.loading.set(issuer.issuer, load);
166
+ return load;
167
+ }
168
+ async fetchKeys(issuer) {
169
+ const base = issuer.issuer.replace(/\/+$/, '');
170
+ const metadata = (await this.fetchJson(`${base}/.well-known/openid-configuration`));
171
+ if (metadata.issuer !== issuer.issuer) {
172
+ throw new TokenError(`issuer metadata names ${quote(metadata.issuer)}`);
173
+ }
174
+ const jwksUri = typeof metadata.jwks_uri === 'string' ? metadata.jwks_uri : '';
175
+ if (!isSecureUrl(jwksUri)) {
176
+ throw new TokenError(`jwks_uri ${quote(jwksUri)} is not https`);
177
+ }
178
+ const jwks = (await this.fetchJson(jwksUri));
179
+ const loaded = { keys: Array.isArray(jwks.keys) ? jwks.keys : [], loadedAt: this.now() };
180
+ this.keys.set(issuer.issuer, loaded);
181
+ return loaded;
182
+ }
183
+ }
184
+ exports.TokenVerifier = TokenVerifier;
185
+ function pickKey(keys, algorithm, kid) {
186
+ const candidates = keys.filter((jwk) => jwk.kty === algorithm.kty &&
187
+ jwk.use !== 'enc' &&
188
+ (algorithm.crv === undefined || jwk.crv === algorithm.crv));
189
+ if (kid !== undefined) {
190
+ return candidates.find((jwk) => jwk.kid === kid);
191
+ }
192
+ // a token without kid is only unambiguous when the issuer has one such key
193
+ return candidates.length === 1 ? candidates[0] : undefined;
194
+ }
195
+ function verifySignature(algorithm, data, key, signature) {
196
+ if (algorithm.kty === 'EC') {
197
+ return (0, crypto_1.verify)(algorithm.hash, data, { key, dsaEncoding: 'ieee-p1363' }, signature);
198
+ }
199
+ if (algorithm.pss) {
200
+ return (0, crypto_1.verify)(algorithm.hash, data, { key, padding: crypto_1.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto_1.constants.RSA_PSS_SALTLEN_DIGEST }, signature);
201
+ }
202
+ return (0, crypto_1.verify)(algorithm.hash, data, key, signature);
203
+ }
204
+ function decodeObject(part, what) {
205
+ try {
206
+ const value = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
207
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
208
+ return value;
209
+ }
210
+ }
211
+ catch {
212
+ // reported below
213
+ }
214
+ throw new TokenError(`token ${what} is not a JSON object`);
215
+ }
216
+ function isSecureUrl(value) {
217
+ try {
218
+ const url = new URL(value);
219
+ return (url.protocol === 'https:' ||
220
+ (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)));
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ }
226
+ //# sourceMappingURL=oauth.js.map
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getGrant = exports.isAdmin = exports.maySeeDefinitions = exports.mayPropose = exports.mayPublish = exports.matches = void 0;
4
+ function matches(entitlement, principal) {
5
+ if (entitlement.subject) {
6
+ return entitlement.subject.iss === principal.iss && entitlement.subject.sub === principal.sub;
7
+ }
8
+ if (entitlement.group) {
9
+ return entitlement.group.iss === principal.iss && principal.groups.includes(entitlement.group.name);
10
+ }
11
+ if (entitlement.claims) {
12
+ return (entitlement.claims.iss === principal.iss &&
13
+ Object.entries(entitlement.claims.match).every(([name, value]) => principal.claims[name] === value));
14
+ }
15
+ return false;
16
+ }
17
+ exports.matches = matches;
18
+ function mayPublish(grant, name) {
19
+ return grant.publish.includes('*') || grant.publish.includes(name);
20
+ }
21
+ exports.mayPublish = mayPublish;
22
+ function mayPropose(grant, name) {
23
+ return grant.propose.includes('*') || grant.propose.includes(name);
24
+ }
25
+ exports.mayPropose = mayPropose;
26
+ // A caller who can neither run nor publish anything sees nothing of the server's
27
+ // build files either. Admins see them, they have to know what they approve.
28
+ function maySeeDefinitions(grant) {
29
+ return grant.accounts.length > 0 || grant.request.length > 0 || grant.publish.length > 0 || grant.propose.length > 0;
30
+ }
31
+ exports.maySeeDefinitions = maySeeDefinitions;
32
+ // Admin of an account decides about the runs on it.
33
+ function isAdmin(grant, account) {
34
+ return account !== null && grant.accounts.includes(account);
35
+ }
36
+ exports.isAdmin = isAdmin;
37
+ // Default deny: a caller without a matching entitlement gets no account.
38
+ function getGrant(entitlements, principal) {
39
+ const matched = [];
40
+ const accounts = [];
41
+ const requested = [];
42
+ const publish = [];
43
+ const propose = [];
44
+ let defaultAccount = null;
45
+ let source = null;
46
+ entitlements.forEach((entitlement, index) => {
47
+ if (!matches(entitlement, principal)) {
48
+ return;
49
+ }
50
+ matched.push(index);
51
+ for (const account of entitlement.accounts) {
52
+ if (!accounts.includes(account)) {
53
+ accounts.push(account);
54
+ }
55
+ }
56
+ for (const account of entitlement.request ?? []) {
57
+ if (!requested.includes(account)) {
58
+ requested.push(account);
59
+ }
60
+ }
61
+ for (const name of entitlement.publish ?? []) {
62
+ if (!publish.includes(name)) {
63
+ publish.push(name);
64
+ }
65
+ }
66
+ for (const name of entitlement.propose ?? []) {
67
+ if (!propose.includes(name)) {
68
+ propose.push(name);
69
+ }
70
+ }
71
+ defaultAccount = defaultAccount ?? entitlement.default ?? null;
72
+ if (entitlement.source) {
73
+ source = source ? source.filter((s) => entitlement.source?.includes(s)) : [...entitlement.source];
74
+ }
75
+ });
76
+ // admin wins over request when two entitlements give both
77
+ const request = requested.filter((account) => !accounts.includes(account));
78
+ const usable = [...accounts, ...request];
79
+ return {
80
+ accounts,
81
+ request,
82
+ publish,
83
+ propose,
84
+ default: defaultAccount ?? (usable.length === 1 ? usable[0] : null),
85
+ source: source ?? ['upload', 'ref'],
86
+ matched,
87
+ };
88
+ }
89
+ exports.getGrant = getGrant;
90
+ //# sourceMappingURL=policy.js.map