anhedral 0.10.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 +78 -39
- package/assets/{anhedral-cli-init-technical.svg → anhedral-stack-technical.svg} +6 -6
- package/assets/{anhedral-cli-init.svg → anhedral-stack.svg} +7 -7
- package/assets/security/audit-deps.mjs +6 -1
- package/dist/bin.js +271 -154
- package/dist/bounded-file.d.ts +1 -0
- package/dist/bounded-file.js +1 -0
- package/dist/capabilities.d.ts +4 -4
- package/dist/capabilities.js +1 -1
- package/dist/capability-contract.d.ts +47 -0
- package/dist/capability-contract.js +136 -0
- package/dist/cli-arguments.d.ts +16 -0
- package/dist/cli-arguments.js +115 -0
- package/dist/cli-output.d.ts +8 -0
- package/dist/cli-output.js +211 -0
- package/dist/cloudflare.d.ts +7 -2
- package/dist/cloudflare.js +166 -36
- package/dist/command-schema.d.ts +21 -0
- package/dist/command-schema.js +374 -0
- package/dist/delivery.d.ts +2 -2
- package/dist/delivery.js +21 -4
- package/dist/dependencies.d.ts +32 -0
- package/dist/dependencies.js +47 -1
- package/dist/index.d.ts +23 -1
- package/dist/index.js +15 -1
- package/dist/inspector-model.d.ts +115 -0
- package/dist/inspector-model.js +1 -0
- package/dist/lifecycle-model.d.ts +15 -0
- package/dist/lifecycle-model.js +20 -0
- package/dist/lifecycle.d.ts +12 -0
- package/dist/lifecycle.js +92 -0
- package/dist/mobile-delivery.d.ts +100 -0
- package/dist/mobile-delivery.js +319 -0
- package/dist/platforms/desktop.js +4 -5
- package/dist/platforms/extension.js +9 -73
- package/dist/platforms/mobile.js +14 -52
- package/dist/project-inspector.d.ts +4 -0
- package/dist/project-inspector.js +646 -0
- package/dist/project-state.d.ts +17 -0
- package/dist/project-state.js +239 -11
- package/dist/provider-auth.d.ts +41 -0
- package/dist/provider-auth.js +210 -0
- package/dist/providers/adapters.d.ts +17 -0
- package/dist/providers/adapters.js +269 -0
- package/dist/providers/api.d.ts +28 -0
- package/dist/providers/api.js +252 -0
- package/dist/providers/config.d.ts +15 -0
- package/dist/providers/config.js +184 -0
- package/dist/providers/definitions.d.ts +318 -0
- package/dist/providers/definitions.js +127 -0
- package/dist/providers/provision.d.ts +24 -0
- package/dist/providers/provision.js +76 -0
- package/dist/providers/receipts.d.ts +20 -0
- package/dist/providers/receipts.js +66 -0
- package/dist/providers/recovery-verification.d.ts +7 -0
- package/dist/providers/recovery-verification.js +112 -0
- package/dist/providers/recovery.d.ts +30 -0
- package/dist/providers/recovery.js +34 -0
- package/dist/providers/runtime.d.ts +10 -0
- package/dist/providers/runtime.js +231 -0
- package/dist/providers/schema.d.ts +28 -0
- package/dist/providers/schema.js +49 -0
- package/dist/providers/state.d.ts +56 -0
- package/dist/providers/state.js +329 -0
- package/dist/provision.d.ts +1 -1
- package/dist/provision.js +61 -3
- package/dist/readiness.js +11 -3
- package/dist/registry/add.d.ts +6 -0
- package/dist/registry/add.js +35 -4
- package/dist/registry/commands.d.ts +3 -2
- package/dist/registry/commands.js +53 -14
- package/dist/registry/options.js +3 -2
- package/dist/registry/preview.d.ts +11 -0
- package/dist/registry/preview.js +91 -0
- package/dist/registry/receipt.js +1 -0
- package/dist/registry/resolve.js +19 -13
- package/dist/registry/types.d.ts +1 -0
- package/dist/registry/usage.d.ts +10 -0
- package/dist/registry/usage.js +22 -37
- package/dist/render.d.ts +2 -0
- package/dist/render.js +10 -0
- package/dist/scaffold/api.js +20 -3
- package/dist/scaffold/auth.js +68 -28
- package/dist/scaffold/bootstrap.d.ts +2 -0
- package/dist/scaffold/bootstrap.js +9 -0
- package/dist/scaffold/database.js +12 -6
- package/dist/scaffold/documentation.js +11 -8
- package/dist/scaffold/environment.d.ts +2 -0
- package/dist/scaffold/environment.js +18 -1
- package/dist/scaffold/files.d.ts +1 -0
- package/dist/scaffold/files.js +15 -4
- package/dist/scaffold/services.js +37 -13
- package/dist/scaffold/stack-documentation.d.ts +3 -0
- package/dist/scaffold/stack-documentation.js +52 -0
- package/dist/scaffold/styling.d.ts +3 -0
- package/dist/scaffold/styling.js +84 -0
- package/dist/scaffold/web.js +31 -11
- package/dist/scaffold/workspace.js +49 -22
- package/dist/ship.d.ts +320 -0
- package/dist/ship.js +211 -0
- package/dist/stack-registry.d.ts +2 -2
- package/dist/stack-registry.js +44 -11
- package/dist/standard.d.ts +2 -1
- package/dist/standard.js +13 -12
- package/dist/vercel.d.ts +3 -3
- package/dist/vercel.js +27 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/vinext.js +9 -18
- package/dist/workspace-graph.js +3 -2
- package/docs/application-stack-standard.md +51 -80
- package/docs/cli-design.md +67 -0
- package/docs/delivery-workflow.md +29 -15
- package/docs/plugin-ui.md +61 -0
- package/docs/provider-configuration.md +117 -0
- package/package.json +16 -12
- package/dist/managed-hosting.d.ts +0 -49
- package/dist/managed-hosting.js +0 -373
- package/docs/managed-hosting-architecture.md +0 -87
- package/docs/sponsorships.md +0 -25
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { object, list, text, resourceId } from './api.js';
|
|
2
|
+
import { setting } from './config.js';
|
|
3
|
+
function result(resources, runtime, nextActions = [], ready = true) {
|
|
4
|
+
return { resources, runtime, ready, nextActions };
|
|
5
|
+
}
|
|
6
|
+
const create = (context, key, route, body) => context.state.create(context.id, key, context.api, route, body);
|
|
7
|
+
function same(value, expected, description) {
|
|
8
|
+
if (value !== expected)
|
|
9
|
+
throw new Error(`Provider ${description} differs from the selected configuration.`);
|
|
10
|
+
}
|
|
11
|
+
async function neon(context) {
|
|
12
|
+
const { settings: config, api } = context;
|
|
13
|
+
let id = setting(config, 'projectId');
|
|
14
|
+
if (!id) {
|
|
15
|
+
const response = await create(context, 'project', '/api/v2/projects', {
|
|
16
|
+
project: {
|
|
17
|
+
name: setting(config, 'name'),
|
|
18
|
+
org_id: setting(config, 'organizationId'),
|
|
19
|
+
region_id: setting(config, 'region'),
|
|
20
|
+
default_branch: {
|
|
21
|
+
name: 'main',
|
|
22
|
+
database_name: setting(config, 'database'),
|
|
23
|
+
role_name: setting(config, 'role'),
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
id = resourceId(object(response.project).id);
|
|
28
|
+
}
|
|
29
|
+
id = resourceId(id);
|
|
30
|
+
const project = object(object(await api(`/api/v2/projects/${id}`)).project);
|
|
31
|
+
same(project.org_id, setting(config, 'organizationId'), 'Neon organization');
|
|
32
|
+
if (project.suspended === true)
|
|
33
|
+
throw new Error('Neon project is suspended.');
|
|
34
|
+
const operations = list(object(await api(`/api/v2/projects/${id}/operations`)).operations);
|
|
35
|
+
if (operations.some((operation) => !['finished', 'skipped'].includes(String(operation.status))))
|
|
36
|
+
return result({ project: id }, {}, ['Neon operations are pending or failed. Inspect the project, then run ship again.'], false);
|
|
37
|
+
const query = new URLSearchParams({
|
|
38
|
+
database_name: setting(config, 'database'),
|
|
39
|
+
role_name: setting(config, 'role'),
|
|
40
|
+
pooled: 'true',
|
|
41
|
+
});
|
|
42
|
+
const uri = text(object(await api(`/api/v2/projects/${id}/connection_uri?${query}`)).uri);
|
|
43
|
+
const parsed = new URL(uri);
|
|
44
|
+
if (!['postgres:', 'postgresql:'].includes(parsed.protocol) ||
|
|
45
|
+
!parsed.hostname.endsWith('.neon.tech'))
|
|
46
|
+
throw new Error('Neon returned an unexpected database connection.');
|
|
47
|
+
return result({ project: id }, { DATABASE_URL: uri });
|
|
48
|
+
}
|
|
49
|
+
async function clerk(context) {
|
|
50
|
+
const { settings: config, api } = context;
|
|
51
|
+
const principal = object(await api('/v1/platform/me'));
|
|
52
|
+
same(principal.subject, setting(config, 'workspaceId'), 'Clerk workspace');
|
|
53
|
+
let id = setting(config, 'applicationId');
|
|
54
|
+
if (!id)
|
|
55
|
+
id = resourceId((await create(context, 'application', '/v1/platform/applications', {
|
|
56
|
+
name: setting(config, 'name'),
|
|
57
|
+
environment_types: [setting(config, 'environment')],
|
|
58
|
+
})).application_id);
|
|
59
|
+
id = resourceId(id);
|
|
60
|
+
const application = object(await api(`/v1/platform/applications/${id}?include_secret_keys=true`));
|
|
61
|
+
same(application.application_id, id, 'Clerk application');
|
|
62
|
+
const instances = list(application.instances).filter((instance) => instance.environment_type === config.environment);
|
|
63
|
+
if (instances.length !== 1)
|
|
64
|
+
throw new Error('Clerk did not return exactly one selected environment.');
|
|
65
|
+
const instance = instances[0];
|
|
66
|
+
const publishable = text(instance.publishable_key);
|
|
67
|
+
const secret = text(instance.secret_key);
|
|
68
|
+
const mode = config.environment === 'production' ? 'live' : 'test';
|
|
69
|
+
if (!publishable.startsWith(`pk_${mode}_`) || !secret.startsWith(`sk_${mode}_`))
|
|
70
|
+
throw new Error('Clerk keys do not match the selected environment.');
|
|
71
|
+
return result({ application: id, instance: resourceId(instance.instance_id) }, {
|
|
72
|
+
CLERK_SECRET_KEY: secret,
|
|
73
|
+
CLERK_PUBLISHABLE_KEY: publishable,
|
|
74
|
+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: publishable,
|
|
75
|
+
}, config.environment === 'production'
|
|
76
|
+
? ['Verify the production Clerk domain, DNS and enabled OAuth providers before delivery.']
|
|
77
|
+
: []);
|
|
78
|
+
}
|
|
79
|
+
async function resend(context) {
|
|
80
|
+
const { settings: config, api } = context;
|
|
81
|
+
let id = setting(config, 'domainId');
|
|
82
|
+
if (!id)
|
|
83
|
+
id = resourceId((await create(context, 'domain', '/domains', {
|
|
84
|
+
name: setting(config, 'domain'),
|
|
85
|
+
...(setting(config, 'region') ? { region: config.region } : {}),
|
|
86
|
+
})).id);
|
|
87
|
+
id = resourceId(id);
|
|
88
|
+
let domain = object(await api(`/domains/${id}`));
|
|
89
|
+
same(domain.name, setting(config, 'domain'), 'Resend domain');
|
|
90
|
+
if (domain.status !== 'verified') {
|
|
91
|
+
await api(`/domains/${id}/verify`, 'POST');
|
|
92
|
+
domain = object(await api(`/domains/${id}`));
|
|
93
|
+
}
|
|
94
|
+
if (domain.status !== 'verified') {
|
|
95
|
+
const records = list(domain.records).map((record) => `${text(record.type)} ${text(record.name)} ${text(record.value)}${record.priority === undefined ? '' : ` (priority ${Number(record.priority)})`}`);
|
|
96
|
+
return result({ domain: id }, {}, ['Publish the Resend DNS records, wait for verification, then run ship again.', ...records], false);
|
|
97
|
+
}
|
|
98
|
+
const key = await create(context, 'sending-key', '/api-keys', {
|
|
99
|
+
name: `anhedral-${id}`.slice(0, 50),
|
|
100
|
+
permission: 'sending_access',
|
|
101
|
+
domain_id: id,
|
|
102
|
+
});
|
|
103
|
+
const keys = object(await api('/api-keys'));
|
|
104
|
+
if (keys.has_more === true || !list(keys.data).some((entry) => entry.id === key.id))
|
|
105
|
+
throw new Error('Resend did not confirm the recorded sending key; inspect key access before configuring runtime.');
|
|
106
|
+
return result({ domain: id, sendingKey: resourceId(key.id) }, { RESEND_API_KEY: text(key.token) });
|
|
107
|
+
}
|
|
108
|
+
async function stripe(context) {
|
|
109
|
+
const { settings: config, api, environment } = context;
|
|
110
|
+
const account = object(await api('/v1/account'));
|
|
111
|
+
same(account.id, setting(config, 'accountId'), 'Stripe account');
|
|
112
|
+
const key = text(environment.STRIPE_SECRET_KEY);
|
|
113
|
+
const prefix = config.mode === 'live' ? /^(sk|rk)_live_/ : /^(sk|rk)_test_/;
|
|
114
|
+
if (!prefix.test(key))
|
|
115
|
+
throw new Error('Stripe key does not match the selected test/live mode.');
|
|
116
|
+
const resources = { account: resourceId(account.id) };
|
|
117
|
+
const runtime = { STRIPE_SECRET_KEY: key };
|
|
118
|
+
if (setting(config, 'webhookUrl')) {
|
|
119
|
+
const webhook = await create(context, 'webhook', '/v1/webhook_endpoints', {
|
|
120
|
+
url: config.webhookUrl,
|
|
121
|
+
enabled_events: config.webhookEvents,
|
|
122
|
+
});
|
|
123
|
+
const id = resourceId(webhook.id);
|
|
124
|
+
const current = object(await api(`/v1/webhook_endpoints/${id}`));
|
|
125
|
+
same(current.url, setting(config, 'webhookUrl'), 'Stripe webhook URL');
|
|
126
|
+
same(current.livemode, config.mode === 'live', 'Stripe webhook mode');
|
|
127
|
+
if (current.status !== 'enabled' ||
|
|
128
|
+
!Array.isArray(current.enabled_events) ||
|
|
129
|
+
JSON.stringify([...current.enabled_events].sort()) !==
|
|
130
|
+
JSON.stringify([...config.webhookEvents].sort()))
|
|
131
|
+
throw new Error('Stripe webhook is disabled or its events differ from the selected configuration.');
|
|
132
|
+
resources.webhook = id;
|
|
133
|
+
runtime.STRIPE_WEBHOOK_SECRET = text(webhook.secret);
|
|
134
|
+
}
|
|
135
|
+
return result(resources, runtime, config.mode === 'live' && account.charges_enabled !== true
|
|
136
|
+
? ['Complete Stripe account activation before accepting live payments.']
|
|
137
|
+
: []);
|
|
138
|
+
}
|
|
139
|
+
async function sentry(context) {
|
|
140
|
+
const { settings: config, api } = context;
|
|
141
|
+
const organization = resourceId(config.organization);
|
|
142
|
+
const team = resourceId(config.team);
|
|
143
|
+
const slug = resourceId(config.project);
|
|
144
|
+
const projects = list(await api(`/api/0/teams/${organization}/${team}/projects/`));
|
|
145
|
+
const matching = projects.filter((project) => project.slug === slug);
|
|
146
|
+
if (matching.length > 1)
|
|
147
|
+
throw new Error('Sentry project selection is ambiguous.');
|
|
148
|
+
if (!matching.length)
|
|
149
|
+
await create(context, 'project', `/api/0/teams/${organization}/${team}/projects/`, {
|
|
150
|
+
name: setting(config, 'name') || slug,
|
|
151
|
+
slug,
|
|
152
|
+
platform: config.platform,
|
|
153
|
+
default_rules: false,
|
|
154
|
+
});
|
|
155
|
+
const project = object(await api(`/api/0/projects/${organization}/${slug}/`));
|
|
156
|
+
same(object(project.organization).slug, organization, 'Sentry organization');
|
|
157
|
+
same(project.platform, setting(config, 'platform'), 'Sentry platform');
|
|
158
|
+
if (!list(project.teams).some((entry) => entry.slug === team))
|
|
159
|
+
throw new Error('Sentry project belongs to a different team.');
|
|
160
|
+
let keys = list(await api(`/api/0/projects/${organization}/${slug}/keys/`)).filter((key) => key.isActive === true);
|
|
161
|
+
if (!keys.length) {
|
|
162
|
+
await create(context, 'client-key', `/api/0/projects/${organization}/${slug}/keys/`, {
|
|
163
|
+
name: 'Anhedral',
|
|
164
|
+
});
|
|
165
|
+
keys = list(await api(`/api/0/projects/${organization}/${slug}/keys/`)).filter((key) => key.isActive === true);
|
|
166
|
+
}
|
|
167
|
+
if (keys.length !== 1)
|
|
168
|
+
throw new Error('Select exactly one active Sentry client key before configuring.');
|
|
169
|
+
const dsn = text(object(keys[0].dsn).public);
|
|
170
|
+
if (new URL(dsn).protocol !== 'https:')
|
|
171
|
+
throw new Error('Sentry DSN must use HTTPS.');
|
|
172
|
+
return result({ project: resourceId(project.id) }, { SENTRY_DSN: dsn });
|
|
173
|
+
}
|
|
174
|
+
async function posthog(context) {
|
|
175
|
+
const { settings: config, api } = context;
|
|
176
|
+
const organization = resourceId(config.organizationId);
|
|
177
|
+
let id = setting(config, 'projectId');
|
|
178
|
+
if (!id)
|
|
179
|
+
id = resourceId((await create(context, 'project', `/api/organizations/${organization}/projects/`, {
|
|
180
|
+
name: config.name,
|
|
181
|
+
})).id);
|
|
182
|
+
id = resourceId(id);
|
|
183
|
+
const project = object(await api(`/api/organizations/${organization}/projects/${id}/`));
|
|
184
|
+
same(resourceId(project.id), id, 'PostHog project');
|
|
185
|
+
same(project.organization, organization, 'PostHog organization');
|
|
186
|
+
return result({ project: id }, {
|
|
187
|
+
POSTHOG_API_KEY: text(project.api_token),
|
|
188
|
+
POSTHOG_HOST: config.region === 'eu' ? 'https://eu.i.posthog.com' : 'https://us.i.posthog.com',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async function openai(context) {
|
|
192
|
+
const { settings: config, api } = context;
|
|
193
|
+
let id = setting(config, 'projectId');
|
|
194
|
+
if (!id)
|
|
195
|
+
id = resourceId((await create(context, 'project', '/v1/organization/projects', { name: config.name })).id);
|
|
196
|
+
id = resourceId(id);
|
|
197
|
+
const project = object(await api(`/v1/organization/projects/${id}`));
|
|
198
|
+
same(project.id, id, 'OpenAI project');
|
|
199
|
+
if (project.status !== 'active')
|
|
200
|
+
throw new Error('OpenAI project is not active.');
|
|
201
|
+
const account = await create(context, 'service-account', `/v1/organization/projects/${id}/service_accounts`, { name: config.serviceAccountName });
|
|
202
|
+
const accountId = resourceId(account.id);
|
|
203
|
+
const current = object(await api(`/v1/organization/projects/${id}/service_accounts/${accountId}`));
|
|
204
|
+
same(current.id, accountId, 'OpenAI service account');
|
|
205
|
+
same(current.name, setting(config, 'serviceAccountName'), 'OpenAI service account name');
|
|
206
|
+
return result({ project: id, serviceAccount: accountId }, { OPENAI_API_KEY: text(object(account.api_key).value) });
|
|
207
|
+
}
|
|
208
|
+
async function revenuecat(context) {
|
|
209
|
+
const { settings: config, api } = context;
|
|
210
|
+
let id = setting(config, 'projectId');
|
|
211
|
+
if (!id)
|
|
212
|
+
id = resourceId((await create(context, 'project', '/v2/projects', { name: config.name })).id);
|
|
213
|
+
id = resourceId(id);
|
|
214
|
+
const project = object(await api(`/v2/projects/${id}`));
|
|
215
|
+
same(project.id, id, 'RevenueCat project');
|
|
216
|
+
const resources = { project: id };
|
|
217
|
+
const runtime = {};
|
|
218
|
+
if (setting(config, 'webAppId') || setting(config, 'stripeAccountId')) {
|
|
219
|
+
let appId = setting(config, 'webAppId');
|
|
220
|
+
if (!appId)
|
|
221
|
+
appId = resourceId((await create(context, 'web-app', `/v2/projects/${id}/apps`, {
|
|
222
|
+
name: `${setting(config, 'name') || id} web`,
|
|
223
|
+
type: 'stripe',
|
|
224
|
+
stripe: { stripe_account_id: config.stripeAccountId },
|
|
225
|
+
})).id);
|
|
226
|
+
appId = resourceId(appId);
|
|
227
|
+
const app = object(await api(`/v2/projects/${id}/apps/${appId}`));
|
|
228
|
+
same(app.type, 'stripe', 'RevenueCat web app');
|
|
229
|
+
if (setting(config, 'stripeAccountId'))
|
|
230
|
+
same(object(app.stripe).stripe_account_id, config.stripeAccountId, 'RevenueCat Stripe account');
|
|
231
|
+
resources.webApp = appId;
|
|
232
|
+
}
|
|
233
|
+
for (const platform of ['ios', 'android']) {
|
|
234
|
+
const type = platform === 'ios' ? 'app_store' : 'play_store';
|
|
235
|
+
const identifier = setting(config, platform === 'ios' ? 'bundleId' : 'packageName');
|
|
236
|
+
let appId = setting(config, `${platform}AppId`);
|
|
237
|
+
if (!identifier && !appId)
|
|
238
|
+
continue;
|
|
239
|
+
if (!appId)
|
|
240
|
+
appId = resourceId((await create(context, `${platform}-app`, `/v2/projects/${id}/apps`, {
|
|
241
|
+
name: `${setting(config, 'name') || id} ${platform}`,
|
|
242
|
+
type,
|
|
243
|
+
[type]: { [platform === 'ios' ? 'bundle_id' : 'package_name']: identifier },
|
|
244
|
+
})).id);
|
|
245
|
+
appId = resourceId(appId);
|
|
246
|
+
const app = object(await api(`/v2/projects/${id}/apps/${appId}`));
|
|
247
|
+
same(app.type, type, 'RevenueCat app platform');
|
|
248
|
+
if (identifier)
|
|
249
|
+
same(object(app[type])[platform === 'ios' ? 'bundle_id' : 'package_name'], identifier, 'RevenueCat app identifier');
|
|
250
|
+
const keys = list(object(await api(`/v2/projects/${id}/apps/${appId}/public_api_keys`)).items).filter((entry) => entry.app_id === appId && entry.environment === 'production');
|
|
251
|
+
if (keys.length !== 1)
|
|
252
|
+
throw new Error('RevenueCat must return exactly one production public SDK key per app.');
|
|
253
|
+
resources[`${platform}App`] = appId;
|
|
254
|
+
runtime[`EXPO_PUBLIC_REVENUECAT_${platform.toUpperCase()}_API_KEY`] = text(keys[0].key);
|
|
255
|
+
}
|
|
256
|
+
return result(resources, runtime, [
|
|
257
|
+
'Configure store credentials or the Stripe connection, products and offerings and verify purchases before enabling billing. SDK keys alone do not activate subscriptions.',
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
260
|
+
export const PROVIDER_ADAPTERS = {
|
|
261
|
+
neon,
|
|
262
|
+
clerk,
|
|
263
|
+
resend,
|
|
264
|
+
stripe,
|
|
265
|
+
revenuecat,
|
|
266
|
+
sentry,
|
|
267
|
+
posthog,
|
|
268
|
+
openai,
|
|
269
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { IntegrationProvider, ProviderSettings } from './config.js';
|
|
2
|
+
export type ApiObject = Record<string, unknown>;
|
|
3
|
+
export declare function object(value: unknown): ApiObject;
|
|
4
|
+
export declare function list(value: unknown): ApiObject[];
|
|
5
|
+
export declare function text(value: unknown): string;
|
|
6
|
+
export declare function resourceId(value: unknown): string;
|
|
7
|
+
export declare function digest(value: unknown): string;
|
|
8
|
+
export declare const CREDENTIALS: Record<IntegrationProvider, string>;
|
|
9
|
+
export interface ApiRequest {
|
|
10
|
+
(route: string, method?: 'GET' | 'POST' | 'PATCH', body?: ApiObject, operation?: string): Promise<unknown>;
|
|
11
|
+
}
|
|
12
|
+
export declare class ProviderRequestError extends Error {
|
|
13
|
+
readonly rejected: boolean;
|
|
14
|
+
readonly status?: number | undefined;
|
|
15
|
+
constructor(message: string, rejected: boolean, status?: number | undefined);
|
|
16
|
+
}
|
|
17
|
+
export interface ProviderRetryOptions {
|
|
18
|
+
/** Reads only; provider mutations always receive exactly one attempt. */
|
|
19
|
+
maxAttempts?: number;
|
|
20
|
+
totalTimeoutMs?: number;
|
|
21
|
+
attemptTimeoutMs?: number;
|
|
22
|
+
baseDelayMs?: number;
|
|
23
|
+
maxDelayMs?: number;
|
|
24
|
+
delay?: (milliseconds: number) => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/** Fixed origins, bounded reads and sanitized errors keep management keys out of output.
|
|
27
|
+
* Only GET requests retry transient failures; unknown mutation outcomes never replay. */
|
|
28
|
+
export declare function providerApi(id: IntegrationProvider, settings: ProviderSettings, environment: NodeJS.ProcessEnv, send: typeof fetch, options?: ProviderRetryOptions): ApiRequest;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { PROVIDER_DEFINITIONS } from './definitions.js';
|
|
3
|
+
export function object(value) {
|
|
4
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
5
|
+
throw new Error('Provider returned an invalid resource.');
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
export function list(value) {
|
|
9
|
+
if (!Array.isArray(value))
|
|
10
|
+
throw new Error('Provider returned an invalid resource list.');
|
|
11
|
+
return value.map(object);
|
|
12
|
+
}
|
|
13
|
+
export function text(value) {
|
|
14
|
+
if (typeof value !== 'string' || !value || value.length > 16384 || /[\r\n\x00]/.test(value))
|
|
15
|
+
throw new Error('Provider returned an invalid resource field.');
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
export function resourceId(value) {
|
|
19
|
+
const id = typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
|
20
|
+
? String(value)
|
|
21
|
+
: text(value);
|
|
22
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,254}$/.test(id))
|
|
23
|
+
throw new Error('Provider returned an invalid resource ID.');
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
export function digest(value) {
|
|
27
|
+
const canonical = (item) => Array.isArray(item)
|
|
28
|
+
? item.map(canonical)
|
|
29
|
+
: item && typeof item === 'object'
|
|
30
|
+
? Object.fromEntries(Object.entries(item)
|
|
31
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
32
|
+
.map(([key, entry]) => [key, canonical(entry)]))
|
|
33
|
+
: item;
|
|
34
|
+
return createHash('sha256')
|
|
35
|
+
.update(JSON.stringify(canonical(value)))
|
|
36
|
+
.digest('hex');
|
|
37
|
+
}
|
|
38
|
+
export const CREDENTIALS = Object.fromEntries(Object.entries(PROVIDER_DEFINITIONS).map(([id, definition]) => [id, definition.credential]));
|
|
39
|
+
export class ProviderRequestError extends Error {
|
|
40
|
+
rejected;
|
|
41
|
+
status;
|
|
42
|
+
constructor(message, rejected, status) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.rejected = rejected;
|
|
45
|
+
this.status = status;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
class TransportFailure extends Error {
|
|
49
|
+
}
|
|
50
|
+
class RequestTimeout extends TransportFailure {
|
|
51
|
+
}
|
|
52
|
+
function retryPolicy(options) {
|
|
53
|
+
const policy = {
|
|
54
|
+
maxAttempts: options.maxAttempts ?? 3,
|
|
55
|
+
totalTimeoutMs: options.totalTimeoutMs ?? 30_000,
|
|
56
|
+
attemptTimeoutMs: options.attemptTimeoutMs ?? 10_000,
|
|
57
|
+
baseDelayMs: options.baseDelayMs ?? 100,
|
|
58
|
+
maxDelayMs: options.maxDelayMs ?? 2_000,
|
|
59
|
+
delay: options.delay ??
|
|
60
|
+
((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))),
|
|
61
|
+
};
|
|
62
|
+
if (!Number.isInteger(policy.maxAttempts) ||
|
|
63
|
+
policy.maxAttempts < 1 ||
|
|
64
|
+
policy.maxAttempts > 5 ||
|
|
65
|
+
[policy.totalTimeoutMs, policy.attemptTimeoutMs].some((value) => !Number.isInteger(value) || value < 1 || value > 30_000) ||
|
|
66
|
+
[policy.baseDelayMs, policy.maxDelayMs].some((value) => !Number.isInteger(value) || value < 0 || value > 2_000) ||
|
|
67
|
+
policy.baseDelayMs > policy.maxDelayMs ||
|
|
68
|
+
typeof policy.delay !== 'function')
|
|
69
|
+
throw new Error('Invalid bounded provider retry policy.');
|
|
70
|
+
return policy;
|
|
71
|
+
}
|
|
72
|
+
async function deadline(milliseconds, operation) {
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
let timer;
|
|
75
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
76
|
+
timer = setTimeout(() => {
|
|
77
|
+
controller.abort();
|
|
78
|
+
reject(new RequestTimeout());
|
|
79
|
+
}, milliseconds);
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
return await Promise.race([operation(controller.signal), timeout]);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function cancelBody(response) {
|
|
89
|
+
try {
|
|
90
|
+
void response.body?.cancel().catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
/* Cancellation is best effort; diagnostics stay sanitized. */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function responseJson(id, response, signal) {
|
|
97
|
+
if (signal.aborted) {
|
|
98
|
+
cancelBody(response);
|
|
99
|
+
throw new RequestTimeout();
|
|
100
|
+
}
|
|
101
|
+
const reader = response.body?.getReader();
|
|
102
|
+
if (!reader)
|
|
103
|
+
throw new Error(`${id}: provider returned an empty response.`);
|
|
104
|
+
const cancel = () => {
|
|
105
|
+
void reader.cancel().catch(() => undefined);
|
|
106
|
+
};
|
|
107
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
108
|
+
const chunks = [];
|
|
109
|
+
let bytes = 0;
|
|
110
|
+
try {
|
|
111
|
+
for (;;) {
|
|
112
|
+
let chunk;
|
|
113
|
+
try {
|
|
114
|
+
chunk = await reader.read();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new TransportFailure();
|
|
118
|
+
}
|
|
119
|
+
if (chunk.done)
|
|
120
|
+
break;
|
|
121
|
+
bytes += chunk.value.length;
|
|
122
|
+
if (bytes > 2 * 1024 * 1024)
|
|
123
|
+
throw new Error('Response exceeded size limit.');
|
|
124
|
+
chunks.push(chunk.value);
|
|
125
|
+
}
|
|
126
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
cancel();
|
|
130
|
+
if (signal.aborted)
|
|
131
|
+
throw new RequestTimeout();
|
|
132
|
+
if (error instanceof TransportFailure)
|
|
133
|
+
throw error;
|
|
134
|
+
throw new Error(`${id}: provider returned invalid or oversized JSON.`);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
signal.removeEventListener('abort', cancel);
|
|
138
|
+
reader.releaseLock();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function requestedDelay(value, maximum) {
|
|
142
|
+
if (!value)
|
|
143
|
+
return 0;
|
|
144
|
+
const milliseconds = /^\d+(?:\.\d+)?$/.test(value)
|
|
145
|
+
? Number(value) * 1000
|
|
146
|
+
: Date.parse(value) - Date.now();
|
|
147
|
+
if (!Number.isFinite(milliseconds))
|
|
148
|
+
return 0;
|
|
149
|
+
// Do not retry sooner than a provider's long waiting requirement. Return the
|
|
150
|
+
// sanitized error when that wait exceeds this deliberately short retry budget.
|
|
151
|
+
return milliseconds > maximum ? undefined : Math.max(0, Math.ceil(milliseconds));
|
|
152
|
+
}
|
|
153
|
+
/** Fixed origins, bounded reads and sanitized errors keep management keys out of output.
|
|
154
|
+
* Only GET requests retry transient failures; unknown mutation outcomes never replay. */
|
|
155
|
+
export function providerApi(id, settings, environment, send, options = {}) {
|
|
156
|
+
const token = environment[CREDENTIALS[id]];
|
|
157
|
+
if (!token || /[^\x21-\x7e]/.test(token))
|
|
158
|
+
throw new Error(`${id}: supply ${CREDENTIALS[id]}.`);
|
|
159
|
+
const policy = retryPolicy(options);
|
|
160
|
+
const definition = PROVIDER_DEFINITIONS[id];
|
|
161
|
+
const origin = definition.regionalOrigins?.[settings.region ?? ''] ?? definition.origin;
|
|
162
|
+
return async (route, method = 'GET', body, operation) => {
|
|
163
|
+
if (!route.startsWith('/') ||
|
|
164
|
+
route.startsWith('//') ||
|
|
165
|
+
route.includes('..') ||
|
|
166
|
+
/[\r\n#\\]/.test(route))
|
|
167
|
+
throw new Error('Invalid provider API path.');
|
|
168
|
+
const url = new URL(route, origin);
|
|
169
|
+
if (url.origin !== origin)
|
|
170
|
+
throw new Error('Provider API origin changed.');
|
|
171
|
+
const headers = {
|
|
172
|
+
Authorization: `Bearer ${token}`,
|
|
173
|
+
Accept: 'application/json',
|
|
174
|
+
};
|
|
175
|
+
let payload;
|
|
176
|
+
if (body) {
|
|
177
|
+
if (id === 'stripe') {
|
|
178
|
+
const form = new URLSearchParams();
|
|
179
|
+
for (const [key, value] of Object.entries(body)) {
|
|
180
|
+
if (Array.isArray(value))
|
|
181
|
+
for (const entry of value)
|
|
182
|
+
form.append(`${key}[]`, String(entry));
|
|
183
|
+
else
|
|
184
|
+
form.set(key, String(value));
|
|
185
|
+
}
|
|
186
|
+
payload = form.toString();
|
|
187
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
payload = JSON.stringify(body);
|
|
191
|
+
headers['Content-Type'] = 'application/json';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (operation && (id === 'stripe' || id === 'resend'))
|
|
195
|
+
headers['Idempotency-Key'] = operation;
|
|
196
|
+
const started = performance.now();
|
|
197
|
+
const attempts = method === 'GET' ? policy.maxAttempts : 1;
|
|
198
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
199
|
+
const remaining = policy.totalTimeoutMs - (performance.now() - started);
|
|
200
|
+
if (remaining <= 0)
|
|
201
|
+
throw new Error(`${id}: provider read retry deadline exceeded.`);
|
|
202
|
+
let retryAfter = 0;
|
|
203
|
+
try {
|
|
204
|
+
return await deadline(Math.min(remaining, method === 'GET' ? policy.attemptTimeoutMs : policy.totalTimeoutMs), async (signal) => {
|
|
205
|
+
let response;
|
|
206
|
+
try {
|
|
207
|
+
response = await send(url.toString(), {
|
|
208
|
+
method,
|
|
209
|
+
headers,
|
|
210
|
+
body: payload,
|
|
211
|
+
redirect: 'error',
|
|
212
|
+
signal,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
throw new TransportFailure();
|
|
217
|
+
}
|
|
218
|
+
if (!response.ok) {
|
|
219
|
+
retryAfter =
|
|
220
|
+
requestedDelay(response.headers.get('Retry-After'), policy.maxDelayMs) ?? -1;
|
|
221
|
+
cancelBody(response);
|
|
222
|
+
throw new ProviderRequestError(`${id}: provider request failed (HTTP ${response.status}). Check account, permissions and quota.`, response.status >= 400 &&
|
|
223
|
+
response.status < 500 &&
|
|
224
|
+
![408, 429].includes(response.status), response.status);
|
|
225
|
+
}
|
|
226
|
+
return responseJson(id, response, signal);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
const retryable = error instanceof TransportFailure ||
|
|
231
|
+
(error instanceof ProviderRequestError &&
|
|
232
|
+
[429, 502, 503, 504].includes(error.status ?? 0));
|
|
233
|
+
const failure = error instanceof TransportFailure
|
|
234
|
+
? new Error(`${id}: provider request did not complete. Inspect remote status before retrying.`)
|
|
235
|
+
: error;
|
|
236
|
+
if (method !== 'GET' || !retryable || retryAfter < 0 || attempt + 1 >= attempts)
|
|
237
|
+
throw failure;
|
|
238
|
+
const delay = Math.max(retryAfter, Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** attempt));
|
|
239
|
+
const budget = policy.totalTimeoutMs - (performance.now() - started);
|
|
240
|
+
if (delay >= budget)
|
|
241
|
+
throw failure;
|
|
242
|
+
try {
|
|
243
|
+
await deadline(budget, async () => policy.delay(delay));
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
throw new Error(`${id}: provider read retry deadline exceeded.`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
throw new Error(`${id}: provider read retry deadline exceeded.`);
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type IntegrationProvider, type ProviderSettings, type ProviderSettingsById } from './definitions.js';
|
|
2
|
+
export { INTEGRATION_PROVIDERS } from './definitions.js';
|
|
3
|
+
export type { IntegrationProvider, ProviderSettings, ProviderSettingsById } from './definitions.js';
|
|
4
|
+
export interface ProviderConfiguration {
|
|
5
|
+
schemaVersion: 1;
|
|
6
|
+
providers: Partial<ProviderSettingsById>;
|
|
7
|
+
}
|
|
8
|
+
/** Validate untrusted JSON before assigning provider-specific types. */
|
|
9
|
+
export declare function validateProviderConfiguration(value: unknown, selected?: readonly IntegrationProvider[]): asserts value is ProviderConfiguration;
|
|
10
|
+
export declare function providerConfiguration(root: string): ProviderConfiguration;
|
|
11
|
+
export declare function setting(settings: ProviderSettings, key: string): string;
|
|
12
|
+
/** No identities, domains, prices or organizations are inferred from a user's last session. */
|
|
13
|
+
export declare function providerRequirements(id: IntegrationProvider, config: ProviderSettings): string[];
|
|
14
|
+
export declare function selectedIntegrations(root: string): IntegrationProvider[];
|
|
15
|
+
export declare function providerConfigurationTemplate(products: readonly string[], name: string): ProviderConfiguration;
|