FN-9000: scope runtime records to their projects
Scope runtime data access to the owning project while preserving explicit unbound compatibility. - Thread optional project identifiers through project-table reads and mutations. - Isolate chat, approvals, artifacts, secrets, audit, branch groups, plugin analytics, and verification cache records. - Add PostgreSQL coverage and a patch changeset documenting multi-project isolation. Files changed: .../fn-9000-project-ownership-runtime-scope.md | 7 + docs/storage.md | 3 +- .../project-ownership-runtime-scope.pg.test.ts | 155 +++++++++++++++++++++ packages/core/src/agents/approval-request-store.ts | 20 ++- .../async-stores/async-approval-request-store.ts | 30 ++-- packages/core/src/async-stores/async-chat-store.ts | 105 +++++++++++--- .../core/src/async-stores/async-secrets-store.ts | 58 +++++--- packages/core/src/chat/chat-store.ts | 22 +-- .../src/plugins/plugin-activation-analytics.ts | 12 +- packages/core/src/secrets/secrets-store.ts | 12 +- packages/core/src/store.ts | 6 +- .../src/task-store/async/async-archive-lineage.ts | 16 ++- packages/core/src/task-store/async/async-audit.ts | 18 ++- .../src/task-store/async/async-branch-groups.ts | 50 ++++--- .../task-store/async/async-comments-attachments.ts | 35 +++-- .../core/src/task-store/branch-and-pr-entities.ts | 15 +- packages/core/src/task-store/branch-group-ops.ts | 2 +- packages/core/src/task-store/task-artifacts-ops.ts | 2 +- packages/core/src/task-store/task-id-integrity.ts | 2 +- packages/core/src/task-store/task-mutation-ops.ts | 14 +- .../core/src/task-store/workflow-definitions.ts | 7 + packages/dashboard/src/server.ts | 2 +- 22 files changed, 472 insertions(+), 121 deletions(-) Fusion-Task-Id: FN-9000 Fusion-Task-Lineage: 9842e9e9-ec50-4459-8534-dfdaa2cdf35e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9000-project-ownership-runtime-scope.md
Normal file
7
.changeset/fn-9000-project-ownership-runtime-scope.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep verification-cache results and project records isolated per project.
|
||||
category: fix
|
||||
dev: Runtime predicates now scope eight project tables; verification-cache results are no longer shared across projects.
|
||||
@@ -38,7 +38,8 @@ See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-revi
|
||||
- `project.agent_ratings` is project-owned with composite `(project_id, id)` identity, allowing the same rating id in separate projects without cross-project reads or deletes. The dynamic `0006_project_ownership.sql` migration reconciles the physical table; `0055_fn_8988_agent_ratings_project_partition.sql` repeats that guarantee idempotently for historical drift. Bound `addRating`, `getRatings`, and `deleteRating` apply the project ownership partition, while unbound compatibility layers retain trigger-stamped writes and unscoped reads/deletes.
|
||||
- FN-8997 audited `workflow_steps`, `chat_room_members`, and `chat_room_messages`: their Drizzle declarations now model 0006's `project_id` and composite keys, and bound workflow/chat helpers scope reads and mutations on that partition. Chat isolation requires both membership/message predicates **and** the parent `chat_rooms.project_id` predicate; either leg alone can resolve a foreign row when room IDs collide. `plugins` remains an intentionally unmodeled compatibility table because it has no runtime Drizzle path. Migration `0056_fn_8997_project_ownership_declaration_drift.sql` is idempotent and adds only partition-prefixed predicate indexes; it does not rewrite healthy 0006 ownership columns or keys.
|
||||
- `project.workflows` has project-local `(project_id, id)` identity. Bound definition reads, updates, deletes, companion workflow settings/prompt-override deletes, and analytics name prefetches use `projectScopeFor`; blank/unbound layers deliberately retain cross-project compatibility reads. The per-project workflow-id counter intentionally scans occupancy across every partition before allocation, because burning a colliding ID is safer than reusing a legacy or stale-counter ID held elsewhere.
|
||||
- FN-9002 makes the `0006_project_ownership.sql` partition expressible for `artifacts`, `secrets`, `branch_groups`, `plugin_activations`, `chat_messages`, `run_audit_events`, `verification_cache`, and `approval_requests`: each declaration retains the database default and models its project-leading identity (plus the artifacts task FK and secrets/branch-name unique keys). Their runtime owners are respectively async attachments/archive/task mutation; async secrets; async branch groups; activation analytics/events; async chat; async audit/merge coordination/data layer; workflow definitions; and async approval requests. This is declaration-only: existing inserts keep omitting `projectId`, public record mappers keep omitting it, and all project predicates are deliberately deferred to FN-9000.
|
||||
- FN-9002 makes the `0006_project_ownership.sql` partition expressible for `artifacts`, `secrets`, `branch_groups`, `plugin_activations`, `chat_messages`, `run_audit_events`, `verification_cache`, and `approval_requests`: each declaration retains the database default and models its project-leading identity (plus the artifacts task FK and secrets/branch-name unique keys).
|
||||
- FN-9000 scopes every load-bearing runtime Drizzle read, update, and delete for those eight tables with the bound `projectId`; blank/unbound layers intentionally retain cross-project compatibility reads. Chat message operations scope both `chat_messages` and their parent `chat_sessions` row. The `central.secrets_global` dispatch remains global, while `project.plugins` has no runtime Drizzle path and pre-cutover SQLite compatibility paths remain unchanged. Verification-cache entries now remain inside their owning project rather than being shared across projects.
|
||||
- Startup and Batch 1 self-healing expire locks when their lease elapsed or the owner task is terminal/missing. They never move a task or alter scheduler, worktree, semaphore, or verification state. Run-audit events are `symbol-lock:acquired`, `symbol-lock:acquire-conflict`, `symbol-lock:renewed`, `symbol-lock:released`, `symbol-lock:reconcile-stale`, and deduplicated `symbol-lock:reconcile-stale-no-action`; metadata uses only counts/outcomes and normalized opaque keys.
|
||||
- FN-8405 adds `Task.declaredSymbols` as the durable, normalized task declaration source. `## Declared Symbols` in PROMPT.md is parsed only on create/update writes: an absent key may hydrate from the prompt, while a present `undefined`, `null` (update), or `[]` clears and suppresses hydration; a non-empty explicit array wins. Store resolution (`resolveTaskSymbols` and `resolveTaskSymbolsForWorkItem({ taskId })`) reads only the durable field, and slim projections plus archive/restore retain it. Scheduler admission remains a separate FN-8306 consumer; File Scope is never treated as a symbol source.
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
createSharedPgTaskStoreTestHarness,
|
||||
pgDescribe,
|
||||
type SharedPgTaskStoreHarness,
|
||||
} from "../../__test-utils__/pg-test-harness.js";
|
||||
import type { AsyncDataLayer } from "../../postgres/data-layer.js";
|
||||
import * as schema from "../../postgres/schema/index.js";
|
||||
import {
|
||||
decideApprovalRequest,
|
||||
getApprovalRequest,
|
||||
listApprovalRequests,
|
||||
} from "../../async-stores/async-approval-request-store.js";
|
||||
import { getChatMessage, getChatMessages } from "../../async-stores/async-chat-store.js";
|
||||
import { getSecretMetadata, listSecrets } from "../../async-stores/async-secrets-store.js";
|
||||
import { aggregatePluginActivations } from "../../plugins/plugin-activation-analytics.js";
|
||||
import { getArtifact, listArtifacts, updateArtifactRow } from "../../task-store/async/async-comments-attachments.js";
|
||||
import { queryRunAuditEvents } from "../../task-store/async/async-audit.js";
|
||||
import { getBranchGroup, listBranchGroups, updateBranchGroup } from "../../task-store/async/async-branch-groups.js";
|
||||
import { TaskStore } from "../../store.js";
|
||||
|
||||
pgDescribe("project ownership runtime scope", () => {
|
||||
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
|
||||
prefix: "fusion_project_ownership_runtime_scope",
|
||||
});
|
||||
const now = "2026-08-12T14:15:00.000Z";
|
||||
const bind = (projectId: string): AsyncDataLayer => ({ ...h.layer(), projectId });
|
||||
|
||||
beforeAll(h.beforeAll);
|
||||
afterAll(h.afterAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
|
||||
it("isolates all drifted project tables for bound callers while preserving unbound reads", async () => {
|
||||
/*
|
||||
FNXC:MultiProjectIsolation 2026-08-12-14:15:
|
||||
FN-9000 requires QueryHandle helpers to accept a trailing optional projectId while layer helpers read layer.projectId. Owner-connected PostgreSQL bypasses RLS, so this two-project fixture makes every runtime predicate load-bearing: bound reads and mutations must keep the colliding foreign row untouched, while empty-string and undefined callers retain deliberate unbound compatibility.
|
||||
*/
|
||||
const a = bind("ownership-a");
|
||||
const b = bind("ownership-b");
|
||||
const unbound = { ...h.layer(), projectId: undefined };
|
||||
const empty = { ...h.layer(), projectId: "" };
|
||||
|
||||
// Seed the physical composite-key shape directly so every public read sees a colliding natural identity.
|
||||
await h.adminDb().insert(schema.project.artifacts).values([
|
||||
{ projectId: a.projectId!, id: "art-1", type: "document", title: "Artifact A", authorId: "owner", authorType: "agent", createdAt: now, updatedAt: now },
|
||||
{ projectId: b.projectId!, id: "art-1", type: "document", title: "Artifact B", authorId: "owner", authorType: "agent", createdAt: now, updatedAt: now },
|
||||
]);
|
||||
await h.adminDb().insert(schema.project.secrets).values([
|
||||
{ projectId: a.projectId!, id: "sec-1", key: "SHARED_SECRET", valueCiphertext: Buffer.from([1]), nonce: Buffer.from([1]), createdAt: now, updatedAt: now },
|
||||
{ projectId: b.projectId!, id: "sec-1", key: "SHARED_SECRET", valueCiphertext: Buffer.from([2]), nonce: Buffer.from([2]), createdAt: now, updatedAt: now },
|
||||
]);
|
||||
await h.adminDb().insert(schema.project.branchGroups).values([
|
||||
{ projectId: a.projectId!, id: "bg-1", sourceType: "planning", sourceId: "shared-source", branchName: "feature/shared", createdAt: 1, updatedAt: 1 },
|
||||
{ projectId: b.projectId!, id: "bg-1", sourceType: "planning", sourceId: "shared-source", branchName: "feature/shared", createdAt: 1, updatedAt: 1 },
|
||||
]);
|
||||
await h.adminDb().insert(schema.project.pluginActivations).values([
|
||||
{ projectId: a.projectId!, pluginId: "plugin-shared", source: "test", activatedAt: now },
|
||||
{ projectId: b.projectId!, pluginId: "plugin-shared", source: "test", activatedAt: now },
|
||||
]);
|
||||
/*
|
||||
FNXC:ChatProjectIsolation 2026-08-12-14:15:
|
||||
Keep the parent only in A: a message predicate alone would still expose B's
|
||||
colliding message, while the mandatory parent-session predicate rejects it.
|
||||
*/
|
||||
await h.adminDb().insert(schema.project.chatSessions).values({
|
||||
id: "sess-1", agentId: "agent", projectId: a.projectId!, createdAt: now, updatedAt: now,
|
||||
});
|
||||
await h.adminDb().insert(schema.project.chatMessages).values([
|
||||
{ projectId: a.projectId!, id: "msg-1", sessionId: "sess-1", role: "user", content: "Message A", createdAt: now },
|
||||
{ projectId: b.projectId!, id: "msg-1", sessionId: "sess-1", role: "user", content: "Message B", createdAt: now },
|
||||
]);
|
||||
await h.adminDb().insert(schema.project.runAuditEvents).values([
|
||||
{ projectId: a.projectId!, id: "rae-older", timestamp: "2026-08-12T14:13:00.000Z", agentId: "agent", runId: "run-shared", domain: "test", mutationType: "shared", target: "A older" },
|
||||
{ projectId: a.projectId!, id: "rae-1", timestamp: "2026-08-12T14:14:00.000Z", agentId: "agent", runId: "run-shared", domain: "test", mutationType: "shared", target: "A newest" },
|
||||
{ projectId: b.projectId!, id: "rae-1", timestamp: "2026-08-12T14:16:00.000Z", agentId: "agent", runId: "run-shared", domain: "test", mutationType: "shared", target: "B newest" },
|
||||
]);
|
||||
await h.adminDb().insert(schema.project.verificationCache).values([
|
||||
{ projectId: a.projectId!, treeSha: "tree-shared", testCommand: "test", buildCommand: "build", recordedAt: now, taskId: "cache-a" },
|
||||
{ projectId: b.projectId!, treeSha: "tree-shared", testCommand: "test", buildCommand: "build", recordedAt: now, taskId: "cache-b" },
|
||||
]);
|
||||
await h.adminDb().insert(schema.project.approvalRequests).values([
|
||||
{ projectId: a.projectId!, id: "ar-1", status: "pending", requesterActorId: "agent", requesterActorType: "agent", requesterActorName: "Agent A", targetActionCategory: "test", targetActionOperation: "shared", targetActionSummary: "A", targetResourceType: "task", targetResourceId: "shared", requestedAt: now, createdAt: now, updatedAt: now },
|
||||
{ projectId: b.projectId!, id: "ar-1", status: "pending", requesterActorId: "agent", requesterActorType: "agent", requesterActorName: "Agent B", targetActionCategory: "test", targetActionOperation: "shared", targetActionSummary: "B", targetResourceType: "task", targetResourceId: "shared", requestedAt: now, createdAt: now, updatedAt: now },
|
||||
]);
|
||||
|
||||
// Cast only bridges Step 1's pre-threading signatures; the runtime calls intentionally carry the future trailing projectId.
|
||||
const artifact = getArtifact as unknown as (db: typeof a.db, id: string, projectId?: string) => ReturnType<typeof getArtifact>;
|
||||
const updateArtifact = updateArtifactRow as unknown as (layer: AsyncDataLayer, id: string, patch: { title?: string }, projectId?: string) => ReturnType<typeof updateArtifactRow>;
|
||||
const artifacts = listArtifacts as unknown as (db: typeof a.db, options: { authorId: string }, projectId?: string) => ReturnType<typeof listArtifacts>;
|
||||
const secret = getSecretMetadata as unknown as (db: typeof a.db, id: string, scope: "project", projectId?: string) => ReturnType<typeof getSecretMetadata>;
|
||||
const secrets = listSecrets as unknown as (db: typeof a.db, scope: "project", projectId?: string) => ReturnType<typeof listSecrets>;
|
||||
const branch = getBranchGroup as unknown as (db: typeof a.db, id: string, projectId?: string) => ReturnType<typeof getBranchGroup>;
|
||||
const branches = listBranchGroups as unknown as (db: typeof a.db, options: undefined, projectId?: string) => ReturnType<typeof listBranchGroups>;
|
||||
const updateBranch = updateBranchGroup as unknown as (db: typeof a.db, id: string, patch: { status: "finalized" }, projectId?: string) => ReturnType<typeof updateBranchGroup>;
|
||||
const chatMessage = getChatMessage as unknown as (db: typeof a.db, id: string, projectId?: string) => ReturnType<typeof getChatMessage>;
|
||||
const chatMessages = getChatMessages as unknown as (db: typeof a.db, sessionId: string, filter: undefined, projectId?: string) => ReturnType<typeof getChatMessages>;
|
||||
const audits = queryRunAuditEvents as unknown as (db: typeof a.db, filter: { runId: string; limit?: number }, projectId?: string) => ReturnType<typeof queryRunAuditEvents>;
|
||||
const approvals = getApprovalRequest as unknown as (db: typeof a.db, id: string, projectId?: string) => ReturnType<typeof getApprovalRequest>;
|
||||
const approvalList = listApprovalRequests as unknown as (db: typeof a.db, input: object, projectId?: string) => ReturnType<typeof listApprovalRequests>;
|
||||
|
||||
expect((await artifact(a.db, "art-1", a.projectId))?.title).toBe("Artifact A");
|
||||
await updateArtifact(a, "art-1", { title: "Artifact A updated" }, a.projectId);
|
||||
expect((await h.adminDb().select({ title: schema.project.artifacts.title }).from(schema.project.artifacts).where(and(eq(schema.project.artifacts.projectId, b.projectId!), eq(schema.project.artifacts.id, "art-1"))))[0]?.title).toBe("Artifact B");
|
||||
expect((await artifacts(a.db, { authorId: "owner" }, a.projectId)).map((row) => row.id)).toEqual(["art-1"]);
|
||||
expect((await artifacts(unbound.db, { authorId: "owner" }, unbound.projectId)).map((row) => row.id)).toHaveLength(2);
|
||||
|
||||
expect((await secret(a.db, "sec-1", "project", a.projectId))?.key).toBe("SHARED_SECRET");
|
||||
expect((await secrets(a.db, "project", a.projectId)).map((row) => row.id)).toEqual(["sec-1"]);
|
||||
expect((await secrets(empty.db, "project", empty.projectId)).map((row) => row.id)).toHaveLength(2);
|
||||
|
||||
expect((await branch(a.db, "bg-1", a.projectId))?.branchName).toBe("feature/shared");
|
||||
await updateBranch(a.db, "bg-1", { status: "finalized" }, a.projectId);
|
||||
expect((await h.adminDb().select({ status: schema.project.branchGroups.status }).from(schema.project.branchGroups).where(and(eq(schema.project.branchGroups.projectId, b.projectId!), eq(schema.project.branchGroups.id, "bg-1"))))[0]?.status).toBe("open");
|
||||
expect((await branches(a.db, undefined, a.projectId)).map((row) => row.id)).toEqual(["bg-1"]);
|
||||
expect((await branches(unbound.db, undefined, unbound.projectId)).map((row) => row.id)).toHaveLength(2);
|
||||
|
||||
expect((await chatMessage(a.db, "msg-1", a.projectId))?.content).toBe("Message A");
|
||||
expect((await chatMessages(a.db, "sess-1", undefined, a.projectId)).map((row) => row.content)).toEqual(["Message A"]);
|
||||
expect((await chatMessages(empty.db, "sess-1", undefined, empty.projectId)).map((row) => row.content)).toEqual(["Message A", "Message B"]);
|
||||
|
||||
expect((await audits(a.db, { runId: "run-shared", limit: 1 }, a.projectId)).map((row) => row.target)).toEqual(["A newest"]);
|
||||
expect((await audits(unbound.db, { runId: "run-shared" }, unbound.projectId)).map((row) => row.target)).toHaveLength(3);
|
||||
|
||||
expect((await aggregatePluginActivations(a)).activations).toBe(1);
|
||||
expect((await aggregatePluginActivations(bind("ownership-empty"))).unavailable).toBe(true);
|
||||
expect((await aggregatePluginActivations(unbound)).activations).toBe(2);
|
||||
|
||||
expect((await approvals(a.db, "ar-1", a.projectId))?.requester.actorName).toBe("Agent A");
|
||||
await decideApprovalRequest(a, "ar-1", "approved", { actor: { actorId: "operator", actorType: "user", actorName: "Operator" } });
|
||||
expect((await approvals(b.db, "ar-1", b.projectId))?.status).toBe("pending");
|
||||
expect((await approvalList(a.db, {}, a.projectId)).map((row) => row.id)).toEqual(["ar-1"]);
|
||||
expect((await approvalList(unbound.db, {}, unbound.projectId)).map((row) => row.id)).toHaveLength(2);
|
||||
|
||||
const storeA = new TaskStore(h.rootDir(), undefined, { asyncLayer: a });
|
||||
const storeB = new TaskStore(h.rootDir(), undefined, { asyncLayer: b });
|
||||
/*
|
||||
FNXC:BranchGroupProjectIsolation 2026-08-12-14:30:
|
||||
TaskStore wrappers are the load-bearing production path: helper-only coverage would not catch a missing layer.projectId argument at this boundary.
|
||||
*/
|
||||
expect((await storeA.getBranchGroup("bg-1"))?.sourceId).toBe("shared-source");
|
||||
expect((await storeA.getBranchGroupBySource("planning", "shared-source"))?.id).toBe("bg-1");
|
||||
expect((await storeA.getBranchGroupByBranchName("feature/shared"))?.id).toBe("bg-1");
|
||||
expect((await storeA.listBranchGroups()).map((row) => row.id)).toEqual(["bg-1"]);
|
||||
await storeA.updateBranchGroup("bg-1", { branchName: "feature/a-runtime" });
|
||||
expect((await storeB.getBranchGroup("bg-1"))?.branchName).toBe("feature/shared");
|
||||
expect((await storeA.ensureBranchGroupForSource("planning", "shared-source", { branchName: "feature/a-runtime" })).id).toBe("bg-1");
|
||||
expect((await storeA.getVerificationCacheHit("tree-shared", "test", "build"))?.taskId).toBe("cache-a");
|
||||
expect((await storeB.getVerificationCacheHit("tree-shared", "test", "build"))?.taskId).toBe("cache-b");
|
||||
expect((await storeB.getVerificationCacheHit("tree-shared", "test", "build"))?.taskId).toBe("cache-b");
|
||||
expect(await h.adminDb().select({ taskId: schema.project.verificationCache.taskId }).from(schema.project.verificationCache).where(eq(schema.project.verificationCache.treeSha, "tree-shared"))).toHaveLength(2);
|
||||
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { count, eq, desc, and } from "drizzle-orm";
|
||||
import type { Database } from "../db/db.js";
|
||||
import { fromJson } from "../db/db.js";
|
||||
import type { AsyncDataLayer } from "../postgres/data-layer.js";
|
||||
import { projectScopeFor, type AsyncDataLayer } from "../postgres/data-layer.js";
|
||||
import * as asyncApprovalRequestStore from "../async-stores/async-approval-request-store.js";
|
||||
import * as schema from "../postgres/schema/index.js";
|
||||
import { appendAgentActivityEvent } from "../task-store/async/async-agent-activity.js";
|
||||
@@ -204,11 +204,11 @@ export class ApprovalRequestStore {
|
||||
}
|
||||
|
||||
async get(id: string): Promise<ApprovalRequest | null> {
|
||||
return asyncApprovalRequestStore.getApprovalRequest(this.asyncLayer!.db, id);
|
||||
return asyncApprovalRequestStore.getApprovalRequest(this.asyncLayer!.db, id, this.asyncLayer!.projectId);
|
||||
}
|
||||
|
||||
async list(input: ApprovalRequestListInput = {}): Promise<ApprovalRequest[]> {
|
||||
return asyncApprovalRequestStore.listApprovalRequests(this.asyncLayer!.db, input);
|
||||
return asyncApprovalRequestStore.listApprovalRequests(this.asyncLayer!.db, input, this.asyncLayer!.projectId);
|
||||
}
|
||||
|
||||
async getPendingCountsByActor(): Promise<Map<string, number>> {
|
||||
@@ -219,7 +219,7 @@ export class ApprovalRequestStore {
|
||||
requestCount: count(),
|
||||
})
|
||||
.from(table)
|
||||
.where(eq(table.status, "pending"))
|
||||
.where(and(eq(table.status, "pending"), projectScopeFor(table.projectId, this.asyncLayer!.projectId)))
|
||||
.groupBy(table.requesterActorId);
|
||||
return new Map(rows.map((row) => [row.actorId, Number(row.requestCount)]));
|
||||
}
|
||||
@@ -231,7 +231,15 @@ export class ApprovalRequestStore {
|
||||
*/
|
||||
if (this.backendMode) {
|
||||
const table = schema.project.approvalRequests;
|
||||
const conditions = [eq(table.requesterActorId, input.requesterActorId)];
|
||||
/*
|
||||
FNXC:ApprovalProjectIsolation 2026-08-12-14:15:
|
||||
Approval request IDs and dedupe keys can repeat in another project after
|
||||
the composite-key migration, so every runtime lookup stays on this layer's project.
|
||||
*/
|
||||
const conditions = [
|
||||
eq(table.requesterActorId, input.requesterActorId),
|
||||
projectScopeFor(table.projectId, this.asyncLayer!.projectId),
|
||||
];
|
||||
if (input.taskId !== undefined) {
|
||||
conditions.push(eq(table.taskId, input.taskId));
|
||||
}
|
||||
@@ -281,6 +289,6 @@ export class ApprovalRequestStore {
|
||||
}
|
||||
|
||||
async getAuditHistory(requestId: string): Promise<ApprovalRequestAuditEvent[]> {
|
||||
return asyncApprovalRequestStore.getApprovalAuditHistory(this.asyncLayer!.db, requestId);
|
||||
return asyncApprovalRequestStore.getApprovalAuditHistory(this.asyncLayer!.db, requestId, this.asyncLayer!.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import * as schema from "../postgres/schema/index.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js";
|
||||
import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../postgres/data-layer.js";
|
||||
// FNXC:ApprovalLifecycleSecurity 2026-07-26-12:25:
|
||||
import { isApprovalRequestExpired } from "../types/agents/agents.js";
|
||||
import {
|
||||
@@ -182,6 +182,7 @@ export async function createApprovalRequest(
|
||||
};
|
||||
await layer.transactionImmediate(async (tx) => {
|
||||
await tx.insert(schema.project.approvalRequests).values({
|
||||
...(layer.projectId?.trim() ? { projectId: layer.projectId } : {}),
|
||||
id: request.id,
|
||||
status: request.status,
|
||||
requesterActorId: request.requester.actorId,
|
||||
@@ -207,16 +208,19 @@ export async function createApprovalRequest(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single approval request by id.
|
||||
* FNXC:ApprovalProjectIsolation 2026-08-12-14:15:
|
||||
* approval_requests now has a composite project/id identity, so id-only reads
|
||||
* and the guarded decision updates must constrain the bound runtime project.
|
||||
*/
|
||||
export async function getApprovalRequest(
|
||||
handle: QueryHandle,
|
||||
id: string,
|
||||
projectId?: string,
|
||||
): Promise<ApprovalRequest | null> {
|
||||
const rows = await handle
|
||||
.select()
|
||||
.from(schema.project.approvalRequests)
|
||||
.where(eq(schema.project.approvalRequests.id, id));
|
||||
.where(and(eq(schema.project.approvalRequests.id, id), projectScopeFor(schema.project.approvalRequests.projectId, projectId)));
|
||||
return rows[0] ? rowToRequest(rows[0] as ApprovalRequestRow) : null;
|
||||
}
|
||||
|
||||
@@ -227,8 +231,9 @@ export async function getApprovalRequest(
|
||||
export async function listApprovalRequests(
|
||||
handle: QueryHandle,
|
||||
input: ApprovalRequestListInput = {},
|
||||
projectId?: string,
|
||||
): Promise<ApprovalRequest[]> {
|
||||
const conditions: ReturnType<typeof eq>[] = [];
|
||||
const conditions = [projectScopeFor(schema.project.approvalRequests.projectId, projectId)];
|
||||
if (input.status) conditions.push(eq(schema.project.approvalRequests.status, input.status));
|
||||
if (input.requesterActorId) conditions.push(eq(schema.project.approvalRequests.requesterActorId, input.requesterActorId));
|
||||
if (input.taskId) conditions.push(eq(schema.project.approvalRequests.taskId, input.taskId));
|
||||
@@ -266,7 +271,7 @@ export async function decideApprovalRequest(
|
||||
): Promise<ApprovalRequest> {
|
||||
const now = new Date().toISOString();
|
||||
return layer.transactionImmediate(async (tx) => {
|
||||
const existing = await getApprovalRequest(tx, requestId);
|
||||
const existing = await getApprovalRequest(tx, requestId, layer.projectId);
|
||||
if (!existing) throw new Error(`Approval request ${requestId} not found`);
|
||||
if (!isValidApprovalRequestTransition(existing.status, status)) {
|
||||
throw new Error(`Invalid approval request transition: ${existing.status} -> ${status}`);
|
||||
@@ -281,17 +286,18 @@ export async function decideApprovalRequest(
|
||||
and(
|
||||
eq(schema.project.approvalRequests.id, requestId),
|
||||
eq(schema.project.approvalRequests.status, existing.status),
|
||||
projectScopeFor(schema.project.approvalRequests.projectId, layer.projectId),
|
||||
),
|
||||
)
|
||||
.returning({ id: schema.project.approvalRequests.id });
|
||||
if (updatedRows.length === 0) {
|
||||
const raced = await getApprovalRequest(tx, requestId);
|
||||
const raced = await getApprovalRequest(tx, requestId, layer.projectId);
|
||||
throw new Error(
|
||||
`Invalid approval request transition: ${raced?.status ?? existing.status} -> ${status}`,
|
||||
);
|
||||
}
|
||||
await appendAuditEvent(tx, layer.projectId ?? "", requestId, status, input.actor, now, input.note);
|
||||
return (await getApprovalRequest(tx, requestId))!;
|
||||
return (await getApprovalRequest(tx, requestId, layer.projectId))!;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,7 +320,7 @@ export async function markApprovalRequestCompleted(
|
||||
): Promise<ApprovalRequest> {
|
||||
const now = new Date().toISOString();
|
||||
return layer.transactionImmediate(async (tx) => {
|
||||
const existing = await getApprovalRequest(tx, requestId);
|
||||
const existing = await getApprovalRequest(tx, requestId, layer.projectId);
|
||||
if (!existing) throw new Error(`Approval request ${requestId} not found`);
|
||||
if (!isValidApprovalRequestTransition(existing.status, "completed")) {
|
||||
throw new Error(`Invalid approval request transition: ${existing.status} -> completed`);
|
||||
@@ -335,17 +341,18 @@ export async function markApprovalRequestCompleted(
|
||||
and(
|
||||
eq(schema.project.approvalRequests.id, requestId),
|
||||
eq(schema.project.approvalRequests.status, existing.status),
|
||||
projectScopeFor(schema.project.approvalRequests.projectId, layer.projectId),
|
||||
),
|
||||
)
|
||||
.returning({ id: schema.project.approvalRequests.id });
|
||||
if (updatedRows.length === 0) {
|
||||
const raced = await getApprovalRequest(tx, requestId);
|
||||
const raced = await getApprovalRequest(tx, requestId, layer.projectId);
|
||||
throw new Error(
|
||||
`Invalid approval request transition: ${raced?.status ?? existing.status} -> completed`,
|
||||
);
|
||||
}
|
||||
await appendAuditEvent(tx, layer.projectId ?? "", requestId, "completed", input.actor, now, input.note);
|
||||
return (await getApprovalRequest(tx, requestId))!;
|
||||
return (await getApprovalRequest(tx, requestId, layer.projectId))!;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -355,11 +362,12 @@ export async function markApprovalRequestCompleted(
|
||||
export async function getApprovalAuditHistory(
|
||||
handle: QueryHandle,
|
||||
requestId: string,
|
||||
projectId?: string,
|
||||
): Promise<ApprovalRequestAuditEvent[]> {
|
||||
const rows = await handle
|
||||
.select()
|
||||
.from(schema.project.approvalRequestAuditEvents)
|
||||
.where(eq(schema.project.approvalRequestAuditEvents.requestId, requestId))
|
||||
.where(and(eq(schema.project.approvalRequestAuditEvents.requestId, requestId), projectScopeFor(schema.project.approvalRequestAuditEvents.projectId, projectId)))
|
||||
.orderBy(
|
||||
sql`${schema.project.approvalRequestAuditEvents.createdAt} ASC, ${schema.project.approvalRequestAuditEvents.id} ASC`,
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* consume.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, desc, eq, gt, ilike, inArray, isNull, lte, ne, or as orFn, sql as drizzleSql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, exists, gt, ilike, inArray, isNull, lte, ne, or as orFn, sql as drizzleSql } from "drizzle-orm";
|
||||
import * as schema from "../postgres/schema/index.js";
|
||||
import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../postgres/data-layer.js";
|
||||
import { sanitizeTextValue, sanitizeJsonbValue } from "../postgres/nul-sanitize.js";
|
||||
@@ -81,6 +81,27 @@ function rowToMessage(row: Record<string, unknown>): ChatMessage {
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatProjectIsolation 2026-08-12-14:16:
|
||||
Messages and their parent sessions must resolve within the same bound partition.
|
||||
A message-only predicate is insufficient when session IDs collide across projects;
|
||||
unbound handles deliberately keep their existing cross-project behavior.
|
||||
*/
|
||||
function chatMessageProjectConditions(handle: QueryHandle, projectId?: string) {
|
||||
const messageScope = projectScopeFor(schema.project.chatMessages.projectId, projectId);
|
||||
const sessionScope = projectScopeFor(schema.project.chatSessions.projectId, projectId);
|
||||
if (!messageScope || !sessionScope) return [];
|
||||
return [
|
||||
messageScope,
|
||||
exists(handle.select({ id: schema.project.chatSessions.id })
|
||||
.from(schema.project.chatSessions)
|
||||
.where(and(
|
||||
eq(schema.project.chatSessions.id, schema.project.chatMessages.sessionId),
|
||||
sessionScope,
|
||||
))),
|
||||
];
|
||||
}
|
||||
|
||||
function rowToRoom(row: Record<string, unknown>): ChatRoom {
|
||||
return {
|
||||
id: row.id as string,
|
||||
@@ -311,6 +332,7 @@ export async function replaceChatSessionTags(layer: AsyncDataLayer, sessionId: s
|
||||
export async function addChatMessage(
|
||||
handle: QueryHandle,
|
||||
message: ChatMessage,
|
||||
projectId?: string,
|
||||
): Promise<ChatMessage> {
|
||||
// FNXC:PostgresMigrationNulSanitize 2026-07-20: agent/tool output persisted
|
||||
// here can contain a raw NUL byte (e.g. piped-through Windows CLI dumps),
|
||||
@@ -341,18 +363,28 @@ export async function addChatMessage(
|
||||
await handle
|
||||
.update(schema.project.chatSessions)
|
||||
.set({ updatedAt: sanitized.createdAt })
|
||||
.where(eq(schema.project.chatSessions.id, sanitized.sessionId));
|
||||
.where(and(
|
||||
eq(schema.project.chatSessions.id, sanitized.sessionId),
|
||||
projectScopeFor(schema.project.chatSessions.projectId, projectId),
|
||||
));
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a chat message by id.
|
||||
*/
|
||||
export async function getChatMessage(handle: QueryHandle, id: string): Promise<ChatMessage | undefined> {
|
||||
export async function getChatMessage(
|
||||
handle: QueryHandle,
|
||||
id: string,
|
||||
projectId?: string,
|
||||
): Promise<ChatMessage | undefined> {
|
||||
const rows = await handle
|
||||
.select()
|
||||
.from(schema.project.chatMessages)
|
||||
.where(eq(schema.project.chatMessages.id, id));
|
||||
.where(and(
|
||||
eq(schema.project.chatMessages.id, id),
|
||||
...chatMessageProjectConditions(handle, projectId),
|
||||
));
|
||||
return rows[0] ? rowToMessage(rows[0]) : undefined;
|
||||
}
|
||||
|
||||
@@ -363,8 +395,12 @@ export async function getChatMessages(
|
||||
handle: QueryHandle,
|
||||
sessionId: string,
|
||||
filter?: { limit?: number; offset?: number; before?: string; order?: "asc" | "desc" },
|
||||
projectId?: string,
|
||||
): Promise<ChatMessage[]> {
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(schema.project.chatMessages.sessionId, sessionId)];
|
||||
const conditions = [
|
||||
eq(schema.project.chatMessages.sessionId, sessionId),
|
||||
...chatMessageProjectConditions(handle, projectId),
|
||||
];
|
||||
if (filter?.before) {
|
||||
conditions.push(lte(schema.project.chatMessages.createdAt, filter.before));
|
||||
}
|
||||
@@ -388,12 +424,16 @@ export async function getChatMessages(
|
||||
export async function getLastMessageForSessions(
|
||||
handle: QueryHandle,
|
||||
sessionIds: string[],
|
||||
projectId?: string,
|
||||
): Promise<Map<string, ChatMessage>> {
|
||||
if (sessionIds.length === 0) return new Map();
|
||||
const rows = await handle
|
||||
.select()
|
||||
.from(schema.project.chatMessages)
|
||||
.where(inArray(schema.project.chatMessages.sessionId, sessionIds))
|
||||
.where(and(
|
||||
inArray(schema.project.chatMessages.sessionId, sessionIds),
|
||||
...chatMessageProjectConditions(handle, projectId),
|
||||
))
|
||||
.orderBy(
|
||||
desc(schema.project.chatMessages.createdAt),
|
||||
desc(schema.project.chatMessages.id),
|
||||
@@ -812,8 +852,9 @@ export async function addChatMessageAttachment(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
attachment: ChatAttachment,
|
||||
projectId?: string,
|
||||
): Promise<ChatMessage> {
|
||||
const message = await getChatMessage(handle, messageId);
|
||||
const message = await getChatMessage(handle, messageId, projectId);
|
||||
if (!message || message.sessionId !== sessionId) {
|
||||
throw new Error(`Message ${messageId} not found in session ${sessionId}`);
|
||||
}
|
||||
@@ -824,8 +865,11 @@ export async function addChatMessageAttachment(
|
||||
await handle
|
||||
.update(schema.project.chatMessages)
|
||||
.set({ attachments: updatedAttachments })
|
||||
.where(eq(schema.project.chatMessages.id, messageId));
|
||||
const updated = await getChatMessage(handle, messageId);
|
||||
.where(and(
|
||||
eq(schema.project.chatMessages.id, messageId),
|
||||
...chatMessageProjectConditions(handle, projectId),
|
||||
));
|
||||
const updated = await getChatMessage(handle, messageId, projectId);
|
||||
if (!updated) throw new Error(`Failed to update message ${messageId}`);
|
||||
return updated;
|
||||
}
|
||||
@@ -838,14 +882,21 @@ export async function addChatMessageAttachment(
|
||||
export async function deleteChatMessage(
|
||||
handle: QueryHandle,
|
||||
id: string,
|
||||
projectId?: string,
|
||||
): Promise<boolean> {
|
||||
const existing = await getChatMessage(handle, id);
|
||||
const existing = await getChatMessage(handle, id, projectId);
|
||||
if (!existing) return false;
|
||||
await handle.delete(schema.project.chatMessages).where(eq(schema.project.chatMessages.id, id));
|
||||
await handle.delete(schema.project.chatMessages).where(and(
|
||||
eq(schema.project.chatMessages.id, id),
|
||||
...chatMessageProjectConditions(handle, projectId),
|
||||
));
|
||||
await handle
|
||||
.update(schema.project.chatSessions)
|
||||
.set({ updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.project.chatSessions.id, existing.sessionId));
|
||||
.where(and(
|
||||
eq(schema.project.chatSessions.id, existing.sessionId),
|
||||
projectScopeFor(schema.project.chatSessions.projectId, projectId),
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -861,6 +912,7 @@ export async function searchChatSessionsByMessageContent(
|
||||
handle: QueryHandle,
|
||||
query: string,
|
||||
sessionIds: string[],
|
||||
projectId?: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed || sessionIds.length === 0) return new Map();
|
||||
@@ -871,6 +923,7 @@ export async function searchChatSessionsByMessageContent(
|
||||
.where(and(
|
||||
inArray(schema.project.chatMessages.sessionId, sessionIds),
|
||||
ilike(schema.project.chatMessages.content, `%${escaped}%`),
|
||||
...chatMessageProjectConditions(handle, projectId),
|
||||
))
|
||||
.orderBy(
|
||||
desc(schema.project.chatMessages.createdAt),
|
||||
@@ -898,18 +951,22 @@ export async function deleteChatMessagesFrom(
|
||||
handle: QueryHandle,
|
||||
sessionId: string,
|
||||
fromMessageId: string,
|
||||
projectId?: string,
|
||||
): Promise<{ deletedIds: string[]; retained: ChatMessage[] }> {
|
||||
const orderedRows = await handle
|
||||
.select()
|
||||
.from(schema.project.chatMessages)
|
||||
.where(eq(schema.project.chatMessages.sessionId, sessionId))
|
||||
.where(and(
|
||||
eq(schema.project.chatMessages.sessionId, sessionId),
|
||||
...chatMessageProjectConditions(handle, projectId),
|
||||
))
|
||||
.orderBy(
|
||||
asc(schema.project.chatMessages.createdAt),
|
||||
asc(schema.project.chatMessages.id),
|
||||
);
|
||||
const ordered = orderedRows.map(rowToMessage);
|
||||
|
||||
const target = await getChatMessage(handle, fromMessageId);
|
||||
const target = await getChatMessage(handle, fromMessageId, projectId);
|
||||
if (!target || target.sessionId !== sessionId) {
|
||||
return { deletedIds: [], retained: ordered };
|
||||
}
|
||||
@@ -927,11 +984,17 @@ export async function deleteChatMessagesFrom(
|
||||
|
||||
await handle
|
||||
.delete(schema.project.chatMessages)
|
||||
.where(inArray(schema.project.chatMessages.id, deletedIds));
|
||||
.where(and(
|
||||
inArray(schema.project.chatMessages.id, deletedIds),
|
||||
...chatMessageProjectConditions(handle, projectId),
|
||||
));
|
||||
await handle
|
||||
.update(schema.project.chatSessions)
|
||||
.set({ updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.project.chatSessions.id, sessionId));
|
||||
.where(and(
|
||||
eq(schema.project.chatSessions.id, sessionId),
|
||||
projectScopeFor(schema.project.chatSessions.projectId, projectId),
|
||||
));
|
||||
|
||||
return { deletedIds, retained };
|
||||
}
|
||||
@@ -947,8 +1010,9 @@ export async function updateChatMessageMetadata(
|
||||
messageId: string,
|
||||
metadata: Record<string, unknown> | null,
|
||||
options?: { merge?: boolean },
|
||||
projectId?: string,
|
||||
): Promise<ChatMessage> {
|
||||
const existing = await getChatMessage(handle, messageId);
|
||||
const existing = await getChatMessage(handle, messageId, projectId);
|
||||
if (!existing) {
|
||||
throw new Error(`Message ${messageId} not found`);
|
||||
}
|
||||
@@ -961,9 +1025,12 @@ export async function updateChatMessageMetadata(
|
||||
await handle
|
||||
.update(schema.project.chatMessages)
|
||||
.set({ metadata: sanitizeJsonbValue(nextMetadata) ?? null })
|
||||
.where(eq(schema.project.chatMessages.id, messageId));
|
||||
.where(and(
|
||||
eq(schema.project.chatMessages.id, messageId),
|
||||
...chatMessageProjectConditions(handle, projectId),
|
||||
));
|
||||
|
||||
const updated = await getChatMessage(handle, messageId);
|
||||
const updated = await getChatMessage(handle, messageId, projectId);
|
||||
if (!updated) {
|
||||
throw new Error(`Failed to update message ${messageId}`);
|
||||
}
|
||||
|
||||
@@ -43,10 +43,10 @@ const severityAuditLog = createLogger("core-async-secrets-store");
|
||||
* it in place of the sync store at the flip.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { asc, eq, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import { isPostgresUniqueError } from "../db/postgres-errors.js";
|
||||
import * as schema from "../postgres/schema/index.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js";
|
||||
import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../postgres/data-layer.js";
|
||||
import {
|
||||
createSecretCipher,
|
||||
SecretCryptoError,
|
||||
@@ -141,6 +141,16 @@ function tableForScope(scope: SecretScope): ProjectSecretsTable {
|
||||
: (schema.central.secretsGlobal as unknown as ProjectSecretsTable);
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ProjectSecretsIsolation 2026-08-12-14:15:
|
||||
* project.secrets IDs are only unique within their owning project after the
|
||||
* composite-key migration. Keep the central/global leg predicate-free because
|
||||
* central.secrets_global is intentionally shared across projects.
|
||||
*/
|
||||
function scopeForSecretTable(scope: SecretScope, table: ProjectSecretsTable, projectId?: string) {
|
||||
return scope === "project" ? projectScopeFor(table.projectId, projectId) : undefined;
|
||||
}
|
||||
|
||||
function isAccessPolicy(value: string): value is SecretAccessPolicy {
|
||||
return value === "auto" || value === "prompt" || value === "deny";
|
||||
}
|
||||
@@ -188,12 +198,13 @@ export async function getSecretMetadata(
|
||||
handle: QueryHandle,
|
||||
id: string,
|
||||
scope: SecretScope,
|
||||
projectId?: string,
|
||||
): Promise<SecretRecord | null> {
|
||||
const table = tableForScope(scope);
|
||||
const rows = await handle
|
||||
.select(metadataColumns(table))
|
||||
.from(table)
|
||||
.where(eq(table.id, id))
|
||||
.where(and(eq(table.id, id), scopeForSecretTable(scope, table, projectId)))
|
||||
.limit(1);
|
||||
const row = rows[0] as SecretRow | undefined;
|
||||
return row ? rowToRecord(row, scope) : null;
|
||||
@@ -207,17 +218,19 @@ export async function getSecretMetadata(
|
||||
export async function listSecrets(
|
||||
handle: QueryHandle,
|
||||
scope?: SecretScope,
|
||||
projectId?: string,
|
||||
): Promise<SecretRecord[]> {
|
||||
if (scope) {
|
||||
const table = tableForScope(scope);
|
||||
const rows = await handle
|
||||
.select(metadataColumns(table))
|
||||
.from(table)
|
||||
.where(scopeForSecretTable(scope, table, projectId))
|
||||
.orderBy(asc(sql`lower(${table.key})`));
|
||||
return (rows as SecretRow[]).map((row) => rowToRecord(row, scope));
|
||||
}
|
||||
const project = await listSecrets(handle, "project");
|
||||
const global = await listSecrets(handle, "global");
|
||||
const project = await listSecrets(handle, "project", projectId);
|
||||
const global = await listSecrets(handle, "global", projectId);
|
||||
return [...project, ...global];
|
||||
}
|
||||
|
||||
@@ -239,6 +252,7 @@ export async function createSecret(
|
||||
envExportable?: boolean;
|
||||
envExportKey?: string | null;
|
||||
},
|
||||
projectId?: string,
|
||||
): Promise<SecretRecord> {
|
||||
const key = input.key.trim();
|
||||
if (!key) {
|
||||
@@ -255,6 +269,7 @@ export async function createSecret(
|
||||
|
||||
try {
|
||||
await handle.insert(table).values({
|
||||
...(input.scope === "project" && projectId?.trim() ? { projectId } : {}),
|
||||
id,
|
||||
key,
|
||||
valueCiphertext: encrypted.ciphertext,
|
||||
@@ -275,7 +290,7 @@ export async function createSecret(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const created = await getSecretMetadata(handle, id, input.scope);
|
||||
const created = await getSecretMetadata(handle, id, input.scope, projectId);
|
||||
if (!created) {
|
||||
throw new SecretsStoreError({ code: "not-found", message: "Secret insert succeeded but row could not be read back" });
|
||||
}
|
||||
@@ -302,8 +317,9 @@ export async function updateSecret(
|
||||
envExportable?: boolean;
|
||||
envExportKey?: string | null;
|
||||
},
|
||||
projectId?: string,
|
||||
): Promise<SecretRecord> {
|
||||
const existing = await getSecretMetadata(handle, id, scope);
|
||||
const existing = await getSecretMetadata(handle, id, scope, projectId);
|
||||
if (!existing) {
|
||||
throw new SecretsStoreError({ code: "not-found", message: "Secret not found" });
|
||||
}
|
||||
@@ -340,7 +356,7 @@ export async function updateSecret(
|
||||
}
|
||||
|
||||
try {
|
||||
await handle.update(table).set(updates).where(eq(table.id, id));
|
||||
await handle.update(table).set(updates).where(and(eq(table.id, id), scopeForSecretTable(scope, table, projectId)));
|
||||
} catch (error) {
|
||||
if (isPostgresUniqueError(error)) {
|
||||
throw new SecretsStoreError({ code: "duplicate-key", message: "Secret key already exists" });
|
||||
@@ -348,7 +364,7 @@ export async function updateSecret(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const updated = await getSecretMetadata(handle, id, scope);
|
||||
const updated = await getSecretMetadata(handle, id, scope, projectId);
|
||||
if (!updated) {
|
||||
throw new SecretsStoreError({ code: "not-found", message: "Secret update succeeded but row could not be read back" });
|
||||
}
|
||||
@@ -364,13 +380,14 @@ export async function deleteSecret(
|
||||
handle: QueryHandle,
|
||||
id: string,
|
||||
scope: SecretScope,
|
||||
projectId?: string,
|
||||
): Promise<void> {
|
||||
const existing = await getSecretMetadata(handle, id, scope);
|
||||
const existing = await getSecretMetadata(handle, id, scope, projectId);
|
||||
if (!existing) {
|
||||
throw new SecretsStoreError({ code: "not-found", message: "Secret not found" });
|
||||
}
|
||||
const table = tableForScope(scope);
|
||||
await handle.delete(table).where(eq(table.id, id));
|
||||
await handle.delete(table).where(and(eq(table.id, id), scopeForSecretTable(scope, table, projectId)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,6 +402,7 @@ export async function revealSecret(
|
||||
id: string,
|
||||
scope: SecretScope,
|
||||
reader: { agentId?: string | null; userId?: string | null },
|
||||
projectId?: string,
|
||||
): Promise<{ key: string; plaintextValue: string }> {
|
||||
const table = tableForScope(scope);
|
||||
const rows = await handle
|
||||
@@ -394,7 +412,7 @@ export async function revealSecret(
|
||||
nonce: table.nonce,
|
||||
})
|
||||
.from(table)
|
||||
.where(eq(table.id, id))
|
||||
.where(and(eq(table.id, id), scopeForSecretTable(scope, table, projectId)))
|
||||
.limit(1);
|
||||
const row = rows[0] as SecretCipherRow | undefined;
|
||||
if (!row) {
|
||||
@@ -419,7 +437,7 @@ export async function revealSecret(
|
||||
await handle
|
||||
.update(table)
|
||||
.set({ lastReadAt: now, lastReadBy, updatedAt: now })
|
||||
.where(eq(table.id, id));
|
||||
.where(and(eq(table.id, id), scopeForSecretTable(scope, table, projectId)));
|
||||
|
||||
return { key: row.key, plaintextValue };
|
||||
}
|
||||
@@ -472,11 +490,11 @@ export class AsyncSecretsStore {
|
||||
}
|
||||
|
||||
listSecrets(scope?: SecretScope): Promise<SecretRecord[]> {
|
||||
return listSecrets(this.layer.db, scope);
|
||||
return listSecrets(this.layer.db, scope, this.layer.projectId);
|
||||
}
|
||||
|
||||
async getSecretMetadata(id: string, scope: SecretScope): Promise<SecretRecord | null> {
|
||||
return getSecretMetadata(this.layer.db, id, scope);
|
||||
return getSecretMetadata(this.layer.db, id, scope, this.layer.projectId);
|
||||
}
|
||||
|
||||
async createSecret(input: {
|
||||
@@ -488,7 +506,7 @@ export class AsyncSecretsStore {
|
||||
envExportable?: boolean;
|
||||
envExportKey?: string | null;
|
||||
}): Promise<SecretRecord> {
|
||||
const created = await createSecret(this.layer.db, this.cipher, input);
|
||||
const created = await createSecret(this.layer.db, this.cipher, input, this.layer.projectId);
|
||||
this.emitAudit({ mutationType: "secret:create", scope: input.scope, secretId: created.id, key: created.key });
|
||||
return created;
|
||||
}
|
||||
@@ -505,17 +523,17 @@ export class AsyncSecretsStore {
|
||||
envExportKey?: string | null;
|
||||
},
|
||||
): Promise<SecretRecord> {
|
||||
const updated = await updateSecret(this.layer.db, this.cipher, id, scope, patch);
|
||||
const updated = await updateSecret(this.layer.db, this.cipher, id, scope, patch, this.layer.projectId);
|
||||
this.emitAudit({ mutationType: "secret:update", scope, secretId: updated.id, key: updated.key });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteSecret(id: string, scope: SecretScope): Promise<void> {
|
||||
const existing = await getSecretMetadata(this.layer.db, id, scope);
|
||||
const existing = await getSecretMetadata(this.layer.db, id, scope, this.layer.projectId);
|
||||
if (!existing) {
|
||||
throw new SecretsStoreError({ code: "not-found", message: "Secret not found" });
|
||||
}
|
||||
await deleteSecret(this.layer.db, id, scope);
|
||||
await deleteSecret(this.layer.db, id, scope, this.layer.projectId);
|
||||
this.emitAudit({ mutationType: "secret:delete", scope, secretId: id, key: existing.key });
|
||||
}
|
||||
|
||||
@@ -524,7 +542,7 @@ export class AsyncSecretsStore {
|
||||
scope: SecretScope,
|
||||
reader: { agentId?: string | null; userId?: string | null },
|
||||
): Promise<{ key: string; plaintextValue: string }> {
|
||||
const revealed = await revealSecret(this.layer.db, this.cipher, id, scope, reader);
|
||||
const revealed = await revealSecret(this.layer.db, this.cipher, id, scope, reader, this.layer.projectId);
|
||||
this.emitAudit({ mutationType: "secret:read", scope, secretId: id, key: revealed.key, actor: reader });
|
||||
return revealed;
|
||||
}
|
||||
|
||||
@@ -373,7 +373,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
attachments: input.attachments,
|
||||
createdAt: now,
|
||||
};
|
||||
const created = await asyncChatStore.addChatMessage(this.asyncLayer.db, message);
|
||||
const created = await asyncChatStore.addChatMessage(this.asyncLayer.db, message, this.asyncLayer.projectId);
|
||||
this.emit("chat:message:added", created);
|
||||
return created;
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
* Append a file attachment metadata record to an existing message.
|
||||
*/
|
||||
async addMessageAttachment(sessionId: string, messageId: string, attachment: ChatAttachment): Promise<ChatMessage> {
|
||||
const updated = await asyncChatStore.addChatMessageAttachment(this.asyncLayer.db, sessionId, messageId, attachment);
|
||||
const updated = await asyncChatStore.addChatMessageAttachment(this.asyncLayer.db, sessionId, messageId, attachment, this.asyncLayer.projectId);
|
||||
this.emit("chat:message:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
@@ -395,7 +395,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
* @returns Array of messages ordered by createdAt ASC (default) or DESC
|
||||
*/
|
||||
async getMessages(sessionId: string, filter?: ChatMessagesFilter): Promise<ChatMessage[]> {
|
||||
return asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, filter);
|
||||
return asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, filter, this.asyncLayer.projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -405,7 +405,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
* @returns The message, or undefined if not found
|
||||
*/
|
||||
async getMessage(id: string): Promise<ChatMessage | undefined> {
|
||||
return asyncChatStore.getChatMessage(this.asyncLayer.db, id);
|
||||
return asyncChatStore.getChatMessage(this.asyncLayer.db, id, this.asyncLayer.projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -416,11 +416,11 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
* @returns Map of sessionId -> latest ChatMessage for that session
|
||||
*/
|
||||
async getLastMessageForSessions(sessionIds: string[]): Promise<Map<string, ChatMessage>> {
|
||||
return asyncChatStore.getLastMessageForSessions(this.asyncLayer.db, sessionIds);
|
||||
return asyncChatStore.getLastMessageForSessions(this.asyncLayer.db, sessionIds, this.asyncLayer.projectId);
|
||||
}
|
||||
|
||||
async hasMessages(sessionId: string): Promise<boolean> {
|
||||
return (await asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, { limit: 1 })).length > 0;
|
||||
return (await asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, { limit: 1 }, this.asyncLayer.projectId)).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,7 +449,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
if (!trimmed || !sessionIds || sessionIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
return asyncChatStore.searchChatSessionsByMessageContent(this.asyncLayer.db, trimmed, sessionIds);
|
||||
return asyncChatStore.searchChatSessionsByMessageContent(this.asyncLayer.db, trimmed, sessionIds, this.asyncLayer.projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -459,9 +459,9 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
* @returns true if deleted, false if not found
|
||||
*/
|
||||
async deleteMessage(id: string): Promise<boolean> {
|
||||
const existing = await asyncChatStore.getChatMessage(this.asyncLayer.db, id);
|
||||
const existing = await asyncChatStore.getChatMessage(this.asyncLayer.db, id, this.asyncLayer.projectId);
|
||||
if (!existing) return false;
|
||||
const deleted = await asyncChatStore.deleteChatMessage(this.asyncLayer.db, id);
|
||||
const deleted = await asyncChatStore.deleteChatMessage(this.asyncLayer.db, id, this.asyncLayer.projectId);
|
||||
if (deleted) {
|
||||
this.emit("chat:message:deleted", id);
|
||||
const updatedSession = await this.getSession(existing.sessionId);
|
||||
@@ -485,7 +485,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
* @returns deletedIds (in ASC order) and retained messages (pre-edit history, ASC order)
|
||||
*/
|
||||
async deleteMessagesFrom(sessionId: string, fromMessageId: string): Promise<{ deletedIds: string[]; retained: ChatMessage[] }> {
|
||||
const result = await asyncChatStore.deleteChatMessagesFrom(this.asyncLayer.db, sessionId, fromMessageId);
|
||||
const result = await asyncChatStore.deleteChatMessagesFrom(this.asyncLayer.db, sessionId, fromMessageId, this.asyncLayer.projectId);
|
||||
if (result.deletedIds.length > 0) {
|
||||
for (const id of result.deletedIds) {
|
||||
this.emit("chat:message:deleted", id);
|
||||
@@ -504,7 +504,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
* is what lets a later edit rewind losslessly via SessionManager.branch()/resetLeaf().
|
||||
*/
|
||||
async updateMessageMetadata(messageId: string, metadata: Record<string, unknown> | null, options?: { merge?: boolean }): Promise<ChatMessage> {
|
||||
const updated = await asyncChatStore.updateChatMessageMetadata(this.asyncLayer.db, messageId, metadata, options);
|
||||
const updated = await asyncChatStore.updateChatMessageMetadata(this.asyncLayer.db, messageId, metadata, options, this.asyncLayer.projectId);
|
||||
this.emit("chat:message:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Database } from "../db/db.js";
|
||||
import type { AsyncDataLayer } from "../postgres/data-layer.js";
|
||||
import { projectScopeFor, type AsyncDataLayer } from "../postgres/data-layer.js";
|
||||
import { and, gte, lte, sql } from "drizzle-orm";
|
||||
import * as schema from "../postgres/schema/index.js";
|
||||
|
||||
@@ -91,7 +91,15 @@ export async function aggregatePluginActivations(
|
||||
// instead of the broken `"execute" in dbOrLayer || ("transactionImmediate" in dbOrLayer)`.
|
||||
if ("ping" in dbOrLayer) {
|
||||
const layer = dbOrLayer as AsyncDataLayer;
|
||||
const conditions = [];
|
||||
/*
|
||||
FNXC:ProjectSchemaOwnership 2026-08-12-14:14:
|
||||
Ecosystem analytics may share one PostgreSQL schema across projects. Scope both
|
||||
aggregate reads to the bound layer so another project's activation history is
|
||||
never presented as this dashboard's metric; unbound analytics remain global.
|
||||
*/
|
||||
const conditions = [projectScopeFor(schema.project.pluginActivations.projectId, layer.projectId)].filter(
|
||||
(condition): condition is NonNullable<typeof condition> => condition !== undefined,
|
||||
);
|
||||
if (query.from !== undefined) conditions.push(gte(schema.project.pluginActivations.activatedAt, query.from));
|
||||
if (query.to !== undefined) conditions.push(lte(schema.project.pluginActivations.activatedAt, query.to));
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
@@ -147,7 +147,7 @@ export class SecretsStore {
|
||||
}
|
||||
|
||||
async listSecrets(scope?: SecretScope): Promise<SecretRecord[]> {
|
||||
return asyncSecretsStore.listSecrets(this.asyncLayer!.db, scope);
|
||||
return asyncSecretsStore.listSecrets(this.asyncLayer!.db, scope, this.asyncLayer!.projectId);
|
||||
}
|
||||
|
||||
async listEnvExportable(opts?: { keyPrefix?: string }): Promise<EnvExportableSecret[]> {
|
||||
@@ -195,7 +195,7 @@ export class SecretsStore {
|
||||
}
|
||||
|
||||
async getSecretMetadata(id: string, scope: SecretScope): Promise<SecretRecord | null> {
|
||||
return asyncSecretsStore.getSecretMetadata(this.asyncLayer!.db, id, scope);
|
||||
return asyncSecretsStore.getSecretMetadata(this.asyncLayer!.db, id, scope, this.asyncLayer!.projectId);
|
||||
}
|
||||
|
||||
async createSecret(input: {
|
||||
@@ -215,7 +215,7 @@ export class SecretsStore {
|
||||
throw new SecretsStoreError({ code: "invalid-policy", message: "Invalid access policy" });
|
||||
}
|
||||
|
||||
const created = await asyncSecretsStore.createSecret(this.asyncLayer!.db, this.cipher, input);
|
||||
const created = await asyncSecretsStore.createSecret(this.asyncLayer!.db, this.cipher, input, this.asyncLayer!.projectId);
|
||||
this.emitAudit({ mutationType: "secret:create", scope: input.scope, secretId: created.id, key: created.key });
|
||||
return created;
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export class SecretsStore {
|
||||
envExportable?: boolean;
|
||||
envExportKey?: string | null;
|
||||
}): Promise<SecretRecord> {
|
||||
const updated = await asyncSecretsStore.updateSecret(this.asyncLayer!.db, this.cipher, id, scope, patch);
|
||||
const updated = await asyncSecretsStore.updateSecret(this.asyncLayer!.db, this.cipher, id, scope, patch, this.asyncLayer!.projectId);
|
||||
this.emitAudit({ mutationType: "secret:update", scope, secretId: updated.id, key: updated.key });
|
||||
return updated;
|
||||
}
|
||||
@@ -238,7 +238,7 @@ export class SecretsStore {
|
||||
if (!existing) {
|
||||
throw new SecretsStoreError({ code: "not-found", message: "Secret not found" });
|
||||
}
|
||||
await asyncSecretsStore.deleteSecret(this.asyncLayer!.db, id, scope);
|
||||
await asyncSecretsStore.deleteSecret(this.asyncLayer!.db, id, scope, this.asyncLayer!.projectId);
|
||||
this.emitAudit({ mutationType: "secret:delete", scope, secretId: id, key: existing.key });
|
||||
return;
|
||||
}
|
||||
@@ -248,7 +248,7 @@ export class SecretsStore {
|
||||
scope: SecretScope,
|
||||
reader: { agentId?: string | null; userId?: string | null },
|
||||
): Promise<{ key: string; plaintextValue: string }> {
|
||||
const revealed = await asyncSecretsStore.revealSecret(this.asyncLayer!.db, this.cipher, id, scope, reader);
|
||||
const revealed = await asyncSecretsStore.revealSecret(this.asyncLayer!.db, this.cipher, id, scope, reader, this.asyncLayer!.projectId);
|
||||
this.emitAudit({ mutationType: "secret:read", scope, secretId: id, key: revealed.key, actor: reader });
|
||||
return revealed;
|
||||
}
|
||||
|
||||
@@ -2544,7 +2544,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
/** PostgreSQL-authoritative audit reader; sync fallback remains for test doubles. */
|
||||
async getRunAuditEventsAsync(options: RunAuditEventFilter = {}): Promise<RunAuditEvent[]> {
|
||||
if (this.asyncLayer) {
|
||||
const events = await queryRunAuditEvents(this.asyncLayer.db, options);
|
||||
const events = await queryRunAuditEvents(
|
||||
this.asyncLayer.db,
|
||||
options,
|
||||
this.asyncLayer.projectId,
|
||||
);
|
||||
return events.map((event) => ({
|
||||
...event,
|
||||
taskId: event.taskId ?? undefined,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
*/
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import * as schema from "../../postgres/schema/index.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "../../postgres/data-layer.js";
|
||||
import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js";
|
||||
import { ACTIVE_TASK_FILTER } from "./async-persistence.js";
|
||||
import { findLiveLineageChildren, projectPartition, removeLineageReferences, type LineageRemovalOutcome } from "./async-lifecycle.js";
|
||||
import { assertLineageCandidatesUnchanged } from "../lineage-approval-invalidation.js";
|
||||
@@ -419,6 +419,7 @@ export async function listLiveTaskDocuments(
|
||||
export async function listLiveArtifacts(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
taskId: string,
|
||||
projectId?: string,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -440,11 +441,16 @@ export async function listLiveArtifacts(
|
||||
.from(schema.project.artifacts)
|
||||
.innerJoin(
|
||||
schema.project.tasks,
|
||||
eq(schema.project.tasks.id, schema.project.artifacts.taskId),
|
||||
and(
|
||||
eq(schema.project.tasks.id, schema.project.artifacts.taskId),
|
||||
eq(schema.project.tasks.projectId, schema.project.artifacts.projectId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.project.artifacts.taskId, taskId),
|
||||
projectScopeFor(schema.project.artifacts.projectId, projectId),
|
||||
projectScopeFor(schema.project.tasks.projectId, projectId),
|
||||
ACTIVE_TASK_FILTER,
|
||||
sql`${schema.project.tasks.column} != 'archived'`,
|
||||
),
|
||||
@@ -481,10 +487,14 @@ export async function listAllTaskDocuments(
|
||||
export async function listAllArtifacts(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
taskId: string,
|
||||
projectId?: string,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.project.artifacts)
|
||||
.where(eq(schema.project.artifacts.taskId, taskId));
|
||||
.where(and(
|
||||
eq(schema.project.artifacts.taskId, taskId),
|
||||
projectScopeFor(schema.project.artifacts.projectId, projectId),
|
||||
));
|
||||
return rows as unknown as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
*/
|
||||
import { and, asc, count, desc, eq, gt, gte, isNotNull, lte, or, sql } from "drizzle-orm";
|
||||
import * as schema from "../../postgres/schema/index.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "../../postgres/data-layer.js";
|
||||
import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js";
|
||||
import {
|
||||
recordRunAuditEventWithinTransaction,
|
||||
recordRunAuditEvent,
|
||||
@@ -89,8 +89,17 @@ function safeJsonParse(value: string | null): Record<string, unknown> | null {
|
||||
export async function queryRunAuditEvents(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
filter: RunAuditEventFilter = {},
|
||||
projectId?: string,
|
||||
): Promise<RunAuditEvent[]> {
|
||||
const conditions = [];
|
||||
/*
|
||||
FNXC:ProjectSchemaOwnership 2026-08-12-14:14:
|
||||
Run-audit events share the flat project schema, so bound runtime readers must
|
||||
classify rows by their owning project before applying audit filters. An absent
|
||||
binding deliberately remains an analytics-wide read for compatibility layers.
|
||||
*/
|
||||
const conditions = [projectScopeFor(schema.project.runAuditEvents.projectId, projectId)].filter(
|
||||
(condition): condition is NonNullable<typeof condition> => condition !== undefined,
|
||||
);
|
||||
if (filter.runId) {
|
||||
conditions.push(eq(schema.project.runAuditEvents.runId, filter.runId));
|
||||
}
|
||||
@@ -136,8 +145,11 @@ export async function queryRunAuditEvents(
|
||||
export async function countRunAuditEvents(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
filter: RunAuditEventFilter = {},
|
||||
projectId?: string,
|
||||
): Promise<number> {
|
||||
const conditions = [];
|
||||
const conditions = [projectScopeFor(schema.project.runAuditEvents.projectId, projectId)].filter(
|
||||
(condition): condition is NonNullable<typeof condition> => condition !== undefined,
|
||||
);
|
||||
if (filter.runId) {
|
||||
conditions.push(eq(schema.project.runAuditEvents.runId, filter.runId));
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
import { and, asc, eq, notInArray, sql } from "drizzle-orm";
|
||||
import * as schema from "../../postgres/schema/index.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "../../postgres/data-layer.js";
|
||||
import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js";
|
||||
import {
|
||||
validateBranchGroupBranchName,
|
||||
} from "../../branch/branch-assignment.js";
|
||||
@@ -97,6 +97,7 @@ export function rowToBranchGroup(row: BranchGroupRow): BranchGroup {
|
||||
export async function createBranchGroup(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
input: BranchGroupCreateInput,
|
||||
projectId?: string,
|
||||
): Promise<BranchGroup> {
|
||||
// Fix #11: reject injection-shaped branch names at the persistence boundary.
|
||||
validateBranchGroupBranchName(input.branchName);
|
||||
@@ -117,7 +118,7 @@ export async function createBranchGroup(
|
||||
updatedAt: now,
|
||||
closedAt: input.closedAt ?? null,
|
||||
});
|
||||
const created = await getBranchGroup(db, id);
|
||||
const created = await getBranchGroup(db, id, projectId);
|
||||
if (!created) throw new Error(`Failed to read branch group ${id} after create`);
|
||||
return created;
|
||||
}
|
||||
@@ -128,11 +129,15 @@ export async function createBranchGroup(
|
||||
export async function getBranchGroup(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
id: string,
|
||||
projectId?: string,
|
||||
): Promise<BranchGroup | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.project.branchGroups)
|
||||
.where(eq(schema.project.branchGroups.id, id))
|
||||
.where(and(
|
||||
eq(schema.project.branchGroups.id, id),
|
||||
projectScopeFor(schema.project.branchGroups.projectId, projectId),
|
||||
))
|
||||
.limit(1);
|
||||
const row = rows[0] as BranchGroupRow | undefined;
|
||||
return row ? rowToBranchGroup(row) : null;
|
||||
@@ -146,6 +151,7 @@ export async function getBranchGroupBySource(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
sourceType: BranchGroup["sourceType"],
|
||||
sourceId: string,
|
||||
projectId?: string,
|
||||
): Promise<BranchGroup | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -154,6 +160,7 @@ export async function getBranchGroupBySource(
|
||||
and(
|
||||
eq(schema.project.branchGroups.sourceType, sourceType),
|
||||
eq(schema.project.branchGroups.sourceId, sourceId),
|
||||
projectScopeFor(schema.project.branchGroups.projectId, projectId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
@@ -168,11 +175,15 @@ export async function getBranchGroupBySource(
|
||||
export async function getBranchGroupByBranchName(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
branchName: string,
|
||||
projectId?: string,
|
||||
): Promise<BranchGroup | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.project.branchGroups)
|
||||
.where(eq(schema.project.branchGroups.branchName, branchName))
|
||||
.where(and(
|
||||
eq(schema.project.branchGroups.branchName, branchName),
|
||||
projectScopeFor(schema.project.branchGroups.projectId, projectId),
|
||||
))
|
||||
.orderBy(sql`${schema.project.branchGroups.createdAt} DESC`)
|
||||
.limit(1);
|
||||
const row = rows[0] as BranchGroupRow | undefined;
|
||||
@@ -191,16 +202,18 @@ export async function ensureBranchGroupForSource(
|
||||
sourceType: BranchGroup["sourceType"],
|
||||
sourceId: string,
|
||||
init: Omit<BranchGroupCreateInput, "sourceType" | "sourceId">,
|
||||
projectId?: string,
|
||||
): Promise<BranchGroup> {
|
||||
const existing = await getBranchGroupBySource(db, sourceType, sourceId);
|
||||
const existing = await getBranchGroupBySource(db, sourceType, sourceId, projectId);
|
||||
if (existing) return existing;
|
||||
|
||||
// branch_groups.branchName is globally UNIQUE — reuse an existing open group
|
||||
// for this branch rather than colliding on insert.
|
||||
const existingByBranch = await getBranchGroupByBranchName(db, init.branchName);
|
||||
// FNXC:BranchGroupProjectIsolation 2026-08-12-14:16: Branch names are unique
|
||||
// per physical project partition, so only this project's existing group may
|
||||
// be reused; matching names in another project must not suppress creation.
|
||||
const existingByBranch = await getBranchGroupByBranchName(db, init.branchName, projectId);
|
||||
if (existingByBranch) return existingByBranch;
|
||||
|
||||
return createBranchGroup(db, { sourceType, sourceId, ...init });
|
||||
return createBranchGroup(db, { sourceType, sourceId, ...init }, projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,14 +222,15 @@ export async function ensureBranchGroupForSource(
|
||||
export async function listBranchGroups(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
options?: { status?: BranchGroup["status"] },
|
||||
projectId?: string,
|
||||
): Promise<BranchGroup[]> {
|
||||
const query = db
|
||||
const conditions = [projectScopeFor(schema.project.branchGroups.projectId, projectId)];
|
||||
if (options?.status) conditions.push(eq(schema.project.branchGroups.status, options.status));
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.project.branchGroups)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(schema.project.branchGroups.createdAt));
|
||||
const rows = options?.status
|
||||
? await query.where(eq(schema.project.branchGroups.status, options.status))
|
||||
: await query;
|
||||
return (rows as BranchGroupRow[]).map((row) => rowToBranchGroup(row));
|
||||
}
|
||||
|
||||
@@ -231,8 +245,9 @@ export async function updateBranchGroup(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
id: string,
|
||||
patch: BranchGroupUpdate,
|
||||
projectId?: string,
|
||||
): Promise<BranchGroup> {
|
||||
const current = await getBranchGroup(db, id);
|
||||
const current = await getBranchGroup(db, id, projectId);
|
||||
if (!current) throw new Error(`Branch group ${id} not found`);
|
||||
|
||||
// Fix #11: a rename must reject injection-shaped names.
|
||||
@@ -261,9 +276,12 @@ export async function updateBranchGroup(
|
||||
updatedAt: now,
|
||||
closedAt: nextClosedAt,
|
||||
})
|
||||
.where(eq(schema.project.branchGroups.id, id));
|
||||
.where(and(
|
||||
eq(schema.project.branchGroups.id, id),
|
||||
projectScopeFor(schema.project.branchGroups.projectId, projectId),
|
||||
));
|
||||
|
||||
const updated = await getBranchGroup(db, id);
|
||||
const updated = await getBranchGroup(db, id, projectId);
|
||||
if (!updated) throw new Error(`Branch group ${id} disappeared after update`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
import { and, desc, eq, ilike, isNull, or } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as schema from "../../postgres/schema/index.js";
|
||||
import { recordRunAuditEventWithinTransaction, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js";
|
||||
import { projectScopeFor, recordRunAuditEventWithinTransaction, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js";
|
||||
import { ACTIVE_TASK_FILTER } from "./async-persistence.js";
|
||||
import { projectPartition } from "./async-lifecycle.js";
|
||||
import {
|
||||
@@ -685,7 +685,10 @@ export async function insertArtifactRow(
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(schema.project.artifacts)
|
||||
.where(eq(schema.project.artifacts.id, id))
|
||||
.where(and(
|
||||
eq(schema.project.artifacts.id, id),
|
||||
projectScopeFor(schema.project.artifacts.projectId, layer.projectId),
|
||||
))
|
||||
.limit(1);
|
||||
const row = rows[0] as ArtifactRow | undefined;
|
||||
if (!row) throw new Error(`Failed to register artifact ${id}`);
|
||||
@@ -707,7 +710,7 @@ export async function updateArtifactRow(
|
||||
|
||||
archivedColumns?: ReadonlySet<string>,): Promise<Artifact> {
|
||||
return layer.transactionImmediate(async (tx) => {
|
||||
const existing = await getArtifact(tx, id);
|
||||
const existing = await getArtifact(tx, id, layer.projectId);
|
||||
if (!existing) {
|
||||
throw new Error(`Artifact ${id} not found`);
|
||||
}
|
||||
@@ -744,9 +747,12 @@ export async function updateArtifactRow(
|
||||
content: updates.content !== undefined ? updates.content : existing.content ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.project.artifacts.id, id));
|
||||
.where(and(
|
||||
eq(schema.project.artifacts.id, id),
|
||||
projectScopeFor(schema.project.artifacts.projectId, layer.projectId),
|
||||
));
|
||||
|
||||
const updated = await getArtifact(tx, id);
|
||||
const updated = await getArtifact(tx, id, layer.projectId);
|
||||
if (!updated) {
|
||||
throw new Error(`Failed to update artifact ${id}`);
|
||||
}
|
||||
@@ -761,11 +767,15 @@ export async function updateArtifactRow(
|
||||
export async function getArtifact(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
id: string,
|
||||
projectId?: string,
|
||||
): Promise<Artifact | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.project.artifacts)
|
||||
.where(eq(schema.project.artifacts.id, id))
|
||||
.where(and(
|
||||
eq(schema.project.artifacts.id, id),
|
||||
projectScopeFor(schema.project.artifacts.projectId, projectId),
|
||||
))
|
||||
.limit(1);
|
||||
const row = rows[0] as ArtifactRow | undefined;
|
||||
return row ? rowToArtifact(row) : null;
|
||||
@@ -803,7 +813,10 @@ export async function getArtifacts(
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.project.artifacts)
|
||||
.where(eq(schema.project.artifacts.taskId, taskId))
|
||||
.where(and(
|
||||
eq(schema.project.artifacts.taskId, taskId),
|
||||
projectScopeFor(schema.project.artifacts.projectId, projectId),
|
||||
))
|
||||
.orderBy(desc(schema.project.artifacts.createdAt));
|
||||
return (rows as ArtifactRow[]).map((row) => rowToArtifact(row));
|
||||
}
|
||||
@@ -838,11 +851,12 @@ export async function listArtifacts(
|
||||
offset?: number;
|
||||
search?: string;
|
||||
},
|
||||
projectId?: string,
|
||||
): Promise<ArtifactWithTask[]> {
|
||||
const limit = Math.min(Math.max(1, options?.limit ?? 200), 1000);
|
||||
const offset = Math.max(0, options?.offset ?? 0);
|
||||
|
||||
const conditions = [];
|
||||
const conditions = [projectScopeFor(schema.project.artifacts.projectId, projectId)];
|
||||
if (options?.type) {
|
||||
conditions.push(eq(schema.project.artifacts.type, options.type));
|
||||
}
|
||||
@@ -888,7 +902,10 @@ export async function listArtifacts(
|
||||
.from(schema.project.artifacts)
|
||||
.leftJoin(
|
||||
schema.project.tasks,
|
||||
eq(schema.project.artifacts.taskId, schema.project.tasks.id),
|
||||
and(
|
||||
eq(schema.project.artifacts.taskId, schema.project.tasks.id),
|
||||
eq(schema.project.artifacts.projectId, schema.project.tasks.projectId),
|
||||
),
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(schema.project.artifacts.createdAt))
|
||||
|
||||
@@ -31,20 +31,24 @@ import { join } from "node:path";
|
||||
import { MoveTaskInternalOptions, MoveTaskOptions, storeLog } from "../store.js";
|
||||
import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js";
|
||||
|
||||
/*
|
||||
FNXC:BranchGroupProjectIsolation 2026-08-12-14:30:
|
||||
TaskStore is the production branch-group boundary, so every wrapper must forward its bound project id to the optional helper predicate. Omitting it makes owner-connected PostgreSQL reads and mutations cross project partitions despite scoped helper implementations.
|
||||
*/
|
||||
export async function getBranchGroupImpl(store: TaskStore, id: string): Promise<BranchGroup | null> {
|
||||
// FNXC:RuntimeWorkflowAsync 2026-06-24-16:21:
|
||||
const layer = store.asyncLayer!;
|
||||
return getBranchGroupAsync(layer.db, id);
|
||||
return getBranchGroupAsync(layer.db, id, layer.projectId);
|
||||
}
|
||||
|
||||
export async function getBranchGroupBySourceImpl(store: TaskStore, sourceType: BranchGroup["sourceType"], sourceId: string): Promise<BranchGroup | null> {
|
||||
const layer = store.asyncLayer!;
|
||||
return getBranchGroupBySourceAsync(layer.db, sourceType, sourceId);
|
||||
return getBranchGroupBySourceAsync(layer.db, sourceType, sourceId, layer.projectId);
|
||||
}
|
||||
|
||||
export async function getBranchGroupByBranchNameImpl(store: TaskStore, branchName: string): Promise<BranchGroup | null> {
|
||||
const layer = store.asyncLayer!;
|
||||
return getBranchGroupByBranchNameAsync(layer.db, branchName);
|
||||
return getBranchGroupByBranchNameAsync(layer.db, branchName, layer.projectId);
|
||||
}
|
||||
|
||||
export async function ensureBranchGroupForSourceImpl(store: TaskStore,
|
||||
@@ -56,12 +60,13 @@ export async function ensureBranchGroupForSourceImpl(store: TaskStore,
|
||||
FNXC:SqliteDualPathCleanup 2026-07-26-14:07:
|
||||
Branch-group ensure is PostgreSQL-only via ensureBranchGroupForSourceAsync (UNIQUE branchName reuse lives in the async helper).
|
||||
*/
|
||||
return ensureBranchGroupForSourceAsync(store.asyncLayer!.db, sourceType, sourceId, init);
|
||||
const layer = store.asyncLayer!;
|
||||
return ensureBranchGroupForSourceAsync(layer.db, sourceType, sourceId, init, layer.projectId);
|
||||
}
|
||||
|
||||
export async function listBranchGroupsImpl(store: TaskStore, options?: { status?: BranchGroup["status"] }): Promise<BranchGroup[]> {
|
||||
const layer = store.asyncLayer!;
|
||||
return listBranchGroupsAsync(layer.db, options);
|
||||
return listBranchGroupsAsync(layer.db, options, layer.projectId);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -392,7 +392,7 @@ export async function listArtifactsImpl(store: TaskStore, options?: { type?: Art
|
||||
// PG backend mode: delegate to the AsyncDataLayer helper. The sync path
|
||||
// below dereferences store.db (no SQLite handle in backend mode) and 500'd
|
||||
// the dashboard /api/artifacts list.
|
||||
return listArtifactsAsync(store.asyncLayer!.db, options);
|
||||
return listArtifactsAsync(store.asyncLayer!.db, options, store.asyncLayer!.projectId);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -835,7 +835,7 @@ export function insertArtifactRowImpl(store: TaskStore, input: ArtifactCreateInp
|
||||
|
||||
export async function getArtifactImpl(store: TaskStore, id: string): Promise<Artifact | null> {
|
||||
const layer = store.asyncLayer!;
|
||||
return getArtifactAsync(layer.db, id);
|
||||
return getArtifactAsync(layer.db, id, layer.projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -888,5 +888,5 @@ export async function invokeTaskCreatedHookImpl(store: TaskStore, task: Task): P
|
||||
|
||||
export async function createBranchGroupImpl(store: TaskStore, input: BranchGroupCreateInput): Promise<BranchGroup> {
|
||||
const layer = store.asyncLayer!;
|
||||
return createBranchGroupAsync(layer.db, input);
|
||||
return createBranchGroupAsync(layer.db, input, layer.projectId);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import {and, asc, eq, inArray, isNotNull, isNull, sql} from "drizzle-orm";
|
||||
import {recoverExpiredMergeQueueLeases as recoverExpiredMergeQueueLeasesAsync} from "../task-store/async/async-merge-coordination.js";
|
||||
import {updateBranchGroup as updateBranchGroupAsync, updatePrEntity as updatePrEntityAsync} from "../task-store/async/async-branch-groups.js";
|
||||
import {recordCompletionHandoff as recordCompletionHandoffAsync, getCompletionHandoffMarker as getCompletionHandoffMarkerAsync} from "../task-store/async/async-workflow-workitems.js";
|
||||
import { taskProjectScope } from "../postgres/data-layer.js";
|
||||
import { projectScopeFor, taskProjectScope } from "../postgres/data-layer.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js";
|
||||
import {getActivityLog as getActivityLogAsync} from "../task-store/async/async-audit.js";
|
||||
import {insertArtifactRow as insertArtifactRowAsync} from "../task-store/async/async-comments-attachments.js";
|
||||
@@ -197,7 +197,7 @@ export async function _maybeAutoArchiveSameAgentDuplicateBackendImpl(store: Task
|
||||
|
||||
export async function updateBranchGroupImpl(store: TaskStore, id: string, patch: BranchGroupUpdate): Promise<BranchGroup> {
|
||||
const layer = store.asyncLayer!;
|
||||
return updateBranchGroupAsync(layer.db, id, patch);
|
||||
return updateBranchGroupAsync(layer.db, id, patch, layer.projectId);
|
||||
}
|
||||
|
||||
export async function updatePrEntityImpl(store: TaskStore, id: string, patch: PrEntityUpdate): Promise<PrEntity> {
|
||||
@@ -982,7 +982,10 @@ async function deleteAttachmentArtifactRows(store: TaskStore, taskId: string, fi
|
||||
.map((artifact) => artifact.id);
|
||||
if (linkedArtifactIds.length === 0) return;
|
||||
for (const artifactId of linkedArtifactIds) {
|
||||
await layer.db.delete(schema.project.artifacts).where(eq(schema.project.artifacts.id, artifactId));
|
||||
await layer.db.delete(schema.project.artifacts).where(and(
|
||||
eq(schema.project.artifacts.id, artifactId),
|
||||
projectScopeFor(schema.project.artifacts.projectId, layer.projectId),
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -994,7 +997,10 @@ async function getArtifactsForAttachmentCleanup(store: TaskStore, taskId: string
|
||||
const rows = await layer.db
|
||||
.select()
|
||||
.from(schema.project.artifacts)
|
||||
.where(eq(schema.project.artifacts.taskId, taskId));
|
||||
.where(and(
|
||||
eq(schema.project.artifacts.taskId, taskId),
|
||||
projectScopeFor(schema.project.artifacts.projectId, layer.projectId),
|
||||
));
|
||||
return rows as unknown as Artifact[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1177,7 +1177,14 @@ export async function getVerificationCacheHitImpl(store: TaskStore,
|
||||
const rows = await store.asyncLayer!.db
|
||||
.select({ recordedAt: table.recordedAt, taskId: table.taskId })
|
||||
.from(table)
|
||||
/*
|
||||
FNXC:ProjectSchemaOwnership 2026-08-12-14:14:
|
||||
Verification cache keys are only unique within their project partition.
|
||||
Read through the owning layer's project scope so an identical tree and
|
||||
command tuple from another project cannot skip this project's verification.
|
||||
*/
|
||||
.where(and(
|
||||
projectScopeFor(table.projectId, store.asyncLayer!.projectId),
|
||||
eq(table.treeSha, treeSha),
|
||||
eq(table.testCommand, normalizedTest),
|
||||
eq(table.buildCommand, normalizedBuild),
|
||||
|
||||
@@ -1905,7 +1905,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
Backend-mode Reliability must use the authoritative async run-audit reader. The synchronous reader is a SQLite/test compatibility surface and intentionally degrades to an empty result under PostgreSQL.
|
||||
*/
|
||||
const runAuditEventsPromise: Promise<RunAuditEvent[]> = asyncLayer
|
||||
? queryRunAuditEvents(asyncLayer.db, auditFilter).then((events) => events.map((event) => ({
|
||||
? queryRunAuditEvents(asyncLayer.db, auditFilter, asyncLayer.projectId).then((events) => events.map((event) => ({
|
||||
...event,
|
||||
domain: event.domain as RunAuditEvent["domain"],
|
||||
mutationType: event.mutationType as RunAuditEvent["mutationType"],
|
||||
|
||||
Reference in New Issue
Block a user