mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
docs(api): publish the document picker routes as cookie-only, and freeze them as owner-only
This commit is contained in:
1 parent
c840d1012a
commit
caf3eb323d
4 files changed
+1181
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
// 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).
|
||||
//
|
||||
|
||||
Reference in new issue
Block a user