create-svc 0.1.10 → 0.1.12
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 +51 -47
- package/index.ts +2 -2
- package/package.json +10 -9
- package/src/cli.test.ts +28 -10
- package/src/cli.ts +196 -33
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +1 -0
- package/src/naming.ts +23 -0
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +17 -4
- package/src/profiles.ts +2 -5
- package/src/scaffold.test.ts +232 -41
- package/src/scaffold.ts +81 -36
- package/src/service.test.ts +30 -0
- package/src/service.ts +65 -0
- package/src/vault.test.ts +61 -1
- package/src/vault.ts +77 -15
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +124 -47
- 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 +14 -5
- package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
- package/templates/shared/scripts/cloudrun/cli.ts +329 -7
- package/templates/shared/scripts/cloudrun/config.ts +11 -4
- package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
- package/templates/shared/scripts/cloudrun/lib.ts +174 -41
- package/templates/shared/scripts/cloudrun/neon.ts +45 -0
- 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 -44
- 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 +402 -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/Makefile +14 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-connectrpc/package.json +12 -5
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
- package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
- package/templates/variants/bun-connectrpc/src/index.ts +76 -176
- 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 +4 -4
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-hono/Makefile +14 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-hono/package.json +12 -5
- package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/repository.ts +68 -421
- package/templates/variants/bun-hono/src/db/schema.ts +15 -64
- package/templates/variants/bun-hono/src/index.ts +65 -180
- 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 +72 -41
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/go-chi/Makefile +27 -11
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +21 -10
- package/templates/variants/go-chi/go.mod +1 -3
- package/templates/variants/go-chi/internal/app/service.go +202 -685
- 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 +27 -11
- package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
- 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 +12 -55
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-connectrpc/Makefile +26 -9
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
- package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
- 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 +1 -1
- package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
- 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 +27 -11
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
- 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 +12 -55
- 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/scripts/cloudrun/integrations.ts +0 -111
- package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
- package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
- package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
- package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
- package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
- package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
- package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
- package/templates/variants/bun-hono/src/chat/service.ts +0 -384
- package/templates/variants/bun-hono/src/chat/types.ts +0 -142
- package/templates/variants/bun-hono/src/storage.ts +0 -72
- package/templates/variants/bun-hono/src/webhooks.ts +0 -35
- package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
- package/templates/variants/go-chi/buf.gen.yaml +0 -12
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
- package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
- package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
- package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
- package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
- package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
- package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
- package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
- /package/bin/{create-svc.mjs → service.mjs} +0 -0
|
@@ -0,0 +1,402 @@
|
|
|
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 || command === "--help" || command === "-h" || command === "help") {
|
|
21
|
+
console.log("Usage: service <create|deploy|migrate|seed|dashboards|dns|doctor|destroy|auth|sdk> [args]");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (command === "create") {
|
|
26
|
+
return runMain("Create", async () => {
|
|
27
|
+
ensureAuthResourceServer();
|
|
28
|
+
const databaseUrl = await resolveDatabaseUrl();
|
|
29
|
+
await applySchema(databaseUrl);
|
|
30
|
+
await ensureHyperdrive(databaseUrl);
|
|
31
|
+
run("wrangler", ["deploy"]);
|
|
32
|
+
return `Created https://${config.hostname}`;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (command === "deploy") {
|
|
37
|
+
return runMain("Deploy", () => {
|
|
38
|
+
run("wrangler", ["deploy", ...rest]);
|
|
39
|
+
return `Deployed https://${config.hostname}`;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (command === "migrate") {
|
|
44
|
+
return runMain("Migrate", async () => {
|
|
45
|
+
await applySchema(await resolveDatabaseUrl());
|
|
46
|
+
return "Workers database schema applied";
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (command === "seed") {
|
|
51
|
+
return runMain("Seed", () => "Seed data is not configured");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (command === "dashboards") {
|
|
55
|
+
return runMain("Dashboards", () => {
|
|
56
|
+
run("gcx", ["dev", "lint", "run", "./grafana", "-o", "compact"]);
|
|
57
|
+
run("gcx", ["resources", "push", "--path", "./grafana"]);
|
|
58
|
+
return "Dashboards pushed";
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (command === "dns") {
|
|
63
|
+
return runMain("DNS", () => `Workers custom domain is configured in wrangler.toml for ${config.hostname}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (command === "doctor") {
|
|
67
|
+
return runMain("Doctor", () => runDoctor());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (command === "auth") {
|
|
71
|
+
return runMain("Auth", () => runAuthCommand(rest));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (command === "destroy") {
|
|
75
|
+
return runMain("Destroy", async () => {
|
|
76
|
+
await requireDestroyConfirmation(rest.includes("--force"));
|
|
77
|
+
const wranglerArgs = rest.filter((arg) => arg !== "--force");
|
|
78
|
+
await deleteHyperdrive();
|
|
79
|
+
run("wrangler", ["delete", "--name", config.serviceName, "--force", ...wranglerArgs]);
|
|
80
|
+
await deleteNeonDatabase();
|
|
81
|
+
await deleteGrafanaResources();
|
|
82
|
+
return `Destroyed ${config.serviceName}`;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (command === "sdk") {
|
|
87
|
+
throw new Error("SDK commands are only available for ConnectRPC services");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
throw new Error("Usage: service <create|deploy|migrate|seed|dashboards|dns|doctor|destroy|auth|sdk> [args]");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function run(command: string, args: string[], options: { allowFailure?: boolean; capture?: boolean } = {}) {
|
|
94
|
+
if (!Bun.which(command)) {
|
|
95
|
+
throw new Error(`missing required command: ${command}`);
|
|
96
|
+
}
|
|
97
|
+
const result = Bun.spawnSync([command, ...args], {
|
|
98
|
+
cwd: process.cwd(),
|
|
99
|
+
env: process.env,
|
|
100
|
+
stdin: "inherit",
|
|
101
|
+
stdout: options.capture ? "pipe" : "inherit",
|
|
102
|
+
stderr: options.capture ? "pipe" : "inherit",
|
|
103
|
+
});
|
|
104
|
+
if (!result.success && !options.allowFailure) {
|
|
105
|
+
throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.exitCode}`);
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function ensureHyperdrive(databaseUrl?: string) {
|
|
111
|
+
const configPath = "./wrangler.toml";
|
|
112
|
+
const text = await Bun.file(configPath).text();
|
|
113
|
+
if (!text.includes('binding = "HYPERDRIVE"')) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!text.includes('id = ""')) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resolvedDatabaseUrl = databaseUrl ?? (await resolveDatabaseUrl());
|
|
121
|
+
|
|
122
|
+
const result = run("wrangler", ["hyperdrive", "create", `${config.serviceName}-hyperdrive`, "--connection-string", resolvedDatabaseUrl], {
|
|
123
|
+
capture: true,
|
|
124
|
+
});
|
|
125
|
+
const output = `${result.stdout ? new TextDecoder().decode(result.stdout) : ""}\n${result.stderr ? new TextDecoder().decode(result.stderr) : ""}`;
|
|
126
|
+
const hyperdriveId = extractHyperdriveId(output);
|
|
127
|
+
if (!hyperdriveId) {
|
|
128
|
+
throw new Error(`Could not find Hyperdrive id in wrangler output:\n${output.trim()}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await Bun.write(configPath, text.replace('id = ""', `id = "${hyperdriveId}"`));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function extractHyperdriveId(output: string) {
|
|
135
|
+
const jsonMatch = output.match(/"id"\s*:\s*"([^"]+)"/);
|
|
136
|
+
if (jsonMatch?.[1]) {
|
|
137
|
+
return jsonMatch[1];
|
|
138
|
+
}
|
|
139
|
+
const tomlMatch = output.match(/id\s*=\s*"([^"]+)"/);
|
|
140
|
+
return tomlMatch?.[1];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function deleteHyperdrive() {
|
|
144
|
+
const text = await Bun.file("./wrangler.toml").text();
|
|
145
|
+
const id = text.match(/binding\s*=\s*"HYPERDRIVE"[\s\S]*?id\s*=\s*"([^"]+)"/)?.[1];
|
|
146
|
+
if (!id) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
run("wrangler", ["hyperdrive", "delete", id, "--force"], { allowFailure: true });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function resolveDatabaseUrl() {
|
|
153
|
+
const direct = Bun.env.DATABASE_URL?.trim();
|
|
154
|
+
if (direct) {
|
|
155
|
+
return direct;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const apiKey = Bun.env.NEON_API_KEY?.trim();
|
|
159
|
+
if (!apiKey) {
|
|
160
|
+
throw new Error("DATABASE_URL or NEON_API_KEY is required to provision the Hyperdrive binding");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { neon, projectId, branchId } = await resolveNeonTarget(apiKey);
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
await neon.getProjectBranchDatabase(projectId, branchId, config.neonDatabaseName);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const status = (error as { response?: { status?: number } })?.response?.status;
|
|
169
|
+
if (status !== 404) {
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
await neon.createProjectBranchDatabase(projectId, branchId, {
|
|
173
|
+
database: {
|
|
174
|
+
name: config.neonDatabaseName,
|
|
175
|
+
owner_name: config.neonRoleName,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const uriPayload = await neon.getConnectionUri({
|
|
181
|
+
projectId,
|
|
182
|
+
branch_id: branchId,
|
|
183
|
+
database_name: config.neonDatabaseName,
|
|
184
|
+
role_name: config.neonRoleName,
|
|
185
|
+
});
|
|
186
|
+
const uri = (uriPayload.data as { uri?: string } | undefined)?.uri;
|
|
187
|
+
if (!uri) {
|
|
188
|
+
throw new Error(`Neon did not return a connection URI for ${config.neonDatabaseName}`);
|
|
189
|
+
}
|
|
190
|
+
return uri;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function resolveNeonTarget(apiKey: string) {
|
|
194
|
+
const neon = createApiClient({ apiKey });
|
|
195
|
+
const projectsPayload = await neon.listProjects({ limit: 100 });
|
|
196
|
+
const projects = ((projectsPayload.data as { projects?: Array<{ id?: string }> } | undefined)?.projects ?? []).filter((project) => project.id);
|
|
197
|
+
const project = projects[0];
|
|
198
|
+
if (!project?.id) {
|
|
199
|
+
throw new Error("No Neon projects are available for Workers provisioning");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const branchesPayload = await neon.listProjectBranches({ projectId: project.id });
|
|
203
|
+
const branches = ((branchesPayload.data as { branches?: Array<{ id?: string; name?: string }> } | undefined)?.branches ?? []).filter(
|
|
204
|
+
(branch) => branch.id
|
|
205
|
+
);
|
|
206
|
+
const branch = branches.find((candidate) => candidate.name === "main") ?? branches[0];
|
|
207
|
+
if (!branch?.id) {
|
|
208
|
+
throw new Error(`No Neon branches are available in project ${project.id}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { neon, projectId: project.id, branchId: branch.id };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function deleteNeonDatabase() {
|
|
215
|
+
const apiKey = Bun.env.NEON_API_KEY?.trim();
|
|
216
|
+
if (!apiKey) {
|
|
217
|
+
log.step("Skipping Neon database deletion because NEON_API_KEY is not set");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { neon, projectId, branchId } = await resolveNeonTarget(apiKey);
|
|
222
|
+
try {
|
|
223
|
+
await neon.getProjectBranchDatabase(projectId, branchId, config.neonDatabaseName);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const status = (error as { response?: { status?: number } })?.response?.status;
|
|
226
|
+
if (status === 404) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const payload = await neon.getProjectBranchDatabase(projectId, branchId, config.neonDatabaseName);
|
|
233
|
+
const database = (payload.data as { database?: { name?: string; owner_name?: string } } | undefined)?.database;
|
|
234
|
+
if (database?.name !== config.neonDatabaseName || (database.owner_name && database.owner_name !== config.neonRoleName)) {
|
|
235
|
+
throw new Error(`Refusing to delete Neon database ${database?.name ?? config.neonDatabaseName}; ownership metadata does not match`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await neon.deleteProjectBranchDatabase(projectId, branchId, config.neonDatabaseName);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function deleteGrafanaResources() {
|
|
242
|
+
if (!(await Bun.file("./grafana").exists())) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!Bun.which("gcx")) {
|
|
246
|
+
log.step("Skipping Grafana deletion because gcx is not installed");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
run("gcx", ["resources", "delete", "--path", "./grafana", "--yes", "--on-error", "ignore"], { allowFailure: true });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function applySchema(databaseUrl: string) {
|
|
253
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
254
|
+
await client.connect();
|
|
255
|
+
try {
|
|
256
|
+
await client.query(`
|
|
257
|
+
create table if not exists waitlist_entries (
|
|
258
|
+
id text primary key,
|
|
259
|
+
email text not null unique,
|
|
260
|
+
name text,
|
|
261
|
+
company text,
|
|
262
|
+
source text,
|
|
263
|
+
status text not null default 'joined',
|
|
264
|
+
created_at timestamptz not null default now(),
|
|
265
|
+
updated_at timestamptz not null default now()
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
create table if not exists waitlist_triggers (
|
|
269
|
+
id text primary key,
|
|
270
|
+
type text not null,
|
|
271
|
+
entry_id text references waitlist_entries(id) on delete set null,
|
|
272
|
+
status text not null default 'queued',
|
|
273
|
+
payload_json text not null default '{}',
|
|
274
|
+
created_at timestamptz not null default now(),
|
|
275
|
+
processed_at timestamptz
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
create index if not exists waitlist_triggers_status_created_idx
|
|
279
|
+
on waitlist_triggers (status, created_at);
|
|
280
|
+
`);
|
|
281
|
+
} finally {
|
|
282
|
+
await client.end();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function runDoctor() {
|
|
287
|
+
const results: Array<{ name: string; status: DoctorStatus; detail: string }> = [];
|
|
288
|
+
|
|
289
|
+
await record(results, "bun CLI", "fail", () => checkCommand("bun"));
|
|
290
|
+
await record(results, "wrangler CLI", "fail", () => checkCommand("wrangler"));
|
|
291
|
+
await record(results, "wrangler auth", "fail", () => {
|
|
292
|
+
run("wrangler", ["whoami"]);
|
|
293
|
+
return "authenticated";
|
|
294
|
+
});
|
|
295
|
+
await record(results, "wrangler.toml", "fail", async () => {
|
|
296
|
+
const text = await Bun.file("./wrangler.toml").text();
|
|
297
|
+
if (!text.includes(`name = "${config.serviceName}"`)) {
|
|
298
|
+
throw new Error(`wrangler.toml does not name ${config.serviceName}`);
|
|
299
|
+
}
|
|
300
|
+
if (!text.includes(`pattern = "${config.hostname}/*"`)) {
|
|
301
|
+
throw new Error(`wrangler.toml does not route ${config.hostname}`);
|
|
302
|
+
}
|
|
303
|
+
return "name and custom domain route configured";
|
|
304
|
+
});
|
|
305
|
+
await record(results, "Cron Trigger", "fail", async () => {
|
|
306
|
+
const text = await Bun.file("./wrangler.toml").text();
|
|
307
|
+
if (!text.includes("[triggers]") || !text.includes("crons")) {
|
|
308
|
+
throw new Error("wrangler.toml is missing a cron trigger");
|
|
309
|
+
}
|
|
310
|
+
return "cron trigger configured";
|
|
311
|
+
});
|
|
312
|
+
await record(results, "Hyperdrive binding", "warn", async () => {
|
|
313
|
+
const text = await Bun.file("./wrangler.toml").text();
|
|
314
|
+
if (!text.includes('binding = "HYPERDRIVE"')) {
|
|
315
|
+
throw new Error("HYPERDRIVE binding is missing");
|
|
316
|
+
}
|
|
317
|
+
if (text.includes('id = ""')) {
|
|
318
|
+
throw new Error("HYPERDRIVE id is not provisioned yet");
|
|
319
|
+
}
|
|
320
|
+
return "Hyperdrive binding has an id";
|
|
321
|
+
});
|
|
322
|
+
await record(results, "dashboard tooling", "warn", () => checkCommand("gcx"));
|
|
323
|
+
await record(results, "dashboard artifacts", "warn", async () => {
|
|
324
|
+
if (!(await Bun.file("./grafana").exists()) && !(await Bun.file("./dashboards").exists())) {
|
|
325
|
+
throw new Error("no grafana/ or dashboards/ directory found");
|
|
326
|
+
}
|
|
327
|
+
return "dashboard directory found";
|
|
328
|
+
});
|
|
329
|
+
await record(results, "authctl", "warn", () => runAuthDoctor().detail);
|
|
330
|
+
await record(results, "deployed health", "warn", async () => {
|
|
331
|
+
const response = await fetch(`https://${config.hostname}/healthz`, { signal: AbortSignal.timeout(5_000) });
|
|
332
|
+
if (!response.ok) {
|
|
333
|
+
throw new Error(`GET /healthz returned ${response.status}`);
|
|
334
|
+
}
|
|
335
|
+
return "GET /healthz ok";
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const output = results.map(formatDoctorResult).join("\n");
|
|
339
|
+
const failures = results.filter((result) => result.status === "fail");
|
|
340
|
+
if (failures.length > 0) {
|
|
341
|
+
throw new Error(`Doctor found ${failures.length} failing check(s)\n${output}`);
|
|
342
|
+
}
|
|
343
|
+
return output;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function record(
|
|
347
|
+
results: Array<{ name: string; status: DoctorStatus; detail: string }>,
|
|
348
|
+
name: string,
|
|
349
|
+
failureStatus: "warn" | "fail",
|
|
350
|
+
check: () => string | Promise<string>
|
|
351
|
+
) {
|
|
352
|
+
try {
|
|
353
|
+
results.push({ name, status: "pass", detail: await check() });
|
|
354
|
+
} catch (error) {
|
|
355
|
+
results.push({ name, status: failureStatus, detail: error instanceof Error ? error.message : String(error) });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function checkCommand(name: string) {
|
|
360
|
+
const path = Bun.which(name);
|
|
361
|
+
if (!path) {
|
|
362
|
+
throw new Error(`${name} is not installed`);
|
|
363
|
+
}
|
|
364
|
+
return path;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function formatDoctorResult(result: { name: string; status: DoctorStatus; detail: string }) {
|
|
368
|
+
const marker = result.status === "pass" ? "PASS" : result.status === "warn" ? "WARN" : "FAIL";
|
|
369
|
+
return `[${marker}] ${result.name}: ${result.detail}`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function requireDestroyConfirmation(force: boolean) {
|
|
373
|
+
if (force) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!process.stdin.isTTY) {
|
|
378
|
+
throw new Error("service destroy requires --force when running non-interactively");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const answer = await confirm({
|
|
382
|
+
message: `Destroy resources owned by ${config.serviceName}?`,
|
|
383
|
+
initialValue: false,
|
|
384
|
+
});
|
|
385
|
+
if (isCancel(answer) || !answer) {
|
|
386
|
+
throw new Error("Destroy cancelled");
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function runMain(name: string, task: () => string | Promise<string>) {
|
|
391
|
+
intro(name);
|
|
392
|
+
try {
|
|
393
|
+
outro(await task());
|
|
394
|
+
} catch (error) {
|
|
395
|
+
log.error(error instanceof Error ? error.message : String(error));
|
|
396
|
+
process.exit(1);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (import.meta.main) {
|
|
401
|
+
await main();
|
|
402
|
+
}
|
|
@@ -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
|
+
}
|