create-soda 0.2.1 → 0.2.2

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/dist/index.js CHANGED
@@ -1230,17 +1230,18 @@ import { stdin as stdin2, stdout as stdout2 } from "node:process";
1230
1230
  import { fileURLToPath } from "node:url";
1231
1231
  import { styleText as styleText3 } from "node:util";
1232
1232
  // ../core/package.json
1233
- var version = "0.2.1";
1233
+ var version = "0.2.2";
1234
1234
  // package.json
1235
- var version2 = "0.2.1";
1235
+ var version2 = "0.2.2";
1236
1236
 
1237
1237
  // src/project.ts
1238
+ import { randomBytes } from "node:crypto";
1238
1239
  import { existsSync } from "node:fs";
1239
1240
  import { cp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
1240
1241
  import { basename, join, resolve } from "node:path";
1241
1242
  var TEMPLATES = [
1242
1243
  { name: "minimal", label: "Minimal", hint: "a module, a controller, a service and a test" },
1243
- { name: "demo", label: "Demo", hint: "users with validation, a guard, logging and CORS" }
1244
+ { name: "demo", label: "Demo", hint: "JWT sign-in with cookies, typed config, users with validation, CORS" }
1244
1245
  ];
1245
1246
  var RENAMED = { _gitignore: ".gitignore", "_oxlintrc.json": ".oxlintrc.json" };
1246
1247
  var formatTargetDir = (dir) => dir.trim().replace(/[<>:"\\|?*]/g, "").replace(/\/+$/, "");
@@ -1260,6 +1261,15 @@ var emptyDir = async (dir) => {
1260
1261
  }
1261
1262
  }
1262
1263
  };
1264
+ var writeEnv = async (root) => {
1265
+ const example = join(root, ".env.example");
1266
+ const env = join(root, ".env");
1267
+ if (!existsSync(example) || existsSync(env)) {
1268
+ return;
1269
+ }
1270
+ const content = await readFile(example, "utf8");
1271
+ await writeFile(env, content.replace(/^(\w+_SECRET)=$/gm, (_, key) => `${key}=${randomBytes(32).toString("base64url")}`));
1272
+ };
1263
1273
  var createProject = async (options) => {
1264
1274
  const root = resolve(options.dir);
1265
1275
  await cp(options.template, root, { recursive: true });
@@ -1268,6 +1278,7 @@ var createProject = async (options) => {
1268
1278
  await rename(join(root, from), join(root, to));
1269
1279
  }
1270
1280
  }
1281
+ await writeEnv(root);
1271
1282
  const manifestPath = join(root, "package.json");
1272
1283
  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
1273
1284
  manifest.name = options.name ?? toValidPackageName(basename(root));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-soda",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Create a Soda project: bun create soda my-app",
5
5
  "keywords": [
6
6
  "soda",
@@ -0,0 +1,5 @@
1
+ # Copied to .env by create-soda, with a random secret. Bun loads .env by itself.
2
+ PORT=3000
3
+ CORS_ORIGIN=*
4
+ # Signs access tokens: random, 32 bytes or more (openssl rand -base64 32).
5
+ JWT_SECRET=
@@ -13,13 +13,17 @@ bun run build # bundles src/main.ts into dist/
13
13
  bun start:prod # runs the bundle
14
14
  ```
15
15
 
16
- - `src/main.ts`: creates the application, CORS, graceful shutdown.
17
- - `src/users`: a module with a service, a controller and a test.
18
- - `src/auth/auth.guard.ts`: a guard passing the current user to handlers.
16
+ - `src/config.ts`: the configuration, read from `.env` and checked at startup.
17
+ - `src/auth`: sign-in with JWT, returned as a token and set as an httpOnly cookie.
18
+ - `src/users`: a module with a service and a controller; writes need a token.
19
+ - `src/app.test.ts`: tests over HTTP, with the configuration replaced.
20
+
21
+ `.env` was created from `.env.example` with a random `JWT_SECRET`; it is not committed.
19
22
 
20
23
  Try it:
21
24
 
22
25
  ```bash
23
- curl -X POST localhost:3000/users -H 'authorization: Bearer ann' -H 'content-type: application/json' -d '{"name":"Bo"}'
26
+ TOKEN=$(curl -s localhost:3000/auth/login -H 'content-type: application/json' -d '{"name":"ann"}' | jq -r .token)
27
+ curl -X POST localhost:3000/users -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"name":"Bo"}'
24
28
  curl localhost:3000/users/1
25
29
  ```
@@ -1,5 +1,7 @@
1
- import { Module } from '@getsoda/core'
1
+ import { ConfigModule, Module } from '@getsoda/core'
2
+ import { AuthModule } from './auth/auth.module.ts'
3
+ import { AppConfig } from './config.ts'
2
4
  import { UsersModule } from './users/users.module.ts'
3
5
 
4
- @Module({ imports: [UsersModule] })
6
+ @Module({ imports: [ConfigModule.forRoot({ load: [AppConfig], global: true }), AuthModule, UsersModule] })
5
7
  export class AppModule {}
@@ -0,0 +1,73 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { createApp, provide } from '@getsoda/core'
3
+ import { AppModule } from './app.module.ts'
4
+ import { AppConfig } from './config.ts'
5
+
6
+ // Tests do not depend on .env: the configuration is replaced.
7
+ const app = await createApp(AppModule, {
8
+ logger: false,
9
+ overrides: [provide(AppConfig, { useValue: { PORT: 0, CORS_ORIGIN: '*', JWT_SECRET: 'a-test-secret-of-at-least-32-characters' } })],
10
+ })
11
+ const server = await app.listen(0)
12
+ const url = (path: string) => new URL(path, server.url)
13
+
14
+ afterAll(() => app.close())
15
+
16
+ const login = async (name: string) => {
17
+ const response = await fetch(url('/auth/login'), {
18
+ method: 'POST',
19
+ headers: { 'content-type': 'application/json' },
20
+ body: JSON.stringify({ name }),
21
+ })
22
+
23
+ return { response, token: ((await response.json()) as { token: string }).token }
24
+ }
25
+
26
+ describe('auth', () => {
27
+ test('signs in with a token in the body and an httpOnly cookie', async () => {
28
+ const { response, token } = await login('ann')
29
+ const cookie = response.headers.getSetCookie()[0]!
30
+
31
+ expect(response.status).toBe(201)
32
+ expect(cookie).toStartWith(`token=${token};`)
33
+ expect(cookie).toContain('HttpOnly')
34
+ expect(await (await fetch(url('/auth/me'), { headers: { authorization: `Bearer ${token}` } })).json()).toEqual({ name: 'ann' })
35
+ expect(await (await fetch(url('/auth/me'), { headers: { cookie: `token=${token}` } })).json()).toEqual({ name: 'ann' })
36
+ })
37
+
38
+ test('refuses requests without a valid token', async () => {
39
+ const anonymous = await fetch(url('/auth/me'))
40
+ const forged = await fetch(url('/auth/me'), { headers: { authorization: 'Bearer a.b.c' } })
41
+
42
+ expect([anonymous.status, forged.status]).toEqual([401, 401])
43
+ })
44
+ })
45
+
46
+ describe('users', () => {
47
+ test('are created by signed-in users, and read by anyone', async () => {
48
+ const { token } = await login('ann')
49
+ const created = await fetch(url('/users'), {
50
+ method: 'POST',
51
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
52
+ body: JSON.stringify({ name: 'Bo' }),
53
+ })
54
+
55
+ expect(created.status).toBe(201)
56
+ expect(await created.json()).toEqual({ id: 1, name: 'Bo', createdBy: 'ann' })
57
+ expect(await (await fetch(url('/users/1'))).json()).toEqual({ id: 1, name: 'Bo', createdBy: 'ann' })
58
+ })
59
+
60
+ test('refuses anonymous writes, invalid bodies and unknown users', async () => {
61
+ const { token } = await login('ann')
62
+ const anonymous = await fetch(url('/users'), { method: 'POST', body: '{}' })
63
+ const invalid = await fetch(url('/users'), {
64
+ method: 'POST',
65
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
66
+ body: JSON.stringify({ name: '' }),
67
+ })
68
+
69
+ expect(anonymous.status).toBe(401)
70
+ expect(invalid.status).toBe(400)
71
+ expect((await fetch(url('/users/999'))).status).toBe(404)
72
+ })
73
+ })
@@ -0,0 +1,39 @@
1
+ import { body, Controller, context, Get, HttpCode, inject, Post, UseGuards, value } from '@getsoda/core'
2
+ import type { Context } from '@getsoda/core'
3
+ import { JWT_PAYLOAD, JwtGuard, JwtService } from '@getsoda/core/jwt'
4
+ import type { JwtPayload } from '@getsoda/core/jwt'
5
+ import { z } from 'zod'
6
+ import { TOKEN_COOKIE, TOKEN_LIFETIME } from './token.ts'
7
+
8
+ const Login = z.object({ name: z.string().trim().min(1) })
9
+
10
+ @Controller('auth')
11
+ export class AuthController {
12
+ readonly #jwt = inject(JwtService)
13
+
14
+ /**
15
+ * Signs anyone in by name: check a password or another credential here.
16
+ * The token is returned for API clients and set as a cookie for browsers,
17
+ * where scripts cannot read it.
18
+ */
19
+ @Post('login', [body(Login), context()])
20
+ async login(input: z.infer<typeof Login>, ctx: Context): Promise<{ token: string }> {
21
+ const token = await this.#jwt.sign({ sub: input.name })
22
+
23
+ ctx.cookies.set(TOKEN_COOKIE, token, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: TOKEN_LIFETIME })
24
+
25
+ return { token }
26
+ }
27
+
28
+ @Post('logout', [context()])
29
+ @HttpCode(204)
30
+ logout(ctx: Context): void {
31
+ ctx.cookies.delete(TOKEN_COOKIE)
32
+ }
33
+
34
+ @Get('me', [value(JWT_PAYLOAD)])
35
+ @UseGuards(JwtGuard)
36
+ me(payload: JwtPayload): { name: string | undefined } {
37
+ return { name: payload.sub }
38
+ }
39
+ }
@@ -0,0 +1,17 @@
1
+ import { Module } from '@getsoda/core'
2
+ import { JwtModule } from '@getsoda/core/jwt'
3
+ import { AppConfig } from '../config.ts'
4
+ import { AuthController } from './auth.controller.ts'
5
+ import { TOKEN_COOKIE, TOKEN_LIFETIME } from './token.ts'
6
+
7
+ @Module({
8
+ imports: [
9
+ JwtModule.registerAsync({
10
+ inject: [AppConfig],
11
+ useFactory: (config) => ({ secret: config.JWT_SECRET, expiresIn: TOKEN_LIFETIME, cookie: TOKEN_COOKIE }),
12
+ global: true,
13
+ }),
14
+ ],
15
+ controllers: [AuthController],
16
+ })
17
+ export class AuthModule {}
@@ -0,0 +1,5 @@
1
+ /** Cookie holding the token for browsers; `JwtGuard` reads it when there is no `Authorization` header. */
2
+ export const TOKEN_COOKIE = 'token'
3
+
4
+ /** Lifetime of a token, in seconds. */
5
+ export const TOKEN_LIFETIME = 60 * 60
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from '@getsoda/core'
2
+ import { z } from 'zod'
3
+
4
+ /** Read from the environment and checked at startup: an invalid value stops the application. */
5
+ export const AppConfig = defineConfig(
6
+ 'app',
7
+ z.object({
8
+ PORT: z.coerce.number().int().default(3000),
9
+ CORS_ORIGIN: z.string().default('*'),
10
+ JWT_SECRET: z.string().min(32, 'must be at least 32 characters: openssl rand -base64 32'),
11
+ })
12
+ )
@@ -1,9 +1,10 @@
1
1
  import { cors, createApp } from '@getsoda/core'
2
2
  import { AppModule } from './app.module.ts'
3
+ import { AppConfig } from './config.ts'
3
4
 
4
- const app = await createApp(AppModule, {
5
- middleware: [cors({ origin: process.env.CORS_ORIGIN ?? '*' })],
6
- })
5
+ // Loaded first for what the application needs before it exists; services inject it.
6
+ const config = await AppConfig.load()
7
+ const app = await createApp(AppModule, { middleware: [cors({ origin: config.CORS_ORIGIN })] })
7
8
 
8
9
  app.enableShutdownSignals()
9
- await app.listen(Number(process.env.PORT ?? 3000))
10
+ await app.listen(config.PORT)
@@ -1,7 +1,7 @@
1
1
  import { body, Controller, Get, inject, int, NotFoundException, param, Post, UseGuards, value } from '@getsoda/core'
2
+ import { JWT_PAYLOAD, JwtGuard } from '@getsoda/core/jwt'
3
+ import type { JwtPayload } from '@getsoda/core/jwt'
2
4
  import { z } from 'zod'
3
- import { AuthGuard, CURRENT_USER } from '../auth/auth.guard.ts'
4
- import type { CurrentUser } from '../auth/auth.guard.ts'
5
5
  import { UsersService } from './users.service.ts'
6
6
  import type { User } from './users.service.ts'
7
7
 
@@ -27,9 +27,9 @@ export class UsersController {
27
27
  return user
28
28
  }
29
29
 
30
- @Post([body(CreateUser), value(CURRENT_USER)])
31
- @UseGuards(AuthGuard)
32
- create(input: z.infer<typeof CreateUser>, currentUser: CurrentUser): User {
33
- return this.#users.create(input.name, currentUser.name)
30
+ @Post([body(CreateUser), value(JWT_PAYLOAD)])
31
+ @UseGuards(JwtGuard)
32
+ create(input: z.infer<typeof CreateUser>, author: JwtPayload): User {
33
+ return this.#users.create(input.name, author.sub ?? 'unknown')
34
34
  }
35
35
  }
@@ -1,26 +0,0 @@
1
- import { ContextKey } from '@getsoda/core'
2
- import type { CanActivate, Context } from '@getsoda/core'
3
-
4
- export interface CurrentUser {
5
- readonly name: string
6
- }
7
-
8
- /** The user the guard found; handlers read it with `ctx.get(CURRENT_USER)` or `value(CURRENT_USER)`. */
9
- export const CURRENT_USER = new ContextKey<CurrentUser>('currentUser')
10
-
11
- /**
12
- * Accepts `Authorization: Bearer <name>`. A placeholder: verify real tokens here.
13
- */
14
- export class AuthGuard implements CanActivate {
15
- canActivate(ctx: Context): boolean {
16
- const header = ctx.header('authorization')
17
-
18
- if (header === undefined || !header.startsWith('Bearer ')) {
19
- return false
20
- }
21
-
22
- ctx.set(CURRENT_USER, { name: header.slice('Bearer '.length) })
23
-
24
- return true
25
- }
26
- }
@@ -1,34 +0,0 @@
1
- import { afterAll, expect, test } from 'bun:test'
2
- import { createApp } from '@getsoda/core'
3
- import { AppModule } from '../app.module.ts'
4
-
5
- const app = await createApp(AppModule, { logger: false })
6
- const server = await app.listen(0)
7
- const url = (path: string) => new URL(path, server.url)
8
-
9
- afterAll(() => app.close())
10
-
11
- test('creates a user when authenticated', async () => {
12
- const response = await fetch(url('/users'), {
13
- method: 'POST',
14
- headers: { authorization: 'Bearer ann', 'content-type': 'application/json' },
15
- body: JSON.stringify({ name: 'Bo' }),
16
- })
17
-
18
- expect(response.status).toBe(201)
19
- expect(await response.json()).toEqual({ id: 1, name: 'Bo', createdBy: 'ann' })
20
- expect(await (await fetch(url('/users/1'))).json()).toEqual({ id: 1, name: 'Bo', createdBy: 'ann' })
21
- })
22
-
23
- test('refuses anonymous writes, invalid bodies and unknown users', async () => {
24
- const anonymous = await fetch(url('/users'), { method: 'POST', body: '{}' })
25
- const invalid = await fetch(url('/users'), {
26
- method: 'POST',
27
- headers: { authorization: 'Bearer ann', 'content-type': 'application/json' },
28
- body: JSON.stringify({ name: '' }),
29
- })
30
-
31
- expect(anonymous.status).toBe(403)
32
- expect(invalid.status).toBe(400)
33
- expect((await fetch(url('/users/999'))).status).toBe(404)
34
- })