@tapestry-mud/cli 0.6.0 → 0.7.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.
package/README.md CHANGED
@@ -83,7 +83,11 @@ my-game/
83
83
  | `tapestry search [query]` | Search the registry by keyword |
84
84
  | `tapestry info [pack]` | Show pack metadata from the registry |
85
85
  | `tapestry register` | Create a registry account |
86
- | `tapestry login` | Authenticate with the registry |
86
+ | `tapestry login` | Authenticate with the registry (interactive password login) |
87
+ | `tapestry logout` | Revoke your session and remove `~/.tapestryrc` |
88
+ | `tapestry trust add <scope> <repo>` | Authorize a GitHub repo to publish to a scope via OIDC |
89
+ | `tapestry trust list` | List your trusted publishers |
90
+ | `tapestry trust rm <id>` | Remove a trusted publisher binding |
87
91
  | `tapestry change-password` | Change your registry password |
88
92
 
89
93
  ### Pack Authoring
@@ -140,16 +144,23 @@ tapestry dist-tag set @yourscope/my-pack stable 0.1.0
140
144
 
141
145
  Players using `tapestry init` with a preset that references your pack will resolve to the tagged version.
142
146
 
143
- ### CI Publishing
147
+ ### CI Publishing (GitHub Actions, OIDC)
144
148
 
145
- For automated pipelines, use token-based auth:
149
+ `tapestry publish` auto-detects the GitHub Actions OIDC environment and exchanges a short-lived
150
+ id-token for a registry access token — no stored secret, no `tapestry login` step. In your workflow:
151
+
152
+ ```yaml
153
+ permissions:
154
+ id-token: write # required for OIDC
155
+ ```
156
+
157
+ Authorize the repo once (scope owner or admin):
146
158
 
147
159
  ```bash
148
- tapestry login --token $REGISTRY_CI_TOKEN
149
- tapestry publish
160
+ tapestry trust add yourscope your-org/your-repo
150
161
  ```
151
162
 
152
- The CI token is a JWT issued by the registry bootstrap script. Store it as a secret in your CI environment.
163
+ Then `tapestry publish` in CI just works. There is no `REGISTRY_CI_TOKEN` and no `--token` flag.
153
164
 
154
165
  ## Registry
155
166
 
package/bin/tapestry.js CHANGED
@@ -11,6 +11,7 @@ const { update } = require('../src/commands/update');
11
11
  const { enable } = require('../src/commands/enable');
12
12
  const { disable } = require('../src/commands/disable');
13
13
  const { login } = require('../src/commands/login');
14
+ const { logout } = require('../src/commands/logout');
14
15
  const { register } = require('../src/commands/register');
15
16
  const { validate } = require('../src/commands/validate');
16
17
  const { pack } = require('../src/commands/pack');
@@ -28,6 +29,7 @@ const { changePassword } = require('../src/commands/change-password');
28
29
  const { unpublish } = require('../src/commands/unpublish');
29
30
  const { distTagSet, distTagList } = require('../src/commands/dist-tag');
30
31
  const { presetSet, presetDelete } = require('../src/commands/preset');
32
+ const { trustAdd, trustList, trustRm } = require('../src/commands/trust');
31
33
 
32
34
  const program = new Command();
33
35
 
