create-tigra 2.8.0 → 3.0.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/README.md +10 -3
- package/bin/create-tigra.js +77 -37
- package/package.json +5 -5
- package/template/_claude/commands/create-server.md +8 -2
- package/template/_claude/rules/client/01-project-structure.md +12 -0
- package/template/_claude/rules/client/03-data-and-state.md +1 -1
- package/template/_claude/rules/client/04-design-system.md +23 -0
- package/template/_claude/rules/client/07-deployment.md +99 -0
- package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
- package/template/_claude/rules/client/core.md +1 -0
- package/template/_claude/rules/global/core.md +20 -1
- package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
- package/template/_claude/rules/server/core.md +2 -0
- package/template/_claude/rules/server/deployment.md +78 -0
- package/template/client/next.config.ts +12 -2
- package/template/client/package-lock.json +12345 -0
- package/template/client/package.json +3 -2
- package/template/client/src/components/common/SafeImage.tsx +2 -1
- package/template/client/src/lib/api/axios.config.ts +19 -4
- package/template/client/src/middleware.ts +7 -0
- package/template/gitignore +1 -0
- package/template/server/.env.example +248 -194
- package/template/server/.env.example.production +221 -168
- package/template/server/Dockerfile +29 -5
- package/template/server/docker-compose.yml +32 -4
- package/template/server/package-lock.json +6544 -6823
- package/template/server/package.json +76 -75
- package/template/server/prisma/seed.ts +20 -4
- package/template/server/src/app.ts +316 -271
- package/template/server/src/config/env.ts +150 -99
- package/template/server/src/config/rate-limit.config.ts +16 -0
- package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
- package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
- package/template/server/src/libs/__tests__/http.test.ts +23 -9
- package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
- package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
- package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
- package/template/server/src/libs/auth-path.ts +14 -0
- package/template/server/src/libs/auth.ts +6 -16
- package/template/server/src/libs/client-ip.ts +77 -0
- package/template/server/src/libs/cookies.ts +1 -1
- package/template/server/src/libs/duration.ts +30 -0
- package/template/server/src/libs/ip-block.ts +220 -206
- package/template/server/src/libs/origin-check.ts +38 -0
- package/template/server/src/libs/query-counter.ts +59 -0
- package/template/server/src/libs/redis.ts +1 -1
- package/template/server/src/libs/url-safety.ts +121 -0
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
- package/template/server/src/modules/auth/auth.controller.ts +128 -127
- package/template/server/src/modules/auth/auth.repo.ts +2 -0
- package/template/server/src/modules/auth/auth.service.ts +103 -12
- package/template/server/src/test/setup.ts +22 -2
- package/template/server/vitest.config.ts +43 -43
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { redactUrl, isPrivateOrReservedIp } from '../url-safety.js';
|
|
3
|
+
|
|
4
|
+
describe('redactUrl', () => {
|
|
5
|
+
it('redacts a Telegram bot token in the API path', () => {
|
|
6
|
+
expect(redactUrl('https://api.telegram.org/bot123456:AAHsecret/getFile')).toBe(
|
|
7
|
+
'https://api.telegram.org/bot<redacted>/getFile',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('redacts a Telegram bot token in the file-download path', () => {
|
|
12
|
+
expect(redactUrl('https://api.telegram.org/file/bot123456:AAHsecret/photos/file_1.jpg')).toBe(
|
|
13
|
+
'https://api.telegram.org/file/bot<redacted>/photos/file_1.jpg',
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('strips the query string (may carry signed media tokens)', () => {
|
|
18
|
+
expect(redactUrl('https://lookaside.fbsbx.com/media?asset_id=1&token=SECRET')).toBe(
|
|
19
|
+
'https://lookaside.fbsbx.com/media',
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('strips a fragment as well', () => {
|
|
24
|
+
expect(redactUrl('https://cdn.example.com/x.jpg#frag')).toBe('https://cdn.example.com/x.jpg');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('leaves a normal URL unchanged', () => {
|
|
28
|
+
expect(redactUrl('https://scontent.xx.fbcdn.net/v/photo.jpg')).toBe(
|
|
29
|
+
'https://scontent.xx.fbcdn.net/v/photo.jpg',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns empty / non-string input as-is without throwing', () => {
|
|
34
|
+
expect(redactUrl('')).toBe('');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('isPrivateOrReservedIp', () => {
|
|
39
|
+
it.each([
|
|
40
|
+
'0.0.0.0',
|
|
41
|
+
'127.0.0.1',
|
|
42
|
+
'10.0.0.5',
|
|
43
|
+
'172.16.0.1',
|
|
44
|
+
'172.31.255.254',
|
|
45
|
+
'192.168.1.1',
|
|
46
|
+
'169.254.169.254',
|
|
47
|
+
'100.64.0.1',
|
|
48
|
+
'100.127.255.255',
|
|
49
|
+
'::1',
|
|
50
|
+
'::',
|
|
51
|
+
'fc00::1',
|
|
52
|
+
'fd12:3456:789a::1',
|
|
53
|
+
'fe80::abcd',
|
|
54
|
+
'::ffff:10.0.0.5',
|
|
55
|
+
'::ffff:127.0.0.1',
|
|
56
|
+
])('blocks internal/reserved address %s', (addr) => {
|
|
57
|
+
expect(isPrivateOrReservedIp(addr)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.each([
|
|
61
|
+
'93.184.216.34',
|
|
62
|
+
'8.8.8.8',
|
|
63
|
+
'1.1.1.1',
|
|
64
|
+
'172.32.0.1', // just outside 172.16/12
|
|
65
|
+
'172.15.0.1', // just below 172.16/12
|
|
66
|
+
'192.169.0.1', // not 192.168
|
|
67
|
+
'100.63.255.255', // just below CGNAT
|
|
68
|
+
'100.128.0.0', // just above CGNAT
|
|
69
|
+
'2606:4700:4700::1111', // Cloudflare public IPv6
|
|
70
|
+
'::ffff:93.184.216.34', // IPv4-mapped public
|
|
71
|
+
])('allows public address %s', (addr) => {
|
|
72
|
+
expect(isPrivateOrReservedIp(addr)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('fails closed (blocks) on malformed input', () => {
|
|
76
|
+
expect(isPrivateOrReservedIp('not-an-ip')).toBe(true);
|
|
77
|
+
expect(isPrivateOrReservedIp('999.999.999.999')).toBe(true);
|
|
78
|
+
expect(isPrivateOrReservedIp('')).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FastifyRequest } from 'fastify';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Auth routes (login / register / refresh / ...) have their own per-route rate
|
|
5
|
+
* limit and an account-lockout ladder. Their 429s must NOT feed the
|
|
6
|
+
* cross-endpoint 1-hour IP auto-ban, or a user mistyping a password would lock
|
|
7
|
+
* themselves out of the entire API (widget included).
|
|
8
|
+
*
|
|
9
|
+
* Exported for unit testing (auth-path.test.ts).
|
|
10
|
+
*/
|
|
11
|
+
export function isAuthPath(request: Pick<FastifyRequest, 'url'>): boolean {
|
|
12
|
+
const pathname = request.url.split('?')[0];
|
|
13
|
+
return pathname.startsWith('/api/v1/auth/');
|
|
14
|
+
}
|
|
@@ -4,8 +4,14 @@ import { env } from '@config/env.js';
|
|
|
4
4
|
import { prisma } from '@libs/prisma.js';
|
|
5
5
|
import { UnauthorizedError, ForbiddenError, BadRequestError } from '@shared/errors/errors.js';
|
|
6
6
|
import { clearAuthCookies } from '@libs/cookies.js';
|
|
7
|
+
import { parseDurationMs } from '@libs/duration.js';
|
|
7
8
|
import type { JwtPayload, UserRole } from '@shared/types/index.js';
|
|
8
9
|
|
|
10
|
+
// Re-export so existing consumers of `parseDurationMs` from '@libs/auth.js'
|
|
11
|
+
// keep working. The implementation lives in the import-free leaf module
|
|
12
|
+
// duration.ts to avoid the cookies.ts ↔ auth.ts circular import (TDZ crash).
|
|
13
|
+
export { parseDurationMs } from '@libs/duration.js';
|
|
14
|
+
|
|
9
15
|
let app: FastifyInstance | null = null;
|
|
10
16
|
|
|
11
17
|
export function initAuth(fastify: FastifyInstance): void {
|
|
@@ -30,22 +36,6 @@ export function generateRefreshToken(): string {
|
|
|
30
36
|
return uuidv4();
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
export function parseDurationMs(duration: string, fallbackMs: number): number {
|
|
34
|
-
const match = duration.match(/^(\d+)([smhd])$/);
|
|
35
|
-
if (!match) return fallbackMs;
|
|
36
|
-
|
|
37
|
-
const value = parseInt(match[1], 10);
|
|
38
|
-
const unit = match[2];
|
|
39
|
-
const multipliers: Record<string, number> = {
|
|
40
|
-
s: 1000,
|
|
41
|
-
m: 60 * 1000,
|
|
42
|
-
h: 60 * 60 * 1000,
|
|
43
|
-
d: 24 * 60 * 60 * 1000,
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
return value * multipliers[unit];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
39
|
export function getRefreshTokenExpiresAt(): Date {
|
|
50
40
|
const ms = parseDurationMs(env.JWT_REFRESH_EXPIRY, 7 * 24 * 60 * 60 * 1000);
|
|
51
41
|
return new Date(Date.now() + ms);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client IP resolution — Cloudflare- and reverse-proxy-aware.
|
|
3
|
+
*
|
|
4
|
+
* Behind a Cloudflare proxy (or any reverse proxy such as Traefik/Coolify),
|
|
5
|
+
* Fastify's `request.ip` (derived from the socket / X-Forwarded-For) resolves
|
|
6
|
+
* to an *edge* IP, not the real visitor. Every request through one edge node
|
|
7
|
+
* then shares a single IP, which collapses the per-IP rate limiter and the IP
|
|
8
|
+
* auto-block onto that shared edge address: one abusive client can rate-limit
|
|
9
|
+
* or self-ban every legitimate user behind the same edge, and conversely abuse
|
|
10
|
+
* from many clients hides behind one IP.
|
|
11
|
+
*
|
|
12
|
+
* Resolution is ordered, and every header value is VALIDATED as a real IPv4/IPv6
|
|
13
|
+
* literal (via `node:net.isIP`) before it is trusted — a junk or spoofed header
|
|
14
|
+
* falls through to the next tier instead of becoming the rate-limit key:
|
|
15
|
+
*
|
|
16
|
+
* 1. `CF-Connecting-IP` — the genuine client IP Cloudflare injects. Consulted
|
|
17
|
+
* ONLY when `TRUST_CLOUDFLARE` is enabled (orange-cloud / proxied origin).
|
|
18
|
+
* 2. Left-most VALID `X-Forwarded-For` entry — the original client, appended by
|
|
19
|
+
* the reverse proxy. XFF is a comma-separated list (client, proxy1, proxy2,
|
|
20
|
+
* ...), so the original client is the left-most entry. This tier is consulted
|
|
21
|
+
* regardless of the flag: it covers the grey-cloud / DNS-only case where the
|
|
22
|
+
* Cloudflare header is absent but traffic still arrives through Traefik —
|
|
23
|
+
* without it, every grey-clouded visitor collapses onto the single shared
|
|
24
|
+
* Traefik upstream IP and trips the auto-block site-wide.
|
|
25
|
+
* 3. `request.ip` — Fastify's socket / trustProxy-resolved peer IP (always
|
|
26
|
+
* non-empty), the safe fallback for any origin reachable directly.
|
|
27
|
+
*
|
|
28
|
+
* SECURITY PRECONDITION: both `CF-Connecting-IP` and `X-Forwarded-For` are
|
|
29
|
+
* client-supplied and therefore SPOOFABLE if a caller can reach the origin
|
|
30
|
+
* directly. They are only trustworthy when the origin accepts traffic
|
|
31
|
+
* EXCLUSIVELY via the proxy (Cloudflare / Traefik) — a documented deploy
|
|
32
|
+
* precondition. Validation here narrows the spoof surface to well-formed IP
|
|
33
|
+
* literals but does not, on its own, prove provenance.
|
|
34
|
+
*
|
|
35
|
+
* Use this for rate-limiting and IP-blocking decisions. Logging / session
|
|
36
|
+
* metadata can keep using `request.ip` — there a spoofed value is harmless.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { isIP } from 'node:net';
|
|
40
|
+
import type { FastifyRequest } from 'fastify';
|
|
41
|
+
import { env } from '@config/env.js';
|
|
42
|
+
|
|
43
|
+
/** Return the trimmed value if it is a valid IPv4/IPv6 literal, else undefined. */
|
|
44
|
+
function asValidIp(value: string | undefined): string | undefined {
|
|
45
|
+
if (!value) return undefined;
|
|
46
|
+
const trimmed = value.trim();
|
|
47
|
+
return isIP(trimmed) !== 0 ? trimmed : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the real client IP for rate-limiting / blocking decisions.
|
|
52
|
+
*
|
|
53
|
+
* Synchronous and always returns a non-empty string (the `request.ip` fallback
|
|
54
|
+
* guarantees this), so it is safe to use directly as a rate-limit `keyGenerator`.
|
|
55
|
+
*
|
|
56
|
+
* @param request - The incoming Fastify request
|
|
57
|
+
* @returns The client IP to use as the rate-limit / block key
|
|
58
|
+
*/
|
|
59
|
+
export function getClientIp(request: FastifyRequest): string {
|
|
60
|
+
// 1. CF-Connecting-IP — only when TRUST_CLOUDFLARE is set (origin locked to CF).
|
|
61
|
+
if (env.TRUST_CLOUDFLARE) {
|
|
62
|
+
const cfHeader = request.headers['cf-connecting-ip'];
|
|
63
|
+
const cf = asValidIp(typeof cfHeader === 'string' ? cfHeader : undefined);
|
|
64
|
+
if (cf) return cf;
|
|
65
|
+
}
|
|
66
|
+
// 2. Left-most VALID X-Forwarded-For entry — the original client, appended by the
|
|
67
|
+
// reverse proxy (Traefik/Coolify). Covers grey-cloud / DNS-only where the CF
|
|
68
|
+
// header is absent. Validated so a junk/spoofed header falls through.
|
|
69
|
+
const xffRaw = request.headers['x-forwarded-for'];
|
|
70
|
+
const xff = typeof xffRaw === 'string' ? xffRaw : Array.isArray(xffRaw) ? xffRaw[0] : undefined;
|
|
71
|
+
if (xff) {
|
|
72
|
+
const leftMost = asValidIp(xff.split(',')[0]);
|
|
73
|
+
if (leftMost) return leftMost;
|
|
74
|
+
}
|
|
75
|
+
// 3. Fastify's socket/trustProxy-resolved peer IP (always non-empty).
|
|
76
|
+
return request.ip;
|
|
77
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration parsing — LEAF MODULE.
|
|
3
|
+
*
|
|
4
|
+
* This file must have ZERO imports. It exists to break the circular import
|
|
5
|
+
* between cookies.ts and auth.ts: cookies.ts calls parseDurationMs at module
|
|
6
|
+
* top-level, so if it imported the function from auth.ts (which imports
|
|
7
|
+
* clearAuthCookies from cookies.ts), the cycle left auth.ts partially
|
|
8
|
+
* initialized and crashed module evaluation with
|
|
9
|
+
* "parseDurationMs is not a function" (TDZ). Keep this module dependency-free.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse a duration string like "15m", "7d", "30s", "12h" into milliseconds.
|
|
14
|
+
* Returns `fallbackMs` when the string doesn't match the expected format.
|
|
15
|
+
*/
|
|
16
|
+
export function parseDurationMs(duration: string, fallbackMs: number): number {
|
|
17
|
+
const match = duration.match(/^(\d+)([smhd])$/);
|
|
18
|
+
if (!match) return fallbackMs;
|
|
19
|
+
|
|
20
|
+
const value = parseInt(match[1], 10);
|
|
21
|
+
const unit = match[2];
|
|
22
|
+
const multipliers: Record<string, number> = {
|
|
23
|
+
s: 1000,
|
|
24
|
+
m: 60 * 1000,
|
|
25
|
+
h: 60 * 60 * 1000,
|
|
26
|
+
d: 24 * 60 * 60 * 1000,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return value * multipliers[unit];
|
|
30
|
+
}
|