Last active
April 19, 2025 13:34
-
-
Save danecando/08485e5cc18a14428201122c795008b9 to your computer and use it in GitHub Desktop.
A simple TypeScript Policy library that can be shared between frontend and backend code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
type Cond<Args extends any[], TReturn = boolean> = TReturn | ((...args: Args) => TReturn); | |
interface PolicyActionResult { | |
permission: boolean; | |
invariant: Error | undefined; | |
actionable: boolean; | |
} | |
interface PolicyActionFn<Args extends any[] = []> { | |
(...args: Args): PolicyActionResult; | |
} | |
export class PolicyError extends Error {} | |
export class PermissionDenied extends PolicyError { | |
kind = "permission" as const; | |
constructor(msg = "You do not have permission.") { | |
super(msg); | |
} | |
} | |
export class InvariantFailed extends PolicyError { | |
kind = "invariant" as const; | |
constructor(readonly cause: Error) { | |
super(cause.message); | |
} | |
} | |
export function Action<Args extends any[] = []>() { | |
let permission: Cond<Args> = false; | |
let invariant: Cond<Args, Error | undefined>; | |
return { | |
allow(cond: Cond<Args>) { | |
permission = cond; | |
return this; | |
}, | |
require(cond: Cond<Args, Error | undefined>) { | |
invariant = cond; | |
return this; | |
}, | |
build(): PolicyActionFn<Args> { | |
const perm = permission; | |
const inv = invariant; | |
return (...a: Args) => { | |
const allowed = typeof perm === "function" ? (perm as (...args: Args) => boolean)(...a) : perm; | |
const failed = typeof inv === "function" ? (inv as (...args: Args) => Error)(...a) : inv; | |
return { | |
actionable: allowed && !failed, | |
permission: allowed, | |
invariant: failed, | |
}; | |
}; | |
}, | |
}; | |
} | |
export abstract class Policy< | |
TRecord extends Record<string, any>, | |
TUser extends Record<string, any> = Record<string, any>, | |
> { | |
abstract get roles(): Record<string, boolean>; | |
abstract get actions(): Record<string, PolicyActionFn>; | |
constructor(private readonly ctx: { record?: TRecord; user?: TUser } = {}) {} | |
protected get record(): TRecord | undefined { | |
return this.ctx.record; | |
} | |
protected get recordOrThrow(): TRecord { | |
if (!this.ctx.record) throw new Error("Record not loaded"); | |
return this.ctx.record; | |
} | |
protected get user(): TUser | undefined { | |
return this.ctx.user; | |
} | |
check<A extends keyof this["actions"]>(name: A, ...args: Parameters<this["actions"][A]>): boolean { | |
// @ts-expect-error | |
return this.actions[name](...args).actionable; | |
} | |
assert<A extends keyof this["actions"]>(name: A, ...args: Parameters<this["actions"][A]>): void { | |
// @ts-expect-error | |
const { permission, invariant } = this.actions[name](...args); | |
if (!permission) throw new PermissionDenied(); | |
if (invariant) throw new InvariantFailed(invariant); | |
} | |
is(role: keyof this["roles"]): boolean { | |
return this.roles[role]; | |
} | |
not(role: keyof this["roles"]): boolean { | |
return !this.is(role); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createInvariant } from "@repo/utils/invariant"; | |
import type { Meeting } from "../repos/meeting"; | |
import { isUpcoming, isLive, isScheduled } from "../meeting-state"; | |
import { isWithinStartWindow, hasStartTimePassed, hasEndTimePassed } from "../meeting"; | |
import { Policy, Action } from "../lib/policy"; | |
export class MeetingPolicy extends Policy<Meeting> { | |
get roles() { | |
const owner = this.record?.group?.ownerId === this.user?.id; | |
const chairperson = this.record?.chairpersonId === this.user?.id; | |
return { | |
authenticated: Boolean(this.user?.id), | |
owner, | |
chairperson, | |
admin: owner || chairperson, | |
speaker: this.record?.speakerId === this.user?.id, | |
}; | |
} | |
get actions() { | |
return { | |
create: Action().allow(this.roles.admin).build(), | |
edit: Action() | |
.allow(this.roles.admin) | |
.require( | |
createInvariant( | |
isUpcoming(this.recordOrThrow) && !hasStartTimePassed(this.recordOrThrow), | |
"Meeting is not upcoming or has already started" | |
) | |
) | |
.build(), | |
cancel: Action() | |
.allow(this.roles.admin) | |
.require( | |
createInvariant( | |
isUpcoming(this.recordOrThrow) && !hasStartTimePassed(this.recordOrThrow), | |
"Meeting is not upcoming or has already started" | |
) | |
) | |
.build(), | |
join: Action() | |
.allow(this.recordOrThrow.group.allowGuests || this.roles.authenticated) | |
.require( | |
createInvariant( | |
this.recordOrThrow.callId && isLive(this.recordOrThrow) && !hasEndTimePassed(this.recordOrThrow), | |
"Meeting is not currently live" | |
) | |
) | |
.build(), | |
subscribe: Action() | |
.allow(this.roles.authenticated && !this.roles.admin) | |
.require( | |
createInvariant( | |
isUpcoming(this.recordOrThrow) && !hasStartTimePassed(this.recordOrThrow), | |
"Meeting is not upcoming or has already started" | |
) | |
) | |
.build(), | |
start: Action() | |
.allow(this.roles.admin) | |
.require( | |
createInvariant( | |
isWithinStartWindow(this.recordOrThrow) && isScheduled(this.recordOrThrow), | |
"Meeting is not upcoming or not within the start time window" | |
) | |
) | |
.build(), | |
rejectPosition: Action() | |
.allow((this.roles.speaker || this.roles.chairperson) && !this.roles.owner) | |
.require(createInvariant(isUpcoming(this.recordOrThrow), "Meeting is not upcoming")) | |
.build(), | |
callAdmin: Action() | |
.allow(this.roles.admin) | |
.require( | |
createInvariant( | |
this.recordOrThrow.state === "live" && !!this.recordOrThrow.callId, | |
"Meeting is not currently live" | |
) | |
) | |
.build(), | |
share: Action() | |
.allow(true) | |
.require(createInvariant(!hasEndTimePassed(this.recordOrThrow), "Meeting has already ended")) | |
.build(), | |
}; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const meetingPolicy = new MeetingPolicy({ | |
record: meeting, | |
user, | |
}); | |
try { | |
meetingPolicy.assert("edit"); | |
} catch (error) { | |
if (error instanceof PermissionDenied) { | |
throw new TRPCError({ code: "FORBIDDEN", message: "You do not have permission to update this meeting." }); | |
} | |
if (error instanceof InvariantFailed) { | |
throw new TRPCError({ code: "BAD_REQUEST", cause: error.cause }); | |
} | |
throw error; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment