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

+10
View File
@@ -42,6 +42,16 @@ npm install -g @tdurieux/anonymous_github
anonymous_github
```
### LLM and automation clients
Use expiring, repository-scoped maintenance tokens to inspect saved status,
edit anonymization settings, refresh snapshots, and extend expiration.
A signed-in owner or coauthor can issue and revoke tokens through
`/api/user/maintenance-tokens`. See the [client guide](public/llms.txt) and
[OpenAPI description](public/maintenance-openapi.json), also served at
`/llms.txt` and `/maintenance-openapi.json` on each instance.
These tokens cannot access GitHub previews, file downloads, or admin routes.
## Self-hosting
Local installation requires Node.js 22.15 or newer in the 22.x series,
+9
View File
@@ -117,6 +117,15 @@
"gistId_not_specified": "A gist ID must be provided.",
"gistId_already_used": "The gist ID is already used.",
"missing_token": "An authentication token is required.",
"token_attempts_rate_limited": "Too many token authentication attempts. Wait before trying again.",
"refresh_rate_limited": "A refresh was already requested for this repository. Wait one minute before trying again.",
"session_required": "Sign in through your browser to manage maintenance tokens.",
"invalid_token_options": "Provide a token name, 1 to 20 repository IDs, and an expiration of 1 to 90 days.",
"token_limit_reached": "You have reached the limit of 50 API tokens. Revoke an unused token before creating another.",
"invalid_token_id": "The token ID is invalid.",
"invalid_settings": "Provide supported settings with boolean values, or a list of anonymization terms.",
"token_scope_forbidden": "This token does not allow this operation or repository.",
"authentication_unavailable": "Authentication is temporarily unavailable. Try again later.",
"invalid_token": "The provided authentication token is invalid.",
"login_failed": "Login failed. Please try again.",
"server_error": "An unexpected server error occurred. Please try again later.",
+70
View File
@@ -0,0 +1,70 @@
# Anonymous GitHub repository maintenance
Anonymous GitHub publishes anonymized snapshots for peer review. Use the
maintenance API to manage existing snapshots you own or coauthor. It is not a
GitHub API proxy and does not edit the upstream GitHub repository.
Machine-readable API description: /maintenance-openapi.json
All paths below are relative to this instance. Send JSON request bodies and
Authorization: Bearer <maintenance-token>. Keep tokens in a secret store;
never put them in URLs, prompts, source control, or logs.
## Connect a client
A person signs in with GitHub and creates the anonymized repository first.
In their signed-in browser session, POST /api/user/maintenance-tokens with:
{"name":"Paper maintenance","repoIds":["paper-123"],"expiresInDays":30}
The response contains the token once, its id, selected repoIds and expiresAt.
Expiry is 1 to 90 days, default 30. Select 1 to 20 existing repository IDs.
There is a limit of 50 stored API tokens per account. A person can inspect
GET /api/user/maintenance-tokens and revoke one with
DELETE /api/user/maintenance-tokens/{id} using the same browser session.
These endpoints require a session, not a bearer token. Do not hand the client
an admin token or browser cookie. Maintenance tokens cannot log into the UI.
## Maintain a repository
1. GET /api/maintenance/repositories/{repoId} reads saved status, branch,
commit and options without querying GitHub or downloading content.
2. PATCH the same URL with only the settings to change, for example:
{"terms":["Alice Smith","Example University"],"image":false,"update":false}
Omitted settings stay unchanged. terms replaces the whole term list;
[] clears it. Supported boolean fields: update, image, pdf, notebook, link.
update controls automatic snapshot updates. Settings edits work in ready
or error state, and cannot change the upstream source or connection.
3. POST /api/repo/{repoId}/refresh with {} requests the latest snapshot of the
saved branch using the repository's existing connection. Poll the saved
status endpoint every 30 seconds until ready or error. Do not repeat
refresh while work is in progress.
4. POST /api/repo/{repoId}/extend with {} adds six months to the current
expiration, or to today if already expired. An expired snapshot is queued
for restoration. This operation is NOT idempotent. Read the expiration
date after an ambiguous network failure before deciding whether to retry.
Refresh completion is asynchronous. A successful response does not mean the
snapshot is ready. Refresh uses the existing GitHub quota and preparation
limits. All maintenance tokens for an account share an API rate budget,
even across different client IPs. Each account can request one refresh per
repository per minute, shared across its tokens. Bearer authentication attempts
also have a limit of 120 per minute per IP, including invalid tokens. Both limits
return HTTP 429 with Retry-After. Do not use maintenance calls to crawl GitHub or retrieve source files.
## Errors and access limits
Errors normally contain {"error":"code"}. The instance rate limiter may
return text for HTTP 429; always check status and Retry-After first.
401: token expired, revoked or invalid. Ask the owner to replace it.
403: token_scope_forbidden or not_authorized. The token must name this
repository and its account must still be an owner, coauthor or administrator.
404: repository does not exist.
409: state changed or operation is unavailable. Read status before retrying.
429: back off until Retry-After or resetAt, when supplied. Never retry in a loop.
5xx: retry reads with backoff. Inspect state before retrying a write.
Scoped tokens cannot create repositories, change source repositories, delete
snapshots, manage coauthors, search GitHub, preview arbitrary branches or files,
download archives, read file contents, access admin routes, or mint tokens.
Use the signed-in UI for those management tasks. Existing public review links
keep their usual access rules. Revocation and membership checks apply on each
maintenance request. Never publish private maintenance responses as review links.
+608
View File
@@ -0,0 +1,608 @@
{
"openapi": "3.0.3",
"info": {
"title": "Anonymous GitHub maintenance API",
"version": "1.0.0",
"description": "Maintain existing anonymized snapshots. Session-only token issuance is documented at /llms.txt. No GitHub proxy or file download access."
},
"servers": [
{
"url": "/"
}
],
"security": [
{
"maintenanceToken": []
}
],
"components": {
"securitySchemes": {
"maintenanceToken": {
"type": "http",
"scheme": "bearer"
}
},
"schemas": {
"Error": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
},
"resetAt": {
"type": "number",
"description": "Unix timestamp in milliseconds, when available."
}
}
},
"RepositoryStatus": {
"type": "object",
"required": [
"repoId",
"options"
],
"properties": {
"repoId": {
"type": "string"
},
"status": {
"type": "string"
},
"statusMessage": {
"type": "string"
},
"statusDate": {
"type": "string",
"format": "date-time"
},
"settingsSavedAt": {
"type": "string",
"format": "date-time"
},
"source": {
"type": "object",
"properties": {
"branch": {
"type": "string"
},
"commit": {
"type": "string"
}
}
},
"options": {
"type": "object",
"properties": {
"terms": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 1000
}
},
"update": {
"type": "boolean"
},
"image": {
"type": "boolean"
},
"pdf": {
"type": "boolean"
},
"notebook": {
"type": "boolean"
},
"link": {
"type": "boolean"
},
"expirationDate": {
"type": "string",
"format": "date-time"
},
"expirationMode": {
"type": "string",
"enum": [
"never",
"redirect",
"remove"
]
},
"page": {
"type": "boolean"
},
"pageSource": {
"type": "object",
"properties": {
"branch": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
}
}
}
}
}
},
"paths": {
"/api/maintenance/repositories/{repoId}": {
"parameters": [
{
"name": "repoId",
"in": "path",
"required": true,
"schema": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]+$"
},
"description": "Existing anonymized repository ID selected when issuing the token."
}
],
"get": {
"operationId": "getRepositoryMaintenanceStatus",
"summary": "Read saved status and settings without GitHub calls",
"responses": {
"200": {
"description": "Operation succeeded; refresh or restoration may still be running.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryStatus"
}
}
}
},
"400": {
"description": "Invalid settings.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Invalid or expired token.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Scope or membership denied.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Repository not found.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Repository state changed or operation unavailable.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Rate limited. Respect Retry-After.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"503": {
"description": "Service unavailable.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"patch": {
"operationId": "patchRepositorySettings",
"summary": "Change only supplied settings; terms replaces the entire list",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"terms": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 1000
}
},
"update": {
"type": "boolean"
},
"image": {
"type": "boolean"
},
"pdf": {
"type": "boolean"
},
"notebook": {
"type": "boolean"
},
"link": {
"type": "boolean"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Operation succeeded; refresh or restoration may still be running.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"repoId": {
"type": "string"
},
"settingsSavedAt": {
"type": "string",
"format": "date-time"
}
}
}
}
}
},
"400": {
"description": "Invalid settings.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Invalid or expired token.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Scope or membership denied.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Repository not found.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Repository state changed or operation unavailable.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Rate limited. Respect Retry-After.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"503": {
"description": "Service unavailable.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/repo/{repoId}/refresh": {
"parameters": [
{
"name": "repoId",
"in": "path",
"required": true,
"schema": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]+$"
},
"description": "Existing anonymized repository ID selected when issuing the token."
}
],
"post": {
"operationId": "refreshRepository",
"summary": "Queue refresh of the saved branch. Poll status, not refresh.",
"responses": {
"200": {
"description": "Operation succeeded; refresh or restoration may still be running.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Invalid settings.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Invalid or expired token.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Scope or membership denied.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Repository not found.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Repository state changed or operation unavailable.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Rate limited. Respect Retry-After.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"503": {
"description": "Service unavailable.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"description": "One refresh per repository per minute per account, shared across tokens and IPs. HTTP 429 includes Retry-After."
}
},
"/api/repo/{repoId}/extend": {
"parameters": [
{
"name": "repoId",
"in": "path",
"required": true,
"schema": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]+$"
},
"description": "Existing anonymized repository ID selected when issuing the token."
}
],
"post": {
"operationId": "extendRepository",
"summary": "Extend expiration by six months. Not idempotent; inspect state before retrying.",
"responses": {
"200": {
"description": "Operation succeeded; refresh or restoration may still be running.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"expirationDate": {
"type": "string",
"format": "date-time"
}
}
}
}
}
},
"400": {
"description": "Invalid settings.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Invalid or expired token.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Scope or membership denied.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Repository not found.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Repository state changed or operation unavailable.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Rate limited. Respect Retry-After.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"503": {
"description": "Service unavailable.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
}
}
+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]);
}
+297
View File
@@ -0,0 +1,297 @@
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const express = require("express");
const UserModel = require("../src/core/model/users/users.model").default;
const { bearerTokenAuth, hashToken, maintenanceTokenAllows } = require("../src/server/routes/token-auth");
const { maintenanceSettings } = require("../src/server/routes/maintenance");
const { router: connection } = require("../src/server/routes/connection");
describe("repository maintenance tokens", () => {
let server, base, originalFind, originalUpdate, model, lookups, logins;
before(async () => {
originalFind = UserModel.findOne;
originalUpdate = UserModel.updateOne;
UserModel.findOne = async () => { lookups++; return model; };
UserModel.updateOne = async () => ({ matchedCount: 1 });
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
// An existing cookie must not override the supplied restricted token.
req.user = { username: "cookie-admin" };
req.login = (_user, callback) => { logins++; callback(); };
next();
});
app.use(bearerTokenAuth);
app.use("/github", connection);
app.use((_req, res) => res.json({ reached: true, scoped: !!res.locals.maintenanceToken }));
server = await new Promise(resolve => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); });
base = `http://127.0.0.1:${server.address().port}`;
});
beforeEach(() => {
lookups = logins = 0;
model = { _id: "owner", username: "alice", status: "active", apiTokens: [{
tokenHash: hashToken("secret"), scope: "repository-maintenance", repoIds: ["paper-123"],
expiresAt: new Date(Date.now() + 60_000),
}] };
});
after(async () => {
UserModel.findOne = originalFind;
UserModel.updateOne = originalUpdate;
await new Promise(resolve => server.close(resolve));
});
const request = (path, method = "GET") => globalThis.fetch(base + path, {
method, headers: { authorization: "Bearer secret" },
});
it("allows only the selected repository and explicit maintenance operations", async () => {
for (const [path, method] of [
["/api/maintenance/repositories/paper-123", "GET"],
["/api/maintenance/repositories/paper-123", "PATCH"],
["/api/repo/paper-123/refresh", "POST"], ["/api/repo/paper-123/extend", "POST"],
]) {
const response = await request(path, method);
expect(response.status).to.equal(200);
expect((await response.json()).scoped).to.equal(true);
}
});
it("blocks downloads, previews, source replacement, token minting, and other repos", async () => {
for (const [path, method] of [
["/api/repo/paper-123/file/README.md", "GET"], ["/api/repo/paper-123/zip", "GET"],
["/api/repo/owner/repository/readme", "GET"], ["/api/repo/paper-123", "POST"],
["/api/maintenance/repositories/another", "PATCH"], ["/api/repo/another/refresh", "POST"],
["/api/admin", "GET"], ["/api/user/maintenance-tokens", "POST"],
["/github/login-token", "POST"], ["/w/paper-123/", "GET"],
]) expect((await request(path, method)).status, path).to.equal(403);
});
it("rejects encoded scope bypasses and unexpected methods", () => {
for (const path of ["/api/repo/paper%2D123/refresh", "/api/repo/paper-123/refresh/extra", "/api/repo/paper-123//refresh"]) {
expect(maintenanceTokenAllows("POST", path, ["paper-123"])).to.equal(false);
}
expect(maintenanceTokenAllows("DELETE", "/api/maintenance/repositories/paper-123", ["paper-123"])).to.equal(false);
});
it("rejects expired, revoked, banned, and malformed scoped tokens despite a session", async () => {
model.apiTokens[0].expiresAt = new Date(0);
expect((await request("/api/repo/paper-123/refresh", "POST")).status).to.equal(401);
model.apiTokens[0].expiresAt = undefined;
expect((await request("/api/repo/paper-123/refresh", "POST")).status).to.equal(401);
model.apiTokens[0].expiresAt = new Date(Date.now() + 60_000);
model.status = "banned";
expect((await request("/api/repo/paper-123/refresh", "POST")).status).to.equal(401);
model = null;
expect((await request("/api/repo/paper-123/refresh", "POST")).status).to.equal(401);
expect(lookups).to.equal(4);
});
it("cannot exchange a maintenance token in a body for an unrestricted session", async () => {
const response = await globalThis.fetch(base + "/github/login-token", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: "secret" }) });
expect(response.status).to.equal(401);
expect(logins).to.equal(0);
});
it("rejects malformed authorization instead of falling back to a cookie", async () => {
for (const authorization of ["Bearer", "Basic anything", "Bearer " + "x".repeat(513)]) {
const response = await globalThis.fetch(base + "/api/admin", { headers: { authorization } });
expect(response.status).to.equal(401);
}
expect(lookups).to.equal(0);
});
it("fails closed when authentication storage is unavailable", async () => {
const find = UserModel.findOne;
UserModel.findOne = async () => { throw new Error("database offline"); };
try {
const response = await request("/api/maintenance/repositories/paper-123");
expect(response.status).to.equal(503);
expect(await response.json()).to.deep.equal({ error: "authentication_unavailable" });
} finally { UserModel.findOne = find; }
});
it("preserves legacy unrestricted admin tokens", async () => {
delete model.apiTokens[0].scope;
expect((await request("/api/admin")).status).to.equal(200);
});
});
describe("maintenance settings patch", () => {
it("preserves omitted fields and accepts explicit false and empty terms", () => {
expect(maintenanceSettings({ terms: [], update: false })).to.deep.equal({
"options.terms": [], "options.update": false,
});
});
it("rejects source changes, arbitrary option paths, invalid types and oversized terms", () => {
for (const body of [null, [], {}, { source: {} }, { "options.update": true },
{ image: "false" }, { terms: [1] }, { terms: [""] }, { terms: ["a".repeat(1001)] },
{ terms: Array(1001).fill("a") }, { pageSource: { branch: "other" } }]) {
expect(() => maintenanceSettings(body)).to.throw("invalid_settings");
}
});
});
describe("maintenance routes enforce current membership", () => {
const db = require("../src/server/database");
const RepoModel = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
const maintenance = require("../src/server/routes/maintenance").default;
const tokens = require("../src/server/routes/maintenance-tokens").default;
let server, base, originals, user, repo, writes, tokenWrite;
before(async () => {
originals = [UserModel.findById, UserModel.updateOne, db.getRepository, RepoModel.updateOne];
UserModel.findById = async () => user;
UserModel.updateOne = async (filter, update) => {
tokenWrite = { filter, update };
return { matchedCount: 1, modifiedCount: 1 };
};
db.getRepository = async () => repo;
RepoModel.updateOne = (filter, update) => {
writes.push({ filter, update });
return { exec: async () => ({ matchedCount: 1 }) };
};
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = { user };
req.isAuthenticated = () => req.headers["x-session"] === "yes";
next();
});
app.use("/api/maintenance", maintenance);
app.use("/api/user/maintenance-tokens", tokens);
server = await new Promise(resolve => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); });
base = `http://127.0.0.1:${server.address().port}`;
});
beforeEach(() => {
user = new UserModel({ username: "alice", externalIDs: { github: "123" } });
repo = { repoId: "paper-123", status: "ready", owner: { id: user.id },
assertNotArchived() {}, model: { _id: "repo", status: "ready", statusDate: new Date(),
source: { branch: "main", commit: "abc", accessToken: "never-return" },
options: { terms: ["Alice"], image: true, pdf: true }, coauthors: [],
githubAccess: { secret: "never-return" } } };
writes = []; tokenWrite = null;
});
after(async () => {
[UserModel.findById, UserModel.updateOne, db.getRepository, RepoModel.updateOne] = originals;
await new Promise(resolve => server.close(resolve));
});
function call(path, method = "GET", body, headers = {}) {
return globalThis.fetch(base + path, { method,
headers: { "Content-Type": "application/json", ...headers },
body: body ? JSON.stringify(body) : undefined });
}
it("returns saved state without credentials and marks it uncacheable", async () => {
const response = await call("/api/maintenance/repositories/paper-123");
expect(response.status).to.equal(200);
expect(response.headers.get("cache-control")).to.equal("no-store");
const body = await response.json();
expect(body.source).to.deep.equal({ branch: "main", commit: "abc" });
expect(JSON.stringify(body)).not.to.include("never-return");
});
it("denies reads and writes after membership removal", async () => {
repo.owner.id = "other-owner";
for (const method of ["GET", "PATCH"]) {
expect((await call("/api/maintenance/repositories/paper-123", method,
method === "PATCH" ? { image: false } : undefined)).status).to.equal(403);
}
expect(writes).to.have.length(0);
});
it("lets coauthors patch individual options without replacing the source", async () => {
repo.owner.id = "other-owner";
repo.model.coauthors = [{ githubId: "123" }];
const response = await call("/api/maintenance/repositories/paper-123", "PATCH", { image: false });
expect(response.status).to.equal(200);
expect(writes[0].update.$set["options.image"]).to.equal(false);
expect(Object.keys(writes[0].update.$set)).to.deep.equal(["options.image", "settingsSavedAt"]);
expect(writes[0].filter.status).to.equal("ready");
});
it("rejects settings changes while removal is underway", async () => {
repo.status = "removing";
expect((await call("/api/maintenance/repositories/paper-123", "PATCH", { image: false })).status).to.equal(409);
expect(writes).to.have.length(0);
});
it("issues only hashed, bounded, scoped credentials from a session", async () => {
const response = await call("/api/user/maintenance-tokens", "POST",
{ name: "client", repoIds: ["paper-123"] }, { "x-session": "yes" });
expect(response.status).to.equal(201);
const body = await response.json();
expect(body.token).to.match(/^[a-f0-9]{64}$/);
expect(tokenWrite.update.$push.apiTokens.tokenHash).to.equal(hashToken(body.token));
expect(tokenWrite.update.$push.apiTokens.scope).to.equal("repository-maintenance");
expect(tokenWrite.filter["apiTokens.50"]).to.deep.equal({ $exists: false });
expect(body).not.to.have.property("tokenHash");
});
it("rejects token issuance without session, with bearer, or for unrelated repositories", async () => {
const body = { name: "client", repoIds: ["paper-123"] };
expect((await call("/api/user/maintenance-tokens", "POST", body)).status).to.equal(401);
expect((await call("/api/user/maintenance-tokens", "POST", body,
{ "x-session": "yes", authorization: "Bearer anything" })).status).to.equal(401);
repo.owner.id = "other-owner";
expect((await call("/api/user/maintenance-tokens", "POST", body, { "x-session": "yes" })).status).to.equal(403);
expect(tokenWrite).to.equal(null);
});
it("rejects token lifetimes above 90 days", async () => {
expect((await call("/api/user/maintenance-tokens", "POST",
{ name: "client", repoIds: ["paper-123"], expiresInDays: 91 },
{ "x-session": "yes" })).status).to.equal(400);
expect(tokenWrite).to.equal(null);
});
it("revokes only the signed-in account's maintenance token", async () => {
const id = "507f1f77bcf86cd799439011";
expect((await call(`/api/user/maintenance-tokens/${id}`, "DELETE", undefined,
{ "x-session": "yes" })).status).to.equal(200);
expect(String(tokenWrite.filter._id)).to.equal(user.id);
expect(tokenWrite.update.$pull.apiTokens).to.deep.equal({ _id: id, scope: "repository-maintenance" });
});
});
describe("maintenance abuse limits", () => {
const { rateLimit } = require("express-rate-limit");
const { tokenAttemptLimitOptions, maintenanceRefreshLimitOptions } = require("../src/server/routes/maintenance-limits");
let server, base, originals, lookups, model, reached;
beforeEach(async () => {
originals = [UserModel.findOne, UserModel.updateOne];
UserModel.findOne = async () => { lookups++; return model; };
UserModel.updateOne = async () => ({ matchedCount: 1 });
const app = express();
app.use(rateLimit({ ...tokenAttemptLimitOptions, limit: 8 }));
app.use(bearerTokenAuth);
app.use(rateLimit(maintenanceRefreshLimitOptions));
app.use((_req, res) => { reached++; res.json({ ok: true }); });
server = await new Promise(resolve => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); });
base = `http://127.0.0.1:${server.address().port}`;
model = { _id: "owner", status: "active", apiTokens: ["one", "two"].map(token => ({
tokenHash: hashToken(token), scope: "repository-maintenance", repoIds: ["paper-123", "Paper-123"],
expiresAt: new Date(Date.now() + 60_000),
})) };
lookups = reached = 0;
});
afterEach(async () => {
[UserModel.findOne, UserModel.updateOne] = originals;
await new Promise(resolve => server.close(resolve));
});
it("shares refresh cooldown across tokens, casing and trailing slashes; allows polling", async () => {
const call = (path, token, method = "POST") => globalThis.fetch(base + path, {
method, headers: { authorization: `Bearer ${token}` },
});
expect((await call("/api/repo/paper-123/refresh", "one")).status).to.equal(200);
const denied = await call("/api/repo/Paper-123/refresh/", "two");
expect(denied.status).to.equal(429);
expect(Number(denied.headers.get("retry-after"))).to.be.greaterThan(0);
expect(await denied.json()).to.deep.equal({ error: "refresh_rate_limited" });
expect((await call("/api/maintenance/repositories/paper-123", "two", "GET")).status).to.equal(200);
// A removed coauthor's requests cannot consume another account's cooldown.
model._id = "another-owner";
expect((await call("/api/repo/paper-123/refresh", "one")).status).to.equal(200);
});
it("charges failed authentication before MongoDB and never reaches a route", async () => {
model = null;
const beforeRoutes = reached;
for (let i = 0; i < 8; i++) {
expect((await globalThis.fetch(base + "/api/admin", {
headers: { authorization: "Bearer invalid" },
})).status).to.equal(401);
}
const beforeLookups = lookups;
const response = await globalThis.fetch(base + "/api/admin", {
headers: { authorization: "Bearer invalid" },
});
expect(response.status).to.equal(429);
expect(await response.json()).to.deep.equal({ error: "token_attempts_rate_limited" });
expect(lookups).to.equal(beforeLookups);
expect(reached).to.equal(beforeRoutes);
// Anonymous review traffic does not use the bearer authentication budget.
expect((await globalThis.fetch(base + "/")).status).to.equal(200);
});
});