@maudecode/kanvil 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/apps/server/.env.example +5 -0
  2. package/apps/server/dist/{auth-CnAQzKyq.mjs → auth-Cs_whzgk.mjs} +382 -127
  3. package/apps/server/dist/client.mjs +4 -3
  4. package/apps/server/dist/{config-DOT1SDQO.mjs → config-DKuNQY5b.mjs} +15 -7
  5. package/apps/server/dist/index.mjs +105 -57
  6. package/apps/server/dist/migrate.mjs +2 -2
  7. package/apps/server/dist/recover.mjs +4 -4
  8. package/apps/server/dist/{src-VxR5jZiK.mjs → src-DptOUNRd.mjs} +67 -23
  9. package/apps/web/dist/assets/{CompositeItem-B5Qlh5m0.js → CompositeItem-CXqewtEL.js} +1 -1
  10. package/apps/web/dist/assets/{Trans-DxqBaUXP.js → Trans-B1rRlWca.js} +1 -1
  11. package/apps/web/dist/assets/{admin-settings-Bp7V2t1V.js → admin-settings-DE3MfBFE.js} +1 -1
  12. package/apps/web/dist/assets/{archive-restore-wsAzhqi-.js → archive-restore-76sStk8K.js} +1 -1
  13. package/apps/web/dist/assets/az-AZ-bFxpMMZN.js +1 -0
  14. package/apps/web/dist/assets/{az-C4KDjOOw.js → az-B7OgHoaE.js} +1 -1
  15. package/apps/web/dist/assets/{corner-left-up-arA2ipmM.js → corner-left-up-PD-73Nae.js} +1 -1
  16. package/apps/web/dist/assets/{de-Z599ZW7b.js → de-B-bv38Q0.js} +1 -1
  17. package/apps/web/dist/assets/de-DE-BeXI30Ll.js +1 -0
  18. package/apps/web/dist/assets/default-bQFGTwIp.js +56 -0
  19. package/apps/web/dist/assets/{delivery-settings-D5NKPlhN.js → delivery-settings-BvMeOsCS.js} +1 -1
  20. package/apps/web/dist/assets/{el-xsvq992p.js → el-BQHQHQgX.js} +1 -1
  21. package/apps/web/dist/assets/el-GR-SgRHlWhY.js +1 -0
  22. package/apps/web/dist/assets/{ellipsis-UOkL14vq.js → ellipsis-GF1HwTDb.js} +1 -1
  23. package/apps/web/dist/assets/es-ES-BF1J5vap.js +1 -0
  24. package/apps/web/dist/assets/{es-D3e9L5_i.js → es-f5Xr8ldr.js} +1 -1
  25. package/apps/web/dist/assets/{forms-BbWAl0BZ.js → forms-BlEL8C_P.js} +1 -1
  26. package/apps/web/dist/assets/{fr-CY35Bcpp.js → fr-DSR0htnl.js} +1 -1
  27. package/apps/web/dist/assets/fr-FR-D_sldwf6.js +1 -0
  28. package/apps/web/dist/assets/{hi-Cqfv-E5P.js → hi-BaAJEWuW.js} +1 -1
  29. package/apps/web/dist/assets/hi-IN-CfOw4TWf.js +1 -0
  30. package/apps/web/dist/assets/{highlight-Ds7xUUQv.js → highlight-DXT2xgt8.js} +1 -1
  31. package/apps/web/dist/assets/id-ID-CLKtHO7Q.js +1 -0
  32. package/apps/web/dist/assets/{id-CFBOVDUC.js → id-kxogWcip.js} +1 -1
  33. package/apps/web/dist/assets/{index-2V6MWx63.js → index-CYVrs22O.js} +5 -5
  34. package/apps/web/dist/assets/{invitations-BPk5fwwU.js → invitations-BPik3J_G.js} +1 -1
  35. package/apps/web/dist/assets/{it-DNLponcC.js → it-C_qk40U6.js} +1 -1
  36. package/apps/web/dist/assets/it-IT-eVZNW5qX.js +1 -0
  37. package/apps/web/dist/assets/{item-DED0Ikqi.js → item-TCHuZNK6.js} +1 -1
  38. package/apps/web/dist/assets/{ja-ZXGlS4WA.js → ja-Dn5h64V9.js} +1 -1
  39. package/apps/web/dist/assets/ja-JP-D96njn-P.js +1 -0
  40. package/apps/web/dist/assets/{ko-CSoj1hQM.js → ko-CjaoEPuG.js} +1 -1
  41. package/apps/web/dist/assets/ko-KR-Bash_ieA.js +1 -0
  42. package/apps/web/dist/assets/{label-settings-D12QOkDs.js → label-settings-DZTorNDm.js} +1 -1
  43. package/apps/web/dist/assets/{markdown-blocks-DbsqN1T2.js → markdown-blocks-BQLO-H_7.js} +1 -1
  44. package/apps/web/dist/assets/{member-directory-BRhEIYZ6.js → member-directory-Dx_jQYfI.js} +1 -1
  45. package/apps/web/dist/assets/mk-MK-BWQWGIRx.js +1 -0
  46. package/apps/web/dist/assets/{mk-D77vceoz.js → mk-jLeGD50T.js} +1 -1
  47. package/apps/web/dist/assets/{nl-Bew8-xmX.js → nl-AVtC5ygT.js} +1 -1
  48. package/apps/web/dist/assets/nl-NL-D1tvo0Gd.js +1 -0
  49. package/apps/web/dist/assets/{pl-CEBl_oa9.js → pl-Dup1h_Y1.js} +1 -1
  50. package/apps/web/dist/assets/pl-PL-DNgGUEcQ.js +1 -0
  51. package/apps/web/dist/assets/{project-lifecycle-DGHAmEB2.js → project-lifecycle-n1ZZbYfu.js} +1 -1
  52. package/apps/web/dist/assets/{project-settings-DY8Ossd-.js → project-settings-B_Ih5kiz.js} +1 -1
  53. package/apps/web/dist/assets/pt-BR-CZsUZQ6P.js +1 -0
  54. package/apps/web/dist/assets/{pt-BR-BDTQeKcp.js → pt-BR-Cm04EWM-.js} +1 -1
  55. package/apps/web/dist/assets/{public-project-BeXla8TL.js → public-project-BivDjU_a.js} +1 -1
  56. package/apps/web/dist/assets/{rotate-ccw-DGM890Fc.js → rotate-ccw-CSaKhZYt.js} +1 -1
  57. package/apps/web/dist/assets/{ru-BeH0eXQq.js → ru-IeycXsyE.js} +1 -1
  58. package/apps/web/dist/assets/ru-RU-4ew92YE_.js +1 -0
  59. package/apps/web/dist/assets/{search-B3-w3oCd.js → search-Ble6WSEU.js} +3 -3
  60. package/apps/web/dist/assets/{send-B9baaCnt.js → send-BLMZ8gQ0.js} +1 -1
  61. package/apps/web/dist/assets/settings-CH48qQK2.js +1 -0
  62. package/apps/web/dist/assets/{sliders-horizontal-yKgghk8h.js → sliders-horizontal-Db6kRvuw.js} +1 -1
  63. package/apps/web/dist/assets/{switch-ByOrazEW.js → switch-BeX-0Fwq.js} +1 -1
  64. package/apps/web/dist/assets/{table-Cm6NQXfq.js → table-UJp5S9yy.js} +1 -1
  65. package/apps/web/dist/assets/ticket-detail-C24opfNW.js +2 -0
  66. package/apps/web/dist/assets/{ticket-editor-CLDBUk0n.js → ticket-editor--d_bG9C1.js} +2 -2
  67. package/apps/web/dist/assets/{ticket-list-BC9acNl4.js → ticket-list-DRbZWYHf.js} +1 -1
  68. package/apps/web/dist/assets/{ticket-list-controls-CRQk5ghe.js → ticket-list-controls-fYBottFC.js} +1 -1
  69. package/apps/web/dist/assets/{tr-BNApk6Hc.js → tr-DtT_dKUK.js} +1 -1
  70. package/apps/web/dist/assets/tr-TR-CM381XCw.js +1 -0
  71. package/apps/web/dist/assets/{uk-CjShsgpN.js → uk-CsbeHSS6.js} +1 -1
  72. package/apps/web/dist/assets/uk-UA-AeOiWCVo.js +1 -0
  73. package/apps/web/dist/assets/{upload-BzLq4CMS.js → upload-DUEg04Nj.js} +1 -1
  74. package/apps/web/dist/assets/{useQueries-D64vLhM6.js → useQueries-5lW2C5Sb.js} +1 -1
  75. package/apps/web/dist/assets/vi-VN-td08xXv9.js +1 -0
  76. package/apps/web/dist/assets/{vi-s5Hfv1Y-.js → vi-rkf1BOLx.js} +1 -1
  77. package/apps/web/dist/assets/{visual-editor-KE96mwCg.js → visual-editor-DkFLjouD.js} +1 -1
  78. package/apps/web/dist/assets/{workspace-settings-C-Phmi9i.js → workspace-settings-Bcsp-VoJ.js} +1 -1
  79. package/apps/web/dist/assets/zh-CN-D3dhEYLS.js +1 -0
  80. package/apps/web/dist/assets/{zh-CN-BBnpHJ9R.js → zh-CN-Xo-QId5w.js} +1 -1
  81. package/apps/web/dist/assets/zh-TW-CfhOEFo8.js +1 -0
  82. package/apps/web/dist/assets/{zh-TW-sSWkqSLN.js → zh-TW-DEnTqjbn.js} +1 -1
  83. package/apps/web/dist/index.html +7 -7
  84. package/docs/api.md +27 -13
  85. package/docs/cli.md +9 -1
  86. package/docs/releasing.md +16 -2
  87. package/docs/self-hosting.md +8 -0
  88. package/package.json +1 -1
  89. package/apps/web/dist/assets/az-AZ-DlsPlUZi.js +0 -1
  90. package/apps/web/dist/assets/de-DE-DLCaqUUe.js +0 -1
  91. package/apps/web/dist/assets/default-6QIAyMq2.js +0 -56
  92. package/apps/web/dist/assets/el-GR-P2XcwrSU.js +0 -1
  93. package/apps/web/dist/assets/es-ES-CJoX8f3w.js +0 -1
  94. package/apps/web/dist/assets/fr-FR-yLkQdy6a.js +0 -1
  95. package/apps/web/dist/assets/hi-IN-y8dqej7z.js +0 -1
  96. package/apps/web/dist/assets/id-ID-DSvh8mGH.js +0 -1
  97. package/apps/web/dist/assets/it-IT-BUcfUqNo.js +0 -1
  98. package/apps/web/dist/assets/ja-JP-C-jLP_Ay.js +0 -1
  99. package/apps/web/dist/assets/ko-KR-C5Ozdm2E.js +0 -1
  100. package/apps/web/dist/assets/mk-MK-BdM_SsE8.js +0 -1
  101. package/apps/web/dist/assets/nl-NL-CwjB5sva.js +0 -1
  102. package/apps/web/dist/assets/pl-PL-6-PMhTBd.js +0 -1
  103. package/apps/web/dist/assets/pt-BR-TD_wt6Hr.js +0 -1
  104. package/apps/web/dist/assets/ru-RU-Bk1a6Oln.js +0 -1
  105. package/apps/web/dist/assets/settings-BKZfEkVk.js +0 -1
  106. package/apps/web/dist/assets/ticket-detail-Cd3_e2jz.js +0 -2
  107. package/apps/web/dist/assets/tr-TR-CUfBb7K9.js +0 -1
  108. package/apps/web/dist/assets/uk-UA-BDaXBsDP.js +0 -1
  109. package/apps/web/dist/assets/vi-VN-Bgb-L6mE.js +0 -1
  110. package/apps/web/dist/assets/zh-CN-BypkvBe6.js +0 -1
  111. package/apps/web/dist/assets/zh-TW-CgMsT_XS.js +0 -1
