@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.
- package/dist/commands/crm/companies.js +1 -5
- package/dist/commands/crm/contacts.js +1 -6
- package/dist/commands/domain-validation.test.d.ts +1 -0
- package/dist/commands/domain-validation.test.js +36 -0
- package/dist/commands/domain.d.ts +14 -0
- package/dist/commands/domain.js +20 -1
- package/dist/commands/funnel-validation.test.d.ts +1 -0
- package/dist/commands/funnel-validation.test.js +86 -0
- package/dist/commands/funnel.d.ts +12 -0
- package/dist/commands/funnel.js +59 -15
- package/dist/commands/payments.js +12 -2
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +51 -2
- package/dist/commands/skill-checksum.test.d.ts +1 -0
- package/dist/commands/skill-checksum.test.js +48 -0
- package/dist/config-envkey.test.d.ts +1 -0
- package/dist/config-envkey.test.js +145 -0
- package/dist/config.js +41 -5
- package/dist/errors.js +11 -0
- package/dist/index.js +6 -1
- package/dist/sdk-transport.test.d.ts +1 -0
- package/dist/sdk-transport.test.js +138 -0
- package/dist/sdk-version.test.d.ts +1 -0
- package/dist/sdk-version.test.js +24 -0
- package/dist/skills/my-api-hq/SKILL.md +1 -1
- package/dist/skills/my-api-hq/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-audience-api/README.md +40 -0
- package/dist/skills/my-audience-api/SKILL.md +139 -0
- package/dist/skills/my-audience-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-audience-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-auth-api/SKILL.md +1 -1
- package/dist/skills/my-auth-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-company-api/README.md +35 -0
- package/dist/skills/my-company-api/SKILL.md +110 -0
- package/dist/skills/my-company-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-company-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-container-api/README.md +34 -0
- package/dist/skills/my-container-api/SKILL.md +77 -0
- package/dist/skills/my-container-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-container-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-crm-api/SKILL.md +3 -3
- package/dist/skills/my-crm-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-database-api/SKILL.md +1 -1
- package/dist/skills/my-database-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-domain-api/SKILL.md +1 -1
- package/dist/skills/my-domain-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-email-api/SKILL.md +1 -1
- package/dist/skills/my-email-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-email-verify-api/SKILL.md +1 -1
- package/dist/skills/my-email-verify-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-function-api/SKILL.md +104 -0
- package/dist/skills/my-function-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-funnel-api/SKILL.md +2 -2
- package/dist/skills/my-funnel-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-git-api/SKILL.md +1 -1
- package/dist/skills/my-git-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-image-api/SKILL.md +1 -1
- package/dist/skills/my-image-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-llm-api/SKILL.md +6 -11
- package/dist/skills/my-llm-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-payments-api/SKILL.md +85 -0
- package/dist/skills/my-payments-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-people-api/README.md +35 -0
- package/dist/skills/my-people-api/SKILL.md +107 -0
- package/dist/skills/my-people-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-people-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-pixel-api/README.md +38 -0
- package/dist/skills/my-pixel-api/SKILL.md +90 -0
- package/dist/skills/my-pixel-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-pixel-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-queue-api/README.md +34 -0
- package/dist/skills/my-queue-api/SKILL.md +78 -0
- package/dist/skills/my-queue-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-queue-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-storage-api/SKILL.md +1 -1
- package/dist/skills/my-storage-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-task-api/README.md +36 -0
- package/dist/skills/my-task-api/SKILL.md +74 -0
- package/dist/skills/my-task-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-task-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-url-to/README.md +32 -0
- package/dist/skills/my-url-to/SKILL.md +55 -0
- package/dist/skills/my-url-to/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-url-to/openapi/.gitkeep +0 -0
- package/dist/skills/my-webhook-api/SKILL.md +1 -1
- package/dist/skills/my-webhook-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-workflow-api/SKILL.md +1 -1
- package/dist/skills/my-workflow-api/claude/.claude-plugin/plugin.json +1 -0
- 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
|
-
|
|
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
|
-
|
|
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-
|
|
7
|
+
checksum: sha256-80bee1daf601eb33abc5d66b7f8053b20abec1c9c0ec07f26e86e26a2d3bdb78
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# MyApiHQ
|
|
@@ -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.
|
|
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-
|
|
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
|
}
|