From f4bd1816569632511b050984ad5770c308124f55 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 24 Jul 2026 03:39:03 +0000 Subject: [PATCH] refactor(core): clarify permission replies --- packages/core/src/permission.ts | 104 ++++++++++++++------------ packages/core/test/permission.test.ts | 52 ++++++++++++- 2 files changed, 105 insertions(+), 51 deletions(-) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 1a93f4f58d..3f742b54d1 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -229,70 +229,76 @@ const layer = Layer.effect( ), ) + const publishReply = (item: Pending, reply: Reply) => + events.publish(Event.Replied, { + sessionID: item.request.sessionID, + requestID: item.request.id, + reply, + }) + + const settle = Effect.fnUntraced(function* (item: Pending, error?: DeclinedError | CorrectedError) { + yield* error ? Deferred.fail(item.deferred, error) : Deferred.succeed(item.deferred, undefined) + pending.delete(item.request.id) + }) + + const rejectSession = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) { + for (const item of pending.values()) { + if (item.request.sessionID !== sessionID) continue + yield* publishReply(item, "reject") + yield* settle(item, new DeclinedError()) + } + }) + + const approveSavedMatches = Effect.fnUntraced(function* () { + const rememberedRules = yield* savedRules() + for (const item of pending.values()) { + const request = item.request + const rules = yield* configured(request.sessionID, item.agent).pipe( + Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)), + ) + if (!rules) continue + if (denied(request, rules)) continue + const effective = [...rules, ...rememberedRules] + if (!request.resources.every((resource) => evaluate(request.action, resource, effective).effect === "allow")) + continue + yield* publishReply(item, "always") + yield* settle(item) + } + }) + const reply = Effect.fn("PermissionV2.reply")((input: ReplyInput) => Effect.uninterruptible( Effect.gen(function* () { const existing = pending.get(input.requestID) if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) - yield* events.publish(Event.Replied, { - sessionID: existing.request.sessionID, - requestID: existing.request.id, - reply: input.reply, - }) + yield* publishReply(existing, input.reply) if (input.reply === "reject") { - yield* Deferred.fail( - existing.deferred, + yield* settle( + existing, input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(), ) - pending.delete(input.requestID) - for (const [id, item] of pending) { - if (item.request.sessionID !== existing.request.sessionID) continue - yield* events.publish(Event.Replied, { - sessionID: item.request.sessionID, - requestID: item.request.id, - reply: "reject", - }) - yield* Deferred.fail(item.deferred, new DeclinedError()) - pending.delete(id) - } + yield* rejectSession(existing.request.sessionID) return } - if (input.reply === "always" && existing.request.save?.length) { - yield* saved.add({ - projectID: location.project.id, - action: existing.request.action, - resources: existing.request.save, - }) + if (input.reply === "once") { + yield* settle(existing) + return } - yield* Deferred.succeed(existing.deferred, undefined) - pending.delete(input.requestID) - if (input.reply !== "always" || !existing.request.save?.length) return - const rememberedRules = yield* savedRules() - for (const [id, item] of pending) { - const input = { ...item.request } - const rules = yield* configured(item.request.sessionID, item.agent).pipe( - Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)), - ) - if (!rules) continue - if (denied(input, rules)) continue - const effective = [...rules, ...rememberedRules] - if ( - !item.request.resources.every( - (resource) => evaluate(item.request.action, resource, effective).effect === "allow", - ) - ) - continue - yield* events.publish(Event.Replied, { - sessionID: item.request.sessionID, - requestID: item.request.id, - reply: "always", - }) - yield* Deferred.succeed(item.deferred, undefined) - pending.delete(id) + if (!existing.request.save?.length) { + yield* settle(existing) + return } + + yield* saved.add({ + projectID: location.project.id, + action: existing.request.action, + resources: existing.request.save, + }) + yield* settle(existing) + yield* approveSavedMatches() }), ), ) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 6abf9c47ca..d1c55a4fda 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -47,10 +47,18 @@ function setup(rules: PermissionV2.Ruleset = []) { .onConflictDoNothing() .run() .pipe(Effect.orDie) + yield* setupSession(SessionV2.ID.make("ses_test")) + yield* setRules(rules) + }) +} + +function setupSession(sessionID: SessionV2.ID) { + return Effect.gen(function* () { + const { db } = yield* Database.Service yield* db .insert(SessionTable) .values({ - id: SessionV2.ID.make("ses_test"), + id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", @@ -61,7 +69,6 @@ function setup(rules: PermissionV2.Ruleset = []) { .onConflictDoNothing() .run() .pipe(Effect.orDie) - yield* setRules(rules) }) } @@ -284,6 +291,21 @@ describe("PermissionV2", () => { }), ) + it.effect("rejects every pending permission in the same session", () => + Effect.gen(function* () { + yield* setup() + const service = yield* PermissionV2.Service + const first = assertion({ id: PermissionV2.ID.create("per_first") }) + const second = assertion({ id: PermissionV2.ID.create("per_second") }) + yield* service.ask(first) + yield* service.ask(second) + + yield* service.reply({ requestID: first.id, reply: "reject" }) + + expect(yield* service.list()).toEqual([]) + }), + ) + it.effect("stores and removes saved resources for a project", () => Effect.gen(function* () { yield* setup() @@ -313,4 +335,30 @@ describe("PermissionV2", () => { expect(yield* saved.list()).toEqual([]) }), ) + + it.effect("resolves pending permissions covered by a saved approval", () => + Effect.gen(function* () { + yield* setup() + const otherSession = SessionV2.ID.make("ses_other") + yield* setupSession(otherSession) + const service = yield* PermissionV2.Service + const selected = assertion({ id: PermissionV2.ID.create("per_selected"), save: ["src/*"] }) + const covered = assertion({ + id: PermissionV2.ID.create("per_covered"), + sessionID: otherSession, + resources: ["src/covered.ts"], + }) + const uncovered = assertion({ + id: PermissionV2.ID.create("per_uncovered"), + resources: ["README.md"], + }) + yield* service.ask(selected) + yield* service.ask(covered) + yield* service.ask(uncovered) + + yield* service.reply({ requestID: selected.id, reply: "always" }) + + expect(yield* service.list()).toEqual([uncovered]) + }), + ) })