mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-15 08:46:58 +01:00
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:
1 parent
6cf5445972
commit
6099356e0e
13 files changed
+1219
-17
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user