import type { Did, Nsid } from "@atcute/lexicons/syntax";
type RepoAction = "create" | "update" | "delete";
export type Permission =
| {
readonly resource: "repo";
readonly collection: Nsid;
readonly actions: readonly RepoAction[];
}
| { readonly resource: "rpc"; readonly lxm: Nsid; readonly aud: Did }
| { readonly resource: "identity"; readonly attr: "handle" };
const ANY = "*";
const ALL_ACTIONS: readonly RepoAction[] = ["create", "update", "delete"];
interface Grant {
readonly resource: string;
readonly positional: string | null;
readonly params: URLSearchParams;
}
const grantFrom = (scope: string): Grant => {
const query = scope.indexOf("?");
const head = query < 0 ? scope : scope.slice(0, query);
const params = new URLSearchParams(query < 0 ? "" : scope.slice(query + 1));
const colon = head.indexOf(":");
return colon < 0
? { resource: head, positional: null, params }
: { resource: head.slice(0, colon), positional: head.slice(colon + 1), params };
};
const listOf = (grant: Grant, name: string): readonly string[] =>
grant.positional === null
? grant.params.getAll(name)
: grant.positional === ""
? []
: [grant.positional];
const isRepoAction = (value: string): value is RepoAction =>
(ALL_ACTIONS as readonly string[]).includes(value);
const nameMatches = (granted: string, wanted: Nsid): boolean =>
granted === ANY || granted === wanted;
const covers = (grant: Grant, wanted: Permission): boolean => {
if (grant.resource !== wanted.resource) return false;
if (wanted.resource === "identity") {
return listOf(grant, "attr").some((attr) => attr === ANY || attr === wanted.attr);
}
if (wanted.resource === "repo") {
const asked = grant.params.getAll("action");
const held = asked.length === 0 ? ALL_ACTIONS : asked.filter(isRepoAction);
return (
listOf(grant, "collection").some((granted) =>
nameMatches(granted, wanted.collection)
) && wanted.actions.every((action) => held.includes(action))
);
}
const auds = grant.params.getAll("aud");
return (
auds.some((aud) => aud === ANY || aud === wanted.aud) &&
listOf(grant, "lxm").some((granted) => nameMatches(granted, wanted.lxm))
);
};
export const missingPermissions = (
granted: string,
wanted: readonly Permission[]
): readonly Permission[] => {
const scopes = granted.split(/\s+/).filter((scope) => scope !== "");
const unreadable = scopes.some(
(scope) => scope === "transition:generic" || scope.startsWith("include:")
);
if (unreadable) return [];
const grants = scopes.map(grantFrom);
return wanted.filter((permission) => !grants.some((grant) => covers(grant, permission)));
};