// Authorization and capability bindings for the Phase 1 Issues backend. var issuesStore = require("./issues-store"); var schema = require("./issues-schema"); var provenance = require("./session-provenance"); var commitVerification = require("./issues-commit-verification"); var accessScope = require("./project-logs-access-scope").attachProjectLogsAccessScope; var PUBLIC_FIELDS = { title: true, summary: true, body: true, resolutionSummary: true, type: true, priority: true, status: true, closeReason: true }; function clone(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); } function attachIssuesService(ctx) { var context = ctx || {}; var projects = context.getProjects; var multiUser = context.isMultiUser || function () { return false; }; var findUserById = context.findUserById || function () { return null; }; var stores = new Map(); var githubLocks = new Map(); var githubAssessments = new Map(); var verify = commitVerification.injectedVerifier(context.verifyCommit); var scope; function projectFor(slug) { var list = typeof projects === "function" ? projects() : null; return typeof slug === "string" && list && typeof list.get === "function" ? list.get(slug) || null : null; } function statusFor(project) { var status; if (!project || typeof project.getStatus !== "function") return null; try { status = project.getStatus(); } catch (e) { return null; } if (!status || typeof status !== "object" || Array.isArray(status)) return null; if (typeof status.slug !== "string" || !status.slug || typeof status.path !== "string" || !status.path) return null; if (typeof status.projectKnowledgeId !== "string" || !status.projectKnowledgeId) return null; if (status.projectOwnerId != null && typeof status.projectOwnerId !== "string") return null; if (status.isWorktree === true && (typeof status.changeSetId !== "string" || !status.changeSetId || typeof status.parentSlug !== "string" || !status.parentSlug)) return null; return status; } function governingStatus(status) { if (!status || status.isWorktree !== true) return status; return statusFor(projectFor(status.parentSlug)); } function ownerKnown(status) { // Legacy ownerless projects still require explicit project access below. // An assigned owner that no longer exists remains invalid. return !multiUser() || status.projectOwnerId == null || !!findUserById(status.projectOwnerId); } function openStore(status) { var governing = governingStatus(status); if (!governing || !governing.path || !governing.projectKnowledgeId) throw new Error("Issues are unavailable for this project."); var key = governing.projectKnowledgeId; if (!stores.has(key)) stores.set(key, issuesStore.createIssuesStore({ projectKnowledgeId: key, baseDir: context.baseDir })); return stores.get(key); } function issueContext(status) { if (!status || status.isWorktree !== true || !status.changeSetId) return null; return { kind: "worktree", changeSetId: status.changeSetId, branch: status.branch || null, baseCommit: status.baseCommit || null, headCommit: status.headCommit || null }; } function author(user, type, session) { return { type: type, userId: user ? user.id || null : null, displayName: user && (user.displayName || user.username) || null, sessionKey: session ? session.cliSessionId || "local:" + session.localId : null, sessionOriginId: session ? session.sessionOriginId || null : null, vendor: session ? session.vendor || null : null, at: Date.now() }; } function cleanPublic(input, controls, allowCloseReason) { if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Issue arguments are required."); var keys = Object.keys(input); var result = {}; for (var i = 0; i < keys.length; i++) { var key = keys[i]; if (Object.prototype.hasOwnProperty.call(controls, key)) continue; if (!Object.prototype.hasOwnProperty.call(PUBLIC_FIELDS, key) || (key === "closeReason" && !allowCloseReason)) throw new Error("Unknown or server-owned issue field: " + key + "."); result[key] = input[key]; } return result; } function osUsersEnabled() { return typeof context.osUsersEnabled === "function" ? context.osUsersEnabled() === true : context.osUsersEnabled === true; } function osIdentity(state) { if (!osUsersEnabled()) return null; var identity = typeof context.getOsUserInfoForActor === "function" ? context.getOsUserInfoForActor(state.user) : null; if (!identity || typeof identity !== "object") throw new Error("An OS identity is required for commit verification."); return clone(identity); } scope = accessScope({ projectFor: projectFor, isMultiUser: multiUser, canAccessProject: context.canAccessProject, hasFullProjectAccess: context.hasFullProjectAccess, openStore: function () { return null; } }); function bindingState(project, session, userId, originId) { var status = statusFor(project); if (!status || status.isMate === true) return null; var governing = governingStatus(status); if (!governing || !ownerKnown(governing)) return null; var user = null; if (multiUser()) { if (!userId || !(user = findUserById(userId))) return null; } else { user = userId ? findUserById(userId) : null; if (!user) user = { id: userId || status.projectOwnerId || null }; } if (session) { var manager = project.getSessionManager ? project.getSessionManager() : project.sm; if (!manager || !manager.sessions || manager.sessions.get(session.localId) !== session) return null; if ((session.ownerId || null) !== (userId || null) || (session.sessionOriginId || null) !== originId) return null; if (multiUser() && !session.ownerId) return null; if (provenance.isWorker(session) || session.hidden === true || session.delegated === true || session._pairDelegation || session._delegatedBy) return null; } var principal = multiUser() ? { userId: user.id, singleUserOwnerId: null } : { userId: null, singleUserOwnerId: user.id || status.projectOwnerId || null }; var authorization; try { authorization = scope.projectAuthorization(principal, status); } catch (e) { return null; } if (!authorization.allowed || (authorization.worktreeOnly && !status.changeSetId)) return null; principal.isProjectOwner = scope.ownsProject(principal, governing); return { project: project, status: status, session: session, user: user, principal: principal, worktreeOnly: authorization.worktreeOnly }; } function bind(source, isSession, canonicalClay) { if (!source || typeof source.projectSlug !== "string") return null; var initialSlug = source.projectSlug; var initialProject = projectFor(initialSlug); var initialSession = isSession ? source.session : null; var clayInitial; if (canonicalClay) { try { clayInitial = require("./canonical-clay-record-authority").resolve(source.authority, initialSlug); } catch (error) { return null; } } if (!initialProject || (isSession && !initialSession)) return null; if (initialSession) provenance.ensureOrigin(initialSession); var initialUserId = initialSession ? initialSession.ownerId || null : canonicalClay ? clayInitial.principal.userId || clayInitial.principal.singleUserOwnerId : source.userId || null; var initialOrigin = initialSession ? initialSession.sessionOriginId || null : null; var initial = bindingState(initialProject, initialSession, initialUserId, initialOrigin); if (!initial) return null; function current() { if (projectFor(initialSlug) !== initialProject) throw new Error("This Issues binding is no longer valid."); if (canonicalClay) { var clayLive = require("./canonical-clay-record-authority").resolve(source.authority, initialSlug); if (clayLive.project !== initialProject || clayLive.status.path !== clayInitial.status.path || clayLive.status.projectKnowledgeId !== clayInitial.status.projectKnowledgeId || clayLive.status.changeSetId !== clayInitial.status.changeSetId) throw new Error("Selected Issues context changed."); } var live = bindingState(initialProject, initialSession, initialUserId, initialOrigin); if (!live) throw new Error("This Issues binding is no longer valid."); return live; } function expected(entry, args) { if (canonicalClay && (!Number.isInteger(args.expectedRevision) || entry.revision !== args.expectedRevision)) throw new Error("Issue revision conflict."); } function actor(state) { return canonicalClay ? require("./canonical-clay-record-authority").resolve(source.authority, initialSlug).actor : author(state.user, state.session ? "session" : "user", state.session); } function visibleIssue(store, ref, state) { var result = store.read(ref); if (state.worktreeOnly && (!result.context || result.context.changeSetId !== state.status.changeSetId)) throw new Error("Issue not found."); return result; } function scopedIssue(store, ref, state) { var result = store.read(ref, true); if (state.worktreeOnly && (!result.context || result.context.changeSetId !== state.status.changeSetId)) throw new Error("Issue not found."); return result; } function scopedOptions(state, args) { var result = Object.assign({}, args || {}); if (state.worktreeOnly) { if (!state.status.changeSetId) throw new Error("Issues are unavailable without a change set."); result.changeSetId = state.status.changeSetId; } return result; } function operation(state, needsIdentity) { var governing = governingStatus(state.status); return { project: state.project, session: state.session, userId: state.user.id || null, path: state.status.path, knowledgeId: governing.projectKnowledgeId, changeSetId: state.status.changeSetId || null, worktreeOnly: state.worktreeOnly, recordContext: clone(issueContext(state.status)), actor: clone(actor(state)), osIdentity: needsIdentity ? osIdentity(state) : null }; } function recheck(before, needsIdentity) { var fresh = current(); var governing = governingStatus(fresh.status); var liveIdentity = needsIdentity ? osIdentity(fresh) : null; if (fresh.project !== before.project || fresh.session !== before.session || (fresh.user.id || null) !== before.userId || fresh.status.path !== before.path || !governing || governing.projectKnowledgeId !== before.knowledgeId || (fresh.status.changeSetId || null) !== before.changeSetId || fresh.worktreeOnly !== before.worktreeOnly || JSON.stringify(issueContext(fresh.status)) !== JSON.stringify(before.recordContext) || JSON.stringify(liveIdentity) !== JSON.stringify(before.osIdentity)) throw new Error("Issue authorization changed during the operation."); return fresh; } function evidence(before, sha) { return verify(before.path, sha, before.knowledgeId, before.osIdentity).then(function (proof) { var normalized = schema.normalizeVerifiedCommit(proof); if (normalized.repository !== before.knowledgeId) throw new Error("Verified commit evidence belongs to a different repository."); return normalized; }); } var github = require('./issues-github').attachIssueGithub({ locks: githubLocks, assessments: githubAssessments, run: context.runGithub, analyze: source.analyzeGithub || context.analyzeGithub, begin: function () { if (isSession || canonicalClay) throw new Error('GitHub filing requires an explicit action in the Issues panel.'); return operation(current(), true); }, read: function (before, ref) { var state = recheck(before, true); return visibleIssue(openStore(state.status), ref, state); }, save: function (before, ref, data, revision) { var state = recheck(before, true); var target = openStore(state.status); visibleIssue(target, ref, state); return target.update(ref, data, revision, before.actor); }, }); return { searchGithubIssue: github.search, linkGithubIssue: github.link, createGithubIssue: github.create, linkWorkSession: function (args, session) { if (canonicalClay) throw new Error("Use reviewed project assignment or follow-up for Driver execution."); var state = current(); var manager = state.project.getSessionManager(); if (!args || !Number.isInteger(args.expectedRevision)) throw new Error("expectedRevision is required."); if (!session || manager.sessions.get(session.localId) !== session || provenance.isWorker(session) || session.hidden === true || session.delegated === true || session._pairDelegation || session._delegatedBy || (session.ownerId || null) !== (state.user.id || null)) throw new Error("Invalid work session."); provenance.ensureOrigin(session); var target = openStore(state.status); var entry = visibleIssue(target, args.ref, state); if (entry.revision !== args.expectedRevision) throw new Error("Issue revision conflict."); if (entry.status === "resolved" || entry.status === "closed") throw new Error("Reopen this issue before starting work."); var links = entry.linkedWorkSessions || []; if (links.some(function (link) { return link.sessionId === session.sessionOriginId; })) return entry; var link = { sessionId: session.sessionOriginId, role: "driver", issueRevision: args.expectedRevision }; if (args.requestId !== undefined) link.requestId = schema.text(args.requestId, "work request id", 200, true); return target.update(args.ref, { linkedWorkSessions: links.concat([link]), status: "in_progress" }, args.expectedRevision, canonicalClay ? actor(state) : author(state.user, "user", null)); }, resolveWorkSession: function (args) { if (canonicalClay) throw new Error("Work sessions require the existing project Driver workflow."); var state = current(); var entry = visibleIssue(openStore(state.status), args.ref, state); if (!(entry.linkedWorkSessions || []).some(function (link) { return link.sessionId === args.sessionOriginId; })) throw new Error("Work session unavailable."); var found = null; state.project.getSessionManager().sessions.forEach(function (session) { if (session.sessionOriginId === args.sessionOriginId && (session.ownerId || null) === (state.user.id || null) && !provenance.isWorker(session) && session.hidden !== true && session.delegated !== true && !session._pairDelegation && !session._delegatedBy) found = session; }); if (!found) throw new Error("Work session unavailable."); return found; }, scopeId: "project/" + governingStatus(initial.status).projectKnowledgeId + "/issues", canComment: true, canDelete: initial.principal.isProjectOwner === true, listIssues: function (args) { var state = current(); return openStore(state.status).list(scopedOptions(state, args)); }, searchIssues: function (args) { var state = current(); return openStore(state.status).search(scopedOptions(state, args)); }, readIssue: function (args) { var state = current(); return visibleIssue(openStore(state.status), args && args.ref, state); }, readIssueTombstone: function (args) { var state = current(); return scopedIssue(openStore(state.status), args && args.ref, state); }, issueHistory: function (args) { var state = current(); var store = openStore(state.status); var history = store.history(args && args.ref); if (state.worktreeOnly) { var historical = scopedIssue(store, args && args.ref, state); if (!historical.context || historical.context.changeSetId !== state.status.changeSetId) throw new Error("Issue not found."); } return history; }, readIssueRevision: function (args) { var state = current(); var store = openStore(state.status); visibleIssue(store, args && args.ref, state); return store.readRevision(args.ref, args.revision); }, commentIssue: function (args) { var state = current(); var target = openStore(state.status); expected(visibleIssue(target, args && args.ref, state), args); return target.comment(args.ref, args.body, canonicalClay ? actor(state) : author(state.user, "user", null)); }, listIssueFeedback: function (args) { if (!isSession && !canonicalClay) throw new Error("Only the Project Driver can review issue comments."); var state = current(); var target = openStore(state.status); var out = []; var feedbackLimit = args && args.limit === undefined ? 200 : Number(args.limit); if (!Number.isInteger(feedbackLimit) || feedbackLimit < 1 || feedbackLimit > 200) throw new Error("Feedback limit must be between 1 and 200."); var position = { issueCursor: null, issueIndex: 0, commentOffset: 0 }; if (args && args.cursor) { try { if (/^\d+$/.test(String(args.cursor))) position.issueCursor = String(args.cursor); else position = JSON.parse(Buffer.from(String(args.cursor), "base64url").toString("utf8")); } catch (e) { throw new Error("Invalid issue feedback cursor."); } if (!position || (position.issueCursor !== null && typeof position.issueCursor !== "string") || !Number.isInteger(position.issueIndex) || position.issueIndex < 0 || !Number.isInteger(position.commentOffset) || position.commentOffset < 0) throw new Error("Invalid issue feedback cursor."); } var nextCursor = null; var scanned = 0; while (out.length < feedbackLimit && scanned < 500) { var result = target.list(scopedOptions(state, { limit: 50, cursor: position.issueCursor })); if (!result.issues.length) break; scanned += result.issues.length; var firstIssueIndex = position.issueIndex; if (firstIssueIndex >= result.issues.length) throw new Error("Invalid issue feedback cursor."); for (var i = firstIssueIndex; i < result.issues.length && out.length < feedbackLimit; i++) { var entry = visibleIssue(target, result.issues[i].ref, state); var waiting = (entry.comments || []).filter(function (item) { return item.status === "pending"; }); var start = i === position.issueIndex ? position.commentOffset : 0; var j = start; for (; j < waiting.length && out.length < feedbackLimit; j++) out.push({ ref: entry.ref, title: entry.title, commentId: waiting[j].id, body: waiting[j].body, author: waiting[j].author, at: waiting[j].at }); if (out.length >= feedbackLimit) { if (j < waiting.length) nextCursor = Buffer.from(JSON.stringify({ issueCursor: position.issueCursor, issueIndex: i, commentOffset: j })).toString("base64url"); else if (i + 1 < result.issues.length) nextCursor = Buffer.from(JSON.stringify({ issueCursor: position.issueCursor, issueIndex: i + 1, commentOffset: 0 })).toString("base64url"); else if (result.nextCursor) nextCursor = Buffer.from(JSON.stringify({ issueCursor: result.nextCursor, issueIndex: 0, commentOffset: 0 })).toString("base64url"); } } if (nextCursor || !result.nextCursor) break; if (scanned >= 500) { nextCursor = Buffer.from(JSON.stringify({ issueCursor: result.nextCursor, issueIndex: 0, commentOffset: 0 })).toString("base64url"); break; } position.issueCursor = result.nextCursor; position.issueIndex = 0; position.commentOffset = 0; } return { feedback: out, total: out.length, nextCursor: nextCursor, truncated: !!nextCursor }; }, reviewIssueComment: function (args) { if (!isSession && !canonicalClay) throw new Error("Only the Project Driver can review issue comments."); var state = current(); var target = openStore(state.status); var entry = visibleIssue(target, args && args.ref, state); expected(entry, args); if (args.action === "incorporate") { if (!Number.isInteger(args.expectedRevision)) throw new Error("expectedRevision is required to incorporate a comment."); var data = cleanPublic(args, { ref: true, expectedRevision: true, commentId: true, action: true, response: true, commitSha: true }, true); if (data.status === "resolved") throw new Error("Use update_issue with verified commit evidence before resolving this Issue."); return target.incorporate(entry.ref, args.commentId, data, args.expectedRevision, args.response, actor(state)); } return target.review(entry.ref, args.commentId, args.action, args.response, actor(state)); }, removeIssue: function (args) { var state = current(); if (!state.principal.isProjectOwner) throw new Error("Only the project owner can delete Issues."); var target = openStore(state.status); var entry = visibleIssue(target, args && args.ref, state); if (!Number.isInteger(args && args.expectedRevision) || entry.revision !== args.expectedRevision) throw new Error("Issue revision conflict."); return target.remove(entry.ref, canonicalClay ? actor(state) : author(state.user, "user", null), args.expectedRevision); }, createIssue: function (args) { if (!isSession && !canonicalClay) throw new Error("People cannot create issues directly. Ask the Project Driver to create one."); var data = cleanPublic(args, { commitSha: true }, true); var status = data.status === undefined ? "open" : data.status; if (schema.STATUSES.indexOf(status) === -1) throw new Error("Invalid status."); var state = current(); if (status !== "resolved") return Promise.resolve(openStore(state.status).create(data, actor(state), issueContext(state.status))); if (typeof data.resolutionSummary !== "string" || !data.resolutionSummary.trim()) throw new Error("resolutionSummary is required when resolving an issue."); var before = operation(state, true); return evidence(before, args.commitSha).then(function (proof) { var after = recheck(before, true); return openStore(after.status).create(data, before.actor, before.recordContext, proof); }); }, updateIssue: function (args) { var data = cleanPublic(args, { ref: true, expectedRevision: true, commitSha: true }, true); if (!Number.isInteger(args.expectedRevision)) throw new Error("expectedRevision is required."); if (data.status !== undefined && schema.STATUSES.indexOf(data.status) === -1) throw new Error("Invalid status."); var ref = args.ref; var expectedRevision = args.expectedRevision; var state = current(); var target = openStore(state.status); var existing = visibleIssue(target, ref, state); if (existing.revision !== expectedRevision) throw new Error("Issue revision conflict."); if (data.status !== "resolved") return Promise.resolve(target.update(ref, data, expectedRevision, actor(state))); if (typeof data.resolutionSummary !== "string" || !data.resolutionSummary.trim()) throw new Error("resolutionSummary is required when resolving an issue."); var before = operation(state, true); return evidence(before, args.commitSha).then(function (proof) { var after = recheck(before, true); var afterStore = openStore(after.status); visibleIssue(afterStore, ref, after); return afterStore.update(ref, data, expectedRevision, before.actor, null, proof); }); }, }; } return { bindUser: function (source) { return bind(source, false); }, bindCanonicalClay: function (source) { return bind(source, false, true); }, bindProjectSession: function (source) { return bind(source, true); }, issueScopeIdForKnowledgeId: issuesStore.issueScopeIdForKnowledgeId }; } module.exports = { attachIssuesService: attachIssuesService };