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/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # davepi-plugin-object-storage
2
+
3
+ Presigned-URL file uploads for [dAvePi][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 server. Pluggable backend supports AWS S3, Cloudflare R2, MinIO, and Google Cloud Storage.
4
+
5
+ [davepi]: https://docs.davepi.dev
6
+
7
+ ## Why this plugin (vs. the in-tree `type: 'File'` field)
8
+
9
+ The framework already ships a per-field, server-proxied upload pipeline via [`type: 'File'`](https://docs.davepi.dev/features/files/). That covers small attachments (avatars, document scans, logos). This plugin solves a different shape:
10
+
11
+ - **Big files.** Multi-GB uploads can't ride a multer multipart request — and on serverless (Lambda ~6 MB, Vercel ~4.5 MB) they can't ride the request body at all.
12
+ - **Direct-to-bucket.** Client → bucket is one network hop; client → API → bucket is two. With presigned URLs the API server never sees the bytes, so you don't pay egress/ingress twice or burn API CPU/RAM.
13
+ - **Files as a first-class resource.** Instead of an embedded `FileMeta` on a parent record, this plugin gives you a real `file` collection — queryable, paginated, deletable, joinable. Right shape for media libraries, chat attachments, CMS asset pickers, anything where the file isn't anchored to one parent.
14
+ - **R2 / MinIO / GCS.** The in-tree field is `local` or `s3` only; this plugin adds Cloudflare R2, self-hosted MinIO, and Google Cloud Storage behind a shared API.
15
+
16
+ Both pipelines coexist in the same app. Use whichever fits.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install davepi-plugin-object-storage
22
+ ```
23
+
24
+ Add it to your project's `package.json` under `davepi.plugins`:
25
+
26
+ ```json
27
+ {
28
+ "davepi": {
29
+ "plugins": ["davepi-plugin-object-storage"]
30
+ }
31
+ }
32
+ ```
33
+
34
+ Set the bucket in `.env`:
35
+
36
+ ```bash
37
+ S3_BACKEND=aws # or r2 / minio / gcs
38
+ S3_BUCKET=my-uploads
39
+ S3_REGION=us-east-1
40
+ S3_ACCESS_KEY_ID=...
41
+ S3_SECRET_ACCESS_KEY=...
42
+ ```
43
+
44
+ That's it — on boot, the plugin constructs the backend adapter, registers the `file` schema, mounts the three custom routes under `/api/files`, and starts a background reaper that sweeps abandoned `pending` uploads.
45
+
46
+ ## Quick start
47
+
48
+ ```js
49
+ // 1. Client requests a presigned PUT URL.
50
+ const presign = await fetch('/api/files/upload-url', {
51
+ method: 'POST',
52
+ headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({
54
+ contentType: 'image/png',
55
+ originalName: 'company-logo.png',
56
+ size: 12345, // optional, used for max-bytes gate
57
+ metadata: { tag: 'avatar' }, // optional, free-form per-record metadata
58
+ }),
59
+ }).then((r) => r.json());
60
+ // presign = { fileId, key, url, expiresIn, contentType }
61
+
62
+ // 2. Client PUTs the bytes directly to S3.
63
+ await fetch(presign.url, {
64
+ method: 'PUT',
65
+ headers: { 'Content-Type': 'image/png' },
66
+ body: blob,
67
+ });
68
+
69
+ // 3. Client tells the server the upload landed.
70
+ await fetch(`/api/files/${presign.fileId}/complete`, {
71
+ method: 'POST',
72
+ headers: { Authorization: `Bearer ${jwt}` },
73
+ }).then((r) => r.json());
74
+ // → { fileId, status: 'uploaded', size, etag, ... }
75
+
76
+ // 4. Later — fetch a short-lived download URL for the file.
77
+ const dl = await fetch(`/api/files/${presign.fileId}/download-url`, {
78
+ headers: { Authorization: `Bearer ${jwt}` },
79
+ }).then((r) => r.json());
80
+ // → { fileId, url, expiresIn }
81
+ ```
82
+
83
+ ## Configure
84
+
85
+ All config is env-driven.
86
+
87
+ | Variable | Required | Default | Description |
88
+ |----------|----------|---------|-------------|
89
+ | `S3_BACKEND` | no | `aws` | One of `aws` / `r2` / `minio` / `gcs`. Picks the adapter. |
90
+ | `S3_BUCKET` | **yes** (else dormant) | — | Bucket name. |
91
+ | `S3_REGION` | yes for `aws` | from `AWS_REGION` | e.g. `us-east-1`. |
92
+ | `S3_ENDPOINT` | required for `r2` / `minio` | — | Custom endpoint URL. |
93
+ | `S3_ACCESS_KEY_ID` | dev / standalone | — | Falls back to AWS SDK default credential chain (IRSA, EC2/ECS metadata, `~/.aws/credentials`). |
94
+ | `S3_SECRET_ACCESS_KEY` | dev / standalone | — | Same. |
95
+ | `S3_FORCE_PATH_STYLE` | no | `true` for minio, else `false` | Forces `bucket-in-path` URLs. |
96
+ | `S3_PUBLIC_BASE_URL` | no | computed | CDN base for `publicUrl` overrides (e.g. `https://cdn.example.com`). |
97
+ | `S3_PUT_URL_TTL_SECONDS` | no | `300` | Lifetime of presigned PUT URLs. |
98
+ | `S3_GET_URL_TTL_SECONDS` | no | `600` | Lifetime of presigned GET URLs. |
99
+ | `S3_MAX_BYTES` | no | `52428800` (50 MiB) | Max accepted `size` for an upload-url request, and the cap re-checked at `/complete` time. |
100
+ | `S3_ALLOWED_MIME` | no | *(any)* | Comma-separated allowlist (e.g. `image/png,image/jpeg,application/pdf`). Wildcards like `image/*` are honoured. |
101
+ | `S3_CASCADE_DELETE` | no | `false` | If `true`, deleting a `file` record via `DELETE /api/v1/file/:id` also deletes the underlying object. Irreversible — opt-in. |
102
+ | `S3_VERIFY_ON_COMPLETE` | no | `true` | If `true`, `/complete` HEADs the object to verify presence + size before flipping status. Disable only when you trust the upload path end-to-end (e.g. a S3 event-trigger Lambda already verified it). |
103
+ | `S3_REAP_ENABLED` | no | `true` | Background sweep of orphaned `pending` records. Disable when you run cron separately. |
104
+ | `S3_REAP_INTERVAL_MS` | no | `300000` (5 min) | Sweep frequency. |
105
+ | `S3_REAP_MULTIPLIER` | no | `3` | A `pending` record is reaped when `createdAt + putUrlTtl × multiplier < now`. The multiplier gives slow networks comfortable headroom before cleanup. |
106
+ | `S3_FILE_PATH` | no | `file` | Schema path. Override if your project already has its own `file` schema. |
107
+ | `S3_FILE_VERSION` | no | `v1` | Schema version key. |
108
+ | `S3_ROUTE_PREFIX` | no | `/api/files` | Where the upload-url / complete / download-url routes mount. |
109
+ | `GCS_PROJECT_ID` | required for `gcs` | — | GCS project. |
110
+ | `GCS_KEY_FILE` | required for `gcs` | — | Path to a service-account JSON. |
111
+
112
+ ## What gets written
113
+
114
+ The plugin registers a `file` schema. Each row carries:
115
+
116
+ | Field | Description |
117
+ |-------|-------------|
118
+ | `userId` | Owner. Tenant-scope predicate for every read. |
119
+ | `accountId` | Owner's accountId, when present on the JWT. |
120
+ | `key` | Storage key (`<userId>/<8-hex>/<safe-original-name>`). Write-locked at the API layer. |
121
+ | `bucket` | Bucket the object lives in. Write-locked. |
122
+ | `contentType` | MIME at upload time. Write-locked. |
123
+ | `size` | Bytes, validated against `S3_MAX_BYTES`. Write-locked. |
124
+ | `status` | `pending` → `uploaded`. Write-locked. The plugin's own routes are the only writers. |
125
+ | `originalName` | Client-supplied filename. Writable via the regular PUT route. |
126
+ | `metadata` | Free-form `Mixed`. Writable via the regular PUT route so consumers can attach labels. |
127
+ | `uploadedAt` | Set on `/complete`. Write-locked. |
128
+ | `etag` | Storage etag at `/complete`. Write-locked. |
129
+
130
+ Write-locks use the same sentinel-ACL trick `davepi-plugin-audit` uses for its `audit` collection — the framework's `filterWritable` strips these keys from any inbound POST / PUT body so no client can lie about where their bytes live.
131
+
132
+ ## Reading the file collection
133
+
134
+ The `file` schema is registered like any other dAvePi schema, so every standard surface works:
135
+
136
+ ```bash
137
+ # List my files
138
+ GET /api/v1/file?status=uploaded&__sort=createdAt:desc
139
+
140
+ # Read one
141
+ GET /api/v1/file/<id>
142
+
143
+ # Update metadata
144
+ PUT /api/v1/file/<id>
145
+ { "metadata": { "tag": "hero-image" } }
146
+
147
+ # Hard-delete (also removes the blob if S3_CASCADE_DELETE=true)
148
+ DELETE /api/v1/file/<id>
149
+ ```
150
+
151
+ GraphQL: `file`, `files`, `fileFilter`, `fileUpdateById`, `fileRemoveById` — same shape as any other dAvePi resource.
152
+
153
+ ## Programmatic API
154
+
155
+ For schema lifecycle hooks and custom routes:
156
+
157
+ ```js
158
+ const storage = require('davepi-plugin-object-storage');
159
+
160
+ // Issue a presigned PUT URL from inside a hook.
161
+ const { url, fileId } = await storage.createUploadUrl({
162
+ user: req.user,
163
+ contentType: 'image/png',
164
+ originalName: 'avatar.png',
165
+ size: 12345,
166
+ metadata: { kind: 'avatar' },
167
+ });
168
+
169
+ // Sign a short-lived GET URL. Returns null if the file isn't owned by
170
+ // the caller — same tenant-isolation posture as the REST route.
171
+ const dl = await storage.createDownloadUrl({ user: req.user, fileId });
172
+
173
+ // Server-side delete (both the blob and the record).
174
+ await storage.deleteFile({ user: req.user, fileId });
175
+
176
+ // Adapter escape hatch — call provider-specific APIs directly.
177
+ const head = await storage.adapter.headObject({ key });
178
+ ```
179
+
180
+ ## Bucket CORS
181
+
182
+ The bucket must allow `PUT` from the origins your client runs on. Paste the JSON below into the bucket's CORS configuration.
183
+
184
+ ### AWS S3
185
+
186
+ ```json
187
+ [
188
+ {
189
+ "AllowedHeaders": ["*"],
190
+ "AllowedMethods": ["PUT", "GET"],
191
+ "AllowedOrigins": ["https://app.example.com"],
192
+ "ExposeHeaders": ["ETag"],
193
+ "MaxAgeSeconds": 3000
194
+ }
195
+ ]
196
+ ```
197
+
198
+ Paste at AWS Console → S3 → Bucket → Permissions → CORS.
199
+
200
+ ### Cloudflare R2
201
+
202
+ R2 uses the same JSON shape as AWS. Paste at Cloudflare Dashboard → R2 → Bucket → Settings → CORS Policy.
203
+
204
+ ```json
205
+ [
206
+ {
207
+ "AllowedHeaders": ["content-type", "content-length"],
208
+ "AllowedMethods": ["PUT", "GET"],
209
+ "AllowedOrigins": ["https://app.example.com"],
210
+ "ExposeHeaders": ["ETag"]
211
+ }
212
+ ]
213
+ ```
214
+
215
+ ### MinIO
216
+
217
+ MinIO has CORS off by default; enable via the `mc` CLI:
218
+
219
+ ```bash
220
+ mc admin config set local cors_allow_origin="https://app.example.com"
221
+ mc admin service restart local
222
+ ```
223
+
224
+ ### Google Cloud Storage
225
+
226
+ GCS uses a slightly different shape — `gsutil` (or the `gcloud storage` newer equivalent) applies CORS from a JSON file:
227
+
228
+ ```json
229
+ [
230
+ {
231
+ "origin": ["https://app.example.com"],
232
+ "method": ["PUT", "GET"],
233
+ "responseHeader": ["Content-Type", "ETag"],
234
+ "maxAgeSeconds": 3000
235
+ }
236
+ ]
237
+ ```
238
+
239
+ Apply with `gsutil cors set cors.json gs://my-bucket`.
240
+
241
+ ## Soft delete vs. cascade delete
242
+
243
+ The `file` schema declares `softDelete: false`: a `DELETE /api/v1/file/:id` is a *hard* delete by design. File records track a mutable external resource — leaving a tombstoned row whose blob may or may not still exist in the bucket is more confusing than helpful.
244
+
245
+ `S3_CASCADE_DELETE` controls whether the **storage object** is removed at the same time. Off by default because storage deletion is irreversible — a misconfigured admin endpoint that runs `DELETE` on every row would otherwise empty the bucket. Once you've validated the operator surface, flip it on.
246
+
247
+ ## Multi-tenant isolation
248
+
249
+ The same rules as every other dAvePi resource:
250
+
251
+ - Keys are namespaced by `userId` (`<userId>/<8-hex>/<safe-name>`), so a flat `aws s3 ls` already shows ownership.
252
+ - Every route filters by `req.user.user_id`. Foreign-tenant `fileId`s return `404 NOT_FOUND` — never `403 FORBIDDEN`, so the response shape doesn't leak existence.
253
+ - The `/complete` and `/download-url` routes refuse to issue presigned URLs for files whose `userId` doesn't match the caller.
254
+ - The plugin's own setUp goes through `schemaLoader.moveErrorHandlerToEnd()` after mounting routes so plugin-thrown errors land in the framework's centralised `{ error: { code, message } }` shape.
255
+
256
+ ## Tests
257
+
258
+ ```bash
259
+ cd packages/davepi-plugin-object-storage
260
+ npm test
261
+ ```
262
+
263
+ 67 unit tests via `node --test` (config, key generation, AWS adapter, GCS adapter, routes, reaper, plugin setup). Plus an integration test under the framework's Jest suite (`test/plugin-object-storage-integration.test.js`) that drives a real `loadPlugins` → REST upload-url → complete → download-url flow against `mongodb-memory-server` with a mock adapter, asserting tenant isolation, mime/size allowlists, and cascade-delete behaviour.
264
+
265
+ ## License
266
+
267
+ ISC
package/index.js ADDED
@@ -0,0 +1,326 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * davepi-plugin-object-storage
5
+ *
6
+ * Presigned-URL file uploads for dAvePi. Mounted by listing the package
7
+ * under the consumer project's `package.json -> davepi.plugins`:
8
+ *
9
+ * {
10
+ * "davepi": { "plugins": ["davepi-plugin-object-storage"] }
11
+ * }
12
+ *
13
+ * Where this fits in the framework: the in-tree `type: 'File'` field
14
+ * is the per-record-field, proxy-the-bytes pipeline that's fine for
15
+ * avatars and document attachments. This plugin is the "client uploads
16
+ * straight to the bucket" pipeline: the API never sees the bytes, big
17
+ * files don't choke serverless request limits, and files become a
18
+ * first-class queryable resource (one row per file, not embedded on a
19
+ * parent record). Both can coexist in the same app.
20
+ *
21
+ * Behaviour at setup:
22
+ *
23
+ * 1. If `S3_BUCKET` is unset (or `S3_BACKEND=gcs` with no GCS SDK
24
+ * installed), the plugin stays dormant. Routes are not mounted,
25
+ * the schema is not registered, the reaper does not start. Calls
26
+ * to `createUploadUrl` / `deleteFile` throw a clear "configure
27
+ * S3_BUCKET" message.
28
+ * 2. The configured adapter is constructed (aws / r2 / minio / gcs).
29
+ * 3. The `file` schema is registered via `schemaLoader.loadSchema`
30
+ * so REST / GraphQL / MCP / Swagger / the admin SPA see it.
31
+ * 4. Three routes are mounted under `S3_ROUTE_PREFIX` (default
32
+ * `/api/files`): upload-url, :id/complete, :id/download-url.
33
+ * 5. The reaper starts (unless `S3_REAP_ENABLED=false`) and sweeps
34
+ * orphaned `pending` records every `S3_REAP_INTERVAL_MS`.
35
+ *
36
+ * The plugin also exports a small programmatic API for callers who want
37
+ * to drive uploads from a schema lifecycle hook or a custom route:
38
+ * `createUploadUrl`, `createDownloadUrl`, `deleteFile`. Each requires a
39
+ * `user` parameter (the JWT payload) so tenant scoping is explicit.
40
+ */
41
+
42
+ const { readConfig, validateUploadRequest } = require('./lib/config');
43
+ const { createAdapter } = require('./lib/adapters');
44
+ const { buildFileSchema } = require('./lib/schema');
45
+ const { buildRouter } = require('./lib/routes');
46
+ const { createReaper } = require('./lib/reaper');
47
+ const { buildKey } = require('./lib/keys');
48
+
49
+ /**
50
+ * Resolve a framework / peer-dep module by name, wrapping a MODULE_NOT_FOUND
51
+ * with a message that points the operator at the broken peer dep
52
+ * instead of leaving them with a bare require stack. The package's own
53
+ * unit tests bypass this entirely by injecting stubs at `createPlugin`
54
+ * time — so this only fires for the dep-resolution path that real
55
+ * consumers walk.
56
+ */
57
+ function requireOrFail(name) {
58
+ try {
59
+ return require(name);
60
+ } catch (err) {
61
+ throw new Error(
62
+ `davepi-plugin-object-storage: failed to load required module '${name}'. ` +
63
+ 'Ensure the davepi peer dependency is installed (>= 1.0.5). ' +
64
+ `Underlying error: ${err && err.message}`
65
+ );
66
+ }
67
+ }
68
+
69
+ function createPlugin(opts = {}) {
70
+ const env = opts.env || process.env;
71
+ const config = { ...readConfig(env), ...(opts.configOverrides || {}) };
72
+ const sdkOverrides = opts.sdkOverrides || {};
73
+ const adapterOverride = opts.adapter || null;
74
+ const injectedErrors = opts.errors || null;
75
+ const injectedAuth = opts.auth || null;
76
+ const injectedAsyncHandler = opts.asyncHandler || null;
77
+ const injectedMongoose = opts.mongoose || null;
78
+ const injectedExpress = opts.express || null;
79
+
80
+ const state = {
81
+ enabled: false,
82
+ adapter: null,
83
+ Model: null,
84
+ reaper: null,
85
+ log: null,
86
+ // Captured at setup() so the programmatic API can route its
87
+ // ValidationError throws through the same constructors the REST
88
+ // layer uses. Lets `createUploadUrl` share `validateUploadRequest`
89
+ // with the route handler — the reviewer's footgun fix on PR #122.
90
+ errors: null,
91
+ };
92
+
93
+ function ensureEnabled(call) {
94
+ if (!state.enabled) {
95
+ throw new Error(
96
+ `davepi-plugin-object-storage: ${call} called but plugin is dormant ` +
97
+ '(S3_BUCKET not set, or setup has not run yet)'
98
+ );
99
+ }
100
+ }
101
+
102
+ async function createUploadUrl({ user, contentType, originalName, size, metadata }) {
103
+ ensureEnabled('createUploadUrl');
104
+ if (!user || !user.user_id) {
105
+ throw new Error('davepi-plugin-object-storage: createUploadUrl requires { user: { user_id } }');
106
+ }
107
+ // Same policy enforcement the REST `POST /upload-url` route runs —
108
+ // a hook author can't bypass S3_ALLOWED_MIME / S3_MAX_BYTES by
109
+ // reaching for the programmatic API instead of the HTTP route.
110
+ validateUploadRequest({ contentType, size, config, errors: state.errors });
111
+ const userId = String(user.user_id);
112
+ const key = buildKey({ userId, originalName });
113
+ const doc = await state.Model.create({
114
+ userId,
115
+ accountId: user.account_id ? String(user.account_id) : undefined,
116
+ key,
117
+ bucket: state.adapter.bucket,
118
+ contentType,
119
+ size: size != null ? size : undefined,
120
+ status: 'pending',
121
+ originalName,
122
+ metadata: metadata && typeof metadata === 'object' ? metadata : undefined,
123
+ });
124
+ const url = await state.adapter.getSignedPutUrl({
125
+ key,
126
+ contentType,
127
+ expires: config.putUrlTtlSeconds,
128
+ });
129
+ return { fileId: String(doc._id), key, url, expiresIn: config.putUrlTtlSeconds };
130
+ }
131
+
132
+ async function createDownloadUrl({ user, fileId }) {
133
+ ensureEnabled('createDownloadUrl');
134
+ if (!user || !user.user_id) {
135
+ throw new Error('davepi-plugin-object-storage: createDownloadUrl requires { user: { user_id } }');
136
+ }
137
+ const doc = await state.Model.findById(fileId);
138
+ if (!doc || String(doc.userId) !== String(user.user_id)) {
139
+ // Same posture as the REST route: don't leak existence of a
140
+ // foreign-tenant file via a distinct 404-vs-403 response.
141
+ return null;
142
+ }
143
+ if (doc.status !== 'uploaded') return null;
144
+ const url = await state.adapter.getSignedGetUrl({
145
+ key: doc.key,
146
+ expires: config.getUrlTtlSeconds,
147
+ });
148
+ return { fileId: String(doc._id), url, expiresIn: config.getUrlTtlSeconds };
149
+ }
150
+
151
+ async function deleteFile({ user, fileId }) {
152
+ ensureEnabled('deleteFile');
153
+ if (!user || !user.user_id) {
154
+ throw new Error('davepi-plugin-object-storage: deleteFile requires { user: { user_id } }');
155
+ }
156
+ const doc = await state.Model.findById(fileId);
157
+ if (!doc || String(doc.userId) !== String(user.user_id)) return false;
158
+ try {
159
+ await state.adapter.deleteObject({ key: doc.key });
160
+ } catch (err) {
161
+ if (state.log && typeof state.log.warn === 'function') {
162
+ state.log.warn(
163
+ { err, plugin: 'object-storage', key: doc.key },
164
+ 'davepi-plugin-object-storage: deleteFile failed to remove storage object'
165
+ );
166
+ }
167
+ // Don't bail — caller asked to delete the record, so we remove
168
+ // it from the DB even if storage hiccups. The orphaned blob will
169
+ // be picked up by a future audit/cleanup; the user-facing API
170
+ // doesn't need to know.
171
+ }
172
+ await state.Model.deleteOne({ _id: doc._id });
173
+ return true;
174
+ }
175
+
176
+ async function setup({ app, schemaLoader, bus, log, appName }) {
177
+ state.log = log;
178
+
179
+ // The ONLY soft-fail path: an unconfigured `S3_BUCKET` is the
180
+ // documented dormancy signal so an operator can ship the plugin
181
+ // in a project before turning S3 on (matches slack / postmark /
182
+ // audit dormancy semantics for their respective top-level config
183
+ // env vars). Everything below this point treats real failures as
184
+ // fail-fast — the pluginLoader contract is explicit that
185
+ // "Errors during plugin setup propagate — a broken plugin fails
186
+ // the boot. Silently dropping a plugin would hide
187
+ // misconfiguration from operators." (utils/pluginLoader.js)
188
+ if (!config.bucket) {
189
+ log.warn(
190
+ { plugin: 'object-storage' },
191
+ 'S3_BUCKET not set; davepi-plugin-object-storage is dormant'
192
+ );
193
+ return;
194
+ }
195
+ if (!schemaLoader || typeof schemaLoader.loadSchema !== 'function') {
196
+ throw new Error(
197
+ 'davepi-plugin-object-storage: setup({ schemaLoader }) is required'
198
+ );
199
+ }
200
+ if (!app || typeof app.use !== 'function') {
201
+ throw new Error(
202
+ 'davepi-plugin-object-storage: setup({ app }) is required'
203
+ );
204
+ }
205
+
206
+ // Lazy-resolve framework deps so the package's own unit tests
207
+ // (which don't install `davepi`) can run standalone. A failure
208
+ // here means the operator's runtime environment is broken — the
209
+ // peer dep on `davepi` should have made these available, so a
210
+ // miss is misconfiguration we fail loud on.
211
+ const mongoose = injectedMongoose || requireOrFail('mongoose');
212
+ const errors = injectedErrors || requireOrFail('davepi/utils/errors');
213
+ const auth = injectedAuth || requireOrFail('davepi/middleware/auth');
214
+ const asyncHandler = injectedAsyncHandler || requireOrFail('davepi/utils/asyncHandler');
215
+ const expressMod = injectedExpress || requireOrFail('express');
216
+
217
+ // Capture errors on state BEFORE adapter / schema steps so the
218
+ // programmatic API can route ValidationError through the same
219
+ // constructors the REST layer uses even if setup throws partway
220
+ // through.
221
+ state.errors = errors;
222
+
223
+ // Adapter construction can throw — most commonly when the operator
224
+ // set `S3_BACKEND=gcs` but didn't install `@google-cloud/storage`
225
+ // (the optionalDependency wasn't pulled in). That's misconfiguration,
226
+ // not a soft case: fail-fast so the operator sees it at boot, not
227
+ // when the first upload request lands.
228
+ state.adapter = adapterOverride || createAdapter(config, { sdkOverrides });
229
+
230
+ // Register the file schema. The afterDelete hook needs the live
231
+ // adapter, so we pass a thunk rather than the adapter itself —
232
+ // makes the schema reusable if the adapter is swapped in tests.
233
+ const schema = buildFileSchema({
234
+ mongoose,
235
+ errors,
236
+ version: config.fileVersion,
237
+ path: config.filePath,
238
+ cascadeDelete: config.cascadeDelete,
239
+ getAdapter: () => state.adapter,
240
+ log,
241
+ });
242
+ await schemaLoader.loadSchema(schema);
243
+ const entry = schemaLoader.getEntry(`${config.fileVersion}/${config.filePath}`);
244
+ if (!entry || !entry.model) {
245
+ throw new Error(
246
+ 'davepi-plugin-object-storage: file schema registered but ' +
247
+ 'schemaLoader.getEntry returned no model — framework contract violation'
248
+ );
249
+ }
250
+ state.Model = entry.model;
251
+ const router = expressMod.Router();
252
+ buildRouter({
253
+ router,
254
+ auth,
255
+ asyncHandler,
256
+ errors,
257
+ getModel: () => state.Model,
258
+ adapter: state.adapter,
259
+ config,
260
+ });
261
+ app.use(config.routePrefix, router);
262
+ // `app.use` appends to the middleware stack — so the just-mounted
263
+ // router sits AFTER the framework's terminal `errorHandler`,
264
+ // meaning a thrown `ValidationError` from our routes would bypass
265
+ // the centralised response shape. The framework re-asserts the
266
+ // errorHandler tail after the whole plugin batch completes (see
267
+ // app.js's `if (app.locals.plugins.length)` block); calling it
268
+ // here as well keeps the invariant correct for tests + hot-paths
269
+ // that load the plugin after boot. The operation is idempotent —
270
+ // it splices any existing errorHandler before re-appending.
271
+ if (typeof schemaLoader.moveErrorHandlerToEnd === 'function') {
272
+ schemaLoader.moveErrorHandlerToEnd();
273
+ }
274
+
275
+ state.reaper = createReaper({
276
+ getModel: () => state.Model,
277
+ adapter: state.adapter,
278
+ config,
279
+ log,
280
+ });
281
+ state.reaper.start();
282
+
283
+ state.enabled = true;
284
+
285
+ log.info(
286
+ {
287
+ plugin: 'object-storage',
288
+ backend: config.backend,
289
+ bucket: config.bucket,
290
+ filePath: `${config.fileVersion}/${config.filePath}`,
291
+ routePrefix: config.routePrefix,
292
+ cascadeDelete: config.cascadeDelete,
293
+ reapEnabled: config.reapEnabled,
294
+ },
295
+ 'davepi-plugin-object-storage ready'
296
+ );
297
+
298
+ // Keep references the contract guarantees we receive even when we
299
+ // don't use them — same convention slack/audit follow so the
300
+ // documented setup signature stays exercised.
301
+ void bus;
302
+ void appName;
303
+ }
304
+
305
+ return {
306
+ name: 'object-storage',
307
+ setup,
308
+ createUploadUrl,
309
+ createDownloadUrl,
310
+ deleteFile,
311
+ // Adapter escape hatch for advanced consumers — see ticket #112's
312
+ // "Adapter escape hatch" note.
313
+ get adapter() {
314
+ return state.adapter;
315
+ },
316
+ // Exposed for tests + integration suites. Not part of the public
317
+ // API contract.
318
+ _state: state,
319
+ _config: config,
320
+ };
321
+ }
322
+
323
+ const defaultPlugin = createPlugin();
324
+ module.exports = defaultPlugin;
325
+ module.exports.createPlugin = createPlugin;
326
+ module.exports.buildKey = buildKey;