Skip to content

Instantly share code, notes, and snippets.

@danecando
Last active April 19, 2025 13:34
Show Gist options
  • Save danecando/08485e5cc18a14428201122c795008b9 to your computer and use it in GitHub Desktop.
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
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);
}
}
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(),
};
}
}
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