@@ -1,5 +1,5 @@
1
- import { a as auth_schema_exports, c as oauthConsent, d as user, i as apikey, l as oauthRefreshToken, o as oauthAccessToken, r as account, s as oauthClient, u as session } from "./config-DOT1SDQO.mjs";
2
- import { $ as permissionLabels, A as hasAdminRole, At as workspaceSchema, D as filterKey, Dt as ticketPredicates, E as eventSchema, Et as ticketImportSchema, F as isHexColor, J as operationPermission, Lt as s3SettingsSchema, Mt as authenticationPolicySchema, N as importSecretFields, Nt as brokerSettingsSchema, O as filterList, Ot as ticketSchema, P as invitationStatuses, Pt as instanceUpdateSchema, Q as parsePublicOutput, Rt as searchAuthentication, S as difficulties, St as states, T as eventLabels, Tt as ticketFilter, V as mentionScheme, X as parseCustomFieldFilter, Y as operations, Z as parseOutput, _ as deletedAccountName, _t as seededPermissions, at as publicOperations, b as destinationEventKeys, bt as stagedUploadLifetime, ct as repositoryProviderDefinitions, d as customFieldSchema, dt as resourceProvider, et as permissionScope, f as customFieldValue, ft as roleCategory, g as defaultWorkflow, gt as searchResultTypes, h as defaultOrder, ht as scopes, i as channelRouteAllows, it as projectSchema, k as formatEvent, kt as workflowOrder, l as credentialProjectsSchema, lt as repositoryProviders, m as defaultClaimMaxLeaseSeconds, mt as roles, n as attachmentLimit, nt as priorities, p as customFieldValueSchema, pt as roleLabels, r as channelKinds, rt as priorityLabels, s as closedStates, st as remoteKinds, t as ApiError, tt as permissions, u as customFieldFilterTokens, ut as repositorySecretFields, v as deliverySecretFields, vt as signInMethodLabels, w as eventCategories, wt as ticketExportFormat, x as destinationKinds, xt as stateLabels, y as deliverySettingsSchema, yt as snapshotSchema, z as isSeededRole, zt as searchSettingsSchema } from "./src-VxR5jZiK.mjs";
1
+ import { a as auth_schema_exports, c as oauthConsent, d as user, i as apikey, l as oauthRefreshToken, o as oauthAccessToken, r as account, s as oauthClient, u as session } from "./config-DKuNQY5b.mjs";
2
+ import { $ as permissionLabels, A as hasAdminRole, At as workflowOrder, Bt as searchSettingsSchema, D as filterKey, Dt as ticketPredicates, E as eventSchema, Et as ticketImportSchema, F as isHexColor, Ft as instanceUpdateSchema, J as operationPermission, N as importSecretFields, Nt as authenticationPolicySchema, O as filterList, Ot as ticketSchema, P as invitationStatuses, Pt as brokerSettingsSchema, Q as parsePublicOutput, Rt as s3SettingsSchema, S as difficulties, St as states, T as eventLabels, Tt as ticketFilter, V as mentionScheme, X as parseCustomFieldFilter, Y as operations, Z as parseOutput, _ as deletedAccountName, _t as seededPermissions, at as publicOperations, b as destinationEventKeys, bt as stagedUploadLifetime, ct as repositoryProviderDefinitions, d as customFieldSchema, dt as resourceProvider, et as permissionScope, f as customFieldValue, ft as roleCategory, g as defaultWorkflow, gt as searchResultTypes, h as defaultOrder, ht as scopes, i as channelRouteAllows, it as projectSchema, jt as workspaceSchema, k as formatEvent, kt as ticketSummarySchema, l as credentialProjectsSchema, lt as repositoryProviders, m as defaultClaimMaxLeaseSeconds, mt as roles, n as attachmentLimit, nt as priorities, p as customFieldValueSchema, pt as roleLabels, r as channelKinds, rt as priorityLabels, s as closedStates, st as remoteKinds, t as ApiError, tt as permissions, u as customFieldFilterTokens, ut as repositorySecretFields, v as deliverySecretFields, vt as signInMethodLabels, w as eventCategories, wt as ticketExportFormat, x as destinationKinds, xt as stateLabels, y as deliverySettingsSchema, yt as snapshotSchema, z as isSeededRole, zt as searchAuthentication } from "./src-DptOUNRd.mjs";
3
3
  import { and, asc, count, desc, eq, getTableColumns, gt, ilike, inArray, isNotNull, isNull, lt, lte, ne, notInArray, or, sql } from "drizzle-orm";
4
4
  import { alias, bigint, boolean, check, customType, foreignKey, index, integer, jsonb, pgTable, primaryKey, text, timestamp, unionAll, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
5
5
  import { z } from "zod";
@@ -15,6 +15,7 @@ import { request as request$1 } from "node:https";
15
15
  import { BlockList, isIP } from "node:net";
16
16
  import nodemailer from "nodemailer";
17
17
  import { APIError, createAuthMiddleware } from "better-auth/api";
18
+ import { AsyncLocalStorage } from "node:async_hooks";
18
19
  import { betterAuth } from "better-auth";
19
20
  import { admin, genericOAuth, jwt } from "better-auth/plugins";
20
21
  import { emailOTP } from "better-auth/plugins/email-otp";
@@ -769,7 +770,7 @@ function createBlobStorage(database, instance, factory = createObjectStore, sche
769
770
  skipLocked: true
770
771
  });
