feat: add scoped repository maintenance access for LLM clients (#913)

* feat: add scoped repository maintenance access for LLM clients

* fix: translate maintenance API errors
This commit is contained in:
Thomas Durieux authored and GitHub committed 2026-11-02 07:18:04 +00:00
1 parent 6cf5445972
commit 6099356e0e
13 files changed
+1219 -17

No files matched your search

+3
View File
@@ -36,6 +36,9 @@ const UserSchema = new Schema({
name: { type: String },
createdAt: { type: Date, default: Date.now },
lastUsedAt: { type: Date },
scope: { type: String, enum: ["repository-maintenance"] },
repoIds: { type: [String], default: undefined },
expiresAt: { type: Date },
},
],
photo: String,
+3
View File
@@ -19,6 +19,9 @@ export interface IUser {
name?: string;
createdAt?: Date;
lastUsedAt?: Date;
scope?: "repository-maintenance";
repoIds?: string[];
expiresAt?: Date;
}[];
notificationEmail?: string;
emailPromptNever?: boolean;
+35 -10
View File
@@ -16,7 +16,10 @@ import * as passport from "passport";
import { connect } from "./database";
import { startTemporaryStorageMaintenance } from "../core/temporary-storage";
import { initSession, router as connectionRouter } from "./routes/connection";
import maintenanceRouter from "./routes/maintenance";
import maintenanceTokens from "./routes/maintenance-tokens";
import { bearerTokenAuth } from "./routes/token-auth";
import { tokenAttemptLimitOptions, maintenanceRefreshLimitOptions } from "./routes/maintenance-limits";
import router from "./routes";
import {
conferenceStatusCheck,
@@ -149,12 +152,6 @@ export default async function start() {
// client-spoofable header.
app.set("trust proxy", resolveTrustProxy(config.TRUST_PROXY));
// handle session and connection
app.use(initSession());
app.use(passport.initialize());
app.use(passport.session());
app.use(bearerTokenAuth);
startWorker();
const redisClient = createClient({
@@ -169,6 +166,32 @@ export default async function start() {
await redisClient.connect();
// Charge failed and forbidden bearer requests before their database lookup.
app.use(rateLimit({
...tokenAttemptLimitOptions,
store: new RedisStore({
prefix: "token-attempt:",
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
}),
}));
// Bearer attempts are limited before session deserialization as well.
app.use(initSession());
app.use(passport.initialize());
app.use(passport.session());
app.use(bearerTokenAuth);
app.use(rateLimit({
...maintenanceRefreshLimitOptions,
store: new RedisStore({
prefix: "maintenance-refresh:",
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
}),
}));
// All maintenance tokens for an account share a budget across client IPs.
const apiRateKey = (req: express.Request, res: express.Response) =>
res.locals.maintenanceOwnerId
? `maintenance:${res.locals.maintenanceOwnerId}`
: requestRateLimitKey(req);
const rate = rateLimit({
store: new RedisStore({
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
@@ -177,7 +200,7 @@ export default async function start() {
skip: async (request: express.Request, _response: express.Response) => {
try {
const user = await getUser(request);
if (user && user.isAdmin) return true;
if (user && user.isAdmin && !_response.locals.maintenanceToken) return true;
} catch {
// ignore: user not connected
}
@@ -193,7 +216,7 @@ export default async function start() {
// if not logged in, limit to half the rate
return config.RATE_LIMIT / 2;
},
keyGenerator: requestRateLimitKey,
keyGenerator: apiRateKey,
standardHeaders: "draft-6",
legacyHeaders: false,
handler: sendRequestRateLimit,
@@ -203,14 +226,14 @@ export default async function start() {
delayAfter: 50,
delayMs: () => 150,
maxDelayMs: 5000,
keyGenerator: requestRateLimitKey,
keyGenerator: apiRateKey,
});
const webViewSpeedLimiter = slowDown({
windowMs: 15 * 60 * 1000, // 15 minutes
delayAfter: 200,
delayMs: () => 150,
maxDelayMs: 5000,
keyGenerator: requestRateLimitKey,
keyGenerator: apiRateKey,
});
app.use("/github", rate, speedLimiter, githubAppRouter);
@@ -220,6 +243,8 @@ export default async function start() {
const apiRouter = express.Router();
app.use("/api", rate, apiRouter);
apiRouter.use("/maintenance", speedLimiter, maintenanceRouter);
apiRouter.use("/user/maintenance-tokens", maintenanceTokens);
apiRouter.use("/admin", router.admin);
apiRouter.use("/options", router.option);
apiRouter.use("/conferences", router.conference);
+5 -1
View File
@@ -252,7 +252,11 @@ router.post(
const model = await UserModel.findOne({
"apiTokens.tokenHash": hashToken(token),
});
if (!model) return res.status(401).json({ error: "invalid_token" });
const entry = model?.apiTokens?.find(t => t.tokenHash === hashToken(token));
if (!model || !entry || entry.scope ||
(entry.expiresAt && entry.expiresAt.getTime() <= Date.now())) {
return res.status(401).json({ error: "invalid_token" });
}
if (isDisabledAccount(model.status)) {
return res.status(model.status === "banned" ? 403 : 401).json({
error: model.status === "banned" ? "user_banned" : "not_connected",
+28
View File
@@ -0,0 +1,28 @@
import { Options } from "express-rate-limit";
import { requestRateLimitKey } from "../rate-limit-key";
// Redis stores are supplied by the server. These policies also run in tests
// with the in-memory store, through real HTTP requests and the auth middleware.
export const tokenAttemptLimitOptions: Partial<Options> = {
windowMs: 60_000,
limit: 120,
skip: req => !req.headers.authorization,
keyGenerator: requestRateLimitKey,
standardHeaders: "draft-6",
legacyHeaders: false,
message: { error: "token_attempts_rate_limited" },
};
export const maintenanceRefreshLimitOptions: Partial<Options> = {
windowMs: 60_000,
limit: 1,
skip: (req, res) => !res.locals.maintenanceToken || req.method !== "POST" ||
!/^\/api\/repo\/[a-zA-Z0-9_-]+\/refresh\/?$/.test(req.path),
// Share the cooldown across this account's tokens and client IPs. Keep
// different accounts separate so a removed coauthor cannot block the owner.
keyGenerator: (req, res) =>
`${res.locals.maintenanceOwnerId}:${req.path.split("/")[3].toLowerCase()}`,
standardHeaders: "draft-6",
legacyHeaders: false,
message: { error: "refresh_rate_limited" },
};
+62
View File
@@ -0,0 +1,62 @@
import * as express from "express";
import { Types } from "mongoose";
import { getUser, handleError, isOwnerCoauthorOrAdmin } from "./route-utils";
import { generateToken, hashToken } from "./token-auth";
import UserModel from "../../core/model/users/users.model";
import { getRepository } from "../database";
import AnonymousError from "../../core/AnonymousError";
const router = express.Router();
router.use((req, res, next) => {
res.set("Cache-Control", "no-store");
// A restricted token must never mint credentials or become a session.
if (req.headers.authorization || !req.isAuthenticated()) {
return res.status(401).json({ error: "session_required" });
}
next();
});
router.get("/", async (req, res) => {
try {
const user = await getUser(req);
res.json((user.model.apiTokens || []).filter(t => t.scope === "repository-maintenance")
.map(t => ({ id: t._id, name: t.name, repoIds: t.repoIds, expiresAt: t.expiresAt,
createdAt: t.createdAt, lastUsedAt: t.lastUsedAt })));
} catch (error) { handleError(error, res, req); }
});
router.post("/", async (req, res) => {
try {
const user = await getUser(req);
const { name, repoIds, expiresInDays = 30 } = req.body;
if (typeof name !== "string" || !name.trim() || name.length > 80 ||
!Array.isArray(repoIds) || !repoIds.length || repoIds.length > 20 ||
!repoIds.every(id => typeof id === "string" && /^[a-zA-Z0-9_-]{3,200}$/.test(id)) ||
!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 90) {
throw new AnonymousError("invalid_token_options", { httpStatus: 400 });
}
for (const id of new Set<string>(repoIds)) {
isOwnerCoauthorOrAdmin(await getRepository(id), user);
}
const token = generateToken();
const entry = { _id: new Types.ObjectId(), name: name.trim(), scope: "repository-maintenance",
repoIds: [...new Set(repoIds)], tokenHash: hashToken(token), createdAt: new Date(),
expiresAt: new Date(Date.now() + expiresInDays * 86400_000) };
const saved = await UserModel.updateOne({ _id: user.model._id,
"apiTokens.50": { $exists: false } }, { $push: { apiTokens: entry } });
if (!saved.matchedCount) throw new AnonymousError("token_limit_reached", { httpStatus: 409 });
res.status(201).json({ id: entry._id, name: entry.name, repoIds: entry.repoIds,
expiresAt: entry.expiresAt, token });
} catch (error) { handleError(error, res, req); }
});
router.delete("/:id", async (req, res) => {
try {
const user = await getUser(req);
if (!Types.ObjectId.isValid(req.params.id)) throw new AnonymousError("invalid_token_id", { httpStatus: 400 });
const result = await UserModel.updateOne({ _id: user.model._id },
{ $pull: { apiTokens: { _id: req.params.id, scope: "repository-maintenance" } } });
res.json({ removed: result.modifiedCount });
} catch (error) { handleError(error, res, req); }
});
export default router;
+59
View File
@@ -0,0 +1,59 @@
import * as express from "express";
import { getRepo, getUser, handleError, isOwnerCoauthorOrAdmin } from "./route-utils";
import AnonymousError from "../../core/AnonymousError";
import RepoModel from "../../core/model/anonymizedRepositories/anonymizedRepositories.model";
const router = express.Router();
const toggles = ["update", "image", "pdf", "notebook", "link"];
/** Only in-place anonymization settings; never accept source or connection input. */
export function maintenanceSettings(body: unknown): Record<string, unknown> {
const invalid = () => new AnonymousError("invalid_settings", { httpStatus: 400 });
if (!body || typeof body !== "object" || Array.isArray(body)) throw invalid();
const entries = Object.entries(body);
if (!entries.length) throw invalid();
const updates: Record<string, unknown> = {};
for (const [key, value] of entries) {
if (key === "terms") {
if (!Array.isArray(value) || value.length > 1000 ||
!value.every(t => typeof t === "string" && t.length > 0 && t.length <= 1000)) throw invalid();
} else if (!toggles.includes(key) || typeof value !== "boolean") throw invalid();
updates[`options.${key}`] = value;
}
return updates;
}
router.route("/repositories/:repoId")
.all((_req, res, next) => { res.set("Cache-Control", "no-store"); next(); })
.get(async (req, res) => {
try {
const user = await getUser(req);
const repo = await getRepo(req, res, { nocheck: true });
if (!repo) return;
isOwnerCoauthorOrAdmin(repo, user);
// Read saved metadata without refreshing GitHub credentials or content.
res.json({ repoId: repo.repoId, status: repo.status, statusMessage: repo.model.statusMessage,
statusDate: repo.model.statusDate, settingsSavedAt: repo.model.settingsSavedAt,
source: { branch: repo.model.source.branch, commit: repo.model.source.commit },
options: repo.model.options });
} catch (error) { handleError(error, res, req); }
})
.patch(async (req, res) => {
try {
const user = await getUser(req);
const repo = await getRepo(req, res, { nocheck: true });
if (!repo) return;
isOwnerCoauthorOrAdmin(repo, user);
if (!["ready", "error"].includes(repo.status || "")) throw new AnonymousError("invalid_status", { httpStatus: 409 });
repo.assertNotArchived();
const updates = maintenanceSettings(req.body);
const settingsSavedAt = new Date();
const saved = await RepoModel.updateOne({ _id: repo.model._id, status: repo.model.status,
statusDate: repo.model.statusDate,
settingsSavedAt: repo.model.settingsSavedAt || { $exists: false } },
{ $set: { ...updates, settingsSavedAt } }).exec();
if (!saved.matchedCount) throw new AnonymousError("invalid_status", { httpStatus: 409 });
res.json({ repoId: repo.repoId, status: repo.status, settingsSavedAt });
} catch (error) { handleError(error, res, req); }
});
export default router;
+30 -6
View File
@@ -16,21 +16,35 @@ export function generateToken(): string {
export async function bearerTokenAuth(
req: express.Request,
_res: express.Response,
res: express.Response,
next: express.NextFunction
): Promise<void> {
if (req.user) return next();
const header = req.headers["authorization"];
if (!header || typeof header !== "string") return next();
const match = header.match(/^Bearer\s+(.+)$/i);
if (!match) return next();
if (!match || !match[1].trim() || match[1].length > 512) {
res.status(401).json({ error: "invalid_token" });
return;
}
const tokenHash = hashToken(match[1].trim());
try {
const model = await UserModel.findOne({ "apiTokens.tokenHash": tokenHash });
if (!model) return next();
if (isDisabledAccount(model.status)) return next();
const token = model?.apiTokens?.find(t => t.tokenHash === tokenHash);
if (!model || !token || isDisabledAccount(model.status) ||
(token.expiresAt && token.expiresAt.getTime() <= Date.now()) ||
(token.scope && (!token.expiresAt || token.scope !== "repository-maintenance"))) {
res.status(401).json({ error: "invalid_token" });
return;
}
if (token.scope) {
if (!maintenanceTokenAllows(req.method, req.path, token.repoIds || [])) {
res.status(403).json({ error: "token_scope_forbidden" });
return;
}
res.locals.maintenanceToken = true;
res.locals.maintenanceOwnerId = String(model._id);
}
// Mirror the shape produced by passport's verify() in connection.ts
// so existing getUser()/route code works unchanged.
@@ -48,6 +62,16 @@ export async function bearerTokenAuth(
);
} catch (err) {
logger.error("lookup failed", serializeError(err));
res.status(503).json({ error: "authentication_unavailable" });
return;
}
return next();
}
/** Exact paths keep scoped tokens out of GitHub proxy and session routes. */
export function maintenanceTokenAllows(method: string, path: string, repoIds: string[]): boolean {
const readOrEdit = path.match(/^\/api\/maintenance\/repositories\/([a-zA-Z0-9_-]+)\/?$/);
if (readOrEdit && ["GET", "PATCH"].includes(method)) return repoIds.includes(readOrEdit[1]);
const action = path.match(/^\/api\/repo\/([a-zA-Z0-9_-]+)\/(refresh|extend)\/?$/);
return !!action && method === "POST" && repoIds.includes(action[1]);
}