create-svc 0.1.16 → 0.1.18

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 CHANGED
@@ -41,7 +41,7 @@ service deploy
41
41
  To install from npm:
42
42
 
43
43
  ```bash
44
- npm install -g create-svc
44
+ bun install -g create-svc
45
45
  ```
46
46
 
47
47
  For the strict one-command production path:
@@ -70,8 +70,7 @@ Without publishing to npm:
70
70
 
71
71
  ```bash
72
72
  bun install
73
- npm pack
74
- npm install -g ./create-svc-*.tgz
73
+ bun link
75
74
  service create my-service
76
75
  ```
77
76
 
@@ -130,6 +129,21 @@ service destroy
130
129
 
131
130
  Language-specific tasks such as local running, linting, formatting, testing, and building stay in package scripts or Make targets. Service lifecycle operations are exposed through the generated `service` CLI.
132
131
 
132
+ After `service create` has provisioned auth, the generated repo can mint a
133
+ client-credentials bearer token for smoke checks:
134
+
135
+ ```bash
136
+ TOKEN="$(service auth token)"
137
+ curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://api.<service_id>.anmho.com/v1/admin/waitlist?limit=1"
138
+ ```
139
+
140
+ For Go ConnectRPC services, use the same token with `grpcurl`:
141
+
142
+ ```bash
143
+ TOKEN="$(service auth token)"
144
+ grpcurl -H "Authorization: Bearer $TOKEN" -d '{"limit":1}' -proto protos/waitlist/v1/waitlist.proto api.<service_id>.anmho.com:443 waitlist.v1.WaitlistService/ListWaitlistEntries
145
+ ```
146
+
133
147
  The generated service is intended to be consumed by a web app, mobile client, or another service over HTTPS. In v1, production is expected to live at `https://api.<service_id>.anmho.com`, while preview and personal environments keep using deterministic platform URLs where appropriate.
134
148
 
135
149
  The generated microservice domain is a small waitlist/launch service example with public submit/status APIs and target-specific scheduled work.
@@ -142,7 +156,8 @@ bun test src scripts
142
156
  bun run index.ts create my-service
143
157
  ```
144
158
 
145
- Validate the generated app matrix against local Docker Compose Postgres:
159
+ Validate the generated service matrix against local Docker Compose Postgres and
160
+ Workers package checks:
146
161
 
147
162
  ```bash
148
163
  bun run validate:generated
@@ -150,7 +165,7 @@ bun run validate:generated -- --variant bun-hono
150
165
  bun run validate:generated -- --variant go-connectrpc --keep
