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.
| App | State |
|---|---|
mail | On storage — the first client (api/src/services/storage-client.service.ts) |
hrm | Moving — the reference for an existing app (hrm#701) |
drive | Moving (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 :3100Two 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:
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… | Use | Data migration |
|---|---|---|
its own key per file (avatarUrl, storagePath…) | keyed: PUT / DELETE /storage/buckets/obj-<org>/objects/<key>, GET …/<key>/link | none — 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: nosniffand 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:
# 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# 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
- Upload a file through the app in an organization where it is activated.
- Open storage's Organizations admin page: the organization appears, with the app's bytes under it.
- Open the file from the app's UI: the URL points at the app's own API, not at
minio:9000or a10.xaddress.
| Symptom | Cause |
|---|---|
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 403 | Step 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 storage | The app is not configured (step 5), so it is still writing to MinIO |