@agent-native/core 0.111.3 → 0.112.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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +18 -0
- package/corpus/core/package.json +3 -1
- package/corpus/core/src/cli/code-agent-executor.ts +17 -5
- package/corpus/core/src/cli/code-agent-runs.ts +43 -2
- package/corpus/core/src/cli/migration-codemod.ts +59 -9
- package/corpus/core/src/cli/multi-frontier-runs.ts +841 -0
- package/corpus/core/src/coding-tools/index.ts +27 -8
- package/corpus/core/src/embeddings/index.ts +233 -0
- package/corpus/core/src/index.ts +2 -0
- package/corpus/core/src/search/index.ts +413 -0
- package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +48 -5
- package/corpus/templates/brain/.agents/skills/brain/SKILL.md +46 -14
- package/corpus/templates/brain/README.md +34 -3
- package/corpus/templates/brain/actions/approve-proposal.ts +2 -0
- package/corpus/templates/brain/actions/claim-distillation.ts +24 -27
- package/corpus/templates/brain/actions/create-source.ts +1 -1
- package/corpus/templates/brain/actions/get-knowledge.ts +6 -2
- package/corpus/templates/brain/actions/get-pilot-report.ts +44 -8
- package/corpus/templates/brain/actions/import-capture.ts +21 -9
- package/corpus/templates/brain/actions/import-transcript.ts +42 -14
- package/corpus/templates/brain/actions/list-captures.ts +10 -1
- package/corpus/templates/brain/actions/list-distillation-queue.ts +39 -14
- package/corpus/templates/brain/actions/list-projects.ts +51 -0
- package/corpus/templates/brain/actions/list-proposals.ts +80 -4
- package/corpus/templates/brain/actions/list-sources.ts +17 -3
- package/corpus/templates/brain/actions/manage-project.ts +118 -0
- package/corpus/templates/brain/actions/mark-capture-distilled.ts +68 -20
- package/corpus/templates/brain/actions/navigate.ts +1 -0
- package/corpus/templates/brain/actions/reject-proposal.ts +6 -1
- package/corpus/templates/brain/actions/resanitize-captures.ts +159 -33
- package/corpus/templates/brain/actions/review-proposal.ts +2 -0
- package/corpus/templates/brain/actions/search-everything.ts +10 -1
- package/corpus/templates/brain/actions/search-knowledge.ts +1 -1
- package/corpus/templates/brain/actions/set-settings.ts +36 -1
- package/corpus/templates/brain/actions/update-proposal.ts +2 -0
- package/corpus/templates/brain/app/hooks/use-distillation-bridge.ts +18 -9
- package/corpus/templates/brain/app/hooks/use-navigation-state.ts +3 -0
- package/corpus/templates/brain/app/i18n-data.ts +731 -2
- package/corpus/templates/brain/app/lib/brain.ts +33 -1
- package/corpus/templates/brain/app/routes/ops.tsx +47 -0
- package/corpus/templates/brain/app/routes/review.tsx +41 -2
- package/corpus/templates/brain/app/routes/search.tsx +33 -2
- package/corpus/templates/brain/app/routes/settings.tsx +158 -0
- package/corpus/templates/brain/app/routes/sources.tsx +31 -0
- package/corpus/templates/brain/changelog/2026-07-19-added-private-source-safe-semantic-search.md +6 -0
- package/corpus/templates/brain/jobs/process-ingest-queue.ts +270 -36
- package/corpus/templates/brain/package.json +1 -0
- package/corpus/templates/brain/server/db/index.ts +18 -0
- package/corpus/templates/brain/server/db/schema.ts +197 -1
- package/corpus/templates/brain/server/jobs/distillation-queue.ts +12 -5
- package/corpus/templates/brain/server/lib/audiences.ts +708 -0
- package/corpus/templates/brain/server/lib/brain-health.ts +251 -38
- package/corpus/templates/brain/server/lib/brain.ts +877 -50
- package/corpus/templates/brain/server/lib/capture-sanitization.ts +217 -53
- package/corpus/templates/brain/server/lib/connectors.ts +556 -70
- package/corpus/templates/brain/server/lib/demo.ts +17 -4
- package/corpus/templates/brain/server/lib/hybrid-search.ts +363 -0
- package/corpus/templates/brain/server/lib/ingest-queue.ts +104 -0
- package/corpus/templates/brain/server/lib/privacy-readiness.ts +24 -0
- package/corpus/templates/brain/server/lib/search-index-contracts.ts +72 -0
- package/corpus/templates/brain/server/lib/search-index.ts +714 -0
- package/corpus/templates/brain/server/lib/search.ts +120 -4
- package/corpus/templates/brain/server/lib/sensitivity-policy.ts +174 -0
- package/corpus/templates/brain/server/lib/slack-events.ts +281 -0
- package/corpus/templates/brain/server/onboarding.ts +29 -0
- package/corpus/templates/brain/server/plugins/db.ts +204 -0
- package/corpus/templates/brain/server/register-secrets.ts +46 -0
- package/corpus/templates/brain/server/routes/api/_agent-native/brain/ingest.post.ts +36 -16
- package/corpus/templates/brain/server/routes/api/_agent-native/brain/slack-events.post.ts +28 -0
- package/corpus/templates/brain/shared/types.ts +10 -0
- package/dist/cli/code-agent-executor.d.ts +8 -9
- package/dist/cli/code-agent-executor.d.ts.map +1 -1
- package/dist/cli/code-agent-executor.js +9 -3
- package/dist/cli/code-agent-executor.js.map +1 -1
- package/dist/cli/code-agent-runs.d.ts +5 -0
- package/dist/cli/code-agent-runs.d.ts.map +1 -1
- package/dist/cli/code-agent-runs.js +33 -2
- package/dist/cli/code-agent-runs.js.map +1 -1
- package/dist/cli/migration-codemod.d.ts.map +1 -1
- package/dist/cli/migration-codemod.js +48 -10
- package/dist/cli/migration-codemod.js.map +1 -1
- package/dist/cli/multi-frontier-runs.d.ts +131 -0
- package/dist/cli/multi-frontier-runs.d.ts.map +1 -0
- package/dist/cli/multi-frontier-runs.js +529 -0
- package/dist/cli/multi-frontier-runs.js.map +1 -0
- package/dist/coding-tools/index.d.ts +1 -0
- package/dist/coding-tools/index.d.ts.map +1 -1
- package/dist/coding-tools/index.js +25 -7
- package/dist/coding-tools/index.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/embeddings/index.d.ts +24 -0
- package/dist/embeddings/index.d.ts.map +1 -0
- package/dist/embeddings/index.js +172 -0
- package/dist/embeddings/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/search/index.d.ts +88 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +267 -0
- package/dist/search/index.js.map +1 -0
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getRequestOrgId,
|
|
3
|
+
getRequestUserEmail,
|
|
4
|
+
} from "@agent-native/core/server/request-context";
|
|
5
|
+
import { accessFilter } from "@agent-native/core/sharing";
|
|
6
|
+
import { and, eq, gte, inArray, ne, or } from "drizzle-orm";
|
|
7
|
+
|
|
8
|
+
import { getDb, schema } from "../db/index.js";
|
|
9
|
+
import { nanoid, nowIso } from "./brain.js";
|
|
10
|
+
import type { BrainAudienceAssignment } from "./search-index-contracts.js";
|
|
11
|
+
|
|
12
|
+
export const SLACK_PRIVATE_AUDIENCE_TTL_MS = 15 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
export function isAudienceMembershipFresh(
|
|
15
|
+
kind: BrainAudienceAssignment["kind"],
|
|
16
|
+
lastSyncedAt: string,
|
|
17
|
+
nowMs = Date.now(),
|
|
18
|
+
) {
|
|
19
|
+
return (
|
|
20
|
+
kind !== "slack-private-channel" ||
|
|
21
|
+
Date.parse(lastSyncedAt) >= nowMs - SLACK_PRIVATE_AUDIENCE_TTL_MS
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function sha256(value: string) {
|
|
26
|
+
const digest = await crypto.subtle.digest(
|
|
27
|
+
"SHA-256",
|
|
28
|
+
new TextEncoder().encode(value),
|
|
29
|
+
);
|
|
30
|
+
return Array.from(new Uint8Array(digest), (byte) =>
|
|
31
|
+
byte.toString(16).padStart(2, "0"),
|
|
32
|
+
).join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeEmail(value: string) {
|
|
36
|
+
return value.trim().toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function computeAudienceAclHash(
|
|
40
|
+
kind: BrainAudienceAssignment["kind"],
|
|
41
|
+
principals: string[],
|
|
42
|
+
) {
|
|
43
|
+
const principalKey = Array.from(new Set(principals)).sort().join(",");
|
|
44
|
+
return sha256(`${kind}:${principalKey}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function audienceMembershipNeedsReplace(
|
|
48
|
+
previousAclHash: string | undefined,
|
|
49
|
+
nextAclHash: string,
|
|
50
|
+
) {
|
|
51
|
+
return previousAclHash !== nextAclHash;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function computeCaptureAudienceId(input: {
|
|
55
|
+
sourceId: string;
|
|
56
|
+
kind: BrainAudienceAssignment["kind"];
|
|
57
|
+
upstreamRefHash: string | null;
|
|
58
|
+
aclHash: string;
|
|
59
|
+
}) {
|
|
60
|
+
const partitionKey = input.upstreamRefHash ?? input.aclHash;
|
|
61
|
+
const identityHash = await sha256(
|
|
62
|
+
`${input.sourceId}:${input.kind}:${partitionKey}`,
|
|
63
|
+
);
|
|
64
|
+
return `aud_${identityHash.slice(0, 24)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type BrainDb = ReturnType<typeof getDb>;
|
|
68
|
+
|
|
69
|
+
interface CaptureAudienceInput {
|
|
70
|
+
captureId: string;
|
|
71
|
+
source: typeof schema.brainSources.$inferSelect;
|
|
72
|
+
kind?: BrainAudienceAssignment["kind"];
|
|
73
|
+
memberEmails?: string[];
|
|
74
|
+
upstreamRefHash?: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface DeferredAudienceInvalidation {
|
|
78
|
+
captureId: string;
|
|
79
|
+
sourceId: string;
|
|
80
|
+
previousAclHash: string;
|
|
81
|
+
aclHash: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function flushAudienceInvalidations(
|
|
85
|
+
invalidations: DeferredAudienceInvalidation[],
|
|
86
|
+
) {
|
|
87
|
+
if (!invalidations.length) return;
|
|
88
|
+
const [{ enqueueCaptureInvalidation }, { invalidateDerivedForCapture }] =
|
|
89
|
+
await Promise.all([import("./ingest-queue.js"), import("./brain.js")]);
|
|
90
|
+
for (const invalidation of invalidations) {
|
|
91
|
+
await invalidateDerivedForCapture(invalidation.captureId);
|
|
92
|
+
await enqueueCaptureInvalidation({
|
|
93
|
+
captureId: invalidation.captureId,
|
|
94
|
+
sourceId: invalidation.sourceId,
|
|
95
|
+
reason: "access-changed",
|
|
96
|
+
previous: { aclHash: invalidation.previousAclHash },
|
|
97
|
+
next: { aclHash: invalidation.aclHash },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function ensureCaptureAudience(
|
|
103
|
+
input: CaptureAudienceInput,
|
|
104
|
+
): Promise<BrainAudienceAssignment> {
|
|
105
|
+
const db = getDb();
|
|
106
|
+
const invalidations: DeferredAudienceInvalidation[] = [];
|
|
107
|
+
const assignment = await db.transaction((tx) =>
|
|
108
|
+
ensureCaptureAudienceMutation(
|
|
109
|
+
input,
|
|
110
|
+
tx as unknown as BrainDb,
|
|
111
|
+
invalidations,
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
await flushAudienceInvalidations(invalidations);
|
|
115
|
+
return assignment;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function ensureCaptureAudienceMutation(
|
|
119
|
+
input: CaptureAudienceInput,
|
|
120
|
+
db: BrainDb,
|
|
121
|
+
invalidations: DeferredAudienceInvalidation[],
|
|
122
|
+
): Promise<BrainAudienceAssignment> {
|
|
123
|
+
const now = nowIso();
|
|
124
|
+
const kind =
|
|
125
|
+
input.kind ??
|
|
126
|
+
(input.source.visibility === "private" ? "restricted" : "org");
|
|
127
|
+
let members = Array.from(
|
|
128
|
+
new Set((input.memberEmails ?? []).map(normalizeEmail).filter(Boolean)),
|
|
129
|
+
).sort();
|
|
130
|
+
if (input.source.visibility === "private" && kind !== "org") {
|
|
131
|
+
members = [normalizeEmail(input.source.ownerEmail)];
|
|
132
|
+
}
|
|
133
|
+
const principalKey =
|
|
134
|
+
kind === "org" ? `org:${input.source.orgId ?? "local"}` : members.join(",");
|
|
135
|
+
const aclHash = await computeAudienceAclHash(kind, [principalKey]);
|
|
136
|
+
const upstreamRefHash = input.upstreamRefHash
|
|
137
|
+
? await sha256(input.upstreamRefHash)
|
|
138
|
+
: null;
|
|
139
|
+
const audienceId = await computeCaptureAudienceId({
|
|
140
|
+
sourceId: input.source.id,
|
|
141
|
+
kind,
|
|
142
|
+
upstreamRefHash,
|
|
143
|
+
aclHash,
|
|
144
|
+
});
|
|
145
|
+
const [previousAudience] = await db
|
|
146
|
+
.select({ aclHash: schema.brainAudiences.aclHash })
|
|
147
|
+
.from(schema.brainAudiences)
|
|
148
|
+
.where(eq(schema.brainAudiences.id, audienceId))
|
|
149
|
+
.limit(1);
|
|
150
|
+
|
|
151
|
+
await db
|
|
152
|
+
.insert(schema.brainAudiences)
|
|
153
|
+
.values({
|
|
154
|
+
id: audienceId,
|
|
155
|
+
sourceId: input.source.id,
|
|
156
|
+
kind,
|
|
157
|
+
upstreamRefHash,
|
|
158
|
+
aclHash,
|
|
159
|
+
membershipState: "current",
|
|
160
|
+
lastSyncedAt: now,
|
|
161
|
+
createdAt: now,
|
|
162
|
+
updatedAt: now,
|
|
163
|
+
})
|
|
164
|
+
.onConflictDoUpdate({
|
|
165
|
+
target: schema.brainAudiences.id,
|
|
166
|
+
set: {
|
|
167
|
+
sourceId: input.source.id,
|
|
168
|
+
kind,
|
|
169
|
+
upstreamRefHash,
|
|
170
|
+
aclHash,
|
|
171
|
+
membershipState: "current",
|
|
172
|
+
lastSyncedAt: now,
|
|
173
|
+
updatedAt: now,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (kind === "org" && input.source.orgId) {
|
|
178
|
+
members.splice(0, members.length);
|
|
179
|
+
await upsertAudienceMember(
|
|
180
|
+
{
|
|
181
|
+
audienceId,
|
|
182
|
+
principalType: "org",
|
|
183
|
+
principalId: input.source.orgId,
|
|
184
|
+
syncedAt: now,
|
|
185
|
+
},
|
|
186
|
+
db,
|
|
187
|
+
);
|
|
188
|
+
} else if (kind === "org") {
|
|
189
|
+
await upsertAudienceMember(
|
|
190
|
+
{
|
|
191
|
+
audienceId,
|
|
192
|
+
principalType: "user",
|
|
193
|
+
principalId: normalizeEmail(input.source.ownerEmail),
|
|
194
|
+
syncedAt: now,
|
|
195
|
+
},
|
|
196
|
+
db,
|
|
197
|
+
);
|
|
198
|
+
} else if (
|
|
199
|
+
audienceMembershipNeedsReplace(previousAudience?.aclHash, aclHash)
|
|
200
|
+
) {
|
|
201
|
+
await replaceAudienceUserMembersMutation(
|
|
202
|
+
audienceId,
|
|
203
|
+
members.map((email) => ({ email })),
|
|
204
|
+
{ previousAclHash: previousAudience?.aclHash },
|
|
205
|
+
db,
|
|
206
|
+
invalidations,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await db
|
|
211
|
+
.delete(schema.brainCaptureAudiences)
|
|
212
|
+
.where(eq(schema.brainCaptureAudiences.captureId, input.captureId));
|
|
213
|
+
await db
|
|
214
|
+
.insert(schema.brainCaptureAudiences)
|
|
215
|
+
.values({
|
|
216
|
+
id: nanoid(),
|
|
217
|
+
captureId: input.captureId,
|
|
218
|
+
audienceId,
|
|
219
|
+
aclHash,
|
|
220
|
+
createdAt: now,
|
|
221
|
+
})
|
|
222
|
+
.onConflictDoUpdate({
|
|
223
|
+
target: [
|
|
224
|
+
schema.brainCaptureAudiences.captureId,
|
|
225
|
+
schema.brainCaptureAudiences.audienceId,
|
|
226
|
+
],
|
|
227
|
+
set: { aclHash },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return { audienceId, aclHash, kind };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function upsertAudienceMember(
|
|
234
|
+
input: {
|
|
235
|
+
audienceId: string;
|
|
236
|
+
principalType: "user" | "org";
|
|
237
|
+
principalId: string;
|
|
238
|
+
upstreamPrincipalHash?: string | null;
|
|
239
|
+
syncedAt: string;
|
|
240
|
+
},
|
|
241
|
+
db = getDb(),
|
|
242
|
+
) {
|
|
243
|
+
const now = nowIso();
|
|
244
|
+
await db
|
|
245
|
+
.insert(schema.brainAudienceMembers)
|
|
246
|
+
.values({
|
|
247
|
+
id: nanoid(),
|
|
248
|
+
...input,
|
|
249
|
+
upstreamPrincipalHash: input.upstreamPrincipalHash ?? null,
|
|
250
|
+
status: "active",
|
|
251
|
+
createdAt: now,
|
|
252
|
+
updatedAt: now,
|
|
253
|
+
})
|
|
254
|
+
.onConflictDoUpdate({
|
|
255
|
+
target: [
|
|
256
|
+
schema.brainAudienceMembers.audienceId,
|
|
257
|
+
schema.brainAudienceMembers.principalType,
|
|
258
|
+
schema.brainAudienceMembers.principalId,
|
|
259
|
+
],
|
|
260
|
+
set: {
|
|
261
|
+
upstreamPrincipalHash: input.upstreamPrincipalHash ?? null,
|
|
262
|
+
status: "active",
|
|
263
|
+
syncedAt: input.syncedAt,
|
|
264
|
+
updatedAt: now,
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function ensureEvidenceIntersectionAudience(input: {
|
|
270
|
+
captureIds: string[];
|
|
271
|
+
sourceId: string;
|
|
272
|
+
}): Promise<BrainAudienceAssignment> {
|
|
273
|
+
const captureIds = Array.from(new Set(input.captureIds));
|
|
274
|
+
if (!captureIds.length) {
|
|
275
|
+
throw new Error("Evidence is required to derive a Brain audience");
|
|
276
|
+
}
|
|
277
|
+
const db = getDb();
|
|
278
|
+
const assignments = await db
|
|
279
|
+
.select({
|
|
280
|
+
captureId: schema.brainCaptureAudiences.captureId,
|
|
281
|
+
audienceId: schema.brainCaptureAudiences.audienceId,
|
|
282
|
+
aclHash: schema.brainCaptureAudiences.aclHash,
|
|
283
|
+
kind: schema.brainAudiences.kind,
|
|
284
|
+
sourceId: schema.brainRawCaptures.sourceId,
|
|
285
|
+
sourceOrgId: schema.brainSources.orgId,
|
|
286
|
+
sourceOwnerEmail: schema.brainSources.ownerEmail,
|
|
287
|
+
})
|
|
288
|
+
.from(schema.brainCaptureAudiences)
|
|
289
|
+
.innerJoin(
|
|
290
|
+
schema.brainAudiences,
|
|
291
|
+
eq(schema.brainAudiences.id, schema.brainCaptureAudiences.audienceId),
|
|
292
|
+
)
|
|
293
|
+
.innerJoin(
|
|
294
|
+
schema.brainRawCaptures,
|
|
295
|
+
eq(schema.brainRawCaptures.id, schema.brainCaptureAudiences.captureId),
|
|
296
|
+
)
|
|
297
|
+
.innerJoin(
|
|
298
|
+
schema.brainSources,
|
|
299
|
+
eq(schema.brainSources.id, schema.brainRawCaptures.sourceId),
|
|
300
|
+
)
|
|
301
|
+
.where(inArray(schema.brainCaptureAudiences.captureId, captureIds));
|
|
302
|
+
if (
|
|
303
|
+
new Set(assignments.map((assignment) => assignment.captureId)).size !==
|
|
304
|
+
captureIds.length
|
|
305
|
+
) {
|
|
306
|
+
throw new Error("Every evidence capture must have a current audience");
|
|
307
|
+
}
|
|
308
|
+
const tenantKey = assertSingleEvidenceTenant(assignments);
|
|
309
|
+
const audienceIds = Array.from(
|
|
310
|
+
new Set(assignments.map((assignment) => assignment.audienceId)),
|
|
311
|
+
);
|
|
312
|
+
if (audienceIds.length === 1) {
|
|
313
|
+
const assignment = assignments[0]!;
|
|
314
|
+
return {
|
|
315
|
+
audienceId: assignment.audienceId,
|
|
316
|
+
aclHash: assignment.aclHash,
|
|
317
|
+
kind: assignment.kind as BrainAudienceAssignment["kind"],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
const members = await db
|
|
321
|
+
.select({
|
|
322
|
+
audienceId: schema.brainAudienceMembers.audienceId,
|
|
323
|
+
principalType: schema.brainAudienceMembers.principalType,
|
|
324
|
+
principalId: schema.brainAudienceMembers.principalId,
|
|
325
|
+
})
|
|
326
|
+
.from(schema.brainAudienceMembers)
|
|
327
|
+
.where(
|
|
328
|
+
and(
|
|
329
|
+
inArray(schema.brainAudienceMembers.audienceId, audienceIds),
|
|
330
|
+
eq(schema.brainAudienceMembers.status, "active"),
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
const nonOrgAudienceIds = assignments
|
|
334
|
+
.filter((assignment) => assignment.kind !== "org")
|
|
335
|
+
.map((assignment) => assignment.audienceId);
|
|
336
|
+
const restrictedSets = Array.from(new Set(nonOrgAudienceIds)).map(
|
|
337
|
+
(audienceId) =>
|
|
338
|
+
new Set(
|
|
339
|
+
members
|
|
340
|
+
.filter(
|
|
341
|
+
(member) =>
|
|
342
|
+
member.audienceId === audienceId &&
|
|
343
|
+
member.principalType === "user",
|
|
344
|
+
)
|
|
345
|
+
.map((member) => normalizeEmail(member.principalId)),
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
let principalType: "user" | "org";
|
|
349
|
+
let principals: string[];
|
|
350
|
+
let kind: BrainAudienceAssignment["kind"];
|
|
351
|
+
if (restrictedSets.length) {
|
|
352
|
+
principalType = "user";
|
|
353
|
+
principals = [...restrictedSets[0]!].filter((principal) =>
|
|
354
|
+
restrictedSets.every((set) => set.has(principal)),
|
|
355
|
+
);
|
|
356
|
+
kind = "restricted";
|
|
357
|
+
} else {
|
|
358
|
+
principalType = "org";
|
|
359
|
+
principals = Array.from(
|
|
360
|
+
new Set(
|
|
361
|
+
members
|
|
362
|
+
.filter((member) => member.principalType === "org")
|
|
363
|
+
.map((member) => member.principalId),
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
if (principals.length > 1) principals = [];
|
|
367
|
+
kind = "org";
|
|
368
|
+
}
|
|
369
|
+
if (!principals.length) {
|
|
370
|
+
throw new Error("Evidence audiences have no common members");
|
|
371
|
+
}
|
|
372
|
+
const aclHash = await computeAudienceAclHash(kind, principals);
|
|
373
|
+
const identityHash = await sha256(
|
|
374
|
+
`evidence:${tenantKey}:${audienceIds.sort().join(",")}`,
|
|
375
|
+
);
|
|
376
|
+
const audienceId = `aud_${identityHash.slice(0, 24)}`;
|
|
377
|
+
const now = nowIso();
|
|
378
|
+
await db
|
|
379
|
+
.insert(schema.brainAudiences)
|
|
380
|
+
.values({
|
|
381
|
+
id: audienceId,
|
|
382
|
+
sourceId: input.sourceId,
|
|
383
|
+
kind,
|
|
384
|
+
upstreamRefHash: null,
|
|
385
|
+
aclHash,
|
|
386
|
+
membershipState: "current",
|
|
387
|
+
lastSyncedAt: now,
|
|
388
|
+
createdAt: now,
|
|
389
|
+
updatedAt: now,
|
|
390
|
+
})
|
|
391
|
+
.onConflictDoUpdate({
|
|
392
|
+
target: schema.brainAudiences.id,
|
|
393
|
+
set: {
|
|
394
|
+
aclHash,
|
|
395
|
+
membershipState: "current",
|
|
396
|
+
lastSyncedAt: now,
|
|
397
|
+
updatedAt: now,
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
await db
|
|
401
|
+
.delete(schema.brainAudienceDependencies)
|
|
402
|
+
.where(eq(schema.brainAudienceDependencies.audienceId, audienceId));
|
|
403
|
+
for (const dependsOnAudienceId of audienceIds) {
|
|
404
|
+
await db
|
|
405
|
+
.insert(schema.brainAudienceDependencies)
|
|
406
|
+
.values({
|
|
407
|
+
id: nanoid(),
|
|
408
|
+
audienceId,
|
|
409
|
+
dependsOnAudienceId,
|
|
410
|
+
createdAt: now,
|
|
411
|
+
})
|
|
412
|
+
.onConflictDoUpdate({
|
|
413
|
+
target: [
|
|
414
|
+
schema.brainAudienceDependencies.audienceId,
|
|
415
|
+
schema.brainAudienceDependencies.dependsOnAudienceId,
|
|
416
|
+
],
|
|
417
|
+
set: { dependsOnAudienceId },
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
await db
|
|
421
|
+
.delete(schema.brainAudienceSourceDependencies)
|
|
422
|
+
.where(eq(schema.brainAudienceSourceDependencies.audienceId, audienceId));
|
|
423
|
+
const evidenceSourceIds = Array.from(
|
|
424
|
+
new Set(assignments.map((assignment) => assignment.sourceId)),
|
|
425
|
+
);
|
|
426
|
+
for (const sourceId of evidenceSourceIds) {
|
|
427
|
+
await db
|
|
428
|
+
.insert(schema.brainAudienceSourceDependencies)
|
|
429
|
+
.values({ id: nanoid(), audienceId, sourceId, createdAt: now })
|
|
430
|
+
.onConflictDoUpdate({
|
|
431
|
+
target: [
|
|
432
|
+
schema.brainAudienceSourceDependencies.audienceId,
|
|
433
|
+
schema.brainAudienceSourceDependencies.sourceId,
|
|
434
|
+
],
|
|
435
|
+
set: { sourceId },
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
for (const principalId of principals) {
|
|
439
|
+
await upsertAudienceMember({
|
|
440
|
+
audienceId,
|
|
441
|
+
principalType,
|
|
442
|
+
principalId,
|
|
443
|
+
syncedAt: now,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return { audienceId, aclHash, kind };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function assertSingleEvidenceTenant(
|
|
450
|
+
assignments: Array<{
|
|
451
|
+
sourceOrgId: string | null;
|
|
452
|
+
sourceOwnerEmail: string;
|
|
453
|
+
}>,
|
|
454
|
+
) {
|
|
455
|
+
const tenantKeys = new Set(
|
|
456
|
+
assignments.map((assignment) =>
|
|
457
|
+
assignment.sourceOrgId
|
|
458
|
+
? `org:${assignment.sourceOrgId}`
|
|
459
|
+
: `owner:${normalizeEmail(assignment.sourceOwnerEmail)}`,
|
|
460
|
+
),
|
|
461
|
+
);
|
|
462
|
+
if (tenantKeys.size !== 1) {
|
|
463
|
+
throw new Error("Evidence audiences must belong to the same tenant");
|
|
464
|
+
}
|
|
465
|
+
return Array.from(tenantKeys)[0]!;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function replaceAudienceUserMembers(
|
|
469
|
+
audienceId: string,
|
|
470
|
+
members: Array<{ email: string; upstreamPrincipalHash?: string }>,
|
|
471
|
+
options: { previousAclHash?: string } = {},
|
|
472
|
+
) {
|
|
473
|
+
const db = getDb();
|
|
474
|
+
const invalidations: DeferredAudienceInvalidation[] = [];
|
|
475
|
+
const result = await db.transaction((tx) =>
|
|
476
|
+
replaceAudienceUserMembersMutation(
|
|
477
|
+
audienceId,
|
|
478
|
+
members,
|
|
479
|
+
options,
|
|
480
|
+
tx as unknown as BrainDb,
|
|
481
|
+
invalidations,
|
|
482
|
+
),
|
|
483
|
+
);
|
|
484
|
+
await flushAudienceInvalidations(invalidations);
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function replaceAudienceUserMembersMutation(
|
|
489
|
+
audienceId: string,
|
|
490
|
+
members: Array<{ email: string; upstreamPrincipalHash?: string }>,
|
|
491
|
+
options: { previousAclHash?: string },
|
|
492
|
+
db: BrainDb,
|
|
493
|
+
invalidations: DeferredAudienceInvalidation[],
|
|
494
|
+
) {
|
|
495
|
+
const now = nowIso();
|
|
496
|
+
const emails = Array.from(
|
|
497
|
+
new Set(members.map((member) => normalizeEmail(member.email))),
|
|
498
|
+
).sort();
|
|
499
|
+
const [audience] = await db
|
|
500
|
+
.select({
|
|
501
|
+
kind: schema.brainAudiences.kind,
|
|
502
|
+
aclHash: schema.brainAudiences.aclHash,
|
|
503
|
+
sourceId: schema.brainAudiences.sourceId,
|
|
504
|
+
})
|
|
505
|
+
.from(schema.brainAudiences)
|
|
506
|
+
.where(eq(schema.brainAudiences.id, audienceId))
|
|
507
|
+
.limit(1);
|
|
508
|
+
if (!audience) throw new Error(`Audience not found: ${audienceId}`);
|
|
509
|
+
await db
|
|
510
|
+
.update(schema.brainAudienceMembers)
|
|
511
|
+
.set({ status: "revoked", updatedAt: now })
|
|
512
|
+
.where(
|
|
513
|
+
and(
|
|
514
|
+
eq(schema.brainAudienceMembers.audienceId, audienceId),
|
|
515
|
+
eq(schema.brainAudienceMembers.principalType, "user"),
|
|
516
|
+
),
|
|
517
|
+
);
|
|
518
|
+
for (const email of emails) {
|
|
519
|
+
const member = members.find(
|
|
520
|
+
(candidate) => normalizeEmail(candidate.email) === email,
|
|
521
|
+
);
|
|
522
|
+
await upsertAudienceMember(
|
|
523
|
+
{
|
|
524
|
+
audienceId,
|
|
525
|
+
principalType: "user",
|
|
526
|
+
principalId: email,
|
|
527
|
+
upstreamPrincipalHash: member?.upstreamPrincipalHash,
|
|
528
|
+
syncedAt: now,
|
|
529
|
+
},
|
|
530
|
+
db,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const aclHash = await computeAudienceAclHash(
|
|
534
|
+
audience.kind as BrainAudienceAssignment["kind"],
|
|
535
|
+
emails,
|
|
536
|
+
);
|
|
537
|
+
await db
|
|
538
|
+
.update(schema.brainAudiences)
|
|
539
|
+
.set({
|
|
540
|
+
aclHash,
|
|
541
|
+
membershipState: "current",
|
|
542
|
+
lastSyncedAt: now,
|
|
543
|
+
updatedAt: now,
|
|
544
|
+
})
|
|
545
|
+
.where(eq(schema.brainAudiences.id, audienceId));
|
|
546
|
+
const previousAclHash = options.previousAclHash ?? audience.aclHash;
|
|
547
|
+
if (previousAclHash !== aclHash) {
|
|
548
|
+
const captures = await db
|
|
549
|
+
.select({ captureId: schema.brainCaptureAudiences.captureId })
|
|
550
|
+
.from(schema.brainCaptureAudiences)
|
|
551
|
+
.where(eq(schema.brainCaptureAudiences.audienceId, audienceId));
|
|
552
|
+
const captureIds = captures.map((capture) => capture.captureId);
|
|
553
|
+
await db
|
|
554
|
+
.update(schema.brainCaptureAudiences)
|
|
555
|
+
.set({ aclHash })
|
|
556
|
+
.where(eq(schema.brainCaptureAudiences.audienceId, audienceId));
|
|
557
|
+
if (captureIds.length) {
|
|
558
|
+
await db
|
|
559
|
+
.update(schema.brainRawCaptures)
|
|
560
|
+
.set({ audienceAclHash: aclHash, updatedAt: now })
|
|
561
|
+
.where(inArray(schema.brainRawCaptures.id, captureIds));
|
|
562
|
+
for (const captureId of captureIds) {
|
|
563
|
+
invalidations.push({
|
|
564
|
+
captureId,
|
|
565
|
+
sourceId: audience.sourceId,
|
|
566
|
+
previousAclHash,
|
|
567
|
+
aclHash,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return { audienceId, aclHash, members: emails.length };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export async function listAccessibleAudienceIds(sourceIds?: string[]) {
|
|
576
|
+
const userEmail = getRequestUserEmail()?.trim().toLowerCase();
|
|
577
|
+
const orgId = getRequestOrgId();
|
|
578
|
+
if (!userEmail && !orgId) return [];
|
|
579
|
+
const principalFilter = or(
|
|
580
|
+
userEmail
|
|
581
|
+
? and(
|
|
582
|
+
eq(schema.brainAudienceMembers.principalType, "user"),
|
|
583
|
+
eq(schema.brainAudienceMembers.principalId, userEmail),
|
|
584
|
+
)
|
|
585
|
+
: undefined,
|
|
586
|
+
orgId
|
|
587
|
+
? and(
|
|
588
|
+
eq(schema.brainAudienceMembers.principalType, "org"),
|
|
589
|
+
eq(schema.brainAudienceMembers.principalId, orgId),
|
|
590
|
+
)
|
|
591
|
+
: undefined,
|
|
592
|
+
);
|
|
593
|
+
if (!principalFilter) return [];
|
|
594
|
+
const slackFreshnessCutoff = new Date(
|
|
595
|
+
Date.now() - SLACK_PRIVATE_AUDIENCE_TTL_MS,
|
|
596
|
+
).toISOString();
|
|
597
|
+
const rows = await getDb()
|
|
598
|
+
.selectDistinct({
|
|
599
|
+
id: schema.brainAudiences.id,
|
|
600
|
+
sourceId: schema.brainAudiences.sourceId,
|
|
601
|
+
})
|
|
602
|
+
.from(schema.brainAudiences)
|
|
603
|
+
.innerJoin(
|
|
604
|
+
schema.brainAudienceMembers,
|
|
605
|
+
eq(schema.brainAudienceMembers.audienceId, schema.brainAudiences.id),
|
|
606
|
+
)
|
|
607
|
+
.where(
|
|
608
|
+
and(
|
|
609
|
+
eq(schema.brainAudiences.membershipState, "current"),
|
|
610
|
+
or(
|
|
611
|
+
ne(schema.brainAudiences.kind, "slack-private-channel"),
|
|
612
|
+
gte(schema.brainAudiences.lastSyncedAt, slackFreshnessCutoff),
|
|
613
|
+
),
|
|
614
|
+
eq(schema.brainAudienceMembers.status, "active"),
|
|
615
|
+
principalFilter,
|
|
616
|
+
),
|
|
617
|
+
);
|
|
618
|
+
if (!rows.length) return [];
|
|
619
|
+
const dependencies = await getDb()
|
|
620
|
+
.select({
|
|
621
|
+
audienceId: schema.brainAudienceDependencies.audienceId,
|
|
622
|
+
dependsOnAudienceId: schema.brainAudienceDependencies.dependsOnAudienceId,
|
|
623
|
+
})
|
|
624
|
+
.from(schema.brainAudienceDependencies)
|
|
625
|
+
.where(
|
|
626
|
+
inArray(
|
|
627
|
+
schema.brainAudienceDependencies.audienceId,
|
|
628
|
+
rows.map((row) => row.id),
|
|
629
|
+
),
|
|
630
|
+
);
|
|
631
|
+
const sourceDependencies = await getDb()
|
|
632
|
+
.select({
|
|
633
|
+
audienceId: schema.brainAudienceSourceDependencies.audienceId,
|
|
634
|
+
sourceId: schema.brainAudienceSourceDependencies.sourceId,
|
|
635
|
+
})
|
|
636
|
+
.from(schema.brainAudienceSourceDependencies)
|
|
637
|
+
.where(
|
|
638
|
+
inArray(
|
|
639
|
+
schema.brainAudienceSourceDependencies.audienceId,
|
|
640
|
+
rows.map((row) => row.id),
|
|
641
|
+
),
|
|
642
|
+
);
|
|
643
|
+
const dependencySourceIds = Array.from(
|
|
644
|
+
new Set(sourceDependencies.map((dependency) => dependency.sourceId)),
|
|
645
|
+
);
|
|
646
|
+
const accessibleDependencySourceIds = dependencySourceIds.length
|
|
647
|
+
? (
|
|
648
|
+
await getDb()
|
|
649
|
+
.select({ id: schema.brainSources.id })
|
|
650
|
+
.from(schema.brainSources)
|
|
651
|
+
.where(
|
|
652
|
+
and(
|
|
653
|
+
inArray(schema.brainSources.id, dependencySourceIds),
|
|
654
|
+
accessFilter(schema.brainSources, schema.brainSourceShares),
|
|
655
|
+
),
|
|
656
|
+
)
|
|
657
|
+
).map((source) => source.id)
|
|
658
|
+
: [];
|
|
659
|
+
const accessibleIds = filterAudienceIdsByDependencies(
|
|
660
|
+
rows.map((row) => row.id),
|
|
661
|
+
dependencies,
|
|
662
|
+
sourceDependencies,
|
|
663
|
+
accessibleDependencySourceIds,
|
|
664
|
+
);
|
|
665
|
+
return rows
|
|
666
|
+
.filter(
|
|
667
|
+
(row) =>
|
|
668
|
+
accessibleIds.has(row.id) &&
|
|
669
|
+
(!sourceIds?.length || sourceIds.includes(row.sourceId)),
|
|
670
|
+
)
|
|
671
|
+
.map((row) => row.id);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function filterAudienceIdsByDependencies(
|
|
675
|
+
candidateIds: string[],
|
|
676
|
+
dependencies: Array<{
|
|
677
|
+
audienceId: string;
|
|
678
|
+
dependsOnAudienceId: string;
|
|
679
|
+
}>,
|
|
680
|
+
sourceDependencies: Array<{ audienceId: string; sourceId: string }> = [],
|
|
681
|
+
accessibleSourceIds: string[] = [],
|
|
682
|
+
) {
|
|
683
|
+
const accessibleIds = new Set(candidateIds);
|
|
684
|
+
const accessibleSources = new Set(accessibleSourceIds);
|
|
685
|
+
let changed = true;
|
|
686
|
+
while (changed) {
|
|
687
|
+
changed = false;
|
|
688
|
+
for (const dependency of dependencies) {
|
|
689
|
+
if (
|
|
690
|
+
accessibleIds.has(dependency.audienceId) &&
|
|
691
|
+
!accessibleIds.has(dependency.dependsOnAudienceId)
|
|
692
|
+
) {
|
|
693
|
+
accessibleIds.delete(dependency.audienceId);
|
|
694
|
+
changed = true;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
for (const dependency of sourceDependencies) {
|
|
698
|
+
if (
|
|
699
|
+
accessibleIds.has(dependency.audienceId) &&
|
|
700
|
+
!accessibleSources.has(dependency.sourceId)
|
|
701
|
+
) {
|
|
702
|
+
accessibleIds.delete(dependency.audienceId);
|
|
703
|
+
changed = true;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return accessibleIds;
|
|
708
|
+
}
|