From 7d35cd1962be196df5e900f60ba745c8a6ba0209 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:46:24 -0700 Subject: [PATCH 1/3] fix(grants): create accepts pod targets only (TASK-019) The target check accepted kind 'seat' and said 'pod or seat', but the outsider gate refuses every non-pod target with pod_required, so a seat grant could never be minted. The mint test's seat 201 cases passed only because that gate is mocked to null. Check and message are pod-only, the create-only seat audience branches are gone, and the seat cases now expect the refusal; a second test pins the real gate's pod_required. Read-side seat handling is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../__tests__/unit/routes/grants.mint.test.js | 17 ++++++----- backend/routes/grants.ts | 28 ++++++------------- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/backend/__tests__/unit/routes/grants.mint.test.js b/backend/__tests__/unit/routes/grants.mint.test.js index a5521bd37c..7d4116ebf7 100644 --- a/backend/__tests__/unit/routes/grants.mint.test.js +++ b/backend/__tests__/unit/routes/grants.mint.test.js @@ -106,17 +106,20 @@ describe('POST /api/grants', () => { expect(res.body.audience).toEqual([OWNER, SEAT]); }); - test.each([{ audience: [] }, { audience: [OWNER] }, { audience: [OWNER, SEAT] }])('a seat audience must still be exactly its target: %p', async ({ audience }) => { - const res = await mint({ target: { kind: 'seat', id: SEAT }, audience }); + // TASK-019: create mints pod grants only. A seat target used to pass the + // target check and reach 201 here only because the outsider gate is mocked + // to null; the real gate refuses every non-pod target. + test.each([{ audience: undefined }, { audience: [SEAT] }, { audience: [OWNER, SEAT] }])('a seat target is refused at the target check: %p', async ({ audience }) => { + const res = await mint({ target: { kind: 'seat', id: SEAT }, ...(audience === undefined ? {} : { audience }) }); expect(res.status).toBe(400); - expect(res.body.error).toBe('invalid_audience'); + expect(res.body).toMatchObject({ error: 'invalid_target', message: 'target.kind must be pod' }); + expect(outsiderGrantPodRefusal).not.toHaveBeenCalled(); expect(await RoomGrant.countDocuments({})).toBe(0); }); - test.each([{ audience: undefined }, { audience: [SEAT] }])('a seat defaults to itself or accepts itself explicitly: %p', async ({ audience }) => { - const res = await mint({ target: { kind: 'seat', id: SEAT }, ...(audience === undefined ? {} : { audience }) }); - expect(res.status).toBe(201); - expect(res.body.audience).toEqual([SEAT]); + test('the real outsider gate refuses a seat target, so the check above loses nothing', async () => { + const real = jest.requireActual('../../../services/outsiderGrantPodEligibility'); + await expect(real.outsiderGrantPodRefusal({ kind: 'seat', id: SEAT })).resolves.toBe('pod_required'); }); test('an agent User ID used as a pod target returns not-found before join-policy checks', async () => { diff --git a/backend/routes/grants.ts b/backend/routes/grants.ts index 3bce56061e..361ade62e2 100644 --- a/backend/routes/grants.ts +++ b/backend/routes/grants.ts @@ -361,34 +361,24 @@ router.post('/', grantRateLimit, auth, async (req: AuthenticatedRequest, res: ex } const target = body.target; - if (!target || (target.kind !== 'pod' && target.kind !== 'seat')) { - return res.status(400).json({ error: 'invalid_target', message: 'target.kind must be pod or seat' }); + // Pod only. The outsider gate below refuses every non-pod target + // (pod_required), so a seat target could never be minted here; accepting it + // at this check only moved the refusal later and behind a misleading message. + if (!target || target.kind !== 'pod') { + return res.status(400).json({ error: 'invalid_target', message: 'target.kind must be pod' }); } const members = await ensureTargetAccess(target, userId); - // Pod grants default to the current member snapshot. A seat has no pod - // census, so its own seat id is the minimum audience and can be extended - // explicitly by the granter. - const audience = target.kind === 'seat' - ? (body.audience === undefined ? [target.id] : body.audience) - : (body.audience === undefined ? members : body.audience); + // Pod grants default to the current member snapshot. + const audience = body.audience === undefined ? members : body.audience; const audienceValues = Array.isArray(audience) ? audience.map(String) : audience; if ( - target.kind === 'seat' - && (!Array.isArray(audienceValues) - || audienceValues.length !== 1 - || audienceValues[0] !== String(target.id)) - ) { - return res.status(400).json({ error: 'invalid_audience', message: 'seat audience must be the target seat' }); - } - if ( - target.kind === 'pod' - && Array.isArray(audienceValues) + Array.isArray(audienceValues) && audienceValues.some((id: string) => !members.includes(id)) ) { return res.status(400).json({ error: 'invalid_audience', message: 'audience must be current target members' }); } - if (target.kind === 'pod' && Array.isArray(body.audience) && body.audience.length === 0) { + if (Array.isArray(body.audience) && body.audience.length === 0) { return res.status(400).json({ error: 'invalid_audience', message: 'name at least one agent, or omit audience for all current members', From 648a65c4cf7fad37d088c8504b5dc3401198c6bd Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:50:08 -0700 Subject: [PATCH 2/3] test(grants): the pod_required mapping row uses a pod target A seat now stops at the target check, so the gate's pod_required is exercised for a pod it cannot resolve. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- backend/__tests__/unit/routes/grants.mint.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/unit/routes/grants.mint.test.js b/backend/__tests__/unit/routes/grants.mint.test.js index 7d4116ebf7..675b8c13c5 100644 --- a/backend/__tests__/unit/routes/grants.mint.test.js +++ b/backend/__tests__/unit/routes/grants.mint.test.js @@ -132,7 +132,9 @@ describe('POST /api/grants', () => { }); test.each([ - ['pod_required', { kind: 'seat', id: SEAT }], + // A seat now stops at the target check (above); the gate's pod_required + // still maps to its 403 for a pod it cannot resolve. + ['pod_required', { kind: 'pod', id: POD }], ['pod_not_private', { kind: 'pod', id: POD }], ['pod_bridged', { kind: 'pod', id: POD }], ['non_native_agent', { kind: 'pod', id: POD }], From b35bc6c0235b7098f85072874c0d9de5747f6aac Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:26:41 -0700 Subject: [PATCH 3/3] test(grants): the refusal table asserts the target and mode the gate receives The gate mock answered any arguments, so the table passed even if the route forwarded a different target or write mode. Asserting the call kills a wrong-mode mutant (5 of 5 rows fail). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- backend/__tests__/unit/routes/grants.mint.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/__tests__/unit/routes/grants.mint.test.js b/backend/__tests__/unit/routes/grants.mint.test.js index 675b8c13c5..15478037b6 100644 --- a/backend/__tests__/unit/routes/grants.mint.test.js +++ b/backend/__tests__/unit/routes/grants.mint.test.js @@ -145,6 +145,7 @@ describe('POST /api/grants', () => { expect(res.status).toBe(403); expect(res.body).toMatchObject({ error: `outsider_grant_${reason}` }); expect(res.body.message).toMatch(/separate pod/); + expect(outsiderGrantPodRefusal).toHaveBeenCalledWith(target, 'read'); expect(await RoomGrant.countDocuments({})).toBe(0); });