From caf3eb323d3a618ebd73d6b75c51abfb7009adc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 13:13:51 +0200 Subject: [PATCH] docs(api): publish the document picker routes as cookie-only, and freeze them as owner-only --- docs/api/openapi.yaml | 668 ++++++++++++++++++ .../document-sources-owner-only-guard.test.ts | 106 +++ src/lib/openapi/routes/document-sources.ts | 402 +++++++++++ src/lib/openapi/routes/index.ts | 5 + 4 files changed, 1181 insertions(+) create mode 100644 src/__tests__/document-sources-owner-only-guard.test.ts create mode 100644 src/lib/openapi/routes/document-sources.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89ecc1799..0827581f2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20966,6 +20966,365 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/documents/sources: + get: + tags: + - Documents + summary: "Document archives: availability and connections" + description: "Whether the operator enabled the document picker (`available`, true when `DOCUMENT_SOURCE_ORIGINS` lists + at least one origin), the listed origins, and the caller's own saved connections to Paperless-ngx or Papra. A + connection never carries its token: `hasToken` is always true for a saved one, and no route returns the token in + whole or in part. `originAllowed` is false once the operator removed the connection's origin from the list. No + request leaves the server. Cookie session only; not delegable." + security: &a52 + - cookieAuth: [] + responses: + "200": + description: The picker's state for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" + "401": *a1 + "403": &a53 + description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, + `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the + owner's archive), or `module.disabled` when the Documents module is off." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /api/documents/sources/{system}: + put: + tags: + - Documents + summary: Save a connection to a document archive + description: "Saves or replaces the caller's connection to Paperless-ngx (`paperless`: base address and API token) or + Papra (`papra`: base address, organization id, API key with `documents:read` and `tags:read`). The base address + may carry a path; its origin must be listed in `DOCUMENT_SOURCE_ORIGINS`. The connection is tested live before + anything is stored. Leaving out `token` keeps the stored one (the first save must carry it). The token is stored + encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not + delegable." + security: *a52 + parameters: + - &a54 + name: system + in: path + required: true + description: "`paperless` (Paperless-ngx) or `papra` (Papra)." + schema: + type: string + enum: + - paperless + - papra + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + baseUrl: + type: string + minLength: 1 + maxLength: 2048 + organizationId: + type: string + minLength: 1 + maxLength: 128 + pattern: ^[\x21-\x7e]+$ + token: + type: string + minLength: 1 + maxLength: 512 + pattern: ^[\x21-\x7e]+$ + required: + - baseUrl + responses: + "200": + description: Saved and verified. The connection, without its token. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" + "401": *a1 + "403": *a53 + "404": &a55 + description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so + the picker is off. `documents.sources.notConnected`: no connection is saved for this system. + `documents.sources.notFound`: the source has no such document, or (Papra) no such organization." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "`documents.sources.originNotAllowed`, `documents.sources.invalidAddress`, + `documents.sources.organizationRequired` (Papra), `documents.sources.tokenRequired` (first save without a + token), or body validation." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 + "502": &a56 + description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at + all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` + (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source + answered with a redirect, which is never followed), `documents.sources.authRefused` (401/403 from the + source), `documents.sources.versionTooOld` (Paperless-ngx older than 2.16 / API version 9), + `documents.sources.badResponse` (not the documented JSON, or larger than 1 MiB)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + delete: + tags: + - Documents + summary: Remove a connection to a document archive + description: Deletes the caller's connection for this system, and its token with it. Works even after the operator + removed the origin from the list. `disconnected` is false when there was nothing to delete. Cookie session only; + not delegable. + security: *a52 + parameters: + - *a54 + responses: + "200": + description: Removed, or there was nothing to remove. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" + "401": *a1 + "403": *a53 + "422": *a4 + "429": *a5 + /api/documents/sources/{system}/test: + post: + tags: + - Documents + summary: Test a saved connection + description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps + `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session + only; not delegable. + security: *a52 + parameters: + - *a54 + responses: + "200": + description: The source answered. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSourceTestEnvelope" + "401": *a1 + "403": *a53 + "404": *a55 + "422": &a57 + description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on + every request, so a connection saved earlier is cut off once its origin is removed), or another validation + failure." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 + "502": *a56 + /api/documents/sources/{system}/tags: + get: + tags: + - Documents + summary: Tags of a connected archive + description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the + search allowance of sixty a minute. Cookie session only; not delegable. + security: *a52 + parameters: + - *a54 + responses: + "200": + description: The tags. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSourceTagsEnvelope" + "401": *a1 + "403": *a53 + "404": *a55 + "422": *a57 + "429": *a5 + "502": *a56 + /api/documents/sources/{system}/search: + get: + tags: + - Documents + summary: Search a connected archive by name, tag and date + description: "Searches the source 25 documents at a time. `q` matches the document's name (Paperless-ngx title, Papra + name); `tag` narrows to one of the source's tags by id; `from` / `to` to a date range, inclusive (Papra's range + is applied to each page after it arrives, so a page can hold fewer than 25). Each result carries what the vault + already holds under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` + (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared + with the tag read. Cookie session only; not delegable." + security: *a52 + parameters: + - *a54 + - name: q + in: query + required: false + schema: + type: string + maxLength: 200 + - name: tag + in: query + required: false + schema: + type: string + maxLength: 64 + - name: from + in: query + required: false + schema: + type: string + format: date + - name: to + in: query + required: false + schema: + type: string + format: date + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 500 + default: 1 + responses: + "200": + description: One page of results. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSourceSearchEnvelope" + "401": *a1 + "403": *a53 + "404": *a55 + "422": *a57 + "429": *a5 + "502": *a56 + /api/documents/sources/{system}/import: + post: + tags: + - Documents + summary: Import one picked document + description: "Fetches one document from the source and stores it in the vault through the same path as an upload, with + `sourceSystem` / `sourceId` provenance. A source key the vault already holds is answered without a download: + `duplicate` for a live document (same id), `deleted` for one the person deleted in HealthLog, which is never + stored again. Bytes already in the vault under another key are `duplicate` too, and the key is remembered for + that document. `link` files the document against the record the picker was opened from (a condition, a visit or + a vaccination, which must be the caller's own and live; checked before anything is fetched), including a + duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. + Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 + otherwise. Cookie session only; not delegable." + security: *a52 + parameters: + - *a54 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + sourceId: + type: string + minLength: 1 + maxLength: 128 + pattern: ^[\x21-\x7e]+$ + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - REFERRAL + - INSURANCE + - VACCINATION + - OTHER + link: + type: object + properties: + kind: + type: string + enum: + - conditionEpisode + - encounter + - vaccination + id: + type: string + minLength: 1 + maxLength: 64 + required: + - kind + - id + required: + - sourceId + responses: + "200": + description: Already held (`duplicate`) or deleted in HealthLog (`deleted`). + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSourceImportHeldEnvelope" + "201": + description: Imported. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSourceImportEnvelope" + "401": *a1 + "403": *a53 + "404": + description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so + the picker is off. `documents.sources.notConnected`: no connection is saved for this system. + `documents.sources.notFound`: the source has no such document, or (Papra) no such organization. + `documents.sources.linkTargetNotFound`: the record in `link` is not the caller's own live conditionEpisode / + encounter / vaccination." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "`documents.inbound.sourceAliasLimit`: these bytes are already stored under too many source ids." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: "`documents.inbound.fileTooLarge` (with `maxFileBytes`) or `documents.inbound.quotaExceeded` (with + `quotaBytes` and `usedBytes`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: "`documents.inbound.fileType`: the file is not a type the vault accepts." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a57 + "429": + description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or + `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "502": *a56 /api/bugreport: post: security: [] @@ -72408,6 +72767,315 @@ components: - data - error additionalProperties: false + DocumentSourcesStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentSourcesStatus" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentSourcesStatus: + type: object + properties: + available: + type: boolean + allowedOrigins: + type: array + items: + type: string + connections: + type: array + items: + $ref: "#/components/schemas/DocumentSourceConnection" + required: + - available + - allowedOrigins + - connections + additionalProperties: false + DocumentSourceConnection: + type: object + properties: + system: + type: string + enum: + - PAPERLESS + - PAPRA + baseUrl: + type: string + organizationId: + anyOf: + - type: string + - type: "null" + hasToken: + type: boolean + const: true + lastVerifiedAt: + anyOf: + - type: string + - type: "null" + originAllowed: + type: boolean + required: + - system + - baseUrl + - organizationId + - hasToken + - lastVerifiedAt + - originAllowed + additionalProperties: false + DocumentSourceConnectionEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentSourceConnection" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentSourceDisconnectEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentSourceDisconnect" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentSourceDisconnect: + type: object + properties: + disconnected: + type: boolean + required: + - disconnected + additionalProperties: false + DocumentSourceTestEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentSourceTest" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentSourceTest: + type: object + properties: + ok: + type: boolean + const: true + latencyMs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + lastVerifiedAt: + type: string + required: + - ok + - latencyMs + - lastVerifiedAt + additionalProperties: false + DocumentSourceTagsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentSourceTags" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentSourceTags: + type: object + properties: + tags: + type: array + items: + $ref: "#/components/schemas/DocumentSourceTag" + required: + - tags + additionalProperties: false + DocumentSourceTag: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + additionalProperties: false + DocumentSourceSearchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentSourceSearch" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentSourceSearch: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/DocumentSourceResult" + page: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + hasMore: + type: boolean + required: + - results + - page + - hasMore + additionalProperties: false + DocumentSourceResult: + type: object + properties: + sourceId: + type: string + title: + type: string + date: + anyOf: + - type: string + - type: "null" + tags: + type: array + items: + type: string + sizeBytes: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + state: + type: string + enum: + - new + - imported + - deleted + documentId: + anyOf: + - type: string + - type: "null" + required: + - sourceId + - title + - date + - tags + - sizeBytes + - state + - documentId + additionalProperties: false + DocumentSourceImportHeldEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentSourceImport" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentSourceImport: + type: object + properties: + outcome: + type: string + enum: + - imported + - duplicate + - deleted + documentId: + anyOf: + - type: string + - type: "null" + linked: + type: boolean + required: + - outcome + - documentId + - linked + additionalProperties: false + DocumentSourceImportEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentSourceImport" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false __schema0: anyOf: - type: string diff --git a/src/__tests__/document-sources-owner-only-guard.test.ts b/src/__tests__/document-sources-owner-only-guard.test.ts new file mode 100644 index 000000000..a61741026 --- /dev/null +++ b/src/__tests__/document-sources-owner-only-guard.test.ts @@ -0,0 +1,106 @@ +/** + * The document picker's routes (#1038) are owner-only, cookie-only, and stay + * that way. + * + * A connection is the owner's credential to a system outside HealthLog. A + * delegate or guardian driving it would read the owner's whole Paperless-ngx or + * Papra archive, which no grant covers; a Bearer (the native client, a script) + * has no business managing it either. The rule is in two halves, and this file + * freezes both at every route module under `src/app/api/documents/sources`: + * + * 1. the module calls a bare `requireAuth()` itself (no scope, so a narrow + * token is refused, and a request acting on another record is refused + * 403 `sharing.not_permitted`), and names none of the resolvers that + * would admit a delegate, a guardian, or an actor surface; + * 2. every handler passes the result through `admitDocumentSourceCaller`, + * which refuses any transport but the cookie. + * + * The scan asserts it found the modules it expects, so an empty directory or a + * moved tree fails rather than passing on nothing. + * + * Mutation checks: swap one route's `requireAuth()` for + * `requireRecordAuth("write", "documents")`, or drop its + * `admitDocumentSourceCaller` call, and this file fails naming that module. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { stripComments } from "./helpers/source-files"; + +const ROOT = join(process.cwd(), "src/app/api/documents/sources"); + +function routeModules(): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name === "route.ts") out.push(full); + } + }; + walk(ROOT); + return out.map((file) => relative(ROOT, file)).sort(); +} + +const EXPECTED = [ + "[system]/import/route.ts", + "[system]/route.ts", + "[system]/search/route.ts", + "[system]/tags/route.ts", + "[system]/test/route.ts", + "route.ts", +]; + +describe("document-source routes are owner-only and cookie-only", () => { + const modules = routeModules(); + + it("finds exactly the route modules it freezes", () => { + expect(modules).toEqual(EXPECTED); + }); + + it.each(EXPECTED)( + "%s resolves a bare requireAuth and no wider resolver", + (rel) => { + const source = stripComments(readFileSync(join(ROOT, rel), "utf8")); + expect(source).toMatch(/requireAuth\(\s*\)/); + expect(source).not.toMatch(/requireAuth\(\s*[^)\s]/); + for (const wider of [ + "requireRecordAuth", + "requireGuardianAuth", + "requireActorAuth", + "requireCookieAuth", + "requireBearerAuth", + ]) { + expect(source, `${rel} names ${wider}`).not.toContain(wider); + } + }, + ); + + it.each(EXPECTED)( + "%s admits every handler through the cookie check", + (rel) => { + const source = stripComments(readFileSync(join(ROOT, rel), "utf8")); + const handlers = source.match( + /export const (GET|POST|PUT|PATCH|DELETE)\b/g, + ); + expect(handlers?.length ?? 0).toBeGreaterThan(0); + const auths = source.match(/await requireAuth\(\s*\)/g) ?? []; + const admits = source.match(/await admitDocumentSourceCaller\(/g) ?? []; + expect(auths.length).toBe(handlers!.length); + expect(admits.length).toBe(handlers!.length); + }, + ); + + it("the cookie check refuses every transport but the cookie", () => { + const support = stripComments( + readFileSync( + join(process.cwd(), "src/lib/documents/sources/route-support.ts"), + "utf8", + ), + ); + expect(support).toContain('auth.authMethod !== "cookie"'); + expect(support).toContain('"documents.sources.browserOnly"'); + }); +}); diff --git a/src/lib/openapi/routes/document-sources.ts b/src/lib/openapi/routes/document-sources.ts new file mode 100644 index 000000000..e25c0b813 --- /dev/null +++ b/src/lib/openapi/routes/document-sources.ts @@ -0,0 +1,402 @@ +/** + * OpenAPI route table for the document picker (`/api/documents/sources/*`, + * #1038): a person's connection to Paperless-ngx or Papra, searching it, and + * importing picked documents into the vault. + * + * Every operation is cookie-only (declared per operation as `cookieAuth`): a + * Bearer, even a `["*"]` one, is refused 403 `documents.sources.browserOnly`, + * and so is a request acting on another person's record. The native client does + * not use these routes. Request bodies reuse the runtime Zod schemas. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + DOCUMENT_PICKER_LINK_KINDS, + DOCUMENT_PICKER_MAX_PAGE, + DOCUMENT_PICKER_QUERY_MAX, + DOCUMENT_PICKER_SYSTEMS, +} from "@/lib/documents/sources/types"; +import { + documentSourceImportSchema, + documentSourceSaveSchema, +} from "@/lib/validations/document-sources"; + +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +const cookieOnly = [{ cookieAuth: [] }]; + +const systemParameter = { + name: "system", + in: "path" as const, + required: true, + description: "`paperless` (Paperless-ngx) or `papra` (Papra).", + schema: { type: "string" as const, enum: ["paperless", "papra"] }, +}; + +const systemEnum = z.enum(DOCUMENT_PICKER_SYSTEMS); + +const connectionSchema = z + .object({ + system: systemEnum, + baseUrl: z.string(), + organizationId: z.string().nullable(), + hasToken: z.literal(true), + lastVerifiedAt: z.string().nullable(), + originAllowed: z.boolean(), + }) + .meta({ id: "DocumentSourceConnection" }); + +const statusSchema = z + .object({ + available: z.boolean(), + allowedOrigins: z.array(z.string()), + connections: z.array(connectionSchema), + }) + .meta({ id: "DocumentSourcesStatus" }); + +const tagSchema = z + .object({ id: z.string(), name: z.string() }) + .meta({ id: "DocumentSourceTag" }); + +const resultSchema = z + .object({ + sourceId: z.string(), + title: z.string(), + date: z.string().nullable(), + tags: z.array(z.string()), + sizeBytes: z.number().int().nullable(), + state: z.enum(["new", "imported", "deleted"]), + documentId: z.string().nullable(), + }) + .meta({ id: "DocumentSourceResult" }); + +const searchSchema = z + .object({ + results: z.array(resultSchema), + page: z.number().int(), + hasMore: z.boolean(), + }) + .meta({ id: "DocumentSourceSearch" }); + +const importResultSchema = z + .object({ + outcome: z.enum(["imported", "duplicate", "deleted"]), + documentId: z.string().nullable(), + linked: z.boolean(), + }) + .meta({ id: "DocumentSourceImport" }); + +const refusal = { + description: + "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the Documents module is off.", + content: { "application/json": { schema: errorEnvelope } }, +}; + +const unavailable = { + description: + "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization.", + content: { "application/json": { schema: errorEnvelope } }, +}; + +const upstream = { + description: + "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source answered with a redirect, which is never followed), `documents.sources.authRefused` (401/403 from the source), `documents.sources.versionTooOld` (Paperless-ngx older than 2.16 / API version 9), `documents.sources.badResponse` (not the documented JSON, or larger than 1 MiB).", + content: { "application/json": { schema: errorEnvelope } }, +}; + +const originRefusal = { + description: + "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure.", + content: { "application/json": { schema: errorEnvelope } }, +}; + +export const documentSourcePaths: NonNullable = { + "/api/documents/sources": { + get: { + tags: ["Documents"], + summary: "Document archives: availability and connections", + description: + "Whether the operator enabled the document picker (`available`, true when `DOCUMENT_SOURCE_ORIGINS` lists at least one origin), the listed origins, and the caller's own saved connections to Paperless-ngx or Papra. A connection never carries its token: `hasToken` is always true for a saved one, and no route returns the token in whole or in part. `originAllowed` is false once the operator removed the connection's origin from the list. No request leaves the server. Cookie session only; not delegable.", + security: cookieOnly, + responses: { + "200": { + description: "The picker's state for the caller.", + content: { + "application/json": { + schema: dataEnvelope( + statusSchema, + "DocumentSourcesStatusEnvelope", + ), + }, + }, + }, + "403": refusal, + ...stdResponses, + }, + }, + }, + "/api/documents/sources/{system}": { + put: { + tags: ["Documents"], + summary: "Save a connection to a document archive", + description: + "Saves or replaces the caller's connection to Paperless-ngx (`paperless`: base address and API token) or Papra (`papra`: base address, organization id, API key with `documents:read` and `tags:read`). The base address may carry a path; its origin must be listed in `DOCUMENT_SOURCE_ORIGINS`. The connection is tested live before anything is stored. Leaving out `token` keeps the stored one (the first save must carry it). The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable.", + security: cookieOnly, + parameters: [systemParameter], + requestBody: { + required: true, + content: { "application/json": { schema: documentSourceSaveSchema } }, + }, + responses: { + "200": { + description: "Saved and verified. The connection, without its token.", + content: { + "application/json": { + schema: dataEnvelope( + connectionSchema, + "DocumentSourceConnectionEnvelope", + ), + }, + }, + }, + "403": refusal, + "404": unavailable, + "422": { + description: + "`documents.sources.originNotAllowed`, `documents.sources.invalidAddress`, `documents.sources.organizationRequired` (Papra), `documents.sources.tokenRequired` (first save without a token), or body validation.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "502": upstream, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, + delete: { + tags: ["Documents"], + summary: "Remove a connection to a document archive", + description: + "Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable.", + security: cookieOnly, + parameters: [systemParameter], + responses: { + "200": { + description: "Removed, or there was nothing to remove.", + content: { + "application/json": { + schema: dataEnvelope( + z + .object({ disconnected: z.boolean() }) + .meta({ id: "DocumentSourceDisconnect" }), + "DocumentSourceDisconnectEnvelope", + ), + }, + }, + }, + "403": refusal, + ...stdResponses, + }, + }, + }, + "/api/documents/sources/{system}/test": { + post: { + tags: ["Documents"], + summary: "Test a saved connection", + description: + "One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable.", + security: cookieOnly, + parameters: [systemParameter], + responses: { + "200": { + description: "The source answered.", + content: { + "application/json": { + schema: dataEnvelope( + z + .object({ + ok: z.literal(true), + latencyMs: z.number().int(), + lastVerifiedAt: z.string(), + }) + .meta({ id: "DocumentSourceTest" }), + "DocumentSourceTestEnvelope", + ), + }, + }, + }, + "403": refusal, + "404": unavailable, + "422": originRefusal, + "502": upstream, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, + }, + "/api/documents/sources/{system}/tags": { + get: { + tags: ["Documents"], + summary: "Tags of a connected archive", + description: + "The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable.", + security: cookieOnly, + parameters: [systemParameter], + responses: { + "200": { + description: "The tags.", + content: { + "application/json": { + schema: dataEnvelope( + z + .object({ tags: z.array(tagSchema) }) + .meta({ id: "DocumentSourceTags" }), + "DocumentSourceTagsEnvelope", + ), + }, + }, + }, + "403": refusal, + "404": unavailable, + "422": originRefusal, + "502": upstream, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, + }, + "/api/documents/sources/{system}/search": { + get: { + tags: ["Documents"], + summary: "Search a connected archive by name, tag and date", + description: + "Searches the source 25 documents at a time. `q` matches the document's name (Paperless-ngx title, Papra name); `tag` narrows to one of the source's tags by id; `from` / `to` to a date range, inclusive (Papra's range is applied to each page after it arrives, so a page can hold fewer than 25). Each result carries what the vault already holds under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable.", + security: cookieOnly, + parameters: [ + systemParameter, + { + name: "q", + in: "query", + required: false, + schema: { type: "string", maxLength: DOCUMENT_PICKER_QUERY_MAX }, + }, + { + name: "tag", + in: "query", + required: false, + schema: { type: "string", maxLength: 64 }, + }, + { + name: "from", + in: "query", + required: false, + schema: { type: "string", format: "date" }, + }, + { + name: "to", + in: "query", + required: false, + schema: { type: "string", format: "date" }, + }, + { + name: "page", + in: "query", + required: false, + schema: { + type: "integer", + minimum: 1, + maximum: DOCUMENT_PICKER_MAX_PAGE, + default: 1, + }, + }, + ], + responses: { + "200": { + description: "One page of results.", + content: { + "application/json": { + schema: dataEnvelope( + searchSchema, + "DocumentSourceSearchEnvelope", + ), + }, + }, + }, + "403": refusal, + "404": unavailable, + "422": originRefusal, + "502": upstream, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, + }, + "/api/documents/sources/{system}/import": { + post: { + tags: ["Documents"], + summary: "Import one picked document", + description: + "Fetches one document from the source and stores it in the vault through the same path as an upload, with `sourceSystem` / `sourceId` provenance. A source key the vault already holds is answered without a download: `duplicate` for a live document (same id), `deleted` for one the person deleted in HealthLog, which is never stored again. Bytes already in the vault under another key are `duplicate` too, and the key is remembered for that document. `link` files the document against the record the picker was opened from (a condition, a visit or a vaccination, which must be the caller's own and live; checked before anything is fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable.", + security: cookieOnly, + parameters: [systemParameter], + requestBody: { + required: true, + content: { + "application/json": { schema: documentSourceImportSchema }, + }, + }, + responses: { + "201": { + description: "Imported.", + content: { + "application/json": { + schema: dataEnvelope( + importResultSchema, + "DocumentSourceImportEnvelope", + ), + }, + }, + }, + "200": { + description: + "Already held (`duplicate`) or deleted in HealthLog (`deleted`).", + content: { + "application/json": { + schema: dataEnvelope( + importResultSchema, + "DocumentSourceImportHeldEnvelope", + ), + }, + }, + }, + "403": refusal, + "404": { + description: `${unavailable.description} \`documents.sources.linkTargetNotFound\`: the record in \`link\` is not the caller's own live ${DOCUMENT_PICKER_LINK_KINDS.join(" / ")}.`, + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "`documents.inbound.sourceAliasLimit`: these bytes are already stored under too many source ids.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: + "`documents.inbound.fileTooLarge` (with `maxFileBytes`) or `documents.inbound.quotaExceeded` (with `quotaBytes` and `usedBytes`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: + "`documents.inbound.fileType`: the file is not a type the vault accepts.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": originRefusal, + "502": upstream, + "401": stdResponses["401"], + "429": { + description: + "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index c1a5a0a21..377f5fb6c 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -50,6 +50,7 @@ import { cyclePaths } from "./cycle"; import { dashboardWidgetPaths } from "./dashboard"; import { biomarkerPaths } from "./biomarkers"; import { inboundDocumentPaths } from "./documents"; +import { documentSourcePaths } from "./document-sources"; import { devicePaths } from "./devices"; import { exportPaths } from "./export"; import { familyHistoryPaths } from "./family-history"; @@ -170,6 +171,10 @@ export const openApiPaths: NonNullable = { // discover: a copy this instance can no longer decrypt (appended, spread // order is load-bearing). ...adminBackupPaths, + // v1.39.3 — the document picker: a person's connection to Paperless-ngx or + // Papra, searching it and importing picked documents; cookie-only + // (appended, spread order is load-bearing). + ...documentSourcePaths, // The environmental-context overview. Its own module because nothing else // owns the surface (appended, spread order is load-bearing). //