fastapi-fullstack 0.2.17__py3-none-any.whl → 0.2.18__py3-none-any.whl

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 (31) hide show
  1. {fastapi_fullstack-0.2.17.dist-info → fastapi_fullstack-0.2.18.dist-info}/METADATA +4 -2
  2. {fastapi_fullstack-0.2.17.dist-info → fastapi_fullstack-0.2.18.dist-info}/RECORD +31 -29
  3. fastapi_gen/UPGRADES.yaml +5 -1
  4. fastapi_gen/template/hooks/post_gen_project.py +18 -12
  5. fastapi_gen/template/{{cookiecutter.project_slug}}/ENV_VARS.md +13 -0
  6. fastapi_gen/template/{{cookiecutter.project_slug}}/Makefile +3 -4
  7. fastapi_gen/template/{{cookiecutter.project_slug}}/README.md +9 -4
  8. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/deepagents_assistant.py +8 -5
  9. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/clients/redis.py +10 -2
  10. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/pyproject.toml +12 -2
  11. fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml +1 -1
  12. fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.frontend.yml +16 -10
  13. fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.prod.yml +35 -2
  14. fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.yml +1 -1
  15. fastapi_gen/template/{{cookiecutter.project_slug}}/docs/deploy.md +39 -2
  16. fastapi_gen/template/{{cookiecutter.project_slug}}/docs/permissions.md +1 -1
  17. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.env.example +13 -12
  18. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/Dockerfile +6 -0
  19. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/README.md +24 -9
  20. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/login/route.ts +4 -14
  21. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/logout/route.ts +2 -14
  22. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/magic-link/verify/route.ts +4 -14
  23. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/me/route.ts +6 -18
  24. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/oauth-callback/route.ts +4 -14
  25. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/refresh/route.ts +6 -31
  26. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/auth-cookies.test.ts +68 -0
  27. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/auth-cookies.ts +61 -0
  28. fastapi_gen/template/{{cookiecutter.project_slug}}/nginx/nginx.conf +5 -2
  29. {fastapi_fullstack-0.2.17.dist-info → fastapi_fullstack-0.2.18.dist-info}/WHEEL +0 -0
  30. {fastapi_fullstack-0.2.17.dist-info → fastapi_fullstack-0.2.18.dist-info}/entry_points.txt +0 -0
  31. {fastapi_fullstack-0.2.17.dist-info → fastapi_fullstack-0.2.18.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-fullstack
3
- Version: 0.2.17
3
+ Version: 0.2.18
4
4
  Summary: Full-stack FastAPI + Next.js template generator with PydanticAI/LangChain agents, WebSocket streaming, 20+ enterprise integrations, and Logfire/LangSmith observability. Ship AI apps fast. CLI tool to generate production-ready FastAPI + Next.js projects with AI agents, auth, and observability.
5
5
  Project-URL: Homepage, https://github.com/vstorm-co/full-stack-ai-agent-template
6
6
  Project-URL: Documentation, https://github.com/vstorm-co/full-stack-ai-agent-template#readme
@@ -298,7 +298,9 @@ For frontend deployment to **Vercel**:
298
298
  cd frontend && npx vercel --prod
299
299
  ```
300
300
 
301
- In the Vercel dashboard set `BACKEND_URL`, `BACKEND_WS_URL`, `NEXT_PUBLIC_AUTH_ENABLED=true`.
301
+ In the Vercel dashboard set `BACKEND_URL` (server-side), plus `NEXT_PUBLIC_API_URL`,
302
+ `NEXT_PUBLIC_WS_URL` (`wss://…`) and `NEXT_PUBLIC_SITE_URL` — the `NEXT_PUBLIC_*` ones are
303
+ inlined at build time, so they need a redeploy to take effect.
302
304
 
303
305
  </details>
304
306
 
@@ -17,24 +17,24 @@ fastapi_gen/upgrade/rename_guard.py,sha256=ap0ds5k93Hh6hhQhOxrDPsh-utTE1BeBYysrT
17
17
  fastapi_gen/upgrade/render.py,sha256=PmEm7BPmEfUK29RcVgPN9PttTX7oHeWnDgf_vgGja1s,5497
18
18
  fastapi_gen/upgrade/report.py,sha256=49v9kq2tkbeqqbdH-kRIM3kccaFfPVfjhi7Tm5cCVvE,3802
19
19
  fastapi_gen/upgrade/runner.py,sha256=msnj2RYii6low6gPeZ4TRMlL01Hq8a0zJyLBzPotxCI,19302
20
- fastapi_gen/UPGRADES.yaml,sha256=1vnWLTRGs4jwND7rdW9Yckyyuoj4zZn6s8hr7PKbedc,691
20
+ fastapi_gen/UPGRADES.yaml,sha256=DciwcDB-T5LBX-PVTLBqSrm3t6gVL3Mhm5McBkOkORo,1524
21
21
  fastapi_gen/template/VARIABLES.md,sha256=9CcimCDgXLshogzr1pjRYUTTmECCL9NAb1OzDFNTfgw,34322
22
22
  fastapi_gen/template/cookiecutter.json,sha256=xikYtcbOLG4VOwZXAk-0acbELW8jXhHAGEg4kGEgRdg,6337
23
- fastapi_gen/template/hooks/post_gen_project.py,sha256=ZmwbJr4nieVo22uftzea-WwMoB5FEiXJT3c_w5cL8Yk,57337
23
+ fastapi_gen/template/hooks/post_gen_project.py,sha256=o79caVy6BKSjgK2rpEYlLx0TEA3kAOg03yPDePuGfkY,58054
24
24
  fastapi_gen/template/{{cookiecutter.project_slug}}/.gitignore,sha256=yC3ZUp8Txp_1TPDabDlz4pGa2p1cNwr49de0dCcctOw,1515
25
25
  fastapi_gen/template/{{cookiecutter.project_slug}}/.gitlab-ci.yml,sha256=dZh-VSLL5beOtj8_UxBbD86sBdE9ubTpmWvMcrRqr-M,4651
26
26
  fastapi_gen/template/{{cookiecutter.project_slug}}/AGENTS.md,sha256=HqhXNdBq1ZXlFJCC4V3CY8DorTkZkwFJcvv2O77Pwk4,2454
27
27
  fastapi_gen/template/{{cookiecutter.project_slug}}/CLAUDE.md,sha256=OwM3tVeznjnSr8h0Ors27AIqB7DqC8X1rYLw1fQE0x0,3657
28
28
  fastapi_gen/template/{{cookiecutter.project_slug}}/CONTRIBUTING.md,sha256=pxP7KcIbmZbao1HPnFEykJbCHa7VfW2RQWfBMNSE_kE,3304
29
- fastapi_gen/template/{{cookiecutter.project_slug}}/ENV_VARS.md,sha256=t8iwCVu3XV3lAJIvA3nYsbE7Y25JXgkrBMAJt_iiJUc,8234
29
+ fastapi_gen/template/{{cookiecutter.project_slug}}/ENV_VARS.md,sha256=NSNkHTEphs6TCKgY5kzhnLmejw9PQKOP3KmWmvCnCSA,8975
30
30
  fastapi_gen/template/{{cookiecutter.project_slug}}/MANUAL_STEPS.md,sha256=6HJW2WE6-U4jbyQT7BjH17N6I2xAXbaAKbANiNEFNRA,7775
31
- fastapi_gen/template/{{cookiecutter.project_slug}}/Makefile,sha256=eW9jQETTbbgSV6rDzfV50OIr__i8hHMBLHYQD2JE2a0,17983
32
- fastapi_gen/template/{{cookiecutter.project_slug}}/README.md,sha256=faY_FSgHSMc5PhrHFbJGmJpRa0gxUwZLF6JZ4Qn7Ac4,13878
31
+ fastapi_gen/template/{{cookiecutter.project_slug}}/Makefile,sha256=Pjqb8ph2XTiuEWhC99ECjYGMjq_ZiT8epdN5YgjzLos,18018
32
+ fastapi_gen/template/{{cookiecutter.project_slug}}/README.md,sha256=nXNh5TKj2wMqxZDxoFp7Q1ej87IL-Rt_VTGBkQLfKCw,14314
33
33
  fastapi_gen/template/{{cookiecutter.project_slug}}/SECURITY.md,sha256=7X5u7feq2AXxWv2CMMNQZf5QQ5QkwBnzKlrcLz3RJ_Q,4735
34
- fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml,sha256=r7WaOV4WNbKenkd28et8KRU6YD5VtF2rSyOysjN3fa8,12347
35
- fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.frontend.yml,sha256=nmMjmzmyVudK9vZoFBmHNv4q4-ydq_lW5X4YrxEpP8E,2052
36
- fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.prod.yml,sha256=cWGAIUXBywj1B1FmhhNzwIOgUtO2B8Fvl8-QNjLtkso,22282
37
- fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.yml,sha256=s9bplsIGSzg4i0AIPtDkAHe7X0lrnJDcBUOdca83FAg,13634
34
+ fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml,sha256=0LdkV4HIJX9lEtqTfYZsjbYARAvxSCeIjZ_VSRrN5LU,12347
35
+ fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.frontend.yml,sha256=5Iu2j3ChoUGn7VTVKLkXbGwtI21z1WdHg0IPhV3dPOQ,2726
36
+ fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.prod.yml,sha256=WJrgVG04QZpuD0j0iOmM7id4gurbFfQu6YJGSwaDWyM,24589
37
+ fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.yml,sha256=rcYyrFsuRdY6yMTfVQvnFbOeeRIRkLLdnX2u_x0_ORs,13634
38
38
  fastapi_gen/template/{{cookiecutter.project_slug}}/.claude/settings.json,sha256=i6XfBDlx6N1AXg4-MrjmIzPhxRXvQC9PRXJFZyfNs1Y,363
39
39
  fastapi_gen/template/{{cookiecutter.project_slug}}/.claude/commands/add-endpoint.md,sha256=p0EsXDvW23v4uOSPRlwA84nVrB7_VVL_FnKFEE442AQ,1648
40
40
  fastapi_gen/template/{{cookiecutter.project_slug}}/.claude/commands/fix-issue.md,sha256=3bcQwYzf_EXTit9nlkplUd0-G5T9-gGranogIDS_kjU,800
@@ -60,7 +60,7 @@ fastapi_gen/template/{{cookiecutter.project_slug}}/backend/.env.example,sha256=H
60
60
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/.pre-commit-config.yaml,sha256=22sYeJOrYywl_5PWgPzCyIwhpqmymIDIzx2hlBRQ-yI,681
61
61
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/Dockerfile,sha256=CKyuUKwt41LHIH9VB28TEzes5_TQkUEo9rIBjE3v8a8,2184
62
62
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic.ini,sha256=vncO8XHTAW1ospWsdvtXD5PyMhp91CJmdVfv-6TakAY,737
63
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/pyproject.toml,sha256=2mEGHW6wmz0FiRrwJ-W4OfuEIs9B3g08ymBKH-zxOY0,14030
63
+ fastapi_gen/template/{{cookiecutter.project_slug}}/backend/pyproject.toml,sha256=nWgclrugZfrxqEA1dgSjMNTMNfCU11Qf0Vs9AjRnpHE,14780
64
64
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic/env.py,sha256=ifDazpmfYDUI0KGxaGcAgJbz6HNzu9bQYB6li88OMcU,3163
65
65
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic/script.py.mako,sha256=ZCfgWrEMJRDF_WF_GRGptwAIJI6YnAditBG1axwAP9Q,700
66
66
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic/versions/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -96,7 +96,7 @@ fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/admin.py,sha256=Q
96
96
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/main.py,sha256=CcUQKjWvANuNCRIS_vEry6ZglCs1wIwD76HkbMa3BHw,18948
97
97
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/__init__.py,sha256=DP_Hrjq0WlyuWhrkplLKmS3pVsoGZgFQgXhAcsjqBlQ,1889
98
98
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py,sha256=NXZTvQBn7iaqQVcXCUwAusSKx1PmBpcDiYl2uUa6hKA,22196
99
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/deepagents_assistant.py,sha256=DDNw13jlftfntPsM2ZVdJQyWqc2FFPcEXFo6wyZttbM,30208
99
+ fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/deepagents_assistant.py,sha256=vziHT47EmLWSh7RJWCAUHGu5orxm1PrINwLEc7OJJN8,30372
100
100
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/langchain_assistant.py,sha256=sX9dbzwR-X-HQvPWLgEunbzEv-DcJp7Db6VsCYwf0eY,13158
101
101
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/langgraph_assistant.py,sha256=uSDnHa7VvbPwQ_csqjMpmTlv_RTym4GkHUasbWlQdpU,15363
102
102
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/mcp.py,sha256=g3-R_dWEwNUzNN8BPsgc1sqNR1n7JoNiwtUYRgTAm8o,8614
@@ -150,7 +150,7 @@ fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/tel
150
150
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/users.py,sha256=maIccIgybZvB_54IaE9wrI8-TxdAoSb1ATWTDbk3sZU,3802
151
151
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/webhooks.py,sha256=1q3lkSy7cEr8KY2PNSbWYhOrJjqHKC3ujMbvxi4mkMs,5647
152
152
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/clients/__init__.py,sha256=gA4EUVDQQoTzeaLzFHZEA6Ps0eO7eo8NYjhNUDkzwzs,281
153
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/clients/redis.py,sha256=i_HpxZnpHkyF5U80fS1wDawC4d3ShzGqAObOjgEPP0k,2731
153
+ fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/clients/redis.py,sha256=Q67nC1xl-ImX-oWh-Ii1QBsl9srd9IZt3v8-Pr0bwzw,3075
154
154
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/__init__.py,sha256=P912XHMGspKPltxdR7Ac7anC76a6DyDVXc_o-clV_94,3097
155
155
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/channel.py,sha256=l5tHqHirVT00g0kkPASvcYQANrsm-fq5_F2yTb-3De8,6182
156
156
  fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/cleanup.py,sha256=ocirlds_LqKbzB-s0Jbz6n0CJdv3Uec34ysZHkm16rQ,2110
@@ -390,10 +390,10 @@ fastapi_gen/template/{{cookiecutter.project_slug}}/docs/adding_features.md,sha25
390
390
  fastapi_gen/template/{{cookiecutter.project_slug}}/docs/architecture.md,sha256=Lg8RzpLDO9tA-zVE3lFnllnTkW-FvQwc7-XMseneTq4,11806
391
391
  fastapi_gen/template/{{cookiecutter.project_slug}}/docs/commands.md,sha256=fwd58qBEN_mRS4ipOaUnR6A4B3SlqE5piMdsUXXQULg,11621
392
392
  fastapi_gen/template/{{cookiecutter.project_slug}}/docs/configuration.md,sha256=dKeJ3tmZp15HbK1ndnfQ3dVVfVGpcj9CSBXQutoxv4A,17645
393
- fastapi_gen/template/{{cookiecutter.project_slug}}/docs/deploy.md,sha256=NkFgg_n6CA9Av-CzM4ALvksafLEF2bt2zBurN-TsgRk,5715
393
+ fastapi_gen/template/{{cookiecutter.project_slug}}/docs/deploy.md,sha256=AvOHofpCqSXTNtt_7LagS5PVIxMQXorALiPe9FtTTU4,7607
394
394
  fastapi_gen/template/{{cookiecutter.project_slug}}/docs/file-processing.md,sha256=ss5pGrKvcKPqi2hU31iKd35JS2FzqPWObavfD5DpMmU,11003
395
395
  fastapi_gen/template/{{cookiecutter.project_slug}}/docs/patterns.md,sha256=FiEkdtRMkhPcU32spCnnEQFts0gh-3kGchKZoA1QK9A,5976
396
- fastapi_gen/template/{{cookiecutter.project_slug}}/docs/permissions.md,sha256=dEoSArf7qyni9dwz1mXBfpRkcUE-y7zIgJu_SgODoEo,9883
396
+ fastapi_gen/template/{{cookiecutter.project_slug}}/docs/permissions.md,sha256=lzXlt-qE2p2R1tXs-Oc5EKJ4OfFDORsdyTYg5oFfYSc,9877
397
397
  fastapi_gen/template/{{cookiecutter.project_slug}}/docs/testing.md,sha256=aWdWyLDRJ7g25KXZqDM1c9OR8BlHE3XrCcB1Awz9imE,2835
398
398
  fastapi_gen/template/{{cookiecutter.project_slug}}/docs/howto/add-agent-tool.md,sha256=vQan0rrqcxNWu0GlWt3_DWN81WUddasINoUzxS3jlhE,2188
399
399
  fastapi_gen/template/{{cookiecutter.project_slug}}/docs/howto/add-api-endpoint.md,sha256=vk4vpjvMxuqpDXNC4_KpJpGsluj6BUtgR-If20B3Egg,5883
@@ -431,12 +431,12 @@ fastapi_gen/template/{{cookiecutter.project_slug}}/emails/compiled/welcome.html,
431
431
  fastapi_gen/template/{{cookiecutter.project_slug}}/emails/compiled/welcome.txt,sha256=WzYZXM4pbV22E_C8KjSCRevn1j7wbx7ojIXYUuzp1e4,240
432
432
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.dockerignore,sha256=aPWb-zCIZu6TI0jiXFs481-eMrOO-FxVvRL3992jrDY,343
433
433
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.editorconfig,sha256=bWeW8XS3NrzXpomZ4w0vGaNxmtkjVdzAz9_OimaGRVM,188
434
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.env.example,sha256=-DZjR8Yz4gVjwlAEVz2WU5lUAtZ6wDUKaUpXc3JGBq8,1076
434
+ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.env.example,sha256=kKFRnghhxAoGij0jMyzwqycIGyjVwGV-dGgI3OUuErA,1481
435
435
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.gitignore,sha256=fZEN8HisQjcSmxEB3IDWoKA5dTYDzEAXLtqYKvdsprs,439
436
436
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.prettierignore,sha256=wYnkkZcvXw2mtuFVzYAjH-eFldFkK5Xn26fsZZ19BK0,161
437
437
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.prettierrc,sha256=1byVwTV0G71t2x5Ko6ryNm2oZ8753NfLLqhrCcon4v0,227
438
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/Dockerfile,sha256=gFukCLmTFut_vKbCOIXSuONONsUjxfc20YzfOSPa5MA,2059
439
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/README.md,sha256=VWOZbs83mmN49T9LhPASrSeH9aM-NOHY2eeAhrtD1uA,2504
438
+ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/Dockerfile,sha256=aRLL3IL_5b-v0Jkev_w9Mif6D3tBa-L_GAwWW5iN1vg,2323
439
+ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/README.md,sha256=2vyKiIM-umf7-RlKkgeNLIJhPAyWsan22OhSsiKyDJs,3689
440
440
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/eslint.config.mjs,sha256=y48aSi3BZvVi6GvePRqKmfaMJdK6ymN9X5fyeHFlpPU,2663
441
441
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/instrumentation.ts,sha256=KKUhYC1lApvs-Vtw9kkzqYiZBaW5axDvHhxeHznljM4,344
442
442
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/next.config.ts,sha256=5LYPHgUWe0H8oDoQ7aFBT-OlwC0SSvFJMmoroPAYqp0,3497
@@ -564,15 +564,15 @@ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/admin/st
564
564
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/admin/users/route.ts,sha256=OHFRS6yqJ0Dssp_2JILdMrlWPOwN9IObsETNlrcQYyA,1260
565
565
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/admin/users/[userId]/route.ts,sha256=n7qz_WZIQsM9982wk8ggIB0KrcioEJ-nnujB_ElrQzQ,2493
566
566
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/admin/users/[userId]/impersonate/route.ts,sha256=IoQ5agh5YZHujKHcEfuiBRF-yL1WALXM6gkR7c0jLWM,962
567
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/login/route.ts,sha256=ns4uvrWQ-d2nacsF1pTYG1cEakvCzNY7NSa7uMZrMKI,1882
568
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/logout/route.ts,sha256=0qxHR2OPvB2-IVKx0oS5f_U85e6EHq1oM5BCkUBsVeA,1167
567
+ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/login/route.ts,sha256=5RaTlPXW0OqhAXyxPBZc4ACbd0zlxskqVE5wkM3FHL0,1600
568
+ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/logout/route.ts,sha256=UzERrsLNPhLVYcD6rSAjpqtdol9txwLdReZAW8xvqpo,905
569
569
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/magic-link/request/route.ts,sha256=NEeaJx8y4KzSCAoYMYy3BxaeIh5P6hnXj4Z3EGe-RIA,684
570
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/magic-link/verify/route.ts,sha256=opdxw2xpY7w6PG_vtQ6ciGeVstBxHY0AtFAil_tnamQ,1468
571
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/me/route.ts,sha256=qifKtoFn6mhc-WKGCgRkMLXAozaV1njzze-7ZC-nMrY,2644
572
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/oauth-callback/route.ts,sha256=6HCAesKyqQS58QfrLM0LX_9aPDdZ4D7FCxFKQOtzvdU,1505
570
+ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/magic-link/verify/route.ts,sha256=2PQ6sHiMH3XcJsQUOs0_T9UEUF1JhR-RNEzwXAbl0bk,1215
571
+ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/me/route.ts,sha256=MEo19GyVCzg8E0SiPvf8pmLxbiKgj_80565XKiJ7Azc,2238
572
+ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/oauth-callback/route.ts,sha256=Frn_q5_2SY-KrtrqKt2m0fLiDZeGtn1i6GK1X74FT-I,1252
573
573
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/password-reset/confirm/route.ts,sha256=u96o_UzWdjLkTFnHfe39BndXK6aNY4F_H-gNjOTwU1M,688
574
574
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/password-reset/request/route.ts,sha256=Udibt_OsmUVlF4KhrhoptCg7nc3X58Qt16mwb-Lg9cQ,688
575
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/refresh/route.ts,sha256=NMmXK5X92mofkQ9ca24xrB5vYb2avJWPa7408M2Ibw8,1978
575
+ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/refresh/route.ts,sha256=siZmFcGWKSQHnxBacQ4Zw0kUdMMFHnaMdzSV1wqYB8A,1307
576
576
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/register/route.ts,sha256=nZVHtIebfXLSFQo1YKiwRSG1sq8sx057O6Y_ufmVF94,792
577
577
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/billing/checkout/route.ts,sha256=p1M8tlu9k2OWCcImClgU8_9HuvQ9RNGzo3Nh8HSvwEg,886
578
578
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/billing/invoices/route.ts,sha256=-x41bnrREpw__eKDQLVvswWo3xaBG80AFKpS2zLPvEo,860
@@ -858,6 +858,8 @@ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/active-route
858
858
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/admin-auth.ts,sha256=2K09o5o8Q24yn1dn1uAfqIIlrCgQQDz4tTFFdJVSCTU,904
859
859
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/agent-step-captions.ts,sha256=JjD-uhglLL3D9aUoNElMkjbdD-r4P9AayGUC2YmVO44,2388
860
860
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/api-client.ts,sha256=v9qO6pac2VH5j-o0oMhawpOUXDKqGQNrPo4KPUlLFX4,4107
861
+ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/auth-cookies.test.ts,sha256=dFoaULW2It1Iv9SZy5MnSAdvYEZHVJuxaOp6yBgLbzM,2802
862
+ fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/auth-cookies.ts,sha256=p9O-xSVIzk0zpJlQ8K2-ANdYxDNhvmzMcQA8ymbKsfA,2344
861
863
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/blog.ts,sha256=xaVKtXjwO53zzvVSPEfaifwfVXQzHnpNvpM8zDfElns,4304
862
864
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/changelog.ts,sha256=1z3pSuUEks_KmO-E9u5VUt1yXqQFDOrsXeJ-RvUHB0s,4340
863
865
  fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/chat-sources.ts,sha256=P7P2-8OvQQTvAzSaRiqRvOb3IkFF6CDgBnRdEAsL9rU,2035
@@ -911,11 +913,11 @@ fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/kustomization.yaml
911
913
  fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/namespace.yaml,sha256=hS9-TeXJNgu28AB0Pv43ob6lLDiavlYu0bwSRI804sA,360
912
914
  fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/secret.yaml,sha256=IyRrqpENJb5bQon8kxhxbJV0ohKPFyihWJ9tGPFDuVI,1524
913
915
  fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/service.yaml,sha256=o3bCXguj8E5aQ0H1o3cBD3z_iZ_RA_Pmx1bc0sVzK28,682
914
- fastapi_gen/template/{{cookiecutter.project_slug}}/nginx/nginx.conf,sha256=gJWUMMqNvr9TwNZOcc_WPPGZvdAlRUH3jLhbllOb_YY,7359
916
+ fastapi_gen/template/{{cookiecutter.project_slug}}/nginx/nginx.conf,sha256=2bWaxnv_rN4driEdtWgVz9eVfoKhB_Ohd_G8eOC3P3M,7619
915
917
  fastapi_gen/template/{{cookiecutter.project_slug}}/nginx/ssl/.gitkeep,sha256=o_WQQOgVGOLttT48x0EXSC4BbbDFemApFpmwhybcVUU,617
916
918
  fastapi_gen/template/{{cookiecutter.project_slug}}/scripts/export_demo_html.py,sha256=H1tbIfr1xrh2pRuoy5NwvAOYEu0S2vgnw_XsKNMKXeM,10414
917
- fastapi_fullstack-0.2.17.dist-info/METADATA,sha256=vEhQBnsjDOX1d_ITkhH3yPVog6s_lbND6Et3A1tcXGA,60444
918
- fastapi_fullstack-0.2.17.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
919
- fastapi_fullstack-0.2.17.dist-info/entry_points.txt,sha256=s9JXrISZp8LMYJGeVofOAd1wPTzpq-jwjSgSf4hWzjs,59
920
- fastapi_fullstack-0.2.17.dist-info/licenses/LICENSE,sha256=bL4JuK_rcA8y__Gg7PEuTs3g2Qf6VvSz2w2Jajd6nVU,1063
921
- fastapi_fullstack-0.2.17.dist-info/RECORD,,
919
+ fastapi_fullstack-0.2.18.dist-info/METADATA,sha256=LJac85BvO3ex5oIo2gj3B0A2qB7fLY8e1s855n2Jb7c,60593
920
+ fastapi_fullstack-0.2.18.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
921
+ fastapi_fullstack-0.2.18.dist-info/entry_points.txt,sha256=s9JXrISZp8LMYJGeVofOAd1wPTzpq-jwjSgSf4hWzjs,59
922
+ fastapi_fullstack-0.2.18.dist-info/licenses/LICENSE,sha256=bL4JuK_rcA8y__Gg7PEuTs3g2Qf6VvSz2w2Jajd6nVU,1063
923
+ fastapi_fullstack-0.2.18.dist-info/RECORD,,
fastapi_gen/UPGRADES.yaml CHANGED
@@ -18,4 +18,8 @@
18
18
  # manual_steps:
19
19
  # - "Run `alembic upgrade head` (new billing tables)."
20
20
 
21
- []
21
+ - version: "0.2.18"
22
+ manual_steps:
23
+ - "Rebuild the frontend image: `docker compose -f docker-compose.prod.yml build --no-cache frontend`. NEXT_PUBLIC_* is inlined into the browser bundle at build time and now comes from compose build args, so an image built before this upgrade still has the old ws://localhost URL baked in."
24
+ - "Serving the frontend over plain http:// with no TLS? Set COOKIE_SECURE=false on the frontend service. The auth cookies are Secure in production and a browser discards a Secure cookie sent over http://, which makes login appear to succeed while every request after it returns 401."
25
+ - "BACKEND_WS_URL and NEXT_PUBLIC_AUTH_ENABLED were never read by any code and are gone from the env examples. If your deployment sets them, the browser-facing WebSocket origin is NEXT_PUBLIC_WS_URL (build-time)."
@@ -623,19 +623,26 @@ else:
623
623
  "# Backend API URL (server-side only - not exposed to browser)",
624
624
  "BACKEND_URL=http://localhost:{{ cookiecutter.backend_port }}",
625
625
  "",
626
- "# WebSocket URL for real-time features",
627
- "BACKEND_WS_URL=ws://localhost:{{ cookiecutter.backend_port }}",
626
+ "# Send the auth cookies with the Secure flag. Unset follows NODE_ENV,",
627
+ "# so production means Secure — and a browser discards a Secure cookie",
628
+ "# served over plain http://, which logs you in and then 401s every",
629
+ "# request after it. Only set false for http:// on a trusted network.",
630
+ "# COOKIE_SECURE=false",
631
+ "",
632
+ "# WebSocket URL for the chat stream. Read by the BROWSER, so it must",
633
+ "# be an address the browser can reach — and NEXT_PUBLIC_* is inlined",
634
+ "# at build time, so in Docker it has to be passed as a build arg.",
635
+ "NEXT_PUBLIC_WS_URL=ws://localhost:{{ cookiecutter.backend_port }}",
636
+ "",
637
+ "# Public API URL, exposed to the browser (OAuth redirects, API docs",
638
+ "# links). Same build-time rule as NEXT_PUBLIC_WS_URL above.",
639
+ "NEXT_PUBLIC_API_URL=http://localhost:{{ cookiecutter.backend_port }}",
628
640
  "",
629
641
  "# Canonical site URL — used for SEO metadata, OG tags, sitemap.xml,",
630
642
  "# robots.txt, schema.org organization. Override in production with",
631
643
  "# the real https origin.",
632
644
  "NEXT_PUBLIC_SITE_URL=http://localhost:{{ cookiecutter.frontend_port }}",
633
645
  ]
