• Closes the P1.9 row of the implementation plan. New platform-files
    subproject exposing a cross-PBC facade for the framework's binary
    blob store, with a local-disk implementation and a thin HTTP
    surface for multipart upload / download / delete / list.
    
    ## api.v1 additions (package `org.vibeerp.api.v1.files`)
    
    - `FileStorage` — injectable facade with five methods:
        * `put(key, contentType, content: InputStream): FileHandle`
        * `get(key): FileReadResult?`
        * `exists(key): Boolean`
        * `delete(key): Boolean`
        * `list(prefix): List<FileHandle>`
      Stream-first (not byte-array-first) so reports PDFs etc. don't
      have to be materialized in memory. Keys are opaque strings with
      slashes allowed for logical grouping; the local-disk backend
      maps them to subdirectories.
    
    - `FileHandle` — read-only metadata DTO (key, size, contentType,
      createdAt, updatedAt).
    
    - `FileReadResult` — the return type of `get()` bundling a handle
      and an open InputStream. The caller MUST close the stream
      (`result.content.use { ... }` is the idiomatic shape); the
      facade is not responsible for managing the consumer's lifetime.
    
    - `PluginContext.files: FileStorage` — new member on the plug-in
      context interface, default implementation throws
      `UnsupportedOperationException("upgrade vibe_erp to v0.8 or later")`.
      Same backward-compat pattern we used for `endpoints`, `jdbc`,
      `taskHandlers`. Plug-ins that need to persist report PDFs,
      uploaded attachments, or exported archives inject this through
      the context.
    
    ## platform-files runtime
    
    - `LocalDiskFileStorage` @Component reading `vibeerp.files.local-path`
      (default `./files-local`, overridden in dev profile to
      `./files-dev`, overridden in production config to
      `/opt/vibe-erp/files`).
    
      **Layout**: files are stored at `<root>/<key>` with a sidecar
      metadata file at `<root>/<key>.meta` containing a single line
      `content_type=<value>`. Sidecars beat xattrs (not portable
      across Linux/macOS) and beat an H2/SQLite index (overkill for
      single-tenant single-instance).
    
      **Atomicity**: every `put` writes to a `.tmp` sibling file and
      atomic-moves it into place so a concurrent read against the same
      key never sees a half-written mix.
    
      **Key safety**: `put`/`get`/`delete` all validate the key:
      rejects blank, leading `/`, `..` (path traversal), and trailing
      `.meta` (sidecar collision). Every resolved path is checked to
      stay under the configured root via `normalize().startsWith(root)`.
    
    - `FileController` at `/api/v1/files/**`:
        * `POST   /api/v1/files?key=...`            multipart upload (form field `file`)
        * `GET    /api/v1/files?prefix=...`         list by prefix
        * `GET    /api/v1/files/metadata?key=...`   metadata only (doesn't open the stream)
        * `GET    /api/v1/files/download?key=...`   stream bytes with the right Content-Type + filename
        * `DELETE /api/v1/files?key=...`            delete by key
      All endpoints @RequirePermission-gated via the keys declared in
      the metadata YAML. The `key` is a query parameter, NOT a path
      variable, so slashes in the key don't collide with Spring's path
      matching.
    
    - `META-INF/vibe-erp/metadata/files.yml` — 2 permissions + 1 menu.
    
    ## Smoke test (fresh DB, as admin)
    
    ```
    POST /api/v1/files?key=reports/smoke-test.txt  (multipart file)
      → 201 {"key":"reports/smoke-test.txt",
             "size":61,
             "contentType":"text/plain",
             "createdAt":"...","updatedAt":"..."}
    
    GET  /api/v1/files?prefix=reports/
      → [{"key":"reports/smoke-test.txt","size":61, ...}]
    
    GET  /api/v1/files/metadata?key=reports/smoke-test.txt
      → same handle, no bytes
    
    GET  /api/v1/files/download?key=reports/smoke-test.txt
      → 200 Content-Type: text/plain
         body: original upload content  (diff == 0)
    
    DELETE /api/v1/files?key=reports/smoke-test.txt
      → 200 {"removed":true}
    
    GET  /api/v1/files/download?key=reports/smoke-test.txt
      → 404
    
    # path traversal
    POST /api/v1/files?key=../escape  (multipart file)
      → 400 "file key must not contain '..' (got '../escape')"
    
    GET  /api/v1/_meta/metadata
      → permissions include ["files.file.read", "files.file.write"]
    ```
    
    Downloaded bytes match the uploaded bytes exactly — round-trip
    verified with `diff -q`.
    
    ## Tests
    
    - 12 new unit tests in `LocalDiskFileStorageTest` using JUnit 5's
      `@TempDir`:
      * `put then get round-trips content and metadata`
      * `put overwrites an existing key with the new content`
      * `get returns null for an unknown key`
      * `exists distinguishes present from absent`
      * `delete removes the file and its metadata sidecar`
      * `delete on unknown key returns false`
      * `list filters by prefix and returns sorted keys`
      * `put rejects a key with dot-dot`
      * `put rejects a key starting with slash`
      * `put rejects a key ending in dot-meta sidecar`
      * `put rejects blank content type`
      * `list sidecar metadata files are hidden from listing results`
    - Total framework unit tests: 327 (was 315), all green.
    
    ## What this unblocks
    
    - **P1.8 JasperReports integration** — now has a first-class home
      for generated PDFs. A report renderer can call
      `fileStorage.put("reports/quote-$code.pdf", "application/pdf", ...)`
      and return the handle to the caller.
    - **Plug-in attachments** — the printing-shop plug-in's future
      "plate scan image" or "QC report" attachments can be stored via
      `context.files` without touching the database.
    - **Export/import flows** — a scheduled job can write a nightly
      CSV export via `FileStorage.put` and a separate endpoint can
      download it; the scheduler-to-storage path is clean and typed.
    - **S3 backend when needed** — the interface is already streaming-
      based; dropping in an `S3FileStorage` @Component and toggling
      `vibeerp.files.backend: s3` in config is a future additive chunk,
      zero api.v1 churn.
    
    ## Non-goals (parking lot)
    
    - S3 backend. The config already reads `vibeerp.files.backend`,
      local is hard-wired for v1.0. Keeps the dependency tree off
      aws-sdk until a real consumer exists.
    - Range reads / HTTP `Range: bytes=...` support. Future
      enhancement for large-file streaming (e.g. video attachments).
    - Presigned URLs (for direct browser-to-S3 upload, skipping the
      framework). Design decision lives with the S3 backend chunk.
    - Per-file ACLs. The four `files.file.*` permissions currently
      gate all files uniformly; per-path or per-owner ACLs would
      require a new metadata table and haven't been asked for by any
      PBC yet.
    - Plug-in loader integration. `PluginContext.files` throws the
      default `UnsupportedOperationException` until the plug-in
      loader is wired to pass the host `FileStorage` through
      `DefaultPluginContext`. Lands in the same chunk as the first
      plug-in that needs to store a file.
    zichun authored
     
    Browse Code »

  • BLOCKER: wire Hibernate multi-tenancy
    - application.yaml: set hibernate.tenant_identifier_resolver and
      hibernate.multiTenancy=DISCRIMINATOR so HibernateTenantResolver is
      actually installed into the SessionFactory
    - AuditedJpaEntity.tenantId: add @org.hibernate.annotations.TenantId so
      every PBC entity inherits the discriminator
    - AuditedJpaEntityListener.onCreate: throw if a caller pre-set tenantId
      to a different value than the current TenantContext, instead of
      silently overwriting (defense against cross-tenant write bugs)
    
    IMPORTANT: dependency hygiene
    - pbc-identity no longer depends on platform-bootstrap (wrong direction;
      bootstrap assembles PBCs at the top of the stack)
    - root build.gradle.kts: tighten the architectural-rule enforcement to
      also reject :pbc:* -> platform-bootstrap; switch plug-in detection
      from a fragile pathname heuristic to an explicit
      extra["vibeerp.module-kind"] = "plugin" marker; reference plug-in
      declares the marker
    
    IMPORTANT: api.v1 surface additions (all non-breaking)
    - Repository: documented closed exception set; new
      PersistenceExceptions.kt declares OptimisticLockConflictException,
      UniqueConstraintViolationException, EntityValidationException, and
      EntityNotFoundException so plug-ins never see Hibernate types
    - TaskContext: now exposes tenantId(), principal(), locale(),
      correlationId() so workflow handlers (which run outside an HTTP
      request) can pass tenant-aware calls back into api.v1
    - EventBus: subscribe() now returns a Subscription with close() so
      long-lived subscribers can deregister explicitly; added a
      subscribe(topic: String, ...) overload for cross-classloader event
      routing where Class<E> equality is unreliable
    - IdentityApi.findUserById: tightened from Id<*> to PrincipalId so the
      type system rejects "wrong-id-kind" mistakes at the cross-PBC boundary
    
    NITs:
    - HealthController.kt -> MetaController.kt (file name now matches the
      class name); added TODO(v0.2) for reading implementationVersion from
      the Spring Boot BuildProperties bean
    vibe_erp authored
     
    Browse Code »
  • vibe_erp authored
     
    Browse Code »