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 +14 -3
- package/package.json +1 -1
- package/templates/demo/.env.example +5 -0
- package/templates/demo/README.md +8 -4
- package/templates/demo/src/app.module.ts +4 -2
- package/templates/demo/src/app.test.ts +73 -0
- package/templates/demo/src/auth/auth.controller.ts +39 -0
- package/templates/demo/src/auth/auth.module.ts +17 -0
- package/templates/demo/src/auth/token.ts +5 -0
- package/templates/demo/src/config.ts +12 -0
- package/templates/demo/src/main.ts +5 -4
- package/templates/demo/src/users/users.controller.ts +6 -6
- package/templates/demo/src/auth/auth.guard.ts +0 -26
- package/templates/demo/src/users/users.test.ts +0 -34
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.
|
|
1233
|
+
var version = "0.2.2";
|
|
1234
1234
|
// package.json
|
|
1235
|
-
var version2 = "0.2.
|
|
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: "
|
|
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
package/templates/demo/README.md
CHANGED
|
@@ -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/
|
|
17
|
-
- `src/
|
|
18
|
-
- `src/
|
|
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 -
|
|
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,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
|
-
|
|
5
|
-
|
|
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(
|
|
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(
|
|
31
|
-
@UseGuards(
|
|
32
|
-
create(input: z.infer<typeof CreateUser>,
|
|
33
|
-
return this.#users.create(input.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
|
-
})
|