@amerged/ohmyhost-mcp 0.1.19 → 0.1.20

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.
@@ -1,37 +1,166 @@
1
- import { appendFile, chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
1
+ import {
2
+ appendFile,
3
+ chmod,
4
+ mkdir,
5
+ readFile,
6
+ readdir,
7
+ rename,
8
+ stat,
9
+ unlink,
10
+ writeFile,
11
+ } from "node:fs/promises";
2
12
  import { join } from "node:path";
3
13
 
14
+ /**
15
+ * Which project this working copy deploys to for one organization. The same checkout may be linked
16
+ * once per organization, so a second account never overwrites or inherits the first one's link.
17
+ */
4
18
  export interface ProjectLinkMetadata {
5
- readonly version: 1;
19
+ readonly version: 2;
6
20
  readonly api_origin: string;
21
+ readonly organization_id: string;
7
22
  readonly project_id: string;
8
23
  readonly installation_id: string;
9
24
  readonly repository_full_name: string;
10
- readonly commit_sha?: string;
25
+ }
26
+
27
+ /** A link written before links named their organization; it is verified before any use. */
28
+ export interface LegacyProjectLink {
29
+ readonly project_id: string;
30
+ readonly installation_id: string;
31
+ readonly repository_full_name: string;
32
+ }
33
+
34
+ export interface DirectoryLinks {
35
+ readonly links: readonly ProjectLinkMetadata[];
36
+ readonly legacy: LegacyProjectLink | null;
11
37
  }
12
38
 
13
39
  export interface ProjectLinkStore {
14
40
  save(metadata: ProjectLinkMetadata): Promise<void>;
15
41
  }
16
42
 
17
- /** The project this directory was linked to for this API, or null when there is no usable link. */
18
- export async function readLinkedProjectId(
43
+ const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u;
44
+ const LINKS = "links";
45
+ const LEGACY_LINK = "link.json";
46
+
47
+ /** Every usable link of this directory for this API; unreadable files are not links. */
48
+ export async function readProjectLinks(
19
49
  directory: string,
20
50
  apiOrigin: string,
21
- ): Promise<string | null> {
51
+ ): Promise<DirectoryLinks> {
52
+ let names: string[] = [];
22
53
  try {
23
- const link = JSON.parse(await readFile(join(directory, "link.json"), "utf8")) as unknown;
24
- if (typeof link !== "object" || link === null) return null;
25
- const { version, api_origin, project_id } = link as Record<string, unknown>;
26
- return version === 1 &&
27
- api_origin === apiOrigin &&
28
- typeof project_id === "string" &&
29
- /^[0-9A-HJKMNP-TV-Z]{26}$/u.test(project_id)
30
- ? project_id
31
- : null;
54
+ names = await readdir(join(directory, LINKS));
32
55
  } catch {
33
- return null;
56
+ names = [];
57
+ }
58
+ const links: ProjectLinkMetadata[] = [];
59
+ for (const name of names.sort()) {
60
+ const organizationId = /^([0-7][0-9A-HJKMNP-TV-Z]{25})\.json$/u.exec(name)?.[1];
61
+ if (organizationId === undefined) continue;
62
+ const link = await readJson(join(directory, LINKS, name));
63
+ if (
64
+ isRecord(link, [
65
+ "version",
66
+ "api_origin",
67
+ "organization_id",
68
+ "project_id",
69
+ "installation_id",
70
+ "repository_full_name",
71
+ ]) &&
72
+ link["version"] === 2 &&
73
+ link["api_origin"] === apiOrigin &&
74
+ link["organization_id"] === organizationId &&
75
+ typeof link["project_id"] === "string" &&
76
+ ULID.test(link["project_id"]) &&
77
+ typeof link["installation_id"] === "string" &&
78
+ typeof link["repository_full_name"] === "string"
79
+ )
80
+ links.push(link as unknown as ProjectLinkMetadata);
34
81
  }
82
+ const legacy = await readJson(join(directory, LEGACY_LINK));
83
+ return {
84
+ links,
85
+ legacy:
86
+ typeof legacy === "object" &&
87
+ legacy !== null &&
88
+ legacy["version"] === 1 &&
89
+ legacy["api_origin"] === apiOrigin &&
90
+ typeof legacy["project_id"] === "string" &&
91
+ ULID.test(legacy["project_id"]) &&
92
+ typeof legacy["installation_id"] === "string" &&
93
+ typeof legacy["repository_full_name"] === "string"
94
+ ? {
95
+ project_id: legacy["project_id"],
96
+ installation_id: legacy["installation_id"],
97
+ repository_full_name: legacy["repository_full_name"],
98
+ }
99
+ : null,
100
+ };
101
+ }
102
+
103
+ export type LinkedProject =
104
+ | {
105
+ readonly outcome: "linked";
106
+ readonly projectId: string;
107
+ /** Null for a legacy link, whose organization is still to be verified. */
108
+ readonly organizationId: string | null;
109
+ }
110
+ | { readonly outcome: "none" }
111
+ | { readonly outcome: "ambiguous"; readonly organizationIds: readonly string[] };
112
+
113
+ /**
114
+ * The project a command in this directory means when it names none. With a known organization only
115
+ * that organization's link counts; otherwise exactly one link must exist.
116
+ */
117
+ export function chooseLinkedProject(
118
+ directory: DirectoryLinks,
119
+ organizationId: string | undefined,
120
+ ): LinkedProject {
121
+ if (organizationId !== undefined) {
122
+ const link = directory.links.find((candidate) => candidate.organization_id === organizationId);
123
+ if (link !== undefined)
124
+ return { outcome: "linked", projectId: link.project_id, organizationId };
125
+ return directory.legacy === null
126
+ ? { outcome: "none" }
127
+ : { outcome: "linked", projectId: directory.legacy.project_id, organizationId: null };
128
+ }
129
+ const candidates = [
130
+ ...directory.links.map((link) => ({
131
+ projectId: link.project_id,
132
+ organizationId: link.organization_id as string | null,
133
+ })),
134
+ ...(directory.legacy === null
135
+ ? []
136
+ : [{ projectId: directory.legacy.project_id, organizationId: null }]),
137
+ ];
138
+ const [only] = candidates;
139
+ if (candidates.length === 1 && only !== undefined) return { outcome: "linked", ...only };
140
+ if (candidates.length === 0) return { outcome: "none" };
141
+ return {
142
+ outcome: "ambiguous",
143
+ organizationIds: directory.links.map((link) => link.organization_id),
144
+ };
145
+ }
146
+
147
+ /** The organization a directory link records for this project, if one does. */
148
+ export function linkedOrganization(
149
+ directory: DirectoryLinks,
150
+ projectId: string,
151
+ ): string | undefined {
152
+ const organizations = new Set(
153
+ directory.links
154
+ .filter((link) => link.project_id === projectId)
155
+ .map((link) => link.organization_id),
156
+ );
157
+ const [organizationId] = organizations;
158
+ return organizations.size === 1 ? organizationId : undefined;
159
+ }
160
+
161
+ /** Drops the pre-organization link once its replacement is written; it is never guessed at. */
162
+ export async function removeLegacyProjectLink(directory: string): Promise<void> {
163
+ await unlink(join(directory, LEGACY_LINK));
35
164
  }
36
165
 
37
166
  export class OwnerOnlyProjectLinkStore implements ProjectLinkStore {
@@ -42,10 +171,14 @@ export class OwnerOnlyProjectLinkStore implements ProjectLinkStore {
42
171
  ) {}
43
172
 
44
173
  public async save(metadata: ProjectLinkMetadata): Promise<void> {
45
- await mkdir(this.directory, { mode: 0o700, recursive: true });
46
- await chmod(this.directory, 0o700);
47
- const target = join(this.directory, "link.json");
48
- const temporary = join(this.directory, `.link-${process.pid}.tmp`);
174
+ if (!ULID.test(metadata.organization_id)) throw new TypeError("Invalid linked organization");
175
+ const links = join(this.directory, LINKS);
176
+ for (const directory of [this.directory, links]) {
177
+ await mkdir(directory, { mode: 0o700, recursive: true });
178
+ await chmod(directory, 0o700);
179
+ }
180
+ const target = join(links, `${metadata.organization_id}.json`);
181
+ const temporary = join(links, `.link-${process.pid}.tmp`);
49
182
  await writeFile(temporary, `${JSON.stringify(metadata)}\n`, { encoding: "utf8", mode: 0o600 });
50
183
  await chmod(temporary, 0o600);
51
184
  await rename(temporary, target);
@@ -54,6 +187,22 @@ export class OwnerOnlyProjectLinkStore implements ProjectLinkStore {
54
187
  }
55
188
  }
56
189
 
190
+ async function readJson(path: string): Promise<Record<string, unknown> | null> {
191
+ try {
192
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
193
+ return typeof value === "object" && value !== null && !Array.isArray(value)
194
+ ? (value as Record<string, unknown>)
195
+ : null;
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ const isRecord = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
202
+ typeof value === "object" &&
203
+ value !== null &&
204
+ Object.keys(value).sort().join(",") === [...keys].sort().join(",");
205
+
57
206
  async function ignoreLinkDirectory(root: string): Promise<void> {
58
207
  try {
59
208
  await stat(join(root, ".git"));
@@ -34,7 +34,7 @@ export const PROBLEM_POLICY = {
34
34
  status: 409,
35
35
  retryable: false,
36
36
  action:
37
- "Run 'ohmyhost github connect --organization ULID --idempotency-key KEY --json' for this workspace, complete its browser link, then repeat the original source link.",
37
+ "This workspace has no GitHub connection yet. Run 'ohmyhost github connect --organization ULID --idempotency-key KEY --json' for it, complete its browser link, then repeat the original source link. If the project belongs to another account, use that account's login instead; error.acting_as names the login this ran as.",
38
38
  },
39
39
  github_connection_revoked: {
40
40
  status: 409,
@@ -120,7 +120,12 @@ export const PROBLEM_POLICY = {
120
120
  action:
121
121
  "Run 'ohmyhost organization list --json' and select one with 'ohmyhost organization use --organization ULID --json', or create the first workspace.",
122
122
  },
123
- resource_not_found: { status: 404, retryable: false, action: "Check the resource identifier." },
123
+ resource_not_found: {
124
+ status: 404,
125
+ retryable: false,
126
+ action:
127
+ "Check the resource identifier and the account: a project is visible only to its own organization, and error.acting_as names the login this ran as. Use the login of the account that owns it.",
128
+ },
124
129
  idempotency_key_reused: {
125
130
  status: 409,
126
131
  retryable: false,
@@ -889,7 +894,13 @@ export const parseCurrentIdentity = (value: unknown): PublicCurrentIdentity => {
889
894
  return input as unknown as PublicCurrentIdentity;
890
895
  };
891
896
 
892
- export const parseAccountProfile = (value: unknown): readonly PublicWorkspace[] => {
897
+ /** The signed-in user and the workspaces it belongs to, as `GET /v1/me/profile` reports them. */
898
+ export interface PublicAccount {
899
+ readonly userId: string;
900
+ readonly organizations: readonly PublicWorkspace[];
901
+ }
902
+
903
+ export const parseAccountProfile = (value: unknown): PublicAccount => {
893
904
  const input = exactRecord(value, [
894
905
  "user_id",
895
906
  "name",
@@ -927,7 +938,7 @@ export const parseAccountProfile = (value: unknown): readonly PublicWorkspace[]
927
938
  });
928
939
  if (new Set(workspaces.map((workspace) => workspace.id)).size !== organizationIds.length)
929
940
  throw new ResponseContractError();
930
- return workspaces;
941
+ return { userId: input["user_id"] as string, organizations: workspaces };
931
942
  };
932
943
 
933
944
  export interface PublicProject {
@@ -1,11 +1,14 @@
1
1
  import type { TokenRefresher } from "./cli.js";
2
2
  import type { CredentialStore, StoredCredential } from "./credential-store.js";
3
- import type { PublicWorkspace } from "./runtime-contract.js";
3
+ import type { LocalProfile, ProfileRegistry } from "./profile-store.js";
4
+ import { parseCurrentIdentity, type PublicWorkspace } from "./runtime-contract.js";
4
5
 
5
- /** The credential material a workspace selection needs; absent for a user API token. */
6
+ /** The saved login a workspace selection works on; absent for a user API token. */
6
7
  export interface WorkspaceSession {
7
8
  readonly store: CredentialStore;
8
9
  readonly refresher: TokenRefresher;
10
+ readonly profiles: ProfileRegistry;
11
+ readonly profile: LocalProfile;
9
12
  }
10
13
 
11
14
  /** A user API token carries a fixed scope and can never select another workspace. */
@@ -16,6 +19,28 @@ export class InteractiveSessionRequiredError extends Error {
16
19
  }
17
20
  }
18
21
 
22
+ /**
23
+ * A saved login keeps the organization it was signed in to. Another organization is its own login,
24
+ * so switching one login between organizations can never retarget another agent's work.
25
+ */
26
+ export class ProfileOrganizationFixedError extends Error {
27
+ public constructor(
28
+ readonly profile: LocalProfile,
29
+ readonly organization: { readonly id: string },
30
+ ) {
31
+ super("The saved login is bound to another organization");
32
+ this.name = "ProfileOrganizationFixedError";
33
+ }
34
+ }
35
+
36
+ /** The provider answered with an identity other than the one that was requested. */
37
+ export class AccountVerificationError extends Error {
38
+ public constructor() {
39
+ super("The signed-in account does not match the requested one");
40
+ this.name = "AccountVerificationError";
41
+ }
42
+ }
43
+
19
44
  export type WorkspaceSelection =
20
45
  | { readonly outcome: "selected"; readonly organization: PublicWorkspace }
21
46
  | { readonly outcome: "no_workspace" }
@@ -43,15 +68,45 @@ export const chooseWorkspace = (
43
68
  return { outcome: "choice_required", organizations };
44
69
  };
45
70
 
71
+ /** The user a verified identity answer names; only interactive users and their keys have one. */
72
+ export const verifiedUserId = (identity: unknown): string => {
73
+ const actor = parseCurrentIdentity(identity).actor_id;
74
+ if (!actor.startsWith("user:") || actor.length === 5) throw new AccountVerificationError();
75
+ return actor.slice(5);
76
+ };
77
+
46
78
  /**
47
- * Put the chosen workspace into the stored credential. The organization lives in the access token,
48
- * so selecting means asking the provider for a token scoped to it and keeping both identifiers.
79
+ * Check that a token belongs to the expected user and organization before anything is stored.
80
+ * A null organization means the token must not carry one.
81
+ */
82
+ export const assertVerifiedLogin = (
83
+ identity: unknown,
84
+ userId: string,
85
+ organizationId: string | null,
86
+ ): void => {
87
+ const organizations = parseCurrentIdentity(identity).organization_ids;
88
+ if (
89
+ verifiedUserId(identity) !== userId ||
90
+ (organizationId === null
91
+ ? organizations.length !== 0
92
+ : organizations.length !== 1 || organizations[0] !== organizationId)
93
+ )
94
+ throw new AccountVerificationError();
95
+ };
96
+
97
+ /**
98
+ * Bind a saved login that has no organization yet to one of its user's organizations. The provider
99
+ * scopes the session to that organization and the result is verified before it replaces the login.
49
100
  */
50
101
  export const selectWorkspace = async (
51
102
  session: WorkspaceSession,
52
103
  organization: PublicWorkspace,
53
104
  signal: AbortSignal,
54
- ): Promise<void> => {
105
+ identity: (accessToken: string) => Promise<unknown>,
106
+ ): Promise<LocalProfile> => {
107
+ if (session.profile.organizationId === organization.id) return session.profile;
108
+ if (session.profile.organizationId !== null)
109
+ throw new ProfileOrganizationFixedError(session.profile, organization);
55
110
  const stored = await session.store.read();
56
111
  if (stored === undefined) throw new InteractiveSessionRequiredError();
57
112
  const refreshed = await session.refresher.refresh(
@@ -59,17 +114,24 @@ export const selectWorkspace = async (
59
114
  signal,
60
115
  organization.workos_id,
61
116
  );
62
- await session.store.replace({
63
- ...refreshed,
64
- workosOrganizationId: organization.workos_id,
117
+ assertVerifiedLogin(
118
+ await identity(refreshed.accessToken),
119
+ session.profile.userId,
120
+ organization.id,
121
+ );
122
+ const bound: LocalProfile = {
123
+ ...session.profile,
65
124
  organizationId: organization.id,
66
- });
125
+ organizationName: organization.name,
126
+ };
127
+ await session.profiles.save(
128
+ bound,
129
+ { ...refreshed, workosOrganizationId: organization.workos_id, organizationId: organization.id },
130
+ session.profile,
131
+ );
132
+ return bound;
67
133
  };
68
134
 
69
- /** The workspace the stored credential is scoped to, or null while none was selected. */
70
- export const selectedWorkspaceId = async (session: WorkspaceSession): Promise<string | null> =>
71
- (await session.store.read())?.organizationId ?? null;
72
-
73
135
  /**
74
136
  * The workspace fields of a credential, present only once one has been selected. A refresh returns
75
137
  * a bare credential, so carrying these forward is what keeps the selection alive.