Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/core/src/__tests__/scope-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
buildProposalWhere,
buildRawProjectFilter,
buildRawProjectFilterV2,
buildKnowledgeWhereV2,
} from "../scope-filter.js";

const USER = "user_abc";
Expand Down Expand Up @@ -256,3 +257,60 @@ describe("buildRawProjectFilterV2 — cross-project reach of user-scope rows", (
expect(on.params).toEqual(off.params);
});
});

// ---------------------------------------------------------------------------
// buildKnowledgeWhereV2 — the Prisma-side twin of buildRawProjectFilterV2.
// Had no coverage at all before 2026-07-31 despite backing the knowledge
// listing route and action-items.
// ---------------------------------------------------------------------------

describe("buildKnowledgeWhereV2 — user-scope reach", () => {
const args = (
activeProjectId: string | null,
accessible: string[] = [],
optIn = false,
scope: "project" | "all" = "project",
) => ({
userId: USER,
activeProjectId,
activeOrgId: null,
accessibleProjectIds: accessible,
scope,
includeUserScopeAcrossProjects: optIn,
});

/** The user-scope disjunct, as it appears in a serialised Prisma where. */
const hasUserScope = (where: object) =>
JSON.stringify(where).includes('{"scope":{"in":["user","global"]}}');

it("does NOT widen under an active project unless opted in", () => {
expect(hasUserScope(buildKnowledgeWhereV2(args(PROJECT)))).toBe(false);
expect(hasUserScope(buildKnowledgeWhereV2(args(PROJECT, ["p2"])))).toBe(false);
});

it("widens under an active project when opted in", () => {
expect(hasUserScope(buildKnowledgeWhereV2(args(PROJECT, [], true)))).toBe(true);
expect(hasUserScope(buildKnowledgeWhereV2(args(PROJECT, ["p2"], true)))).toBe(true);
});

it('applies the opt-in to scope="all" too', () => {
expect(hasUserScope(buildKnowledgeWhereV2(args(PROJECT, ["p2"], false, "all")))).toBe(false);
expect(hasUserScope(buildKnowledgeWhereV2(args(PROJECT, ["p2"], true, "all")))).toBe(true);
});

// Parity with buildRawProjectFilterV2: with no active project there is no
// boundary to enforce, so user/global rows are admitted regardless of the
// flag. The raw helper has done this since 2026-05-12; the two must agree.
it("admits user/global rows with no active project, even when not opted in", () => {
expect(hasUserScope(buildKnowledgeWhereV2(args(null)))).toBe(true);
const raw = buildRawProjectFilterV2(args(null), 3).sql;
expect(raw).toMatch(/scope IN \('user',\s*'global'\)/);
});

it("pins the user-scope disjunct to ownerUserId", () => {
const where = buildKnowledgeWhereV2(args(PROJECT, [], true));
const json = JSON.stringify(where);
const idx = json.indexOf('{"scope":{"in":["user","global"]}}');
expect(json.slice(idx, idx + 120)).toContain(`"ownerUserId":"${USER}"`);
});
});
10 changes: 10 additions & 0 deletions packages/core/src/action-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ function baseWhere(opts: {
// content into other projects' Oracle context.
accessibleProjectIds: [],
scope: "project",
// Deliberately NOT opting into includeUserScopeAcrossProjects (#174):
// that finding was about personal rules following the user, which is
// the opposite of what tasks want. One case still admits
// `scope:'user'|'global'` rows — `projectId === null`, where there is
// no boundary left to enforce (the raw filter has behaved that way
// since 2026-05-12 and the two helpers must agree). Acceptable here:
// the query still ANDs `type: action_item` and stays pinned to
// `ownerUserId`, and action items are created project-scoped by
// contract, so that set is empty in practice. Revisit if action items
// ever become user-scoped.
}),
{ type: ACTION_ITEM_TYPE },
// Decay is the abandonment path (spec §3): fully-decayed items are
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export interface Bucket {

export interface Limit {
name: string;
/**
* Requests allowed per window. `0` blocks everything — it is NOT a
* "disabled" sentinel, and no caller treats it as one. (Before the atomic
* rewrite the fresh-bucket path returned `ok: true` unconditionally, so
* `max: 0` leaked one request per window; that inconsistency is gone.) To
* disable a limit, don't route the path through the limiter.
*/
max: number;
windowMs: number;
}
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/scope-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,18 @@ export interface VisibilityScopeArgs {
* OR (visibility="org" AND ownerProjectId IN accessibleProjectIds)
* OR (visibility="private" AND ownerUserId=userId AND ownerProjectId=activeProjectId)
* OR (ownerProjectId IS NULL AND ownerUserId=userId) // legacy/personal rows
* OR (scope IN ('user','global') AND ownerUserId=userId)
* — only when `includeUserScopeAcrossProjects` is set, OR when there is
* no activeProjectId (no boundary to enforce). See #174.
*
* scope="all":
* (visibility IN ('project','org') AND ownerProjectId IN accessibleProjectIds)
* OR (visibility="private" AND ownerUserId=userId)
* OR (ownerProjectId IS NULL AND ownerUserId=userId)
* OR (scope IN ('user','global') AND ownerUserId=userId) // opt-in only
*
* Mirrors `buildRawProjectFilterV2` clause for clause — the two are one policy
* on two query surfaces, so a change here needs the same change there.
*/
export function buildKnowledgeWhereV2(args: VisibilityScopeArgs): object {
const { userId, activeProjectId, accessibleProjectIds, scope } = args;
Expand Down Expand Up @@ -299,7 +306,12 @@ export function buildKnowledgeWhereV2(args: VisibilityScopeArgs): object {
}

orClauses.push(legacyBranch);
if (crossProject) orClauses.push(userScopeBranch);
// Parity with buildRawProjectFilterV2: with no active project there is no
// boundary to enforce, so cross-project rows are admitted regardless of the
// flag (that branch has behaved this way since 2026-05-12). With an active
// project, the widening is opt-in. The two helpers must express one policy —
// they back the same visibility rule on different query surfaces.
if (crossProject || !activeProjectId) orClauses.push(userScopeBranch);

return {
AND: [
Expand Down
Loading