mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
feat(documents): add a document upload token for imports from other systems
A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.
Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.
The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.
Refs #1038, #938
This commit is contained in:
1 parent
af65f01fec
commit
55a32ff7d1
45 files changed
+2261
-96
No files matched your search
@@ -312,7 +312,10 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"source-priority.body.invalid_json",
|
||||
"source-priority.body.invalid_shape",
|
||||
],
|
||||
tokens: ["tokens.measurements.ceiling_reached"],
|
||||
tokens: [
|
||||
"tokens.documents.ceiling_reached",
|
||||
"tokens.measurements.ceiling_reached",
|
||||
],
|
||||
vaccination: [
|
||||
"vaccination.booster-invalid",
|
||||
"vaccination.booster-no-antigen",
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
mfaWebauthnLoginVerifySchema,
|
||||
} from "@/lib/validations/mfa";
|
||||
import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
import { createMeasurementTokenSchema } from "@/lib/validations/tokens";
|
||||
import {
|
||||
createDocumentTokenSchema,
|
||||
createMeasurementTokenSchema,
|
||||
} from "@/lib/validations/tokens";
|
||||
import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff";
|
||||
import {
|
||||
stepUpMintSchema,
|
||||
@@ -436,6 +439,28 @@ const createMeasurementTokenResponse = z
|
||||
"The one response that carries a usable measurement-ingest Bearer value.",
|
||||
});
|
||||
|
||||
const createDocumentTokenRequest = createDocumentTokenSchema.meta({
|
||||
id: "CreateDocumentTokenRequest",
|
||||
description:
|
||||
"Mint a document-upload Bearer. No `scope` field: the endpoint mints one shape. `name` is what the token is listed under; name it after the system it is pasted into. `expiresInDays` defaults to 365.",
|
||||
});
|
||||
|
||||
const createDocumentTokenResponse = z
|
||||
.object({
|
||||
token: z
|
||||
.string()
|
||||
.describe(
|
||||
"The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash and cannot be retrieved later. Treat it as a secret.",
|
||||
),
|
||||
name: z.string(),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "CreateDocumentTokenResponse",
|
||||
description:
|
||||
"The one response that carries a usable document-upload Bearer value.",
|
||||
});
|
||||
|
||||
// ── Registration, credential rotation, Codex device-auth ─────────────
|
||||
|
||||
const registerRequest = registerSchema.meta({
|
||||
@@ -1536,7 +1561,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "List the caller's API tokens",
|
||||
description:
|
||||
"Every `ApiToken` row belonging to the caller, newest first, revoked ones included — presence in this list is not validity, `revoked` is. The hash is never returned and there is no path that re-reveals a token's plaintext.\n\n" +
|
||||
"Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no GENERIC mint any more — the POST at this exact path issued `[\"medication:ingest\"]` and was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The credentials that work are minted where they are scoped: the per-medication API-endpoint toggle, `POST /api/mcp/tokens` for a connector, and the sibling `POST /api/tokens/measurements` for third-party measurement ingest. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them.",
|
||||
"Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no GENERIC mint any more — the POST at this exact path issued `[\"medication:ingest\"]` and was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The credentials that work are minted where they are scoped: the per-medication API-endpoint toggle, `POST /api/mcp/tokens` for a connector, the sibling `POST /api/tokens/measurements` for third-party measurement ingest, and `POST /api/tokens/documents` for document upload from another system. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's tokens, newest first.",
|
||||
@@ -1620,6 +1645,70 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/tokens/documents": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Mint a document-upload token",
|
||||
description:
|
||||
"Mints a Bearer scoped to exactly `documents:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN, once; it is stored as an HMAC and no path re-reveals it.\n\n" +
|
||||
"**What it can do.** `POST /api/documents/inbound` on its owner's own record, and nothing else. It is the door another document system pushes through: a Paperless-ngx workflow posting a newly tagged document, or an import script copying an archive. Uploads through it draw on a bucket of their own, keyed on the token (default 120 an hour, operator-tunable via `DOCUMENT_UPLOAD_LIMIT_PER_HOUR`), and get back a receipt (`id`, `duplicate`, and `deleted` when the source key belongs to a document the owner deleted) rather than the stored row.\n\n" +
|
||||
"**What it cannot do.** List, read, download, preview, edit, delete or bulk-act on a document, reach the document-AI routes, act on a shared record, or mint another token. Every other route names no scope and refuses it 403.\n\n" +
|
||||
"Minting requires a COOKIE SESSION, for the reason the measurement mint gives: a day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live document tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: createDocumentTokenRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description:
|
||||
"Token minted. The `token` field is the only copy of the secret.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
createDocumentTokenResponse,
|
||||
"CreateDocumentTokenEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "Body is not parseable JSON.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator has switched the API off instance-wide. Nothing was minted. A Bearer caller is refused 401 before the switch is read.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Body exceeds 16 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"415": {
|
||||
description: "Content-Type is not `application/json`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Validation failed. The envelope carries every offending issue.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 10 mints from this account in a minute. Nothing was minted.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The account already holds 10 live document tokens (`meta.errorCode` = `tokens.documents.ceiling_reached`). Nothing was minted. Revoke one at `DELETE /api/tokens/{id}`; only live tokens carrying `documents:write` count.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/tokens/{id}": {
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
documentUpdateSchema,
|
||||
inboundConfirmSchema,
|
||||
inboundFactEditSchema,
|
||||
DOCUMENT_SOURCE_ID_MAX,
|
||||
DOCUMENT_SOURCE_SYSTEMS,
|
||||
DOCUMENT_SUMMARY_STATES,
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
INBOUND_DOCUMENT_STATUSES,
|
||||
@@ -204,13 +206,15 @@ const inboundDocument = z
|
||||
])
|
||||
.nullable(),
|
||||
hasThumbnail: z.boolean(),
|
||||
sourceSystem: z.enum(DOCUMENT_SOURCE_SYSTEMS).nullable(),
|
||||
sourceId: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "InboundDocument",
|
||||
description:
|
||||
"A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was produced — `vision` means an AI provider read the original, the other values are provider-free local extractions, and it is null when `hasContentIndex` is false. `lastIndexAttemptAt` is when the most recent index attempt finished (successful or not; null when none ran); `lastIndexOutcome` says why that attempt produced no index, and is null when the attempt succeeded or none ran. `hasThumbnail` is true when a preview thumbnail has been rendered in the background (fetch it from `/api/documents/inbound/{id}/thumbnail`); false means no preview yet or an unsupported type — show a placeholder. Treat an unknown `kind` value as OTHER when decoding.",
|
||||
"A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was produced — `vision` means an AI provider read the original, the other values are provider-free local extractions, and it is null when `hasContentIndex` is false. `lastIndexAttemptAt` is when the most recent index attempt finished (successful or not; null when none ran); `lastIndexOutcome` says why that attempt produced no index, and is null when the attempt succeeded or none ran. `hasThumbnail` is true when a preview thumbnail has been rendered in the background (fetch it from `/api/documents/inbound/{id}/thumbnail`); false means no preview yet or an unsupported type — show a placeholder. `sourceSystem` / `sourceId` say where an imported document came from and its id there (both null for a document added in HealthLog); treat an unknown `sourceSystem` as OTHER when decoding. Treat an unknown `kind` value as OTHER when decoding.",
|
||||
});
|
||||
|
||||
const inboundDocumentDetail = inboundDocument
|
||||
@@ -262,11 +266,23 @@ const confirmResponse = z
|
||||
|
||||
const kindValues = [...INBOUND_DOCUMENT_KINDS];
|
||||
|
||||
const documentUploadReceipt = z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
duplicate: z.boolean(),
|
||||
deleted: z.literal(true).optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "DocumentUploadReceipt",
|
||||
description:
|
||||
"What an upload answers instead of the stored row. A narrow `documents:write` token always gets this shape (`duplicate: false` on 201, `true` on 200): a credential that can only add files learns whether a file is stored, not its title, filename or links. Every caller gets it when the upload's source key belongs to a document the owner DELETED (`deleted: true`, 200, nothing stored); `id` is the tombstoned row, or null once the 30-day purge removed it.",
|
||||
});
|
||||
|
||||
// Shared by the POST 200 (duplicate) and 201 (created) responses — the
|
||||
// envelope id must be registered exactly once.
|
||||
const inboundDocumentEnvelope = dataEnvelope(
|
||||
inboundDocument,
|
||||
"InboundDocumentEnvelope",
|
||||
const documentUploadEnvelope = dataEnvelope(
|
||||
z.union([inboundDocument, documentUploadReceipt]),
|
||||
"DocumentUploadEnvelope",
|
||||
);
|
||||
|
||||
export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
@@ -387,7 +403,9 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Store a document (no extraction)",
|
||||
description:
|
||||
'STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction — provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider configured. `multipart/form-data`: a `file` plus optional `title`, `kind`, `documentDate` (YYYY-MM-DD), and repeated `episodeIds` form fields (pre-link to the caller\'s illness/condition episodes). Accepted types (magic-byte sniffed, never the wire Content-Type): PDF/JPEG/PNG/WebP/GIF render inline; Office (docx/xlsx/pptx/doc/xls/ppt), text/CSV/Markdown/RTF, TIFF, HEIC/HEIF, XML/JSON are stored verbatim and served download-only. HEIC is stored as-is but attachment-only — prefer transcoding to JPEG client-side for inline preview parity. Error contract: `413` with `meta.reason = "fileTooLarge"` (+ `maxFileBytes`) or `"quotaExceeded"` (+ `quotaBytes`, `usedBytes`); `415` with `meta.reason = "unsupportedType"`. A same-user duplicate (same bytes) returns 200 + `meta.duplicate: true` with the existing row — not an error. `Idempotency-Key` honoured. Read `GET /api/documents/inbound/usage` for the effective limits before offering an upload. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`.',
|
||||
'STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction — provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider configured. `multipart/form-data`: a `file` plus optional `title`, `kind`, `documentDate` (YYYY-MM-DD), and repeated `episodeIds` form fields (pre-link to the caller\'s illness/condition episodes). Accepted types (magic-byte sniffed, never the wire Content-Type): PDF/JPEG/PNG/WebP/GIF render inline; Office (docx/xlsx/pptx/doc/xls/ppt), text/CSV/Markdown/RTF, TIFF, HEIC/HEIF, XML/JSON are stored verbatim and served download-only. HEIC is stored as-is but attachment-only — prefer transcoding to JPEG client-side for inline preview parity. Error contract: `413` with `meta.reason = "fileTooLarge"` (+ `maxFileBytes`) or `"quotaExceeded"` (+ `quotaBytes`, `usedBytes`); `415` with `meta.reason = "unsupportedType"`. A same-user duplicate (same bytes) returns 200 + `meta.duplicate: true` with the existing row — not an error. `Idempotency-Key` honoured. Read `GET /api/documents/inbound/usage` for the effective limits before offering an upload. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`.\n\n' +
|
||||
"**Importing from another system (v1.39.2).** Optional `sourceSystem` (`PAPERLESS`, `PAPRA`, `OTHER`) and `sourceId` (its id there, printable, up to 128 characters; needs `sourceSystem`) key the upload. A re-send with a known key is a duplicate: 200 with the live row (`meta.duplicate: true`), or, when the owner DELETED that document, 200 with `data.deleted: true` and `meta.deleted: true` and nothing stored; this holds after the 30-day purge too. `aiRead=defer` holds back automatic AI reading for this upload: the thumbnail and a local text index still run, the summary and lab staging do not, so the document waits for the person to read it deliberately. Absent, behaviour is unchanged.\n\n" +
|
||||
"**Narrow token.** Also reachable with a `documents:write` Bearer (minted at `POST /api/tokens/documents`), and it is the only route that scope reaches. Such a caller draws on a bucket of its own, keyed on the token (default 120 an hour, `DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, clamped 1-1000; the 429 carries `Retry-After` and `X-RateLimit-*`), and gets a `DocumentUploadReceipt` instead of the stored row. A cookie or wildcard caller keeps the 60-an-hour per-user bucket and the full row. With the vault module off the answer is 403 `module.disabled` for every caller.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -417,6 +435,18 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description:
|
||||
"Optional visit pre-links (repeat the field per id). Every id must be a live visit of the caller; an unknown one refuses the upload rather than dropping the link the person asked for.",
|
||||
}),
|
||||
sourceSystem: z.enum(DOCUMENT_SOURCE_SYSTEMS).optional().meta({
|
||||
description:
|
||||
"Optional. The system an imported document comes from. Shown on the detail view.",
|
||||
}),
|
||||
sourceId: z.string().max(DOCUMENT_SOURCE_ID_MAX).optional().meta({
|
||||
description:
|
||||
"Optional. The document's id in `sourceSystem`; requires it. Unique per account across live and deleted documents.",
|
||||
}),
|
||||
aiRead: z.enum(["defer"]).optional().meta({
|
||||
description:
|
||||
"Optional. `defer` holds back automatic AI reading for this upload (local index and thumbnail only).",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -425,18 +455,19 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description:
|
||||
"Duplicate — the same bytes are already stored: the existing row, with `meta.duplicate: true` at the envelope level.",
|
||||
"Duplicate — the same bytes, or the same source key, are already stored: the existing row (a `DocumentUploadReceipt` for a `documents:write` token), with `meta.duplicate: true` at the envelope level. When the source key belongs to a document the owner deleted: a receipt with `deleted: true` and `meta.deleted: true`, nothing stored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: inboundDocumentEnvelope,
|
||||
schema: documentUploadEnvelope,
|
||||
},
|
||||
},
|
||||
},
|
||||
"201": {
|
||||
description: "The stored document.",
|
||||
description:
|
||||
"The stored document (a `DocumentUploadReceipt` for a `documents:write` token).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: inboundDocumentEnvelope,
|
||||
schema: documentUploadEnvelope,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user