@amerged/ohmyhost-mcp 0.1.38 → 0.1.40
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/THIRD_PARTY_NOTICES.md +26 -0
- package/dist/{chunk-Z7AT2PTU.js → chunk-OW4IDEWK.js} +11 -11
- package/dist/index.js +1 -1
- package/dist/licenses/pgcf-bridge-LICENSE +201 -0
- package/dist/licenses/pgcf-bridge-NOTICE +1 -0
- package/dist/stdio-main.js +3847 -32
- package/package.json +1 -1
- package/src/apps/mcp/package.json +1 -1
- package/src/apps/mcp/src/local-client.ts +15 -9
- package/src/apps/mcp/src/local-server.ts +36 -18
- package/src/apps/product-cli/package.json +3 -2
- package/src/apps/product-cli/src/cli.ts +7 -5
- package/src/apps/product-cli/src/command.ts +18 -5
- package/src/apps/product-cli/src/product-api.ts +22 -15
- package/src/apps/product-cli/src/psql-session.ts +54 -19
- package/src/packages/agent-skills/src/generated-skill-resources.ts +10 -10
- package/src/packages/contracts/package.json +5 -0
- package/src/packages/contracts/src/credit-pricing.ts +7 -1
- package/src/packages/contracts/src/database-access.ts +53 -12
- package/src/packages/contracts/src/database-compute.ts +66 -11
- package/src/packages/contracts/src/database-sizes.ts +114 -0
- package/src/packages/contracts/src/framework-admission.ts +1 -1
- package/src/packages/contracts/src/framework-source-rules.ts +1 -1
- package/src/packages/contracts/src/index.ts +1 -1
- package/src/packages/contracts/src/operation-failure.ts +2 -2
- package/src/packages/contracts/src/platform-origins.ts +8 -0
- package/src/packages/contracts/src/problem-policy.ts +16 -2
- package/src/packages/contracts/src/storage-gateway-origin.ts +93 -0
- package/src/packages/postgres-bridge/LICENSE +201 -0
- package/src/packages/postgres-bridge/NOTICE +1 -0
- package/src/packages/postgres-bridge/package.json +26 -0
- package/src/packages/postgres-bridge/src/auth.ts +159 -0
- package/src/packages/postgres-bridge/src/bridge.ts +168 -0
- package/src/packages/postgres-bridge/src/index.ts +2 -0
- package/src/packages/postgres-bridge/src/options.ts +49 -0
- package/src/packages/sdk-ts/package.json +1 -1
- package/src/packages/sdk-ts/src/generated/index.ts +1 -0
- package/src/packages/sdk-ts/src/generated/sdk.gen.ts +9 -8
- package/src/packages/sdk-ts/src/generated/types.gen.ts +91 -24
- package/src/packages/sdk-ts/src/index.ts +1 -0
- package/src/packages/sdk-ts/src/managed-mail-attachment.ts +107 -0
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"exports": {
|
|
8
|
+
"./database-sizes": {
|
|
9
|
+
"source": "./src/database-sizes.ts",
|
|
10
|
+
"types": "./dist/database-sizes.d.ts",
|
|
11
|
+
"import": "./dist/database-sizes.js"
|
|
12
|
+
},
|
|
8
13
|
"./shared-mail": {
|
|
9
14
|
"source": "./src/shared-mail.ts",
|
|
10
15
|
"types": "./dist/shared-mail.d.ts",
|
|
@@ -96,7 +96,7 @@ export interface PublishedCreditRateCard {
|
|
|
96
96
|
readonly meter: string;
|
|
97
97
|
readonly unit: string;
|
|
98
98
|
readonly units_per_charge: string;
|
|
99
|
-
readonly provider_cost_micros: string;
|
|
99
|
+
readonly provider_cost_micros: string | null;
|
|
100
100
|
readonly credit_micros: string;
|
|
101
101
|
}[];
|
|
102
102
|
}
|
|
@@ -159,6 +159,12 @@ export function assertPublishedCreditRateCards(
|
|
|
159
159
|
meters.add(identity);
|
|
160
160
|
for (const key of ["units_per_charge", "provider_cost_micros", "credit_micros"]) {
|
|
161
161
|
const amount = rate[key];
|
|
162
|
+
if (
|
|
163
|
+
key === "provider_cost_micros" &&
|
|
164
|
+
amount === null &&
|
|
165
|
+
rate["meter"] === "pgcf.memory.active"
|
|
166
|
+
)
|
|
167
|
+
continue;
|
|
162
168
|
if (
|
|
163
169
|
typeof amount !== "string" ||
|
|
164
170
|
!/^(0|[1-9][0-9]{0,18})$/u.test(amount) ||
|
|
@@ -93,13 +93,12 @@ export interface ProjectDatabaseAccessRequest {
|
|
|
93
93
|
readonly ttl_seconds: number;
|
|
94
94
|
readonly label: string | null;
|
|
95
95
|
}
|
|
96
|
-
export interface
|
|
96
|
+
export interface ProjectDatabaseAccessFields {
|
|
97
97
|
readonly access_id: string;
|
|
98
98
|
readonly environment: DatabaseEnvironment;
|
|
99
99
|
readonly mode: DatabaseAccessMode;
|
|
100
100
|
readonly role_name: string;
|
|
101
101
|
readonly host: string;
|
|
102
|
-
readonly database: "neondb";
|
|
103
102
|
readonly label: string | null;
|
|
104
103
|
readonly state: "active" | "expired" | "revoked";
|
|
105
104
|
readonly issued_at: string;
|
|
@@ -113,10 +112,25 @@ export interface ProjectDatabaseAccess {
|
|
|
113
112
|
| "data_change"
|
|
114
113
|
| null;
|
|
115
114
|
}
|
|
116
|
-
export
|
|
115
|
+
export type ProjectDatabaseAccess = ProjectDatabaseAccessFields &
|
|
116
|
+
(
|
|
117
|
+
| {
|
|
118
|
+
readonly database: "neondb";
|
|
119
|
+
readonly provider?: never;
|
|
120
|
+
readonly transport?: never;
|
|
121
|
+
readonly websocket_url?: never;
|
|
122
|
+
}
|
|
123
|
+
| {
|
|
124
|
+
readonly provider: "pgcf";
|
|
125
|
+
readonly transport: "wss";
|
|
126
|
+
readonly websocket_url: string;
|
|
127
|
+
readonly database: string;
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
export type ProjectDatabaseAccessCredential = ProjectDatabaseAccess & {
|
|
117
131
|
readonly connection_uri: string;
|
|
118
132
|
readonly psql_command: string;
|
|
119
|
-
}
|
|
133
|
+
};
|
|
120
134
|
export interface ProjectDatabaseAccessPage {
|
|
121
135
|
readonly items: readonly ProjectDatabaseAccess[];
|
|
122
136
|
}
|
|
@@ -163,17 +177,27 @@ export function parseProjectDatabaseAccessRequest(value: unknown): ProjectDataba
|
|
|
163
177
|
export function assertProjectDatabaseAccess(
|
|
164
178
|
value: unknown,
|
|
165
179
|
): asserts value is ProjectDatabaseAccess {
|
|
166
|
-
if (!record(value) || !exact(value,
|
|
180
|
+
if (!record(value) || !exact(value, accessKeys(value)))
|
|
181
|
+
throw new TypeError("Invalid database access");
|
|
167
182
|
assertAccessFields(value);
|
|
168
183
|
}
|
|
169
184
|
export function assertProjectDatabaseAccessCredential(
|
|
170
185
|
value: unknown,
|
|
171
186
|
): asserts value is ProjectDatabaseAccessCredential {
|
|
172
|
-
if (!record(value) || !exact(value, [...
|
|
187
|
+
if (!record(value) || !exact(value, [...accessKeys(value), "connection_uri", "psql_command"]))
|
|
173
188
|
throw new TypeError("Invalid database access credential");
|
|
174
189
|
assertAccessFields(value);
|
|
175
190
|
const uri = value["connection_uri"];
|
|
176
|
-
if (
|
|
191
|
+
if (
|
|
192
|
+
typeof uri !== "string" ||
|
|
193
|
+
(value["provider"] === "pgcf"
|
|
194
|
+
? typeof value["psql_command"] !== "string" ||
|
|
195
|
+
!new RegExp(
|
|
196
|
+
`^ohmyhost database psql --project [0-7][0-9A-HJKMNP-TV-Z]{25} --environment ${value["environment"]} --mode ${value["mode"]}$`,
|
|
197
|
+
"u",
|
|
198
|
+
).test(value["psql_command"])
|
|
199
|
+
: value["psql_command"] !== `psql '${uri}'`)
|
|
200
|
+
)
|
|
177
201
|
throw new TypeError("Invalid database access credential");
|
|
178
202
|
let parsed: URL;
|
|
179
203
|
try {
|
|
@@ -182,13 +206,20 @@ export function assertProjectDatabaseAccessCredential(
|
|
|
182
206
|
throw new TypeError("Invalid database access credential");
|
|
183
207
|
}
|
|
184
208
|
if (
|
|
185
|
-
|
|
209
|
+
(value["provider"] === "pgcf"
|
|
210
|
+
? !["postgres:", "postgresql:"].includes(parsed.protocol)
|
|
211
|
+
: parsed.protocol !== "postgresql:") ||
|
|
186
212
|
decodeURIComponent(parsed.username) !== value["role_name"] ||
|
|
187
213
|
parsed.password === "" ||
|
|
188
214
|
parsed.hostname !== value["host"] ||
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
215
|
+
(value["provider"] === "pgcf"
|
|
216
|
+
? parsed.port !== "" && parsed.port !== "5432"
|
|
217
|
+
: parsed.host !== value["host"]) ||
|
|
218
|
+
decodeURIComponent(parsed.pathname.slice(1)) !== value["database"] ||
|
|
219
|
+
parsed.hash !== "" ||
|
|
220
|
+
(value["provider"] === "pgcf"
|
|
221
|
+
? !["", "?sslmode=require", "?sslmode=verify-full"].includes(parsed.search)
|
|
222
|
+
: parsed.search !== "?sslmode=require")
|
|
192
223
|
)
|
|
193
224
|
throw new TypeError("Invalid database access credential");
|
|
194
225
|
}
|
|
@@ -204,6 +235,11 @@ export function assertProjectDatabaseAccessPage(
|
|
|
204
235
|
throw new TypeError("Invalid database access page");
|
|
205
236
|
for (const item of value["items"]) assertProjectDatabaseAccess(item);
|
|
206
237
|
}
|
|
238
|
+
function accessKeys(value: Record<string, unknown>): readonly string[] {
|
|
239
|
+
return value["provider"] === "pgcf"
|
|
240
|
+
? [...ACCESS_KEYS, "provider", "transport", "websocket_url"]
|
|
241
|
+
: ACCESS_KEYS;
|
|
242
|
+
}
|
|
207
243
|
function assertAccessFields(value: Record<string, unknown>): void {
|
|
208
244
|
const revoked = value["revoked_at"],
|
|
209
245
|
reason = value["revocation_reason"];
|
|
@@ -216,7 +252,12 @@ function assertAccessFields(value: Record<string, unknown>): void {
|
|
|
216
252
|
!ACCESS_ROLE_PATTERN.test(value["role_name"]) ||
|
|
217
253
|
typeof value["host"] !== "string" ||
|
|
218
254
|
!/^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/u.test(value["host"]) ||
|
|
219
|
-
value["
|
|
255
|
+
(value["provider"] === "pgcf"
|
|
256
|
+
? typeof value["database"] !== "string" ||
|
|
257
|
+
!/^[a-z][a-z0-9]{19}$/u.test(value["database"]) ||
|
|
258
|
+
value["transport"] !== "wss" ||
|
|
259
|
+
value["websocket_url"] !== `wss://${value["host"]}/`
|
|
260
|
+
: value["database"] !== "neondb") ||
|
|
220
261
|
(value["label"] !== null && accessLabel(value["label"]) !== value["label"]) ||
|
|
221
262
|
!["active", "expired", "revoked"].includes(String(value["state"])) ||
|
|
222
263
|
!timestamp(value["issued_at"]) ||
|
|
@@ -1,19 +1,39 @@
|
|
|
1
|
+
import { databaseSizeForClass } from "./database-sizes.js";
|
|
2
|
+
|
|
3
|
+
export type PgcfComputeState = "provisioning" | "cold" | "warm" | "suspended" | "error";
|
|
4
|
+
export interface PgcfDatabaseCompute {
|
|
5
|
+
readonly provider: "pgcf";
|
|
6
|
+
readonly size_class_id: string;
|
|
7
|
+
readonly memory_mib: number;
|
|
8
|
+
readonly cpu_millicores: number;
|
|
9
|
+
readonly storage_mib: number;
|
|
10
|
+
readonly max_connections: number;
|
|
11
|
+
readonly suspend_timeout_seconds: number;
|
|
12
|
+
readonly state: PgcfComputeState;
|
|
13
|
+
readonly pending_state: PgcfComputeState | null;
|
|
14
|
+
readonly disabled: boolean;
|
|
15
|
+
readonly region: "us" | "eu";
|
|
16
|
+
}
|
|
17
|
+
|
|
1
18
|
export interface DatabaseCompute {
|
|
2
19
|
readonly project_id: string;
|
|
3
20
|
readonly environment: "dev" | "prod";
|
|
4
21
|
readonly data_mode: "shared" | "isolated";
|
|
5
22
|
readonly observed_at: string;
|
|
6
|
-
readonly database:
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
readonly database:
|
|
24
|
+
| null
|
|
25
|
+
| PgcfDatabaseCompute
|
|
26
|
+
| {
|
|
27
|
+
readonly min_cu: number;
|
|
28
|
+
readonly max_cu: number;
|
|
29
|
+
readonly min_memory_gb: number;
|
|
30
|
+
readonly max_memory_gb: number;
|
|
31
|
+
readonly suspend_timeout_seconds: number;
|
|
32
|
+
readonly state: "init" | "active" | "idle";
|
|
33
|
+
readonly pending_state: "init" | "active" | "idle" | null;
|
|
34
|
+
readonly disabled: boolean;
|
|
35
|
+
readonly region: string;
|
|
36
|
+
};
|
|
17
37
|
}
|
|
18
38
|
export function assertDatabaseCompute(value: unknown): asserts value is DatabaseCompute {
|
|
19
39
|
if (
|
|
@@ -30,6 +50,10 @@ export function assertDatabaseCompute(value: unknown): asserts value is Database
|
|
|
30
50
|
throw new TypeError("Invalid database compute report");
|
|
31
51
|
const db = value["database"];
|
|
32
52
|
if (db === null) return;
|
|
53
|
+
if (record(db) && db["provider"] === "pgcf") {
|
|
54
|
+
assertPgcfCompute(db);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
33
57
|
if (
|
|
34
58
|
!record(db) ||
|
|
35
59
|
!exact(db, [
|
|
@@ -64,6 +88,37 @@ export function assertDatabaseCompute(value: unknown): asserts value is Database
|
|
|
64
88
|
)
|
|
65
89
|
throw new TypeError("Invalid database compute configuration");
|
|
66
90
|
}
|
|
91
|
+
function assertPgcfCompute(db: Record<string, unknown>): void {
|
|
92
|
+
const size = databaseSizeForClass(db["size_class_id"]);
|
|
93
|
+
const states = ["provisioning", "cold", "warm", "suspended", "error"];
|
|
94
|
+
if (
|
|
95
|
+
!exact(db, [
|
|
96
|
+
"cpu_millicores",
|
|
97
|
+
"disabled",
|
|
98
|
+
"max_connections",
|
|
99
|
+
"memory_mib",
|
|
100
|
+
"pending_state",
|
|
101
|
+
"provider",
|
|
102
|
+
"region",
|
|
103
|
+
"size_class_id",
|
|
104
|
+
"state",
|
|
105
|
+
"storage_mib",
|
|
106
|
+
"suspend_timeout_seconds",
|
|
107
|
+
]) ||
|
|
108
|
+
db["memory_mib"] !== size.memoryMiB ||
|
|
109
|
+
db["cpu_millicores"] !== size.cpuMillicores ||
|
|
110
|
+
db["storage_mib"] !== size.storageMiB ||
|
|
111
|
+
db["max_connections"] !== size.maxConnections ||
|
|
112
|
+
!Number.isSafeInteger(db["suspend_timeout_seconds"]) ||
|
|
113
|
+
Number(db["suspend_timeout_seconds"]) < 60 ||
|
|
114
|
+
Number(db["suspend_timeout_seconds"]) > 900 ||
|
|
115
|
+
!states.includes(String(db["state"])) ||
|
|
116
|
+
(db["pending_state"] !== null && !states.includes(String(db["pending_state"]))) ||
|
|
117
|
+
typeof db["disabled"] !== "boolean" ||
|
|
118
|
+
!["us", "eu"].includes(String(db["region"]))
|
|
119
|
+
)
|
|
120
|
+
throw new TypeError("Invalid fixed database compute configuration");
|
|
121
|
+
}
|
|
67
122
|
function record(v: unknown): v is Record<string, unknown> {
|
|
68
123
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
69
124
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export type DatabaseMemoryMiB = 256 | 512 | 1024 | 2048 | 4096;
|
|
2
|
+
export type DatabaseComputeSelection =
|
|
3
|
+
| { readonly memoryMiB: DatabaseMemoryMiB; readonly profile?: never }
|
|
4
|
+
| { readonly profile: "standard" | "performance"; readonly memoryMiB?: never };
|
|
5
|
+
|
|
6
|
+
export function parseDatabaseComputeChangeRequest(value: unknown): {
|
|
7
|
+
readonly environment: "dev" | "prod";
|
|
8
|
+
readonly selection: DatabaseComputeSelection;
|
|
9
|
+
} {
|
|
10
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
11
|
+
throw new TypeError("Invalid database compute change");
|
|
12
|
+
const input = value as Record<string, unknown>;
|
|
13
|
+
const keys = Object.keys(input).sort().join(",");
|
|
14
|
+
if (
|
|
15
|
+
(keys !== "confirm,environment,memory_mib" && keys !== "confirm,environment,profile") ||
|
|
16
|
+
input["confirm"] !== true ||
|
|
17
|
+
(input["environment"] !== "dev" && input["environment"] !== "prod")
|
|
18
|
+
)
|
|
19
|
+
throw new TypeError("Invalid database compute change");
|
|
20
|
+
if (keys === "confirm,environment,memory_mib")
|
|
21
|
+
return {
|
|
22
|
+
environment: input["environment"],
|
|
23
|
+
selection: { memoryMiB: databaseSizeForMemory(input["memory_mib"]).memoryMiB },
|
|
24
|
+
};
|
|
25
|
+
if (input["profile"] !== "standard" && input["profile"] !== "performance")
|
|
26
|
+
throw new TypeError("Invalid transitional database profile");
|
|
27
|
+
return { environment: input["environment"], selection: { profile: input["profile"] } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DatabaseSize {
|
|
31
|
+
readonly memoryMiB: DatabaseMemoryMiB;
|
|
32
|
+
readonly storageMiB: number;
|
|
33
|
+
readonly cpuMillicores: number;
|
|
34
|
+
readonly maxConnections: number;
|
|
35
|
+
readonly sizeClassId: string;
|
|
36
|
+
readonly creditsPerAwakeHour: number;
|
|
37
|
+
readonly paidRequired: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Operator-approved product sizes; storage is total nominal volume, including WAL. */
|
|
41
|
+
export const DATABASE_SIZES: readonly DatabaseSize[] = Object.freeze([
|
|
42
|
+
Object.freeze({
|
|
43
|
+
memoryMiB: 256,
|
|
44
|
+
storageMiB: 500,
|
|
45
|
+
cpuMillicores: 250,
|
|
46
|
+
maxConnections: 20,
|
|
47
|
+
sizeClassId: "r256-s500m",
|
|
48
|
+
creditsPerAwakeHour: 10,
|
|
49
|
+
paidRequired: false,
|
|
50
|
+
} as const),
|
|
51
|
+
Object.freeze({
|
|
52
|
+
memoryMiB: 512,
|
|
53
|
+
storageMiB: 1024,
|
|
54
|
+
cpuMillicores: 250,
|
|
55
|
+
maxConnections: 40,
|
|
56
|
+
sizeClassId: "r512-s1g",
|
|
57
|
+
creditsPerAwakeHour: 20,
|
|
58
|
+
paidRequired: true,
|
|
59
|
+
} as const),
|
|
60
|
+
Object.freeze({
|
|
61
|
+
memoryMiB: 1024,
|
|
62
|
+
storageMiB: 2048,
|
|
63
|
+
cpuMillicores: 500,
|
|
64
|
+
maxConnections: 80,
|
|
65
|
+
sizeClassId: "r1024-s2g",
|
|
66
|
+
creditsPerAwakeHour: 40,
|
|
67
|
+
paidRequired: true,
|
|
68
|
+
} as const),
|
|
69
|
+
Object.freeze({
|
|
70
|
+
memoryMiB: 2048,
|
|
71
|
+
storageMiB: 4096,
|
|
72
|
+
cpuMillicores: 1000,
|
|
73
|
+
maxConnections: 160,
|
|
74
|
+
sizeClassId: "r2048-s4g",
|
|
75
|
+
creditsPerAwakeHour: 80,
|
|
76
|
+
paidRequired: true,
|
|
77
|
+
} as const),
|
|
78
|
+
Object.freeze({
|
|
79
|
+
memoryMiB: 4096,
|
|
80
|
+
storageMiB: 8192,
|
|
81
|
+
cpuMillicores: 2000,
|
|
82
|
+
maxConnections: 200,
|
|
83
|
+
sizeClassId: "r4096-s8g",
|
|
84
|
+
creditsPerAwakeHour: 160,
|
|
85
|
+
paidRequired: true,
|
|
86
|
+
} as const),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
export function databaseSizeForMemory(value: unknown): DatabaseSize {
|
|
90
|
+
const size = DATABASE_SIZES.find((candidate) => candidate.memoryMiB === value);
|
|
91
|
+
if (size === undefined) throw new TypeError("Unsupported database memory size");
|
|
92
|
+
return size;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function databaseSizeForClass(value: unknown): DatabaseSize {
|
|
96
|
+
const size = DATABASE_SIZES.find((candidate) => candidate.sizeClassId === value);
|
|
97
|
+
if (size === undefined) throw new TypeError("Unsupported database size class");
|
|
98
|
+
return size;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A downgrade never silently reduces a database's physical allocation. */
|
|
102
|
+
export function assertDatabaseSizeChange(input: {
|
|
103
|
+
readonly memoryMiB: unknown;
|
|
104
|
+
readonly paid: boolean;
|
|
105
|
+
readonly currentStorageMiB: number;
|
|
106
|
+
}): DatabaseSize {
|
|
107
|
+
const size = databaseSizeForMemory(input.memoryMiB);
|
|
108
|
+
if (size.paidRequired && !input.paid) throw new TypeError("Database size requires Paid");
|
|
109
|
+
if (!Number.isSafeInteger(input.currentStorageMiB) || input.currentStorageMiB < 0)
|
|
110
|
+
throw new TypeError("Invalid current database allocation");
|
|
111
|
+
if (size.storageMiB < input.currentStorageMiB)
|
|
112
|
+
throw new TypeError("A smaller database volume requires a separate data migration");
|
|
113
|
+
return size;
|
|
114
|
+
}
|
|
@@ -450,7 +450,7 @@ const FRAMEWORK_CONVERSION_DETAILS = Object.freeze({
|
|
|
450
450
|
better_auth_version_required: {
|
|
451
451
|
paths: ["manifest"],
|
|
452
452
|
template:
|
|
453
|
-
'"{{path}}" must declare better-auth at exactly 1.7.
|
|
453
|
+
'"{{path}}" must declare better-auth at exactly 1.7.7 in dependencies because ohmyhost.yaml sets auth.provider: better-auth. Pin that version or set auth.provider: none, commit and push the change, then plan the new commit.',
|
|
454
454
|
},
|
|
455
455
|
package_manifest_invalid: {
|
|
456
456
|
paths: ["manifest"],
|
|
@@ -32,7 +32,7 @@ import { classifyFrameworkFamily } from "./framework-family.js";
|
|
|
32
32
|
import { parseOhmyhostConfigYaml, type OhmyhostConfig } from "./index.js";
|
|
33
33
|
|
|
34
34
|
/** The only better-auth release the managed auth bridge installs. */
|
|
35
|
-
export const MANAGED_BETTER_AUTH_VERSION = "1.7.
|
|
35
|
+
export const MANAGED_BETTER_AUTH_VERSION = "1.7.7" as const;
|
|
36
36
|
/** The largest framework config the rules inspect, in UTF-8 bytes. */
|
|
37
37
|
export const MAXIMUM_FRAMEWORK_CONFIG_BYTES = 256 * 1024;
|
|
38
38
|
/** The customer Worker module of the functions runtime and of Next.js or TanStack Start crons. */
|
|
@@ -209,7 +209,7 @@ function normalizeEgressOrigins(value: readonly string[] | undefined): readonly
|
|
|
209
209
|
function forbiddenEgressHostname(hostname: string): boolean {
|
|
210
210
|
const normalized = hostname.toLowerCase();
|
|
211
211
|
return (
|
|
212
|
-
|
|
212
|
+
/(?:^|\.)ohmyho-(?:mail|storage)-gateway-[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*\.workers\.dev\.?$/u.test(
|
|
213
213
|
normalized,
|
|
214
214
|
) ||
|
|
215
215
|
normalized === "localhost" ||
|
|
@@ -111,8 +111,8 @@ const failures = {
|
|
|
111
111
|
"The built Worker could not be loaded: top-level code throws or calls fetch, timers or random values outside a handler, an import cannot be resolved, a Worker module such as src/ohmyhost/companion.ts has no default export, or the bundle is too large. Fix that, save or push the corrected version and plan its commit; replaying this operation cannot pass. If none of this applies, report the operation ID through feedback.",
|
|
112
112
|
],
|
|
113
113
|
storage_jurisdiction_conflict: [
|
|
114
|
-
"storage.jurisdiction must
|
|
115
|
-
"
|
|
114
|
+
"storage.jurisdiction must match the project's recorded storage jurisdiction.",
|
|
115
|
+
"Read 'Storage jurisdiction' in 'ohmyhost project context --project ULID --json' and set storage.jurisdiction in ohmyhost.yaml to that recorded value (us or eu). Existing R2 buckets and reservations keep their jurisdiction after a project-region migration; only new storage without a recorded bucket uses the project region. If context reports unavailable, report the request ID through feedback rather than guessing. Save or push the corrected version and plan its commit; for promotion, deploy it to Dev and promote again. Do not delete or recreate the project to move files.",
|
|
116
116
|
],
|
|
117
117
|
provider_state_absent: [
|
|
118
118
|
"Reconciliation confirmed that the required deployment resources are absent.",
|
|
@@ -3,3 +3,11 @@ export type SaasCnameTarget = "customers.omh.st" | "customers.ohmyho.st";
|
|
|
3
3
|
export function isSaasCnameTarget(value: unknown): value is SaasCnameTarget {
|
|
4
4
|
return value === "customers.omh.st" || value === "customers.ohmyho.st";
|
|
5
5
|
}
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
classifyPlatformGatewayHostname,
|
|
9
|
+
createPinnedStorageGatewayOrigin,
|
|
10
|
+
parsePinnedStorageGatewayOrigin,
|
|
11
|
+
type PinnedStorageGatewayOrigin,
|
|
12
|
+
type StorageGatewayOriginScope,
|
|
13
|
+
} from "./storage-gateway-origin.js";
|
|
@@ -120,6 +120,20 @@ export const PROBLEM_POLICY = {
|
|
|
120
120
|
restAction:
|
|
121
121
|
"Performance compute needs active Paid and usable credits or grace; repeating unchanged does not help. Read 'ohmyhost credits account --organization ULID --json'. On Free, the Owner may start Paid with 'ohmyhost billing checkout --offer paid'; without usable credits, they may add credits. Until then, keep standard compute.",
|
|
122
122
|
},
|
|
123
|
+
compute_paid_required: {
|
|
124
|
+
status: 403,
|
|
125
|
+
retryable: false,
|
|
126
|
+
action:
|
|
127
|
+
"Database sizes above 256 MiB require active Paid access. Read the account status; keep the existing database until the owner chooses an eligible size or Paid access.",
|
|
128
|
+
title: "Database size requires Paid access",
|
|
129
|
+
},
|
|
130
|
+
compute_storage_shrink_requires_migration: {
|
|
131
|
+
status: 409,
|
|
132
|
+
retryable: false,
|
|
133
|
+
action:
|
|
134
|
+
"This size includes less storage than the current volume. Keep the current database and plan an explicit data migration to a fresh smaller volume; do not retry the resize or delete data automatically.",
|
|
135
|
+
title: "Smaller storage requires a data migration",
|
|
136
|
+
},
|
|
123
137
|
compute_performance_unavailable: {
|
|
124
138
|
status: 503,
|
|
125
139
|
retryable: false,
|
|
@@ -327,10 +341,10 @@ export const PROBLEM_POLICY = {
|
|
|
327
341
|
status: 409,
|
|
328
342
|
retryable: false,
|
|
329
343
|
action:
|
|
330
|
-
"
|
|
344
|
+
"Read 'Storage jurisdiction' in 'ohmyhost project context --project ULID --json' and set storage.jurisdiction in ohmyhost.yaml to that recorded value (us or eu). Existing R2 buckets and reservations keep their jurisdiction after a project-region migration; only new storage without a recorded bucket uses the project region. If context reports unavailable, report the request ID through feedback rather than guessing. Save or push the corrected version and plan its commit; for promotion, deploy it to Dev and promote again. Do not delete or recreate the project to move files.",
|
|
331
345
|
title: "Storage jurisdiction conflict",
|
|
332
346
|
restAction:
|
|
333
|
-
"
|
|
347
|
+
"Read 'Storage jurisdiction' in 'ohmyhost project context --project ULID --json' and set storage.jurisdiction in ohmyhost.yaml to that recorded value (us or eu). Existing R2 buckets and reservations keep their jurisdiction after a project-region migration; only new storage without a recorded bucket uses the project region. If context reports unavailable, report the request ID through feedback rather than guessing. Save or push the corrected version and plan its commit; for promotion, deploy it to Dev and promote again. Do not delete or recreate the project to move files.",
|
|
334
348
|
},
|
|
335
349
|
shared_data_requires_promotion: {
|
|
336
350
|
status: 409,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** The platform supplies the project separately through its trusted dispatch context. */
|
|
2
|
+
export interface StorageGatewayOriginScope {
|
|
3
|
+
readonly organizationId: string;
|
|
4
|
+
readonly environmentId: string;
|
|
5
|
+
readonly dataEnvironmentId: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PinnedStorageGatewayOrigin extends StorageGatewayOriginScope {
|
|
8
|
+
readonly baseOrigin: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const GATEWAY_FAMILY =
|
|
12
|
+
/(?:^|\.)ohmyho-(mail|storage)-gateway-[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*\.workers\.dev\.?$/u;
|
|
13
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u;
|
|
14
|
+
const PIN_ORGANIZATION = /^s1-([0-7][0-9a-hjkmnp-tv-z]{25})$/u;
|
|
15
|
+
const PIN_ENVIRONMENTS = /^([0-7][0-9a-hjkmnp-tv-z]{25})-([0-7][0-9a-hjkmnp-tv-z]{25})$/u;
|
|
16
|
+
|
|
17
|
+
/** A malformed prefixed pin is still a platform gateway and must never reach public fetch. */
|
|
18
|
+
export function classifyPlatformGatewayHostname(hostname: unknown): "mail" | "storage" | null {
|
|
19
|
+
if (typeof hostname !== "string") return null;
|
|
20
|
+
const family = GATEWAY_FAMILY.exec(hostname)?.[1];
|
|
21
|
+
return family === "mail" || family === "storage" ? family : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Encodes all three immutable data identities using two canonical, bounded DNS labels. */
|
|
25
|
+
export function createPinnedStorageGatewayOrigin(baseOrigin: string, scope: unknown): string {
|
|
26
|
+
const base = canonicalOrigin(baseOrigin);
|
|
27
|
+
if (
|
|
28
|
+
base === null ||
|
|
29
|
+
base.hostname.split(".").length !== 4 ||
|
|
30
|
+
classifyPlatformGatewayHostname(base.hostname) !== "storage" ||
|
|
31
|
+
typeof scope !== "object" ||
|
|
32
|
+
scope === null ||
|
|
33
|
+
Array.isArray(scope)
|
|
34
|
+
)
|
|
35
|
+
throw new TypeError("Storage gateway origin scope is invalid");
|
|
36
|
+
const record = scope as Record<string, unknown>;
|
|
37
|
+
const values = [record["organizationId"], record["environmentId"], record["dataEnvironmentId"]];
|
|
38
|
+
if (values.some((value) => typeof value !== "string" || value.length !== 26 || !ULID.test(value)))
|
|
39
|
+
throw new TypeError("Storage gateway origin scope is invalid");
|
|
40
|
+
const [organization, environment, data] = values.map((value) => String(value).toLowerCase());
|
|
41
|
+
const origin = `https://s1-${organization}.${environment}-${data}.${base.hostname}`;
|
|
42
|
+
if (canonicalOrigin(origin) === null) throw new TypeError("Storage gateway origin is too long");
|
|
43
|
+
return origin;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Decodes only the versioned canonical pin; the trusted caller must bind its base and project. */
|
|
47
|
+
export function parsePinnedStorageGatewayOrigin(value: unknown): PinnedStorageGatewayOrigin | null {
|
|
48
|
+
const origin = canonicalOrigin(value);
|
|
49
|
+
if (origin === null) return null;
|
|
50
|
+
const labels = origin.hostname.split(".");
|
|
51
|
+
if (labels.length !== 6) return null;
|
|
52
|
+
const organization = PIN_ORGANIZATION.exec(labels[0] ?? "")?.[1];
|
|
53
|
+
const environments = PIN_ENVIRONMENTS.exec(labels[1] ?? "");
|
|
54
|
+
const environment = environments?.[1];
|
|
55
|
+
const data = environments?.[2];
|
|
56
|
+
const baseHostname = labels.slice(2).join(".");
|
|
57
|
+
if (
|
|
58
|
+
organization === undefined ||
|
|
59
|
+
environment === undefined ||
|
|
60
|
+
data === undefined ||
|
|
61
|
+
classifyPlatformGatewayHostname(baseHostname) !== "storage"
|
|
62
|
+
)
|
|
63
|
+
return null;
|
|
64
|
+
return Object.freeze({
|
|
65
|
+
baseOrigin: `https://${baseHostname}`,
|
|
66
|
+
organizationId: organization.toUpperCase(),
|
|
67
|
+
environmentId: environment.toUpperCase(),
|
|
68
|
+
dataEnvironmentId: data.toUpperCase(),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function canonicalOrigin(value: unknown): URL | null {
|
|
73
|
+
if (typeof value !== "string") return null;
|
|
74
|
+
try {
|
|
75
|
+
const url = new URL(value);
|
|
76
|
+
return url.protocol === "https:" &&
|
|
77
|
+
url.origin === value &&
|
|
78
|
+
url.port === "" &&
|
|
79
|
+
url.username === "" &&
|
|
80
|
+
url.password === "" &&
|
|
81
|
+
url.pathname === "/" &&
|
|
82
|
+
url.search === "" &&
|
|
83
|
+
url.hash === "" &&
|
|
84
|
+
url.hostname.length <= 253 &&
|
|
85
|
+
url.hostname
|
|
86
|
+
.split(".")
|
|
87
|
+
.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label))
|
|
88
|
+
? url
|
|
89
|
+
: null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|