create-svc 0.1.17 → 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.17",
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",
@@ -16,6 +16,13 @@ describe("buildPostScaffoldCommands", () => {
16
16
  { command: "bun", args: ["./scripts/cloudrun/cli.ts", "deploy"] },
17
17
  ]);
18
18
  });
19
+
20
+ test("uses the workers service CLI for workers services", () => {
21
+ expect(buildPostScaffoldCommands({ target: "workers", framework: "hono" })).toEqual([
22
+ { command: "bun", args: ["./scripts/workers/cli.ts", "create"] },
23
+ { command: "bun", args: ["./scripts/workers/cli.ts", "deploy"] },
24
+ ]);
25
+ });
19
26
  });
20
27
 
21
28
  describe("buildDeploymentVerificationCommands", () => {
@@ -23,13 +30,40 @@ describe("buildDeploymentVerificationCommands", () => {
23
30
  expect(buildDeploymentVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "hono", runtime: "bun" })).toEqual([
24
31
  { command: "curl", args: ["--fail", "--show-error", "--silent", "https://api.launch.anmho.com/healthz"] },
25
32
  { command: "curl", args: ["--fail", "--show-error", "--silent", "https://api.launch.anmho.com/readyz"] },
33
+ {
34
+ command: "sh",
35
+ args: [
36
+ "-c",
37
+ 'TOKEN="$(bun ./scripts/cloudrun/cli.ts auth token)" && curl --fail --show-error --silent -H "Authorization: Bearer $TOKEN" "https://api.launch.anmho.com/v1/admin/waitlist?limit=1"',
38
+ ],
39
+ },
26
40
  ]);
27
41
  });
28
42
 
