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:
Marc-André Bombeck committed 2026-09-25 21:48:04 +02:00
1 parent af65f01fec
commit 55a32ff7d1
45 files changed
+2261 -96

No files matched your search

@@ -37,6 +37,8 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
lastIndexAttemptAt: null,
lastIndexOutcome: null,
hasThumbnail: false,
sourceSystem: null,
sourceId: null,
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
...overrides,
@@ -89,6 +89,8 @@ function doc(
lastIndexAttemptAt: null,
lastIndexOutcome: null,
hasThumbnail: false,
sourceSystem: null,
sourceId: null,
createdAt: "2026-08-01T10:00:00.000Z",
updatedAt: "2026-08-01T10:00:00.000Z",
facts: [fact()],
@@ -57,6 +57,8 @@ function doc(id: string, title: string): InboundDocumentDto {
lastIndexAttemptAt: null,
lastIndexOutcome: null,
hasThumbnail: false,
sourceSystem: null,
sourceId: null,
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
};
@@ -45,6 +45,8 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
lastIndexAttemptAt: null,
lastIndexOutcome: null,
hasThumbnail: false,
sourceSystem: null,
sourceId: null,
createdAt: "2026-03-11T08:00:00.000Z",
updatedAt: "2026-03-11T08:00:00.000Z",
...overrides,
@@ -73,6 +73,7 @@ import {
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
type DocumentSourceSystemValue,
type DocumentSuggestionDto,
type DocumentSummaryMode,
type InboundDocumentDetailDto,
@@ -698,6 +699,14 @@ export function DocumentDetailSheet({
: null;
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
// The product names are proper nouns and stay untranslated; only "another
// system" is prose.
const sourceSystemName = (system: DocumentSourceSystemValue): string =>
system === "PAPERLESS"
? "Paperless-ngx"
: system === "PAPRA"
? "Papra"
: t("documents.detail.sourceOther");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
// v1.37.0 — the ONE eager fetch of the original, and only for a document
// that is going to render it.
@@ -1167,6 +1176,25 @@ export function DocumentDetailSheet({
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
{/* Where an imported document came from (#1038). Meta, so
muted like the line above; the id is what the person looks
up in the other system. */}
{doc.sourceSystem ? (
<p
className="text-muted-foreground text-xs break-all"
data-slot="document-detail-provenance"
>
{doc.sourceId
? t("documents.detail.importedFromWithId", {
system: sourceSystemName(doc.sourceSystem),
id: doc.sourceId,
})
: t("documents.detail.importedFrom", {
system: sourceSystemName(doc.sourceSystem),
})}
</p>
) : null}
</div>
</div>
) : null}
+75 -10
View File
@@ -6,10 +6,12 @@ import {
Check,
ChevronDown,
Copy,
FileUp,
Key,
Loader2,
Trash2,
Upload,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
@@ -55,6 +57,7 @@ export function ApiSection() {
<div className="space-y-6">
<ApiEndpointsCard />
<MeasurementTokensCard />
<DocumentTokensCard />
<ApiTokensCard />
</div>
);
@@ -70,6 +73,64 @@ export function ApiSection() {
* below is where credentials are reviewed and killed.
*/
function MeasurementTokensCard() {
const { t } = useTranslations();
return (
<IngestTokenCard
endpoint="/api/tokens/measurements"
icon={Upload}
slot="settings-measurements-token-created"
copy={{
title: t("settings.measurementsToken.title"),
description: t("settings.measurementsToken.description"),
detail: t("settings.measurementsToken.detail"),
scopeNote: t("settings.measurementsToken.scopeNote"),
createFailed: t("settings.measurementsToken.createFailed"),
}}
/>
);
}
/**
* Mint a token another document system uploads through (#1038): a Paperless
* workflow or the import script. Same card, same no-list reasoning as the
* measurement one above.
*/
function DocumentTokensCard() {
const { t } = useTranslations();
return (
<IngestTokenCard
endpoint="/api/tokens/documents"
icon={FileUp}
slot="settings-documents-token-created"
copy={{
title: t("settings.documentsToken.title"),
description: t("settings.documentsToken.description"),
detail: t("settings.documentsToken.detail"),
scopeNote: t("settings.documentsToken.scopeNote"),
createFailed: t("settings.documentsToken.createFailed"),
}}
/>
);
}
/** One single-scope mint: a name field, the token shown once, a copy button. */
function IngestTokenCard({
endpoint,
icon,
slot,
copy,
}: {
endpoint: "/api/tokens/measurements" | "/api/tokens/documents";
icon: LucideIcon;
slot: string;
copy: {
title: string;
description: string;
detail: string;
scopeNote: string;
createFailed: string;
};
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [newName, setNewName] = useState("");
@@ -84,7 +145,7 @@ function MeasurementTokensCard() {
setTokenMsg(null);
setNewToken(null);
try {
const res = await apiFetchRaw("/api/tokens/measurements", {
const res = await apiFetchRaw(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
// No scope field: the endpoint mints one shape, so there is nothing
@@ -101,7 +162,7 @@ function MeasurementTokensCard() {
// The server's own message when it sent one — it names the actual
// cause (switch off, rate limit, validation) — and a message about
// this specific action when it did not.
setTokenMsg(json.error || t("settings.measurementsToken.createFailed"));
setTokenMsg(json.error || copy.createFailed);
}
} catch {
setTokenMsg(t("common.networkError"));
@@ -125,17 +186,15 @@ function MeasurementTokensCard() {
return (
<SettingsCard>
<SettingsCardHeader
icon={Upload}
title={t("settings.measurementsToken.title")}
description={t("settings.measurementsToken.description")}
icon={icon}
title={copy.title}
description={copy.description}
/>
<div className="space-y-3">
<p className="text-sm leading-relaxed">
{t("settings.measurementsToken.detail")}
</p>
<p className="text-sm leading-relaxed">{copy.detail}</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("settings.measurementsToken.scopeNote")}
{copy.scopeNote}
</p>
<form onSubmit={handleCreate} className="flex items-center gap-2">
@@ -162,7 +221,7 @@ function MeasurementTokensCard() {
{newToken && (
<div
className="bg-success/10 rounded-lg p-3 text-sm"
data-slot="settings-measurements-token-created"
data-slot={slot}
>
<p className="text-success mb-1 font-medium">
{t("settings.tokenCreated")}
@@ -223,6 +282,12 @@ function ApiEndpointsCard() {
auth: "Authorization: Bearer hlk_...",
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
},
{
method: "POST",
path: "/api/documents/inbound",
auth: "Authorization: Bearer hlk_...",
example: `multipart: file, title, documentDate, sourceSystem, sourceId`,
},
];
return (