@heroku/sdk 0.9.0 → 0.11.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/README.md CHANGED
@@ -16,7 +16,7 @@ The simplest entry point is the `HerokuSDK` class. It exposes both the Platform
16
16
  import { HerokuSDK } from '@heroku/sdk'
17
17
  import { appExtensions } from '@heroku/sdk/extensions/platform'
18
18
 
19
- // Reads token from HEROKU_API_KEY or ~/.netrc
19
+ // Zero-arg reads the token from HEROKU_API_KEY (see Authentication below)
20
20
  const sdk = new HerokuSDK({ extensions: [appExtensions] })
21
21
 
22
22
  // Upstream route (auto-generated from the API spec)
@@ -49,6 +49,40 @@ You can also pass options directly:
49
49
  const sdk = new HerokuSDK({ clientOptions: { token: 'your-api-token' } })
50
50
  ```
51
51
 
52
+ ### Authentication
53
+
54
+ The SDK core is transport-agnostic: it reads no local files or keychains itself,
55
+ so it also runs in the browser. Token resolution is handled by
56
+ `@heroku/heroku-fetch`, which has a single default and otherwise defers to
57
+ whatever you pass in:
58
+
59
+ 1. **`HEROKU_API_KEY` (the default)** — with no explicit token, heroku-fetch's
60
+ default provider reads `HEROKU_API_KEY` from the environment, so a zero-arg
61
+ `new HerokuSDK()` is authenticated whenever that variable is set.
62
+ 2. **An explicit `token` (opt in to anything else)** — pass `clientOptions.token`
63
+ (or a per-service `clientOptionsByService`) and the SDK uses it instead of the
64
+ `HEROKU_API_KEY` default. This is how you authenticate from any other
65
+ credential source — for example a token stored by `heroku login`, which the
66
+ Heroku CLI resolves and injects itself (see the note below).
67
+
68
+ > **Note:** To authenticate as a logged-in Heroku CLI user, resolve the stored
69
+ > token in your (Node) application with
70
+ > [`@heroku/heroku-credential-manager`](https://github.com/heroku/heroku-credential-manager)
71
+ > and pass it in:
72
+ >
73
+ > ```ts
74
+ > import { getAuth } from '@heroku/heroku-credential-manager'
75
+ > import { HerokuSDK } from '@heroku/sdk'
76
+ >
77
+ > // account: your Heroku login email; host: the API host (usually api.heroku.com)
78
+ > const { token } = await getAuth(account, 'api.heroku.com')
79
+ > const sdk = new HerokuSDK({ clientOptions: { token } })
80
+ > ```
81
+ >
82
+ > This keeps keychain/filesystem access in the Node consumer (CLI, MCP server,
83
+ > VS Code extension) rather than in the SDK core, so the SDK stays usable in the
84
+ > browser.
85
+
52
86
  Common client options apply to every service. In particular, a common `baseUrl`
53
87
  overrides every service's default host, including Metrics and Repositories. Use
54
88
  shallow per-service overrides when only specific services should use a host or
@@ -2,32 +2,32 @@ import { extendResource } from '../../core/extend-resource.js';
2
2
  import { resolvePgDatabase } from './internal/resolve-pg-database.js';
3
3
  export async function describe(ctx, appIdentity, addonIdentity, options = {}) {
4
4
  options.signal?.throwIfAborted();
5
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
5
+ const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
6
6
  return ctx.data.database.info(addon.id);
7
7
  }
8
8
  export async function upgradeWaitStatus(ctx, appIdentity, addonIdentity, options = {}) {
9
9
  options.signal?.throwIfAborted();
10
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
10
+ const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
11
11
  return ctx.data.database.upgradeWaitStatus(addon.id);
12
12
  }
13
13
  export async function dryRunUpgrade(ctx, appIdentity, addonIdentity, body = {}, options = {}) {
14
14
  options.signal?.throwIfAborted();
15
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
15
+ const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
16
16
  return ctx.data.database.dryRunUpgrade(addon.id, body);
17
17
  }
18
18
  export async function runUpgrade(ctx, appIdentity, addonIdentity, body = {}, options = {}) {
19
19
  options.signal?.throwIfAborted();
20
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
20
+ const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
21
21
  return ctx.data.database.runUpgrade(addon.id, body);
22
22
  }
23
23
  export async function prepareUpgrade(ctx, appIdentity, addonIdentity, body = {}, options = {}) {
24
24
  options.signal?.throwIfAborted();
25
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
25
+ const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
26
26
  return ctx.data.database.prepareUpgrade(addon.id, body);
27
27
  }
28
28
  export async function cancelUpgrade(ctx, appIdentity, addonIdentity, options = {}) {
29
29
  options.signal?.throwIfAborted();
30
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
30
+ const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
31
31
  return ctx.data.database.cancelUpgrade(addon.id);
32
32
  }
33
33
  export const databaseExtensions = extendResource('data', 'database', ctx => ({
@@ -1,10 +1,11 @@
1
1
  import type { AddOn } from '@heroku/types/3.sdk';
2
2
  import { ResourceCtx } from '../../../core/extend-resource.js';
3
+ import { AddOnAttachmentWithInclusions } from '../../platform/add-on-attachment/resolve.js';
3
4
  /**
4
5
  * Returns an arbitrary legacy database add-on based on the provided app name.
5
6
  */
6
7
  export declare function getArbitraryLegacyDB(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string): Promise<AddOn>;
7
8
  export declare const getAddonService: () => string;
8
- export declare const isPostgresAddon: (addon: AddOn) => boolean;
9
+ export declare const isPostgresAddon: (addon: AddOn | AddOnAttachmentWithInclusions["addon"]) => boolean;
9
10
  export declare const isAdvancedDatabase: (addon: AddOn) => boolean;
10
11
  export declare const isLegacyDatabase: (addon: AddOn) => boolean;
@@ -6,10 +6,19 @@ export type ResolvedPgDatabaseWithAttachment = {
6
6
  attachment: ResolvedAddOnAttachment;
7
7
  };
8
8
  export type ResolvePgDatabaseOptions = ResolveAddonAttachmentOptions & {
9
- appIdentity?: string;
10
9
  includeAttachment?: boolean;
11
10
  input?: string;
12
11
  };
12
+ /**
13
+ * Thrown by `resolvePgDatabase` when the attachment resolution endpoint
14
+ * does not find a match, the matched attachment is not the configured
15
+ * Postgres service, or the app has no Postgres databases at all.
16
+ */
17
+ export declare class PgDatabaseNotFoundError extends Error {
18
+ constructor(message: string, options?: {
19
+ cause?: unknown;
20
+ });
21
+ }
13
22
  /**
14
23
  * Resolve a Heroku Postgres database add-on from an attachment identity.
15
24
  *
@@ -23,7 +32,8 @@ export type ResolvePgDatabaseOptions = ResolveAddonAttachmentOptions & {
23
32
  * `::` ourselves and send `app`/`addon` as separate fields, which
24
33
  * works for either shape.
25
34
  * - any other string (attachment name, config var, UUID, or
26
- * globally-unique add-on name) → routed through `resolveAddonAttachment`.
35
+ * globally-unique add-on name) → routed through `resolveAddonAttachment`,
36
+ * scoped to `appIdentity`.
27
37
  *
28
38
  * This function only uses the attachment resolver. To resolve a database
29
39
  * through the add-on resolve endpoint, call `resolveAddon` directly.
@@ -37,11 +47,22 @@ export type ResolvePgDatabaseOptions = ResolveAddonAttachmentOptions & {
37
47
  * resolves to the same value on the app, they're treated as equivalent
38
48
  * (e.g. the same database attached under two names) rather than ambiguous.
39
49
  *
40
- * Throws `AddonAttachmentNotFoundError` (or `AddonAttachmentAmbiguousError`)
41
- * from the underlying resolver. Throws if no input is given and no `appIdentity`
42
- * is available to default the attachment lookup to.
50
+ * Throws a `PgDatabaseNotFoundError` when the resolve endpoint 404s, the matched
51
+ * attachment is not the configured Postgres service, or the app has no databases.
52
+ * Throws an `AddonAttachmentAmbiguousError` when the remaining matches are not
53
+ * the same database. Throws a plain `Error` if `appIdentity` is empty and `input`
54
+ * has no `::`-prefixed app to fall back on.
55
+ *
56
+ * `resolvePgDatabase` should not throw a bare `NotFoundError`.
57
+ * Extensions that use it typically couple it with a second data call
58
+ * (e.g. `data.database.info(addon.id)`) that can also 404. Callers often
59
+ * catch `NotFoundError` to handle that second call's failure specifically.
60
+ * However, if a resolution failure surfaced as a `NotFoundError` too, that
61
+ * catch would swallow it and mislabel it as the second call's failure instead.
62
+ * Resolution failures are reported as `PgDatabaseNotFoundError` so the two stay
63
+ * separable by type.
43
64
  */
44
- export declare function resolvePgDatabase(ctx: Pick<ResourceCtx, 'platform'>, options: ResolvePgDatabaseOptions & {
65
+ export declare function resolvePgDatabase(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options: ResolvePgDatabaseOptions & {
45
66
  includeAttachment: true;
46
67
  }): Promise<ResolvedPgDatabaseWithAttachment>;
47
- export declare function resolvePgDatabase(ctx: Pick<ResourceCtx, 'platform'>, options?: ResolvePgDatabaseOptions): Promise<ResolvedPgDatabase>;
68
+ export declare function resolvePgDatabase(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: ResolvePgDatabaseOptions): Promise<ResolvedPgDatabase>;
@@ -1,36 +1,105 @@
1
+ import { NotFoundError } from '@heroku/heroku-fetch';
1
2
  import createDebug from 'debug';
2
- import { AddonAttachmentAmbiguousError, resolveAddonAttachment } from '../../platform/add-on-attachment/resolve.js';
3
- import { getAddonService } from './postgres-utils.js';
3
+ import { AddonAttachmentAmbiguousError, AddonAttachmentNotFoundError, resolveAddonAttachment } from '../../platform/add-on-attachment/resolve.js';
4
+ import { getAddonService, isPostgresAddon } from './postgres-utils.js';
4
5
  const debug = createDebug('heroku:sdk:resources:database');
5
6
  const DEFAULT_PG_ATTACHMENT = 'DATABASE_URL';
6
- const PG_ADDON_SERVICE = getAddonService();
7
- export async function resolvePgDatabase(ctx, options = {}) {
8
- const { appIdentity, includeAttachment, input, ...rest } = options;
9
- if (!input && !appIdentity) {
10
- throw new Error('resolvePgDatabase requires either input or appIdentity to default to DATABASE_URL.');
7
+ /**
8
+ * Thrown by `resolvePgDatabase` when the attachment resolution endpoint
9
+ * does not find a match, the matched attachment is not the configured
10
+ * Postgres service, or the app has no Postgres databases at all.
11
+ */
12
+ export class PgDatabaseNotFoundError extends Error {
13
+ constructor(message, options) {
14
+ super(message, options);
15
+ this.name = 'PgDatabaseNotFoundError';
11
16
  }
17
+ }
18
+ export async function resolvePgDatabase(ctx, appIdentity, options = {}) {
19
+ const { includeAttachment, input, ...rest } = options;
12
20
  const databaseReference = input ?? DEFAULT_PG_ATTACHMENT;
13
21
  const { addon, app } = databaseReference.includes('::')
14
22
  ? parseBranchReference(databaseReference, appIdentity)
15
23
  : { addon: databaseReference, app: appIdentity };
16
- debug('resolve input=%s addon=%s app=%s', input ?? '<default>', addon, app ?? '<global>');
24
+ if (!app) {
25
+ throw new Error('resolvePgDatabase requires a non-empty app identity.');
26
+ }
27
+ debug('resolve input=%s addon=%s app=%s', input ?? '<default>', addon, app);
17
28
  let attachment;
18
29
  try {
19
- attachment = await resolveAddonAttachment(ctx, app, addon, { ...rest, addonService: PG_ADDON_SERVICE });
30
+ attachment = await resolveAddonAttachment(ctx, app, addon, { ...rest, addonService: getAddonService() });
20
31
  }
21
32
  catch (error) {
22
- if (!(error instanceof AddonAttachmentAmbiguousError))
33
+ if (error instanceof NotFoundError || error instanceof AddonAttachmentNotFoundError) {
34
+ throw await buildDatabaseNotFoundError(ctx, app, addon, error, rest.signal);
35
+ }
36
+ if (error instanceof AddonAttachmentAmbiguousError) {
37
+ const equivalent = collapseIfEquivalent(error.matches);
38
+ if (!equivalent)
39
+ throw error;
40
+ let config;
41
+ try {
42
+ config = await ctx.platform
43
+ .withOptions({ signal: rest.signal })
44
+ .configVar.infoForApp(equivalent[0].app.name);
45
+ }
46
+ catch (configVarError) {
47
+ rest.signal?.throwIfAborted();
48
+ debug('config var lookup failed while collapsing ambiguous matches: %O', configVarError);
49
+ throw error;
50
+ }
51
+ if (!allMatchSameUrl(equivalent, config))
52
+ throw error;
53
+ attachment = equivalent[0];
54
+ }
55
+ else {
23
56
  throw error;
24
- const equivalent = collapseIfEquivalent(error.matches);
25
- if (!equivalent)
26
- throw error;
27
- const config = await ctx.platform.configVar.infoForApp(equivalent[0].app.name);
28
- if (!allMatchSameUrl(equivalent, config))
29
- throw error;
30
- attachment = equivalent[0];
57
+ }
31
58
  }
32
59
  return includeAttachment ? { addon: attachment.addon, attachment } : attachment.addon;
33
60
  }
61
+ async function buildDatabaseNotFoundError(ctx, appIdentity, attachmentId, error, signal) {
62
+ let attachments;
63
+ try {
64
+ attachments = await listPostgresAttachments(ctx, appIdentity, signal);
65
+ }
66
+ catch (listError) {
67
+ signal?.throwIfAborted();
68
+ if (listError instanceof NotFoundError) {
69
+ // `NotFoundError` from the attachment resolution endpoint is ambiguous.
70
+ // It could be because the app doesn't exist, or because the attachment
71
+ // doesn't exist. The endpoint for listing all attachments only 404s when
72
+ // the app doesn't exist, so we rethrow the error to cover that case.
73
+ throw new PgDatabaseNotFoundError(`Couldn't find that app: ${appIdentity}`, { cause: listError });
74
+ }
75
+ debug('listing attachments failed while building not-found error: %O', listError);
76
+ throw new PgDatabaseNotFoundError(`Could not resolve ${stripUrlSuffix(attachmentId)} on ${appIdentity}`, { cause: error });
77
+ }
78
+ if (attachments.length === 0) {
79
+ return new PgDatabaseNotFoundError(`${appIdentity} has no ${getAddonService()} databases`, { cause: error });
80
+ }
81
+ const validOptions = attachments
82
+ .map(attachment => {
83
+ const urlVarName = urlConfigVarName(attachment.config_vars);
84
+ return urlVarName ? stripUrlSuffix(urlVarName) : attachment.name;
85
+ })
86
+ .join(', ');
87
+ const databaseName = stripUrlSuffix(attachmentId);
88
+ if (error instanceof AddonAttachmentNotFoundError) {
89
+ return new PgDatabaseNotFoundError(`${databaseName} is not a ${getAddonService()} database on ${appIdentity}. Valid options are: ${validOptions}`, { cause: error });
90
+ }
91
+ return new PgDatabaseNotFoundError(`Unknown database: ${databaseName} on ${appIdentity}. Valid options are: ${validOptions}`, { cause: error });
92
+ }
93
+ async function listPostgresAttachments(ctx, appIdentity, signal) {
94
+ const attachments = await ctx.platform
95
+ .withHeaders({ 'Accept-Inclusion': 'addon:plan,config_vars' })
96
+ .withOptions({ signal })
97
+ .addOnAttachment.listByApp(appIdentity);
98
+ return attachments.filter(attachment => isPostgresAddon(attachment.addon));
99
+ }
100
+ function stripUrlSuffix(name) {
101
+ return name.endsWith('_URL') ? name.slice(0, -4) : name;
102
+ }
34
103
  /**
35
104
  * Narrows ambiguous matches to a same-add-on group worth comparing by
36
105
  * config var value. Returns `undefined` (not worth a config-vars round
@@ -62,5 +131,5 @@ function parseBranchReference(reference, fallbackApp) {
62
131
  if (match) {
63
132
  return { addon: match[2], app: match[1] };
64
133
  }
65
- return fallbackApp ? { addon: reference, app: fallbackApp } : { addon: reference };
134
+ return { addon: reference, app: fallbackApp };
66
135
  }
@@ -2,7 +2,7 @@ import { extendResource } from '../../core/extend-resource.js';
2
2
  import { resolvePgDatabase } from './internal/resolve-pg-database.js';
3
3
  export async function info(ctx, appIdentity, addonIdentity, options = {}) {
4
4
  options.signal?.throwIfAborted();
5
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
5
+ const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
6
6
  return ctx.data.maintenance.info(addon.id);
7
7
  }
8
8
  export const maintenanceExtensions = extendResource('data', 'maintenance', ctx => ({
@@ -2,7 +2,7 @@ import { extendResource } from '../../core/extend-resource.js';
2
2
  import { resolvePgDatabase } from './internal/resolve-pg-database.js';
3
3
  export async function listCredentials(ctx, appIdentity, addonIdentity, options = {}) {
4
4
  options.signal?.throwIfAborted();
5
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
5
+ const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
6
6
  return ctx.data.postgresDatabase.listCredentials(addon.id);
7
7
  }
8
8
  export const postgresDatabaseExtensions = extendResource('data', 'postgresDatabase', ctx => ({
@@ -3,7 +3,7 @@ import { waitForTransfer } from './wait-for-transfer.js';
3
3
  export async function captureAndWait(ctx, appIdentity, addonIdentity, options = {}) {
4
4
  const { capturePoller, signal, waitPoller } = options;
5
5
  signal?.throwIfAborted();
6
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
6
+ const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
7
7
  capturePoller?.onStart?.(addon);
8
8
  const backup = await ctx.data.backup.create(addon.id);
9
9
  capturePoller?.onStop?.(addon);
@@ -3,7 +3,7 @@ import { waitForTransfer } from './wait-for-transfer.js';
3
3
  export async function restoreAndWait(ctx, appIdentity, addonIdentity, backupUrl, options = {}) {
4
4
  const { extensions, restorePoller, signal, waitPoller } = options;
5
5
  signal?.throwIfAborted();
6
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
6
+ const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
7
7
  restorePoller?.onStart?.(addon);
8
8
  // eslint-disable-next-line camelcase
9
9
  const restore = await ctx.data.restore.create(addon.id, { backup_url: backupUrl, extensions });
@@ -9,8 +9,8 @@ export async function list(ctx, appIdentity, options = {}) {
9
9
  }
10
10
  export async function create(ctx, appIdentity, addonIdentity, body, options = {}) {
11
11
  options.signal?.throwIfAborted();
12
- const { addon, attachment } = await resolvePgDatabase(ctx, {
13
- appIdentity, includeAttachment: true, input: addonIdentity, ...options,
12
+ const { addon, attachment } = await resolvePgDatabase(ctx, appIdentity, {
13
+ includeAttachment: true, input: addonIdentity, ...options,
14
14
  });
15
15
  let requestBody = body;
16
16
  if (!requestBody.schedule_name) {
@@ -21,7 +21,7 @@ export async function create(ctx, appIdentity, addonIdentity, body, options = {}
21
21
  }
22
22
  export async function del(ctx, appIdentity, addonIdentity, scheduleId, options = {}) {
23
23
  options.signal?.throwIfAborted();
24
- const addon = await resolvePgDatabase(ctx, { appIdentity, input: addonIdentity, ...options });
24
+ const addon = await resolvePgDatabase(ctx, appIdentity, { input: addonIdentity, ...options });
25
25
  return ctx.data.transferSchedule.delete(addon.id, scheduleId);
26
26
  }
27
27
  export const transferScheduleExtensions = extendResource('data', 'transferSchedule', ctx => ({
@@ -6,7 +6,7 @@ export function createMetricsClient(options = {}) {
6
6
  // Metrics is a distinct host that heroku-fetch does not model as a first-class
7
7
  // service, so we use `service: 'custom'` with an explicit base URL. Auth (bearer
8
8
  // token) is resolved by heroku-fetch exactly as for platform/data — same
9
- // HEROKU_API_KEY / netrc source, shared across services on one SDK instance.
9
+ // HEROKU_API_KEY / explicit-token source, shared across services on one SDK instance.
10
10
  //
11
11
  // Unlike platform/data, `service: 'custom'` sends no default Accept header, so we
12
12
  // set `application/vnd.heroku+json; version=3` explicitly to match what meetas
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroku/sdk",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Heroku SDK",
5
5
  "author": "Heroku",
6
6
  "type": "module",
@@ -110,6 +110,7 @@
110
110
  "types": "./dist/resources/data/transfer/index.d.ts",
111
111
  "default": "./dist/resources/data/transfer/index.js"
112
112
  },
113
+ "./resources/data/internal/*": null,
113
114
  "./resources/*": {
114
115
  "types": "./dist/resources/*.d.ts",
115
116
  "default": "./dist/resources/*.js"
@@ -153,7 +154,7 @@
153
154
  "vitest": "^4.1.4"
154
155
  },
155
156
  "dependencies": {
156
- "@heroku/heroku-fetch": "^0.1.4",
157
+ "@heroku/heroku-fetch": "^0.2.0",
157
158
  "@heroku/types": "^5.0.0",
158
159
  "debug": "^4.4.0"
159
160
  }