29
- test("uses grpcurl for Go ConnectRPC services", () => {
43
+ test("uses auth token and grpcurl for Go ConnectRPC services", () => {
30
44
  expect(buildDeploymentVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "connectrpc", runtime: "go" })).toContainEqual({
31
- command: "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: ["./scripts/cloudrun/cli.ts", "sdk", "build"] }] : []),
59
- { command: "bun", args: ["./scripts/cloudrun/cli.ts", "create"] },
60
- { command: "bun", args: ["./scripts/cloudrun/cli.ts", "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,7 +103,11 @@ 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() {
@@ -101,6 +116,34 @@ export function ensureAuthResourceServer() {
101
116
  return `Auth resource server ready: ${serviceConfig.auth.resource_server.audience}`;
102
117
  }
103
118
 
119
+ export function ensureAuthClient() {
120
+ const existing = readStoredClientCredentials();
121
+ if (existing) {
122
+ return `Auth client ready: ${existing.client_id}`;
123
+ }
124
+
125
+ const result = authctl(
126
+ [
127
+ "clients",
128
+ "create",
129
+ "--client-app",
130
+ serviceConfig.auth.client.app_id,
131
+ "--client-identity",
132
+ serviceConfig.auth.client.identity,
133
+ ...defaultClientTargetArgs([]),
134
+ "--stage",
135
+ serviceConfig.stage_default,
136
+ "--yes",
137
+ "--json",
138
+ ],
139
+ { quiet: true }
140
+ );
141
+
142
+ const created = parseClientSecretResponse(result.stdout);
143
+ storeClientCredentials(created);
144
+ return `Auth client ready: ${created.client_id}`;
145
+ }
146
+
104
147
  export function deleteAuthResourceServer() {
105
148
  const command = resolveResourceServerCommand();
106
149
  if (!command?.actions.includes("delete")) {
@@ -164,8 +207,12 @@ export function runAuthDoctor(): AuthDoctorResult {
164
207
  }
165
208
 
166
209
  function runClientCommand(action = "", rest: string[]) {
210
+ if (!action || action === "ensure") {
211
+ return ensureAuthClient();
212
+ }
213
+
167
214
  if (action === "create") {
168
- authctl([
215
+ const result = authctl([
169
216
  "clients",
170
217
  "create",
171
218
  "--client-app",
@@ -179,6 +226,8 @@ function runClientCommand(action = "", rest: string[]) {
179
226
  "--json",
180
227
  ...rest,
181
228
  ]);
229
+ const created = parseClientSecretResponse(result.stdout);
230
+ storeClientCredentials(created);
182
231
  return "Auth client created";
183
232
  }
184
233
 
@@ -199,6 +248,112 @@ function defaultClientTargetArgs(rest: string[]) {
199
248
  ];
200
249
  }
201
250
 
251
+ function parseTokenOptions(args: string[]) {
252
+ const options: { json: boolean; scope?: string[]; resource?: string; audience?: string } = { json: false };
253
+ for (let index = 0; index < args.length; index += 1) {
254
+ const token = args[index];
255
+ if (!token) continue;
256
+ const next = args[index + 1];
257
+ const readValue = () => {
258
+ if (!next || next.startsWith("-")) {
259
+ throw new Error(`Missing value for ${token}`);
260
+ }
261
+ index += 1;
262
+ return next;
263
+ };
264
+ if (token === "--json") {
265
+ options.json = true;
266
+ continue;
267
+ }
268
+ if (token === "--scope") {
269
+ options.scope = [...(options.scope ?? []), readValue()];
270
+ continue;
271
+ }
272
+ if (token.startsWith("--scope=")) {
273
+ options.scope = [...(options.scope ?? []), token.slice("--scope=".length)];
274
+ continue;
275
+ }
276
+ if (token === "--resource") {
277
+ options.resource = readValue();
278
+ continue;
279
+ }
280
+ if (token.startsWith("--resource=")) {
281
+ options.resource = token.slice("--resource=".length);
282
+ continue;
283
+ }
284
+ if (token === "--audience") {
285
+ options.audience = readValue();
286
+ continue;
287
+ }
288
+ if (token.startsWith("--audience=")) {
289
+ options.audience = token.slice("--audience=".length);
290
+ continue;
291
+ }
292
+ throw new Error(`Unknown service auth token argument: ${token}`);
293
+ }
294
+ return options;
295
+ }
296
+
297
+ function mintAuthToken(options: { json: boolean; scope?: string[]; resource?: string; audience?: string }) {
298
+ const credentials = readStoredClientCredentials() ?? createAndStoreClientCredentials();
299
+ const scopes = options.scope?.length ? options.scope : serviceConfig.auth.resource_server.default_scopes;
300
+ const resource = options.resource ?? serviceConfig.auth.resource_server.audience;
301
+ const token = requestClientCredentialsToken(credentials, {
302
+ scope: scopes.join(" "),
303
+ resource,
304
+ audience: options.audience,
305
+ });
306
+
307
+ if (options.json) {
308
+ return JSON.stringify(token, null, 2);
309
+ }
310
+
311
+ if (!token.access_token) {
312
+ throw new Error("token response did not include access_token");
313
+ }
314
+ return token.access_token;
315
+ }
316
+
317
+ function createAndStoreClientCredentials() {
318
+ ensureAuthClient();
319
+ const stored = readStoredClientCredentials();
320
+ if (!stored) {
321
+ throw new Error(`Auth client credentials were not written to Vault path ${clientVaultPath()}`);
322
+ }
323
+ return stored;
324
+ }
325
+
326
+ function requestClientCredentialsToken(credentials: ClientCredentials, options: { scope: string; resource: string; audience?: string }) {
327
+ const body = new URLSearchParams({
328
+ grant_type: "client_credentials",
329
+ scope: options.scope,
330
+ resource: options.resource,
331
+ });
332
+ if (options.audience) {
333
+ body.set("audience", options.audience);
334
+ }
335
+
336
+ const response = fetchSync(serviceConfig.auth.token_endpoint, {
337
+ method: "POST",
338
+ headers: {
339
+ authorization: `Basic ${basicAuth(credentials.client_id, credentials.client_secret)}`,
340
+ "content-type": "application/x-www-form-urlencoded",
341
+ accept: "application/json",
342
+ },
343
+ body: body.toString(),
344
+ });
345
+
346
+ if (response.status < 200 || response.status >= 300) {
347
+ throw new Error(`token request failed: ${response.status} ${response.body}`);
348
+ }
349
+
350
+ const token = JSON.parse(response.body) as TokenResponse;
351
+ if (token.token_type?.toLowerCase() !== "bearer" || !token.access_token) {
352
+ throw new Error("token response did not include a bearer access_token");
353
+ }
354
+ return token;
355
+ }
356
+
202
357
  function hasFlag(args: string[], name: string) {
203
358
  return args.some((arg) => arg === name || arg.startsWith(`${name}=`));
204
359
  }
@@ -335,3 +490,112 @@ function readAuthctlAccessVaultField(env: Record<string, string | undefined>, fi
335
490
 
336
491
  return decoder.decode(result.stdout).trim();
337
492
  }
493
+
494
+ function parseClientSecretResponse(stdout: string): ClientCredentials {
495
+ if (!stdout) {
496
+ throw new Error("authctl did not return client credentials");
497
+ }
498
+ const parsed = JSON.parse(stdout) as { client_id?: string; client_secret?: string };
499
+ if (!parsed.client_id || !parsed.client_secret) {
500
+ throw new Error("authctl client create did not return one-time client credentials");
501
+ }
502
+ return {
503
+ client_id: parsed.client_id,
504
+ client_secret: parsed.client_secret,
505
+ };
506
+ }
507
+
508
+ function readStoredClientCredentials(): ClientCredentials | undefined {
509
+ const clientId = readVaultField(clientVaultPath(), "client_id");
510
+ const clientSecret = readVaultField(clientVaultPath(), "client_secret");
511
+ if (!clientId || !clientSecret) {
512
+ return undefined;
513
+ }
514
+ return {
515
+ client_id: clientId,
516
+ client_secret: clientSecret,
517
+ };
518
+ }
519
+
520
+ function storeClientCredentials(credentials: ClientCredentials) {
521
+ const vault = requireVaultCommand();
522
+ const result = Bun.spawnSync(
523
+ [
524
+ vault,
525
+ "kv",
526
+ "put",
527
+ `-mount=${vaultMount()}`,
528
+ clientVaultPath(),
529
+ `client_id=${credentials.client_id}`,
530
+ `client_secret=${credentials.client_secret}`,
531
+ ],
532
+ {
533
+ cwd: process.cwd(),
534
+ env: process.env,
535
+ stdout: "pipe",
536
+ stderr: "pipe",
537
+ }
538
+ );
539
+ if (!result.success) {
540
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
541
+ throw new Error(`failed to store auth client credentials in Vault at ${clientVaultPath()}\n${stderr}`);
542
+ }
543
+ }
544
+
545
+ function readVaultField(path: string, field: string) {
546
+ const vault = Bun.which("vault");
547
+ if (!vault) {
548
+ return "";
549
+ }
550
+ const result = Bun.spawnSync([vault, "kv", "get", `-mount=${vaultMount()}`, `-field=${field}`, path], {
551
+ cwd: process.cwd(),
552
+ env: process.env,
553
+ stdout: "pipe",
554
+ stderr: "pipe",
555
+ });
556
+ if (!result.success || !result.stdout) {
557
+ return "";
558
+ }
559
+ return decoder.decode(result.stdout).trim();
560
+ }
561
+
562
+ function requireVaultCommand() {
563
+ const vault = Bun.which("vault");
564
+ if (!vault) {
565
+ throw new Error("vault is required to store generated auth client credentials");
566
+ }
567
+ return vault;
568
+ }
569
+
570
+ function vaultMount() {
571
+ return serviceConfig.providers.vault.mount || "secret";
572
+ }
573
+
574
+ function clientVaultPath() {
575
+ return `${serviceConfig.auth.client.vault_path_prefix}/${serviceConfig.auth.resource_server.id}`;
576
+ }
577
+
578
+ function basicAuth(clientId: string, clientSecret: string) {
579
+ return Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
580
+ }
581
+
582
+ function fetchSync(url: string, init: { method: string; headers: Record<string, string>; body: string }) {
583
+ const script = [
584
+ "const url = process.argv[1];",
585
+ "const init = JSON.parse(process.argv[2]);",
586
+ "const response = await fetch(url, init);",
587
+ "const body = await response.text();",
588
+ "console.log(JSON.stringify({ status: response.status, body }));",
589
+ ].join("\n");
590
+ const result = Bun.spawnSync([process.execPath, "--eval", script, url, JSON.stringify(init)], {
591
+ cwd: process.cwd(),
592
+ env: process.env,
593
+ stdout: "pipe",
594
+ stderr: "pipe",
595
+ });
596
+ if (!result.success) {
597
+ const stderr = result.stderr ? decoder.decode(result.stderr).trim() : "";
598
+ throw new Error(`token request process failed\n${stderr}`);
599
+ }
600
+ return JSON.parse(decoder.decode(result.stdout).trim()) as { status: number; body: string };
601
+ }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { mkdir } from "node:fs/promises";
4
- import { ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
4
+ import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
5
5
  import { bootstrap, prepareGcpProject } from "./bootstrap";
6
6
  import { cleanup } from "./cleanup";
7
7
  import { deploy } from "./deploy";
@@ -37,6 +37,7 @@ async function main(argv = Bun.argv.slice(2)) {
37
37
  assertProductionDomainAvailable(config.serviceName);
38
38
  await prepareGcpProject();
39
39
  await runStep("Registering auth resource server", () => ensureAuthResourceServer());
40
+ await runStep("Provisioning auth client", () => ensureAuthClient());
40
41
  await bootstrap({ skipProjectSetup: true });
41
42
  const target = resolveDeploymentTarget("main");
42
43
  const databaseUrl = await runStep("Reading production database URL", () => accessSecretVersion(target.databaseSecretName));
@@ -79,6 +80,10 @@ async function main(argv = Bun.argv.slice(2)) {
79
80
  }
80
81
 
81
82
  if (command === "auth") {
83
+ if (rest[0] === "token") {
84
+ console.log(runAuthCommand(rest));
85
+ return;
86
+ }
82
87
  await runMain("Auth", () => runAuthCommand(rest));
83
88
  return;
84
89
  }
@@ -108,6 +113,7 @@ function formatHelp() {
108
113
  " seed Run the seed script when configured",
109
114
  " doctor Check local tools and cloud access",
110
115
  " auth Manage auth resource server and clients",
116
+ " auth token Mint a bearer token for protected API checks",
111
117
  " sdk Build or publish generated SDK artifacts",
112
118
  " dns Repair or inspect DNS mappings",
113
119
  " dashboards Publish Grafana resources",
@@ -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",