@amerged/ohmyhost-mcp 0.1.19 → 0.1.21

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,
@@ -268,6 +273,12 @@ export const PROBLEM_POLICY = {
268
273
  retryable: false,
269
274
  action: "Finish database provisioning, then request the export again. No backup was accepted.",
270
275
  },
276
+ powered_by_flag_required: {
277
+ status: 409,
278
+ retryable: false,
279
+ action:
280
+ "Keep the flag on, remove the custom domain with 'ohmyhost domain paid delete', or buy Paid, then switch the flag off.",
281
+ },
271
282
  rate_limited: { status: 429, retryable: true, action: "Retry after the server delay." },
272
283
  insufficient_organization_credits: {
273
284
  status: 402,
@@ -284,7 +295,7 @@ export const PROBLEM_POLICY = {
284
295
  status: 402,
285
296
  retryable: false,
286
297
  action:
287
- "Ask the organization Owner to activate Paid through billing checkout, then retry. Top-ups and signup credits do not activate Paid.",
298
+ "Ask the organization Owner to activate Paid through billing checkout, then retry. Top-ups and signup credits do not activate Paid. For a website domain alone, a Free workspace may instead show the powered-by flag on the project.",
288
299
  },
289
300
  interactive_login_required: {
290
301
  status: 403,
@@ -735,6 +746,39 @@ export const parseDevAccessState = (value: unknown): PublicDevAccessState => {
735
746
  return input as unknown as PublicDevAccessState;
736
747
  };
737
748
 
749
+ export interface PublicPoweredByFlag {
750
+ readonly project_id: string;
751
+ readonly enabled: boolean;
752
+ readonly enabled_at: string | null;
753
+ }
754
+
755
+ export const parsePoweredByFlag = (value: unknown): PublicPoweredByFlag => {
756
+ const input = exactRecord(value, ["project_id", "enabled", "enabled_at"]);
757
+ if (
758
+ !isUlid(input["project_id"]) ||
759
+ typeof input["enabled"] !== "boolean" ||
760
+ (input["enabled"] ? !isIsoInstant(input["enabled_at"]) : input["enabled_at"] !== null)
761
+ )
762
+ throw new ResponseContractError();
763
+ return input as unknown as PublicPoweredByFlag;
764
+ };
765
+
766
+ export interface PublicOrganizationReferral {
767
+ readonly organization_id: string;
768
+ readonly referral_url: string;
769
+ }
770
+
771
+ export const parseOrganizationReferral = (value: unknown): PublicOrganizationReferral => {
772
+ const input = exactRecord(value, ["organization_id", "referral_url"]);
773
+ if (
774
+ !isUlid(input["organization_id"]) ||
775
+ input["referral_url"] !==
776
+ `https://ohmyho.st/?r=ref-${String(input["organization_id"]).toLowerCase()}`
777
+ )
778
+ throw new ResponseContractError();
779
+ return input as unknown as PublicOrganizationReferral;
780
+ };
781
+
738
782
  export const parseDevAccessTicket = (value: unknown): PublicDevAccessTicket => {
739
783
  const input = exactRecord(value, [
740
784
  "ticket_id",
@@ -889,7 +933,13 @@ export const parseCurrentIdentity = (value: unknown): PublicCurrentIdentity => {
889
933
  return input as unknown as PublicCurrentIdentity;
890
934
  };
891
935
 
892
- export const parseAccountProfile = (value: unknown): readonly PublicWorkspace[] => {
936
+ /** The signed-in user and the workspaces it belongs to, as `GET /v1/me/profile` reports them. */
937
+ export interface PublicAccount {
938
+ readonly userId: string;
939
+ readonly organizations: readonly PublicWorkspace[];
940
+ }
941
+
942
+ export const parseAccountProfile = (value: unknown): PublicAccount => {
893
943
  const input = exactRecord(value, [
894
944
  "user_id",
895
945
  "name",
@@ -927,7 +977,7 @@ export const parseAccountProfile = (value: unknown): readonly PublicWorkspace[]
927
977
  });
928
978
  if (new Set(workspaces.map((workspace) => workspace.id)).size !== organizationIds.length)
929
979
  throw new ResponseContractError();
930
- return workspaces;
980
+ return { userId: input["user_id"] as string, organizations: workspaces };
931
981
  };
932
982
 
933
983
  export interface PublicProject {
@@ -1130,6 +1180,8 @@ const parseProjectDeployment = (value: unknown): void => {
1130
1180
 
1131
1181
  export interface PublicOperation {
1132
1182
  readonly blocking_operation_id?: string;
1183
+ /** The deployment this operation created; its diagnostics explain a failure. */
1184
+ readonly deployment_id?: string;
1133
1185
  readonly progress?: OperationDeploymentProgress;
1134
1186
  readonly id: string;
1135
1187
  readonly state: "queued" | "running" | "succeeded" | "failed" | "cancelled";
@@ -1569,6 +1621,7 @@ export const parseSafeProblem = (value: unknown): SafeProblem => {
1569
1621
  export const parseOperation = (value: unknown): PublicOperation => {
1570
1622
  const input = exactRecord(value, [
1571
1623
  "blocking_operation_id",
1624
+ "deployment_id",
1572
1625
  "id",
1573
1626
  "state",
1574
1627
  "created_at",
@@ -1584,9 +1637,11 @@ export const parseOperation = (value: unknown): PublicOperation => {
1584
1637
  const updatedAt = input["updated_at"];
1585
1638
  const result = input["result"];
1586
1639
  const blockingOperationId = input["blocking_operation_id"];
1640
+ const deploymentId = input["deployment_id"];
1587
1641
  if (
1588
- blockingOperationId !== undefined &&
1589
- (state !== "queued" || !isUlid(blockingOperationId) || blockingOperationId === id)
1642
+ (blockingOperationId !== undefined &&
1643
+ (state !== "queued" || !isUlid(blockingOperationId) || blockingOperationId === id)) ||
1644
+ (deploymentId !== undefined && !isUlid(deploymentId))
1590
1645
  )
1591
1646
  throw new ResponseContractError();
1592
1647
  if (
@@ -1648,6 +1703,7 @@ export const parseOperation = (value: unknown): PublicOperation => {
1648
1703
  ...(blockingOperationId === undefined
1649
1704
  ? {}
1650
1705
  : { blocking_operation_id: blockingOperationId as string }),
1706
+ ...(deploymentId === undefined ? {} : { deployment_id: deploymentId as string }),
1651
1707
  ...(progress === undefined ? {} : { progress }),
1652
1708
  ...(result === undefined ? {} : { result }),
1653
1709
  ...(error === undefined ? {} : { error }),
@@ -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.