@heroku/heroku-cli-util 9.0.1 → 9.1.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.
@@ -1,21 +1,104 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getBastion = exports.env = exports.bastionKeyPlan = void 0;
4
- exports.fetchConfig = fetchConfig;
5
- exports.getConfigs = getConfigs;
3
+ exports.getBastionConfig = void 0;
4
+ exports.bastionKeyPlan = bastionKeyPlan;
5
+ exports.fetchBastionConfig = fetchBastionConfig;
6
+ exports.getPsqlConfigs = getPsqlConfigs;
6
7
  exports.sshTunnel = sshTunnel;
7
- exports.tunnelConfig = tunnelConfig;
8
- const core_1 = require("@oclif/core");
9
- const debug_1 = require("debug");
10
- const EventEmitter = require("node:events");
8
+ const tslib_1 = require("tslib");
9
+ const debug_1 = tslib_1.__importDefault(require("debug"));
10
+ const node_events_1 = require("node:events");
11
11
  const node_util_1 = require("node:util");
12
- const createTunnel = require("tunnel-ssh");
13
- const host_1 = require("./host");
12
+ const createTunnel = tslib_1.__importStar(require("tunnel-ssh"));
13
+ const host_1 = tslib_1.__importDefault(require("./host"));
14
14
  const pgDebug = (0, debug_1.default)('pg');
