@maudecode/kanvil 0.2.1 → 0.3.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.
- package/apps/server/.env.example +19 -0
- package/apps/server/dist/auth-JcS53IcO.mjs +12424 -0
- package/apps/server/dist/{config-CbNqtJEj.mjs → config-D3cAABi7.mjs} +27 -2
- package/apps/server/dist/index.mjs +2500 -294
- package/apps/server/dist/migrate.mjs +1 -1
- package/apps/server/dist/recover.mjs +4 -4
- package/apps/server/drizzle/0014_inbox_dismissal.sql +1 -0
- package/apps/server/drizzle/0015_notification_categories.sql +14 -0
- package/apps/server/drizzle/0016_ticket_resources.sql +19 -0
- package/apps/server/drizzle/0017_project_backgrounds.sql +3 -0
- package/apps/server/drizzle/0018_delivery_outbox.sql +65 -0
- package/apps/server/drizzle/0019_retire_push_deliveries.sql +1 -0
- package/apps/server/drizzle/0020_calendar_feeds.sql +16 -0
- package/apps/server/drizzle/0021_staged_uploads.sql +14 -0
- package/apps/server/drizzle/0022_manual_order.sql +6 -0
- package/apps/server/drizzle/0023_workspace_invitations.sql +22 -0
- package/apps/server/drizzle/0024_workflow_states.sql +39 -0
- package/apps/server/drizzle/0025_workspace_labels.sql +10 -0
- package/apps/server/drizzle/0026_public_read.sql +1 -0
- package/apps/server/drizzle/0027_custom_fields.sql +16 -0
- package/apps/server/drizzle/0028_project_lifecycle.sql +11 -0
- package/apps/server/drizzle/0029_ticket_relocation.sql +16 -0
- package/apps/server/drizzle/0030_repository_connections.sql +39 -0
- package/apps/server/drizzle/0031_repository_providers.sql +3 -0
- package/apps/server/drizzle/0032_workspace_ownership.sql +11 -0
- package/apps/server/drizzle/0033_import_runs.sql +38 -0
- package/apps/server/drizzle/0034_issue_sync.sql +38 -0
- package/apps/server/drizzle/0035_account_deletion.sql +9 -0
- package/apps/server/drizzle/0036_workspace_roles.sql +16 -0
- package/apps/server/drizzle/0037_email_code_owner.sql +14 -0
- package/apps/server/drizzle/meta/0014_snapshot.json +4246 -0
- package/apps/server/drizzle/meta/0015_snapshot.json +4297 -0
- package/apps/server/drizzle/meta/0016_snapshot.json +4443 -0
- package/apps/server/drizzle/meta/0017_snapshot.json +4468 -0
- package/apps/server/drizzle/meta/0018_snapshot.json +4948 -0
- package/apps/server/drizzle/meta/0019_snapshot.json +4826 -0
- package/apps/server/drizzle/meta/0020_snapshot.json +4947 -0
- package/apps/server/drizzle/meta/0021_snapshot.json +5059 -0
- package/apps/server/drizzle/meta/0022_snapshot.json +5094 -0
- package/apps/server/drizzle/meta/0023_snapshot.json +5267 -0
- package/apps/server/drizzle/meta/0024_snapshot.json +5405 -0
- package/apps/server/drizzle/meta/0025_snapshot.json +5474 -0
- package/apps/server/drizzle/meta/0026_snapshot.json +5481 -0
- package/apps/server/drizzle/meta/0027_snapshot.json +5605 -0
- package/apps/server/drizzle/meta/0028_snapshot.json +5699 -0
- package/apps/server/drizzle/meta/0029_snapshot.json +5809 -0
- package/apps/server/drizzle/meta/0030_snapshot.json +6066 -0
- package/apps/server/drizzle/meta/0031_snapshot.json +6074 -0
- package/apps/server/drizzle/meta/0032_snapshot.json +6100 -0
- package/apps/server/drizzle/meta/0033_snapshot.json +6406 -0
- package/apps/server/drizzle/meta/0034_snapshot.json +6698 -0
- package/apps/server/drizzle/meta/0035_snapshot.json +6704 -0
- package/apps/server/drizzle/meta/0036_snapshot.json +6825 -0
- package/apps/server/drizzle/meta/0037_snapshot.json +6825 -0
- package/apps/server/drizzle/meta/_journal.json +168 -0
- package/apps/web/dist/assets/CompositeItem-BbuEp99X.js +1 -0
- package/apps/web/dist/assets/Trans-DJGH63s1.js +1 -0
- package/apps/web/dist/assets/abnfDiagram-WEYOIA3C-DTOx8N4B.js +1 -0
- package/apps/web/dist/assets/admin-settings-CJG9PMKR.js +1 -0
- package/apps/web/dist/assets/arc-Y1ARJVZ4.js +1 -0
- package/apps/web/dist/assets/architecture-WOLXFQ4H-DlC3p5z3.js +1 -0
- package/apps/web/dist/assets/architectureDiagram-ZHVYKT53-DooN3Z58.js +36 -0
- package/apps/web/dist/assets/archive-restore-B6_SsOIP.js +1 -0
- package/apps/web/dist/assets/array-BifhSqXX.js +1 -0
- package/apps/web/dist/assets/az-AZ-BtrlbgjS.js +1 -0
- package/apps/web/dist/assets/az-D-kj4lmf.js +1 -0
- package/apps/web/dist/assets/az-DqY__MpS.js +1 -0
- package/apps/web/dist/assets/blockDiagram-RJAE7QA3-CD5AgY4U.js +140 -0
- package/apps/web/dist/assets/c4Diagram-XDPPPE54-CTDOcLuN.js +38 -0
- package/apps/web/dist/assets/channel-BhHvU4I-.js +1 -0
- package/apps/web/dist/assets/chunk-2E7WHVMD-D9qnBqTV.js +88 -0
- package/apps/web/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/apps/web/dist/assets/chunk-4EMNKR2T-Kz5JiJ85.js +1 -0
- package/apps/web/dist/assets/chunk-55XCU76K-DnKiFpYM.js +1 -0
- package/apps/web/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/apps/web/dist/assets/chunk-7S6XSTFO-CpdNd6Ke.js +2 -0
- package/apps/web/dist/assets/chunk-AQDTSKZB-CpyuSWt8.js +72 -0
- package/apps/web/dist/assets/chunk-DSNLSHJZ-DTC3y9rb.js +129 -0
- package/apps/web/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/apps/web/dist/assets/chunk-HAGFOTED-Bv8PrMYK.js +62 -0
- package/apps/web/dist/assets/chunk-HNE4Q36P-CDDJ_yRa.js +1 -0
- package/apps/web/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/apps/web/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/apps/web/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/apps/web/dist/assets/chunk-NQOKRVIF-lNtnsWHS.js +213 -0
- package/apps/web/dist/assets/chunk-UHW7PFBP-D79ptlTN.js +1 -0
- package/apps/web/dist/assets/chunk-VQ5W62QI-B8hWFCiM.js +1 -0
- package/apps/web/dist/assets/chunk-WQQL4CDK-DAGuWzS3.js +10 -0
- package/apps/web/dist/assets/chunk-XXDRQBXY-CnbRkih_.js +1 -0
- package/apps/web/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/apps/web/dist/assets/chunk-YZO67HFG-C0ptzlKO.js +1 -0
- package/apps/web/dist/assets/chunk-ZAUX2LTX-ah8U5xoJ.js +1 -0
- package/apps/web/dist/assets/chunk-ZCHMQSOR-DYqATOIZ.js +2 -0
- package/apps/web/dist/assets/classDiagram-v2-4MYWR3UA-DYytA-ua.js +217 -0
- package/apps/web/dist/assets/core-jXc03Sty.js +1 -0
- package/apps/web/dist/assets/corner-left-up-DA7MG5uX.js +1 -0
- package/apps/web/dist/assets/cose-bilkent-JH36ORCC-lWVj4msc.js +1 -0
- package/apps/web/dist/assets/cynefin-EF2NZ3EQ-Xy6e6m1E.js +1 -0
- package/apps/web/dist/assets/cynefinDiagram-WF24UDRP-BYarZJSm.js +62 -0
- package/apps/web/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/apps/web/dist/assets/dagre-NK4GIZVX-WLvvUY1o.js +4 -0
- package/apps/web/dist/assets/de-DAv77-wP.js +1 -0
- package/apps/web/dist/assets/de-DE-Bu_Ko2QA.js +1 -0
- package/apps/web/dist/assets/de-UHreLwyV.js +1 -0
- package/apps/web/dist/assets/default-D5TdIZQS.js +56 -0
- package/apps/web/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/apps/web/dist/assets/delivery-settings-BE5MW8U0.js +1 -0
- package/apps/web/dist/assets/diagram-CBK3MWTC-D6PtxkLg.js +24 -0
- package/apps/web/dist/assets/diagram-I5XIIV2D-DTOKQwIY.js +24 -0
- package/apps/web/dist/assets/diagram-ODIL2UCW-BmERt0Xu.js +30 -0
- package/apps/web/dist/assets/diagram-PFBEJUXE-WDUe08eD.js +3 -0
- package/apps/web/dist/assets/diagram-RJA3I4VV-B2K-NLGG.js +41 -0
- package/apps/web/dist/assets/diagram-TYUNFSNR-BLf7PpV7.js +200 -0
- package/apps/web/dist/assets/dist-CumnGVmJ.js +1 -0
- package/apps/web/dist/assets/ebnfDiagram-V4P3TTPT-DwBUQs9p.js +1 -0
- package/apps/web/dist/assets/el-D2ypddWn.js +1 -0
- package/apps/web/dist/assets/el-EJnrsyVr.js +1 -0
- package/apps/web/dist/assets/el-GR-CpKEps47.js +1 -0
- package/apps/web/dist/assets/elk-QONTBCTQ-AuT8PinL.js +27 -0
- package/apps/web/dist/assets/ellipsis-D6154JKs.js +1 -0
- package/apps/web/dist/assets/en-EnSRisMz.js +1 -0
- package/apps/web/dist/assets/en-US-DKYSujv3.js +1 -0
- package/apps/web/dist/assets/erDiagram-KYQIGQAA-h--u4Eo6.js +99 -0
- package/apps/web/dist/assets/es-B_49BMhR.js +1 -0
- package/apps/web/dist/assets/es-D2ScuV-5.js +1 -0
- package/apps/web/dist/assets/es-ES-DVkfi7H-.js +1 -0
- package/apps/web/dist/assets/eventmodeling-K75KTNOO-CW4hDt1j.js +1 -0
- package/apps/web/dist/assets/flowDiagram-IUGLLWBU-BGUscnNP.js +1 -0
- package/apps/web/dist/assets/forms-BcD6Wwyv.js +1 -0
- package/apps/web/dist/assets/fr-B-WmPGZD.js +1 -0
- package/apps/web/dist/assets/fr-C_VpS8u6.js +1 -0
- package/apps/web/dist/assets/fr-FR-DkHaEHqi.js +1 -0
- package/apps/web/dist/assets/ganttDiagram-R7GPNTJY-CB3lsQvj.js +292 -0
- package/apps/web/dist/assets/gitGraph-VSP46ZUC-Hu-kvdCR.js +1 -0
- package/apps/web/dist/assets/gitGraphDiagram-PDYL7XHY-DCuY3C1Q.js +106 -0
- package/apps/web/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/apps/web/dist/assets/hi-CW4rdp_m.js +1 -0
- package/apps/web/dist/assets/hi-DvoDcUpL.js +1 -0
- package/apps/web/dist/assets/hi-IN-CbUDNSTK.js +1 -0
- package/apps/web/dist/assets/highlight-CMydIf9M.js +5 -0
- package/apps/web/dist/assets/id-CPASBZ2r.js +1 -0
- package/apps/web/dist/assets/id-CvL3yBVr.js +1 -0
- package/apps/web/dist/assets/id-ID-DLTkxt2j.js +1 -0
- package/apps/web/dist/assets/index-DoqlyejP.js +17 -0
- package/apps/web/dist/assets/index-DyQNFS69.css +2 -0
- package/apps/web/dist/assets/info-OHQRW6UA-Cc8VwTsk.js +1 -0
- package/apps/web/dist/assets/infoDiagram-MVDRKAX5-KIMOx55J.js +2 -0
- package/apps/web/dist/assets/init-C-OQMol4.js +1 -0
- package/apps/web/dist/assets/invitations-BmpelTJu.js +1 -0
- package/apps/web/dist/assets/isSameWeek-2ftYGvBT.js +1 -0
- package/apps/web/dist/assets/ishikawaDiagram-HPEHV2GE-CAou726L.js +70 -0
- package/apps/web/dist/assets/it-Cd7u97Jd.js +1 -0
- package/apps/web/dist/assets/it-DOLotXOj.js +1 -0
- package/apps/web/dist/assets/it-IT-CIzq0YwC.js +1 -0
- package/apps/web/dist/assets/item-Cq_ZbivE.js +1 -0
- package/apps/web/dist/assets/ja-B1iJt9qB.js +1 -0
- package/apps/web/dist/assets/ja-JP-CIE4-D-v.js +1 -0
- package/apps/web/dist/assets/ja-LkOrjegp.js +1 -0
- package/apps/web/dist/assets/journeyDiagram-BOSHW7O7-YbJuPYDH.js +139 -0
- package/apps/web/dist/assets/kanban-definition-FNSA4HC5-nuRnm9y8.js +89 -0
- package/apps/web/dist/assets/katex-B_JcIwqj.js +257 -0
- package/apps/web/dist/assets/ko-DPpXw683.js +1 -0
- package/apps/web/dist/assets/ko-KR-DYP5Zae6.js +1 -0
- package/apps/web/dist/assets/ko-fmUq1-8W.js +1 -0
- package/apps/web/dist/assets/label-settings-B3LsJxhk.js +1 -0
- package/apps/web/dist/assets/line-WsxZYwZr.js +1 -0
- package/apps/web/dist/assets/linear-C9iwaMOi.js +1 -0
- package/apps/web/dist/assets/lucide-DIlszs-T.js +1 -0
- package/apps/web/dist/assets/markdown-blocks-Bw26pRKB.js +2 -0
- package/apps/web/dist/assets/member-directory-CHKUw0u4.js +1 -0
- package/apps/web/dist/assets/mermaid-parser.core-B5c-1CnP.js +7 -0
- package/apps/web/dist/assets/mermaid.core-vGVkgzA3.js +44 -0
- package/apps/web/dist/assets/mindmap-definition-6OY57UG3-uooJyUak.js +98 -0
- package/apps/web/dist/assets/mk-CkaQRpqe.js +1 -0
- package/apps/web/dist/assets/mk-LWVrR-T-.js +1 -0
- package/apps/web/dist/assets/mk-MK-CpOo5Bd8.js +1 -0
- package/apps/web/dist/assets/nl-DiEjqOA8.js +1 -0
- package/apps/web/dist/assets/nl-NL-Mu8G8RON.js +1 -0
- package/apps/web/dist/assets/nl-ro6hbqo5.js +1 -0
- package/apps/web/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/apps/web/dist/assets/packet-JDAUHWVQ-WJpqlH1d.js +1 -0
- package/apps/web/dist/assets/path-fybaL0A-.js +1 -0
- package/apps/web/dist/assets/pegDiagram-ECIUJNBX-CUvajD93.js +1 -0
- package/apps/web/dist/assets/pie-XZMESJXO-DvandPby.js +1 -0
- package/apps/web/dist/assets/pieDiagram-GNMPY74T-CqxeyIaP.js +39 -0
- package/apps/web/dist/assets/pl-Bx_fYLKY.js +1 -0
- package/apps/web/dist/assets/pl-CauoMfkf.js +1 -0
- package/apps/web/dist/assets/pl-PL-DGuxe2Aq.js +1 -0
- package/apps/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/apps/web/dist/assets/project-lifecycle-Ba7iRF3u.js +1 -0
- package/apps/web/dist/assets/project-settings-CtOWgEam.js +1 -0
- package/apps/web/dist/assets/pt-BR-BpOhwoDW.js +1 -0
- package/apps/web/dist/assets/pt-BR-D-a7F5zJ.js +1 -0
- package/apps/web/dist/assets/pt-BR-DwkMPgq1.js +1 -0
- package/apps/web/dist/assets/public-project-CHRjhFIn.js +1 -0
- package/apps/web/dist/assets/quadrantDiagram-D57K4MC3-DFjNmaZc.js +7 -0
- package/apps/web/dist/assets/radar-ABXABTNO-CJhZF9j2.js +1 -0
- package/apps/web/dist/assets/railroad-I3PHUGI6-DZC10oBD.js +1 -0
- package/apps/web/dist/assets/railroad-abnf-LNEFI6M7-BUJF2n6u.js +1 -0
- package/apps/web/dist/assets/railroad-ebnf-DWYD2UWJ-CG7oB02K.js +1 -0
- package/apps/web/dist/assets/railroad-peg-JR7BVNR7-DBCZuZJY.js +1 -0
- package/apps/web/dist/assets/railroadDiagram-Y72NRBL7-CvN3poBq.js +1 -0
- package/apps/web/dist/assets/requirementDiagram-EQYSW5RA-BEItNirc.js +84 -0
- package/apps/web/dist/assets/rolldown-runtime-COiEfhsA.js +1 -0
- package/apps/web/dist/assets/rotate-ccw-Bm0ESNH_.js +1 -0
- package/apps/web/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/apps/web/dist/assets/ru-D-WIMI5_.js +1 -0
- package/apps/web/dist/assets/ru-DOaRp00B.js +1 -0
- package/apps/web/dist/assets/ru-RU-DrXnqZbV.js +1 -0
- package/apps/web/dist/assets/sankeyDiagram-SY24JHO5-DluLIjax.js +40 -0
- package/apps/web/dist/assets/search-D94SZwrC.js +85 -0
- package/apps/web/dist/assets/send-D9TETK3s.js +1 -0
- package/apps/web/dist/assets/sequenceDiagram-BLEHNHQU-ZcwnsNxK.js +169 -0
- package/apps/web/dist/assets/settings-Dd9EVxua.js +1 -0
- package/apps/web/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/apps/web/dist/assets/sliders-horizontal-CiZZFL1o.js +1 -0
- package/apps/web/dist/assets/src-BWF7jMDv.js +1 -0
- package/apps/web/dist/assets/stateDiagram-v2-A6IESKWZ-DD6vH3rM.js +291 -0
- package/apps/web/dist/assets/swimlanes-B3ENRKFQ-pX_qyO4X.js +1 -0
- package/apps/web/dist/assets/swimlanesDiagram-AYQRV4AK-CeVsLUsg.js +8 -0
- package/apps/web/dist/assets/switch-B_u8n1o6.js +1 -0
- package/apps/web/dist/assets/table-DcCLC7fi.js +1 -0
- package/apps/web/dist/assets/ticket-detail-C9egaVSf.js +2 -0
- package/apps/web/dist/assets/ticket-editor-UlSZgpWF.js +2 -0
- package/apps/web/dist/assets/ticket-list-controls-BidzkMOD.js +1 -0
- package/apps/web/dist/assets/ticket-list-vNBO8mkk.js +5 -0
- package/apps/web/dist/assets/timeline-definition-JXYKGLGB-DLBjAxKq.js +120 -0
- package/apps/web/dist/assets/toDate-B3HYxwtT.js +1 -0
- package/apps/web/dist/assets/tr-BZEJyY-F.js +1 -0
- package/apps/web/dist/assets/tr-DeYojjLx.js +1 -0
- package/apps/web/dist/assets/tr-TR-B92YeFW5.js +1 -0
- package/apps/web/dist/assets/treeView-D4JQ5SDB-DsJ44q_q.js +1 -0
- package/apps/web/dist/assets/treemap-SAJKECNS-BmXoBpVL.js +1 -0
- package/apps/web/dist/assets/uk-DACrfgCP.js +1 -0
- package/apps/web/dist/assets/uk-UA-DmcDs-MU.js +1 -0
- package/apps/web/dist/assets/uk-f8X5q5C_.js +1 -0
- package/apps/web/dist/assets/upload-CGpna3R2.js +1 -0
- package/apps/web/dist/assets/useQueries-BihXYrAP.js +1 -0
- package/apps/web/dist/assets/usecaseDiagram-WPOLBNNX-eU2tr2si.js +328 -0
- package/apps/web/dist/assets/vennDiagram-Q3Y3IDCW-CoVTWGNg.js +34 -0
- package/apps/web/dist/assets/vi-CSGaJFlR.js +1 -0
- package/apps/web/dist/assets/vi-CkqUo4w_.js +1 -0
- package/apps/web/dist/assets/vi-VN-DM5JYNuP.js +1 -0
- package/apps/web/dist/assets/visual-editor-B6HiiLMg.js +208 -0
- package/apps/web/dist/assets/wardley-7MLQ67FV-C6mVVk98.js +1 -0
- package/apps/web/dist/assets/wardleyDiagram-IVAIDKWA-m3CeJc7a.js +78 -0
- package/apps/web/dist/assets/workspace-settings-CSvVqaL6.js +1 -0
- package/apps/web/dist/assets/xychartDiagram-4URNHDU4-Dw6fLaip.js +7 -0
- package/apps/web/dist/assets/zh-CN-BdRcimyZ.js +1 -0
- package/apps/web/dist/assets/zh-CN-CC7ghLLr.js +1 -0
- package/apps/web/dist/assets/zh-CN-CiUOkQXe.js +1 -0
- package/apps/web/dist/assets/zh-TW-BH1L9tOt.js +1 -0
- package/apps/web/dist/assets/zh-TW-BlIRMi1C.js +1 -0
- package/apps/web/dist/assets/zh-TW-Co4UBSfi.js +1 -0
- package/apps/web/dist/index.html +14 -8
- package/compose.yml +12 -0
- package/docs/api.md +285 -10
- package/docs/infrastructure.md +2 -2
- package/docs/localization.md +25 -0
- package/docs/plan.md +28 -10
- package/docs/self-hosting.md +44 -5
- package/docs/themes.md +1 -1
- package/docs/verification.md +1 -1
- package/docs/workspaces.md +18 -2
- package/package.json +12 -8
- package/apps/server/dist/auth-I1XEr0d4.mjs +0 -2931
- package/apps/web/dist/assets/CompositeItem-BWPJ5Mol.js +0 -1
- package/apps/web/dist/assets/admin-settings-DZqkC6ju.js +0 -1
- package/apps/web/dist/assets/date-picker-6H_iwP6t.js +0 -1
- package/apps/web/dist/assets/ellipsis-BT172Uvu.js +0 -1
- package/apps/web/dist/assets/forms-B4Vg3CPz.js +0 -92
- package/apps/web/dist/assets/image-edit-trigger-DGmVtWLC.js +0 -1
- package/apps/web/dist/assets/index-C_a21taR.js +0 -50
- package/apps/web/dist/assets/index-DM-D_3Sv.css +0 -2
- package/apps/web/dist/assets/kbd-tTQGZxaK.js +0 -1
- package/apps/web/dist/assets/link-DoIP9KJ6.js +0 -2
- package/apps/web/dist/assets/list-order-Bzsrihyr.js +0 -1
- package/apps/web/dist/assets/lucide-U995NW8E.js +0 -1
- package/apps/web/dist/assets/member-directory-BiCE8DRq.js +0 -1
- package/apps/web/dist/assets/popover-BhzZ0U1d.js +0 -1
- package/apps/web/dist/assets/project-label-provider-9L7Uts3_.js +0 -1
- package/apps/web/dist/assets/project-settings-Q7Q9Reec.js +0 -1
- package/apps/web/dist/assets/rotate-ccw-sjyLwLLy.js +0 -1
- package/apps/web/dist/assets/settings-B6qD6q1J.js +0 -1
- package/apps/web/dist/assets/settings-layout-B-AwUIMu.js +0 -1
- package/apps/web/dist/assets/switch-D0nFHj6l.js +0 -1
- package/apps/web/dist/assets/table-CYZiwo9g.js +0 -1
- package/apps/web/dist/assets/ticket-detail-B-3vbHSB.js +0 -2
- package/apps/web/dist/assets/ticket-editor-C3d8TZIT.js +0 -1
- package/apps/web/dist/assets/ticket-list-LtGGOnwN.js +0 -5
- package/apps/web/dist/assets/ticket-tags-2rdmkcJV.js +0 -1
- package/apps/web/dist/assets/upload-DXPRzAaa.js +0 -1
- package/apps/web/dist/assets/useNavigate-DFohfUD3.js +0 -1
- package/apps/web/dist/assets/workspace-settings-D3tCm1vH.js +0 -1
package/docs/api.md
CHANGED
|
@@ -8,7 +8,7 @@ Runnable MCP and durable realtime examples, plus the worker ownership sequence,
|
|
|
8
8
|
|
|
9
9
|
Browser and native clients use Better Auth sessions with local login or configured OIDC. Public registration is closed. Initial owner setup requires `KANVIL_SETUP_TOKEN` and is available only before any account exists.
|
|
10
10
|
|
|
11
|
-
Unattended consumers use revocable, project-scoped API credentials in `Authorization: Bearer <key>`. Create credentials through an authenticated account at `POST /api/v1/credentials`, with a name, project IDs,
|
|
11
|
+
Unattended consumers use revocable, project-scoped API credentials in `Authorization: Bearer <key>`. Create credentials through an authenticated account at `POST /api/v1/credentials`, with a name, project IDs, `read`, `write`, `claim`, or `admin` scopes, and an optional `expiresIn` in seconds (one hour to one year). An omitted `expiresIn` expires the key after 90 days; `null` creates a key that never expires. A scope never grants more access than the owning account has. The secret is returned only on creation. Delete a credential at `/api/v1/credentials/{id}` to revoke it.
|
|
12
12
|
|
|
13
13
|
## Writes and ownership
|
|
14
14
|
|
|
@@ -22,31 +22,103 @@ Claims require an eligible ticket and current version. Successful claiming moves
|
|
|
22
22
|
|
|
23
23
|
Evidence records identify the submitting account and supplied commit. A reported passing outcome is an assertion by that identity, not independent verification by Kanvil.
|
|
24
24
|
|
|
25
|
+
Links are editable references on a ticket, kept apart from append-only evidence. `add_resource` (`POST /api/v1/projects/{projectId}/tickets/{ticketId}/resources`) takes an HTTP or HTTPS `url` and an optional `title` (up to 200 characters); `update_resource` (`PATCH /api/v1/projects/{projectId}/resources/{resourceId}`) changes either at the link's current `expectedVersion`, and `delete_resource` (`DELETE`, same path) removes it at that version. `list_resources` returns them oldest first. Writes require `write`, follow the same machine claim rules as evidence (pass `claimId`), accept `requestId`, and publish `resource.added`, `resource.updated`, or `resource.removed` with the `resourceId`. `provider` names a recognized host (`GitHub`, `GitLab`, `Bitbucket`) or is `null`. `remoteKind` (`branch`, `pull_request`, or `issue`), `remoteState`, and `remoteCheckedAt` are written only by a [repository connection](#repository-connections) and stay `null` otherwise, which clients show as unknown; changing the URL clears all three.
|
|
26
|
+
|
|
25
27
|
Attachments use `upload_attachment` or the corresponding HTTP POST with `name`, `contentType`, and base64 `data`, up to 10 MB. Bytes and metadata commit together in PostgreSQL. Download content at `/api/v1/projects/{projectId}/attachments/{id}/content` using the same authorization. Filenames are metadata, never filesystem paths. Deleting an attachment deletes its bytes transactionally.
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
Files embedded in a ticket that does not exist yet are staged first. `stage_upload` (`POST /api/v1/projects/{projectId}/uploads`) takes the same `name`, `contentType`, and base64 `data` and returns `{id, projectId, actorId, name, contentType, size, createdAt, expiresAt}` without publishing an event. Until it is claimed, only its uploader can read it, at the same `/attachments/{id}/content` address it keeps afterwards, so Markdown that embeds it never needs rewriting. `create_ticket` accepts `uploads`, a list of up to 50 staged IDs; in the same transaction they become the ticket's attachments with unchanged IDs and one `attachment.created` event each after `ticket.created`. If any ID is missing, expired, staged by someone else, or staged in another project, nothing is created and the request fails with `not_found`, whose `details.uploads` lists the unusable IDs. Retrying with the same `requestId` returns the created ticket without claiming twice. `discard_upload` (`DELETE /api/v1/projects/{projectId}/uploads/{uploadId}`) deletes one of your unclaimed uploads with its bytes. Unclaimed uploads expire after 24 hours, and the storage cleanup job removes them and their bytes. Both operations require `write`.
|
|
30
|
+
|
|
31
|
+
Tickets carry optional `startAt` and `dueAt` ISO timestamps. Both name whole days: clients store a start as the first instant of the chosen day and a due date as its last instant, each in the author's time zone, so a ticket that starts and is due on the same day is valid. A start after the due date fails with `400 validation_error` (path `startAt`) on create, update, bulk update, and duplication, checked against the dates the write leaves on the ticket. Clients reading a server that predates start dates get `startAt: null`. `list_tickets` takes `scheduledFrom` and `scheduledTo` (ISO timestamps) to return tickets whose span, from the start (or due) date to the due (or start) date, overlaps `[scheduledFrom, scheduledTo)`; either bound alone also leaves out undated tickets. `sort: "start"` orders by that span's first day. `list_tickets` accepts `due` as `overdue` (past due), `today` (due within the next 24 hours), `week` (due within the next 7 days), `scheduled`, or `none`; the first three apply to open tickets only and use the database clock and `sort: "due"` for soonest first with undated tickets last. Other sorts: `priority` (default), `updated`, `created`, `title`, `labels`, `number`, `status`, `difficulty`, `kind`, and `assignee`. Each has a natural direction (newest first for `updated` and `created`, ascending otherwise); pass `order: "asc"` or `"desc"` to override it. Ties break by ticket number, and null values sort last in both directions. `manual` follows the project's shared manual order (see below). The `assigneeId` filter takes a user id or `none` for unassigned tickets.
|
|
32
|
+
|
|
33
|
+
The manual order is one server-kept order per project, shared by every list, board, and planning view; a status section shows it filtered. `move_ticket` (`POST /api/v1/projects/{projectId}/tickets/{ticketId}/move`) takes the ticket's `expectedVersion` and places it directly `before` or `after` one other ticket of the project (an ID or key; name one, not both), optionally setting `status`, `stateId`, or both in the same write, resolved like a ticket update's (see Workflow states); a status or state alone changes it and keeps the place. Clients never send positions: only the moved ticket changes place, so a move made in a filtered view leaves hidden tickets in their relative order, and an anchor that has moved since the caller read it still places the ticket beside it. A deleted or foreign anchor is a `404 not_found` and nothing moves. The write needs `write`, follows the machine claim rules of `update_ticket` (pass `claimId`), accepts `requestId`, bumps the ticket's version, and publishes `ticket.updated` with `changedFields` naming `position` and, when the state changed, `status` and `stateId`. A reorder alone notifies no follower. New tickets join the end of the order; existing tickets started in creation order. `move_project` (`POST /api/v1/projects/{projectId}/move`) places a project `before` or `after` another project of the same workspace that the caller can read, with the project's `expectedVersion`; it needs project administration and publishes `project.updated`. `list_projects` returns projects in that order, which started alphabetical.
|
|
34
|
+
|
|
35
|
+
The property filters `status`, `priority`, `kind`, `difficulty`, `label`, `assigneeId`, and `customField` (see [Custom fields](#custom-fields)) each take one value or a list and match tickets with any of them. Each has a `Not` twin (`statusNot`, `labelNot`, `assigneeIdNot`, and so on) that excludes the listed values; excluding a value keeps tickets where the field is unset. Over HTTP a list is a repeated query parameter, such as `?status=todo&status=in_progress&labelNot=blocked`; JSON and MCP callers pass an array. Filters combine with AND. A list holds at most 25 values, and a value in both a filter and its `Not` twin is a `400 validation_error`. Saved views use the same schema, so views saved with single values keep their meaning. New filterable fields join this catalog in the contracts package and get both operators. `list_projects` returns `openTickets` and `overdueTickets` per project (unarchived tickets not done or cancelled; overdue means a due date in the past), `closedTickets` (unarchived tickets done or cancelled), `nextDueAt` (the soonest upcoming due date of an open ticket, or `null`), and the caller's effective `role` there (`reader`, `writer`, or `admin`; always `admin` for instance administrators), with `roleId` and `capabilities` as described in [Roles and permissions](#roles-and-permissions).
|
|
36
|
+
|
|
37
|
+
`list_my_work` (`GET /api/v1/my-work`) pages through the open, unarchived tickets assigned to the caller across every project they can read, optionally narrowed to one `workspaceId`. It accepts `q`, `sort`, and `order` like `list_tickets`, plus `limit` and `offset`; `total` counts every match so clients can fetch the remaining pages. Set `activity` (up to 50) to also receive that many of the most recent events from the same accessible projects, newest first. Revoked memberships and project-bound credentials narrow the tickets, totals, and activity alike.
|
|
28
38
|
|
|
29
39
|
`search_tickets` (`GET /api/v1/tickets/search?q=...`) finds unarchived tickets by exact key or text across every project the caller can read, optionally limited to one `workspaceId`, returning each ticket with its project `id`, `key`, and `name`. Exact key matches sort first.
|
|
30
40
|
|
|
31
|
-
|
|
41
|
+
`search_workspace` (`GET /api/v1/search?q=...`) searches projects, unarchived tickets, comments on unarchived tickets, and activity across every project the caller can read, optionally within one `workspaceId`. Each result has a `type` (`project`, `ticket`, `comment`, or `activity`), a stable `id` (the project, ticket, or comment ID, or the event ID), its `project`, its `ticket` (`id`, `key`, `title`, `status`, or `null`), a display `title`, an `excerpt` around the first literal match, `actorId`, and `at`. Activity matches its event label, its ticket key, or the name of an active (not disabled) actor. Results order by exact key match, then type in the order above, then most recent first. `limit` (1–50, default 20) and `offset` (up to 1000) page through them; `type` narrows the page to one kind while `counts` still reports every kind and `total` counts the page's kind. The property filters above restrict results to matching tickets and their comments and activity, and leave out projects. Ticket text uses the external index when one is configured and current (`engine: "index"`); otherwise PostgreSQL answers (`engine: "postgres"`), and `indexing: true` says an index is configured but behind or unavailable. Projects, comments, and activity always come from PostgreSQL, and every result is filtered by current project access before counts and excerpts are computed.
|
|
42
|
+
|
|
43
|
+
Use `list_tickets` with `parentId` set to a parent UUID or ticket key to page through child tickets. Set `archived: "include"` to include archived children. Set `relatedTo` to a ticket UUID or key to page through the tickets linked to it by any relationship, in either direction. `list_relations` also returns the current source/target keys and full titles, so consumers can show readable dependency context without separate lookups.
|
|
44
|
+
|
|
45
|
+
`duplicate_ticket` (`POST /api/v1/projects/{projectId}/tickets/{ticketId}/duplicate`) creates a new ticket in the same project with the next key, the original title followed by ` (copy)` (shortened to fit 500 characters), and the caller as `createdBy`. It copies the description, acceptance criteria, priority, kind, difficulty, due date, labels, scopes, and parent, and the assignee while they are still a project member. The copy starts in To do, unarchived, with `automationReady` false and no hold reason; the original's claims, comments, evidence, attachments, relationships, and children are not copied. A `clones` relationship links the copy to the original. One transaction writes the ticket, the relationship, a `ticket.created` event carrying `duplicateOf` (the original ID), and `relation.added` events for both tickets. It requires `write`; reuse `requestId` to retry without creating a second copy.
|
|
32
46
|
|
|
33
|
-
`
|
|
47
|
+
`bulk_update_tickets` (`POST /api/v1/projects/{projectId}/tickets/bulk`) applies one set of `changes` to up to 100 tickets, and `bulk_delete_tickets` (`POST /api/v1/projects/{projectId}/tickets/bulk-delete`) deletes up to 100. Both take `tickets` as `{ ticketId, expectedVersion }` pairs (UUID or key) and are all or nothing: one missing ticket, stale version, or (for deletion) active claim rejects the whole request with `not_found`, `version_conflict`, or `claim_conflict` (its `details.ticketId` names the claimed ticket) before anything changes. Naming one ticket twice, for example by key and by UUID, is a `validation_error`. Deletion requires `admin`, like `delete_ticket`; children outside the selection lose their parent with a `ticket.updated` event, followed by one `ticket.deleted` event per ticket in the same transaction. Reuse `requestId` to retry either write.
|
|
34
48
|
|
|
35
|
-
`
|
|
49
|
+
`list_ticket_labels` (`GET /api/v1/projects/{projectId}/labels`) returns distinct label names and ticket counts for the whole authorized project. Its `archived` option matches ticket listing. The `label` ticket filter matches exact labels; combine it with other filters or saved views. `sort: "labels"` orders by the first label alphabetically, ignoring case and label-array order, followed by ticket number. Tickets without labels come last.
|
|
50
|
+
|
|
51
|
+
`list_project_labels` (`GET /api/v1/projects/{projectId}/labels/catalog`) returns the labels the project's tickets can use, with stable IDs, names, colors, versions, and this project's usage counts including archived tickets. Each entry has a `scope`: `project` entries carry `projectId` and belong to the project; `workspace` entries carry `workspaceId` and are shared by every project in the workspace. Unused definitions remain available. Existing ticket label names are backfilled during upgrade as project labels; new freeform labels from ticket writes register automatically as project labels with gray as their color, unless the workspace already defines the name. Ticket payloads continue to use label-name arrays.
|
|
36
52
|
|
|
37
53
|
Project administrators can `create_project_label` (`POST .../labels`), `update_project_label` (`PATCH .../labels/{labelId}`), and `delete_project_label` (`DELETE .../labels/{labelId}`) through HTTP or MCP. Creation takes `name` and optional `color`; updates take `name`, `color`, and `expectedVersion`; deletion takes `expectedVersion`. Renames update every affected ticket, including archived tickets, and saved-view label filters. Deletion removes the label from tickets and removes its filter from saved views, retaining other filters. These changes commit atomically, preserve ticket write/claim checks, increment affected ticket versions, and emit normal `ticket.updated` events plus `label.created`, `label.updated`, or `label.deleted`. Recoloring emits a label event without changing ticket versions. Realtime consumers can refresh the label catalog on label events.
|
|
38
54
|
|
|
55
|
+
Workspace administrators manage shared labels with `list_workspace_labels` (`GET /api/v1/workspaces/{workspaceId}/labels`), `create_workspace_label` (`POST .../labels`), `update_workspace_label` (`PATCH .../labels/{labelId}`), and `delete_workspace_label` (`DELETE .../labels/{labelId}`); any workspace member can list them. Inputs match the project operations. List counts include only tickets in projects the caller can read, so private projects never show up in them. A rename or deletion updates tickets, saved-view filters, and calendar feed filters in every project of the workspace in one transaction with the same version and claim checks: one claimed ticket or failed check rolls the whole change back. The label event goes to each project of the workspace with `scope: "workspace"` and `workspaceId`.
|
|
56
|
+
|
|
57
|
+
Within a workspace a name belongs to the workspace or to projects, never both, so a ticket label always resolves to one color. Creating or renaming a project label onto a workspace label's name fails with `409 duplicate_label`. Creating or renaming a workspace label onto a name that projects already define fails with `409 label_conflict`, whose `details.projects` lists their keys. Retry with `adopt: true` to replace those project definitions: their tickets keep the label and show the workspace color, and a ticket that carries both names after an adopting rename keeps one.
|
|
58
|
+
|
|
39
59
|
Label colors are a preset name (`default`, `red`, `orange`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`, `gray`) or a custom `#rrggbb` hex value. Saving a label with a hex value adds it to the site-wide custom palette; `list_label_colors` (`GET /api/v1/label-colors`) returns that palette newest first and `remove_label_color` (`POST /api/v1/label-colors/remove`) drops an entry without recoloring the labels that use it; only an instance administrator or the person who added the color may remove it, and the removal publishes `label.color_removed` to every project the caller can write. Saving a label with a color it already had does not re-add a removed color.
|
|
40
60
|
|
|
61
|
+
### Ticket keys, prefix changes, and moves
|
|
62
|
+
|
|
63
|
+
A key such as `ABC-12` is the project prefix and the ticket's number in that project. Numbers only grow, and every prefix a project has had stays reserved for it, so no key is ever issued twice. Every ID-or-key parameter (`projectId`, `ticketId`, neighbors, the event feed's `projectId`) resolves through one server-side resolver that accepts an ID, the current key, or any earlier key. The resolver only names a record: access, project-bound credentials, versions, and claims are checked exactly as for the ID, and an unknown or unreachable record reads as `404 not_found`.
|
|
64
|
+
|
|
65
|
+
Prefixes were permanent before this release; they are now editable and permanently reserved instead. A project administrator changes one with `update_project` and a new `key` (same format as at creation) at the project's `expectedVersion`. Every ticket of the project takes the new prefix with its number unchanged, new tickets keep counting, and the old prefix and every key issued under it keep resolving, so existing links and stored keys keep working. Ticket versions do not change. A prefix that any other project has or had fails with `409 duplicate_prefix`; a project can return to one of its own. The write publishes `project.updated` with `key` and `previousKey`, on which a configured external search index reindexes every ticket of the project. Clients that cache keys should refresh project tickets on that event.
|
|
66
|
+
|
|
67
|
+
`check_ticket_relocation` (`GET /api/v1/projects/{projectId}/tickets/{ticketId}/relocation?destinationProjectId=...`) previews a move to another project of the same workspace. It returns the `destination` (`id`, `key`, `name`), its workflow `states`, the default `stateId` (the destination's first state of the ticket's current status), and `blockers`. Each blocker has a `code` (`claimed`, `parent`, `children`, `relations`, `assignee`, `custom_fields`, or `required_fields`), a `message`, and for hierarchy and relationship blockers the related `ticketId`. Kanvil never severs a claim, a parent link, children, or relationships to make a move succeed, never drops an assignee who cannot access the destination, and never discards custom field values, which belong to the source project's fields; resolve them first. A destination field that is required and has no default also blocks the move, since the moved ticket takes the destination's custom field defaults.
|
|
68
|
+
|
|
69
|
+
`relocate_ticket` (`POST /api/v1/projects/{projectId}/tickets/{ticketId}/relocate`) moves the ticket with its `expectedVersion`, `destinationProjectId` (ID or key), and optionally `status`, `stateId`, or both, resolved in the destination like a ticket update's. Both projects require `write`, so a project-bound credential can move tickets only between projects it is bound to; a destination the caller cannot reach reads as `404 not_found`, an archived destination is a `409 project_archived`, and a different workspace or the same project is a `400 validation_error`. An archived source refuses the move like any other write. Any blocker fails the move with `409 relocation_blocked` and `details.blockers`. Machine callers follow the claim rules of `update_ticket`. The ticket keeps its ID, content, comments, attachments, evidence, links, and followers, takes the destination's next number as its new key, bumps its version, and its labels register in the destination. Every key it had in the source, under each prefix the source has used, keeps resolving to it in the destination and through `resolve_ticket`, never in the source. Retrying with the same `requestId` returns the moved ticket only while the caller can still write to both projects. Files embedded before the move keep loading from their original `/attachments/{id}/content` address, authorized in the ticket's current project.
|
|
70
|
+
|
|
71
|
+
One transaction publishes `ticket.moved_away` to the source with `ticketId: null` and `data` holding only the ticket's `ticketId`, old `key`, and `title`, so source readers drop it without learning the destination (the source's outbound destinations receive it as an update), then `ticket.moved` to the destination with the ticket's ID, `from` (the old key), the new `key`, and `version`. Followers who can read the destination are notified once. Activity recorded before the move stays with the source project and no longer refers to the ticket there: search results for it carry `ticket: null`, ticket filters skip it, and queued source notifications treat the ticket as gone.
|
|
72
|
+
|
|
73
|
+
`resolve_ticket` (`GET /api/v1/tickets/resolve?ticketId=...`) finds a ticket by ID, current key, or earlier key across every project the caller can read, archived tickets included, and returns `{ ticket, project }` with the project's `id`, `key`, and `name`. Clients use it to follow a link whose project no longer holds the ticket. `search_tickets` and `search_workspace` also match earlier keys.
|
|
74
|
+
|
|
75
|
+
### Workflow states
|
|
76
|
+
|
|
77
|
+
Each project has named workflow states. Every state maps to one of the six canonical statuses (`backlog`, `todo`, `in_progress`, `in_review`, `done`, `cancelled`), its category. A ticket stores one state, `stateId`. Its `status` is that state's category, kept equal by the database, so every rule that reads `status` reads the category: open and closed counts (`done` and `cancelled` close work), readiness (a ticket must be in a To do state), finished dependencies, filters, `sort: "status"` (category, then the state's place), claims, notifications, and calendar feeds. New projects and projects upgraded from earlier versions start with one state per category, whose `slug` is the category name.
|
|
78
|
+
|
|
79
|
+
`list_workflow_states` (`GET /api/v1/projects/{projectId}/workflow`) returns the states in display order (by category, then `position`) with `id`, `slug`, `name`, `category`, `color` (a label color; `default` uses the category's tint), `position`, `version`, and `count` (tickets in the state, archived included). Project administrators `create_workflow_state` (`POST …/workflow`) with `name`, `category`, and optional `color`; the state joins the end of its category, and its `slug` comes from the first name and never changes. `update_workflow_state` (`PATCH …/workflow/{stateId}`) takes `expectedVersion` and any of `name`, `color`, `category`, and `position` (the state's new place within its category). A category change moves every ticket in the state to that status, increments their versions, checks machine claims, and publishes `ticket.updated` with `changedFields: ["status"]` for each. `delete_workflow_state` (`DELETE …/workflow/{stateId}`) takes `expectedVersion` and `reassignTo`, another state of the project; its tickets, archived included, move there through the regular ticket update before the state is removed. State names are unique within a project ignoring case (`409 duplicate_state`), and every category keeps at least one state: recategorizing or deleting its last one returns `409 last_state`. A project holds at most 50 states. These writes publish `workflow.created`, `workflow.updated`, or `workflow.deleted` with the `stateId`.
|
|
80
|
+
|
|
81
|
+
Ticket writes take `status`, `stateId`, or both. A `stateId` must name a state of the ticket's project, and a `status` written with it must equal that state's category; otherwise the write fails with `400 validation_error` instead of choosing a state. A `status` alone keeps the ticket's state when the category is unchanged and otherwise moves it to that category's first state, so clients that predate workflows keep working. Creation without either starts in the first To do state, duplication copies into it, and claiming moves the ticket to the first In progress state. A change to either field reports both `status` and `stateId` in `changedFields`. `list_tickets` takes `stateId` to list one state's tickets; the `status` filter matches categories. Clients reading a server that predates workflows get `stateId: null`. Clients built from this contract ignore ticket fields they do not know, so later additions do not break them.
|
|
82
|
+
|
|
41
83
|
`add_relation` accepts `blocks`, `related`, `duplicate`, `causes`, `clones`, `implements`, or `reviews`. `direction` defaults to `outgoing` for existing clients. Use `incoming` to express the inverse from the current ticket: for example, `{ticketId: "<current>", targetId: "<blocker>", type: "blocks", direction: "incoming"}` means the current ticket is blocked by the selected ticket. The server stores a single canonical edge, checks project access on both tickets, and rejects dependency cycles in either direction. Related links are symmetric. Only blocking dependencies add readiness blockers; duplicate sources remain ineligible for agent work. Other link types are informational. Parent/child hierarchy continues to use `parentId` rather than a second relationship edge.
|
|
42
84
|
|
|
85
|
+
### Custom fields
|
|
86
|
+
|
|
87
|
+
A project defines typed ticket fields; tickets carry their values in `customFields`, an object keyed by field ID. `list_custom_fields` (`GET /api/v1/projects/{projectId}/custom-fields`) returns the definitions in display order, each with `id`, `name`, `type`, `options`, `required`, `defaultValue`, `version`, and `count` (tickets holding a value, archived included). Types and their values: `text` (up to 1,000 characters, trimmed), `number`, `date` (a calendar day as `YYYY-MM-DD`, the same in every time zone), `checkbox` (`true`), `select` (one option ID), and `multiselect` (a list of option IDs). An empty value is no value: blank text, an unchecked box, and an empty option list are stored as absent.
|
|
88
|
+
|
|
89
|
+
Project administrators manage definitions: `create_custom_field` (`POST .../custom-fields`) takes `name`, `type`, optional `options` (select types only, each `{ id?, name, color }` with a label color), `required`, and `defaultValue`; `update_custom_field` (`PATCH .../custom-fields/{fieldId}`) takes `expectedVersion` and any of `name`, the complete `options` list in display order, `required`, and `defaultValue` (`null` clears it; omitting it keeps it). The type never changes. Option IDs stay stable through renames and reorders; a new option may bring its own UUID. Removing an option that tickets or the default still use fails with `400 validation_error` (path `reassign.<option id>`, naming how many tickets use it) unless `reassign` maps it to a kept option or to `null`, which clears it; the affected tickets, the default, and saved-view filters then move in the same transaction. `move_custom_field` (`POST .../custom-fields/{fieldId}/move`) places a field `before` or `after` another, and `delete_custom_field` (`DELETE .../custom-fields/{fieldId}`) removes the field, its values, and its saved-view filters. Names are unique per project, ignoring case (`409 duplicate_field`); a project holds at most 50 fields and a field 100 options. A checkbox cannot be required. Each write checks `expectedVersion` (`409 version_conflict`), accepts `requestId`, and publishes `custom_field.created`, `custom_field.updated`, or `custom_field.deleted`; tickets whose values change get `ticket.updated` events with `changedFields: ["customFields"]`, and machine credentials still need the ticket's claim.
|
|
90
|
+
|
|
91
|
+
`create_ticket` takes `customFields`: an omitted field takes its default, and a required field must end with a value. `update_ticket` and `bulk_update_tickets` take `changes.customFields`, which sets only the named fields and leaves the others alone, so a bulk edit of one field keeps each ticket's other values; `null` clears a field, except a required one. Values are checked against the ticket's own project: a field of another project, a value of the wrong type, or a removed option is a `400 validation_error` with the path `customFields.<field id>`. Required applies to new tickets and to edits of that field; tickets created before a field became required keep no value until someone sets one. `duplicate_ticket` copies the values.
|
|
92
|
+
|
|
93
|
+
The `customField` filter (and `customFieldNot`) takes `<field id>:<value>`, where the value is an option ID, `true` for a checked box, or `none` for no value, such as `?customField=<severity>:<high>&customFieldNot=<blocked>:true`. Values of one field match any of them, and different fields must all match. `list_tickets`, `search_workspace`, and saved views share it; a field the caller cannot read, or a value its type cannot hold, is a `400 validation_error`.
|
|
94
|
+
|
|
95
|
+
### Calendar feeds
|
|
96
|
+
|
|
97
|
+
Project administrators share a project's schedule as an iCalendar subscription. `create_calendar_feed` (`POST /api/v1/projects/{projectId}/calendar-feeds`) takes a `name`, optional `labels` (only tickets with any of them; empty means every label), and an IANA `timeZone`, and returns the feed with a `token` and its `path`, `/api/calendar/{token}.ics`. The token appears only in that response: the server stores its SHA-256 hash, never the token, so the operation takes no `requestId` and a retry creates a second feed. `list_calendar_feeds` returns metadata only, with `active: false` once the creator can no longer read the project. `revoke_calendar_feed` (`DELETE …/calendar-feeds/{feedId}`) deletes the feed, and its link stops working on the next request. All three require project administration; creating and revoking publish `calendar_feed.created` and `calendar_feed.revoked` without the token.
|
|
98
|
+
|
|
99
|
+
`GET {path}` needs no session, so calendar apps can subscribe. **Anyone with the link can read the keys, titles, statuses, priorities, and dates of the tickets it shares**; revoke a link that leaked. It serves open, unarchived tickets of the project with a start or due date that match the feed's labels, at most 2,000, as all-day events from the start (or due) day through the due (or start) day in the feed's time zone, with links back to each ticket. Done, cancelled, and archived tickets leave the feed, and date changes appear on the next fetch. Unknown, malformed, and revoked tokens and feeds whose creator lost access all return `404`. Renaming a label updates feed filters; deleting one leaves it in the filter, where it matches nothing, so a deletion never widens what a feed shares.
|
|
100
|
+
|
|
101
|
+
### Public project links
|
|
102
|
+
|
|
103
|
+
Project administrators share a project read-only with anyone who has its link by sending `publicReadEnabled: true` to `update_project`, and stop sharing with `false`. Projects start, and existing projects stay, private; the flag is separate from `visibility` and membership. The change publishes `project.updated` with the new `publicReadEnabled`. The web link is `/public/projects/{projectId}`.
|
|
104
|
+
|
|
105
|
+
Three operations answer under `/api/public` without a session or credential, and ignore any that is sent:
|
|
106
|
+
|
|
107
|
+
| Operation | Route | Returns |
|
|
108
|
+
| --------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
109
|
+
| `get_public_project` | `GET /api/public/projects/{projectId}` | Name, key, description, icon, workflow states, and label colors |
|
|
110
|
+
| `list_public_tickets` | `GET /api/public/projects/{projectId}/tickets` | A page (`limit` up to 100, `offset`) of unarchived tickets, ordered by status, then number |
|
|
111
|
+
| `get_public_ticket` | `GET /api/public/projects/{projectId}/tickets/{ticketId}` | One unarchived ticket by ID or key, with its unarchived parent, children, and relationships within the project |
|
|
112
|
+
|
|
113
|
+
**Anyone with the link can read every unarchived ticket's key, title, description, acceptance criteria, status, priority, kind, difficulty, labels, scopes, start and due dates, and relationships within the project.** Each response is the public projection of the member read it reuses: people (assignees, authors, members, emails), comments, activity, attachments, evidence, claims, readiness, hold reasons, and versions are never included, and archived tickets do not exist publicly: a `parentId` naming one reads as `null`. The project ID must be its UUID. An unknown, unshared, or revoked project and an unknown or archived ticket all return `404 not_found`. Each request reads the sharing flag and the rows in one snapshot, and every public response, errors included, carries `Cache-Control: no-store`, so turning sharing off takes effect on the next request. Sharing grants nothing elsewhere: the `/api/v1` operations, search, realtime, attachment and image routes, and MCP still require their own access, and the public routes accept only `GET`.
|
|
114
|
+
|
|
43
115
|
## Realtime
|
|
44
116
|
|
|
45
117
|
`GET /api/v1/snapshot?projectId=<id>` returns a consistent project snapshot and event cursor. Connect to `/api/v1/events?projectId=<id>&cursor=<cursor>` to replay committed changes and continue receiving new ones.
|
|
46
118
|
|
|
47
119
|
The SSE feed sends `change` and `checkpoint` events with durable IDs. Persist checkpoints only after processing preceding changes. Delivery is at least once: handle duplicates. Reconnect with `cursor` or `Last-Event-ID`. A `410 snapshot_required` response means fetch a new snapshot. Consumers of the unfiltered feed can obtain `/api/v1/checkpoint`, then refresh their authorized queries before subscribing from that cursor. Without a cursor, the feed starts at the current position; wait for its first checkpoint before fetching initial query results.
|
|
48
120
|
|
|
49
|
-
Events are permission-filtered and contain ticket IDs, versions, and change metadata. Fetch authoritative records through the API. Membership, account, and credential access are rechecked while streaming. An SSE connection consumes one API request; internal frames and heartbeats do not consume more of the credential's rate allowance. Reverse proxies must preserve streaming and disable response buffering.
|
|
121
|
+
Events are permission-filtered and contain ticket IDs, versions, and change metadata. `actorId` is `null` for events the server raises on its own clock, such as due-date reminders. Fetch authoritative records through the API. Membership, account, and credential access are rechecked while streaming. An SSE connection consumes one API request; internal frames and heartbeats do not consume more of the credential's rate allowance. Reverse proxies must preserve streaming and disable response buffering.
|
|
50
122
|
|
|
51
123
|
The portable `@kanvil/client` package supplies validated calls, one safe network retry for registered operations using the original request ID, and a parsed SSE consumer. Its `subscribe` method accepts an abort signal, processing/checkpoint callbacks, connection-state updates, and an optional fresh-snapshot callback for expired cursors. Its `watch` helper coordinates initial query refresh and expired-cursor recovery for query caches. Its query/action options work with TanStack Query without importing React into the SDK. Native clients can set `credentials: "omit"` and supply their securely stored session cookie through the headers callback. Custom `request` calls do not retry automatically, because arbitrary endpoints may lack idempotency. Actions execute immediately and are not queued for offline submission.
|
|
52
124
|
|
|
@@ -54,12 +126,125 @@ The portable `@kanvil/client` package supplies validated calls, one safe network
|
|
|
54
126
|
|
|
55
127
|
Subscribe to a ticket with `set_subscription`. Inbox entries are created atomically with subscribed ticket events and filtered by current project access when read. Human edits do not notify their author; agent credentials do notify their subscribing owner. Event data includes the ticket key/title and server-recorded `source.kind` and `source.id` for the session account or credential.
|
|
56
128
|
|
|
57
|
-
Signed-in accounts can read and update `/api/v1/notifications/preferences`
|
|
129
|
+
Signed-in accounts can read and update `/api/v1/notifications/preferences` using GET/PUT. A preference reads as `{ "inApp": true, "push": false, "categories": {...}, "reminderLead": { "amount": 1, "unit": "days" } }`. Disabling Inbox notifications affects future entries; existing history is retained. Read the latest 100 entries at `/api/v1/notifications`; each has `id`, `readAt`, `createdAt`, `category`, and its `event`. POST `/api/v1/notifications/read` with `{ "ids": [...] }` marks up to 100 entries read; add `"read": false` to mark them unread. POST `/api/v1/notifications/dismiss` with `{ "ids": [...] }` removes entries from the Inbox; the notification and its event stay recorded, and dismissed entries are no longer listed. Both act only on the listed UUIDs the account owns, so a client sets the scope of "mark all" or "clear read" by sending the entries it shows. Credentials cannot change personal preferences, mark Inbox entries, or dismiss them.
|
|
130
|
+
|
|
131
|
+
`categories` holds one flag per category: `assignment` (the assignee changed), `status`, `comment`, `mention`, `update` (any other ticket activity), `due_soon`, and `overdue`. Every flag defaults to `true`, so accounts saved before categories existed keep receiving every subscribed update. A ticket edit that changes several properties belongs to each matching category; it is delivered when any of them is enabled and filed under the first enabled one in that order. Turning a category off also drops its queued push and channel notices. A PUT may send only the flags it changes; the rest keep their saved values, and an unknown category is a `400 validation_error`.
|
|
58
132
|
|
|
59
|
-
|
|
133
|
+
A comment mentions a member with a Markdown link to `mention:<user id>`, written `[@Name](mention:<user id>)` so the raw text still reads "@Name"; escape `[`, `]`, and `\` in the name with a backslash. `add_comment` and `update_comment` parse the body as Markdown on the server, so mentions inside code, escaped brackets, plain `@name` text, and links to other targets are ignored. The `comment.created` event carries the mentioned user IDs in `data.mentions`, once each; a `comment.updated` event carries only the IDs the edit added, so removing or keeping a mention notifies nobody. Each mentioned user who can currently read the project receives that event under `mention`, whether or not they follow the ticket, and once even when they also follow it. A follower who turned `mention` off still gets the event as a `comment`; anyone else who turned it off gets nothing. The author is never notified of their own mention, unknown IDs and users without project access are ignored without an error, and mentioning someone does not subscribe them. A retried request with the same `requestId` returns the recorded comment and notifies no one again.
|
|
134
|
+
|
|
135
|
+
A background job checks due dates every minute. Each follower gets one `due_soon` notice once the ticket is within their `reminderLead` (1–999 `minutes`, `hours`, `days`, or `weeks`) and one `overdue` notice when the due date passes. Notices honor the ticket's current due date, skip done, cancelled, and archived tickets, and recheck project access, subscription, and category when written and again before a push is sent. Changing the due date starts a fresh pair of notices for the new date. Due dates more than seven days past are skipped, so upgrades and outages do not flood Inboxes. Each notice is a `ticket.due_soon` or `ticket.overdue` event with `actorId: null`, `data.dueAt`, and `data.source` `{ "kind": "system", "id": "due-reminders" }`; it does not change the ticket version. Each run that reminds someone publishes its own event, so followers with different lead times each get a live update. The job runs under the write lock and records each notice at most once per follower, ticket, due date, and kind (the notification's unique reminder key), so restarts, concurrent servers, and retries cannot repeat it; a failed run is logged and retried on the next tick.
|
|
136
|
+
|
|
137
|
+
Omitting `push` from a preference update retains its current value. Explicitly unsubscribing cancels queued push notices for that ticket, except mentions. Ticket deletion preserves already queued notices, including the deletion event, while account and project access still apply.
|
|
60
138
|
|
|
61
139
|
When `/api/v1/info` reports `push: true`, account sessions can POST or DELETE `/api/v1/notifications/device` with `{ "token": "ExpoPushToken[...]" }`. Registration is tied to the current login session. Reassigning a device token to another account removes its previous account's pending deliveries. Push preferences apply to all registered devices; unregistering affects only the supplied device. Push payloads contain `server`, `projectId`, `ticketId`, and `notificationId`; native routing accepts only the currently connected server. Tokens and server-held Expo credentials are never returned by a listing endpoint.
|
|
62
140
|
|
|
141
|
+
## Notification channels and project destinations
|
|
142
|
+
|
|
143
|
+
Outbound delivery has two owners with separate recipient policies. **Account channels** carry one person's notifications, the same ones their Inbox receives, to email, [ntfy](https://ntfy.sh), Gotify, or a signed webhook. **Project destinations** carry a project's ticket activity to a signed webhook, Slack, Discord, Mattermost, or Telegram, whoever follows the tickets. Both start off. A channel sends nothing until a workspace route selects it, and a destination sends only the events it lists.
|
|
144
|
+
|
|
145
|
+
| Operation | HTTP | Access |
|
|
146
|
+
| ------------------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
147
|
+
| `list_notification_channels`, `create_notification_channel` | `GET`/`POST /api/v1/notification-channels` | The account's own signed-in sessions; credentials get `403 session_required` |
|
|
148
|
+
| `update_notification_channel`, `delete_notification_channel` | `PATCH`/`DELETE /api/v1/notification-channels/{channelId}` | Same |
|
|
149
|
+
| `test_notification_channel` | `POST /api/v1/notification-channels/{channelId}/test` | Same |
|
|
150
|
+
| `list_project_destinations`, `create_project_destination` | `GET`/`POST /api/v1/projects/{projectId}/destinations` | Project `admin` |
|
|
151
|
+
| `update_project_destination`, `delete_project_destination` | `PATCH`/`DELETE /api/v1/projects/{projectId}/destinations/{destinationId}` | Project `admin` |
|
|
152
|
+
| `test_project_destination` | `POST /api/v1/projects/{projectId}/destinations/{destinationId}/test` | Project `admin` |
|
|
153
|
+
|
|
154
|
+
`settings` is an object with a `kind` and that kind's fields:
|
|
155
|
+
|
|
156
|
+
| Kind | Fields (secret fields in bold) |
|
|
157
|
+
| ------------------ | ----------------------------------------------------------------------------------------- |
|
|
158
|
+
| `email` | `address`. Offered when `/api/v1/info` reports `email: true` (the server has `SMTP_URL`). |
|
|
159
|
+
| `ntfy` | `server` (default `https://ntfy.sh`), `topic`, optional **`token`** |
|
|
160
|
+
| `gotify` | `server`, **`token`** (an application token) |
|
|
161
|
+
| `webhook` | `url`, optional **`secret`** |
|
|
162
|
+
| `slack`, `discord` | **`url`** (the incoming webhook URL) |
|
|
163
|
+
| `mattermost` | **`url`**, optional `channel` |
|
|
164
|
+
| `telegram` | **`token`** (the bot token), `chatId` (numeric ID or `@channel`), optional `topicId` |
|
|
165
|
+
|
|
166
|
+
Endpoints are `http` or `https` URLs without credentials in them. Settings are stored encrypted under a key derived from `BETTER_AUTH_SECRET`. Responses never contain a secret field: `settings` holds the other fields, `secrets` names the secret fields that hold a value, and `endpoint` shows the host, address, or chat the target sends to. On update, `settings` keeps the saved kind; an omitted field keeps its saved value and an empty string clears an optional one, so a client can edit a channel without seeing its secrets. Updates take `expectedVersion` and answer a stale one with `409 version_conflict`. Changing `settings` resets health.
|
|
167
|
+
|
|
168
|
+
A channel's `routes` list one entry per workspace: `{ "workspaceId", "mode": "off" | "all" | "selected", "projectIds": [] }`. `selected` sends notifications from the listed projects only. `off`, and a workspace that is not listed, send nothing; `off` keeps `projectIds`, and `paused: true` keeps every setting. Personal category flags apply to channels as they do to the Inbox.
|
|
169
|
+
|
|
170
|
+
A destination's `events` choose the activity it carries: `created` (new tickets) and the notification categories `assignment`, `status`, `comment`, `update`, `due_soon`, and `overdue`. A ticket edit that matches several is sent once. Creating, changing, and removing a destination records `destination.created`, `destination.updated`, or `destination.deleted` project events with its ID, name, kind, and pause state, never its settings.
|
|
171
|
+
|
|
172
|
+
Every target reports `health`: `status` (`idle` before the first attempt, `ok`, or `failing`), the latest `error` code, `lastAttemptAt`, `lastSuccessAt`, and `pending` (queued sends). A test queues a sample message, sent even while paused, and its result shows in `health`.
|
|
173
|
+
|
|
174
|
+
**Delivery.** Every channel, destination, and push device is fed from one outbox. A row commits in the same transaction as its event, at most once per notification and channel or per event and destination. A background worker sends due rows every few seconds; rows are durable, so a restart resumes them. Before each send it rechecks that the target exists and is not paused. For a channel it also rechecks the account (active, still able to read the project, still following the ticket), category, route, and reminder freshness; for a destination, that the event belongs to its project and its kind is still enabled. A skipped row records why, such as `access_revoked`, `paused`, `route_disabled`, `category_disabled`, or `event_disabled`. Network errors, timeouts, `408`, `429`, and `5xx` retry with exponential backoff, up to five attempts. Other `4xx` responses, redirects (`redirect_blocked`, never followed), and blocked addresses end the row. Queued rows older than a day expire.
|
|
175
|
+
|
|
176
|
+
Outbound HTTP resolves each host itself and refuses loopback, private, link-local, shared, documentation, multicast, and NAT64 addresses (`blocked_address`). It connects to the address it checked, so DNS rebinding cannot redirect a send. Set `KANVIL_DELIVERY_ALLOW_PRIVATE=true` to reach services on a private network, such as a LAN ntfy. Email uses the operator's SMTP relay.
|
|
177
|
+
|
|
178
|
+
Webhooks receive `POST` JSON `{ "id", "type", "title", "text", "url", "project", "event" }` with headers `Kanvil-Delivery` (the outbox row ID; a retry or a crash after sending can repeat a delivery, so deduplicate on it), `Kanvil-Event`, and `Kanvil-Timestamp`. With a `secret`, `Kanvil-Signature` is `v1=` followed by the hex HMAC-SHA256 of `{timestamp}.{body}` under the secret. Compare it in constant time and reject old timestamps. Chat messages carry the ticket key, title, change, actor, and link. Mentions are neutralized, so ticket text cannot ping a channel.
|
|
179
|
+
|
|
180
|
+
The server answers `503 delivery_unavailable` when it was started without a delivery key (only embedded test domains do this), and `503 configuration_key_changed` when saved settings no longer decrypt because `BETTER_AUTH_SECRET` changed.
|
|
181
|
+
|
|
182
|
+
## Repository connections
|
|
183
|
+
|
|
184
|
+
A project administrator connects a GitHub, Gitea (or Forgejo), or GitLab repository so its signed webhooks attach verified links to the tickets they name and, optionally, move those tickets. Nothing is connected by default. Every provider shares the operations, lifecycle, health, link states, and transitions below; only the driver that checks signatures, reads payloads, and builds API requests differs.
|
|
185
|
+
|
|
186
|
+
| Operation | HTTP | Access |
|
|
187
|
+
| -------------------------------------------------------------- | ------------------------------------------------------------------------- | --------------- |
|
|
188
|
+
| `list_repository_connections`, `create_repository_connection` | `GET`/`POST /api/v1/projects/{projectId}/repositories` | Project `admin` |
|
|
189
|
+
| `update_repository_connection`, `delete_repository_connection` | `PATCH`/`DELETE /api/v1/projects/{projectId}/repositories/{connectionId}` | Project `admin` |
|
|
190
|
+
| `verify_repository_connection` | `POST /api/v1/projects/{projectId}/repositories/{connectionId}/verify` | Project `admin` |
|
|
191
|
+
|
|
192
|
+
`create_repository_connection` takes `provider` (`github` by default, `gitea`, or `gitlab`), `repository` (`owner/name`; GitLab paths may nest groups, such as `group/subgroup/project`), a **`token`** that can read the repository (and write issues and pull requests to comment), a **`webhookSecret`**, `commentOnLink`, `transitions`, and `paused`. Gitea and GitLab connections also take `baseUrl`, the instance's web address (required for Gitea; GitLab defaults to `https://gitlab.com`). It must be HTTP or HTTPS without credentials, a query, or a fragment; the host is lowercased and a default port and trailing slash are dropped, so one instance has one spelling for the uniqueness check. GitHub connections reject `baseUrl` and use `KANVIL_GITHUB_API_URL`. The outbox sends to the instance under the same address rules as destinations: loopback and private addresses are refused unless the operator sets `KANVIL_DELIVERY_ALLOW_PRIVATE`, and redirects are not followed. A path or URL that breaks its provider's rules fails with `400 validation_error` naming `repository` or `baseUrl`. Both secrets are write-only and stored encrypted like delivery settings; responses name the saved ones in `secrets`. On update an omitted secret keeps its saved value; a new `repository`, `baseUrl`, or `token` reconnects: the connection then acts as the administrator who saved it, and its access is checked again. The provider cannot change. Changing `repository` or `baseUrl` ends comments still queued for the old one (`repository_changed`). Connecting a repository already connected on the same provider and instance fails with `409 repository_connected`. Updates and disconnection take `expectedVersion`. Changes record `repository.connected`, `repository.updated`, or `repository.disconnected` project events without secrets.
|
|
193
|
+
|
|
194
|
+
The access check runs in the delivery outbox (GitHub `GET /repos/{owner}/{name}` with a bearer token, Gitea `GET {baseUrl}/api/v1/repos/{owner}/{name}` with `Authorization: token`, GitLab `GET {baseUrl}/api/v4/projects/{url-encoded path}` with `PRIVATE-TOKEN`, which accepts personal, group, and project access tokens), on creation, on reconnection, and on `verify_repository_connection`. Its result is the connection's `health`, with the same fields and retry rules as a destination: a revoked or under-scoped token reports `http_401` or `http_404` at once, while `5xx` responses retry. `lastEventAt` is when the latest webhook arrived, and `lastEventError` is `access_revoked` when the administrator who connected the repository can no longer write to the project or `repository_mismatch` when a delivery named another repository. A paused connection accepts webhooks without applying them. While the project is archived, deliveries apply nothing (`project_archived`) and administrators can only disconnect. Deleting the project removes its connections, their secrets, queued checks and comments, and their webhooks.
|
|
195
|
+
|
|
196
|
+
**Webhooks.** Every provider posts to the connection's `webhookUrl` (`POST /api/repository-hooks/{connectionId}`):
|
|
197
|
+
|
|
198
|
+
| Provider | Webhook settings | Authentication | Delivery ID |
|
|
199
|
+
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
200
|
+
| GitHub | Content type `application/json`, the secret, and _Branch or tag creation_, _Branch or tag deletion_, _Issues_, _Issue comments_, _Pull requests_ | `X-Hub-Signature-256`: `sha256=` and the HMAC-SHA256 of the raw body | `X-GitHub-Delivery` |
|
|
201
|
+
| Gitea | Content type `application/json`, the secret, and _Create_, _Delete_, _Issues_, _Issue Label_, _Issue Comment_, _Pull Request_ | `X-Gitea-Signature`: the hex HMAC-SHA256 of the raw body | `X-Gitea-Delivery` |
|
|
202
|
+
| GitLab | The secret token and _Push_, _Issues_, _Comments_, _Merge request_ events | `X-Gitlab-Token`: the secret itself, compared in constant time | `Idempotency-Key`, or `X-Gitlab-Event-UUID` on older versions |
|
|
203
|
+
|
|
204
|
+
A missing or wrong signature or token answers `401 invalid_signature`, an unreadable payload `400 invalid_request`, and an unknown or disconnected connection `404 not_found`. A handled delivery answers `202` with `{ "outcome", "tickets": [{ "key", "result" }] }` and records a `repository.received` project event (no actor; `data` holds the connection ID, the provider's event name, outcome, and `lastEventError`), so open clients refresh the connection's status. Its delivery ID is recorded in the same transaction as its effects and kept for 30 days, so a redelivery or concurrent copy answers `200` with `outcome: "duplicate"` and changes nothing.
|
|
205
|
+
|
|
206
|
+
A delivery names tickets by key (`KAN-12`, any case) in a branch name, a pull request's title or head branch, or an issue title. Keys resolve like any ticket identifier, so keys under the project's earlier prefixes and keys of tickets moved in from other projects still name their tickets; up to 20 key-shaped words per delivery are considered. A ticket already linked to the delivery's URL is always included, so removing the key from a title does not stop updates. For each named ticket of the project the connection creates or updates one link per URL (`remoteKind` `branch`, `pull_request`, or `issue`; `remoteState` `active` or `deleted` for a branch, `open`, `draft`, `merged`, or `closed` otherwise) and publishes `resource.added` or `resource.updated`. `remoteCheckedAt` is when the host recorded that state (receipt time for branches, and for GitLab times it cannot read), and a delivery older than the recorded state reports `stale` and applies nothing, so a late or replayed event cannot roll a link or ticket back. With `commentOnLink`, a newly linked issue or pull request gets one comment pointing at the ticket, sent and retried by the outbox. Before each send it rechecks that comments are on, the connection is not paused, the project is not archived, and the connecting administrator can still write to the project, and the link is still on its ticket in the project; otherwise the comment ends as `comments_disabled`, `paused`, `project_archived`, `access_revoked`, or `link_removed`.
|
|
207
|
+
|
|
208
|
+
`transitions` maps `branch_created`, `pull_request_opened` (opened, reopened, or marked ready), `pull_request_draft`, `pull_request_merged`, and `pull_request_closed` (closed unmerged) to a workflow state ID of the project; an omitted trigger moves nothing. A move is a normal versioned ticket update by the connecting administrator acting as a worker without a claim: it publishes `ticket.updated`, and a claimed ticket or one that requires a claim keeps its state (`claim_conflict`). Deleting a workflow state moves its transitions to the state its tickets move to. Results per ticket are `moved`, `linked`, `stale`, `not_linked` (a deleted branch Kanvil never linked), or the refusing error code.
|
|
209
|
+
|
|
210
|
+
Providers normalize to the same changes. GitLab merge requests are `pull_request` links and comments go to their notes endpoint; a GitLab push that creates or deletes a branch (an all-zero `before` or `after`) is a branch event, while other pushes and tag pushes are ignored; `opened` issues are `open`; a merge request update that flips `draft` maps to `pull_request_draft` or `pull_request_opened`, and approvals are ignored. Gitea marks drafts by a `WIP:` or `[WIP]` title prefix, so an edit that adds or removes it maps the same way; branch links point at `/src/branch/`.
|
|
211
|
+
|
|
212
|
+
`KANVIL_GITHUB_API_URL` (default `https://api.github.com`) points the connection at GitHub Enterprise Server. Access checks, comments, and issue sync use the same outbound HTTP rules as destinations.
|
|
213
|
+
|
|
214
|
+
### Issue sync
|
|
215
|
+
|
|
216
|
+
A connection can also keep tickets and issues in step: title, description, open or closed state, and new comments, in both directions. It is off until an administrator turns it on, and needs a token that can write issues.
|
|
217
|
+
|
|
218
|
+
| Operation | Route | Access |
|
|
219
|
+
| ------------------------- | ---------------------------------------------------------------------------- | --------------- |
|
|
220
|
+
| `preview_repository_sync` | `POST /api/v1/projects/{projectId}/repositories/{connectionId}/sync/preview` | Project `admin` |
|
|
221
|
+
| `apply_repository_sync` | `POST /api/v1/projects/{projectId}/repositories/{connectionId}/sync` | Project `admin` |
|
|
222
|
+
| `list_issue_links` | `GET /api/v1/projects/{projectId}/repositories/{connectionId}/issue-links` | Project `admin` |
|
|
223
|
+
| `compare_issue_link` | `GET /api/v1/projects/{projectId}/issue-links/{linkId}` | Project `admin` |
|
|
224
|
+
| `reconcile_issue_link` | `POST /api/v1/projects/{projectId}/issue-links/{linkId}/reconcile` | Project `admin` |
|
|
225
|
+
|
|
226
|
+
**Rules.** A connection has two rules, `outbound` (tickets that export as issues) and `inbound` (issues that import as tickets), each `{ enabled, labels, match }`. `labels` are workspace label IDs; a ticket or issue qualifies when it carries any (`match: "any"`) or all (`"all"`) of their names, compared without case, and an empty list qualifies everything. Rules change only through a preview: `preview_repository_sync` takes both rules and answers `matching` (links that keep syncing), `export` (open, unarchived tickets without a link that would get a new issue), `pause` (links that would stop), `review` (paused links that would qualify again), and a `fingerprint` of exactly those sets. `apply_repository_sync` takes the same rules, the connection's `expectedVersion`, and that `fingerprint`, recomputes the preview, and refuses with `409 version_conflict` when anything differs; otherwise it saves the rules, queues the exports, and pauses or holds links in the same transaction. A label ID outside the project's workspace is a `400 validation_error` in a rule that is on; a rule that is off may keep labels deleted since. Imports are not counted: an issue imports when the host next reports it open and qualifying, as an open ticket carrying the rule's labels the issue has.
|
|
227
|
+
|
|
228
|
+
**Links.** Each synced ticket has one link per connection (`list_issue_links`), with `origin` (`outbound` or `inbound`), `state`, `reason`, and the issue's `number` and `url`. A link keeps syncing while the rule of its origin still covers it: an exported ticket by its own labels, an imported issue by its labels on the host. Losing them pauses the link (`reason: "labels"`) without deleting the ticket or the issue; qualifying again moves it to `review`, never straight back to syncing. A ticket that moves to another project loses its link, and changing the connection's repository or instance removes its links.
|
|
229
|
+
|
|
230
|
+
| `state` | Meaning |
|
|
231
|
+
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
232
|
+
| `active` | Syncing |
|
|
233
|
+
| `creating` | The outbox is creating the issue |
|
|
234
|
+
| `unknown` | The host's answer to the creation was lost or unreadable, so the issue may or may not exist |
|
|
235
|
+
| `paused` | Stopped by labels, a refused creation (`http_4xx`), or a missing issue (`http_404`) |
|
|
236
|
+
| `review` | Stopped by a change on both sides (`conflict`), a change while the connection was paused (`changed_while_paused`), a refused ticket update (such as `claim_conflict`), issue content a ticket cannot hold (`invalid_content`), an issue update that failed after its retries or was refused (its error code), or an unconfirmed write (`unverified`) |
|
|
237
|
+
|
|
238
|
+
**Convergence.** A link keeps `base`, the content both sides last agreed on, and `remote`, the issue as the host last reported it. A side that differs from `base` changed: a ticket change is written to the issue by the outbox, and an issue change is applied to the ticket as a normal versioned update by the connecting administrator acting as a worker without a claim, so claims, workflow rules, and access apply as they do to transitions. Closing the issue moves the ticket to its project's first `done` state, reopening it to the first `todo` state. When both sides changed the same field to different values, the link stops for review instead of choosing one. Whitespace differences are not changes. An issue title over 500 characters or description over 100,000 is never shortened into a ticket: it does not import (`invalid_content`), it stops an active link for review, and resolving such a link with `remote` fails with `400 validation_error`. A delivery older than the recorded issue state applies nothing.
|
|
239
|
+
|
|
240
|
+
The outbox writes issues and reads the host's answer. An update (`PATCH`, or `PUT` on GitLab) sets whole values, so failures retry; once its retries are used up or the host refuses it, the link stops for review with the error code, and the answer to a sent update must show what was sent, or the link stops as `unverified`. A creation is sent once: only a refusal the host certainly did not act on (a `4xx` other than `408`, a blocked address, or an unresolvable host) is retried or pauses the link, while a timeout, network error, `5xx`, or unreadable answer leaves the link `unknown`. The created issue's body ends with a hidden `<!-- kanvil:ticket:{id} -->` marker, so its own webhook still confirms the link when the answer was lost. Kanvil's own edits arriving back as webhooks change nothing.
|
|
241
|
+
|
|
242
|
+
New comments copy both ways while a link is active: a ticket comment is posted to the issue with its author's name and a hidden marker, and a person's issue comment is added to the ticket by the connecting administrator, headed with the host account's name. Each comment is copied once to each linked issue, Kanvil never copies its own copies back, and comments older than the link or made while it was stopped are not copied; edits and deletions stay on their side. A comment the host may have taken without answering is not posted again; its webhook records it by the marker.
|
|
243
|
+
|
|
244
|
+
**Reconciling.** `compare_issue_link` returns `local` and `remote` (`null` until the host reported the issue) as `{ title, description, open }`, the agreed `base`, the link with its `version`, and `ticketVersion`. `reconcile_issue_link` takes both versions and a `resolution`: `kanvil` writes the ticket to the issue (or, for a link without a confirmed issue, creates it again as the administrator's explicit choice), `remote` writes the issue to the ticket, and `unlink` removes the link and keeps both records. A change on either side since the comparison fails with `409 version_conflict`. Resuming a link the rules no longer cover fails with `409 sync_not_qualified`, `remote` without a reported issue with `409 remote_unknown`, and resuming while a creation is in flight with `409 issue_creation_pending`.
|
|
245
|
+
|
|
246
|
+
While the connection is paused nothing syncs; issue changes reported meanwhile are kept, and a link whose issue changed waits for review. Resuming the connection sends the ticket changes made while it was paused. A sync skipped because the connecting administrator lost write access or the project was archived stops its link instead (`access_revoked` or `project_archived`), for review once that is resolved. Link state changes publish a project-level `issue_link.updated` event (no actor; `data` holds the connection, link, and ticket IDs, `state`, `reason`, and `version`) that notifies no follower or destination. Webhook results add `sync_` entries such as `sync_synced`, `sync_imported`, `sync_conflict`, `sync_paused`, `sync_review`, or `sync_stale`, and comment deliveries answer `commented`, `echo`, `duplicate`, or `historical`.
|
|
247
|
+
|
|
63
248
|
## MCP
|
|
64
249
|
|
|
65
250
|
The authenticated Streamable HTTP endpoint is `/mcp`. Tool inputs use the shared HTTP contracts, including expected versions, request IDs, and claim IDs. Successful structured results have a `result` property. Business failures return an error result with a stable code.
|
|
@@ -74,18 +259,108 @@ A signed-in user uploads their own photo with `POST /api/v1/profile/photo` using
|
|
|
74
259
|
|
|
75
260
|
## Workspaces and project icons
|
|
76
261
|
|
|
77
|
-
The shared operation registry includes `list_workspaces`, `create_workspace`, `update_workspace`, `list_workspace_members`,
|
|
262
|
+
The shared operation registry includes `list_workspaces`, `create_workspace`, `update_workspace`, `list_workspace_members`, `set_workspace_member`, the [role operations](#roles-and-permissions), the ownership and deletion operations below, and the invitation operations; each is available through HTTP and MCP. See [workspace access](workspaces.md) for inheritance, private projects, guests, and safe upgrades. `list_projects` accepts an optional `workspaceId`; create projects with an explicit workspace and `visibility: "workspace" | "private"`. Older calls that omit a workspace choose a writable workspace, creating a personal one when necessary. Ticket prefixes remain unique across the instance, including every prefix a project used before (see Ticket keys, prefix changes, and moves).
|
|
263
|
+
|
|
264
|
+
Members with `invitation.manage` invite people by email with `create_invitation` (`POST /api/v1/workspaces/{workspaceId}/invitations`): `email`, `role`, `projectIds` (`null` for the whole workspace, or up to 100 projects in it), and `expiresInDays` (1–30, default 7). The response carries the invitation, its one-time `url`, and `emailed`, which is true when the instance has `SMTP_URL` and the link was handed to the relay after the write committed; otherwise share the link yourself. Only a SHA-256 hash of the token is stored, so the link cannot be read again: `resend_invitation` replaces it with a new link and expiry and makes the caller the sender, and `revoke_invitation` cancels it. Both take `expectedVersion`. Create, resend, and revoke publish `invitation.updated` to the workspace's projects with only `invitationId` and `status`, never the address. `list_invitations` returns pending and expired invitations. Creating a second live invitation for the same address returns `409 invitation_pending`. Create and resend take no `requestId`, because a replay receipt would keep the link; a retried create returns `invitation_pending` instead of a second invitation.
|
|
265
|
+
|
|
266
|
+
A signed-in session opens a link with `get_invitation` (`GET /api/v1/invitations/{token}`) and accepts it with `accept_invitation` (`POST /api/v1/invitations/accept`, `{token}`). API keys and MCP tokens receive `403 session_required`. Acceptance requires the account's email to equal the invited address (`403 invitation_email_mismatch`) and the sender to still be active, hold `invitation.manage`, and be allowed to grant the invited role (`403 invitation_unauthorized`). An expired, revoked, already accepted, or project-less invitation returns `410 invitation_unavailable` with `details.reason` (`expired`, `revoked`, `accepted`, or `projects_removed`); an unknown or replaced link returns `404 not_found`. Accepting grants the invited role on the workspace, or on the invited projects that still exist, and only raises access: it replaces a current role only when the invited one grants everything it does and more (or is Admin), so a custom role with an action the invited role lacks stays; it publishes `membership.updated`. Invitations create no accounts: the recipient signs in with an existing account or through OIDC provisioning when the instance allows it.
|
|
267
|
+
|
|
268
|
+
### Roles and permissions
|
|
269
|
+
|
|
270
|
+
A role is a set of actions beyond reading: `ticket.write` (create, edit, move, claim, and comment on tickets; create projects and imports), `ticket.delete`, `project.manage` (project details, workflow, labels, custom fields, integrations, lifecycle, and claim recovery), `member.manage`, `invitation.manage`, `sharing.manage` (the public link and calendar feeds), and `workspace.manage` (workspace details, shared labels, and roles). The catalog is `permissions` in `@kanvil/contracts`, and `operationPermission(name)` names the action each operation needs. Every workspace has the seeded roles `reader` (no actions), `writer` (`ticket.write`), and `admin` (every action, every project in the workspace including private ones, and ownership eligibility); their IDs are those names. Workspace administrators add custom roles with any subset; their IDs are UUIDs.
|
|
271
|
+
|
|
272
|
+
Every operation needs both its credential scope and its action from the caller's current role where it runs, resolved on each request, so a changed or removed role applies to the next request, stream reconnect, and file download. Credential scopes stay a ceiling: `ticket.write` needs `write` (claims need `claim`) and every other action needs `admin`. `update_project` needs `project.manage` to change details and `sharing.manage` to change `publicReadEnabled`. Moderating another person's comment needs `project.manage`, within the credential's scopes like every action.
|
|
273
|
+
|
|
274
|
+
`list_workspace_roles` (`GET /api/v1/workspaces/{workspaceId}/roles`) and `list_project_roles` (`GET /api/v1/projects/{projectId}/roles`) list the seeded roles, then the workspace's custom roles, as `{id, name, permissions, seeded, assigned, version}`; `assigned` counts the workspace and project memberships that hold the role. `create_workspace_role` (`POST`, `{name, permissions}`), `update_workspace_role` (`PATCH .../roles/{roleId}`, `{expectedVersion, name, permissions}`), and `delete_workspace_role` (`DELETE .../roles/{roleId}`, `{expectedVersion, reassignTo?}`) need `workspace.manage`. Names are unique in the workspace, case-insensitively, and the seeded names and labels (`reader`, `Read only`, and so on) are reserved: `409 duplicate_role`. An update applies to every membership holding the role in the same write. Deleting a role that memberships hold fails with `409 role_in_use` and `details.assigned` unless `reassignTo` names another role of the workspace; the memberships move to it in the same write. These writes publish `role.created`, `role.updated`, or `role.deleted` with `roleId` to the workspace's projects.
|
|
275
|
+
|
|
276
|
+
`set_workspace_member` and `set_member` take `role` as any role ID: a seeded name, as before, or a custom role of the workspace (`404 not_found` for a role of another workspace). They need `member.manage`. No one grants an action they do not hold: the role assigned, and any role the person holds there now (for a workspace removal, also every direct project role it drops; for a direct project role's removal, also the workspace role it hands back), must grant nothing beyond the caller's own capabilities, and only an administrator assigns, changes, or removes `admin`; otherwise `403 forbidden`. The same ceiling applies to role definitions and edits, to invitations (`invitation.manage`), and to an invitation's acceptance, which checks the sender again. An invitation names only projects the sender can read and grant its role on (`404 not_found` otherwise); acceptance skips projects the sender can no longer share, and `list_invitations` leaves out projects the caller cannot read. The owner keeps `admin`, and the last administrator guard is unchanged, since a custom role never counts as one.
|
|
277
|
+
|
|
278
|
+
Responses keep the seeded fields older clients read and add the new ones. `list_workspaces` and `list_projects` return `role`, the seeded role standing in for the caller's role (`writer` for a custom role with `ticket.write`, otherwise `reader`), plus `roleId` and `capabilities`, the actions the role grants that the credential's scopes allow; clients decide what to show from `capabilities`, and `capabilitiesOf` falls back to the seeded role for servers that predate them. `list_workspace_members` and `list_members` add `roleId`, and project members `inheritedRoleId`. Moving a project to another workspace turns direct members' custom roles into their seeded stand-ins, since custom roles belong to one workspace.
|
|
279
|
+
|
|
280
|
+
`get_project` reads workspace and appearance metadata. `set_project_icon` accepts `expectedVersion` and `icon`: `{kind:"library", name:"rocket", color:"purple"}`, `{kind:"image", contentType:"image/png", data:base64}`, or `null` to reset. Library names are checked against the bundled Lucide collection. Image limits and supported formats match profile photos. `GET /api/v1/projects/{projectId}/icon` checks project read access, including project-bound API keys. Uploaded icons share blob storage, orphan cleanup protection, and backup requirements with attachments and profile photos. `set_project_background` accepts `expectedVersion` and `background`: `{contentType:"image/png", data:base64}` or `null` to remove it; project admins only, with the same image formats and limits. The project's `background` field holds its versioned URL, and `GET /api/v1/projects/{projectId}/background` checks project read access like the icon. Hiding a background is a personal client preference and never changes the project.
|
|
281
|
+
|
|
282
|
+
### Project lifecycle
|
|
78
283
|
|
|
79
|
-
|
|
284
|
+
Project administrators archive a project with `archive_project` (`POST /api/v1/projects/{projectId}/archive`, `{expectedVersion, archived: true}`) and restore it with `archived: false`. The project's `archivedAt` records when. An archived project keeps its tickets, history, members, and links, but `list_projects` leaves it out unless `archived` is `include` or `only`; `list_my_work`, `search_tickets`, and `search_workspace` skip it; its calendar feeds serve an empty calendar and its public link reads as not found; due reminders stop, including ones already queued; and every ticket's readiness carries `project_archived`. Writes to it return `409 project_archived`, except restoring, moving, deleting, editing its details, icon, background, order, or members, revoking calendar feeds, and removing destinations. The web settings keep exactly those controls. Archiving publishes `project.archived` and restoring `project.restored`.
|
|
285
|
+
|
|
286
|
+
`get_project_impact` (`GET /api/v1/projects/{projectId}/impact`, project administrators) previews a deletion: ticket, comment, attachment, byte, and event counts, plus `blockers`. With `workspaceId` it previews a move to that workspace instead and adds `transfer`: the people who lose or gain access (instance administrators read every project and are not listed), shared labels the tickets use that become project labels (`copiedLabels`), names the destination already shares (`mergedLabels`), follows removed from people who lose access, and pending invitations that drop the project. A blocker is `{code, message, tickets}`: `claim_conflict` while any ticket has a claim, live or expired, and, for moves, `relation_conflict` for relationships or parents that link to tickets in other projects.
|
|
287
|
+
|
|
288
|
+
`transfer_project` (`POST /api/v1/projects/{projectId}/transfer`, `{expectedVersion, workspaceId}`) moves the project into another workspace. The caller must administer the project, its current workspace, and the destination; project-bound credentials cannot. The move re-runs the preview inside its transaction and fails with the first blocker's code and `details.blockers`, so a permission or data change after the preview is caught. Ticket IDs, keys, and links stay the same; direct project members keep their roles, inherited access follows the destination workspace, and the project goes to the end of the destination's order. It publishes `project.transferred` with both workspace IDs.
|
|
289
|
+
|
|
290
|
+
`delete_project` (`DELETE /api/v1/projects/{projectId}`, `{expectedVersion, confirmKey}`) requires `confirmKey` to repeat the prefix, case-insensitively, and fails with `claim_conflict` while any ticket is claimed. It removes tickets with their comments, attachments, relationships, follows, evidence, and links, and the project's labels, direct memberships, calendar feeds, destinations, and staged uploads; pending invitations drop the project. The project row stays as a tombstone that no one can open: its prefix is never reused, and its activity history remains with one `ticket.deleted` event per removed ticket and a final `project.deleted`, written in the same transaction without notifying followers. Unreferenced files are collected by storage cleanup. Retrying with the same `requestId` returns the original `{ok: true}`. A deleted project's public link stops serving.
|
|
291
|
+
|
|
292
|
+
People who could read a project when it was deleted, or whose access a move ended, still receive its `project.deleted` or `project.transferred` event on the unfiltered event stream, so clients can drop what they cached. That audience is recorded with the event, so membership changes afterwards neither add nor remove anyone. They receive no other events of that project.
|
|
293
|
+
|
|
294
|
+
### Workspace ownership and deletion
|
|
295
|
+
|
|
296
|
+
Each workspace's `ownerId` names its owner: an active administrator member who alone, with instance administrators, transfers or deletes it. Creating a workspace makes the caller its owner. `set_workspace_member` refuses to remove or demote the owner with `409 workspace_owner`, and disabling the owner's account through the instance administration routes returns the same code with `details.workspaces`, and `delete_account` reports it as a blocker; ownership never passes on implicitly. `list_workspace_members` flags disabled accounts with `disabled: true`.
|
|
297
|
+
|
|
298
|
+
`transfer_workspace` (`POST /api/v1/workspaces/{workspaceId}/owner`, `{expectedVersion, userId}`) makes another member the owner. The caller must be the owner or an instance administrator (`403 forbidden` otherwise), and the destination must be an active administrator of the workspace (`409 owner_ineligible`). The previous owner stays an administrator. The write checks the destination inside the same serialized transaction that member changes and account disabling also take, so a concurrent removal, demotion, or disable either lands first and fails the transfer, or is refused because the person already owns the workspace. It bumps the workspace `version` and publishes `workspace.owner_changed` with `ownerId` and `previousOwnerId` to the workspace's projects. Events always belong to a project, so a workspace without projects publishes none for a transfer or a deletion; other sessions see the change on their next refetch, as with member changes in such a workspace (KAN-49 tracks workspace-level events).
|
|
299
|
+
|
|
300
|
+
`get_workspace_impact` (`GET /api/v1/workspaces/{workspaceId}/impact`, owner or instance administrator) previews a deletion: every project, archived ones included, as `{id, key, name, archived, tickets}`, the summed ticket, comment, attachment, byte, and event counts that `get_project_impact` reports, the member, pending-invitation, and shared-label counts, and `blockers` merged across projects (`claim_conflict` lists every claimed ticket).
|
|
301
|
+
|
|
302
|
+
`delete_workspace` (`DELETE /api/v1/workspaces/{workspaceId}`, `{expectedVersion, confirmSlug}`) is limited to the owner or an instance administrator in a signed-in session; API keys and MCP tokens receive `403 session_required`, and a session that signed in more than 15 minutes ago receives `403 reauthentication_required`, so sign in again first. `confirmSlug` must repeat the URL key. The preview runs again inside the transaction, and a blocker fails the write with its code and `details.blockers` before anything changes. Each project is then deleted as `delete_project` deletes it, with its `ticket.deleted` and `project.deleted` events and their recorded audience, and the workspace's memberships, invitations, and shared labels are removed. The workspace row stays as a tombstone: it leaves every listing, its ID reads as not found, and its URL key stays reserved. Accounts are never changed, so every instance administrator stays one and the activity history keeps its actors. Retrying with the same `requestId` returns the original `{ok: true}`.
|
|
303
|
+
|
|
304
|
+
### Export and import
|
|
305
|
+
|
|
306
|
+
`export_project` (`GET /api/v1/projects/{projectId}/export`, `read`) returns the project as a `kanvil.tickets` bundle, version 1, read in one snapshot. It holds the project's name, key, and description; its workflow states, custom fields (option IDs included), and labels with colors (workspace labels only when tickets use them); every ticket, archived ones included, with its content, properties, dates, labels, scopes, readiness, hold, state, assignee, parent, field values, creator, and creation time; relationships; comments; evidence; links; and the name and email of each person a record names. Records reference each other by `ref`, the source ID, never by key. `completeness.omitted` lists what the bundle leaves out with counts: attachments, activity history, claims, follows, and the project's settings (access, saved views, calendar feeds, destinations, public link, icon, and background). The schema is `ticketExportSchema` in `@kanvil/contracts`; import sources from other trackers translate into it.
|
|
307
|
+
|
|
308
|
+
An import creates a new project in a workspace, as the signed-in person; credentials get `403 session_required`. It runs in three steps:
|
|
309
|
+
|
|
310
|
+
1. `preview_import` (`POST /api/v1/workspaces/{workspaceId}/imports`, workspace writers) takes `{key, name, visibility, bundle}` and writes no tickets. It checks the prefix, repeated references, the workflow (a state named like a default state of its category maps onto it; the rest are added, up to 50 states), custom field definitions and every ticket's values, unknown states and fields, required fields, and cycles across parents and blockers. People match by email to accounts that can open the new project: every workspace member for workspace access, otherwise workspace administrators and the importer. The run lists each person with the matched `userId` or `null`, record `counts`, and `issues`. An `error` blocks the start. A `warning` describes what changes: lost assignees, dropped parents, relationships or records naming tickets outside the file, workspace labels reused, new keys and creator, comments and evidence written as the importer with their original author and date as the first line, and the bundle's own omissions. The bundle is staged as one record per item; unstarted previews expire after 24 hours.
|
|
311
|
+
2. `start_import` (`POST .../imports/{importId}/start`, `{expectedVersion}`) starts a preview without errors. Otherwise it fails with `409 import_blocked` and the errors in `details.issues`, or with `409 import_closed` when the run already started or ended.
|
|
312
|
+
3. A background job writes the records in order (project, states, fields, labels, tickets with parents first, relationships, comments, evidence, links) through the same operations as any client, as the importer, so every authorization, validation, version, and claim rule applies. Each write uses a request ID derived from the run and the record, so after a restart the job resumes from the first unsettled record and a write that committed before the interruption replays its receipt instead of running twice. Every event the run writes carries `data.import.runId`, notifies no follower or mentioned person, and queues for no project destination. A record a write rejects is marked failed with its code and message and the run continues; a failed project fails the run.
|
|
313
|
+
|
|
314
|
+
**Other trackers.** `list_import_source_projects` (`POST /api/v1/workspaces/{workspaceId}/import-sources/projects`, workspace writers) takes a `source` and lists the teams, projects, or boards it can read as `{ref, key, name, group}`; `group` names what holds the item where the source nests them (a PLANKA project, a Kaneo workspace) and is empty otherwise. `preview_source_import` (`POST /api/v1/workspaces/{workspaceId}/imports/source`) takes `{key, name, visibility, source, sourceProject, includeComments}`, reads that team, project, or board, translates it into a `kanvil.tickets` bundle, and stages it exactly like `preview_import`; start it with `start_import`. A `source` is one of:
|
|
315
|
+
|
|
316
|
+
| `provider` | Fields | Credentials |
|
|
317
|
+
| ---------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
318
|
+
| `linear` | `token` | A Linear personal API key |
|
|
319
|
+
| `jira` | `edition`, `baseUrl`, `email`, `token` | Jira Cloud (`edition: "cloud"`): the account `email` and an API token. Data Center and Server (`"data_center"`): a personal access token |
|
|
320
|
+
| `trello` | `apiKey`, `token` | The API key of a Power-Up and a token generated for it, sent in the `Authorization` header, never in a URL |
|
|
321
|
+
| `plane` | `baseUrl`, `workspace`, `token` | A Plane personal access token for the API at `baseUrl` (`https://api.plane.so` for Plane Cloud) and the workspace slug |
|
|
322
|
+
| `planka` | `baseUrl`, `signIn`, `username`, `password`, `token` | `signIn: "password"`: a PLANKA account's username or email and password; Kanvil opens one session and deletes it when the read ends. `"api_key"`: a PLANKA 2 API key in `token` |
|
|
323
|
+
| `kaneo` | `baseUrl`, `token` | A Kaneo API key; the API is read under `baseUrl/api` |
|
|
324
|
+
|
|
325
|
+
Both operations only read the source (Linear GraphQL queries; `GET`s everywhere else, apart from the PLANKA password sign-in and sign-out) and use the credentials for that request alone: they are never stored, logged, or returned, and the staged run holds only the translated records, so the background job never reads the source again. Changes made in the source after the preview therefore do not reach the run; preview again to pick them up. The source is read before the write lock, so a slow source holds up no other write; listing then ends, and the preview stages its run under the lock like any write. `preview_source_import` takes a `requestId`: a retry replays the staged run without reading the source again, and its receipt compares the request without the token, API key, or password. Listing writes nothing and takes none. Credentials get `403 session_required`.
|
|
326
|
+
|
|
327
|
+
| Source | Translates | Disclosed instead of imported |
|
|
328
|
+
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
329
|
+
| Linear | Team workflow states by type (`triage`/`backlog` → backlog, `unstarted` → todo, `started` → in progress, `completed` → done, `canceled` → cancelled), issues and archived issues, sub-issues as parents, priority (urgent, high, medium, low), labels with colors, assignee and creator, due date, `blocks`, `duplicate`, `related`, and `similar` (as related) relations, comments | Cycles, estimates, milestones, Linear projects, attachments |
|
|
330
|
+
| Jira | Project statuses by category (`new` → todo, `indeterminate` → in progress, `done` → done), issues, sub-task and Cloud epic parents, priority by name (Highest/Blocker/Critical → urgent, High/Major → high, Medium, Low/Minor/Lowest/Trivial → low), labels, assignee and reporter, due date, outward `Blocks`, `Duplicate`, `Cloners`, and `Problem/Incident` links (others as related), every comment page | Estimates, fix versions, attachments, sprints and story points, Data Center epic links; wiki markup stays plain text |
|
|
331
|
+
| Trello | Lists as workflow states (archived lists only when cards are still in them), cards and archived cards, labels with their color, the first member as assignee, start and due dates, checklists as Markdown task lists in the description, comments; every page past Trello's 1,000-item cap | Additional members, completed due-date marks, attachments, Power-Up custom fields |
|
|
332
|
+
| Plane | States by group (`triage`/`backlog` → backlog, `unstarted` → todo, `started` → in progress, `completed` → done, `cancelled` → cancelled), work items with their parents, priority, labels with colors, the first assignee, creator, start and target dates, `blocking`, `duplicate`, and `relates_to` relations (Plane 1.3 and later), comments; descriptions and comments turn from HTML into Markdown. Plane before 1.1 is read through its `issues` routes | Additional assignees, estimates, cycles, modules, attachments, links, start and finish date dependencies; archived, draft, and intake work items, which Plane's API does not list |
|
|
333
|
+
| PLANKA | Lists as workflow states (PLANKA 2 closed lists → done), cards, archived cards in the list they left, labels by color name, the first card member as assignee, creator, due date, task lists as Markdown task lists in the description, comments (PLANKA 1 card actions of the comment type); PLANKA 1 and 2 | Additional card members, attachments, custom field values, stopwatches, cards in the trash |
|
|
334
|
+
| Kaneo | Columns as workflow states (final columns → done), planned tasks in a backlog state of their own, archived tasks in the last final column, every task page with all of its related label pages, long descriptions read one by one, priority, labels, assignee, start and due dates, subtasks as parents, `blocks` and `related` relations, comments | External links, custom fields, time entries |
|
|
335
|
+
|
|
336
|
+
A source state named like a default state (such as "In Review") takes that state's category; board lists that share a name are numbered apart ("Done 2"). Issues of a bug type or with a `bug` label are bugs, the rest features; difficulty stays unset, and sources without priorities import tickets without one. People match by email as for files; Jira Cloud, Trello, and PLANKA 2 usually hide emails, so their people stay unmatched. Someone a source names but no longer lists imports as "Former member". Ticket keys from the source appear in warnings only (Trello `#12`, PLANKA the card's name); every ticket gets a new key. Text longer than a ticket holds is shortened and counted, and comments left out on request are noted; each of these, and everything in the table's last column, appears as a `Not imported:` warning in the preview with a count where the source gives one. A team or project with more items than `importLimits` fails with `400 validation_error`.
|
|
337
|
+
|
|
338
|
+
Rate limits (`429`, or Linear's `RATELIMITED`), server errors, and dropped connections are retried up to four times, honoring `Retry-After` up to 30 seconds; a Trello, Plane, or Kaneo limit without `Retry-After` waits a third of its window (10 seconds for Trello, a minute for Plane and Kaneo) between attempts; then the read fails with `429 source_rate_limited` or `502 source_unavailable`. A host that does not resolve or answer within 30 seconds fails at once with `502 source_unavailable`, refused credentials with `422 source_rejected` (Plane also answers this way for a project the token cannot read, and PLANKA for an account that must first accept terms or finish two-factor sign-in in the browser), and a missing team, project, or board with `404 not_found`. A failed read stages nothing, and a PLANKA session is deleted however the read ends. Plane and Kaneo serve comments and relationships one work item at a time and limit requests per minute (60 and 100 by default), so a large project takes minutes to read; allow long responses on `/imports/source` behind a reverse proxy. Jira, Plane, PLANKA, and Kaneo sites follow the outbound HTTP address rules: loopback and private addresses need `KANVIL_DELIVERY_ALLOW_PRIVATE`. `KANVIL_LINEAR_API_URL` (default `https://api.linear.app/graphql`) and `KANVIL_TRELLO_API_URL` (default `https://api.trello.com`) point Linear and Trello reads elsewhere.
|
|
339
|
+
|
|
340
|
+
`get_import` (`GET .../imports/{importId}`) returns the run with `progress` (`total`, `done`, `failed`) and the first 100 failed records in `errors`. `list_imports` (`GET .../imports`) returns your 20 most recently started runs in the workspace. `cancel_import` (`POST .../imports/{importId}/cancel`, `{expectedVersion}`) stops a previewed or running import at its next record; what was written stays. Runs belong to the person who staged them; anyone else reads them as `404 not_found`. A bundle is bounded by the 15 MB request limit and the counts in `importLimits` (5,000 tickets; 20,000 relationships and comments; 10,000 evidence entries and links), and the job holds at most 50 staged records in memory at a time.
|
|
80
341
|
|
|
81
342
|
## Account administration
|
|
82
343
|
|
|
83
344
|
Human instance administrators can search `GET /api/v1/admin/users` with `q` (name or email), `role` (`all`, `user`, `admin`), `status` (`all`, `active`, `disabled`), `limit`, and `offset`. Results contain display data and counts, never passwords or session tokens. `GET /api/v1/admin/users/{id}` adds workspace memberships, direct project grants, sign-in provider names, and an active-session count.
|
|
84
345
|
|
|
346
|
+
`GET /api/v1/admin/workspaces` lists every workspace on the instance, including ones the administrator has not joined, with `q` (name or URL key), `limit`, and `offset`. Each row carries `id`, `name`, `slug`, `createdAt`, `memberCount`, `projectCount`, and the workspace administrators' display identities. Instance administrators manage those members through the regular `list_workspace_members` and `set_workspace_member` operations, which keep the last-administrator guard (`409 last_administrator`) and publish `membership.updated`. Ordinary accounts and API keys, including project-bound ones, receive `403`.
|
|
347
|
+
|
|
85
348
|
The web app uses Better Auth's existing account creation, profile, role, suspension, and session-management operations. Administrative role/status mutations share a serialized guard that preserves an active instance administrator, including the alternative user-update endpoint. `POST /api/v1/admin/users/{id}/password` accepts `{password}` (12–128 characters), sets a local password through the auth library, and revokes existing sessions. Password changes through the auth plugin's administrative endpoint also revoke sessions. Password reset keeps API keys; disabling the account blocks API-key access as well as sign-in.
|
|
86
349
|
|
|
350
|
+
### Account deletion
|
|
351
|
+
|
|
352
|
+
`get_account_impact` (`GET /api/v1/accounts/{userId}/impact`) previews deleting an account: its email and name, sign-in `providers`, active `sessions`, `credentials` (API keys and OAuth grants), `devices` (push devices and notification channels), workspace and direct project memberships, the `claims` it would release and `imports` it would cancel, the `tickets`, `comments`, and `events` that stay, and `blockers`. A blocker is `workspace_owner` while the account owns a workspace, or `last_administrator` for the last active instance administrator or, while the account is active, the only active administrator of a workspace without an owner.
|
|
353
|
+
|
|
354
|
+
`delete_account` (`DELETE /api/v1/accounts/{userId}`, `{confirmEmail}`) deletes the account. Only the account itself or an instance administrator can read the preview or delete (`403 forbidden` otherwise); API keys and MCP tokens receive `403 session_required`, and a session that signed in more than 15 minutes ago, or any impersonation session, receives `403 reauthentication_required`. `confirmEmail` must repeat the account's email, ignoring case (`400 validation_error`). The blockers are checked again inside the transaction and fail the write with the first code and `details.blockers`; a blocked, mistyped, or failed request changes nothing. The deletion runs in the serialized transaction that role and disable changes also take, so the last two administrators cannot delete each other at once.
|
|
355
|
+
|
|
356
|
+
In one transaction it removes the account's local password and OIDC bindings, sessions (including ones it opened by impersonating someone), API keys, OAuth (MCP) clients, tokens and consents, push devices, notification channels, preferences and notifications, follows, saved views, calendar feeds, profile photo, and workspace and project memberships; releases its claims with `claim.released` (`reason: "account_deleted"`); cancels its imports; and publishes `membership.updated` with `role: null` and `deleted: true` to each project it could reach or where its activity or an assignment remains. The `user` row stays as a tombstone so tickets, comments, evidence, links, and activity keep their `actorId`, `createdBy`, and `assigneeId`: its name becomes `Deleted account`, its email `deleted+<id>@kanvil.invalid`, and it has no photo, no role, and stays disabled. A database constraint holds that shape, so no later update renames, re-enables, or re-keys it, and no administrator route, email lookup, or `kanvil recover` finds it. The person's provider identity no longer reaches it either: signing in again through OIDC creates a new, empty account when provisioning allows it.
|
|
357
|
+
|
|
358
|
+
`list_deleted_accounts` (`GET /api/v1/accounts/deleted`) returns the tombstone IDs, so clients can show `Deleted account` (`deletedAccountName` and `memberName` in `@kanvil/contracts`) instead of a former member. Deleted accounts leave the admin account list and `GET /api/v1/admin/users/{id}` reads them as `404`. Better Auth's `POST /api/auth/admin/remove-user` returns `403 forbidden`: removing the row would break the history the tombstone keeps.
|
|
359
|
+
|
|
87
360
|
The web client discards persisted administrator data when the account's instance role changes. Personal settings, workspace administration, and instance administration have separate navigation and server-side permissions.
|
|
88
361
|
|
|
89
362
|
`GET /api/v1/info` includes `authentication: { emailPasswordEnabled, ssoAutoProvision }`, also returned by the admin instance endpoint. Older responses default to `true` and `false` in the shared contract. `PATCH /api/v1/admin/instance` accepts an optional `authentication` object alongside the existing versioned settings. Omitting it preserves both policies. Disabled password sign-in returns `password_sign_in_disabled` from the direct auth endpoint; password reset does not change the policy. Disabling passwords requires a configured provider and the acting administrator's OIDC binding for that issuer and client ID, otherwise `409 oidc_required` or `409 oidc_binding_required` leaves settings unchanged. SSO creation requires a verified email and enabled provisioning; existing unlinked local emails require explicit linking.
|
|
90
363
|
|
|
364
|
+
`GET /api/v1/info` also lists `providers`, the configured sign-in providers in display order (`{ id, name }`; `oidc`, `github`, `google`, `discord`), and `emailCodes`, true when the instance has SMTP and password sign-in is enabled. `oidc` remains for older clients; the shared contract derives `providers` from it when a server omits the list. Start a provider with `POST /api/auth/sign-in/social` or link it with `POST /api/auth/link-social` (`{ provider, callbackURL }`). With `emailCodes`, `POST /api/auth/email-otp/send-verification-otp` (`{ email, type: "sign-in" }`) and `POST /api/auth/sign-in/email-otp` (`{ email, otp }`) sign in by code, and `POST /api/auth/email-otp/request-password-reset` (`{ email }`) and `POST /api/auth/email-otp/reset-password` (`{ email, otp, password }`) reset a password. Sending answers `{ "success": true }` whether or not the address has an account. An `X-Kanvil-Return` header with a path on this instance (such as `/invite/<token>`) makes the emailed link open that page; otherwise it opens `/login`. Without `emailCodes` these return `403 email_codes_unavailable`; the plugin's other email-code endpoints, including other `type` values, return `404`.
|
|
365
|
+
|
|
91
366
|
`POST /api/auth/unlink-account` also returns `409 oidc_binding_required` if it would remove a user's last binding for the configured provider while password sign-in is disabled. This shares the settings write lock so a concurrent password-policy change cannot race the unlink. Unlinking remains available when another matching binding or password sign-in is available.
|
package/docs/infrastructure.md
CHANGED
|
@@ -78,9 +78,9 @@ RabbitMQ improves notification distribution for operators who already run it. It
|
|
|
78
78
|
|
|
79
79
|
## Adapter interfaces
|
|
80
80
|
|
|
81
|
-
The application composition root is `apps/server/src/runtime-adapters.ts`. It selects authentication, push, realtime delivery, object storage, indexing, and background scheduling. HTTP and MCP share domain handlers and receive application services instead of importing provider SDKs.
|
|
81
|
+
The application composition root is `apps/server/src/runtime-adapters.ts`. It selects authentication, outbound senders (Expo push, HTTP, and SMTP), realtime delivery, object storage, indexing, and background scheduling. HTTP and MCP share domain handlers and receive application services instead of importing provider SDKs.
|
|
82
82
|
|
|
83
|
-
`adapters/ports.ts` defines the external I/O interfaces. S3 drivers implement `ObjectStore`, search drivers implement `TicketIndex`,
|
|
83
|
+
`adapters/ports.ts` defines the external I/O interfaces. S3 drivers implement `ObjectStore`, search drivers implement `TicketIndex`, message brokers implement `WakeupBus`, and outbound transports implement `OutboundSenders`. Provider registries select the driver. The blob service owns transactions, object references, integrity checks, and cleanup; the search service owns indexing checkpoints and SQL authorization; event delivery owns durable notification checkpoints and PostgreSQL fallback; the outbox owns retries, send-time authorization, and channel health while its HTTP transport owns address checks. Provider drivers perform only their protocol operations.
|
|
84
84
|
|
|
85
85
|
To add a provider, implement its matching interface, register its factory, add validated and sanitized configuration, and cover real I/O and failure recovery. Do not change ticket handlers or add provider checks to HTTP/MCP operations. PostgreSQL transactions, ordered event cursors, version checks, and claims are intentional durability contracts, not a generic database abstraction.
|
|
86
86
|
|