create-workframe 0.1.0

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 (415) hide show
  1. package/.dockerignore +22 -0
  2. package/.gitignore +73 -0
  3. package/LICENSE +201 -0
  4. package/NOTICE +12 -0
  5. package/README.md +111 -0
  6. package/SECURITY.md +40 -0
  7. package/bin/create-workframe.js +2814 -0
  8. package/bin/workframe.js +329 -0
  9. package/docs/workspace-instructions/WORKFRAME_DISCORD.md +20 -0
  10. package/docs/workspace-instructions/WORKFRAME_DOCUMENTS_AND_ARTIFACTS.md +20 -0
  11. package/docs/workspace-instructions/WORKFRAME_KANBAN.md +20 -0
  12. package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +21 -0
  13. package/docs/workspace-instructions/WORKFRAME_ROUTING.md +29 -0
  14. package/docs/workspace-instructions/WORKFRAME_TELEGRAM.md +19 -0
  15. package/package.json +67 -0
  16. package/profiles/README.md +15 -0
  17. package/profiles/architect/AGENTS.md +29 -0
  18. package/profiles/architect/SOUL.md +44 -0
  19. package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -0
  20. package/profiles/designer/AGENTS.md +26 -0
  21. package/profiles/designer/SOUL.md +31 -0
  22. package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -0
  23. package/profiles/dev/AGENTS.md +28 -0
  24. package/profiles/dev/SOUL.md +31 -0
  25. package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -0
  26. package/profiles/docs/AGENTS.md +27 -0
  27. package/profiles/docs/SOUL.md +30 -0
  28. package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -0
  29. package/profiles/research/AGENTS.md +26 -0
  30. package/profiles/research/SOUL.md +31 -0
  31. package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -0
  32. package/profiles/visionary/AGENTS.md +25 -0
  33. package/profiles/visionary/SOUL.md +31 -0
  34. package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -0
  35. package/profiles/workframe-agent/AGENTS.md +37 -0
  36. package/profiles/workframe-agent/SETUP.md +185 -0
  37. package/profiles/workframe-agent/SOUL.md +61 -0
  38. package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -0
  39. package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -0
  40. package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -0
  41. package/prompts/WORKFRAME_PROMPT_TEMPLATES.md +16 -0
  42. package/rules/.hermes.md +11 -0
  43. package/rules/AGENTS.md +22 -0
  44. package/rules/workspace-README.md +5 -0
  45. package/scripts/apply-update-hermes.sh +17 -0
  46. package/scripts/apply-update-workframe.sh +77 -0
  47. package/scripts/bootstrap-workspace-link.sh +8 -0
  48. package/scripts/bundle-workframe-ui.mjs +77 -0
  49. package/scripts/compose-docker-host.sh +37 -0
  50. package/scripts/create_workframe_scaffold.py +648 -0
  51. package/scripts/ensure-compose-host-paths.mjs +51 -0
  52. package/scripts/fix-zk-encryption-key.sh +35 -0
  53. package/scripts/lib/install-identity.mjs +212 -0
  54. package/scripts/lib/workframe-registry.mjs +290 -0
  55. package/scripts/new-project.mjs +68 -0
  56. package/scripts/restart-gateway-hermes.sh +12 -0
  57. package/scripts/security_audit.py +156 -0
  58. package/scripts/select_agent_pack.py +31 -0
  59. package/scripts/set-compose-public-url.mjs +92 -0
  60. package/scripts/setup-stack-secrets.sh +50 -0
  61. package/scripts/sync-canonical-to-package.mjs +146 -0
  62. package/scripts/test-scaffold.mjs +390 -0
  63. package/scripts/verify-public-deploy.sh +105 -0
  64. package/shared/WORKFRAME_AGENT_LIBRARY.md +31 -0
  65. package/shared/WORKFRAME_AGENT_OPERATIONS.md +29 -0
  66. package/shared/WORKFRAME_AGENT_PACKS.json +64 -0
  67. package/shared/WORKFRAME_AGENT_PACKS.yaml +20 -0
  68. package/shared/WORKFRAME_CHAT_PERMISSION_MODEL.md +20 -0
  69. package/shared/WORKFRAME_HANDOFF_SCHEMA.md +25 -0
  70. package/shared/WORKFRAME_SKILL_CURATION.md +27 -0
  71. package/shared/agent-avatars/ada.png +0 -0
  72. package/shared/agent-avatars/aibert.png +0 -0
  73. package/shared/agent-avatars/amelia.png +0 -0
  74. package/shared/agent-avatars/andy.png +0 -0
  75. package/shared/agent-avatars/arc.png +0 -0
  76. package/shared/agent-avatars/bob.png +0 -0
  77. package/shared/agent-avatars/buzz.png +0 -0
  78. package/shared/agent-avatars/carl.png +0 -0
  79. package/shared/agent-avatars/catalog.json +171 -0
  80. package/shared/agent-avatars/corbu.png +0 -0
  81. package/shared/agent-avatars/diana.png +0 -0
  82. package/shared/agent-avatars/ella.png +0 -0
  83. package/shared/agent-avatars/elvis.png +0 -0
  84. package/shared/agent-avatars/f1.png +0 -0
  85. package/shared/agent-avatars/f2.png +0 -0
  86. package/shared/agent-avatars/f3.png +0 -0
  87. package/shared/agent-avatars/f4.png +0 -0
  88. package/shared/agent-avatars/f5.png +0 -0
  89. package/shared/agent-avatars/f6.png +0 -0
  90. package/shared/agent-avatars/frida.png +0 -0
  91. package/shared/agent-avatars/george.png +0 -0
  92. package/shared/agent-avatars/grace.png +0 -0
  93. package/shared/agent-avatars/hedy.png +0 -0
  94. package/shared/agent-avatars/hermes.png +0 -0
  95. package/shared/agent-avatars/isaac.png +0 -0
  96. package/shared/agent-avatars/jes.png +0 -0
  97. package/shared/agent-avatars/john.png +0 -0
  98. package/shared/agent-avatars/joni.png +0 -0
  99. package/shared/agent-avatars/leo.png +0 -0
  100. package/shared/agent-avatars/louis.png +0 -0
  101. package/shared/agent-avatars/ludwig.png +0 -0
  102. package/shared/agent-avatars/m1.png +0 -0
  103. package/shared/agent-avatars/m2.png +0 -0
  104. package/shared/agent-avatars/m3.png +0 -0
  105. package/shared/agent-avatars/m4.png +0 -0
  106. package/shared/agent-avatars/m5.png +0 -0
  107. package/shared/agent-avatars/m6.png +0 -0
  108. package/shared/agent-avatars/marie.png +0 -0
  109. package/shared/agent-avatars/marilyn.png +0 -0
  110. package/shared/agent-avatars/neil.png +0 -0
  111. package/shared/agent-avatars/nikola.png +0 -0
  112. package/shared/agent-avatars/nina.png +0 -0
  113. package/shared/agent-avatars/paul.png +0 -0
  114. package/shared/agent-avatars/ringo.png +0 -0
  115. package/shared/agent-avatars/rosie.png +0 -0
  116. package/shared/agent-avatars/ste.png +0 -0
  117. package/shared/agent-avatars/steve.png +0 -0
  118. package/shared/agent-avatars/sun.png +0 -0
  119. package/shared/agent-avatars/tom.png +0 -0
  120. package/shared/agent-avatars/warren.png +0 -0
  121. package/shared/agent-avatars/woz.png +0 -0
  122. package/shared/agent-avatars/zaha.png +0 -0
  123. package/workframe-api/Dockerfile +14 -0
  124. package/workframe-api/README.md +28 -0
  125. package/workframe-api/action_proxy.py +131 -0
  126. package/workframe-api/auth_rate_limit.py +49 -0
  127. package/workframe-api/catalog/avatar-catalog.json +171 -0
  128. package/workframe-api/catalog/logo-catalog.json +86 -0
  129. package/workframe-api/catalog/user-avatar-catalog.json +171 -0
  130. package/workframe-api/credential_vault.py +445 -0
  131. package/workframe-api/data/.gitkeep +0 -0
  132. package/workframe-api/data/avatar-catalog.json +41 -0
  133. package/workframe-api/data/logo-catalog.json +14 -0
  134. package/workframe-api/data/user-avatar-catalog.json +18 -0
  135. package/workframe-api/email_sender.py +220 -0
  136. package/workframe-api/google_auth.py +90 -0
  137. package/workframe-api/install_api.py +359 -0
  138. package/workframe-api/internal_proxy_auth.py +150 -0
  139. package/workframe-api/llm_proxy.py +277 -0
  140. package/workframe-api/oidc_jwt.py +108 -0
  141. package/workframe-api/package.json +13 -0
  142. package/workframe-api/platform_auth.py +194 -0
  143. package/workframe-api/profile_secret_policy.py +86 -0
  144. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -0
  145. package/workframe-api/public/assets/index-DYnLrCZZ.js +9 -0
  146. package/workframe-api/public/assets/index-DglUqFB_.js +9 -0
  147. package/workframe-api/public/index.html +12 -0
  148. package/workframe-api/requirements.txt +2 -0
  149. package/workframe-api/server.py +19646 -0
  150. package/workframe-api/site_meta.py +271 -0
  151. package/workframe-api/stack_config.py +427 -0
  152. package/workframe-api/tests/__init__.py +0 -0
  153. package/workframe-api/tests/db_setup.py +13 -0
  154. package/workframe-api/tests/test_admin_updates_gated.py +30 -0
  155. package/workframe-api/tests/test_agent_dm_bootstrap.py +196 -0
  156. package/workframe-api/tests/test_agent_profile_sync.py +76 -0
  157. package/workframe-api/tests/test_auth_email.py +222 -0
  158. package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +99 -0
  159. package/workframe-api/tests/test_auth_rate_limit.py +19 -0
  160. package/workframe-api/tests/test_avatar_resolve.py +77 -0
  161. package/workframe-api/tests/test_child_soul_template.py +71 -0
  162. package/workframe-api/tests/test_credential_canary.py +135 -0
  163. package/workframe-api/tests/test_credential_isolation.py +448 -0
  164. package/workframe-api/tests/test_credential_resolution.py +206 -0
  165. package/workframe-api/tests/test_device_oauth.py +108 -0
  166. package/workframe-api/tests/test_doctor_repair.py +103 -0
  167. package/workframe-api/tests/test_ensure_profile_api.py +77 -0
  168. package/workframe-api/tests/test_gateway_compose_security.py +136 -0
  169. package/workframe-api/tests/test_install_secure_host.py +39 -0
  170. package/workframe-api/tests/test_internal_proxy_auth.py +125 -0
  171. package/workframe-api/tests/test_invite_runtime_bootstrap.py +72 -0
  172. package/workframe-api/tests/test_kanban_delegation.py +185 -0
  173. package/workframe-api/tests/test_llm_proxy.py +155 -0
  174. package/workframe-api/tests/test_login_access_policy.py +183 -0
  175. package/workframe-api/tests/test_mvp_model_bootstrap.py +75 -0
  176. package/workframe-api/tests/test_onboarding_bootstrap.py +248 -0
  177. package/workframe-api/tests/test_platform_auth.py +47 -0
  178. package/workframe-api/tests/test_profile_config_path.py +56 -0
  179. package/workframe-api/tests/test_profile_config_yaml_repair.py +63 -0
  180. package/workframe-api/tests/test_profile_create.py +72 -0
  181. package/workframe-api/tests/test_profile_identity_overlay.py +61 -0
  182. package/workframe-api/tests/test_profile_install_health.py +45 -0
  183. package/workframe-api/tests/test_profile_secret_policy.py +57 -0
  184. package/workframe-api/tests/test_profile_workspace_cwd.py +34 -0
  185. package/workframe-api/tests/test_provider_bootstrap.py +75 -0
  186. package/workframe-api/tests/test_provider_connect.py +54 -0
  187. package/workframe-api/tests/test_room_crud.py +192 -0
  188. package/workframe-api/tests/test_room_tenancy.py +701 -0
  189. package/workframe-api/tests/test_runtime_identity_backfill.py +34 -0
  190. package/workframe-api/tests/test_site_meta.py +81 -0
  191. package/workframe-api/tests/test_soul_stub.py +42 -0
  192. package/workframe-api/tests/test_space_member_sync.py +99 -0
  193. package/workframe-api/tests/test_stripe_stack_config.py +37 -0
  194. package/workframe-api/tests/test_supervisor_lifecycle.py +52 -0
  195. package/workframe-api/tests/test_turn_credential_vault.py +125 -0
  196. package/workframe-api/tests/test_updates.py +176 -0
  197. package/workframe-api/tests/test_user_cohort.py +113 -0
  198. package/workframe-api/tests/test_vault_envelope.py +110 -0
  199. package/workframe-api/tests/test_workspace_members.py +183 -0
  200. package/workframe-api/tests/test_workspace_messaging_sync.py +125 -0
  201. package/workframe-api/tests/test_workspace_provider_list.py +57 -0
  202. package/workframe-api/time-bind-chat.py +99 -0
  203. package/workframe-api/turn_credentials.py +226 -0
  204. package/workframe-api/updates.py +417 -0
  205. package/workframe-api/vault_kek.py +159 -0
  206. package/workframe-api/zk_auth.py +633 -0
  207. package/workframe-supervisor/Dockerfile +11 -0
  208. package/workframe-supervisor/profile_secret_policy.py +76 -0
  209. package/workframe-supervisor/server.py +787 -0
  210. package/workframe-supervisor/tests/test_exec_guard.py +42 -0
  211. package/workframe-supervisor/tests/test_server_import.py +21 -0
  212. package/workframe-ui/docker/nginx.conf +85 -0
  213. package/workframe-ui/public/assets/1-DLJbBkOb.png +0 -0
  214. package/workframe-ui/public/assets/10-uwRwj5ce.png +0 -0
  215. package/workframe-ui/public/assets/11-5OuV9F_e.png +0 -0
  216. package/workframe-ui/public/assets/12-u_axjxW-.png +0 -0
  217. package/workframe-ui/public/assets/13-ldSvcMsH.png +0 -0
  218. package/workframe-ui/public/assets/14-xdcALEYD.png +0 -0
  219. package/workframe-ui/public/assets/15-aZ4snEFB.png +0 -0
  220. package/workframe-ui/public/assets/16-L_5-DttY.png +0 -0
  221. package/workframe-ui/public/assets/2-zOPZTppD.png +0 -0
  222. package/workframe-ui/public/assets/3-Dc3WoVu5.png +0 -0
  223. package/workframe-ui/public/assets/4-C50hk7_m.png +0 -0
  224. package/workframe-ui/public/assets/5-Eweetkq4.png +0 -0
  225. package/workframe-ui/public/assets/6-5sOXgfkw.png +0 -0
  226. package/workframe-ui/public/assets/7-BqRBCbiC.png +0 -0
  227. package/workframe-ui/public/assets/8-DEDKS94h.png +0 -0
  228. package/workframe-ui/public/assets/9-DNj34GW-.png +0 -0
  229. package/workframe-ui/public/assets/ada-DsvuOc9n.png +0 -0
  230. package/workframe-ui/public/assets/aibert-BCz8Lo8H.png +0 -0
  231. package/workframe-ui/public/assets/amelia-DUf3EBGu.png +0 -0
  232. package/workframe-ui/public/assets/andy-Cpymuhhx.png +0 -0
  233. package/workframe-ui/public/assets/arc-CBDYvkAF.js +1 -0
  234. package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +1 -0
  235. package/workframe-ui/public/assets/architectureDiagram-3BPJPVTR-XnBRKeW0.js +36 -0
  236. package/workframe-ui/public/assets/array-BifhSqXX.js +1 -0
  237. package/workframe-ui/public/assets/avatars/ada.png +0 -0
  238. package/workframe-ui/public/assets/avatars/aibert.png +0 -0
  239. package/workframe-ui/public/assets/avatars/amelia.png +0 -0
  240. package/workframe-ui/public/assets/avatars/andy.png +0 -0
  241. package/workframe-ui/public/assets/avatars/bob.png +0 -0
  242. package/workframe-ui/public/assets/avatars/buzz.png +0 -0
  243. package/workframe-ui/public/assets/avatars/carl.png +0 -0
  244. package/workframe-ui/public/assets/avatars/catalog.json +171 -0
  245. package/workframe-ui/public/assets/avatars/corbu.png +0 -0
  246. package/workframe-ui/public/assets/avatars/diana.png +0 -0
  247. package/workframe-ui/public/assets/avatars/elvis.png +0 -0
  248. package/workframe-ui/public/assets/avatars/frida.png +0 -0
  249. package/workframe-ui/public/assets/avatars/george.png +0 -0
  250. package/workframe-ui/public/assets/avatars/grace.png +0 -0
  251. package/workframe-ui/public/assets/avatars/hedy.png +0 -0
  252. package/workframe-ui/public/assets/avatars/hermes.png +0 -0
  253. package/workframe-ui/public/assets/avatars/isaac.png +0 -0
  254. package/workframe-ui/public/assets/avatars/john.png +0 -0
  255. package/workframe-ui/public/assets/avatars/joni.png +0 -0
  256. package/workframe-ui/public/assets/avatars/leo.png +0 -0
  257. package/workframe-ui/public/assets/avatars/louis.png +0 -0
  258. package/workframe-ui/public/assets/avatars/ludwig.png +0 -0
  259. package/workframe-ui/public/assets/avatars/marie.png +0 -0
  260. package/workframe-ui/public/assets/avatars/marilyn.png +0 -0
  261. package/workframe-ui/public/assets/avatars/nikola.png +0 -0
  262. package/workframe-ui/public/assets/avatars/nina.png +0 -0
  263. package/workframe-ui/public/assets/avatars/paul.png +0 -0
  264. package/workframe-ui/public/assets/avatars/ringo.png +0 -0
  265. package/workframe-ui/public/assets/avatars/rosie.png +0 -0
  266. package/workframe-ui/public/assets/avatars/steve.png +0 -0
  267. package/workframe-ui/public/assets/avatars/sun.png +0 -0
  268. package/workframe-ui/public/assets/avatars/warren.png +0 -0
  269. package/workframe-ui/public/assets/avatars/woz.png +0 -0
  270. package/workframe-ui/public/assets/avatars/zaha.png +0 -0
  271. package/workframe-ui/public/assets/blockDiagram-GPEHLZMM-VYHUfVhd.js +132 -0
  272. package/workframe-ui/public/assets/bob-DRz-48Id.png +0 -0
  273. package/workframe-ui/public/assets/branding/banner.png +0 -0
  274. package/workframe-ui/public/assets/branding/og-default.png +0 -0
  275. package/workframe-ui/public/assets/branding/workframe'white.png +0 -0
  276. package/workframe-ui/public/assets/branding/workframe-1.png +0 -0
  277. package/workframe-ui/public/assets/branding/workframe-2.png +0 -0
  278. package/workframe-ui/public/assets/branding/workframe-3.png +0 -0
  279. package/workframe-ui/public/assets/branding/workframe-4.png +0 -0
  280. package/workframe-ui/public/assets/branding/workframe-5.png +0 -0
  281. package/workframe-ui/public/assets/branding/workframe-banner.png +0 -0
  282. package/workframe-ui/public/assets/branding/workframe-logo-horizontal-mini.png +0 -0
  283. package/workframe-ui/public/assets/branding/workframe-logo-horizontal-nano.png +0 -0
  284. package/workframe-ui/public/assets/branding/workframe-logo-horizontal.png +0 -0
  285. package/workframe-ui/public/assets/branding/workframe-logo-vertical-alt.png +0 -0
  286. package/workframe-ui/public/assets/branding/workframe-logo-vertical.png +0 -0
  287. package/workframe-ui/public/assets/branding/workframe.png +0 -0
  288. package/workframe-ui/public/assets/buzz-mC4PtMvC.png +0 -0
  289. package/workframe-ui/public/assets/c4Diagram-AAUBKEIU-BTjUcJpm.js +10 -0
  290. package/workframe-ui/public/assets/carl-CtE74db_.png +0 -0
  291. package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +1 -0
  292. package/workframe-ui/public/assets/chunk-2J33WTMH-w7uu7R-b.js +1 -0
  293. package/workframe-ui/public/assets/chunk-3OPIFGDE-Cb9LtnDX.js +62 -0
  294. package/workframe-ui/public/assets/chunk-4BX2VUAB-DiQ-qCwH.js +1 -0
  295. package/workframe-ui/public/assets/chunk-55IACEB6-C-mLFr7z.js +1 -0
  296. package/workframe-ui/public/assets/chunk-5ZQYHXKU-DOesfiCI.js +2 -0
  297. package/workframe-ui/public/assets/chunk-727SXJPM-BJ3oBZuz.js +206 -0
  298. package/workframe-ui/public/assets/chunk-AQP2D5EJ-CCA6xpGs.js +231 -0
  299. package/workframe-ui/public/assets/chunk-BSJP7CBP-a0cMNFb2.js +1 -0
  300. package/workframe-ui/public/assets/chunk-CSCIHK7Q-kuqN8EIY.js +122 -0
  301. package/workframe-ui/public/assets/chunk-FMBD7UC4-DyPgYHCg.js +15 -0
  302. package/workframe-ui/public/assets/chunk-KSCS5N6A-CdUuvR0V.js +10 -0
  303. package/workframe-ui/public/assets/chunk-L5ZTLDWV-Dq9NoWmK.js +1 -0
  304. package/workframe-ui/public/assets/chunk-LZXEDZCA-p74rddlO.js +2 -0
  305. package/workframe-ui/public/assets/chunk-ND2GUHAM-DBD2u1Gz.js +1 -0
  306. package/workframe-ui/public/assets/chunk-NNHCCRGN-DlpIbxXb.js +159 -0
  307. package/workframe-ui/public/assets/chunk-NZK2D7GU-BeIeYFnd.js +1 -0
  308. package/workframe-ui/public/assets/chunk-O5CBEL6O-ClHc56ib.js +70 -0
  309. package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +1 -0
  310. package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +1 -0
  311. package/workframe-ui/public/assets/chunk-XPW4576I-EFr8R_1p.js +32 -0
  312. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +1 -0
  313. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +1 -0
  314. package/workframe-ui/public/assets/corbu-KiaMXzXQ.png +0 -0
  315. package/workframe-ui/public/assets/cose-bilkent-S5V4N54A-C7aPBODd.js +1 -0
  316. package/workframe-ui/public/assets/cytoscape.esm-h6BdjjI9.js +321 -0
  317. package/workframe-ui/public/assets/dagre-BM42HDAG-BdU1Rv-H.js +4 -0
  318. package/workframe-ui/public/assets/dagre-Bx709z4p.js +1 -0
  319. package/workframe-ui/public/assets/defaultLocale-C8Fc0cco.js +1 -0
  320. package/workframe-ui/public/assets/diagram-2AECGRRQ-DWowSo85.js +43 -0
  321. package/workframe-ui/public/assets/diagram-5GNKFQAL-MnxBbceO.js +10 -0
  322. package/workframe-ui/public/assets/diagram-KO2AKTUF-DQaLRXFf.js +3 -0
  323. package/workframe-ui/public/assets/diagram-LMA3HP47-CQaBud9k.js +24 -0
  324. package/workframe-ui/public/assets/diagram-OG6HWLK6-D8bAXbY9.js +24 -0
  325. package/workframe-ui/public/assets/diana-DW0MsL38.png +0 -0
  326. package/workframe-ui/public/assets/dist-DGpTLHr_.js +1 -0
  327. package/workframe-ui/public/assets/elvis-LCFaZIcT.png +0 -0
  328. package/workframe-ui/public/assets/erDiagram-TEJ5UH35-1E-xSvBK.js +85 -0
  329. package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +1 -0
  330. package/workframe-ui/public/assets/flowDiagram-I6XJVG4X-CgOVD5hu.js +162 -0
  331. package/workframe-ui/public/assets/frida-CXFA0w3F.png +0 -0
  332. package/workframe-ui/public/assets/ganttDiagram-6RSMTGT7-JFYAIauo.js +292 -0
  333. package/workframe-ui/public/assets/george-DBSH2Sm2.png +0 -0
  334. package/workframe-ui/public/assets/gitGraph-WXDBUCRP-B9REenIl.js +1 -0
  335. package/workframe-ui/public/assets/gitGraphDiagram-PVQCEYII-BQ7NcMSn.js +106 -0
  336. package/workframe-ui/public/assets/grace-BhV0UPc0.png +0 -0
  337. package/workframe-ui/public/assets/graphlib-B8gBHxth.js +1 -0
  338. package/workframe-ui/public/assets/hedy-BR2IHift.png +0 -0
  339. package/workframe-ui/public/assets/hermes-CqCzcE0y.png +0 -0
  340. package/workframe-ui/public/assets/index-Dnw6vjqb.js +133 -0
  341. package/workframe-ui/public/assets/index-DpAGxump.css +1 -0
  342. package/workframe-ui/public/assets/info-J43DQDTF-CL6-eTjH.js +1 -0
  343. package/workframe-ui/public/assets/infoDiagram-5YYISTIA-LJTODW4W.js +2 -0
  344. package/workframe-ui/public/assets/init-D6jRqBbL.js +1 -0
  345. package/workframe-ui/public/assets/isaac-D1nhJAuv.png +0 -0
  346. package/workframe-ui/public/assets/ishikawaDiagram-YF4QCWOH-bchrQVuo.js +70 -0
  347. package/workframe-ui/public/assets/john-zSPWwNi4.png +0 -0
  348. package/workframe-ui/public/assets/joni-BFLoyfJP.png +0 -0
  349. package/workframe-ui/public/assets/journeyDiagram-JHISSGLW-DkrvYuxP.js +139 -0
  350. package/workframe-ui/public/assets/kanban-definition-UN3LZRKU-DFRbj0IG.js +89 -0
  351. package/workframe-ui/public/assets/katex-Vhh-h91d.js +257 -0
  352. package/workframe-ui/public/assets/leo-C_3IOL11.png +0 -0
  353. package/workframe-ui/public/assets/line-Vd48P7-O.js +1 -0
  354. package/workframe-ui/public/assets/linear-Ckizh2G7.js +1 -0
  355. package/workframe-ui/public/assets/louis-DEEECFSX.png +0 -0
  356. package/workframe-ui/public/assets/ludwig-_hoKhhyK.png +0 -0
  357. package/workframe-ui/public/assets/marie-DET6MsfO.png +0 -0
  358. package/workframe-ui/public/assets/marilyn-DTqwt8Yh.png +0 -0
  359. package/workframe-ui/public/assets/mermaid-parser.core-Bkimsnqj.js +4 -0
  360. package/workframe-ui/public/assets/mermaid.core-x0TvVuPo.js +9 -0
  361. package/workframe-ui/public/assets/mindmap-definition-RKZ34NQL-6ykAFPEz.js +96 -0
  362. package/workframe-ui/public/assets/nikola-B4PtHrJv.png +0 -0
  363. package/workframe-ui/public/assets/nina-BYbrOn0d.png +0 -0
  364. package/workframe-ui/public/assets/ordinal-hYBb2elL.js +1 -0
  365. package/workframe-ui/public/assets/packet-YPE3B663-Dw3xgMDt.js +1 -0
  366. package/workframe-ui/public/assets/path-BWPyau1x.js +1 -0
  367. package/workframe-ui/public/assets/paul-CGURYQIn.png +0 -0
  368. package/workframe-ui/public/assets/pie-LRSECV5Y-DATysawG.js +1 -0
  369. package/workframe-ui/public/assets/pieDiagram-4H26LBE5-SJKD1S0S.js +30 -0
  370. package/workframe-ui/public/assets/project-logos/1.png +0 -0
  371. package/workframe-ui/public/assets/project-logos/10.png +0 -0
  372. package/workframe-ui/public/assets/project-logos/11.png +0 -0
  373. package/workframe-ui/public/assets/project-logos/12.png +0 -0
  374. package/workframe-ui/public/assets/project-logos/13.png +0 -0
  375. package/workframe-ui/public/assets/project-logos/14.png +0 -0
  376. package/workframe-ui/public/assets/project-logos/15.png +0 -0
  377. package/workframe-ui/public/assets/project-logos/16.png +0 -0
  378. package/workframe-ui/public/assets/project-logos/2.png +0 -0
  379. package/workframe-ui/public/assets/project-logos/3.png +0 -0
  380. package/workframe-ui/public/assets/project-logos/4.png +0 -0
  381. package/workframe-ui/public/assets/project-logos/5.png +0 -0
  382. package/workframe-ui/public/assets/project-logos/6.png +0 -0
  383. package/workframe-ui/public/assets/project-logos/7.png +0 -0
  384. package/workframe-ui/public/assets/project-logos/8.png +0 -0
  385. package/workframe-ui/public/assets/project-logos/9.png +0 -0
  386. package/workframe-ui/public/assets/project-logos/catalog.json +86 -0
  387. package/workframe-ui/public/assets/quadrantDiagram-W4KKPZXB-BrYDZX8q.js +7 -0
  388. package/workframe-ui/public/assets/radar-GUYGQ44K-BmWYPCds.js +1 -0
  389. package/workframe-ui/public/assets/requirementDiagram-4Y6WPE33-DwL9Mc8e.js +84 -0
  390. package/workframe-ui/public/assets/ringo-WhfUNOyY.png +0 -0
  391. package/workframe-ui/public/assets/rosie-CAtcIf87.png +0 -0
  392. package/workframe-ui/public/assets/rough.esm-CSKSodPl.js +1 -0
  393. package/workframe-ui/public/assets/sankeyDiagram-5OEKKPKP-DYIFsL8h.js +40 -0
  394. package/workframe-ui/public/assets/sequenceDiagram-3UESZ5HK-0-FPkFk8.js +162 -0
  395. package/workframe-ui/public/assets/src-B_od6b6h.js +1 -0
  396. package/workframe-ui/public/assets/stateDiagram-AJRCARHV-BQCiBk6u.js +1 -0
  397. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B89jAMFF.js +1 -0
  398. package/workframe-ui/public/assets/steve-CgXXJ9EZ.png +0 -0
  399. package/workframe-ui/public/assets/sun-BLNAhoZd.png +0 -0
  400. package/workframe-ui/public/assets/timeline-definition-PNZ67QCA-DS3tFcXj.js +120 -0
  401. package/workframe-ui/public/assets/treeView-BLDUP644-DSyUCKLY.js +1 -0
  402. package/workframe-ui/public/assets/treemap-LRROVOQU-CEZaNh5Y.js +1 -0
  403. package/workframe-ui/public/assets/vennDiagram-CIIHVFJN-CD-Vc9NF.js +34 -0
  404. package/workframe-ui/public/assets/wardley-L42UT6IY-Drq5w1Mc.js +1 -0
  405. package/workframe-ui/public/assets/wardleyDiagram-YWT4CUSO-DouXDJoF.js +78 -0
  406. package/workframe-ui/public/assets/warren-DIH7UKMY.png +0 -0
  407. package/workframe-ui/public/assets/woz-D2yleG-V.png +0 -0
  408. package/workframe-ui/public/assets/xychartDiagram-2RQKCTM6-DDf_Lol5.js +7 -0
  409. package/workframe-ui/public/assets/zaha-wersOEq9.png +0 -0
  410. package/workframe-ui/public/favicon.ico +0 -0
  411. package/workframe-ui/public/favicon.svg +7 -0
  412. package/workframe-ui/public/icons.svg +24 -0
  413. package/workframe-ui/public/index.html +50 -0
  414. package/workframe-ui/public/manifest.webmanifest +18 -0
  415. package/workframe-ui/public/workframe-config.json +4 -0
