@magoz/provision 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +498 -2
- package/dist/cli.js +29 -0
- package/dist/contract.js +13 -0
- package/dist/db/branch-lifecycle.js +23 -0
- package/dist/db/commands.js +303 -0
- package/dist/db/config.js +75 -0
- package/dist/db/credentials.js +177 -0
- package/dist/db/domain.js +106 -0
- package/dist/db/environment.js +14 -0
- package/dist/db/lease.js +132 -0
- package/dist/db/neon.js +350 -0
- package/dist/db/operations.js +246 -0
- package/dist/db/policy.js +59 -0
- package/dist/env/commands.js +39 -0
- package/dist/env/domain.js +14 -0
- package/dist/env/install.js +25 -0
- package/dist/env/paths.js +105 -0
- package/dist/env/provision.js +320 -0
- package/dist/env/sanitize.js +88 -0
- package/dist/env/vercel.js +180 -0
- package/dist/factory/approval.js +33 -0
- package/dist/factory/commands.js +173 -0
- package/dist/factory/config-commands.js +215 -0
- package/dist/factory/context.js +129 -0
- package/dist/factory/http.js +37 -0
- package/dist/factory/onboarding.js +155 -0
- package/dist/factory/onepassword.js +140 -0
- package/dist/factory/op-credential.js +154 -0
- package/dist/factory/passphrase.js +145 -0
- package/dist/factory/providers/cloudflare.js +126 -0
- package/dist/factory/providers/neon.js +81 -0
- package/dist/factory/providers/report-receiver.js +37 -0
- package/dist/factory/providers/resend.js +32 -0
- package/dist/factory/providers/upstash.js +21 -0
- package/dist/factory/registry.js +50 -0
- package/dist/factory/scoped-key.js +30 -0
- package/dist/factory/sealed.js +64 -0
- package/dist/factory/secret-input.js +43 -0
- package/dist/factory/steps/domain.js +23 -0
- package/dist/factory/steps/neon.js +101 -0
- package/dist/factory/steps/r2.js +70 -0
- package/dist/factory/steps/reports.js +44 -0
- package/dist/factory/steps/resend.js +37 -0
- package/dist/factory/steps/secrets.js +104 -0
- package/dist/factory/steps/upstash.js +92 -0
- package/dist/factory/steps/vercel.js +68 -0
- package/dist/factory/vercel-api.js +165 -0
- package/dist/package-info.js +15 -0
- package/dist/shared/agent.js +28 -0
- package/dist/shared/env-file.js +54 -0
- package/dist/shared/git.js +48 -0
- package/dist/shared/output.js +10 -0
- package/dist/shared/private-file.js +63 -0
- package/dist/shared/process.js +55 -0
- package/dist/shared/repo-config.js +104 -0
- package/dist/shared/sandbox-profile.js +12 -0
- package/package.json +44 -4
- package/skills/provision/SKILL.md +172 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Effect, Option, Redacted, Schema } from 'effect';
|
|
3
|
+
import { ProviderError, requestJson, requestJsonRequired } from '../http.js';
|
|
4
|
+
const API_ROOT = 'https://api.cloudflare.com/client/v4';
|
|
5
|
+
const headers = (token) => ({
|
|
6
|
+
Authorization: `Bearer ${Redacted.value(token)}`
|
|
7
|
+
});
|
|
8
|
+
const envelope = (result) => Schema.Struct({ success: Schema.Literal(true), result });
|
|
9
|
+
const account = (admin) => `${API_ROOT}/accounts/${encodeURIComponent(admin.accountId)}`;
|
|
10
|
+
export const r2Endpoint = (accountId) => `https://${accountId}.r2.cloudflarestorage.com`;
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// buckets
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
export const bucketExists = (admin, name) => requestJson('R2 bucket lookup', `${account(admin)}/r2/buckets/${encodeURIComponent(name)}`, envelope(Schema.Struct({ name: Schema.String })), { headers: headers(admin.apiToken), allowNotFound: true }).pipe(Effect.map(Option.isSome));
|
|
15
|
+
export const createBucket = (admin, name) => requestJsonRequired('R2 bucket creation', `${account(admin)}/r2/buckets`, envelope(Schema.Struct({ name: Schema.String })), { method: 'POST', headers: headers(admin.apiToken), body: { name } }).pipe(Effect.asVoid);
|
|
16
|
+
/** Browser uploads use presigned PUTs; CORS allows exactly that. */
|
|
17
|
+
const corsRules = (origin) => [
|
|
18
|
+
{
|
|
19
|
+
id: 'browser-uploads',
|
|
20
|
+
allowed: { methods: ['PUT'], origins: [origin], headers: ['content-type'] },
|
|
21
|
+
exposeHeaders: ['etag'],
|
|
22
|
+
maxAgeSeconds: 3600
|
|
23
|
+
}
|
|
24
|
+
];
|
|
25
|
+
const CorsRule = Schema.Struct({
|
|
26
|
+
allowed: Schema.Struct({
|
|
27
|
+
methods: Schema.Array(Schema.String),
|
|
28
|
+
origins: Schema.Array(Schema.String),
|
|
29
|
+
headers: Schema.optionalKey(Schema.Array(Schema.String))
|
|
30
|
+
}),
|
|
31
|
+
exposeHeaders: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
32
|
+
maxAgeSeconds: Schema.optionalKey(Schema.Number)
|
|
33
|
+
});
|
|
34
|
+
const normalize = (values) => [...new Set(values.map(value => value.toLowerCase()))].toSorted().join('\n');
|
|
35
|
+
const sameSet = (left = [], right = []) => normalize(left) === normalize(right);
|
|
36
|
+
/**
|
|
37
|
+
* Whether `rules` grant exactly the upload policy for `origin`. Compares values, not JSON text:
|
|
38
|
+
* the API may reorder fields, drop the rule id, or change header case.
|
|
39
|
+
*/
|
|
40
|
+
export const corsRulesMatch = (rules, origin) => {
|
|
41
|
+
const [actual, ...rest] = rules;
|
|
42
|
+
const [expected] = corsRules(origin);
|
|
43
|
+
return (actual !== undefined &&
|
|
44
|
+
expected !== undefined &&
|
|
45
|
+
rest.length === 0 &&
|
|
46
|
+
sameSet(actual.allowed.methods, expected.allowed.methods) &&
|
|
47
|
+
sameSet(actual.allowed.origins, expected.allowed.origins) &&
|
|
48
|
+
sameSet(actual.allowed.headers, expected.allowed.headers) &&
|
|
49
|
+
sameSet(actual.exposeHeaders, expected.exposeHeaders) &&
|
|
50
|
+
actual.maxAgeSeconds === expected.maxAgeSeconds);
|
|
51
|
+
};
|
|
52
|
+
/** Whether the bucket already has exactly the upload CORS policy for `origin`. */
|
|
53
|
+
export const bucketCorsMatches = (admin, name, origin) => requestJson('R2 CORS lookup', `${account(admin)}/r2/buckets/${encodeURIComponent(name)}/cors`, envelope(Schema.Struct({ rules: Schema.Array(CorsRule) })), { headers: headers(admin.apiToken), allowNotFound: true }).pipe(Effect.map(Option.match({
|
|
54
|
+
onNone: () => false,
|
|
55
|
+
onSome: response => corsRulesMatch(response.result.rules, origin)
|
|
56
|
+
})));
|
|
57
|
+
export const putBucketCors = (admin, name, origin) => requestJsonRequired('R2 CORS update', `${account(admin)}/r2/buckets/${encodeURIComponent(name)}/cors`, Schema.Struct({ success: Schema.Literal(true) }), {
|
|
58
|
+
method: 'PUT',
|
|
59
|
+
headers: headers(admin.apiToken),
|
|
60
|
+
body: { rules: corsRules(origin) }
|
|
61
|
+
}).pipe(Effect.asVoid);
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// bucket-scoped account tokens
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
const permissionGroupNames = [
|
|
66
|
+
'Workers R2 Storage Bucket Item Read',
|
|
67
|
+
'Workers R2 Storage Bucket Item Write'
|
|
68
|
+
];
|
|
69
|
+
const bucketResource = (accountId, bucket) => `com.cloudflare.edge.r2.bucket.${accountId}_default_${bucket}`;
|
|
70
|
+
const Token = Schema.Struct({ id: Schema.String, name: Schema.String });
|
|
71
|
+
export const findToken = (admin, name) => Effect.gen(function* () {
|
|
72
|
+
const tokens = [];
|
|
73
|
+
let page = 1;
|
|
74
|
+
let totalPages = 1;
|
|
75
|
+
do {
|
|
76
|
+
const response = yield* requestJsonRequired('Cloudflare token listing', `${account(admin)}/tokens?page=${page}&per_page=100`, Schema.Struct({
|
|
77
|
+
success: Schema.Literal(true),
|
|
78
|
+
result: Schema.Array(Token),
|
|
79
|
+
result_info: Schema.Struct({ total_pages: Schema.Number })
|
|
80
|
+
}), { headers: headers(admin.apiToken) });
|
|
81
|
+
tokens.push(...response.result);
|
|
82
|
+
totalPages = response.result_info.total_pages;
|
|
83
|
+
page += 1;
|
|
84
|
+
} while (page <= totalPages);
|
|
85
|
+
const matches = tokens.filter(token => token.name === name);
|
|
86
|
+
if (matches.length > 1) {
|
|
87
|
+
return yield* new ProviderError({ message: `several Cloudflare tokens are named ${name}` });
|
|
88
|
+
}
|
|
89
|
+
return Option.map(Option.fromUndefinedOr(matches[0]), token => token.id);
|
|
90
|
+
});
|
|
91
|
+
const permissionGroupIds = (admin) => requestJsonRequired('Cloudflare permission groups', `${account(admin)}/tokens/permission_groups`, envelope(Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String }))), { headers: headers(admin.apiToken) }).pipe(Effect.flatMap(response => Effect.forEach(permissionGroupNames, name => {
|
|
92
|
+
const group = response.result.find(candidate => candidate.name === name);
|
|
93
|
+
return group === undefined
|
|
94
|
+
? Effect.fail(new ProviderError({ message: `Cloudflare permission group ${name} is unavailable` }))
|
|
95
|
+
: Effect.succeed(group.id);
|
|
96
|
+
})));
|
|
97
|
+
/**
|
|
98
|
+
* Creates an object read/write token scoped to exactly one bucket. The S3 access key is the token
|
|
99
|
+
* id; the secret is the SHA-256 of the one-time token value (Cloudflare's R2 token contract).
|
|
100
|
+
*/
|
|
101
|
+
export const createBucketToken = (admin, name, bucket) => Effect.gen(function* () {
|
|
102
|
+
const ids = yield* permissionGroupIds(admin);
|
|
103
|
+
const created = yield* requestJsonRequired('Cloudflare token creation', `${account(admin)}/tokens`, envelope(Schema.Struct({ id: Schema.String, value: Schema.String })), {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: headers(admin.apiToken),
|
|
106
|
+
body: {
|
|
107
|
+
name,
|
|
108
|
+
policies: [
|
|
109
|
+
{
|
|
110
|
+
effect: 'allow',
|
|
111
|
+
permission_groups: ids.map(id => ({ id })),
|
|
112
|
+
resources: { [bucketResource(admin.accountId, bucket)]: '*' }
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
const value = Redacted.make(created.result.value);
|
|
118
|
+
// Verify the new token authenticates before anything depends on it.
|
|
119
|
+
yield* requestJsonRequired('Cloudflare token verification', `${account(admin)}/tokens/verify`, envelope(Schema.Struct({ status: Schema.Literal('active') })), { headers: headers(value) });
|
|
120
|
+
return {
|
|
121
|
+
id: created.result.id,
|
|
122
|
+
accessKeyId: created.result.id,
|
|
123
|
+
secretAccessKey: Redacted.make(createHash('sha256').update(Redacted.value(value)).digest('hex'))
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
export const deleteToken = (admin, tokenId) => requestJson('Cloudflare token deletion', `${account(admin)}/tokens/${encodeURIComponent(tokenId)}`, Schema.Unknown, { method: 'DELETE', headers: headers(admin.apiToken), allowNotFound: true }).pipe(Effect.asVoid);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Effect, Option, Redacted, Schedule, Schema } from 'effect';
|
|
2
|
+
import { ProviderError, requestJson, requestJsonRequired } from '../http.js';
|
|
3
|
+
const API_ROOT = 'https://console.neon.tech/api/v2';
|
|
4
|
+
export const neonRegion = 'aws-eu-central-1';
|
|
5
|
+
export const neonPgVersion = 18;
|
|
6
|
+
/** Production keeps a 7-day restore window. */
|
|
7
|
+
export const neonProductionHistoryRetentionSeconds = 7 * 24 * 3600;
|
|
8
|
+
const headers = (admin) => ({ Authorization: `Bearer ${Redacted.value(admin.apiKey)}` });
|
|
9
|
+
const Project = Schema.Struct({ id: Schema.String, name: Schema.String });
|
|
10
|
+
const Branch = Schema.Struct({
|
|
11
|
+
id: Schema.String,
|
|
12
|
+
name: Schema.String,
|
|
13
|
+
default: Schema.optional(Schema.Boolean),
|
|
14
|
+
protected: Schema.optional(Schema.Boolean)
|
|
15
|
+
});
|
|
16
|
+
const Database = Schema.Struct({ name: Schema.String, owner_name: Schema.String });
|
|
17
|
+
export const findProject = (admin, name) => Effect.gen(function* () {
|
|
18
|
+
const query = new URLSearchParams({ org_id: admin.orgId, search: name, limit: '100' });
|
|
19
|
+
const response = yield* requestJsonRequired('Neon project lookup', `${API_ROOT}/projects?${query.toString()}`, Schema.Struct({ projects: Schema.Array(Project) }), { headers: headers(admin) });
|
|
20
|
+
const matches = response.projects.filter(project => project.name === name);
|
|
21
|
+
if (matches.length > 1) {
|
|
22
|
+
return yield* new ProviderError({ message: `several Neon projects are named ${name}` });
|
|
23
|
+
}
|
|
24
|
+
return Option.fromUndefinedOr(matches[0]);
|
|
25
|
+
});
|
|
26
|
+
export const createProject = (admin, options) => requestJsonRequired('Neon project creation', `${API_ROOT}/projects`, Schema.Struct({ project: Project, branch: Branch }), {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: headers(admin),
|
|
29
|
+
body: {
|
|
30
|
+
project: {
|
|
31
|
+
name: options.name,
|
|
32
|
+
org_id: admin.orgId,
|
|
33
|
+
region_id: neonRegion,
|
|
34
|
+
pg_version: neonPgVersion,
|
|
35
|
+
branch: { name: options.branchName },
|
|
36
|
+
...(options.historyRetentionSeconds === undefined
|
|
37
|
+
? {}
|
|
38
|
+
: { history_retention_seconds: options.historyRetentionSeconds })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
export const listBranches = (admin, projectId) => requestJsonRequired('Neon branch listing', `${API_ROOT}/projects/${encodeURIComponent(projectId)}/branches`, Schema.Struct({ branches: Schema.Array(Branch) }), { headers: headers(admin) }).pipe(Effect.map(response => response.branches));
|
|
43
|
+
/** Neon rejects changes while project operations run (423/409); retry briefly. */
|
|
44
|
+
const whileLocked = Schedule.max([Schedule.spaced('1 second'), Schedule.recurs(30)]);
|
|
45
|
+
export const protectBranch = (admin, projectId, branchId) => requestJsonRequired('Neon branch protection', `${API_ROOT}/projects/${encodeURIComponent(projectId)}/branches/${encodeURIComponent(branchId)}`, Schema.Struct({ branch: Branch }), { method: 'PATCH', headers: headers(admin), body: { branch: { protected: true } } }).pipe(Effect.retry({
|
|
46
|
+
schedule: whileLocked,
|
|
47
|
+
while: error => error.status === 423 || error.status === 409
|
|
48
|
+
}));
|
|
49
|
+
export const connectionUri = (admin, projectId, branchId, pooled) => Effect.gen(function* () {
|
|
50
|
+
const databases = yield* requestJsonRequired('Neon database listing', `${API_ROOT}/projects/${encodeURIComponent(projectId)}/branches/${encodeURIComponent(branchId)}/databases`, Schema.Struct({ databases: Schema.Array(Database) }), { headers: headers(admin) });
|
|
51
|
+
const database = databases.databases.length === 1 ? databases.databases[0] : undefined;
|
|
52
|
+
if (database === undefined) {
|
|
53
|
+
return yield* new ProviderError({
|
|
54
|
+
message: `expected one database on Neon branch ${branchId}, found ${databases.databases.length}`
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const query = new URLSearchParams({
|
|
58
|
+
branch_id: branchId,
|
|
59
|
+
database_name: database.name,
|
|
60
|
+
role_name: database.owner_name,
|
|
61
|
+
pooled: String(pooled)
|
|
62
|
+
});
|
|
63
|
+
const response = yield* requestJsonRequired('Neon connection URI', `${API_ROOT}/projects/${encodeURIComponent(projectId)}/connection_uri?${query.toString()}`, Schema.Struct({ uri: Schema.String }), { headers: headers(admin) });
|
|
64
|
+
return Redacted.make(response.uri);
|
|
65
|
+
}).pipe(Effect.retry({
|
|
66
|
+
schedule: whileLocked,
|
|
67
|
+
while: error => error.status === 423 || error.status === 409
|
|
68
|
+
}));
|
|
69
|
+
const ApiKey = Schema.Struct({
|
|
70
|
+
id: Schema.Number,
|
|
71
|
+
name: Schema.String,
|
|
72
|
+
project_id: Schema.optional(Schema.String)
|
|
73
|
+
});
|
|
74
|
+
export const findApiKey = (admin, name) => requestJsonRequired('Neon API key listing', `${API_ROOT}/organizations/${encodeURIComponent(admin.orgId)}/api_keys`, Schema.Array(ApiKey), { headers: headers(admin) }).pipe(Effect.flatMap(keys => {
|
|
75
|
+
const matches = keys.filter(key => key.name === name);
|
|
76
|
+
return matches.length > 1
|
|
77
|
+
? Effect.fail(new ProviderError({ message: `several Neon API keys are named ${name}` }))
|
|
78
|
+
: Effect.succeed(Option.map(Option.fromUndefinedOr(matches[0]), key => String(key.id)));
|
|
79
|
+
}));
|
|
80
|
+
export const createProjectApiKey = (admin, name, projectId) => requestJsonRequired('Neon API key creation', `${API_ROOT}/organizations/${encodeURIComponent(admin.orgId)}/api_keys`, Schema.Struct({ id: Schema.Number, key: Schema.String }), { method: 'POST', headers: headers(admin), body: { key_name: name, project_id: projectId } }).pipe(Effect.map(created => ({ id: String(created.id), key: Redacted.make(created.key) })));
|
|
81
|
+
export const revokeApiKey = (admin, keyId) => requestJson('Neon API key revocation', `${API_ROOT}/organizations/${encodeURIComponent(admin.orgId)}/api_keys/${encodeURIComponent(keyId)}`, Schema.Unknown, { method: 'DELETE', headers: headers(admin), allowNotFound: true }).pipe(Effect.asVoid);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Effect, Option, Redacted, Schema } from 'effect';
|
|
2
|
+
import { ProviderError, requestJson, requestJsonRequired } from '../http.js';
|
|
3
|
+
/** The receiver's factory API lives on the same origin: `/api/factory/report-tokens`. */
|
|
4
|
+
const tokensUrl = (receiver) => Effect.try({
|
|
5
|
+
try: () => new URL('/api/factory/report-tokens', receiver.url).href,
|
|
6
|
+
catch: () => new ProviderError({ message: 'report_receiver.url is not a valid URL' })
|
|
7
|
+
});
|
|
8
|
+
const headers = (receiver) => ({
|
|
9
|
+
Authorization: `Bearer ${Redacted.value(receiver.factoryKey)}`
|
|
10
|
+
});
|
|
11
|
+
const ReportToken = Schema.Struct({
|
|
12
|
+
id: Schema.String,
|
|
13
|
+
name: Schema.String,
|
|
14
|
+
revokedAt: Schema.optional(Schema.NullOr(Schema.String))
|
|
15
|
+
});
|
|
16
|
+
export const findReportToken = (receiver, name) => Effect.gen(function* () {
|
|
17
|
+
const url = yield* tokensUrl(receiver);
|
|
18
|
+
const tokens = yield* requestJsonRequired('report token listing', url, Schema.Struct({ tokens: Schema.Array(ReportToken) }), { headers: headers(receiver) });
|
|
19
|
+
const active = tokens.tokens.filter(token => token.name === name && (token.revokedAt === undefined || token.revokedAt === null));
|
|
20
|
+
if (active.length > 1) {
|
|
21
|
+
return yield* new ProviderError({ message: `several active report tokens are named ${name}` });
|
|
22
|
+
}
|
|
23
|
+
return Option.map(Option.fromUndefinedOr(active[0]), token => token.id);
|
|
24
|
+
});
|
|
25
|
+
export const createReportToken = (receiver, name) => Effect.gen(function* () {
|
|
26
|
+
const url = yield* tokensUrl(receiver);
|
|
27
|
+
const created = yield* requestJsonRequired('report token creation', url, Schema.Struct({ id: Schema.String, token: Schema.String }), { method: 'POST', headers: headers(receiver), body: { name } });
|
|
28
|
+
return { id: created.id, token: Redacted.make(created.token) };
|
|
29
|
+
});
|
|
30
|
+
export const revokeReportToken = (receiver, id) => Effect.gen(function* () {
|
|
31
|
+
const url = yield* tokensUrl(receiver);
|
|
32
|
+
yield* requestJson('report token revocation', `${url}/${encodeURIComponent(id)}`, Schema.Unknown, {
|
|
33
|
+
method: 'DELETE',
|
|
34
|
+
headers: headers(receiver),
|
|
35
|
+
allowNotFound: true
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Effect, Option, Redacted, Schema } from 'effect';
|
|
2
|
+
import { ProviderError, requestJson, requestJsonRequired } from '../http.js';
|
|
3
|
+
const API_ROOT = 'https://api.resend.com';
|
|
4
|
+
const headers = (apiKey) => ({
|
|
5
|
+
Authorization: `Bearer ${Redacted.value(apiKey)}`
|
|
6
|
+
});
|
|
7
|
+
const Domain = Schema.Struct({ id: Schema.String, name: Schema.String, status: Schema.String });
|
|
8
|
+
/** The sender domain must be registered and verified in this Resend account. */
|
|
9
|
+
export const requireVerifiedDomain = (apiKey, name) => Effect.gen(function* () {
|
|
10
|
+
const response = yield* requestJsonRequired('Resend domain listing', `${API_ROOT}/domains`, Schema.Struct({ data: Schema.Array(Domain) }), { headers: headers(apiKey) });
|
|
11
|
+
const domain = response.data.find(candidate => candidate.name === name);
|
|
12
|
+
if (domain === undefined || domain.status !== 'verified') {
|
|
13
|
+
return yield* new ProviderError({
|
|
14
|
+
message: `Resend domain ${name} must be verified in the factory's Resend account (${domain === undefined ? 'not registered' : `status ${domain.status}`})`
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
export const findApiKey = (apiKey, name) => requestJsonRequired('Resend API key listing', `${API_ROOT}/api-keys`, Schema.Struct({
|
|
19
|
+
data: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String }))
|
|
20
|
+
}), { headers: headers(apiKey) }).pipe(Effect.flatMap(response => {
|
|
21
|
+
const matches = response.data.filter(key => key.name === name);
|
|
22
|
+
return matches.length > 1
|
|
23
|
+
? Effect.fail(new ProviderError({ message: `several Resend API keys are named ${name}` }))
|
|
24
|
+
: Effect.succeed(Option.map(Option.fromUndefinedOr(matches[0]), key => key.id));
|
|
25
|
+
}));
|
|
26
|
+
/** A sending-only key (it can send from any verified domain, but read and manage nothing). */
|
|
27
|
+
export const createSendingKey = (apiKey, name) => requestJsonRequired('Resend API key creation', `${API_ROOT}/api-keys`, Schema.Struct({ id: Schema.String, token: Schema.String }), {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: headers(apiKey),
|
|
30
|
+
body: { name, permission: 'sending_access' }
|
|
31
|
+
}).pipe(Effect.map(created => ({ id: created.id, token: Redacted.make(created.token) })));
|
|
32
|
+
export const deleteApiKey = (apiKey, keyId) => requestJson('Resend API key deletion', `${API_ROOT}/api-keys/${encodeURIComponent(keyId)}`, Schema.Unknown, { method: 'DELETE', headers: headers(apiKey), allowNotFound: true }).pipe(Effect.asVoid);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Effect, Option, Redacted, Schema } from 'effect';
|
|
2
|
+
import { ProviderError, requestJsonRequired } from '../http.js';
|
|
3
|
+
const API_ROOT = 'https://api.upstash.com/v2';
|
|
4
|
+
const basic = (email, apiKey) => ({
|
|
5
|
+
Authorization: `Basic ${Buffer.from(`${email}:${Redacted.value(apiKey)}`).toString('base64')}`
|
|
6
|
+
});
|
|
7
|
+
const Team = Schema.Struct({ team_id: Schema.String, team_name: Schema.String });
|
|
8
|
+
export const findTeam = (email, apiKey, name) => requestJsonRequired('Upstash team listing', `${API_ROOT}/teams`, Schema.Array(Team), {
|
|
9
|
+
headers: basic(email, apiKey)
|
|
10
|
+
}).pipe(Effect.flatMap(teams => {
|
|
11
|
+
const matches = teams.filter(team => team.team_name === name);
|
|
12
|
+
return matches.length > 1
|
|
13
|
+
? Effect.fail(new ProviderError({ message: `several Upstash teams are named ${name}` }))
|
|
14
|
+
: Effect.succeed(Option.map(Option.fromUndefinedOr(matches[0]), team => team.team_id));
|
|
15
|
+
}));
|
|
16
|
+
/** Creates a team without copying the payment method or billing details. */
|
|
17
|
+
export const createTeam = (email, apiKey, name) => requestJsonRequired('Upstash team creation', `${API_ROOT}/team`, Team, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: basic(email, apiKey),
|
|
20
|
+
body: { team_name: name, copy_cc: false }
|
|
21
|
+
}).pipe(Effect.map(team => team.team_id));
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { Context, Data, Effect, FileSystem, Layer, Option, Schema } from 'effect';
|
|
3
|
+
import { readPrivately, writePrivately } from '../shared/private-file.js';
|
|
4
|
+
/**
|
|
5
|
+
* The CLI-owned factory registry: where each factory's 1Password item lives. Non-secret; stored at
|
|
6
|
+
* `${XDG_CONFIG_HOME:-~/.config}/provision/config.json` with mode `0600`.
|
|
7
|
+
*/
|
|
8
|
+
export class RegistryLocation extends Context.Service()('provision/factory/RegistryLocation') {
|
|
9
|
+
}
|
|
10
|
+
export const RegistryLocationLive = Layer.sync(RegistryLocation, () => ({
|
|
11
|
+
file: join(process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? '', '.config'), 'provision', 'config.json')
|
|
12
|
+
}));
|
|
13
|
+
export class RegistryError extends Data.TaggedError('RegistryError') {
|
|
14
|
+
}
|
|
15
|
+
export const factoryNamePattern = /^[a-z][a-z0-9-]{0,31}$/;
|
|
16
|
+
const FactoryName = Schema.String.check(Schema.isPattern(factoryNamePattern));
|
|
17
|
+
export const FactoryEntry = Schema.Struct({
|
|
18
|
+
vault: Schema.NonEmptyString,
|
|
19
|
+
item: Schema.NonEmptyString,
|
|
20
|
+
account: Schema.optionalKey(Schema.NonEmptyString),
|
|
21
|
+
/** Copied from the item by `config add`, so selecting a factory reads only that factory's item. */
|
|
22
|
+
githubOwner: Schema.optionalKey(Schema.NonEmptyString)
|
|
23
|
+
});
|
|
24
|
+
const RegistryFile = Schema.Struct({
|
|
25
|
+
factories: Schema.Record(FactoryName, FactoryEntry)
|
|
26
|
+
});
|
|
27
|
+
const RegistryJson = Schema.fromJsonString(RegistryFile);
|
|
28
|
+
const emptyRegistry = { factories: {} };
|
|
29
|
+
export const readRegistry = Effect.gen(function* () {
|
|
30
|
+
const { file } = yield* RegistryLocation;
|
|
31
|
+
const content = yield* readPrivately(file).pipe(Effect.mapError(error => new RegistryError({ message: error.message })));
|
|
32
|
+
if (content === undefined)
|
|
33
|
+
return emptyRegistry;
|
|
34
|
+
return yield* Schema.decodeUnknownEffect(RegistryJson, { onExcessProperty: 'error' })(content).pipe(Effect.mapError(cause => new RegistryError({ message: `invalid factory registry ${file}: ${cause.message}` })));
|
|
35
|
+
});
|
|
36
|
+
export const writeRegistry = (registry) => Effect.gen(function* () {
|
|
37
|
+
const fs = yield* FileSystem.FileSystem;
|
|
38
|
+
const { file } = yield* RegistryLocation;
|
|
39
|
+
const directory = file.slice(0, file.lastIndexOf('/'));
|
|
40
|
+
yield* fs
|
|
41
|
+
.makeDirectory(directory, { recursive: true })
|
|
42
|
+
.pipe(Effect.mapError(() => new RegistryError({ message: `cannot create ${directory}` })));
|
|
43
|
+
yield* writePrivately(file, `${JSON.stringify(registry, null, 2)}\n`).pipe(Effect.mapError(error => new RegistryError({ message: error.message })));
|
|
44
|
+
});
|
|
45
|
+
export const validateFactoryName = (name) => factoryNamePattern.test(name)
|
|
46
|
+
? Effect.succeed(name)
|
|
47
|
+
: Effect.fail(new RegistryError({
|
|
48
|
+
message: 'factory names are 1-32 lowercase letters, digits, or hyphens, starting with a letter'
|
|
49
|
+
}));
|
|
50
|
+
export const findFactory = (registry, name) => Option.fromUndefinedOr(registry.factories[name]);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Console, Effect, Option } from 'effect';
|
|
2
|
+
import { decide, factoryError, mutate } from './context.js';
|
|
3
|
+
/**
|
|
4
|
+
* Ensures one scoped key per environment group:
|
|
5
|
+
* - none exists: create it and write Vercel;
|
|
6
|
+
* - it exists and Vercel is complete: reuse, or rotate on overwrite;
|
|
7
|
+
* - it exists but Vercel is incomplete: the value cannot be recovered, so only rotation fixes it.
|
|
8
|
+
*
|
|
9
|
+
* Rotation creates the new key and writes Vercel before deleting the old key.
|
|
10
|
+
*/
|
|
11
|
+
export const ensureScopedKey = (context, key) => Effect.gen(function* () {
|
|
12
|
+
if (Option.isNone(key.existing)) {
|
|
13
|
+
yield* mutate(context, `create ${key.description}`, key.createAndWrite);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const description = key.vercelComplete
|
|
17
|
+
? key.description
|
|
18
|
+
: `${key.description} (its value is missing from Vercel and cannot be recovered)`;
|
|
19
|
+
const decision = yield* decide(context, 'key', description);
|
|
20
|
+
if (decision === 'reuse') {
|
|
21
|
+
if (!key.vercelComplete && !context.dryRun) {
|
|
22
|
+
return yield* factoryError(`${key.description} cannot be reused because Vercel lacks its value; rerun with --on-existing=overwrite to rotate it`);
|
|
23
|
+
}
|
|
24
|
+
yield* Console.log(` reusing ${key.description}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const previous = key.existing.value;
|
|
28
|
+
yield* mutate(context, `rotate ${key.description}`, key.createAndWrite);
|
|
29
|
+
yield* mutate(context, `delete the previous ${key.description}`, key.remove(previous));
|
|
30
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'node:crypto';
|
|
2
|
+
import { Data, Effect, Redacted, Schema } from 'effect';
|
|
3
|
+
/**
|
|
4
|
+
* Passphrase sealing for stored tokens: scrypt-derived key, AES-256-GCM, with the credential
|
|
5
|
+
* name as associated data so a sealed token cannot be swapped between factories.
|
|
6
|
+
*/
|
|
7
|
+
export class SealError extends Data.TaggedError('SealError') {
|
|
8
|
+
}
|
|
9
|
+
/** ~128 MiB and a few hundred milliseconds per derivation. */
|
|
10
|
+
export const defaultKdf = { N: 2 ** 17, r: 8, p: 1 };
|
|
11
|
+
const Envelope = Schema.Struct({
|
|
12
|
+
v: Schema.Literal(1),
|
|
13
|
+
kdf: Schema.Literal('scrypt'),
|
|
14
|
+
N: Schema.Number,
|
|
15
|
+
r: Schema.Number,
|
|
16
|
+
p: Schema.Number,
|
|
17
|
+
salt: Schema.String,
|
|
18
|
+
iv: Schema.String,
|
|
19
|
+
tag: Schema.String,
|
|
20
|
+
data: Schema.String
|
|
21
|
+
});
|
|
22
|
+
const EnvelopeJson = Schema.fromJsonString(Envelope);
|
|
23
|
+
const deriveKey = (passphrase, salt, kdf) => Effect.callback(resume => {
|
|
24
|
+
const options = { ...kdf, maxmem: 256 * kdf.N * kdf.r + 1024 * 1024 };
|
|
25
|
+
scrypt(Redacted.value(passphrase), salt, 32, options, (error, key) => {
|
|
26
|
+
resume(error === null
|
|
27
|
+
? Effect.succeed(key)
|
|
28
|
+
: Effect.fail(new SealError({ reason: 'crypto', message: 'key derivation failed' })));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
export const seal = (passphrase, plaintext, associatedData, kdf = defaultKdf) => Effect.gen(function* () {
|
|
32
|
+
const salt = randomBytes(16);
|
|
33
|
+
const iv = randomBytes(12);
|
|
34
|
+
const key = yield* deriveKey(passphrase, salt, kdf);
|
|
35
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
36
|
+
cipher.setAAD(Buffer.from(associatedData, 'utf8'));
|
|
37
|
+
const data = Buffer.concat([cipher.update(Redacted.value(plaintext), 'utf8'), cipher.final()]);
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
v: 1,
|
|
40
|
+
kdf: 'scrypt',
|
|
41
|
+
...kdf,
|
|
42
|
+
salt: salt.toString('base64'),
|
|
43
|
+
iv: iv.toString('base64'),
|
|
44
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
45
|
+
data: data.toString('base64')
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
export const unseal = (passphrase, sealed, associatedData) => Effect.gen(function* () {
|
|
49
|
+
const envelope = yield* Schema.decodeUnknownEffect(EnvelopeJson)(sealed.trim()).pipe(Effect.mapError(() => new SealError({ reason: 'malformed', message: 'stored token is not sealed' })));
|
|
50
|
+
const key = yield* deriveKey(passphrase, Buffer.from(envelope.salt, 'base64'), envelope);
|
|
51
|
+
return yield* Effect.try({
|
|
52
|
+
try: () => {
|
|
53
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(envelope.iv, 'base64'));
|
|
54
|
+
decipher.setAAD(Buffer.from(associatedData, 'utf8'));
|
|
55
|
+
decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
|
|
56
|
+
const plaintext = Buffer.concat([
|
|
57
|
+
decipher.update(Buffer.from(envelope.data, 'base64')),
|
|
58
|
+
decipher.final()
|
|
59
|
+
]);
|
|
60
|
+
return Redacted.make(plaintext.toString('utf8'));
|
|
61
|
+
},
|
|
62
|
+
catch: () => new SealError({ reason: 'wrong-passphrase', message: 'wrong passphrase' })
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option, Redacted } from 'effect';
|
|
2
|
+
import { readHiddenLines } from './passphrase.js';
|
|
3
|
+
/** One `KEY=value` line of a `.env` block (optionally `export`ed, value optionally quoted). */
|
|
4
|
+
export const parseEnvLine = (line) => {
|
|
5
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
|
6
|
+
if (match === null)
|
|
7
|
+
return undefined;
|
|
8
|
+
const [, key = '', raw = ''] = match;
|
|
9
|
+
const quoted = /^(["'])(.*)\1$/.exec(raw);
|
|
10
|
+
return { key, value: quoted?.[2] ?? raw };
|
|
11
|
+
};
|
|
12
|
+
/** The values for `keys` found in `lines`; later lines win. */
|
|
13
|
+
export const parseEnvBlock = (lines, keys) => {
|
|
14
|
+
const values = new Map();
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
const parsed = parseEnvLine(line);
|
|
17
|
+
if (parsed !== undefined && keys.includes(parsed.key) && parsed.value !== '') {
|
|
18
|
+
values.set(parsed.key, Redacted.make(parsed.value));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return values;
|
|
22
|
+
};
|
|
23
|
+
export class SecretInput extends Context.Service()('provision/factory/SecretInput') {
|
|
24
|
+
}
|
|
25
|
+
const interactive = () => process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
26
|
+
export const SecretInputLive = Layer.succeed(SecretInput, SecretInput.of({
|
|
27
|
+
readEnv: (message, keys) => interactive()
|
|
28
|
+
? readHiddenLines(message, {
|
|
29
|
+
complete: lines => parseEnvBlock(lines, keys).size === keys.length,
|
|
30
|
+
note: line => {
|
|
31
|
+
const parsed = parseEnvLine(line);
|
|
32
|
+
return parsed !== undefined && keys.includes(parsed.key)
|
|
33
|
+
? ` ✓ ${parsed.key}`
|
|
34
|
+
: line.trim() === ''
|
|
35
|
+
? undefined
|
|
36
|
+
: ' · ignored a line that is not one of the expected variables';
|
|
37
|
+
}
|
|
38
|
+
}).pipe(Effect.map(lines => {
|
|
39
|
+
const values = parseEnvBlock(lines.map(line => Redacted.value(line)), keys);
|
|
40
|
+
return values.size === 0 ? Option.none() : Option.some(values);
|
|
41
|
+
}))
|
|
42
|
+
: Effect.succeed(Option.none())
|
|
43
|
+
}));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Console, Effect, Option } from 'effect';
|
|
2
|
+
import { decide, factoryError, mutate, requireProject } from '../context.js';
|
|
3
|
+
import { addProjectDomain, getProjectDomain } from '../vercel-api.js';
|
|
4
|
+
/** Attaches `--domain` to the Vercel project and reports whether DNS verification is pending. */
|
|
5
|
+
export const domainStep = (context) => Effect.gen(function* () {
|
|
6
|
+
const domain = context.domain;
|
|
7
|
+
if (domain === undefined)
|
|
8
|
+
return yield* factoryError('the domain step needs --domain');
|
|
9
|
+
const handle = yield* requireProject(context);
|
|
10
|
+
if (Option.isNone(handle)) {
|
|
11
|
+
yield* Console.log(` would attach ${domain} after the vercel step creates the project`);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const teamId = context.credentials.vercel.teamId;
|
|
15
|
+
const projectId = handle.value.project.id;
|
|
16
|
+
const existing = yield* getProjectDomain(teamId, projectId, domain);
|
|
17
|
+
const attached = Option.isSome(existing)
|
|
18
|
+
? yield* decide(context, 'data', `Vercel domain ${domain}`).pipe(Effect.as(existing))
|
|
19
|
+
: yield* mutate(context, `attach ${domain} to ${context.names.project}`, addProjectDomain(teamId, projectId, domain));
|
|
20
|
+
if (Option.isSome(attached) && attached.value.verified === false) {
|
|
21
|
+
yield* Console.log(` manual: ${domain} needs DNS verification; see the project's Domains page in Vercel`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Console, Effect, Option, Redacted } from 'effect';
|
|
2
|
+
import { defaultDatabaseUrlKeys, sandboxProfileKeys } from '../../shared/sandbox-profile.js';
|
|
3
|
+
import { decide, envGroups, factoryError, groupHasKeys, mutate, requireProject, writeGroup } from '../context.js';
|
|
4
|
+
import { connectionUri, createProject, createProjectApiKey, findApiKey, findProject, listBranches, neonProductionHistoryRetentionSeconds, protectBranch, revokeApiKey } from '../providers/neon.js';
|
|
5
|
+
import { ensureScopedKey } from '../scoped-key.js';
|
|
6
|
+
import { listEnvRecords } from '../vercel-api.js';
|
|
7
|
+
/** Finds or creates a Neon project; returns its id and protected default branch (`None` in a dry run). */
|
|
8
|
+
const ensureProject = (context, admin, name, branchName, historyRetentionSeconds) => Effect.gen(function* () {
|
|
9
|
+
const existing = yield* findProject(admin, name);
|
|
10
|
+
if (Option.isSome(existing)) {
|
|
11
|
+
yield* decide(context, 'data', `Neon project ${name}`);
|
|
12
|
+
const branches = yield* listBranches(admin, existing.value.id);
|
|
13
|
+
const branch = branches.find(candidate => candidate.default === true);
|
|
14
|
+
if (branch === undefined) {
|
|
15
|
+
return yield* factoryError(`Neon project ${name} has no default branch`);
|
|
16
|
+
}
|
|
17
|
+
if (branch.protected !== true) {
|
|
18
|
+
yield* mutate(context, `protect Neon branch ${name}/${branch.name}`, protectBranch(admin, existing.value.id, branch.id));
|
|
19
|
+
}
|
|
20
|
+
return Option.some({ projectId: existing.value.id, branch });
|
|
21
|
+
}
|
|
22
|
+
const created = yield* mutate(context, `create Neon project ${name} with protected branch ${branchName}`, createProject(admin, {
|
|
23
|
+
name,
|
|
24
|
+
branchName,
|
|
25
|
+
...(historyRetentionSeconds === undefined ? {} : { historyRetentionSeconds })
|
|
26
|
+
}).pipe(Effect.tap(response => protectBranch(admin, response.project.id, response.branch.id))));
|
|
27
|
+
return Option.map(created, response => ({
|
|
28
|
+
projectId: response.project.id,
|
|
29
|
+
branch: response.branch
|
|
30
|
+
}));
|
|
31
|
+
});
|
|
32
|
+
/**
|
|
33
|
+
* `<repo>-dev`: the sandbox project whose parent branch disposable databases clone, plus a
|
|
34
|
+
* project-scoped key, written to Vercel Development as the `SANDBOX_DB_*` profile.
|
|
35
|
+
* `<repo>-prod`: the production project, written to Vercel Production as `DATABASE_URL(_UNPOOLED)`.
|
|
36
|
+
*/
|
|
37
|
+
export const neonStep = (context) => Effect.gen(function* () {
|
|
38
|
+
const admin = context.credentials.neon;
|
|
39
|
+
if (admin === undefined) {
|
|
40
|
+
return yield* factoryError('the factory item has no neon section');
|
|
41
|
+
}
|
|
42
|
+
const handle = yield* requireProject(context);
|
|
43
|
+
if (Option.isNone(handle)) {
|
|
44
|
+
yield* Console.log(' would run after the vercel step creates the project');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const teamId = context.credentials.vercel.teamId;
|
|
48
|
+
const groups = envGroups(handle.value);
|
|
49
|
+
const records = yield* listEnvRecords(teamId, handle.value.project.id);
|
|
50
|
+
const mode = context.repoConfig.factory.neon.sandboxParent ?? 'empty-baseline';
|
|
51
|
+
// Development sandbox ----------------------------------------------------
|
|
52
|
+
const sandbox = mode === 'empty-baseline'
|
|
53
|
+
? Option.map(yield* ensureProject(context, admin, context.names.development, 'baseline'), project => ({ projectId: project.projectId, parentBranch: project.branch.id }))
|
|
54
|
+
: yield* findProject(admin, context.names.development).pipe(Effect.flatMap(Option.match({
|
|
55
|
+
onNone: () => Effect.fail(factoryError(`existing-branch mode needs the Neon project ${context.names.development} to exist`)),
|
|
56
|
+
onSome: project => decide(context, 'data', `Neon project ${project.name}`).pipe(Effect.as(Option.some({ projectId: project.id, parentBranch: context.neonParentBranch })))
|
|
57
|
+
})));
|
|
58
|
+
if (Option.isSome(sandbox)) {
|
|
59
|
+
const { projectId, parentBranch } = sandbox.value;
|
|
60
|
+
const keyName = `${context.names.development}-sandbox-branch-manager`;
|
|
61
|
+
yield* ensureScopedKey(context, {
|
|
62
|
+
description: `Neon key ${keyName}`,
|
|
63
|
+
existing: yield* findApiKey(admin, keyName),
|
|
64
|
+
vercelComplete: groupHasKeys(records, groups.development, sandboxProfileKeys),
|
|
65
|
+
createAndWrite: Effect.gen(function* () {
|
|
66
|
+
if (parentBranch === undefined) {
|
|
67
|
+
return yield* factoryError('existing-branch mode needs --neon-parent-branch to (re)write the sandbox profile');
|
|
68
|
+
}
|
|
69
|
+
const created = yield* createProjectApiKey(admin, keyName, projectId);
|
|
70
|
+
const [apiKey, project, parent] = sandboxProfileKeys;
|
|
71
|
+
const written = yield* writeGroup(context, handle.value, groups.development, [
|
|
72
|
+
{ key: apiKey, value: created.key, type: 'encrypted' },
|
|
73
|
+
{ key: project, value: Redacted.make(projectId), type: 'encrypted' },
|
|
74
|
+
{ key: parent, value: Redacted.make(parentBranch), type: 'encrypted' }
|
|
75
|
+
]).pipe(Effect.tapError(() => revokeApiKey(admin, created.id).pipe(Effect.ignore)));
|
|
76
|
+
return written;
|
|
77
|
+
}).pipe(Effect.asVoid),
|
|
78
|
+
remove: id => revokeApiKey(admin, id)
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Production ---------------------------------------------------------------
|
|
82
|
+
const production = yield* ensureProject(context, admin, context.names.production, 'production', neonProductionHistoryRetentionSeconds);
|
|
83
|
+
if (Option.isNone(production))
|
|
84
|
+
return;
|
|
85
|
+
const { projectId, branch } = production.value;
|
|
86
|
+
const writeUrls = Effect.gen(function* () {
|
|
87
|
+
const [pooledKey, directKey] = defaultDatabaseUrlKeys;
|
|
88
|
+
const pooled = yield* connectionUri(admin, projectId, branch.id, true);
|
|
89
|
+
const direct = yield* connectionUri(admin, projectId, branch.id, false);
|
|
90
|
+
yield* writeGroup(context, handle.value, groups.production, [
|
|
91
|
+
{ key: pooledKey, value: pooled, type: 'sensitive' },
|
|
92
|
+
{ key: directKey, value: direct, type: 'sensitive' }
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
if (groupHasKeys(records, groups.production, defaultDatabaseUrlKeys)) {
|
|
96
|
+
const decision = yield* decide(context, 'key', 'production DATABASE_URL records');
|
|
97
|
+
if (decision === 'reuse')
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
yield* writeUrls;
|
|
101
|
+
});
|