create-workframe 0.1.5 → 0.1.7

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 (185) hide show
  1. package/.dockerignore +22 -22
  2. package/.gitignore +73 -73
  3. package/LICENSE +201 -201
  4. package/NOTICE +12 -12
  5. package/README.md +40 -40
  6. package/SECURITY.md +38 -38
  7. package/bin/create-workframe.js +2816 -2814
  8. package/bin/workframe.js +329 -329
  9. package/docs/workspace-instructions/WORKFRAME_DISCORD.md +20 -20
  10. package/docs/workspace-instructions/WORKFRAME_DOCUMENTS_AND_ARTIFACTS.md +20 -20
  11. package/docs/workspace-instructions/WORKFRAME_KANBAN.md +20 -20
  12. package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +21 -21
  13. package/docs/workspace-instructions/WORKFRAME_ROUTING.md +29 -29
  14. package/docs/workspace-instructions/WORKFRAME_TELEGRAM.md +19 -19
  15. package/package.json +64 -64
  16. package/profiles/README.md +15 -15
  17. package/profiles/architect/AGENTS.md +29 -29
  18. package/profiles/architect/SOUL.md +44 -44
  19. package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
  20. package/profiles/designer/AGENTS.md +26 -26
  21. package/profiles/designer/SOUL.md +31 -31
  22. package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
  23. package/profiles/dev/AGENTS.md +28 -28
  24. package/profiles/dev/SOUL.md +31 -31
  25. package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
  26. package/profiles/docs/AGENTS.md +27 -27
  27. package/profiles/docs/SOUL.md +30 -30
  28. package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
  29. package/profiles/research/AGENTS.md +26 -26
  30. package/profiles/research/SOUL.md +31 -31
  31. package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
  32. package/profiles/visionary/AGENTS.md +25 -25
  33. package/profiles/visionary/SOUL.md +31 -31
  34. package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
  35. package/profiles/workframe-agent/AGENTS.md +37 -37
  36. package/profiles/workframe-agent/SETUP.md +185 -185
  37. package/profiles/workframe-agent/SOUL.md +61 -61
  38. package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
  39. package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
  40. package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
  41. package/prompts/WORKFRAME_PROMPT_TEMPLATES.md +16 -16
  42. package/rules/.hermes.md +11 -11
  43. package/rules/AGENTS.md +22 -22
  44. package/rules/workspace-README.md +5 -5
  45. package/scripts/apply-update-workframe.sh +91 -21
  46. package/scripts/bundle-workframe-ui.mjs +83 -83
  47. package/scripts/create_workframe_scaffold.py +648 -648
  48. package/scripts/ensure-compose-host-paths.mjs +51 -51
  49. package/scripts/lib/install-identity.mjs +212 -212
  50. package/scripts/lib/workframe-registry.mjs +290 -290
  51. package/scripts/new-project.mjs +68 -68
  52. package/scripts/select_agent_pack.py +31 -31
  53. package/scripts/set-compose-public-url.mjs +92 -92
  54. package/scripts/sync-canonical-to-package.mjs +164 -164
  55. package/shared/WORKFRAME_AGENT_LIBRARY.md +31 -31
  56. package/shared/WORKFRAME_AGENT_OPERATIONS.md +29 -29
  57. package/shared/WORKFRAME_AGENT_PACKS.json +64 -64
  58. package/shared/WORKFRAME_AGENT_PACKS.yaml +20 -20
  59. package/shared/WORKFRAME_CHAT_PERMISSION_MODEL.md +20 -20
  60. package/shared/WORKFRAME_HANDOFF_SCHEMA.md +25 -25
  61. package/shared/WORKFRAME_SKILL_CURATION.md +27 -27
  62. package/workframe-api/Dockerfile +14 -14
  63. package/workframe-api/README.md +24 -24
  64. package/workframe-api/action_proxy.py +131 -131
  65. package/workframe-api/auth_rate_limit.py +49 -49
  66. package/workframe-api/credential_vault.py +445 -445
  67. package/workframe-api/data/avatar-catalog.json +41 -41
  68. package/workframe-api/data/logo-catalog.json +14 -14
  69. package/workframe-api/data/user-avatar-catalog.json +18 -18
  70. package/workframe-api/email_sender.py +220 -220
  71. package/workframe-api/google_auth.py +90 -90
  72. package/workframe-api/install_api.py +359 -359
  73. package/workframe-api/internal_proxy_auth.py +150 -150
  74. package/workframe-api/llm_proxy.py +280 -280
  75. package/workframe-api/oidc_jwt.py +108 -108
  76. package/workframe-api/package.json +12 -12
  77. package/workframe-api/platform_auth.py +194 -194
  78. package/workframe-api/profile_secret_policy.py +86 -86
  79. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  80. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  81. package/workframe-api/public/assets/index-DglUqFB_.js +8 -8
  82. package/workframe-api/public/index.html +12 -12
  83. package/workframe-api/requirements.txt +2 -2
  84. package/workframe-api/server.py +20295 -19866
  85. package/workframe-api/site_meta.py +271 -271
  86. package/workframe-api/stack_config.py +435 -427
  87. package/workframe-api/test_ensure_native_profile.py +29 -0
  88. package/workframe-api/test_oauth_llm.py +58 -0
  89. package/workframe-api/test_provider_model_surface.py +31 -0
  90. package/workframe-api/test_stack_config_install_smtp.py +51 -0
  91. package/workframe-api/time-bind-chat.py +99 -99
  92. package/workframe-api/turn_credentials.py +226 -226
  93. package/workframe-api/updates.py +506 -506
  94. package/workframe-api/vault_kek.py +159 -159
  95. package/workframe-api/zk_auth.py +633 -633
  96. package/workframe-supervisor/Dockerfile +11 -11
  97. package/workframe-supervisor/profile_secret_policy.py +76 -76
  98. package/workframe-supervisor/server.py +860 -860
  99. package/workframe-ui/docker/nginx.conf +85 -85
  100. package/workframe-ui/public/assets/{arc-CnriPN3G.js → arc-Cn-lt9fT.js} +1 -1
  101. package/workframe-ui/public/assets/architecture-7EHR7CIX-CCMNXHod.js +1 -0
  102. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DYYB1Ic2.js → architectureDiagram-3BPJPVTR-qXoGCqfH.js} +1 -1
  103. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bn-fFOyj.js → blockDiagram-GPEHLZMM-DoopFlWj.js} +1 -1
  104. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BPPDtAnD.js → c4Diagram-AAUBKEIU-BRa7hfzE.js} +1 -1
  105. package/workframe-ui/public/assets/channel-CtFV3sE3.js +1 -0
  106. package/workframe-ui/public/assets/{chunk-2J33WTMH-Bz2CJiC3.js → chunk-2J33WTMH-Cl33E2hh.js} +1 -1
  107. package/workframe-ui/public/assets/{chunk-3OPIFGDE-B8aMeE1B.js → chunk-3OPIFGDE-BRywYZQO.js} +1 -1
  108. package/workframe-ui/public/assets/{chunk-4BX2VUAB-bLFHQTuT.js → chunk-4BX2VUAB-CI3xzp-0.js} +1 -1
  109. package/workframe-ui/public/assets/{chunk-55IACEB6-BRxhx-lG.js → chunk-55IACEB6-Bkf3Ye3F.js} +1 -1
  110. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BLyOgEJ8.js → chunk-5ZQYHXKU-C5RN3hUT.js} +1 -1
  111. package/workframe-ui/public/assets/{chunk-727SXJPM-DiuvBl7G.js → chunk-727SXJPM-BaY0yDwt.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-BE1_aBdC.js → chunk-AQP2D5EJ-VMMBaDqd.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-BSJP7CBP-BicGsmBs.js → chunk-BSJP7CBP-DlNZ1UV3.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-C3VTns3d.js → chunk-CSCIHK7Q-tT2HyVy0.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DSiXY3bY.js → chunk-FMBD7UC4-CyACRaUT.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-KSCS5N6A-Cvw5s3fR.js → chunk-KSCS5N6A-seK5WAZs.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-jJifGdIQ.js → chunk-L5ZTLDWV-r5cyG5AH.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Maee1aqo.js → chunk-LZXEDZCA-SnbeVxsJ.js} +2 -2
  119. package/workframe-ui/public/assets/{chunk-ND2GUHAM-C77N3VAM.js → chunk-ND2GUHAM-zbFgEXSJ.js} +1 -1
  120. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BY68lrPB.js → chunk-NZK2D7GU-UhZc8kOk.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-O5CBEL6O-D_c4kWzh.js → chunk-O5CBEL6O-DHvu2iZ-.js} +1 -1
  122. package/workframe-ui/public/assets/chunk-QZHKN3VN-B1g7l1_B.js +1 -0
  123. package/workframe-ui/public/assets/chunk-WU5MYG2G-B_qfZ6QP.js +1 -0
  124. package/workframe-ui/public/assets/{chunk-XPW4576I-DlQgvqBp.js → chunk-XPW4576I-CCPNKbzG.js} +1 -1
  125. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CJoeCNq6.js +1 -0
  126. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CJoeCNq6.js +1 -0
  127. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CHCOHDSd.js → cose-bilkent-S5V4N54A-D1CLe6Y0.js} +1 -1
  128. package/workframe-ui/public/assets/{dagre-BM42HDAG-CKPcSYwU.js → dagre-BM42HDAG-DYHcGVs_.js} +1 -1
  129. package/workframe-ui/public/assets/{diagram-2AECGRRQ-C6qe0jP-.js → diagram-2AECGRRQ-DVUkJ-II.js} +1 -1
  130. package/workframe-ui/public/assets/{diagram-5GNKFQAL-DhQhrieU.js → diagram-5GNKFQAL-C9Y1Acc2.js} +1 -1
  131. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Bqf6Iw1r.js → diagram-KO2AKTUF-Db3zez-n.js} +1 -1
  132. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt1ms2D2.js → diagram-LMA3HP47-BaOR5sIr.js} +1 -1
  133. package/workframe-ui/public/assets/{diagram-OG6HWLK6-C0Rrxe1i.js → diagram-OG6HWLK6-fLBzMY5W.js} +1 -1
  134. package/workframe-ui/public/assets/{dist-BLcLhTn6.js → dist-uZYeD3o1.js} +1 -1
  135. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Cy1GbZVu.js → erDiagram-TEJ5UH35-BoCxDSyE.js} +1 -1
  136. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bw910jho.js +1 -0
  137. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-DL3ALqDG.js → flowDiagram-I6XJVG4X-BnYa3CAj.js} +1 -1
  138. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-CMpwBhXQ.js → ganttDiagram-6RSMTGT7-Dm_e_Kaa.js} +1 -1
  139. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-C0mwHUXL.js → gitGraph-WXDBUCRP-CBAj8ntv.js} +1 -1
  140. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-6gzhWaF0.js → gitGraphDiagram-PVQCEYII-D5auJjJh.js} +1 -1
  141. package/workframe-ui/public/assets/index-DHLQNAXd.js +129 -0
  142. package/workframe-ui/public/assets/index-auJ0bLMU.css +1 -0
  143. package/workframe-ui/public/assets/{info-J43DQDTF-Bb_wZTxU.js → info-J43DQDTF-BNMADxE2.js} +1 -1
  144. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-601evYFV.js → infoDiagram-5YYISTIA-C8c45T4J.js} +1 -1
  145. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BYtF-wHm.js → ishikawaDiagram-YF4QCWOH-CyWuHB4e.js} +1 -1
  146. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BMizyHzC.js → journeyDiagram-JHISSGLW-B43pCuDT.js} +1 -1
  147. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CLLJsdnh.js → kanban-definition-UN3LZRKU-Dyn480BI.js} +1 -1
  148. package/workframe-ui/public/assets/{line-BFiSvu-q.js → line-CNtIbnmt.js} +1 -1
  149. package/workframe-ui/public/assets/{linear-D6HYZ18_.js → linear-pmSg_4uV.js} +1 -1
  150. package/workframe-ui/public/assets/{mermaid-parser.core-Cid5yDBQ.js → mermaid-parser.core-BX4tnojE.js} +2 -2
  151. package/workframe-ui/public/assets/{mermaid.core-DqIiju5Z.js → mermaid.core-BtpBva5o.js} +3 -3
  152. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Bg7xJdsz.js → mindmap-definition-RKZ34NQL-DcMcB_69.js} +1 -1
  153. package/workframe-ui/public/assets/{packet-YPE3B663-1ebGt86D.js → packet-YPE3B663-KSzK9WlD.js} +1 -1
  154. package/workframe-ui/public/assets/{pie-LRSECV5Y-DJ9wOBC_.js → pie-LRSECV5Y-DLOH6Mg8.js} +1 -1
  155. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-BmK96Aus.js → pieDiagram-4H26LBE5-BhrX2aGk.js} +1 -1
  156. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-ByUqtWPZ.js → quadrantDiagram-W4KKPZXB-B5dDjbTX.js} +1 -1
  157. package/workframe-ui/public/assets/{radar-GUYGQ44K-DN-Hkt96.js → radar-GUYGQ44K-BPdjqZiu.js} +1 -1
  158. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ByU2r8gb.js → requirementDiagram-4Y6WPE33-21Dq5e0N.js} +1 -1
  159. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-elyWa0GK.js → sankeyDiagram-5OEKKPKP-CiA99dbO.js} +1 -1
  160. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-CzZwsAiK.js → sequenceDiagram-3UESZ5HK-Bwpui7_x.js} +1 -1
  161. package/workframe-ui/public/assets/{src-5vgsM_dl.js → src-B3Axsi1U.js} +1 -1
  162. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-B0O5w5lJ.js → stateDiagram-AJRCARHV-C76qwtE8.js} +1 -1
  163. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-Dn0CDWJD.js +1 -0
  164. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-oyqa5w5y.js → timeline-definition-PNZ67QCA-CufInOyk.js} +1 -1
  165. package/workframe-ui/public/assets/{treeView-BLDUP644-MCV12-eP.js → treeView-BLDUP644-C4sxh0R-.js} +1 -1
  166. package/workframe-ui/public/assets/{treemap-LRROVOQU-DiA1P9n5.js → treemap-LRROVOQU-BjV72ANV.js} +1 -1
  167. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CO_4x7OR.js → vennDiagram-CIIHVFJN-DgsdR0Rl.js} +1 -1
  168. package/workframe-ui/public/assets/{wardley-L42UT6IY-CmQNv6Xv.js → wardley-L42UT6IY-DZ279G_t.js} +1 -1
  169. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CJxDkbIj.js → wardleyDiagram-YWT4CUSO-Bde25kvz.js} +1 -1
  170. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-B02MxgzG.js → xychartDiagram-2RQKCTM6-B8VnS2Pb.js} +1 -1
  171. package/workframe-ui/public/favicon.svg +7 -7
  172. package/workframe-ui/public/icons.svg +24 -24
  173. package/workframe-ui/public/index.html +47 -47
  174. package/workframe-ui/public/manifest.webmanifest +18 -18
  175. package/workframe-ui/public/workframe-config.json +3 -3
  176. package/workframe-ui/public/assets/architecture-7EHR7CIX-CFhr7xAe.js +0 -1
  177. package/workframe-ui/public/assets/channel-Dok_-T0o.js +0 -1
  178. package/workframe-ui/public/assets/chunk-QZHKN3VN-C3wwLrTa.js +0 -1
  179. package/workframe-ui/public/assets/chunk-WU5MYG2G-GFFSg2kf.js +0 -1
  180. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DKbGfT5Z.js +0 -1
  181. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DKbGfT5Z.js +0 -1
  182. package/workframe-ui/public/assets/eventmodeling-FCH6USID-C_PrceHe.js +0 -1
  183. package/workframe-ui/public/assets/index-DyO6K0TD.css +0 -1
  184. package/workframe-ui/public/assets/index-yvpSUs8T.js +0 -133
  185. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-RXZRaSgT.js +0 -1