151
166
  ```
152
167
 
153
- The validation harness scaffolds generated services into ignored `bin/generated/run-*` workspaces, runs the generated public commands, starts the local server, and smoke-tests health or typed ConnectRPC clients where applicable.
168
+ The validation harness scaffolds generated services into ignored `bin/generated/run-*` workspaces, runs the generated public commands, starts the local server for Cloud Run presets, smoke-tests health plus ConnectRPC clients where applicable, and verifies the Workers preset package compiles and tests.
154
169
 
155
170
  ## npm Trusted Publishing
156
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  "dev": "bun run index.ts",
22
22
  "test": "bun test src scripts",
23
23
  "validate:generated": "bun run ./scripts/validate-generated.ts",
24
- "typecheck": "bunx tsc --noEmit"
24
+ "typecheck": "tsc --noEmit"
25
25
  },
26
26
  "repository": {
27
27
  "type": "git",
@@ -4,16 +4,23 @@ import { buildDeploymentVerificationCommands, buildPostScaffoldCommands } from "
4
4
  describe("buildPostScaffoldCommands", () => {
5
5
  test("runs create and deploy for HTTP services", () => {
6
6
  expect(buildPostScaffoldCommands({ framework: "hono" })).toEqual([
7
- { command: "bun", args: ["run", "service", "--", "create"] },
8
- { command: "bun", args: ["run", "service", "--", "deploy"] },
7
+ { command: "bun", args: ["./scripts/cloudrun/cli.ts", "create"] },
8
+ { command: "bun", args: ["./scripts/cloudrun/cli.ts", "deploy"] },
9
9
  ]);
10
10
  });
11
11
 
12
12
  test("builds SDK artifacts before create and deploy for ConnectRPC services", () => {
13
13
  expect(buildPostScaffoldCommands({ framework: "connectrpc" })).toEqual([
14
- { command: "bun", args: ["run", "service", "--", "sdk", "build"] },
15
- { command: "bun", args: ["run", "service", "--", "create"] },
16
- { command: "bun", args: ["run", "service", "--", "deploy"] },
14
+ { command: "bun", args: ["./scripts/cloudrun/cli.ts", "sdk", "build"] },
15
+ { command: "bun", args: ["./scripts/cloudrun/cli.ts", "create"] },
16
+ { command: "bun", args: ["./scripts/cloudrun/cli.ts", "deploy"] },
17
+ ]);
18
+ });
19
+
20
+ test("uses the workers service CLI for workers services", () => {
21
+ expect(buildPostScaffoldCommands({ target: "workers", framework: "hono" })).toEqual([
22
+ { command: "bun", args: ["./scripts/workers/cli.ts", "create"] },
23
+ { command: "bun", args: ["./scripts/workers/cli.ts", "deploy"] },
17
24
  ]);
18
25
  });
19
26
  });
@@ -23,13 +30,40 @@ describe("buildDeploymentVerificationCommands", () => {
23
30
  expect(buildDeploymentVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "hono", runtime: "bun" })).toEqual([
24
31
  { command: "curl", args: ["--fail", "--show-error", "--silent", "https://api.launch.anmho.com/healthz"] },
25
32
  { command: "curl", args: ["--fail", "--show-error", "--silent", "https://api.launch.anmho.com/readyz"] },
33
+ {
34
+ command: "sh",
35
+ args: [
36
+ "-c",
37
+ 'TOKEN="$(bun ./scripts/cloudrun/cli.ts auth token)" && curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://api.launch.anmho.com/v1/admin/waitlist?limit=1"',
38
+ ],
39
+ },
26
40
  ]);
27
41
  });
28
42
 
29
- test("uses grpcurl for Go ConnectRPC services", () => {
43
+ test("uses auth token and grpcurl for Go ConnectRPC services", () => {
30
44
  expect(buildDeploymentVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "connectrpc", runtime: "go" })).toContainEqual({
31
- command: "grpcurl",
32
- args: ["api.launch.anmho.com:443", "list"],
45
+ command: "sh",
46
+ args: [
47
+ "-c",
48
+ 'TOKEN="$(bun ./scripts/cloudrun/cli.ts auth token)" && grpcurl -H "Authorization: Bearer $TOKEN" -d \'{"limit":1}\' -proto protos/waitlist/v1/waitlist.proto api.launch.anmho.com:443 waitlist.v1.WaitlistService/ListWaitlistEntries',
49
+ ],
50
+ });
51
+ });
52
+
53
+ test("uses the workers service CLI for protected workers verification", () => {
54
+ expect(
55
+ buildDeploymentVerificationCommands({
56
+ target: "workers",
57
+ apiHostname: "api.launch.anmho.com",
58
+ framework: "hono",
59
+ runtime: "bun",
60
+ })
61
+ ).toContainEqual({
62
+ command: "sh",
63
+ args: [
64
+ "-c",
65
+ 'TOKEN="$(bun ./scripts/workers/cli.ts auth token)" && curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://api.launch.anmho.com/v1/admin/waitlist?limit=1"',
66
+ ],
33
67
  });
34
68
  });
35
69
  });
@@ -37,30 +37,86 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
37
37
  }
38
38
 
39
39
  export function buildDeploymentVerificationCommands(
40
- config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime">
40
+ config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime"> & Partial<Pick<ScaffoldConfig, "target">>
41
41
  ): PostScaffoldCommand[] {
42
42
  const origin = `https://${config.apiHostname}`;
43
+ const tokenCommand = `TOKEN="$(bun ${serviceCliPath(config)} auth token)"`;
43
44
  return [
44
45
  { command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/healthz`] },
45
46
  { command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/readyz`] },
46
- ...(config.framework === "connectrpc"
47
- ? [
48
- config.runtime === "go"
49
- ? { command: "grpcurl", args: [`${config.apiHostname}:443`, "list"] }
50
- : { command: "curl", args: ["--fail", "--show-error", "--silent", `${origin}/debug/connectrpc`] },
51
- ]
52
- : []),
47
+ protectedVerificationCommand(config, origin, tokenCommand),
53
48
  ];
54
49
  }
55
50
 
