@haneinfo/attachments 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +75 -2
  3. package/agent-guide.md +86 -0
  4. package/dist/cleanup.worker.d.ts +13 -0
  5. package/dist/cleanup.worker.d.ts.map +1 -0
  6. package/dist/cleanup.worker.js +57 -0
  7. package/dist/cleanup.worker.js.map +1 -0
  8. package/dist/config.d.ts +38 -0
  9. package/dist/config.d.ts.map +1 -0
  10. package/dist/config.js +74 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/controller.d.ts +45 -0
  13. package/dist/controller.d.ts.map +1 -0
  14. package/dist/controller.js +214 -0
  15. package/dist/controller.js.map +1 -0
  16. package/dist/index.d.ts +8 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +24 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/mcp-tools.d.ts +10 -0
  21. package/dist/mcp-tools.d.ts.map +1 -0
  22. package/dist/mcp-tools.js +74 -0
  23. package/dist/mcp-tools.js.map +1 -0
  24. package/dist/module.d.ts +6 -0
  25. package/dist/module.d.ts.map +1 -0
  26. package/dist/module.js +54 -0
  27. package/dist/module.js.map +1 -0
  28. package/dist/plugin.d.ts +44 -0
  29. package/dist/plugin.d.ts.map +1 -0
  30. package/dist/plugin.js +90 -0
  31. package/dist/plugin.js.map +1 -0
  32. package/dist/service.d.ts +123 -0
  33. package/dist/service.d.ts.map +1 -0
  34. package/dist/service.js +384 -0
  35. package/dist/service.js.map +1 -0
  36. package/dist/storage.d.ts +54 -0
  37. package/dist/storage.d.ts.map +1 -0
  38. package/dist/storage.js +239 -0
  39. package/dist/storage.js.map +1 -0
  40. package/dist/store.d.ts +61 -0
  41. package/dist/store.d.ts.map +1 -0
  42. package/dist/store.js +25 -0
  43. package/dist/store.js.map +1 -0
  44. package/dist/validation.d.ts +29 -0
  45. package/dist/validation.d.ts.map +1 -0
  46. package/dist/validation.js +297 -0
  47. package/dist/validation.js.map +1 -0
  48. package/haneinfo.plugin.json +72 -0
  49. package/package.json +80 -3
  50. package/plugin.d.ts +2 -0
  51. package/plugin.js +4 -0
  52. package/prisma/attachments.prisma +32 -0
  53. package/prisma/attachments.sql +13 -0
