Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton f4bd181656 refactor(core): clarify permission replies 2026-07-24 03:39:03 +00:00
2 changed files with 105 additions and 51 deletions
+55 -49
View File
@@ -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()
}),
),
)
+50 -2
View File
@@ -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])
}),
)
})