@@ -53,12 +55,16 @@ program.configureHelp({
53
55
  },
54
56
  {
55
57
  title: 'Account',
56
- commands: ['register', 'login', 'change-password'],
58
+ commands: ['register', 'login', 'logout', 'change-password'],
57
59
  },
58
60
  {
59
61
  title: 'Pack Authoring',
60
62
  commands: ['create', 'validate', 'pack', 'publish', 'unpublish'],
61
63
  },
64
+ {
65
+ title: 'Trusted Publishing',
66
+ commands: ['trust'],
67
+ },
62
68
  {
63
69
  title: 'Admin',
64
70
  commands: ['dist-tag', 'preset'],
@@ -243,11 +249,22 @@ program
243
249
 
244
250
  program
245
251
  .command('login')
246
- .description('Authenticate with the registry and store token in ~/.tapestryrc')
247
- .option('--token <token>', 'Save a raw token directly (for CI use, skips interactive login)')
248
- .action(async (options) => {
252
+ .description('Authenticate with the registry (interactive password login)')
253
+ .action(async () => {
249
254
  try {
250
- await login({}, { token: options.token });
255
+ await login();
256
+ } catch (e) {
257
+ console.error(`error: ${e.message}`);
258
+ process.exit(1);
259
+ }
260
+ });
261
+
262
+ program
263
+ .command('logout')
264
+ .description('Revoke your session and remove ~/.tapestryrc')
265
+ .action(async () => {
266
+ try {
267
+ await logout();
251
268
  } catch (e) {
252
269
  console.error(`error: ${e.message}`);
253
270
  process.exit(1);
@@ -266,6 +283,47 @@ program
266
283
  }
267
284
  });
268
285
 
286
+ const trustCmd = program.command('trust').description('Manage trusted publishers (OIDC CI publishing)');
287
+
288
+ trustCmd
289
+ .command('add <scope> <repo>')
290
+ .description('Authorize a GitHub repo (owner/name) to publish to a scope via OIDC')
291
+ .option('--ref <ref>', 'Restrict to a git ref, e.g. refs/heads/master')
292
+ .option('--environment <env>', 'Restrict to a GitHub Actions environment')
293
+ .action(async (scope, repo, options) => {
294
+ try {
295
+ await trustAdd(scope, repo, { ref: options.ref, environment: options.environment });
296
+ } catch (e) {
297
+ console.error(`error: ${e.message}`);
298
+ process.exit(1);
299
+ }
300
+ });
301
+
302
+ trustCmd
303
+ .command('list')
304
+ .description('List trusted publishers you own (all, if admin)')
305
+ .option('--scope <scope>', 'Filter by scope')
306
+ .action(async (options) => {
307
+ try {
308
+ await trustList(options.scope);
309
+ } catch (e) {
310
+ console.error(`error: ${e.message}`);
311
+ process.exit(1);
312
+ }
313
+ });
314
+
315
+ trustCmd
316
+ .command('rm <id>')
317
+ .description('Remove a trusted publisher binding by id')
318
+ .action(async (id) => {
319
+ try {
320
+ await trustRm(id);
321
+ } catch (e) {
322
+ console.error(`error: ${e.message}`);
323
+ process.exit(1);
324
+ }
325
+ });
326
+
269
327
  program
270
328
  .command('validate')
271
329
  .description('Validate pack.yaml in the current directory')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapestry-mud/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI for the Tapestry MUD engine",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,12 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  const fetch = require('node-fetch');
4
- const { requireToken } = require('../lib/auth');
4
+ const { requireAccess } = require('../lib/auth');
5
5
  const { DEFAULT_REGISTRY, throwIfError } = require('../lib/registry-client');
6
6
  const { createInterface, askPassword } = require('../util/prompt');
7
7
 
8
8
  async function changePassword({ registryUrl = DEFAULT_REGISTRY } = {}) {
9
- const token = requireToken();
9
+ const token = await requireAccess();
10
10
  const rl = createInterface();
11
11
  try {
12
12
  const currentPassword = await askPassword(rl, 'Current password: ');
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
- const { requireToken } = require('../lib/auth');
3
+ const { requireAccess } = require('../lib/auth');
4
4
  const { patchDistTag, listDistTags, DEFAULT_REGISTRY } = require('../lib/registry-client');
5
5
 
6
6
  async function distTagSet(packName, tag, version, { registryUrl = DEFAULT_REGISTRY } = {}) {
7
- const token = requireToken();
7
+ const token = await requireAccess();
8
8
  await patchDistTag(packName, tag, version, token, registryUrl);
9
9
  console.log(` ${packName} ${tag} -> ${version}`);
10
10
  console.log('Done.');
@@ -9,7 +9,7 @@ const { readLock, writeLock, hashDeps } = require('../lib/lock-file');
9
9
  const { fetchTarball, DEFAULT_REGISTRY } = require('../lib/registry-client');
10
10
  const { verifyIntegrity, saveTarball, extractTarball } = require('../lib/tarball');
11
11
  const { addPackageToBoot } = require('../lib/boot');
12
- const { loadToken } = require('../lib/auth');
12
+ const { loadAccess } = require('../lib/auth');
13
13
  const { PACK_MANIFEST } = require('../lib/manifest');
14
14
  const { readLinks } = require('../lib/links');
15
15
 
@@ -85,7 +85,7 @@ async function install(packageArg, { cwd = process.cwd(), registryUrl = DEFAULT_
85
85
  throw new Error('No tapestry.yaml found. Run `tapestry init` first.');
86
86
  }
87
87
 
88
- const token = loadToken();
88
+ const token = await loadAccess();
89
89
  const manifest = readYaml(manifestPath);
90
90
  let resolved;
91
91
 
@@ -10,7 +10,7 @@ const { addPackageToBoot, removePackageFromBoot } = require('../lib/boot');
10
10
  const { resolve } = require('../lib/semver-resolver');
11
11
  const { installResolved, packInstallPath } = require('./install');
12
12
  const { readLock, writeLock } = require('../lib/lock-file');
13
- const { loadToken } = require('../lib/auth');
13
+ const { loadAccess } = require('../lib/auth');
14
14
  const { DEFAULT_REGISTRY } = require('../lib/registry-client');
15
15
 
16
16
  function requireProject(cwd) {
@@ -69,7 +69,7 @@ async function link(targetPath, { cwd = process.cwd(), noInstall = false, regist
69
69
 
70
70
  let toRollback = [];
71
71
  try {
72
- const token = loadToken();
72
+ const token = await loadAccess();
73
73
  const resolved = await resolve(needsInstall, registryUrl, token);
74
74
 
75
75
  // New installs: not on disk yet. Upgrade targets: in needsInstall AND on disk
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const fetch = require('node-fetch');
4
- const { saveToken } = require('../lib/auth');
4
+ const { saveSession, decodeExp } = require('../lib/auth');
5
5
  const { DEFAULT_REGISTRY, throwIfError } = require('../lib/registry-client');
6
6
  const { createInterface, ask, askPassword } = require('../util/prompt');
7
7
 
@@ -16,12 +16,7 @@ async function promptCredentials() {
16
16
  }
17
17
  }
18
18
 
19
- async function login({ email, password } = {}, { registryUrl = DEFAULT_REGISTRY, token = null } = {}) {
20
- if (token) {
21
- saveToken(token);
22
- console.log('Token saved.');
23
- return;
24
- }
19
+ async function login({ email, password } = {}, { registryUrl = DEFAULT_REGISTRY } = {}) {
25
20
  if (!email || !password) {
26
21
  ({ email, password } = await promptCredentials());
27
22
  }
@@ -34,8 +29,8 @@ async function login({ email, password } = {}, { registryUrl = DEFAULT_REGISTRY,
34
29
 
35
30
  await throwIfError(res, 'Login failed');
36
31
 
37
- const { token: authToken } = await res.json();
38
- saveToken(authToken);
32
+ const { access_token, refresh_token } = await res.json();
33
+ saveSession({ registry: registryUrl, access: access_token, access_exp: decodeExp(access_token), refresh: refresh_token });
39
34
  console.log('Logged in.');
40
35
  }
41
36
 
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const { readSession, clearSession, DEFAULT_REGISTRY } = require('../lib/auth');
4
+ const { postLogout } = require('../lib/registry-client');
5
+
6
+ async function logout() {
7
+ const s = readSession();
8
+ if (s && s.refresh) {
9
+ try {
10
+ await postLogout(s.refresh, s.registry || DEFAULT_REGISTRY);
11
+ } catch {
12
+ // Best-effort server revoke; always clear the local session below.
13
+ }
14
+ }
15
+ clearSession();
16
+ console.log('Logged out.');
17
+ }
18
+
19
+ module.exports = { logout };
@@ -1,17 +1,17 @@
1
1
  'use strict';
2
2
 
3
- const { requireToken } = require('../lib/auth');
3
+ const { requireAccess } = require('../lib/auth');
4
4
  const { patchPreset, deletePreset, DEFAULT_REGISTRY } = require('../lib/registry-client');
5
5
 
6
6
  async function presetSet(name, version, engineChannel, packs, { registryUrl = DEFAULT_REGISTRY } = {}) {
7
- const token = requireToken();
7
+ const token = await requireAccess();
8
8
  await patchPreset(name, { version, engine_channel: engineChannel, packs }, token, registryUrl);
9
9
  console.log(` Updated preset '${name}' to v${version}`);
10
10
  console.log('Done.');
11
11
  }
12
12
 
13
13
  async function presetDelete(name, { registryUrl = DEFAULT_REGISTRY } = {}) {
14
- const token = requireToken();
14
+ const token = await requireAccess();
15
15
  await deletePreset(name, token, registryUrl);
16
16
  console.log(` Deleted preset '${name}'`);
17
17
  console.log('Done.');
@@ -9,14 +9,25 @@ const { readYaml } = require('../util/yaml');
9
9
  const { validate } = require('./validate');
10
10
  const { buildTarball, computeIntegrity } = require('../lib/tarball-builder');
11
11
  const { PACK_MANIFEST } = require('../lib/manifest');
12
- const { requireToken } = require('../lib/auth');
13
- const { DEFAULT_REGISTRY, throwIfError } = require('../lib/registry-client');
12
+ const { requireAccess } = require('../lib/auth');
13
+ const { DEFAULT_REGISTRY, throwIfError, exchangeOIDCForAccess } = require('../lib/registry-client');
14
+ const { detectCI, fetchGitHubIdToken, AUDIENCE } = require('../lib/oidc');
14
15
 
15
16
  async function publish({ cwd = process.cwd(), registryUrl = DEFAULT_REGISTRY } = {}) {
16
17
  validate({ cwd });
17
18
 
18
19
  const manifest = readYaml(path.join(cwd, PACK_MANIFEST));
19
- const token = requireToken();
20
+
21
+ const scope = manifest.name.match(/^@([^/]+)\//)[1];
22
+ const ciMode = detectCI();
23
+ let token;
24
+ if (ciMode) {
25
+ console.log('Detected GitHub Actions OIDC environment — exchanging id-token...');
26
+ const idToken = await fetchGitHubIdToken(AUDIENCE);
27
+ token = await exchangeOIDCForAccess(scope, idToken, registryUrl);
28
+ } else {
29
+ token = await requireAccess();
30
+ }
20
31
 
21
32
  const shortName = manifest.name.split('/')[1];
22
33
  const tmpPath = path.join(
@@ -37,6 +48,10 @@ async function publish({ cwd = process.cwd(), registryUrl = DEFAULT_REGISTRY } =
37
48
  });
38
49
  form.append('metadata', JSON.stringify({ ...manifest, integrity }));
39
50
 
51
+ if (ciMode) {
52
+ form.append('tag', 'stable');
53
+ }
54
+
40
55
  const res = await fetch(`${registryUrl}/v1/publish`, {
41
56
  method: 'POST',
42
57
  headers: {
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const fetch = require('node-fetch');
4
- const { saveToken } = require('../lib/auth');
4
+ const { saveSession, decodeExp } = require('../lib/auth');
5
5
  const { DEFAULT_REGISTRY, throwIfError } = require('../lib/registry-client');
6
6
  const { createInterface, ask, askPassword } = require('../util/prompt');
7
7
 
@@ -30,8 +30,8 @@ async function register({ handle, email, password } = {}, { registryUrl = DEFAUL
30
30
 
31
31
  await throwIfError(res, 'Registration failed');
32
32
 
33
- const { token } = await res.json();
34
- saveToken(token);
33
+ const { access_token, refresh_token } = await res.json();
34
+ saveSession({ registry: registryUrl, access: access_token, access_exp: decodeExp(access_token), refresh: refresh_token });
35
35
  console.log(`Registered as ${handle}. Logged in.`);
36
36
  }
37
37
 
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ const { requireAccess, readSession, DEFAULT_REGISTRY } = require('../lib/auth');
4
+ const {
5
+ createTrustedPublisher, listTrustedPublishers, deleteTrustedPublisher,
6
+ } = require('../lib/registry-client');
7
+
8
+ function registryUrl() {
9
+ const s = readSession();
10
+ return (s && s.registry) || DEFAULT_REGISTRY;
11
+ }
12
+
13
+ async function trustAdd(scope, repo, { ref, environment } = {}) {
14
+ const token = await requireAccess();
15
+ const body = { scope, repo };
16
+ if (ref) { body.ref = ref; }
17
+ if (environment) { body.environment = environment; }
18
+ const row = await createTrustedPublisher(body, token, registryUrl());
19
+ console.log(`Trusted publisher #${row.id}: @${row.scope} <- ${row.repo}`);
20
+ }
21
+
22
+ async function trustList(scope) {
23
+ const token = await requireAccess();
24
+ const rows = await listTrustedPublishers(scope, token, registryUrl());
25
+ if (!rows.length) {
26
+ console.log('No trusted publishers.');
27
+ return;
28
+ }
29
+ for (const r of rows) {
30
+ const extra = [r.ref ? `ref=${r.ref}` : null, r.environment ? `env=${r.environment}` : null]
31
+ .filter(Boolean).join(' ');
32
+ console.log(`#${r.id} @${r.scope} <- ${r.repo}${extra ? ` (${extra})` : ''}`);
33
+ }
34
+ }
35
+
36
+ async function trustRm(id) {
37
+ const token = await requireAccess();
38
+ await deleteTrustedPublisher(id, token, registryUrl());
39
+ console.log(`Removed trusted publisher #${id}.`);
40
+ }
41
+
42
+ module.exports = { trustAdd, trustList, trustRm };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const fetch = require('node-fetch');
4
- const { requireToken } = require('../lib/auth');
4
+ const { requireAccess } = require('../lib/auth');
5
5
  const { DEFAULT_REGISTRY } = require('../lib/registry-client');
6
6
  const { createInterface, ask } = require('../util/prompt');
7
7
 
@@ -16,7 +16,7 @@ function parsePackageArg(arg) {
16
16
  }
17
17
 
18
18
  async function unpublish(packageArg, { force = false, registryUrl = DEFAULT_REGISTRY } = {}) {
19
- const token = requireToken();
19
+ const token = await requireAccess();
20
20
  const { pkg, version } = parsePackageArg(packageArg);
21
21
 
22
22
  if (!/^@[a-z0-9-]+\/[a-z0-9-]+$/.test(pkg)) {
package/src/lib/auth.js CHANGED
@@ -4,31 +4,81 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const yaml = require('js-yaml');
7
+ const fetch = require('node-fetch');
7
8
 
8
9
  const RC_PATH = path.join(os.homedir(), '.tapestryrc');
10
+ const DEFAULT_REGISTRY = process.env.TAPESTRY_REGISTRY || 'https://registry.tapestryengine.com';
11
+ const REFRESH_SKEW_SECONDS = 60; // refresh slightly before actual expiry
9
12
 
10
- function loadToken() {
13
+ function readSession() {
11
14
  if (!fs.existsSync(RC_PATH)) {
12
15
  return null;
13
16
  }
14
17
  try {
15
- const data = yaml.load(fs.readFileSync(RC_PATH, 'utf8'));
16
- return data?.token ?? null;
18
+ return yaml.load(fs.readFileSync(RC_PATH, 'utf8')) || null;
17
19
  } catch {
18
20
  return null;
19
21
  }
20
22
  }
21
23
 
22
- function saveToken(token) {
23
- fs.writeFileSync(RC_PATH, yaml.dump({ token }, { lineWidth: -1 }), { mode: 0o600 });
24
+ function saveSession({ registry, access, access_exp, refresh }) {
25
+ fs.writeFileSync(
26
+ RC_PATH,
27
+ yaml.dump({ registry, access, access_exp, refresh }, { lineWidth: -1 }),
28
+ { mode: 0o600 }
29
+ );
24
30
  }
25
31
 
26
- function requireToken() {
27
- const token = loadToken();
28
- if (!token) {
32
+ function clearSession() {
33
+ if (fs.existsSync(RC_PATH)) {
34
+ fs.unlinkSync(RC_PATH);
35
+ }
36
+ }
37
+
38
+ function decodeExp(jwtString) {
39
+ try {
40
+ const payload = JSON.parse(Buffer.from(jwtString.split('.')[1], 'base64url').toString('utf8'));
41
+ return typeof payload.exp === 'number' ? payload.exp : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ async function loadAccess() {
48
+ const s = readSession();
49
+ if (!s || !s.access || !s.refresh) {
50
+ return null; // absent, or legacy token-only rc
51
+ }
52
+ const now = Math.floor(Date.now() / 1000);
53
+ if (typeof s.access_exp === 'number' && now < s.access_exp - REFRESH_SKEW_SECONDS) {
54
+ return s.access;
55
+ }
56
+ // Access expired (or near it): silently refresh.
57
+ const registry = s.registry || DEFAULT_REGISTRY;
58
+ const res = await fetch(`${registry}/v1/auth/refresh`, {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({ refresh_token: s.refresh }),
62
+ });
63
+ if (!res.ok) {
64
+ clearSession();
65
+ return null;
66
+ }
67
+ const { access_token, refresh_token } = await res.json();
68
+ saveSession({ registry, access: access_token, access_exp: decodeExp(access_token), refresh: refresh_token });
69
+ return access_token;
70
+ }
71
+
72
+ async function requireAccess() {
73
+ const access = await loadAccess();
74
+ if (!access) {
29
75
  throw new Error('Not logged in. Run: tapestry login');
30
76
  }
31
- return token;
77
+ return access;
32
78
  }
33
79
 
34
- module.exports = { RC_PATH, loadToken, saveToken, requireToken };
80
+ module.exports = {
81
+ RC_PATH, DEFAULT_REGISTRY,
82
+ readSession, saveSession, clearSession, decodeExp,
83
+ loadAccess, requireAccess,
84
+ };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const fetch = require('node-fetch');
4
+
5
+ const AUDIENCE = 'https://registry.tapestryengine.com';
6
+
7
+ function detectCI() {
8
+ return !!(process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN);
9
+ }
10
+
11
+ async function fetchGitHubIdToken(audience = AUDIENCE) {
12
+ const base = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
13
+ const reqToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
14
+ const url = `${base}&audience=${encodeURIComponent(audience)}`;
15
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${reqToken}` } });
16
+ if (!res.ok) {
17
+ throw new Error(`failed to fetch GitHub OIDC id-token (HTTP ${res.status})`);
18
+ }
19
+ const { value } = await res.json();
20
+ if (!value) {
21
+ throw new Error('GitHub OIDC response had no token value');
22
+ }
23
+ return value;
24
+ }
25
+
26
+ module.exports = { detectCI, fetchGitHubIdToken, AUDIENCE };
@@ -128,7 +128,59 @@ async function deletePreset(name, token, registryUrl = DEFAULT_REGISTRY) {
128
128
  return res.json();
129
129
  }
130
130
 
131
+ async function postLogout(refreshToken, registryUrl = DEFAULT_REGISTRY) {
132
+ const res = await fetch(`${registryUrl.replace(/\/$/, '')}/v1/auth/logout`, {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify({ refresh_token: refreshToken }),
136
+ });
137
+ await throwIfError(res, 'Logout failed');
138
+ return res.json();
139
+ }
140
+
141
+ async function createTrustedPublisher(body, token, registryUrl = DEFAULT_REGISTRY) {
142
+ const res = await fetch(`${registryUrl.replace(/\/$/, '')}/v1/trusted-publishers`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
145
+ body: JSON.stringify(body),
146
+ });
147
+ await throwIfError(res, 'Failed to create trusted publisher');
148
+ return res.json();
149
+ }
150
+
151
+ async function listTrustedPublishers(scope, token, registryUrl = DEFAULT_REGISTRY) {
152
+ const q = scope ? `?scope=${encodeURIComponent(scope)}` : '';
153
+ const res = await fetch(`${registryUrl.replace(/\/$/, '')}/v1/trusted-publishers${q}`, {
154
+ headers: { Authorization: `Bearer ${token}` },
155
+ });
156
+ await throwIfError(res, 'Failed to list trusted publishers');
157
+ return res.json();
158
+ }
159
+
160
+ async function deleteTrustedPublisher(id, token, registryUrl = DEFAULT_REGISTRY) {
161
+ const res = await fetch(`${registryUrl.replace(/\/$/, '')}/v1/trusted-publishers/${id}`, {
162
+ method: 'DELETE',
163
+ headers: { Authorization: `Bearer ${token}` },
164
+ });
165
+ await throwIfError(res, 'Failed to delete trusted publisher');
166
+ return res.json();
167
+ }
168
+
169
+ async function exchangeOIDCForAccess(scope, idToken, registryUrl = DEFAULT_REGISTRY) {
170
+ const res = await fetch(`${registryUrl.replace(/\/$/, '')}/v1/token`, {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${idToken}` },
173
+ body: JSON.stringify({ scope }),
174
+ });
175
+ await throwIfError(res, 'OIDC token exchange failed');
176
+ const { access_token } = await res.json();
177
+ return access_token;
178
+ }
179
+
131
180
  module.exports = {
132
181
  fetchPackageMetadata, fetchTarball, throwIfError, DEFAULT_REGISTRY,
133
182
  fetchPreset, fetchPresetList, patchDistTag, listDistTags, patchPreset, deletePreset,
183
+ postLogout,
184
+ createTrustedPublisher, listTrustedPublishers, deleteTrustedPublisher,
185
+ exchangeOIDCForAccess,
134
186
  };