diff --git a/README.md b/README.md index 5df6034..0aeece3 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/public/i18n/locale-en.json b/public/i18n/locale-en.json index a8284a5..dee6ad3 100644 --- a/public/i18n/locale-en.json +++ b/public/i18n/locale-en.json @@ -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.", diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 0000000..7291787 --- /dev/null +++ b/public/llms.txt @@ -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 . 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. diff --git a/public/maintenance-openapi.json b/public/maintenance-openapi.json new file mode 100644 index 0000000..00c90d0 --- /dev/null +++ b/public/maintenance-openapi.json @@ -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" + } + } + } + } + } + } + } + } +} diff --git a/src/core/model/users/users.schema.ts b/src/core/model/users/users.schema.ts index 348597d..aa27ec4 100644 --- a/src/core/model/users/users.schema.ts +++ b/src/core/model/users/users.schema.ts @@ -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, diff --git a/src/core/model/users/users.types.ts b/src/core/model/users/users.types.ts index fc0ada6..c2d23e9 100644 --- a/src/core/model/users/users.types.ts +++ b/src/core/model/users/users.types.ts @@ -19,6 +19,9 @@ export interface IUser { name?: string; createdAt?: Date; lastUsedAt?: Date; + scope?: "repository-maintenance"; + repoIds?: string[]; + expiresAt?: Date; }[]; notificationEmail?: string; emailPromptNever?: boolean; diff --git a/src/server/index.ts b/src/server/index.ts index 59e87d9..ace551c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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); diff --git a/src/server/routes/connection.ts b/src/server/routes/connection.ts index 57339a1..c7ac330 100644 --- a/src/server/routes/connection.ts +++ b/src/server/routes/connection.ts @@ -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", diff --git a/src/server/routes/maintenance-limits.ts b/src/server/routes/maintenance-limits.ts new file mode 100644 index 0000000..a6cb115 --- /dev/null +++ b/src/server/routes/maintenance-limits.ts @@ -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 = { + 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 = { + 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" }, +}; diff --git a/src/server/routes/maintenance-tokens.ts b/src/server/routes/maintenance-tokens.ts new file mode 100644 index 0000000..f091f76 --- /dev/null +++ b/src/server/routes/maintenance-tokens.ts @@ -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(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; diff --git a/src/server/routes/maintenance.ts b/src/server/routes/maintenance.ts new file mode 100644 index 0000000..89f047d --- /dev/null +++ b/src/server/routes/maintenance.ts @@ -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 { + 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 = {}; + 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; diff --git a/src/server/routes/token-auth.ts b/src/server/routes/token-auth.ts index 313d0ae..5dcb5a3 100644 --- a/src/server/routes/token-auth.ts +++ b/src/server/routes/token-auth.ts @@ -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 { - 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]); +} diff --git a/test/maintenance-api.test.js b/test/maintenance-api.test.js new file mode 100644 index 0000000..e893607 --- /dev/null +++ b/test/maintenance-api.test.js @@ -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); + }); +});