@myapihq/cli 2.4.1 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/commands/crm/companies.js +1 -5
  2. package/dist/commands/crm/contacts.js +1 -6
  3. package/dist/commands/domain-validation.test.d.ts +1 -0
  4. package/dist/commands/domain-validation.test.js +36 -0
  5. package/dist/commands/domain.d.ts +14 -0
  6. package/dist/commands/domain.js +20 -1
  7. package/dist/commands/funnel-validation.test.d.ts +1 -0
  8. package/dist/commands/funnel-validation.test.js +86 -0
  9. package/dist/commands/funnel.d.ts +12 -0
  10. package/dist/commands/funnel.js +59 -15
  11. package/dist/commands/payments.js +12 -2
  12. package/dist/commands/setup.d.ts +1 -0
  13. package/dist/commands/setup.js +51 -2
  14. package/dist/commands/skill-checksum.test.d.ts +1 -0
  15. package/dist/commands/skill-checksum.test.js +48 -0
  16. package/dist/config-envkey.test.d.ts +1 -0
  17. package/dist/config-envkey.test.js +145 -0
  18. package/dist/config.js +41 -5
  19. package/dist/errors.js +11 -0
  20. package/dist/index.js +6 -1
  21. package/dist/sdk-transport.test.d.ts +1 -0
  22. package/dist/sdk-transport.test.js +138 -0
  23. package/dist/sdk-version.test.d.ts +1 -0
  24. package/dist/sdk-version.test.js +24 -0
  25. package/dist/skills/my-api-hq/SKILL.md +1 -1
  26. package/dist/skills/my-api-hq/claude/.claude-plugin/plugin.json +1 -0
  27. package/dist/skills/my-audience-api/README.md +40 -0
  28. package/dist/skills/my-audience-api/SKILL.md +139 -0
  29. package/dist/skills/my-audience-api/claude/.claude-plugin/plugin.json +7 -0
  30. package/dist/skills/my-audience-api/openapi/.gitkeep +0 -0
  31. package/dist/skills/my-auth-api/SKILL.md +1 -1
  32. package/dist/skills/my-auth-api/claude/.claude-plugin/plugin.json +1 -0
  33. package/dist/skills/my-company-api/README.md +35 -0
  34. package/dist/skills/my-company-api/SKILL.md +110 -0
  35. package/dist/skills/my-company-api/claude/.claude-plugin/plugin.json +7 -0
  36. package/dist/skills/my-company-api/openapi/.gitkeep +0 -0
  37. package/dist/skills/my-container-api/README.md +34 -0
  38. package/dist/skills/my-container-api/SKILL.md +77 -0
  39. package/dist/skills/my-container-api/claude/.claude-plugin/plugin.json +7 -0
  40. package/dist/skills/my-container-api/openapi/.gitkeep +0 -0
  41. package/dist/skills/my-crm-api/SKILL.md +3 -3
  42. package/dist/skills/my-crm-api/claude/.claude-plugin/plugin.json +1 -0
  43. package/dist/skills/my-database-api/SKILL.md +1 -1
  44. package/dist/skills/my-database-api/claude/.claude-plugin/plugin.json +1 -0
  45. package/dist/skills/my-domain-api/SKILL.md +1 -1
  46. package/dist/skills/my-domain-api/claude/.claude-plugin/plugin.json +1 -0
  47. package/dist/skills/my-email-api/SKILL.md +1 -1
  48. package/dist/skills/my-email-api/claude/.claude-plugin/plugin.json +1 -0
  49. package/dist/skills/my-email-verify-api/SKILL.md +1 -1
  50. package/dist/skills/my-email-verify-api/claude/.claude-plugin/plugin.json +1 -0
  51. package/dist/skills/my-function-api/SKILL.md +104 -0
  52. package/dist/skills/my-function-api/claude/.claude-plugin/plugin.json +7 -0
  53. package/dist/skills/my-funnel-api/SKILL.md +2 -2
  54. package/dist/skills/my-funnel-api/claude/.claude-plugin/plugin.json +1 -0
  55. package/dist/skills/my-git-api/SKILL.md +1 -1
  56. package/dist/skills/my-git-api/claude/.claude-plugin/plugin.json +1 -0
  57. package/dist/skills/my-image-api/SKILL.md +1 -1
  58. package/dist/skills/my-image-api/claude/.claude-plugin/plugin.json +1 -0
  59. package/dist/skills/my-llm-api/SKILL.md +6 -11
  60. package/dist/skills/my-llm-api/claude/.claude-plugin/plugin.json +1 -0
  61. package/dist/skills/my-payments-api/SKILL.md +85 -0
  62. package/dist/skills/my-payments-api/claude/.claude-plugin/plugin.json +7 -0
  63. package/dist/skills/my-people-api/README.md +35 -0
  64. package/dist/skills/my-people-api/SKILL.md +107 -0
  65. package/dist/skills/my-people-api/claude/.claude-plugin/plugin.json +7 -0
  66. package/dist/skills/my-people-api/openapi/.gitkeep +0 -0
  67. package/dist/skills/my-pixel-api/README.md +38 -0
  68. package/dist/skills/my-pixel-api/SKILL.md +90 -0
  69. package/dist/skills/my-pixel-api/claude/.claude-plugin/plugin.json +7 -0
  70. package/dist/skills/my-pixel-api/openapi/.gitkeep +0 -0
  71. package/dist/skills/my-queue-api/README.md +34 -0
  72. package/dist/skills/my-queue-api/SKILL.md +78 -0
  73. package/dist/skills/my-queue-api/claude/.claude-plugin/plugin.json +7 -0
  74. package/dist/skills/my-queue-api/openapi/.gitkeep +0 -0
  75. package/dist/skills/my-storage-api/SKILL.md +1 -1
  76. package/dist/skills/my-storage-api/claude/.claude-plugin/plugin.json +1 -0
  77. package/dist/skills/my-task-api/README.md +36 -0
  78. package/dist/skills/my-task-api/SKILL.md +74 -0
  79. package/dist/skills/my-task-api/claude/.claude-plugin/plugin.json +7 -0
  80. package/dist/skills/my-task-api/openapi/.gitkeep +0 -0
  81. package/dist/skills/my-url-to/README.md +32 -0
  82. package/dist/skills/my-url-to/SKILL.md +55 -0
  83. package/dist/skills/my-url-to/claude/.claude-plugin/plugin.json +7 -0
  84. package/dist/skills/my-url-to/openapi/.gitkeep +0 -0
  85. package/dist/skills/my-webhook-api/SKILL.md +1 -1
  86. package/dist/skills/my-webhook-api/claude/.claude-plugin/plugin.json +1 -0
  87. package/dist/skills/my-workflow-api/SKILL.md +1 -1
  88. package/dist/skills/my-workflow-api/claude/.claude-plugin/plugin.json +1 -0
  89. package/package.json +8 -2