15
- const bastionKeyPlan = (a) => Boolean(/private/.test(a.addon.plan.name));
16
- exports.bastionKeyPlan = bastionKeyPlan;
17
- const env = (db) => {
18
- const baseEnv = Object.assign({ PGAPPNAME: 'psql non-interactive', PGSSLMODE: (!db.host || db.host === 'localhost') ? 'prefer' : 'require' }, process.env);
15
+ /**
16
+ * Determines whether the attachment belongs to an add-on installed onto a non-shield Private Space.
17
+ * If true, the bastion information needs to be fetched from the Data API.
18
+ * For add-ons installed onto a Shield Private Space, the bastion information should be fetched from config vars.
19
+ *
20
+ * @param attachment - The add-on attachment to check
21
+ * @returns True if the attachment belongs to a non-shield Private Space, false otherwise
22
+ */
23
+ function bastionKeyPlan(attachment) {
24
+ return Boolean(/private/.test(attachment.addon.plan.name.split(':', 2)[1]));
25
+ }
26
+ /**
27
+ * Fetches the bastion configuration from the Data API (only relevant for add-ons installed onto a
28
+ * non-shield Private Space).
29
+ * For add-ons installed onto a Shield Private Space, the bastion information is stored in the config vars.
30
+ *
31
+ * @param heroku - The Heroku API client
32
+ * @param addon - The add-on information
33
+ * @returns Promise that resolves to the bastion configuration
34
+ */
35
+ async function fetchBastionConfig(heroku, addon) {
36
+ const { body: bastionConfig } = await heroku.get(`/client/v11/databases/${encodeURIComponent(addon.id)}/bastion`, { hostname: (0, host_1.default)() });
37
+ if (bastionConfig.host && bastionConfig.private_key) {
38
+ return {
39
+ bastionHost: bastionConfig.host,
40
+ bastionKey: bastionConfig.private_key,
41
+ };
42
+ }
43
+ return {};
44
+ }
45
+ /**
46
+ * Returns the bastion configuration from the config vars for add-ons installed onto Shield
47
+ * Private Spaces.
48
+ *
49
+ * If there are bastions, extracts a host and a key from the config vars.
50
+ * If there are no bastions, returns an empty Object.
51
+ *
52
+ * We assert that _BASTIONS and _BASTION_KEY always exist together.
53
+ * If either is falsy, pretend neither exist.
54
+ *
55
+ * @param config - The configuration variables object
56
+ * @param baseName - The base name for the configuration variables
57
+ * @returns The bastion configuration object
58
+ */
59
+ const getBastionConfig = function (config, baseName) {
60
+ // <BASE_NAME>_BASTION_KEY contains the private key for the bastion.
61
+ const bastionKey = config[`${baseName}_BASTION_KEY`];
62
+ // <BASE_NAME>_BASTIONS contains a comma-separated list of hosts, select one at random.
63
+ const bastions = (config[`${baseName}_BASTIONS`] || '').split(',');
64
+ const bastionHost = bastions[Math.floor(Math.random() * bastions.length)];
65
+ if (bastionKey && bastionHost) {
66
+ return { bastionHost, bastionKey };
67
+ }
68
+ return {};
69
+ };
70
+ exports.getBastionConfig = getBastionConfig;
71
+ /**
72
+ * Returns both the required environment variables to effect the psql command execution and the tunnel
73
+ * configuration according to the database connection details.
74
+ *
75
+ * @param connectionDetails - The database connection details with attachment information
76
+ * @returns Object containing database environment variables and tunnel configuration
77
+ */
78
+ function getPsqlConfigs(connectionDetails) {
79
+ const dbEnv = baseEnv(connectionDetails);
80
+ const dbTunnelConfig = tunnelConfig(connectionDetails);
81
+ // If a tunnel is required, we need to adjust the environment variables for psql to use the tunnel host and port.
82
+ if (connectionDetails.bastionKey) {
83
+ Object.assign(dbEnv, {
84
+ PGHOST: dbTunnelConfig.localHost,
85
+ PGPORT: dbTunnelConfig.localPort.toString(),
86
+ });
87
+ }
88
+ return {
89
+ dbEnv,
90
+ dbTunnelConfig,
91
+ };
92
+ }
93
+ /**
94
+ * Returns the base environment variables for the database connection based on the connection details
95
+ * only, without taking into account if a tunnel is required for connecting to the database through a bastion host.
96
+ *
97
+ * @param connectionDetails - The database connection details
98
+ * @returns The base environment variables for the database connection
99
+ */
100
+ function baseEnv(connectionDetails) {
101
+ // Mapping of environment variables to ConnectionDetails properties
19
102
  const mapping = {
20
103
  PGDATABASE: 'database',
21
104
  PGHOST: 'host',
@@ -23,54 +106,49 @@ const env = (db) => {
23
106
  PGPORT: 'port',
24
107
  PGUSER: 'user',
25
108
  };
109
+ const baseEnv = Object.assign({ PGAPPNAME: 'psql non-interactive', PGSSLMODE: (!connectionDetails.host || connectionDetails.host === 'localhost') ? 'prefer' : 'require' }, process.env);
26
110
  for (const envVar of Object.keys(mapping)) {
27
- const val = db[mapping[envVar]];
111
+ const val = connectionDetails[mapping[envVar]];
28
112
  if (val) {
29
113
  baseEnv[envVar] = val;
30
114
  }
31
115
  }
32
116
  return baseEnv;
33
- };
34
- exports.env = env;
35
- async function fetchConfig(heroku, db) {
36
- return heroku.get(`/client/v11/databases/${encodeURIComponent(db.id)}/bastion`, {
37
- hostname: (0, host_1.default)(),
38
- });
39
117
  }
40
- const getBastion = function (config, baseName) {
41
- // If there are bastions, extract a host and a key
42
- // otherwise, return an empty Object
43
- // If there are bastions:
44
- // * there should be one *_BASTION_KEY
45
- // * pick one host from the comma-separated list in *_BASTIONS
46
- // We assert that _BASTIONS and _BASTION_KEY always exist together
47
- // If either is falsy, pretend neither exist
48
- const bastionKey = config[`${baseName}_BASTION_KEY`];
49
- const bastions = (config[`${baseName}_BASTIONS`] || '').split(',');
50
- const bastionHost = bastions[Math.floor(Math.random() * bastions.length)];
51
- return (bastionKey && bastionHost) ? { bastionHost, bastionKey } : {};
52
- };
53
- exports.getBastion = getBastion;
54
- function getConfigs(db) {
55
- const dbEnv = (0, exports.env)(db);
56
- const dbTunnelConfig = tunnelConfig(db);
57
- if (db.bastionKey) {
58
- Object.assign(dbEnv, {
59
- PGHOST: dbTunnelConfig.localHost,
60
- PGPORT: dbTunnelConfig.localPort,
61
- });
62
- }
118
+ /**
119
+ * Creates a tunnel configuration object based on the connection details.
120
+ *
121
+ * @param connectionDetails - The database connection details with attachment information
122
+ * @returns The tunnel configuration object
123
+ */
124
+ function tunnelConfig(connectionDetails) {
125
+ const localHost = '127.0.0.1';
126
+ const localPort = Math.floor((Math.random() * (65535 - 49152)) + 49152);
63
127
  return {
64
- dbEnv,
65
- dbTunnelConfig,
128
+ dstHost: connectionDetails.host,
129
+ dstPort: Number.parseInt(connectionDetails.port, 10),
130
+ host: connectionDetails.bastionHost,
131
+ localHost,
132
+ localPort,
133
+ privateKey: connectionDetails.bastionKey,
134
+ username: 'bastion',
66
135
  };
67
136
  }
68
- async function sshTunnel(db, dbTunnelConfig, timeout = 10000) {
69
- if (!db.bastionKey) {
70
- return null;
137
+ /**
138
+ * Establishes an SSH tunnel to the database using the provided configuration.
139
+ *
140
+ * @param connectionDetails - The database connection details with attachment information
141
+ * @param dbTunnelConfig - The tunnel configuration object
142
+ * @param timeout - The timeout in milliseconds (default: 10000)
143
+ * @param createSSHTunnel - The function to create the SSH tunnel (default: promisified createTunnel.default)
144
+ * @returns Promise that resolves to the tunnel server or null if no bastion key is provided
145
+ * @throws Error if unable to establish the tunnel
146
+ */
147
+ async function sshTunnel(connectionDetails, dbTunnelConfig, timeout = 10000, createSSHTunnel = (0, node_util_1.promisify)(createTunnel.default)) {
148
+ if (!connectionDetails.bastionKey) {
149
+ return;
71
150
  }
72
151
  const timeoutInstance = new Timeout(timeout, 'Establishing a secure tunnel timed out');
73
- const createSSHTunnel = (0, node_util_1.promisify)(createTunnel);
74
152
  try {
75
153
  return await Promise.race([
76
154
  timeoutInstance.promise(),
@@ -79,41 +157,48 @@ async function sshTunnel(db, dbTunnelConfig, timeout = 10000) {
79
157
  }
80
158
  catch (error) {
81
159
  pgDebug(error);
82
- core_1.ux.error('Unable to establish a secure tunnel to your database.');
160
+ throw new Error(`Unable to establish a secure tunnel to your database: ${error.message}.`);
83
161
  }
84
162
  finally {
85
163
  timeoutInstance.cancel();
86
164
  }
87
165
  }
88
- function tunnelConfig(db) {
89
- const localHost = '127.0.0.1';
90
- const localPort = Math.floor((Math.random() * (65535 - 49152)) + 49152);
91
- return {
92
- dstHost: db.host || undefined,
93
- dstPort: (db.port && Number.parseInt(db.port, 10)) || undefined,
94
- host: db.bastionHost,
95
- localHost,
96
- localPort,
97
- privateKey: db.bastionKey,
98
- username: 'bastion',
99
- };
100
- }
166
+ /**
167
+ * A timeout utility class that can be cancelled.
168
+ */
101
169
  class Timeout {
170
+ /**
171
+ * Creates a new Timeout instance.
172
+ *
173
+ * @param timeout - The timeout duration in milliseconds
174
+ * @param message - The error message to display when timeout occurs
175
+ */
102
176
  constructor(timeout, message) {
103
177
  // eslint-disable-next-line unicorn/prefer-event-target
104
- this.events = new EventEmitter();
178
+ this.events = new node_events_1.EventEmitter();
105
179
  this.timeout = timeout;
106
180
  this.message = message;
107
181
  }
182
+ /**
183
+ * Cancels the timeout.
184
+ *
185
+ * @returns void
186
+ */
108
187
  cancel() {
109
188
  this.events.emit('cancelled');
110
189
  }
190
+ /**
191
+ * Returns a promise that resolves when the timeout is cancelled or rejects when the timeout occurs.
192
+ *
193
+ * @returns Promise that resolves to void when cancelled or rejects with an error when timeout occurs
194
+ */
111
195
  async promise() {
112
- this.timer = setTimeout(() => {
113
- this.events.emit('error', new Error(this.message));
114
- }, this.timeout);
196
+ this.timer = setTimeout(() => this.events.emit('timeout'), this.timeout);
115
197
  try {
116
- await EventEmitter.once(this.events, 'cancelled');
198
+ await new Promise((resolve, reject) => {
199
+ this.events.once('cancelled', () => resolve());
200
+ this.events.once('timeout', () => reject(new Error(this.message)));
201
+ });
117
202
  }
118
203
  finally {
119
204
  clearTimeout(this.timer);
@@ -1,8 +1,34 @@
1
+ import type { HTTP } from '@heroku/http-call';
1
2
  import type { APIClient } from '@heroku-cli/command';
2
- import type { AddOnAttachment } from '@heroku-cli/schema';
3
- import type { AddOnAttachmentWithConfigVarsAndPlan } from '../../types/pg/data-api';
4
- export declare function getConfig(heroku: APIClient, app: string): Promise<Record<string, string> | undefined>;
5
- export declare function getConfigVarName(configVars: string[]): string;
6
- export declare function getConfigVarNameFromAttachment(attachment: Required<{
7
- addon: AddOnAttachmentWithConfigVarsAndPlan;
8
- } & AddOnAttachment>, config?: Record<string, string>): string;
3
+ import type { ExtendedAddonAttachment } from '../../types/pg/data-api';
4
+ /**
5
+ * Cache of app config vars.
6
+ */
7
+ export declare const configVarsByAppIdCache: Map<string, Promise<HTTP<Record<string, string>>>>;
8
+ /**
9
+ * Returns the app's config vars as a record of key-value pairs, either from the cache or from the API.
10
+ *
11
+ * @param heroku - The Heroku API client
12
+ * @param appId - The ID of the app to get config vars for
13
+ * @returns Promise resolving to a record of config var key-value pairs
14
+ */
15
+ export declare function getConfig(heroku: APIClient, appId: string): Promise<Record<string, string>>;
16
+ /**
17
+ * Returns the attachment's first config var name that has a `_URL` suffix, expected to be the name of the one
18
+ * that contains the database URL connection string.
19
+ *
20
+ * @param configVarNames - Array of config var names from the attachment
21
+ * @returns The first config var name ending with '_URL'
22
+ * @throws {Error} When no config var names end with '_URL'
23
+ */
24
+ export declare function getConfigVarName(configVarNames: ExtendedAddonAttachment['config_vars']): string;
25
+ /**
26
+ * Returns the config var name that contains the database URL connection string for the given
27
+ * attachment, based on the contents of the app's config vars.
28
+ *
29
+ * @param attachment - The addon attachment to get the config var name for
30
+ * @param config - Optional record of app config vars (defaults to empty object)
31
+ * @returns The config var name containing the database URL
32
+ * @throws {Error} When no config vars are found or when they don't contain a database URL
33
+ */
34
+ export declare function getConfigVarNameFromAttachment(attachment: ExtendedAddonAttachment, config?: Record<string, string>): string;
@@ -1,34 +1,68 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.configVarsByAppIdCache = void 0;
3
4
  exports.getConfig = getConfig;
4
5
  exports.getConfigVarName = getConfigVarName;
5
6
  exports.getConfigVarNameFromAttachment = getConfigVarNameFromAttachment;
6
7
  const color_1 = require("@heroku-cli/color");
7
- const core_1 = require("@oclif/core");
8
- const responseByAppId = new Map();
9
- async function getConfig(heroku, app) {
10
- if (!responseByAppId.has(app)) {
11
- const promise = heroku.get(`/apps/${app}/config-vars`);
12
- responseByAppId.set(app, promise);
8
+ /**
9
+ * Cache of app config vars.
10
+ */
11
+ exports.configVarsByAppIdCache = new Map();
12
+ /**
13
+ * Returns the app's config vars as a record of key-value pairs, either from the cache or from the API.
14
+ *
15
+ * @param heroku - The Heroku API client
16
+ * @param appId - The ID of the app to get config vars for
17
+ * @returns Promise resolving to a record of config var key-value pairs
18
+ */
19
+ async function getConfig(heroku, appId) {
20
+ let promise = exports.configVarsByAppIdCache.get(appId);
21
+ if (!promise) {
22
+ promise = heroku.get(`/apps/${appId}/config-vars`);
23
+ exports.configVarsByAppIdCache.set(appId, promise);
13
24
  }
14
- const result = await responseByAppId.get(app);
15
- return result === null || result === void 0 ? void 0 : result.body;
25
+ const { body: config } = await promise;
26
+ return config;
16
27
  }
17
- function getConfigVarName(configVars) {
18
- const connStringVars = configVars.filter(cv => (cv.endsWith('_URL')));
19
- if (connStringVars.length === 0)
28
+ /**
29
+ * Returns the attachment's first config var name that has a `_URL` suffix, expected to be the name of the one
30
+ * that contains the database URL connection string.
31
+ *
32
+ * @param configVarNames - Array of config var names from the attachment
33
+ * @returns The first config var name ending with '_URL'
34
+ * @throws {Error} When no config var names end with '_URL'
35
+ */
36
+ function getConfigVarName(configVarNames) {
37
+ const urlConfigVarNames = configVarNames.filter(cv => (cv.endsWith('_URL')));
38
+ if (urlConfigVarNames.length === 0)
20
39
  throw new Error('Database URL not found for this addon');
21
- return connStringVars[0];
40
+ return urlConfigVarNames[0];
22
41
  }
42
+ /**
43
+ * Returns the config var name that contains the database URL connection string for the given
44
+ * attachment, based on the contents of the app's config vars.
45
+ *
46
+ * @param attachment - The addon attachment to get the config var name for
47
+ * @param config - Optional record of app config vars (defaults to empty object)
48
+ * @returns The config var name containing the database URL
49
+ * @throws {Error} When no config vars are found or when they don't contain a database URL
50
+ */
23
51
  function getConfigVarNameFromAttachment(attachment, config = {}) {
24
- var _a, _b;
25
- const configVars = (_b = (_a = attachment.addon.config_vars) === null || _a === void 0 ? void 0 : _a.filter((cv) => { var _a; return (_a = config[cv]) === null || _a === void 0 ? void 0 : _a.startsWith('postgres://'); })) !== null && _b !== void 0 ? _b : [];
26
- if (configVars.length === 0) {
27
- core_1.ux.error(`No config vars found for ${attachment.name}; perhaps they were removed as a side effect of ${color_1.default.cmd('heroku rollback')}? Use ${color_1.default.cmd('heroku addons:attach')} to create a new attachment and then ${color_1.default.cmd('heroku addons:detach')} to remove the current attachment.`);
52
+ // Handle the case where no attachment config var names remain after filtering out those that don't contain a
53
+ // database URL connection string in the app's config vars.
54
+ const connStringConfigVarNames = attachment.config_vars
55
+ .filter(cvn => { var _a; return (_a = config[cvn]) === null || _a === void 0 ? void 0 : _a.startsWith('postgres://'); });
56
+ if (connStringConfigVarNames.length === 0) {
57
+ throw new Error(`No config vars found for ${attachment.name}; perhaps they were removed as a side effect of`
58
+ + ` ${color_1.color.cmd('heroku rollback')}? Use ${color_1.color.cmd('heroku addons:attach')} to create a new attachment and `
59
+ + `then ${color_1.color.cmd('heroku addons:detach')} to remove the current attachment.`);
28
60
  }
61
+ // Generate the default config var name and return it if it contains a database URL connection string.
29
62
  const configVarName = `${attachment.name}_URL`;
30
- if (configVars.includes(configVarName) && configVarName in config) {
63
+ if (connStringConfigVarNames.includes(configVarName) && configVarName in config) {
31
64
  return configVarName;
32
65
  }
33
- return getConfigVarName(configVars);
66
+ // Return the first config var name that has a `_URL` suffix. This might not be needed at all anymore.
67
+ return getConfigVarName(connStringConfigVarNames);
34
68
  }
@@ -1,12 +1,76 @@
1
- import type { AddOnAttachment } from '@heroku-cli/schema';
2
1
  import { APIClient } from '@heroku-cli/command';
3
- import type { AddOnAttachmentWithConfigVarsAndPlan } from '../../types/pg/data-api';
2
+ import type { ExtendedAddonAttachment } from '../../types/pg/data-api';
4
3
  import type { ConnectionDetails, ConnectionDetailsWithAttachment } from '../../types/pg/tunnel';
5
- export declare function getAttachment(heroku: APIClient, app: string, db?: string, namespace?: string): Promise<Required<{
6
- addon: AddOnAttachmentWithConfigVarsAndPlan;
7
- } & AddOnAttachment>>;
8
- export declare const getConnectionDetails: (attachment: Required<{
9
- addon: AddOnAttachmentWithConfigVarsAndPlan;
10
- } & AddOnAttachment>, configVars?: Record<string, string>) => ConnectionDetailsWithAttachment;
11
- export declare function getDatabase(heroku: APIClient, app: string, db?: string, namespace?: string): Promise<ConnectionDetailsWithAttachment>;
12
- export declare const parsePostgresConnectionString: (db: string) => ConnectionDetails;
4
+ import { fetchBastionConfig } from './bastion';
5
+ import { getConfig } from './config-vars';
6
+ export default class DatabaseResolver {
7
+ private readonly heroku;
8
+ private readonly getConfigFn;
9
+ private readonly fetchBastionConfigFn;
10
+ private readonly addonAttachmentResolver;
11
+ private readonly attachmentHeaders;
12
+ constructor(heroku: APIClient, getConfigFn?: typeof getConfig, fetchBastionConfigFn?: typeof fetchBastionConfig);
13
+ /**
14
+ * Resolves a database attachment based on the provided database identifier
15
+ * (attachment name, id, or config var name) and namespace (credential).
16
+ *
17
+ * @param appId - The ID of the app to get the attachment for
18
+ * @param attachmentId - The database identifier (defaults to 'DATABASE_URL')
19
+ * @param namespace - Optional namespace/credential for the attachment
20
+ * @returns Promise resolving to the database attachment
21
+ * @throws {Error} When no databases exist or when database identifier is unknown
22
+ * @throws {AmbiguousError} When multiple matching attachments are found
23
+ */
24
+ getAttachment(appId: string, attachmentId?: string, namespace?: string): Promise<ExtendedAddonAttachment>;
25
+ /**
26
+ * Returns the connection details for a database attachment resolved through the identifiers passed as
27
+ * arguments: appId, attachmentId and namespace (credential).
28
+ *
29
+ * @param appId - The ID of the app containing the database
30
+ * @param attachmentId - Optional database identifier (defaults to 'DATABASE_URL')
31
+ * @param namespace - Optional namespace/credential for the attachment
32
+ * @returns Promise resolving to connection details with attachment information
33
+ */
34
+ getDatabase(appId: string, attachmentId?: string, namespace?: string): Promise<ConnectionDetailsWithAttachment>;
35
+ /**
36
+ * Parses a PostgreSQL connection string (or a local database name) into a ConnectionDetails object.
37
+ *
38
+ * @param connStringOrDbName - PostgreSQL connection string or local database name
39
+ * @returns Connection details object with parsed connection information
40
+ */
41
+ parsePostgresConnectionString(connStringOrDbName: string): ConnectionDetails;
42
+ /**
43
+ * Fetches all Heroku PostgreSQL add-on attachments for a given app.
44
+ *
45
+ * This is used internally by the `getAttachment` function to get all valid Heroku PostgreSQL add-on attachments
46
+ * to generate a list of possible valid attachments when the user passes a database name that doesn't match any
47
+ * attachments.
48
+ *
49
+ * @param appId - The ID of the app to get the attachments for
50
+ * @returns Promise resolving to array of PostgreSQL add-on attachments
51
+ */
52
+ private allPostgresAttachments;
53
+ /**
54
+ * Returns the connection details for a database attachment according to the app config vars.
55
+ *
56
+ * @param attachment - The attachment to get the connection details for
57
+ * @param config - The record of app config vars with their values
58
+ * @returns Connection details with attachment information
59
+ */
60
+ private getConnectionDetails;
61
+ /**
62
+ * Helper function that attempts to find a single addon attachment matching the given database identifier
63
+ * (attachment name, id, or config var name).
64
+ *
65
+ * This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
66
+ * It returns either a single match, multiple matches (for ambiguous cases), or null if no matches are found.
67
+ *
68
+ * The AddonAttachmentResolver uses the Platform API add-on attachment resolver endpoint to get the attachment.
69
+ *
70
+ * @param appId - The ID of the app to search for attachments
71
+ * @param attachmentId - The database identifier to match
72
+ * @param namespace - Optional namespace/credential filter
73
+ * @returns Promise resolving to either a single match, multiple matches with error, or no matches with error
74
+ */
75
+ private matchesHelper;
76
+ }