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.
Files changed (163) hide show
  1. package/README.md +138 -16
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +19 -11
  4. package/src/cli.test.ts +46 -7
  5. package/src/cli.ts +282 -84
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +5 -2
  9. package/src/naming.ts +32 -1
  10. package/src/neon.ts +10 -8
  11. package/src/post-scaffold.test.ts +19 -0
  12. package/src/post-scaffold.ts +18 -26
  13. package/src/profiles.ts +25 -0
  14. package/src/scaffold.test.ts +320 -18
  15. package/src/scaffold.ts +154 -28
  16. package/src/vault.test.ts +94 -10
  17. package/src/vault.ts +81 -18
  18. package/templates/shared/.github/workflows/ci.yml +2 -1
  19. package/templates/shared/.github/workflows/deploy.yml +2 -0
  20. package/templates/shared/README.md +217 -29
  21. package/templates/shared/docker-compose.yml +19 -0
  22. package/templates/shared/grafana/alerts.yaml +54 -0
  23. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  24. package/templates/shared/scripts/authctl.ts +231 -0
  25. package/templates/shared/scripts/cloudrun/bootstrap.ts +24 -42
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
  27. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +21 -19
  29. package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
  30. package/templates/shared/scripts/cloudrun/lib.ts +232 -123
  31. package/templates/shared/scripts/cloudrun/neon.ts +127 -13
  32. package/templates/shared/scripts/dev.ts +22 -0
  33. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  34. package/templates/shared/scripts/local-docker.ts +63 -0
  35. package/templates/shared/scripts/local-env.ts +27 -0
  36. package/templates/shared/scripts/seed.ts +73 -0
  37. package/templates/shared/scripts/wait-for-db.ts +32 -0
  38. package/templates/shared/service.config.ts +59 -0
  39. package/templates/shared/service.yaml +24 -1
  40. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  41. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  42. package/templates/targets/workers/Makefile +33 -0
  43. package/templates/targets/workers/README.md +75 -0
  44. package/templates/targets/workers/package.json +35 -0
  45. package/templates/targets/workers/scripts/workers/cli.ts +397 -0
  46. package/templates/targets/workers/src/auth.ts +178 -0
  47. package/templates/targets/workers/src/index.ts +198 -0
  48. package/templates/targets/workers/src/storage.ts +370 -0
  49. package/templates/targets/workers/test/app.test.ts +108 -0
  50. package/templates/targets/workers/tsconfig.json +11 -0
  51. package/templates/targets/workers/wrangler.toml +24 -0
  52. package/templates/variants/bun-connectrpc/Dockerfile +1 -0
  53. package/templates/variants/bun-connectrpc/Makefile +17 -8
  54. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  55. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
  56. package/templates/variants/bun-connectrpc/package.json +25 -1
  57. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  58. package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
  59. package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
  60. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  61. package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
  62. package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
  63. package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
  64. package/templates/variants/bun-connectrpc/src/index.ts +194 -22
  65. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  66. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  67. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  68. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  69. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  70. package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
  71. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  72. package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
  73. package/templates/variants/bun-hono/Makefile +17 -8
  74. package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
  75. package/templates/variants/bun-hono/package.json +21 -1
  76. package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
  77. package/templates/variants/bun-hono/src/auth.ts +181 -0
  78. package/templates/variants/bun-hono/src/db/client.ts +15 -0
  79. package/templates/variants/bun-hono/src/db/repository.ts +126 -0
  80. package/templates/variants/bun-hono/src/db/schema.ts +26 -0
  81. package/templates/variants/bun-hono/src/index.ts +141 -10
  82. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  83. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  84. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  85. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  86. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  87. package/templates/variants/bun-hono/test/app.test.ts +90 -5
  88. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  89. package/templates/variants/bun-hono/tsconfig.json +1 -0
  90. package/templates/variants/go-chi/Makefile +30 -10
  91. package/templates/variants/go-chi/atlas.hcl +8 -0
  92. package/templates/variants/go-chi/cmd/server/main.go +25 -13
  93. package/templates/variants/go-chi/go.mod +3 -2
  94. package/templates/variants/go-chi/internal/app/service.go +279 -70
  95. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  96. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  97. package/templates/variants/go-chi/internal/config/config.go +38 -7
  98. package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
  99. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  100. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  101. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  102. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  103. package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
  104. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  105. package/templates/variants/go-chi/package.json +7 -1
  106. package/templates/variants/go-chi/test/go.test.ts +4 -1
  107. package/templates/variants/go-connectrpc/Makefile +29 -8
  108. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  109. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
  110. package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
  111. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  112. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  113. package/templates/variants/go-connectrpc/go.mod +4 -0
  114. package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
  115. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  116. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  117. package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
  118. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
  119. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  120. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
  121. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  122. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  123. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  124. package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
  125. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  126. package/templates/variants/go-connectrpc/package.json +7 -1
  127. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  128. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  129. package/templates/root/.github/workflows/ci.yml +0 -26
  130. package/templates/root/.github/workflows/deploy.yml +0 -22
  131. package/templates/root/Dockerfile +0 -23
  132. package/templates/root/README.md +0 -69
  133. package/templates/root/buf.gen.yaml +0 -10
  134. package/templates/root/buf.yaml +0 -9
  135. package/templates/root/cmd/server/main.go +0 -44
  136. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  137. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  138. package/templates/root/go.mod +0 -10
  139. package/templates/root/internal/app/service.go +0 -152
  140. package/templates/root/internal/app/token_source.go +0 -50
  141. package/templates/root/internal/cloudflare/client.go +0 -160
  142. package/templates/root/internal/config/config.go +0 -55
  143. package/templates/root/internal/connectapi/handler.go +0 -79
  144. package/templates/root/internal/httpapi/routes.go +0 -93
  145. package/templates/root/internal/vault/client.go +0 -148
  146. package/templates/root/package.json +0 -12
  147. package/templates/root/protos/dns/v1/dns.proto +0 -58
  148. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  149. package/templates/root/scripts/cloudrun/config.ts +0 -50
  150. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  151. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  152. package/templates/root/service.yaml +0 -50
  153. package/templates/root/test/go.test.ts +0 -19
  154. package/templates/shared/.env.example +0 -10
  155. package/templates/variants/go-chi/buf.gen.yaml +0 -10
  156. package/templates/variants/go-chi/buf.yaml +0 -9
  157. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
  158. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  159. package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
  160. package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
  161. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
  162. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  163. 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
+ }