davepi-plugin-object-storage 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.
package/lib/routes.js ADDED
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The three plugin-owned REST routes that drive the presigned-URL
5
+ * upload pipeline. Mounted by the plugin's setup() onto the same
6
+ * Express app the schema layer uses, so requests go through the
7
+ * framework's terminal `errorHandler` for response shape consistency.
8
+ *
9
+ * All three require `auth(true)` so a JWT is present; `userId` comes
10
+ * off `req.user.user_id` exactly like the framework's auto-generated
11
+ * REST handlers.
12
+ *
13
+ * Routes:
14
+ *
15
+ * POST /api/files/upload-url — issue a presigned PUT URL +
16
+ * create a `pending` file record
17
+ * POST /api/files/:fileId/complete — verify the upload landed,
18
+ * flip status to `uploaded`
19
+ * GET /api/files/:fileId/download-url — issue a short-lived
20
+ * presigned GET URL
21
+ *
22
+ * Tenant scope is enforced at the route layer (`userId` filter on
23
+ * every Mongo query). The schema's write-locked fields are a second
24
+ * line of defence at the framework's generic CRUD surface, but for
25
+ * these custom routes the plugin is the only writer and validates
26
+ * directly.
27
+ */
28
+
29
+ const { buildKey } = require('./keys');
30
+ const { validateUploadRequest } = require('./config');
31
+
32
+ function buildRouter({
33
+ router,
34
+ auth,
35
+ asyncHandler,
36
+ errors,
37
+ getModel,
38
+ adapter,
39
+ config,
40
+ }) {
41
+ const { NotFoundError, ValidationError, ForbiddenError } = errors;
42
+
43
+ function ownerOnly(record, userId) {
44
+ if (!record) throw new NotFoundError('file');
45
+ if (String(record.userId) !== String(userId)) {
46
+ // 404, not 403 — never leak that a foreign-tenant file exists.
47
+ throw new NotFoundError('file');
48
+ }
49
+ }
50
+
51
+ router.post(
52
+ '/upload-url',
53
+ auth(true),
54
+ asyncHandler(async (req, res) => {
55
+ const userId = req.user && req.user.user_id;
56
+ if (!userId) throw new ForbiddenError('auth required');
57
+
58
+ const { contentType, originalName, size, metadata } = req.body || {};
59
+ // Single source of truth for upload-policy validation — shared
60
+ // with the programmatic `createUploadUrl` API so a hook author
61
+ // can't bypass MIME / size checks by reaching for the JS surface.
62
+ validateUploadRequest({ contentType, size, config, errors });
63
+
64
+ const key = buildKey({ userId, originalName });
65
+ const Model = getModel();
66
+ const doc = await Model.create({
67
+ userId: String(userId),
68
+ accountId: req.user.account_id ? String(req.user.account_id) : undefined,
69
+ key,
70
+ bucket: adapter.bucket,
71
+ contentType,
72
+ size: size != null ? size : undefined,
73
+ status: 'pending',
74
+ originalName: originalName || undefined,
75
+ metadata: metadata && typeof metadata === 'object' ? metadata : undefined,
76
+ });
77
+
78
+ const url = await adapter.getSignedPutUrl({
79
+ key,
80
+ contentType,
81
+ expires: config.putUrlTtlSeconds,
82
+ });
83
+
84
+ res.status(201).json({
85
+ fileId: String(doc._id),
86
+ key,
87
+ url,
88
+ expiresIn: config.putUrlTtlSeconds,
89
+ contentType,
90
+ });
91
+ })
92
+ );
93
+
94
+ router.post(
95
+ '/:fileId/complete',
96
+ auth(true),
97
+ asyncHandler(async (req, res) => {
98
+ const userId = req.user && req.user.user_id;
99
+ if (!userId) throw new ForbiddenError('auth required');
100
+
101
+ const Model = getModel();
102
+ const doc = await Model.findById(req.params.fileId);
103
+ ownerOnly(doc, userId);
104
+
105
+ if (doc.status === 'uploaded') {
106
+ // Idempotent — the client may legitimately retry /complete if
107
+ // the PUT response was ambiguous (network blip, mobile network
108
+ // killing the connection mid-200). Return the existing state.
109
+ return res.status(200).json(serialize(doc));
110
+ }
111
+
112
+ if (config.verifyOnComplete) {
113
+ const head = await adapter.headObject({ key: doc.key });
114
+ if (!head.exists) {
115
+ throw new ValidationError(
116
+ 'upload not found in storage; client must PUT to the presigned URL before calling /complete'
117
+ );
118
+ }
119
+ // If the client provided a size at upload-url time, validate
120
+ // the storage layer reports the same value. A mismatch usually
121
+ // means the client lied at presign time to bypass the maxBytes
122
+ // gate, then PUT a bigger file.
123
+ if (
124
+ typeof doc.size === 'number' &&
125
+ typeof head.contentLength === 'number' &&
126
+ doc.size !== head.contentLength
127
+ ) {
128
+ throw new ValidationError(
129
+ `uploaded size ${head.contentLength} does not match declared size ${doc.size}`
130
+ );
131
+ }
132
+ if (
133
+ typeof head.contentLength === 'number' &&
134
+ head.contentLength > config.maxBytes
135
+ ) {
136
+ // Even if the client didn't declare a size up-front, refuse
137
+ // to flip to `uploaded` if the actual blob is over the limit.
138
+ // The blob stays in the bucket — the reaper / cascade-delete
139
+ // path will eventually clean it up.
140
+ throw new ValidationError(
141
+ `uploaded size ${head.contentLength} exceeds S3_MAX_BYTES (${config.maxBytes})`
142
+ );
143
+ }
144
+ if (head.contentLength != null) doc.size = head.contentLength;
145
+ if (head.etag) doc.etag = head.etag;
146
+ }
147
+
148
+ doc.status = 'uploaded';
149
+ doc.uploadedAt = new Date();
150
+ await doc.save();
151
+
152
+ res.status(200).json(serialize(doc));
153
+ })
154
+ );
155
+
156
+ router.get(
157
+ '/:fileId/download-url',
158
+ auth(true),
159
+ asyncHandler(async (req, res) => {
160
+ const userId = req.user && req.user.user_id;
161
+ if (!userId) throw new ForbiddenError('auth required');
162
+
163
+ const Model = getModel();
164
+ const doc = await Model.findById(req.params.fileId);
165
+ ownerOnly(doc, userId);
166
+
167
+ if (doc.status !== 'uploaded') {
168
+ throw new ValidationError(
169
+ `file ${req.params.fileId} is in status "${doc.status}"; only uploaded files can be downloaded`
170
+ );
171
+ }
172
+
173
+ const url = await adapter.getSignedGetUrl({
174
+ key: doc.key,
175
+ expires: config.getUrlTtlSeconds,
176
+ });
177
+
178
+ res.status(200).json({
179
+ fileId: String(doc._id),
180
+ url,
181
+ expiresIn: config.getUrlTtlSeconds,
182
+ });
183
+ })
184
+ );
185
+
186
+ return router;
187
+ }
188
+
189
+ function serialize(doc) {
190
+ const o = typeof doc.toObject === 'function' ? doc.toObject() : doc;
191
+ return {
192
+ fileId: String(o._id),
193
+ userId: o.userId,
194
+ accountId: o.accountId || null,
195
+ key: o.key,
196
+ bucket: o.bucket || null,
197
+ contentType: o.contentType,
198
+ size: o.size != null ? o.size : null,
199
+ status: o.status,
200
+ originalName: o.originalName || null,
201
+ metadata: o.metadata || null,
202
+ uploadedAt: o.uploadedAt || null,
203
+ etag: o.etag || null,
204
+ createdAt: o.createdAt || null,
205
+ updatedAt: o.updatedAt || null,
206
+ };
207
+ }
208
+
209
+ module.exports = { buildRouter, serialize };
package/lib/schema.js ADDED
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const { userIdOfKey } = require('./keys');
4
+
5
+ /**
6
+ * Build the `file` schema the plugin registers with the framework's
7
+ * schemaLoader. Once registered, the file collection is queryable
8
+ * through every standard surface (REST list / read, GraphQL, MCP,
9
+ * Swagger, the admin SPA) like any other dAvePi resource — but the
10
+ * fields that describe where the bytes live are write-locked so
11
+ * clients can't lie about them.
12
+ *
13
+ * Write-locked fields: `key`, `bucket`, `status`, `size`, `contentType`.
14
+ * Anyone trying to POST or PUT these via the standard CRUD routes has
15
+ * them filtered out by `filterWritable` (the sentinel ACL role no real
16
+ * user holds — same trick `davepi-plugin-audit` uses on its `audit`
17
+ * collection). The plugin's own upload-url / complete endpoints write
18
+ * these directly via the Mongoose model, bypassing the API surface.
19
+ *
20
+ * `originalName` and `metadata` stay writable so a client can rename a
21
+ * file or attach app-specific labels through the regular PUT route.
22
+ *
23
+ * The schema declares an `afterDelete` hook that talks to the storage
24
+ * adapter when `S3_CASCADE_DELETE=true`. The hook is best-effort —
25
+ * `after*` hooks already swallow throws per the framework contract, so
26
+ * a transient bucket error logs but doesn't surface as a 5xx.
27
+ */
28
+
29
+ function buildFileSchema({
30
+ mongoose,
31
+ errors,
32
+ version,
33
+ path,
34
+ cascadeDelete,
35
+ getAdapter,
36
+ log,
37
+ }) {
38
+ const Mixed = mongoose.Schema.Types.Mixed;
39
+ const NO_ONE = ['__davepi_plugin_object_storage_only__'];
40
+ const withWriteLock = (field) => ({
41
+ ...field,
42
+ acl: { ...(field.acl || {}), create: NO_ONE, update: NO_ONE },
43
+ });
44
+
45
+ const afterDelete = async ({ record }) => {
46
+ if (!cascadeDelete) return;
47
+ if (!record || !record.key) return;
48
+ // Defence-in-depth: the `key` field is API-write-locked via the
49
+ // sentinel ACL above, so a client can't directly stamp a foreign
50
+ // tenant's key onto a file record. But framework bugs, corrupted
51
+ // documents, or a misconfigured admin tool that wrote directly
52
+ // through Mongoose could leave a row whose key disagrees with its
53
+ // userId. `userIdOfKey` parses the leading segment of the key
54
+ // (which buildKey always stamps as `<userId>/...`) — if it doesn't
55
+ // match record.userId, refuse to delete the blob rather than
56
+ // potentially wiping another tenant's bytes.
57
+ const keyUserId = userIdOfKey(record.key);
58
+ if (!keyUserId || String(keyUserId) !== String(record.userId)) {
59
+ if (log && typeof log.warn === 'function') {
60
+ log.warn(
61
+ { plugin: 'object-storage', key: record.key, recordUserId: record.userId },
62
+ 'davepi-plugin-object-storage: cascade-delete refused — key does not belong to record owner'
63
+ );
64
+ }
65
+ return;
66
+ }
67
+ try {
68
+ const adapter = getAdapter();
69
+ if (!adapter) return;
70
+ await adapter.deleteObject({ key: record.key });
71
+ } catch (err) {
72
+ // Best-effort: storage failure must not back-propagate into the
73
+ // delete response. Same posture as audit's bus write — log via
74
+ // the framework's pino instance and move on.
75
+ if (log && typeof log.error === 'function') {
76
+ log.error(
77
+ { err, plugin: 'object-storage', key: record.key },
78
+ 'davepi-plugin-object-storage: cascade-delete of storage object failed'
79
+ );
80
+ }
81
+ }
82
+ };
83
+
84
+ return {
85
+ path,
86
+ collection: path,
87
+ version,
88
+ // Soft-delete leaves the record around but the blob's still in the
89
+ // bucket. Cascade-delete only runs on hard-delete (the framework's
90
+ // afterDelete hook fires once per delete path; the soft-delete path
91
+ // fires it with the tombstoned record, which we ignore unless the
92
+ // hard-delete path runs). To keep semantics clean — and because file
93
+ // records track an external mutable resource — `softDelete: false`
94
+ // means the API always hard-deletes. Consumers who want a "trash"
95
+ // workflow can layer their own status (`archived` etc.) on top.
96
+ softDelete: false,
97
+ fields: [
98
+ withWriteLock({ name: 'userId', type: String, required: true }),
99
+ withWriteLock({ name: 'accountId', type: String }),
100
+ withWriteLock({ name: 'key', type: String, required: true }),
101
+ withWriteLock({ name: 'bucket', type: String }),
102
+ withWriteLock({ name: 'contentType', type: String, required: true }),
103
+ withWriteLock({ name: 'size', type: Number }),
104
+ withWriteLock({
105
+ name: 'status',
106
+ type: String,
107
+ enum: ['pending', 'uploaded', 'deleted'],
108
+ default: 'pending',
109
+ required: true,
110
+ }),
111
+ { name: 'originalName', type: String },
112
+ { name: 'metadata', type: Mixed },
113
+ withWriteLock({ name: 'uploadedAt', type: Date }),
114
+ withWriteLock({ name: 'etag', type: String }),
115
+ ],
116
+ hooks: { afterDelete },
117
+ // No `acl.list` bypass — files are tenant-scoped like every other
118
+ // resource. Admins who need cross-tenant visibility can add a
119
+ // schema override in their consumer project; the plugin's default
120
+ // is the strict invariant.
121
+ };
122
+ }
123
+
124
+ module.exports = { buildFileSchema };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "davepi-plugin-object-storage",
3
+ "version": "0.1.0",
4
+ "description": "Presigned-URL object-storage uploads for dAvePi. Auto-registers a generic `file` schema, mounts upload-url / complete / download-url routes that hand the client a presigned URL so bytes travel client→bucket without proxying through the API. Pluggable backend supports AWS S3, Cloudflare R2, MinIO, and Google Cloud Storage.",
5
+ "license": "ISC",
6
+ "homepage": "https://docs.davepi.dev/features/plugins/",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/projik/davepi.git",
10
+ "directory": "packages/davepi-plugin-object-storage"
11
+ },
12
+ "keywords": [
13
+ "davepi",
14
+ "davepi-plugin",
15
+ "object-storage",
16
+ "s3",
17
+ "r2",
18
+ "minio",
19
+ "gcs",
20
+ "uploads",
21
+ "presigned-url"
22
+ ],
23
+ "main": "index.js",
24
+ "files": [
25
+ "index.js",
26
+ "lib",
27
+ "README.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "dependencies": {
33
+ "@aws-sdk/client-s3": "^3.583.0",
34
+ "@aws-sdk/s3-request-presigner": "^3.583.0"
35
+ },
36
+ "optionalDependencies": {
37
+ "@google-cloud/storage": "^7.11.0"
38
+ },
39
+ "peerDependencies": {
40
+ "davepi": ">=1.0.5"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "davepi": {
44
+ "optional": false
45
+ }
46
+ },
47
+ "scripts": {
48
+ "test": "node --test test/*.test.js"
49
+ }
50
+ }