03 / Develop · Store files

Store files on the storage app ​

An app that keeps files — avatars, attachments, documents — keeps them on the platform's storage app, not in a MinIO bucket of its own. Storage gives every organization one bucket (obj-<org>), counts each file against the organization's quota, and shows it in its admin view. A file an app writes straight to MinIO is invisible to all three: the organization's usage reads zero and the organization does not even appear in storage's Organizations page.

The app reaches storage as itself, with a short-lived installation token the Shell issues per organization. The contract — token format, checks, locks — is owned by storage/docs/installation-token.md; this page is the how-to for moving an app onto it.

AppState
mailOn storage — the first client (api/src/services/storage-client.service.ts)
hrmMoving — the reference for an existing app (hrm#701)
driveMoving (drive#52) — files over 32 MiB go through storage's multipart upload
veni-builder (and its vendor/app-template)Still on MinIO directly

How it fits ​

app backend ──① POST /apps/installation-token {organizationId, audience:"storage"}──▶ Shell :3100
            ◀───────────────── installation token (≤ 1 h) ─────────────────────────
            ──② POST /auth/installation {token}──────────────────────────────────▶ storage :3100
            ◀───────────────── storage JWT (app principal for that org) ──────────
            ──③ PUT / GET …/link / DELETE  /storage/buckets/obj-<org>/objects/<key>─▶ storage :3100

Two things must be true for ① to answer a token, and neither is the app's code:

  • the app is activated for the organization (a binding — the org admin's Kích hoạt, or a platform admin's assign). That is the grant: no platform-admin approval is needed for storage;
  • the Shell knows who is calling: its pod's ServiceAccount is mapped to the app's slug (APP_ENV_WORKLOAD_PLATFORM_APPS), or it holds an app credential.

A platform admin can still refuse an app for storage (reject / revoke in app_platform_services) — the kill switch.

Both doors are internal

/apps/installation-token and /auth/installation are served only on the internal listeners (shell-api:3100, storage-api:3100); the public ports answer 404. Point the app at http://shell-api:3100/api and http://storage-api:3100/api, never at the public hostnames.

Move an app onto storage ​

1 · Add the client ​

Copy HRM's api/src/services/platform-storage.client.ts (or mail's storage-client.service.ts if the app should let storage pick the key). It already follows the contract's client rules (§8): one cached token per organization, refreshed when under five minutes remain, one in-flight exchange per organization, a storage 401 refreshes and retries once, a 403 is remembered for a minute, and an unconfigured client is a supported, typed state.

Add its configuration to env.config.ts:

ts
APP_ENV_SHELL_API_URL: z.string().url().optional(),          // http://shell-api:3100/api
APP_ENV_STORAGE_API_URL: z.string().url().optional(),        // http://storage-api:3100/api
APP_ENV_SHELL_WORKLOAD_TOKEN_PATH: z.string().optional(),    // in clusters
APP_ENV_SHELL_APP_CLIENT_ID: z.string().uuid().optional(),   // locally, issued by a platform admin
APP_ENV_SHELL_APP_CLIENT_SECRET: z.string().min(32).optional(),

2 · Address files by key ​

Storage has two ways in. Pick by what the app's tables already hold:

The app stores…UseData migration
its own key per file (avatarUrl, storagePath…)keyed: PUT / DELETE /storage/buckets/obj-<org>/objects/<key>, GET …/<key>/linknone — the keys stay valid
nothing yet (a new feature)by id: POST /storage/files → PUT the putUrl → POST /storage/files/:id/confirm; keep the returned id—

With keys, prefix each family so they do not collide in the one bucket — HRM writes avatars/<key>, cvs/<key>, documents/<key>, knowledge/<key>. Storage files every key under apps/<app>/ on its side, so two apps never see each other's keys.

Every call names the organization. Take it from the authenticated request (the JWT), never from a request body.

3 · Keep the old files readable ​

Files written before the switch are still in the app's MinIO buckets. Keep MinIO configured and, while storage is on:

  • write to storage only;
  • read from storage first, and fall back to MinIO when storage has no object at that key;
  • delete from both (a missing object on MinIO is not an error).

With storage unset the app stays on MinIO exactly as before — local development keeps working without a storage deployment. Moving the old objects across is a separate, one-off job.

4 · Serve files to the browser through the app ​

Storage's download links are presigned for the object store's in-cluster address — a browser cannot follow them, and an <img> cannot send the app's bearer token either. Do not return those links to the UI. Hand the browser a short-lived link to the app's own route instead, signed so the route needs no session:

  • HMAC over (family, organization, key, expiry) with a secret only the API holds, compared in constant time;
  • minted only after the caller passed the same access checks as the route that hands it out;
  • the route reads the bytes from storage (with the MinIO fallback) and streams them with Content-Type, X-Content-Type-Options: nosniff and a cache lifetime no longer than the link's.

HRM's file-link.ts + files.controller.ts are the pattern. If the app runs the all-routes-authenticated test, add the route to PUBLIC_ROUTES with its gate written out.

5 · Give the pod an identity ​

In each overlay the app runs in (dev first), add the URLs and a projected ServiceAccount token with audience veni-shell:

yaml
# api-env-patch.yaml
- name: APP_ENV_SHELL_API_URL
  value: http://shell-api:3100/api
- name: APP_ENV_STORAGE_API_URL
  value: http://storage-api:3100/api
- name: APP_ENV_SHELL_WORKLOAD_TOKEN_PATH
  value: /var/run/secrets/veni-shell/token
yaml
# api-shell-token-patch.yaml — add it to the overlay's kustomization patches
- op: add
  path: /spec/template/spec/volumes/-
  value:
    name: shell-token
    projected:
      sources:
        - serviceAccountToken: { audience: veni-shell, expirationSeconds: 3600, path: token }
- op: add
  path: /spec/template/spec/containers/0/volumeMounts/-
  value: { name: shell-token, mountPath: /var/run/secrets/veni-shell, readOnly: true }

The token is read on every exchange — the kubelet rewrites it before it expires. Nothing goes into Vault.

6 · Tell the Shell who the pod is ​

In veni-shell's overlay, append <namespace>/<serviceaccount>=<slug> to APP_ENV_WORKLOAD_PLATFORM_APPS, for example veni-dev/hrm-api=hrm. Without it the Shell answers 401 to the pod. Builder-published apps need no entry.

7 · Activate it in an organization ​

Each organization's admin activates the app (Kích hoạt). From then on the Shell issues tokens for that organization; deactivating the app stops them within the hour, and at once on storage through the installation lock.

Check it on dev ​

  1. Upload a file through the app in an organization where it is activated.
  2. Open storage's Organizations admin page: the organization appears, with the app's bytes under it.
  3. Open the file from the app's UI: the URL points at the app's own API, not at minio:9000 or a 10.x address.
SymptomCause
Upload fails with 503, the log says the Shell refused the app's identity (401)Step 6 — the ServiceAccount is not mapped
Upload fails with 403Step 7 — the app is not activated for that organization, or a platform admin refused it for storage
Upload fails with 503 "unreachable"A public URL instead of :3100, or storage is down
The organization is still missing from storageThe app is not configured (step 5), so it is still writing to MinIO