@@ -0,0 +1,633 @@
1
+ """
2
+ zk-auth compatible auth module for Workframe API.
3
+
4
+ Ported from /d/ab/projects/zk-auth/ with the same:
5
+ - AES-256-GCM encryption for secrets (emails, tokens)
6
+ - HMAC-SHA256 for email hashing, OTP hashing, token hashing
7
+ - Atomic OTP consume with attempt counting
8
+ - Session management with refresh tokens
9
+ - Profile management (display_name, avatar_url, tagline, bio)
10
+
11
+ Two modes:
12
+ - DEV_LOCAL_UNSAFE: OTP is returned in response (no email needed)
13
+ - SECURE_MODE: OTP is sent via email (requires SMTP config)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import hmac as _hmac_mod
20
+ import json
21
+ import os
22
+ import re
23
+ import secrets
24
+ import sqlite3
25
+ import uuid
26
+ from datetime import datetime, timedelta, timezone
27
+
28
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Config
33
+ # ---------------------------------------------------------------------------
34
+
35
+ DATA_DIR = os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data")
36
+ AUTH_KEYS_FILE = os.path.join(DATA_DIR, ".auth_keys")
37
+
38
+
39
+ def _load_or_generate_keys() -> tuple[str, str, str]:
40
+ """Load auth keys from env, disk, or generate on first boot."""
41
+ hmac_env = os.environ.get("ZK_AUTH_HMAC_KEY", "").strip()
42
+ enc_env = os.environ.get("ZK_AUTH_ENCRYPTION_KEY", "").strip()
43
+ session_env = os.environ.get("ZK_AUTH_SESSION_SECRET", "").strip()
44
+ if hmac_env and enc_env and session_env:
45
+ return hmac_env, enc_env, session_env
46
+
47
+ os.makedirs(DATA_DIR, exist_ok=True)
48
+ if os.path.exists(AUTH_KEYS_FILE):
49
+ try:
50
+ with open(AUTH_KEYS_FILE, "r") as f:
51
+ keys = json.load(f)
52
+ return keys["hmac"], keys["enc"], keys["session"]
53
+ except Exception:
54
+ pass
55
+ # First boot — generate and persist
56
+ hmac_key = secrets.token_hex(32)
57
+ enc_key = base64.b64encode(os.urandom(32)).decode()
58
+ session_key = secrets.token_hex(32)
59
+ try:
60
+ with open(AUTH_KEYS_FILE, "w") as f:
61
+ json.dump({"hmac": hmac_key, "enc": enc_key, "session": session_key}, f)
62
+ os.chmod(AUTH_KEYS_FILE, 0o600)
63
+ except Exception:
64
+ pass # container may be read-only; fall back to in-memory
65
+ return hmac_key, enc_key, session_key
66
+
67
+
68
+ ZK_AUTH_HMAC_KEY, ZK_AUTH_ENCRYPTION_KEY, ZK_AUTH_SESSION_SECRET = _load_or_generate_keys()
69
+ OTP_TTL_MINUTES = int(os.environ.get("OTP_TTL_MINUTES", "10"))
70
+ SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30"))
71
+ OTP_MAX_ATTEMPTS = 5
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Crypto (ported from zk-auth/src/crypto/)
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def _decode_aes256_key(value: str) -> bytes:
80
+ key = base64.b64decode(value)
81
+ if len(key) != 32:
82
+ raise ValueError("ZK_AUTH_ENCRYPTION_KEY must be a base64-encoded 32-byte key.")
83
+ return key
84
+
85
+
86
+ def encrypt_string(value: str, secret: str) -> dict:
87
+ """AES-256-GCM encrypt. Returns {v, alg, iv, tag, ciphertext}."""
88
+ key = _decode_aes256_key(secret)
89
+ iv = os.urandom(12)
90
+ aesgcm = AESGCM(key)
91
+ ct = aesgcm.encrypt(iv, value.encode("utf8"), None)
92
+ return {
93
+ "v": 1,
94
+ "alg": "AES-256-GCM",
95
+ "iv": base64.b64encode(iv).decode("ascii"),
96
+ "tag": base64.b64encode(ct[-16:]).decode("ascii"),
97
+ "ciphertext": base64.b64encode(ct[:-16]).decode("ascii"),
98
+ }
99
+
100
+
101
+ def decrypt_string(payload: dict, secret: str) -> str:
102
+ """AES-256-GCM decrypt."""
103
+ if payload.get("v") != 1 or payload.get("alg") != "AES-256-GCM":
104
+ raise ValueError("Unsupported encrypted payload format.")
105
+ key = _decode_aes256_key(secret)
106
+ iv = base64.b64decode(payload["iv"])
107
+ tag = base64.b64decode(payload["tag"])
108
+ ct = base64.b64decode(payload["ciphertext"])
109
+ aesgcm = AESGCM(key)
110
+ pt = aesgcm.decrypt(iv, ct + tag, None)
111
+ return pt.decode("utf8")
112
+
113
+
114
+ def hmac_sha256(secret: str, value: str) -> str:
115
+ return _hmac_mod.new(secret.encode("utf8"), value.encode("utf8"), "sha256").hexdigest()
116
+
117
+
118
+ def hash_email(email: str) -> str:
119
+ return hmac_sha256(ZK_AUTH_HMAC_KEY, email.lower().strip())
120
+
121
+
122
+ def encrypt_email(email: str) -> str:
123
+ return json.dumps(encrypt_string(email.lower().strip(), ZK_AUTH_ENCRYPTION_KEY))
124
+
125
+
126
+ def decrypt_email(encrypted: str) -> str:
127
+ return decrypt_string(json.loads(encrypted), ZK_AUTH_ENCRYPTION_KEY)
128
+
129
+
130
+ def generate_otp_code(length: int = 6) -> str:
131
+ return "".join(str(secrets.randbelow(10)) for _ in range(length))
132
+
133
+
134
+ def hash_otp_code(challenge_id: str, code: str) -> str:
135
+ return hmac_sha256(ZK_AUTH_SESSION_SECRET, f"otp:{challenge_id}:{code}")
136
+
137
+
138
+ def create_opaque_token(byte_length: int = 32) -> str:
139
+ return base64.urlsafe_b64encode(os.urandom(byte_length)).rstrip(b"=").decode("ascii")
140
+
141
+
142
+ def hash_session_token(token: str) -> str:
143
+ return hmac_sha256(ZK_AUTH_SESSION_SECRET, f"session:{token}")
144
+
145
+
146
+ def hash_refresh_token(token: str) -> str:
147
+ return hmac_sha256(ZK_AUTH_SESSION_SECRET, f"refresh:{token}")
148
+
149
+
150
+ def safe_equal(a: str, b: str) -> bool:
151
+ return _hmac_mod.compare_digest(a.encode("utf8"), b.encode("utf8"))
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Database helpers
156
+ # ---------------------------------------------------------------------------
157
+
158
+ def _zk_db_path() -> str:
159
+ data_dir = os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data")
160
+ return os.path.join(data_dir, "zk_auth.db")
161
+
162
+
163
+ def _zk_db() -> sqlite3.Connection:
164
+ db_path = _zk_db_path()
165
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
166
+ conn = sqlite3.connect(db_path, timeout=10.0)
167
+ conn.row_factory = sqlite3.Row
168
+ conn.execute("PRAGMA journal_mode=WAL")
169
+ conn.execute("PRAGMA busy_timeout=5000")
170
+ _zk_init_db(conn)
171
+ return conn
172
+
173
+
174
+ def _zk_init_db(conn: sqlite3.Connection) -> None:
175
+ conn.executescript("""
176
+ CREATE TABLE IF NOT EXISTS users (
177
+ id TEXT PRIMARY KEY,
178
+ status TEXT NOT NULL DEFAULT 'active'
179
+ CHECK (status IN ('active', 'suspended', 'deleted')),
180
+ created_at TEXT NOT NULL,
181
+ updated_at TEXT NOT NULL
182
+ );
183
+
184
+ CREATE TABLE IF NOT EXISTS identities (
185
+ id TEXT PRIMARY KEY,
186
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
187
+ type TEXT NOT NULL CHECK (type IN ('email', 'passkey', 'oauth')),
188
+ identifier_hash TEXT NOT NULL,
189
+ identifier_encrypted TEXT NOT NULL,
190
+ verified_at TEXT,
191
+ created_at TEXT NOT NULL,
192
+ updated_at TEXT NOT NULL,
193
+ UNIQUE (type, identifier_hash)
194
+ );
195
+
196
+ CREATE TABLE IF NOT EXISTS verification_challenges (
197
+ id TEXT PRIMARY KEY,
198
+ identity_type TEXT NOT NULL CHECK (identity_type IN ('email', 'phone')),
199
+ identifier_hash TEXT NOT NULL,
200
+ code_hash TEXT NOT NULL,
201
+ purpose TEXT NOT NULL CHECK (purpose IN ('login', 'signup', 'recovery')),
202
+ attempt_count INTEGER NOT NULL DEFAULT 0,
203
+ max_attempts INTEGER NOT NULL DEFAULT 5,
204
+ expires_at TEXT NOT NULL,
205
+ used_at TEXT,
206
+ created_at TEXT NOT NULL
207
+ );
208
+
209
+ CREATE TABLE IF NOT EXISTS sessions (
210
+ id TEXT PRIMARY KEY,
211
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
212
+ refresh_token_hash TEXT NOT NULL UNIQUE,
213
+ token_family_id TEXT NOT NULL,
214
+ user_agent_summary TEXT,
215
+ ip_summary TEXT,
216
+ created_at TEXT NOT NULL,
217
+ last_seen_at TEXT,
218
+ expires_at TEXT NOT NULL,
219
+ revoked_at TEXT
220
+ );
221
+
222
+ CREATE TABLE IF NOT EXISTS profiles (
223
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
224
+ display_name TEXT,
225
+ avatar_url TEXT,
226
+ tagline TEXT,
227
+ bio TEXT,
228
+ created_at TEXT NOT NULL,
229
+ updated_at TEXT NOT NULL
230
+ );
231
+
232
+ CREATE INDEX IF NOT EXISTS idx_identities_user_id ON identities(user_id);
233
+ CREATE INDEX IF NOT EXISTS idx_identities_hash ON identities(type, identifier_hash);
234
+ CREATE INDEX IF NOT EXISTS idx_challenges_hash ON verification_challenges(identity_type, identifier_hash, expires_at);
235
+ CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
236
+ CREATE INDEX IF NOT EXISTS idx_sessions_family ON sessions(token_family_id);
237
+ CREATE INDEX IF NOT EXISTS idx_sessions_refresh ON sessions(refresh_token_hash);
238
+ """)
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Auth service (ported from zk-auth/src/services/)
243
+ # ---------------------------------------------------------------------------
244
+
245
+ def start_email_verification(
246
+ email: str,
247
+ *,
248
+ dev_local_unsafe: bool = False,
249
+ expose_otp: bool = False,
250
+ ) -> dict:
251
+ normalized = email.lower().strip()
252
+ identifier_hash = hash_email(normalized)
253
+ challenge_id = str(uuid.uuid4())
254
+ code = generate_otp_code()
255
+ code_hash = hash_otp_code(challenge_id, code)
256
+ now_ts = datetime.now(timezone.utc).isoformat()
257
+ expires = (datetime.now(timezone.utc) + timedelta(minutes=OTP_TTL_MINUTES)).isoformat()
258
+
259
+ conn = _zk_db()
260
+ try:
261
+ conn.execute(
262
+ "UPDATE verification_challenges SET used_at = ? WHERE identifier_hash = ? AND used_at IS NULL AND expires_at > ?",
263
+ (now_ts, identifier_hash, now_ts),
264
+ )
265
+ conn.execute(
266
+ """INSERT INTO verification_challenges
267
+ (id, identity_type, identifier_hash, code_hash, purpose, max_attempts, expires_at, created_at)
268
+ VALUES (?, 'email', ?, ?, 'login', ?, ?, ?)""",
269
+ (challenge_id, identifier_hash, code_hash, OTP_MAX_ATTEMPTS, expires, now_ts),
270
+ )
271
+ conn.commit()
272
+ finally:
273
+ conn.close()
274
+
275
+ result: dict = {"challenge_id": challenge_id}
276
+
277
+ # Build verification URL
278
+ import email_sender as email_mod
279
+ from urllib.parse import urlencode
280
+ verification_url = f"{email_mod.APP_BASE_URL}/?{urlencode({'email': normalized, 'code': code})}"
281
+
282
+ email_sent = False
283
+ email_error: str | None = None
284
+ try:
285
+ email_mod.send_verification_email(normalized, code, verification_url)
286
+ email_sent = True
287
+ except Exception as exc:
288
+ email_error = str(exc)
289
+ print(f"[zk-auth] Failed to send email: {exc}")
290
+
291
+ result["email_sent"] = email_sent
292
+ if email_error:
293
+ result["email_error"] = email_error
294
+
295
+ # ponytail: OTP in JSON only when DEV_LOCAL_UNSAFE and email did not go out, or explicit E2E install harness
296
+ if expose_otp or (dev_local_unsafe and not email_sent):
297
+ result["otp_code"] = code
298
+ if dev_local_unsafe and not email_sent:
299
+ result["_dev_warning"] = (
300
+ "DEV_LOCAL_UNSAFE: OTP returned because email was not sent"
301
+ )
302
+ elif expose_otp:
303
+ result["_e2e_warning"] = "WORKFRAME_E2E: OTP returned during install window"
304
+
305
+ return result
306
+
307
+
308
+ def verify_email_code(email: str, code: str) -> dict:
309
+ normalized = email.lower().strip()
310
+ identifier_hash = hash_email(normalized)
311
+ now_ts = datetime.now(timezone.utc).isoformat()
312
+
313
+ conn = _zk_db()
314
+ try:
315
+ challenge = conn.execute(
316
+ """SELECT * FROM verification_challenges
317
+ WHERE identifier_hash = ? AND used_at IS NULL AND expires_at > ?
318
+ ORDER BY created_at DESC LIMIT 1""",
319
+ (identifier_hash, now_ts),
320
+ ).fetchone()
321
+
322
+ if not challenge:
323
+ raise ValueError("Invalid or expired verification code.")
324
+
325
+ if challenge["attempt_count"] >= challenge["max_attempts"]:
326
+ raise ValueError("Invalid or expired verification code.")
327
+
328
+ expected_hash = hash_otp_code(challenge["id"], code)
329
+ if not safe_equal(expected_hash, challenge["code_hash"]):
330
+ conn.execute(
331
+ "UPDATE verification_challenges SET attempt_count = attempt_count + 1 WHERE id = ?",
332
+ (challenge["id"],),
333
+ )
334
+ conn.commit()
335
+ raise ValueError("Invalid or expired verification code.")
336
+
337
+ conn.execute(
338
+ "UPDATE verification_challenges SET used_at = ? WHERE id = ?",
339
+ (now_ts, challenge["id"]),
340
+ )
341
+
342
+ identity = conn.execute(
343
+ "SELECT * FROM identities WHERE type = 'email' AND identifier_hash = ?",
344
+ (identifier_hash,),
345
+ ).fetchone()
346
+
347
+ is_new_user = False
348
+ if not identity:
349
+ user_id = str(uuid.uuid4())
350
+ conn.execute(
351
+ "INSERT INTO users (id, status, created_at, updated_at) VALUES (?, 'active', ?, ?)",
352
+ (user_id, now_ts, now_ts),
353
+ )
354
+ conn.execute(
355
+ """INSERT INTO identities
356
+ (id, user_id, type, identifier_hash, identifier_encrypted, verified_at, created_at, updated_at)
357
+ VALUES (?, ?, 'email', ?, ?, ?, ?, ?)""",
358
+ (str(uuid.uuid4()), user_id, identifier_hash, encrypt_email(normalized), now_ts, now_ts, now_ts),
359
+ )
360
+ conn.execute(
361
+ "INSERT INTO profiles (user_id, created_at, updated_at) VALUES (?, ?, ?)",
362
+ (user_id, now_ts, now_ts),
363
+ )
364
+ is_new_user = True
365
+ else:
366
+ user_id = identity["user_id"]
367
+
368
+ session_id = str(uuid.uuid4())
369
+ refresh_token = create_opaque_token(32)
370
+ refresh_token_hash = hash_refresh_token(refresh_token)
371
+ token_family_id = str(uuid.uuid4())
372
+ expires_at = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).isoformat()
373
+
374
+ conn.execute(
375
+ """INSERT INTO sessions
376
+ (id, user_id, refresh_token_hash, token_family_id, created_at, expires_at)
377
+ VALUES (?, ?, ?, ?, ?, ?)""",
378
+ (session_id, user_id, refresh_token_hash, token_family_id, now_ts, expires_at),
379
+ )
380
+ conn.commit()
381
+
382
+ return {
383
+ "user_id": user_id,
384
+ "session_id": session_id,
385
+ "refresh_token": refresh_token,
386
+ "expires_at": expires_at,
387
+ "is_new_user": is_new_user,
388
+ }
389
+ except ValueError:
390
+ raise
391
+ except Exception as exc:
392
+ conn.rollback()
393
+ raise RuntimeError(f"Auth failed: {exc}") from exc
394
+ finally:
395
+ conn.close()
396
+
397
+
398
+ def create_session_for_email(email: str) -> dict:
399
+ """Trusted invite path — session without OTP when invite token already proved mailbox."""
400
+ normalized = email.lower().strip()
401
+ identifier_hash = hash_email(normalized)
402
+ now_ts = datetime.now(timezone.utc).isoformat()
403
+ conn = _zk_db()
404
+ try:
405
+ identity = conn.execute(
406
+ "SELECT * FROM identities WHERE type = 'email' AND identifier_hash = ?",
407
+ (identifier_hash,),
408
+ ).fetchone()
409
+ is_new_user = False
410
+ if not identity:
411
+ user_id = str(uuid.uuid4())
412
+ conn.execute(
413
+ "INSERT INTO users (id, status, created_at, updated_at) VALUES (?, 'active', ?, ?)",
414
+ (user_id, now_ts, now_ts),
415
+ )
416
+ conn.execute(
417
+ """INSERT INTO identities
418
+ (id, user_id, type, identifier_hash, identifier_encrypted, verified_at, created_at, updated_at)
419
+ VALUES (?, ?, 'email', ?, ?, ?, ?, ?)""",
420
+ (str(uuid.uuid4()), user_id, identifier_hash, encrypt_email(normalized), now_ts, now_ts, now_ts),
421
+ )
422
+ conn.execute(
423
+ "INSERT INTO profiles (user_id, created_at, updated_at) VALUES (?, ?, ?)",
424
+ (user_id, now_ts, now_ts),
425
+ )
426
+ is_new_user = True
427
+ else:
428
+ user_id = identity["user_id"]
429
+ session_id = str(uuid.uuid4())
430
+ refresh_token = create_opaque_token(32)
431
+ refresh_token_hash = hash_refresh_token(refresh_token)
432
+ token_family_id = str(uuid.uuid4())
433
+ expires_at = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).isoformat()
434
+ conn.execute(
435
+ """INSERT INTO sessions
436
+ (id, user_id, refresh_token_hash, token_family_id, created_at, expires_at)
437
+ VALUES (?, ?, ?, ?, ?, ?)""",
438
+ (session_id, user_id, refresh_token_hash, token_family_id, now_ts, expires_at),
439
+ )
440
+ conn.commit()
441
+ return {
442
+ "user_id": user_id,
443
+ "session_id": session_id,
444
+ "refresh_token": refresh_token,
445
+ "expires_at": expires_at,
446
+ "is_new_user": is_new_user,
447
+ }
448
+ except Exception as exc:
449
+ conn.rollback()
450
+ raise RuntimeError(f"Auth failed: {exc}") from exc
451
+ finally:
452
+ conn.close()
453
+
454
+
455
+ def logout_session(session_id: str) -> None:
456
+ now_ts = datetime.now(timezone.utc).isoformat()
457
+ conn = _zk_db()
458
+ try:
459
+ conn.execute("UPDATE sessions SET revoked_at = ? WHERE id = ?", (now_ts, session_id))
460
+ conn.commit()
461
+ finally:
462
+ conn.close()
463
+
464
+
465
+ def refresh_session(refresh_token: str) -> dict:
466
+ """Rotate a refresh token. Returns new session_id + refresh_token.
467
+
468
+ Implements token family revocation: if a previously-used refresh token
469
+ is replayed, the entire family is revoked (reuse detection).
470
+ """
471
+ token_hash = hash_refresh_token(refresh_token)
472
+ now_ts = datetime.now(timezone.utc).isoformat()
473
+
474
+ conn = _zk_db()
475
+ try:
476
+ session = conn.execute(
477
+ "SELECT * FROM sessions WHERE refresh_token_hash = ?",
478
+ (token_hash,),
479
+ ).fetchone()
480
+
481
+ if not session:
482
+ # Possible token reuse — revoke the whole family
483
+ # We can't look up by hash since the token is unknown, but we
484
+ # can check if any session in a family has been revoked.
485
+ # For now, just reject.
486
+ raise ValueError("Invalid refresh token.")
487
+
488
+ if session["revoked_at"] is not None:
489
+ # Token reuse detected — revoke entire family
490
+ conn.execute(
491
+ "UPDATE sessions SET revoked_at = ? WHERE token_family_id = ?",
492
+ (now_ts, session["token_family_id"]),
493
+ )
494
+ conn.commit()
495
+ raise ValueError("Refresh token reuse detected. Session family revoked.")
496
+
497
+ if session["expires_at"] < now_ts:
498
+ raise ValueError("Refresh token expired.")
499
+
500
+ # Rotate: new session ID + new refresh token, same family
501
+ new_session_id = str(uuid.uuid4())
502
+ new_refresh_token = create_opaque_token(32)
503
+ new_refresh_hash = hash_refresh_token(new_refresh_token)
504
+ new_expires = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).isoformat()
505
+
506
+ # Revoke old session
507
+ conn.execute(
508
+ "UPDATE sessions SET revoked_at = ? WHERE id = ?",
509
+ (now_ts, session["id"]),
510
+ )
511
+ # Insert new session in same family
512
+ conn.execute(
513
+ """INSERT INTO sessions
514
+ (id, user_id, refresh_token_hash, token_family_id, user_agent_summary, ip_summary, created_at, expires_at)
515
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
516
+ (new_session_id, session["user_id"], new_refresh_hash,
517
+ session["token_family_id"], session["user_agent_summary"],
518
+ session["ip_summary"], now_ts, new_expires),
519
+ )
520
+ conn.commit()
521
+
522
+ return {
523
+ "user_id": session["user_id"],
524
+ "session_id": new_session_id,
525
+ "refresh_token": new_refresh_token,
526
+ "expires_at": new_expires,
527
+ }
528
+ except ValueError:
529
+ raise
530
+ except Exception as exc:
531
+ conn.rollback()
532
+ raise RuntimeError(f"Refresh failed: {exc}") from exc
533
+ finally:
534
+ conn.close()
535
+
536
+
537
+ def validate_session_token(session_id: str) -> dict | None:
538
+ now_ts = datetime.now(timezone.utc).isoformat()
539
+ conn = _zk_db()
540
+ try:
541
+ session = conn.execute(
542
+ """SELECT s.*, u.status as user_status
543
+ FROM sessions s JOIN users u ON u.id = s.user_id
544
+ WHERE s.id = ? AND s.revoked_at IS NULL AND s.expires_at > ?""",
545
+ (session_id, now_ts),
546
+ ).fetchone()
547
+ if not session or session["user_status"] != "active":
548
+ return None
549
+ conn.execute("UPDATE sessions SET last_seen_at = ? WHERE id = ?", (now_ts, session_id))
550
+ conn.commit()
551
+ return {"user_id": session["user_id"], "session_id": session["id"], "expires_at": session["expires_at"]}
552
+ finally:
553
+ conn.close()
554
+
555
+
556
+ def get_profile(user_id: str) -> dict | None:
557
+ conn = _zk_db()
558
+ try:
559
+ row = conn.execute("SELECT * FROM profiles WHERE user_id = ?", (user_id,)).fetchone()
560
+ if not row:
561
+ return None
562
+ return {
563
+ "user_id": row["user_id"],
564
+ "display_name": row["display_name"],
565
+ "avatar_url": row["avatar_url"],
566
+ "tagline": row["tagline"],
567
+ "bio": row["bio"],
568
+ }
569
+ finally:
570
+ conn.close()
571
+
572
+
573
+ def update_profile(user_id: str, updates: dict) -> dict:
574
+ allowed = {"display_name", "avatar_url", "tagline", "bio"}
575
+ fields = {k: v for k, v in updates.items() if k in allowed and v is not None}
576
+ if not fields:
577
+ return get_profile(user_id)
578
+
579
+ now_ts = datetime.now(timezone.utc).isoformat()
580
+ fields["updated_at"] = now_ts
581
+
582
+ conn = _zk_db()
583
+ try:
584
+ existing = conn.execute("SELECT 1 FROM profiles WHERE user_id = ?", (user_id,)).fetchone()
585
+ if not existing:
586
+ conn.execute(
587
+ "INSERT INTO profiles (user_id, created_at, updated_at) VALUES (?, ?, ?)",
588
+ (user_id, now_ts, now_ts),
589
+ )
590
+ set_clause = ", ".join(f"{k} = ?" for k in fields)
591
+ values = list(fields.values()) + [user_id]
592
+ conn.execute(f"UPDATE profiles SET {set_clause} WHERE user_id = ?", values)
593
+ conn.commit()
594
+ finally:
595
+ conn.close()
596
+ return get_profile(user_id)
597
+
598
+
599
+ # ---------------------------------------------------------------------------
600
+ # Cookie helpers
601
+ # ---------------------------------------------------------------------------
602
+
603
+ def session_cookie_name() -> str:
604
+ # ponytail: WORKFRAME_INSTALL_ID scopes cookies per install; auth DB is per install (same email OK across installs).
605
+ install_id = os.environ.get("WORKFRAME_INSTALL_ID", "").strip()
606
+ if install_id:
607
+ safe = re.sub(r"[^a-zA-Z0-9_-]+", "_", install_id).strip("_")
608
+ if safe:
609
+ return f"{safe}_session"
610
+ slug = re.sub(
611
+ r"[^a-z0-9]+",
612
+ "_",
613
+ os.environ.get("WORKFRAME_PROJECT", "workframe").lower(),
614
+ ).strip("_")
615
+ return f"wf_{slug or 'workframe'}_session"
616
+
617
+
618
+ def session_cookie_value(session_id: str, ttl: int = None, secure: bool = True) -> str:
619
+ if ttl is None:
620
+ ttl = SESSION_TTL_DAYS * 86400
621
+ name = session_cookie_name()
622
+ val = f"{name}={session_id}; HttpOnly; SameSite=Lax; Path=/; Max-Age={ttl}"
623
+ if secure:
624
+ val += "; Secure"
625
+ return val
626
+
627
+
628
+ def clear_session_cookie(secure: bool = True) -> str:
629
+ name = session_cookie_name()
630
+ val = f"{name}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
631
+ if secure:
632
+ val += "; Secure"
633
+ return val
@@ -0,0 +1,11 @@
1
+ FROM python:3-alpine
2
+
3
+ # ponytail: host.setup_public_https and compose restart shell out to docker CLI (socket is mounted).
4
+ # set-compose-public-url.mjs needs node on the supervisor image.
5
+ RUN apk add --no-cache docker-cli docker-cli-compose bash nodejs npm
6
+
7
+ WORKDIR /app
8
+ COPY server.py profile_secret_policy.py .
9
+
10
+ EXPOSE 8090
11
+ CMD ["python3", "server.py"]