package/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ Copyright (c) 2026 Haneinfo. All Rights Reserved.
2
+
3
+ This software and associated documentation files (the "Software") are the proprietary and
4
+ confidential property of Haneinfo. No part of the Software may be copied, modified, merged,
5
+ published, distributed, sublicensed, or sold without the prior written permission of Haneinfo.
6
+
7
+ The Software is provided for internal use by Haneinfo and its authorized personnel only, and is
8
+ NOT licensed for external distribution or open-source use.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
11
+ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
12
+ NONINFRINGEMENT. IN NO EVENT SHALL HANEINFO BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
13
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH
14
+ THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,76 @@
1
- # Temporary Holding Version
1
+ # @haneinfo/attachments
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Private immutable files attached to StateMachine instances. Implements draft decision 202610-5f3b;
4
+ the decision remains draft. Package version stays 0.0.0 with a minor changeset for first release 0.1.0.
5
+ Use `attachments()` from `@haneinfo/attachments/plugin` in the existing plugin inventory. See
6
+ [agent-guide.md](agent-guide.md) for adoption and operations.
7
+
8
+ Apply `prisma/attachments.prisma` and `prisma/attachments.sql` in an App migration. The App must
9
+ add `attachments Attachment[]` to its StateMachineInstance model. This relation has the same
10
+ Cascade policy as instance readers. No core fragment depends on installing this optional package.
11
+ Configure `runtime.attachments` explicitly; missing drivers fail startup:
12
+
13
+ ```ts
14
+ attachments: {
15
+ driver: 'local', localRoot: '/var/lib/app/attachments',
16
+ maxFileBytes: 26214400, maxAttachmentsPerInstance: 20,
17
+ deletedRetentionDays: 7, cleanupWorkerEnabled: true,
18
+ }
19
+ // Or: { driver: 's3', s3: { bucket: 'private-files', region: 'us-east-1',
20
+ // endpoint: 'https://s3.example.test', forcePathStyle: true } }
21
+ ```
22
+
23
+ The module exports FILE_STORAGE, AttachmentsService and AttachmentsMcpTools. Local storage validates
24
+ opaque keys and refuses link components. S3 dynamically loads optional peers `@aws-sdk/client-s3`
25
+ and `@aws-sdk/lib-storage` (^3.1154.0). Install both to select S3; a missing SDK fails startup with an
26
+ explicit error. Set ATTACHMENTS_S3_ACCESS_KEY_ID, ATTACHMENTS_S3_SECRET_ACCESS_KEY and optionally
27
+ ATTACHMENTS_S3_SESSION_TOKEN in the environment only. The manifest marks all three secret. Config
28
+ rejects credential fields. The S3 client uses only these credentials, never a default credential chain.
29
+
30
+ Uploads use Busboy 1.6.0 (already present through Nest's Express adapter) as a direct dependency to
31
+ avoid depending on transitive hoisting. Incoming bytes stream through a size limiter, SHA-256 and
32
+ UTF-8/prefix validator into private temporary files; bounded ZIP metadata/content-types reads verify
33
+ OOXML without extracting the document. A second stream sends the validated file to private storage.
34
+ No production path reads a whole file into a Buffer. Files are not publicly addressable.
35
+
36
+ The default allowlist is pdf, png, jpeg (jpg accepted), webp, gif, docx, xlsx, pptx, csv, txt. App config
37
+ can only narrow/reorder these types; active HTML/SVG/script/executable types cannot be enabled.
38
+ Names lose path components and controls, normalize to NFC and are limited to 180 Unicode characters.
39
+ Known dangerous or supported preceding extensions are refused. CSV/txt require valid UTF-8 with no
40
+ NUL, active HTML/SVG/script markup or executable signature. Downloads are always attachments with
41
+ RFC 5987 filename encoding, nosniff, no-store, detected MIME and exact Content-Length.
42
+
43
+ All five HTTP routes require InteractiveAuthGuard under `/state-machine-instances/:id/attachments`:
44
+
45
+ | Method/path | Behavior |
46
+ | --- | --- |
47
+ | GET / | List visible metadata, uploadedByName and backend canDelete |
48
+ | POST / | Exactly one multipart field named file, no other fields |
49
+ | GET /:attachmentId | One visible metadata item |
50
+ | GET /:attachmentId/content | Reauthorize and stream READY content |
51
+ | DELETE /:attachmentId | Idempotent soft deletion |
52
+
53
+ Readable instances use the shared state-machine access helper: submitter, effective instance:read,
54
+ or matching USER/ROLE READ/WRITE. Check readership first. Upload additionally requires submitter,
55
+ effective instance:write or matching WRITE. Delete requires uploader or effective instance:admin
56
+ after readership. Frozen/final instances accept files. No new permissions are introduced. History
57
+ records ATTACHMENT with decision.attachment (action, id, plain-text name); no storage key or notice.
58
+
59
+ Register `AttachmentsMcpTools.registerInto(registry, { scopeNamespace })`. The sole tool is
60
+ `list_instance_attachments`, requiring `<namespace>:instance:read` and a bound user. It returns only
61
+ id, name, size, detected type, status, uploader and time. Names use `$userContent`: data, never
62
+ instructions. No content, upload, delete or public URL tool exists.
63
+
64
+ | Error | HTTP |
65
+ | --- | ---: |
66
+ | INSTANCE_NOT_FOUND | 404 (missing or unreadable instance, identical message) |
67
+ | ATTACHMENT_NOT_FOUND | 404 (missing, deleted or non-READY download) |
68
+ | FORBIDDEN | 403 (readable but cannot upload/delete) |
69
+ | ATTACHMENT_TOO_LARGE | 413 |
70
+ | ATTACHMENT_LIMIT_EXCEEDED | 409 |
71
+ | ATTACHMENT_TYPE_NOT_ALLOWED / ATTACHMENT_TYPE_MISMATCH | 400 |
72
+ | ATTACHMENT_NAME_INVALID / ATTACHMENT_MULTIPART_INVALID / ATTACHMENT_UPLOAD_ABORTED | 400 |
73
+
74
+ Filename errors never echo the user's filename. HTTP metadata is plain text and must be rendered
75
+ with text nodes. Declared MIME is retained for audit, never used for downloads. See the guide for
76
+ scan recovery, cleanup limits and coordinated database/storage backups.
package/agent-guide.md ADDED
@@ -0,0 +1,86 @@
1
+ # Attachments agent guide
2
+
3
+ Use this package for private immutable instance attachments. Draft decision 202610-5f3b remains draft.
4
+ Do not use it for exports, inline previews, versions, public links, folders or frontend panels.
5
+ It has no definition kind or adapters; configure the runtime rather than create a definition.
6
+ The HTTP, MCP, errors, storage configuration and authorization contract are in [README.md](README.md).
7
+
8
+ ## Adoption
9
+
10
+ Upgrade plugin-api (new contract 1.15.0), state-machine and collaboration together. Include the new
11
+ fragment, add the reverse `attachments Attachment[]` relation in the App, and deploy the supplied SQL
12
+ as an ordinary migration before starting the plugin. No instance/history columns change. Keep the
13
+ App's existing interactive authentication, RBAC and background-jobs composition. The plugin owns
14
+ Attachment and AttachmentGarbage only and uses the shared instanceAccessAllowed helper.
15
+
16
+ ## Configuration
17
+
18
+ All options are under `runtime.attachments`, validated identically by direct dynamic-module registration.
19
+
20
+ | Option | Default / meaning |
21
+ | --- | --- |
22
+ | driver | Required: local or s3 |
23
+ | localRoot | Required for local; trusted private persistent directory |
24
+ | s3.bucket / s3.region | Required for s3 |
25
+ | s3.endpoint / s3.forcePathStyle | Optional URL / false |
26
+ | maxFileBytes | 26214400; maximum Number.MAX_SAFE_INTEGER, persisted as BigInt |
27
+ | maxAttachmentsPerInstance | 20; maximum 1000 |
28
+ | allowedTypes | pdf, png, jpeg, webp, gif, docx, xlsx, pptx, csv, txt |
29
+ | deletedRetentionDays | 7; nonnegative integer |
30
+ | pendingScanTimeoutMs | 900000; expired pending files fail closed as REJECTED |
31
+ | maxUploadMs | 300000; maximum 1800000, covering envelope, staging and storage |
32
+ | cleanupBatchSize | 100; maximum 500 per metadata/garbage/staging batch |
33
+ | cleanupPollIntervalMs | 60000 |
34
+ | cleanupWorkerEnabled | true; startup check and periodic cleanup enqueueing |
35
+
36
+ To drive jobs manually in tests, disable cleanupWorkerEnabled and the existing background-jobs
37
+ worker (`BACKGROUND_JOBS_WORKER_ENABLED=false` in Apps that map that environment setting). The
38
+ producer never reclaims RUNNING jobs automatically. Existing background-jobs retry and recovery
39
+ rules apply. Each replica may enqueue cleanup; object deletion and record purging are idempotent.
40
+
41
+ ## Optional scanning
42
+
43
+ Provide ATTACHMENT_SCANNER / haneinfo.attachment-scanner using AttachmentScannerPort. There is no
44
+ built-in scanner. New records are PENDING_SCAN until the registered `attachments.scan` job consumes
45
+ a storage stream and returns `{ status: 'CLEAN' }` or `{ status: 'REJECTED', reason }`. CLEAN sets
46
+ READY; rejection deletes content and persists REJECTED. Reasons are not exposed or copied to history.
47
+ The supplied stream is destroyed after each attempt, including failures/cancellation. Exceptions
48
+ leave PENDING_SCAN and use background-jobs retry. Missing content after a failed rejection commit
49
+ is rejected on retry. Non-READY downloads and deleted downloads uniformly return 404.
50
+
51
+ Cleanup recovers missing/failed scan enqueueing, skips active scan jobs, and rejects pending files
52
+ older than pendingScanTimeoutMs, deleting their content. Removing a scanner never silently approves
53
+ existing pending files. Rejected metadata is retained until its createdAt exceeds the deleted
54
+ retention period; its content is removed immediately. No scan timeout makes a file downloadable.
55
+
56
+ ## Operations and consistency
57
+
58
+ Back up and restore both PostgreSQL and the configured storage backend as one coordinated set.
59
+ Storage keys are random `attachments/<UTC year>/<month>/<uuid>`; never derive them from names or
60
+ accept user-specified keys. Preserve local directory ownership/private permissions, or a private
61
+ S3 bucket. Configure S3 lifecycle abortion of incomplete multipart uploads for process crashes.
62
+ The SDK aborts failed live uploads, uses queueSize=1 and a 5 MiB part buffer.
63
+
64
+ Authorization/count preflight happens before consuming multipart bytes. Counts are checked again
65
+ under the existing instance lock at commit, so concurrent uploads cannot exceed the cap. A durable
66
+ AttachmentGarbage marker precedes every storage write. Metadata, history and marker removal commit
67
+ together. A failed DB write deletes the object; failed deletion leaves its marker for cleanup after
68
+ one hour. Ambiguous commits check for a referenced object before deletion and leave recovery to
69
+ cleanup when the DB is unavailable. Garbage cleanup also refuses to delete referenced objects.
70
+
71
+ Temporary staging files live under the OS temp directory's haneinfo-attachments-staging folder,
72
+ with random UUID names and private modes. Normal failures and client aborts remove partial files.
73
+ Startup/periodic cleanup reclaims stale staging files after one hour, exceeding the maximum upload
74
+ deadline. Run replicas with separate local temp directories; remote/shared staging is unsupported.
75
+ Cleanup removes content first and then DB metadata, making retries safe if either step fails.
76
+ Soft deletion hides records immediately; retained files must never be served outside this API.
77
+
78
+ OOXML checks use bounded central-directory metadata (128 KiB, 2000 entries), local headers, required
79
+ main-part names and at most 64 KiB of decompressed content-types XML. ZIP64, encrypted/macro-enabled
80
+ archives, oversized metadata and malformed containers are refused conservatively. Magic detection
81
+ is a type gate, not malware detection or complete format validation; install a scanner when needed.
82
+
83
+ For common errors, use the README table: adjust limits/allowlists, fix file extensions/content, or
84
+ ask the instance owner for access. Never ask an agent to upload/delete through MCP; a person uses
85
+ the authenticated App. Related recipe: the equipment-loan attachments validation overlay in the
86
+ package repository demonstrates migration, private local storage and read-only MCP registration.
@@ -0,0 +1,13 @@
1
+ import { type OnApplicationBootstrap, type OnModuleDestroy } from '@nestjs/common';
2
+ import { AttachmentsService } from './service';
3
+ export declare class AttachmentsCleanupWorker implements OnApplicationBootstrap, OnModuleDestroy {
4
+ private readonly attachments;
5
+ private timer?;
6
+ private pending?;
7
+ private readonly logger;
8
+ constructor(attachments: AttachmentsService);
9
+ onApplicationBootstrap(): void;
10
+ tick(): Promise<void>;
11
+ onModuleDestroy(): Promise<void>;
12
+ }
13
+ //# sourceMappingURL=cleanup.worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cleanup.worker.d.ts","sourceRoot":"","sources":["../src/cleanup.worker.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,sBAAsB,EAC3B,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAE/C,qBACa,wBAAyB,YAAW,sBAAsB,EAAE,eAAe;IAI9C,OAAO,CAAC,QAAQ,CAAC,WAAW;IAHpE,OAAO,CAAC,KAAK,CAAC,CAAiC;IAC/C,OAAO,CAAC,OAAO,CAAC,CAAmB;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0C;gBACR,WAAW,EAAE,kBAAkB;IACxF,sBAAsB;IAQhB,IAAI;IAWJ,eAAe;CAItB"}
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.AttachmentsCleanupWorker = void 0;
16
+ const common_1 = require("@nestjs/common");
17
+ const service_1 = require("./service");
18
+ let AttachmentsCleanupWorker = class AttachmentsCleanupWorker {
19
+ constructor(attachments) {
20
+ this.attachments = attachments;
21
+ this.logger = new common_1.Logger('AttachmentsCleanupWorker');
22
+ }
23
+ onApplicationBootstrap() {
24
+ if (!this.attachments.config.cleanupWorkerEnabled)
25
+ return;
26
+ void this.tick();
27
+ this.timer = setInterval(() => {
28
+ void this.tick();
29
+ }, this.attachments.config.cleanupPollIntervalMs);
30
+ this.timer.unref?.();
31
+ }
32
+ async tick() {
33
+ if (this.pending || !this.attachments.config.cleanupWorkerEnabled)
34
+ return;
35
+ this.pending = this.attachments
36
+ .enqueueCleanup()
37
+ .catch(() => this.logger.error('Could not enqueue attachment cleanup'));
38
+ try {
39
+ await this.pending;
40
+ }
41
+ finally {
42
+ this.pending = undefined;
43
+ }
44
+ }
45
+ async onModuleDestroy() {
46
+ if (this.timer)
47
+ clearInterval(this.timer);
48
+ await this.pending;
49
+ }
50
+ };
51
+ exports.AttachmentsCleanupWorker = AttachmentsCleanupWorker;
52
+ exports.AttachmentsCleanupWorker = AttachmentsCleanupWorker = __decorate([
53
+ (0, common_1.Injectable)(),
54
+ __param(0, (0, common_1.Inject)(service_1.AttachmentsService)),
55
+ __metadata("design:paramtypes", [service_1.AttachmentsService])
56
+ ], AttachmentsCleanupWorker);
57
+ //# sourceMappingURL=cleanup.worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cleanup.worker.js","sourceRoot":"","sources":["../src/cleanup.worker.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAMwB;AACxB,uCAA+C;AAGxC,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IAInC,YAAwC,WAAgD;QAA/B,gBAAW,GAAX,WAAW,CAAoB;QADvE,WAAM,GAAG,IAAI,eAAM,CAAC,0BAA0B,CAAC,CAAC;IAC0B,CAAC;IAC5F,sBAAsB;QACpB,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,oBAAoB;YAAE,OAAO;QAC1D,KAAK,IAAI,CAAC,IAAI,EAAE,CAAC;QACjB,IAAI,CAAC,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE;YAC5B,KAAK,IAAI,CAAC,IAAI,EAAE,CAAC;QACnB,CAAC,EAAE,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,qBAAqB,CAAC,CAAC;QAClD,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;IACvB,CAAC;IACD,KAAK,CAAC,IAAI;QACR,IAAI,IAAI,CAAC,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,oBAAoB;YAAE,OAAO;QAC1E,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,WAAW;aAC5B,cAAc,EAAE;aAChB,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,sCAAsC,CAAC,CAAC,CAAC;QAC1E,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,OAAO,CAAC;QACrB,CAAC;gBAAS,CAAC;YACT,IAAI,CAAC,OAAO,GAAG,SAAS,CAAC;QAC3B,CAAC;IACH,CAAC;IACD,KAAK,CAAC,eAAe;QACnB,IAAI,IAAI,CAAC,KAAK;YAAE,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC1C,MAAM,IAAI,CAAC,OAAO,CAAC;IACrB,CAAC;CACF,CAAA;AA5BY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,mBAAU,GAAE;IAKE,WAAA,IAAA,eAAM,EAAC,4BAAkB,CAAC,CAAA;qCAA+B,4BAAkB;GAJ7E,wBAAwB,CA4BpC"}
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ export declare const attachmentTypes: readonly ["pdf", "png", "jpeg", "webp", "gif", "docx", "xlsx", "pptx", "csv", "txt"];
3
+ export declare const attachmentsConfigSchema: z.ZodObject<{
4
+ driver: z.ZodEnum<{
5
+ local: "local";
6
+ s3: "s3";
7
+ }>;
8
+ localRoot: z.ZodOptional<z.ZodString>;
9
+ s3: z.ZodOptional<z.ZodObject<{
10
+ endpoint: z.ZodOptional<z.ZodURL>;
11
+ bucket: z.ZodString;
12
+ region: z.ZodString;
13
+ forcePathStyle: z.ZodDefault<z.ZodBoolean>;
14
+ }, z.core.$strict>>;
15
+ maxFileBytes: z.ZodDefault<z.ZodNumber>;
16
+ maxAttachmentsPerInstance: z.ZodDefault<z.ZodNumber>;
17
+ allowedTypes: z.ZodDefault<z.ZodArray<z.ZodEnum<{
18
+ pdf: "pdf";
19
+ png: "png";
20
+ jpeg: "jpeg";
21
+ webp: "webp";
22
+ gif: "gif";
23
+ docx: "docx";
24
+ xlsx: "xlsx";
25
+ pptx: "pptx";
26
+ csv: "csv";
27
+ txt: "txt";
28
+ }>>>;
29
+ deletedRetentionDays: z.ZodDefault<z.ZodNumber>;
30
+ pendingScanTimeoutMs: z.ZodDefault<z.ZodNumber>;
31
+ maxUploadMs: z.ZodDefault<z.ZodNumber>;
32
+ cleanupBatchSize: z.ZodDefault<z.ZodNumber>;
33
+ cleanupPollIntervalMs: z.ZodDefault<z.ZodNumber>;
34
+ cleanupWorkerEnabled: z.ZodDefault<z.ZodBoolean>;
35
+ }, z.core.$strict>;
36
+ export type AttachmentsConfig = z.input<typeof attachmentsConfigSchema>;
37
+ export type ResolvedAttachmentsConfig = z.output<typeof attachmentsConfigSchema>;
38
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,eAAe,sFAWlB,CAAC;AACX,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAwDhC,CAAC;AACL,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC;AACxE,MAAM,MAAM,yBAAyB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,uBAAuB,CAAC,CAAC"}
package/dist/config.js ADDED
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.attachmentsConfigSchema = exports.attachmentTypes = void 0;
4
+ const zod_1 = require("zod");
5
+ exports.attachmentTypes = [
6
+ 'pdf',
7
+ 'png',
8
+ 'jpeg',
9
+ 'webp',
10
+ 'gif',
11
+ 'docx',
12
+ 'xlsx',
13
+ 'pptx',
14
+ 'csv',
15
+ 'txt',
16
+ ];
17
+ exports.attachmentsConfigSchema = zod_1.z
18
+ .object({
19
+ driver: zod_1.z.enum(['local', 's3'], {
20
+ error: 'Attachments require an explicit local or s3 storage driver',
21
+ }),
22
+ localRoot: zod_1.z.string().min(1).optional(),
23
+ s3: zod_1.z
24
+ .object({
25
+ endpoint: zod_1.z.url().optional(),
26
+ bucket: zod_1.z.string().min(1),
27
+ region: zod_1.z.string().min(1),
28
+ forcePathStyle: zod_1.z.boolean().default(false),
29
+ })
30
+ .strict()
31
+ .optional(),
32
+ maxFileBytes: zod_1.z
33
+ .number()
34
+ .int()
35
+ .positive()
36
+ .max(Number.MAX_SAFE_INTEGER)
37
+ .default(25 * 1024 * 1024),
38
+ maxAttachmentsPerInstance: zod_1.z.number().int().positive().max(1000).default(20),
39
+ allowedTypes: zod_1.z
40
+ .array(zod_1.z.enum(exports.attachmentTypes))
41
+ .min(1)
42
+ .default([...exports.attachmentTypes]),
43
+ deletedRetentionDays: zod_1.z.number().int().nonnegative().default(7),
44
+ pendingScanTimeoutMs: zod_1.z
45
+ .number()
46
+ .int()
47
+ .positive()
48
+ .default(15 * 60 * 1000),
49
+ maxUploadMs: zod_1.z
50
+ .number()
51
+ .int()
52
+ .positive()
53
+ .max(30 * 60 * 1000)
54
+ .default(5 * 60 * 1000),
55
+ cleanupBatchSize: zod_1.z.number().int().positive().max(500).default(100),
56
+ cleanupPollIntervalMs: zod_1.z.number().int().positive().default(60000),
57
+ cleanupWorkerEnabled: zod_1.z.boolean().default(true),
58
+ })
59
+ .strict()
60
+ .superRefine((config, ctx) => {
61
+ if (config.driver === 'local' && !config.localRoot)
62
+ ctx.addIssue({
63
+ code: 'custom',
64
+ path: ['localRoot'],
65
+ message: 'Local storage requires localRoot',
66
+ });
67
+ if (config.driver === 's3' && !config.s3)
68
+ ctx.addIssue({
69
+ code: 'custom',
70
+ path: ['s3'],
71
+ message: 'S3 storage requires bucket and region',
72
+ });
73
+ });
74
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,45 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { type InteractivePrincipal } from '@haneinfo/plugin-api';
3
+ import { AttachmentsService } from './service';
4
+ /** No body interceptor: authorization/count checks happen before the parser consumes bytes. */
5
+ export declare function receiveMultipart(request: IncomingMessage, response: ServerResponse, service: AttachmentsService, userId: string, instanceId: string): Promise<unknown>;
6
+ export declare class AttachmentsController {
7
+ private readonly attachments;
8
+ constructor(attachments: AttachmentsService);
9
+ list(user: InteractivePrincipal, id: string): Promise<{
10
+ items: {
11
+ id: string;
12
+ instanceId: string;
13
+ fileName: string;
14
+ declaredContentType: string;
15
+ detectedContentType: string;
16
+ size: number;
17
+ sha256: string;
18
+ status: "READY" | "PENDING_SCAN" | "REJECTED";
19
+ uploadedBy: string;
20
+ uploadedByName: string | null;
21
+ createdAt: Date;
22
+ canDelete: boolean;
23
+ }[];
24
+ }>;
25
+ get(user: InteractivePrincipal, id: string, attachmentId: string): Promise<{
26
+ id: string;
27
+ instanceId: string;
28
+ fileName: string;
29
+ declaredContentType: string;
30
+ detectedContentType: string;
31
+ size: number;
32
+ sha256: string;
33
+ status: "READY" | "PENDING_SCAN" | "REJECTED";
34
+ uploadedBy: string;
35
+ uploadedByName: string | null;
36
+ createdAt: Date;
37
+ canDelete: boolean;
38
+ }>;
39
+ upload(user: InteractivePrincipal, id: string, request: IncomingMessage, response: ServerResponse): Promise<unknown>;
40
+ content(user: InteractivePrincipal, id: string, attachmentId: string, response: ServerResponse): Promise<void>;
41
+ delete(user: InteractivePrincipal, id: string, attachmentId: string): Promise<{
42
+ deleted: boolean;
43
+ }>;
44
+ }
45
+ //# sourceMappingURL=controller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,EAAE,KAAK,oBAAoB,EAAkB,MAAM,sBAAsB,CAAC;AAKjF,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAE/C,+FAA+F;AAC/F,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,eAAe,EACxB,QAAQ,EAAE,cAAc,EACxB,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,oBA+GnB;AAED,qBAEa,qBAAqB;IACQ,OAAO,CAAC,QAAQ,CAAC,WAAW;gBAAX,WAAW,EAAE,kBAAkB;IAExF,IAAI,CAAgB,IAAI,EAAE,oBAAoB,EAAe,EAAE,EAAE,MAAM;;;;;;;;;;;;;;;;IAIvE,GAAG,CACc,IAAI,EAAE,oBAAoB,EAC5B,EAAE,EAAE,MAAM,EACA,YAAY,EAAE,MAAM;;;;;;;;;;;;;;IAK7C,MAAM,CACW,IAAI,EAAE,oBAAoB,EAC5B,EAAE,EAAE,MAAM,EAChB,OAAO,EAAE,eAAe,EACH,QAAQ,EAAE,cAAc;IAKtD,OAAO,CACU,IAAI,EAAE,oBAAoB,EAC5B,EAAE,EAAE,MAAM,EACA,YAAY,EAAE,MAAM,EACpC,QAAQ,EAAE,cAAc;IAkBjC,MAAM,CACW,IAAI,EAAE,oBAAoB,EAC5B,EAAE,EAAE,MAAM,EACA,YAAY,EAAE,MAAM;;;CAI9C"}
@@ -0,0 +1,214 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.AttachmentsController = void 0;
19
+ exports.receiveMultipart = receiveMultipart;
20
+ const promises_1 = require("node:stream/promises");
21
+ const plugin_api_1 = require("@haneinfo/plugin-api");
22
+ const plugin_host_nest_1 = require("@haneinfo/plugin-host-nest");
23
+ const state_machine_1 = require("@haneinfo/state-machine");
24
+ const common_1 = require("@nestjs/common");
25
+ const busboy_1 = __importDefault(require("busboy"));
26
+ const service_1 = require("./service");
27
+ /** No body interceptor: authorization/count checks happen before the parser consumes bytes. */
28
+ async function receiveMultipart(request, response, service, userId, instanceId) {
29
+ await service.preflight(userId, instanceId);
30
+ const abort = new AbortController();
31
+ let parser;
32
+ try {
33
+ parser = (0, busboy_1.default)({
34
+ headers: request.headers,
35
+ defParamCharset: 'utf8',
36
+ preservePath: true,
37
+ limits: {
38
+ fileSize: service.config.maxFileBytes + 1,
39
+ files: 1,
40
+ fields: 0,
41
+ fieldNameSize: 100,
42
+ headerPairs: 100,
43
+ },
44
+ });
45
+ }
46
+ catch {
47
+ throw new plugin_api_1.MechanismError('ATTACHMENT_MULTIPART_INVALID', 'One multipart file field named file is required');
48
+ }
49
+ let count = 0;
50
+ let resolveComplete, rejectComplete;
51
+ const complete = new Promise((resolve, reject) => {
52
+ resolveComplete = resolve;
53
+ rejectComplete = reject;
54
+ });
55
+ void complete.catch(() => { });
56
+ let upload, failure;
57
+ const fail = (error) => {
58
+ if (failure)
59
+ return;
60
+ failure = error;
61
+ rejectComplete(error);
62
+ abort.abort(error);
63
+ request.unpipe(parser);
64
+ request.pause();
65
+ response.setHeader('Connection', 'close');
66
+ parser.destroy(error);
67
+ };
68
+ const invalid = () => fail(new plugin_api_1.MechanismError('ATTACHMENT_MULTIPART_INVALID', 'Exactly one file field and no other fields are allowed'));
69
+ const aborted = () => fail(new plugin_api_1.MechanismError('ATTACHMENT_UPLOAD_ABORTED', 'Upload interrupted', 400));
70
+ const timeout = setTimeout(aborted, service.config.maxUploadMs);
71
+ timeout.unref?.();
72
+ request.once('aborted', aborted);
73
+ request.once('error', aborted);
74
+ const closed = () => {
75
+ if (!response.writableFinished)
76
+ aborted();
77
+ };
78
+ response.once('close', closed);
79
+ parser.on('file', (name, file, info) => {
80
+ count++;
81
+ file.on('error', () => {
82
+ if (!failure)
83
+ fail(new plugin_api_1.MechanismError('ATTACHMENT_MULTIPART_INVALID', 'Malformed multipart upload'));
84
+ });
85
+ if (name !== 'file' || count !== 1) {
86
+ file.resume();
87
+ invalid();
88
+ return;
89
+ }
90
+ file.once('limit', () => fail(new plugin_api_1.MechanismError('ATTACHMENT_TOO_LARGE', 'The file exceeds maxFileBytes', 413)));
91
+ upload = service.uploadFor(userId, instanceId, { fileName: info.filename ?? '', declaredContentType: info.mimeType, stream: file, complete }, abort.signal);
92
+ void upload.catch((error) => fail(error));
93
+ });
94
+ parser.on('field', invalid);
95
+ parser.on('filesLimit', invalid);
96
+ parser.on('fieldsLimit', invalid);
97
+ parser.on('error', (error) => fail(new plugin_api_1.MechanismError('ATTACHMENT_MULTIPART_INVALID', error instanceof plugin_api_1.MechanismError ? error.message : 'Malformed multipart upload')));
98
+ parser.once('close', () => {
99
+ if (!failure && count === 1)
100
+ resolveComplete();
101
+ else if (!failure)
102
+ invalid();
103
+ });
104
+ try {
105
+ request.pipe(parser);
106
+ await complete;
107
+ const result = await upload;
108
+ if (failure)
109
+ throw failure;
110
+ return result;
111
+ }
112
+ catch (error) {
113
+ if (upload)
114
+ await upload.catch(() => { });
115
+ throw failure ?? error;
116
+ }
117
+ finally {
118
+ clearTimeout(timeout);
119
+ request.removeListener('aborted', aborted);
120
+ request.removeListener('error', aborted);
121
+ response.removeListener('close', closed);
122
+ }
123
+ }
124
+ let AttachmentsController = class AttachmentsController {
125
+ constructor(attachments) {
126
+ this.attachments = attachments;
127
+ }
128
+ list(user, id) {
129
+ return (0, state_machine_1.runHttp)(() => this.attachments.listFor(user.sub, id));
130
+ }
131
+ get(user, id, attachmentId) {
132
+ return (0, state_machine_1.runHttp)(() => this.attachments.getFor(user.sub, id, attachmentId));
133
+ }
134
+ upload(user, id, request, response) {
135
+ return (0, state_machine_1.runHttp)(() => receiveMultipart(request, response, this.attachments, user.sub, id));
136
+ }
137
+ content(user, id, attachmentId, response) {
138
+ return (0, state_machine_1.runHttp)(async () => {
139
+ const { stream, headers } = await this.attachments.downloadFor(user.sub, id, attachmentId);
140
+ for (const [key, value] of Object.entries(headers))
141
+ response.setHeader(key, value);
142
+ const close = () => stream.destroy();
143
+ response.once('close', close);
144
+ try {
145
+ await (0, promises_1.pipeline)(stream, response);
146
+ }
147
+ catch (error) {
148
+ if (!response.destroyed)
149
+ throw error;
150
+ }
151
+ finally {
152
+ response.removeListener('close', close);
153
+ stream.destroy();
154
+ }
155
+ });
156
+ }
157
+ delete(user, id, attachmentId) {
158
+ return (0, state_machine_1.runHttp)(() => this.attachments.deleteFor(user.sub, id, attachmentId));
159
+ }
160
+ };
161
+ exports.AttachmentsController = AttachmentsController;
162
+ __decorate([
163
+ (0, common_1.Get)(),
164
+ __param(0, (0, plugin_host_nest_1.CurrentUser)()),
165
+ __param(1, (0, common_1.Param)('id')),
166
+ __metadata("design:type", Function),
167
+ __metadata("design:paramtypes", [Object, String]),
168
+ __metadata("design:returntype", void 0)
169
+ ], AttachmentsController.prototype, "list", null);
170
+ __decorate([
171
+ (0, common_1.Get)(':attachmentId'),
172
+ __param(0, (0, plugin_host_nest_1.CurrentUser)()),
173
+ __param(1, (0, common_1.Param)('id')),
174
+ __param(2, (0, common_1.Param)('attachmentId')),
175
+ __metadata("design:type", Function),
176
+ __metadata("design:paramtypes", [Object, String, String]),
177
+ __metadata("design:returntype", void 0)
178
+ ], AttachmentsController.prototype, "get", null);
179
+ __decorate([
180
+ (0, common_1.Post)(),
181
+ __param(0, (0, plugin_host_nest_1.CurrentUser)()),
182
+ __param(1, (0, common_1.Param)('id')),
183
+ __param(2, (0, common_1.Req)()),
184
+ __param(3, (0, common_1.Res)({ passthrough: true })),
185
+ __metadata("design:type", Function),
186
+ __metadata("design:paramtypes", [Object, String, Function, Function]),
187
+ __metadata("design:returntype", void 0)
188
+ ], AttachmentsController.prototype, "upload", null);
189
+ __decorate([
190
+ (0, common_1.Get)(':attachmentId/content'),
191
+ __param(0, (0, plugin_host_nest_1.CurrentUser)()),
192
+ __param(1, (0, common_1.Param)('id')),
193
+ __param(2, (0, common_1.Param)('attachmentId')),
194
+ __param(3, (0, common_1.Res)()),
195
+ __metadata("design:type", Function),
196
+ __metadata("design:paramtypes", [Object, String, String, Function]),
197
+ __metadata("design:returntype", void 0)
198
+ ], AttachmentsController.prototype, "content", null);
199
+ __decorate([
200
+ (0, common_1.Delete)(':attachmentId'),
201
+ __param(0, (0, plugin_host_nest_1.CurrentUser)()),
202
+ __param(1, (0, common_1.Param)('id')),
203
+ __param(2, (0, common_1.Param)('attachmentId')),
204
+ __metadata("design:type", Function),
205
+ __metadata("design:paramtypes", [Object, String, String]),
206
+ __metadata("design:returntype", void 0)
207
+ ], AttachmentsController.prototype, "delete", null);
208
+ exports.AttachmentsController = AttachmentsController = __decorate([
209
+ (0, common_1.Controller)('state-machine-instances/:id/attachments'),
210
+ (0, common_1.UseGuards)(plugin_host_nest_1.InteractiveAuthGuard),
211
+ __param(0, (0, common_1.Inject)(service_1.AttachmentsService)),
212
+ __metadata("design:paramtypes", [service_1.AttachmentsService])
213
+ ], AttachmentsController);
214
+ //# sourceMappingURL=controller.js.map