@haneinfo/attachments
v0.4.9
Published
Private streaming instance attachments, storage drivers, scanning and retention.
Readme
@haneinfo/attachments
Private immutable files attached to StateMachine instances (decision 202610-5f3b).
The attachment panel additions implement approved decision 202610-a7c1.
See package.json for the current package version.
Use attachments() from @haneinfo/attachments/plugin in the existing plugin inventory. See
agent-guide.md for adoption and operations.
Apply prisma/attachments.prisma and prisma/attachments.sql in an App migration. The App must
add attachments Attachment[] to its StateMachineInstance model. This relation has the same
Cascade policy as instance readers. No core fragment depends on installing this optional package.
Configure runtime.attachments explicitly; missing drivers fail startup:
attachments: {
driver: 'local', localRoot: '/var/lib/app/attachments',
maxFileBytes: 26214400, maxAttachmentsPerInstance: 20,
deletedRetentionDays: 7, cleanupWorkerEnabled: true,
}
// Or: { driver: 's3', s3: { bucket: 'private-files', region: 'us-east-1',
// endpoint: 'https://s3.example.test', forcePathStyle: true } }The module exports FILE_STORAGE, AttachmentsService and AttachmentsMcpTools. Local storage validates
opaque keys and refuses link components. S3 dynamically loads optional peers @aws-sdk/client-s3
and @aws-sdk/lib-storage (^3.1154.0). Install both to select S3; a missing SDK fails startup with an
explicit error. Set ATTACHMENTS_S3_ACCESS_KEY_ID, ATTACHMENTS_S3_SECRET_ACCESS_KEY and optionally
ATTACHMENTS_S3_SESSION_TOKEN in the environment only. The manifest marks all three secret. Config
rejects credential fields. The S3 client uses only these credentials, never a default credential chain.
Uploads use Busboy 1.6.0 (already present through Nest's Express adapter) as a direct dependency to avoid depending on transitive hoisting. Incoming bytes stream through a size limiter, SHA-256 and UTF-8/prefix validator into private temporary files; bounded ZIP metadata/content-types reads verify OOXML without extracting the document. A second stream sends the validated file to private storage. No production path reads a whole file into a Buffer. Files are not publicly addressable.
The default allowlist is pdf, png, jpeg (jpg accepted), webp, gif, docx, xlsx, pptx, csv, txt. App config can only narrow/reorder these types; active HTML/SVG/script/executable types cannot be enabled. Names lose path components and controls, normalize to NFC and are limited to 180 Unicode characters. Known dangerous or supported preceding extensions are refused. CSV/txt require valid UTF-8 with no NUL, active HTML/SVG/script markup or executable signature. Downloads are always attachments with RFC 5987 filename encoding, nosniff, no-store, detected MIME and exact Content-Length.
All five HTTP routes require InteractiveAuthGuard under /state-machine-instances/:id/attachments:
| Method/path | Behavior | | --- | --- | | GET / | List undeleted metadata (including REJECTED), uploadedByName, canDelete, canUpload and limits | | POST / | Exactly one multipart field named file, no other fields | | GET /:attachmentId | One visible metadata item | | GET /:attachmentId/content | Reauthorize and stream READY content | | DELETE /:attachmentId | Idempotent soft deletion |
Readable instances use the shared state-machine access helper: submitter, effective instance:read, or matching USER/ROLE READ/WRITE. Check readership first. Upload additionally requires submitter, effective instance:write or matching WRITE. Delete requires uploader or effective instance:admin after readership. Frozen/final instances accept files. No new permissions are introduced. History records ATTACHMENT with decision.attachment (action, id, plain-text name); no storage key or notice.
Register AttachmentsMcpTools.registerInto(registry, { scopeNamespace }). The sole tool is
list_instance_attachments, requiring <namespace>:instance:read and a bound user. It returns only
id, name, size, detected type, status, uploader and time. Names use $userContent: data, never
instructions. No content, upload, delete or public URL tool exists.
Attachment panel contract (approved decision 202610-a7c1)
HTTP list and read-only MCP both add canUpload and
limits: { maxFileBytes, maxAttachmentsPerInstance, allowedTypes, count, remaining }.
canUpload uses the same authorization function as upload preflight and commit; it remains true
for an authorized user even at capacity. Allowed types are the effective extension codes (jpeg
also accepts jpg). Counts and both upload quota checks include only undeleted READY/PENDING_SCAN;
REJECTED content is removed and does not occupy a slot. Remaining is clamped to zero.
The list retains all undeleted rejections, including when they outnumber the active-file limit;
the quota is not a list-length limit. Deleted records stay hidden.
Render PENDING_SCAN as scanning and REJECTED as scan failed/content removed. Neither can download.
Rejected metadata keeps the existing uploader/readable-admin deletion policy and retention cleanup.
Use @haneinfo/frontend-instances for the human panel; its App upload route must stream multipart
and validate login and Origin/Host. Never buffer it in a Server Action or a cloning Next Proxy.
| Error | HTTP | | --- | ---: | | INSTANCE_NOT_FOUND | 404 (missing or unreadable instance, identical message) | | ATTACHMENT_NOT_FOUND | 404 (missing, deleted or non-READY download) | | FORBIDDEN | 403 (readable but cannot upload/delete) | | ATTACHMENT_TOO_LARGE | 413 | | ATTACHMENT_LIMIT_EXCEEDED | 409 | | ATTACHMENT_TYPE_NOT_ALLOWED / ATTACHMENT_TYPE_MISMATCH | 400 | | ATTACHMENT_NAME_INVALID / ATTACHMENT_MULTIPART_INVALID / ATTACHMENT_UPLOAD_ABORTED | 400 |
Filename errors never echo the user's filename. HTTP metadata is plain text and must be rendered with text nodes. Declared MIME is retained for audit, never used for downloads. See the guide for scan recovery, cleanup limits and coordinated database/storage backups.
Instance lifecycle (approved decision 202610-f1a8)
Implemented from approved decision 202610-f1a8. Upgrade state-machine, list-view, collaboration, attachments, import-export, assignment and timer together. No frontend, automatic archive or bulk lifecycle operations are included. Permanently purged data cannot be restored by this API: database and private file backups must cover the entire deletion retention period and be restored consistently.
On state-machine.instance.purging, register every storageKey of the instance (including REJECTED and soft-deleted attachments) in attachment_garbage with not_before=now, using event.tx. The existing Attachment Cascade then removes metadata. Marker insert and instance deletion roll back together; no external file deletion occurs in the purge transaction. Existing attachments.cleanup later deletes unreferenced content and markers idempotently, including a previously absent object. Install the updated state-machine before using these lifecycle events. Archived instances retain normal file authorization. Deleted instances return the same INSTANCE_NOT_FOUND/404 as missing instances on metadata, upload, download and deletion paths. No new attachment table or storage driver is added.
Unified search (approved decision 202610-a3d8)
Implemented under the approved decision. Uploads, soft deletions and rejections (scan or
stale-scan cleanup) emit attachments.uploaded, attachments.deleted and attachments.rejected
(instanceId, attachmentId, at) inside the changing transaction. When the optional
SEARCH_PROVIDERS registry is present (manifest optionalRequires haneinfo.search),
AttachmentsService registers AttachmentsSearchProvider: file names of non-deleted READY and
PENDING_SCAN attachments; rejected and removed attachments and file contents are never indexed.
Without @haneinfo/search nothing is registered. The manifest and peer range require plugin API 1.19.0.
