@the-meridian/cli 1.0.3 → 1.2.0

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
@@ -67,6 +67,7 @@ var deviceInfoResponseSchema = z.object({
67
67
  created_at: z.string().optional()
68
68
  }).loose();
69
69
  var devDatabaseStatusSchema = z.union([z.enum(["provisioning", "ready", "error"]), z.string()]);
70
+ var devDatabaseAccessSchema = z.union([z.enum(["gateway", "proxy"]), z.string()]);
70
71
  var devDatabaseSchema = z.object({
71
72
  id: idSchema,
72
73
  status: devDatabaseStatusSchema,
@@ -78,7 +79,21 @@ var devDatabaseSchema = z.object({
78
79
  region: z.string().nullish(),
79
80
  created_at: z.string().nullish(),
80
81
  /** Set by the API only while `status` is `error`; surfaced verbatim to the developer. */
81
- error_message: z.string().nullish()
82
+ error_message: z.string().nullish(),
83
+ /**
84
+ * How the CLI reaches the database: `gateway` (the Meridian database
85
+ * gateway with a per-developer certificate) or `proxy` (cloud-sql-proxy).
86
+ * Absent from an API older than the gateway, which means `proxy`.
87
+ */
88
+ access: devDatabaseAccessSchema.nullish()
89
+ }).loose();
90
+ var devDatabaseCertificateSchema = z.object({
91
+ certificate_pem: z.string(),
92
+ ca_pem: z.string(),
93
+ host: z.string(),
94
+ port: z.coerce.number(),
95
+ expires_at: z.string(),
96
+ fingerprint: z.string()
82
97
  }).loose();
83
98
  var devCredentialsSchema = z.object({
84
99
  host: z.string(),
@@ -117,6 +132,51 @@ var migrationRunSchema = z.object({
117
132
  output_excerpt: z.string().nullish(),
118
133
  created_at: z.string().nullish()
119
134
  }).loose();
135
+ function envelopeSchema(data) {
136
+ return z.object({ data }).loose();
137
+ }
138
+ var hostingAddonSchema = z.object({
139
+ uuid: z.string(),
140
+ type: z.string().nullish(),
141
+ name: z.string().nullish(),
142
+ status: z.string().nullish(),
143
+ database: z.object({
144
+ type: z.string().nullish(),
145
+ database: z.string().nullish()
146
+ }).loose().nullish()
147
+ }).loose();
148
+ var hostingEnvironmentSchema = z.object({
149
+ uuid: z.string(),
150
+ type: z.string().nullish(),
151
+ name: z.string().nullish(),
152
+ addons: z.array(hostingAddonSchema).nullish()
153
+ }).loose();
154
+ var externalCredentialSchema = z.object({
155
+ id: z.string(),
156
+ label: z.string(),
157
+ username: z.string(),
158
+ access_mode: z.string(),
159
+ max_connections: z.number().nullish(),
160
+ allowed_cidrs: z.array(z.string()).nullish(),
161
+ certificate_serial: z.string().nullish(),
162
+ certificate_expires_at: z.string().nullish(),
163
+ last_connected_at: z.string().nullish(),
164
+ last_connected_ip: z.string().nullish(),
165
+ locked_until: z.string().nullish(),
166
+ status: z.string(),
167
+ revoked_at: z.string().nullish(),
168
+ created_at: z.string().nullish()
169
+ }).loose();
170
+ var externalCredentialListMetaSchema = z.object({
171
+ available: z.boolean().nullish(),
172
+ host: z.string().nullish(),
173
+ port: z.coerce.number().nullish(),
174
+ max_credentials: z.number().nullish()
175
+ }).loose();
176
+ var externalCredentialListSchema = z.object({
177
+ data: z.array(externalCredentialSchema),
178
+ meta: externalCredentialListMetaSchema.nullish()
179
+ }).loose();
120
180
 
121
181
  // src/lib/errors.ts
122
182
  var CliError = class extends Error {
@@ -297,6 +357,18 @@ var ApiClient = class _ApiClient {
297
357
  `/app/cli/apps/${encodePathSegment(appId)}/dev-database/proxy-token`
298
358
  );
299
359
  }
360
+ /**
361
+ * Signs `publicKeyPem` (SPKI) as this developer's client certificate for the
362
+ * database gateway. Each call replaces the previous certificate server-side.
363
+ */
364
+ async issueDevDatabaseCertificate(appId, publicKeyPem) {
365
+ return this.request(
366
+ devDatabaseCertificateSchema,
367
+ "POST",
368
+ `/app/cli/apps/${encodePathSegment(appId)}/dev-database/certificate`,
369
+ { body: { public_key: publicKeyPem } }
370
+ );
371
+ }
300
372
  async resetDevDatabase(appId) {
301
373
  return this.request(devDatabaseSchema, "POST", `/app/cli/apps/${encodePathSegment(appId)}/dev-database/reset`);
302
374
  }
@@ -313,6 +385,28 @@ var ApiClient = class _ApiClient {
313
385
  `/app/cli/apps/${encodePathSegment(appId)}/dev-database/migrations`
314
386
  );
315
387
  }
388
+ /** `GET /app/apps/{app}/environments`, each with its add-ons. Used to find an environment's database. */
389
+ async listEnvironments(appId) {
390
+ const body = await this.request(
391
+ envelopeSchema(z2.array(hostingEnvironmentSchema)),
392
+ "GET",
393
+ `/app/apps/${encodePathSegment(appId)}/environments`
394
+ );
395
+ return body.data;
396
+ }
397
+ /** `GET /app/addons/{addon}/database/external-credentials`: the credentials, plus whether external access is on. */
398
+ async listExternalCredentials(addonId) {
399
+ const body = await this.request(externalCredentialListSchema, "GET", externalCredentialsPath(addonId));
400
+ return { credentials: body.data, meta: body.meta ?? {} };
401
+ }
402
+ async revokeExternalCredential(addonId, credentialId) {
403
+ const body = await this.request(
404
+ envelopeSchema(externalCredentialSchema),
405
+ "DELETE",
406
+ `${externalCredentialsPath(addonId)}/${encodePathSegment(credentialId)}`
407
+ );
408
+ return body.data;
409
+ }
316
410
  #buildUrl(path, query) {
317
411
  const url = new URL(`${this.baseUrl}${path}`);
318
412
  if (query !== void 0) {
@@ -370,6 +464,9 @@ var ApiClient = class _ApiClient {
370
464
  function encodePathSegment(id) {
371
465
  return encodeURIComponent(String(id));
372
466
  }
467
+ function externalCredentialsPath(addonId) {
468
+ return `/app/addons/${encodePathSegment(addonId)}/database/external-credentials`;
469
+ }
373
470
  function parseJson(text, method, path) {
374
471
  try {
375
472
  return JSON.parse(text);
@@ -452,9 +549,12 @@ function extractErrorCode(body) {
452
549
  return record !== void 0 && typeof record.error === "string" ? record.error : void 0;
453
550
  }
454
551
  var HINTS_BY_ERROR_CODE = {
455
- dev_database_limit_reached: "Your plan does not include a development database for you on this app. Every collaborator gets their own out of the plan's allowance, so upgrading the organization's plan in the Meridian dashboard is what unlocks it.",
552
+ dev_database_limit_reached: "Your plan does not include a developer database for you on this app. Every collaborator gets their own out of the plan's allowance, so upgrading the organization's plan in the Meridian dashboard is what unlocks it.",
553
+ dev_databases_unavailable: "This Meridian environment does not offer developer databases. Run `meridian dev` against production (the default API URL) instead.",
456
554
  dev_database_over_storage_limit: "Run `meridian db reset` to drop and recreate the schema. Reset and delete stay available while you're over the limit.",
457
- FEATURE_NOT_ENTITLED: "Your organization's plan does not include managed development databases; upgrade it in the Meridian dashboard."
555
+ cli_upgrade_required: "Upgrade the Meridian CLI: npm i -g @the-meridian/cli",
556
+ EXTERNAL_ACCESS_UNAVAILABLE: "External access is not switched on for this database yet, or the database is not ready. Check the database under Hosting in the Meridian dashboard.",
557
+ FEATURE_NOT_ENTITLED: "Your organization's plan does not include managed developer databases; upgrade it in the Meridian dashboard."
458
558
  };
459
559
  function hintForError(status, errorCode) {
460
560
  if (errorCode !== void 0) {
@@ -465,7 +565,7 @@ function hintForError(status, errorCode) {
465
565
  if (status === 403) return "You may not have access to this organization or app; check your Meridian membership.";
466
566
  if (status === 404) return "Run `meridian link` to select a valid organization and app.";
467
567
  if (status === 409) {
468
- return "The development database is not ready yet. Wait for provisioning to finish, then try again. `meridian dev` polls for you.";
568
+ return "The developer database is not ready yet. Wait for provisioning to finish, then try again. `meridian dev` polls for you.";
469
569
  }
470
570
  if (status === 422) return "Check the request for validation errors and try again.";
471
571
  if (status === 429) return "You're being rate limited by the Meridian API; wait a moment and try again.";
@@ -1671,14 +1771,770 @@ function redactDatabaseUrl(url) {
1671
1771
  return url.replace(/^(mysql:\/\/[^:@/]+):([^@]*)@/, "$1:****@");
1672
1772
  }
1673
1773
 
1774
+ // src/lib/gateway/certificate.ts
1775
+ import { generateKeyPair } from "crypto";
1776
+ import { createSecureContext } from "tls";
1777
+ import { promisify } from "util";
1778
+ var generateKeyPairAsync = promisify(generateKeyPair);
1779
+ function gatewayCertificateFromPem(material) {
1780
+ return {
1781
+ host: material.host,
1782
+ port: material.port,
1783
+ expiresAt: material.expiresAt,
1784
+ fingerprint: material.fingerprint,
1785
+ secureContext: createSecureContext({
1786
+ key: material.keyPem,
1787
+ cert: material.certificatePem,
1788
+ ca: material.caPem,
1789
+ minVersion: "TLSv1.2"
1790
+ })
1791
+ };
1792
+ }
1793
+ async function generateGatewayKeyPair() {
1794
+ const { publicKey, privateKey } = await generateKeyPairAsync("ec", { namedCurve: "prime256v1" });
1795
+ return {
1796
+ publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
1797
+ privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString()
1798
+ };
1799
+ }
1800
+ async function issueGatewayCertificate(client, appId) {
1801
+ const { publicKeyPem, privateKeyPem } = await generateGatewayKeyPair();
1802
+ const issued = await client.issueDevDatabaseCertificate(appId, publicKeyPem);
1803
+ return gatewayCertificateFromPem({
1804
+ keyPem: privateKeyPem,
1805
+ certificatePem: issued.certificate_pem,
1806
+ caPem: issued.ca_pem,
1807
+ host: issued.host,
1808
+ port: issued.port,
1809
+ expiresAt: issued.expires_at,
1810
+ fingerprint: issued.fingerprint
1811
+ });
1812
+ }
1813
+ var ASSUMED_LIFETIME_MS = 24 * 60 * 6e4;
1814
+ var DEFAULT_REFRESH_BEFORE_MS = 4 * 60 * 6e4;
1815
+ var MIN_REFRESH_DELAY_MS = 6e4;
1816
+ function certificateRefreshDelay(expiresAt, now, refreshBeforeMs = DEFAULT_REFRESH_BEFORE_MS) {
1817
+ const parsed = Date.parse(expiresAt);
1818
+ const expiry = Number.isNaN(parsed) ? now + ASSUMED_LIFETIME_MS : parsed;
1819
+ return Math.max(MIN_REFRESH_DELAY_MS, expiry - refreshBeforeMs - now);
1820
+ }
1821
+
1822
+ // src/lib/gateway/forwarder.ts
1823
+ import { connect as netConnect, createServer, isIP } from "net";
1824
+ import { connect as tlsConnect } from "tls";
1825
+
1826
+ // src/lib/gateway/packets.ts
1827
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
1828
+ var CLIENT = {
1829
+ LONG_PASSWORD: 1,
1830
+ FOUND_ROWS: 2,
1831
+ LONG_FLAG: 4,
1832
+ CONNECT_WITH_DB: 8,
1833
+ NO_SCHEMA: 16,
1834
+ COMPRESS: 32,
1835
+ ODBC: 64,
1836
+ LOCAL_FILES: 128,
1837
+ IGNORE_SPACE: 256,
1838
+ PROTOCOL_41: 512,
1839
+ INTERACTIVE: 1024,
1840
+ SSL: 2048,
1841
+ IGNORE_SIGPIPE: 4096,
1842
+ TRANSACTIONS: 8192,
1843
+ RESERVED: 16384,
1844
+ SECURE_CONNECTION: 32768,
1845
+ MULTI_STATEMENTS: 65536,
1846
+ MULTI_RESULTS: 131072,
1847
+ PS_MULTI_RESULTS: 262144,
1848
+ PLUGIN_AUTH: 524288,
1849
+ CONNECT_ATTRS: 1048576,
1850
+ PLUGIN_AUTH_LENENC_CLIENT_DATA: 2097152,
1851
+ CAN_HANDLE_EXPIRED_PASSWORDS: 4194304,
1852
+ SESSION_TRACK: 8388608,
1853
+ DEPRECATE_EOF: 16777216,
1854
+ OPTIONAL_RESULTSET_METADATA: 33554432,
1855
+ ZSTD_COMPRESSION: 67108864,
1856
+ QUERY_ATTRIBUTES: 134217728
1857
+ };
1858
+ var GATEWAY_CAPABILITIES = CLIENT.LONG_PASSWORD | CLIENT.FOUND_ROWS | CLIENT.LONG_FLAG | CLIENT.CONNECT_WITH_DB | CLIENT.IGNORE_SPACE | CLIENT.PROTOCOL_41 | CLIENT.INTERACTIVE | CLIENT.SSL | CLIENT.IGNORE_SIGPIPE | CLIENT.TRANSACTIONS | CLIENT.RESERVED | CLIENT.SECURE_CONNECTION | CLIENT.MULTI_STATEMENTS | CLIENT.MULTI_RESULTS | CLIENT.PS_MULTI_RESULTS | CLIENT.PLUGIN_AUTH | CLIENT.CONNECT_ATTRS | CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA | CLIENT.CAN_HANDLE_EXPIRED_PASSWORDS | CLIENT.SESSION_TRACK | CLIENT.DEPRECATE_EOF;
1859
+ var LOCAL_CAPABILITIES = GATEWAY_CAPABILITIES & ~CLIENT.SSL;
1860
+ var PLUGIN_NATIVE = "mysql_native_password";
1861
+ var PLUGIN_CACHING_SHA2 = "caching_sha2_password";
1862
+ var SCRAMBLE_LENGTH = 20;
1863
+ var DEFAULT_CHARSET = 255;
1864
+ var MAX_PAYLOAD = 16777215;
1865
+ var MAX_HANDSHAKE_PACKET = 64 * 1024;
1866
+ var AUTH_MORE_DATA = 1;
1867
+ var FAST_AUTH_SUCCESS = 3;
1868
+ var PERFORM_FULL_AUTH = 4;
1869
+ var SERVER_STATUS_AUTOCOMMIT = 2;
1870
+ function encodePacket(seq, payload) {
1871
+ if (payload.length >= MAX_PAYLOAD) throw new Error(`refusing to write a ${payload.length} byte handshake packet`);
1872
+ const header = Buffer.alloc(4);
1873
+ header.writeUIntLE(payload.length, 0, 3);
1874
+ header[3] = seq & 255;
1875
+ return Buffer.concat([header, payload]);
1876
+ }
1877
+ var PacketReader = class {
1878
+ #socket;
1879
+ #maxPacket;
1880
+ #buffer = Buffer.alloc(0);
1881
+ #failure;
1882
+ #waiter;
1883
+ #onData = (chunk) => {
1884
+ this.#buffer = this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]);
1885
+ this.#settle();
1886
+ };
1887
+ #onClose = () => {
1888
+ this.#fail(new Error("the connection closed during the MySQL handshake"));
1889
+ };
1890
+ #onError = (error2) => {
1891
+ this.#fail(error2);
1892
+ };
1893
+ constructor(socket, maxPacket = MAX_HANDSHAKE_PACKET) {
1894
+ this.#socket = socket;
1895
+ this.#maxPacket = maxPacket;
1896
+ socket.on("data", this.#onData);
1897
+ socket.on("end", this.#onClose);
1898
+ socket.on("close", this.#onClose);
1899
+ socket.on("error", this.#onError);
1900
+ }
1901
+ read() {
1902
+ return new Promise((resolve2, reject) => {
1903
+ this.#waiter = { resolve: resolve2, reject };
1904
+ this.#settle();
1905
+ });
1906
+ }
1907
+ /** Stops reading. `pause` keeps later bytes in the socket for a pipe() to pick up. */
1908
+ release(options) {
1909
+ if (options.pause) this.#socket.pause();
1910
+ this.#socket.off("data", this.#onData);
1911
+ this.#socket.off("end", this.#onClose);
1912
+ this.#socket.off("close", this.#onClose);
1913
+ this.#socket.off("error", this.#onError);
1914
+ const rest = this.#buffer;
1915
+ this.#buffer = Buffer.alloc(0);
1916
+ return rest;
1917
+ }
1918
+ #fail(error2) {
1919
+ if (this.#failure === void 0) this.#failure = error2;
1920
+ this.#settle();
1921
+ }
1922
+ #settle() {
1923
+ const waiter = this.#waiter;
1924
+ if (waiter === void 0) return;
1925
+ if (this.#buffer.length >= 4) {
1926
+ const length = this.#buffer.readUIntLE(0, 3);
1927
+ if (length > this.#maxPacket) {
1928
+ this.#waiter = void 0;
1929
+ waiter.reject(new Error(`a ${length} byte packet during the MySQL handshake is too large`));
1930
+ return;
1931
+ }
1932
+ if (this.#buffer.length >= 4 + length) {
1933
+ const seq = this.#buffer[3] ?? 0;
1934
+ const payload = Buffer.from(this.#buffer.subarray(4, 4 + length));
1935
+ this.#buffer = this.#buffer.subarray(4 + length);
1936
+ this.#waiter = void 0;
1937
+ waiter.resolve({ seq, payload });
1938
+ return;
1939
+ }
1940
+ }
1941
+ if (this.#failure !== void 0) {
1942
+ this.#waiter = void 0;
1943
+ waiter.reject(this.#failure);
1944
+ }
1945
+ }
1946
+ };
1947
+ var Cursor = class {
1948
+ constructor(buf) {
1949
+ this.buf = buf;
1950
+ }
1951
+ buf;
1952
+ pos = 0;
1953
+ get remaining() {
1954
+ return this.buf.length - this.pos;
1955
+ }
1956
+ bytes(n, what) {
1957
+ if (n < 0 || this.remaining < n) throw new Error(`truncated packet reading ${what}`);
1958
+ const out = this.buf.subarray(this.pos, this.pos + n);
1959
+ this.pos += n;
1960
+ return out;
1961
+ }
1962
+ u8(what) {
1963
+ return this.bytes(1, what)[0] ?? 0;
1964
+ }
1965
+ u16(what) {
1966
+ return this.bytes(2, what).readUInt16LE(0);
1967
+ }
1968
+ u32(what) {
1969
+ return this.bytes(4, what).readUInt32LE(0);
1970
+ }
1971
+ nulString(what) {
1972
+ const end = this.buf.indexOf(0, this.pos);
1973
+ if (end < 0) throw new Error(`truncated packet reading ${what}`);
1974
+ const out = this.buf.subarray(this.pos, end);
1975
+ this.pos = end + 1;
1976
+ return out;
1977
+ }
1978
+ /** Everything left, without a trailing NUL. */
1979
+ restNul() {
1980
+ let out = this.buf.subarray(this.pos);
1981
+ this.pos = this.buf.length;
1982
+ if (out.length > 0 && out[out.length - 1] === 0) out = out.subarray(0, out.length - 1);
1983
+ return out;
1984
+ }
1985
+ lenencInt(what) {
1986
+ const first = this.u8(what);
1987
+ if (first < 251) return first;
1988
+ if (first === 252) return this.u16(what);
1989
+ if (first === 253) return this.bytes(3, what).readUIntLE(0, 3);
1990
+ if (first === 254) {
1991
+ const value = this.bytes(8, what).readBigUInt64LE(0);
1992
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`${what} is too large`);
1993
+ return Number(value);
1994
+ }
1995
+ throw new Error(`invalid length-encoded integer reading ${what}`);
1996
+ }
1997
+ lenencBytes(what) {
1998
+ return this.bytes(this.lenencInt(what), what);
1999
+ }
2000
+ };
2001
+ function lenencInt(n) {
2002
+ if (n < 251) return Buffer.from([n]);
2003
+ if (n <= 65535) {
2004
+ const b2 = Buffer.alloc(3);
2005
+ b2[0] = 252;
2006
+ b2.writeUInt16LE(n, 1);
2007
+ return b2;
2008
+ }
2009
+ if (n <= 16777215) {
2010
+ const b2 = Buffer.alloc(4);
2011
+ b2[0] = 253;
2012
+ b2.writeUIntLE(n, 1, 3);
2013
+ return b2;
2014
+ }
2015
+ const b = Buffer.alloc(9);
2016
+ b[0] = 254;
2017
+ b.writeBigUInt64LE(BigInt(n), 1);
2018
+ return b;
2019
+ }
2020
+ function lenencBytes(value) {
2021
+ return Buffer.concat([lenencInt(value.length), value]);
2022
+ }
2023
+ function u16(n) {
2024
+ const b = Buffer.alloc(2);
2025
+ b.writeUInt16LE(n & 65535, 0);
2026
+ return b;
2027
+ }
2028
+ function u32(n) {
2029
+ const b = Buffer.alloc(4);
2030
+ b.writeUInt32LE(n >>> 0, 0);
2031
+ return b;
2032
+ }
2033
+ var NUL = Buffer.from([0]);
2034
+ function encodeGreeting(g) {
2035
+ if (g.scramble.length !== SCRAMBLE_LENGTH) throw new Error("the greeting scramble must be 20 bytes");
2036
+ return Buffer.concat([
2037
+ Buffer.from([10]),
2038
+ Buffer.from(g.serverVersion, "utf8"),
2039
+ NUL,
2040
+ u32(g.connectionId),
2041
+ g.scramble.subarray(0, 8),
2042
+ NUL,
2043
+ u16(g.capabilities),
2044
+ Buffer.from([g.charset]),
2045
+ u16(g.status),
2046
+ u16(g.capabilities >>> 16),
2047
+ Buffer.from([SCRAMBLE_LENGTH + 1]),
2048
+ Buffer.alloc(10),
2049
+ g.scramble.subarray(8),
2050
+ NUL,
2051
+ Buffer.from(g.authPlugin, "utf8"),
2052
+ NUL
2053
+ ]);
2054
+ }
2055
+ function localGreeting(connectionId, scramble, serverVersion) {
2056
+ return {
2057
+ serverVersion,
2058
+ connectionId,
2059
+ scramble,
2060
+ capabilities: LOCAL_CAPABILITIES,
2061
+ charset: DEFAULT_CHARSET,
2062
+ status: SERVER_STATUS_AUTOCOMMIT,
2063
+ authPlugin: PLUGIN_NATIVE
2064
+ };
2065
+ }
2066
+ function parseGreeting(payload) {
2067
+ if (payload[0] === 255) throw serverErrorFromPacket(payload);
2068
+ const r = new Cursor(payload);
2069
+ const version = r.u8("protocol version");
2070
+ if (version !== 10) throw new Error(`unsupported handshake version ${version}`);
2071
+ const serverVersion = r.nulString("server version").toString("utf8");
2072
+ const connectionId = r.u32("connection id");
2073
+ const part1 = Buffer.from(r.bytes(8, "scramble part 1"));
2074
+ r.u8("filler");
2075
+ let capabilities = r.u16("capabilities");
2076
+ let charset = DEFAULT_CHARSET;
2077
+ let status = 0;
2078
+ let authPlugin = "";
2079
+ let scramble = part1;
2080
+ if (r.remaining > 0) {
2081
+ charset = r.u8("charset");
2082
+ status = r.u16("status");
2083
+ capabilities = (capabilities | r.u16("capabilities upper") << 16) >>> 0;
2084
+ const authLength = r.u8("auth data length");
2085
+ r.bytes(10, "reserved");
2086
+ if ((capabilities & CLIENT.SECURE_CONNECTION) !== 0) {
2087
+ let part2 = r.bytes(Math.max(13, authLength - 8), "scramble part 2");
2088
+ if (part2.length > 0 && part2[part2.length - 1] === 0) part2 = part2.subarray(0, part2.length - 1);
2089
+ scramble = Buffer.concat([part1, part2]);
2090
+ }
2091
+ if ((capabilities & CLIENT.PLUGIN_AUTH) !== 0) authPlugin = r.restNul().toString("utf8");
2092
+ }
2093
+ if (scramble.length < SCRAMBLE_LENGTH) throw new Error(`the server scramble is ${scramble.length} bytes`);
2094
+ return {
2095
+ serverVersion,
2096
+ connectionId,
2097
+ scramble: scramble.subarray(0, SCRAMBLE_LENGTH),
2098
+ capabilities,
2099
+ charset,
2100
+ status,
2101
+ authPlugin: authPlugin === "" ? PLUGIN_NATIVE : authPlugin
2102
+ };
2103
+ }
2104
+ function encodeSslRequest(capabilities, maxPacketSize, charset) {
2105
+ return Buffer.concat([u32(capabilities | CLIENT.SSL), u32(maxPacketSize), Buffer.from([charset]), Buffer.alloc(23)]);
2106
+ }
2107
+ function parseHandshakeResponse(payload) {
2108
+ const r = new Cursor(payload);
2109
+ const capabilities = r.u32("capabilities");
2110
+ if ((capabilities & CLIENT.PROTOCOL_41) === 0) throw new Error("the client does not speak protocol 4.1");
2111
+ const maxPacketSize = r.u32("max packet size");
2112
+ const charset = r.u8("charset");
2113
+ r.bytes(23, "filler");
2114
+ if (r.remaining === 0) {
2115
+ return { capabilities, maxPacketSize, charset, username: "", authResponse: Buffer.alloc(0), database: "", authPlugin: "", attributes: [] };
2116
+ }
2117
+ const username = r.nulString("username").toString("utf8");
2118
+ let authResponse;
2119
+ if ((capabilities & CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA) !== 0) authResponse = r.lenencBytes("auth response");
2120
+ else if ((capabilities & CLIENT.SECURE_CONNECTION) !== 0) authResponse = r.bytes(r.u8("auth response length"), "auth response");
2121
+ else authResponse = r.nulString("auth response");
2122
+ let database = "";
2123
+ if ((capabilities & CLIENT.CONNECT_WITH_DB) !== 0 && r.remaining > 0) database = r.nulString("database").toString("utf8");
2124
+ let authPlugin = "";
2125
+ if ((capabilities & CLIENT.PLUGIN_AUTH) !== 0 && r.remaining > 0) authPlugin = r.nulString("auth plugin").toString("utf8");
2126
+ const attributes = [];
2127
+ if ((capabilities & CLIENT.CONNECT_ATTRS) !== 0 && r.remaining > 0) {
2128
+ const attrs = new Cursor(r.lenencBytes("attributes"));
2129
+ while (attrs.remaining > 0) {
2130
+ const key = attrs.lenencBytes("attribute key").toString("utf8");
2131
+ const value = attrs.lenencBytes("attribute value").toString("utf8");
2132
+ attributes.push([key, value]);
2133
+ }
2134
+ }
2135
+ return {
2136
+ capabilities,
2137
+ maxPacketSize,
2138
+ charset,
2139
+ username,
2140
+ authResponse: Buffer.from(authResponse),
2141
+ database,
2142
+ authPlugin,
2143
+ attributes
2144
+ };
2145
+ }
2146
+ function encodeHandshakeResponse(h) {
2147
+ const parts = [u32(h.capabilities), u32(h.maxPacketSize), Buffer.from([h.charset]), Buffer.alloc(23)];
2148
+ parts.push(Buffer.from(h.username, "utf8"), NUL);
2149
+ if ((h.capabilities & CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA) !== 0) parts.push(lenencBytes(h.authResponse));
2150
+ else if ((h.capabilities & CLIENT.SECURE_CONNECTION) !== 0) parts.push(Buffer.from([h.authResponse.length]), h.authResponse);
2151
+ else parts.push(h.authResponse, NUL);
2152
+ if ((h.capabilities & CLIENT.CONNECT_WITH_DB) !== 0) parts.push(Buffer.from(h.database, "utf8"), NUL);
2153
+ if ((h.capabilities & CLIENT.PLUGIN_AUTH) !== 0) parts.push(Buffer.from(h.authPlugin, "utf8"), NUL);
2154
+ if ((h.capabilities & CLIENT.CONNECT_ATTRS) !== 0) {
2155
+ const attrs = h.attributes.map(
2156
+ ([key, value]) => Buffer.concat([lenencBytes(Buffer.from(key, "utf8")), lenencBytes(Buffer.from(value, "utf8"))])
2157
+ );
2158
+ parts.push(lenencBytes(Buffer.concat(attrs)));
2159
+ }
2160
+ return Buffer.concat(parts);
2161
+ }
2162
+ function encodeAuthSwitchRequest(plugin, scramble) {
2163
+ return Buffer.concat([Buffer.from([254]), Buffer.from(plugin, "utf8"), NUL, scramble, NUL]);
2164
+ }
2165
+ function parseAuthSwitchRequest(payload) {
2166
+ if (payload[0] !== 254) throw new Error("not an auth switch request");
2167
+ const r = new Cursor(payload);
2168
+ r.pos = 1;
2169
+ const plugin = r.nulString("auth plugin").toString("utf8");
2170
+ let scramble = Buffer.from(r.restNul());
2171
+ if (scramble.length > SCRAMBLE_LENGTH) scramble = scramble.subarray(0, SCRAMBLE_LENGTH);
2172
+ return { plugin, scramble };
2173
+ }
2174
+ var isOk = (payload) => payload[0] === 0;
2175
+ var isErr = (payload) => payload[0] === 255;
2176
+ var isAuthSwitch = (payload) => payload[0] === 254;
2177
+ var isAuthMoreData = (payload) => payload[0] === AUTH_MORE_DATA;
2178
+ function encodeErr(code, sqlState, message) {
2179
+ const state2 = sqlState.length === 5 ? sqlState : "HY000";
2180
+ return Buffer.concat([Buffer.from([255]), u16(code), Buffer.from(`#${state2}`, "latin1"), Buffer.from(message, "utf8")]);
2181
+ }
2182
+ var MysqlServerError = class extends Error {
2183
+ constructor(code, sqlState, message) {
2184
+ super(message);
2185
+ this.code = code;
2186
+ this.sqlState = sqlState;
2187
+ this.name = "MysqlServerError";
2188
+ }
2189
+ code;
2190
+ sqlState;
2191
+ };
2192
+ function serverErrorFromPacket(payload) {
2193
+ if (payload.length < 3 || payload[0] !== 255) return new MysqlServerError(0, "HY000", "unknown error");
2194
+ const code = payload.readUInt16LE(1);
2195
+ let rest = payload.subarray(3);
2196
+ let sqlState = "HY000";
2197
+ if (rest.length >= 6 && rest[0] === 35) {
2198
+ sqlState = rest.subarray(1, 6).toString("latin1");
2199
+ rest = rest.subarray(6);
2200
+ }
2201
+ return new MysqlServerError(code, sqlState, rest.toString("utf8"));
2202
+ }
2203
+ function newScramble() {
2204
+ const b = randomBytes(SCRAMBLE_LENGTH);
2205
+ for (let i = 0; i < b.length; i++) {
2206
+ let v = (b[i] ?? 0) & 127 | 1;
2207
+ if (v === 36) v = 37;
2208
+ b[i] = v;
2209
+ }
2210
+ return b;
2211
+ }
2212
+ function xor(a, b) {
2213
+ const out = Buffer.alloc(a.length);
2214
+ for (let i = 0; i < a.length; i++) out[i] = (a[i] ?? 0) ^ (b[i] ?? 0);
2215
+ return out;
2216
+ }
2217
+ function scrambleNative(password, nonce) {
2218
+ if (password === "") return Buffer.alloc(0);
2219
+ const h1 = createHash("sha1").update(password, "utf8").digest();
2220
+ const h2 = createHash("sha1").update(h1).digest();
2221
+ const h3 = createHash("sha1").update(nonce).update(h2).digest();
2222
+ return xor(h1, h3);
2223
+ }
2224
+ function scrambleCachingSha2(password, nonce) {
2225
+ if (password === "") return Buffer.alloc(0);
2226
+ const h1 = createHash("sha256").update(password, "utf8").digest();
2227
+ const h2 = createHash("sha256").update(h1).digest();
2228
+ const h3 = createHash("sha256").update(h2).update(nonce).digest();
2229
+ return xor(h1, h3);
2230
+ }
2231
+ function scrambleFor(plugin, password, nonce) {
2232
+ switch (plugin) {
2233
+ case PLUGIN_NATIVE:
2234
+ return scrambleNative(password, nonce);
2235
+ case PLUGIN_CACHING_SHA2:
2236
+ return scrambleCachingSha2(password, nonce);
2237
+ default:
2238
+ throw new Error(`unsupported auth plugin "${plugin}"`);
2239
+ }
2240
+ }
2241
+ function scrambleMatches(received, expected) {
2242
+ return received.length === expected.length && timingSafeEqual(received, expected);
2243
+ }
2244
+
2245
+ // src/lib/gateway/forwarder.ts
2246
+ var DEFAULT_HOST = "127.0.0.1";
2247
+ var DEFAULT_HANDSHAKE_TIMEOUT_MS = 3e4;
2248
+ var REFRESH_RETRY_BACKOFFS_MS = [3e4, 6e4, 12e4, 3e5];
2249
+ var SERVER_VERSION = "8.0.40-meridian-forwarder";
2250
+ var CLOSE_GRACE_MS = 2e3;
2251
+ var ER_ACCESS_DENIED = 1045;
2252
+ var ER_HANDSHAKE = 1043;
2253
+ var CR_CONN_HOST_ERROR = 2003;
2254
+ var LocalRefusal = class extends Error {
2255
+ constructor(code, sqlState, message) {
2256
+ super(message);
2257
+ this.code = code;
2258
+ this.sqlState = sqlState;
2259
+ }
2260
+ code;
2261
+ sqlState;
2262
+ };
2263
+ function accessDenied(username) {
2264
+ return new LocalRefusal(ER_ACCESS_DENIED, "28000", `Access denied for user '${username}'@'localhost' (using password: YES)`);
2265
+ }
2266
+ function upstreamCapabilities(clientCapabilities, database) {
2267
+ let caps = clientCapabilities & LOCAL_CAPABILITIES | CLIENT.SSL | CLIENT.PROTOCOL_41 | CLIENT.SECURE_CONNECTION | CLIENT.PLUGIN_AUTH | CLIENT.PLUGIN_AUTH_LENENC_CLIENT_DATA;
2268
+ if (database !== "") caps |= CLIENT.CONNECT_WITH_DB;
2269
+ return caps >>> 0;
2270
+ }
2271
+ function waitFor(socket, event) {
2272
+ return new Promise((resolve2, reject) => {
2273
+ const onError = (error2) => {
2274
+ socket.off(event, onDone);
2275
+ reject(error2);
2276
+ };
2277
+ const onDone = () => {
2278
+ socket.off("error", onError);
2279
+ resolve2();
2280
+ };
2281
+ socket.once(event, onDone);
2282
+ socket.once("error", onError);
2283
+ });
2284
+ }
2285
+ function splice(a, b) {
2286
+ const closeOther = (other) => () => {
2287
+ if (other.destroyed) return;
2288
+ other.end();
2289
+ setTimeout(() => other.destroy(), CLOSE_GRACE_MS).unref();
2290
+ };
2291
+ a.on("close", closeOther(b));
2292
+ b.on("close", closeOther(a));
2293
+ a.pipe(b);
2294
+ b.pipe(a);
2295
+ }
2296
+ async function startGatewayForwarder(options) {
2297
+ const host = options.host ?? DEFAULT_HOST;
2298
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
2299
+ const refreshBeforeMs = options.refreshBeforeMs ?? DEFAULT_REFRESH_BEFORE_MS;
2300
+ const now = options.now ?? Date.now;
2301
+ const emit = (event) => {
2302
+ try {
2303
+ options.onEvent?.(event);
2304
+ } catch {
2305
+ }
2306
+ };
2307
+ let certificate = options.certificate;
2308
+ let running = false;
2309
+ let stopped = false;
2310
+ let connectionId = 0;
2311
+ let refreshTimer;
2312
+ const sockets = /* @__PURE__ */ new Set();
2313
+ const track = (socket) => {
2314
+ sockets.add(socket);
2315
+ socket.once("close", () => sockets.delete(socket));
2316
+ socket.on("error", () => socket.destroy());
2317
+ };
2318
+ async function openUpstream(cert, client) {
2319
+ const raw = netConnect({ host: cert.host, port: cert.port });
2320
+ track(raw);
2321
+ raw.setNoDelay(true);
2322
+ await waitFor(raw, "connect");
2323
+ const rawReader = new PacketReader(raw);
2324
+ const greetingPacket = await rawReader.read();
2325
+ const greeting = parseGreeting(greetingPacket.payload);
2326
+ if ((greeting.capabilities & CLIENT.SSL) === 0) throw new Error("the gateway did not offer TLS");
2327
+ const capabilities = upstreamCapabilities(client.capabilities, client.database);
2328
+ let seq = greetingPacket.seq + 1;
2329
+ raw.write(encodePacket(seq, encodeSslRequest(capabilities, client.maxPacketSize, client.charset)));
2330
+ rawReader.release({ pause: false });
2331
+ const socket = tlsConnect({
2332
+ socket: raw,
2333
+ secureContext: cert.secureContext,
2334
+ host: cert.host,
2335
+ // SNI carries host names only; an IP is still verified against the certificate.
2336
+ ...isIP(cert.host) === 0 ? { servername: cert.host } : {},
2337
+ rejectUnauthorized: true
2338
+ });
2339
+ track(socket);
2340
+ socket.setNoDelay(true);
2341
+ await waitFor(socket, "secureConnect");
2342
+ const reader = new PacketReader(socket);
2343
+ let plugin = PLUGIN_CACHING_SHA2;
2344
+ seq++;
2345
+ socket.write(
2346
+ encodePacket(
2347
+ seq,
2348
+ encodeHandshakeResponse({
2349
+ capabilities,
2350
+ maxPacketSize: client.maxPacketSize,
2351
+ charset: client.charset,
2352
+ username: options.username,
2353
+ authResponse: scrambleCachingSha2(options.password, greeting.scramble),
2354
+ database: client.database,
2355
+ authPlugin: plugin,
2356
+ attributes: client.attributes
2357
+ })
2358
+ )
2359
+ );
2360
+ for (let round = 0; round < 6; round++) {
2361
+ const packet = await reader.read();
2362
+ seq = packet.seq;
2363
+ const payload = packet.payload;
2364
+ if (isOk(payload)) return { socket, ok: payload, leftover: reader.release({ pause: true }) };
2365
+ if (isErr(payload)) throw serverErrorFromPacket(payload);
2366
+ if (isAuthSwitch(payload)) {
2367
+ const request = parseAuthSwitchRequest(payload);
2368
+ plugin = request.plugin;
2369
+ seq++;
2370
+ socket.write(encodePacket(seq, scrambleFor(plugin, options.password, request.scramble)));
2371
+ continue;
2372
+ }
2373
+ if (isAuthMoreData(payload) && plugin === PLUGIN_CACHING_SHA2 && payload.length >= 2) {
2374
+ if (payload[1] === FAST_AUTH_SUCCESS) continue;
2375
+ if (payload[1] === PERFORM_FULL_AUTH) {
2376
+ seq++;
2377
+ socket.write(encodePacket(seq, Buffer.concat([Buffer.from(options.password, "utf8"), Buffer.from([0])])));
2378
+ continue;
2379
+ }
2380
+ }
2381
+ throw new Error(`unexpected packet 0x${(payload[0] ?? 0).toString(16)} from the gateway during authentication`);
2382
+ }
2383
+ throw new Error("the gateway's authentication did not finish");
2384
+ }
2385
+ async function authenticateLocal(local, reader) {
2386
+ const scramble = newScramble();
2387
+ connectionId = connectionId + 1 >>> 0;
2388
+ local.write(encodePacket(0, encodeGreeting(localGreeting(connectionId, scramble, SERVER_VERSION))));
2389
+ const first = await reader.read();
2390
+ let seq = first.seq;
2391
+ const client = parseHandshakeResponse(first.payload);
2392
+ if (first.payload.length === 32) {
2393
+ throw new LocalRefusal(ER_HANDSHAKE, "08S01", "The Meridian forwarder on this port does not use TLS; connect without it (the connection never leaves this machine).");
2394
+ }
2395
+ if ((client.capabilities & (CLIENT.COMPRESS | CLIENT.ZSTD_COMPRESSION)) !== 0) {
2396
+ throw new LocalRefusal(ER_HANDSHAKE, "08S01", "Compression is not supported by the Meridian database gateway; turn it off in your client.");
2397
+ }
2398
+ if (client.username !== options.username) throw accessDenied(client.username);
2399
+ let authResponse = client.authResponse;
2400
+ let plugin = client.authPlugin;
2401
+ if (plugin === "" && (client.capabilities & CLIENT.SECURE_CONNECTION) !== 0) plugin = PLUGIN_NATIVE;
2402
+ if (plugin !== PLUGIN_NATIVE && plugin !== PLUGIN_CACHING_SHA2) {
2403
+ seq++;
2404
+ local.write(encodePacket(seq, encodeAuthSwitchRequest(PLUGIN_NATIVE, scramble)));
2405
+ const answer = await reader.read();
2406
+ seq = answer.seq;
2407
+ authResponse = answer.payload;
2408
+ plugin = PLUGIN_NATIVE;
2409
+ }
2410
+ const expected = plugin === PLUGIN_CACHING_SHA2 ? scrambleCachingSha2(options.password, scramble) : scrambleNative(options.password, scramble);
2411
+ if (!scrambleMatches(authResponse, expected)) throw accessDenied(client.username);
2412
+ if (plugin === PLUGIN_CACHING_SHA2) {
2413
+ seq++;
2414
+ local.write(encodePacket(seq, Buffer.from([AUTH_MORE_DATA, FAST_AUTH_SUCCESS])));
2415
+ }
2416
+ return { client, seq };
2417
+ }
2418
+ async function handleConnection(local) {
2419
+ track(local);
2420
+ local.setNoDelay(true);
2421
+ const reader = new PacketReader(local);
2422
+ let upstream;
2423
+ const timer = setTimeout(() => {
2424
+ emit({ type: "connection-error", message: "A local MySQL handshake timed out." });
2425
+ local.destroy();
2426
+ upstream?.destroy();
2427
+ }, handshakeTimeoutMs);
2428
+ timer.unref();
2429
+ let seq = 0;
2430
+ try {
2431
+ const authenticated = await authenticateLocal(local, reader);
2432
+ seq = authenticated.seq;
2433
+ const cert = certificate;
2434
+ let session;
2435
+ try {
2436
+ session = await openUpstream(cert, authenticated.client);
2437
+ } catch (error2) {
2438
+ if (error2 instanceof MysqlServerError) {
2439
+ throw new LocalRefusal(error2.code, error2.sqlState, error2.message);
2440
+ }
2441
+ throw new LocalRefusal(
2442
+ CR_CONN_HOST_ERROR,
2443
+ "HY000",
2444
+ `Cannot reach the Meridian database gateway at ${cert.host}:${cert.port}: ${describeError(error2)}`
2445
+ );
2446
+ }
2447
+ upstream = session.socket;
2448
+ clearTimeout(timer);
2449
+ local.write(encodePacket(seq + 1, session.ok));
2450
+ const pipelined = reader.release({ pause: true });
2451
+ if (pipelined.length > 0) upstream.write(pipelined);
2452
+ if (session.leftover.length > 0) local.write(session.leftover);
2453
+ splice(local, upstream);
2454
+ } catch (error2) {
2455
+ clearTimeout(timer);
2456
+ upstream?.destroy();
2457
+ if (error2 instanceof LocalRefusal) {
2458
+ if (error2.code !== ER_ACCESS_DENIED) emit({ type: "connection-error", message: error2.message });
2459
+ if (!local.destroyed) local.end(encodePacket(seq + 1, encodeErr(error2.code, error2.sqlState, error2.message)));
2460
+ return;
2461
+ }
2462
+ emit({ type: "connection-error", message: describeError(error2) });
2463
+ local.destroy();
2464
+ }
2465
+ }
2466
+ async function refresh() {
2467
+ if (options.refreshCertificate === void 0) return;
2468
+ const next = await options.refreshCertificate();
2469
+ certificate = next;
2470
+ emit({ type: "certificate-refreshed", expiresAt: next.expiresAt });
2471
+ }
2472
+ function scheduleRefresh(delayMs, attempt) {
2473
+ if (options.refreshCertificate === void 0 || stopped) return;
2474
+ refreshTimer = setTimeout(() => {
2475
+ refresh().then(
2476
+ () => scheduleRefresh(certificateRefreshDelay(certificate.expiresAt, now(), refreshBeforeMs), 0),
2477
+ (error2) => {
2478
+ const backoff = REFRESH_RETRY_BACKOFFS_MS[Math.min(attempt, REFRESH_RETRY_BACKOFFS_MS.length - 1)] ?? 3e5;
2479
+ emit({
2480
+ type: "error",
2481
+ message: `Could not renew the database gateway certificate (${describeError(error2)}); retrying in ${Math.round(backoff / 1e3)}s.`
2482
+ });
2483
+ scheduleRefresh(backoff, attempt + 1);
2484
+ }
2485
+ );
2486
+ }, delayMs);
2487
+ refreshTimer.unref();
2488
+ }
2489
+ const server = createServer((local) => {
2490
+ void handleConnection(local);
2491
+ });
2492
+ await new Promise((resolve2, reject) => {
2493
+ server.once("error", (error2) => {
2494
+ reject(
2495
+ error2.code === "EADDRINUSE" ? new CliError(`Port ${options.port} is already in use.`, {
2496
+ hint: "Pick a different port with --port, or free up that port and try again."
2497
+ }) : error2
2498
+ );
2499
+ });
2500
+ server.listen({ port: options.port, host, exclusive: true }, () => resolve2());
2501
+ });
2502
+ server.on("error", (error2) => emit({ type: "error", message: describeError(error2) }));
2503
+ const address = server.address();
2504
+ const port = typeof address === "object" && address !== null ? address.port : options.port;
2505
+ running = true;
2506
+ emit({ type: "ready", port });
2507
+ scheduleRefresh(certificateRefreshDelay(certificate.expiresAt, now(), refreshBeforeMs), 0);
2508
+ return {
2509
+ host,
2510
+ port,
2511
+ get running() {
2512
+ return running;
2513
+ },
2514
+ get certificate() {
2515
+ return certificate;
2516
+ },
2517
+ refresh,
2518
+ async stop() {
2519
+ if (stopped) return;
2520
+ stopped = true;
2521
+ running = false;
2522
+ if (refreshTimer !== void 0) clearTimeout(refreshTimer);
2523
+ const closed = new Promise((resolve2) => server.close(() => resolve2()));
2524
+ for (const socket of sockets) socket.destroy();
2525
+ await closed;
2526
+ }
2527
+ };
2528
+ }
2529
+
1674
2530
  // src/lib/port.ts
1675
- import { createServer } from "net";
2531
+ import { createServer as createServer2 } from "net";
1676
2532
  var DEFAULT_START_PORT = 3307;
1677
2533
  var DEFAULT_ATTEMPTS = 20;
1678
2534
  var MAX_PORT = 65535;
1679
2535
  function isPortFree(port, host = "127.0.0.1") {
1680
2536
  return new Promise((resolve2) => {
1681
- const server = createServer();
2537
+ const server = createServer2();
1682
2538
  server.once("error", () => {
1683
2539
  resolve2(false);
1684
2540
  });
@@ -1690,7 +2546,7 @@ function isPortFree(port, host = "127.0.0.1") {
1690
2546
  }
1691
2547
  function bindEphemeralPort(host) {
1692
2548
  return new Promise((resolve2) => {
1693
- const server = createServer();
2549
+ const server = createServer2();
1694
2550
  server.once("error", () => resolve2(null));
1695
2551
  server.listen({ port: 0, host, exclusive: true }, () => {
1696
2552
  const address = server.address();
@@ -1852,7 +2708,7 @@ function addShadowDatabaseUrl(root) {
1852
2708
  function wrongUrlError(datasource, url) {
1853
2709
  const problem = url.kind === "literal" ? `hardcodes \`url = "${url.value}"\`` : `reads \`env("${url.name}")\`, but Meridian sets \`${REQUIRED_URL_ENV}\``;
1854
2710
  return new CliError(
1855
- `\`${datasource.schemaPath}\` ${problem}, so this command would not touch the managed development database.`,
2711
+ `\`${datasource.schemaPath}\` ${problem}, so this command would not touch the managed developer database.`,
1856
2712
  {
1857
2713
  hint: `Change the \`${datasource.name}\` datasource to \`url = env("${REQUIRED_URL_ENV}")\` with \`provider = "${REQUIRED_PROVIDER}"\`. Migrations generated against another database use dialect-specific SQL and have to be regenerated. ${BYPASS_HINT}`
1858
2714
  }
@@ -1860,7 +2716,7 @@ function wrongUrlError(datasource, url) {
1860
2716
  }
1861
2717
  function wrongProviderError(datasource, provider) {
1862
2718
  return new CliError(
1863
- `\`${datasource.schemaPath}\` sets \`provider = "${provider}"\`, but the managed development database is MySQL.`,
2719
+ `\`${datasource.schemaPath}\` sets \`provider = "${provider}"\`, but the managed developer database is MySQL.`,
1864
2720
  {
1865
2721
  hint: `Change the \`${datasource.name}\` datasource to \`provider = "${REQUIRED_PROVIDER}"\` and regenerate any migrations generated for ${provider}, which use dialect-specific SQL. ${BYPASS_HINT}`
1866
2722
  }
@@ -1927,7 +2783,7 @@ function diagnoseMigrationFailure(root) {
1927
2783
  }
1928
2784
 
1929
2785
  // src/lib/proxy/binary.ts
1930
- import { createHash } from "crypto";
2786
+ import { createHash as createHash2 } from "crypto";
1931
2787
  import { chmodSync as chmodSync3, existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync as renameSync3, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
1932
2788
  import { join as join4 } from "path";
1933
2789
  var PROXY_VERSION = "2.24.1";
@@ -1976,7 +2832,7 @@ function resolveProxyAsset(options) {
1976
2832
  };
1977
2833
  }
1978
2834
  function sha256(data) {
1979
- return createHash("sha256").update(data).digest("hex");
2835
+ return createHash2("sha256").update(data).digest("hex");
1980
2836
  }
1981
2837
  var CHECKSUM_ROW_PATTERN = /\[([^\]]+)]\([^)]*\)\s*\|\s*([0-9a-fA-F]{64})/g;
1982
2838
  function parseChecksumTable(releaseBody) {
@@ -2117,8 +2973,8 @@ var PROBE_INTERVAL_MS = 150;
2117
2973
  var KILL_GRACE_MS = 2e3;
2118
2974
  var CRASH_BACKOFFS_MS = [200, 1e3, 3e3];
2119
2975
  var MAX_CRASH_RESTARTS = CRASH_BACKOFFS_MS.length;
2120
- var REFRESH_RETRY_BACKOFFS_MS = [3e4, 6e4, 12e4];
2121
- var MIN_REFRESH_DELAY_MS = 6e4;
2976
+ var REFRESH_RETRY_BACKOFFS_MS2 = [3e4, 6e4, 12e4];
2977
+ var MIN_REFRESH_DELAY_MS2 = 6e4;
2122
2978
  var LOG_RING_SIZE = 20;
2123
2979
  var STDERR_TAIL_LINES = 10;
2124
2980
  function buildProxyArgs(input2) {
@@ -2147,7 +3003,7 @@ function computeRefreshDelayMs(token, refreshSkewMs, assumedLifetimeMs) {
2147
3003
  if (!Number.isNaN(parsed)) expiresAtMs = parsed;
2148
3004
  }
2149
3005
  const effectiveExpiryMs = expiresAtMs ?? Date.now() + assumedLifetimeMs;
2150
- return Math.max(effectiveExpiryMs - Date.now() - refreshSkewMs, MIN_REFRESH_DELAY_MS);
3006
+ return Math.max(effectiveExpiryMs - Date.now() - refreshSkewMs, MIN_REFRESH_DELAY_MS2);
2151
3007
  }
2152
3008
  function buildStartupFailureError(code, signal, lastLines) {
2153
3009
  const tail = lastLines.slice(-STDERR_TAIL_LINES).join("\n");
@@ -2325,7 +3181,7 @@ async function startProxy(options) {
2325
3181
  }
2326
3182
  function scheduleRefreshRetry() {
2327
3183
  if (stopped) return;
2328
- const delayMs = REFRESH_RETRY_BACKOFFS_MS[Math.min(refreshBackoffIndex, REFRESH_RETRY_BACKOFFS_MS.length - 1)] ?? 12e4;
3184
+ const delayMs = REFRESH_RETRY_BACKOFFS_MS2[Math.min(refreshBackoffIndex, REFRESH_RETRY_BACKOFFS_MS2.length - 1)] ?? 12e4;
2329
3185
  refreshBackoffIndex += 1;
2330
3186
  if (refreshTimer !== void 0) clearTimeout(refreshTimer);
2331
3187
  refreshTimer = setTimeoutImpl(() => {
@@ -2525,6 +3381,8 @@ function resolveDeps(overrides) {
2525
3381
  return {
2526
3382
  ensureProxyBinary,
2527
3383
  startProxy,
3384
+ issueGatewayCertificate,
3385
+ startGatewayForwarder,
2528
3386
  findFreePort,
2529
3387
  patchEnvFile,
2530
3388
  waitForDatabase,
@@ -2541,17 +3399,17 @@ var DEV_DATABASE_ID_ENV = "MERIDIAN_DEV_DATABASE_ID";
2541
3399
  function provisioningFailedError(devDatabase) {
2542
3400
  const reason = devDatabase.error_message;
2543
3401
  const detail2 = typeof reason === "string" && reason.trim() !== "" ? ` ${reason.trim()}` : "";
2544
- return new CliError(`Provisioning the development database failed.${detail2}`, {
3402
+ return new CliError(`Provisioning the developer database failed.${detail2}`, {
2545
3403
  hint: "Check the Meridian dashboard for details, or contact support if this keeps happening."
2546
3404
  });
2547
3405
  }
2548
3406
  function provisioningTimeoutError() {
2549
- return new CliError("Timed out waiting for the development database to be provisioned.", {
3407
+ return new CliError("Timed out waiting for the developer database to be provisioned.", {
2550
3408
  hint: "Try `meridian dev` again in a few minutes, and check the Meridian dashboard for its status."
2551
3409
  });
2552
3410
  }
2553
3411
  function provisioningNotFoundError() {
2554
- return new CliError("The Meridian API never finished creating the development database.", {
3412
+ return new CliError("The Meridian API never finished creating the developer database.", {
2555
3413
  hint: "Try `meridian dev` again; if this keeps happening, contact Meridian support."
2556
3414
  });
2557
3415
  }
@@ -2563,7 +3421,7 @@ async function pollDevDatabase(client, appId, options, spinner2) {
2563
3421
  const start = now();
2564
3422
  const report = (status) => {
2565
3423
  const elapsedMs = now() - start;
2566
- spinner2.update(`Provisioning the development database... (${status}, ${Math.round(elapsedMs / 1e3)}s)`);
3424
+ spinner2.update(`Provisioning the developer database... (${status}, ${Math.round(elapsedMs / 1e3)}s)`);
2567
3425
  options.onStatus?.(status, elapsedMs);
2568
3426
  };
2569
3427
  let current2 = await client.ensureDevDatabase(appId);
@@ -2590,9 +3448,9 @@ async function pollDevDatabase(client, appId, options, spinner2) {
2590
3448
  }
2591
3449
  async function ensureDevDatabaseReady(client, appId, options = {}) {
2592
3450
  return withSpinner(
2593
- "Provisioning the development database...",
3451
+ "Provisioning the developer database...",
2594
3452
  (spinner2) => pollDevDatabase(client, appId, options, spinner2),
2595
- { success: (db) => `Development database "${db.database}" is ready.` }
3453
+ { success: (db) => `Developer database "${db.database}" is ready.` }
2596
3454
  );
2597
3455
  }
2598
3456
  function toProxyToken(token) {
@@ -2651,6 +3509,97 @@ function handleProxyEvent(event) {
2651
3509
  return;
2652
3510
  }
2653
3511
  }
3512
+ async function pickLocalPort(options, deps) {
3513
+ const findPortOptions = { start: DEFAULT_PORT_START };
3514
+ if (options.port !== void 0) findPortOptions.preferred = options.port;
3515
+ const port = await deps.findFreePort(findPortOptions);
3516
+ if (options.port !== void 0 && port !== options.port) {
3517
+ throw new CliError(`Port ${options.port} is already in use.`, {
3518
+ hint: "Pick a different port with --port, or free up that port and try again."
3519
+ });
3520
+ }
3521
+ return port;
3522
+ }
3523
+ async function startProxyEndpoint(client, appId, devDatabase, credentials, options, deps) {
3524
+ const proxyToken = await withSpinner(
3525
+ "Requesting a Cloud SQL Auth Proxy token...",
3526
+ () => client.issueProxyToken(appId),
3527
+ { success: "Issued a Cloud SQL Auth Proxy token." }
3528
+ );
3529
+ const binaryPath = await withSpinner(
3530
+ "Preparing the Cloud SQL Auth Proxy...",
3531
+ (spinner2) => {
3532
+ const ensureOptions = {
3533
+ onProgress: (received, total) => {
3534
+ spinner2.update(
3535
+ total !== void 0 ? `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)}/${formatMb(total)} MB)` : `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)} MB)`
3536
+ );
3537
+ }
3538
+ };
3539
+ return deps.ensureProxyBinary(ensureOptions);
3540
+ },
3541
+ { success: "Cloud SQL Auth Proxy is ready." }
3542
+ );
3543
+ const port = await pickLocalPort(options, deps);
3544
+ const instanceConnectionName = firstNonEmpty(
3545
+ credentials.instance_connection_name,
3546
+ proxyToken.instance_connection_name,
3547
+ devDatabase.instance_connection_name
3548
+ );
3549
+ if (instanceConnectionName === void 0) {
3550
+ throw new CliError("The Meridian API did not return a Cloud SQL instance connection name.", {
3551
+ hint: "Try again, or contact Meridian support if this persists."
3552
+ });
3553
+ }
3554
+ return withSpinner(
3555
+ `Starting the Cloud SQL Auth Proxy on 127.0.0.1:${port}...`,
3556
+ () => deps.startProxy({
3557
+ binaryPath,
3558
+ instanceConnectionName,
3559
+ token: toProxyToken(proxyToken),
3560
+ port,
3561
+ ...options.refreshToken !== false ? { refreshToken: async () => toProxyToken(await client.issueProxyToken(appId)) } : {},
3562
+ onEvent: handleProxyEvent
3563
+ }),
3564
+ { success: `Cloud SQL Auth Proxy listening on 127.0.0.1:${port}.` }
3565
+ );
3566
+ }
3567
+ function handleForwarderEvent(event) {
3568
+ switch (event.type) {
3569
+ case "ready":
3570
+ debug(`Database gateway forwarder listening on port ${event.port}.`);
3571
+ return;
3572
+ case "connection-error":
3573
+ debug(`Database gateway connection failed: ${event.message}`);
3574
+ return;
3575
+ case "certificate-refreshed":
3576
+ step(`Renewed the database gateway certificate (expires ${event.expiresAt}).`);
3577
+ return;
3578
+ case "error":
3579
+ warn(`Database gateway: ${event.message} Restart \`meridian dev\` if the database becomes unreachable.`);
3580
+ return;
3581
+ }
3582
+ }
3583
+ async function startGatewayEndpoint(client, appId, credentials, options, deps) {
3584
+ const certificate = await withSpinner(
3585
+ "Requesting a database gateway certificate...",
3586
+ () => deps.issueGatewayCertificate(client, appId),
3587
+ { success: (cert) => `Issued a database gateway certificate (expires ${cert.expiresAt}).` }
3588
+ );
3589
+ const port = await pickLocalPort(options, deps);
3590
+ return withSpinner(
3591
+ `Starting the database gateway forwarder on 127.0.0.1:${port}...`,
3592
+ () => deps.startGatewayForwarder({
3593
+ port,
3594
+ username: credentials.username,
3595
+ password: credentials.password,
3596
+ certificate,
3597
+ ...options.refreshToken !== false ? { refreshCertificate: () => deps.issueGatewayCertificate(client, appId) } : {},
3598
+ onEvent: handleForwarderEvent
3599
+ }),
3600
+ { success: `Database gateway forwarder listening on 127.0.0.1:${port} (${certificate.host}).` }
3601
+ );
3602
+ }
2654
3603
  async function startDevSession(options = {}) {
2655
3604
  const deps = resolveDeps(options.deps);
2656
3605
  let proxy;
@@ -2667,55 +3616,8 @@ async function startDevSession(options = {}) {
2667
3616
  success: (creds) => `Fetched credentials for database "${creds.database}"${typeof creds.expires_at === "string" ? ` (expire ${creds.expires_at})` : ""}.`
2668
3617
  }
2669
3618
  );
2670
- const proxyToken = await withSpinner(
2671
- "Requesting a Cloud SQL Auth Proxy token...",
2672
- () => client.issueProxyToken(appId),
2673
- { success: "Issued a Cloud SQL Auth Proxy token." }
2674
- );
2675
- const binaryPath = await withSpinner(
2676
- "Preparing the Cloud SQL Auth Proxy...",
2677
- (spinner2) => {
2678
- const ensureOptions = {
2679
- onProgress: (received, total) => {
2680
- spinner2.update(
2681
- total !== void 0 ? `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)}/${formatMb(total)} MB)` : `Downloading the Cloud SQL Auth Proxy... (${formatMb(received)} MB)`
2682
- );
2683
- }
2684
- };
2685
- return deps.ensureProxyBinary(ensureOptions);
2686
- },
2687
- { success: "Cloud SQL Auth Proxy is ready." }
2688
- );
2689
- const findPortOptions = { start: DEFAULT_PORT_START };
2690
- if (options.port !== void 0) findPortOptions.preferred = options.port;
2691
- const port = await deps.findFreePort(findPortOptions);
2692
- if (options.port !== void 0 && port !== options.port) {
2693
- throw new CliError(`Port ${options.port} is already in use.`, {
2694
- hint: "Pick a different port with --port, or free up that port and try again."
2695
- });
2696
- }
2697
- const instanceConnectionName = firstNonEmpty(
2698
- credentials.instance_connection_name,
2699
- proxyToken.instance_connection_name,
2700
- devDatabase.instance_connection_name
2701
- );
2702
- if (instanceConnectionName === void 0) {
2703
- throw new CliError("The Meridian API did not return a Cloud SQL instance connection name.", {
2704
- hint: "Try again, or contact Meridian support if this persists."
2705
- });
2706
- }
2707
- proxy = await withSpinner(
2708
- `Starting the Cloud SQL Auth Proxy on 127.0.0.1:${port}...`,
2709
- () => deps.startProxy({
2710
- binaryPath,
2711
- instanceConnectionName,
2712
- token: toProxyToken(proxyToken),
2713
- port,
2714
- ...options.refreshToken !== false ? { refreshToken: async () => toProxyToken(await client.issueProxyToken(appId)) } : {},
2715
- onEvent: handleProxyEvent
2716
- }),
2717
- { success: `Cloud SQL Auth Proxy listening on 127.0.0.1:${port}.` }
2718
- );
3619
+ const access = devDatabase.access === "gateway" ? "gateway" : "proxy";
3620
+ proxy = access === "gateway" ? await startGatewayEndpoint(client, appId, credentials, options, deps) : await startProxyEndpoint(client, appId, devDatabase, credentials, options, deps);
2719
3621
  const connection = connectionInfoFromCredentials(credentials, { host: proxy.host, port: proxy.port });
2720
3622
  if (options.waitForDb !== false) {
2721
3623
  await withSpinner(
@@ -2767,6 +3669,7 @@ async function startDevSession(options = {}) {
2767
3669
  connection,
2768
3670
  databaseUrl,
2769
3671
  ...shadowDatabaseUrl !== void 0 ? { shadowDatabaseUrl } : {},
3672
+ access,
2770
3673
  proxy,
2771
3674
  env: {
2772
3675
  ...process.env,
@@ -2787,6 +3690,232 @@ async function startDevSession(options = {}) {
2787
3690
  }
2788
3691
  }
2789
3692
 
3693
+ // src/lib/db/external-credential.ts
3694
+ function describeAccessMode(mode) {
3695
+ if (mode === "read_only") return "read-only";
3696
+ if (mode === "read_write") return "read-write";
3697
+ return mode;
3698
+ }
3699
+ function resolveCredentialRef(credentials, ref) {
3700
+ const wanted = ref.trim();
3701
+ const byId = credentials.find((credential) => credential.id === wanted);
3702
+ if (byId !== void 0) return byId;
3703
+ const matchers = [
3704
+ (credential) => credential.label === wanted,
3705
+ (credential) => credential.label.toLowerCase() === wanted.toLowerCase()
3706
+ ];
3707
+ for (const matches of matchers) {
3708
+ const found = credentials.filter(matches);
3709
+ const live = found.filter((credential) => credential.status !== "revoked");
3710
+ const pool = live.length > 0 ? live : found;
3711
+ if (pool.length === 1 && pool[0] !== void 0) return pool[0];
3712
+ if (pool.length > 1) {
3713
+ throw new CliError(`More than one credential is labelled "${wanted}".`, {
3714
+ hint: `Name it by id instead: ${pool.map((credential) => credential.id).join(", ")}.`
3715
+ });
3716
+ }
3717
+ }
3718
+ throw new CliError(`No credential matches "${wanted}".`, {
3719
+ hint: "Run `meridian db credential list` to see each credential's label and id."
3720
+ });
3721
+ }
3722
+ function isMysqlDatabase(addon) {
3723
+ if (addon.type !== "database") return false;
3724
+ const type = addon.database?.type;
3725
+ return type === void 0 || type === null || type === "mysql";
3726
+ }
3727
+ function databaseTargets(environments) {
3728
+ const targets = [];
3729
+ for (const environment of environments) {
3730
+ const addon = (environment.addons ?? []).find(isMysqlDatabase);
3731
+ if (addon !== void 0) targets.push({ environment, addon });
3732
+ }
3733
+ return targets;
3734
+ }
3735
+ function environmentLabel(environment) {
3736
+ return environment.name ?? environment.type ?? environment.uuid;
3737
+ }
3738
+ function matchEnvironment(targets, wanted) {
3739
+ const target = wanted.trim().toLowerCase();
3740
+ return targets.find(
3741
+ ({ environment }) => [environment.name, environment.type, environment.uuid].some(
3742
+ (value) => typeof value === "string" && value.toLowerCase() === target
3743
+ )
3744
+ );
3745
+ }
3746
+ function formatTimestamp(value, dateOnly = false) {
3747
+ if (value === null || value === void 0 || value === "") return "-";
3748
+ const date = new Date(value);
3749
+ if (Number.isNaN(date.getTime())) return value;
3750
+ const iso = date.toISOString();
3751
+ return dateOnly ? iso.slice(0, 10) : `${iso.slice(0, 16).replace("T", " ")} UTC`;
3752
+ }
3753
+ function formatCredentialTable(credentials) {
3754
+ const rows = [
3755
+ ["LABEL", "USERNAME", "MODE", "STATUS", "LAST CONNECTED", "CERT EXPIRES", "ID"],
3756
+ ...credentials.map((credential) => [
3757
+ credential.label,
3758
+ credential.username,
3759
+ describeAccessMode(credential.access_mode),
3760
+ credential.status,
3761
+ credential.last_connected_at === null || credential.last_connected_at === void 0 ? "never" : formatTimestamp(credential.last_connected_at),
3762
+ formatTimestamp(credential.certificate_expires_at, true),
3763
+ credential.id
3764
+ ])
3765
+ ];
3766
+ const widths = rows[0]?.map((_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length))) ?? [];
3767
+ return rows.map(
3768
+ (row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd()
3769
+ );
3770
+ }
3771
+
3772
+ // src/commands/db-credential.ts
3773
+ function defaultDeps() {
3774
+ return { createApiClient, requireProject };
3775
+ }
3776
+ var MANAGE_HINT = "Revoking an external credential needs the Credentials: Manage permission on this app. Ask an organization owner or admin to grant it, or to revoke it for you.";
3777
+ var CLI_SCOPE_HINT = "This Meridian API does not accept CLI tokens for external credentials yet. Manage them under Hosting > Database > External access in the dashboard.";
3778
+ var DASHBOARD_HINT = "Create and rotate credentials in the dashboard: Hosting > Database > External access. The key is generated in your browser there.";
3779
+ async function withAccessHints(fn, needsManage) {
3780
+ try {
3781
+ return await fn();
3782
+ } catch (err) {
3783
+ if (isApiError(err) && err.status === 403) {
3784
+ const scope = err.message.includes("/app/cli/");
3785
+ if (scope || needsManage) {
3786
+ throw new ApiError(err.message, {
3787
+ status: err.status,
3788
+ body: err.body,
3789
+ ...err.errorCode !== void 0 ? { errorCode: err.errorCode } : {},
3790
+ hint: scope ? CLI_SCOPE_HINT : MANAGE_HINT,
3791
+ cause: err
3792
+ });
3793
+ }
3794
+ }
3795
+ throw err;
3796
+ }
3797
+ }
3798
+ async function resolveTarget(client, appId, env, deps) {
3799
+ const environments = await withAccessHints(() => client.listEnvironments(appId), false);
3800
+ const targets = databaseTargets(environments);
3801
+ if (targets.length === 0) {
3802
+ throw new CliError("None of this app's environments has a hosted MySQL database.", {
3803
+ hint: "Add a database to an environment under Hosting in the Meridian dashboard first."
3804
+ });
3805
+ }
3806
+ if (env !== void 0) {
3807
+ const match = matchEnvironment(targets, env);
3808
+ if (match === void 0) {
3809
+ throw new CliError(`No environment named "${env}" has a hosted MySQL database.`, {
3810
+ hint: `Environments with one: ${targets.map(({ environment }) => environmentLabel(environment)).join(", ")}.`
3811
+ });
3812
+ }
3813
+ return match;
3814
+ }
3815
+ const index = await selectOne({
3816
+ message: "Which environment's database?",
3817
+ choices: targets.map((target, position) => ({
3818
+ value: String(position),
3819
+ name: environmentLabel(target.environment),
3820
+ ...target.addon.database?.database ? { description: target.addon.database.database } : {}
3821
+ })),
3822
+ flag: "--env",
3823
+ ...deps.selectImpl !== void 0 ? { promptImpl: deps.selectImpl } : {}
3824
+ });
3825
+ const chosen = targets[Number(index)];
3826
+ if (chosen === void 0) throw new CliError("Nothing to choose from.");
3827
+ return chosen;
3828
+ }
3829
+ async function openContext(env, deps) {
3830
+ const project = deps.requireProject(workingDirectory());
3831
+ const client = deps.createApiClient({});
3832
+ const target = await resolveTarget(client, project.config.app_id, env, deps);
3833
+ return { client, target };
3834
+ }
3835
+ async function runDbCredentialList(options = {}) {
3836
+ const deps = { ...defaultDeps(), ...options.deps };
3837
+ const { client, target } = await openContext(options.env, deps);
3838
+ const { credentials, meta } = await withAccessHints(() => client.listExternalCredentials(target.addon.uuid), false);
3839
+ const envName = environmentLabel(target.environment);
3840
+ if (isJsonOutput()) {
3841
+ printJson({
3842
+ environment: envName,
3843
+ addon_id: target.addon.uuid,
3844
+ available: meta.available ?? null,
3845
+ host: meta.host ?? null,
3846
+ port: meta.port ?? null,
3847
+ credentials
3848
+ });
3849
+ return 0;
3850
+ }
3851
+ if (meta.available === false) {
3852
+ warn(`External access is not available for the ${envName} database yet.`);
3853
+ }
3854
+ if (credentials.length === 0) {
3855
+ log(`No external credentials for the ${envName} database.`);
3856
+ hint(DASHBOARD_HINT);
3857
+ return 0;
3858
+ }
3859
+ log(`External credentials for the ${style.bold(envName)} database${meta.host ? ` (${meta.host}:${meta.port ?? 3306})` : ""}:`);
3860
+ log();
3861
+ for (const line of formatCredentialTable(credentials)) log(` ${line}`);
3862
+ return 0;
3863
+ }
3864
+ async function runDbCredentialRevoke(options) {
3865
+ const deps = { ...defaultDeps(), ...options.deps };
3866
+ const { client, target } = await openContext(options.env, deps);
3867
+ const { credentials } = await withAccessHints(() => client.listExternalCredentials(target.addon.uuid), false);
3868
+ const credential = resolveCredentialRef(credentials, options.credential);
3869
+ const envName = environmentLabel(target.environment);
3870
+ if (credential.status === "revoked") {
3871
+ log(`Credential "${credential.label}" is already revoked.`);
3872
+ if (isJsonOutput()) printJson({ revoked: true, already_revoked: true, credential });
3873
+ return 0;
3874
+ }
3875
+ const confirmed = await confirmAction({
3876
+ message: `Revoke "${credential.label}" (${credential.username}) on the ${envName} database? Anything using it loses access within 30 seconds.`,
3877
+ default: false,
3878
+ assumeYes: isAssumeYes(),
3879
+ ...deps.confirmImpl !== void 0 ? { promptImpl: deps.confirmImpl } : {}
3880
+ });
3881
+ if (!confirmed) {
3882
+ log("Aborted.");
3883
+ if (isJsonOutput()) printJson({ revoked: false, aborted: true, credential });
3884
+ return 0;
3885
+ }
3886
+ const revoked = await withAccessHints(
3887
+ () => withSpinner(
3888
+ `Revoking credential "${credential.label}"...`,
3889
+ () => client.revokeExternalCredential(target.addon.uuid, credential.id)
3890
+ ),
3891
+ true
3892
+ );
3893
+ if (isJsonOutput()) printJson({ revoked: true, already_revoked: false, credential: revoked });
3894
+ else success(`Revoked "${credential.label}". Its MySQL user is gone and open connections close within 30 seconds.`);
3895
+ return 0;
3896
+ }
3897
+ async function runAndSetExitCode(fn) {
3898
+ const code = await fn();
3899
+ if (code !== 0) process.exitCode = code;
3900
+ }
3901
+ function registerDbCredentialCommand(db) {
3902
+ const credential = db.command("credential").description(
3903
+ "List and revoke credentials for reaching a hosted environment's database from outside Meridian. Create and rotate them in the dashboard."
3904
+ );
3905
+ credential.action(() => {
3906
+ credential.outputHelp();
3907
+ });
3908
+ const envOption = ["--env <name>", "the environment whose database to use (name or type, e.g. production)"];
3909
+ credential.command("list").description("List the database's external credentials.").option(...envOption).action(async (opts) => {
3910
+ await runAndSetExitCode(() => runDbCredentialList({ ...opts.env !== void 0 ? { env: opts.env } : {} }));
3911
+ });
3912
+ credential.command("revoke").description("Revoke a credential: its MySQL user is dropped and open connections close within 30 seconds.").argument("<credential>", "the credential's id or label").option(...envOption).action(async (ref, opts) => {
3913
+ await runAndSetExitCode(
3914
+ () => runDbCredentialRevoke({ credential: ref, ...opts.env !== void 0 ? { env: opts.env } : {} })
3915
+ );
3916
+ });
3917
+ }
3918
+
2790
3919
  // src/commands/db-shell-repl.ts
2791
3920
  import { accessSync, constants as fsConstants } from "fs";
2792
3921
  import { delimiter as delimiter2, join as join5 } from "path";
@@ -3059,7 +4188,7 @@ async function runDbSeed(options = {}) {
3059
4188
  inherit: true
3060
4189
  });
3061
4190
  if (result.code === 0) {
3062
- success("Seeded the development database.");
4191
+ success("Seeded the developer database.");
3063
4192
  } else {
3064
4193
  error(`\`npx prisma db seed\` exited with code ${result.code}.`);
3065
4194
  hint("Make sure `prisma.seed` (or your Prisma 6 seed config) is set in package.json.");
@@ -3107,17 +4236,19 @@ async function runDbShell(options = {}) {
3107
4236
  if (session !== void 0) await session.stop();
3108
4237
  }
3109
4238
  }
3110
- async function runAndSetExitCode(fn) {
4239
+ async function runAndSetExitCode2(fn) {
3111
4240
  const code = await fn();
3112
4241
  if (code !== 0) process.exitCode = code;
3113
4242
  }
3114
4243
  function registerDbCommand(program) {
3115
- const db = program.command("db").description("Manage the Meridian-managed MySQL development database: reset, seed, or open a SQL shell.");
4244
+ const db = program.command("db").description(
4245
+ "Manage the Meridian-managed MySQL developer database (reset, seed, or open a SQL shell), and external credentials for hosted databases."
4246
+ );
3116
4247
  db.action(() => {
3117
4248
  db.outputHelp();
3118
4249
  });
3119
- db.command("reset").description("Drop and recreate every table in the development database (keeps the MySQL user).").option("--migrate", "run `npx prisma migrate deploy` afterwards, without asking").option("--seed", "run `npx prisma db seed` afterwards, without asking").option("--port <port>", "local port for the Cloud SQL Auth Proxy", (value) => parsePortOption(value)).option("--env-file <file>", "project .env file to update (defaults to .env)").action(async (opts) => {
3120
- await runAndSetExitCode(
4250
+ db.command("reset").description("Drop and recreate every table in the developer database (keeps the MySQL user).").option("--migrate", "run `npx prisma migrate deploy` afterwards, without asking").option("--seed", "run `npx prisma db seed` afterwards, without asking").option("--port <port>", "local port for the Cloud SQL Auth Proxy", (value) => parsePortOption(value)).option("--env-file <file>", "project .env file to update (defaults to .env)").action(async (opts) => {
4251
+ await runAndSetExitCode2(
3121
4252
  () => runDbReset({
3122
4253
  ...opts.migrate === true ? { migrate: true } : {},
3123
4254
  ...opts.seed === true ? { seed: true } : {},
@@ -3127,7 +4258,7 @@ function registerDbCommand(program) {
3127
4258
  );
3128
4259
  });
3129
4260
  db.command("seed").description("Run `npx prisma db seed` with the Cloud SQL Auth Proxy up and DATABASE_URL set.").option("--port <port>", "local port for the Cloud SQL Auth Proxy", (value) => parsePortOption(value)).option("--env-file <file>", "project .env file to update (defaults to .env)").passThroughOptions().argument("[args...]", "extra arguments forwarded to `npx prisma db seed` (put them after `--`)").action(async (args, opts) => {
3130
- await runAndSetExitCode(
4261
+ await runAndSetExitCode2(
3131
4262
  () => runDbSeed({
3132
4263
  ...opts.port !== void 0 ? { port: opts.port } : {},
3133
4264
  ...opts.envFile !== void 0 ? { envFile: opts.envFile } : {},
@@ -3135,8 +4266,8 @@ function registerDbCommand(program) {
3135
4266
  })
3136
4267
  );
3137
4268
  });
3138
- db.command("shell").description("Open a SQL shell against the development database (the `mysql` client if available, otherwise a built-in REPL).").option("--port <port>", "local port for the Cloud SQL Auth Proxy", (value) => parsePortOption(value)).option("--env-file <file>", "project .env file to read/patch (defaults to .env)").option("--repl", "force the built-in SQL REPL even when `mysql` is on PATH").option("-e, --execute <sql>", "run one SQL statement and exit").option("--no-env", "do not patch DATABASE_URL into the project's .env file").action(async (opts) => {
3139
- await runAndSetExitCode(
4269
+ db.command("shell").description("Open a SQL shell against the developer database (the `mysql` client if available, otherwise a built-in REPL).").option("--port <port>", "local port for the Cloud SQL Auth Proxy", (value) => parsePortOption(value)).option("--env-file <file>", "project .env file to read/patch (defaults to .env)").option("--repl", "force the built-in SQL REPL even when `mysql` is on PATH").option("-e, --execute <sql>", "run one SQL statement and exit").option("--no-env", "do not patch DATABASE_URL into the project's .env file").action(async (opts) => {
4270
+ await runAndSetExitCode2(
3140
4271
  () => runDbShell({
3141
4272
  ...opts.port !== void 0 ? { port: opts.port } : {},
3142
4273
  ...opts.envFile !== void 0 ? { envFile: opts.envFile } : {},
@@ -3146,10 +4277,11 @@ function registerDbCommand(program) {
3146
4277
  })
3147
4278
  );
3148
4279
  });
4280
+ registerDbCredentialCommand(db);
3149
4281
  }
3150
4282
 
3151
4283
  // src/commands/dev.ts
3152
- function defaultDeps() {
4284
+ function defaultDeps2() {
3153
4285
  return { startDevSession, startProcess, readPackageScript, requireProject };
3154
4286
  }
3155
4287
  var RECURSION_HINT = "Set `dev_command` in `.meridian/project.json` to the underlying command (e.g. `next dev`), or run `meridian dev -- next dev` to bypass the script entirely.";
@@ -3206,7 +4338,7 @@ function printDevSummary(session, noEnv) {
3206
4338
  const entries = [
3207
4339
  ["App", appLabel(session.project)],
3208
4340
  ["Database", session.devDatabase.database],
3209
- ["Proxy", `127.0.0.1:${session.proxy.port}`],
4341
+ [session.access === "gateway" ? "Gateway" : "Proxy", `127.0.0.1:${session.proxy.port}`],
3210
4342
  ["Env file", envEntry],
3211
4343
  ["DATABASE_URL", redactDatabaseUrl(session.databaseUrl)]
3212
4344
  ];
@@ -3221,6 +4353,7 @@ function buildDevJsonPayload(session) {
3221
4353
  return {
3222
4354
  app: appLabel(session.project),
3223
4355
  database: session.devDatabase.database,
4356
+ access: session.access,
3224
4357
  proxy: { host: session.proxy.host, port: session.proxy.port },
3225
4358
  database_url: redactDatabaseUrl(session.databaseUrl),
3226
4359
  env_file: session.envPatch?.path ?? null,
@@ -3228,7 +4361,7 @@ function buildDevJsonPayload(session) {
3228
4361
  };
3229
4362
  }
3230
4363
  async function runDev(options = {}) {
3231
- const deps = { ...defaultDeps(), ...options.deps };
4364
+ const deps = { ...defaultDeps2(), ...options.deps };
3232
4365
  if (isInsideDevSession()) throw nestedSessionError();
3233
4366
  const project = deps.requireProject(workingDirectory());
3234
4367
  const resolved = resolveDevCommand(deps, project, options.command);
@@ -3272,7 +4405,7 @@ async function runDev(options = {}) {
3272
4405
  }
3273
4406
  }
3274
4407
  function registerDevCommand(program) {
3275
- program.command("dev").description("Run the managed dev database proxy and your app's dev server together.").option("--port <port>", "preferred local port for the Cloud SQL Auth Proxy").option("--env-file <file>", "the .env file to patch, relative to the project root (default .env)").option("--no-env", "don't touch any .env file; just print the DATABASE_URL for manual use").argument("[command...]", "run this instead of the app's dev script, e.g. `meridian dev -- next dev`").passThroughOptions().action(async (command, opts) => {
4408
+ program.command("dev").description("Run the managed developer database proxy and your app's dev server together.").option("--port <port>", "preferred local port for the Cloud SQL Auth Proxy").option("--env-file <file>", "the .env file to patch, relative to the project root (default .env)").option("--no-env", "don't touch any .env file; just print the DATABASE_URL for manual use").argument("[command...]", "run this instead of the app's dev script, e.g. `meridian dev -- next dev`").passThroughOptions().action(async (command, opts) => {
3276
4409
  const port = parsePortOption(opts.port);
3277
4410
  const code = await runDev({
3278
4411
  ...command.length > 0 ? { command } : {},
@@ -3287,7 +4420,7 @@ function registerDevCommand(program) {
3287
4420
  // src/commands/link.ts
3288
4421
  import { existsSync as existsSync5 } from "fs";
3289
4422
  import { join as join7 } from "path";
3290
- function defaultDeps2() {
4423
+ function defaultDeps3() {
3291
4424
  return { createClient: createApiClient };
3292
4425
  }
3293
4426
  function formatOrg(org) {
@@ -3302,7 +4435,7 @@ function resolveChoiceAnswer(choices, raw, keysOf) {
3302
4435
  return match !== void 0 ? match.name : raw;
3303
4436
  }
3304
4437
  async function runLink(options = {}) {
3305
- const deps = { ...defaultDeps2(), ...options.deps };
4438
+ const deps = { ...defaultDeps3(), ...options.deps };
3306
4439
  const json = isJsonOutput();
3307
4440
  const root = resolveProjectRoot(workingDirectory()) ?? workingDirectory();
3308
4441
  if (!existsSync5(join7(root, "package.json"))) {
@@ -3498,7 +4631,7 @@ async function defaultOpenUrl(url) {
3498
4631
  const { default: open } = await import("open");
3499
4632
  await open(url);
3500
4633
  }
3501
- function defaultDeps3() {
4634
+ function defaultDeps4() {
3502
4635
  return { createClient: createApiClient, runDeviceFlow, openUrl: defaultOpenUrl };
3503
4636
  }
3504
4637
  function shouldOpenBrowser(noBrowser) {
@@ -3519,7 +4652,7 @@ function hintUnlessLinked(json) {
3519
4652
  hint("Run `meridian link` to connect this project to a Meridian app.");
3520
4653
  }
3521
4654
  async function runLogin(options = {}) {
3522
- const deps = { ...defaultDeps3(), ...options.deps };
4655
+ const deps = { ...defaultDeps4(), ...options.deps };
3523
4656
  const json = isJsonOutput();
3524
4657
  if (options.force !== true) {
3525
4658
  const auth = getAuth();
@@ -3656,7 +4789,7 @@ async function createSnapshot(info, options) {
3656
4789
  }
3657
4790
  });
3658
4791
  } catch (error2) {
3659
- throw new CliError(`Could not snapshot the development database: ${describeError(error2)}`, {
4792
+ throw new CliError(`Could not snapshot the developer database: ${describeError(error2)}`, {
3660
4793
  hint: SKIP_SNAPSHOT_HINT,
3661
4794
  cause: error2
3662
4795
  });
@@ -3665,13 +4798,13 @@ async function createSnapshot(info, options) {
3665
4798
  try {
3666
4799
  bytes = (await stat(path)).size;
3667
4800
  } catch (error2) {
3668
- throw new CliError("Could not snapshot the development database: the dump did not produce a file.", {
4801
+ throw new CliError("Could not snapshot the developer database: the dump did not produce a file.", {
3669
4802
  hint: SKIP_SNAPSHOT_HINT,
3670
4803
  cause: error2
3671
4804
  });
3672
4805
  }
3673
4806
  if (bytes === 0) {
3674
- throw new CliError("Could not snapshot the development database: the dump produced an empty file.", {
4807
+ throw new CliError("Could not snapshot the developer database: the dump produced an empty file.", {
3675
4808
  hint: SKIP_SNAPSHOT_HINT
3676
4809
  });
3677
4810
  }
@@ -3862,7 +4995,7 @@ async function pruneSnapshots(dir, keep) {
3862
4995
  // src/commands/migrate.ts
3863
4996
  var DEFAULT_KEEP_SNAPSHOTS = 5;
3864
4997
  var MAX_OUTPUT_EXCERPT = 4e3;
3865
- function defaultDeps4() {
4998
+ function defaultDeps5() {
3866
4999
  return {
3867
5000
  startDevSession,
3868
5001
  createSnapshot,
@@ -3880,7 +5013,7 @@ function defaultDeps4() {
3880
5013
  };
3881
5014
  }
3882
5015
  function resolveDeps2(overrides) {
3883
- return { ...defaultDeps4(), ...overrides };
5016
+ return { ...defaultDeps5(), ...overrides };
3884
5017
  }
3885
5018
  function formatSize(bytes) {
3886
5019
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -3907,10 +5040,10 @@ async function runMigration(session, deps, ctx) {
3907
5040
  let snapshot;
3908
5041
  if (options.skipSnapshot !== true) {
3909
5042
  snapshot = await withSpinner(
3910
- "Snapshotting the development database...",
5043
+ "Snapshotting the developer database...",
3911
5044
  () => deps.createSnapshot(session.connection, { dir, now: deps.now }),
3912
5045
  {
3913
- success: (result) => result.path === null ? "Development database is empty. Nothing to snapshot." : `Snapshot saved to ${style.path(result.path)} (${formatSize(result.bytes)}).`
5046
+ success: (result) => result.path === null ? "Developer database is empty. Nothing to snapshot." : `Snapshot saved to ${style.path(result.path)} (${formatSize(result.bytes)}).`
3914
5047
  }
3915
5048
  );
3916
5049
  }
@@ -3943,12 +5076,12 @@ async function runMigration(session, deps, ctx) {
3943
5076
  } else {
3944
5077
  const snapshotPath = snapshot.path;
3945
5078
  warn(
3946
- `\`${commandString}\` failed (exit code ${runResult.code}); ` + (snapshotPath === null ? "rolling the development database back to its pre-migration (empty) state..." : "rolling back the development database to the pre-migration snapshot...")
5079
+ `\`${commandString}\` failed (exit code ${runResult.code}); ` + (snapshotPath === null ? "rolling the developer database back to its pre-migration (empty) state..." : "rolling back the developer database to the pre-migration snapshot...")
3947
5080
  );
3948
5081
  try {
3949
5082
  await client.resetDevDatabase(appId);
3950
5083
  if (snapshotPath === null) {
3951
- success("Development database rolled back to its pre-migration (empty) state.");
5084
+ success("Developer database rolled back to its pre-migration (empty) state.");
3952
5085
  } else {
3953
5086
  await withSpinner(
3954
5087
  "Restoring the pre-migration snapshot...",
@@ -3961,7 +5094,7 @@ async function runMigration(session, deps, ctx) {
3961
5094
  status = "rolled_back";
3962
5095
  rolledBack = true;
3963
5096
  } catch {
3964
- error("Automatic rollback failed. The development database may be in a partial state. Treat it as unsafe.");
5097
+ error("Automatic rollback failed. The developer database may be in a partial state. Treat it as unsafe.");
3965
5098
  if (snapshotPath === null) {
3966
5099
  hint("Recover by hand with `meridian db reset`. The database was empty before this migration.");
3967
5100
  } else {
@@ -4036,7 +5169,7 @@ async function runMigrate(options = {}) {
4036
5169
  }
4037
5170
  function registerMigrateCommand(program) {
4038
5171
  program.command("migrate").description(
4039
- "Snapshot the development database, run `prisma migrate dev`, and roll back to the snapshot automatically if the migration fails."
5172
+ "Snapshot the developer database, run `prisma migrate dev`, and roll back to the snapshot automatically if the migration fails."
4040
5173
  ).option("--skip-snapshot", "run without a pre-migration snapshot (no rollback is possible)").option(
4041
5174
  "--allow-foreign-migrations",
4042
5175
  "run even when prisma/migrations was generated for SQLite or Postgres and cannot apply to MySQL"
@@ -4089,7 +5222,7 @@ function registerUnlinkCommand(program) {
4089
5222
 
4090
5223
  // src/commands/whoami.ts
4091
5224
  var SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1e3;
4092
- function defaultDeps5() {
5225
+ function defaultDeps6() {
4093
5226
  return { createClient: createApiClient };
4094
5227
  }
4095
5228
  function toConfigUser2(user) {
@@ -4153,7 +5286,7 @@ function printJsonResult(result, project) {
4153
5286
  });
4154
5287
  }
4155
5288
  async function runWhoami(options = {}) {
4156
- const deps = { ...defaultDeps5(), ...options.deps };
5289
+ const deps = { ...defaultDeps6(), ...options.deps };
4157
5290
  const json = isJsonOutput();
4158
5291
  const auth = getAuth();
4159
5292
  if (auth === null) throw notLoggedInError();
@@ -4260,7 +5393,7 @@ function resolveGlobalFlags(actionCommand) {
4260
5393
  function buildProgram() {
4261
5394
  const program = new Command();
4262
5395
  program.name("meridian").description(
4263
- "Managed remote development databases for Meridian apps.\n\nRun `meridian login` once, `meridian link` per project, then `meridian dev`."
5396
+ "Managed remote developer databases for Meridian apps.\n\nRun `meridian login` once, `meridian link` per project, then `meridian dev`."
4264
5397
  ).version(VERSION, "-V, --version").enablePositionalOptions().showHelpAfterError("(run `meridian --help` for usage)");
4265
5398
  addGlobalFlags(program);
4266
5399
  program.hook("preAction", (_thisCommand, actionCommand) => {