@@ -1,108 +1,108 @@
1
- """Minimal OIDC id_token verification (RS256 + JWKS) — no extra dependencies."""
2
-
3
- from __future__ import annotations
4
-
5
- import base64
6
- import json
7
- import time
8
- import urllib.error
9
- import urllib.request
10
- from typing import Any
11
-
12
- from cryptography.hazmat.primitives import hashes
13
- from cryptography.hazmat.primitives.asymmetric import padding, rsa
14
- from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
15
-
16
- _JWKS_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
17
- _JWKS_TTL = 3600.0
18
-
19
-
20
- def _b64url_decode(raw: str) -> bytes:
21
- padded = raw + "=" * (-len(raw) % 4)
22
- return base64.urlsafe_b64decode(padded.encode("ascii"))
23
-
24
-
25
- def _jwk_to_rsa(key: dict[str, Any]) -> RSAPublicKey:
26
- n = int.from_bytes(_b64url_decode(str(key["n"])), "big")
27
- e = int.from_bytes(_b64url_decode(str(key["e"])), "big")
28
- return rsa.RSAPublicNumbers(e, n).public_key()
29
-
30
-
31
- def _fetch_jwks(jwks_url: str) -> dict[str, Any]:
32
- now = time.time()
33
- cached = _JWKS_CACHE.get(jwks_url)
34
- if cached and cached[0] > now:
35
- return cached[1]
36
- req = urllib.request.Request(jwks_url, headers={"Accept": "application/json"})
37
- with urllib.request.urlopen(req, timeout=15) as resp:
38
- payload = json.loads(resp.read().decode("utf-8"))
39
- if not isinstance(payload, dict):
40
- raise ValueError("invalid jwks")
41
- _JWKS_CACHE[jwks_url] = (now + _JWKS_TTL, payload)
42
- return payload
43
-
44
-
45
- def verify_rs256_id_token(
46
- token: str,
47
- *,
48
- audience: str,
49
- issuer: str | tuple[str, ...] = (),
50
- jwks_url: str,
51
- clock_skew: int = 60,
52
- ) -> dict[str, Any]:
53
- parts = str(token or "").split(".")
54
- if len(parts) != 3:
55
- raise ValueError("invalid id_token")
56
- header = json.loads(_b64url_decode(parts[0]))
57
- payload = json.loads(_b64url_decode(parts[1]))
58
- if str(header.get("alg") or "") != "RS256":
59
- raise ValueError("unsupported jwt alg")
60
- kid = str(header.get("kid") or "")
61
- jwks = _fetch_jwks(jwks_url)
62
- keys = jwks.get("keys") if isinstance(jwks.get("keys"), list) else []
63
- pub: RSAPublicKey | None = None
64
- for entry in keys:
65
- if not isinstance(entry, dict):
66
- continue
67
- if kid and str(entry.get("kid") or "") != kid:
68
- continue
69
- if str(entry.get("kty") or "") != "RSA":
70
- continue
71
- pub = _jwk_to_rsa(entry)
72
- break
73
- if pub is None:
74
- raise ValueError("jwks key not found")
75
- signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
76
- signature = _b64url_decode(parts[2])
77
- pub.verify(signature, signing_input, padding.PKCS1v15(), hashes.SHA256())
78
-
79
- now = int(time.time())
80
- exp = int(payload.get("exp") or 0)
81
- if exp and now > exp + clock_skew:
82
- raise ValueError("id_token expired")
83
- iat = int(payload.get("iat") or 0)
84
- if iat and now + clock_skew < iat:
85
- raise ValueError("id_token not yet valid")
86
-
87
- iss = str(payload.get("iss") or "")
88
- allowed_iss = issuer or ()
89
- if allowed_iss and iss not in allowed_iss:
90
- raise ValueError("issuer mismatch")
91
-
92
- aud = payload.get("aud")
93
- aud_ok = audience in (aud if isinstance(aud, list) else [aud])
94
- if not aud_ok:
95
- raise ValueError("audience mismatch")
96
-
97
- if not isinstance(payload, dict):
98
- raise ValueError("invalid claims")
99
- return payload
100
-
101
-
102
- def verify_google_id_token(token: str, client_id: str) -> dict[str, Any]:
103
- return verify_rs256_id_token(
104
- token,
105
- audience=str(client_id or "").strip(),
106
- issuer=("https://accounts.google.com", "accounts.google.com"),
107
- jwks_url="https://www.googleapis.com/oauth2/v3/certs",
108
- )
1
+ """Minimal OIDC id_token verification (RS256 + JWKS) — no extra dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+ from typing import Any
11
+
12
+ from cryptography.hazmat.primitives import hashes
13
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
14
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
15
+
16
+ _JWKS_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
17
+ _JWKS_TTL = 3600.0
18
+
19
+
20
+ def _b64url_decode(raw: str) -> bytes:
21
+ padded = raw + "=" * (-len(raw) % 4)
22
+ return base64.urlsafe_b64decode(padded.encode("ascii"))
23
+
24
+
25
+ def _jwk_to_rsa(key: dict[str, Any]) -> RSAPublicKey:
26
+ n = int.from_bytes(_b64url_decode(str(key["n"])), "big")
27
+ e = int.from_bytes(_b64url_decode(str(key["e"])), "big")
28
+ return rsa.RSAPublicNumbers(e, n).public_key()
29
+
30
+
31
+ def _fetch_jwks(jwks_url: str) -> dict[str, Any]:
32
+ now = time.time()
33
+ cached = _JWKS_CACHE.get(jwks_url)
34
+ if cached and cached[0] > now:
35
+ return cached[1]
36
+ req = urllib.request.Request(jwks_url, headers={"Accept": "application/json"})
37
+ with urllib.request.urlopen(req, timeout=15) as resp:
38
+ payload = json.loads(resp.read().decode("utf-8"))
39
+ if not isinstance(payload, dict):
40
+ raise ValueError("invalid jwks")
41
+ _JWKS_CACHE[jwks_url] = (now + _JWKS_TTL, payload)
42
+ return payload
43
+
44
+
45
+ def verify_rs256_id_token(
46
+ token: str,
47
+ *,
48
+ audience: str,
49
+ issuer: str | tuple[str, ...] = (),
50
+ jwks_url: str,
51
+ clock_skew: int = 60,
52
+ ) -> dict[str, Any]:
53
+ parts = str(token or "").split(".")
54
+ if len(parts) != 3:
55
+ raise ValueError("invalid id_token")
56
+ header = json.loads(_b64url_decode(parts[0]))
57
+ payload = json.loads(_b64url_decode(parts[1]))
58
+ if str(header.get("alg") or "") != "RS256":
59
+ raise ValueError("unsupported jwt alg")
60
+ kid = str(header.get("kid") or "")
61
+ jwks = _fetch_jwks(jwks_url)
62
+ keys = jwks.get("keys") if isinstance(jwks.get("keys"), list) else []
63
+ pub: RSAPublicKey | None = None
64
+ for entry in keys:
65
+ if not isinstance(entry, dict):
66
+ continue
67
+ if kid and str(entry.get("kid") or "") != kid:
68
+ continue
69
+ if str(entry.get("kty") or "") != "RSA":
70
+ continue
71
+ pub = _jwk_to_rsa(entry)
72
+ break
73
+ if pub is None:
74
+ raise ValueError("jwks key not found")
75
+ signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
76
+ signature = _b64url_decode(parts[2])
77
+ pub.verify(signature, signing_input, padding.PKCS1v15(), hashes.SHA256())
78
+
79
+ now = int(time.time())
80
+ exp = int(payload.get("exp") or 0)
81
+ if exp and now > exp + clock_skew:
82
+ raise ValueError("id_token expired")
83
+ iat = int(payload.get("iat") or 0)
84
+ if iat and now + clock_skew < iat:
85
+ raise ValueError("id_token not yet valid")
86
+
87
+ iss = str(payload.get("iss") or "")
88
+ allowed_iss = issuer or ()
89
+ if allowed_iss and iss not in allowed_iss:
90
+ raise ValueError("issuer mismatch")
91
+
92
+ aud = payload.get("aud")
93
+ aud_ok = audience in (aud if isinstance(aud, list) else [aud])
94
+ if not aud_ok:
95
+ raise ValueError("audience mismatch")
96
+
97
+ if not isinstance(payload, dict):
98
+ raise ValueError("invalid claims")
99
+ return payload
100
+
101
+
102
+ def verify_google_id_token(token: str, client_id: str) -> dict[str, Any]:
103
+ return verify_rs256_id_token(
104
+ token,
105
+ audience=str(client_id or "").strip(),
106
+ issuer=("https://accounts.google.com", "accounts.google.com"),
107
+ jwks_url="https://www.googleapis.com/oauth2/v3/certs",
108
+ )
@@ -1,12 +1,12 @@
1
- {
2
- "name": "@workframe/workframe-api",
3
- "version": "0.1.5",
4
- "private": true,
5
- "type": "module",
6
- "scripts": {
7
- "dev": "python3 server.py",
8
- "start": "python3 server.py",
9
- "typecheck": "python3 -m py_compile server.py zk_auth.py email_sender.py",
10
- "build": "python3 -m py_compile server.py zk_auth.py email_sender.py"
11
- }
12
- }
1
+ {
2
+ "name": "@workframe/workframe-api",
3
+ "version": "0.1.7",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "python3 server.py",
8
+ "start": "python3 server.py",
9
+ "typecheck": "python3 -m py_compile server.py zk_auth.py email_sender.py",
10
+ "build": "python3 -m py_compile server.py zk_auth.py email_sender.py"
11
+ }
12
+ }
@@ -1,194 +1,194 @@
1
- """Stack-level sign-in: Discord OAuth + Telegram Login (workframe admin config)."""
2
-
3
- from __future__ import annotations
4
-
5
- import hashlib
6
- import hmac
7
- import json
8
- import os
9
- import secrets
10
- import urllib.parse
11
- import urllib.request
12
- from typing import Any
13
- from urllib.parse import urlparse
14
-
15
- import stack_config
16
-
17
- APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://127.0.0.1:18644").rstrip("/")
18
-
19
-
20
- def _oauth_public(block: dict[str, Any]) -> dict[str, Any]:
21
- client_id = str(block.get("client_id") or "").strip()
22
- return {
23
- "client_id": client_id,
24
- "has_secret": bool(str(block.get("client_secret") or "").strip()),
25
- "enabled": bool(client_id),
26
- }
27
-
28
-
29
- def resolved_discord_oauth() -> dict[str, str]:
30
- block = stack_config._stack_oauth_raw("discord_oauth") # ponytail: stack_config sibling module
31
- return {
32
- "client_id": str(block.get("client_id") or "").strip(),
33
- "client_secret": str(block.get("client_secret") or "").strip(),
34
- }
35
-
36
-
37
- def discord_oauth_configured() -> bool:
38
- cfg = resolved_discord_oauth()
39
- return bool(cfg.get("client_id") and cfg.get("client_secret"))
40
-
41
-
42
- def resolved_telegram_login() -> dict[str, str]:
43
- block = stack_config._stack_oauth_raw("telegram_login")
44
- return {
45
- "bot_username": str(block.get("bot_username") or "").strip().lstrip("@"),
46
- "bot_token": str(block.get("bot_token") or "").strip(),
47
- }
48
-
49
-
50
- def telegram_login_configured() -> bool:
51
- cfg = resolved_telegram_login()
52
- return bool(cfg.get("bot_username") and cfg.get("bot_token"))
53
-
54
-
55
- def telegram_login_domain() -> str:
56
- host = urlparse(APP_BASE_URL).hostname or ""
57
- return host.strip()
58
-
59
-
60
- def public_discord_oauth() -> dict[str, Any]:
61
- return _oauth_public(stack_config._stack_oauth_raw("discord_oauth"))
62
-
63
-
64
- def public_telegram_login() -> dict[str, Any]:
65
- cfg = resolved_telegram_login()
66
- domain = telegram_login_domain()
67
- return {
68
- "bot_username": cfg.get("bot_username") or "",
69
- "has_token": bool(cfg.get("bot_token")),
70
- "enabled": telegram_login_configured(),
71
- "domain": domain,
72
- }
73
-
74
-
75
- def discord_redirect_uri() -> str:
76
- return f"{APP_BASE_URL}/api/oauth/discord/callback"
77
-
78
-
79
- def _discord_exchange_code(code: str, client_id: str, client_secret: str, redirect_uri: str) -> dict[str, Any]:
80
- body = urllib.parse.urlencode(
81
- {
82
- "client_id": client_id,
83
- "client_secret": client_secret,
84
- "grant_type": "authorization_code",
85
- "code": code,
86
- "redirect_uri": redirect_uri,
87
- },
88
- ).encode("utf-8")
89
- req = urllib.request.Request(
90
- "https://discord.com/api/oauth2/token",
91
- data=body,
92
- headers={"Content-Type": "application/x-www-form-urlencoded"},
93
- method="POST",
94
- )
95
- try:
96
- with urllib.request.urlopen(req, timeout=30) as resp:
97
- raw = resp.read().decode("utf-8", errors="replace")
98
- except urllib.error.HTTPError as exc:
99
- raw = exc.read().decode("utf-8", errors="replace")
100
- except OSError as exc:
101
- return {"error": str(exc)}
102
- try:
103
- data = json.loads(raw)
104
- except json.JSONDecodeError:
105
- return {"error": raw or "discord_token_exchange_failed"}
106
- return data if isinstance(data, dict) else {"error": "invalid_discord_response"}
107
-
108
-
109
- def _discord_fetch_user(access_token: str) -> dict[str, Any]:
110
- req = urllib.request.Request(
111
- "https://discord.com/api/users/@me",
112
- headers={"Authorization": f"Bearer {access_token}"},
113
- )
114
- try:
115
- with urllib.request.urlopen(req, timeout=20) as resp:
116
- data = json.loads(resp.read().decode("utf-8", errors="replace"))
117
- except (OSError, urllib.error.HTTPError, json.JSONDecodeError) as exc:
118
- return {"error": str(exc)}
119
- return data if isinstance(data, dict) else {"error": "invalid_discord_user"}
120
-
121
-
122
- def start_discord_link(
123
- *,
124
- state: str,
125
- redirect_url: str,
126
- ) -> dict[str, Any]:
127
- cfg = resolved_discord_oauth()
128
- client_id = str(cfg.get("client_id") or "").strip()
129
- if not client_id or not str(cfg.get("client_secret") or "").strip():
130
- return {
131
- "ok": False,
132
- "error": "discord_oauth_not_configured",
133
- "message": "Workframe admin must register a Discord OAuth app under Integrations.",
134
- }
135
- params = urllib.parse.urlencode(
136
- {
137
- "client_id": client_id,
138
- "redirect_uri": discord_redirect_uri(),
139
- "response_type": "code",
140
- "scope": "identify",
141
- "state": state,
142
- "prompt": "consent",
143
- },
144
- )
145
- return {
146
- "ok": True,
147
- "provider": "discord",
148
- "redirect_url": redirect_url or f"https://discord.com/api/oauth2/authorize?{params}",
149
- "output": "",
150
- "error": None,
151
- }
152
-
153
-
154
- def complete_discord_link(code: str) -> dict[str, Any]:
155
- cfg = resolved_discord_oauth()
156
- client_id = str(cfg.get("client_id") or "").strip()
157
- client_secret = str(cfg.get("client_secret") or "").strip()
158
- if not client_id or not client_secret:
159
- return {"ok": False, "error": "discord_oauth_not_configured"}
160
- token_data = _discord_exchange_code(code, client_id, client_secret, discord_redirect_uri())
161
- if token_data.get("error"):
162
- return {"ok": False, "error": str(token_data.get("error"))}
163
- access_token = str(token_data.get("access_token") or "").strip()
164
- if not access_token:
165
- return {"ok": False, "error": "discord_missing_access_token"}
166
- user = _discord_fetch_user(access_token)
167
- if user.get("error"):
168
- return {"ok": False, "error": str(user.get("error"))}
169
- discord_id = str(user.get("id") or "").strip()
170
- if not discord_id:
171
- return {"ok": False, "error": "discord_missing_user_id"}
172
- return {"ok": True, "provider": "discord", "platform_ids": {"discord": discord_id}}
173
-
174
-
175
- def verify_telegram_login(payload: dict[str, Any]) -> dict[str, Any]:
176
- """Validate Telegram Login widget payload; returns user id on success."""
177
- cfg = resolved_telegram_login()
178
- bot_token = str(cfg.get("bot_token") or "").strip()
179
- if not bot_token:
180
- return {"ok": False, "error": "telegram_login_not_configured"}
181
- data = {k: str(v) for k, v in payload.items() if k != "hash" and v is not None}
182
- check_hash = str(payload.get("hash") or "").strip()
183
- if not check_hash or not data.get("id"):
184
- return {"ok": False, "error": "telegram_invalid_payload"}
185
- check_line = "\n".join(f"{k}={data[k]}" for k in sorted(data))
186
- secret = hashlib.sha256(bot_token.encode("utf-8")).digest()
187
- computed = hmac.new(secret, check_line.encode("utf-8"), hashlib.sha256).hexdigest()
188
- if not hmac.compare_digest(computed, check_hash):
189
- return {"ok": False, "error": "telegram_hash_mismatch"}
190
- return {"ok": True, "provider": "telegram", "platform_ids": {"telegram": str(data["id"]).strip()}}
191
-
192
-
193
- def new_oauth_state() -> str:
194
- return secrets.token_urlsafe(24)
1
+ """Stack-level sign-in: Discord OAuth + Telegram Login (workframe admin config)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import os
9
+ import secrets
10
+ import urllib.parse
11
+ import urllib.request
12
+ from typing import Any
13
+ from urllib.parse import urlparse
14
+
15
+ import stack_config
16
+
17
+ APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://127.0.0.1:18644").rstrip("/")
18
+
19
+
20
+ def _oauth_public(block: dict[str, Any]) -> dict[str, Any]:
21
+ client_id = str(block.get("client_id") or "").strip()
22
+ return {
23
+ "client_id": client_id,
24
+ "has_secret": bool(str(block.get("client_secret") or "").strip()),
25
+ "enabled": bool(client_id),
26
+ }
27
+
28
+
29
+ def resolved_discord_oauth() -> dict[str, str]:
30
+ block = stack_config._stack_oauth_raw("discord_oauth") # ponytail: stack_config sibling module
31
+ return {
32
+ "client_id": str(block.get("client_id") or "").strip(),
33
+ "client_secret": str(block.get("client_secret") or "").strip(),
34
+ }
35
+
36
+
37
+ def discord_oauth_configured() -> bool:
38
+ cfg = resolved_discord_oauth()
39
+ return bool(cfg.get("client_id") and cfg.get("client_secret"))
40
+
41
+
42
+ def resolved_telegram_login() -> dict[str, str]:
43
+ block = stack_config._stack_oauth_raw("telegram_login")
44
+ return {
45
+ "bot_username": str(block.get("bot_username") or "").strip().lstrip("@"),
46
+ "bot_token": str(block.get("bot_token") or "").strip(),
47
+ }
48
+
49
+
50
+ def telegram_login_configured() -> bool:
51
+ cfg = resolved_telegram_login()
52
+ return bool(cfg.get("bot_username") and cfg.get("bot_token"))
53
+
54
+
55
+ def telegram_login_domain() -> str:
56
+ host = urlparse(APP_BASE_URL).hostname or ""
57
+ return host.strip()
58
+
59
+
60
+ def public_discord_oauth() -> dict[str, Any]:
61
+ return _oauth_public(stack_config._stack_oauth_raw("discord_oauth"))
62
+
63
+
64
+ def public_telegram_login() -> dict[str, Any]:
65
+ cfg = resolved_telegram_login()
66
+ domain = telegram_login_domain()
67
+ return {
68
+ "bot_username": cfg.get("bot_username") or "",
69
+ "has_token": bool(cfg.get("bot_token")),
70
+ "enabled": telegram_login_configured(),
71
+ "domain": domain,
72
+ }
73
+
74
+
75
+ def discord_redirect_uri() -> str:
76
+ return f"{APP_BASE_URL}/api/oauth/discord/callback"
77
+
78
+
79
+ def _discord_exchange_code(code: str, client_id: str, client_secret: str, redirect_uri: str) -> dict[str, Any]:
80
+ body = urllib.parse.urlencode(
81
+ {
82
+ "client_id": client_id,
83
+ "client_secret": client_secret,
84
+ "grant_type": "authorization_code",
85
+ "code": code,
86
+ "redirect_uri": redirect_uri,
87
+ },
88
+ ).encode("utf-8")
89
+ req = urllib.request.Request(
90
+ "https://discord.com/api/oauth2/token",
91
+ data=body,
92
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
93
+ method="POST",
94
+ )
95
+ try:
96
+ with urllib.request.urlopen(req, timeout=30) as resp:
97
+ raw = resp.read().decode("utf-8", errors="replace")
98
+ except urllib.error.HTTPError as exc:
99
+ raw = exc.read().decode("utf-8", errors="replace")
100
+ except OSError as exc:
101
+ return {"error": str(exc)}
102
+ try:
103
+ data = json.loads(raw)
104
+ except json.JSONDecodeError:
105
+ return {"error": raw or "discord_token_exchange_failed"}
106
+ return data if isinstance(data, dict) else {"error": "invalid_discord_response"}
107
+
108
+
109
+ def _discord_fetch_user(access_token: str) -> dict[str, Any]:
110
+ req = urllib.request.Request(
111
+ "https://discord.com/api/users/@me",
112
+ headers={"Authorization": f"Bearer {access_token}"},
113
+ )
114
+ try:
115
+ with urllib.request.urlopen(req, timeout=20) as resp:
116
+ data = json.loads(resp.read().decode("utf-8", errors="replace"))
117
+ except (OSError, urllib.error.HTTPError, json.JSONDecodeError) as exc:
118
+ return {"error": str(exc)}
119
+ return data if isinstance(data, dict) else {"error": "invalid_discord_user"}
120
+
121
+
122
+ def start_discord_link(
123
+ *,
124
+ state: str,
125
+ redirect_url: str,
126
+ ) -> dict[str, Any]:
127
+ cfg = resolved_discord_oauth()
128
+ client_id = str(cfg.get("client_id") or "").strip()
129
+ if not client_id or not str(cfg.get("client_secret") or "").strip():
130
+ return {
131
+ "ok": False,
132
+ "error": "discord_oauth_not_configured",
133
+ "message": "Workframe admin must register a Discord OAuth app under Integrations.",
134
+ }
135
+ params = urllib.parse.urlencode(
136
+ {
137
+ "client_id": client_id,
138
+ "redirect_uri": discord_redirect_uri(),
139
+ "response_type": "code",
140
+ "scope": "identify",
141
+ "state": state,
142
+ "prompt": "consent",
143
+ },
144
+ )
145
+ return {
146
+ "ok": True,
147
+ "provider": "discord",
148
+ "redirect_url": redirect_url or f"https://discord.com/api/oauth2/authorize?{params}",
149
+ "output": "",
150
+ "error": None,
151
+ }
152
+
153
+
154
+ def complete_discord_link(code: str) -> dict[str, Any]:
155
+ cfg = resolved_discord_oauth()
156
+ client_id = str(cfg.get("client_id") or "").strip()
157
+ client_secret = str(cfg.get("client_secret") or "").strip()
158
+ if not client_id or not client_secret:
159
+ return {"ok": False, "error": "discord_oauth_not_configured"}
160
+ token_data = _discord_exchange_code(code, client_id, client_secret, discord_redirect_uri())
161
+ if token_data.get("error"):
162
+ return {"ok": False, "error": str(token_data.get("error"))}
163
+ access_token = str(token_data.get("access_token") or "").strip()
164
+ if not access_token:
165
+ return {"ok": False, "error": "discord_missing_access_token"}
166
+ user = _discord_fetch_user(access_token)
167
+ if user.get("error"):
168
+ return {"ok": False, "error": str(user.get("error"))}
169
+ discord_id = str(user.get("id") or "").strip()
170
+ if not discord_id:
171
+ return {"ok": False, "error": "discord_missing_user_id"}
172
+ return {"ok": True, "provider": "discord", "platform_ids": {"discord": discord_id}}
173
+
174
+
175
+ def verify_telegram_login(payload: dict[str, Any]) -> dict[str, Any]:
176
+ """Validate Telegram Login widget payload; returns user id on success."""
177
+ cfg = resolved_telegram_login()
178
+ bot_token = str(cfg.get("bot_token") or "").strip()
179
+ if not bot_token:
180
+ return {"ok": False, "error": "telegram_login_not_configured"}
181
+ data = {k: str(v) for k, v in payload.items() if k != "hash" and v is not None}
182
+ check_hash = str(payload.get("hash") or "").strip()
183
+ if not check_hash or not data.get("id"):
184
+ return {"ok": False, "error": "telegram_invalid_payload"}
185
+ check_line = "\n".join(f"{k}={data[k]}" for k in sorted(data))
186
+ secret = hashlib.sha256(bot_token.encode("utf-8")).digest()
187
+ computed = hmac.new(secret, check_line.encode("utf-8"), hashlib.sha256).hexdigest()
188
+ if not hmac.compare_digest(computed, check_hash):
189
+ return {"ok": False, "error": "telegram_hash_mismatch"}
190
+ return {"ok": True, "provider": "telegram", "platform_ids": {"telegram": str(data["id"]).strip()}}
191
+
192
+
193
+ def new_oauth_state() -> str:
194
+ return secrets.token_urlsafe(24)