771
772
  for (const row of rows) {
772
- const backend = await instance.backend(requireRow(row.backendId ?? void 0)), key = requireRow(row.objectKey ?? void 0), bytes = requireRow(row.data ?? void 0);
773
+ const backend = await instance.backend(requireRow(row.backendId ?? void 0), tx), key = requireRow(row.objectKey ?? void 0), bytes = requireRow(row.data ?? void 0);
773
774
  await transfer(backend.spec, async (store) => {
774
775
  await store.put(key, bytes, row.contentType);
775
776
  const verified = await store.get(key, row.size);
@@ -818,7 +819,7 @@ function createBlobStorage(database, instance, factory = createObjectStore, sche
818
819
  });
819
820
  for (const { object } of rows) {
820
821
  if (object.backendId && object.objectKey) {
821
- const backend = await instance.backend(object.backendId), key = object.objectKey;
822
+ const backend = await instance.backend(object.backendId, tx), key = object.objectKey;
822
823
  await transfer(backend.spec, (store) => store.remove(key));
823
824
  }
824
825
  await tx.delete(blobObjects).where(eq(blobObjects.id, object.id));
@@ -872,6 +873,14 @@ function imageResponse(blob) {
872
873
  }
873
874
  //#endregion
874
875
  //#region src/adapters/elasticsearch.ts
876
+ const fields$1 = [
877
+ "title^3",
878
+ "key^3",
879
+ "description",
880
+ "acceptanceCriteria",
881
+ "labels",
882
+ "scopes"
883
+ ];
875
884
  function fail$1(_status, _code, message) {
876
885
  throw new Error(message);
877
886
  }
@@ -970,37 +979,44 @@ function createElasticsearchIndex(configuration) {
970
979
  let scrollId;
971
980
  try {
972
981
  const found = [];
982
+ let score;
973
983
  let response = await accepted(await call$1(settings, `${settings.index}/_search?scroll=1m`, {
974
984
  method: "POST",
975
985
  body: JSON.stringify({
976
986
  size: 1e3,
977
987
  _source: false,
978
- sort: ["_doc"],
979
988
  query: { bool: {
980
989
  filter: [{ terms: { projectId: [...projectIds] } }, { term: { instanceId: identity } }],
981
- must: [{ multi_match: {
982
- query,
983
- fields: [
984
- "title^3",
985
- "key^3",
986
- "description",
987
- "acceptanceCriteria",
988
- "labels",
989
- "scopes"
990
- ],
990
+ must: [...query.terms ? [{ multi_match: {
991
+ query: query.terms,
992
+ fields: fields$1,
991
993
  operator: "and",
992
994
  fuzziness: "AUTO"
993
- } }]
995
+ } }] : [], ...query.phrases.map((phrase) => ({ multi_match: {
996
+ query: phrase,
997
+ fields: fields$1,
998
+ type: "phrase"
999
+ } }))]
994
1000
  } }
995
1001
  })
996
1002
  }));
997
1003
  for (;;) {
998
1004
  const result = z.object({
999
1005
  _scroll_id: z.string().optional(),
1000
- hits: z.object({ hits: z.array(z.object({ _id: z.uuid() })) })
1006
+ hits: z.object({ hits: z.array(z.object({
1007
+ _id: z.uuid(),
1008
+ _score: z.number()
1009
+ })) })
1001
1010
  }).parse(await response.json());
1002
1011
  scrollId = result["_scroll_id"] ?? scrollId;
1003
- found.push(...result.hits.hits.map((hit) => hit["_id"]));
1012
+ for (const hit of result.hits.hits) {
1013
+ const rank = (found.at(-1)?.rank ?? 0) + (score === hit["_score"] ? 0 : 1);
1014
+ score = hit["_score"];
1015
+ found.push({
1016
+ id: hit["_id"],
1017
+ rank
1018
+ });
1019
+ }
1004
1020
  if (found.length > 1e4) throw new Error("External search unavailable");
1005
1021
  if (!result.hits.hits.length || !scrollId) break;
1006
1022
  response = await accepted(await call$1(settings, "_search/scroll", {
@@ -1034,12 +1050,32 @@ const createTicketIndex = (configuration) => {
1034
1050
  //#endregion
1035
1051
  //#region src/search.ts
1036
1052
  const likePattern = (query) => `%${query.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
1053
+ function parseQuery(query) {
1054
+ const phrases = [];
1055
+ const terms = query.replace(/"([^"]*)"/gu, (_, phrase) => {
1056
+ const value = phrase.replace(/\s+/gu, " ").trim();
1057
+ if (value && phrases.length < 8 && !phrases.some((seen) => seen.toLowerCase() === value.toLowerCase())) phrases.push(value);
1058
+ return " ";
1059
+ }).replace(/\s+/gu, " ").trim();
1060
+ return phrases.length || terms ? {
1061
+ phrases,
1062
+ terms
1063
+ } : {
1064
+ phrases,
1065
+ terms: query
1066
+ };
1067
+ }
1068
+ const querySegments = ({ phrases, terms }) => terms ? [...phrases, terms] : phrases;
1037
1069
  function postgresSearch(query) {
1038
- const value = likePattern(query);
1039
- return or(ilike(tickets.title, value), ilike(tickets.description, value), ilike(tickets.key, value), ilike(tickets.acceptanceCriteria, value), sql`array_to_string(${tickets.labels}, ' ') ILIKE ${value}`, sql`array_to_string(${tickets.scopes}, ' ') ILIKE ${value}`);
1070
+ return and(...querySegments(parseQuery(query)).map((segment) => {
1071
+ const value = likePattern(segment);
1072
+ return or(ilike(tickets.title, value), ilike(tickets.description, value), ilike(tickets.key, value), ilike(tickets.acceptanceCriteria, value), sql`array_to_string(${tickets.labels}, ' ') ILIKE ${value}`, sql`array_to_string(${tickets.scopes}, ' ') ILIKE ${value}`);
1073
+ }));
1040
1074
  }
1075
+ const postgresRank = (query) => sql`CASE WHEN ${and(...querySegments(parseQuery(query)).map((segment) => ilike(tickets.title, likePattern(segment))))} THEN 0 ELSE 1 END`;
1041
1076
  const builtinSearch = { where: async (_db, _ids, query) => ({
1042
1077
  where: postgresSearch(query),
1078
+ rank: postgresRank(query),
1043
1079
  engine: "postgres",
1044
1080
  indexing: false
1045
1081
  }) };
@@ -1093,6 +1129,7 @@ function createTicketSearch(database, instance, factory = createTicketIndex, sch
1093
1129
  const settings = await instance.search();
1094
1130
  const fallback = () => ({
1095
1131
  where: postgresSearch(query),
1132
+ rank: postgresRank(query),
1096
1133
  engine: "postgres",
1097
1134
  indexing: !!settings
1098
1135
  });
@@ -1101,12 +1138,14 @@ function createTicketSearch(database, instance, factory = createTicketIndex, sch
1101
1138
  if (!state || !clock || state.error || state.configHash !== fingerprint(settings) || state.cursor !== clock.value) return fallback();
1102
1139
  const root = await instance.read();
1103
1140
  try {
1104
- const ids = await factory({
1141
+ const hits = await factory({
1105
1142
  provider: "elasticsearch",
1106
1143
  config: settings
1107
- }).find(root.identity, projectIds, query);
1144
+ }).find(root.identity, projectIds, parseQuery(query));
1145
+ const ids = hits.map((hit) => hit.id), ranks = hits.map((hit) => hit.rank);
1108
1146
  return {
1109
1147
  where: ids.length ? inArray(tickets.id, ids) : sql`false`,
1148
+ rank: sql`(${`{${ranks.join(",")}}`}::int[])[array_position(${`{${ids.join(",")}}`}::uuid[], ${tickets.id})]`,
1110
1149
  engine: "index",
1111
1150
  indexing: false
1112
1151
  };
@@ -1700,7 +1739,7 @@ async function rememberLabelColor(ctx, color) {
1700
1739
  async function changeLabelUsage(ctx, name, replacement) {
1701
1740
  const affected = await ctx.db.select().from(tickets).where(and(eq(tickets.projectId, project(ctx).id), sql`${name} = ANY(${tickets.labels})`));
1702
1741
  const pending = [];
1703
- for (const ticket of affected) pending.push((await updateTicketLabelsForDefinition(ctx, {
1742
+ for (const ticket of affected) pending.push((await updateTicketLabels(ctx, {
1704
1743
  ticketId: ticket.id,
1705
1744
  expectedVersion: ticket.version,
1706
1745
  labels: [...new Set(replacement ? ticket.labels.map((tag) => tag === name ? replacement : tag) : ticket.labels.filter((tag) => tag !== name))]
@@ -1997,7 +2036,7 @@ async function rewriteTicketValues(ctx, fieldId, map) {
1997
2036
  if (value === void 0) continue;
1998
2037
  const next = map(value);
1999
2038
  if (JSON.stringify(next) === JSON.stringify(value)) continue;
2000
- await assertWorkerClaim(ctx, ticket);
2039
+ await assertDefinitionMigration(ctx, [ticket]);
2001
2040
  const values = { ...ticket.customFields };
2002
2041
  if (next === null) delete values[fieldId];
2003
2042
  else values[fieldId] = next;
@@ -2254,23 +2293,17 @@ async function validateReferences(ctx, ticketId, changes) {
2254
2293
  }
2255
2294
  async function assertNoCycle(ctx, sourceId, targetId) {
2256
2295
  if (sourceId === targetId) fail(409, "dependency_cycle", "A ticket cannot depend on itself");
2257
- const all = await ctx.db.select({
2258
- id: tickets.id,
2259
- parentId: tickets.parentId
2260
- }).from(tickets).where(eq(tickets.projectId, project(ctx).id));
2261
- const links = await ctx.db.select().from(relations$1).where(and(eq(relations$1.projectId, project(ctx).id), eq(relations$1.type, "blocks")));
2262
- const edges = /* @__PURE__ */ new Map();
2263
- const add = (from, to) => edges.set(from, [...edges.get(from) ?? [], to]);
2264
- for (const row of all) if (row.parentId) add(row.id, row.parentId);
2265
- for (const row of links) add(row.sourceId, row.targetId);
2266
- const pending = [targetId], visited = /* @__PURE__ */ new Set();
2267
- while (pending.length) {
2268
- const node = pending.pop();
2269
- if (!node || visited.has(node)) continue;
2270
- if (node === sourceId) fail(409, "dependency_cycle", "This relationship would create a dependency cycle");
2271
- visited.add(node);
2272
- pending.push(...edges.get(node) ?? []);
2273
- }
2296
+ const { rows } = await ctx.db.execute(sql`WITH RECURSIVE reach(id) AS (
2297
+ SELECT ${targetId}::uuid
2298
+ UNION
2299
+ SELECT edge.next FROM reach JOIN (
2300
+ SELECT source_id AS id, target_id AS next FROM ${relations$1} WHERE type = 'blocks'
2301
+ UNION ALL
2302
+ SELECT id, parent_id FROM ${tickets} WHERE parent_id IS NOT NULL
2303
+ ) edge ON edge.id = reach.id
2304
+ )
2305
+ SELECT 1 FROM reach WHERE id = ${sourceId}::uuid LIMIT 1`);
2306
+ if (rows.length) fail(409, "dependency_cycle", "This relationship would create a dependency cycle");
2274
2307
  }
2275
2308
  async function updateTicketRow(ctx, input) {
2276
2309
  const current = await getTicket(ctx, input.ticketId);
@@ -2278,12 +2311,18 @@ async function updateTicketRow(ctx, input) {
2278
2311
  await assertWorkerClaim(ctx, current, input.claimId);
2279
2312
  return persistTicketChanges(ctx, current, input.changes);
2280
2313
  }
2281
- async function updateTicketLabelsForDefinition(ctx, input) {
2314
+ async function updateTicketLabels(ctx, input) {
2282
2315
  if (!can(ctx, "project.manage") && !can(ctx, "workspace.manage")) fail(403, "forbidden", "Label administration permission is required");
2283
2316
  const current = await getTicket(ctx, input.ticketId);
2284
2317
  assertVersion(current, input.expectedVersion, "ticket");
2285
2318
  return persistTicketChanges(ctx, current, { labels: input.labels });
2286
2319
  }
2320
+ async function migrateTicketRow(ctx, input) {
2321
+ const current = await getTicket(ctx, input.ticketId);
2322
+ assertVersion(current, input.expectedVersion, "ticket");
2323
+ await assertDefinitionMigration(ctx, [current]);
2324
+ return persistTicketChanges(ctx, current, input.changes);
2325
+ }
2287
2326
  async function persistTicketChanges(ctx, current, changes) {
2288
2327
  await validateReferences(ctx, current.id, changes);
2289
2328
  const state = await resolveState(ctx, changes, current);
@@ -2411,7 +2450,7 @@ async function createTicketRow(ctx, input) {
2411
2450
  ...fields,
2412
2451
  ...state,
2413
2452
  ...dates,
2414
- customFields: applyCustomFieldValues(await projectCustomFields(ctx), null, customFields),
2453
+ customFields: intakeOnly(ctx.actor) ? {} : applyCustomFieldValues(await projectCustomFields(ctx), null, customFields),
2415
2454
  ...await allocateTicket(ctx),
2416
2455
  id,
2417
2456
  createdBy: ctx.actor.id
@@ -2458,6 +2497,20 @@ const hitRow = z.object({
2458
2497
  title: z.string().nullable(),
2459
2498
  status: z.enum(states).nullable()
2460
2499
  });
2500
+ const summarize = (ticket) => ticketSummarySchema.parse(json(ticket));
2501
+ const snippetLength = 240;
2502
+ function snippet(body, query) {
2503
+ const text = body?.replace(/\s+/gu, " ").trim() ?? "", lower = text.toLowerCase(), parsed = parseQuery(query);
2504
+ let at = -1, size = 0;
2505
+ for (const needle of [...querySegments(parsed), ...parsed.terms.split(" ")]) {
2506
+ const found = needle ? lower.indexOf(needle.toLowerCase()) : -1;
2507
+ if (found >= 0 && (at < 0 || found < at)) [at, size] = [found, needle.length];
2508
+ }
2509
+ if (at < 0) return "";
2510
+ if (text.length <= snippetLength) return text;
2511
+ const start = Math.max(0, Math.min(at + Math.floor((size - snippetLength) / 2), text.length - snippetLength)), end = start + snippetLength;
2512
+ return `${start > 0 ? "…" : ""}${text.slice(start > 0 ? start + 1 : start, end < text.length ? end - 1 : end)}${end < text.length ? "…" : ""}`;
2513
+ }
2461
2514
  function excerpt(body, query, radius = 80) {
2462
2515
  const text = body?.replace(/\s+/gu, " ").trim() ?? "", at = text.toLowerCase().indexOf(query.toLowerCase());
2463
2516
  if (at < 0) return "";
@@ -2475,25 +2528,29 @@ async function deleteTicketRows(ctx, selection) {
2475
2528
  const ids = [...selected.keys()];
2476
2529
  const [claimed] = await ctx.db.select({ ticketId: claims.ticketId }).from(claims).where(inArray(claims.ticketId, ids)).limit(1);
2477
2530
  if (claimed) fail(409, "claim_conflict", "Release ownership before deleting this ticket", { ticketId: claimed.ticketId });
2478
- await publishMany(ctx, [...(await ctx.db.update(tickets).set({
2479
- parentId: null,
2480
- version: sql`${tickets.version} + 1`,
2481
- updatedAt: /* @__PURE__ */ new Date()
2482
- }).where(and(inArray(tickets.parentId, ids), notInArray(tickets.id, ids))).returning()).map((child) => ({
2483
- type: "ticket.updated",
2484
- ticketId: child.id,
2485
- data: {
2486
- version: child.version,
2487
- changedFields: ["parentId"]
2488
- }
2489
- })), ...[...selected.values()].map((ticket) => ({
2490
- type: "ticket.deleted",
2491
- ticketId: ticket.id,
2492
- data: {
2493
- key: ticket.key,
2494
- version: ticket.version
2495
- }
2496
- }))]);
2531
+ await publishMany(ctx, [
2532
+ ...(await ctx.db.update(tickets).set({
2533
+ parentId: null,
2534
+ version: sql`${tickets.version} + 1`,
2535
+ updatedAt: /* @__PURE__ */ new Date()
2536
+ }).where(and(inArray(tickets.parentId, ids), notInArray(tickets.id, ids))).returning()).map((child) => ({
2537
+ type: "ticket.updated",
2538
+ ticketId: child.id,
2539
+ data: {
2540
+ version: child.version,
2541
+ changedFields: ["parentId"]
2542
+ }
2543
+ })),
2544
+ ...[...selected.values()].map((ticket) => ({
2545
+ type: "ticket.deleted",
2546
+ ticketId: ticket.id,
2547
+ data: {
2548
+ key: ticket.key,
2549
+ version: ticket.version
2550
+ }
2551
+ })),
2552
+ ...await severedRelationEvents(ctx, ids)
2553
+ ]);
2497
2554
  await ctx.db.delete(tickets).where(inArray(tickets.id, ids));
2498
2555
  }
2499
2556
  async function relocation(ctx, ticket, destinationProjectId) {
@@ -2511,6 +2568,10 @@ async function relocation(ctx, ticket, destinationProjectId) {
2511
2568
  code: "claimed",
2512
2569
  message: `Claimed by ${claim.workerId}; release or recover the claim first`
2513
2570
  });
2571
+ else if (ctx.actor.machine && ticket.claimRequired) blockers.push({
2572
+ code: "claim_conflict",
2573
+ message: "A worker needs a claim to move this ticket once it has been claimed; a person can move it, or return it to To do so a worker can claim it again"
2574
+ });
2514
2575
  const [parent] = ticket.parentId ? await ctx.db.select({
2515
2576
  id: tickets.id,
2516
2577
  key: tickets.key
@@ -2558,6 +2619,16 @@ async function relocation(ctx, ticket, destinationProjectId) {
2558
2619
  fields
2559
2620
  };
2560
2621
  }
2622
+ async function readableTicket(ctx, identifier) {
2623
+ const allowed = await accessibleProjectRows(ctx.db, ctx.actor);
2624
+ const [ticket] = allowed.length ? await ctx.db.select().from(tickets).where(and(inArray(tickets.projectId, allowed.map((row) => row.id)), ticketMatch(identifier))).limit(1) : [];
2625
+ const owner = allowed.find((row) => row.id === ticket?.projectId);
2626
+ if (!ticket || !owner) fail(404, "not_found", "Ticket was not found");
2627
+ return {
2628
+ ticket,
2629
+ project: owner
2630
+ };
2631
+ }
2561
2632
  const ticketHandlers = {
2562
2633
  async list_ticket_labels(ctx, input) {
2563
2634
  const labels = labelUsage(ctx, input.archived).as("usage");
@@ -2568,7 +2639,11 @@ const ticketHandlers = {
2568
2639
  const related = input.relatedTo ? await getTicket(ctx, input.relatedTo) : void 0;
2569
2640
  const search = input.q ? await ctx.search.where(ctx.db, [project(ctx).id], input.q) : void 0;
2570
2641
  await assertCustomFieldFilters(ctx, [project(ctx).id], input);
2571
- return pageOf(ctx, and(eq(tickets.projectId, project(ctx).id), parent ? eq(tickets.parentId, parent.id) : void 0, related ? or(inArray(tickets.id, ctx.db.select({ id: relations$1.targetId }).from(relations$1).where(eq(relations$1.sourceId, related.id))), inArray(tickets.id, ctx.db.select({ id: relations$1.sourceId }).from(relations$1).where(eq(relations$1.targetId, related.id)))) : void 0, predicateWhere(input), input.archived === "include" ? void 0 : eq(tickets.archived, input.archived === "only"), dueWhere(input.due), input.stateId ? eq(tickets.stateId, input.stateId) : void 0, input.scheduledFrom ? sql`${scheduleEnd} >= ${input.scheduledFrom}::timestamptz` : void 0, input.scheduledTo ? sql`${scheduleStart} < ${input.scheduledTo}::timestamptz` : void 0, search?.where), ticketOrder(input), input);
2642
+ const page = await pageOf(ctx, and(eq(tickets.projectId, project(ctx).id), parent ? eq(tickets.parentId, parent.id) : void 0, related ? or(inArray(tickets.id, ctx.db.select({ id: relations$1.targetId }).from(relations$1).where(eq(relations$1.sourceId, related.id))), inArray(tickets.id, ctx.db.select({ id: relations$1.sourceId }).from(relations$1).where(eq(relations$1.targetId, related.id)))) : void 0, predicateWhere(input), input.archived === "include" ? void 0 : eq(tickets.archived, input.archived === "only"), dueWhere(input.due), input.stateId ? eq(tickets.stateId, input.stateId) : void 0, input.scheduledFrom ? sql`${scheduleEnd} >= ${input.scheduledFrom}::timestamptz` : void 0, input.scheduledTo ? sql`${scheduleStart} < ${input.scheduledTo}::timestamptz` : void 0, search?.where), ticketOrder(input), input);
2643
+ return input.view === "summary" ? {
2644
+ ...page,
2645
+ items: page.items.map(summarize)
2646
+ } : page;
2572
2647
  },
2573
2648
  async list_my_work(ctx, input) {
2574
2649
  const allowed = await accessibleProjectRows(ctx.db, ctx.actor, input.workspaceId, "exclude");
@@ -2597,6 +2672,21 @@ const ticketHandlers = {
2597
2672
  };
2598
2673
  },
2599
2674
  async create_ticket(ctx, input) {
2675
+ if (intakeOnly(ctx.actor)) input = {
2676
+ ...input,
2677
+ automationReady: false,
2678
+ holdReason: intakeHoldReason,
2679
+ status: "todo",
2680
+ stateId: void 0,
2681
+ assigneeId: null,
2682
+ parentId: null,
2683
+ archived: false,
2684
+ dueAt: null,
2685
+ startAt: null,
2686
+ customFields: {},
2687
+ uploads: [],
2688
+ labels: operations.create_ticket.input.shape.labels.parse([.../* @__PURE__ */ new Set([...input.labels, "intake"])])
2689
+ };
2600
2690
  const { row, event } = await createTicketRow(ctx, input);
2601
2691
  await publishMany(ctx, [event, ...(await claimStagedUploads(ctx, row.id, input.uploads)).map((item) => ({
2602
2692
  type: "attachment.created",
@@ -2642,10 +2732,15 @@ const ticketHandlers = {
2642
2732
  ...event.data,
2643
2733
  duplicateOf: source.id
2644
2734
  }
2645
- }, ...relationEvents("relation.added", relation)]);
2735
+ }, ...relationEvents("relation.added", relation, [row, source])]);
2646
2736
  return row;
2647
2737
  },
2648
2738
  update_ticket: updateTicket,
2739
+ async update_ticket_labels(ctx, input) {
2740
+ const { row, event } = await updateTicketLabels(ctx, input);
2741
+ await publish(ctx, event.type, event.ticketId, event.data);
2742
+ return row;
2743
+ },
2649
2744
  async move_ticket(ctx, input) {
2650
2745
  const current = await getTicket(ctx, input.ticketId);
2651
2746
  assertVersion(current, input.expectedVersion, "ticket", "moving");
@@ -2686,7 +2781,6 @@ const ticketHandlers = {
2686
2781
  assertVersion(current, input.expectedVersion, "ticket", "moving");
2687
2782
  const { target, blockers, fields } = await relocation(ctx, current, input.destinationProjectId);
2688
2783
  if (blockers.length) fail(409, "relocation_blocked", blockers.map((item) => item.message).join(". "), { blockers });
2689
- await assertWorkerClaim(ctx, current);
2690
2784
  const state = requireRow(await resolveState(target, {
2691
2785
  status: input.status ?? (input.stateId ? void 0 : z.enum(states).parse(current.status)),
2692
2786
  stateId: input.stateId
@@ -2728,10 +2822,7 @@ const ticketHandlers = {
2728
2822
  return row;
2729
2823
  },
2730
2824
  async resolve_ticket(ctx, input) {
2731
- const allowed = await accessibleProjectRows(ctx.db, ctx.actor);
2732
- const [ticket] = allowed.length ? await ctx.db.select().from(tickets).where(and(inArray(tickets.projectId, allowed.map((row) => row.id)), ticketMatch(input.ticketId))).limit(1) : [];
2733
- const owner = allowed.find((row) => row.id === ticket?.projectId);
2734
- if (!ticket || !owner) fail(404, "not_found", "Ticket was not found");
2825
+ const { ticket, project: owner } = await readableTicket(ctx, input.ticketId);
2735
2826
  return {
2736
2827
  ticket,
2737
2828
  project: {
@@ -2748,6 +2839,7 @@ const ticketHandlers = {
2748
2839
  projectId: input.projectId,
2749
2840
  ticketId: item.ticketId,
2750
2841
  expectedVersion: item.expectedVersion,
2842
+ claimId: item.claimId,
2751
2843
  changes: input.changes
2752
2844
  });
2753
2845
  result.push(row);
@@ -2778,17 +2870,20 @@ const ticketHandlers = {
2778
2870
  async search_tickets(ctx, input) {
2779
2871
  const allowed = await accessibleProjectRows(ctx.db, ctx.actor, input.workspaceId, "exclude");
2780
2872
  if (!allowed.length) return { items: [] };
2781
- const ids = allowed.map((row) => row.id);
2782
- return { items: (await ctx.db.select().from(tickets).where(and(inArray(tickets.projectId, ids), eq(tickets.archived, false), or(ticketMatch(input.q), (await ctx.search.where(ctx.db, ids, input.q)).where))).orderBy(sql`CASE WHEN ${ticketMatch(input.q)} THEN 0 ELSE 1 END`, desc(tickets.updatedAt)).limit(input.limit)).flatMap((ticket) => {
2873
+ const ids = allowed.map((row) => row.id), match = await ctx.search.where(ctx.db, ids, input.q);
2874
+ return { items: (await ctx.db.select().from(tickets).where(and(inArray(tickets.projectId, ids), eq(tickets.archived, false), or(ticketMatch(input.q), match.where))).orderBy(sql`CASE WHEN ${ticketMatch(input.q)} THEN 0 ELSE 1 END`, sql`${match.rank} NULLS LAST`, desc(tickets.updatedAt)).limit(input.limit)).flatMap((ticket) => {
2783
2875
  const owner = allowed.find((row) => row.id === ticket.projectId);
2784
- return owner ? [{
2785
- ticket,
2876
+ if (!owner) return [];
2877
+ const summary = input.view === "summary", body = `${ticket.description} ${ticket.acceptanceCriteria}`;
2878
+ return [{
2879
+ ticket: summary ? summarize(ticket) : ticket,
2786
2880
  project: {
2787
2881
  id: owner.id,
2788
2882
  key: owner.key,
2789
2883
  name: owner.name
2790
- }
2791
- }] : [];
2884
+ },
2885
+ ...summary && { snippet: snippet(body, input.q) }
2886
+ }];
2792
2887
  }) };
2793
2888
  },
2794
2889
  async search_workspace(ctx, input) {
@@ -2813,23 +2908,26 @@ const ticketHandlers = {
2813
2908
  filtered ? void 0 : sql`SELECT 'project' AS type, projects.id::text AS id, projects.id AS project_id,
2814
2909
  NULL::uuid AS ticket_id, NULL::text AS actor_id, projects.description AS body,
2815
2910
  NULL::text AS event_type, NULL::jsonb AS data, projects.updated_at AS at,
2816
- CASE WHEN projects.key = ${key} THEN 0 ELSE 1 END AS rank, 0 AS family
2911
+ CASE WHEN projects.key = ${key} THEN 0 ELSE 1 END AS rank, 0 AS family,
2912
+ NULL::int AS score
2817
2913
  FROM projects WHERE ${inArray(projects.id, ids)}
2818
2914
  AND (projects.name ILIKE ${like} OR projects.key ILIKE ${like} OR projects.description ILIKE ${like})`,
2819
2915
  sql`SELECT 'ticket' AS type, tickets.id::text AS id, tickets.project_id,
2820
2916
  tickets.id AS ticket_id, NULL::text AS actor_id,
2821
2917
  concat_ws(' ', tickets.description, tickets.acceptance_criteria) AS body,
2822
2918
  NULL::text AS event_type, NULL::jsonb AS data, tickets.updated_at AS at,
2823
- CASE WHEN ${ticketMatch(input.q)} THEN 0 ELSE 1 END AS rank, 1 AS family
2919
+ CASE WHEN ${ticketMatch(input.q)} THEN 0 ELSE 1 END AS rank, 1 AS family,
2920
+ ${match.rank}::int AS score
2824
2921
  FROM tickets WHERE ${and(ticketScope, or(ticketMatch(input.q), match.where))}`,
2825
2922
  sql`SELECT 'comment' AS type, comments.id::text AS id, comments.project_id,
2826
2923
  comments.ticket_id, comments.actor_id, comments.body, NULL::text AS event_type,
2827
- NULL::jsonb AS data, comments.created_at AS at, 1 AS rank, 2 AS family
2924
+ NULL::jsonb AS data, comments.created_at AS at, 1 AS rank, 2 AS family,
2925
+ NULL::int AS score
2828
2926
  FROM comments JOIN tickets ON tickets.id = comments.ticket_id
2829
2927
  WHERE ${and(ticketScope, sql`comments.body ILIKE ${like}`)}`,
2830
2928
  sql`SELECT 'activity' AS type, events.id::text AS id, events.project_id,
2831
2929
  events.ticket_id, events.actor_id, NULL::text AS body, events.type AS event_type,
2832
- events.data, events.created_at AS at, 1 AS rank, 3 AS family
2930
+ events.data, events.created_at AS at, 1 AS rank, 3 AS family, NULL::int AS score
2833
2931
  FROM events
2834
2932
  LEFT JOIN tickets ON tickets.id = events.ticket_id AND tickets.project_id = events.project_id
2835
2933
  LEFT JOIN "user" ON "user".id = events.actor_id
@@ -2846,14 +2944,15 @@ const ticketHandlers = {
2846
2944
  FROM (${hits}) AS hits
2847
2945
  LEFT JOIN tickets ON tickets.id = hits.ticket_id AND tickets.project_id = hits.project_id
2848
2946
  WHERE ${input.type ? sql`hits.type = ${input.type}` : sql`true`}
2849
- ORDER BY hits.rank, hits.family, hits.at DESC, hits.id DESC
2947
+ ORDER BY hits.rank, hits.family, hits.score NULLS LAST, hits.at DESC, hits.id DESC
2850
2948
  LIMIT ${input.limit} OFFSET ${input.offset}`)).rows.map((row) => hitRow.parse(row));
2851
2949
  const projectsById = new Map(allowed.map((row) => [row.id, row]));
2950
+ const ticketIds = rows.flatMap((row) => row.ticket_id ? [row.ticket_id] : []), summaries = input.view === "summary" && ticketIds.length ? new Map((await ctx.db.select().from(tickets).where(inArray(tickets.id, ticketIds))).map((row) => [row.id, summarize(row)])) : void 0;
2852
2951
  return {
2853
2952
  items: rows.flatMap((row) => {
2854
2953
  const owner = projectsById.get(row.project_id);
2855
2954
  if (!owner) return [];
2856
- const ticket = row.ticket_id && row.key && row.title && row.status ? {
2955
+ const ticket = row.ticket_id && row.key && row.title && row.status ? summaries?.get(row.ticket_id) ?? {
2857
2956
  id: row.ticket_id,
2858
2957
  key: row.key,
2859
2958
  title: row.title,
@@ -2879,6 +2978,7 @@ const ticketHandlers = {
2879
2978
  createdAt: at
2880
2979
  }, true) : ticket?.title ?? "",
2881
2980
  excerpt: excerpt(row.body, input.q),
2981
+ ...input.view === "summary" && { snippet: snippet(row.body, input.q) },
2882
2982
  actorId: row.actor_id,
2883
2983
  at
2884
2984
  }];
@@ -2950,6 +3050,16 @@ const ticketHandlers = {
2950
3050
  version: row.version,
2951
3051
  changedFields: ["status", "stateId"]
2952
3052
  });
3053
+ } else if (ticket.claimRequired) {
3054
+ const row = one(await ctx.db.update(tickets).set({
3055
+ claimRequired: false,
3056
+ version: ticket.version + 1,
3057
+ updatedAt: /* @__PURE__ */ new Date()
3058
+ }).where(eq(tickets.id, ticket.id)).returning());
3059
+ await publish(ctx, "ticket.updated", ticket.id, {
3060
+ version: row.version,
3061
+ changedFields: ["claimRequired"]
3062
+ });
2953
3063
  }
2954
3064
  await ctx.db.delete(claims).where(eq(claims.id, current.id));
2955
3065
  await publish(ctx, "claim.recovered", ticket.id, {
@@ -3234,6 +3344,13 @@ function mentionedUserIds(markdown) {
3234
3344
  //#region src/collaboration-operations.ts
3235
3345
  const sourceTicket = alias(tickets, "source_ticket");
3236
3346
  const targetTicket = alias(tickets, "target_ticket");
3347
+ const sourceProject = alias(projects, "source_project");
3348
+ const targetProject = alias(projects, "target_project");
3349
+ async function assertMayConstrain(ctx, relation, sides) {
3350
+ const constrained = relation.type === "blocks" ? relation.targetId : relation.type === "duplicate" ? relation.sourceId : null;
3351
+ const side = sides.find((item) => item.id === constrained);
3352
+ if (side && side.projectId !== project(ctx).id) await context(ctx.db, ctx.actor, "write", side.projectId);
3353
+ }
3237
3354
  async function editableComment(ctx, commentId, expectedVersion) {
3238
3355
  const row = one(await ctx.db.select().from(comments).where(and(eq(comments.projectId, project(ctx).id), eq(comments.id, commentId))));
3239
3356
  if (row.actorId !== ctx.actor.id && !can(ctx, "project.manage")) fail(403, "forbidden", "Only the author or a project administrator can edit this comment");
@@ -3264,18 +3381,45 @@ async function addComment(ctx, input) {
3264
3381
  const collaborationHandlers = {
3265
3382
  async list_relations(ctx, input) {
3266
3383
  const ticket = await getTicket(ctx, input.ticketId);
3267
- return ctx.db.select({
3384
+ const rows = await ctx.db.select({
3268
3385
  ...getTableColumns(relations$1),
3269
3386
  sourceKey: sourceTicket.key,
3270
3387
  sourceTitle: sourceTicket.title,
3388
+ sourceProjectId: sourceTicket.projectId,
3389
+ sourceProjectKey: sourceProject.key,
3271
3390
  targetKey: targetTicket.key,
3272
- targetTitle: targetTicket.title
3273
- }).from(relations$1).innerJoin(sourceTicket, eq(sourceTicket.id, relations$1.sourceId)).innerJoin(targetTicket, eq(targetTicket.id, relations$1.targetId)).where(and(eq(relations$1.projectId, project(ctx).id), or(eq(relations$1.sourceId, ticket.id), eq(relations$1.targetId, ticket.id))));
3391
+ targetTitle: targetTicket.title,
3392
+ targetProjectId: targetTicket.projectId,
3393
+ targetProjectKey: targetProject.key
3394
+ }).from(relations$1).innerJoin(sourceTicket, eq(sourceTicket.id, relations$1.sourceId)).innerJoin(targetTicket, eq(targetTicket.id, relations$1.targetId)).innerJoin(sourceProject, eq(sourceProject.id, sourceTicket.projectId)).innerJoin(targetProject, eq(targetProject.id, targetTicket.projectId)).where(or(eq(relations$1.sourceId, ticket.id), eq(relations$1.targetId, ticket.id)));
3395
+ const here = project(ctx).id, elsewhere = rows.some((row) => row.sourceProjectId !== here || row.targetProjectId !== here), readable = /* @__PURE__ */ new Set([here, ...(elsewhere ? await accessibleProjectRows(ctx.db, ctx.actor) : []).map((row) => row.id)]);
3396
+ return rows.map(({ sourceTitle, sourceProjectId, sourceProjectKey, targetTitle, targetProjectId, targetProjectKey, ...row }) => {
3397
+ const source = readable.has(sourceProjectId), target = readable.has(targetProjectId);
3398
+ return {
3399
+ ...row,
3400
+ ...source && {
3401
+ sourceTitle,
3402
+ sourceProjectId,
3403
+ sourceProjectKey
3404
+ },
3405
+ ...target && {
3406
+ targetTitle,
3407
+ targetProjectId,
3408
+ targetProjectKey
3409
+ },
3410
+ restricted: !source || !target
3411
+ };
3412
+ });
3274
3413
  },
3275
3414
  async add_relation(ctx, input) {
3276
- const current = await getTicket(ctx, input.ticketId), other = await getTicket(ctx, input.targetId);
3415
+ const current = await getTicket(ctx, input.ticketId), { ticket: other } = await readableTicket(ctx, input.targetId);
3277
3416
  const [source, target] = input.direction === "incoming" ? [other, current] : [current, other];
3278
3417
  if (source.id === target.id) fail(409, "invalid_relation", "Choose a different ticket");
3418
+ await assertMayConstrain(ctx, {
3419
+ type: input.type,
3420
+ sourceId: source.id,
3421
+ targetId: target.id
3422
+ }, [current, other]);
3279
3423
  if (input.type === "blocks") await assertNoCycle(ctx, source.id, target.id);
3280
3424
  const [sourceId, targetId] = input.type === "related" ? [source.id, target.id].toSorted() : [source.id, target.id];
3281
3425
  const row = one(await ctx.db.insert(relations$1).values({
@@ -3291,12 +3435,19 @@ const collaborationHandlers = {
3291
3435
  ],
3292
3436
  set: { type: input.type }
3293
3437
  }).returning());
3294
- await publishMany(ctx, relationEvents("relation.added", row));
3438
+ await publishMany(ctx, relationEvents("relation.added", row, [current, other]));
3295
3439
  return row;
3296
3440
  },
3297
3441
  async remove_relation(ctx, input) {
3298
- const row = one(await ctx.db.delete(relations$1).where(and(eq(relations$1.projectId, project(ctx).id), eq(relations$1.id, input.relationId))).returning());
3299
- await publishMany(ctx, relationEvents("relation.removed", row));
3442
+ const found = one(await ctx.db.select().from(relations$1).where(eq(relations$1.id, input.relationId)));
3443
+ const sides = await ctx.db.select({
3444
+ id: tickets.id,
3445
+ projectId: tickets.projectId
3446
+ }).from(tickets).where(inArray(tickets.id, [found.sourceId, found.targetId]));
3447
+ if (!sides.some((side) => side.projectId === project(ctx).id)) fail(404, "not_found", "The requested record was not found");
3448
+ await assertMayConstrain(ctx, found, sides);
3449
+ const row = one(await ctx.db.delete(relations$1).where(eq(relations$1.id, found.id)).returning());
3450
+ await publishMany(ctx, relationEvents("relation.removed", row, sides));
3300
3451
  return { ok: true };
3301
3452
  },
3302
3453
  async list_comments(ctx, input) {
@@ -3949,7 +4100,7 @@ function matches(rule, names, labels) {
3949
4100
  const has = (id) => carried.has(names.get(id)?.toLowerCase() ?? "\0");
3950
4101
  return rule.match === "all" ? rule.labels.every(has) : rule.labels.some(has);
3951
4102
  }
3952
- const qualifies = (rules, names, link, ticket) => link.origin === "outbound" ? matches(rules.outbound, names, ticket.labels) : matches(rules.inbound, names, link.remote?.labels ?? []);
4103
+ const qualifies = (rules, names, link, ticket) => !intakeQuarantined(ticket) && (link?.origin === "inbound" ? matches(rules.inbound, names, link.remote?.labels ?? []) : matches(rules.outbound, names, ticket.labels));
3953
4104
  const workspaceOf = async (db, projectId) => one(await db.select({ workspaceId: projects.workspaceId }).from(projects).where(eq(projects.id, projectId))).workspaceId;
3954
4105
  async function setLinks(db, projectId, links, changes) {
3955
4106
  if (!links.length) return [];
@@ -4011,7 +4162,7 @@ async function queueIssueSync(db, rows) {
4011
4162
  for (const ticket of found) for (const connection of connections) {
4012
4163
  const link = links.find((item) => item.connectionId === connection.id && item.ticketId === ticket.id), labelled = names.get(connection.id) ?? /* @__PURE__ */ new Map();
4013
4164
  if (!link) {
4014
- if (connection.projectId === ticket.projectId && !ticket.archived && !complete(ticket.status) && matches(connection.outbound, labelled, ticket.labels)) await queueSync(db, await db.insert(issueLinks).values({
4165
+ if (connection.projectId === ticket.projectId && !ticket.archived && !complete(ticket.status) && qualifies(connection, labelled, null, ticket)) await queueSync(db, await db.insert(issueLinks).values({
4015
4166
  connectionId: connection.id,
4016
4167
  ticketId: ticket.id,
4017
4168
  origin: "outbound",
@@ -4032,8 +4183,9 @@ async function queueIssueSync(db, rows) {
4032
4183
  state: "review",
4033
4184
  reason: "labels"
4034
4185
  });
4035
- else if (link.state === "active" && !connection.paused) {
4186
+ else if ((link.state === "active" || link.state === "creating") && qualified && !connection.paused) {
4036
4187
  await queueSync(db, [link]);
4188
+ if (link.state !== "active") continue;
4037
4189
  const fresh = commentEvents.filter((event) => event.ticketId === ticket.id && !copied.has(`${link.id}:${String(event.data.commentId)}`));
4038
4190
  if (fresh.length) await db.insert(deliveries).values(fresh.map((event) => ({
4039
4191
  connectionId: connection.id,
@@ -4286,6 +4438,10 @@ async function sendIssueSync(database, senders, secrets, drivers, job, event) {
4286
4438
  }).from(issueLinks).innerJoin(tickets, eq(tickets.id, issueLinks.ticketId)).innerJoin(repositoryConnections, eq(repositoryConnections.id, issueLinks.connectionId)).innerJoin(projects, eq(projects.id, repositoryConnections.projectId)).where(eq(issueLinks.id, job.issueLinkId ?? "")))[0];
4287
4439
  if (!loaded) return finish(database.db, job, "link_removed");
4288
4440
  const { link, ticket, connection } = loaded;
4441
+ if (intakeQuarantined(ticket)) return locked(database, async (db) => {
4442
+ const current = (await db.select({ holdReason: tickets.holdReason }).from(tickets).where(eq(tickets.id, ticket.id)))[0];
4443
+ if (!current || intakeQuarantined(current)) await finish(db, job, current ? "quarantined" : "link_removed");
4444
+ });
4289
4445
  const skip = connection.paused ? "paused" : !loaded.writer ? "access_revoked" : loaded.archivedAt ? "project_archived" : ticket.projectId !== connection.projectId ? "moved" : null;
4290
4446
  if (skip === "access_revoked" || skip === "project_archived") return locked(database, async (db) => {
4291
4447
  await finish(db, job, skip);
@@ -4500,7 +4656,7 @@ async function plan(ctx, connection, { inbound, outbound }) {
4500
4656
  if (link.state === "active") (qualified ? matching : pause).push(link);
4501
4657
  else if (link.state === "paused" && link.reason === "labels" && qualified) review.push(link);
4502
4658
  }
4503
- const exported = rules.outbound.enabled ? (await ctx.db.select().from(tickets).where(and(eq(tickets.projectId, connection.projectId), eq(tickets.archived, false), notInArray(tickets.status, [...closedStates]), notInArray(tickets.id, ctx.db.select({ id: issueLinks.ticketId }).from(issueLinks).where(eq(issueLinks.connectionId, connection.id)))))).filter((ticket) => matches(rules.outbound, names, ticket.labels)) : [];
4659
+ const exported = rules.outbound.enabled ? (await ctx.db.select().from(tickets).where(and(eq(tickets.projectId, connection.projectId), eq(tickets.archived, false), notInArray(tickets.status, [...closedStates]), notInArray(tickets.id, ctx.db.select({ id: issueLinks.ticketId }).from(issueLinks).where(eq(issueLinks.connectionId, connection.id)))))).filter((ticket) => qualifies(rules, names, null, ticket)) : [];
4504
4660
  return {
4505
4661
  matching,
4506
4662
  pause,
@@ -5063,7 +5219,7 @@ const workflowHandlers = {
5063
5219
  if (moving) assertNotLast(all, current, "move");
5064
5220
  const siblings = all.filter((state) => state.category === next && state.id !== current.id), at = Math.min(input.position ?? (moving ? siblings.length : current.position), siblings.length);
5065
5221
  const held = moving ? await ctx.db.select().from(tickets).where(eq(tickets.stateId, current.id)) : [];
5066
- for (const ticket of held) await assertWorkerClaim(ctx, ticket);
5222
+ await assertDefinitionMigration(ctx, held);
5067
5223
  const row = one(await ctx.db.update(workflowStates).set({
5068
5224
  ...input.name === void 0 ? {} : { name: input.name },
5069
5225
  ...input.color === void 0 ? {} : { color: input.color },
@@ -5101,8 +5257,7 @@ const workflowHandlers = {
5101
5257
  assertNotLast(all, current, "delete");
5102
5258
  const held = await ctx.db.select().from(tickets).where(eq(tickets.stateId, current.id));
5103
5259
  const pending = [];
5104
- for (const ticket of held) pending.push((await updateTicketRow(ctx, {
5105
- projectId: project(ctx).id,
5260
+ for (const ticket of held) pending.push((await migrateTicketRow(ctx, {
5106
5261
  ticketId: ticket.id,
5107
5262
  expectedVersion: ticket.version,
5108
5263
  changes: { stateId: target.id }
@@ -5745,22 +5900,26 @@ async function removeProject(ctx) {
5745
5900
  key: tickets.key,
5746
5901
  version: tickets.version
5747
5902
  }).from(tickets).where(eq(tickets.projectId, current.id)).orderBy(asc(tickets.number));
5748
- await recordAudience(ctx, requireRow((await publishMany(ctx, [...removed.map((ticket) => ({
5749
- type: "ticket.deleted",
5750
- ticketId: ticket.id,
5751
- data: {
5752
- key: ticket.key,
5753
- version: ticket.version
5754
- }
5755
- })), {
5756
- type: "project.deleted",
5757
- ticketId: null,
5758
- data: {
5759
- key: current.key,
5760
- name: current.name,
5761
- tickets: removed.length
5903
+ await recordAudience(ctx, requireRow((await publishMany(ctx, [
5904
+ ...removed.map((ticket) => ({
5905
+ type: "ticket.deleted",
5906
+ ticketId: ticket.id,
5907
+ data: {
5908
+ key: ticket.key,
5909
+ version: ticket.version
5910
+ }
5911
+ })),
5912
+ ...await severedRelationEvents(ctx),
5913
+ {
5914
+ type: "project.deleted",
5915
+ ticketId: null,
5916
+ data: {
5917
+ key: current.key,
5918
+ name: current.name,
5919
+ tickets: removed.length
5920
+ }
5762
5921
  }
5763
- }], false)).at(-1)), readers.map((person) => person.userId));
5922
+ ], false)).at(-1)), readers.map((person) => person.userId));
5764
5923
  await ctx.db.delete(tickets).where(eq(tickets.projectId, current.id));
5765
5924
  await ctx.db.delete(stagedUploads).where(eq(stagedUploads.projectId, current.id));
5766
5925
  await ctx.db.delete(projectLabels).where(eq(projectLabels.projectId, current.id));
@@ -5997,7 +6156,8 @@ const timeEntryHandlers = {
5997
6156
  //#region src/public-operations.ts
5998
6157
  const listTickets = async (ctx, input) => parseOutput("list_tickets", json(await ticketHandlers.list_tickets(ctx, operations.list_tickets.input.parse({
5999
6158
  ...input,
6000
- projectId: project(ctx).id
6159
+ projectId: project(ctx).id,
6160
+ view: "full"
6001
6161
  }))));
6002
6162
  async function published(ctx, ids) {
6003
6163
  if (!ids.length) return /* @__PURE__ */ new Set();
@@ -6739,6 +6899,36 @@ function requireRecentSignIn(actor, message) {
6739
6899
  function fail(status, code, message, details) {
6740
6900
  throw new ApiError(status, code, message, details);
6741
6901
  }
6902
+ function errorEnvelope(error) {
6903
+ if (error instanceof ApiError) return {
6904
+ status: error.status,
6905
+ body: { error: {
6906
+ status: error.status,
6907
+ code: error.code,
6908
+ message: error.message,
6909
+ details: error.details
6910
+ } }
6911
+ };
6912
+ if (error instanceof z.ZodError) return {
6913
+ status: 400,
6914
+ body: { error: {
6915
+ code: "validation_error",
6916
+ message: "Invalid request",
6917
+ details: error.issues.map((issue) => ({
6918
+ path: issue.path.join("."),
6919
+ message: issue.message
6920
+ }))
6921
+ } }
6922
+ };
6923
+ console.error("Kanvil request failed", { name: error instanceof Error ? error.name : typeof error });
6924
+ return {
6925
+ status: 500,
6926
+ body: { error: {
6927
+ code: "internal_error",
6928
+ message: "The request could not be completed"
6929
+ } }
6930
+ };
6931
+ }
6742
6932
  function requireRow(row) {
6743
6933
  if (!row) fail(404, "not_found", "The requested record was not found");
6744
6934
  return row;
@@ -6770,7 +6960,13 @@ function json(value) {
6770
6960
  return JSON.parse(JSON.stringify(value, (_, item) => typeof item === "bigint" ? item.toString() : item));
6771
6961
  }
6772
6962
  const complete = (status) => closedStates.some((closed) => closed === status);
6773
- const hasScope = (actor, scope) => actor.scopes.includes("admin") || actor.scopes.includes(scope) || scope === "read" && actor.scopes.length > 0;
6963
+ const intakeHoldReason = "Untrusted intake: human triage required";
6964
+ const intakeQuarantined = (ticket) => ticket.holdReason === intakeHoldReason;
6965
+ const intakeOnly = (actor) => actor.scopes.length > 0 && actor.scopes.every((scope) => scope === "intake");
6966
+ const hasScope = (actor, scope, operation) => intakeOnly(actor) ? operation === "create_ticket" : actor.scopes.includes("admin") || actor.scopes.includes(scope) || scope === "read" && actor.scopes.some((value) => value !== "intake");
6967
+ function requireScope(actor, scope, operation) {
6968
+ if (!hasScope(actor, scope, operation)) fail(403, "insufficient_scope", `This credential requires ${scope} permission`);
6969
+ }
6774
6970
  const capabilities = (actor, granted) => granted.filter((permission) => hasScope(actor, permissionScope(permission)));
6775
6971
  const can = (ctx, permission) => capabilities(ctx.actor, ctx.permissions).includes(permission);
6776
6972
  function requirePermission(granted, permission, where) {
@@ -6821,8 +7017,8 @@ function ticketMatch(identifier) {
6821
7017
  const key = identifier.toUpperCase(), [, prefix, number] = issuedKey.exec(key) ?? [];
6822
7018
  return or(sql`${tickets.id}::text = ${identifier}`, eq(tickets.key, key), sql`${tickets.id} IN (SELECT ta.ticket_id FROM ${ticketAliases} ta WHERE ta.key = ${key})`, prefix && number ? and(eq(tickets.number, Number(number)), sql`${tickets.projectId} IN (SELECT pp.project_id FROM ${projectPrefixes} pp WHERE pp.prefix = ${prefix})`) : void 0);
6823
7019
  }
6824
- async function context(db, actor, scope, projectId, workspaceId, permission = scope === "read" ? null : scope === "admin" ? "project.manage" : "ticket.write") {
6825
- if (!hasScope(actor, scope)) fail(403, "insufficient_scope", `This credential requires ${scope} permission`);
7020
+ async function context(db, actor, scope, projectId, workspaceId, permission = scope === "read" ? null : scope === "admin" ? "project.manage" : "ticket.write", operation) {
7021
+ requireScope(actor, scope, operation);
6826
7022
  const ctx = baseContext(db, actor);
6827
7023
  if (workspaceId) {
6828
7024
  const access = await requireWorkspace(ctx, workspaceId, permission);
@@ -6998,11 +7194,26 @@ async function publish(ctx, type, ticketId, data) {
6998
7194
  data
6999
7195
  }]));
7000
7196
  }
7001
- const relationEvents = (type, relation) => [relation.sourceId, relation.targetId].map((ticketId) => ({
7197
+ const relationEvents = (type, relation, sides) => [relation.sourceId, relation.targetId].map((ticketId) => ({
7002
7198
  type,
7003
7199
  ticketId,
7004
- data: { relation }
7200
+ data: { relation },
7201
+ projectId: requireRow(sides.find((side) => side.id === ticketId)).projectId
7005
7202
  }));
7203
+ const deletedSide = alias(tickets, "deleted_side");
7204
+ const survivingSide = alias(tickets, "surviving_side");
7205
+ async function severedRelationEvents(ctx, ticketIds) {
7206
+ return (await ctx.db.select({
7207
+ relation: relations$1,
7208
+ ticketId: survivingSide.id,
7209
+ projectId: survivingSide.projectId
7210
+ }).from(relations$1).innerJoin(deletedSide, or(eq(deletedSide.id, relations$1.sourceId), eq(deletedSide.id, relations$1.targetId))).innerJoin(survivingSide, or(eq(survivingSide.id, relations$1.sourceId), eq(survivingSide.id, relations$1.targetId))).where(and(ticketIds ? inArray(deletedSide.id, [...ticketIds]) : eq(deletedSide.projectId, project(ctx).id), ne(survivingSide.projectId, project(ctx).id)))).map(({ relation, ticketId, projectId }) => ({
7211
+ type: "relation.removed",
7212
+ ticketId,
7213
+ projectId,
7214
+ data: { relation }
7215
+ }));
7216
+ }
7006
7217
  function ownsLiveClaim(ctx, claim) {
7007
7218
  return claim.ownerId === ctx.actor.ownerId && claim.expiresAt > /* @__PURE__ */ new Date() && hasScope(ctx.actor, "claim") && ctx.permissions.includes("ticket.write");
7008
7219
  }
@@ -7012,6 +7223,11 @@ async function assertWorkerClaim(ctx, ticket, claimId) {
7012
7223
  if (claim && (claim.id !== claimId || !ownsLiveClaim(ctx, claim))) fail(409, "claim_conflict", "An active matching claim is required for this worker write");
7013
7224
  if (!claim && (claimId || ticket.claimRequired)) fail(409, "claim_conflict", "This ticket requires current ownership for worker writes");
7014
7225
  }
7226
+ async function assertDefinitionMigration(ctx, affected) {
7227
+ if (!can(ctx, "project.manage")) fail(403, "forbidden", "Project administration permission is required");
7228
+ if (!ctx.actor.machine || !affected.length) return;
7229
+ if ((await ctx.db.select({ id: claims.id }).from(claims).where(inArray(claims.ticketId, affected.map((ticket) => ticket.id))).limit(1)).length) fail(409, "claim_conflict", "A claimed ticket blocks this definition change");
7230
+ }
7015
7231
  function createDomain(database, services = {}) {
7016
7232
  const handlers = {
7017
7233
  ...workspaceHandlers,
@@ -7089,6 +7305,7 @@ function createDomain(database, services = {}) {
7089
7305
  const input = spec.input.parse(value);
7090
7306
  const projectId = "projectId" in input ? input.projectId : void 0;
7091
7307
  const workspaceId = "workspaceId" in input && !workspaceIsFilter.has(name) ? input.workspaceId : void 0;
7308
+ if (intakeOnly(actor)) requireScope(actor, spec.scope, name);
7092
7309
  if (actor.machine && sessionOnly.has(name)) fail(403, "session_required", "Use a signed-in account for this action");
7093
7310
  if (actor.projectIds && spec.method !== "GET" && !projectId) fail(403, "insufficient_scope", "Project-bound credentials can only change their configured projects");
7094
7311
  const outgoing = [];
@@ -7102,19 +7319,20 @@ function createDomain(database, services = {}) {
7102
7319
  return parseOutput(name, json(await handler(ctx, input)));
7103
7320
  };
7104
7321
  const permission = operationPermission(name);
7105
- if (spec.method === "GET" || remoteReads.has(name)) return invoke(await context(database.db, actor, spec.scope, projectId, workspaceId, permission));
7322
+ if (spec.method === "GET" || remoteReads.has(name)) return invoke(await context(database.db, actor, spec.scope, projectId, workspaceId, permission, name));
7106
7323
  const requestId = "requestId" in input ? input.requestId : void 0;
7107
7324
  const reader = sourceReads[name];
7108
- if (reader && !(requestId && (await database.db.select({ id: receipts.requestId }).from(receipts).where(and(eq(receipts.actorId, actor.id), eq(receipts.requestId, requestId))).limit(1)).length)) read = await reader.read(withServices(await context(database.db, actor, spec.scope, projectId, workspaceId, permission)), input);
7325
+ if (reader && !(requestId && (await database.db.select({ id: receipts.requestId }).from(receipts).where(and(eq(receipts.actorId, actor.id), eq(receipts.requestId, requestId))).limit(1)).length)) read = await reader.read(withServices(await context(database.db, actor, spec.scope, projectId, workspaceId, permission, name)), input);
7109
7326
  const committed = await locked(async (tx) => {
7110
7327
  const hash = createHash("sha256").update(JSON.stringify({
7111
7328
  name,
7112
- input: reader ? reader.receipt(input) : input
7329
+ input: reader ? reader.receipt(input) : input,
7330
+ ...intakeOnly(actor) && { intakeCredential: actor.ownerId }
7113
7331
  })).digest("hex");
7114
7332
  const receipt = requestId ? (await tx.select().from(receipts).where(and(eq(receipts.actorId, actor.id), eq(receipts.requestId, requestId))).limit(1))[0] : void 0;
7115
7333
  if (receipt && (receipt.hash !== hash || receipt.operation !== name)) fail(409, "idempotency_conflict", "This requestId was already used for another write");
7116
7334
  if (receipt && (name === "delete_project" || name === "delete_workspace")) return parseOutput(name, receipt.result);
7117
- const ctx = await context(tx, actor, spec.scope, projectId, workspaceId, permission);
7335
+ const ctx = await context(tx, actor, spec.scope, projectId, workspaceId, permission, name);
7118
7336
  if (receipt) {
7119
7337
  if ((name === "create_time_entry" || name === "update_time_entry" || name === "delete_time_entry") && "ticketId" in input) await getTicket(ctx, input.ticketId);
7120
7338
  const prior = parseOutput(name, receipt.result);
@@ -7606,6 +7824,13 @@ const googleProfileSchema = z.object({
7606
7824
  picture: z.string().default("")
7607
7825
  });
7608
7826
  function createAuth(database, config) {
7827
+ const adapter = drizzleAdapter(database.db, {
7828
+ provider: "pg",
7829
+ schema: auth_schema_exports,
7830
+ transaction: true
7831
+ });
7832
+ const verification = new AsyncLocalStorage();
7833
+ const consumed = () => verification.getStore()?.row ?? null;
7609
7834
  const instance = createInstance(database, config);
7610
7835
  const credentials = socialCredentials(config);
7611
7836
  const mail = config.SMTP_URL && config.SMTP_FROM ? createMailSender(config.SMTP_URL, config.SMTP_FROM) : void 0;
@@ -7640,15 +7865,37 @@ function createAuth(database, config) {
7640
7865
  disableIdTokenSignIn: true
7641
7866
  };
7642
7867
  };
7643
- return betterAuth({
7868
+ const auth = betterAuth({
7644
7869
  appName: "Kanvil",
7645
7870
  baseURL: config.KANVIL_URL,
7646
7871
  secret: config.BETTER_AUTH_SECRET,
7647
- database: drizzleAdapter(database.db, {
7648
- provider: "pg",
7649
- schema: auth_schema_exports,
7650
- transaction: true
7651
- }),
7872
+ database: (options) => {
7873
+ const base = adapter(options);
7874
+ const update = async (input) => {
7875
+ const fields = Object.keys(input.update);
7876
+ if (input.model === "apikey" && fields.length > 0 && fields.every((field) => field === "lastRequest" || field === "updatedAt")) return await base.findOne({
7877
+ model: input.model,
7878
+ where: input.where
7879
+ }) ?? consumed();
7880
+ return base.update(input);
7881
+ };
7882
+ const incrementOne = async (input) => {
7883
+ const row = await base.incrementOne(input);
7884
+ const store = input.model === "apikey" ? verification.getStore() : void 0;
7885
+ if (!store) return row;
7886
+ if (row) store.row = row;
7887
+ else if (store.row && !await base.findOne({
7888
+ model: input.model,
7889
+ where: input.where.filter(({ field }) => field === "id")
7890
+ })) return store.row;
7891
+ return row;
7892
+ };
7893
+ return {
7894
+ ...base,
7895
+ update,
7896
+ incrementOne
7897
+ };
7898
+ },
7652
7899
  trustedOrigins: config.trustedOrigins,
7653
7900
  emailAndPassword: {
7654
7901
  enabled: true,
@@ -7797,6 +8044,14 @@ function createAuth(database, config) {
7797
8044
  }] : [] })
7798
8045
  ]
7799
8046
  });
8047
+ const verifyApiKey = Object.assign((input) => verification.run({}, () => auth.api.verifyApiKey(input)), auth.api.verifyApiKey);
8048
+ return {
8049
+ ...auth,
8050
+ api: {
8051
+ ...auth.api,
8052
+ verifyApiKey
8053
+ }
8054
+ };
7800
8055
  }
7801
8056
  //#endregion
7802
- export { subscriptions as $, createTicketSearch as A, blobObjects as B, DeliveryError as C, requireInstanceAdmin as D, createWriteLimiter as E, stagedUploadCutoff as F, members as G, eventClock as H, createObjectStore as I, profilePhotos as J, notificationPreferences as K, createPollingJob as L, createBlobStorage as M, decodeImage as N, requireSession as O, imageResponse as P, storageBackends as Q, attachmentContent as R, deliverBatch as S, createAccess as T, events as U, deliveryCheckpoints as V, instanceSettings as W, pushDevices as X, projects as Y, stagedUploads as Z, recipientColumns as _, deliverySecrets as a, receiveRepositoryWebhook as b, ownerBlocker as c, createDomain as d, tickets as et, fail as f, reachable as g, one as h, createInstance as i, createTicketIndex as j, readCalendarFeed as k, appendEvents as l, notify as m, emailCodesAvailable as n, workspaces as nt, otherActiveAdministrator as o, json as p, notifications as q, signInProviders as r, matchesOIDCBinding as rt, ownedWorkspaces as s, createAuth as t, workspaceMembers as tt, assertVersion as u, requireRow as v, createHttpSender as w, defaultSenders as x, processImports as y, attachments as z };
8057
+ export { stagedUploads as $, requireSession as A, attachmentContent as B, defaultSenders as C, createAccess as D, createHttpSender as E, decodeImage as F, events as G, blobObjects as H, imageResponse as I, notificationPreferences as J, instanceSettings as K, stagedUploadCutoff as L, createTicketSearch as M, createTicketIndex as N, createWriteLimiter as O, createBlobStorage as P, pushDevices as Q, createObjectStore as R, receiveRepositoryWebhook as S, DeliveryError as T, deliveryCheckpoints as U, attachments as V, eventClock as W, profilePhotos as X, notifications as Y, projects as Z, one as _, deliverySecrets as a, matchesOIDCBinding as at, requireRow as b, ownerBlocker as c, createDomain as d, storageBackends as et, errorEnvelope as f, notify as g, json as h, createInstance as i, workspaces as it, readCalendarFeed as j, requireInstanceAdmin as k, appendEvents as l, intakeOnly as m, emailCodesAvailable as n, tickets as nt, otherActiveAdministrator as o, fail as p, members as q, signInProviders as r, workspaceMembers as rt, ownedWorkspaces as s, createAuth as t, subscriptions as tt, assertVersion as u, reachable as v, deliverBatch as w, processImports as x, recipientColumns as y, createPollingJob as z };