@@ -0,0 +1,145 @@
1
+ // loadConfig()'s env-key rule.
2
+ //
3
+ // The bug this pins: MYAPI_API_KEY used to override only `api_key` while the
4
+ // rest of the active account — default_org, default_funnel, default_domain —
5
+ // came along for the ride. Authenticating as account B against account A's
6
+ // default org is a cross-tenant write that no command-level guard can catch,
7
+ // because the org being written to genuinely is the configured default.
8
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
9
+ import * as fs from 'node:fs';
10
+ import * as os from 'node:os';
11
+ import * as path from 'node:path';
12
+ const KEY_A = 'hq_live_aaaaaaaaaaaaaaaaaaaa';
13
+ const KEY_B = 'hq_live_bbbbbbbbbbbbbbbbbbbb';
14
+ const ORG_A = 'org-aaaa';
15
+ const ORG_B = 'org-bbbb';
16
+ let tmpHome;
17
+ const FULL_CONFIG = {
18
+ active: 0,
19
+ autocomplete_setup: true,
20
+ accounts: [
21
+ { api_key: KEY_A, account_id: 'acct-a', default_org: ORG_A, default_funnel: 'fun-a', default_domain: 'a.com' },
22
+ { api_key: KEY_B, account_id: 'acct-b', default_org: ORG_B, default_funnel: 'fun-b', default_domain: 'b.com' },
23
+ ],
24
+ };
25
+ // config.ts resolves CONFIG_DIR from os.homedir() at import time, so HOME has
26
+ // to point at the fixture before the dynamic import below. os.homedir() can't
27
+ // be spied in ESM, but on POSIX it reads $HOME — which is also how the smoke
28
+ // tier keeps tests off the real ~/.myapi/config.json.
29
+ let realHome;
30
+ async function loadConfigFresh() {
31
+ vi.resetModules();
32
+ const mod = await import('./config.js');
33
+ return mod.loadConfig();
34
+ }
35
+ beforeEach(() => {
36
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'myapi-cfg-'));
37
+ realHome = process.env.HOME;
38
+ process.env.HOME = tmpHome;
39
+ fs.mkdirSync(path.join(tmpHome, '.myapi'), { recursive: true });
40
+ fs.writeFileSync(path.join(tmpHome, '.myapi', 'config.json'), JSON.stringify(FULL_CONFIG));
41
+ delete process.env.MYAPI_API_KEY;
42
+ delete process.env.MYAPI_KEY;
43
+ });
44
+ afterEach(() => {
45
+ vi.restoreAllMocks();
46
+ vi.resetModules();
47
+ if (realHome === undefined)
48
+ delete process.env.HOME;
49
+ else
50
+ process.env.HOME = realHome;
51
+ delete process.env.MYAPI_API_KEY;
52
+ delete process.env.MYAPI_KEY;
53
+ fs.rmSync(tmpHome, { recursive: true, force: true });
54
+ });
55
+ describe('loadConfig with no env key', () => {
56
+ it('returns the active account with its own defaults', async () => {
57
+ const c = await loadConfigFresh();
58
+ expect(c?.api_key).toBe(KEY_A);
59
+ expect(c?.default_org).toBe(ORG_A);
60
+ expect(c?.autocomplete_setup).toBe(true);
61
+ });
62
+ });
63
+ describe('loadConfig with an env key belonging to a stored account', () => {
64
+ it("adopts THAT account's defaults, not the active one's", async () => {
65
+ // Account A is active; the env key is B's. Every default must follow B.
66
+ process.env.MYAPI_API_KEY = KEY_B;
67
+ const c = await loadConfigFresh();
68
+ expect(c?.api_key).toBe(KEY_B);
69
+ expect(c?.account_id).toBe('acct-b');
70
+ expect(c?.default_org).toBe(ORG_B);
71
+ expect(c?.default_funnel).toBe('fun-b');
72
+ expect(c?.default_domain).toBe('b.com');
73
+ });
74
+ });
75
+ describe('MYAPI_CONFIG_DIR', () => {
76
+ // The agent test-harness used to write the operator's real
77
+ // ~/.myapi/config.json to point at its throwaway org, then restore it
78
+ // best-effort. This override is what lets it stop doing that.
79
+ it('redirects the config surface away from the home directory', async () => {
80
+ const alt = fs.mkdtempSync(path.join(os.tmpdir(), 'myapi-alt-'));
81
+ fs.writeFileSync(path.join(alt, 'config.json'), JSON.stringify({
82
+ active: 0,
83
+ accounts: [{ api_key: 'hq_live_alt', account_id: 'acct-alt', default_org: 'org-alt' }],
84
+ }));
85
+ const prev = process.env.MYAPI_CONFIG_DIR;
86
+ process.env.MYAPI_CONFIG_DIR = alt;
87
+ try {
88
+ const c = await loadConfigFresh();
89
+ expect(c?.api_key).toBe('hq_live_alt');
90
+ expect(c?.default_org).toBe('org-alt');
91
+ // The HOME fixture (account A, org-aaaa) must not bleed through.
92
+ expect(c?.default_org).not.toBe(ORG_A);
93
+ }
94
+ finally {
95
+ if (prev === undefined)
96
+ delete process.env.MYAPI_CONFIG_DIR;
97
+ else
98
+ process.env.MYAPI_CONFIG_DIR = prev;
99
+ fs.rmSync(alt, { recursive: true, force: true });
100
+ }
101
+ });
102
+ it('leaves the home-directory config untouched', async () => {
103
+ const homeCfg = path.join(tmpHome, '.myapi', 'config.json');
104
+ const before = fs.readFileSync(homeCfg, 'utf-8');
105
+ const alt = fs.mkdtempSync(path.join(os.tmpdir(), 'myapi-alt-'));
106
+ fs.writeFileSync(path.join(alt, 'config.json'), JSON.stringify({
107
+ active: 0, accounts: [{ api_key: 'hq_live_alt', account_id: 'acct-alt' }],
108
+ }));
109
+ const prev = process.env.MYAPI_CONFIG_DIR;
110
+ process.env.MYAPI_CONFIG_DIR = alt;
111
+ try {
112
+ await loadConfigFresh();
113
+ expect(fs.readFileSync(homeCfg, 'utf-8')).toBe(before);
114
+ }
115
+ finally {
116
+ if (prev === undefined)
117
+ delete process.env.MYAPI_CONFIG_DIR;
118
+ else
119
+ process.env.MYAPI_CONFIG_DIR = prev;
120
+ fs.rmSync(alt, { recursive: true, force: true });
121
+ }
122
+ });
123
+ });
124
+ describe('loadConfig with an unknown env key', () => {
125
+ it('inherits NO namespace pointers from the active account', async () => {
126
+ // This is the regression. Previously default_org came back as ORG_A,
127
+ // so a `funnel push` with a stranger's key wrote into account A's org.
128
+ process.env.MYAPI_API_KEY = 'hq_live_cccccccccccccccccccc';
129
+ const c = await loadConfigFresh();
130
+ expect(c?.api_key).toBe('hq_live_cccccccccccccccccccc');
131
+ expect(c?.default_org).toBeUndefined();
132
+ expect(c?.default_funnel).toBeUndefined();
133
+ expect(c?.default_domain).toBeUndefined();
134
+ });
135
+ it('still carries non-namespace globals', async () => {
136
+ process.env.MYAPI_API_KEY = 'hq_live_cccccccccccccccccccc';
137
+ const c = await loadConfigFresh();
138
+ expect(c?.autocomplete_setup).toBe(true);
139
+ });
140
+ it('applies the same rule to the MYAPI_KEY alias', async () => {
141
+ process.env.MYAPI_KEY = 'hq_live_cccccccccccccccccccc';
142
+ const c = await loadConfigFresh();
143
+ expect(c?.default_org).toBeUndefined();
144
+ });
145
+ });
package/dist/config.js CHANGED
@@ -1,7 +1,23 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as os from 'os';
4
- export const CONFIG_DIR = path.join(os.homedir(), '.myapi');
4
+ // MYAPI_CONFIG_DIR redirects the whole config surface — key, accounts,
5
+ // defaults — somewhere other than ~/.myapi.
6
+ //
7
+ // This exists so an automated caller can run against an isolated config
8
+ // WITHOUT mutating the operator's real one. The agent test-harness used to
9
+ // reach into ~/.myapi/config.json, overwrite `default_org` to point at its
10
+ // throwaway test org, and restore it afterwards on a best-effort basis. A
11
+ // crash between those two points left the operator's default pointing at an
12
+ // org that no longer exists, and the restore swallowed its own failures.
13
+ // Pointing the subprocess at its own config dir removes the shared mutable
14
+ // state instead of trying to undo it.
15
+ //
16
+ // Deliberately not HOME: the harness spawns coding agents that keep their own
17
+ // credentials under HOME, so moving it breaks them.
18
+ export const CONFIG_DIR = process.env.MYAPI_CONFIG_DIR
19
+ ? path.resolve(process.env.MYAPI_CONFIG_DIR)
20
+ : path.join(os.homedir(), '.myapi');
5
21
  export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
