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.
Files changed (53) hide show
  1. package/README.md +10 -3
  2. package/bin/create-tigra.js +77 -37
  3. package/package.json +5 -5
  4. package/template/_claude/commands/create-server.md +8 -2
  5. package/template/_claude/rules/client/01-project-structure.md +12 -0
  6. package/template/_claude/rules/client/03-data-and-state.md +1 -1
  7. package/template/_claude/rules/client/04-design-system.md +23 -0
  8. package/template/_claude/rules/client/07-deployment.md +99 -0
  9. package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
  10. package/template/_claude/rules/client/core.md +1 -0
  11. package/template/_claude/rules/global/core.md +20 -1
  12. package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
  13. package/template/_claude/rules/server/core.md +2 -0
  14. package/template/_claude/rules/server/deployment.md +78 -0
  15. package/template/client/next.config.ts +12 -2
  16. package/template/client/package-lock.json +12345 -0
  17. package/template/client/package.json +3 -2
  18. package/template/client/src/components/common/SafeImage.tsx +2 -1
  19. package/template/client/src/lib/api/axios.config.ts +19 -4
  20. package/template/client/src/middleware.ts +7 -0
  21. package/template/gitignore +1 -0
  22. package/template/server/.env.example +248 -194
  23. package/template/server/.env.example.production +221 -168
  24. package/template/server/Dockerfile +29 -5
  25. package/template/server/docker-compose.yml +32 -4
  26. package/template/server/package-lock.json +6544 -6823
  27. package/template/server/package.json +76 -75
  28. package/template/server/prisma/seed.ts +20 -4
  29. package/template/server/src/app.ts +316 -271
  30. package/template/server/src/config/env.ts +150 -99
  31. package/template/server/src/config/rate-limit.config.ts +16 -0
  32. package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
  33. package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
  34. package/template/server/src/libs/__tests__/http.test.ts +23 -9
  35. package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
  36. package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
  37. package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
  38. package/template/server/src/libs/auth-path.ts +14 -0
  39. package/template/server/src/libs/auth.ts +6 -16
  40. package/template/server/src/libs/client-ip.ts +77 -0
  41. package/template/server/src/libs/cookies.ts +1 -1
  42. package/template/server/src/libs/duration.ts +30 -0
  43. package/template/server/src/libs/ip-block.ts +220 -206
  44. package/template/server/src/libs/origin-check.ts +38 -0
  45. package/template/server/src/libs/query-counter.ts +59 -0
  46. package/template/server/src/libs/redis.ts +1 -1
  47. package/template/server/src/libs/url-safety.ts +121 -0
  48. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
  49. package/template/server/src/modules/auth/auth.controller.ts +128 -127
  50. package/template/server/src/modules/auth/auth.repo.ts +2 -0
  51. package/template/server/src/modules/auth/auth.service.ts +103 -12
  52. package/template/server/src/test/setup.ts +22 -2
  53. 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
+ }
@@ -1,6 +1,6 @@
1
1
  import type { FastifyReply } from 'fastify';
2
2
  import { env } from '@config/env.js';
3
- import { parseDurationMs } from '@libs/auth.js';
3
+ import { parseDurationMs } from '@libs/duration.js';
4
4
 
5
5
  const isProduction = env.NODE_ENV === 'production';
6
6
 
@@ -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
+ }