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,76 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { defaultRunner, withApplicationLock } from '../project-state.js';
|
|
3
|
+
import { CREDENTIALS, providerApi, digest } from './api.js';
|
|
4
|
+
import { PROVIDER_ADAPTERS } from './adapters.js';
|
|
5
|
+
import { providerConfiguration, providerRequirements, selectedIntegrations } from './config.js';
|
|
6
|
+
import { privateStateDirectory, ProvisioningState } from './state.js';
|
|
7
|
+
import { configureRuntime } from './runtime.js';
|
|
8
|
+
export function planProviderProvisioning(root) {
|
|
9
|
+
const configuration = providerConfiguration(root);
|
|
10
|
+
const providers = selectedIntegrations(root).map((id) => {
|
|
11
|
+
const settings = configuration.providers[id] ?? {};
|
|
12
|
+
return {
|
|
13
|
+
provider: id,
|
|
14
|
+
settings,
|
|
15
|
+
credential: CREDENTIALS[id],
|
|
16
|
+
missing: providerRequirements(id, settings),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
schemaVersion: 1,
|
|
21
|
+
providers,
|
|
22
|
+
configured: providers.every((provider) => !provider.missing.length),
|
|
23
|
+
mutation: false,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export async function provisionSelectedProviders(root, dryRun = false, run = defaultRunner, send = fetch, environment = process.env) {
|
|
27
|
+
root = realpathSync(root);
|
|
28
|
+
const plan = planProviderProvisioning(root);
|
|
29
|
+
const fingerprint = digest(providerConfiguration(root));
|
|
30
|
+
const assertConfiguration = () => {
|
|
31
|
+
if (digest(providerConfiguration(root)) !== fingerprint)
|
|
32
|
+
throw new Error('Provider configuration changed during provisioning; inspect recorded resources before retrying.');
|
|
33
|
+
};
|
|
34
|
+
if (dryRun || !plan.providers.length)
|
|
35
|
+
return { ...plan, ready: plan.configured, results: [], configuredFiles: [] };
|
|
36
|
+
for (const provider of plan.providers) {
|
|
37
|
+
if (provider.missing.length)
|
|
38
|
+
throw new Error(`${provider.provider}: complete anhedral.providers.json (${provider.missing.join(', ')}).`);
|
|
39
|
+
if (!environment[provider.credential])
|
|
40
|
+
throw new Error(`${provider.provider}: supply ${provider.credential} before provisioning.`);
|
|
41
|
+
}
|
|
42
|
+
const directory = await privateStateDirectory(root, run);
|
|
43
|
+
return withApplicationLock(root, async () => {
|
|
44
|
+
const state = new ProvisioningState(directory);
|
|
45
|
+
for (const provider of plan.providers)
|
|
46
|
+
state.context(provider.provider, provider.settings);
|
|
47
|
+
const results = [];
|
|
48
|
+
const values = {};
|
|
49
|
+
for (const provider of plan.providers) {
|
|
50
|
+
assertConfiguration();
|
|
51
|
+
const response = await PROVIDER_ADAPTERS[provider.provider]({
|
|
52
|
+
id: provider.provider,
|
|
53
|
+
settings: provider.settings,
|
|
54
|
+
state,
|
|
55
|
+
environment,
|
|
56
|
+
api: providerApi(provider.provider, provider.settings, environment, send),
|
|
57
|
+
});
|
|
58
|
+
Object.assign(values, response.runtime);
|
|
59
|
+
results.push({
|
|
60
|
+
provider: provider.provider,
|
|
61
|
+
resources: response.resources,
|
|
62
|
+
ready: response.ready,
|
|
63
|
+
nextActions: response.nextActions,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
assertConfiguration();
|
|
67
|
+
const configuredFiles = await configureRuntime(root, values, run);
|
|
68
|
+
return {
|
|
69
|
+
schemaVersion: 1,
|
|
70
|
+
results,
|
|
71
|
+
ready: results.every((result) => result.ready),
|
|
72
|
+
configuredFiles,
|
|
73
|
+
mutation: true,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ApiObject } from './api.js';
|
|
2
|
+
import type { IntegrationProvider } from './config.js';
|
|
3
|
+
export type CredentialField = 'token' | 'secret' | 'api_key';
|
|
4
|
+
export type OperationCredentials = Partial<Record<CredentialField, string>>;
|
|
5
|
+
export interface MinimalReceipt {
|
|
6
|
+
id?: string | number;
|
|
7
|
+
application_id?: string | number;
|
|
8
|
+
project?: {
|
|
9
|
+
id: string | number;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Keep only identities required for adapter retries and separately retain supported
|
|
13
|
+
* one-time credentials. Provider responses often include unrelated secrets or metadata. */
|
|
14
|
+
export declare function splitReceipt(id: IntegrationProvider, value: unknown): {
|
|
15
|
+
receipt: MinimalReceipt;
|
|
16
|
+
credentials: Partial<Record<CredentialField, string>>;
|
|
17
|
+
};
|
|
18
|
+
export declare function validReceipt(id: IntegrationProvider, value: unknown): value is MinimalReceipt;
|
|
19
|
+
export declare function validCredentials(id: IntegrationProvider, value: unknown): value is OperationCredentials;
|
|
20
|
+
export declare function restoreReceipt(receipt: MinimalReceipt, credentials: OperationCredentials): ApiObject;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { object, resourceId, text } from './api.js';
|
|
2
|
+
const allowedCredentials = {
|
|
3
|
+
resend: 'token',
|
|
4
|
+
stripe: 'secret',
|
|
5
|
+
openai: 'api_key',
|
|
6
|
+
};
|
|
7
|
+
function identity(value) {
|
|
8
|
+
resourceId(value);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
/** Keep only identities required for adapter retries and separately retain supported
|
|
12
|
+
* one-time credentials. Provider responses often include unrelated secrets or metadata. */
|
|
13
|
+
export function splitReceipt(id, value) {
|
|
14
|
+
const response = object(value);
|
|
15
|
+
const receipt = {};
|
|
16
|
+
if (Object.hasOwn(response, 'id'))
|
|
17
|
+
receipt.id = identity(response.id);
|
|
18
|
+
if (id === 'neon' && Object.hasOwn(response, 'project'))
|
|
19
|
+
receipt.project = { id: identity(object(response.project).id) };
|
|
20
|
+
if (id === 'clerk' && Object.hasOwn(response, 'application_id'))
|
|
21
|
+
receipt.application_id = identity(response.application_id);
|
|
22
|
+
if (!Object.keys(receipt).length)
|
|
23
|
+
throw new Error('Provider returned no creation identity.');
|
|
24
|
+
const credentials = {};
|
|
25
|
+
const field = allowedCredentials[id];
|
|
26
|
+
if (field && Object.hasOwn(response, field))
|
|
27
|
+
credentials[field] = text(field === 'api_key' ? object(response.api_key).value : response[field]);
|
|
28
|
+
return { receipt, credentials };
|
|
29
|
+
}
|
|
30
|
+
export function validReceipt(id, value) {
|
|
31
|
+
try {
|
|
32
|
+
const response = object(value);
|
|
33
|
+
const allowed = [
|
|
34
|
+
'id',
|
|
35
|
+
...(id === 'neon' ? ['project'] : []),
|
|
36
|
+
...(id === 'clerk' ? ['application_id'] : []),
|
|
37
|
+
];
|
|
38
|
+
if (Object.keys(response).some((key) => !allowed.includes(key)))
|
|
39
|
+
return false;
|
|
40
|
+
if (response.project && Object.keys(object(response.project)).some((key) => key !== 'id'))
|
|
41
|
+
return false;
|
|
42
|
+
splitReceipt(id, response);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function validCredentials(id, value) {
|
|
50
|
+
try {
|
|
51
|
+
const credentials = object(value);
|
|
52
|
+
const allowed = allowedCredentials[id];
|
|
53
|
+
return Object.entries(credentials).every(([field, value]) => field === allowed && text(value) === value);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function restoreReceipt(receipt, credentials) {
|
|
60
|
+
return {
|
|
61
|
+
...receipt,
|
|
62
|
+
...(credentials.token ? { token: credentials.token } : {}),
|
|
63
|
+
...(credentials.secret ? { secret: credentials.secret } : {}),
|
|
64
|
+
...(credentials.api_key ? { api_key: { value: credentials.api_key } } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ApiRequest } from './api.js';
|
|
2
|
+
import { type IntegrationProvider, type ProviderSettings } from './config.js';
|
|
3
|
+
import type { OperationDescriptor } from './state.js';
|
|
4
|
+
import type { MinimalReceipt } from './receipts.js';
|
|
5
|
+
/** Only verified identities may finish a pending journal operation. Every request is
|
|
6
|
+
* GET; key creation cannot be recovered by inventing a one-time credential. */
|
|
7
|
+
export declare function verifyRecoveredResource(provider: IntegrationProvider, descriptor: OperationDescriptor, requested: string, settings: ProviderSettings, api: ApiRequest): Promise<MinimalReceipt>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { object, list, resourceId } from './api.js';
|
|
2
|
+
import { setting } from './config.js';
|
|
3
|
+
function same(actual, expected, label) {
|
|
4
|
+
if (!expected || actual !== expected)
|
|
5
|
+
throw new Error(`Recovered provider ${label} does not match the desired configuration.`);
|
|
6
|
+
}
|
|
7
|
+
function route(descriptor, key, expected) {
|
|
8
|
+
if (descriptor.key !== key || descriptor.route !== expected)
|
|
9
|
+
throw new Error('Pending provider operation does not match the supported recovery route.');
|
|
10
|
+
}
|
|
11
|
+
/** Only verified identities may finish a pending journal operation. Every request is
|
|
12
|
+
* GET; key creation cannot be recovered by inventing a one-time credential. */
|
|
13
|
+
export async function verifyRecoveredResource(provider, descriptor, requested, settings, api) {
|
|
14
|
+
const id = resourceId(requested);
|
|
15
|
+
if (['sending-key', 'webhook', 'service-account'].includes(descriptor.key))
|
|
16
|
+
throw new Error('This operation creates a one-time credential. Restore its securely retained credential and verify it with the provider; resource-only recovery is unavailable.');
|
|
17
|
+
if (provider === 'neon') {
|
|
18
|
+
route(descriptor, 'project', '/api/v2/projects');
|
|
19
|
+
const project = object(object(await api(`/api/v2/projects/${id}`)).project);
|
|
20
|
+
same(resourceId(project.id), id, 'Neon identity');
|
|
21
|
+
same(project.org_id, setting(settings, 'organizationId'), 'Neon organization');
|
|
22
|
+
same(project.name, setting(settings, 'name'), 'Neon project name');
|
|
23
|
+
same(project.region_id, setting(settings, 'region'), 'Neon region');
|
|
24
|
+
return { project: { id } };
|
|
25
|
+
}
|
|
26
|
+
if (provider === 'clerk') {
|
|
27
|
+
route(descriptor, 'application', '/v1/platform/applications');
|
|
28
|
+
same(object(await api('/v1/platform/me')).subject, setting(settings, 'workspaceId'), 'Clerk workspace');
|
|
29
|
+
const application = object(await api(`/v1/platform/applications/${id}`));
|
|
30
|
+
same(application.application_id, id, 'Clerk application identity');
|
|
31
|
+
same(application.name, setting(settings, 'name'), 'Clerk application name');
|
|
32
|
+
const instances = list(application.instances).filter((entry) => entry.environment_type === settings.environment);
|
|
33
|
+
if (instances.length !== 1)
|
|
34
|
+
throw new Error('Recovered Clerk application does not have exactly one selected environment.');
|
|
35
|
+
return { application_id: id };
|
|
36
|
+
}
|
|
37
|
+
if (provider === 'resend') {
|
|
38
|
+
route(descriptor, 'domain', '/domains');
|
|
39
|
+
const domain = object(await api(`/domains/${id}`));
|
|
40
|
+
same(domain.id, id, 'Resend domain identity');
|
|
41
|
+
same(domain.name, setting(settings, 'domain'), 'Resend domain name');
|
|
42
|
+
if (setting(settings, 'region'))
|
|
43
|
+
same(domain.region, setting(settings, 'region'), 'Resend region');
|
|
44
|
+
return { id };
|
|
45
|
+
}
|
|
46
|
+
if (provider === 'posthog') {
|
|
47
|
+
const organization = resourceId(setting(settings, 'organizationId'));
|
|
48
|
+
route(descriptor, 'project', `/api/organizations/${organization}/projects/`);
|
|
49
|
+
const project = object(await api(`/api/organizations/${organization}/projects/${id}/`));
|
|
50
|
+
same(resourceId(project.id), id, 'PostHog project identity');
|
|
51
|
+
same(project.organization, organization, 'PostHog organization');
|
|
52
|
+
same(project.name, setting(settings, 'name'), 'PostHog project name');
|
|
53
|
+
return { id };
|
|
54
|
+
}
|
|
55
|
+
if (provider === 'openai') {
|
|
56
|
+
route(descriptor, 'project', '/v1/organization/projects');
|
|
57
|
+
const project = object(await api(`/v1/organization/projects/${id}`));
|
|
58
|
+
same(project.id, id, 'OpenAI project identity');
|
|
59
|
+
same(project.name, setting(settings, 'name'), 'OpenAI project name');
|
|
60
|
+
same(project.status, 'active', 'OpenAI project status');
|
|
61
|
+
return { id };
|
|
62
|
+
}
|
|
63
|
+
if (provider === 'sentry') {
|
|
64
|
+
const organization = resourceId(setting(settings, 'organization'));
|
|
65
|
+
const team = resourceId(setting(settings, 'team'));
|
|
66
|
+
const slug = resourceId(setting(settings, 'project'));
|
|
67
|
+
route(descriptor, 'project', `/api/0/teams/${organization}/${team}/projects/`);
|
|
68
|
+
const project = object(await api(`/api/0/projects/${organization}/${slug}/`));
|
|
69
|
+
same(resourceId(project.id), id, 'Sentry project identity');
|
|
70
|
+
same(object(project.organization).slug, organization, 'Sentry organization');
|
|
71
|
+
same(project.slug, slug, 'Sentry project slug');
|
|
72
|
+
same(project.platform, setting(settings, 'platform'), 'Sentry platform');
|
|
73
|
+
if (!list(project.teams).some((entry) => entry.slug === team))
|
|
74
|
+
throw new Error('Recovered Sentry project does not belong to the selected team.');
|
|
75
|
+
return { id };
|
|
76
|
+
}
|
|
77
|
+
if (provider === 'revenuecat') {
|
|
78
|
+
if (descriptor.key === 'project') {
|
|
79
|
+
route(descriptor, 'project', '/v2/projects');
|
|
80
|
+
const project = object(await api(`/v2/projects/${id}`));
|
|
81
|
+
same(project.id, id, 'RevenueCat project identity');
|
|
82
|
+
same(project.name, setting(settings, 'name'), 'RevenueCat project name');
|
|
83
|
+
return { id };
|
|
84
|
+
}
|
|
85
|
+
// Recovery of an app requires an explicit parent project choice. Never infer
|
|
86
|
+
// that parent from whichever project the current credential most recently used.
|
|
87
|
+
const project = resourceId(setting(settings, 'projectId'));
|
|
88
|
+
if (!['ios-app', 'android-app', 'web-app'].includes(descriptor.key))
|
|
89
|
+
throw new Error('Unsupported RevenueCat recovery operation.');
|
|
90
|
+
route(descriptor, descriptor.key, `/v2/projects/${project}/apps`);
|
|
91
|
+
const app = object(await api(`/v2/projects/${project}/apps/${id}`));
|
|
92
|
+
same(app.id, id, 'RevenueCat app identity');
|
|
93
|
+
const type = descriptor.key === 'ios-app'
|
|
94
|
+
? 'app_store'
|
|
95
|
+
: descriptor.key === 'android-app'
|
|
96
|
+
? 'play_store'
|
|
97
|
+
: 'stripe';
|
|
98
|
+
same(app.type, type, 'RevenueCat app platform');
|
|
99
|
+
const field = type === 'app_store'
|
|
100
|
+
? 'bundle_id'
|
|
101
|
+
: type === 'play_store'
|
|
102
|
+
? 'package_name'
|
|
103
|
+
: 'stripe_account_id';
|
|
104
|
+
same(object(app[type])[field], setting(settings, type === 'app_store'
|
|
105
|
+
? 'bundleId'
|
|
106
|
+
: type === 'play_store'
|
|
107
|
+
? 'packageName'
|
|
108
|
+
: 'stripeAccountId'), 'RevenueCat app identifier');
|
|
109
|
+
return { id };
|
|
110
|
+
}
|
|
111
|
+
throw new Error('Resource-only recovery is unsupported for this provider operation.');
|
|
112
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type DeliveryRunner } from '../project-state.js';
|
|
2
|
+
import { type IntegrationProvider } from './config.js';
|
|
3
|
+
export declare function integrationStatus(root: string): {
|
|
4
|
+
mutation: boolean;
|
|
5
|
+
schemaVersion: number;
|
|
6
|
+
providers: {
|
|
7
|
+
provider: string;
|
|
8
|
+
operations: {
|
|
9
|
+
recovery: string;
|
|
10
|
+
descriptor?: import("./state.js").OperationDescriptor | undefined;
|
|
11
|
+
resources?: import("./receipts.js").MinimalReceipt | undefined;
|
|
12
|
+
operation: string;
|
|
13
|
+
status: "pending" | "complete";
|
|
14
|
+
}[];
|
|
15
|
+
}[];
|
|
16
|
+
};
|
|
17
|
+
export interface IntegrationRecoveryRequest {
|
|
18
|
+
provider: IntegrationProvider;
|
|
19
|
+
operation: string;
|
|
20
|
+
resourceId: string;
|
|
21
|
+
}
|
|
22
|
+
/** Explicitly reconcile a verified resource; never recreate or delete it. Local
|
|
23
|
+
* receipt writes are authorized by this action, provider calls remain read-only. */
|
|
24
|
+
export declare function recoverIntegrationResource(root: string, request: IntegrationRecoveryRequest, run?: DeliveryRunner, send?: typeof fetch, environment?: NodeJS.ProcessEnv): Promise<{
|
|
25
|
+
provider: keyof import("./definitions.js").ProviderSettingsById;
|
|
26
|
+
operation: string;
|
|
27
|
+
status: string;
|
|
28
|
+
resources: import("./receipts.js").MinimalReceipt;
|
|
29
|
+
mutation: string;
|
|
30
|
+
}>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { defaultRunner, withApplicationLock } from '../project-state.js';
|
|
4
|
+
import { providerApi, resourceId, digest } from './api.js';
|
|
5
|
+
import { providerConfiguration, selectedIntegrations, INTEGRATION_PROVIDERS, } from './config.js';
|
|
6
|
+
import { privateStateDirectory, ProvisioningState, readProviderStateStatus } from './state.js';
|
|
7
|
+
export function integrationStatus(root) {
|
|
8
|
+
root = realpathSync(root);
|
|
9
|
+
return { ...readProviderStateStatus(path.join(root, '.anhedral')), mutation: false };
|
|
10
|
+
}
|
|
11
|
+
/** Explicitly reconcile a verified resource; never recreate or delete it. Local
|
|
12
|
+
* receipt writes are authorized by this action, provider calls remain read-only. */
|
|
13
|
+
export async function recoverIntegrationResource(root, request, run = defaultRunner, send = fetch, environment = process.env) {
|
|
14
|
+
root = realpathSync(root);
|
|
15
|
+
if (!INTEGRATION_PROVIDERS.includes(request.provider) ||
|
|
16
|
+
!/^[a-f0-9]{64}$/.test(request.operation))
|
|
17
|
+
throw new Error('Select a supported provider and an exact pending operation hash.');
|
|
18
|
+
const id = resourceId(request.resourceId);
|
|
19
|
+
if (!selectedIntegrations(root).includes(request.provider))
|
|
20
|
+
throw new Error('Recovery requires a selected provider.');
|
|
21
|
+
const configuration = providerConfiguration(root);
|
|
22
|
+
const settings = configuration.providers[request.provider] ?? {};
|
|
23
|
+
const fingerprint = digest(configuration);
|
|
24
|
+
if (!existsSync(path.join(root, '.anhedral/providers.json')))
|
|
25
|
+
throw new Error('There is no pending provider journal to reconcile.');
|
|
26
|
+
const directory = await privateStateDirectory(root, run);
|
|
27
|
+
return withApplicationLock(root, async () => {
|
|
28
|
+
const state = new ProvisioningState(directory);
|
|
29
|
+
return state.recoverResource(request.provider, request.operation, id, settings, providerApi(request.provider, settings, environment, send), () => {
|
|
30
|
+
if (digest(providerConfiguration(root)) !== fingerprint)
|
|
31
|
+
throw new Error('Provider configuration changed during recovery; no completion receipt was written.');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type DeliveryRunner } from '../project-state.js';
|
|
2
|
+
export type RuntimeValues = Record<string, string>;
|
|
3
|
+
export declare const RUNTIME_KEYS: string[];
|
|
4
|
+
export declare function runtimeTargets(root: string, values: RuntimeValues): Record<string, RuntimeValues>;
|
|
5
|
+
/** Preflight every destination before writing any file; preserve unrelated configuration verbatim. */
|
|
6
|
+
export declare function configureRuntime(root: string, values: RuntimeValues, run: DeliveryRunner): Promise<string[]>;
|
|
7
|
+
export declare function configuredRuntime(root: string): RuntimeValues;
|
|
8
|
+
/** Read application-owned credentials separately from integration receipts. Never write,
|
|
9
|
+
* return unrelated environment keys, or copy authentication secrets to other targets. */
|
|
10
|
+
export declare function applicationRuntime(root: string, run: DeliveryRunner): Promise<Record<string, RuntimeValues>>;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, lstatSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { readBoundedFile, MAX_PROJECT_JSON_BYTES } from '../bounded-file.js';
|
|
5
|
+
import { atomicJson, readConfig } from '../project-state.js';
|
|
6
|
+
import { digest } from './api.js';
|
|
7
|
+
import { validateSetupPlan } from '../readiness.js';
|
|
8
|
+
import { isIP } from 'node:net';
|
|
9
|
+
import { providerConfiguration } from './config.js';
|
|
10
|
+
export const RUNTIME_KEYS = [
|
|
11
|
+
'BETTER_AUTH_SECRET',
|
|
12
|
+
'BETTER_AUTH_URL',
|
|
13
|
+
'DATABASE_URL',
|
|
14
|
+
'CLERK_SECRET_KEY',
|
|
15
|
+
'CLERK_PUBLISHABLE_KEY',
|
|
16
|
+
'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY',
|
|
17
|
+
'RESEND_API_KEY',
|
|
18
|
+
'STRIPE_SECRET_KEY',
|
|
19
|
+
'STRIPE_WEBHOOK_SECRET',
|
|
20
|
+
'SENTRY_DSN',
|
|
21
|
+
'POSTHOG_API_KEY',
|
|
22
|
+
'POSTHOG_HOST',
|
|
23
|
+
'OPENAI_API_KEY',
|
|
24
|
+
'EXPO_PUBLIC_REVENUECAT_IOS_API_KEY',
|
|
25
|
+
'EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY',
|
|
26
|
+
];
|
|
27
|
+
function validateValues(values) {
|
|
28
|
+
if (!values ||
|
|
29
|
+
typeof values !== 'object' ||
|
|
30
|
+
Array.isArray(values) ||
|
|
31
|
+
Object.entries(values).some(([key, value]) => !RUNTIME_KEYS.includes(key) ||
|
|
32
|
+
typeof value !== 'string' ||
|
|
33
|
+
!value ||
|
|
34
|
+
value.length > 16384 ||
|
|
35
|
+
/[\r\n\x00]/.test(value)))
|
|
36
|
+
throw new Error('Invalid provider runtime configuration.');
|
|
37
|
+
}
|
|
38
|
+
export function runtimeTargets(root, values) {
|
|
39
|
+
validateValues(values);
|
|
40
|
+
const setup = JSON.parse(readBoundedFile(path.join(root, 'anhedral.setup.json'), MAX_PROJECT_JSON_BYTES).toString('utf8'));
|
|
41
|
+
validateSetupPlan(setup);
|
|
42
|
+
const targets = {};
|
|
43
|
+
const runtime = setup.products.includes('hono') ? 'api' : 'web';
|
|
44
|
+
const server = Object.fromEntries(Object.entries(values).filter(([key]) => !key.startsWith('EXPO_PUBLIC_')));
|
|
45
|
+
if (existsSync(path.join(root, `apps/${runtime}`)))
|
|
46
|
+
targets[`apps/${runtime}/${runtime === 'web' && setup.hosting === 'vercel' ? '.env.local' : '.dev.vars'}`] = server;
|
|
47
|
+
if (runtime === 'api' &&
|
|
48
|
+
existsSync(path.join(root, 'apps/web')) &&
|
|
49
|
+
values.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY)
|
|
50
|
+
targets[`apps/web/${setup.hosting === 'vercel' ? '.env.local' : '.dev.vars'}`] = {
|
|
51
|
+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: values.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
|
52
|
+
...(values.CLERK_SECRET_KEY ? { CLERK_SECRET_KEY: values.CLERK_SECRET_KEY } : {}),
|
|
53
|
+
};
|
|
54
|
+
if (values.DATABASE_URL && existsSync(path.join(root, 'packages/db')))
|
|
55
|
+
targets['packages/db/.env'] = { DATABASE_URL: values.DATABASE_URL };
|
|
56
|
+
if (setup.products.includes('expo')) {
|
|
57
|
+
const mobile = Object.fromEntries(Object.entries(values).filter(([key]) => key.startsWith('EXPO_PUBLIC_')));
|
|
58
|
+
if (values.CLERK_PUBLISHABLE_KEY)
|
|
59
|
+
mobile.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY = values.CLERK_PUBLISHABLE_KEY;
|
|
60
|
+
if (Object.keys(mobile).length)
|
|
61
|
+
targets['apps/mobile/.env.local'] = mobile;
|
|
62
|
+
}
|
|
63
|
+
return targets;
|
|
64
|
+
}
|
|
65
|
+
/** Preflight every destination before writing any file; preserve unrelated configuration verbatim. */
|
|
66
|
+
export async function configureRuntime(root, values, run) {
|
|
67
|
+
root = realpathSync(root);
|
|
68
|
+
const writes = [];
|
|
69
|
+
for (const [relative, entries] of Object.entries(runtimeTargets(root, values))) {
|
|
70
|
+
if (!Object.keys(entries).length)
|
|
71
|
+
continue;
|
|
72
|
+
const file = path.join(root, relative);
|
|
73
|
+
if (realpathSync(path.dirname(file)) !== path.dirname(file) ||
|
|
74
|
+
(existsSync(file) && lstatSync(file).isSymbolicLink()))
|
|
75
|
+
throw new Error('Runtime environment destination must not contain symbolic links.');
|
|
76
|
+
if ((await run('git', ['ls-files', '--', relative], root, process.env)).trim())
|
|
77
|
+
throw new Error('Runtime environment files must not be tracked.');
|
|
78
|
+
if ((await run('git', ['check-ignore', '--no-index', '--', relative], root, process.env)).trim() !== relative)
|
|
79
|
+
throw new Error('Ignore runtime environment files before provisioning.');
|
|
80
|
+
let content = existsSync(file)
|
|
81
|
+
? readBoundedFile(file, MAX_PROJECT_JSON_BYTES).toString('utf8')
|
|
82
|
+
: '';
|
|
83
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
84
|
+
const existing = content
|
|
85
|
+
.split(/\r?\n/)
|
|
86
|
+
.filter((line) => new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`).test(line));
|
|
87
|
+
if (existing.length > 1)
|
|
88
|
+
throw new Error(`Duplicate runtime value for ${key}; reconcile before configuring.`);
|
|
89
|
+
if (existing.length) {
|
|
90
|
+
let previous = existing[0].slice(existing[0].indexOf('=') + 1).trim();
|
|
91
|
+
if (previous.startsWith('"')) {
|
|
92
|
+
try {
|
|
93
|
+
previous = JSON.parse(previous);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* Conflicting formats require review. */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (previous.startsWith("'") && previous.endsWith("'"))
|
|
100
|
+
previous = previous.slice(1, -1);
|
|
101
|
+
if (previous !== value)
|
|
102
|
+
throw new Error(`Existing runtime value for ${key} differs. Reconcile it before configuring.`);
|
|
103
|
+
}
|
|
104
|
+
else
|
|
105
|
+
content += `${content && !content.endsWith('\n') ? '\n' : ''}${key}=${JSON.stringify(value)}\n`;
|
|
106
|
+
}
|
|
107
|
+
writes.push({ file, content, relative });
|
|
108
|
+
}
|
|
109
|
+
for (const { file, content } of writes) {
|
|
110
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
111
|
+
try {
|
|
112
|
+
writeFileSync(temporary, content, { mode: 0o600, flag: 'wx' });
|
|
113
|
+
renameSync(temporary, file);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
rmSync(temporary, { force: true });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
atomicJson(path.join(root, '.anhedral/runtime.json'), {
|
|
120
|
+
schemaVersion: 1,
|
|
121
|
+
configuration: digest(providerConfiguration(root)),
|
|
122
|
+
values,
|
|
123
|
+
});
|
|
124
|
+
return writes.map((write) => write.relative);
|
|
125
|
+
}
|
|
126
|
+
export function configuredRuntime(root) {
|
|
127
|
+
const file = path.join(root, '.anhedral/runtime.json');
|
|
128
|
+
if (!existsSync(file))
|
|
129
|
+
return {};
|
|
130
|
+
if (realpathSync(path.dirname(file)) !== path.join(realpathSync(root), '.anhedral'))
|
|
131
|
+
throw new Error('Runtime state directory must not be a symbolic link.');
|
|
132
|
+
if (process.platform !== 'win32' && lstatSync(file).mode & 0o077)
|
|
133
|
+
throw new Error('Runtime state permissions must be 0600.');
|
|
134
|
+
const state = JSON.parse(readBoundedFile(file, MAX_PROJECT_JSON_BYTES).toString('utf8'));
|
|
135
|
+
if (state.schemaVersion !== 1 || state.configuration !== digest(providerConfiguration(root)))
|
|
136
|
+
throw new Error('Runtime configuration does not match the selected provider configuration.');
|
|
137
|
+
validateValues(state.values);
|
|
138
|
+
return state.values;
|
|
139
|
+
}
|
|
140
|
+
function applicationValue(content, key) {
|
|
141
|
+
const matches = content
|
|
142
|
+
.split(/\r?\n/)
|
|
143
|
+
.filter((line) => new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`).test(line));
|
|
144
|
+
if (matches.length !== 1)
|
|
145
|
+
throw new Error(`Configure exactly one ${key} in the application's private runtime file.`);
|
|
146
|
+
const raw = matches[0].slice(matches[0].indexOf('=') + 1).trim();
|
|
147
|
+
let value;
|
|
148
|
+
if (raw.startsWith('"')) {
|
|
149
|
+
try {
|
|
150
|
+
value = JSON.parse(raw);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new Error(`Use a valid quoted value for ${key}.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else if (raw.startsWith("'")) {
|
|
157
|
+
if (!raw.endsWith("'") || raw.length < 2)
|
|
158
|
+
throw new Error(`Use a valid quoted value for ${key}.`);
|
|
159
|
+
value = raw.slice(1, -1);
|
|
160
|
+
}
|
|
161
|
+
else
|
|
162
|
+
value = raw.replace(/\s+#.*$/, '').trim();
|
|
163
|
+
if (typeof value !== 'string' || !value || value.length > 16384 || /[\r\n\x00]/.test(value))
|
|
164
|
+
throw new Error(`Configure a valid ${key} in the application's private runtime file.`);
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
function publicAuthOrigin(value) {
|
|
168
|
+
if (/[\\\s]/.test(value))
|
|
169
|
+
return false;
|
|
170
|
+
let url;
|
|
171
|
+
try {
|
|
172
|
+
url = new URL(value);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const hostname = url.hostname.toLowerCase().replace(/\.$/, '');
|
|
178
|
+
// Authentication callbacks must target a public deployment, not local development.
|
|
179
|
+
return (url.protocol === 'https:' &&
|
|
180
|
+
!url.username &&
|
|
181
|
+
!url.password &&
|
|
182
|
+
!url.search &&
|
|
183
|
+
!url.hash &&
|
|
184
|
+
url.pathname === '/' &&
|
|
185
|
+
!isIP(hostname.replace(/^\[|\]$/g, '')) &&
|
|
186
|
+
hostname.includes('.') &&
|
|
187
|
+
!['localhost', 'local', 'internal', 'test', 'invalid', 'example'].some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`)));
|
|
188
|
+
}
|
|
189
|
+
/** Read application-owned credentials separately from integration receipts. Never write,
|
|
190
|
+
* return unrelated environment keys, or copy authentication secrets to other targets. */
|
|
191
|
+
export async function applicationRuntime(root, run) {
|
|
192
|
+
root = realpathSync(root);
|
|
193
|
+
const setup = JSON.parse(readBoundedFile(path.join(root, 'anhedral.setup.json'), MAX_PROJECT_JSON_BYTES).toString('utf8'));
|
|
194
|
+
validateSetupPlan(setup);
|
|
195
|
+
if (!setup.products.includes('better-auth'))
|
|
196
|
+
return {};
|
|
197
|
+
const runtime = setup.products.includes('hono') ? 'api' : 'web';
|
|
198
|
+
const relative = `apps/${runtime}/${runtime === 'web' && setup.hosting === 'vercel' ? '.env.local' : '.dev.vars'}`;
|
|
199
|
+
const file = path.join(root, relative);
|
|
200
|
+
if (!existsSync(file))
|
|
201
|
+
throw new Error(`Configure BETTER_AUTH_SECRET and BETTER_AUTH_URL in ignored ${relative} before shipping.`);
|
|
202
|
+
if (realpathSync(path.dirname(file)) !== path.dirname(file) ||
|
|
203
|
+
!lstatSync(file).isFile() ||
|
|
204
|
+
lstatSync(file).isSymbolicLink())
|
|
205
|
+
throw new Error('Application runtime files must be regular files without symbolic links.');
|
|
206
|
+
if (process.platform !== 'win32' && lstatSync(file).mode & 0o077)
|
|
207
|
+
throw new Error('Application runtime file permissions must be 0600 before shipping.');
|
|
208
|
+
if ((await run('git', ['ls-files', '--', relative], root, process.env)).trim())
|
|
209
|
+
throw new Error('Application runtime files must not be tracked.');
|
|
210
|
+
if ((await run('git', ['check-ignore', '--no-index', '--', relative], root, process.env)).trim() !==
|
|
211
|
+
relative)
|
|
212
|
+
throw new Error('Ignore application runtime files before shipping.');
|
|
213
|
+
if (relative.endsWith('/.dev.vars') &&
|
|
214
|
+
existsSync(path.join(path.dirname(file), 'wrangler.jsonc'))) {
|
|
215
|
+
const config = readConfig(path.dirname(file));
|
|
216
|
+
if (config.vars &&
|
|
217
|
+
typeof config.vars === 'object' &&
|
|
218
|
+
['BETTER_AUTH_SECRET', 'BETTER_AUTH_URL'].some((key) => Object.hasOwn(config.vars, key)))
|
|
219
|
+
throw new Error('Remove Better Auth values from tracked Wrangler vars, review and commit that change, then configure the private runtime file before shipping.');
|
|
220
|
+
}
|
|
221
|
+
const content = readBoundedFile(file, MAX_PROJECT_JSON_BYTES).toString('utf8');
|
|
222
|
+
const secret = applicationValue(content, 'BETTER_AUTH_SECRET');
|
|
223
|
+
if (secret.length < 32 ||
|
|
224
|
+
/change.?me|replace.?me|placeholder|your.?secret|example|generate.?me/i.test(secret) ||
|
|
225
|
+
new Set(secret).size < 8)
|
|
226
|
+
throw new Error('BETTER_AUTH_SECRET requires a generated nonplaceholder secret of at least 32 characters.');
|
|
227
|
+
const origin = applicationValue(content, 'BETTER_AUTH_URL');
|
|
228
|
+
if (!publicAuthOrigin(origin))
|
|
229
|
+
throw new Error('BETTER_AUTH_URL must be an HTTPS public origin without credentials, path, query or fragment.');
|
|
230
|
+
return { [relative]: { BETTER_AUTH_SECRET: secret, BETTER_AUTH_URL: origin } };
|
|
231
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Editor schema validates syntax; selected-stack and provisioning readiness checks
|
|
2
|
+
* remain application rules, since templates deliberately contain empty choices. */
|
|
3
|
+
export declare const PROVIDER_CONFIGURATION_SCHEMA: {
|
|
4
|
+
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
|
|
5
|
+
readonly title: "Anhedral provider configuration";
|
|
6
|
+
readonly description: "Nonsecret resource choices. Supply credentials through the environment.";
|
|
7
|
+
readonly type: "object";
|
|
8
|
+
readonly additionalProperties: false;
|
|
9
|
+
readonly required: readonly ["schemaVersion", "providers"];
|
|
10
|
+
readonly properties: {
|
|
11
|
+
readonly schemaVersion: {
|
|
12
|
+
readonly const: 1;
|
|
13
|
+
};
|
|
14
|
+
readonly providers: {
|
|
15
|
+
readonly type: "object";
|
|
16
|
+
readonly additionalProperties: false;
|
|
17
|
+
readonly properties: {
|
|
18
|
+
[k: string]: {
|
|
19
|
+
type: string;
|
|
20
|
+
additionalProperties: boolean;
|
|
21
|
+
properties: {
|
|
22
|
+
[k: string]: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
};
|