@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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/dist/{chunk-Z7AT2PTU.js → chunk-OW4IDEWK.js} +11 -11
  4. package/dist/index.js +1 -1
  5. package/dist/licenses/pgcf-bridge-LICENSE +201 -0
  6. package/dist/licenses/pgcf-bridge-NOTICE +1 -0
  7. package/dist/stdio-main.js +3847 -32
  8. package/package.json +1 -1
  9. package/src/apps/mcp/package.json +1 -1
  10. package/src/apps/mcp/src/local-client.ts +15 -9
  11. package/src/apps/mcp/src/local-server.ts +36 -18
  12. package/src/apps/product-cli/package.json +3 -2
  13. package/src/apps/product-cli/src/cli.ts +7 -5
  14. package/src/apps/product-cli/src/command.ts +18 -5
  15. package/src/apps/product-cli/src/product-api.ts +22 -15
  16. package/src/apps/product-cli/src/psql-session.ts +54 -19
  17. package/src/packages/agent-skills/src/generated-skill-resources.ts +10 -10
  18. package/src/packages/contracts/package.json +5 -0
  19. package/src/packages/contracts/src/credit-pricing.ts +7 -1
  20. package/src/packages/contracts/src/database-access.ts +53 -12
  21. package/src/packages/contracts/src/database-compute.ts +66 -11
  22. package/src/packages/contracts/src/database-sizes.ts +114 -0
  23. package/src/packages/contracts/src/framework-admission.ts +1 -1
  24. package/src/packages/contracts/src/framework-source-rules.ts +1 -1
  25. package/src/packages/contracts/src/index.ts +1 -1
  26. package/src/packages/contracts/src/operation-failure.ts +2 -2
  27. package/src/packages/contracts/src/platform-origins.ts +8 -0
  28. package/src/packages/contracts/src/problem-policy.ts +16 -2
  29. package/src/packages/contracts/src/storage-gateway-origin.ts +93 -0
  30. package/src/packages/postgres-bridge/LICENSE +201 -0
  31. package/src/packages/postgres-bridge/NOTICE +1 -0
  32. package/src/packages/postgres-bridge/package.json +26 -0
  33. package/src/packages/postgres-bridge/src/auth.ts +159 -0
  34. package/src/packages/postgres-bridge/src/bridge.ts +168 -0
  35. package/src/packages/postgres-bridge/src/index.ts +2 -0
  36. package/src/packages/postgres-bridge/src/options.ts +49 -0
  37. package/src/packages/sdk-ts/package.json +1 -1
  38. package/src/packages/sdk-ts/src/generated/index.ts +1 -0
  39. package/src/packages/sdk-ts/src/generated/sdk.gen.ts +9 -8
  40. package/src/packages/sdk-ts/src/generated/types.gen.ts +91 -24
  41. package/src/packages/sdk-ts/src/index.ts +1 -0
  42. 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 ProjectDatabaseAccess {
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 interface ProjectDatabaseAccessCredential extends ProjectDatabaseAccess {
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, ACCESS_KEYS)) throw new TypeError("Invalid database access");
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, [...ACCESS_KEYS, "connection_uri", "psql_command"]))
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 (typeof uri !== "string" || value["psql_command"] !== `psql '${uri}'`)
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
- parsed.protocol !== "postgresql:" ||
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
- parsed.host !== value["host"] ||
190
- decodeURIComponent(parsed.pathname.slice(1)) !== "neondb" ||
191
- parsed.search !== "?sslmode=require"
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["database"] !== "neondb" ||
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: null | {
7
- readonly min_cu: number;
8
- readonly max_cu: number;
9
- readonly min_memory_gb: number;
10
- readonly max_memory_gb: number;
11
- readonly suspend_timeout_seconds: number;
12
- readonly state: "init" | "active" | "idle";
13
- readonly pending_state: "init" | "active" | "idle" | null;
14
- readonly disabled: boolean;
15
- readonly region: string;
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.1 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.',
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.1" as const;
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
- /^ohmyho-(?:mail|storage)-gateway-[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*\.workers\.dev\.?$/u.test(
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 equal the project's hosting region.",
115
- "Set storage.jurisdiction in ohmyhost.yaml to the project's region (us or eu) shown by 'ohmyhost project status', save or push the corrected version, then plan its commit (for a promotion, deploy it to Dev and promote again); the unchanged commit fails again. A project cannot move its files between regions; do not delete or recreate the project to change it.",
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
- "Set storage.jurisdiction in ohmyhost.yaml to the project's region (us or eu) shown by 'ohmyhost project status', save or push the corrected version, then plan its commit (for a promotion, deploy it to Dev and promote again); the unchanged commit fails again. A project cannot move its files between regions; do not delete or recreate the project to change it.",
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
- "Set storage.jurisdiction in ohmyhost.yaml to the project's region (us or eu) shown by 'ohmyhost project status', save or push the corrected version, then plan its commit (for a promotion, deploy it to Dev and promote again); the unchanged commit fails again. A project cannot move its files between regions; do not delete or recreate the project to change it.",
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
+ }