@amerged/ohmyhost-cli 0.1.36 → 0.1.38

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/dist/index.js CHANGED
@@ -99,7 +99,7 @@ import {
99
99
  selectWorkspace,
100
100
  verifiedUserId,
101
101
  workspaceOf
102
- } from "./chunk-LU7ONYZG.js";
102
+ } from "./chunk-EWKJGJQU.js";
103
103
  import {
104
104
  CredentialStoreIntegrityError,
105
105
  CredentialStoreUnavailableError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amerged/ohmyhost-cli",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ohmyhost/product-cli",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "private": true,
5
5
  "ohmyhost": {
6
6
  "deployment": "production",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc --project tsconfig.build.json",
35
- "typecheck": "tsc --project tsconfig.json --noEmit",
35
+ "typecheck": "tsc --project tsconfig.json --noEmit --incremental --tsBuildInfoFile typecheck.tsbuildinfo",
36
36
  "test": "vitest run --root ../.. apps/product-cli/test"
37
37
  },
38
38
  "dependencies": {
@@ -470,6 +470,37 @@ const HELP_USAGE = Object.freeze({
470
470
  "printf '%s' \"$SECRET_VALUE\" | ohmyhost secret set NAME --project ULID --environment ENVIRONMENT_ULID --idempotency-key KEY [--profile-user USER_ID --profile-organization ULID | --token-user USER_ID --token-organization ULID] --stdin [--wait] --json (with --profile-user and --profile-organization the saved login that runs it must belong to that user and organization; with --token-user and --token-organization it runs only with an OHMYHOST_TOKEN of that user and organization, never with a saved login)",
471
471
  "secret delete":
472
472
  "ohmyhost secret delete NAME --project ULID --environment ENVIRONMENT_ULID --idempotency-key KEY [--wait] --json",
473
+ "mail workspace list": "ohmyhost mail workspace list --organization ULID --json",
474
+ "mail workspace create":
475
+ "ohmyhost mail workspace create --organization ULID --domain DOMAIN --sending true --idempotency-key KEY --json",
476
+ "mail workspace status":
477
+ "ohmyhost mail workspace status --organization ULID --mail-domain ULID --json",
478
+ "mail workspace retire":
479
+ "ohmyhost mail workspace retire --organization ULID --mail-domain ULID --expected-revision N --idempotency-key KEY --yes --json",
480
+ "mail workspace dns-authorize":
481
+ "ohmyhost mail workspace dns-authorize --organization ULID --mail-domain ULID --expected-revision N --zone DOMAIN --idempotency-key KEY --json",
482
+ "mail workspace dns-revoke":
483
+ "ohmyhost mail workspace dns-revoke --organization ULID --mail-domain ULID --expected-revision N --idempotency-key KEY --yes --json",
484
+ "mail workspace dns-status":
485
+ "ohmyhost mail workspace dns-status --organization ULID --mail-domain ULID --json",
486
+ "mail binding get": "ohmyhost mail binding get --project ULID --environment PROD_ULID --json",
487
+ "mail binding set":
488
+ "ohmyhost mail binding set --project ULID --environment PROD_ULID --mail-domain ULID --sending true --sender-addresses contact@mail.example.com,contracts@mail.example.com --default-from contracts@mail.example.com [--reply-to owner@example.com] --expected-revision N --idempotency-key KEY --json",
489
+ "mail binding detach":
490
+ "ohmyhost mail binding detach --project ULID --environment PROD_ULID --expected-revision N --idempotency-key KEY --yes --json",
491
+ "mail identity get": "ohmyhost mail identity get --project ULID --json",
492
+ "mail identity declare":
493
+ "ohmyhost mail identity declare --project ULID --hostname sign.example.com --idempotency-key KEY --json",
494
+ "mail identity verify":
495
+ "ohmyhost mail identity verify --project ULID --expected-revision N --idempotency-key KEY --json",
496
+ "mail identity revoke":
497
+ "ohmyhost mail identity revoke --project ULID --expected-revision N --idempotency-key KEY --yes --json",
498
+ "mail identity authorize":
499
+ "ohmyhost mail identity authorize --project ULID --expected-revision N --zone example.com --idempotency-key KEY --json",
500
+ "mail identity authorization-revoke":
501
+ "ohmyhost mail identity authorization-revoke --project ULID --expected-revision N --idempotency-key KEY --yes --json",
502
+ "mail identity authorization-status":
503
+ "ohmyhost mail identity authorization-status --project ULID --json",
473
504
  "mail setup":
474
505
  "ohmyhost mail setup --project ULID --environment ULID --domain DOMAIN --sending true --receiving false --idempotency-key KEY --json",
475
506
  "mail status": "ohmyhost mail status --project ULID --environment ULID --json",
@@ -1114,6 +1145,11 @@ export class ProductCli {
1114
1145
  if (command.kind === "secret-list") return await this.listEnvironmentSecrets(command, signal);
1115
1146
  if (command.kind === "function-runs") return await this.listFunctionRuns(command, signal);
1116
1147
  if (command.kind === "secret-set") return await this.putEnvironmentSecret(command, signal);
1148
+ if (command.kind === "shared-mail") {
1149
+ const api = await this.authenticatedApi(command.credentialStore, signal);
1150
+ if (!api.sharedMail) throw new Error("Shared mail unavailable");
1151
+ return success(command.label, { mail: await api.sharedMail(command.request) });
1152
+ }
1117
1153
  if (command.kind === "managed-mail") {
1118
1154
  const api = await this.authenticatedApi(command.credentialStore, signal);
1119
1155
  if (!api.managedMail) throw new Error("Mail unavailable");
@@ -3120,7 +3156,7 @@ const parsedCommandName = (command: ProductCliCommand): CommandLabel => {
3120
3156
  if (command.kind === "function-runs") return "function runs";
3121
3157
  if (command.kind === "secret-set") return "secret set";
3122
3158
  if (command.kind === "secret-delete") return "secret delete";
3123
- if (command.kind === "managed-mail") return command.label;
3159
+ if (command.kind === "shared-mail" || command.kind === "managed-mail") return command.label;
3124
3160
  if (command.kind === "profile-list") return "profile list";
3125
3161
  return command.kind;
3126
3162
  };
@@ -3,7 +3,7 @@ import {
3
3
  isProjectDataChangeKind,
4
4
  type ProjectDataChangeKind,
5
5
  } from "@ohmyhost/contracts/project-data-changes";
6
- import type { ManagedMailCommand } from "@ohmyhost/sdk-ts";
6
+ import type { ManagedMailCommand, SharedMailCommand } from "@ohmyhost/sdk-ts";
7
7
  import { USER_API_KEY_ID } from "@ohmyhost/contracts/user-api-keys";
8
8
  import { WORKOS_USER_API_KEY_PATTERN } from "@ohmyhost/workos-auth-contracts/user-api-keys";
9
9
  import { isProfileName, isProfileUserId } from "./profile-store.js";
@@ -536,6 +536,12 @@ export type ProductCliCommand =
536
536
  readonly wait: boolean;
537
537
  readonly credentialStore: "native";
538
538
  }
539
+ | {
540
+ readonly kind: "shared-mail";
541
+ readonly request: SharedMailCommand;
542
+ readonly label: SharedMailCommandLabel;
543
+ readonly credentialStore: "native";
544
+ }
539
545
  | {
540
546
  readonly kind: "managed-mail";
541
547
  readonly request: ManagedMailCommand;
@@ -902,7 +908,27 @@ export type RecognizedCommandLabel =
902
908
  | "mail messages retry"
903
909
  | "mail domain set"
904
910
  | "mail domain status"
905
- | "mail domain delete";
911
+ | "mail domain delete"
912
+ | SharedMailCommandLabel;
913
+
914
+ export type SharedMailCommandLabel =
915
+ | "mail workspace list"
916
+ | "mail workspace create"
917
+ | "mail workspace status"
918
+ | "mail workspace retire"
919
+ | "mail workspace dns-authorize"
920
+ | "mail workspace dns-status"
921
+ | "mail workspace dns-revoke"
922
+ | "mail binding get"
923
+ | "mail binding set"
924
+ | "mail binding detach"
925
+ | "mail identity get"
926
+ | "mail identity declare"
927
+ | "mail identity verify"
928
+ | "mail identity revoke"
929
+ | "mail identity authorize"
930
+ | "mail identity authorization-status"
931
+ | "mail identity authorization-revoke";
906
932
 
907
933
  export const recognizedCommandLabel = (argv: readonly string[]): RecognizedCommandLabel => {
908
934
  const values = [...argv];
@@ -1052,6 +1078,11 @@ export const recognizedCommandLabel = (argv: readonly string[]): RecognizedComma
1052
1078
  }
1053
1079
  if (value === "function" && values[0] === "runs") return "function runs";
1054
1080
  if (value === "mail") {
1081
+ if (["workspace", "binding", "identity"].includes(values[0] ?? "")) {
1082
+ const candidate = `mail ${values[0]} ${values[1]}`;
1083
+ if (SHARED_MAIL_LABELS.includes(candidate as SharedMailCommandLabel))
1084
+ return candidate as SharedMailCommandLabel;
1085
+ }
1055
1086
  if (values[0] === "setup") return "mail setup";
1056
1087
  if (values[0] === "status") return "mail status";
1057
1088
  if (values[0] === "webhook") {
@@ -2328,6 +2359,10 @@ export const parseProductCliCommand = (argv: readonly string[]): ProductCliComma
2328
2359
  }
2329
2360
  throw new InvalidCommandError();
2330
2361
  }
2362
+ if (first === "mail" && ["workspace", "binding", "identity"].includes(values[0] ?? "")) {
2363
+ if (wait || follow || stdin) throw new InvalidCommandError();
2364
+ return parseSharedMailCommand(values, yes);
2365
+ }
2331
2366
  if (first === "mail") {
2332
2367
  const area = values.shift();
2333
2368
  const sub =
@@ -2538,3 +2573,194 @@ const validPaidHostname = (value: string): boolean =>
2538
2573
  !value.endsWith(".omh.st") &&
2539
2574
  value !== "ohmyho.st" &&
2540
2575
  !value.endsWith(".ohmyho.st");
2576
+
2577
+ const SHARED_MAIL_LABELS: readonly SharedMailCommandLabel[] = [
2578
+ "mail workspace list",
2579
+ "mail workspace create",
2580
+ "mail workspace status",
2581
+ "mail workspace retire",
2582
+ "mail workspace dns-authorize",
2583
+ "mail workspace dns-status",
2584
+ "mail workspace dns-revoke",
2585
+ "mail binding get",
2586
+ "mail binding set",
2587
+ "mail binding detach",
2588
+ "mail identity get",
2589
+ "mail identity declare",
2590
+ "mail identity verify",
2591
+ "mail identity revoke",
2592
+ "mail identity authorize",
2593
+ "mail identity authorization-status",
2594
+ "mail identity authorization-revoke",
2595
+ ];
2596
+
2597
+ /** New workspace sender operations stay separate from the exact legacy project mail contract. */
2598
+ function parseSharedMailCommand(values: string[], yes: boolean): ProductCliCommand {
2599
+ const area = values.shift(),
2600
+ operation = values.shift();
2601
+ const label = `mail ${area} ${operation}` as SharedMailCommandLabel;
2602
+ if (!SHARED_MAIL_LABELS.includes(label)) throw new InvalidCommandError();
2603
+ const destructive =
2604
+ operation === "retire" ||
2605
+ operation === "detach" ||
2606
+ operation === "revoke" ||
2607
+ operation === "dns-revoke" ||
2608
+ operation === "authorization-revoke";
2609
+ if (yes !== destructive) throw new InvalidCommandError();
2610
+ const allowed = area === "workspace" ? ["--organization"] : ["--project"];
2611
+ const writing = [
2612
+ "create",
2613
+ "retire",
2614
+ "dns-authorize",
2615
+ "dns-revoke",
2616
+ "set",
2617
+ "detach",
2618
+ "declare",
2619
+ "verify",
2620
+ "revoke",
2621
+ "authorize",
2622
+ "authorization-revoke",
2623
+ ].includes(operation ?? "");
2624
+ if (area === "workspace" && operation !== "list" && operation !== "create")
2625
+ allowed.push("--mail-domain");
2626
+ if (area === "binding") allowed.push("--environment");
2627
+ if (operation === "create") allowed.push("--domain", "--sending");
2628
+ if (area === "binding" && operation === "set")
2629
+ allowed.push(
2630
+ "--mail-domain",
2631
+ "--sending",
2632
+ "--sender-addresses",
2633
+ "--default-from",
2634
+ "--reply-to",
2635
+ );
2636
+ if (operation === "declare") allowed.push("--hostname");
2637
+ if (operation === "authorize" || operation === "dns-authorize") allowed.push("--zone");
2638
+ if (writing) allowed.push("--idempotency-key");
2639
+ if (writing && operation !== "create" && operation !== "declare")
2640
+ allowed.push("--expected-revision");
2641
+ const options = parseOptionalOptions(values, allowed);
2642
+ const required = (key: keyof typeof options) => {
2643
+ const value = options[key];
2644
+ if (!value) throw new InvalidCommandError();
2645
+ return value;
2646
+ };
2647
+ const revision = () => {
2648
+ const value = required("--expected-revision");
2649
+ if (!/^(?:0|[1-9][0-9]*)$/u.test(value) || !Number.isSafeInteger(Number(value)))
2650
+ throw new InvalidCommandError();
2651
+ return Number(value);
2652
+ };
2653
+ const sending = () => {
2654
+ const value = required("--sending");
2655
+ if (value !== "true" && value !== "false") throw new InvalidCommandError();
2656
+ return value === "true";
2657
+ };
2658
+ const idempotencyKey = () => required("--idempotency-key");
2659
+ let request: SharedMailCommand;
2660
+ if (area === "workspace") {
2661
+ const scope = { organizationId: required("--organization") };
2662
+ if (operation === "list") request = { ...scope, action: "domains_list" };
2663
+ else if (operation === "create") {
2664
+ const enabled = sending();
2665
+ if (!enabled) throw new InvalidCommandError();
2666
+ request = {
2667
+ ...scope,
2668
+ action: "domain_create",
2669
+ domain: required("--domain"),
2670
+ sending: enabled,
2671
+ idempotencyKey: idempotencyKey(),
2672
+ };
2673
+ } else {
2674
+ const target = { ...scope, mailDomainId: required("--mail-domain") };
2675
+ if (operation === "status") request = { ...target, action: "domain_get" };
2676
+ else if (operation === "dns-status") request = { ...target, action: "domain_dns_status" };
2677
+ else if (operation === "dns-revoke")
2678
+ request = {
2679
+ ...target,
2680
+ action: "domain_dns_revoke",
2681
+ expectedRevision: revision(),
2682
+ idempotencyKey: idempotencyKey(),
2683
+ };
2684
+ else if (operation === "retire")
2685
+ request = {
2686
+ ...target,
2687
+ action: "domain_retire",
2688
+ expectedRevision: revision(),
2689
+ idempotencyKey: idempotencyKey(),
2690
+ };
2691
+ else
2692
+ request = {
2693
+ ...target,
2694
+ action: "domain_dns_authorize",
2695
+ expectedRevision: revision(),
2696
+ zone: required("--zone"),
2697
+ idempotencyKey: idempotencyKey(),
2698
+ };
2699
+ }
2700
+ } else if (area === "binding") {
2701
+ const scope = { projectId: required("--project"), environmentId: required("--environment") };
2702
+ if (operation === "get") request = { ...scope, action: "binding_get" };
2703
+ else if (operation === "detach")
2704
+ request = {
2705
+ ...scope,
2706
+ action: "binding_detach",
2707
+ expectedRevision: revision(),
2708
+ idempotencyKey: idempotencyKey(),
2709
+ };
2710
+ else {
2711
+ const senderAddresses = required("--sender-addresses").split(",");
2712
+ if (
2713
+ senderAddresses.length > 32 ||
2714
+ senderAddresses.some((address) => !address || address.trim() !== address) ||
2715
+ new Set(senderAddresses).size !== senderAddresses.length
2716
+ )
2717
+ throw new InvalidCommandError();
2718
+ request = {
2719
+ ...scope,
2720
+ action: "binding_set",
2721
+ mailDomainId: required("--mail-domain"),
2722
+ sending: sending(),
2723
+ senderAddresses,
2724
+ defaultFrom: required("--default-from"),
2725
+ defaultReplyTo: options["--reply-to"] ?? null,
2726
+ expectedRevision: revision(),
2727
+ idempotencyKey: idempotencyKey(),
2728
+ };
2729
+ }
2730
+ } else {
2731
+ const scope = { projectId: required("--project") };
2732
+ if (operation === "get") request = { ...scope, action: "identity_get" };
2733
+ else if (operation === "authorization-status")
2734
+ request = { ...scope, action: "identity_authorization_status" };
2735
+ else if (operation === "authorization-revoke")
2736
+ request = {
2737
+ ...scope,
2738
+ action: "identity_authorization_revoke",
2739
+ expectedRevision: revision(),
2740
+ idempotencyKey: idempotencyKey(),
2741
+ };
2742
+ else if (operation === "declare")
2743
+ request = {
2744
+ ...scope,
2745
+ action: "identity_declare",
2746
+ hostname: required("--hostname"),
2747
+ idempotencyKey: idempotencyKey(),
2748
+ };
2749
+ else if (operation === "authorize")
2750
+ request = {
2751
+ ...scope,
2752
+ action: "identity_authorize",
2753
+ zone: required("--zone"),
2754
+ expectedRevision: revision(),
2755
+ idempotencyKey: idempotencyKey(),
2756
+ };
2757
+ else
2758
+ request = {
2759
+ ...scope,
2760
+ action: operation === "verify" ? "identity_verify" : "identity_revoke",
2761
+ expectedRevision: revision(),
2762
+ idempotencyKey: idempotencyKey(),
2763
+ };
2764
+ }
2765
+ return { kind: "shared-mail", request, label, credentialStore: "native" };
2766
+ }
@@ -528,6 +528,8 @@ const readTokenIdentity = async (input: {
528
528
  /** The organization a command names explicitly; `organization use` names its target instead. */
529
529
  const commandOrganizationId = (command: ProductCliCommand): string | undefined => {
530
530
  if (command.kind === "organization-use" || command.kind === "login") return undefined;
531
+ if (command.kind === "shared-mail" && "organizationId" in command.request)
532
+ return command.request.organizationId;
531
533
  if (command.kind === "feedback-submit") return command.report.organization_id;
532
534
  return "organizationId" in command && typeof command.organizationId === "string"
533
535
  ? command.organizationId
@@ -535,6 +537,8 @@ const commandOrganizationId = (command: ProductCliCommand): string | undefined =
535
537
  };
536
538
 
537
539
  const commandProjectId = (command: ProductCliCommand): string | undefined => {
540
+ if (command.kind === "shared-mail")
541
+ return "projectId" in command.request ? command.request.projectId : undefined;
538
542
  if (command.kind === "managed-mail") return command.request.projectId;
539
543
  return "projectId" in command && typeof command.projectId === "string"
540
544
  ? command.projectId
@@ -1,4 +1,9 @@
1
- import { executeManagedMail, type ManagedMailCommand } from "@ohmyhost/sdk-ts";
1
+ import {
2
+ executeManagedMail,
3
+ executeSharedMail,
4
+ type ManagedMailCommand,
5
+ type SharedMailCommand,
6
+ } from "@ohmyhost/sdk-ts";
2
7
  import type {
3
8
  ProjectDataChangeKind,
4
9
  ProjectDataChangePlan,
@@ -434,6 +439,7 @@ export interface ProductApi {
434
439
  readonly name: string;
435
440
  readonly idempotencyKey: string;
436
441
  }): Promise<DeleteEnvironmentSecretResult>;
442
+ sharedMail?(input: SharedMailCommand): Promise<unknown>;
437
443
  managedMail?(input: ManagedMailCommand): Promise<unknown>;
438
444
  }
439
445
 
@@ -1237,6 +1243,10 @@ export class GeneratedSdkProductApi implements ProductApi {
1237
1243
  );
1238
1244
  }
1239
1245
 
1246
+ public sharedMail(input: SharedMailCommand) {
1247
+ return executeSharedMail(this.#client, input);
1248
+ }
1249
+
1240
1250
  public managedMail(input: ManagedMailCommand) {
1241
1251
  return executeManagedMail(this.#client, input);
1242
1252
  }
@@ -75,6 +75,8 @@ export type RepositoryFramework = Exclude<FrameworkFamilyClassification, "ambigu
75
75
  const MAXIMUM_SOURCE_BYTES = 2 * 1024 * 1024;
76
76
  const SOURCE_MODULE_PATTERN = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
77
77
  const STORAGE_CLIENT_CALL_PATTERN = /\bcreatePrivateStorageClient\s*\(/u;
78
+ const GATEWAY_BINDING_NAME = /^OHMYHOST_(?:MAIL|STORAGE)_GATEWAY$/u;
79
+ const GATEWAY_WORD_TOKEN = /[\p{ID_Start}_$][\p{ID_Continue}$]*/uy;
78
80
  // The catalog names a module only by a path of its grammar; a file name with a space, for example,
79
81
  // is not one, so its blocker carries the path beside a sentence that does not quote it.
80
82
  const UNNAMED_MODULE_MESSAGES: Readonly<Record<DatabaseContractCode, string>> = Object.freeze({
@@ -89,15 +91,19 @@ function callsStorageClient(source: string): boolean {
89
91
  return STORAGE_CLIENT_CALL_PATTERN.test(sourceWithoutComments(source));
90
92
  }
91
93
 
92
- // Storage is reached through a private Service Binding plus the three values and the key that
93
- // `createPrivateStorageClient` reads. No raw bucket binding is handed to customer code.
94
+ // Global fetch reaches the private storage gateway using these values and its environment key.
95
+ // No raw bucket binding is handed to customer code.
94
96
  const STORAGE_RUNTIME_BINDINGS = Object.freeze([
95
97
  "OHMYHOST_ENVIRONMENT_ID",
96
98
  "OHMYHOST_PROJECT_ID",
97
- "OHMYHOST_STORAGE_GATEWAY",
98
99
  "OHMYHOST_STORAGE_GATEWAY_URL",
99
100
  "OHMYHOST_STORAGE_KEY",
100
101
  ]);
102
+ const MAIL_RUNTIME_BINDINGS = Object.freeze([
103
+ "OHMYHOST_MAIL_GATEWAY_URL",
104
+ "OHMYHOST_MAIL_KEY",
105
+ "OHMYHOST_PROJECT_ID",
106
+ ]);
101
107
  // The runtime release is published unchanged as `@amerged/ohmyhost-runtime`; this pinned alias keeps
102
108
  // its import name and, unlike the website archive each release removes, stays installable.
103
109
  const CUSTOMER_RUNTIME_SOURCE = `npm:@amerged/ohmyhost-runtime@${cliPackage.version}`;
@@ -105,6 +111,7 @@ const CUSTOMER_RUNTIME_SOURCE = `npm:@amerged/ohmyhost-runtime@${cliPackage.vers
105
111
  /** A catalog code, which planning answers with the same sentence, or a check only init runs. */
106
112
  export type RepositoryInitBlockerCode =
107
113
  | FrameworkConversionDiagnosticCode
114
+ | "gateway_binding_unsupported"
108
115
  | "migration_filename_noncanonical"
109
116
  | "migration_sql_not_admitted"
110
117
  | "storage_client_missing";
@@ -507,7 +514,7 @@ interface SourceCheckContext {
507
514
  /**
508
515
  * Every blocker of a source with a valid or proposed configuration, in the order planning judges:
509
516
  * migration filenames, the shared source rules, the Worker module handlers and the database
510
- * contract; init's own storage client and migration SQL checks come last.
517
+ * contract; init's own gateway binding, storage client and migration SQL checks come last.
511
518
  */
512
519
  async function judgeSource(
513
520
  context: SourceCheckContext &
@@ -528,6 +535,7 @@ async function judgeSource(
528
535
  ...source.findings.map((finding) => requiredCatalogBlocker(finding, context.applicationRoot)),
529
536
  ...(await workerModuleBlockers(context)),
530
537
  ...(await databaseContractBlockers(context, context.provider)),
538
+ ...(await gatewayBindingBlockers(context)),
531
539
  ...(await storageClientBlockers(context)),
532
540
  ...(await migrationSqlBlockers(context, context.provider)),
533
541
  ],
@@ -621,6 +629,168 @@ async function workerModuleBlockers(context: SourceCheckContext): Promise<Reposi
621
629
  );
622
630
  }
623
631
 
632
+ /** Keep literal text inert, but scan executable template interpolations as ordinary code. */
633
+ function gatewaySourceTokens(source: string): readonly string[] {
634
+ const tokens: string[] = [];
635
+ let cursor = 0;
636
+ const quoted = (quote: string) => {
637
+ const start = cursor++;
638
+ while (cursor < source.length) {
639
+ const character = source[cursor++];
640
+ if (character === "\\") cursor++;
641
+ else if (character === quote) break;
642
+ }
643
+ tokens.push(source.slice(start, cursor));
644
+ };
645
+ const code = (interpolation: boolean): void => {
646
+ let braces = 0;
647
+ while (cursor < source.length) {
648
+ const character = source[cursor] as string;
649
+ if (interpolation && character === "}" && braces === 0) {
650
+ cursor++;
651
+ return;
652
+ }
653
+ if (character === '"' || character === "'") {
654
+ quoted(character);
655
+ continue;
656
+ }
657
+ if (character === "`") {
658
+ cursor++;
659
+ while (cursor < source.length) {
660
+ const literal = source[cursor++];
661
+ if (literal === "\\") cursor++;
662
+ else if (literal === "`") break;
663
+ else if (literal === "$" && source[cursor] === "{") {
664
+ cursor++;
665
+ tokens.push("(");
666
+ code(true);
667
+ tokens.push(")");
668
+ }
669
+ }
670
+ continue;
671
+ }
672
+ if (/\s/u.test(character)) {
673
+ cursor++;
674
+ continue;
675
+ }
676
+ if (character === "{") braces++;
677
+ if (character === "}") braces--;
678
+ GATEWAY_WORD_TOKEN.lastIndex = cursor;
679
+ const word = GATEWAY_WORD_TOKEN.exec(source)?.[0];
680
+ const token =
681
+ word ??
682
+ (["?.", "=>"].includes(source.slice(cursor, cursor + 2))
683
+ ? source.slice(cursor, cursor + 2)
684
+ : character);
685
+ tokens.push(token);
686
+ cursor += token.length;
687
+ }
688
+ };
689
+ code(false);
690
+ return tokens;
691
+ }
692
+
693
+ /** Static value accesses only: declarations, literal messages and object-literal keys are inert. */
694
+ function usesGatewayBinding(source: string): boolean {
695
+ const tokens = gatewaySourceTokens(sourceWithoutComments(source));
696
+ const objects: boolean[] = [];
697
+ let declaration: "type" | "interface" | "annotation" | undefined;
698
+ let declarationDepth = 0;
699
+ for (let index = 0; index < tokens.length; index++) {
700
+ const token = tokens[index] as string;
701
+ const previous = tokens[index - 1];
702
+ const next = tokens[index + 1];
703
+ if (declaration !== undefined) {
704
+ if (
705
+ declarationDepth === 0 &&
706
+ (token === ";" || /^(?:export|const|let|var|function|class|type|interface)$/u.test(token))
707
+ ) {
708
+ declaration = undefined;
709
+ if (token === ";") continue;
710
+ } else {
711
+ if (token === "{" || token === "(" || token === "[") declarationDepth++;
712
+ if (token === "}" || token === ")" || token === "]") {
713
+ declarationDepth--;
714
+ if (declaration !== "type" && declarationDepth === 0) declaration = undefined;
715
+ }
716
+ continue;
717
+ }
718
+ }
719
+ if (
720
+ (token === "type" || token === "interface") &&
721
+ next !== undefined &&
722
+ /^[\p{ID_Start}_$][\p{ID_Continue}$]*$/u.test(next) &&
723
+ previous !== "." &&
724
+ previous !== "?."
725
+ ) {
726
+ declaration = token;
727
+ continue;
728
+ }
729
+ // An inline variable type is not an object binding pattern; resume at its initializer.
730
+ if (token === "{" && previous === ":" && /^(?:const|let|var)$/u.test(tokens[index - 3] ?? "")) {
731
+ declaration = "annotation";
732
+ declarationDepth = 1;
733
+ continue;
734
+ }
735
+ if (token === "{") objects.push(false);
736
+ if (token === "}") {
737
+ const gateway = objects.pop();
738
+ let after = index + 1;
739
+ if (tokens[after] === ":") {
740
+ after++;
741
+ while (after < tokens.length && !/^(?:=|;|\{|\})$/u.test(tokens[after] as string)) after++;
742
+ }
743
+ if (gateway && tokens[after] === "=") return true;
744
+ }
745
+ const quoted = token.startsWith('"') || token.startsWith("'");
746
+ const name = quoted ? token.slice(1, -1) : token;
747
+ if (!GATEWAY_BINDING_NAME.test(name)) continue;
748
+ if (!quoted && (previous === "." || previous === "?.")) return true;
749
+ const receiver = tokens[index - (tokens[index - 2] === "?." ? 3 : 2)] ?? "";
750
+ if (
751
+ quoted &&
752
+ previous === "[" &&
753
+ next === "]" &&
754
+ (/^[\p{ID_Start}_$][\p{ID_Continue}$]*$/u.test(receiver) ||
755
+ receiver === ")" ||
756
+ receiver === "]") &&
757
+ !/^(?:return|throw|yield|case|new|typeof|void|delete|await|in|of)$/u.test(receiver)
758
+ )
759
+ return true;
760
+ if (
761
+ objects.length > 0 &&
762
+ (previous === "{" || previous === ",") &&
763
+ (next === ":" || next === "," || next === "}" || next === "=")
764
+ )
765
+ objects[objects.length - 1] = true;
766
+ }
767
+ return false;
768
+ }
769
+
770
+ /**
771
+ * Init names legacy mail/storage bindings before a customer builds code whose gateway calls the
772
+ * egress policy cannot serve. This local guidance does not change planning's admission policy.
773
+ */
774
+ async function gatewayBindingBlockers(
775
+ context: SourceCheckContext,
776
+ ): Promise<RepositoryInitBlocker[]> {
777
+ const blockers: RepositoryInitBlocker[] = [];
778
+ for (const path of context.files) {
779
+ if (!SOURCE_MODULE_PATTERN.test(path) || /\.d\.[cm]?ts$/u.test(path)) continue;
780
+ const absolute = resolve(context.applicationDirectory, path);
781
+ const information = await stat(absolute);
782
+ if (!information.isFile() || information.size > MAXIMUM_SOURCE_BYTES) continue;
783
+ if (!usesGatewayBinding(await readFile(absolute, "utf8"))) continue;
784
+ blockers.push({
785
+ code: "gateway_binding_unsupported",
786
+ path: repositoryPath(context.applicationRoot, path),
787
+ message:
788
+ "Mail and storage gateway Service Bindings are unsupported. In server code, create the client from its OHMYHOST_MAIL_GATEWAY_URL or OHMYHOST_STORAGE_GATEWAY_URL, environment key and project ID, plus the environment ID for storage. Use the global fetch with fetch: (request) => fetch(request), and capabilityFetch: (request) => fetch(request) for storage signed URLs.",
789
+ });
790
+ }
791
+ return blockers;
792
+ }
793
+
624
794
  /**
625
795
  * Declared storage is reached only through the private gateway client. A storage-enabled app whose
626
796
  * source never constructs that client deploys green and fails at its first upload, so name it here.
@@ -641,7 +811,7 @@ async function storageClientBlockers(
641
811
  code: "storage_client_missing",
642
812
  path: null,
643
813
  message:
644
- 'ohmyhost.yaml declares storage, but no application source calls createPrivateStorageClient. Import it from "@ohmyhost/customer-runtime/storage" and build it in server code from OHMYHOST_STORAGE_GATEWAY, OHMYHOST_STORAGE_GATEWAY_URL, OHMYHOST_PROJECT_ID, OHMYHOST_ENVIRONMENT_ID and OHMYHOST_STORAGE_KEY, then reserve an upload, PUT the bytes to the returned signed URL, complete it, and serve reads with createSignedRead. There is no FILES bucket binding.',
814
+ 'ohmyhost.yaml declares storage, but no application source calls createPrivateStorageClient. Import it from "@ohmyhost/customer-runtime/storage" and build it in server code from OHMYHOST_STORAGE_GATEWAY_URL, OHMYHOST_PROJECT_ID, OHMYHOST_ENVIRONMENT_ID and OHMYHOST_STORAGE_KEY, with the global fetch as fetch: (request) => fetch(request) and capabilityFetch: (request) => fetch(request). Reserve an upload, PUT the bytes to the returned signed URL, complete it, and serve reads with createSignedRead. There is no FILES bucket binding.',
645
815
  },
646
816
  ];
647
817
  }
@@ -1418,10 +1588,12 @@ function runtimeContract(capabilities: RuntimeCapabilities): Readonly<{
1418
1588
  return Object.freeze({
1419
1589
  bindings: Object.freeze(
1420
1590
  [
1421
- ...(capabilities.auth ? ["BETTER_AUTH_SECRET"] : []),
1422
- ...(capabilities.storage ? STORAGE_RUNTIME_BINDINGS : []),
1423
- ...(capabilities.database ? ["OHMYHOST_DATABASE"] : []),
1424
- ...(capabilities.mail ? ["OHMYHOST_MAIL_KEY"] : []),
1591
+ ...new Set([
1592
+ ...(capabilities.auth ? ["BETTER_AUTH_SECRET"] : []),
1593
+ ...(capabilities.storage ? STORAGE_RUNTIME_BINDINGS : []),
1594
+ ...(capabilities.database ? ["OHMYHOST_DATABASE"] : []),
1595
+ ...(capabilities.mail ? MAIL_RUNTIME_BINDINGS : []),
1596
+ ]),
1425
1597
  ].sort(),
1426
1598
  ),
1427
1599
  packages: Object.freeze({