@vantageos/backend-doctor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +94 -0
- package/dist/contract.d.ts +18 -0
- package/dist/contract.js +29 -0
- package/dist/coverage-cli.d.ts +2 -0
- package/dist/coverage-cli.js +17 -0
- package/dist/coverage.d.ts +32 -0
- package/dist/coverage.js +77 -0
- package/dist/detectors/oracle-axis1.d.ts +86 -0
- package/dist/detectors/oracle-axis1.js +221 -0
- package/dist/detectors/oracle-axis2.d.ts +22 -0
- package/dist/detectors/oracle-axis2.js +149 -0
- package/dist/detectors/predicates.d.ts +19 -0
- package/dist/detectors/predicates.js +334 -0
- package/dist/detectors/shared.d.ts +56 -0
- package/dist/detectors/shared.js +103 -0
- package/dist/detectors/source/_scan.d.ts +43 -0
- package/dist/detectors/source/_scan.js +82 -0
- package/dist/detectors/source/backfill.d.ts +27 -0
- package/dist/detectors/source/backfill.js +211 -0
- package/dist/detectors/source/contract.d.ts +33 -0
- package/dist/detectors/source/contract.js +385 -0
- package/dist/detectors/source/identity.d.ts +47 -0
- package/dist/detectors/source/identity.js +410 -0
- package/dist/detectors/source/jobs.d.ts +10 -0
- package/dist/detectors/source/jobs.js +209 -0
- package/dist/detectors/source/lifecycle.d.ts +6 -0
- package/dist/detectors/source/lifecycle.js +147 -0
- package/dist/detectors/source/readbudget.d.ts +6 -0
- package/dist/detectors/source/readbudget.js +133 -0
- package/dist/detectors/source/refusal.d.ts +6 -0
- package/dist/detectors/source/refusal.js +125 -0
- package/dist/detectors/source/security.d.ts +37 -0
- package/dist/detectors/source/security.js +392 -0
- package/dist/detectors/source/writesafety.d.ts +14 -0
- package/dist/detectors/source/writesafety.js +345 -0
- package/dist/detectors/standard/_doc.d.ts +66 -0
- package/dist/detectors/standard/_doc.js +152 -0
- package/dist/detectors/standard/r41-frontier.d.ts +58 -0
- package/dist/detectors/standard/r41-frontier.js +176 -0
- package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
- package/dist/detectors/standard/r46-returns-validator.js +474 -0
- package/dist/detectors/standard/standard-doc.d.ts +12 -0
- package/dist/detectors/standard/standard-doc.js +271 -0
- package/dist/detectors/structural.d.ts +14 -0
- package/dist/detectors/structural.js +83 -0
- package/dist/engine-types.d.ts +10 -0
- package/dist/engine-types.js +1 -0
- package/dist/not-checkable.d.ts +22 -0
- package/dist/not-checkable.js +37 -0
- package/dist/oracle.d.ts +33 -0
- package/dist/oracle.js +133 -0
- package/dist/registry.d.ts +11 -0
- package/dist/registry.js +140 -0
- package/dist/report.d.ts +35 -0
- package/dist/report.js +138 -0
- package/dist/severity.d.ts +30 -0
- package/dist/severity.js +121 -0
- package/dist/tally.d.ts +57 -0
- package/dist/tally.js +152 -0
- package/dist/trigger.d.ts +47 -0
- package/dist/trigger.js +143 -0
- package/fixtures/backend-standard-51ec7af1.md +648 -0
- package/fixtures/backend-standard-a9d7877b.md +629 -0
- package/fixtures/mcp-app-standard-a74a042b.md +279 -0
- package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
- package/fixtures/mcp-server-standard-a74a042b.md +434 -0
- package/fixtures/mcp-standard-a74a042b.md +354 -0
- package/fixtures/vp-1200-green/.pin +1 -0
- package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
- package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
- package/fixtures/vp-1200-red/.pin +1 -0
- package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
- package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
- package/fixtures/vp-by-tool-day158.csv +190 -0
- package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
- package/fixtures/vp-convex/.pin +1 -0
- package/fixtures/vp-convex/bun.lock +783 -0
- package/fixtures/vp-convex/convex/auth.config.ts +8 -0
- package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
- package/fixtures/vp-convex/convex/convex.config.ts +14 -0
- package/fixtures/vp-convex/convex/credentials.ts +591 -0
- package/fixtures/vp-convex/convex/crons.ts +72 -0
- package/fixtures/vp-convex/convex/dashboard.ts +199 -0
- package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
- package/fixtures/vp-convex/convex/issues.ts +544 -0
- package/fixtures/vp-convex/convex/kb.ts +305 -0
- package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
- package/fixtures/vp-convex/convex/kbShared.ts +56 -0
- package/fixtures/vp-convex/convex/licenses.ts +443 -0
- package/fixtures/vp-convex/convex/memories.ts +406 -0
- package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
- package/fixtures/vp-convex/convex/messages.ts +971 -0
- package/fixtures/vp-convex/convex/migrations.ts +239 -0
- package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
- package/fixtures/vp-convex/convex/oauth.ts +1608 -0
- package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
- package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
- package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
- package/fixtures/vp-convex/convex/schema.ts +1229 -0
- package/fixtures/vp-convex/convex/tasks.ts +2782 -0
- package/fixtures/vp-r31-green/.pin +1 -0
- package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
- package/fixtures/vp-r31-red/.pin +1 -0
- package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
- package/fixtures/vr-by-tool-day159.csv +89 -0
- package/package.json +39 -0
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
import { ConvexError, v } from "convex/values";
|
|
2
|
+
// convex-strict-mode-doc-type-import-needed-when-refactoring-list-query-from-early-return-to-accumulator-post-filter
|
|
3
|
+
import type { Doc } from "./_generated/dataModel";
|
|
4
|
+
import { mutation, query } from "./_generated/server";
|
|
5
|
+
import { requireScope, withOrgScope } from "./lib/auth";
|
|
6
|
+
import { requireId } from "./lib/ids";
|
|
7
|
+
import { creatorValidator } from "./schema";
|
|
8
|
+
import { computeStaleInProgress } from "./lib/taskClosureGate";
|
|
9
|
+
|
|
10
|
+
const staleInProgressValidator = v.array(
|
|
11
|
+
v.object({
|
|
12
|
+
taskId: v.id("tasks"),
|
|
13
|
+
title: v.string(),
|
|
14
|
+
age: v.number(),
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// sendMessage — send a message to one, many, or all orchestrators
|
|
20
|
+
// channel: "broadcast" | "tau" | "pi,phi" (comma-separated for multi)
|
|
21
|
+
// Creates one message row + one receipt per recipient.
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
// Broadcast resolves dynamically from the profiles table.
|
|
25
|
+
// Any orchestrator with a profile receives broadcasts.
|
|
26
|
+
// No hardcoded list — new orchestrators are included automatically after calling update_profile.
|
|
27
|
+
|
|
28
|
+
export const sendMessage = mutation({
|
|
29
|
+
args: {
|
|
30
|
+
from: creatorValidator,
|
|
31
|
+
fromInstanceId: v.optional(v.string()),
|
|
32
|
+
channel: v.string(),
|
|
33
|
+
content: v.string(),
|
|
34
|
+
sessionDay: v.optional(v.number()),
|
|
35
|
+
tenantId: v.optional(v.string()),
|
|
36
|
+
},
|
|
37
|
+
returns: v.id("messages"),
|
|
38
|
+
handler: async (ctx, args) => {
|
|
39
|
+
const messageId = await ctx.db.insert("messages", {
|
|
40
|
+
from: args.from,
|
|
41
|
+
fromInstanceId: args.fromInstanceId,
|
|
42
|
+
channel: args.channel,
|
|
43
|
+
content: args.content,
|
|
44
|
+
sessionDay: args.sessionDay,
|
|
45
|
+
tenantId: args.tenantId,
|
|
46
|
+
createdAt: Date.now(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Resolve recipients — channel can be a role or instanceId
|
|
50
|
+
// If channel contains "-" (e.g. "pi-vps"), treat as instance-level
|
|
51
|
+
//
|
|
52
|
+
// Bounce contract (task k17dr97dwpe07n9zfgzzypkfm18bv6ws): a channel that
|
|
53
|
+
// resolves to ZERO real recipients — unknown role, non-existent instance,
|
|
54
|
+
// reserved non-target word, empty string, or an unknown comma-list part
|
|
55
|
+
// — is refused with an actionable ConvexError instead of silently
|
|
56
|
+
// succeeding with no receipts written. The recipient set is DERIVED from
|
|
57
|
+
// the org (the `profiles` table), never a hardcoded denylist — mirrors
|
|
58
|
+
// the broadcast branch below.
|
|
59
|
+
let recipients: string[];
|
|
60
|
+
if (args.channel === "broadcast") {
|
|
61
|
+
// Dynamic: get all registered orchestrators from profiles
|
|
62
|
+
const profiles = await ctx.db.query("profiles").collect();
|
|
63
|
+
const orchestratorIds = [
|
|
64
|
+
...new Set(profiles.map((p) => p.orchestratorId)),
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// Cross-tenant leak fix (mission fix-broadcast-org-scoped-v1, T1):
|
|
68
|
+
// bound the broadcast fan-out to the EMITTER's own tenant, derived
|
|
69
|
+
// from withOrgScope(ctx) — never from the client-supplied
|
|
70
|
+
// args.tenantId (unauthenticated/self-declared, see T0 audit
|
|
71
|
+
// analysis/broadcast-org-scope-audit-t0-day157.md). Resolved
|
|
72
|
+
// FAIL-CLOSED (no allowNoIdentityMaster) — consistent with the
|
|
73
|
+
// Day-156 SEC-AUDIT doctrine. The MCP server forwards its
|
|
74
|
+
// service-account identity, which resolves to master via the
|
|
75
|
+
// CLERK_SERVICE_ACCOUNT_USER_ID carve-out (lib/auth.ts:111-121),
|
|
76
|
+
// so legitimate internal broadcasts still resolve to master. An
|
|
77
|
+
// anonymous/no-identity caller resolves to isMaster=false with an
|
|
78
|
+
// empty allowedOrchestrators, so it falls into the client branch
|
|
79
|
+
// below and yields zero recipients — the existing zero-recipient
|
|
80
|
+
// bounce fires (no fail-open path to the internal fleet).
|
|
81
|
+
const scope = await withOrgScope(ctx);
|
|
82
|
+
|
|
83
|
+
// convex-reviewer CRITICAL (mission fix-broadcast-org-scoped-v1, T1
|
|
84
|
+
// REVISE): scope.isMaster is OVERLOADED — it is also true for a
|
|
85
|
+
// CLIENT org whose client_org_mapping row carries the ["*"] read
|
|
86
|
+
// sentinel (lib/auth.ts:182: isMaster =
|
|
87
|
+
// allowedOrchestrators.includes("*")). Gating the fleet-wide
|
|
88
|
+
// master branch on isMaster alone would let a client
|
|
89
|
+
// (mis)configured with ["*"] fan out to the entire internal
|
|
90
|
+
// fleet + other tenants. The true internal master (service
|
|
91
|
+
// account / Laurent, lib/auth.ts:77-89 and :123-135) is the ONLY
|
|
92
|
+
// case with orgSlug === null — a client's isMaster=true always
|
|
93
|
+
// carries a set orgSlug (lib/auth.ts:177-183). So the
|
|
94
|
+
// fleet-wide branch is gated on BOTH isMaster AND orgSlug===null;
|
|
95
|
+
// this discriminant is applied locally here, not in lib/auth.ts
|
|
96
|
+
// (shared type, out of scope for this fix).
|
|
97
|
+
if (scope.isMaster && scope.orgSlug === null) {
|
|
98
|
+
// True internal/master emitter: exclude every orchestrator bound
|
|
99
|
+
// to any client tenant — active OR inactive — so an internal
|
|
100
|
+
// broadcast never reaches a client orchestrator, the exact leak
|
|
101
|
+
// reported in the T0 audit. Inactive mappings are included in
|
|
102
|
+
// the exclusion set deliberately: an inactive client_org_mapping
|
|
103
|
+
// row still identifies that orchestratorId as CLIENT-bound, not
|
|
104
|
+
// internal — dropping the isActive filter here would let a
|
|
105
|
+
// merely-disabled client's orchestrator silently rejoin the
|
|
106
|
+
// internal broadcast pool, which is itself a leak class. This
|
|
107
|
+
// is independent of whether that inactive org could ever
|
|
108
|
+
// authenticate (it can't — withOrgScope throws Forbidden on
|
|
109
|
+
// inactive orgs); the exclusion is about the identity of the
|
|
110
|
+
// orchestratorId, not the org's ability to log in.
|
|
111
|
+
const mappings = await ctx.db.query("client_org_mapping").collect();
|
|
112
|
+
const clientBound = new Set<string>();
|
|
113
|
+
for (const mapping of mappings) {
|
|
114
|
+
for (const orchestratorId of mapping.allowedOrchestrators) {
|
|
115
|
+
if (orchestratorId !== "*") clientBound.add(orchestratorId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
recipients = orchestratorIds.filter(
|
|
119
|
+
(o) => o !== args.from && !clientBound.has(o),
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
// Client-scoped emitter (including a client-org isMaster=true
|
|
123
|
+
// with orgSlug set — the ["*"] read-sentinel case): recipients
|
|
124
|
+
// bounded to this org's own allowedOrchestrators — never
|
|
125
|
+
// another tenant, never the internal fleet. A ["*"]
|
|
126
|
+
// allowedOrchestrators list never matches a real
|
|
127
|
+
// orchestratorId (the literal string "*" is not a
|
|
128
|
+
// registered orchestrator), so this yields zero recipients
|
|
129
|
+
// and the bounce below fires — fail-closed, not a leak.
|
|
130
|
+
const allowed = new Set(scope.allowedOrchestrators);
|
|
131
|
+
recipients = orchestratorIds.filter(
|
|
132
|
+
(o) => o !== args.from && allowed.has(o),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Zero-recipient bounce contract (task k17dr97dwpe07n9zfgzzypkfm18bv6ws)
|
|
137
|
+
// extends to the tenant-scoped broadcast: an anonymous/no-identity
|
|
138
|
+
// caller resolves to isMaster=false with allowedOrchestrators=[],
|
|
139
|
+
// so it would otherwise silently "succeed" with zero receipts
|
|
140
|
+
// written. Fail-closed here means an explicit refusal, never a
|
|
141
|
+
// fall-through to the internal fleet.
|
|
142
|
+
if (recipients.length === 0) {
|
|
143
|
+
throw new ConvexError(
|
|
144
|
+
`recipient error / message non livré : "broadcast" ne correspond à aucun destinataire de l'organisation pour cet émetteur.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
const profiles = await ctx.db.query("profiles").collect();
|
|
149
|
+
const knownRoles = new Set(profiles.map((p) => p.orchestratorId));
|
|
150
|
+
const knownInstances = new Set(
|
|
151
|
+
profiles
|
|
152
|
+
.map((p) => p.instanceId)
|
|
153
|
+
.filter((id): id is string => id !== undefined),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const rawParts = args.channel
|
|
157
|
+
.split(",")
|
|
158
|
+
.map((s) => s.trim())
|
|
159
|
+
.filter((s) => s.length > 0);
|
|
160
|
+
|
|
161
|
+
const bounce = () => {
|
|
162
|
+
throw new ConvexError(
|
|
163
|
+
`recipient error / message non livré : "${args.channel}" ne correspond à aucun destinataire de l'organisation. Formes valides : <role existant> | <instance> | broadcast | liste "eta,pi".`,
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (rawParts.length === 0) {
|
|
168
|
+
bounce();
|
|
169
|
+
}
|
|
170
|
+
for (const part of rawParts) {
|
|
171
|
+
if (part === args.from) continue; // sender excluding itself never needs to resolve
|
|
172
|
+
const isKnown = knownRoles.has(part) || knownInstances.has(part);
|
|
173
|
+
if (!isKnown) {
|
|
174
|
+
bounce();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
recipients = rawParts.filter((s) => s !== args.from);
|
|
179
|
+
if (recipients.length === 0) {
|
|
180
|
+
bounce();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const recipient of recipients) {
|
|
185
|
+
// Determine if this is an instance target or role target
|
|
186
|
+
const isInstance = recipient.includes("-");
|
|
187
|
+
const role = isInstance ? recipient.split("-")[0] : recipient;
|
|
188
|
+
|
|
189
|
+
await ctx.db.insert("messageReceipts", {
|
|
190
|
+
messageId,
|
|
191
|
+
recipient: role,
|
|
192
|
+
recipientInstanceId: isInstance ? recipient : undefined,
|
|
193
|
+
tenantId: args.tenantId,
|
|
194
|
+
readAt: undefined,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return messageId;
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
203
|
+
// checkNewMessages — get unread messages for a recipient
|
|
204
|
+
// Returns messages with their receipt IDs (for marking as read).
|
|
205
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
export const checkNewMessages = query({
|
|
208
|
+
args: {
|
|
209
|
+
recipient: creatorValidator,
|
|
210
|
+
recipientInstanceId: v.optional(v.string()),
|
|
211
|
+
tenantId: v.optional(v.string()),
|
|
212
|
+
since: v.optional(v.number()), // Unix ms — only return receipts with _creationTime > since
|
|
213
|
+
},
|
|
214
|
+
returns: v.array(
|
|
215
|
+
v.object({
|
|
216
|
+
receiptId: v.id("messageReceipts"),
|
|
217
|
+
messageId: v.id("messages"),
|
|
218
|
+
from: creatorValidator,
|
|
219
|
+
fromInstanceId: v.optional(v.string()),
|
|
220
|
+
channel: v.optional(v.string()),
|
|
221
|
+
content: v.string(),
|
|
222
|
+
createdAt: v.number(),
|
|
223
|
+
}),
|
|
224
|
+
),
|
|
225
|
+
// FROZEN CONTRACT — do not change this return shape. checkNewMessages is
|
|
226
|
+
// intentionally left as a bare array for vp-mcp <2.12.0 callers ("no
|
|
227
|
+
// break" — see mcp-server/CHANGELOG.md:40). The MCP server only calls
|
|
228
|
+
// checkNewMessagesEnvelope in practice (mcp-server/src/tools.ts:2776);
|
|
229
|
+
// staleInProgress (Day 130) is delivered there, not here. Protected by
|
|
230
|
+
// convex/__tests__/staleInProgress.test.ts
|
|
231
|
+
// "checkNewMessages frozen contract — returns a bare array".
|
|
232
|
+
// tenantId filtering: when provided, only returns messages for that tenant.
|
|
233
|
+
// When omitted, returns all messages (backward-compatible single-tenant mode).
|
|
234
|
+
// This is intentional — omitting tenantId = admin/legacy access, not a bypass.
|
|
235
|
+
handler: async (ctx, args) => {
|
|
236
|
+
let receipts;
|
|
237
|
+
|
|
238
|
+
if (args.recipientInstanceId !== undefined) {
|
|
239
|
+
// Instance-level: get messages targeted at this specific instance
|
|
240
|
+
// PLUS role-level messages (recipientInstanceId === undefined)
|
|
241
|
+
const instanceReceipts = await ctx.db
|
|
242
|
+
.query("messageReceipts")
|
|
243
|
+
.withIndex("by_instance_unread", (q) =>
|
|
244
|
+
q.eq("recipientInstanceId", args.recipientInstanceId!),
|
|
245
|
+
)
|
|
246
|
+
.filter((q) => {
|
|
247
|
+
const base = q.eq(q.field("readAt"), undefined);
|
|
248
|
+
return args.since !== undefined
|
|
249
|
+
? q.and(base, q.gt(q.field("_creationTime"), args.since))
|
|
250
|
+
: base;
|
|
251
|
+
})
|
|
252
|
+
.take(100);
|
|
253
|
+
|
|
254
|
+
const roleReceipts = await ctx.db
|
|
255
|
+
.query("messageReceipts")
|
|
256
|
+
.withIndex("by_recipient_unread", (q) =>
|
|
257
|
+
q.eq("recipient", args.recipient),
|
|
258
|
+
)
|
|
259
|
+
.filter((q) => {
|
|
260
|
+
const base = q.and(
|
|
261
|
+
q.eq(q.field("readAt"), undefined),
|
|
262
|
+
q.eq(q.field("recipientInstanceId"), undefined),
|
|
263
|
+
);
|
|
264
|
+
return args.since !== undefined
|
|
265
|
+
? q.and(base, q.gt(q.field("_creationTime"), args.since))
|
|
266
|
+
: base;
|
|
267
|
+
})
|
|
268
|
+
.take(100);
|
|
269
|
+
|
|
270
|
+
// Merge and deduplicate by receiptId
|
|
271
|
+
const seen = new Set<string>();
|
|
272
|
+
receipts = [];
|
|
273
|
+
for (const r of [...instanceReceipts, ...roleReceipts]) {
|
|
274
|
+
if (!seen.has(r._id)) {
|
|
275
|
+
seen.add(r._id);
|
|
276
|
+
receipts.push(r);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// No tenant+instance index — JS filter is acceptable for instance-targeted messages (low volume)
|
|
281
|
+
if (args.tenantId !== undefined) {
|
|
282
|
+
receipts = receipts.filter((r) => r.tenantId === args.tenantId);
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
// Role-level: get all unread for this role
|
|
286
|
+
if (args.tenantId !== undefined) {
|
|
287
|
+
receipts = await ctx.db
|
|
288
|
+
.query("messageReceipts")
|
|
289
|
+
.withIndex("by_tenant_recipient_unread", (q) =>
|
|
290
|
+
q
|
|
291
|
+
.eq("tenantId", args.tenantId)
|
|
292
|
+
.eq("recipient", args.recipient)
|
|
293
|
+
.eq("readAt", undefined),
|
|
294
|
+
)
|
|
295
|
+
.filter((q) =>
|
|
296
|
+
args.since !== undefined
|
|
297
|
+
? q.gt(q.field("_creationTime"), args.since)
|
|
298
|
+
: true,
|
|
299
|
+
)
|
|
300
|
+
.take(100);
|
|
301
|
+
} else {
|
|
302
|
+
receipts = await ctx.db
|
|
303
|
+
.query("messageReceipts")
|
|
304
|
+
.withIndex("by_recipient_unread", (q) =>
|
|
305
|
+
q.eq("recipient", args.recipient),
|
|
306
|
+
)
|
|
307
|
+
.filter((q) => {
|
|
308
|
+
const base = q.eq(q.field("readAt"), undefined);
|
|
309
|
+
return args.since !== undefined
|
|
310
|
+
? q.and(base, q.gt(q.field("_creationTime"), args.since))
|
|
311
|
+
: base;
|
|
312
|
+
})
|
|
313
|
+
.take(100);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const results = [];
|
|
318
|
+
for (const receipt of receipts) {
|
|
319
|
+
const message = await ctx.db.get(receipt.messageId);
|
|
320
|
+
if (message !== null) {
|
|
321
|
+
results.push({
|
|
322
|
+
receiptId: receipt._id,
|
|
323
|
+
messageId: receipt.messageId,
|
|
324
|
+
from: message.from,
|
|
325
|
+
fromInstanceId: message.fromInstanceId,
|
|
326
|
+
channel: message.channel,
|
|
327
|
+
content: message.content,
|
|
328
|
+
createdAt: message.createdAt,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return results;
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
338
|
+
// checkNewMessagesEnvelope — envelope-guarded variant of checkNewMessages.
|
|
339
|
+
//
|
|
340
|
+
// Day 102 — Pi BLOCKER task k1702xaahb: 36 unread messages = 53 KB of content
|
|
341
|
+
// exceeded the Claude Code tool-response cap and crashed the cron. This variant
|
|
342
|
+
// adds (a) a `limit` arg (default 20, clamp [1,50]), (b) a `maxBytes` arg
|
|
343
|
+
// (default 40_000, clamp [1_000, 60_000]) summing JSON.stringify(projected)
|
|
344
|
+
// per included row, (c) returns `{messages, truncated, nextSince}` so callers
|
|
345
|
+
// can resume via `since=nextSince` on the next tick.
|
|
346
|
+
//
|
|
347
|
+
// The legacy `checkNewMessages` is intentionally left intact for vp-mcp
|
|
348
|
+
// <2.12.0 consumers. New mcp-server >=2.12.0 calls THIS variant.
|
|
349
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
export const checkNewMessagesEnvelope = query({
|
|
352
|
+
args: {
|
|
353
|
+
recipient: creatorValidator,
|
|
354
|
+
recipientInstanceId: v.optional(v.string()),
|
|
355
|
+
tenantId: v.optional(v.string()),
|
|
356
|
+
since: v.optional(v.number()),
|
|
357
|
+
limit: v.optional(v.number()),
|
|
358
|
+
maxBytes: v.optional(v.number()),
|
|
359
|
+
},
|
|
360
|
+
returns: v.object({
|
|
361
|
+
messages: v.array(
|
|
362
|
+
v.object({
|
|
363
|
+
receiptId: v.id("messageReceipts"),
|
|
364
|
+
messageId: v.id("messages"),
|
|
365
|
+
from: creatorValidator,
|
|
366
|
+
fromInstanceId: v.optional(v.string()),
|
|
367
|
+
channel: v.optional(v.string()),
|
|
368
|
+
content: v.string(),
|
|
369
|
+
createdAt: v.number(),
|
|
370
|
+
}),
|
|
371
|
+
),
|
|
372
|
+
truncated: v.boolean(),
|
|
373
|
+
nextSince: v.union(v.number(), v.null()),
|
|
374
|
+
staleInProgress: staleInProgressValidator,
|
|
375
|
+
}),
|
|
376
|
+
handler: async (ctx, args) => {
|
|
377
|
+
const limit = Math.min(Math.max(args.limit ?? 20, 1), 50);
|
|
378
|
+
const maxBytes = Math.min(Math.max(args.maxBytes ?? 40_000, 1_000), 60_000);
|
|
379
|
+
const takeBudget = limit + 1;
|
|
380
|
+
|
|
381
|
+
let receipts: Doc<"messageReceipts">[];
|
|
382
|
+
|
|
383
|
+
if (args.recipientInstanceId !== undefined) {
|
|
384
|
+
const instanceReceipts = await ctx.db
|
|
385
|
+
.query("messageReceipts")
|
|
386
|
+
.withIndex("by_instance_unread", (q) =>
|
|
387
|
+
q.eq("recipientInstanceId", args.recipientInstanceId!),
|
|
388
|
+
)
|
|
389
|
+
.filter((q) => {
|
|
390
|
+
const base = q.eq(q.field("readAt"), undefined);
|
|
391
|
+
return args.since !== undefined
|
|
392
|
+
? q.and(base, q.gt(q.field("_creationTime"), args.since))
|
|
393
|
+
: base;
|
|
394
|
+
})
|
|
395
|
+
.take(takeBudget);
|
|
396
|
+
|
|
397
|
+
const roleReceipts = await ctx.db
|
|
398
|
+
.query("messageReceipts")
|
|
399
|
+
.withIndex("by_recipient_unread", (q) =>
|
|
400
|
+
q.eq("recipient", args.recipient),
|
|
401
|
+
)
|
|
402
|
+
.filter((q) => {
|
|
403
|
+
const base = q.and(
|
|
404
|
+
q.eq(q.field("readAt"), undefined),
|
|
405
|
+
q.eq(q.field("recipientInstanceId"), undefined),
|
|
406
|
+
);
|
|
407
|
+
return args.since !== undefined
|
|
408
|
+
? q.and(base, q.gt(q.field("_creationTime"), args.since))
|
|
409
|
+
: base;
|
|
410
|
+
})
|
|
411
|
+
.take(takeBudget);
|
|
412
|
+
|
|
413
|
+
const seen = new Set<string>();
|
|
414
|
+
receipts = [];
|
|
415
|
+
for (const r of [...instanceReceipts, ...roleReceipts]) {
|
|
416
|
+
if (!seen.has(r._id)) {
|
|
417
|
+
seen.add(r._id);
|
|
418
|
+
receipts.push(r);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (args.tenantId !== undefined) {
|
|
422
|
+
receipts = receipts.filter((r) => r.tenantId === args.tenantId);
|
|
423
|
+
}
|
|
424
|
+
} else if (args.tenantId !== undefined) {
|
|
425
|
+
receipts = await ctx.db
|
|
426
|
+
.query("messageReceipts")
|
|
427
|
+
.withIndex("by_tenant_recipient_unread", (q) =>
|
|
428
|
+
q
|
|
429
|
+
.eq("tenantId", args.tenantId!)
|
|
430
|
+
.eq("recipient", args.recipient)
|
|
431
|
+
.eq("readAt", undefined),
|
|
432
|
+
)
|
|
433
|
+
.filter((q) =>
|
|
434
|
+
args.since !== undefined
|
|
435
|
+
? q.gt(q.field("_creationTime"), args.since)
|
|
436
|
+
: true,
|
|
437
|
+
)
|
|
438
|
+
.take(takeBudget);
|
|
439
|
+
} else {
|
|
440
|
+
receipts = await ctx.db
|
|
441
|
+
.query("messageReceipts")
|
|
442
|
+
.withIndex("by_recipient_unread", (q) =>
|
|
443
|
+
q.eq("recipient", args.recipient),
|
|
444
|
+
)
|
|
445
|
+
.filter((q) => {
|
|
446
|
+
const base = q.eq(q.field("readAt"), undefined);
|
|
447
|
+
return args.since !== undefined
|
|
448
|
+
? q.and(base, q.gt(q.field("_creationTime"), args.since))
|
|
449
|
+
: base;
|
|
450
|
+
})
|
|
451
|
+
.take(takeBudget);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
receipts.sort((a, b) => a._creationTime - b._creationTime);
|
|
455
|
+
|
|
456
|
+
const messages: Array<{
|
|
457
|
+
receiptId: Doc<"messageReceipts">["_id"];
|
|
458
|
+
messageId: Doc<"messages">["_id"];
|
|
459
|
+
from: Doc<"messages">["from"];
|
|
460
|
+
fromInstanceId?: string;
|
|
461
|
+
channel?: string;
|
|
462
|
+
content: string;
|
|
463
|
+
createdAt: number;
|
|
464
|
+
}> = [];
|
|
465
|
+
let bytes = 0;
|
|
466
|
+
let truncated = false;
|
|
467
|
+
let lastIncludedReceiptCreationTime: number | null = null;
|
|
468
|
+
|
|
469
|
+
for (const receipt of receipts) {
|
|
470
|
+
if (messages.length >= limit) {
|
|
471
|
+
truncated = true;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
const message = await ctx.db.get(receipt.messageId);
|
|
475
|
+
if (message === null) continue;
|
|
476
|
+
const projected = {
|
|
477
|
+
receiptId: receipt._id,
|
|
478
|
+
messageId: receipt.messageId,
|
|
479
|
+
from: message.from,
|
|
480
|
+
fromInstanceId: message.fromInstanceId,
|
|
481
|
+
channel: message.channel,
|
|
482
|
+
content: message.content,
|
|
483
|
+
createdAt: message.createdAt,
|
|
484
|
+
};
|
|
485
|
+
const projectedBytes = JSON.stringify(projected).length;
|
|
486
|
+
if (messages.length > 0 && bytes + projectedBytes > maxBytes) {
|
|
487
|
+
truncated = true;
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
messages.push(projected);
|
|
491
|
+
bytes += projectedBytes;
|
|
492
|
+
lastIncludedReceiptCreationTime = receipt._creationTime;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const nextSince = truncated ? lastIncludedReceiptCreationTime : null;
|
|
496
|
+
|
|
497
|
+
// Day 130 closure gate — surface the recipient's own overdue
|
|
498
|
+
// in_progress work on every check_messages call, no extra cron. This
|
|
499
|
+
// is the ONLY path staleInProgress is delivered on: mcp-server calls
|
|
500
|
+
// exclusively checkNewMessagesEnvelope (tools.ts:2776); the legacy
|
|
501
|
+
// checkNewMessages array contract stays frozen (see its own comment).
|
|
502
|
+
const now = Date.now();
|
|
503
|
+
const staleInProgress = await computeStaleInProgress(
|
|
504
|
+
ctx,
|
|
505
|
+
args.recipient,
|
|
506
|
+
now,
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
messages,
|
|
511
|
+
truncated,
|
|
512
|
+
nextSince,
|
|
513
|
+
staleInProgress,
|
|
514
|
+
};
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
519
|
+
// markAsRead — mark one or more receipts as read
|
|
520
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
export const markAsRead = mutation({
|
|
523
|
+
args: {
|
|
524
|
+
// Accept raw strings (not v.id("messageReceipts")) so we can normalize
|
|
525
|
+
// each element ourselves and throw an actionable ConvexError instead of
|
|
526
|
+
// letting the v.id() validator reject before the handler runs. Convex
|
|
527
|
+
// prod REDACTS non-ConvexError validator messages before they reach the
|
|
528
|
+
// client (issue #1064) — only an explicitly-thrown ConvexError's .data
|
|
529
|
+
// payload survives the wire.
|
|
530
|
+
receiptIds: v.array(v.string()),
|
|
531
|
+
// k179nrp3apj700pm0h1ckewm2h8b3nz7 — ownership gate. When provided, every
|
|
532
|
+
// resolved receipt MUST belong to this recipient or the whole call is
|
|
533
|
+
// rejected (RBAC_DENIED). Optional only to stay behavior-preserving for
|
|
534
|
+
// legacy/system callers that predate the MCP-layer guard (mirrors the
|
|
535
|
+
// deleteMessage callerOrchestrator pattern above); the MCP tool now
|
|
536
|
+
// ALWAYS passes it (tools.ts mark_as_read), closing the cross-owner hole
|
|
537
|
+
// where any caller could mark another orchestrator's mail read.
|
|
538
|
+
callerOrchestrator: v.optional(creatorValidator),
|
|
539
|
+
},
|
|
540
|
+
returns: v.number(),
|
|
541
|
+
handler: async (ctx, args) => {
|
|
542
|
+
const normalizedIds = args.receiptIds.map((raw, index) =>
|
|
543
|
+
requireId(
|
|
544
|
+
ctx,
|
|
545
|
+
"messageReceipts",
|
|
546
|
+
raw,
|
|
547
|
+
`receiptIds[${index}]`,
|
|
548
|
+
"Use the full 32-char receiptId returned by check_messages.",
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const now = Date.now();
|
|
553
|
+
let count = 0;
|
|
554
|
+
for (const receiptId of normalizedIds) {
|
|
555
|
+
const receipt = await ctx.db.get(receiptId);
|
|
556
|
+
if (receipt === null) continue;
|
|
557
|
+
if (
|
|
558
|
+
args.callerOrchestrator !== undefined &&
|
|
559
|
+
receipt.recipient !== args.callerOrchestrator
|
|
560
|
+
) {
|
|
561
|
+
throw new ConvexError(
|
|
562
|
+
`RBAC_DENIED: ${args.callerOrchestrator} is not the recipient of receipt ${receiptId} — mark_as_read denied`,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
if (receipt.readAt === undefined) {
|
|
566
|
+
await ctx.db.patch(receiptId, { readAt: now });
|
|
567
|
+
count++;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return count;
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
575
|
+
// deleteMessage — delete a message and cascade-delete its receipts
|
|
576
|
+
// RBAC: only the sender or "system" may delete. Pass callerOrchestrator=undefined
|
|
577
|
+
// to bypass the check (server-to-server / admin use).
|
|
578
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
export const deleteMessage = mutation({
|
|
581
|
+
args: {
|
|
582
|
+
messageId: v.id("messages"),
|
|
583
|
+
callerOrchestrator: v.optional(creatorValidator),
|
|
584
|
+
},
|
|
585
|
+
returns: v.object({ deleted: v.boolean(), receiptsDeleted: v.number() }),
|
|
586
|
+
handler: async (ctx, args) => {
|
|
587
|
+
const message = await ctx.db.get(args.messageId);
|
|
588
|
+
if (!message) throw new Error("Message not found");
|
|
589
|
+
|
|
590
|
+
// RBAC: callerOrchestrator is required and must match message.from
|
|
591
|
+
if (args.callerOrchestrator === undefined) {
|
|
592
|
+
throw new Error(
|
|
593
|
+
"Unauthorized: callerOrchestrator is required to delete a message — omitting it is refused, not exempted",
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
if (
|
|
597
|
+
args.callerOrchestrator !== "system" &&
|
|
598
|
+
message.from !== args.callerOrchestrator
|
|
599
|
+
) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
`Unauthorized: only ${message.from} (sender) or system can delete this message`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Cascade: delete all receipts for this message
|
|
606
|
+
const receipts = await ctx.db
|
|
607
|
+
.query("messageReceipts")
|
|
608
|
+
.withIndex("by_message", (q) => q.eq("messageId", args.messageId))
|
|
609
|
+
.collect();
|
|
610
|
+
|
|
611
|
+
for (const receipt of receipts) {
|
|
612
|
+
await ctx.db.delete(receipt._id);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Delete the message
|
|
616
|
+
await ctx.db.delete(args.messageId);
|
|
617
|
+
|
|
618
|
+
return { deleted: true, receiptsDeleted: receipts.length };
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
623
|
+
// listMessages — get messages for a day or from a sender (history/replay)
|
|
624
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
// PR #635 wide-scan-cap pattern (see convex/tasks.ts TASK_LIST_SCAN_CAP,
|
|
627
|
+
// convex/profiles.ts PROFILES_LIST_SCAN_CAP, lot 1 mission k574p02m). When
|
|
628
|
+
// paginating via `createdBefore`, the post-take filter only finds rows
|
|
629
|
+
// older than the cursor if the FETCH is wide enough to include them —
|
|
630
|
+
// mission k574p02m DEFECT 2, lot 2.
|
|
631
|
+
export const MESSAGES_LIST_SCAN_CAP = 2000;
|
|
632
|
+
|
|
633
|
+
export const listMessages = query({
|
|
634
|
+
args: {
|
|
635
|
+
fields: v.optional(v.union(v.literal("lite"), v.literal("full"))), // v2.4.12 accept (no-op for now) — closes ArgumentValidationError from MCP wrappers passing fields
|
|
636
|
+
sessionDay: v.optional(v.number()),
|
|
637
|
+
from: v.optional(creatorValidator),
|
|
638
|
+
limit: v.optional(v.number()),
|
|
639
|
+
// S3.3 B8 follow-up batch 2 — cursor paging anchor (newest-first).
|
|
640
|
+
createdBefore: v.optional(v.number()),
|
|
641
|
+
},
|
|
642
|
+
returns: v.array(
|
|
643
|
+
v.object({
|
|
644
|
+
_id: v.id("messages"),
|
|
645
|
+
_creationTime: v.number(),
|
|
646
|
+
from: creatorValidator,
|
|
647
|
+
fromInstanceId: v.optional(v.string()),
|
|
648
|
+
channel: v.optional(v.string()),
|
|
649
|
+
to: v.optional(creatorValidator),
|
|
650
|
+
content: v.string(),
|
|
651
|
+
sessionDay: v.optional(v.number()),
|
|
652
|
+
createdAt: v.number(),
|
|
653
|
+
// Day 98 Cat A k17e611z4 fix — issues #655, #644, #643. Multi-tenant
|
|
654
|
+
// rows carry tenantId; returns shape must declare it or Convex
|
|
655
|
+
// emits ReturnsValidationError "extra field tenantId".
|
|
656
|
+
tenantId: v.optional(v.string()),
|
|
657
|
+
}),
|
|
658
|
+
),
|
|
659
|
+
handler: async (ctx, args) => {
|
|
660
|
+
// ── Identity-derived tenant scope ────────────────────────────────────────
|
|
661
|
+
// m977mqck: no-identity callers (MCP server / CLI) → isMaster=true, all rows.
|
|
662
|
+
// m9748paff: Clerk callers are fail-CLOSED — scoped to their own tenantId.
|
|
663
|
+
// k179fk0c: same per-tool tenancy doctrine as tasks.list.
|
|
664
|
+
const scope = await withOrgScope(ctx);
|
|
665
|
+
requireScope(scope, "view-own-tasks");
|
|
666
|
+
|
|
667
|
+
const limit = args.limit ?? 100;
|
|
668
|
+
const before = args.createdBefore;
|
|
669
|
+
const needsWideScan = before !== undefined;
|
|
670
|
+
const fetchCap = needsWideScan ? MESSAGES_LIST_SCAN_CAP + 1 : limit;
|
|
671
|
+
|
|
672
|
+
let rows: Doc<"messages">[];
|
|
673
|
+
|
|
674
|
+
// Defense-in-depth (#776 Eta follow-up): degenerate !isMaster && orgSlug===null
|
|
675
|
+
// is unreachable per withOrgScope invariant but explicit guard prevents regression.
|
|
676
|
+
if (!scope.isMaster && scope.orgSlug === null) return [];
|
|
677
|
+
|
|
678
|
+
if (!scope.isMaster && scope.orgSlug !== null) {
|
|
679
|
+
// ── Clerk (non-master) path — tenant-scoped index ─────────────────────
|
|
680
|
+
// Push tenantId equality BEFORE .take(limit) using by_tenant_created so
|
|
681
|
+
// fleet (null-tenant) traffic cannot crowd out tenant rows in the window.
|
|
682
|
+
// sessionDay and full-scan branches are intentionally merged here: Clerk
|
|
683
|
+
// callers have no business querying a specific sessionDay of fleet traffic,
|
|
684
|
+
// so we always use the tenant-scoped index and apply `from` as a post-index
|
|
685
|
+
// .filter() (acceptable: within a single tenant, volume is low compared to
|
|
686
|
+
// the full cross-tenant table). (task k176wgsrhha0fr0dxxahctvhw588q5a1,
|
|
687
|
+
// Eta completeness edge from PR #775 verdict jn7563v34)
|
|
688
|
+
const orgSlug = scope.orgSlug;
|
|
689
|
+
rows = await ctx.db
|
|
690
|
+
.query("messages")
|
|
691
|
+
.withIndex("by_tenant_created", (q) => q.eq("tenantId", orgSlug))
|
|
692
|
+
.order("desc")
|
|
693
|
+
.filter((q) =>
|
|
694
|
+
args.from !== undefined ? q.eq(q.field("from"), args.from) : true,
|
|
695
|
+
)
|
|
696
|
+
.take(fetchCap);
|
|
697
|
+
|
|
698
|
+
// Belt-and-suspenders: ensure no cross-tenant row leaks through.
|
|
699
|
+
rows = rows.filter((r) => r.tenantId === orgSlug);
|
|
700
|
+
} else {
|
|
701
|
+
// ── Master path — existing index logic, unchanged ──────────────────────
|
|
702
|
+
if (args.sessionDay !== undefined) {
|
|
703
|
+
const sessionDay = args.sessionDay;
|
|
704
|
+
rows = await ctx.db
|
|
705
|
+
.query("messages")
|
|
706
|
+
.withIndex("by_day", (q) => q.eq("sessionDay", sessionDay))
|
|
707
|
+
.order("asc")
|
|
708
|
+
.take(fetchCap);
|
|
709
|
+
} else if (args.from !== undefined) {
|
|
710
|
+
const from = args.from;
|
|
711
|
+
rows = await ctx.db
|
|
712
|
+
.query("messages")
|
|
713
|
+
.withIndex("by_from", (q) => q.eq("from", from))
|
|
714
|
+
.order("desc")
|
|
715
|
+
.take(fetchCap);
|
|
716
|
+
} else {
|
|
717
|
+
rows = await ctx.db.query("messages").order("desc").take(fetchCap);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// S3.3 B8 follow-up batch 2 — drop rows newer-or-equal to anchor.
|
|
722
|
+
if (before !== undefined) {
|
|
723
|
+
rows = rows.filter((r) => r._creationTime < before);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return rows.slice(0, limit);
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
731
|
+
// getUnreadCount — count unread receipts for a recipient role
|
|
732
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
733
|
+
|
|
734
|
+
export const getUnreadCount = query({
|
|
735
|
+
args: { orchestratorId: creatorValidator },
|
|
736
|
+
returns: v.number(),
|
|
737
|
+
handler: async (ctx, { orchestratorId }) => {
|
|
738
|
+
const receipts = await ctx.db
|
|
739
|
+
.query("messageReceipts")
|
|
740
|
+
.withIndex("by_recipient_unread", (q) =>
|
|
741
|
+
q.eq("recipient", orchestratorId),
|
|
742
|
+
)
|
|
743
|
+
.filter((q) => q.eq(q.field("readAt"), undefined))
|
|
744
|
+
.take(500);
|
|
745
|
+
return receipts.length;
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
750
|
+
// listBroadcastStatus — show who read a broadcast message and who didn't
|
|
751
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
752
|
+
|
|
753
|
+
export const listBroadcastStatus = query({
|
|
754
|
+
args: {
|
|
755
|
+
fields: v.optional(v.union(v.literal("lite"), v.literal("full"))), // v2.4.12 accept (no-op for now) — closes ArgumentValidationError from MCP wrappers passing fields
|
|
756
|
+
messageId: v.id("messages"),
|
|
757
|
+
// Fix for the "list_broadcast_status returns Server Error" incident: the
|
|
758
|
+
// MCP wrapper (mcp-server/src/tools.ts) always sent `limit` even when the
|
|
759
|
+
// caller omitted it, but this arg list previously did not declare it,
|
|
760
|
+
// so Convex rejected EVERY call with ArgumentValidationError. `limit` is
|
|
761
|
+
// now declared AND applied (see `truncated` below — a capped list never
|
|
762
|
+
// looks complete).
|
|
763
|
+
limit: v.optional(v.number()),
|
|
764
|
+
},
|
|
765
|
+
returns: v.object({
|
|
766
|
+
messageId: v.id("messages"),
|
|
767
|
+
from: creatorValidator,
|
|
768
|
+
channel: v.optional(v.string()),
|
|
769
|
+
createdAt: v.number(),
|
|
770
|
+
receipts: v.array(
|
|
771
|
+
v.object({
|
|
772
|
+
recipient: v.string(),
|
|
773
|
+
recipientInstanceId: v.optional(v.string()),
|
|
774
|
+
read: v.boolean(),
|
|
775
|
+
readAt: v.optional(v.number()),
|
|
776
|
+
}),
|
|
777
|
+
),
|
|
778
|
+
// True when `limit` truncated the receipts list — "I showed you N of M"
|
|
779
|
+
// must never render identically to "there are N".
|
|
780
|
+
truncated: v.boolean(),
|
|
781
|
+
}),
|
|
782
|
+
handler: async (ctx, { messageId, limit }) => {
|
|
783
|
+
const message = await ctx.db.get(messageId);
|
|
784
|
+
if (!message) throw new Error("Message not found");
|
|
785
|
+
|
|
786
|
+
// Deterministic order from the `by_message` index — no Date.now() here,
|
|
787
|
+
// queries must stay reproducible for reactivity.
|
|
788
|
+
const receipts = await ctx.db
|
|
789
|
+
.query("messageReceipts")
|
|
790
|
+
.withIndex("by_message", (q) => q.eq("messageId", messageId))
|
|
791
|
+
.collect();
|
|
792
|
+
|
|
793
|
+
const mapped = receipts.map((r) => ({
|
|
794
|
+
recipient: r.recipient,
|
|
795
|
+
recipientInstanceId: r.recipientInstanceId,
|
|
796
|
+
read: r.readAt !== undefined,
|
|
797
|
+
readAt: r.readAt,
|
|
798
|
+
}));
|
|
799
|
+
|
|
800
|
+
const truncated = limit !== undefined && mapped.length > limit;
|
|
801
|
+
const page = limit !== undefined ? mapped.slice(0, limit) : mapped;
|
|
802
|
+
|
|
803
|
+
return {
|
|
804
|
+
messageId,
|
|
805
|
+
from: message.from,
|
|
806
|
+
channel: message.channel,
|
|
807
|
+
createdAt: message.createdAt,
|
|
808
|
+
receipts: page,
|
|
809
|
+
truncated,
|
|
810
|
+
};
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
815
|
+
// listByChannel — list recent messages for a channel (or all if unspecified)
|
|
816
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
817
|
+
|
|
818
|
+
export const listByChannel = query({
|
|
819
|
+
args: {
|
|
820
|
+
channel: v.optional(v.string()),
|
|
821
|
+
limit: v.optional(v.number()),
|
|
822
|
+
},
|
|
823
|
+
returns: v.array(
|
|
824
|
+
v.object({
|
|
825
|
+
_id: v.id("messages"),
|
|
826
|
+
_creationTime: v.number(),
|
|
827
|
+
from: creatorValidator,
|
|
828
|
+
fromInstanceId: v.optional(v.string()),
|
|
829
|
+
channel: v.string(),
|
|
830
|
+
content: v.string(),
|
|
831
|
+
sessionDay: v.optional(v.number()),
|
|
832
|
+
createdAt: v.number(),
|
|
833
|
+
}),
|
|
834
|
+
),
|
|
835
|
+
handler: async (ctx, { channel, limit }) => {
|
|
836
|
+
const take = limit ?? 100;
|
|
837
|
+
const scope = await withOrgScope(ctx);
|
|
838
|
+
|
|
839
|
+
// Fail-closed channel scoping: messages carry no orgId/tenantId column
|
|
840
|
+
// (schema.ts), so channel-name proximity to the caller's own scope is the
|
|
841
|
+
// only generic (non-hardcoded) signal available. A non-master, org-scoped
|
|
842
|
+
// caller may only read: "broadcast" (universally shared), a channel
|
|
843
|
+
// exactly matching one of its allowedOrchestrators, or one prefixed with
|
|
844
|
+
// its own "team/<orgSlug>/" convention. Anything else is denied.
|
|
845
|
+
const isChannelAllowed = (ch: string): boolean => {
|
|
846
|
+
if (scope.isMaster) return true;
|
|
847
|
+
if (ch === "broadcast") return true;
|
|
848
|
+
if (scope.orgSlug !== null && ch.startsWith(`team/${scope.orgSlug}`)) {
|
|
849
|
+
return true;
|
|
850
|
+
}
|
|
851
|
+
return scope.allowedOrchestrators.includes(ch);
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
if (channel !== undefined) {
|
|
855
|
+
if (!isChannelAllowed(channel)) return [];
|
|
856
|
+
return await ctx.db
|
|
857
|
+
.query("messages")
|
|
858
|
+
.withIndex("by_channel", (q) => q.eq("channel", channel))
|
|
859
|
+
.order("desc")
|
|
860
|
+
.take(take);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (scope.isMaster) {
|
|
864
|
+
return await ctx.db.query("messages").order("desc").take(take);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const rows = await ctx.db.query("messages").order("desc").take(take);
|
|
868
|
+
return rows.filter((r) => isChannelAllowed(r.channel));
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
873
|
+
// Day 100 — Phase 2 get_by_id surface fix (task k172735brsw6bc3j2dkkkfxqrx88kkjq)
|
|
874
|
+
// Single-row read by Convex doc ID. Returns null on miss (MCP layer reshapes
|
|
875
|
+
// to scope-aware "not found"). No validator on returns — schema for messages
|
|
876
|
+
// rows varies by send variant; raw row is fine for read-by-id.
|
|
877
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
878
|
+
|
|
879
|
+
export const getById = query({
|
|
880
|
+
// A wrong-table but well-formed 32-char id passes the `v.id("messages")`
|
|
881
|
+
// validator's format check yet fails table membership, and that rejection
|
|
882
|
+
// happens BEFORE the handler, so a wrong-table ID is rejected with a message
|
|
883
|
+
// Convex redacts in prod (`Server Error`, `error.data` undefined — measured).
|
|
884
|
+
// Narrowing inside the handler via requireId() throws a ConvexError whose
|
|
885
|
+
// payload survives redaction. Same contract as PR #1069 (markAsRead) and
|
|
886
|
+
// #1072 (tasks:getById), on a read.
|
|
887
|
+
args: { messageId: v.string() },
|
|
888
|
+
handler: async (ctx, args) => {
|
|
889
|
+
const messageId = requireId(
|
|
890
|
+
ctx,
|
|
891
|
+
"messages",
|
|
892
|
+
args.messageId,
|
|
893
|
+
"messageId",
|
|
894
|
+
"Use the full 32-char messageId returned by list_messages or checkNewMessages.",
|
|
895
|
+
);
|
|
896
|
+
return await ctx.db.get(messageId);
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
901
|
+
// Day 102 v2.11.0 — CRUD baseline PR-C-bis option B (mission k575kc1r).
|
|
902
|
+
// BM25 keyword search over message content via Convex native .searchIndex().
|
|
903
|
+
//
|
|
904
|
+
// Backed by the `search_content` searchIndex declared in schema.ts.
|
|
905
|
+
// Filter axes: from, channel, sessionDay, tenantId (matches messages.list()).
|
|
906
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
907
|
+
|
|
908
|
+
export const searchMessagesByKeyword = query({
|
|
909
|
+
args: {
|
|
910
|
+
query: v.string(),
|
|
911
|
+
from: v.optional(creatorValidator),
|
|
912
|
+
channel: v.optional(v.string()),
|
|
913
|
+
sessionDay: v.optional(v.number()),
|
|
914
|
+
tenantId: v.optional(v.string()),
|
|
915
|
+
limit: v.optional(v.number()),
|
|
916
|
+
fields: v.optional(v.union(v.literal("lite"), v.literal("full"))),
|
|
917
|
+
},
|
|
918
|
+
handler: async (ctx, args) => {
|
|
919
|
+
// ── Identity-derived tenant scope ────────────────────────────────────────
|
|
920
|
+
// m977mqck: no-identity callers (MCP server / CLI) → isMaster=true, all rows.
|
|
921
|
+
// m9748paff: Clerk callers are fail-CLOSED — scoped to their own tenantId.
|
|
922
|
+
// k179fk0c: same per-tool tenancy doctrine as tasks.searchTasksByKeyword.
|
|
923
|
+
const scope = await withOrgScope(ctx);
|
|
924
|
+
requireScope(scope, "view-own-tasks");
|
|
925
|
+
|
|
926
|
+
// Defense-in-depth (#776 Eta follow-up): degenerate !isMaster && orgSlug===null
|
|
927
|
+
// is unreachable per withOrgScope invariant but explicit guard prevents regression.
|
|
928
|
+
if (!scope.isMaster && scope.orgSlug === null) return [];
|
|
929
|
+
|
|
930
|
+
const limit = Math.min(Math.max(args.limit ?? 20, 1), 200);
|
|
931
|
+
const lite = args.fields === "lite";
|
|
932
|
+
|
|
933
|
+
const results = await ctx.db
|
|
934
|
+
.query("messages")
|
|
935
|
+
.withSearchIndex("search_content", (q) => {
|
|
936
|
+
let qb = q.search("content", args.query);
|
|
937
|
+
if (args.from !== undefined) qb = qb.eq("from", args.from);
|
|
938
|
+
if (args.channel !== undefined) qb = qb.eq("channel", args.channel);
|
|
939
|
+
if (args.sessionDay !== undefined)
|
|
940
|
+
qb = qb.eq("sessionDay", args.sessionDay);
|
|
941
|
+
// For Clerk callers: push scope.orgSlug into the search index filter
|
|
942
|
+
// (primary isolation). For master callers: use caller-supplied tenantId
|
|
943
|
+
// if provided (backward-compatible narrow by tenant for admin queries).
|
|
944
|
+
if (!scope.isMaster && scope.orgSlug !== null) {
|
|
945
|
+
qb = qb.eq("tenantId", scope.orgSlug);
|
|
946
|
+
} else if (args.tenantId !== undefined) {
|
|
947
|
+
qb = qb.eq("tenantId", args.tenantId);
|
|
948
|
+
}
|
|
949
|
+
return qb;
|
|
950
|
+
})
|
|
951
|
+
.take(limit);
|
|
952
|
+
|
|
953
|
+
// Defense-in-depth: messages have no pilot/assignedTo so filterByOrgScope()
|
|
954
|
+
// does not fit. Enforce tenantId match inline for non-master scopes — the
|
|
955
|
+
// index .eq("tenantId", scope.orgSlug) above is the primary isolation; this
|
|
956
|
+
// is the belt-and-suspenders pass (mirrors briefingNotes pattern).
|
|
957
|
+
const filtered = !scope.isMaster
|
|
958
|
+
? results.filter((r) => r.tenantId === scope.orgSlug)
|
|
959
|
+
: results;
|
|
960
|
+
|
|
961
|
+
if (!lite) return filtered;
|
|
962
|
+
return filtered.map((m) => ({
|
|
963
|
+
_id: m._id,
|
|
964
|
+
from: m.from,
|
|
965
|
+
channel: m.channel,
|
|
966
|
+
content: m.content,
|
|
967
|
+
sessionDay: m.sessionDay,
|
|
968
|
+
createdAt: m.createdAt,
|
|
969
|
+
}));
|
|
970
|
+
},
|
|
971
|
+
});
|