rakomi 0.1.2 → 0.2.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
@@ -9,7 +9,15 @@ npx rakomi login
9
9
  ```
10
10
 
11
11
  Signs you in with your browser (loopback OAuth + PKCE). Add `--no-browser` on a headless/SSH
12
- session to get a short code to enter on another device instead.
12
+ session to get a short code to enter on another device instead. If you don't already have a
13
+ `accounts.rakomi.com` session or an invitation link, pass `--tenant-id <uuid>` (or set
14
+ `RAKOMI_PLATFORM_TENANT_ID`) so sign-in knows which tenant to authenticate against — ask your
15
+ Rakomi administrator for the right value.
16
+
17
+ `rakomi whoami` shows the tenant you signed in against ("Home tenant") and the tenant your other
18
+ commands should treat as active ("Active tenant"). `rakomi use <tenant-id>` remembers a tenant id
19
+ locally for that; `--tenant <tenant-id>` overrides it for one command. Neither is a verified
20
+ grant — the tenant `whoami` shows as your Home tenant is the only one the server has confirmed.
13
21
 
14
22
  ## Connect an AI agent
15
23
 
@@ -45,7 +53,8 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
45
53
  | --- | --- |
46
54
  | `login` | sign in (browser by default; `--no-browser` for a device code) |
47
55
  | `logout` | clear the local session |
48
- | `whoami` | show the signed-in account |
56
+ | `whoami` | show the signed-in account, home tenant, and active tenant |
57
+ | `use <tenant-id>` | remember a tenant id locally for `whoami`/future commands |
49
58
  | `connect` | connect Claude Code / Claude Desktop to your tenant (read access) |
50
59
  | `tenants create <name>` | create a tenant (`--owner me\|<email>`, `--slug <slug>`) |
51
60
  | `tenants list` | list tenants you provisioned |
@@ -59,6 +68,8 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
59
68
  | `--dry-run` | print what would happen, make no writes or mutating calls |
60
69
  | `--no-browser` | use the device-code flow instead of opening a browser |
61
70
  | `--no-keychain` | store the session in a `0600` file instead of the OS keychain |
71
+ | `--tenant-id <uuid>` | (`login`) the tenant to sign in against, if `RAKOMI_PLATFORM_TENANT_ID` isn't set for you |
72
+ | `--tenant <tenant-id>` | override the active tenant for a single command (`whoami` today) |
62
73
  | `--client <name>` | `claude-code` or `claude-desktop` — required when more than one applies |
63
74
  | `--undo` | restore the `.mcp.json` `connect` last backed up |
64
75
  | `--cimd-url <url>` | (`connect`) the connecting client's own CIMD document URL — confirms/re-checks the connection |
@@ -1,7 +1,8 @@
1
1
  import { resolveCiOidcToken } from '../ci-oidc-token.js';
2
- import { accountsBaseUrl, apiBaseUrl, clientId as resolveClientId, isCimdClientId, isCimdPlatformSupported, } from '../env.js';
2
+ import { generateEphemeralDpopKey } from '../dpop.js';
3
+ import { accountsBaseUrl, apiBaseUrl, CI_FEDERATION_SESSION_CLIENT_ID, clientId as resolveClientId, isCimdClientId, isCimdPlatformSupported, isValidTenantId, platformTenantId, } from '../env.js';
3
4
  import { CliError, EXIT } from '../errors.js';
4
- import { bindInstallKey, getOrCreateInstallKey, signClientAssertion } from '../install-key.js';
5
+ import { bindInstallKey, getOrCreateInstallKey, peekInstallKey, persistInstallKey, signClientAssertion, } from '../install-key.js';
5
6
  import { buildAuthorizeUrl, exchangeAuthorizationCode } from '../oauth-authcode.js';
6
7
  import { loginWithDeviceGrant } from '../oauth-device.js';
7
8
  import { exchangeOidcSubjectToken } from '../oidc-federation-login.js';
@@ -14,6 +15,25 @@ function cimdUnsupportedMessage(cid) {
14
15
  'the dashboard, then run `rakomi login --client <your-client-id>` (or set RAKOMI_CLIENT_ID) ' +
15
16
  'instead.');
16
17
  }
18
+ function platformTenantMissingMessage() {
19
+ return ('rakomi login needs to know which tenant to authenticate your account against. Set ' +
20
+ 'RAKOMI_PLATFORM_TENANT_ID to a real tenant UUID, or pass --tenant-id <uuid>. If neither is ' +
21
+ 'set, this Rakomi environment likely does not have a platform tenant provisioned yet — ask ' +
22
+ 'your operator.');
23
+ }
24
+ function explicitTenantIdMalformedMessage(value) {
25
+ return (`--tenant-id "${value}" is not a valid tenant id — expected a well-formed UUID (not the nil ` +
26
+ 'or max UUID). Pass the real tenant id, or omit --tenant-id to fall back to RAKOMI_PLATFORM_TENANT_ID.');
27
+ }
28
+ function resolvePlatformTenantId(env, explicit) {
29
+ if (explicit !== undefined) {
30
+ if (!isValidTenantId(explicit)) {
31
+ throw new CliError(explicitTenantIdMalformedMessage(explicit), EXIT.FAIL);
32
+ }
33
+ return explicit;
34
+ }
35
+ return platformTenantId(env);
36
+ }
17
37
  export async function runLogin(deps) {
18
38
  const apiUrl = apiBaseUrl(deps.env);
19
39
  const cid = resolveClientId(deps.env);
@@ -29,9 +49,16 @@ export async function runLogin(deps) {
29
49
  }
30
50
  }
31
51
  const installKey = isCimdClientId(cid) ? getOrCreateInstallKey(deps.keys, cid) : undefined;
52
+ let tenantId;
53
+ if (!deps.noBrowser && isCimdClientId(cid)) {
54
+ tenantId = resolvePlatformTenantId(deps.env, deps.explicitTenantId);
55
+ if (!tenantId) {
56
+ throw new CliError(platformTenantMissingMessage(), EXIT.FAIL);
57
+ }
58
+ }
32
59
  const token = deps.noBrowser
33
60
  ? await runDeviceGrantLogin(deps, apiUrl, cid)
34
- : await runLoopbackLogin(deps, apiUrl, cid, installKey);
61
+ : await runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId);
35
62
  const session = {
36
63
  access_token: token.access_token,
37
64
  refresh_token: token.refresh_token,
@@ -39,6 +66,7 @@ export async function runLogin(deps) {
39
66
  expires_at: deps.now() + token.expires_in * 1000,
40
67
  api_base_url: apiUrl,
41
68
  client_id: cid,
69
+ ...(tenantId ? { home_tenant_id: tenantId } : {}),
42
70
  };
43
71
  if (token.token_type !== 'Bearer' && token.token_type !== 'DPoP') {
44
72
  deps.stdout.write(`(debug) unexpected token_type "${token.token_type}" from the server — treating this session as Bearer.\n`);
@@ -49,7 +77,7 @@ export async function runLogin(deps) {
49
77
  await bindInstallKey(deps, deps.keys, { apiBaseUrl: apiUrl, clientId: cid, accessToken: token.access_token, dpopBound: session.token_type === 'DPoP' }, installKey);
50
78
  }
51
79
  }
52
- async function runLoopbackLogin(deps, apiUrl, cid, installKey) {
80
+ async function runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId) {
53
81
  const listener = await deps.startLoopback();
54
82
  try {
55
83
  const pkce = generatePkcePair();
@@ -59,6 +87,7 @@ async function runLoopbackLogin(deps, apiUrl, cid, installKey) {
59
87
  redirectUri: listener.redirectUri,
60
88
  pkce,
61
89
  scope: LOGIN_SCOPE,
90
+ tenantId,
62
91
  });
63
92
  deps.stdout.write(`Opening your browser to sign in:\n ${authorizeUrl}\n`);
64
93
  await deps.openBrowser(authorizeUrl);
@@ -94,7 +123,6 @@ async function runDeviceGrantLogin(deps, apiUrl, cid) {
94
123
  },
95
124
  }, { apiBaseUrl: apiUrl, clientId: cid, scope: LOGIN_SCOPE });
96
125
  }
97
- const CI_FEDERATION_SESSION_CLIENT_ID = 'oidc-federation';
98
126
  export async function runLoginCi(deps) {
99
127
  const apiUrl = apiBaseUrl(deps.env);
100
128
  const subjectToken = await resolveCiOidcToken(deps, {
@@ -103,7 +131,11 @@ export async function runLoginCi(deps) {
103
131
  readTextFile: deps.readTextFile,
104
132
  audience: apiUrl,
105
133
  });
106
- const token = await exchangeOidcSubjectToken(deps, { apiBaseUrl: apiUrl, subjectToken, resource: apiUrl });
134
+ const existingKey = peekInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
135
+ const dpopKey = existingKey ?? { clientId: CI_FEDERATION_SESSION_CLIENT_ID, ...generateEphemeralDpopKey(), confirmedBound: false };
136
+ const token = await exchangeOidcSubjectToken(deps, { apiBaseUrl: apiUrl, subjectToken, resource: apiUrl, dpopKey });
137
+ if (!existingKey)
138
+ persistInstallKey(deps.keys, dpopKey);
107
139
  const session = {
108
140
  access_token: token.access_token,
109
141
  token_type: token.token_type,
@@ -0,0 +1,45 @@
1
+ import { resolveCiOidcToken } from '../ci-oidc-token.js';
2
+ import { generateEphemeralDpopKey } from '../dpop.js';
3
+ import { apiBaseUrl, CI_FEDERATION_SESSION_CLIENT_ID, DEFAULT_MCP_URL } from '../env.js';
4
+ import { getOrCreateInstallKey, peekInstallKey, persistInstallKey } from '../install-key.js';
5
+ import { listMcpToolsCi, MCP_RESOURCE } from '../mcp-tools-ci.js';
6
+ import { exchangeOidcSubjectToken } from '../oidc-federation-login.js';
7
+ function hintLine(t) {
8
+ const hints = [];
9
+ if (t.annotations?.readOnlyHint)
10
+ hints.push('read-only');
11
+ if (t.annotations?.idempotentHint)
12
+ hints.push('idempotent');
13
+ return hints.length > 0 ? ` (${hints.join(', ')})` : '';
14
+ }
15
+ export async function runMcpToolsCi(deps) {
16
+ const apiUrl = apiBaseUrl(deps.env);
17
+ const mcpUrl = deps.env.RAKOMI_API_URL ? `${apiUrl}/mcp` : DEFAULT_MCP_URL;
18
+ const subjectToken = await resolveCiOidcToken(deps, {
19
+ env: deps.ciEnv,
20
+ oidcTokenFile: deps.oidcTokenFile,
21
+ readTextFile: deps.readTextFile,
22
+ audience: MCP_RESOURCE,
23
+ });
24
+ const existingKey = peekInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
25
+ const dpopKey = existingKey ?? { clientId: CI_FEDERATION_SESSION_CLIENT_ID, ...generateEphemeralDpopKey(), confirmedBound: false };
26
+ const token = await exchangeOidcSubjectToken(deps, {
27
+ apiBaseUrl: apiUrl,
28
+ subjectToken,
29
+ resource: MCP_RESOURCE,
30
+ dpopKey,
31
+ });
32
+ if (!existingKey)
33
+ persistInstallKey(deps.keys, dpopKey);
34
+ const resolvedKey = getOrCreateInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
35
+ const tools = await listMcpToolsCi(deps, { mcpUrl, accessToken: token.access_token, dpopKey: resolvedKey });
36
+ if (deps.json) {
37
+ deps.stdout.write(JSON.stringify({ tools }) + '\n');
38
+ return;
39
+ }
40
+ if (tools.length === 0) {
41
+ deps.stdout.write('No MCP tools visible to this CI identity.\n');
42
+ return;
43
+ }
44
+ deps.stdout.write(tools.map((t) => `${t.name}${hintLine(t)} — ${t.description}`).join('\n') + '\n');
45
+ }
@@ -0,0 +1,10 @@
1
+ import { isValidTenantId } from '../env.js';
2
+ import { UsageError } from '../errors.js';
3
+ export function runUse(deps, args) {
4
+ if (!isValidTenantId(args.tenantId)) {
5
+ throw new UsageError(`"${args.tenantId}" is not a valid tenant id — expected a UUID (e.g. the value \`rakomi login\` ` +
6
+ 'printed as "Home tenant", or the id shown in the dashboard).');
7
+ }
8
+ deps.tenantConfig.write(args.tenantId);
9
+ deps.stdout.write(`Active tenant set to ${args.tenantId}.\n`);
10
+ }
@@ -7,6 +7,7 @@ export async function runWhoami(deps) {
7
7
  throw new NotLoggedInError();
8
8
  const dpop = resolveDpopKey(deps.keys, session);
9
9
  const info = await fetchUserInfo(deps, { apiBaseUrl: session.api_base_url, accessToken: session.access_token, dpop });
10
+ const activeTenantId = deps.explicitTenant ?? deps.tenantConfig.read() ?? undefined;
10
11
  if (deps.json) {
11
12
  deps.stdout.write(JSON.stringify({
12
13
  sub: info.sub,
@@ -15,6 +16,8 @@ export async function runWhoami(deps) {
15
16
  org_role: info.org_role,
16
17
  session_store: deps.session.describePath(),
17
18
  token_type: session.token_type,
19
+ home_tenant_id: session.home_tenant_id,
20
+ active_tenant_id: activeTenantId,
18
21
  }) + '\n');
19
22
  return;
20
23
  }
@@ -22,6 +25,8 @@ export async function runWhoami(deps) {
22
25
  if (info.org_id)
23
26
  lines.push(`Organization: ${info.org_id}${info.org_role ? ` (${info.org_role})` : ''}`);
24
27
  lines.push(`API: ${session.api_base_url}`);
28
+ lines.push(`Home tenant: ${session.home_tenant_id ?? 'unknown (this session did not authenticate via the CIMD-default flow)'}`);
29
+ lines.push(`Active tenant: ${activeTenantId ?? 'none set (run `rakomi use <tenant-id>`)'}`);
25
30
  lines.push(`Session stored in: ${deps.session.describePath()}`);
26
31
  lines.push(`Token type: ${session.token_type}`);
27
32
  deps.stdout.write(lines.join('\n') + '\n');
package/dist/dpop.js CHANGED
@@ -6,7 +6,7 @@ export function generateEphemeralDpopKey() {
6
6
  return { privateKeyPem, publicJwk };
7
7
  }
8
8
  function base64url(buf) {
9
- return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
9
+ return buf.toString('base64url');
10
10
  }
11
11
  export function canonicalizeHtu(input) {
12
12
  const url = new URL(input);
package/dist/env.js CHANGED
@@ -3,6 +3,7 @@ export const DEFAULT_API_BASE_URL = 'https://api.rakomi.com';
3
3
  export const DEFAULT_ACCOUNTS_BASE_URL = 'https://accounts.rakomi.com';
4
4
  export const DEFAULT_MCP_URL = 'https://mcp.rakomi.com/mcp';
5
5
  export const DEFAULT_CIMD_CLIENT_ID_URL = 'https://api.rakomi.com/.well-known/rakomi-cli/client-metadata.json';
6
+ export const CI_FEDERATION_SESSION_CLIENT_ID = 'oidc-federation';
6
7
  export function isCimdClientId(id) {
7
8
  let url;
8
9
  try {
@@ -22,6 +23,21 @@ export function accountsBaseUrl(env) {
22
23
  export function clientId(env) {
23
24
  return env.RAKOMI_CLIENT_ID || DEFAULT_CIMD_CLIENT_ID_URL;
24
25
  }
26
+ const TENANT_ID_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
27
+ const NIL_TENANT_ID = '00000000-0000-0000-0000-000000000000';
28
+ const MAX_TENANT_ID = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
29
+ export function isValidTenantId(id) {
30
+ if (id.length !== 36)
31
+ return false;
32
+ if (!TENANT_ID_UUID_PATTERN.test(id))
33
+ return false;
34
+ const lower = id.toLowerCase();
35
+ return lower !== NIL_TENANT_ID && lower !== MAX_TENANT_ID;
36
+ }
37
+ export function platformTenantId(env) {
38
+ const raw = env.RAKOMI_PLATFORM_TENANT_ID;
39
+ return raw && isValidTenantId(raw) ? raw : undefined;
40
+ }
25
41
  const CLIENT_ID_METADATA_DOCUMENT_SUPPORTED_FIELD = 'client_id_metadata_document_supported';
26
42
  export async function isCimdPlatformSupported(deps, apiUrl) {
27
43
  try {
package/dist/index.js CHANGED
@@ -8,12 +8,15 @@ import { systemBrowserOpener } from './browser.js';
8
8
  import { runConnect } from './commands/connect.js';
9
9
  import { runLogin, runLoginCi } from './commands/login.js';
10
10
  import { runLogout } from './commands/logout.js';
11
+ import { runMcpToolsCi } from './commands/mcp.js';
11
12
  import { runTenantsCreate, runTenantsList } from './commands/tenants.js';
13
+ import { runUse } from './commands/use.js';
12
14
  import { runWhoami } from './commands/whoami.js';
13
15
  import { apiBaseUrl, DEFAULT_MCP_URL } from './env.js';
14
16
  import { CliError, EXIT, UsageError } from './errors.js';
15
17
  import { startLoopbackListener } from './loopback-server.js';
16
18
  import { resolveStores } from './session.js';
19
+ import { FileTenantConfigStore } from './tenant-config.js';
17
20
  import { helpText, usageLine } from './usage.js';
18
21
  const GLOBAL_OPTIONS = {
19
22
  json: { type: 'boolean' },
@@ -28,6 +31,8 @@ const GLOBAL_OPTIONS = {
28
31
  write: { type: 'boolean' },
29
32
  owner: { type: 'string' },
30
33
  slug: { type: 'string' },
34
+ 'tenant-id': { type: 'string' },
35
+ tenant: { type: 'string' },
31
36
  'cimd-url': { type: 'string' },
32
37
  status: { type: 'boolean' },
33
38
  help: { type: 'boolean', short: 'h' },
@@ -82,6 +87,7 @@ async function dispatch(args, deps) {
82
87
  env: deps.env,
83
88
  ciEnv: deps.ciEnv ?? {},
84
89
  session: deps.session,
90
+ keys: deps.keys,
85
91
  oidcTokenFile: typeof values['oidc-token-file'] === 'string' ? values['oidc-token-file'] : undefined,
86
92
  readTextFile: deps.readTextFile ?? ((p) => readFileSync(p, 'utf8')),
87
93
  stdout: deps.stdout,
@@ -100,6 +106,7 @@ async function dispatch(args, deps) {
100
106
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
101
107
  stdout: deps.stdout,
102
108
  now: () => Date.now(),
109
+ explicitTenantId: typeof values['tenant-id'] === 'string' ? values['tenant-id'] : undefined,
103
110
  });
104
111
  return EXIT.OK;
105
112
  }
@@ -107,8 +114,23 @@ async function dispatch(args, deps) {
107
114
  runLogout({ session: deps.session, stdout: deps.stdout });
108
115
  return EXIT.OK;
109
116
  case 'whoami':
110
- await runWhoami({ ...httpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
117
+ await runWhoami({
118
+ ...httpDeps,
119
+ session: deps.session,
120
+ keys: deps.keys,
121
+ json,
122
+ stdout: deps.stdout,
123
+ tenantConfig: deps.tenantConfig,
124
+ explicitTenant: typeof values.tenant === 'string' ? values.tenant : undefined,
125
+ });
126
+ return EXIT.OK;
127
+ case 'use': {
128
+ const tenantId = rest[0];
129
+ if (!tenantId)
130
+ throw new UsageError('Usage: rakomi use <tenant-id>');
131
+ runUse({ tenantConfig: deps.tenantConfig, stdout: deps.stdout }, { tenantId });
111
132
  return EXIT.OK;
133
+ }
112
134
  case 'connect':
113
135
  await runConnect({
114
136
  ...httpDeps,
@@ -128,6 +150,26 @@ async function dispatch(args, deps) {
128
150
  status: values.status === true,
129
151
  });
130
152
  return EXIT.OK;
153
+ case 'mcp': {
154
+ const [sub] = rest;
155
+ if (sub !== 'tools') {
156
+ throw new UsageError('Usage: rakomi mcp tools --ci');
157
+ }
158
+ if (values.ci !== true) {
159
+ throw new UsageError('rakomi mcp tools requires --ci (workload-identity federation; no interactive form exists yet).');
160
+ }
161
+ await runMcpToolsCi({
162
+ ...httpDeps,
163
+ env: deps.env,
164
+ ciEnv: deps.ciEnv ?? {},
165
+ keys: deps.keys,
166
+ oidcTokenFile: typeof values['oidc-token-file'] === 'string' ? values['oidc-token-file'] : undefined,
167
+ readTextFile: deps.readTextFile ?? ((p) => readFileSync(p, 'utf8')),
168
+ json,
169
+ stdout: deps.stdout,
170
+ });
171
+ return EXIT.OK;
172
+ }
131
173
  case 'tenants': {
132
174
  const [sub, ...tenantArgs] = rest;
133
175
  if (sub === 'create') {
@@ -170,6 +212,7 @@ async function main() {
170
212
  RAKOMI_ACCOUNTS_URL: process.env.RAKOMI_ACCOUNTS_URL,
171
213
  RAKOMI_CLIENT_ID: process.env.RAKOMI_CLIENT_ID,
172
214
  RAKOMI_CONFIG_DIR: process.env.RAKOMI_CONFIG_DIR,
215
+ RAKOMI_PLATFORM_TENANT_ID: process.env.RAKOMI_PLATFORM_TENANT_ID,
173
216
  CI: process.env.CI,
174
217
  NO_COLOR: process.env.NO_COLOR,
175
218
  };
@@ -194,6 +237,7 @@ async function main() {
194
237
  fetchImpl: fetch,
195
238
  session: resolved.session,
196
239
  keys: resolved.keys,
240
+ tenantConfig: new FileTenantConfigStore(env),
197
241
  detectClaudeCode: () => detectClaudeCodeCli(),
198
242
  ciEnv,
199
243
  });
@@ -1,5 +1,5 @@
1
1
  import { createPrivateKey, generateKeyPairSync, randomUUID, sign as cryptoSign } from 'node:crypto';
2
- import { isCimdClientId } from './env.js';
2
+ import { CI_FEDERATION_SESSION_CLIENT_ID, isCimdClientId } from './env.js';
3
3
  import { request } from './http.js';
4
4
  const INSTALL_KEY_NAME = 'install-key';
5
5
  const ASSERTION_LIFETIME_SEC = 60;
@@ -19,23 +19,32 @@ function generateKeyPair(clientId) {
19
19
  const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
20
20
  return { clientId, privateKeyPem, publicJwk, confirmedBound: false };
21
21
  }
22
- export function getOrCreateInstallKey(keys, clientId) {
22
+ export function peekInstallKey(keys, clientId) {
23
23
  const raw = keys.get(INSTALL_KEY_NAME);
24
- if (raw !== null) {
25
- try {
26
- const parsed = JSON.parse(raw);
27
- if (isStoredInstallKey(parsed) && parsed.clientId === clientId)
28
- return parsed;
29
- }
30
- catch {
31
- }
24
+ if (raw === null)
25
+ return undefined;
26
+ try {
27
+ const parsed = JSON.parse(raw);
28
+ if (isStoredInstallKey(parsed) && parsed.clientId === clientId)
29
+ return parsed;
32
30
  }
31
+ catch {
32
+ }
33
+ return undefined;
34
+ }
35
+ export function persistInstallKey(keys, key) {
36
+ keys.set(INSTALL_KEY_NAME, JSON.stringify(key));
37
+ }
38
+ export function getOrCreateInstallKey(keys, clientId) {
39
+ const existing = peekInstallKey(keys, clientId);
40
+ if (existing)
41
+ return existing;
33
42
  const fresh = generateKeyPair(clientId);
34
- keys.set(INSTALL_KEY_NAME, JSON.stringify(fresh));
43
+ persistInstallKey(keys, fresh);
35
44
  return fresh;
36
45
  }
37
46
  function base64url(buf) {
38
- return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
47
+ return buf.toString('base64url');
39
48
  }
40
49
  export function signClientAssertion(key, opts) {
41
50
  const privateKey = createPrivateKey(key.privateKeyPem);
@@ -79,7 +88,7 @@ export async function bindInstallKey(deps, keys, opts, key) {
79
88
  export function resolveDpopKey(keys, session) {
80
89
  if (session.token_type !== 'DPoP')
81
90
  return undefined;
82
- if (!isCimdClientId(session.client_id))
91
+ if (!isCimdClientId(session.client_id) && session.client_id !== CI_FEDERATION_SESSION_CLIENT_ID)
83
92
  return undefined;
84
93
  return getOrCreateInstallKey(keys, session.client_id);
85
94
  }
@@ -0,0 +1,44 @@
1
+ import { CliError, EXIT } from './errors.js';
2
+ import { describeError, request } from './http.js';
3
+ export const MCP_RESOURCE = 'https://mcp.rakomi.com';
4
+ const MCP_PROTOCOL_VERSION = '2026-07-28';
5
+ const MCP_PROTOCOL_VERSION_HEADER = 'MCP-Protocol-Version';
6
+ const MCP_METHOD_HEADER = 'Mcp-Method';
7
+ const MCP_META_PROTOCOL_VERSION_KEY = 'io.modelcontextprotocol/protocolVersion';
8
+ const MCP_META_CLIENT_CAPABILITIES_KEY = 'io.modelcontextprotocol/clientCapabilities';
9
+ function isJsonRpcError(body) {
10
+ return typeof body === 'object' && body !== null && 'error' in body;
11
+ }
12
+ export async function listMcpToolsCi(deps, opts) {
13
+ const message = {
14
+ jsonrpc: '2.0',
15
+ id: 1,
16
+ method: 'tools/list',
17
+ params: {
18
+ _meta: {
19
+ [MCP_META_PROTOCOL_VERSION_KEY]: MCP_PROTOCOL_VERSION,
20
+ [MCP_META_CLIENT_CAPABILITIES_KEY]: {},
21
+ },
22
+ },
23
+ };
24
+ const result = await request(deps, {
25
+ method: 'POST',
26
+ url: opts.mcpUrl,
27
+ headers: {
28
+ [MCP_PROTOCOL_VERSION_HEADER]: MCP_PROTOCOL_VERSION,
29
+ [MCP_METHOD_HEADER]: 'tools/list',
30
+ },
31
+ body: message,
32
+ dpop: { key: opts.dpopKey, accessToken: opts.accessToken },
33
+ });
34
+ if (result.status === 401) {
35
+ throw new CliError(`Could not list MCP tools: ${describeError(result.body, result.status)}`, EXIT.FAIL);
36
+ }
37
+ if (isJsonRpcError(result.body)) {
38
+ throw new CliError(`Could not list MCP tools: ${result.body.error.message}`, EXIT.FAIL);
39
+ }
40
+ if (result.status !== 200 || typeof result.body !== 'object' || result.body === null || !('result' in result.body)) {
41
+ throw new CliError(`Could not list MCP tools: ${describeError(result.body, result.status)}`, EXIT.FAIL);
42
+ }
43
+ return result.body.result.tools;
44
+ }
@@ -9,6 +9,8 @@ export function buildAuthorizeUrl(opts) {
9
9
  url.searchParams.set('state', opts.pkce.state);
10
10
  url.searchParams.set('code_challenge', opts.pkce.codeChallenge);
11
11
  url.searchParams.set('code_challenge_method', opts.pkce.codeChallengeMethod);
12
+ if (opts.tenantId)
13
+ url.searchParams.set('tenant_id', opts.tenantId);
12
14
  return url.toString();
13
15
  }
14
16
  export async function exchangeAuthorizationCode(deps, opts) {
@@ -1,11 +1,10 @@
1
- import { buildDpopProof, canonicalizeHtu, generateEphemeralDpopKey } from './dpop.js';
1
+ import { buildDpopProof, canonicalizeHtu } from './dpop.js';
2
2
  import { CliError, EXIT } from './errors.js';
3
3
  import { describeError, request } from './http.js';
4
4
  export const OIDC_FEDERATION_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token';
5
5
  export async function exchangeOidcSubjectToken(deps, opts) {
6
6
  const tokenUrl = `${opts.apiBaseUrl}/oauth/token`;
7
- const dpopKey = generateEphemeralDpopKey();
8
- const proof = buildDpopProof(dpopKey, { htm: 'POST', htu: canonicalizeHtu(tokenUrl) });
7
+ const proof = buildDpopProof(opts.dpopKey, { htm: 'POST', htu: canonicalizeHtu(tokenUrl) });
9
8
  const result = await request(deps, {
10
9
  method: 'POST',
11
10
  url: tokenUrl,
package/dist/pkce.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  function base64url(buf) {
3
- return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
3
+ return buf.toString('base64url');
4
4
  }
5
5
  export function generateCodeVerifier() {
6
6
  return base64url(randomBytes(32));
package/dist/session.js CHANGED
@@ -98,7 +98,8 @@ function isStoredSession(value) {
98
98
  (v.token_type === 'Bearer' || v.token_type === 'DPoP') &&
99
99
  typeof v.expires_at === 'number' &&
100
100
  typeof v.api_base_url === 'string' &&
101
- typeof v.client_id === 'string');
101
+ typeof v.client_id === 'string' &&
102
+ (v.home_tenant_id === undefined || typeof v.home_tenant_id === 'string'));
102
103
  }
103
104
  export function isExpired(session, now) {
104
105
  return now >= session.expires_at;
@@ -0,0 +1,50 @@
1
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { configDir } from './session.js';
4
+ function isStoredTenantConfig(value) {
5
+ if (typeof value !== 'object' || value === null)
6
+ return false;
7
+ const v = value;
8
+ return typeof v.active_tenant_id === 'string' && v.active_tenant_id.length > 0;
9
+ }
10
+ function tenantConfigFilePath(env) {
11
+ return join(configDir(env), 'tenant.json');
12
+ }
13
+ export class FileTenantConfigStore {
14
+ path;
15
+ dir;
16
+ constructor(env) {
17
+ this.dir = configDir(env);
18
+ this.path = tenantConfigFilePath(env);
19
+ }
20
+ read() {
21
+ try {
22
+ const raw = readFileSync(this.path, 'utf8');
23
+ const parsed = JSON.parse(raw);
24
+ return isStoredTenantConfig(parsed) ? parsed.active_tenant_id : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ write(tenantId) {
31
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
32
+ const config = { active_tenant_id: tenantId };
33
+ writeFileSync(this.path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
34
+ try {
35
+ chmodSync(this.path, 0o600);
36
+ }
37
+ catch {
38
+ }
39
+ }
40
+ clear() {
41
+ try {
42
+ rmSync(this.path, { force: true });
43
+ }
44
+ catch {
45
+ }
46
+ }
47
+ describePath() {
48
+ return this.path;
49
+ }
50
+ }
package/dist/usage.js CHANGED
@@ -9,8 +9,10 @@ export function helpText() {
9
9
  ' login sign in (browser by default; --no-browser for a device code)',
10
10
  ' --ci sign in from CI via the runner\'s own OIDC identity (no browser/device code)',
11
11
  ' --oidc-token-file <p> read the OIDC token from a file instead of the CI platform\'s own env',
12
+ ' --tenant-id <uuid> the tenant to authenticate against (or set RAKOMI_PLATFORM_TENANT_ID)',
12
13
  ' logout clear the local session',
13
- ' whoami show the signed-in account',
14
+ ' whoami show the signed-in account, home tenant, and active tenant',
15
+ ' use <tenant-id> remember a tenant id locally for whoami/future commands (a value, not a verified membership)',
14
16
  ' connect connect Claude Code / Claude Desktop to your tenant (read access)',
15
17
  ' --write also request write access for the connected client',
16
18
  ' --client <name> claude-code | claude-desktop (required if more than one applies)',
@@ -21,6 +23,7 @@ export function helpText() {
21
23
  ' --owner <me|email> who becomes the new tenant\'s owner (default: me)',
22
24
  ' --slug <slug> optional; auto-derived + suffixed if omitted',
23
25
  ' tenants list list tenants you provisioned',
26
+ ' mcp tools --ci list MCP read tools your CI identity can invoke (no login needed first)',
24
27
  '',
25
28
  'Global options:',
26
29
  ' --json machine-readable output',
@@ -28,6 +31,7 @@ export function helpText() {
28
31
  ' --dry-run print what would happen, make no writes or mutating calls',
29
32
  ' --no-browser use the RFC 8628 device-code flow instead of opening a browser',
30
33
  ' --no-keychain store the session in a 0600 file instead of the OS keychain',
34
+ ' --tenant <id> override the active tenant for this invocation (whoami only today)',
31
35
  ' -h, --help show this help and exit',
32
36
  ' -V, --version print the version and exit',
33
37
  '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rakomi",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "The Rakomi CLI — sign in, connect an AI agent (Claude Code, Claude Desktop) to your tenant, and provision tenants. EU-native auth-as-a-service.",
5
5
  "keywords": [
6
6
  "rakomi",
package/sbom.cdx.json CHANGED
@@ -26,10 +26,10 @@
26
26
  },
27
27
  "component": {
28
28
  "type": "library",
29
- "bom-ref": "pkg:npm/rakomi@0.1.2",
29
+ "bom-ref": "pkg:npm/rakomi@0.2.0",
30
30
  "name": "rakomi",
31
- "version": "0.1.2",
32
- "purl": "pkg:npm/rakomi@0.1.2",
31
+ "version": "0.2.0",
32
+ "purl": "pkg:npm/rakomi@0.2.0",
33
33
  "licenses": [
34
34
  {
35
35
  "license": {