@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.
- package/README.md +1 -1
- package/dist/{chunk-PXGLL6SR.js → chunk-4WY7AAPR.js} +11 -11
- package/dist/index.js +1 -1
- package/dist/stdio-main.js +1575 -310
- package/package.json +1 -1
- package/src/apps/mcp/local-client.ts +119 -17
- package/src/apps/mcp/local-server.ts +321 -88
- package/src/apps/mcp/stdio-main.ts +11 -2
- package/src/apps/product-cli/cli.ts +370 -92
- package/src/apps/product-cli/command.ts +163 -6
- package/src/apps/product-cli/composition.ts +748 -120
- package/src/apps/product-cli/credential-store.ts +5 -3
- package/src/apps/product-cli/index.ts +4 -0
- package/src/apps/product-cli/product-api.ts +32 -4
- package/src/apps/product-cli/profile-store.ts +609 -0
- package/src/apps/product-cli/project-link-store.ts +169 -20
- package/src/apps/product-cli/runtime-contract.ts +63 -7
- package/src/apps/product-cli/workspace.ts +75 -13
- package/src/packages/agent-skills/generated-skill-resources.ts +10 -10
- package/src/packages/contracts/operation-failure.ts +3 -3
- package/src/packages/sdk-ts/generated/index.ts +21 -0
- package/src/packages/sdk-ts/generated/sdk.gen.ts +142 -0
- package/src/packages/sdk-ts/generated/types.gen.ts +200 -2
|
@@ -1,37 +1,166 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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<
|
|
51
|
+
): Promise<DirectoryLinks> {
|
|
52
|
+
let names: string[] = [];
|
|
22
53
|
try {
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
3
|
+
import type { LocalProfile, ProfileRegistry } from "./profile-store.js";
|
|
4
|
+
import { parseCurrentIdentity, type PublicWorkspace } from "./runtime-contract.js";
|
|
4
5
|
|
|
5
|
-
/** The
|
|
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
|
-
*
|
|
48
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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.
|