@snagset/server 0.1.15 → 0.1.18
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/LICENSE +661 -0
- package/README.md +9 -10
- package/dist/app.js +52 -48
- package/dist/app.js.map +1 -1
- package/dist/bin.js +31 -32
- package/dist/bin.js.map +1 -1
- package/dist/boot.js +21 -21
- package/dist/boot.js.map +1 -1
- package/dist/config.generated.js +6 -8
- package/dist/config.generated.js.map +1 -1
- package/dist/config.js +14 -14
- package/dist/config.js.map +1 -1
- package/dist/crypto.js +1 -1
- package/dist/crypto.js.map +1 -1
- package/dist/db/connect.js +1 -1
- package/dist/db/migrate.js +11 -11
- package/dist/db/rls.js +3 -3
- package/dist/db/rls.js.map +1 -1
- package/dist/domain.js +73 -5
- package/dist/domain.js.map +1 -1
- package/dist/explain.js +1 -1
- package/dist/instance/address.js +3 -3
- package/dist/instance/address.js.map +1 -1
- package/dist/instance/settings.js +1 -1
- package/dist/integrations/github.js +3 -3
- package/dist/integrations/github.js.map +1 -1
- package/dist/integrations/inbound.js +3 -3
- package/dist/integrations/inbound.js.map +1 -1
- package/dist/integrations/linear.js +1 -1
- package/dist/integrations/linear.js.map +1 -1
- package/dist/integrations/outbox.js +4 -4
- package/dist/integrations/outbox.js.map +1 -1
- package/dist/integrations/slack.js +2 -2
- package/dist/integrations/slack.js.map +1 -1
- package/dist/jobs/index.js +3 -3
- package/dist/jobs/reaper.js +11 -11
- package/dist/jobs/reaper.js.map +1 -1
- package/dist/jobs/set.js +3 -3
- package/dist/jobs/set.js.map +1 -1
- package/dist/jobs/soon.js +3 -3
- package/dist/keys.js +3 -3
- package/dist/keys.js.map +1 -1
- package/dist/listen.js +3 -3
- package/dist/listen.js.map +1 -1
- package/dist/mail/assigned.js +328 -0
- package/dist/mail/assigned.js.map +1 -0
- package/dist/mail/digest.js +3 -3
- package/dist/mail/digest.js.map +1 -1
- package/dist/mail/index.js +2 -2
- package/dist/mail/links.js +5 -5
- package/dist/mail/mailer.js +2 -2
- package/dist/mail/notify.js +85 -20
- package/dist/mail/notify.js.map +1 -1
- package/dist/mail/resend.js +1 -1
- package/dist/mail/resolve.js +1 -1
- package/dist/mail/resolve.js.map +1 -1
- package/dist/mail/smtp.js +1 -1
- package/dist/mail/unsubscribe.js +1 -1
- package/dist/net/publicFetch.js +1 -1
- package/dist/orgs.js +5 -5
- package/dist/orgs.js.map +1 -1
- package/dist/plan.js +1 -1
- package/dist/projects.js +4 -4
- package/dist/projects.js.map +1 -1
- package/dist/routes/account.js +1 -1
- package/dist/routes/account.js.map +1 -1
- package/dist/routes/activity.js +1 -1
- package/dist/routes/anchorReports.js +9 -9
- package/dist/routes/anchorReports.js.map +1 -1
- package/dist/routes/assignees.js +4 -4
- package/dist/routes/assignees.js.map +1 -1
- package/dist/routes/attachments.js +6 -6
- package/dist/routes/attachments.js.map +1 -1
- package/dist/routes/auth.js +4 -4
- package/dist/routes/bodyLimit.js +5 -5
- package/dist/routes/bodyLimit.js.map +1 -1
- package/dist/routes/commentEdit.js +5 -5
- package/dist/routes/commentEdit.js.map +1 -1
- package/dist/routes/comments.js +24 -24
- package/dist/routes/comments.js.map +1 -1
- package/dist/routes/cors.js +33 -15
- package/dist/routes/cors.js.map +1 -1
- package/dist/routes/instance.js +3 -3
- package/dist/routes/instance.js.map +1 -1
- package/dist/routes/integrations.js +14 -11
- package/dist/routes/integrations.js.map +1 -1
- package/dist/routes/invites.js +7 -7
- package/dist/routes/invites.js.map +1 -1
- package/dist/routes/mcpAuth.js +13 -13
- package/dist/routes/mcpAuth.js.map +1 -1
- package/dist/routes/mcpToken.js +4 -4
- package/dist/routes/mcpToken.js.map +1 -1
- package/dist/routes/mcpTokens.js +27 -14
- package/dist/routes/mcpTokens.js.map +1 -1
- package/dist/routes/members.js +9 -5
- package/dist/routes/members.js.map +1 -1
- package/dist/routes/owner.js +13 -13
- package/dist/routes/owner.js.map +1 -1
- package/dist/routes/reviewLinks.js +4 -4
- package/dist/routes/reviewLinks.js.map +1 -1
- package/dist/routes/rounds.js +3 -3
- package/dist/routes/rounds.js.map +1 -1
- package/dist/routes/sessions.js +186 -71
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/sites.js +9 -9
- package/dist/routes/sites.js.map +1 -1
- package/dist/routes/threads.js +144 -90
- package/dist/routes/threads.js.map +1 -1
- package/dist/routes/widget.js +5 -5
- package/dist/routes/widget.js.map +1 -1
- package/dist/serverless.js +4 -4
- package/dist/serverless.js.map +1 -1
- package/dist/session.js +8 -8
- package/dist/session.js.map +1 -1
- package/dist/storage/bucket.js +4 -4
- package/dist/storage/bucket.js.map +1 -1
- package/dist/storage/settings.js +4 -4
- package/dist/storage/settings.js.map +1 -1
- package/dist/studioReviewers.js +4 -4
- package/dist/vendor/adapters/outbound/fence.js +2 -2
- package/dist/vendor/adapters/outbound/sanitise.js +14 -14
- package/dist/vendor/mcp/bin.js +4 -4
- package/dist/vendor/mcp/client.js +3 -3
- package/dist/vendor/mcp/server.js +3 -3
- package/dist/vendor/mcp/source.js +13 -13
- package/dist/vendor/mcp/view.js +15 -15
- package/dist/vendor/types/brief.js +2 -2
- package/dist/vendor/types/index.js +1 -1
- package/dist/vendor/types/names.js +20 -18
- package/dist/vendor/widget/object.js +3 -3
- package/dist/vendor/widget/predicates.js +38 -39
- package/package.json +1 -5
- package/static/dashboard/assets/{CompositeItem-DcTtfhMj.js → CompositeItem-D1SCvKo_.js} +1 -1
- package/static/dashboard/assets/{ConfirmDialog-C7IpllNH.js → ConfirmDialog-DdUOWt96.js} +1 -1
- package/static/dashboard/assets/{CopyButton-C0gZH7mu.js → CopyButton-DX7vax3M.js} +1 -1
- package/static/dashboard/assets/{FieldItemContext-D0qd3DQv.js → FieldItemContext-CMpCHNX8.js} +1 -1
- package/static/dashboard/assets/{InstallTag-DTw-EtuD.js → InstallTag-DkYPsEI0.js} +1 -1
- package/static/dashboard/assets/{SaveState-Bk30DUJ3.js → SaveState-D0Hl8cny.js} +1 -1
- package/static/dashboard/assets/{_app-CAmVh6lR.js → _app-CcUZ9WT2.js} +1 -1
- package/static/dashboard/assets/{_app-gSqPuozu.js → _app-CnG-wKIX.js} +1 -1
- package/static/dashboard/assets/{activity-zmYVgnQm.js → activity-DnvsedIb.js} +1 -1
- package/static/dashboard/assets/{arrow-left-CqsX1hoI.js → arrow-left-4jbTF1bA.js} +1 -1
- package/static/dashboard/assets/{avatar-08tu7BM9.js → avatar-BKSc7tnv.js} +1 -1
- package/static/dashboard/assets/{badge-CgsMqJa4.js → badge-aWVtPffe.js} +1 -1
- package/static/dashboard/assets/{card-CPsX9FTO.js → card-Bz0VdsxC.js} +1 -1
- package/static/dashboard/assets/{check-CJRIq1X0.js → check-Ti2IOlgc.js} +1 -1
- package/static/dashboard/assets/{chevron-right-BEmxTSY1.js → chevron-right-g-zbIopD.js} +1 -1
- package/static/dashboard/assets/{copy-xpnXaoOA.js → copy-BwBuD0FO.js} +1 -1
- package/static/dashboard/assets/{dialog-CxlCupuA.js → dialog-DdpHZobU.js} +2 -2
- package/static/dashboard/assets/{dropdown-menu-CGMdCM3V.js → dropdown-menu-CB2L3nig.js} +1 -1
- package/static/dashboard/assets/{image-off-XJf-5RS9.js → image-off-w_ej9pIJ.js} +1 -1
- package/static/dashboard/assets/index-DAgnfv_E.css +1 -0
- package/static/dashboard/assets/{index-CR63M02l.js → index-DtF8YHqn.js} +3 -3
- package/static/dashboard/assets/{invite-CcYboRQr.js → invite-Dy3iQjzf.js} +1 -1
- package/static/dashboard/assets/{label-Ds2N_0qv.js → label-Dj8L94iS.js} +1 -1
- package/static/dashboard/assets/{layout-grid-Dw9tiVXr.js → layout-grid-DuweH0Vl.js} +1 -1
- package/static/dashboard/assets/me-7-r9OeMR.js +1 -0
- package/static/dashboard/assets/{parts-BYKOSeUb.js → parts-CA4cbfy3.js} +1 -1
- package/static/dashboard/assets/{plus-DvfThULG.js → plus-CEWgXDLj.js} +1 -1
- package/static/dashboard/assets/s._site-D69M50OI.js +1 -0
- package/static/dashboard/assets/{s._site.comments-DTMz620W.js → s._site.comments-DNJ2SE5c.js} +1 -1
- package/static/dashboard/assets/{s._site.reviewers-CDhFQ_du.js → s._site.reviewers-B07NzSWj.js} +1 -1
- package/static/dashboard/assets/{s._site.settings-BWE3eC3w.js → s._site.settings-B577G5iG.js} +1 -1
- package/static/dashboard/assets/{setup-BP-kGzjw.js → setup-DcjjtB3g.js} +1 -1
- package/static/dashboard/assets/{sign-in-Brn6kGrC.js → sign-in-gfvOs6Jw.js} +1 -1
- package/static/dashboard/assets/{sign-up-DxeJ0RND.js → sign-up-CFJt7ibH.js} +1 -1
- package/static/dashboard/assets/{sites-BSP6CMHL.js → sites-DbB5cjz5.js} +1 -1
- package/static/dashboard/assets/studio-BO-MHFqc.js +1 -0
- package/static/dashboard/assets/{studio-BqA_XZwz.js → studio-D9GxJaSF.js} +1 -1
- package/static/dashboard/assets/{studio.index-CjkyAcKF.js → studio.index-DktAxIPT.js} +1 -1
- package/static/dashboard/assets/studio.integrations-CbLyqPLX.js +1 -0
- package/static/dashboard/assets/{studio.members-CECLI69a.js → studio.members-DFtIL3JS.js} +1 -1
- package/static/dashboard/assets/{sun-B5BhapBo.js → sun-Bg7U-6Y0.js} +1 -1
- package/static/dashboard/assets/{switch-C4RnvllL.js → switch-CTN6hIca.js} +1 -1
- package/static/dashboard/assets/{table-DOCWCO9W.js → table-zQ3pj50_.js} +1 -1
- package/static/dashboard/assets/{useAriaLabelledBy-C6VUMzW5.js → useAriaLabelledBy-BsNncGbx.js} +1 -1
- package/static/dashboard/assets/{useBaseQuery-ldF9uyzz.js → useBaseQuery-DSqwWD_O.js} +1 -1
- package/static/dashboard/assets/useQuery-OgrKuHbc.js +1 -0
- package/static/dashboard/assets/useRender-DOFVvGfR.js +1 -0
- package/static/dashboard/assets/{useValueChanged-BVjcYor6.js → useValueChanged-B9k7l-mp.js} +1 -1
- package/static/dashboard/assets/{users-Ji1BLjAt.js → users-CRiOyD0f.js} +1 -1
- package/static/dashboard/assets/{welcome-DzAUdMme.js → welcome-DT2DB4jN.js} +1 -1
- package/static/dashboard/assets/{widget-signin-CKUvfq3K.js → widget-signin-CC9owVvs.js} +1 -1
- package/static/dashboard/assets/{you-2QsQK86v.js → you-CtcXklc_.js} +1 -1
- package/static/dashboard/assets/{zod-D-eI47yt.js → zod-CLxHj89d.js} +1 -1
- package/static/dashboard/index.html +2 -2
- package/static/widget/snag.capture.js +1 -1
- package/static/widget/snag.dock.js +5 -5
- package/static/widget/snag.runtime.js +2 -2
- package/static/widget/snag.screenshot.js +1 -1
- package/static/dashboard/assets/index-Ddi7bl87.css +0 -1
- package/static/dashboard/assets/me-BbIUR0d4.js +0 -1
- package/static/dashboard/assets/s._site-D7IqF1qB.js +0 -1
- package/static/dashboard/assets/studio-BW7OdRP1.js +0 -1
- package/static/dashboard/assets/studio.integrations-NDAXAPKG.js +0 -1
- package/static/dashboard/assets/useQuery-VzaemGSO.js +0 -1
- package/static/dashboard/assets/useRender-CRLtPZ-q.js +0 -1
package/dist/routes/threads.js
CHANGED
|
@@ -4,6 +4,7 @@ import { THREAD_REF_PREFIX } from "../vendor/types/names.js";
|
|
|
4
4
|
import { ID_PREFIX } from "../vendor/types/names.js";
|
|
5
5
|
import { csrfOk } from "./owner.js";
|
|
6
6
|
import { queueCommentNotice, queueResolvedNotice } from "../mail/notify.js";
|
|
7
|
+
import { dropAssignedBy, queueAssignedNotice } from "../mail/assigned.js";
|
|
7
8
|
import { effectiveWriteMode } from "./sessions.js";
|
|
8
9
|
import { mcpAllows, scopeDenied, checkWriteBudget, budgetExhausted, recordAgentWrite, agentReviewer, } from "./mcpAuth.js";
|
|
9
10
|
import { inPool } from "./assignees.js";
|
|
@@ -12,13 +13,13 @@ import { queueIntegrationEvent } from "../integrations/outbox.js";
|
|
|
12
13
|
import { inOrg } from "../db/scope.js";
|
|
13
14
|
import { isStudioModerator } from "../studioReviewers.js";
|
|
14
15
|
/**
|
|
15
|
-
* Wire id ⇄ row id
|
|
16
|
+
* Wire id ⇄ row id, verbatim: *"id uuid PRIMARY KEY — client-minted
|
|
16
17
|
* UUIDv7; wire form 'thr_…'"*.
|
|
17
18
|
*
|
|
18
19
|
* The row is a bare uuid so it indexes and joins as one; the prefixed form is
|
|
19
20
|
* the identity everything OUTSIDE the database uses, because a bare uuid in a
|
|
20
21
|
* webhook payload, a GitHub issue marker or a support ticket tells you nothing
|
|
21
|
-
* about what you are looking at
|
|
22
|
+
* about what you are looking at.
|
|
22
23
|
*
|
|
23
24
|
* Both directions live here rather than at each call site. The widget mints
|
|
24
25
|
* the id, so the very first POST arrives prefixed, and a server that stores
|
|
@@ -33,10 +34,10 @@ function rowId(wire, prefix) {
|
|
|
33
34
|
}
|
|
34
35
|
const wireId_ = (row, prefix) => prefix + row;
|
|
35
36
|
/**
|
|
36
|
-
*
|
|
37
|
+
* The envelope. `details` was missing from this file's copy while
|
|
37
38
|
* `comments.ts` had it, so every `field_not_permitted` here — including the
|
|
38
39
|
* long-standing refusal to move a thread between rounds — shipped without the
|
|
39
|
-
* `details.field`
|
|
40
|
+
* `details.field` registers for that code. A client cannot mark the
|
|
40
41
|
* offending input without it.
|
|
41
42
|
*/
|
|
42
43
|
function fail(c, status, code, message, details) {
|
|
@@ -44,8 +45,8 @@ function fail(c, status, code, message, details) {
|
|
|
44
45
|
}
|
|
45
46
|
/**
|
|
46
47
|
* Every request runs inside a transaction with `app.org_id` set, which is what
|
|
47
|
-
* makes
|
|
48
|
-
* connecting role can bypass them
|
|
48
|
+
* makes the RLS policies do anything at all. It is set even where the
|
|
49
|
+
* connecting role can bypass them — the day someone points DATABASE_URL
|
|
49
50
|
* at a properly restricted role, every query here has to already be correct,
|
|
50
51
|
* and a `SET LOCAL` added later would be added under incident conditions.
|
|
51
52
|
*/
|
|
@@ -57,12 +58,12 @@ function fail(c, status, code, message, details) {
|
|
|
57
58
|
* added to each by hand after a link session's list was caught carrying a
|
|
58
59
|
* private reply: a rule answered in three places drifts.
|
|
59
60
|
*
|
|
60
|
-
* ── TEAM-ONLY
|
|
61
|
+
* ── TEAM-ONLY ──
|
|
61
62
|
*
|
|
62
63
|
* A `private` reply is for the studio's eyes and never reaches a reviewer
|
|
63
64
|
* session or an agent. The owner sees all of them.
|
|
64
65
|
*
|
|
65
|
-
* ── TOMBSTONES, FOR THE OWNER ONLY
|
|
66
|
+
* ── TOMBSTONES, FOR THE OWNER ONLY ──
|
|
66
67
|
*
|
|
67
68
|
* A deleted comment is a row with `body` NULL. Everyone else gets it filtered
|
|
68
69
|
* out, as before — the widget drops it and has nothing to render a hole with.
|
|
@@ -73,10 +74,10 @@ function fail(c, status, code, message, details) {
|
|
|
73
74
|
* on the studio's screen as a tombstone exactly until the next poll replaced
|
|
74
75
|
* the row, and then gone.
|
|
75
76
|
*
|
|
76
|
-
* The tombstone is
|
|
77
|
-
* NAME only — plus `createdAt`, which
|
|
77
|
+
* The tombstone is the shape — id, NULL body, deletedAt, the author's
|
|
78
|
+
* NAME only — plus `createdAt`, which leaves out and the studio needs:
|
|
78
79
|
* a hole is placed in time by when it was written. `seq` stays server-side
|
|
79
|
-
* for
|
|
80
|
+
* for the reason, and the author's id and kind go with the words.
|
|
80
81
|
*/
|
|
81
82
|
function commentsJson(tx, asOwner) {
|
|
82
83
|
return tx `COALESCE(
|
|
@@ -96,7 +97,7 @@ function commentsJson(tx, asOwner) {
|
|
|
96
97
|
'[]'::json)`;
|
|
97
98
|
}
|
|
98
99
|
export function threadRoutes(sql,
|
|
99
|
-
/** The 10-second cache
|
|
100
|
+
/** The 10-second cache, not a fresh SELECT per handler. Every
|
|
100
101
|
* route here starts by resolving `{uid}`, and on a database one round trip
|
|
101
102
|
* away that lookup was costing more than the work it introduced. */
|
|
102
103
|
projects,
|
|
@@ -105,8 +106,8 @@ projects,
|
|
|
105
106
|
* not need to know how storage is configured. */
|
|
106
107
|
features,
|
|
107
108
|
/**
|
|
108
|
-
* Is a mail transport configured
|
|
109
|
-
* and for the same reason: with no transport,
|
|
109
|
+
* Is a mail transport configured ? Same gate `commentRoutes` takes,
|
|
110
|
+
* and for the same reason: with no transport, the design wants notifications
|
|
110
111
|
* "off and say so, rather than silently queueing" — rows that would otherwise
|
|
111
112
|
* accumulate for the life of the install and arrive in one burst the day
|
|
112
113
|
* somebody configures Resend.
|
|
@@ -115,11 +116,13 @@ notifications = async () => false,
|
|
|
115
116
|
/** Where this instance lives (SNAGSET_BASE_URL) — the brief's screenshot links are absolute. */
|
|
116
117
|
baseUrl = async () => "http://localhost:8080",
|
|
117
118
|
/**
|
|
118
|
-
*
|
|
119
|
+
* The site's tracker connections — id, provider, and whether
|
|
119
120
|
* issues are made on every new thread. Injected so this file never opens
|
|
120
121
|
* a token; the worker does that.
|
|
121
122
|
*/
|
|
122
|
-
trackers = async () => []
|
|
123
|
+
trackers = async () => [],
|
|
124
|
+
/** How long an assignment waits before its email (SNAGSET_ASSIGN_MAIL_DELAY_S). */
|
|
125
|
+
assignDelayMs) {
|
|
123
126
|
const app = new Hono();
|
|
124
127
|
/** Resolve `{uid}`, and 404 rather than leak whether a uid exists. */
|
|
125
128
|
const project = (uid) => projects.get(uid);
|
|
@@ -129,7 +132,7 @@ trackers = async () => []) {
|
|
|
129
132
|
// request and, worse, ran AFTER the real one and overwrote
|
|
130
133
|
// `Access-Control-Allow-Headers` with a hand-typed `content-type` — harmless
|
|
131
134
|
// only because a preflight never reaches this far.
|
|
132
|
-
/**
|
|
135
|
+
/** What the widget reads at mount to know what this deployment can do. */
|
|
133
136
|
app.get("/api/v1/projects/:uid/config", async (c) => {
|
|
134
137
|
const p = await project(c.req.param("uid"));
|
|
135
138
|
if (!p)
|
|
@@ -137,7 +140,7 @@ trackers = async () => []) {
|
|
|
137
140
|
c.header("x-snagset-config-version", String(p.config_version));
|
|
138
141
|
c.header("ETag", `"${p.config_etag}"`);
|
|
139
142
|
/**
|
|
140
|
-
* CACHEABLE, briefly —
|
|
143
|
+
* CACHEABLE, briefly — the decision makes this the stub's visibility check, fetched
|
|
141
144
|
* once per visitor per page-load-cluster rather than per page. 60 seconds:
|
|
142
145
|
* long enough that a browsing session costs one request, short enough that
|
|
143
146
|
* flipping the audience in the dashboard lands within a minute.
|
|
@@ -148,7 +151,7 @@ trackers = async () => []) {
|
|
|
148
151
|
// full body to a stranger origin, and the split below would be theatre.
|
|
149
152
|
c.header("Vary", "Origin");
|
|
150
153
|
/**
|
|
151
|
-
*
|
|
154
|
+
* The ORIGIN-AWARE BODY, finally conformed to — the maintainer's
|
|
152
155
|
* question, verbatim: "the api gets the url that should be there and then
|
|
153
156
|
* just validates in the frontend?" Which is this design: the server never
|
|
154
157
|
* learns the page's URL (the browser sends only its origin, which CORS
|
|
@@ -158,14 +161,14 @@ trackers = async () => []) {
|
|
|
158
161
|
* for it is printed in their page source.
|
|
159
162
|
*
|
|
160
163
|
* A stranger origin still gets a 200 with the public subset (writeMode
|
|
161
|
-
* included) rather than a 403: the fetch is also how
|
|
164
|
+
* included) rather than a 403: the fetch is also how the origin
|
|
162
165
|
* suggestion is born, and the stub on an archive.org mirror needs a
|
|
163
166
|
* readable "writable: false" to stay dormant — a CORS-omitted header would
|
|
164
167
|
* do that too, but this route is public-read so the suggestion flow works.
|
|
165
168
|
* `originAllowed` is absent (never false) when there is no Origin header:
|
|
166
169
|
* same-origin pages and curl are not strangers.
|
|
167
170
|
*/
|
|
168
|
-
//
|
|
171
|
+
// The switch, layer 6: a paused site is writable from
|
|
169
172
|
// nowhere, and the stub reads `writable` and stays dormant. Said before
|
|
170
173
|
// the origin verdict, because "switched off" is the more useful sentence.
|
|
171
174
|
const paused = p.status !== "active";
|
|
@@ -179,9 +182,9 @@ trackers = async () => []) {
|
|
|
179
182
|
// mode is a complete product, not a degraded one.
|
|
180
183
|
features: await features(p.org_id),
|
|
181
184
|
/**
|
|
182
|
-
* EFFECTIVE, not the raw column —
|
|
185
|
+
* EFFECTIVE, not the raw column — turns this field into the thing
|
|
183
186
|
* that decides whether every visitor sees the toolbar, and the raw value
|
|
184
|
-
* lies during
|
|
187
|
+
* lies during the grace: a site 25 hours old still says `open` while
|
|
185
188
|
* every write is already refused. The session route learned this first
|
|
186
189
|
* (see effectiveWriteMode's own comment); the config route repeating the
|
|
187
190
|
* raw column would show a toolbar nobody can type into.
|
|
@@ -191,9 +194,9 @@ trackers = async () => []) {
|
|
|
191
194
|
};
|
|
192
195
|
if (!writable)
|
|
193
196
|
return c.json({ ...base, reason: paused ? "project_paused" : "origin_not_allowed" });
|
|
194
|
-
// T10
|
|
197
|
+
// T10/: the round HISTORY rides the config the widget already fetches
|
|
195
198
|
// at mount, so the dock's selector costs no extra request. Behind the
|
|
196
|
-
// origin split with `keyPrefix
|
|
199
|
+
// origin split with `keyPrefix`: the least sensitive PRIVATE things
|
|
197
200
|
// on the project, which is not the same as public.
|
|
198
201
|
const rounds = await inOrg(sql, p.org_id, (tx) => tx `
|
|
199
202
|
SELECT number, name, closed_at IS NOT NULL AS closed
|
|
@@ -207,7 +210,7 @@ trackers = async () => []) {
|
|
|
207
210
|
});
|
|
208
211
|
});
|
|
209
212
|
/**
|
|
210
|
-
*
|
|
213
|
+
* The list, endpoint 7, which the route table marks **W/D**.
|
|
211
214
|
*
|
|
212
215
|
* ONE endpoint, TWO audiences, and the credential decides the scope. That is
|
|
213
216
|
* the spec's design and not a shortcut: a dashboard is not a different kind
|
|
@@ -224,7 +227,7 @@ trackers = async () => []) {
|
|
|
224
227
|
* cross-origin credentialed read is refused by the browser whatever this
|
|
225
228
|
* returns.
|
|
226
229
|
*
|
|
227
|
-
* `route` is the generated projection off the anchor
|
|
230
|
+
* `route` is the generated projection off the anchor, so filtering by
|
|
228
231
|
* it is an index scan rather than a JSONB walk on every row.
|
|
229
232
|
*/
|
|
230
233
|
app.get("/api/v1/projects/:uid/threads", async (c) => {
|
|
@@ -238,7 +241,7 @@ trackers = async () => []) {
|
|
|
238
241
|
const owner = c.get("owner");
|
|
239
242
|
const asOwner = !!owner && owner.orgId === p.org_id;
|
|
240
243
|
/**
|
|
241
|
-
* An MCP token with `threads:read`
|
|
244
|
+
* An MCP token with `threads:read` reads with the studio's
|
|
242
245
|
* FILTERS but not the studio's PRIVACY, and the split is deliberate.
|
|
243
246
|
*
|
|
244
247
|
* Filters: an agent asked to "list the open snags on checkout" needs
|
|
@@ -246,10 +249,10 @@ trackers = async () => []) {
|
|
|
246
249
|
* Denying those would make `list_threads` return the latest open round of
|
|
247
250
|
* the default environment and call it the project.
|
|
248
251
|
*
|
|
249
|
-
* Privacy: `private` threads and team-only replies
|
|
252
|
+
* Privacy: `private` threads and team-only replies stay OUT. The
|
|
250
253
|
* scope table grants "list and read threads, comments, anchor state" and
|
|
251
|
-
* says nothing about private, while
|
|
252
|
-
* minimalism* and
|
|
254
|
+
* says nothing about private, while the design is *scope
|
|
255
|
+
* minimalism* and separates `context:read` precisely because the
|
|
253
256
|
* personal-data-bearing half should not ride along by default. A team-only
|
|
254
257
|
* note is the studio talking about the client; an agent that reads one and
|
|
255
258
|
* quotes it into a pull request has published it.
|
|
@@ -265,8 +268,8 @@ trackers = async () => []) {
|
|
|
265
268
|
// Better a sentence naming the missing scope than a confident short list.
|
|
266
269
|
if (mcp && !asAgent)
|
|
267
270
|
return scopeDenied(c, "threads:read");
|
|
268
|
-
//
|
|
269
|
-
// gated the same way (`asOwner ?
|
|
271
|
+
// The filters. `status` and `q` stay dashboard-only. `round` used to be
|
|
272
|
+
// gated the same way (`asOwner ? …: null`), which is the bug: a
|
|
270
273
|
// review-link session's `?round` was silently discarded and every reader
|
|
271
274
|
// without an owner cookie saw every round's pins forever, including ones
|
|
272
275
|
// long since closed. A reviewer session may now ask for a round exactly
|
|
@@ -276,7 +279,7 @@ trackers = async () => []) {
|
|
|
276
279
|
const requestedRound = Number(c.req.query("round")) || null;
|
|
277
280
|
const q = asStudio ? (c.req.query("q") ?? "").trim() : "";
|
|
278
281
|
/**
|
|
279
|
-
* `?assignee=rev_…|unassigned` —
|
|
282
|
+
* `?assignee=rev_…|unassigned` — the triage filter ("what has
|
|
280
283
|
* nobody picked up"). `unassigned` is a word rather than an empty value
|
|
281
284
|
* because `assignee=` with nothing after it is indistinguishable from an
|
|
282
285
|
* absent parameter on the wire. Studio-only, like status and q: a filter
|
|
@@ -291,7 +294,7 @@ trackers = async () => []) {
|
|
|
291
294
|
}
|
|
292
295
|
}
|
|
293
296
|
/**
|
|
294
|
-
* `?since=` —
|
|
297
|
+
* `?since=` — the delta poll, and the whole realtime story.
|
|
295
298
|
*
|
|
296
299
|
* *"`GET …/threads?since=<iso>` returns everything with `updated_at >
|
|
297
300
|
* since`, plus the `deleted[]` tombstone array. The client keeps
|
|
@@ -303,14 +306,14 @@ trackers = async () => []) {
|
|
|
303
306
|
* forever in a background tab.
|
|
304
307
|
*/
|
|
305
308
|
/**
|
|
306
|
-
* `?key=SN-14` —
|
|
309
|
+
* `?key=SN-14` — the server-side permalink, and the thing that makes
|
|
307
310
|
* a shared link survive a round.
|
|
308
311
|
*
|
|
309
312
|
* `?snag_t=SN-14` resolves LOCALLY in the widget, against the list it
|
|
310
313
|
* already holds. That list is round-scoped: a non-owner reader defaults to
|
|
311
314
|
* the latest OPEN round. So a permalink to a thread from a closed round —
|
|
312
315
|
* which is most permalinks anyone bothers to share, since they share the
|
|
313
|
-
* ones that mattered — found nothing and showed F-
|
|
316
|
+
* ones that mattered — found nothing and showed F-the "we could not find
|
|
314
317
|
* that comment". The thread was there the whole time, one round back.
|
|
315
318
|
*
|
|
316
319
|
* An exact match on `UNIQUE (project_id, key)`, so at most one row, and it
|
|
@@ -318,7 +321,7 @@ trackers = async () => []) {
|
|
|
318
321
|
* named a specific thread, which is a stronger statement of intent than
|
|
319
322
|
* any default. Privacy is not ignored — a private thread is absent for a
|
|
320
323
|
* non-owner, which reads as "no such key" rather than confirming it exists
|
|
321
|
-
* (
|
|
324
|
+
* (the rule for `GET /threads/{id}`).
|
|
322
325
|
*/
|
|
323
326
|
const key = c.req.query("key")?.trim().toUpperCase();
|
|
324
327
|
const sinceRaw = c.req.query("since");
|
|
@@ -333,7 +336,7 @@ trackers = async () => []) {
|
|
|
333
336
|
/**
|
|
334
337
|
* ── WHY `visible` IS A COLUMN AND NOT A FILTER ──
|
|
335
338
|
*
|
|
336
|
-
*
|
|
339
|
+
* Acceptance A36: a thread the studio marks `private` must land
|
|
337
340
|
* in the caller's `deleted[]` so a cached copy is DROPPED. With the
|
|
338
341
|
* predicate in the WHERE clause the row simply stops being returned, which
|
|
339
342
|
* is indistinguishable from "nothing changed" — the client keeps showing a
|
|
@@ -342,7 +345,7 @@ trackers = async () => []) {
|
|
|
342
345
|
*
|
|
343
346
|
* Only the two genuine VISIBILITY conditions are computed. Status, search,
|
|
344
347
|
* environment and round are FILTERS: status does not hide a thread, and a
|
|
345
|
-
* thread can never change environment or round (
|
|
348
|
+
* thread can never change environment or round (`environment_id` is
|
|
346
349
|
* set at creation and a thread never moves), so none of them can take a
|
|
347
350
|
* client from seeing a thread to not seeing it.
|
|
348
351
|
*
|
|
@@ -389,7 +392,7 @@ trackers = async () => []) {
|
|
|
389
392
|
JOIN environments e ON e.id = t.environment_id
|
|
390
393
|
-- Joined for the round filter, and the join is the fix for a real
|
|
391
394
|
-- trap: threads.round_number is the thread's position WITHIN its round
|
|
392
|
-
-- (
|
|
395
|
+
-- (-- the number on the pin), while rounds.number is which
|
|
393
396
|
-- round it is. Two different integers whose names differ by one word,
|
|
394
397
|
-- and filtering on the first is how round=1 returned the single thread
|
|
395
398
|
-- that happened to be first in its round.
|
|
@@ -421,7 +424,7 @@ trackers = async () => []) {
|
|
|
421
424
|
: tx `AND t.assignee_id = ${assigneeFilter}::uuid`}
|
|
422
425
|
-- Absent round query param: an OWNER still sees every round
|
|
423
426
|
-- (unchanged), but a non-owner reader defaults to the latest OPEN
|
|
424
|
-
-- round, so a fresh page load is
|
|
427
|
+
-- round, so a fresh page load is -clean rather than showing a
|
|
425
428
|
-- round that closed weeks ago. The ORDER BY number DESC LIMIT 1
|
|
426
429
|
-- correlated subquery is cheap here -- one project's round table
|
|
427
430
|
-- is a handful of rows, never a scan of threads.
|
|
@@ -476,11 +479,11 @@ trackers = async () => []) {
|
|
|
476
479
|
* clocks, one comparison: if the app server ran even a second ahead of the
|
|
477
480
|
* database, every thread updated inside that second would be skipped on the
|
|
478
481
|
* next poll and never returned again — a permanently missing comment, on a
|
|
479
|
-
* skew nobody would think to look for.
|
|
482
|
+
* skew nobody would think to look for. The design says the client must never
|
|
480
483
|
* use its own clock; the same argument applies one layer down.
|
|
481
484
|
*/
|
|
482
485
|
const [clock] = await sql `SELECT now() AS now`;
|
|
483
|
-
//
|
|
486
|
+
// The tombstones. Ids only — the client already has these threads, and
|
|
484
487
|
// a body would be handing back the private content that made it invisible.
|
|
485
488
|
const visible = rows.filter((r) => r["visible"] !== false);
|
|
486
489
|
const deleted = rows
|
|
@@ -494,7 +497,7 @@ trackers = async () => []) {
|
|
|
494
497
|
});
|
|
495
498
|
});
|
|
496
499
|
/**
|
|
497
|
-
*
|
|
500
|
+
* Create. The whole thing is one transaction because a thread
|
|
498
501
|
* without its first comment is not a snag, it is a pin nobody can read.
|
|
499
502
|
*/
|
|
500
503
|
app.post("/api/v1/projects/:uid/threads", async (c) => {
|
|
@@ -506,11 +509,11 @@ trackers = async () => []) {
|
|
|
506
509
|
return fail(c, 401, "unauthorized", "Open your review link, or reload the page, and try again.");
|
|
507
510
|
}
|
|
508
511
|
if (p.status !== "active") {
|
|
509
|
-
//
|
|
512
|
+
// The switch is off — writes refused, reads work (layer 6).
|
|
510
513
|
return fail(c, 403, "project_paused", "This site's widget is switched off. Its owner can switch it back on.");
|
|
511
514
|
}
|
|
512
515
|
if (!session.perm.includes("thread:create")) {
|
|
513
|
-
//
|
|
516
|
+
// The matrix. A read-only session gets a sentence about permission,
|
|
514
517
|
// not about authentication — they ARE signed in.
|
|
515
518
|
return fail(c, 403, "forbidden", "This review link does not allow new comments.");
|
|
516
519
|
}
|
|
@@ -532,8 +535,8 @@ trackers = async () => []) {
|
|
|
532
535
|
}
|
|
533
536
|
const commentBody = typeof body.comment?.body === "string" && body.comment.body ? body.comment.body : null;
|
|
534
537
|
const commentId = rowId(body.comment?.id, ID_PREFIX.comment) ?? randomUUID();
|
|
535
|
-
// The attachments uploaded moments ago, to be BOUND by the write below
|
|
536
|
-
//
|
|
538
|
+
// The attachments uploaded moments ago, to be BOUND by the write below
|
|
539
|
+
// Until that runs they are `pending` rows with no owner: The
|
|
537
540
|
// CHECK forbids a bound attachment without exactly one of
|
|
538
541
|
// thread_id/comment_id, which is what makes an orphan detectable rather
|
|
539
542
|
// than merely unlikely. The bind is scoped to this project AND to rows
|
|
@@ -546,7 +549,7 @@ trackers = async () => []) {
|
|
|
546
549
|
//
|
|
547
550
|
// This used to read `createdBy.holderSecret` off the request and mint a
|
|
548
551
|
// reviewer from it — a second, weaker answer to "who is this?" sitting
|
|
549
|
-
// beside the session that already answers it.
|
|
552
|
+
// beside the session that already answers it. The design is explicit that a
|
|
550
553
|
// session's identity is decided at exchange and never taken from a request,
|
|
551
554
|
// and leaving the body path in place would have meant a caller could author
|
|
552
555
|
// as anyone by presenting a secret they had seen once.
|
|
@@ -563,7 +566,7 @@ trackers = async () => []) {
|
|
|
563
566
|
* work never was the cost. The *count* was.
|
|
564
567
|
*
|
|
565
568
|
* What survives is the ORDER, because the order is load-bearing —
|
|
566
|
-
*
|
|
569
|
+
* the allocation still row-locks the project before the insert
|
|
567
570
|
* reads it, still serialises creation per project, and still yields a
|
|
568
571
|
* gapless human id. A CTE does not relax that: data-modifying CTEs run
|
|
569
572
|
* exactly once, to completion, and the insert that reads pseq/rseq
|
|
@@ -585,14 +588,14 @@ trackers = async () => []) {
|
|
|
585
588
|
`;
|
|
586
589
|
// Kept as its own statement rather than folded into the write below,
|
|
587
590
|
// because these two are the only failures a caller can act on and
|
|
588
|
-
//
|
|
591
|
+
// the design gives them distinct codes. A single statement that inserted
|
|
589
592
|
// nothing would have to report "something was missing".
|
|
590
593
|
if (!ctx?.environment_id) {
|
|
591
594
|
throw new HttpError(400, "environment_not_found", envKey
|
|
592
595
|
? `This project has no environment called "${envKey}".`
|
|
593
596
|
: "This project has no default environment.");
|
|
594
597
|
}
|
|
595
|
-
// The active round: highest number, not closed.
|
|
598
|
+
// The active round: highest number, not closed. — a thread belongs
|
|
596
599
|
// to whichever round was active when it was created, and never moves.
|
|
597
600
|
if (!ctx.round_id)
|
|
598
601
|
throw new HttpError(409, "no_active_round", "This project has no open round.");
|
|
@@ -639,7 +642,7 @@ trackers = async () => []) {
|
|
|
639
642
|
),
|
|
640
643
|
-- The author's row: seen now, and the counters the assignee pool
|
|
641
644
|
-- reads (05 section 3.4, section 6.4b -- "everyone who has
|
|
642
|
-
-- commented" is thread_count > 0 OR comment_count > 0). Until
|
|
645
|
+
-- commented" is thread_count > 0 OR comment_count > 0). Until
|
|
643
646
|
-- nothing moved thread_count at all, so a client whose only act
|
|
644
647
|
-- was leaving a pin was invisible to the pool. ONE update: two
|
|
645
648
|
-- data-modifying CTEs touching the same row in one statement is
|
|
@@ -695,7 +698,7 @@ trackers = async () => []) {
|
|
|
695
698
|
pageUrl: body.context?.url ?? "",
|
|
696
699
|
}, randomUUID());
|
|
697
700
|
}
|
|
698
|
-
//
|
|
701
|
+
// A site whose tracker makes an issue on every new thread. Same
|
|
699
702
|
// transaction as the thread, keyed on the thread, so a retried POST
|
|
700
703
|
// cannot make a second issue.
|
|
701
704
|
for (const link of await trackers(p.org_id, p.id)) {
|
|
@@ -720,7 +723,7 @@ trackers = async () => []) {
|
|
|
720
723
|
if (e instanceof HttpError)
|
|
721
724
|
return fail(c, e.status, e.code, e.message);
|
|
722
725
|
/**
|
|
723
|
-
* A RETRY IS NOT AN ERROR —
|
|
726
|
+
* A RETRY IS NOT AN ERROR — the idempotency, by natural key.
|
|
724
727
|
*
|
|
725
728
|
* A double-tapped Send, or a reviewer on a train whose request landed
|
|
726
729
|
* while their connection dropped before the response did, sends the same
|
|
@@ -731,7 +734,7 @@ trackers = async () => []) {
|
|
|
731
734
|
* connection is the moment they try again, or give up and stop using it.
|
|
732
735
|
*
|
|
733
736
|
* The id makes this decidable with no header and no client change: the
|
|
734
|
-
* widget already mints it
|
|
737
|
+
* widget already mints it, so a second POST carrying an id we
|
|
735
738
|
* already hold IS the same request. Returning what the first one created
|
|
736
739
|
* is the whole of the fix.
|
|
737
740
|
*
|
|
@@ -787,7 +790,7 @@ trackers = async () => []) {
|
|
|
787
790
|
}
|
|
788
791
|
});
|
|
789
792
|
/**
|
|
790
|
-
*
|
|
793
|
+
* Status changes, endpoint 9, marked **W/D** like the list.
|
|
791
794
|
*
|
|
792
795
|
* IT SHIPPED WITH NO AUTHENTICATION CHECK AT ALL. Not a weak one — none: the
|
|
793
796
|
* handler read the uid, read the body and wrote. `bearerAuth` was mounted on
|
|
@@ -798,15 +801,15 @@ trackers = async () => []) {
|
|
|
798
801
|
*
|
|
799
802
|
* Two credentials are legitimate here and they are checked separately: an
|
|
800
803
|
* OWNER of this project's org, and a reviewer session carrying
|
|
801
|
-
* `thread:resolve` (
|
|
802
|
-
* a client marking their own snag done is the loop
|
|
804
|
+
* `thread:resolve` (the matrix — it is in the default link grant, because
|
|
805
|
+
* a client marking their own snag done is the loop the decision is built around).
|
|
803
806
|
*/
|
|
804
807
|
/**
|
|
805
|
-
*
|
|
808
|
+
* Endpoint 8 — one thread — and the brief: the same row as
|
|
806
809
|
* `text/markdown` when the id ends in `.md`, for curl, an agent's shell or
|
|
807
810
|
* a ticket body. The privacy rule is the list's: a private thread is absent
|
|
808
811
|
* for a non-owner, which reads as "no such thread" rather than confirming
|
|
809
|
-
* one exists
|
|
812
|
+
* one exists, and team-only replies stay out of a client's copy.
|
|
810
813
|
*/
|
|
811
814
|
app.get("/api/v1/projects/:uid/threads/:id", async (c) => {
|
|
812
815
|
const p = await project(c.req.param("uid"));
|
|
@@ -877,10 +880,10 @@ trackers = async () => []) {
|
|
|
877
880
|
const mcp = c.get("mcp");
|
|
878
881
|
const asOwner = !!owner && owner.orgId === p.org_id;
|
|
879
882
|
/**
|
|
880
|
-
* `mark_ready`
|
|
883
|
+
* `mark_ready` lands here — and this is the whole of an agent's
|
|
881
884
|
* write surface on a thread, `threads:write`.
|
|
882
885
|
*
|
|
883
|
-
*
|
|
886
|
+
* *"an agent can propose, never dispose"*. There is
|
|
884
887
|
* deliberately no tool that resolves a thread, and the refusal below is
|
|
885
888
|
* where that is actually true rather than merely absent from a tool list:
|
|
886
889
|
* an agent PATCHing `status: 'resolved'` on this route is refused even
|
|
@@ -897,20 +900,20 @@ trackers = async () => []) {
|
|
|
897
900
|
return fail(c, 403, "forbidden", "This review link does not allow changing status.");
|
|
898
901
|
}
|
|
899
902
|
const body = (await c.req.json().catch(() => null));
|
|
900
|
-
//
|
|
903
|
+
// A thread belongs to exactly one round and NEVER moves. Refused
|
|
901
904
|
// explicitly rather than ignored, because a caller that sends `round` and
|
|
902
905
|
// gets a 200 will believe it moved — and the whole model rests on the fact
|
|
903
|
-
// that it did not.
|
|
906
|
+
// that it did not. The `field_not_permitted` is the registered code.
|
|
904
907
|
for (const field of ["round", "roundId", "roundNumber"]) {
|
|
905
908
|
if (body && field in body) {
|
|
906
909
|
return fail(c, 422, "field_not_permitted", "A comment stays in the round it was left in. Rounds are periods of time, not folders.", { field });
|
|
907
910
|
}
|
|
908
911
|
}
|
|
909
912
|
/**
|
|
910
|
-
* `private
|
|
913
|
+
* `private`, and the write that makes `deleted[]` mean something.
|
|
911
914
|
*
|
|
912
915
|
* Studio-only, like the thread-level field it is: an owner always may, a
|
|
913
|
-
* reviewer session needs the link permission that names it.
|
|
916
|
+
* reviewer session needs the link permission that names it. The design is
|
|
914
917
|
* explicit that this is `403 field_not_permitted` with `details.field`
|
|
915
918
|
* rather than a silently ignored field, because a caller who sends
|
|
916
919
|
* `private: true` and gets a 200 will believe the thread is private.
|
|
@@ -922,14 +925,14 @@ trackers = async () => []) {
|
|
|
922
925
|
});
|
|
923
926
|
}
|
|
924
927
|
if (wantsPrivate !== undefined && asAgent && !asOwner) {
|
|
925
|
-
//
|
|
928
|
+
// An agent write is never laundered into a trust tier it did not
|
|
926
929
|
// earn, and team-only is the studio's register.
|
|
927
930
|
return fail(c, 403, "field_not_permitted", "An agent cannot make a thread team-only.", {
|
|
928
931
|
field: "private",
|
|
929
932
|
});
|
|
930
933
|
}
|
|
931
934
|
/**
|
|
932
|
-
* `assignee
|
|
935
|
+
* `assignee`. A reviewer id from the pool, or null to
|
|
933
936
|
* un-assign. Same register as `private`: the studio's own routing of
|
|
934
937
|
* work, so an owner always may, a reviewer session needs the permission
|
|
935
938
|
* that names it, and a refusal is `403 field_not_permitted` with
|
|
@@ -951,7 +954,7 @@ trackers = async () => []) {
|
|
|
951
954
|
}
|
|
952
955
|
}
|
|
953
956
|
if (asAgent && !asOwner) {
|
|
954
|
-
//
|
|
957
|
+
// Again: an agent proposes, never disposes
|
|
955
958
|
// and handing a thread to a person is disposing of it.
|
|
956
959
|
return fail(c, 403, "field_not_permitted", "An agent cannot assign a comment to someone.", {
|
|
957
960
|
field: "assignee",
|
|
@@ -973,7 +976,7 @@ trackers = async () => []) {
|
|
|
973
976
|
return fail(c, 400, "validation_failed", "status must be one of open, progress, review, resolved.");
|
|
974
977
|
}
|
|
975
978
|
/**
|
|
976
|
-
* F-26's expected version
|
|
979
|
+
* F-26's expected version, the "no silent overwrite".
|
|
977
980
|
*
|
|
978
981
|
* `threads.version` has been incremented on every write since 0001 and
|
|
979
982
|
* READ by nothing: the column existed, the guarantee did not. Two studio
|
|
@@ -984,7 +987,7 @@ trackers = async () => []) {
|
|
|
984
987
|
* OPTIONAL, deliberately. A caller that sends no `version` is saying "I am
|
|
985
988
|
* not editing a copy I read earlier" — the CLI, an agent acting on a fresh
|
|
986
989
|
* read, a curl. Making it mandatory would break every existing caller to
|
|
987
|
-
* protect a case they are not in, and
|
|
990
|
+
* protect a case they are not in, and the additive rule forbids
|
|
988
991
|
* that. The dashboard and the widget send it because they ARE editing a
|
|
989
992
|
* copy they read earlier, which is exactly the population F-26 is about.
|
|
990
993
|
*/
|
|
@@ -1001,7 +1004,7 @@ trackers = async () => []) {
|
|
|
1001
1004
|
return fail(c, 403, "forbidden", "An agent may mark a snag ready for review, but not resolve it. " +
|
|
1002
1005
|
"Whoever reported it decides when it is fixed — tell your user it is ready.");
|
|
1003
1006
|
}
|
|
1004
|
-
//
|
|
1007
|
+
// The stop signal, spent before the write rather than after it.
|
|
1005
1008
|
const spent = await checkWriteBudget(sql, mcp.tokenId);
|
|
1006
1009
|
if (spent !== null)
|
|
1007
1010
|
return budgetExhausted(c);
|
|
@@ -1027,6 +1030,20 @@ trackers = async () => []) {
|
|
|
1027
1030
|
let conflict;
|
|
1028
1031
|
// Set by the transaction when the candidate assignee is not in the pool.
|
|
1029
1032
|
let badAssignee = false;
|
|
1033
|
+
// Read by the transaction when an assignee is being set.
|
|
1034
|
+
let previousAssignee = null;
|
|
1035
|
+
/**
|
|
1036
|
+
* The person pressing the button, as the assignment mail needs them:
|
|
1037
|
+
* never mailed about handing a snag to themselves, and — when they ARE
|
|
1038
|
+
* the assignee and move its status — the mail about it is dropped,
|
|
1039
|
+
* because they have plainly seen it. An agent is nobody here: it cannot
|
|
1040
|
+
* assign, and its `mark_ready` is not the assignee looking.
|
|
1041
|
+
*/
|
|
1042
|
+
const actor = asOwner
|
|
1043
|
+
? { userId: owner.userId, email: owner.email }
|
|
1044
|
+
: session && !asAgent
|
|
1045
|
+
? { reviewerId: session.sub }
|
|
1046
|
+
: null;
|
|
1030
1047
|
const [row] = await inOrg(sql, p.org_id, async (tx) => {
|
|
1031
1048
|
/**
|
|
1032
1049
|
* F-26's check, INSIDE the transaction and holding the row.
|
|
@@ -1053,7 +1070,7 @@ trackers = async () => []) {
|
|
|
1053
1070
|
}
|
|
1054
1071
|
}
|
|
1055
1072
|
/**
|
|
1056
|
-
* The candidate must be IN THE POOL —
|
|
1073
|
+
* The candidate must be IN THE POOL — the predicate, shared with
|
|
1057
1074
|
* the pool route — and in THIS project. Blocked and erased reviewers
|
|
1058
1075
|
* keep the assignments they have but take no new ones; a guest who
|
|
1059
1076
|
* never wrote is not assignable; a reviewer id from another project is
|
|
@@ -1068,9 +1085,18 @@ trackers = async () => []) {
|
|
|
1068
1085
|
badAssignee = true;
|
|
1069
1086
|
return [];
|
|
1070
1087
|
}
|
|
1088
|
+
// Who held it BEFORE, under the row lock the UPDATE below would take
|
|
1089
|
+
// anyway: re-sending the assignee a thread already has is not a new
|
|
1090
|
+
// assignment, and must not be a second mail.
|
|
1091
|
+
const [was] = await tx `
|
|
1092
|
+
SELECT assignee_id FROM threads
|
|
1093
|
+
WHERE id = ${threadRow}::uuid AND project_id = ${p.id}::uuid
|
|
1094
|
+
FOR UPDATE
|
|
1095
|
+
`;
|
|
1096
|
+
previousAssignee = was?.assignee_id ?? null;
|
|
1071
1097
|
}
|
|
1072
1098
|
/**
|
|
1073
|
-
*
|
|
1099
|
+
* Consequence TWO of three, and it runs before the CTE rather than
|
|
1074
1100
|
* inside it.
|
|
1075
1101
|
*
|
|
1076
1102
|
* *"every comment on a thread becoming private is set `private = true` in
|
|
@@ -1159,7 +1185,7 @@ trackers = async () => []) {
|
|
|
1159
1185
|
`;
|
|
1160
1186
|
/**
|
|
1161
1187
|
* `thread.resolved` — B2's second topic, queued in the SAME transaction
|
|
1162
|
-
* as the status change
|
|
1188
|
+
* as the status change, so a resolve that commits without its
|
|
1163
1189
|
* notification is impossible and a notification about a resolve that
|
|
1164
1190
|
* rolled back cannot exist.
|
|
1165
1191
|
*
|
|
@@ -1186,7 +1212,35 @@ trackers = async () => []) {
|
|
|
1186
1212
|
pageUrl: String(t["context"]?.["url"] ?? ""),
|
|
1187
1213
|
});
|
|
1188
1214
|
}
|
|
1189
|
-
|
|
1215
|
+
/**
|
|
1216
|
+
* "this snag is yours now", an hour from now and only if it is
|
|
1217
|
+
* still true then (`mail/assigned.ts`). Queued here, in the transaction
|
|
1218
|
+
* that assigned it, like every outbox row; only when it is a NEW holder
|
|
1219
|
+
* and only when mail is configured at all (off and say so,
|
|
1220
|
+
* rather than rows that arrive in a burst the day somebody sets it up).
|
|
1221
|
+
*/
|
|
1222
|
+
if (assigneeRow && assigneeRow !== previousAssignee && actor && updated?.[0] && (await notifications())) {
|
|
1223
|
+
const t = updated[0];
|
|
1224
|
+
const actorName = asOwner
|
|
1225
|
+
? owner.name || owner.email
|
|
1226
|
+
: ((await tx `SELECT name FROM reviewers WHERE id = ${session.sub}::uuid`)[0]?.name ?? "Someone");
|
|
1227
|
+
await queueAssignedNotice(tx, {
|
|
1228
|
+
orgId: p.org_id,
|
|
1229
|
+
projectId: p.id,
|
|
1230
|
+
threadId: threadRow,
|
|
1231
|
+
threadKey: String(t["key"] ?? ""),
|
|
1232
|
+
version: Number(t["version"] ?? 0),
|
|
1233
|
+
assigneeId: assigneeRow,
|
|
1234
|
+
actor,
|
|
1235
|
+
actorName,
|
|
1236
|
+
...(assignDelayMs !== undefined ? { delayMs: assignDelayMs } : {}),
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
// …and the assignee moving the status themselves has seen it.
|
|
1240
|
+
if (status !== undefined && actor && updated?.[0]) {
|
|
1241
|
+
await dropAssignedBy(tx, threadRow, actor);
|
|
1242
|
+
}
|
|
1243
|
+
// Our status is canonical, the tracker's follows — queued here, in
|
|
1190
1244
|
// the transaction that moved it, keyed on the version so every change
|
|
1191
1245
|
// is one row. The worker maps by state type: resolved closes as
|
|
1192
1246
|
// completed, anything else reopens; a thread with no issue is skipped.
|
|
@@ -1207,7 +1261,7 @@ trackers = async () => []) {
|
|
|
1207
1261
|
});
|
|
1208
1262
|
/**
|
|
1209
1263
|
* F-26, on the wire. 409 and not 412: `If-Match` semantics would make this
|
|
1210
|
-
* a header concern, and
|
|
1264
|
+
* a header concern, and the design describes a product event ("someone else
|
|
1211
1265
|
* updated this while you were typing") that the client renders with **Post
|
|
1212
1266
|
* anyway** and **See what changed** — a conflict, not a failed
|
|
1213
1267
|
* precondition. `currentVersion` rides along so the refetch has something
|
|
@@ -1225,7 +1279,7 @@ trackers = async () => []) {
|
|
|
1225
1279
|
if (!row)
|
|
1226
1280
|
return fail(c, 404, "thread_not_found", "No such thread in this project.");
|
|
1227
1281
|
/**
|
|
1228
|
-
*
|
|
1282
|
+
* Every agent write lands in `events` with the
|
|
1229
1283
|
* reviewer id and the token id.
|
|
1230
1284
|
*
|
|
1231
1285
|
* A SECOND transaction, and this is the one place that is not ideal: the
|
|
@@ -1262,12 +1316,12 @@ trackers = async () => []) {
|
|
|
1262
1316
|
* 05 endpoint 10 — deleting a whole thread, project-scoped like the PATCH
|
|
1263
1317
|
* above. The studio, any thread; a reviewer, their own.
|
|
1264
1318
|
*
|
|
1265
|
-
*
|
|
1319
|
+
* The matrix gives a reviewer "own only", behind the link permission
|
|
1266
1320
|
* `thread:delete:own` — on by default since 0.1.14 for guests and new
|
|
1267
1321
|
* links, because the widget offered Delete on a reviewer's own snag and
|
|
1268
1322
|
* this route refused it. "Own" is narrower than "opened": every live
|
|
1269
1323
|
* comment in the thread must be theirs, so nobody's reply goes with it.
|
|
1270
|
-
* An agent is still refused (
|
|
1324
|
+
* An agent is still refused (an agent proposes, never
|
|
1271
1325
|
* disposes, and deleting is the most disposing act there is).
|
|
1272
1326
|
*
|
|
1273
1327
|
* ── SOFT, LIKE A SITE ──
|
|
@@ -1279,7 +1333,7 @@ trackers = async () => []) {
|
|
|
1279
1333
|
* column, and an operator who deletes the wrong thread has a row to
|
|
1280
1334
|
* restore. `version` and `updated_at` move with it so a `?since=` poll —
|
|
1281
1335
|
* every other open dashboard, every reviewer's widget — gets the id in
|
|
1282
|
-
* `deleted[]` and drops its copy
|
|
1336
|
+
* `deleted[]` and drops its copy, rather than showing a thread that
|
|
1283
1337
|
* is gone until somebody reloads.
|
|
1284
1338
|
*
|
|
1285
1339
|
* ── THE COMMENTS ARE LEFT ALONE, DELIBERATELY ──
|
|
@@ -1289,7 +1343,7 @@ trackers = async () => []) {
|
|
|
1289
1343
|
* thread's `deleted_at`; the two comment routes that address a comment by
|
|
1290
1344
|
* its own id (commentEdit.ts) answer 404 once its thread is deleted. So
|
|
1291
1345
|
* tombstoning them would hide nothing more today, and it would make the
|
|
1292
|
-
* delete irreversible —
|
|
1346
|
+
* delete irreversible — the tombstone NULLs the body, and a restored
|
|
1293
1347
|
* thread with every word gone is not a restore. The counters stay as they
|
|
1294
1348
|
* are for the same reason.
|
|
1295
1349
|
*
|
|
@@ -1307,7 +1361,7 @@ trackers = async () => []) {
|
|
|
1307
1361
|
* with and never looks back at the thread. A tracker row would be
|
|
1308
1362
|
* dropped by its worker anyway ("the thread is gone"); dropping it here
|
|
1309
1363
|
* says so in one place. Rows already in flight are left to finish.
|
|
1310
|
-
* · Linked issues in GitHub, Linear, Jira or Slack are NOT touched.
|
|
1364
|
+
* · Linked issues in GitHub, Linear, Jira or Slack are NOT touched. The
|
|
1311
1365
|
* `thread.deleted` event is not built, and closing a developer's issue
|
|
1312
1366
|
* because a comment was moderated is not obviously what anyone wants.
|
|
1313
1367
|
* An inbound webhook for that issue finds no thread and is ignored.
|
|
@@ -1344,7 +1398,7 @@ trackers = async () => []) {
|
|
|
1344
1398
|
if (!session.perm.includes("thread:delete:own")) {
|
|
1345
1399
|
return fail(c, 403, "forbidden", "Your review link does not allow deleting comments.");
|
|
1346
1400
|
}
|
|
1347
|
-
// A studio owner or admin signed in from the widget
|
|
1401
|
+
// A studio owner or admin signed in from the widget deletes
|
|
1348
1402
|
// any thread, as in the dashboard.
|
|
1349
1403
|
const moderator = await inOrg(sql, p.org_id, (tx) => isStudioModerator(tx, p.org_id, session.sub));
|
|
1350
1404
|
const [t] = moderator ? [{ all_mine: true, any: true }] : await inOrg(sql, p.org_id, (tx) => tx `
|
|
@@ -1366,7 +1420,7 @@ trackers = async () => []) {
|
|
|
1366
1420
|
}
|
|
1367
1421
|
}
|
|
1368
1422
|
else {
|
|
1369
|
-
//
|
|
1423
|
+
// The matrix: member, admin and owner may; a viewer may not. Nobody
|
|
1370
1424
|
// can be GIVEN a viewer seat yet (members.ts wires owner and admin only),
|
|
1371
1425
|
// so this is the rule written down where it applies, before the day the
|
|
1372
1426
|
// seat exists and every route has to be audited for it.
|
|
@@ -1397,7 +1451,7 @@ trackers = async () => []) {
|
|
|
1397
1451
|
`;
|
|
1398
1452
|
return !!was;
|
|
1399
1453
|
}
|
|
1400
|
-
// The thread's own pictures and any bound to its comments:
|
|
1454
|
+
// The thread's own pictures and any bound to its comments:
|
|
1401
1455
|
// section A6 hangs an attachment off exactly one of the two.
|
|
1402
1456
|
await tx `
|
|
1403
1457
|
UPDATE attachments SET deleted_at = now()
|
|
@@ -1437,7 +1491,7 @@ class HttpError extends Error {
|
|
|
1437
1491
|
this.code = code;
|
|
1438
1492
|
}
|
|
1439
1493
|
}
|
|
1440
|
-
/** Rows →
|
|
1494
|
+
/** Rows → the `Thread`. The shape the widget already renders, unchanged. */
|
|
1441
1495
|
function shape(r) {
|
|
1442
1496
|
return {
|
|
1443
1497
|
id: wireId_(r.id, ID_PREFIX.thread),
|
|
@@ -1473,7 +1527,7 @@ function shape(r) {
|
|
|
1473
1527
|
isTeam: !!r.assignee_is_team,
|
|
1474
1528
|
}
|
|
1475
1529
|
: null,
|
|
1476
|
-
//
|
|
1530
|
+
// Where this thread lives elsewhere — the GitHub issue, later the
|
|
1477
1531
|
// Linear and Jira ones. Always an array, so a merge can clear it.
|
|
1478
1532
|
refs: r.refs ?? [],
|
|
1479
1533
|
createdAt: new Date(r.created_at).toISOString(),
|