634
- env_lines.extend([
635
- "",
636
- "# Authentication (always enabled)",
637
- "NEXT_PUBLIC_AUTH_ENABLED=true",
638
- ])
639
646
  if enable_oauth:
640
647
  # Build the comma-separated provider list the OAuth buttons read.
641
648
  # Currently only Google has full backend wiring; expand here when
@@ -644,9 +651,6 @@ else:
644
651
  if "{{ cookiecutter.enable_oauth_google }}" == "True":
645
652
  providers.append("google")
646
653
  env_lines.extend([
647
- "",
648
- "# Public API URL for OAuth redirects (exposed to browser)",
649
- "NEXT_PUBLIC_API_URL=http://localhost:{{ cookiecutter.backend_port }}",
650
654
  "",
651
655
  "# OAuth providers shown on /login + /register (comma-separated).",
652
656
  "# Drives the <OAuthButtons> component — must include only providers",
@@ -670,8 +674,10 @@ else:
670
674
  if "{{ cookiecutter.enable_brand_from_config }}" == "True":
671
675
  env_lines.extend([
672
676
  "",
673
- "# Runtime brand override (white-label)",
674
- "# Set NEXT_PUBLIC_BRAND_COLOR to one of: blue, green, red, violet, orange",
677
+ "# Brand override (white-label). NEXT_PUBLIC_* is inlined at build",
678
+ "# time, so changing these needs a rebuild — in Docker they are",
679
+ "# passed as build args (see docker-compose.frontend.yml).",
680
+ "# NEXT_PUBLIC_BRAND_COLOR: blue, green, red, violet or orange",
675
681
  "NEXT_PUBLIC_BRAND_COLOR={{ cookiecutter.brand_color }}",
676
682
  "NEXT_PUBLIC_BRAND_LOGO_URL=",
677
683
  ])
@@ -174,6 +174,19 @@ group is for and which are required vs optional.
174
174
  | `S3_REGION` | optional | `us-east-1` | AWS region |
175
175
  {%- endif %}
176
176
 
177
+ {%- if cookiecutter.use_frontend %}
178
+
179
+ ## Frontend (`frontend/.env.local`)
180
+
181
+ Separate from the backend `.env` above — `frontend/README.md` has the full table.
182
+ The two that decide whether a deployment works at all:
183
+
184
+ | Variable | Read by | Description |
185
+ |---|---|---|
186
+ | `NEXT_PUBLIC_WS_URL` | browser | WebSocket origin for chat. Inlined at **build** time, so it needs a rebuild to change, and it must be an address the browser can reach — not a Docker service name |
187
+ | `COOKIE_SECURE` | server | `Secure` flag on the auth cookies. Unset follows `NODE_ENV`. A browser drops a `Secure` cookie served over plain `http://`, which makes every request after login return 401 — set `false` only for `http://` on a trusted network |
188
+ {%- endif %}
189
+
177
190
  ## Validation
178
191
 
179
192
  ```bash
@@ -387,10 +387,9 @@ vercel-deploy:
387
387
  @echo "✅ Frontend deployed to Vercel!"
388
388
  @echo " Set environment variables in Vercel dashboard:"
389
389
  @echo " BACKEND_URL=https://api.your-domain.com"
390
- @echo " BACKEND_WS_URL=wss://api.your-domain.com"
391
- {%- if cookiecutter.use_jwt %}
392
- @echo " NEXT_PUBLIC_AUTH_ENABLED=true"
393
- {%- endif %}
390
+ @echo " NEXT_PUBLIC_API_URL=https://api.your-domain.com"
391
+ @echo " NEXT_PUBLIC_WS_URL=wss://api.your-domain.com"
392
+ @echo " NEXT_PUBLIC_SITE_URL=https://your-domain.com"
394
393
  {%- if cookiecutter.enable_rag %}
395
394
  @echo " NEXT_PUBLIC_RAG_ENABLED=true"
396
395
  {%- endif %}
@@ -318,7 +318,7 @@ bun run lint
318
318
  bun run build
319
319
  ```
320
320
 
321
- The frontend talks to the backend through Next.js API route handlers in `src/app/api/*` (server-side proxy that forwards auth cookies to the FastAPI backend). Direct calls to `localhost:{{ cookiecutter.backend_port }}` from the browser are deliberately avoided.
321
+ The frontend talks to the backend through Next.js API route handlers in `src/app/api/*` (server-side proxy that forwards auth cookies to the FastAPI backend), so HTTP traffic never leaves the frontend origin. The one exception is the chat WebSocket: the browser opens it straight against the backend at `NEXT_PUBLIC_WS_URL`, so that origin must be reachable from the browser and the backend port (or a proxy route to it) has to be exposed.
322
322
 
323
323
  i18n (PL + EN) ships out of the box via `next-intl`. Add a new locale by extending `messages/<lang>.json` and `src/i18n.ts`.
324
324
  {%- endif %}
@@ -335,13 +335,18 @@ cd frontend && npx vercel --prod
335
335
 
336
336
  Set in the Vercel dashboard:
337
337
 
338
- - `BACKEND_URL` = `https://api.your-domain.com`
339
- - `BACKEND_WS_URL` = `wss://api.your-domain.com`
340
- - `NEXT_PUBLIC_AUTH_ENABLED` = `true`
338
+ - `BACKEND_URL` = `https://api.your-domain.com` (server-side only)
339
+ - `NEXT_PUBLIC_API_URL` = `https://api.your-domain.com`
340
+ - `NEXT_PUBLIC_WS_URL` = `wss://api.your-domain.com`
341
+ - `NEXT_PUBLIC_SITE_URL` = `https://your-domain.com`
341
342
  {%- if cookiecutter.enable_rag %}
342
343
  - `NEXT_PUBLIC_RAG_ENABLED` = `true`
343
344
  {%- endif %}
344
345
 
346
+ `NEXT_PUBLIC_*` values are inlined into the browser bundle at build time, so
347
+ changing one needs a redeploy, and each must be an address the **browser** can
348
+ reach.
349
+
345
350
  ### Backend → your server
346
351
 
347
352
  ```bash
@@ -25,11 +25,10 @@ Configuration via settings:
25
25
  """
26
26
 
27
27
  import logging
28
- from collections.abc import Callable
29
28
  from typing import Any, TypedDict
30
29
 
31
30
  from deepagents import create_deep_agent
32
- from deepagents.backends import StateBackend
31
+ from deepagents.backends import BackendProtocol, StateBackend
33
32
  from langchain_core.language_models import BaseChatModel
34
33
  from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
35
34
  {%- if cookiecutter.enable_rag or cookiecutter.enable_web_search or cookiecutter.web_fetch_tool or cookiecutter.enable_charts %}
@@ -307,13 +306,17 @@ class DeepAgentsAssistant:
307
306
  self._graph = None
308
307
  self._checkpointer = MemorySaver()
309
308
 
310
- def _create_backend(self) -> Callable:
309
+ def _create_backend(self) -> BackendProtocol:
311
310
  """Create the file-storage backend.
312
311
 
312
+ `create_deep_agent` takes a backend instance, and StateBackend reads the
313
+ current run's state out of the LangGraph config itself, so it needs no
314
+ constructor argument.
315
+
313
316
  Returns:
314
- BackendFactory (callable) for StateBackend (in-memory, ephemeral).
317
+ StateBackend (in-memory file state, ephemeral, no external deps).
315
318
  """
316
- return lambda rt: StateBackend(rt)
319
+ return StateBackend()
317
320
 
318
321
  def _create_model(self) -> BaseChatModel:
319
322
  """Create the LLM model for DeepAgents."""
@@ -39,10 +39,18 @@ class RedisClient:
39
39
  self.client = None
40
40
 
41
41
  async def get(self, key: str) -> str | None:
42
- """Get a value by key."""
42
+ """Get a value by key.
43
+
44
+ `connect()` sets decode_responses=True, so the value comes back decoded —
45
+ but redis-py's annotation cannot express that and still admits `bytes`.
46
+ Decode explicitly rather than asserting the flag through a type: ignore.
47
+ """
43
48
  if not self.client:
44
49
  raise RuntimeError("Redis client not connected")
45
- return await self.client.get(key) # type: ignore[no-any-return]
50
+ value: str | bytes | None = await self.client.get(key)
51
+ if isinstance(value, bytes):
52
+ return value.decode("utf-8")
53
+ return value
46
54
 
47
55
  async def set(
48
56
  self,
@@ -75,7 +75,13 @@ dependencies = [
75
75
  "aiosmtplib>=3.0.0",
76
76
  {%- endif %}
77
77
  {%- if cookiecutter.enable_pagination %}
78
- "fastapi-pagination>=0.12.31",
78
+ # 0.15.16 is broken against every FastAPI without the private _get_body_field
79
+ # helpers (i.e. anything below 0.140.5, which includes the pin above): its
80
+ # ImportError fallback binds the public get_body_field but still sets
81
+ # _get_body_field_new_signature = True, so add_pagination() calls it with a
82
+ # body_params kwarg it does not accept and the app dies on import. Lift the
83
+ # cap once upstream fixes the fallback or the FastAPI pin moves past 0.140.5.
84
+ "fastapi-pagination>=0.12.31,<0.15.16",
79
85
  {%- endif %}
80
86
  {%- if cookiecutter.enable_sentry %}
81
87
  "sentry-sdk[fastapi]>=2.53.0",
@@ -154,7 +160,11 @@ dependencies = [
154
160
  "langgraph-checkpoint>=4.0.0",
155
161
  {%- endif %}
156
162
  {%- if cookiecutter.use_deepagents %}
157
- "deepagents>=0.1.0",
163
+ # Capped: the backend API is still moving between minors (0.7 dropped the
164
+ # runtime argument from StateBackend and switched create_deep_agent to take
165
+ # an instance instead of a factory), so an unbounded pin breaks generation
166
+ # on release day.
167
+ "deepagents>=0.7.1,<0.8",
158
168
  {%- if cookiecutter.use_openai %}
159
169
  "langchain-openai>=1.1.0",
160
170
  {%- endif %}
@@ -162,7 +162,7 @@ services:
162
162
  restart: unless-stopped
163
163
 
164
164
  milvus:
165
- image: milvusdb/milvus:v2.6.21
165
+ image: milvusdb/milvus:v2.6.22
166
166
  container_name: {{ cookiecutter.project_slug }}_milvus
167
167
  command: ["milvus", "run", "standalone"]
168
168
  ports:
@@ -11,26 +11,32 @@ services:
11
11
  context: ./frontend
12
12
  dockerfile: Dockerfile
13
13
  args:
14
- # NEXT_PUBLIC_* are baked into the browser bundle at build time. The
15
- # browser runs on the host, so it reaches the backend on localhost.
16
- - NEXT_PUBLIC_API_URL=http://localhost:{{ cookiecutter.backend_port }}
17
- - NEXT_PUBLIC_WS_URL=ws://localhost:{{ cookiecutter.backend_port }}
18
- - NEXT_PUBLIC_SITE_URL=http://localhost:{{ cookiecutter.frontend_port }}
14
+ # NEXT_PUBLIC_* are baked into the browser bundle at build time, so a
15
+ # runtime `environment:` entry cannot change them — after editing these
16
+ # you must rebuild: docker compose -f docker-compose.frontend.yml build
17
+ # --no-cache frontend. They must point at an address the BROWSER can
18
+ # reach; the defaults assume the browser runs on the Docker host. When
19
+ # you open the app from another machine, set PUBLIC_HOST to this host's
20
+ # address (e.g. PUBLIC_HOST=10.0.0.5) or override each URL directly.
21
+ - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://${PUBLIC_HOST:-localhost}:{{ cookiecutter.backend_port }}}
22
+ - NEXT_PUBLIC_WS_URL=${NEXT_PUBLIC_WS_URL:-ws://${PUBLIC_HOST:-localhost}:{{ cookiecutter.backend_port }}}
23
+ - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://${PUBLIC_HOST:-localhost}:{{ cookiecutter.frontend_port }}}
19
24
  - NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB=50
20
25
  {%- if cookiecutter.enable_oauth %}
21
26
  - NEXT_PUBLIC_OAUTH_PROVIDERS={% if cookiecutter.enable_oauth_google %}google{% endif %}
22
27
  {%- endif %}
23
28
  - NEXT_PUBLIC_RAG_ENABLED={% if cookiecutter.enable_rag %}true{% else %}false{% endif %}
29
+ {%- if cookiecutter.enable_brand_from_config %}
30
+ - NEXT_PUBLIC_BRAND_COLOR=${NEXT_PUBLIC_BRAND_COLOR:-{{ cookiecutter.brand_color }}}
31
+ - NEXT_PUBLIC_BRAND_LOGO_URL=${NEXT_PUBLIC_BRAND_LOGO_URL:-}
32
+ {%- endif %}
24
33
  container_name: {{ cookiecutter.project_slug }}_frontend
25
34
  ports:
26
35
  - "{{ cookiecutter.frontend_port }}:{{ cookiecutter.frontend_port }}"
27
36
  environment:
28
37
  - NODE_ENV=development
29
- - BACKEND_URL=http://host.docker.internal:{{ cookiecutter.backend_port }}
30
- - BACKEND_WS_URL=ws://host.docker.internal:{{ cookiecutter.backend_port }}
31
- {%- if cookiecutter.enable_rag %}
32
- - NEXT_PUBLIC_RAG_ENABLED=true
33
- {%- endif %}
38
+ # Server-side only: resolved inside this container, never by the browser.
39
+ - BACKEND_URL=${BACKEND_URL:-http://host.docker.internal:{{ cookiecutter.backend_port }}}
34
40
  extra_hosts:
35
41
  - "host.docker.internal:host-gateway"
36
42
  restart: unless-stopped
@@ -313,7 +313,7 @@ services:
313
313
  memory: 256M
314
314
 
315
315
  milvus:
316
- image: milvusdb/milvus:v2.6.21
316
+ image: milvusdb/milvus:v2.6.22
317
317
  container_name: {{ cookiecutter.project_slug }}_milvus
318
318
  command: ["milvus", "run", "standalone"]
319
319
  # NO external ports - only accessible within backend-internal network
@@ -655,11 +655,44 @@ services:
655
655
  build:
656
656
  context: ./frontend
657
657
  dockerfile: Dockerfile
658
+ # NEXT_PUBLIC_* are inlined into the browser bundle at build time, so they
659
+ # must be build args — an `environment:` entry cannot override them, and
660
+ # changing one needs a rebuild (docker compose -f docker-compose.prod.yml
661
+ # build --no-cache frontend). Every value here has to be reachable from
662
+ # the BROWSER, not from inside the compose network.
663
+ args:
664
+ {%- if cookiecutter.include_traefik_labels or cookiecutter.include_nginx_service %}
665
+ # The proxy terminates TLS and serves the backend on api.DOMAIN.
666
+ - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://api.${DOMAIN:-localhost}}
667
+ - NEXT_PUBLIC_WS_URL=${NEXT_PUBLIC_WS_URL:-wss://api.${DOMAIN:-localhost}}
668
+ - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://${DOMAIN:-localhost}}
669
+ {%- else %}
670
+ # No proxy in front, so the browser talks to the published ports.
671
+ # Set PUBLIC_HOST to this host's address — the localhost default only
672
+ # works when the browser runs on the Docker host itself.
673
+ - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://${PUBLIC_HOST:-localhost}:{{ cookiecutter.backend_port }}}
674
+ - NEXT_PUBLIC_WS_URL=${NEXT_PUBLIC_WS_URL:-ws://${PUBLIC_HOST:-localhost}:{{ cookiecutter.backend_port }}}
675
+ - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://${PUBLIC_HOST:-localhost}:{{ cookiecutter.frontend_port }}}
676
+ {%- endif %}
677
+ - NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB=${NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB:-50}
678
+ {%- if cookiecutter.enable_oauth %}
679
+ - NEXT_PUBLIC_OAUTH_PROVIDERS={% if cookiecutter.enable_oauth_google %}google{% endif %}
680
+ {%- endif %}
681
+ - NEXT_PUBLIC_RAG_ENABLED={% if cookiecutter.enable_rag %}true{% else %}false{% endif %}
682
+ {%- if cookiecutter.enable_brand_from_config %}
683
+ - NEXT_PUBLIC_BRAND_COLOR=${NEXT_PUBLIC_BRAND_COLOR:-{{ cookiecutter.brand_color }}}
684
+ - NEXT_PUBLIC_BRAND_LOGO_URL=${NEXT_PUBLIC_BRAND_LOGO_URL:-}
685
+ {%- endif %}
658
686
  container_name: {{ cookiecutter.project_slug }}_frontend
659
687
  environment:
660
688
  - NODE_ENV=production
689
+ # Server-side only: resolved inside the compose network by the Next server.
661
690
  - BACKEND_URL=http://app:{{ cookiecutter.backend_port }}
662
- - BACKEND_WS_URL=ws://app:{{ cookiecutter.backend_port }}
691
+ # The auth cookies are Secure in production, and a browser discards a
692
+ # Secure cookie that arrives over plain HTTP — login then "works" while
693
+ # every request after it is 401. Serve this over TLS, or set
694
+ # COOKIE_SECURE=false if it really is http:// on a trusted network.
695
+ - COOKIE_SECURE=${COOKIE_SECURE:-true}
663
696
  {%- if cookiecutter.include_traefik_labels %}
664
697
  networks:
665
698
  - traefik-public
@@ -164,7 +164,7 @@ services:
164
164
  restart: unless-stopped
165
165
 
166
166
  milvus:
167
- image: milvusdb/milvus:v2.6.21
167
+ image: milvusdb/milvus:v2.6.22
168
168
  container_name: {{ cookiecutter.project_slug }}_milvus
169
169
  command: ["milvus", "run", "standalone"]
170
170
  ports:
@@ -32,10 +32,45 @@ curl http://localhost:{{ cookiecutter.backend_port }}/api/v1/health
32
32
  {% if cookiecutter.use_frontend %}# Frontend: http://localhost:{{ cookiecutter.frontend_port }}{% endif %}
33
33
  ```
34
34
 
35
+ {% if cookiecutter.use_frontend %}
36
+ ### Serving from a host that isn't localhost
37
+
38
+ The defaults assume the browser runs on the Docker host. Opening the app from
39
+ another machine (a LAN IP, a staging box, a tunnel) needs two things set, and
40
+ both fail in a way that looks like something else:
41
+
42
+ ```bash
43
+ # In the .env next to docker-compose.prod.yml
44
+ PUBLIC_HOST=10.0.0.5 # an address the BROWSER can reach
45
+ COOKIE_SECURE=false # ONLY if you serve over plain http:// (see below)
46
+ ```
47
+
48
+ ```bash
49
+ # NEXT_PUBLIC_* is inlined into the bundle, so this needs a real rebuild
50
+ docker compose -f docker-compose.prod.yml build --no-cache frontend
51
+ docker compose -f docker-compose.prod.yml up -d frontend
52
+ ```
53
+
54
+ 1. **`NEXT_PUBLIC_*` is baked in at build time.** Next.js inlines those values
55
+ into the JavaScript the browser downloads, so a runtime env var cannot change
56
+ them — `docker-compose.prod.yml` passes them as `build:` args instead. Miss
57
+ this and the chat socket dials `ws://localhost:{{ cookiecutter.backend_port }}` from
58
+ the visitor's own machine and the input stays disabled ("Offline").
59
+ 2. **The auth cookies are `Secure` in production, and a browser silently drops a
60
+ `Secure` cookie that arrives over `http://`.** Login looks like it worked (the
61
+ user comes back in the response body) and then every request carrying no
62
+ cookie answers 401, `/api/auth/me` included, so the token never refreshes.
63
+ TLS is the real fix; `COOKIE_SECURE=false` is the escape hatch for a trusted
64
+ network.
65
+
66
+ Reaching the backend matters too: the chat socket is opened by the browser
67
+ directly against `NEXT_PUBLIC_WS_URL`, so either publish the backend port or
68
+ route `/api/v1/ws` through your proxy with the `Upgrade` headers set.
69
+ {% endif %}
35
70
  ### Reverse proxy
36
71
 
37
72
  {%- if cookiecutter.use_nginx %}
38
- Nginx config in `nginx/` proxies `/` → frontend, `/api` → backend, `/ws` → backend WebSocket. Update `server_name` and TLS cert paths in `nginx/conf.d/app.conf`.
73
+ Nginx config in `nginx/nginx.conf` proxies `/` → frontend, `/` on `api.DOMAIN` → backend, and `/api/v1/ws` → backend WebSocket. Update `server_name` and the TLS cert paths there.
39
74
  {%- elif cookiecutter.use_traefik %}
40
75
  Traefik labels in `docker-compose.yml` route based on `Host()`. Set `DOMAIN` env var, then point your DNS at the host. ACME / Let's Encrypt configured via labels — uncomment in `docker-compose.yml` and set `ACME_EMAIL`.
41
76
  {%- else %}
@@ -108,7 +143,9 @@ cd frontend
108
143
  vercel
109
144
  ```
110
145
 
111
- Set `BACKEND_URL` and `NEXT_PUBLIC_API_URL` env vars in Vercel dashboard pointing to your backend host.
146
+ Set `BACKEND_URL`, `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_WS_URL` (`wss://…`) and
147
+ `NEXT_PUBLIC_SITE_URL` in the Vercel dashboard, pointing at your backend host,
148
+ then redeploy — the `NEXT_PUBLIC_*` ones are only picked up by a fresh build.
112
149
  {% else %}
113
150
  Not applicable — this project doesn't generate a frontend.
114
151
  {% endif %}
@@ -51,7 +51,7 @@ These are defined in `app/api/deps.py` and used throughout the route layer:
51
51
 
52
52
  | Endpoint | Method | Admin | User | Notes |
53
53
  |----------|--------|-------|------|-------|
54
- | `/agent/ws/agent` | WS | Y | Y | WebSocket chat with AI agent |
54
+ | `/ws/agent` | WS | Y | Y | WebSocket chat with AI agent |
55
55
 
56
56
  ### Conversations
57
57
 
@@ -1,21 +1,22 @@
1
1
  # Backend API URL (server-side only - not exposed to browser)
2
2
  BACKEND_URL=http://localhost:{{ cookiecutter.backend_port }}
3
3
 
4
- # WebSocket URL for real-time features
5
- BACKEND_WS_URL=ws://localhost:{{ cookiecutter.backend_port }}
4
+ # Send the auth cookies with the Secure flag. Unset follows NODE_ENV, so
5
+ # production means Secure — and a browser silently discards a Secure cookie
6
+ # served over plain http://, which logs you in and then 401s every request
7
+ # after it. Only set this to false for an http:// deployment on a trusted
8
+ # network; the real fix is to serve the app over TLS.
9
+ # COOKIE_SECURE=false
6
10
 
7
- {%- if cookiecutter.use_jwt or cookiecutter.enable_oauth %}
8
- # Authentication (set to true when JWT or OAuth is enabled)
9
- NEXT_PUBLIC_AUTH_ENABLED=true
10
- {%- else %}
11
- # Authentication (set to true when JWT or OAuth is enabled)
12
- NEXT_PUBLIC_AUTH_ENABLED=false
13
- {%- endif %}
14
- {%- if cookiecutter.enable_oauth %}
11
+ # WebSocket URL for the chat stream. Read by the BROWSER, so it must be an
12
+ # address the browser can reach (not a Docker service name), and because
13
+ # NEXT_PUBLIC_* is inlined at build time it has to be set before `bun run
14
+ # build` — in Docker that means a build arg, see docker-compose.frontend.yml.
15
+ NEXT_PUBLIC_WS_URL=ws://localhost:{{ cookiecutter.backend_port }}
15
16
 
16
- # Public API URL for OAuth redirects (exposed to browser)
17
+ # Public API URL, exposed to the browser (OAuth redirects, links to the API
18
+ # docs). Same build-time rule as NEXT_PUBLIC_WS_URL above.
17
19
  NEXT_PUBLIC_API_URL=http://localhost:{{ cookiecutter.backend_port }}
18
- {%- endif %}
19
20
  {%- if cookiecutter.enable_rag %}
20
21
 
21
22
  # RAG (Retrieval Augmented Generation)
@@ -36,6 +36,12 @@ ENV NEXT_PUBLIC_OAUTH_PROVIDERS=$NEXT_PUBLIC_OAUTH_PROVIDERS
36
36
  {%- endif %}
37
37
  ARG NEXT_PUBLIC_RAG_ENABLED=false
38
38
  ENV NEXT_PUBLIC_RAG_ENABLED=$NEXT_PUBLIC_RAG_ENABLED
39
+ {%- if cookiecutter.enable_brand_from_config %}
40
+ ARG NEXT_PUBLIC_BRAND_COLOR={{ cookiecutter.brand_color }}
41
+ ENV NEXT_PUBLIC_BRAND_COLOR=$NEXT_PUBLIC_BRAND_COLOR
42
+ ARG NEXT_PUBLIC_BRAND_LOGO_URL=
43
+ ENV NEXT_PUBLIC_BRAND_LOGO_URL=$NEXT_PUBLIC_BRAND_LOGO_URL
44
+ {%- endif %}
39
45
 
40
46
  # Build the application
41
47
  ENV NEXT_TELEMETRY_DISABLED=1
@@ -21,18 +21,30 @@ Or run it in Docker from the project root: `make dev-frontend`.
21
21
 
22
22
  Copy `.env.example` to `.env.local` and adjust as needed:
23
23
 
24
- | Variable | Description |
25
- |----------|-------------|
26
- | `BACKEND_URL` | Backend HTTP base URL (server-side calls / proxying) |
27
- | `BACKEND_WS_URL` | Backend WebSocket URL for the chat stream |
28
- | `NEXT_PUBLIC_AUTH_ENABLED` | Toggle auth UI (`true` when JWT/OAuth is enabled) |
24
+ | Variable | Read by | Description |
25
+ |----------|---------|-------------|
26
+ | `BACKEND_URL` | server | Backend HTTP base URL used by the route handlers in `src/app/api/*` |
27
+ | `COOKIE_SECURE` | server | `Secure` flag on the auth cookies. Unset follows `NODE_ENV`; set `false` only for an `http://` deployment on a trusted network |
28
+ | `NEXT_PUBLIC_WS_URL` | browser | Backend WebSocket origin for the chat stream (e.g. `wss://api.example.com`) |
29
+ | `NEXT_PUBLIC_API_URL` | browser | Public API URL (OAuth redirects, links to the API docs) |
30
+ | `NEXT_PUBLIC_SITE_URL` | browser | Canonical site origin for SEO metadata, OG tags, `sitemap.xml` |
29
31
  {%- if cookiecutter.enable_oauth %}
30
- | `NEXT_PUBLIC_API_URL` | Public API URL used by OAuth redirects |
32
+ | `NEXT_PUBLIC_OAUTH_PROVIDERS` | browser | Comma-separated providers shown on `/login` + `/register` |
31
33
  {%- endif %}
32
34
  {%- if cookiecutter.enable_rag %}
33
- | `NEXT_PUBLIC_RAG_ENABLED` | Show knowledge-base / RAG UI |
35
+ | `NEXT_PUBLIC_RAG_ENABLED` | browser | Show knowledge-base / RAG UI |
34
36
  {%- endif %}
35
37
 
38
+ Two rules that cause most of the deployment confusion:
39
+
40
+ - **`NEXT_PUBLIC_*` is inlined into the browser bundle at build time.** Setting one
41
+ at runtime does nothing — you have to set it before `bun run build`, which in
42
+ Docker means a `build:` arg (see `docker-compose.frontend.yml`) followed by a
43
+ rebuild. Everything else in the table is read at runtime.
44
+ - **`NEXT_PUBLIC_*` values must be reachable from the browser**, so never a Docker
45
+ service name. `BACKEND_URL` is the opposite: it is resolved inside the
46
+ container, so a service name is exactly right there.
47
+
36
48
  ## Scripts
37
49
 
38
50
  ```bash
@@ -71,5 +83,8 @@ Add a locale by extending `i18n.ts` and providing its message catalog.
71
83
  npx vercel --prod
72
84
  ```
73
85
 
74
- In the Vercel dashboard set `BACKEND_URL`, `BACKEND_WS_URL`, and
75
- `NEXT_PUBLIC_AUTH_ENABLED=true`. See the project root `docs/deploy.md` for details.
86
+ In the Vercel dashboard set `BACKEND_URL=https://api.your-domain.com`,
87
+ `NEXT_PUBLIC_API_URL=https://api.your-domain.com`, `NEXT_PUBLIC_WS_URL=wss://api.your-domain.com`
88
+ and `NEXT_PUBLIC_SITE_URL=https://your-domain.com`, then redeploy — the
89
+ `NEXT_PUBLIC_*` ones only take effect on a fresh build. See the project root
90
+ `docs/deploy.md` for details.
@@ -1,4 +1,5 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { setAuthCookies } from "@/lib/auth-cookies";
2
3
  import { backendFetch, BackendApiError } from "@/lib/server-api";
3
4
  import type { LoginResponse } from "@/types";
4
5
 
@@ -31,20 +32,9 @@ export async function POST(request: NextRequest) {
31
32
  message: "Login successful",
32
33
  });
33
34
 
34
- response.cookies.set("access_token", data.access_token, {
35
- httpOnly: true,
36
- secure: process.env.NODE_ENV === "production",
37
- sameSite: "lax",
38
- maxAge: 60 * 15, // 15 minutes
39
- path: "/",
40
- });
41
-
42
- response.cookies.set("refresh_token", data.refresh_token, {
43
- httpOnly: true,
44
- secure: process.env.NODE_ENV === "production",
45
- sameSite: "lax",
46
- maxAge: 60 * 60 * 24 * 7, // 7 days
47
- path: "/",
35
+ setAuthCookies(response, {
36
+ accessToken: data.access_token,
37
+ refreshToken: data.refresh_token,
48
38
  });
49
39
 
50
40
  return response;
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
 
3
+ import { clearAuthCookies } from "@/lib/auth-cookies";
3
4
  import { backendFetch, BackendApiError } from "@/lib/server-api";
4
5
 
5
6
  export async function POST(request: NextRequest) {
@@ -22,20 +23,7 @@ export async function POST(request: NextRequest) {
22
23
 
23
24
  const response = NextResponse.json({ message: "Logged out successfully" });
24
25
 
25
- response.cookies.set("access_token", "", {
26
- httpOnly: true,
27
- secure: process.env.NODE_ENV === "production",
28
- sameSite: "lax",
29
- maxAge: 0,
30
- path: "/",
31
- });
32
- response.cookies.set("refresh_token", "", {
33
- httpOnly: true,
34
- secure: process.env.NODE_ENV === "production",
35
- sameSite: "lax",
36
- maxAge: 0,
37
- path: "/",
38
- });
26
+ clearAuthCookies(response);
39
27
 
40
28
  return response;
41
29
  }
@@ -1,5 +1,6 @@
1
1
  import { NextResponse, type NextRequest } from "next/server";
2
2
 
3
+ import { setAuthCookies } from "@/lib/auth-cookies";
3
4
  import { BackendApiError, backendFetch } from "@/lib/server-api";
4
5
 
5
6
  interface TokenResponse {
@@ -26,20 +27,9 @@ export async function POST(request: NextRequest) {
26
27
  message: "Sign-in successful",
27
28
  });
28
29
 
29
- const isProd = process.env.NODE_ENV === "production";
30
- response.cookies.set("access_token", data.access_token, {
31
- httpOnly: true,
32
- secure: isProd,
33
- sameSite: "lax",
34
- maxAge: 60 * 15,
35
- path: "/",
36
- });
37
- response.cookies.set("refresh_token", data.refresh_token, {
38
- httpOnly: true,
39
- secure: isProd,
40
- sameSite: "lax",
41
- maxAge: 60 * 60 * 24 * 7,
42
- path: "/",
30
+ setAuthCookies(response, {
31
+ accessToken: data.access_token,
32
+ refreshToken: data.refresh_token,
43
33
  });
44
34
  return response;
45
35
  } catch (error) {
@@ -1,19 +1,8 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { clearAuthCookies, setAuthCookies } from "@/lib/auth-cookies";
2
3
  import { backendFetch, BackendApiError } from "@/lib/server-api";
3
4
  import type { User } from "@/types";
4
5
 
5
- const ACCESS_MAXAGE = 60 * 15; // 15 min
6
- const REFRESH_MAXAGE = 60 * 60 * 24 * 7; // 7 days
7
-
8
- const cookieOpts = (maxAge: number) =>
9
- ({
10
- httpOnly: true,
11
- secure: process.env.NODE_ENV === "production",
12
- sameSite: "lax" as const,
13
- maxAge,
14
- path: "/",
15
- }) as const;
16
-
17
6
  function fetchMe(token: string) {
18
7
  return backendFetch<User>("/api/v1/auth/me", {
19
8
  headers: { Authorization: `Bearer ${token}` },
@@ -55,16 +44,15 @@ export async function GET(request: NextRequest) {
55
44
  );
56
45
  const data = await fetchMe(refreshed.access_token);
57
46
  const response = NextResponse.json({ ...data, access_token: refreshed.access_token });
58
- response.cookies.set("access_token", refreshed.access_token, cookieOpts(ACCESS_MAXAGE));
59
- if (refreshed.refresh_token) {
60
- response.cookies.set("refresh_token", refreshed.refresh_token, cookieOpts(REFRESH_MAXAGE));
61
- }
47
+ setAuthCookies(response, {
48
+ accessToken: refreshed.access_token,
49
+ refreshToken: refreshed.refresh_token,
50
+ });
62
51
  return response;
63
52
  } catch {
64
53
  // Refresh failed → truly logged out. Clear cookies.
65
54
  const response = NextResponse.json({ detail: "Not authenticated" }, { status: 401 });
66
- response.cookies.set("access_token", "", cookieOpts(0));
67
- response.cookies.set("refresh_token", "", cookieOpts(0));
55
+ clearAuthCookies(response);
68
56
  return response;
69
57
  }
70
58
  }
@@ -1,5 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
 
3
+ import { setAuthCookies } from "@/lib/auth-cookies";
3
4
  import { backendFetch, BackendApiError } from "@/lib/server-api";
4
5
 
5
6
  interface OAuthCallbackBody {
@@ -24,20 +25,9 @@ export async function POST(request: NextRequest) {
24
25
  message: "Sign-in successful",
25
26
  });
26
27
 
27
- const isProd = process.env.NODE_ENV === "production";
28
- response.cookies.set("access_token", body.access_token, {
29
- httpOnly: true,
30
- secure: isProd,
31
- sameSite: "lax",
32
- maxAge: 60 * 15,
33
- path: "/",
34
- });
35
- response.cookies.set("refresh_token", body.refresh_token, {
36
- httpOnly: true,
37
- secure: isProd,
38
- sameSite: "lax",
39
- maxAge: 60 * 60 * 24 * 7,
40
- path: "/",
28
+ setAuthCookies(response, {
29
+ accessToken: body.access_token,
30
+ refreshToken: body.refresh_token,
41
31
  });
42
32
  return response;
43
33
  } catch (error) {
@@ -1,4 +1,5 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { clearAuthCookies, setAuthCookies } from "@/lib/auth-cookies";
2
3
  import { backendFetch, BackendApiError } from "@/lib/server-api";
3
4
  import type { RefreshTokenResponse } from "@/types";
4
5
 
@@ -20,44 +21,18 @@ export async function POST(request: NextRequest) {
20
21
  message: "Token refreshed",
21
22
  });
22
23
 
23
- response.cookies.set("access_token", data.access_token, {
24
- httpOnly: true,
25
- secure: process.env.NODE_ENV === "production",
26
- sameSite: "lax",
27
- maxAge: 60 * 15, // 15 minutes
28
- path: "/",
24
+ // The refresh token is only re-set when the backend rotated it.
25
+ setAuthCookies(response, {
26
+ accessToken: data.access_token,
27
+ refreshToken: data.refresh_token,
29
28
  });
30
29
 
31
- // Rotate refresh token if backend returns a new one
32
- if (data.refresh_token) {
33
- response.cookies.set("refresh_token", data.refresh_token, {
34
- httpOnly: true,
35
- secure: process.env.NODE_ENV === "production",
36
- sameSite: "lax",
37
- maxAge: 60 * 60 * 24 * 7, // 7 days
38
- path: "/",
39
- });
40
- }
41
-
42
30
  return response;
43
31
  } catch (error) {
44
32
  if (error instanceof BackendApiError) {
45
33
  const response = NextResponse.json({ detail: "Session expired" }, { status: 401 });
46
34
 
47
- response.cookies.set("access_token", "", {
48
- httpOnly: true,
49
- secure: process.env.NODE_ENV === "production",
50
- sameSite: "lax",
51
- maxAge: 0,
52
- path: "/",
53
- });
54
- response.cookies.set("refresh_token", "", {
55
- httpOnly: true,
56
- secure: process.env.NODE_ENV === "production",
57
- sameSite: "lax",
58
- maxAge: 0,
59
- path: "/",
60
- });
35
+ clearAuthCookies(response);
61
36
 
62
37
  return response;
63
38
  }
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { NextResponse } from "next/server";
3
+ import {
4
+ ACCESS_TOKEN_MAX_AGE,
5
+ REFRESH_TOKEN_MAX_AGE,
6
+ clearAuthCookies,
7
+ resolveCookieSecure,
8
+ setAuthCookies,
9
+ } from "./auth-cookies";
10
+
11
+ describe("resolveCookieSecure", () => {
12
+ it("follows NODE_ENV when COOKIE_SECURE is unset", () => {
13
+ expect(resolveCookieSecure({ NODE_ENV: "production" })).toBe(true);
14
+ expect(resolveCookieSecure({ NODE_ENV: "development" })).toBe(false);
15
+ expect(resolveCookieSecure({})).toBe(false);
16
+ });
17
+
18
+ it("lets COOKIE_SECURE override NODE_ENV in both directions", () => {
19
+ // The whole point of the flag: a production build served over plain HTTP
20
+ // has to be able to turn Secure off, or the browser drops the cookie.
21
+ expect(resolveCookieSecure({ COOKIE_SECURE: "false", NODE_ENV: "production" })).toBe(false);
22
+ expect(resolveCookieSecure({ COOKIE_SECURE: "true", NODE_ENV: "development" })).toBe(true);
23
+ expect(resolveCookieSecure({ COOKIE_SECURE: "FALSE", NODE_ENV: "production" })).toBe(false);
24
+ });
25
+
26
+ it("ignores a value it does not understand instead of guessing", () => {
27
+ // A typo must not silently downgrade production to insecure cookies.
28
+ expect(resolveCookieSecure({ COOKIE_SECURE: "0", NODE_ENV: "production" })).toBe(true);
29
+ expect(resolveCookieSecure({ COOKIE_SECURE: "", NODE_ENV: "production" })).toBe(true);
30
+ expect(resolveCookieSecure({ COOKIE_SECURE: "yes", NODE_ENV: "development" })).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe("setAuthCookies", () => {
35
+ it("stores both tokens httpOnly, scoped to the whole site", () => {
36
+ const response = NextResponse.json({});
37
+ setAuthCookies(response, { accessToken: "access", refreshToken: "refresh" });
38
+
39
+ const access = response.cookies.get("access_token");
40
+ expect(access?.value).toBe("access");
41
+ expect(access?.httpOnly).toBe(true);
42
+ expect(access?.sameSite).toBe("lax");
43
+ expect(access?.path).toBe("/");
44
+ expect(access?.maxAge).toBe(ACCESS_TOKEN_MAX_AGE);
45
+
46
+ const refresh = response.cookies.get("refresh_token");
47
+ expect(refresh?.value).toBe("refresh");
48
+ expect(refresh?.maxAge).toBe(REFRESH_TOKEN_MAX_AGE);
49
+ });
50
+
51
+ it("leaves the refresh cookie alone when the backend did not rotate it", () => {
52
+ const response = NextResponse.json({});
53
+ setAuthCookies(response, { accessToken: "access" });
54
+
55
+ expect(response.cookies.get("access_token")?.value).toBe("access");
56
+ expect(response.cookies.get("refresh_token")).toBeUndefined();
57
+ });
58
+ });
59
+
60
+ describe("clearAuthCookies", () => {
61
+ it("expires both cookies", () => {
62
+ const response = NextResponse.json({});
63
+ clearAuthCookies(response);
64
+
65
+ expect(response.cookies.get("access_token")?.maxAge).toBe(0);
66
+ expect(response.cookies.get("refresh_token")?.maxAge).toBe(0);
67
+ });
68
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Auth cookie handling shared by every `/api/auth/*` route handler.
3
+ *
4
+ * The browser silently discards a `Secure` cookie sent from an insecure origin.
5
+ * A deployment served over plain HTTP — a LAN IP, a bare Docker host, a tunnel
6
+ * without TLS — therefore accepts the login response and then sends no cookie
7
+ * back on any request after it, so `/api/auth/me` answers 401 forever, the
8
+ * access token is never refreshed and the chat socket never authenticates.
9
+ *
10
+ * `COOKIE_SECURE` makes that flag explicit: unset follows `NODE_ENV`, `false`
11
+ * opts a trusted-network HTTP deployment out, `true` forces it on (e.g. a dev
12
+ * server behind an HTTPS proxy). Anything else falls back to the `NODE_ENV`
13
+ * default rather than guessing, so a typo cannot downgrade production.
14
+ */
15
+ import type { NextResponse } from "next/server";
16
+
17
+ export const ACCESS_TOKEN_MAX_AGE = 60 * 15; // 15 minutes
18
+ export const REFRESH_TOKEN_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
19
+
20
+ /** Exported for the unit test — the module-level value is read once at startup. */
21
+ export function resolveCookieSecure(env: { COOKIE_SECURE?: string; NODE_ENV?: string }): boolean {
22
+ switch (env.COOKIE_SECURE?.toLowerCase()) {
23
+ case "false":
24
+ return false;
25
+ case "true":
26
+ return true;
27
+ default:
28
+ return env.NODE_ENV === "production";
29
+ }
30
+ }
31
+
32
+ const COOKIE_SECURE = resolveCookieSecure(process.env);
33
+
34
+ const cookieOptions = (maxAge: number) =>
35
+ ({
36
+ httpOnly: true,
37
+ secure: COOKIE_SECURE,
38
+ sameSite: "lax",
39
+ maxAge,
40
+ path: "/",
41
+ }) as const;
42
+
43
+ interface AuthTokens {
44
+ accessToken: string;
45
+ /** Only set when the backend issued (or rotated) one. */
46
+ refreshToken?: string;
47
+ }
48
+
49
+ /** Store the session tokens as httpOnly cookies on an outgoing response. */
50
+ export function setAuthCookies(response: NextResponse, { accessToken, refreshToken }: AuthTokens) {
51
+ response.cookies.set("access_token", accessToken, cookieOptions(ACCESS_TOKEN_MAX_AGE));
52
+ if (refreshToken) {
53
+ response.cookies.set("refresh_token", refreshToken, cookieOptions(REFRESH_TOKEN_MAX_AGE));
54
+ }
55
+ }
56
+
57
+ /** Expire both session cookies — logout, or a refresh token the backend rejected. */
58
+ export function clearAuthCookies(response: NextResponse) {
59
+ response.cookies.set("access_token", "", cookieOptions(0));
60
+ response.cookies.set("refresh_token", "", cookieOptions(0));
61
+ }
@@ -96,8 +96,11 @@ server {
96
96
  proxy_set_header X-Forwarded-Host $host;
97
97
  proxy_set_header X-Forwarded-Port $server_port;
98
98
 
99
- # WebSocket support for /ws endpoint
100
- location /ws {
99
+ # WebSocket support. The chat sockets are mounted under the versioned API
100
+ # prefix (/api/v1/ws/agent, /api/v1/ws/projects/...), so this prefix has to
101
+ # match it — anything falling through to `location /` below is proxied
102
+ # without the Upgrade headers and the handshake fails.
103
+ location /api/v1/ws {
101
104
  proxy_pass http://backend;
102
105
  proxy_http_version 1.1;
103
106
  proxy_set_header Upgrade $http_upgrade;