create-svc 0.1.9 → 0.1.11
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 +138 -16
- package/bin/create-service.mjs +2 -0
- package/package.json +19 -11
- package/src/cli.test.ts +46 -7
- package/src/cli.ts +282 -84
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +5 -2
- package/src/naming.ts +32 -1
- package/src/neon.ts +10 -8
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +18 -26
- package/src/profiles.ts +25 -0
- package/src/scaffold.test.ts +320 -18
- package/src/scaffold.ts +154 -28
- package/src/vault.test.ts +94 -10
- package/src/vault.ts +81 -18
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +217 -29
- package/templates/shared/docker-compose.yml +19 -0
- package/templates/shared/grafana/alerts.yaml +54 -0
- package/templates/shared/grafana/waitlist-dashboard.json +63 -0
- package/templates/shared/scripts/authctl.ts +231 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +24 -42
- package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
- package/templates/shared/scripts/cloudrun/cli.ts +324 -7
- package/templates/shared/scripts/cloudrun/config.ts +21 -19
- package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
- package/templates/shared/scripts/cloudrun/lib.ts +232 -123
- package/templates/shared/scripts/cloudrun/neon.ts +127 -13
- package/templates/shared/scripts/dev.ts +22 -0
- package/templates/shared/scripts/ensure-local-db.ts +3 -0
- package/templates/shared/scripts/local-docker.ts +63 -0
- package/templates/shared/scripts/local-env.ts +27 -0
- package/templates/shared/scripts/seed.ts +73 -0
- package/templates/shared/scripts/wait-for-db.ts +32 -0
- package/templates/shared/service.config.ts +59 -0
- package/templates/shared/service.yaml +24 -1
- package/templates/targets/workers/.github/workflows/ci.yml +19 -0
- package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
- package/templates/targets/workers/Makefile +33 -0
- package/templates/targets/workers/README.md +75 -0
- package/templates/targets/workers/package.json +35 -0
- package/templates/targets/workers/scripts/workers/cli.ts +397 -0
- package/templates/targets/workers/src/auth.ts +178 -0
- package/templates/targets/workers/src/index.ts +198 -0
- package/templates/targets/workers/src/storage.ts +370 -0
- package/templates/targets/workers/test/app.test.ts +108 -0
- package/templates/targets/workers/tsconfig.json +11 -0
- package/templates/targets/workers/wrangler.toml +24 -0
- package/templates/variants/bun-connectrpc/Dockerfile +1 -0
- package/templates/variants/bun-connectrpc/Makefile +17 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-connectrpc/package.json +25 -1
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
- package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
- package/templates/variants/bun-connectrpc/src/index.ts +194 -22
- package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
- package/templates/variants/bun-hono/Makefile +17 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-hono/package.json +21 -1
- package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/client.ts +15 -0
- package/templates/variants/bun-hono/src/db/repository.ts +126 -0
- package/templates/variants/bun-hono/src/db/schema.ts +26 -0
- package/templates/variants/bun-hono/src/index.ts +141 -10
- package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
- package/templates/variants/bun-hono/test/app.test.ts +90 -5
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/bun-hono/tsconfig.json +1 -0
- package/templates/variants/go-chi/Makefile +30 -10
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +25 -13
- package/templates/variants/go-chi/go.mod +3 -2
- package/templates/variants/go-chi/internal/app/service.go +279 -70
- package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
- package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-chi/internal/config/config.go +38 -7
- package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
- package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
- package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
- package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
- package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-chi/test/go.test.ts +4 -1
- package/templates/variants/go-connectrpc/Makefile +29 -8
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
- package/templates/variants/go-connectrpc/go.mod +4 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
- package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
- package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
- package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
- package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
- package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
- package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
- package/templates/variants/go-connectrpc/package.json +7 -1
- package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
- package/templates/root/.github/workflows/buf-publish.yml +0 -19
- package/templates/root/.github/workflows/ci.yml +0 -26
- package/templates/root/.github/workflows/deploy.yml +0 -22
- package/templates/root/Dockerfile +0 -23
- package/templates/root/README.md +0 -69
- package/templates/root/buf.gen.yaml +0 -10
- package/templates/root/buf.yaml +0 -9
- package/templates/root/cmd/server/main.go +0 -44
- package/templates/root/gen/dns/v1/dns.pb.go +0 -623
- package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/root/go.mod +0 -10
- package/templates/root/internal/app/service.go +0 -152
- package/templates/root/internal/app/token_source.go +0 -50
- package/templates/root/internal/cloudflare/client.go +0 -160
- package/templates/root/internal/config/config.go +0 -55
- package/templates/root/internal/connectapi/handler.go +0 -79
- package/templates/root/internal/httpapi/routes.go +0 -93
- package/templates/root/internal/vault/client.go +0 -148
- package/templates/root/package.json +0 -12
- package/templates/root/protos/dns/v1/dns.proto +0 -58
- package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
- package/templates/root/scripts/cloudrun/config.ts +0 -50
- package/templates/root/scripts/cloudrun/deploy.ts +0 -41
- package/templates/root/scripts/cloudrun/lib.ts +0 -244
- package/templates/root/service.yaml +0 -50
- package/templates/root/test/go.test.ts +0 -19
- package/templates/shared/.env.example +0 -10
- package/templates/variants/go-chi/buf.gen.yaml +0 -10
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# {{SERVICE_NAME}}
|
|
2
|
+
|
|
3
|
+
Generated by `create-service`.
|
|
4
|
+
|
|
5
|
+
This `{{PROFILE}}` profile targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloudflare Workers with:
|
|
6
|
+
|
|
7
|
+
- one `wrangler.toml`
|
|
8
|
+
- a lightweight waitlist/launch API
|
|
9
|
+
- a local `service` CLI for create, deploy, doctor, dashboards, DNS, and destroy
|
|
10
|
+
- Cron Trigger wiring for scheduled follow-up work
|
|
11
|
+
- a Hyperdrive binding for Neon-backed Postgres persistence
|
|
12
|
+
- a production API origin at `https://{{API_HOSTNAME}}`
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
wrangler dev
|
|
18
|
+
bun run test
|
|
19
|
+
bun run lint
|
|
20
|
+
bun run migrate
|
|
21
|
+
bun run create
|
|
22
|
+
bun run deploy
|
|
23
|
+
bun run dashboards
|
|
24
|
+
bun run doctor
|
|
25
|
+
bun run destroy
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Local Development
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bun install
|
|
32
|
+
bun run dev
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The Workers target starts with an HTTP waitlist API. Local unit tests use an
|
|
36
|
+
in-memory store. Deployed Workers use the `HYPERDRIVE` binding and create the
|
|
37
|
+
small waitlist/trigger schema on first use.
|
|
38
|
+
|
|
39
|
+
## API
|
|
40
|
+
|
|
41
|
+
- `GET /healthz`
|
|
42
|
+
- `GET /readyz`
|
|
43
|
+
- `POST /v1/waitlist`
|
|
44
|
+
- `GET /v1/waitlist?email=<email>`
|
|
45
|
+
- `GET /v1/waitlist/:entryId`
|
|
46
|
+
- `POST /v1/triggers/waitlist`
|
|
47
|
+
- `POST /webhooks/:provider`
|
|
48
|
+
- `GET /webhooks/:provider/health`
|
|
49
|
+
|
|
50
|
+
## Production
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
bun run create
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`service create` deploys the Worker through Wrangler. The custom domain is
|
|
57
|
+
configured in `wrangler.toml`:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
https://{{API_HOSTNAME}}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Use `bun run doctor` after create to verify Wrangler auth, route config, Cron,
|
|
64
|
+
Hyperdrive, dashboard tooling, auth tooling, and deployed health.
|
|
65
|
+
|
|
66
|
+
If the Hyperdrive binding id is empty, `service create` uses `DATABASE_URL`, or
|
|
67
|
+
`NEON_API_KEY` to create/resolve the generated Neon database and connection URI,
|
|
68
|
+
applies the waitlist schema, then runs `wrangler hyperdrive create` and writes
|
|
69
|
+
the returned id back into `wrangler.toml` before deploy.
|
|
70
|
+
|
|
71
|
+
You can also apply the schema manually:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
DATABASE_URL=postgres://... bun run migrate
|
|
75
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{SERVICE_NAME}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"service": "./scripts/workers/cli.ts"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "wrangler dev",
|
|
10
|
+
"service": "bun run ./scripts/workers/cli.ts",
|
|
11
|
+
"migrate": "bun run ./scripts/workers/cli.ts migrate",
|
|
12
|
+
"seed": "bun run ./scripts/workers/cli.ts seed",
|
|
13
|
+
"lint": "bunx tsc --noEmit",
|
|
14
|
+
"test": "bun test",
|
|
15
|
+
"create": "bun run ./scripts/workers/cli.ts create",
|
|
16
|
+
"deploy": "bun run ./scripts/workers/cli.ts deploy",
|
|
17
|
+
"dashboards": "bun run ./scripts/workers/cli.ts dashboards",
|
|
18
|
+
"auth": "bun run ./scripts/workers/cli.ts auth",
|
|
19
|
+
"destroy": "bun run ./scripts/workers/cli.ts destroy"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@anmho/authctl": "0.1.1",
|
|
23
|
+
"@clack/prompts": "^1.2.0",
|
|
24
|
+
"@neondatabase/api-client": "^2.7.1",
|
|
25
|
+
"hono": "^4.10.1",
|
|
26
|
+
"pg": "^8.16.3"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@cloudflare/workers-types": "latest",
|
|
30
|
+
"@types/pg": "^8.16.0",
|
|
31
|
+
"@types/bun": "latest",
|
|
32
|
+
"typescript": "^5.9.3",
|
|
33
|
+
"wrangler": "^4.49.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { confirm, intro, isCancel, log, outro } from "@clack/prompts";
|
|
4
|
+
import { createApiClient } from "@neondatabase/api-client";
|
|
5
|
+
import { Client } from "pg";
|
|
6
|
+
import { ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
|
|
7
|
+
|
|
8
|
+
const config = {
|
|
9
|
+
serviceName: "{{SERVICE_NAME}}",
|
|
10
|
+
hostname: "{{API_HOSTNAME}}",
|
|
11
|
+
neonDatabaseName: "{{NEON_DATABASE_NAME}}",
|
|
12
|
+
neonRoleName: "neondb_owner",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type DoctorStatus = "pass" | "warn" | "fail";
|
|
16
|
+
|
|
17
|
+
async function main(argv = Bun.argv.slice(2)) {
|
|
18
|
+
const [command, ...rest] = argv;
|
|
19
|
+
|
|
20
|
+
if (command === "create") {
|
|
21
|
+
return runMain("Create", async () => {
|
|
22
|
+
ensureAuthResourceServer();
|
|
23
|
+
const databaseUrl = await resolveDatabaseUrl();
|
|
24
|
+
await applySchema(databaseUrl);
|
|
25
|
+
await ensureHyperdrive(databaseUrl);
|
|
26
|
+
run("wrangler", ["deploy"]);
|
|
27
|
+
return `Created https://${config.hostname}`;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (command === "deploy") {
|
|
32
|
+
return runMain("Deploy", () => {
|
|
33
|
+
run("wrangler", ["deploy", ...rest]);
|
|
34
|
+
return `Deployed https://${config.hostname}`;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (command === "migrate") {
|
|
39
|
+
return runMain("Migrate", async () => {
|
|
40
|
+
await applySchema(await resolveDatabaseUrl());
|
|
41
|
+
return "Workers database schema applied";
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (command === "seed") {
|
|
46
|
+
return runMain("Seed", () => "Seed data is not configured");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (command === "dashboards") {
|
|
50
|
+
return runMain("Dashboards", () => {
|
|
51
|
+
run("gcx", ["dev", "lint", "run", "./grafana", "-o", "compact"]);
|
|
52
|
+
run("gcx", ["resources", "push", "--path", "./grafana"]);
|
|
53
|
+
return "Dashboards pushed";
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (command === "dns") {
|
|
58
|
+
return runMain("DNS", () => `Workers custom domain is configured in wrangler.toml for ${config.hostname}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (command === "doctor") {
|
|
62
|
+
return runMain("Doctor", () => runDoctor());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (command === "auth") {
|
|
66
|
+
return runMain("Auth", () => runAuthCommand(rest));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (command === "destroy") {
|
|
70
|
+
return runMain("Destroy", async () => {
|
|
71
|
+
await requireDestroyConfirmation(rest.includes("--force"));
|
|
72
|
+
const wranglerArgs = rest.filter((arg) => arg !== "--force");
|
|
73
|
+
await deleteHyperdrive();
|
|
74
|
+
run("wrangler", ["delete", "--name", config.serviceName, "--force", ...wranglerArgs]);
|
|
75
|
+
await deleteNeonDatabase();
|
|
76
|
+
await deleteGrafanaResources();
|
|
77
|
+
return `Destroyed ${config.serviceName}`;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (command === "sdk") {
|
|
82
|
+
throw new Error("SDK commands are only available for ConnectRPC services");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error("Usage: service <create|deploy|migrate|seed|dashboards|dns|doctor|destroy|auth|sdk> [args]");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function run(command: string, args: string[], options: { allowFailure?: boolean; capture?: boolean } = {}) {
|
|
89
|
+
if (!Bun.which(command)) {
|
|
90
|
+
throw new Error(`missing required command: ${command}`);
|
|
91
|
+
}
|
|
92
|
+
const result = Bun.spawnSync([command, ...args], {
|
|
93
|
+
cwd: process.cwd(),
|
|
94
|
+
env: process.env,
|
|
95
|
+
stdin: "inherit",
|
|
96
|
+
stdout: options.capture ? "pipe" : "inherit",
|
|
97
|
+
stderr: options.capture ? "pipe" : "inherit",
|
|
98
|
+
});
|
|
99
|
+
if (!result.success && !options.allowFailure) {
|
|
100
|
+
throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.exitCode}`);
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function ensureHyperdrive(databaseUrl?: string) {
|
|
106
|
+
const configPath = "./wrangler.toml";
|
|
107
|
+
const text = await Bun.file(configPath).text();
|
|
108
|
+
if (!text.includes('binding = "HYPERDRIVE"')) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!text.includes('id = ""')) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const resolvedDatabaseUrl = databaseUrl ?? (await resolveDatabaseUrl());
|
|
116
|
+
|
|
117
|
+
const result = run("wrangler", ["hyperdrive", "create", `${config.serviceName}-hyperdrive`, "--connection-string", resolvedDatabaseUrl], {
|
|
118
|
+
capture: true,
|
|
119
|
+
});
|
|
120
|
+
const output = `${result.stdout ? new TextDecoder().decode(result.stdout) : ""}\n${result.stderr ? new TextDecoder().decode(result.stderr) : ""}`;
|
|
121
|
+
const hyperdriveId = extractHyperdriveId(output);
|
|
122
|
+
if (!hyperdriveId) {
|
|
123
|
+
throw new Error(`Could not find Hyperdrive id in wrangler output:\n${output.trim()}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await Bun.write(configPath, text.replace('id = ""', `id = "${hyperdriveId}"`));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function extractHyperdriveId(output: string) {
|
|
130
|
+
const jsonMatch = output.match(/"id"\s*:\s*"([^"]+)"/);
|
|
131
|
+
if (jsonMatch?.[1]) {
|
|
132
|
+
return jsonMatch[1];
|
|
133
|
+
}
|
|
134
|
+
const tomlMatch = output.match(/id\s*=\s*"([^"]+)"/);
|
|
135
|
+
return tomlMatch?.[1];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function deleteHyperdrive() {
|
|
139
|
+
const text = await Bun.file("./wrangler.toml").text();
|
|
140
|
+
const id = text.match(/binding\s*=\s*"HYPERDRIVE"[\s\S]*?id\s*=\s*"([^"]+)"/)?.[1];
|
|
141
|
+
if (!id) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
run("wrangler", ["hyperdrive", "delete", id, "--force"], { allowFailure: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function resolveDatabaseUrl() {
|
|
148
|
+
const direct = Bun.env.DATABASE_URL?.trim();
|
|
149
|
+
if (direct) {
|
|
150
|
+
return direct;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const apiKey = Bun.env.NEON_API_KEY?.trim();
|
|
154
|
+
if (!apiKey) {
|
|
155
|
+
throw new Error("DATABASE_URL or NEON_API_KEY is required to provision the Hyperdrive binding");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { neon, projectId, branchId } = await resolveNeonTarget(apiKey);
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await neon.getProjectBranchDatabase(projectId, branchId, config.neonDatabaseName);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const status = (error as { response?: { status?: number } })?.response?.status;
|
|
164
|
+
if (status !== 404) {
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
await neon.createProjectBranchDatabase(projectId, branchId, {
|
|
168
|
+
database: {
|
|
169
|
+
name: config.neonDatabaseName,
|
|
170
|
+
owner_name: config.neonRoleName,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const uriPayload = await neon.getConnectionUri({
|
|
176
|
+
projectId,
|
|
177
|
+
branch_id: branchId,
|
|
178
|
+
database_name: config.neonDatabaseName,
|
|
179
|
+
role_name: config.neonRoleName,
|
|
180
|
+
});
|
|
181
|
+
const uri = (uriPayload.data as { uri?: string } | undefined)?.uri;
|
|
182
|
+
if (!uri) {
|
|
183
|
+
throw new Error(`Neon did not return a connection URI for ${config.neonDatabaseName}`);
|
|
184
|
+
}
|
|
185
|
+
return uri;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function resolveNeonTarget(apiKey: string) {
|
|
189
|
+
const neon = createApiClient({ apiKey });
|
|
190
|
+
const projectsPayload = await neon.listProjects({ limit: 100 });
|
|
191
|
+
const projects = ((projectsPayload.data as { projects?: Array<{ id?: string }> } | undefined)?.projects ?? []).filter((project) => project.id);
|
|
192
|
+
const project = projects[0];
|
|
193
|
+
if (!project?.id) {
|
|
194
|
+
throw new Error("No Neon projects are available for Workers provisioning");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const branchesPayload = await neon.listProjectBranches({ projectId: project.id });
|
|
198
|
+
const branches = ((branchesPayload.data as { branches?: Array<{ id?: string; name?: string }> } | undefined)?.branches ?? []).filter(
|
|
199
|
+
(branch) => branch.id
|
|
200
|
+
);
|
|
201
|
+
const branch = branches.find((candidate) => candidate.name === "main") ?? branches[0];
|
|
202
|
+
if (!branch?.id) {
|
|
203
|
+
throw new Error(`No Neon branches are available in project ${project.id}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { neon, projectId: project.id, branchId: branch.id };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function deleteNeonDatabase() {
|
|
210
|
+
const apiKey = Bun.env.NEON_API_KEY?.trim();
|
|
211
|
+
if (!apiKey) {
|
|
212
|
+
log.step("Skipping Neon database deletion because NEON_API_KEY is not set");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const { neon, projectId, branchId } = await resolveNeonTarget(apiKey);
|
|
217
|
+
try {
|
|
218
|
+
await neon.getProjectBranchDatabase(projectId, branchId, config.neonDatabaseName);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
const status = (error as { response?: { status?: number } })?.response?.status;
|
|
221
|
+
if (status === 404) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const payload = await neon.getProjectBranchDatabase(projectId, branchId, config.neonDatabaseName);
|
|
228
|
+
const database = (payload.data as { database?: { name?: string; owner_name?: string } } | undefined)?.database;
|
|
229
|
+
if (database?.name !== config.neonDatabaseName || (database.owner_name && database.owner_name !== config.neonRoleName)) {
|
|
230
|
+
throw new Error(`Refusing to delete Neon database ${database?.name ?? config.neonDatabaseName}; ownership metadata does not match`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await neon.deleteProjectBranchDatabase(projectId, branchId, config.neonDatabaseName);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function deleteGrafanaResources() {
|
|
237
|
+
if (!(await Bun.file("./grafana").exists())) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!Bun.which("gcx")) {
|
|
241
|
+
log.step("Skipping Grafana deletion because gcx is not installed");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
run("gcx", ["resources", "delete", "--path", "./grafana", "--yes", "--on-error", "ignore"], { allowFailure: true });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function applySchema(databaseUrl: string) {
|
|
248
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
249
|
+
await client.connect();
|
|
250
|
+
try {
|
|
251
|
+
await client.query(`
|
|
252
|
+
create table if not exists waitlist_entries (
|
|
253
|
+
id text primary key,
|
|
254
|
+
email text not null unique,
|
|
255
|
+
name text,
|
|
256
|
+
company text,
|
|
257
|
+
source text,
|
|
258
|
+
status text not null default 'joined',
|
|
259
|
+
created_at timestamptz not null default now(),
|
|
260
|
+
updated_at timestamptz not null default now()
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
create table if not exists waitlist_triggers (
|
|
264
|
+
id text primary key,
|
|
265
|
+
type text not null,
|
|
266
|
+
entry_id text references waitlist_entries(id) on delete set null,
|
|
267
|
+
status text not null default 'queued',
|
|
268
|
+
payload_json text not null default '{}',
|
|
269
|
+
created_at timestamptz not null default now(),
|
|
270
|
+
processed_at timestamptz
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
create index if not exists waitlist_triggers_status_created_idx
|
|
274
|
+
on waitlist_triggers (status, created_at);
|
|
275
|
+
`);
|
|
276
|
+
} finally {
|
|
277
|
+
await client.end();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function runDoctor() {
|
|
282
|
+
const results: Array<{ name: string; status: DoctorStatus; detail: string }> = [];
|
|
283
|
+
|
|
284
|
+
await record(results, "bun CLI", "fail", () => checkCommand("bun"));
|
|
285
|
+
await record(results, "wrangler CLI", "fail", () => checkCommand("wrangler"));
|
|
286
|
+
await record(results, "wrangler auth", "fail", () => {
|
|
287
|
+
run("wrangler", ["whoami"]);
|
|
288
|
+
return "authenticated";
|
|
289
|
+
});
|
|
290
|
+
await record(results, "wrangler.toml", "fail", async () => {
|
|
291
|
+
const text = await Bun.file("./wrangler.toml").text();
|
|
292
|
+
if (!text.includes(`name = "${config.serviceName}"`)) {
|
|
293
|
+
throw new Error(`wrangler.toml does not name ${config.serviceName}`);
|
|
294
|
+
}
|
|
295
|
+
if (!text.includes(`pattern = "${config.hostname}/*"`)) {
|
|
296
|
+
throw new Error(`wrangler.toml does not route ${config.hostname}`);
|
|
297
|
+
}
|
|
298
|
+
return "name and custom domain route configured";
|
|
299
|
+
});
|
|
300
|
+
await record(results, "Cron Trigger", "fail", async () => {
|
|
301
|
+
const text = await Bun.file("./wrangler.toml").text();
|
|
302
|
+
if (!text.includes("[triggers]") || !text.includes("crons")) {
|
|
303
|
+
throw new Error("wrangler.toml is missing a cron trigger");
|
|
304
|
+
}
|
|
305
|
+
return "cron trigger configured";
|
|
306
|
+
});
|
|
307
|
+
await record(results, "Hyperdrive binding", "warn", async () => {
|
|
308
|
+
const text = await Bun.file("./wrangler.toml").text();
|
|
309
|
+
if (!text.includes('binding = "HYPERDRIVE"')) {
|
|
310
|
+
throw new Error("HYPERDRIVE binding is missing");
|
|
311
|
+
}
|
|
312
|
+
if (text.includes('id = ""')) {
|
|
313
|
+
throw new Error("HYPERDRIVE id is not provisioned yet");
|
|
314
|
+
}
|
|
315
|
+
return "Hyperdrive binding has an id";
|
|
316
|
+
});
|
|
317
|
+
await record(results, "dashboard tooling", "warn", () => checkCommand("gcx"));
|
|
318
|
+
await record(results, "dashboard artifacts", "warn", async () => {
|
|
319
|
+
if (!(await Bun.file("./grafana").exists()) && !(await Bun.file("./dashboards").exists())) {
|
|
320
|
+
throw new Error("no grafana/ or dashboards/ directory found");
|
|
321
|
+
}
|
|
322
|
+
return "dashboard directory found";
|
|
323
|
+
});
|
|
324
|
+
await record(results, "authctl", "warn", () => runAuthDoctor().detail);
|
|
325
|
+
await record(results, "deployed health", "warn", async () => {
|
|
326
|
+
const response = await fetch(`https://${config.hostname}/healthz`, { signal: AbortSignal.timeout(5_000) });
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
throw new Error(`GET /healthz returned ${response.status}`);
|
|
329
|
+
}
|
|
330
|
+
return "GET /healthz ok";
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const output = results.map(formatDoctorResult).join("\n");
|
|
334
|
+
const failures = results.filter((result) => result.status === "fail");
|
|
335
|
+
if (failures.length > 0) {
|
|
336
|
+
throw new Error(`Doctor found ${failures.length} failing check(s)\n${output}`);
|
|
337
|
+
}
|
|
338
|
+
return output;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function record(
|
|
342
|
+
results: Array<{ name: string; status: DoctorStatus; detail: string }>,
|
|
343
|
+
name: string,
|
|
344
|
+
failureStatus: "warn" | "fail",
|
|
345
|
+
check: () => string | Promise<string>
|
|
346
|
+
) {
|
|
347
|
+
try {
|
|
348
|
+
results.push({ name, status: "pass", detail: await check() });
|
|
349
|
+
} catch (error) {
|
|
350
|
+
results.push({ name, status: failureStatus, detail: error instanceof Error ? error.message : String(error) });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function checkCommand(name: string) {
|
|
355
|
+
const path = Bun.which(name);
|
|
356
|
+
if (!path) {
|
|
357
|
+
throw new Error(`${name} is not installed`);
|
|
358
|
+
}
|
|
359
|
+
return path;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function formatDoctorResult(result: { name: string; status: DoctorStatus; detail: string }) {
|
|
363
|
+
const marker = result.status === "pass" ? "PASS" : result.status === "warn" ? "WARN" : "FAIL";
|
|
364
|
+
return `[${marker}] ${result.name}: ${result.detail}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function requireDestroyConfirmation(force: boolean) {
|
|
368
|
+
if (force) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!process.stdin.isTTY) {
|
|
373
|
+
throw new Error("service destroy requires --force when running non-interactively");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const answer = await confirm({
|
|
377
|
+
message: `Destroy resources owned by ${config.serviceName}?`,
|
|
378
|
+
initialValue: false,
|
|
379
|
+
});
|
|
380
|
+
if (isCancel(answer) || !answer) {
|
|
381
|
+
throw new Error("Destroy cancelled");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function runMain(name: string, task: () => string | Promise<string>) {
|
|
386
|
+
intro(name);
|
|
387
|
+
try {
|
|
388
|
+
outro(await task());
|
|
389
|
+
} catch (error) {
|
|
390
|
+
log.error(error instanceof Error ? error.message : String(error));
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (import.meta.main) {
|
|
396
|
+
await main();
|
|
397
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
type AuthConfig = {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
issuer: string;
|
|
4
|
+
audience: string;
|
|
5
|
+
jwksUrl: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type AuthEnv = {
|
|
9
|
+
AUTH_ENABLED?: string;
|
|
10
|
+
AUTH_ISSUER?: string;
|
|
11
|
+
AUTH_AUDIENCE?: string;
|
|
12
|
+
AUTH_JWKS_URL?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type JwtHeader = {
|
|
16
|
+
alg?: string;
|
|
17
|
+
kid?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type JwtClaims = {
|
|
21
|
+
iss?: string;
|
|
22
|
+
aud?: string | string[];
|
|
23
|
+
exp?: number;
|
|
24
|
+
nbf?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Jwk = JsonWebKey & {
|
|
28
|
+
kid?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type Jwks = {
|
|
32
|
+
keys: Jwk[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
const jwksCache = new Map<string, { expiresAt: number; jwks: Jwks }>();
|
|
37
|
+
|
|
38
|
+
export function authMiddleware() {
|
|
39
|
+
return async (context: any, next: () => Promise<void>) => {
|
|
40
|
+
const config = authConfigFromEnv(context.env ?? {});
|
|
41
|
+
if (!config.enabled) {
|
|
42
|
+
await next();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const token = bearerToken(context.req.header("authorization") ?? "");
|
|
47
|
+
if (!token) {
|
|
48
|
+
return context.json({ error: "missing bearer token", code: "unauthorized" }, 401);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await verifyAccessToken(token, config);
|
|
53
|
+
await next();
|
|
54
|
+
} catch {
|
|
55
|
+
return context.json({ error: "invalid bearer token", code: "unauthorized" }, 401);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function authConfigFromEnv(env: AuthEnv): AuthConfig {
|
|
61
|
+
return {
|
|
62
|
+
enabled: truthy(env.AUTH_ENABLED),
|
|
63
|
+
issuer: env.AUTH_ISSUER ?? "",
|
|
64
|
+
audience: env.AUTH_AUDIENCE ?? "",
|
|
65
|
+
jwksUrl: env.AUTH_JWKS_URL ?? "",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function verifyAccessToken(token: string, config: AuthConfig): Promise<JwtClaims> {
|
|
70
|
+
const parts = token.split(".");
|
|
71
|
+
if (parts.length !== 3 || !config.issuer || !config.audience || !config.jwksUrl) {
|
|
72
|
+
throw new Error("invalid auth config or token");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
|
76
|
+
const header = decodeJSON<JwtHeader>(encodedHeader);
|
|
77
|
+
const claims = decodeJSON<JwtClaims>(encodedPayload);
|
|
78
|
+
const jwks = await fetchJwks(config.jwksUrl);
|
|
79
|
+
const key = selectKey(jwks, header);
|
|
80
|
+
if (!key || !header.alg) {
|
|
81
|
+
throw new Error("matching jwk not found");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const algorithm = importAlgorithm(header.alg, key);
|
|
85
|
+
const cryptoKey = await crypto.subtle.importKey("jwk", key, algorithm.import, false, ["verify"]);
|
|
86
|
+
const verified = await crypto.subtle.verify(
|
|
87
|
+
algorithm.verify,
|
|
88
|
+
cryptoKey,
|
|
89
|
+
toArrayBuffer(decodeBase64Url(encodedSignature)),
|
|
90
|
+
encoder.encode(`${encodedHeader}.${encodedPayload}`)
|
|
91
|
+
);
|
|
92
|
+
if (!verified) {
|
|
93
|
+
throw new Error("bad signature");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
validateClaims(claims, config);
|
|
97
|
+
return claims;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function fetchJwks(jwksUrl: string): Promise<Jwks> {
|
|
101
|
+
const cached = jwksCache.get(jwksUrl);
|
|
102
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
103
|
+
return cached.jwks;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const response = await fetch(jwksUrl);
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new Error(`jwks fetch failed: ${response.status}`);
|
|
109
|
+
}
|
|
110
|
+
const jwks = (await response.json()) as Jwks;
|
|
111
|
+
jwksCache.set(jwksUrl, { jwks, expiresAt: Date.now() + 5 * 60 * 1000 });
|
|
112
|
+
return jwks;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function selectKey(jwks: Jwks, header: JwtHeader): Jwk | undefined {
|
|
116
|
+
if (header.kid) {
|
|
117
|
+
return jwks.keys.find((key) => key.kid === header.kid);
|
|
118
|
+
}
|
|
119
|
+
return jwks.keys.length === 1 ? jwks.keys[0] : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function importAlgorithm(alg: string, key: JsonWebKey) {
|
|
123
|
+
if (alg === "RS256") {
|
|
124
|
+
return {
|
|
125
|
+
import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
126
|
+
verify: { name: "RSASSA-PKCS1-v1_5" },
|
|
127
|
+
} as const;
|
|
128
|
+
}
|
|
129
|
+
if (alg === "ES256" && key.crv === "P-256") {
|
|
130
|
+
return {
|
|
131
|
+
import: { name: "ECDSA", namedCurve: "P-256" },
|
|
132
|
+
verify: { name: "ECDSA", hash: "SHA-256" },
|
|
133
|
+
} as const;
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`unsupported jwt alg: ${alg}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validateClaims(claims: JwtClaims, config: AuthConfig) {
|
|
139
|
+
const now = Math.floor(Date.now() / 1000);
|
|
140
|
+
if (claims.iss !== config.issuer) {
|
|
141
|
+
throw new Error("issuer mismatch");
|
|
142
|
+
}
|
|
143
|
+
if (!audienceMatches(claims.aud, config.audience)) {
|
|
144
|
+
throw new Error("audience mismatch");
|
|
145
|
+
}
|
|
146
|
+
if (typeof claims.exp !== "number" || claims.exp <= now - 30) {
|
|
147
|
+
throw new Error("token expired");
|
|
148
|
+
}
|
|
149
|
+
if (typeof claims.nbf === "number" && claims.nbf > now + 30) {
|
|
150
|
+
throw new Error("token not active");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function audienceMatches(audience: JwtClaims["aud"], expected: string) {
|
|
155
|
+
return Array.isArray(audience) ? audience.includes(expected) : audience === expected;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function decodeJSON<T>(value: string): T {
|
|
159
|
+
return JSON.parse(new TextDecoder().decode(decodeBase64Url(value))) as T;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function decodeBase64Url(value: string): Uint8Array {
|
|
163
|
+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
164
|
+
return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
168
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function bearerToken(value: string) {
|
|
172
|
+
const [scheme, token] = value.trim().split(/\s+/, 2);
|
|
173
|
+
return /^Bearer$/i.test(scheme) ? token : "";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function truthy(value: string | undefined) {
|
|
177
|
+
return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
|
|
178
|
+
}
|