6
22
  function ensureDir() {
7
23
  if (!fs.existsSync(CONFIG_DIR))
@@ -30,6 +46,20 @@ export function loadFullConfig() {
30
46
  return isFullConfig(raw) ? raw : migrate(raw);
31
47
  }
32
48
  // loadConfig returns the active account merged with globals — unchanged interface for all callers.
49
+ //
50
+ // The env-key rule is the subtle part. This used to spread the active account
51
+ // (default_org / default_funnel / default_domain and all) and then override
52
+ // only `api_key` from the environment. Export account B's key while account A
53
+ // is active and every command authenticated as B but resolved its target from
54
+ // A's namespace pointers — `funnel push` and `domain assign` take their org
55
+ // from exactly those defaults, so a cross-tenant write looked like a normal
56
+ // one. The command-level guards can't catch it either: the org they check
57
+ // against genuinely IS the configured default.
58
+ //
59
+ // So: an env key is only allowed to inherit defaults that belong to it.
60
+ // - matches a stored account → use THAT account's entry, defaults included
61
+ // - matches nothing stored → key only, no inherited namespace pointers
62
+ // (commands then require an explicit --org, which is the safe failure)
33
63
  export function loadConfig() {
34
64
  const envKey = process.env.MYAPI_API_KEY || process.env.MYAPI_KEY;
35
65
  const full = loadFullConfig();
@@ -38,15 +68,21 @@ export function loadConfig() {
38
68
  if (!full)
39
69
  return null;
40
70
  const active = full.accounts[full.active] ?? full.accounts[0];
71
+ if (envKey) {
72
+ const owner = full.accounts.find(a => a.api_key === envKey);
73
+ if (owner) {
74
+ return { ...owner, autocomplete_setup: full.autocomplete_setup };
75
+ }
76
+ // Unknown key: carry only the globals. Deliberately NOT falling back to
77
+ // the active account's defaults — that is the whole bug.
78
+ return { api_key: envKey, account_id: '', autocomplete_setup: full.autocomplete_setup };
79
+ }
41
80
  if (!active)
42
81
  return null;
43
- const config = {
82
+ return {
44
83
  ...active,
45
84
  autocomplete_setup: full.autocomplete_setup,
46
85
  };
47
- if (envKey)
48
- config.api_key = envKey;
49
- return config;
50
86
  }
51
87
  // Single point of truth for writes. Ensures CONFIG_DIR exists and that the
52
88
  // file is created with mode 0o600 (owner read/write only) — no caller has
package/dist/errors.js CHANGED
@@ -16,6 +16,10 @@ export const ERROR_MESSAGES = {
16
16
  DOMAIN_NOT_AVAILABLE: 'This domain is not available for registration.',
17
17
  db_error: 'Resource not found or invalid ID.',
18
18
  NOT_FOUND: 'Resource not found.',
19
+ // Lowercase variant — what the funnel/org handlers actually return. Only
20
+ // the uppercase form was mapped, so a real 404 printed the bare code
21
+ // `not_found` with no guidance. Observed live 2026-07-26.
22
+ not_found: 'Resource not found. Check the id, and that --org points at the org that owns it.',
19
23
  domain_not_found: 'Domain not found.',
20
24
  invalid_domain: 'Invalid domain name.',
21
25
  invalid_org: 'Invalid organization.',
@@ -34,6 +38,13 @@ export const ERROR_MESSAGES = {
34
38
  // invalid_json_response intentionally absent — the SDK's MyApiError now
35
39
  // builds a useful detailed message for that case (status + URL + body
36
40
  // snippet), and friendlyError(err.code) would override it.
41
+ //
42
+ // Transport-level codes (status 0). The SDK used to let these escape as a
43
+ // bare `TypeError: fetch failed`, which told an agent nothing about whether
44
+ // retrying was sensible. Each of these says so explicitly.
45
+ network_error: 'Could not reach the MyAPI backend — check your network connection. The request never arrived, so nothing was charged or changed.',
46
+ timeout: 'The request timed out. The SDK already retried where it was safe to; raise the deadline with MYAPI_TIMEOUT_MS if this call is legitimately slow.',
47
+ request_aborted: 'Request cancelled.',
37
48
  };
38
49
  export function friendlyError(err) {
39
50
  const body = err.body ?? {};
package/dist/index.js CHANGED
@@ -1,12 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { error, info, success, banner } from './output.js';
3
3
  import { loadConfig } from './config.js';
4
- import { MyApiError } from '@myapihq/sdk';
4
+ import { MyApiError, setUserAgent } from '@myapihq/sdk';
5
5
  import { friendlyError } from './errors.js';
6
6
  import * as fs from 'fs';
7
7
  import { parseFlags } from './flags.js';
8
8
  const pkgPath = new URL('../package.json', import.meta.url);
9
9
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
10
+ // Identify the CLI (and its version) on every outbound request. Without this
11
+ // every call looked identical to a raw SDK consumer, so there was no way to
12
+ // tell which client versions are still in the wild — which is what you need
13
+ // before you can deprecate anything server-side.
14
+ setUserAgent(`myapi-cli/${pkg.version} node/${process.versions.node}`);
10
15
  import * as accountCmd from './commands/account.js';
11
16
  import * as keysCmd from './commands/keys.js';
12
17
  import * as billingCmd from './commands/billing.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,138 @@
1
+ // Transport policy for the SDK's single fetch chokepoint (packages/sdk/src/client.ts).
2
+ //
3
+ // Everything here guards a behaviour that was absent before: a bare `fetch`
4
+ // with no deadline, no retry, no request identity. The rules that matter most
5
+ // are the *negative* ones — a POST must not be replayed, because until the
6
+ // backend honors Idempotency-Key a replayed charge is a second charge.
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8
+ import { hq, crm, request, MyApiError } from '@myapihq/sdk';
9
+ const API_KEY = 'myapi_test_abc';
10
+ const ORG_ID = '11111111-1111-4111-8111-111111111111';
11
+ let fetchMock;
12
+ function ok(data) {
13
+ return new Response(JSON.stringify({ success: true, data, meta: {} }), {
14
+ status: 200,
15
+ headers: { 'content-type': 'application/json' },
16
+ });
17
+ }
18
+ function fail(status, headers = {}) {
19
+ return new Response(JSON.stringify({ success: false, error: { code: 'transient' } }), {
20
+ status,
21
+ headers: { 'content-type': 'application/json', ...headers },
22
+ });
23
+ }
24
+ function headersOf(call) {
25
+ return fetchMock.mock.calls[call][1].headers;
26
+ }
27
+ beforeEach(() => {
28
+ fetchMock = vi.fn();
29
+ vi.stubGlobal('fetch', fetchMock);
30
+ // Backoff is real time; collapse it so the suite stays fast.
31
+ vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn) => { fn(); return 0; }));
32
+ });
33
+ afterEach(() => {
34
+ vi.restoreAllMocks();
35
+ vi.unstubAllGlobals();
36
+ });
37
+ describe('request identity', () => {
38
+ it('sends a User-Agent on every request', async () => {
39
+ fetchMock.mockResolvedValueOnce(ok([]));
40
+ await hq.listOrgs(API_KEY);
41
+ expect(headersOf(0)['User-Agent']).toMatch(/^myapihq-sdk\/\d+\.\d+\.\d+$/);
42
+ });
43
+ it('sends an Idempotency-Key on mutating calls', async () => {
44
+ fetchMock.mockResolvedValueOnce(ok({ id: 'c1' }));
45
+ await crm.createContact(API_KEY, ORG_ID, { email: 'a@b.com' });
46
+ expect(headersOf(0)['Idempotency-Key']).toBeTruthy();
47
+ });
48
+ it('does not send an Idempotency-Key on reads', async () => {
49
+ fetchMock.mockResolvedValueOnce(ok([]));
50
+ await hq.listOrgs(API_KEY);
51
+ expect(headersOf(0)['Idempotency-Key']).toBeUndefined();
52
+ });
53
+ });
54
+ describe('retry policy', () => {
55
+ it('retries an idempotent call on 503 and returns the eventual success', async () => {
56
+ fetchMock
57
+ .mockResolvedValueOnce(fail(503))
58
+ .mockResolvedValueOnce(ok([{ id: 'org-1' }]));
59
+ const orgs = await hq.listOrgs(API_KEY);
60
+ expect(fetchMock).toHaveBeenCalledTimes(2);
61
+ expect(orgs).toEqual([{ id: 'org-1' }]);
62
+ });
63
+ it('retries an idempotent call on a network failure', async () => {
64
+ fetchMock
65
+ .mockRejectedValueOnce(new TypeError('fetch failed'))
66
+ .mockResolvedValueOnce(ok([]));
67
+ await hq.listOrgs(API_KEY);
68
+ expect(fetchMock).toHaveBeenCalledTimes(2);
69
+ });
70
+ it('gives up after the attempt cap and reports a network_error', async () => {
71
+ fetchMock.mockRejectedValue(new TypeError('ECONNRESET'));
72
+ const err = await hq.listOrgs(API_KEY).catch(e => e);
73
+ expect(err).toBeInstanceOf(MyApiError);
74
+ expect(err.code).toBe('network_error');
75
+ expect(fetchMock).toHaveBeenCalledTimes(3);
76
+ });
77
+ it('NEVER retries a POST — a replayed charge is a second charge', async () => {
78
+ fetchMock.mockResolvedValue(fail(503));
79
+ await crm.createContact(API_KEY, ORG_ID, { email: 'a@b.com' }).catch(() => { });
80
+ expect(fetchMock).toHaveBeenCalledTimes(1);
81
+ });
82
+ it('NEVER retries a POST that fails at the transport layer either', async () => {
83
+ fetchMock.mockRejectedValue(new TypeError('fetch failed'));
84
+ await crm.createContact(API_KEY, ORG_ID, { email: 'a@b.com' }).catch(() => { });
85
+ expect(fetchMock).toHaveBeenCalledTimes(1);
86
+ });
87
+ it('does not retry 500 — the write may already have landed', async () => {
88
+ fetchMock.mockResolvedValue(fail(500));
89
+ await hq.listOrgs(API_KEY).catch(() => { });
90
+ expect(fetchMock).toHaveBeenCalledTimes(1);
91
+ });
92
+ it('does not retry 4xx client errors', async () => {
93
+ fetchMock.mockResolvedValue(fail(404));
94
+ await hq.listOrgs(API_KEY).catch(() => { });
95
+ expect(fetchMock).toHaveBeenCalledTimes(1);
96
+ });
97
+ it('reuses one Idempotency-Key across retries, so an opted-in POST can dedupe', async () => {
98
+ // This is the invariant that makes `retryUnsafe` safe to turn on once the
99
+ // backend honors the header: every attempt of one logical call must carry
100
+ // the SAME key. A per-attempt key would deduplicate nothing.
101
+ fetchMock
102
+ .mockResolvedValueOnce(fail(429))
103
+ .mockResolvedValueOnce(ok({ id: 'c1' }));
104
+ await request('POST', 'https://api.example.com/thing', API_KEY, { a: 1 }, undefined, { retryUnsafe: true });
105
+ expect(fetchMock).toHaveBeenCalledTimes(2);
106
+ const first = headersOf(0)['Idempotency-Key'];
107
+ expect(first).toBeTruthy();
108
+ expect(headersOf(1)['Idempotency-Key']).toBe(first);
109
+ });
110
+ it('honors Retry-After ahead of the backoff schedule', async () => {
111
+ fetchMock
112
+ .mockResolvedValueOnce(fail(429, { 'retry-after': '2' }))
113
+ .mockResolvedValueOnce(ok([]));
114
+ await hq.listOrgs(API_KEY);
115
+ // setTimeout is stubbed to fire synchronously; assert the delay we asked for.
116
+ const delays = setTimeout.mock.calls.map(c => c[1]);
117
+ expect(delays).toContain(2000);
118
+ });
119
+ it('lets a caller-supplied Idempotency-Key win', async () => {
120
+ fetchMock.mockResolvedValueOnce(ok({ id: 'c1' }));
121
+ await request('POST', 'https://api.example.com/thing', API_KEY, { a: 1 }, { 'Idempotency-Key': 'caller-key' });
122
+ expect(headersOf(0)['Idempotency-Key']).toBe('caller-key');
123
+ });
124
+ });
125
+ describe('timeout', () => {
126
+ it('surfaces an aborted request as a timeout, not a raw AbortError', async () => {
127
+ const abortErr = Object.assign(new Error('aborted'), { name: 'AbortError' });
128
+ fetchMock.mockRejectedValue(abortErr);
129
+ const err = await crm.createContact(API_KEY, ORG_ID, { email: 'a@b.com' }).catch(e => e);
130
+ expect(err).toBeInstanceOf(MyApiError);
131
+ expect(err.code).toBe('timeout');
132
+ });
133
+ it('passes an AbortSignal to fetch so a hung call cannot hang forever', async () => {
134
+ fetchMock.mockResolvedValueOnce(ok([]));
135
+ await hq.listOrgs(API_KEY);
136
+ expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
137
+ });
138
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ // Drift guard: packages/sdk/src/version.ts carries the version string that
2
+ // ships in the User-Agent of every request. It can't read package.json at
3
+ // runtime (the SDK runs on edge runtimes with no node:fs), so the literal is
4
+ // synced by scripts/release.mjs. This test is what makes a missed sync loud
5
+ // instead of shipping a User-Agent that lies about which client is calling.
6
+ import { describe, it, expect } from 'vitest';
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { SDK_VERSION } from '@myapihq/sdk';
11
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
12
+ const SDK_ROOT = path.resolve(HERE, '..', '..', 'sdk');
13
+ describe('SDK_VERSION', () => {
14
+ it('matches packages/sdk/package.json', () => {
15
+ const pkg = JSON.parse(fs.readFileSync(path.join(SDK_ROOT, 'package.json'), 'utf-8'));
16
+ expect(SDK_VERSION).toBe(pkg.version);
17
+ });
18
+ it('is a plain x.y.z literal in version.ts', () => {
19
+ // Asserted against the source, not the build: release.mjs rewrites the
20
+ // literal with a regex, so the regex and the source must stay compatible.
21
+ const src = fs.readFileSync(path.join(SDK_ROOT, 'src', 'version.ts'), 'utf-8');
22
+ expect(src).toMatch(/export const SDK_VERSION = '\d+\.\d+\.\d+';/);
23
+ });
24
+ });
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  Auth, organizations, and billing hub. Start here to get an api_key and org_id — every other service depends on both.
6
6
  triggers: [api key, account, organization, org, billing, balance, topup, credits, setup, defaults, brand, sync brand, doctor, health check, is my org healthy]
7
- checksum: sha256-pending
7
+ checksum: sha256-80bee1daf601eb33abc5d66b7f8053b20abec1c9c0ec07f26e86e26a2d3bdb78
8
8
  ---
9
9
 
10
10
  # MyApiHQ
@@ -2,5 +2,6 @@
2
2
  "name": "my-api-hq",
3
3
  "description": "Core identity and billing hub. Manage auth, organizations, and billing.",
4
4
  "version": "1.0.0",
5
+ "status": "ga",
5
6
  "published": true
6
7
  }
@@ -0,0 +1,40 @@
1
+ ---
2
+ # my-audience-api
3
+
4
+ Saved audiences = named filter snapshots over the people/company database. Build a target list once, reuse it across campaigns, refresh to re-evaluate.
5
+
6
+ ## What it does
7
+
8
+ - Save a filter (over `my-people-api` or `my-company-api`) as a named, addressable audience
9
+ - Get paged members (people or companies depending on source)
10
+ - Refresh on demand — re-evaluates filter against current data, returns delta vs previous count
11
+ - Full CRUD: create / list / get / update / delete
12
+
13
+ ## Quickstart
14
+
15
+ ```bash
16
+ # Create an audience
17
+ myapi audience create "EU SaaS VPs" \
18
+ --source people \
19
+ --filter '{"industry":["saas"],"seniority":["vp","c_level"],"country":["DE","FR","GB"]}'
20
+
21
+ # Inspect members
22
+ myapi audience members <id> --limit 20
23
+
24
+ # Refresh after new data lands
25
+ myapi audience refresh <id>
26
+ ```
27
+
28
+ ## Authentication
29
+
30
+ ```bash
31
+ export MYAPI_KEY=mak_...
32
+ ```
33
+
34
+ Requires `api_key` and `org_id` from **myapihq**.
35
+
36
+ ## Documentation
37
+
38
+ Full filter contract and end-to-end recipes: see `SKILL.md`.
39
+
40
+ Run `myapi audience --help` for inline reference.
@@ -0,0 +1,139 @@
1
+ ---
2
+ name: my-audience-api
3
+ version: 1.0.0
4
+ description: >
5
+ Saved audiences = named Goldfox-filter snapshots over the people or company database. Build a target list once, name it, reuse it across campaigns, refresh to re-evaluate against current data. The persistence layer on top of my-people-api + my-company-api.
6
+ triggers: [audience, segment, target list, saved filter, goldfox, lead list, abm list, refresh, members, prospect database]
7
+ checksum: sha256-5d9a2731d587c476eff2955241001fecb8481bbc5e567f06b8e9e76a079eda92
8
+ ---
9
+
10
+ # MyAudienceAPI
11
+
12
+ A saved audience is a **named filter snapshot** over the Goldfox people or company database. Instead of repeating the same filter every time you run a campaign, save it once, reference the audience by id, and call `refresh` to re-evaluate against current data when the underlying dataset grows.
13
+
14
+ ## Capabilities
15
+ <!-- llm:start -->
16
+ Use when:
17
+
18
+ - You'll target the same kind of list across multiple campaigns
19
+ - You want a stable id to feed into email campaigns, workflows, or external tools
20
+ - You need to know how many candidates match a filter without paging the whole result
21
+ - You want to refresh a list as new rows are added to the Goldfox crawl
22
+
23
+ Each audience has:
24
+ - `name` and optional `description`
25
+ - `source` — `"people"` or `"company"` (which database to filter against)
26
+ - `filter` — the canonical Goldfox filter (same shape as `my-people-api` / `my-company-api`)
27
+ - `member_count` — computed when filter is created or refreshed
28
+ - An `id` for retrieving members, refreshing, deleting
29
+
30
+ **Filter contract** (full Goldfox shape):
31
+
32
+ ```json
33
+ {
34
+ "confidence": ["high"],
35
+ "country": ["US", "DE"],
36
+ "country_consistent": true,
37
+ "seniority": ["c_level", "vp_director"], // people-source only
38
+ "email_type": ["corporate"], // people-source only
39
+ "tld_class": ["cctld"],
40
+ "has_c_level": true,
41
+ "has_decision_maker": true,
42
+ "has_careers_page": true,
43
+ "has_investors_page": false,
44
+ "has_shop_page": false,
45
+ "is_registered_entity": true,
46
+ "min_headcount": 5,
47
+ "min_source_count": 3,
48
+ "min_link_confidence": 0.8, // people-source only
49
+ "keyword": "platform",
50
+ "limit": 100,
51
+ "offset": 0
52
+ }
53
+ ```
54
+
55
+ OR within an array; AND across fields. Empty filter = all rows with `confidence=high` (Goldfox's curated default tier).
56
+
57
+ Allowed values:
58
+ - **confidence**: `high` (default) / `low` / `very_low`
59
+ - **seniority** (people only): `c_level`, `vp_director`, `manager`, `senior_ic`, `ic`
60
+ - **email_type** (people only): `corporate`, `freemail`, `role_based`, `other_corporate`
61
+ - **tld_class**: `cctld`, `generic`, `vanity`, `low_trust`, `other`
62
+ - **country**: ISO 3166-1 alpha-2
63
+
64
+ **Member fetch:** the response shape is `{ total, limit, offset, has_more, people?: […], companies?: […] }` — only one of `people` / `companies` is populated based on the audience's source. Use `--offset` to paginate.
65
+
66
+ **Refresh delta:** `audience refresh` returns `{ total, previous, delta, refreshed_at }` — detect newly-matched rows.
67
+ <!-- llm:end -->
68
+
69
+ ## Commands
70
+ <!-- generated:start -->
71
+ | Command | What it does |
72
+ |---|---|
73
+ | `myapi audience create <name> --source <people\|company> --filter '<json>' [--description <text>]` | Save a Goldfox filter as a named audience; returns id + initial member_count |
74
+ | `myapi audience list` | List all audiences in the org |
75
+ | `myapi audience get <id>` | Single audience (name, filter, member_count, timestamps) |
76
+ | `myapi audience update <id> [--name <x>] [--description <y>] [--filter '<json>']` | Patch name/description/filter; member_count re-evaluates if filter changes |
77
+ | `myapi audience delete <id>` | Remove an audience |
78
+ | `myapi audience members <id> [--limit N] [--offset N]` | Paged member snapshot (people or companies depending on source) |
79
+ | `myapi audience refresh <id>` | Re-evaluate filter against current data; returns delta vs previous count |
80
+ <!-- generated:end -->
81
+
82
+ ## Examples
83
+ <!-- llm:start -->
84
+ ```bash
85
+ # 1. Create the audience
86
+ AID=$(myapi audience create "EU decision makers w/ corporate emails" \
87
+ --source people \
88
+ --filter '{"seniority":["c_level","vp_director"],"country":["DE","FR","GB"],"email_type":["corporate"]}' \
89
+ --json | jq -r .id)
90
+
91
+ # 2. Inspect members
92
+ myapi audience members $AID --limit 20
93
+
94
+ # 3. Refresh after new Goldfox data lands
95
+ myapi audience refresh $AID
96
+ # → Refreshed. 1247 → 1289 (+42)
97
+
98
+ # 4. Update filter without losing the id
99
+ myapi audience update $AID \
100
+ --filter '{"seniority":["c_level","vp_director"],"country":["DE","FR","GB","NL"],"email_type":["corporate"]}'
101
+
102
+ # 5. Delete when done
103
+ myapi audience delete $AID
104
+ ```
105
+
106
+ ### End-to-end recipe — outbound campaign driven by an audience
107
+
108
+ ```bash
109
+ # Build with quality controls — definitive links + registered companies + careers signal
110
+ AID=$(myapi audience create "EU growth-stage decision makers" \
111
+ --source people \
112
+ --filter '{"seniority":["c_level","vp_director"],"country":["DE","FR","GB"],"email_type":["corporate"],"min_link_confidence":0.9,"has_careers_page":true,"is_registered_entity":true}' \
113
+ --json | jq -r .id)
114
+
115
+ # Pull all members as JSON
116
+ myapi audience members $AID --limit 100 --json > targets.json
117
+
118
+ # (later) Re-check before next batch
119
+ myapi audience refresh $AID
120
+ ```
121
+
122
+ ### Company-source audience (ABM)
123
+
124
+ ```bash
125
+ myapi audience create "EU growth-stage SaaS accounts" \
126
+ --source company \
127
+ --filter '{"country":["DE","FR","GB","NL"],"has_careers_page":true,"has_decision_maker":true,"is_registered_entity":true,"min_source_count":3}'
128
+ ```
129
+ <!-- llm:end -->
130
+
131
+ ## Notes
132
+
133
+ - The underlying dataset is the **Goldfox crawl** — multi-million-row corporate data with provenance signals. Defaults to `confidence=high` which is the curated tier.
134
+ - `audience refresh` is cheap and idempotent; call it before any campaign run that depends on a current list.
135
+ - `update --filter` REPLACES the whole filter (not merged). To shift one field, fetch the audience, merge in code, then update.
136
+ - Audiences are per-org. Two orgs can have audiences with the same name; ids are globally unique.
137
+ - Look before you delete: confirm the target with `myapi audience get <id>`, pass `--org` explicitly; delete verbs require `--yes` in non-interactive runs.
138
+
139
+ Run `myapi audience --help` for full flag reference.
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "my-audience-api",
3
+ "description": "Saved audiences = named filter snapshots over people/company database. Reuse across campaigns; refresh re-evaluates against current data.",
4
+ "version": "1.0.0",
5
+ "status": "preview",
6
+ "published": true
7
+ }
File without changes
@@ -4,7 +4,7 @@ version: 1.0.0
4
4
  description: >
5
5
  Add authentication to apps you build on MyAPI — a managed OIDC identity provider for your app's END USERS (à la Kinde/Auth0). One auth tenant per org; register OIDC clients; sign users in with managed Google or the hosted login page; verify RS256 tokens against the tenant JWKS.
6
6
  triggers: [auth, authentication, login, sign-in, oidc, oauth, jwt, jwks, sso, google sign-in, user accounts, identity provider, kinde, auth0, clerk]
7
- checksum: sha256-pending
7
+ checksum: sha256-11dac8e8da70bd6bbf63fe11fae5adc038584dc23a09350b9e7b977a669aed41
8
8
  ---
9
9
 
10
10
  # MyAuthAPI
@@ -2,5 +2,6 @@
2
2
  "name": "my-auth-api",
3
3
  "description": "Managed OIDC identity provider for the end users of apps built on MyAPI. Per-org auth tenant + OIDC clients, RS256/JWKS, hosted login, managed Google sign-in. A Kinde alternative.",
4
4
  "version": "1.0.0",
5
+ "status": "ga",
5
6
  "published": true
6
7
  }