Files
jason.woltje 90245765f2 feat(bus): copy revoke CB4, lifecycle events fence and revoke bound copies (#1873)
Every lifecycle event that ends a copy's authority (unbind, emergency
revoke, slot removal or expiry, role change, fire, collision, shadowing,
archive, delete and recreate) now closes admissions, drains in-flight
uses, stops runs with a confirmed ack, removes the slot and revokes the
Gitea token through the org admin slot. The binding moves through a
durable revoking state, so a restart resumes the same op and never
resurrects authority; an uncertain outcome stays fenced.
2026-11-12 16:08:07 -06:00

417 lines
26 KiB
JavaScript

// Copy credential bindings, slices CB0 and CB1 (#1873; design
// docs/plans/2026-11-11_copy-credential-binding-design.md sections 3, 5 and 7).
//
// An organization binds a service to one concrete copy (a root agent's copy or a project agent's
// copy) through a record under the organization file's top-level `copyBindings`, keyed by the
// copy's instance name. A record holds identities, known scope names, project ids and integers:
// never a token, a token fragment or digest, a path or an environment name.
//
// { copyId, orgIncarnation, source: { level, name, project }, definition: { ref, rev, sha256 },
// generation, state, services: { gitea?: { slot, scopes, resources, rotateBy?, grantId?,
// grantGeneration? }, vikunja?: {...} }, decision, tracker? }
//
// copyId is 128 random bits minted on the first approval for that copy; it is kept, with the
// generation counting up, through every later change and the unbound tombstone. orgIncarnation is
// the registry's (org-registry.mjs): a record copied from another organization, or from a removed
// organization with the same id, never matches. `source` is the copy's level, name and project, so
// a dotted-name collision or a reused source name never inherits a record. `definition` pins the
// copy's definition identity: an org definition by its rev and the hash of its whole entry
// (composition, instructions and personality), a role template by the hash readTemplate takes over
// its file and contract.
//
// States: `pending` is approved and not verified. Only a verifier (slice CB2) may turn a record
// `bound`, and only `bound` resolves. `unbound` is the tombstone an unbind leaves.
//
// CB2 (grant metadata): a service the verifier proved carries `rotateBy` (the owner's rotation day),
// `grantId` (the service's opaque grant id, `gitea:<user id>:<token id>`: ids the service assigned,
// never token bytes) and `grantGeneration` (counts every verified grant of that service; it stays
// when a new issue strips the other two). The verifier's commit writes `bound` only when every
// service holds a grant, and no grant id appears twice across the organization's copies. Launch
// admission, which re-proves each grant, is CB3.
//
// CB4 (revocation and lifecycle, design section 6): `revoking` is the durable pending operation a
// revoke, an unbind of a granted record, a slot loss, an expiry, an upstream revoke, a role change,
// a fire, a shadow, a collision, an archive or a delete leaves before any stop or remote call. It
// keeps its services (and grant ids), so a restart knows what to revoke and the boot sweep keeps the
// slot until the job ends, and it never resolves. `revoke` is { id, cause, at, failed?, remote? } while
// revoking: `failed` lists the steps (stop, slot, remote) whose outcome was not definite, `remote` the
// services whose outcome already was, and the record stays fenced (credential-revoke-failed) until a
// retry finishes them. The unbound tombstone
// it ends in carries { id, cause, at, done: true, remote: { service: outcome } }, where an outcome
// is revoked, absent, not-managed (no admin slot to revoke with) or not-attempted. Never a token.
//
// Everything here is pure: no file is read, no secret, no slot.
import { randomBytes } from "node:crypto";
import { refuse } from "./errors.mjs";
import { GITEA_SCOPE_CATEGORIES, HOST_SERVICE, ID_PATTERN, SERVICES, VIKUNJA_GRANTABLE } from "./vocabulary.mjs";
import { ORG_PREFIX } from "./definitions.mjs";
import { canonicalJson, isPlainObject, sha256 } from "./util.mjs";
export const COPY_ID = /^[0-9a-f]{32}$/;
export const INCARNATION = /^[0-9a-f]{32}$/;
const SHA256 = /^[0-9a-f]{64}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
const RECORD_KEYS = ["copyId", "orgIncarnation", "source", "definition", "generation", "state", "services", "decision", "tracker", "revoke"];
const SERVICE_KEYS = ["slot", "scopes", "resources"];
const GRANT_KEYS = ["rotateBy", "grantId", "grantGeneration"];
const GRANT_ID = /^(gitea|vikunja):[1-9][0-9]{0,15}:[1-9][0-9]{0,15}$/;
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const realDay = (value) => typeof value === "string" && DAY.test(value) && !Number.isNaN(Date.parse(value))
&& new Date(value).toISOString().slice(0, 10) === value;
export const BINDING_STATES = Object.freeze(["pending", "bound", "unbound", "revoking"]);
// CB4: why a record is revoking or tombstoned, and what the next launch or call answers for it.
export const REVOKE_CAUSES = Object.freeze(["revoke", "unbind", "unavailable", "expired", "upstream", "role-changed", "retire", "shadowed", "collision", "archive", "delete"]);
export const REVOKE_STEPS = Object.freeze(["stop", "slot", "remote"]);
export const REMOTE_OUTCOMES = Object.freeze(["revoked", "absent", "not-managed", "not-attempted"]);
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
// The tombstone codes the resolver answers for a copy that is still there; the copy codes come from
// the copy itself, and an archive or delete tombstone answers credential-unbound.
const TOMBSTONE_CODES = ["credential-revoked", "credential-expired", "credential-unavailable", "credential-role-changed"];
// The refusal a tombstone's cause answers at the next launch or call (design section 6).
export const CAUSE_CODES = Object.freeze({
revoke: "credential-revoked", upstream: "credential-revoked", unbind: "credential-unbound", unavailable: "credential-unavailable",
expired: "credential-expired", "role-changed": "credential-role-changed", retire: "copy-unavailable", shadowed: "copy-unavailable",
collision: "copy-refused", archive: "credential-unbound", delete: "credential-unbound",
});
// CB1 binds the core services (vocabulary SERVICES) only. A plugin service has no verifier design yet.
export const BINDABLE_SERVICES = SERVICES;
export const MAX_BINDINGS = 256;
export const newCopyId = () => randomBytes(16).toString("hex");
// CB2: the one service account a copy's binding may use, derived from the registry incarnation, the
// copyId and the service, so another organization, another copy and another service never share
// it. Gitea limits a login to 40 characters; this is 36. Not a secret and not token-derived.
export function copyBotLogin(incarnation, copyId, service) {
if (typeof incarnation !== "string" || !INCARNATION.test(incarnation) || typeof copyId !== "string" || !COPY_ID.test(copyId)
|| !BINDABLE_SERVICES.includes(service)) return null;
return `mosaic-copy-${sha256(`copy-bot\0${incarnation}\0${copyId}\0${service}`).slice(0, 24)}`;
}
// Whether every service of a binding record holds a verified grant (CB2).
export const allGranted = (services) => isPlainObject(services) && Object.keys(services).length > 0
&& Object.values(services).every((v) => isPlainObject(v) && typeof v.grantId === "string");
// Fixed text for every binding refusal. `service` is a registered service label or null, never a
// submitted value.
const TEXT = {
"human-required": () => "Only the organization owner can approve credential binding.",
"credential-unbound": (s) => `credentials.${s} needs an owner-approved binding.`,
"credential-scope-exceeded": (s) => `credentials.${s}.scopes exceeds the approved role.`,
"credential-scope-unverified": (s) => `credentials.${s} permissions could not be verified.`,
"credential-identity-refused": (s) => `credentials.${s} is not this copy's own service account.`,
"credential-date": (s) => `credentials.${s} rotation date is invalid.`,
"credential-binding-changed": (s) => `credentials.${s} changed; read and approve again.`,
"credential-role-changed": (s) => `credentials.${s} no longer matches the approved definition; the owner must review the binding.`,
"copy-unavailable": () => "copy is no longer active in this organization.",
"copy-refused": () => "copy identity is ambiguous; the owner must resolve the definition collision.",
"credential-revoked": (s) => `credentials.${s} was revoked for this copy.`,
"credential-revoking": (s) => `credentials.${s} is being revoked for this copy.`,
"credential-unavailable": (s) => `credentials.${s} is not available for this copy.`,
"credential-expired": (s) => `credentials.${s} passed its rotation date.`,
"credential-revoke-failed": (s) => `credentials.${s} revocation is incomplete; the owner must retry cleanup.`,
};
const label = (service) => (BINDABLE_SERVICES.includes(service) ? service : "service");
export function bindingText(code, service = null) {
const text = TEXT[code];
return text ? text(label(service)) : code;
}
export class CopyBindingError extends Error {
constructor(code, service = null) {
super(bindingText(code, service));
this.name = "CopyBindingError";
this.code = code;
if (service !== null) this.service = label(service);
}
}
export const bindingFail = (code, service = null) => { throw new CopyBindingError(code, service); };
// ---- Definition pin ----
// The identity a binding pins for a copy's definition. `definitions` is the merged root and org
// definition map as the boot snapshot holds it; `readTemplate` reads a role template (definitions.mjs).
// An org definition: its rev and the hash of its canonical entry. A template: rev null and the
// template hash. A version 1 template has no contract and cannot be pinned: null.
export function definitionPin(ref, { definitions = {}, rolesDir, readTemplate }) {
if (typeof ref !== "string") return null;
if (ref.startsWith(ORG_PREFIX)) {
const name = ref.slice(ORG_PREFIX.length);
const entry = Object.hasOwn(definitions, name) ? definitions[name] : null;
if (!isPlainObject(entry) || !Number.isSafeInteger(entry.rev) || entry.rev < 1) return null;
return { ref, rev: entry.rev, sha256: sha256(canonicalJson(entry)) };
}
if (!ID_PATTERN.test(ref) || typeof readTemplate !== "function") return null;
try {
return { ref, rev: null, sha256: readTemplate(rolesDir, ref).sha256 };
} catch (error) {
if (error?.name === "BusinessError") return null;
throw error;
}
}
export const samePin = (a, b) => isPlainObject(a) && isPlainObject(b) && a.ref === b.ref && a.rev === b.rev && a.sha256 === b.sha256;
// ---- Scope semantics ----
// Gitea: {category: "read"|"write"}, one level per category. admin and all are never granted.
function giteaLevels(scopes) {
if (!Array.isArray(scopes)) return null;
const out = {};
for (const scope of scopes) {
const m = typeof scope === "string" ? GITEA_SCOPE.exec(scope) : null;
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2]) || Object.hasOwn(out, m[2])) return null;
out[m[2]] = m[1];
}
return out;
}
const RANK = { read: 1, write: 2 };
// Vikunja: the set of "group verb" pairs, each one VIKUNJA_GRANTABLE lists.
function vikunjaPairs(scopes) {
if (!isPlainObject(scopes)) return null;
const out = new Set();
for (const [group, verbs] of Object.entries(scopes)) {
if (!Object.hasOwn(VIKUNJA_GRANTABLE, group) || !Array.isArray(verbs) || verbs.length === 0) return null;
for (const verb of verbs) {
if (typeof verb !== "string" || !VIKUNJA_GRANTABLE[group].includes(verb) || out.has(`${group} ${verb}`)) return null;
out.add(`${group} ${verb}`);
}
}
return out;
}
export function validScopes(service, scopes) {
if (service === "gitea") { const l = giteaLevels(scopes); return l !== null && Object.keys(l).length > 0; }
if (service === "vikunja") { const p = vikunjaPairs(scopes); return p !== null && p.size > 0; }
return false;
}
// Whether `approved` is within `role` for the service, compared by the service's semantics: Gitea
// read is narrower than write in one category and categories are independent; Vikunja compares
// each group and verb pair. A missing category or group is no permission. Never a string compare.
export function scopesWithin(service, approved, role) {
if (service === "gitea") {
const a = giteaLevels(approved), r = giteaLevels(role);
if (!a || !r) return false;
return Object.entries(a).every(([category, level]) => Object.hasOwn(r, category) && RANK[level] <= RANK[r[category]]);
}
if (service === "vikunja") {
const a = vikunjaPairs(approved), r = vikunjaPairs(role);
if (!a || !r) return false;
return [...a].every((pair) => r.has(pair));
}
return false;
}
// effective(V) = role-declared(V) intersect approved(V), as scopes of the service's own shape.
// Null when the intersection is empty.
export function intersectScopes(service, approved, role) {
if (service === "gitea") {
const a = giteaLevels(approved), r = giteaLevels(role);
if (!a || !r) return null;
const out = Object.keys(a).filter((c) => Object.hasOwn(r, c)).sort()
.map((c) => `${RANK[a[c]] <= RANK[r[c]] ? a[c] : r[c]}:${c}`);
return out.length ? out : null;
}
if (service === "vikunja") {
const a = vikunjaPairs(approved), r = vikunjaPairs(role);
if (!a || !r) return null;
const out = {};
for (const pair of [...a].filter((p) => r.has(p)).sort()) {
const [group, verb] = pair.split(" ");
(out[group] ??= []).push(verb);
}
return Object.keys(out).length ? out : null;
}
return null;
}
// The role's declared scopes per bindable service. A host service is bound per host elsewhere and
// is not a copy binding; a service outside the core pair cannot be bound in CB1.
export function roleServices(role) {
const out = {};
for (const c of role?.credentials ?? []) {
if (c.service === HOST_SERVICE) continue;
out[c.service] = c.scopes;
}
return out;
}
// ---- Record schema ----
// The positional refusal for record `i` (1-based): it never names a submitted key or value.
const at = (i, field) => `copyBindings entry ${i} ${field} field is invalid`;
// Checks the whole `copyBindings` value of an organization document. `trackers` is the list of
// {bot, botId} the document's instances and its tracker sync already use; a copy's tracker bot is
// unique against them and against other copies. Throws a BusinessError with a positional message.
export function validateCopyBindings(value, { trackers = [] } = {}) {
if (value === undefined) return;
if (!isPlainObject(value)) refuse("copyBindings field is invalid");
const keys = Object.keys(value);
if (keys.length > MAX_BINDINGS) refuse("copyBindings field is invalid");
const bots = new Set(trackers.map((t) => t.bot)), botIds = new Set(trackers.map((t) => t.botId)), copyIds = new Set(), grants = new Set();
keys.forEach((instance, n) => {
const i = n + 1, r = value[instance];
if (!ID_PATTERN.test(instance)) refuse(`copyBindings entry ${i} name is invalid`);
if (!isPlainObject(r)) refuse(`copyBindings entry ${i} is invalid`);
const unknown = Object.keys(r).some((k) => !RECORD_KEYS.includes(k));
if (unknown) refuse(`copyBindings entry ${i} has an unknown field`);
if (typeof r.copyId !== "string" || !COPY_ID.test(r.copyId)) refuse(at(i, "copyId"));
if (copyIds.has(r.copyId)) refuse(at(i, "copyId"));
copyIds.add(r.copyId);
if (typeof r.orgIncarnation !== "string" || !INCARNATION.test(r.orgIncarnation)) refuse(at(i, "orgIncarnation"));
const s = r.source;
if (!isPlainObject(s) || Object.keys(s).sort().join(",") !== "level,name,project"
|| !["root", "project"].includes(s.level) || typeof s.name !== "string" || !ID_PATTERN.test(s.name)
|| (s.level === "root" ? s.project !== null : typeof s.project !== "string" || !ID_PATTERN.test(s.project))
|| (s.level === "root" ? s.name : `${s.name}.${s.project}`) !== instance) refuse(at(i, "source"));
const d = r.definition;
if (!isPlainObject(d) || Object.keys(d).sort().join(",") !== "ref,rev,sha256" || typeof d.ref !== "string"
|| typeof d.sha256 !== "string" || !SHA256.test(d.sha256)
|| (d.ref.startsWith(ORG_PREFIX)
? !ID_PATTERN.test(d.ref.slice(ORG_PREFIX.length)) || !Number.isSafeInteger(d.rev) || d.rev < 1
: !ID_PATTERN.test(d.ref) || d.rev !== null)) refuse(at(i, "definition"));
if (!Number.isSafeInteger(r.generation) || r.generation < 1) refuse(at(i, "generation"));
if (!BINDING_STATES.includes(r.state)) refuse(at(i, "state"));
if (typeof r.decision !== "string" || !UUID.test(r.decision)) refuse(at(i, "decision"));
if (!isPlainObject(r.services)) refuse(at(i, "services"));
const services = Object.keys(r.services);
// CB4: a revoking record keeps what it is revoking, which may be nothing (an older tombstone
// whose slot a shadow, an archive or a delete cleans).
if (r.state !== "revoking" && (r.state === "unbound") !== (services.length === 0)) refuse(at(i, "services"));
checkRevoke(r, i);
for (const service of services) {
const v = r.services[service];
const extra = isPlainObject(v) ? Object.keys(v).filter((k) => !SERVICE_KEYS.includes(k)) : [];
if (!BINDABLE_SERVICES.includes(service) || !isPlainObject(v) || SERVICE_KEYS.some((k) => !Object.hasOwn(v, k))
|| extra.some((k) => !GRANT_KEYS.includes(k)) || v.slot !== "self" || !validScopes(service, v.scopes)) refuse(at(i, "services"));
// CB2: rotateBy and grantId come together; a grant id needs its generation; one grant id per organization.
if ((v.rotateBy === undefined) !== (v.grantId === undefined) || (v.grantId !== undefined && v.grantGeneration === undefined)
|| (v.rotateBy !== undefined && !realDay(v.rotateBy))
|| (v.grantId !== undefined && (typeof v.grantId !== "string" || !GRANT_ID.test(v.grantId) || v.grantId.split(":")[0] !== service))
|| (v.grantGeneration !== undefined && (!Number.isSafeInteger(v.grantGeneration) || v.grantGeneration < 1))) refuse(at(i, "services"));
if (v.grantId !== undefined) {
if (grants.has(v.grantId)) refuse(`copyBindings entry ${i} services field uses a grant another binding uses`);
grants.add(v.grantId);
}
const p = v.resources;
if (!isPlainObject(p) || Object.keys(p).join(",") !== "projects" || !Array.isArray(p.projects) || p.projects.length === 0
|| p.projects.some((x) => typeof x !== "string" || !ID_PATTERN.test(x)) || new Set(p.projects).size !== p.projects.length
|| (s.level === "project" && (p.projects.length !== 1 || p.projects[0] !== s.project))) refuse(at(i, "services"));
}
if (r.tracker !== undefined) {
const t = r.tracker;
if (!services.includes("vikunja") || !isPlainObject(t) || Object.keys(t).sort().join(",") !== "bot,botId"
|| typeof t.bot !== "string" || !BOT_NAME.test(t.bot) || !Number.isSafeInteger(t.botId) || t.botId < 1) refuse(at(i, "tracker"));
if (bots.has(t.bot) || botIds.has(t.botId)) refuse(`copyBindings entry ${i} tracker field uses a bot another instance uses`);
bots.add(t.bot);
botIds.add(t.botId);
}
});
}
// CB4: the `revoke` field. Required while revoking, optional on the unbound tombstone (its done
// form), absent otherwise.
function checkRevoke(r, i) {
const v = r.revoke;
if (v === undefined) {
if (r.state === "revoking") refuse(at(i, "revoke"));
return;
}
if (!isPlainObject(v) || !["revoking", "unbound"].includes(r.state) || typeof v.id !== "string" || !UUID.test(v.id)
|| !REVOKE_CAUSES.includes(v.cause) || typeof v.at !== "string" || !ISO.test(v.at) || Number.isNaN(Date.parse(v.at))) refuse(at(i, "revoke"));
const keys = Object.keys(v);
if (r.state === "revoking") {
if (keys.some((k) => !["id", "cause", "at", "failed", "remote"].includes(k))) refuse(at(i, "revoke"));
if (v.failed !== undefined && (!Array.isArray(v.failed) || v.failed.length === 0 || new Set(v.failed).size !== v.failed.length
|| v.failed.some((x) => !REVOKE_STEPS.includes(x)))) refuse(at(i, "revoke"));
// The definite remote outcomes an incomplete pass already had, so a retry never calls a revoked
// grant again or records its 404 as `absent`.
if (v.remote !== undefined && (!isPlainObject(v.remote) || Object.keys(v.remote).length === 0
|| Object.entries(v.remote).some(([s, o]) => !BINDABLE_SERVICES.includes(s) || !REMOTE_OUTCOMES.includes(o)))) refuse(at(i, "revoke"));
return;
}
if (keys.sort().join(",") !== "at,cause,done,id,remote" || v.done !== true || !isPlainObject(v.remote)
|| Object.entries(v.remote).some(([s, o]) => !BINDABLE_SERVICES.includes(s) || !REMOTE_OUTCOMES.includes(o))) refuse(at(i, "revoke"));
}
// ---- Resolver (CB0) ----
// What a copy's binding grants in this organization, from plain inputs only:
// orgId, incarnation the organization and its live registry incarnation (null: none)
// snapshot the boot snapshot materializeCopies made for orgId
// instance the copy's instance name
// record copyBindings[instance] from the organization document, or undefined
// pin definitionPin of the copy's current definition (null: cannot pin)
// role the copy's current resolved role ({credentials: [...]})
// projects the project ids that admit this copy now: for a root copy, the projects
// whose union holds it (an excluded or refusing project is left out), not
// the organization's whole project list
// Returns { ok: true, copyId, generation, services: { V: { scopes, projects } } } when every
// service the role needs is bound, or { ok: false, code, service } with the first refusal. Only a
// `bound` record resolves; pending, partial, tombstoned, foreign and stale records never do.
export function resolveCopyBinding({ orgId, incarnation, snapshot, instance, record, pin, role, projects = [] }) {
const no = (code, service = null) => ({ ok: false, code, service: service === null ? null : label(service) });
if (!snapshot || snapshot.org !== orgId) return no("copy-unavailable");
const copy = Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
// An org instance of the same name shadows the copy, and a removed or excluded source makes none.
if (!copy) return no("copy-unavailable");
if (copy.state === "refused") return no(copy.claims ? "copy-refused" : "copy-unavailable");
const needed = roleServices(role);
const first = Object.keys(needed).sort()[0] ?? null;
if (record === undefined || record === null) return no("credential-unbound", first);
if (typeof incarnation !== "string" || !INCARNATION.test(incarnation) || record.orgIncarnation !== incarnation) return no("credential-unbound", first);
const source = { level: copy.level, name: copy.name, project: copy.project };
if (canonicalJson(record.source) !== canonicalJson(source)) return no("credential-unbound", first);
// CB4: a revoke in progress or one whose outcome is not definite never resolves; a finished one
// answers its cause's code (design section 6) until a new binding.
if (record.state === "revoking") return no(record.revoke?.failed ? "credential-revoke-failed" : "credential-revoking", first);
if (record.state === "unbound" && record.revoke?.done === true && TOMBSTONE_CODES.includes(CAUSE_CODES[record.revoke.cause]))
return no(CAUSE_CODES[record.revoke.cause], first);
if (!pin || !samePin(record.definition, pin) || record.definition.ref !== copy.definition) return no("credential-role-changed", first);
if (record.state !== "bound") return no("credential-unbound", first);
const approved = record.services ?? {};
for (const service of Object.keys(approved).sort()) {
if (!Object.hasOwn(needed, service)) return no("credential-scope-exceeded", service);
if (!scopesWithin(service, approved[service].scopes, needed[service])) return no("credential-scope-exceeded", service);
}
const admitted = new Set(copy.level === "project" ? (projects.includes(copy.project) ? [copy.project] : []) : projects);
const services = {};
for (const service of Object.keys(needed).sort()) {
if (!BINDABLE_SERVICES.includes(service) || !Object.hasOwn(approved, service)) return no("credential-unbound", service);
const scopes = intersectScopes(service, approved[service].scopes, needed[service]);
const reach = approved[service].resources.projects.filter((p) => admitted.has(p)).sort();
if (!scopes || reach.length === 0) return no("credential-unbound", service);
services[service] = { scopes, projects: reach };
}
if (!Object.keys(services).length) return no("credential-unbound", first);
return { ok: true, copyId: record.copyId, generation: record.generation, services };
}
// ---- Proposal checks (CB1) ----
// The binding payload a chief proposes: { service: { scopes, projects } } for a bind, or null for
// an unbind. Each service must be one the copy's role declares, its scopes within the role and its
// projects admitted for the copy (`projects`: for a root copy, the projects whose union holds it).
// Never trims a broader request. Returns the normalized services.
export function checkBindingRequest(services, { copy, role, projects }) {
if (services === null) return null;
if (!isPlainObject(services) || Object.keys(services).length === 0) refuse("binding services field is invalid");
const needed = roleServices(role);
const out = {};
for (const service of Object.keys(services).sort()) {
if (!BINDABLE_SERVICES.includes(service)) refuse("binding services field is invalid");
const v = services[service];
if (!isPlainObject(v) || Object.keys(v).sort().join(",") !== "projects,scopes") refuse("binding services field is invalid");
if (!validScopes(service, v.scopes)) refuse("binding scopes field is invalid");
if (!Object.hasOwn(needed, service) || !scopesWithin(service, v.scopes, needed[service])) bindingFail("credential-scope-exceeded", service);
if (!Array.isArray(v.projects) || v.projects.length === 0 || v.projects.some((p) => typeof p !== "string" || !ID_PATTERN.test(p))
|| new Set(v.projects).size !== v.projects.length) refuse("binding projects field is invalid");
const allowed = copy.level === "project" ? [copy.project] : projects;
if (v.projects.some((p) => !allowed.includes(p))) bindingFail("credential-scope-exceeded", service);
out[service] = { slot: "self", scopes: structuredClone(v.scopes), resources: { projects: [...v.projects].sort() } };
}
return out;
}