create-svc 0.1.17 → 0.1.19
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 +20 -5
- package/package.json +2 -2
- package/src/post-scaffold.test.ts +37 -3
- package/src/post-scaffold.ts +87 -13
- package/src/scaffold.test.ts +12 -0
- package/src/scaffold.ts +36 -0
- package/templates/shared/README.md +8 -0
- package/templates/shared/scripts/authctl.ts +266 -2
- package/templates/shared/scripts/cloudrun/cli.ts +7 -1
- package/templates/shared/scripts/cloudrun/config.ts +3 -0
- package/templates/shared/scripts/cloudrun/lib.ts +165 -1
- package/templates/shared/service.config.ts +4 -0
- package/templates/targets/workers/Makefile +1 -1
- package/templates/targets/workers/README.md +3 -0
- package/templates/targets/workers/package.json +1 -1
- package/templates/targets/workers/scripts/workers/cli.ts +7 -1
- package/templates/variants/bun-connectrpc/Makefile +1 -1
- package/templates/variants/bun-connectrpc/package.json +1 -1
- package/templates/variants/bun-hono/Makefile +1 -1
- package/templates/variants/bun-hono/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ service deploy
|
|
|
41
41
|
To install from npm:
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
|
|
44
|
+
bun install -g create-svc
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
For the strict one-command production path:
|
|
@@ -70,8 +70,7 @@ Without publishing to npm:
|
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
bun install
|
|
73
|
-
|
|
74
|
-
npm install -g ./create-svc-*.tgz
|
|
73
|
+
bun link
|
|
75
74
|
service create my-service
|
|
76
75
|
```
|
|
77
76
|
|
|
@@ -130,6 +129,21 @@ service destroy
|
|
|
130
129
|
|
|
131
130
|
Language-specific tasks such as local running, linting, formatting, testing, and building stay in package scripts or Make targets. Service lifecycle operations are exposed through the generated `service` CLI.
|
|
132
131
|
|
|
132
|
+
After `service create` has provisioned auth, the generated repo can mint a
|
|
133
|
+
client-credentials bearer token for smoke checks:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
TOKEN="$(service auth token)"
|
|
137
|
+
curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://api.<service_id>.anmho.com/v1/admin/waitlist?limit=1"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
For Go ConnectRPC services, use the same token with `grpcurl`:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
TOKEN="$(service auth token)"
|
|
144
|
+
grpcurl -H "Authorization: Bearer $TOKEN" -d '{"limit":1}' -proto protos/waitlist/v1/waitlist.proto api.<service_id>.anmho.com:443 waitlist.v1.WaitlistService/ListWaitlistEntries
|
|
145
|
+
```
|
|
146
|
+
|
|
133
147
|
The generated service is intended to be consumed by a web app, mobile client, or another service over HTTPS. In v1, production is expected to live at `https://api.<service_id>.anmho.com`, while preview and personal environments keep using deterministic platform URLs where appropriate.
|
|
134
148
|
|
|
135
149
|
The generated microservice domain is a small waitlist/launch service example with public submit/status APIs and target-specific scheduled work.
|
|
@@ -142,7 +156,8 @@ bun test src scripts
|
|
|
142
156
|
bun run index.ts create my-service
|
|
143
157
|
```
|
|
144
158
|
|
|
145
|
-
Validate the generated
|
|
159
|
+
Validate the generated service matrix against local Docker Compose Postgres and
|
|
160
|
+
Workers package checks:
|
|
146
161
|
|
|
147
162
|
```bash
|
|
148
163
|
bun run validate:generated
|
|
@@ -150,7 +165,7 @@ bun run validate:generated -- --variant bun-hono
|
|
|
150
165
|
bun run validate:generated -- --variant go-connectrpc --keep
|
|
151
166
|
```
|
|
152
167
|
|
|
153
|
-
The validation harness scaffolds generated services into ignored `bin/generated/run-*` workspaces, runs the generated public commands, starts the local server,
|
|
168
|
+
The validation harness scaffolds generated services into ignored `bin/generated/run-*` workspaces, runs the generated public commands, starts the local server for Cloud Run presets, smoke-tests health plus ConnectRPC clients where applicable, and verifies the Workers preset package compiles and tests.
|
|
154
169
|
|
|
155
170
|
## npm Trusted Publishing
|
|
156
171
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-svc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
|
|
5
5
|
"module": "index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"dev": "bun run index.ts",
|
|
22
22
|
"test": "bun test src scripts",
|
|
23
23
|
"validate:generated": "bun run ./scripts/validate-generated.ts",
|
|
24
|
-
"typecheck": "
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
25
|
},
|
|
26
26
|
"repository": {
|
|
27
27
|
"type": "git",
|
|
@@ -16,6 +16,13 @@ describe("buildPostScaffoldCommands", () => {
|
|
|
16
16
|
{ command: "bun", args: ["./scripts/cloudrun/cli.ts", "deploy"] },
|
|
17
17
|
]);
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
test("uses the workers service CLI for workers services", () => {
|
|
21
|
+
expect(buildPostScaffoldCommands({ target: "workers", framework: "hono" })).toEqual([
|
|
22
|
+
{ command: "bun", args: ["./scripts/workers/cli.ts", "create"] },
|
|
23
|
+
{ command: "bun", args: ["./scripts/workers/cli.ts", "deploy"] },
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
19
26
|
});
|
|
20
27
|
|
|
21
28
|
describe("buildDeploymentVerificationCommands", () => {
|
|
@@ -23,13 +30,40 @@ describe("buildDeploymentVerificationCommands", () => {
|
|
|
23
30
|
expect(buildDeploymentVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "hono", runtime: "bun" })).toEqual([
|
|
24
31
|
{ command: "curl", args: ["--fail", "--show-error", "--silent", "https://api.launch.anmho.com/healthz"] },
|
|
25
32
|
{ command: "curl", args: ["--fail", "--show-error", "--silent", "https://api.launch.anmho.com/readyz"] },
|
|
33
|
+
{
|
|
34
|
+
command: "sh",
|
|
35
|
+
args: [
|
|
36
|
+
"-c",
|
|
37
|
+
'TOKEN="$(bun ./scripts/cloudrun/cli.ts auth token)" && curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://api.launch.anmho.com/v1/admin/waitlist?limit=1"',
|
|
38
|
+
],
|
|
39
|
+
},
|
|
26
40
|
]);
|
|
27
41
|
});
|
|
28
42
|
|
|
29
|
-
test("uses grpcurl for Go ConnectRPC services", () => {
|
|
43
|
+
test("uses auth token and grpcurl for Go ConnectRPC services", () => {
|
|
30
44
|
expect(buildDeploymentVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "connectrpc", runtime: "go" })).toContainEqual({
|
|
31
|
-
command: "
|
|
32
|
-
args: [
|
|
45
|
+
command: "sh",
|
|
46
|
+
args: [
|
|
47
|
+
"-c",
|
|
48
|
+
'TOKEN="$(bun ./scripts/cloudrun/cli.ts auth token)" && grpcurl -H "Authorization: Bearer $TOKEN" -d \'{"limit":1}\' -proto protos/waitlist/v1/waitlist.proto api.launch.anmho.com:443 waitlist.v1.WaitlistService/ListWaitlistEntries',
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("uses the workers service CLI for protected workers verification", () => {
|
|
54
|
+
expect(
|
|
55
|
+
buildDeploymentVerificationCommands({
|
|
56
|
+
target: "workers",
|
|
57
|
+
apiHostname: "api.launch.anmho.com",
|
|
58
|
+
framework: "hono",
|
|
59
|
+
runtime: "bun",
|
|
60
|
+
})
|
|
61
|
+
).toContainEqual({
|
|
62
|
+
command: "sh",
|
|
63
|
+
args: [
|
|
64
|
+
"-c",
|
|
65
|
+
'TOKEN="$(bun ./scripts/workers/cli.ts auth token)" && curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://api.launch.anmho.com/v1/admin/waitlist?limit=1"',
|
|
66
|
+
],
|
|
33
67
|
});
|
|
34
68
|
});
|
|
35
69
|
});
|
package/src/post-scaffold.ts
CHANGED
|
@@ -20,6 +20,8 @@ type PostScaffoldCommand = {
|
|
|
20
20
|
|
|
21
21
|
const decoder = new TextDecoder();
|
|
22
22
|
const encoder = new TextEncoder();
|
|
23
|
+
const DEPLOYMENT_VERIFY_ATTEMPTS = 36;
|
|
24
|
+
const DEPLOYMENT_VERIFY_DELAY_MS = 10_000;
|
|
23
25
|
|
|
24
26
|
export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
|
|
25
27
|
if (config.autoDeploy) {
|
|
@@ -28,7 +30,7 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
|
|
|
28
30
|
run(command.command, command.args, { cwd });
|
|
29
31
|
}
|
|
30
32
|
for (const command of buildDeploymentVerificationCommands(config)) {
|
|
31
|
-
|
|
33
|
+
runWithRetries(command, { cwd, quiet: true }, DEPLOYMENT_VERIFY_ATTEMPTS, DEPLOYMENT_VERIFY_DELAY_MS);
|
|
32
34
|
}
|
|
33
35
|
return { message: "Dependencies installed, service created, service deployed, and production health verified" };
|
|
34
36
|
}
|
|
@@ -36,31 +38,103 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
|
|
|
36
38
|
return { message: "Backend package generated" };
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
function runWithRetries(command: PostScaffoldCommand, options: CommandOptions, attempts: number, delayMs: number) {
|
|
42
|
+
let lastError: unknown;
|
|
43
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
44
|
+
try {
|
|
45
|
+
return run(command.command, command.args, options);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
lastError = error;
|
|
48
|
+
if (attempt === attempts) {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
Bun.sleepSync(delayMs);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw lastError;
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
export function buildDeploymentVerificationCommands(
|
|
40
|
-
config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime">
|
|
58
|
+
config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime"> & Partial<Pick<ScaffoldConfig, "target">>
|
|
41
59
|
): PostScaffoldCommand[] {
|
|
42
60
|
const origin = `https://${config.apiHostname}`;
|
|
61
|
+
const tokenCommand = `TOKEN="$(bun ${serviceCliPath(config)} auth token)"`;
|
|
43
62
|
return [
|
|
44
63
|
{ command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/healthz`] },
|
|
45
64
|
{ command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/readyz`] },
|
|
46
|
-
|
|
47
|
-
? [
|
|
48
|
-
config.runtime === "go"
|
|
49
|
-
? { command: "grpcurl", args: [`${config.apiHostname}:443`, "list"] }
|
|
50
|
-
: { command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/debug/connectrpc`] },
|
|
51
|
-
]
|
|
52
|
-
: []),
|
|
65
|
+
protectedVerificationCommand(config, origin, tokenCommand),
|
|
53
66
|
];
|
|
54
67
|
}
|
|
55
68
|
|
|
56
|
-
|
|
69
|
+
function protectedVerificationCommand(
|
|
70
|
+
config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime">,
|
|
71
|
+
origin: string,
|
|
72
|
+
tokenCommand: string
|
|
73
|
+
): PostScaffoldCommand {
|
|
74
|
+
if (config.framework === "connectrpc" && config.runtime === "go") {
|
|
75
|
+
return {
|
|
76
|
+
command: "sh",
|
|
77
|
+
args: [
|
|
78
|
+
"-c",
|
|
79
|
+
[
|
|
80
|
+
`${tokenCommand} &&`,
|
|
81
|
+
"grpcurl",
|
|
82
|
+
'-H "Authorization: Bearer $TOKEN"',
|
|
83
|
+
"-d '{\"limit\":1}'",
|
|
84
|
+
"-proto protos/waitlist/v1/waitlist.proto",
|
|
85
|
+
`${config.apiHostname}:443`,
|
|
86
|
+
"waitlist.v1.WaitlistService/ListWaitlistEntries",
|
|
87
|
+
].join(" "),
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (config.framework === "connectrpc") {
|
|
93
|
+
return {
|
|
94
|
+
command: "sh",
|
|
95
|
+
args: [
|
|
96
|
+
"-c",
|
|
97
|
+
[
|
|
98
|
+
`${tokenCommand} &&`,
|
|
99
|
+
"curl --fail --show-error --silent",
|
|
100
|
+
'-H "Authorization: Bearer $TOKEN"',
|
|
101
|
+
'-H "Content-Type: application/json"',
|
|
102
|
+
"-d '{\"limit\":1}'",
|
|
103
|
+
`"${origin}/waitlist.v1.WaitlistService/ListWaitlistEntries"`,
|
|
104
|
+
].join(" "),
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
command: "sh",
|
|
111
|
+
args: [
|
|
112
|
+
"-c",
|
|
113
|
+
[
|
|
114
|
+
`${tokenCommand} &&`,
|
|
115
|
+
"curl --fail --show-error --silent",
|
|
116
|
+
'-H "Authorization: Bearer $TOKEN"',
|
|
117
|
+
`"${origin}/v1/admin/waitlist?limit=1"`,
|
|
118
|
+
].join(" "),
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildPostScaffoldCommands(
|
|
124
|
+
config: Pick<ScaffoldConfig, "framework"> & Partial<Pick<ScaffoldConfig, "target">>
|
|
125
|
+
): PostScaffoldCommand[] {
|
|
126
|
+
const serviceCli = serviceCliPath(config);
|
|
57
127
|
return [
|
|
58
|
-
...(config.framework === "connectrpc" ? [{ command: "bun", args: [
|
|
59
|
-
{ command: "bun", args: [
|
|
60
|
-
{ command: "bun", args: [
|
|
128
|
+
...(config.target !== "workers" && config.framework === "connectrpc" ? [{ command: "bun", args: [serviceCli, "sdk", "build"] }] : []),
|
|
129
|
+
{ command: "bun", args: [serviceCli, "create"] },
|
|
130
|
+
{ command: "bun", args: [serviceCli, "deploy"] },
|
|
61
131
|
];
|
|
62
132
|
}
|
|
63
133
|
|
|
134
|
+
function serviceCliPath(config: Partial<Pick<ScaffoldConfig, "target">>) {
|
|
135
|
+
return config.target === "workers" ? "./scripts/workers/cli.ts" : "./scripts/cloudrun/cli.ts";
|
|
136
|
+
}
|
|
137
|
+
|
|
64
138
|
function installProjectDependencies(cwd: string) {
|
|
65
139
|
requireCommand("bun");
|
|
66
140
|
run("bun", ["install"], { cwd });
|
package/src/scaffold.test.ts
CHANGED
|
@@ -59,6 +59,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
59
59
|
expect(serviceConfig).toContain('service_id: "dns-api"');
|
|
60
60
|
expect(serviceConfig).toContain('target: "cloudrun"');
|
|
61
61
|
expect(serviceConfig).toContain('module: "buf.build/anmho/dns-api"');
|
|
62
|
+
expect(serviceConfig).toContain('cloudflare_vault_path: "prod/providers/cloudflare"');
|
|
62
63
|
expect(serviceConfig).toContain('issuer: "https://auth.anmho.com/api/auth"');
|
|
63
64
|
expect(serviceConfig).toContain('audience: "api://dns-api"');
|
|
64
65
|
expect(serviceConfig).toContain('vault_path_prefix: "prod/apps/dns-api/server/oauth-clients"');
|
|
@@ -79,6 +80,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
79
80
|
expect(configScript).toContain('baseBranchName: "main"');
|
|
80
81
|
expect(configScript).toContain('previewBranchPrefix: "dns-api-pr"');
|
|
81
82
|
expect(configScript).toContain('hostname: "api.dns-api.anmho.com"');
|
|
83
|
+
expect(configScript).toContain('cloudflareVaultPath: "prod/providers/cloudflare"');
|
|
82
84
|
expect(configScript).not.toContain("github:");
|
|
83
85
|
expect(configScript).not.toContain("attachmentBucket");
|
|
84
86
|
|
|
@@ -87,6 +89,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
87
89
|
expect(deployScript).toContain('projectMode === "use_existing"');
|
|
88
90
|
expect(deployScript).toContain("serviceDomain");
|
|
89
91
|
expect(deployScript).toContain("ensureProductionDomainMapping");
|
|
92
|
+
expect(deployScript).toContain("ensureCloudflareDnsRecord");
|
|
93
|
+
expect(deployScript).toContain("CLOUDFLARE_API_TOKEN");
|
|
90
94
|
expect(deployScript).toContain('"domain-mappings",');
|
|
91
95
|
expect(deployScript).toContain('"--region",');
|
|
92
96
|
expect(deployScript).toContain("assertProductionDomainAvailable");
|
|
@@ -152,6 +156,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
152
156
|
expect(localEnv).toContain(`DATABASE_URL=postgres://postgres:postgres@127.0.0.1:${localPort}/dns_api?sslmode=disable`);
|
|
153
157
|
expect(localEnv).toContain("VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access");
|
|
154
158
|
expect(localEnv).toContain("VAULT_NEON_API_KEY_PATH=prod/providers/neon");
|
|
159
|
+
expect(localEnv).toContain("VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare");
|
|
155
160
|
expect(localEnv).not.toContain("ATTACHMENT_PUBLIC_BASE_URL=");
|
|
156
161
|
|
|
157
162
|
const ciWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "ci.yml")).text();
|
|
@@ -218,6 +223,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
218
223
|
expect(serviceCli).toContain("Provision auth, database, migrations, and first deploy");
|
|
219
224
|
expect(serviceCli).toContain("assertServiceNameAvailable(config.serviceName)");
|
|
220
225
|
expect(serviceCli).toContain("ensureAuthResourceServer");
|
|
226
|
+
expect(serviceCli).toContain("ensureAuthClient");
|
|
227
|
+
expect(serviceCli).toContain("auth token");
|
|
221
228
|
expect(serviceCli).toContain('["resources", "push", "--path", "./grafana"]');
|
|
222
229
|
const cloudrunLib = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "lib.ts")).text();
|
|
223
230
|
expect(cloudrunLib).toContain("resolveTemporalRuntimeConfig");
|
|
@@ -229,6 +236,9 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
229
236
|
expect(authctlScript).toContain("resource-servers");
|
|
230
237
|
expect(authctlScript).toContain("clients");
|
|
231
238
|
expect(authctlScript).toContain("defaultClientTargetArgs");
|
|
239
|
+
expect(authctlScript).toContain("ensureAuthClient");
|
|
240
|
+
expect(authctlScript).toContain("mintAuthToken");
|
|
241
|
+
expect(authctlScript).toContain("clientVaultPath");
|
|
232
242
|
expect(authctlScript).toContain("deleteAuthResourceServer");
|
|
233
243
|
expect(authctlScript).toContain("readAuthctlAccessVaultField");
|
|
234
244
|
expect(authctlScript).toContain("prod/apps/auth/authctl/cloudflare-access");
|
|
@@ -363,6 +373,8 @@ test("scaffolds the workers target with wrangler lifecycle commands", async () =
|
|
|
363
373
|
expect(workerCli).toContain("hyperdrive");
|
|
364
374
|
expect(workerCli).toContain('["resources", "push", "--path", "./grafana"]');
|
|
365
375
|
expect(workerCli).toContain("ensureAuthResourceServer");
|
|
376
|
+
expect(workerCli).toContain("ensureAuthClient");
|
|
377
|
+
expect(workerCli).toContain("auth token");
|
|
366
378
|
expect(workerCli).toContain("Workers database schema applied");
|
|
367
379
|
expect(workerCli).toContain("create table if not exists waitlist_entries");
|
|
368
380
|
expect(workerCli).toContain("DATABASE_URL or NEON_API_KEY is required to provision the Hyperdrive binding");
|
package/src/scaffold.ts
CHANGED
|
@@ -218,6 +218,7 @@ function buildReplacements(config: ScaffoldConfig) {
|
|
|
218
218
|
COMMAND_DEPLOY: "service deploy",
|
|
219
219
|
COMMAND_AUTH_RESOURCE: "service auth resource-server",
|
|
220
220
|
COMMAND_AUTH_CLIENT: "service auth client create",
|
|
221
|
+
COMMAND_AUTH_TOKEN: "service auth token",
|
|
221
222
|
COMMAND_DEPLOY_PERSONAL: "service deploy --environment personal --name <name>",
|
|
222
223
|
COMMAND_DEPLOY_DESTROY: "service destroy --environment personal --name <name>",
|
|
223
224
|
COMMAND_CLEANUP: "service destroy",
|
|
@@ -236,9 +237,42 @@ function buildReplacements(config: ScaffoldConfig) {
|
|
|
236
237
|
"- override with `ENABLE_RPC_INTROSPECTION=true|false`",
|
|
237
238
|
].join("\n")
|
|
238
239
|
: "",
|
|
240
|
+
PRODUCTION_PROTECTED_CHECKS: buildProtectedChecks(config),
|
|
239
241
|
};
|
|
240
242
|
}
|
|
241
243
|
|
|
244
|
+
function buildProtectedChecks(config: ScaffoldConfig) {
|
|
245
|
+
const tokenCommand = 'TOKEN="$(service auth token)"';
|
|
246
|
+
if (config.framework === "connectrpc" && config.runtime === "go") {
|
|
247
|
+
return [
|
|
248
|
+
"After deploy, verify protected reads with:",
|
|
249
|
+
"",
|
|
250
|
+
"```bash",
|
|
251
|
+
tokenCommand,
|
|
252
|
+
`grpcurl -H "Authorization: Bearer $TOKEN" -d '{"limit":1}' -proto protos/waitlist/v1/waitlist.proto ${config.apiHostname}:443 waitlist.v1.WaitlistService/ListWaitlistEntries`,
|
|
253
|
+
"```",
|
|
254
|
+
].join("\n");
|
|
255
|
+
}
|
|
256
|
+
if (config.framework === "connectrpc") {
|
|
257
|
+
return [
|
|
258
|
+
"After deploy, verify protected reads with:",
|
|
259
|
+
"",
|
|
260
|
+
"```bash",
|
|
261
|
+
tokenCommand,
|
|
262
|
+
`curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"limit":1}' "https://${config.apiHostname}/waitlist.v1.WaitlistService/ListWaitlistEntries"`,
|
|
263
|
+
"```",
|
|
264
|
+
].join("\n");
|
|
265
|
+
}
|
|
266
|
+
return [
|
|
267
|
+
"After deploy, verify protected reads with:",
|
|
268
|
+
"",
|
|
269
|
+
"```bash",
|
|
270
|
+
tokenCommand,
|
|
271
|
+
`curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://${config.apiHostname}/v1/admin/waitlist?limit=1"`,
|
|
272
|
+
"```",
|
|
273
|
+
].join("\n");
|
|
274
|
+
}
|
|
275
|
+
|
|
242
276
|
async function writeLocalEnvFile(targetDir: string, replacements: Record<string, string>) {
|
|
243
277
|
const envPath = join(targetDir, ".env.local");
|
|
244
278
|
if (await Bun.file(envPath).exists()) {
|
|
@@ -259,6 +293,8 @@ async function writeLocalEnvFile(targetDir: string, replacements: Record<string,
|
|
|
259
293
|
"VAULT_AUTHCTL_ACCESS_CLIENT_SECRET_FIELD=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET",
|
|
260
294
|
"VAULT_NEON_API_KEY_PATH=prod/providers/neon",
|
|
261
295
|
"VAULT_NEON_API_KEY_FIELD=api_key",
|
|
296
|
+
"VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare",
|
|
297
|
+
"VAULT_CLOUDFLARE_API_TOKEN_FIELD=api_token",
|
|
262
298
|
"",
|
|
263
299
|
].join("\n"),
|
|
264
300
|
replacements
|
|
@@ -179,6 +179,7 @@ Use `service auth` for follow-up auth operations:
|
|
|
179
179
|
{{COMMAND_BOOTSTRAP}} # includes resource-server registration
|
|
180
180
|
{{COMMAND_AUTH_RESOURCE}}
|
|
181
181
|
{{COMMAND_AUTH_CLIENT}} --resource-server <target-service> --scope <scope>
|
|
182
|
+
{{COMMAND_AUTH_TOKEN}}
|
|
182
183
|
```
|
|
183
184
|
|
|
184
185
|
`authctl clients create` prints a one-time client secret plus the recommended
|
|
@@ -194,6 +195,10 @@ target resource server as `resource=api://<resource_server_id>`. The generated
|
|
|
194
195
|
runtime expects a JWT with that audience; omitting `resource` can return an
|
|
195
196
|
opaque access token that the service will reject.
|
|
196
197
|
|
|
198
|
+
`{{COMMAND_AUTH_TOKEN}}` reads the generated service-client credentials from
|
|
199
|
+
Vault, creates and stores them if missing, and prints a bearer token suitable
|
|
200
|
+
for local smoke checks.
|
|
201
|
+
|
|
197
202
|
Webhook signature hooks are provider-specific and optional in v1. Add provider
|
|
198
203
|
secrets only when you add a provider adapter. A generic adapter can honor:
|
|
199
204
|
|
|
@@ -231,6 +236,8 @@ The main environment is intended to be public at:
|
|
|
231
236
|
https://{{API_HOSTNAME}}
|
|
232
237
|
```
|
|
233
238
|
|
|
239
|
+
{{PRODUCTION_PROTECTED_CHECKS}}
|
|
240
|
+
|
|
234
241
|
Preview and personal environments keep using deterministic Cloud Run hostnames in v1 so the generated backend stays easy to use inside standalone repos or monorepos.
|
|
235
242
|
|
|
236
243
|
## Generated backend domain
|
|
@@ -253,6 +260,7 @@ Hono variants expose:
|
|
|
253
260
|
- `POST /v1/waitlist`
|
|
254
261
|
- `GET /v1/waitlist?email=...`
|
|
255
262
|
- `GET /v1/waitlist/{entryId}`
|
|
263
|
+
- `GET /v1/admin/waitlist`
|
|
256
264
|
- `POST /v1/triggers/waitlist`
|
|
257
265
|
- `POST /webhooks/:provider`
|
|
258
266
|
|
|
@@ -27,6 +27,17 @@ type ResourceServerMutationCommand = ResourceServerCommand & {
|
|
|
27
27
|
mutationAction: "upsert" | "create";
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
type ClientCredentials = {
|
|
31
|
+
client_id: string;
|
|
32
|
+
client_secret: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type TokenResponse = {
|
|
36
|
+
access_token?: string;
|
|
37
|
+
token_type?: string;
|
|
38
|
+
expires_in?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
30
41
|
export function defaultAuthResourceServerArgs() {
|
|
31
42
|
const auth = serviceConfig.auth;
|
|
32
43
|
return [
|
|
@@ -92,7 +103,11 @@ export function runAuthCommand(args: string[]) {
|
|
|
92
103
|
return runClientCommand(action, rest);
|
|
93
104
|
}
|
|
94
105
|
|
|
95
|
-
|
|
106
|
+
if (subject === "token") {
|
|
107
|
+
return mintAuthToken(parseTokenOptions([action, ...rest].filter(Boolean) as string[]));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new Error("Usage: service auth <doctor|resource-server|client|token> [args]");
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
export function ensureAuthResourceServer() {
|
|
@@ -101,6 +116,34 @@ export function ensureAuthResourceServer() {
|
|
|
101
116
|
return `Auth resource server ready: ${serviceConfig.auth.resource_server.audience}`;
|
|
102
117
|
}
|
|
103
118
|
|
|
119
|
+
export function ensureAuthClient() {
|
|
120
|
+
const existing = readStoredClientCredentials();
|
|
121
|
+
if (existing) {
|
|
122
|
+
return `Auth client ready: ${existing.client_id}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const result = authctl(
|
|
126
|
+
[
|
|
127
|
+
"clients",
|
|
128
|
+
"create",
|
|
129
|
+
"--client-app",
|
|
130
|
+
serviceConfig.auth.client.app_id,
|
|
131
|
+
"--client-identity",
|
|
132
|
+
serviceConfig.auth.client.identity,
|
|
133
|
+
...defaultClientTargetArgs([]),
|
|
134
|
+
"--stage",
|
|
135
|
+
serviceConfig.stage_default,
|
|
136
|
+
"--yes",
|
|
137
|
+
"--json",
|
|
138
|
+
],
|
|
139
|
+
{ quiet: true }
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const created = parseClientSecretResponse(result.stdout);
|
|
143
|
+
storeClientCredentials(created);
|
|
144
|
+
return `Auth client ready: ${created.client_id}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
104
147
|
export function deleteAuthResourceServer() {
|
|
105
148
|
const command = resolveResourceServerCommand();
|
|
106
149
|
if (!command?.actions.includes("delete")) {
|
|
@@ -164,8 +207,12 @@ export function runAuthDoctor(): AuthDoctorResult {
|
|
|
164
207
|
}
|
|
165
208
|
|
|
166
209
|
function runClientCommand(action = "", rest: string[]) {
|
|
210
|
+
if (!action || action === "ensure") {
|
|
211
|
+
return ensureAuthClient();
|
|
212
|
+
}
|
|
213
|
+
|
|
167
214
|
if (action === "create") {
|
|
168
|
-
authctl([
|
|
215
|
+
const result = authctl([
|
|
169
216
|
"clients",
|
|
170
217
|
"create",
|
|
171
218
|
"--client-app",
|
|
@@ -179,6 +226,8 @@ function runClientCommand(action = "", rest: string[]) {
|
|
|
179
226
|
"--json",
|
|
180
227
|
...rest,
|
|
181
228
|
]);
|
|
229
|
+
const created = parseClientSecretResponse(result.stdout);
|
|
230
|
+
storeClientCredentials(created);
|
|
182
231
|
return "Auth client created";
|
|
183
232
|
}
|
|
184
233
|
|
|
@@ -199,6 +248,112 @@ function defaultClientTargetArgs(rest: string[]) {
|
|
|
199
248
|
];
|
|
200
249
|
}
|
|
201
250
|
|
|
251
|
+
function parseTokenOptions(args: string[]) {
|
|
252
|
+
const options: { json: boolean; scope?: string[]; resource?: string; audience?: string } = { json: false };
|
|
253
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
254
|
+
const token = args[index];
|
|
255
|
+
if (!token) continue;
|
|
256
|
+
const next = args[index + 1];
|
|
257
|
+
const readValue = () => {
|
|
258
|
+
if (!next || next.startsWith("-")) {
|
|
259
|
+
throw new Error(`Missing value for ${token}`);
|
|
260
|
+
}
|
|
261
|
+
index += 1;
|
|
262
|
+
return next;
|
|
263
|
+
};
|
|
264
|
+
if (token === "--json") {
|
|
265
|
+
options.json = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (token === "--scope") {
|
|
269
|
+
options.scope = [...(options.scope ?? []), readValue()];
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (token.startsWith("--scope=")) {
|
|
273
|
+
options.scope = [...(options.scope ?? []), token.slice("--scope=".length)];
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (token === "--resource") {
|
|
277
|
+
options.resource = readValue();
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (token.startsWith("--resource=")) {
|
|
281
|
+
options.resource = token.slice("--resource=".length);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (token === "--audience") {
|
|
285
|
+
options.audience = readValue();
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (token.startsWith("--audience=")) {
|
|
289
|
+
options.audience = token.slice("--audience=".length);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
throw new Error(`Unknown service auth token argument: ${token}`);
|
|
293
|
+
}
|
|
294
|
+
return options;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function mintAuthToken(options: { json: boolean; scope?: string[]; resource?: string; audience?: string }) {
|
|
298
|
+
const credentials = readStoredClientCredentials() ?? createAndStoreClientCredentials();
|
|
299
|
+
const scopes = options.scope?.length ? options.scope : serviceConfig.auth.resource_server.default_scopes;
|
|
300
|
+
const resource = options.resource ?? serviceConfig.auth.resource_server.audience;
|
|
301
|
+
const token = requestClientCredentialsToken(credentials, {
|
|
302
|
+
scope: scopes.join(" "),
|
|
303
|
+
resource,
|
|
304
|
+
audience: options.audience,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
if (options.json) {
|
|
308
|
+
return JSON.stringify(token, null, 2);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!token.access_token) {
|
|
312
|
+
throw new Error("token response did not include access_token");
|
|
313
|
+
}
|
|
314
|
+
return token.access_token;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function createAndStoreClientCredentials() {
|
|
318
|
+
ensureAuthClient();
|
|
319
|
+
const stored = readStoredClientCredentials();
|
|
320
|
+
if (!stored) {
|
|
321
|
+
throw new Error(`Auth client credentials were not written to Vault path ${clientVaultPath()}`);
|
|
322
|
+
}
|
|
323
|
+
return stored;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function requestClientCredentialsToken(credentials: ClientCredentials, options: { scope: string; resource: string; audience?: string }) {
|
|
327
|
+
const body = new URLSearchParams({
|
|
328
|
+
grant_type: "client_credentials",
|
|
329
|
+
scope: options.scope,
|
|
330
|
+
resource: options.resource,
|
|
331
|
+
});
|
|
332
|
+
if (options.audience) {
|
|
333
|
+
body.set("audience", options.audience);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const response = fetchSync(serviceConfig.auth.token_endpoint, {
|
|
337
|
+
method: "POST",
|
|
338
|
+
headers: {
|
|
339
|
+
authorization: `Basic ${basicAuth(credentials.client_id, credentials.client_secret)}`,
|
|
340
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
341
|
+
accept: "application/json",
|
|
342
|
+
},
|
|
343
|
+
body: body.toString(),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
if (response.status < 200 || response.status >= 300) {
|
|
347
|
+
throw new Error(`token request failed: ${response.status} ${response.body}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const token = JSON.parse(response.body) as TokenResponse;
|
|
351
|
+
if (token.token_type?.toLowerCase() !== "bearer" || !token.access_token) {
|
|
352
|
+
throw new Error("token response did not include a bearer access_token");
|
|
353
|
+
}
|
|
354
|
+
return token;
|
|
355
|
+
}
|
|
356
|
+
|
|
202
357
|
function hasFlag(args: string[], name: string) {
|
|
203
358
|
return args.some((arg) => arg === name || arg.startsWith(`${name}=`));
|
|
204
359
|
}
|
|
@@ -335,3 +490,112 @@ function readAuthctlAccessVaultField(env: Record<string, string | undefined>, fi
|
|
|
335
490
|
|
|
336
491
|
return decoder.decode(result.stdout).trim();
|
|
337
492
|
}
|
|
493
|
+
|
|
494
|
+
function parseClientSecretResponse(stdout: string): ClientCredentials {
|
|
495
|
+
if (!stdout) {
|
|
496
|
+
throw new Error("authctl did not return client credentials");
|
|
497
|
+
}
|
|
498
|
+
const parsed = JSON.parse(stdout) as { client_id?: string; client_secret?: string };
|
|
499
|
+
if (!parsed.client_id || !parsed.client_secret) {
|
|
500
|
+
throw new Error("authctl client create did not return one-time client credentials");
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
client_id: parsed.client_id,
|
|
504
|
+
client_secret: parsed.client_secret,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function readStoredClientCredentials(): ClientCredentials | undefined {
|
|
509
|
+
const clientId = readVaultField(clientVaultPath(), "client_id");
|
|
510
|
+
const clientSecret = readVaultField(clientVaultPath(), "client_secret");
|
|
511
|
+
if (!clientId || !clientSecret) {
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
client_id: clientId,
|
|
516
|
+
client_secret: clientSecret,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function storeClientCredentials(credentials: ClientCredentials) {
|
|
521
|
+
const vault = requireVaultCommand();
|
|
522
|
+
const result = Bun.spawnSync(
|
|
523
|
+
[
|
|
524
|
+
vault,
|
|
525
|
+
"kv",
|
|
526
|
+
"put",
|
|
527
|
+
`-mount=${vaultMount()}`,
|
|
528
|
+
clientVaultPath(),
|
|
529
|
+
`client_id=${credentials.client_id}`,
|
|
530
|
+
`client_secret=${credentials.client_secret}`,
|
|
531
|
+
],
|
|
532
|
+
{
|
|
533
|
+
cwd: process.cwd(),
|
|
534
|
+
env: process.env,
|
|
535
|
+
stdout: "pipe",
|
|
536
|
+
stderr: "pipe",
|
|
537
|
+
}
|
|
538
|
+
);
|
|
539
|
+
if (!result.success) {
|
|
540
|
+
const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
|
|
541
|
+
throw new Error(`failed to store auth client credentials in Vault at ${clientVaultPath()}\n${stderr}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function readVaultField(path: string, field: string) {
|
|
546
|
+
const vault = Bun.which("vault");
|
|
547
|
+
if (!vault) {
|
|
548
|
+
return "";
|
|
549
|
+
}
|
|
550
|
+
const result = Bun.spawnSync([vault, "kv", "get", `-mount=${vaultMount()}`, `-field=${field}`, path], {
|
|
551
|
+
cwd: process.cwd(),
|
|
552
|
+
env: process.env,
|
|
553
|
+
stdout: "pipe",
|
|
554
|
+
stderr: "pipe",
|
|
555
|
+
});
|
|
556
|
+
if (!result.success || !result.stdout) {
|
|
557
|
+
return "";
|
|
558
|
+
}
|
|
559
|
+
return decoder.decode(result.stdout).trim();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function requireVaultCommand() {
|
|
563
|
+
const vault = Bun.which("vault");
|
|
564
|
+
if (!vault) {
|
|
565
|
+
throw new Error("vault is required to store generated auth client credentials");
|
|
566
|
+
}
|
|
567
|
+
return vault;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function vaultMount() {
|
|
571
|
+
return serviceConfig.providers.vault.mount || "secret";
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function clientVaultPath() {
|
|
575
|
+
return `${serviceConfig.auth.client.vault_path_prefix}/${serviceConfig.auth.resource_server.id}`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function basicAuth(clientId: string, clientSecret: string) {
|
|
579
|
+
return Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function fetchSync(url: string, init: { method: string; headers: Record<string, string>; body: string }) {
|
|
583
|
+
const script = [
|
|
584
|
+
"const url = process.argv[1];",
|
|
585
|
+
"const init = JSON.parse(process.argv[2]);",
|
|
586
|
+
"const response = await fetch(url, init);",
|
|
587
|
+
"const body = await response.text();",
|
|
588
|
+
"console.log(JSON.stringify({ status: response.status, body }));",
|
|
589
|
+
].join("\n");
|
|
590
|
+
const result = Bun.spawnSync([process.execPath, "--eval", script, url, JSON.stringify(init)], {
|
|
591
|
+
cwd: process.cwd(),
|
|
592
|
+
env: process.env,
|
|
593
|
+
stdout: "pipe",
|
|
594
|
+
stderr: "pipe",
|
|
595
|
+
});
|
|
596
|
+
if (!result.success) {
|
|
597
|
+
const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
|
|
598
|
+
throw new Error(`token request process failed\n${stderr}`);
|
|
599
|
+
}
|
|
600
|
+
return JSON.parse(decoder.decode(result.stdout).trim()) as { status: number; body: string };
|
|
601
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { mkdir } from "node:fs/promises";
|
|
4
|
-
import { ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
|
|
4
|
+
import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
|
|
5
5
|
import { bootstrap, prepareGcpProject } from "./bootstrap";
|
|
6
6
|
import { cleanup } from "./cleanup";
|
|
7
7
|
import { deploy } from "./deploy";
|
|
@@ -37,6 +37,7 @@ async function main(argv = Bun.argv.slice(2)) {
|
|
|
37
37
|
assertProductionDomainAvailable(config.serviceName);
|
|
38
38
|
await prepareGcpProject();
|
|
39
39
|
await runStep("Registering auth resource server", () => ensureAuthResourceServer());
|
|
40
|
+
await runStep("Provisioning auth client", () => ensureAuthClient());
|
|
40
41
|
await bootstrap({ skipProjectSetup: true });
|
|
41
42
|
const target = resolveDeploymentTarget("main");
|
|
42
43
|
const databaseUrl = await runStep("Reading production database URL", () => accessSecretVersion(target.databaseSecretName));
|
|
@@ -79,6 +80,10 @@ async function main(argv = Bun.argv.slice(2)) {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
if (command === "auth") {
|
|
83
|
+
if (rest[0] === "token") {
|
|
84
|
+
console.log(runAuthCommand(rest));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
82
87
|
await runMain("Auth", () => runAuthCommand(rest));
|
|
83
88
|
return;
|
|
84
89
|
}
|
|
@@ -108,6 +113,7 @@ function formatHelp() {
|
|
|
108
113
|
" seed Run the seed script when configured",
|
|
109
114
|
" doctor Check local tools and cloud access",
|
|
110
115
|
" auth Manage auth resource server and clients",
|
|
116
|
+
" auth token Mint a bearer token for protected API checks",
|
|
111
117
|
" sdk Build or publish generated SDK artifacts",
|
|
112
118
|
" dns Repair or inspect DNS mappings",
|
|
113
119
|
" dashboards Publish Grafana resources",
|
|
@@ -22,6 +22,9 @@ export const config = {
|
|
|
22
22
|
domain: {
|
|
23
23
|
hostname: "{{API_HOSTNAME}}",
|
|
24
24
|
baseDomain: "{{API_BASE_DOMAIN}}",
|
|
25
|
+
cloudflareApiBaseUrl: "https://api.cloudflare.com/client/v4",
|
|
26
|
+
cloudflareVaultPath: "prod/providers/cloudflare",
|
|
27
|
+
cloudflareVaultField: "api_token",
|
|
25
28
|
},
|
|
26
29
|
auth: {
|
|
27
30
|
issuer: "https://auth.anmho.com/api/auth",
|
|
@@ -42,6 +42,7 @@ type CommandResult = {
|
|
|
42
42
|
|
|
43
43
|
const decoder = new TextDecoder();
|
|
44
44
|
const encoder = new TextEncoder();
|
|
45
|
+
const CLOUDFLARE_DNS_TTL_AUTO = 1;
|
|
45
46
|
|
|
46
47
|
export class CommandError extends Error {
|
|
47
48
|
command: string;
|
|
@@ -485,12 +486,13 @@ export function ensureProductionDomainMapping(serviceName: string) {
|
|
|
485
486
|
if (existing) {
|
|
486
487
|
const mappedService = existing.spec?.routeName ?? existing.status?.resourceRecords?.[0]?.rrdata;
|
|
487
488
|
if (!mappedService || mappedService === serviceName) {
|
|
489
|
+
ensureCloudflareDnsRecord(existing);
|
|
488
490
|
return;
|
|
489
491
|
}
|
|
490
492
|
throw new Error(`${config.domain.hostname} is already mapped to ${mappedService}; refusing to take it over`);
|
|
491
493
|
}
|
|
492
494
|
|
|
493
|
-
gcloud([
|
|
495
|
+
const result = gcloud([
|
|
494
496
|
"beta",
|
|
495
497
|
"run",
|
|
496
498
|
"domain-mappings",
|
|
@@ -504,6 +506,8 @@ export function ensureProductionDomainMapping(serviceName: string) {
|
|
|
504
506
|
"--region",
|
|
505
507
|
config.region,
|
|
506
508
|
]);
|
|
509
|
+
const created = parseDomainMappingOutput(result.stdout) ?? describeProductionDomainMapping();
|
|
510
|
+
ensureCloudflareDnsRecord(created);
|
|
507
511
|
}
|
|
508
512
|
|
|
509
513
|
export function describeProductionDomainMapping():
|
|
@@ -561,6 +565,7 @@ export function assertServiceNameAvailable(serviceName: string) {
|
|
|
561
565
|
}
|
|
562
566
|
|
|
563
567
|
export function deleteProductionDomainMapping() {
|
|
568
|
+
deleteCloudflareDnsRecord();
|
|
564
569
|
gcloud(["beta", "run", "domain-mappings", "delete", "--domain", config.domain.hostname, "--project", config.project.id, "--quiet"], {
|
|
565
570
|
allowFailure: true,
|
|
566
571
|
});
|
|
@@ -573,6 +578,165 @@ export function listCloudRunServices() {
|
|
|
573
578
|
.filter(Boolean);
|
|
574
579
|
}
|
|
575
580
|
|
|
581
|
+
function parseDomainMappingOutput(stdout: string) {
|
|
582
|
+
if (!stdout.trim().startsWith("{")) {
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
return JSON.parse(stdout) as ReturnType<typeof describeProductionDomainMapping>;
|
|
587
|
+
} catch {
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function ensureCloudflareDnsRecord(
|
|
593
|
+
mapping:
|
|
594
|
+
| { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
|
|
595
|
+
| undefined
|
|
596
|
+
) {
|
|
597
|
+
const desired = desiredCloudflareRecord(mapping);
|
|
598
|
+
const zoneId = cloudflareZoneId();
|
|
599
|
+
const records = listCloudflareDnsRecords(zoneId, config.domain.hostname);
|
|
600
|
+
const conflicting = records.find((record) => record.type !== desired.type);
|
|
601
|
+
if (conflicting) {
|
|
602
|
+
throw new Error(
|
|
603
|
+
`Cloudflare DNS record ${config.domain.hostname} already exists as ${conflicting.type}; remove or update it before provisioning`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
const existing = records.find((record) => record.type === desired.type);
|
|
607
|
+
if (!existing) {
|
|
608
|
+
cloudflareFetch("POST", `/zones/${zoneId}/dns_records`, desired);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (existing.content === desired.content && existing.proxied === desired.proxied) {
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
cloudflareFetch("PUT", `/zones/${zoneId}/dns_records/${existing.id}`, desired);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function deleteCloudflareDnsRecord() {
|
|
618
|
+
const token = resolveCloudflareApiToken({ required: false });
|
|
619
|
+
if (!token) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const zoneId = cloudflareZoneId(token);
|
|
623
|
+
const records = listCloudflareDnsRecords(zoneId, config.domain.hostname, token);
|
|
624
|
+
for (const record of records) {
|
|
625
|
+
cloudflareFetch("DELETE", `/zones/${zoneId}/dns_records/${record.id}`, undefined, token);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function desiredCloudflareRecord(
|
|
630
|
+
mapping:
|
|
631
|
+
| { status?: { resourceRecords?: Array<{ name?: string; rrdata?: string; type?: string }> } }
|
|
632
|
+
| undefined
|
|
633
|
+
) {
|
|
634
|
+
const cname = mapping?.status?.resourceRecords?.find((record) => record.type === "CNAME" && record.rrdata);
|
|
635
|
+
const content = (cname?.rrdata ?? "ghs.googlehosted.com.").replace(/\.$/, "");
|
|
636
|
+
return {
|
|
637
|
+
type: "CNAME",
|
|
638
|
+
name: config.domain.hostname,
|
|
639
|
+
content,
|
|
640
|
+
ttl: CLOUDFLARE_DNS_TTL_AUTO,
|
|
641
|
+
proxied: false,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function cloudflareZoneId(token = resolveCloudflareApiToken({ required: true })) {
|
|
646
|
+
const response = cloudflareFetch("GET", `/zones?name=${encodeURIComponent(config.domain.baseDomain)}`, undefined, token);
|
|
647
|
+
const zone = response.result?.[0] as { id?: string } | undefined;
|
|
648
|
+
if (!zone?.id) {
|
|
649
|
+
throw new Error(`Cloudflare zone not found for ${config.domain.baseDomain}`);
|
|
650
|
+
}
|
|
651
|
+
return zone.id;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function listCloudflareDnsRecords(zoneId: string, name: string, token = resolveCloudflareApiToken({ required: true })) {
|
|
655
|
+
const response = cloudflareFetch(
|
|
656
|
+
"GET",
|
|
657
|
+
`/zones/${zoneId}/dns_records?name=${encodeURIComponent(name)}&per_page=100`,
|
|
658
|
+
undefined,
|
|
659
|
+
token
|
|
660
|
+
);
|
|
661
|
+
return (response.result ?? []) as Array<{ id: string; type: string; content: string; proxied: boolean }>;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function cloudflareFetch(method: string, path: string, body?: unknown, token = resolveCloudflareApiToken({ required: true })) {
|
|
665
|
+
const response = fetchJsonSync(`${config.domain.cloudflareApiBaseUrl}${path}`, {
|
|
666
|
+
method,
|
|
667
|
+
headers: {
|
|
668
|
+
authorization: `Bearer ${token}`,
|
|
669
|
+
"content-type": "application/json",
|
|
670
|
+
accept: "application/json",
|
|
671
|
+
},
|
|
672
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
673
|
+
});
|
|
674
|
+
if (response.status < 200 || response.status >= 300) {
|
|
675
|
+
throw new Error(`Cloudflare ${method} ${path} failed: ${response.status} ${response.body}`);
|
|
676
|
+
}
|
|
677
|
+
const parsed = response.body ? JSON.parse(response.body) : {};
|
|
678
|
+
if (parsed.success === false) {
|
|
679
|
+
throw new Error(`Cloudflare ${method} ${path} failed: ${response.body}`);
|
|
680
|
+
}
|
|
681
|
+
return parsed;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function resolveCloudflareApiToken(options: { required: boolean }) {
|
|
685
|
+
const direct = process.env.CLOUDFLARE_API_TOKEN?.trim();
|
|
686
|
+
if (direct) {
|
|
687
|
+
return direct;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const vault = Bun.which("vault");
|
|
691
|
+
if (vault) {
|
|
692
|
+
const path = process.env.VAULT_CLOUDFLARE_API_TOKEN_PATH?.trim() || config.domain.cloudflareVaultPath;
|
|
693
|
+
const field = process.env.VAULT_CLOUDFLARE_API_TOKEN_FIELD?.trim() || config.domain.cloudflareVaultField;
|
|
694
|
+
const result = Bun.spawnSync(
|
|
695
|
+
[vault, "kv", "get", `-mount=${process.env.VAULT_SECRET_MOUNT || "secret"}`, `-field=${field}`, path],
|
|
696
|
+
{
|
|
697
|
+
cwd: process.cwd(),
|
|
698
|
+
env: process.env,
|
|
699
|
+
stdout: "pipe",
|
|
700
|
+
stderr: "pipe",
|
|
701
|
+
}
|
|
702
|
+
);
|
|
703
|
+
if (result.success && result.stdout) {
|
|
704
|
+
return decoder.decode(result.stdout).trim();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (!options.required) {
|
|
709
|
+
return "";
|
|
710
|
+
}
|
|
711
|
+
throw new Error(
|
|
712
|
+
[
|
|
713
|
+
"CLOUDFLARE_API_TOKEN is required to create DNS records for the production Cloud Run domain.",
|
|
714
|
+
`Set CLOUDFLARE_API_TOKEN or store it at secret/${config.domain.cloudflareVaultPath} field ${config.domain.cloudflareVaultField}.`,
|
|
715
|
+
].join(" ")
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function fetchJsonSync(url: string, init: { method: string; headers: Record<string, string>; body?: string }) {
|
|
720
|
+
const script = [
|
|
721
|
+
"const url = process.argv[1];",
|
|
722
|
+
"const init = JSON.parse(process.argv[2]);",
|
|
723
|
+
"const response = await fetch(url, init);",
|
|
724
|
+
"const body = await response.text();",
|
|
725
|
+
"console.log(JSON.stringify({ status: response.status, body }));",
|
|
726
|
+
].join("\n");
|
|
727
|
+
const result = Bun.spawnSync([process.execPath, "--eval", script, url, JSON.stringify(init)], {
|
|
728
|
+
cwd: process.cwd(),
|
|
729
|
+
env: process.env,
|
|
730
|
+
stdout: "pipe",
|
|
731
|
+
stderr: "pipe",
|
|
732
|
+
});
|
|
733
|
+
if (!result.success) {
|
|
734
|
+
const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
|
|
735
|
+
throw new Error(`Cloudflare request process failed\n${stderr}`);
|
|
736
|
+
}
|
|
737
|
+
return JSON.parse(decoder.decode(result.stdout).trim()) as { status: number; body: string };
|
|
738
|
+
}
|
|
739
|
+
|
|
576
740
|
export function describeCloudRunService(serviceName: string): GcpResourceWithLabels | undefined {
|
|
577
741
|
const result = gcloud(
|
|
578
742
|
["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=json"],
|
|
@@ -7,6 +7,9 @@ export default {
|
|
|
7
7
|
dns: {
|
|
8
8
|
hostname: "{{API_HOSTNAME}}",
|
|
9
9
|
base_domain: "{{API_BASE_DOMAIN}}",
|
|
10
|
+
cloudflare_api_base_url: "https://api.cloudflare.com/client/v4",
|
|
11
|
+
cloudflare_vault_path: "prod/providers/cloudflare",
|
|
12
|
+
cloudflare_vault_field: "api_token",
|
|
10
13
|
},
|
|
11
14
|
ownership: {
|
|
12
15
|
managed_by: "create-service",
|
|
@@ -38,6 +41,7 @@ export default {
|
|
|
38
41
|
vault: {
|
|
39
42
|
mount: "secret",
|
|
40
43
|
neon_path: "prod/providers/neon",
|
|
44
|
+
cloudflare_path: "prod/providers/cloudflare",
|
|
41
45
|
grafana_path: "prod/providers/grafana",
|
|
42
46
|
clerk_m2m_path: "prod/providers/clerk-m2m",
|
|
43
47
|
temporal_path: "prod/providers/temporal",
|
|
@@ -43,6 +43,7 @@ small waitlist/trigger schema on first use.
|
|
|
43
43
|
- `POST /v1/waitlist`
|
|
44
44
|
- `GET /v1/waitlist?email=<email>`
|
|
45
45
|
- `GET /v1/waitlist/:entryId`
|
|
46
|
+
- `GET /v1/admin/waitlist`
|
|
46
47
|
- `POST /v1/triggers/waitlist`
|
|
47
48
|
- `POST /webhooks/:provider`
|
|
48
49
|
- `GET /webhooks/:provider/health`
|
|
@@ -63,6 +64,8 @@ https://{{API_HOSTNAME}}
|
|
|
63
64
|
Use `service doctor` after create to verify Wrangler auth, route config, Cron,
|
|
64
65
|
Hyperdrive, dashboard tooling, auth tooling, and deployed health.
|
|
65
66
|
|
|
67
|
+
{{PRODUCTION_PROTECTED_CHECKS}}
|
|
68
|
+
|
|
66
69
|
If the Hyperdrive binding id is empty, `service create` uses `DATABASE_URL`, or
|
|
67
70
|
`NEON_API_KEY` to create/resolve the generated Neon database and connection URI,
|
|
68
71
|
applies the waitlist schema, then runs `wrangler hyperdrive create` and writes
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"service": "bun run ./scripts/workers/cli.ts",
|
|
11
11
|
"migrate": "bun run ./scripts/workers/cli.ts migrate",
|
|
12
12
|
"seed": "bun run ./scripts/workers/cli.ts seed",
|
|
13
|
-
"lint": "
|
|
13
|
+
"lint": "tsc --noEmit",
|
|
14
14
|
"test": "bun test",
|
|
15
15
|
"create": "bun run ./scripts/workers/cli.ts create",
|
|
16
16
|
"deploy": "bun run ./scripts/workers/cli.ts deploy",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { confirm, intro, isCancel, log, outro } from "@clack/prompts";
|
|
4
4
|
import { createApiClient } from "@neondatabase/api-client";
|
|
5
5
|
import { Client } from "pg";
|
|
6
|
-
import { ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
|
|
6
|
+
import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
|
|
7
7
|
|
|
8
8
|
const config = {
|
|
9
9
|
serviceName: "{{SERVICE_NAME}}",
|
|
@@ -25,6 +25,7 @@ async function main(argv = Bun.argv.slice(2)) {
|
|
|
25
25
|
if (command === "create") {
|
|
26
26
|
return runMain("Create", async () => {
|
|
27
27
|
ensureAuthResourceServer();
|
|
28
|
+
ensureAuthClient();
|
|
28
29
|
const databaseUrl = await resolveDatabaseUrl();
|
|
29
30
|
await applySchema(databaseUrl);
|
|
30
31
|
await ensureHyperdrive(databaseUrl);
|
|
@@ -68,6 +69,10 @@ async function main(argv = Bun.argv.slice(2)) {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
if (command === "auth") {
|
|
72
|
+
if (rest[0] === "token") {
|
|
73
|
+
console.log(runAuthCommand(rest));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
71
76
|
return runMain("Auth", () => runAuthCommand(rest));
|
|
72
77
|
}
|
|
73
78
|
|
|
@@ -102,6 +107,7 @@ function formatHelp() {
|
|
|
102
107
|
" seed Report seed status",
|
|
103
108
|
" doctor Check local tools and cloud access",
|
|
104
109
|
" auth Manage auth resource server and clients",
|
|
110
|
+
" auth token Mint a bearer token for protected API checks",
|
|
105
111
|
" dns Show Workers custom-domain configuration",
|
|
106
112
|
" dashboards Publish Grafana resources",
|
|
107
113
|
" destroy Remove service-managed Worker resources",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"service": "bun run ./scripts/cloudrun/cli.ts",
|
|
11
11
|
"migrate": "bun run ./scripts/migrate.ts",
|
|
12
12
|
"gen": "bun run ./scripts/codegen.ts",
|
|
13
|
-
"lint": "
|
|
13
|
+
"lint": "tsc --noEmit",
|
|
14
14
|
"test": "bun test",
|
|
15
15
|
"create": "bun run ./scripts/cloudrun/cli.ts create",
|
|
16
16
|
"deploy": "bun run ./scripts/cloudrun/cli.ts deploy",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"service": "bun run ./scripts/cloudrun/cli.ts",
|
|
11
11
|
"migrate": "bun run ./scripts/migrate.ts",
|
|
12
12
|
"gen": "bun run ./scripts/codegen.ts",
|
|
13
|
-
"lint": "
|
|
13
|
+
"lint": "tsc --noEmit",
|
|
14
14
|
"test": "bun test",
|
|
15
15
|
"create": "bun run ./scripts/cloudrun/cli.ts create",
|
|
16
16
|
"deploy": "bun run ./scripts/cloudrun/cli.ts deploy",
|