56
- export function buildPostScaffoldCommands(config: Pick<ScaffoldConfig, "framework">): PostScaffoldCommand[] {
51
+ function protectedVerificationCommand(
52
+ config: Pick<ScaffoldConfig, "apiHostname" | "framework" | "runtime">,
53
+ origin: string,
54
+ tokenCommand: string
55
+ ): PostScaffoldCommand {
56
+ if (config.framework === "connectrpc" && config.runtime === "go") {
57
+ return {
58
+ command: "sh",
59
+ args: [
60
+ "-c",
61
+ [
62
+ `${tokenCommand} &&`,
63
+ "grpcurl",
64
+ '-H "Authorization: Bearer $TOKEN"',
65
+ "-d '{\"limit\":1}'",
66
+ "-proto protos/waitlist/v1/waitlist.proto",
67
+ `${config.apiHostname}:443`,
68
+ "waitlist.v1.WaitlistService/ListWaitlistEntries",
69
+ ].join(" "),
70
+ ],
71
+ };
72
+ }
73
+
74
+ if (config.framework === "connectrpc") {
75
+ return {
76
+ command: "sh",
77
+ args: [
78
+ "-c",
79
+ [
80
+ `${tokenCommand} &&`,
81
+ "curl --fail --show-error --silent",
82
+ '-H "Authorization: Bearer $TOKEN"',
83
+ '-H "Content-Type: application/json"',
84
+ "-d '{\"limit\":1}'",
85
+ `"${origin}/waitlist.v1.WaitlistService/ListWaitlistEntries"`,
86
+ ].join(" "),
87
+ ],
88
+ };
89
+ }
90
+
91
+ return {
92
+ command: "sh",
93
+ args: [
94
+ "-c",
95
+ [
96
+ `${tokenCommand} &&`,
97
+ "curl --fail --show-error --silent",
98
+ '-H "Authorization: Bearer $TOKEN"',
99
+ `"${origin}/v1/admin/waitlist?limit=1"`,
100
+ ].join(" "),
101
+ ],
102
+ };
103
+ }
104
+
105
+ export function buildPostScaffoldCommands(
106
+ config: Pick<ScaffoldConfig, "framework"> & Partial<Pick<ScaffoldConfig, "target">>
107
+ ): PostScaffoldCommand[] {
108
+ const serviceCli = serviceCliPath(config);
57
109
  return [
58
- ...(config.framework === "connectrpc" ? [{ command: "bun", args: ["run", "service", "--", "sdk", "build"] }] : []),
59
- { command: "bun", args: ["run", "service", "--", "create"] },
60
- { command: "bun", args: ["run", "service", "--", "deploy"] },
110
+ ...(config.target !== "workers" && config.framework === "connectrpc" ? [{ command: "bun", args: [serviceCli, "sdk", "build"] }] : []),
111
+ { command: "bun", args: [serviceCli, "create"] },
112
+ { command: "bun", args: [serviceCli, "deploy"] },
61
113
  ];
62
114
  }
63
115
 
116
+ function serviceCliPath(config: Partial<Pick<ScaffoldConfig, "target">>) {
117
+ return config.target === "workers" ? "./scripts/workers/cli.ts" : "./scripts/cloudrun/cli.ts";
118
+ }
119
+
64
120
  function installProjectDependencies(cwd: string) {
65
121
  requireCommand("bun");
66
122
  run("bun", ["install"], { cwd });
@@ -218,6 +218,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
218
218
  expect(serviceCli).toContain("Provision auth, database, migrations, and first deploy");
219
219
  expect(serviceCli).toContain("assertServiceNameAvailable(config.serviceName)");
220
220
  expect(serviceCli).toContain("ensureAuthResourceServer");
221
+ expect(serviceCli).toContain("ensureAuthClient");
222
+ expect(serviceCli).toContain("auth token");
221
223
  expect(serviceCli).toContain('["resources", "push", "--path", "./grafana"]');
222
224
  const cloudrunLib = await Bun.file(join(generatedRoot, "scripts", "cloudrun", "lib.ts")).text();
223
225
  expect(cloudrunLib).toContain("resolveTemporalRuntimeConfig");
@@ -229,6 +231,9 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
229
231
  expect(authctlScript).toContain("resource-servers");
230
232
  expect(authctlScript).toContain("clients");
231
233
  expect(authctlScript).toContain("defaultClientTargetArgs");
234
+ expect(authctlScript).toContain("ensureAuthClient");
235
+ expect(authctlScript).toContain("mintAuthToken");
236
+ expect(authctlScript).toContain("clientVaultPath");
232
237
  expect(authctlScript).toContain("deleteAuthResourceServer");
233
238
  expect(authctlScript).toContain("readAuthctlAccessVaultField");
234
239
  expect(authctlScript).toContain("prod/apps/auth/authctl/cloudflare-access");
@@ -363,6 +368,8 @@ test("scaffolds the workers target with wrangler lifecycle commands", async () =
363
368
  expect(workerCli).toContain("hyperdrive");
364
369
  expect(workerCli).toContain('["resources", "push", "--path", "./grafana"]');
365
370
  expect(workerCli).toContain("ensureAuthResourceServer");
371
+ expect(workerCli).toContain("ensureAuthClient");
372
+ expect(workerCli).toContain("auth token");
366
373
  expect(workerCli).toContain("Workers database schema applied");
367
374
  expect(workerCli).toContain("create table if not exists waitlist_entries");
368
375
  expect(workerCli).toContain("DATABASE_URL or NEON_API_KEY is required to provision the Hyperdrive binding");
package/src/scaffold.ts CHANGED
@@ -218,6 +218,7 @@ function buildReplacements(config: ScaffoldConfig) {
218
218
  COMMAND_DEPLOY: "service deploy",
219
219
  COMMAND_AUTH_RESOURCE: "service auth resource-server",
220
220
  COMMAND_AUTH_CLIENT: "service auth client create",
221
+ COMMAND_AUTH_TOKEN: "service auth token",
221
222
  COMMAND_DEPLOY_PERSONAL: "service deploy --environment personal --name <name>",
222
223
  COMMAND_DEPLOY_DESTROY: "service destroy --environment personal --name <name>",
223
224
  COMMAND_CLEANUP: "service destroy",
@@ -236,9 +237,42 @@ function buildReplacements(config: ScaffoldConfig) {
236
237
  "- override with `ENABLE_RPC_INTROSPECTION=true|false`",
237
238
  ].join("\n")
238
239
  : "",
240
+ PRODUCTION_PROTECTED_CHECKS: buildProtectedChecks(config),
239
241
  };
240
242
  }
241
243
 
244
+ function buildProtectedChecks(config: ScaffoldConfig) {
245
+ const tokenCommand = 'TOKEN="$(service auth token)"';
246
+ if (config.framework === "connectrpc" && config.runtime === "go") {
247
+ return [
248
+ "After deploy, verify protected reads with:",
249
+ "",
250
+ "```bash",
251
+ tokenCommand,
252
+ `grpcurl -H "Authorization: Bearer $TOKEN" -d '{"limit":1}' -proto protos/waitlist/v1/waitlist.proto ${config.apiHostname}:443 waitlist.v1.WaitlistService/ListWaitlistEntries`,
253
+ "```",
254
+ ].join("\n");
255
+ }
256
+ if (config.framework === "connectrpc") {
257
+ return [
258
+ "After deploy, verify protected reads with:",
259
+ "",
260
+ "```bash",
261
+ tokenCommand,
262
+ `curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"limit":1}' "https://${config.apiHostname}/waitlist.v1.WaitlistService/ListWaitlistEntries"`,
263
+ "```",
264
+ ].join("\n");
265
+ }
266
+ return [
267
+ "After deploy, verify protected reads with:",
268
+ "",
269
+ "```bash",
270
+ tokenCommand,
271
+ `curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://${config.apiHostname}/v1/admin/waitlist?limit=1"`,
272
+ "```",
273
+ ].join("\n");
274
+ }
275
+
242
276
  async function writeLocalEnvFile(targetDir: string, replacements: Record<string, string>) {
243
277
  const envPath = join(targetDir, ".env.local");
244
278
  if (await Bun.file(envPath).exists()) {
@@ -179,6 +179,7 @@ Use `service auth` for follow-up auth operations:
179
179
  {{COMMAND_BOOTSTRAP}} # includes resource-server registration
180
180
  {{COMMAND_AUTH_RESOURCE}}
181
181
  {{COMMAND_AUTH_CLIENT}} --resource-server <target-service> --scope <scope>
182
+ {{COMMAND_AUTH_TOKEN}}
182
183
  ```
183
184
 
184
185
  `authctl clients create` prints a one-time client secret plus the recommended
@@ -194,6 +195,10 @@ target resource server as `resource=api://<resource_server_id>`. The generated
194
195
  runtime expects a JWT with that audience; omitting `resource` can return an
195
196
  opaque access token that the service will reject.
196
197
 
198
+ `{{COMMAND_AUTH_TOKEN}}` reads the generated service-client credentials from
199
+ Vault, creates and stores them if missing, and prints a bearer token suitable
200
+ for local smoke checks.
201
+
197
202
  Webhook signature hooks are provider-specific and optional in v1. Add provider
198
203
  secrets only when you add a provider adapter. A generic adapter can honor:
199
204
 
@@ -231,6 +236,8 @@ The main environment is intended to be public at:
231
236
  https://{{API_HOSTNAME}}
232
237
  ```
233
238
 
239
+ {{PRODUCTION_PROTECTED_CHECKS}}
240
+
234
241
  Preview and personal environments keep using deterministic Cloud Run hostnames in v1 so the generated backend stays easy to use inside standalone repos or monorepos.
235
242
 
236
243
  ## Generated backend domain
@@ -253,6 +260,7 @@ Hono variants expose:
253
260
  - `POST /v1/waitlist`
254
261
  - `GET /v1/waitlist?email=...`
255
262
  - `GET /v1/waitlist/{entryId}`
263
+ - `GET /v1/admin/waitlist`
256
264
  - `POST /v1/triggers/waitlist`
257
265
  - `POST /webhooks/:provider`
258
266
 
@@ -27,6 +27,17 @@ type ResourceServerMutationCommand = ResourceServerCommand & {
27
27
  mutationAction: "upsert" | "create";
28
28
  };
29
29
 
30
+ type ClientCredentials = {
31
+ client_id: string;
32
+ client_secret: string;
33
+ };
34
+
35
+ type TokenResponse = {
36
+ access_token?: string;
37
+ token_type?: string;
38
+ expires_in?: number;
39
+ };
40
+
30
41
  export function defaultAuthResourceServerArgs() {
31
42
  const auth = serviceConfig.auth;
32
43
  return [
@@ -92,31 +103,66 @@ export function runAuthCommand(args: string[]) {
92
103
  return runClientCommand(action, rest);
93
104
  }
94
105
 
95
- throw new Error("Usage: service auth <doctor|resource-server|client> [args]");
106
+ if (subject === "token") {
107
+ return mintAuthToken(parseTokenOptions([action, ...rest].filter(Boolean) as string[]));
108
+ }
109
+
110
+ throw new Error("Usage: service auth <doctor|resource-server|client|token> [args]");
96
111
  }
97
112
 
98
113
  export function ensureAuthResourceServer() {
99
114
  const command = ensureResourceServerCommandAvailable();
100
- authctl([command.subject, command.mutationAction, ...defaultAuthResourceServerArgs(), "--json"]);
115
+ authctl([command.subject, command.mutationAction, ...defaultAuthResourceServerArgs(), "--json"], { quiet: true });
101
116
  return `Auth resource server ready: ${serviceConfig.auth.resource_server.audience}`;
102
117
  }
103
118
 
119
+ export function ensureAuthClient() {
120
+ const existing = readStoredClientCredentials();
121
+ if (existing) {
122
+ return `Auth client ready: ${existing.client_id}`;
123
+ }
124
+
125
+ const result = authctl(
126
+ [
127
+ "clients",
128
+ "create",
129
+ "--client-app",
130
+ serviceConfig.auth.client.app_id,
131
+ "--client-identity",
132
+ serviceConfig.auth.client.identity,
133
+ ...defaultClientTargetArgs([]),
134
+ "--stage",
135
+ serviceConfig.stage_default,
136
+ "--yes",
137
+ "--json",
138
+ ],
139
+ { quiet: true }
140
+ );
141
+
142
+ const created = parseClientSecretResponse(result.stdout);
143
+ storeClientCredentials(created);
144
+ return `Auth client ready: ${created.client_id}`;
145
+ }
146
+
104
147
  export function deleteAuthResourceServer() {
105
148
  const command = resolveResourceServerCommand();
106
149
  if (!command?.actions.includes("delete")) {
107
150
  return "authctl does not expose resource-server delete; auth resource server was not deleted";
108
151
  }
109
152
 
110
- authctl([
111
- command.subject,
112
- "delete",
113
- "--resource-server",
114
- serviceConfig.auth.resource_server.id,
115
- "--stage",
116
- serviceConfig.stage_default,
117
- "--force",
118
- "--json",
119
- ]);
153
+ authctl(
154
+ [
155
+ command.subject,
156
+ "delete",
157
+ "--resource-server",
158
+ serviceConfig.auth.resource_server.id,
159
+ "--stage",
160
+ serviceConfig.stage_default,
161
+ "--force",
162
+ "--json",
163
+ ],
164
+ { quiet: true }
165
+ );
120
166
  return `Auth resource server deleted: ${serviceConfig.auth.resource_server.id}`;
121
167
  }
122
168
 
@@ -161,8 +207,12 @@ export function runAuthDoctor(): AuthDoctorResult {
161
207
  }
162
208
 
163
209
  function runClientCommand(action = "", rest: string[]) {
210
+ if (!action || action === "ensure") {
211
+ return ensureAuthClient();
212
+ }
213
+
164
214
  if (action === "create") {
165
- authctl([
215
+ const result = authctl([
166
216
  "clients",
167
217
  "create",
168
218
  "--client-app",
@@ -176,6 +226,8 @@ function runClientCommand(action = "", rest: string[]) {
176
226
  "--json",
177
227
  ...rest,
178
228
  ]);
229
+ const created = parseClientSecretResponse(result.stdout);
230
+ storeClientCredentials(created);
179
231
  return "Auth client created";
180
232
  }
181
233
 
@@ -196,6 +248,112 @@ function defaultClientTargetArgs(rest: string[]) {
196
248
  ];
197
249
  }
198
250
 
251
+ function parseTokenOptions(args: string[]) {
252
+ const options: { json: boolean; scope?: string[]; resource?: string; audience?: string } = { json: false };
253
+ for (let index = 0; index < args.length; index += 1) {
254
+ const token = args[index];
255
+ if (!token) continue;
256
+ const next = args[index + 1];
257
+ const readValue = () => {
258
+ if (!next || next.startsWith("-")) {
259
+ throw new Error(`Missing value for ${token}`);
260
+ }
261
+ index += 1;
262
+ return next;
263
+ };
264
+ if (token === "--json") {
265
+ options.json = true;
266
+ continue;
267
+ }
268
+ if (token === "--scope") {
269
+ options.scope = [...(options.scope ?? []), readValue()];
270
+ continue;
271
+ }
272
+ if (token.startsWith("--scope=")) {
273
+ options.scope = [...(options.scope ?? []), token.slice("--scope=".length)];
274
+ continue;
275
+ }
276
+ if (token === "--resource") {
277
+ options.resource = readValue();
278
+ continue;
279
+ }
280
+ if (token.startsWith("--resource=")) {
281
+ options.resource = token.slice("--resource=".length);
282
+ continue;
283
+ }
284
+ if (token === "--audience") {
285
+ options.audience = readValue();
286
+ continue;
287
+ }
288
+ if (token.startsWith("--audience=")) {
289
+ options.audience = token.slice("--audience=".length);
290
+ continue;
291
+ }
292
+ throw new Error(`Unknown service auth token argument: ${token}`);
293
+ }
294
+ return options;
295
+ }
296
+
297
+ function mintAuthToken(options: { json: boolean; scope?: string[]; resource?: string; audience?: string }) {
298
+ const credentials = readStoredClientCredentials() ?? createAndStoreClientCredentials();
299
+ const scopes = options.scope?.length ? options.scope : serviceConfig.auth.resource_server.default_scopes;
300
+ const resource = options.resource ?? serviceConfig.auth.resource_server.audience;
301
+ const token = requestClientCredentialsToken(credentials, {
302
+ scope: scopes.join(" "),
303
+ resource,
304
+ audience: options.audience,
305
+ });
306
+
307
+ if (options.json) {
308
+ return JSON.stringify(token, null, 2);
309
+ }
310
+
311
+ if (!token.access_token) {
312
+ throw new Error("token response did not include access_token");
313
+ }
314
+ return token.access_token;
315
+ }
316
+
317
+ function createAndStoreClientCredentials() {
318
+ ensureAuthClient();
319
+ const stored = readStoredClientCredentials();
320
+ if (!stored) {
321
+ throw new Error(`Auth client credentials were not written to Vault path ${clientVaultPath()}`);
322
+ }
323
+ return stored;
324
+ }
325
+
326
+ function requestClientCredentialsToken(credentials: ClientCredentials, options: { scope: string; resource: string; audience?: string }) {
327
+ const body = new URLSearchParams({
328
+ grant_type: "client_credentials",
329
+ scope: options.scope,
330
+ resource: options.resource,
331
+ });
332
+ if (options.audience) {
333
+ body.set("audience", options.audience);
334
+ }
335
+
336
+ const response = fetchSync(serviceConfig.auth.token_endpoint, {
337
+ method: "POST",
338
+ headers: {
339
+ authorization: `Basic ${basicAuth(credentials.client_id, credentials.client_secret)}`,
340
+ "content-type": "application/x-www-form-urlencoded",
341
+ accept: "application/json",
342
+ },
343
+ body: body.toString(),
344
+ });
345
+
346
+ if (response.status < 200 || response.status >= 300) {
347
+ throw new Error(`token request failed: ${response.status} ${response.body}`);
348
+ }
349
+
350
+ const token = JSON.parse(response.body) as TokenResponse;
351
+ if (token.token_type?.toLowerCase() !== "bearer" || !token.access_token) {
352
+ throw new Error("token response did not include a bearer access_token");
353
+ }
354
+ return token;
355
+ }
356
+
199
357
  function hasFlag(args: string[], name: string) {
200
358
  return args.some((arg) => arg === name || arg.startsWith(`${name}=`));
201
359
  }
@@ -332,3 +490,112 @@ function readAuthctlAccessVaultField(env: Record<string, string | undefined>, fi
332
490
 
333
491
  return decoder.decode(result.stdout).trim();
334
492
  }
493
+
494
+ function parseClientSecretResponse(stdout: string): ClientCredentials {
495
+ if (!stdout) {
496
+ throw new Error("authctl did not return client credentials");
497
+ }
498
+ const parsed = JSON.parse(stdout) as { client_id?: string; client_secret?: string };
499
+ if (!parsed.client_id || !parsed.client_secret) {
500
+ throw new Error("authctl client create did not return one-time client credentials");
501
+ }
502
+ return {
503
+ client_id: parsed.client_id,
504
+ client_secret: parsed.client_secret,
505
+ };
506
+ }
507
+
508
+ function readStoredClientCredentials(): ClientCredentials | undefined {
509
+ const clientId = readVaultField(clientVaultPath(), "client_id");
510
+ const clientSecret = readVaultField(clientVaultPath(), "client_secret");
511
+ if (!clientId || !clientSecret) {
512
+ return undefined;
513
+ }
514
+ return {
515
+ client_id: clientId,
516
+ client_secret: clientSecret,
517
+ };
518
+ }
519
+
520
+ function storeClientCredentials(credentials: ClientCredentials) {
521
+ const vault = requireVaultCommand();
522
+ const result = Bun.spawnSync(
523
+ [
524
+ vault,
525
+ "kv",
526
+ "put",
527
+ `-mount=${vaultMount()}`,
528
+ clientVaultPath(),
529
+ `client_id=${credentials.client_id}`,
530
+ `client_secret=${credentials.client_secret}`,
531
+ ],
532
+ {
533
+ cwd: process.cwd(),
534
+ env: process.env,
535
+ stdout: "pipe",
536
+ stderr: "pipe",
537
+ }
538
+ );
539
+ if (!result.success) {
540
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
541
+ throw new Error(`failed to store auth client credentials in Vault at ${clientVaultPath()}\n${stderr}`);
542
+ }
543
+ }
544
+
545
+ function readVaultField(path: string, field: string) {
546
+ const vault = Bun.which("vault");
547
+ if (!vault) {
548
+ return "";
549
+ }
550
+ const result = Bun.spawnSync([vault, "kv", "get", `-mount=${vaultMount()}`, `-field=${field}`, path], {
551
+ cwd: process.cwd(),
552
+ env: process.env,
553
+ stdout: "pipe",
554
+ stderr: "pipe",
555
+ });
556
+ if (!result.success || !result.stdout) {
557
+ return "";
558
+ }
559
+ return decoder.decode(result.stdout).trim();
560
+ }
561
+
562
+ function requireVaultCommand() {
563
+ const vault = Bun.which("vault");
564
+ if (!vault) {
565
+ throw new Error("vault is required to store generated auth client credentials");
566
+ }
567
+ return vault;
568
+ }
569
+
570
+ function vaultMount() {
571
+ return serviceConfig.providers.vault.mount || "secret";
572
+ }
573
+
574
+ function clientVaultPath() {
575
+ return `${serviceConfig.auth.client.vault_path_prefix}/${serviceConfig.auth.resource_server.id}`;
576
+ }
577
+
578
+ function basicAuth(clientId: string, clientSecret: string) {
579
+ return Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
580
+ }
581
+
582
+ function fetchSync(url: string, init: { method: string; headers: Record<string, string>; body: string }) {
583
+ const script = [
584
+ "const url = process.argv[1];",
585
+ "const init = JSON.parse(process.argv[2]);",
586
+ "const response = await fetch(url, init);",
587
+ "const body = await response.text();",
588
+ "console.log(JSON.stringify({ status: response.status, body }));",
589
+ ].join("\n");
590
+ const result = Bun.spawnSync([process.execPath, "--eval", script, url, JSON.stringify(init)], {
591
+ cwd: process.cwd(),
592
+ env: process.env,
593
+ stdout: "pipe",
594
+ stderr: "pipe",
595
+ });
596
+ if (!result.success) {
597
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
598
+ throw new Error(`token request process failed\n${stderr}`);
599
+ }
600
+ return JSON.parse(decoder.decode(result.stdout).trim()) as { status: number; body: string };
601
+ }
@@ -17,13 +17,13 @@ import {
17
17
  runStep,
18
18
  } from "./lib";
19
19
 
20
- export async function bootstrap() {
20
+ export async function bootstrap(options: { skipProjectSetup?: boolean } = {}) {
21
21
  requireCommand("gcloud");
22
22
  requireGcloudAuth();
23
23
 
24
- await runStep("Ensuring GCP project", () => ensureProject());
25
- await runStep("Attaching billing", () => attachBilling());
26
- await runStep("Enabling required GCP APIs", () => gcloud(["services", "enable", ...config.requiredApis, "--project", config.project.id]));
24
+ if (!options.skipProjectSetup) {
25
+ await prepareGcpProject();
26
+ }
27
27
 
28
28
  await runStep("Ensuring runtime service account", () => {
29
29
  ensureServiceAccount(config.runtimeServiceAccount);
@@ -53,6 +53,12 @@ export async function bootstrap() {
53
53
  await runStep("Publishing Temporal secrets", () => publishTemporalSecrets());
54
54
  }
55
55
 
56
+ export async function prepareGcpProject() {
57
+ await runStep("Ensuring GCP project", () => ensureProject());
58
+ await runStep("Attaching billing", () => attachBilling());
59
+ await runStep("Enabling required GCP APIs", () => gcloud(["services", "enable", ...config.requiredApis, "--project", config.project.id]));
60
+ }
61
+
56
62
  function publishTemporalSecrets() {
57
63
  const temporal = resolveTemporalRuntimeConfig();
58
64
  const apiKey = process.env.TEMPORAL_API_KEY?.trim();
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { mkdir } from "node:fs/promises";
4
- import { ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
5
- import { bootstrap } from "./bootstrap";
4
+ import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
5
+ import { bootstrap, prepareGcpProject } from "./bootstrap";
6
6
  import { cleanup } from "./cleanup";
7
7
  import { deploy } from "./deploy";
8
8
  import { config } from "./config";
@@ -35,8 +35,10 @@ async function main(argv = Bun.argv.slice(2)) {
35
35
  await runMain("Create", async () => {
36
36
  assertServiceNameAvailable(config.serviceName);
37
37
  assertProductionDomainAvailable(config.serviceName);
38
+ await prepareGcpProject();
38
39
  await runStep("Registering auth resource server", () => ensureAuthResourceServer());
39
- await bootstrap();
40
+ await runStep("Provisioning auth client", () => ensureAuthClient());
41
+ await bootstrap({ skipProjectSetup: true });
40
42
  const target = resolveDeploymentTarget("main");
41
43
  const databaseUrl = await runStep("Reading production database URL", () => accessSecretVersion(target.databaseSecretName));
42
44
  await runStep("Applying production migrations", () => runLanguageTask("migrate", { DATABASE_URL: databaseUrl }));
@@ -78,6 +80,10 @@ async function main(argv = Bun.argv.slice(2)) {
78
80
  }
79
81
 
80
82
  if (command === "auth") {
83
+ if (rest[0] === "token") {
84
+ console.log(runAuthCommand(rest));
85
+ return;
86
+ }
81
87
  await runMain("Auth", () => runAuthCommand(rest));
82
88
  return;
83
89
  }
@@ -107,6 +113,7 @@ function formatHelp() {
107
113
  " seed Run the seed script when configured",
108
114
  " doctor Check local tools and cloud access",
109
115
  " auth Manage auth resource server and clients",
116
+ " auth token Mint a bearer token for protected API checks",
110
117
  " sdk Build or publish generated SDK artifacts",
111
118
  " dns Repair or inspect DNS mappings",
112
119
  " dashboards Publish Grafana resources",
@@ -12,7 +12,7 @@ gen:
12
12
  @echo "no generated code for workers"
13
13
 
14
14
  lint:
15
- bunx tsc --noEmit
15
+ bun run lint
16
16
 
17
17
  test:
18
18
  bun test
@@ -43,6 +43,7 @@ small waitlist/trigger schema on first use.
43
43
  - `POST /v1/waitlist`
44
44
  - `GET /v1/waitlist?email=<email>`
45
45
  - `GET /v1/waitlist/:entryId`
46
+ - `GET /v1/admin/waitlist`
46
47
  - `POST /v1/triggers/waitlist`
47
48
  - `POST /webhooks/:provider`
48
49
  - `GET /webhooks/:provider/health`
@@ -63,6 +64,8 @@ https://{{API_HOSTNAME}}
63
64
  Use `service doctor` after create to verify Wrangler auth, route config, Cron,
64
65
  Hyperdrive, dashboard tooling, auth tooling, and deployed health.
65
66
 
67
+ {{PRODUCTION_PROTECTED_CHECKS}}
68
+
66
69
  If the Hyperdrive binding id is empty, `service create` uses `DATABASE_URL`, or
67
70
  `NEON_API_KEY` to create/resolve the generated Neon database and connection URI,
68
71
  applies the waitlist schema, then runs `wrangler hyperdrive create` and writes
@@ -10,7 +10,7 @@
10
10
  "service": "bun run ./scripts/workers/cli.ts",
11
11
  "migrate": "bun run ./scripts/workers/cli.ts migrate",
12
12
  "seed": "bun run ./scripts/workers/cli.ts seed",
13
- "lint": "bunx tsc --noEmit",
13
+ "lint": "tsc --noEmit",
14
14
  "test": "bun test",
15
15
  "create": "bun run ./scripts/workers/cli.ts create",
16
16
  "deploy": "bun run ./scripts/workers/cli.ts deploy",
@@ -3,7 +3,7 @@
3
3
  import { confirm, intro, isCancel, log, outro } from "@clack/prompts";
4
4
  import { createApiClient } from "@neondatabase/api-client";
5
5
  import { Client } from "pg";
6
- import { ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
6
+ import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
7
7
 
8
8
  const config = {
9
9
  serviceName: "{{SERVICE_NAME}}",
@@ -25,6 +25,7 @@ async function main(argv = Bun.argv.slice(2)) {
25
25
  if (command === "create") {
26
26
  return runMain("Create", async () => {
27
27
  ensureAuthResourceServer();
28
+ ensureAuthClient();
28
29
  const databaseUrl = await resolveDatabaseUrl();
29
30
  await applySchema(databaseUrl);
30
31
  await ensureHyperdrive(databaseUrl);
@@ -68,6 +69,10 @@ async function main(argv = Bun.argv.slice(2)) {
68
69
  }
69
70
 
70
71
  if (command === "auth") {
72
+ if (rest[0] === "token") {
73
+ console.log(runAuthCommand(rest));
74
+ return;
75
+ }
71
76
  return runMain("Auth", () => runAuthCommand(rest));
72
77
  }
73
78
 
@@ -102,6 +107,7 @@ function formatHelp() {
102
107
  " seed Report seed status",
103
108
  " doctor Check local tools and cloud access",
104
109
  " auth Manage auth resource server and clients",
110
+ " auth token Mint a bearer token for protected API checks",
105
111
  " dns Show Workers custom-domain configuration",
106
112
  " dashboards Publish Grafana resources",
107
113
  " destroy Remove service-managed Worker resources",
@@ -12,7 +12,7 @@ gen:
12
12
  bun run ./scripts/codegen.ts
13
13
 
14
14
  lint:
15
- bunx tsc --noEmit
15
+ bun run lint
16
16
 
17
17
  test:
18
18
  bun test
@@ -10,7 +10,7 @@
10
10
  "service": "bun run ./scripts/cloudrun/cli.ts",
11
11
  "migrate": "bun run ./scripts/migrate.ts",
12
12
  "gen": "bun run ./scripts/codegen.ts",
13
- "lint": "bunx tsc --noEmit",
13
+ "lint": "tsc --noEmit",
14
14
  "test": "bun test",
15
15
  "create": "bun run ./scripts/cloudrun/cli.ts create",
16
16
  "deploy": "bun run ./scripts/cloudrun/cli.ts deploy",
@@ -12,7 +12,7 @@ gen:
12
12
  bun run ./scripts/codegen.ts
13
13
 
14
14
  lint:
15
- bunx tsc --noEmit
15
+ bun run lint
16
16
 
17
17
  test:
18
18
  bun test
@@ -10,7 +10,7 @@
10
10
  "service": "bun run ./scripts/cloudrun/cli.ts",
11
11
  "migrate": "bun run ./scripts/migrate.ts",
12
12
  "gen": "bun run ./scripts/codegen.ts",
13
- "lint": "bunx tsc --noEmit",
13
+ "lint": "tsc --noEmit",
14
14
  "test": "bun test",
15
15
  "create": "bun run ./scripts/cloudrun/cli.ts create",
16
16
  "deploy": "bun run ./scripts/cloudrun/cli.ts deploy",