Asset Downloads β
Authenticated file downloads that work with <img>, <a download>, and plain fetch β without putting JWTs in the URL.
Browsers cannot attach custom auth headers to media and download links. Asset tokens solve that: the client gets a short opaque URL, and core streams the file only while the caller's session cookie is valid.
Overview β
| Concern | Owner |
|---|---|
| Resolve + authorize the file | Your plugin (registerAssetResolver) |
| Mint a download URL | Your backend or raclette__core assetLink from any widget |
Serve /auth/asset/:token and /auth/session-asset/:token | Core |
assetLink is a routes-only core datatype (no model / store entity). Call it with options: { useStore: false }.
Choose a token type β
| Single-use | Session-bound | |
|---|---|---|
| Mint (backend) | mintAssetToken | mintSessionAssetToken |
| Mint (frontend) | $data.assetLink.createSingle | $data.assetLink.createSession |
| Download | GET /auth/asset/:token | GET /auth/session-asset/:token |
| Reuse | Once (atomic consume) | Many times |
| Lifetime | 5 minutes | Until the bound session ends |
| Binding | User id | User id + session id |
Use single-use for one-shot downloads (export, attachment click). Use session-bound when the same URL must work repeatedly (preview <img>, PDF viewer, links the user may click again).
1. Register a resolver β
Call this once at plugin boot. Both token types reuse the same resolver.
fastify.auth.registerAssetResolver("your-plugin-id", async (tokenData) => {
// MUST authorize against tokenData.sessionUserId.
// tokenData.resource / tokenData.permission are opaque to core.
const file = await resolveFileForUser(
tokenData.sessionUserId,
tokenData.resource,
)
if (!file) return null
return {
stream: file.createReadStream(),
contentType: file.mimeType ?? "application/octet-stream",
filename: file.name ?? "download.bin",
size: file.size,
}
})Authorization
Any authenticated user can mint a token for any pluginId / resource. Core does not check ownership. Your resolver must authorize against tokenData.sessionUserId (and optionally tokenData.permission).
Return null when the resource is missing or the user is not allowed β the download route answers 404.
2. Mint a link β
From the backend β
Single-use:
const handler = async (req, reply) => {
const token = await fastify.auth.mintAssetToken(
req.authUser!.userId,
"your-plugin-id",
"path/to/resource",
)
return { downloadUrl: `/auth/asset/${token}` }
}Session-bound (requires req.sessionId from the auth hook):
const handler = async (req, reply) => {
const token = await fastify.auth.mintSessionAssetToken(
req.authUser!.userId,
req.sessionId!,
"your-plugin-id",
"path/to/resource",
)
return { downloadUrl: `/auth/session-asset/${token}` }
}From the frontend (raclette__core) β
Plugins do not need their own mint route. Use the core assetLink datatype the same way you read core tags:
Session-bound:
import { usePluginApi } from "@raclettejs/core/orchestrator/composables"
const { $data: $coreData } = usePluginApi("raclette__core")
// assetLink is routes-only β not a store entity.
const { execute: mintSession } = $coreData.assetLink.createSession({
options: { useStore: false },
})
const res = await mintSession({
pluginId: "your-plugin-id",
resource: "path/to/resource",
})
const url = res.response.data.urlSingle-use (same pattern, different operation):
import { usePluginApi } from "@raclettejs/core/orchestrator/composables"
const { $data: $coreData } = usePluginApi("raclette__core")
// assetLink is routes-only β not a store entity.
const { execute: mintSingle } = $coreData.assetLink.createSingle({
options: { useStore: false },
})
const res = await mintSingle({
pluginId: "your-plugin-id",
resource: "path/to/resource",
})
const url = res.response.data.urlMint body: { pluginId, resource, permission? }. Response: { url } (for example /auth/session-asset/{token}).
3. Download with credentials β
Hit the returned path against the API base and send cookies:
const apiBase =
(window as { configs?: Record<string, string | undefined> }).configs
?.RACLETTE_SERVER_BASE_URL || "/api"
const res = await fetch(`${apiBase}${url}`, {
credentials: "include",
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = objectUrl
anchor.download = "download.bin"
anchor.click()
URL.revokeObjectURL(objectUrl)You can also use the URL as src / href so the browser sends the session cookie automatically.
Session-bound downloads re-check that the caller's live session matches the token (same user and same session id). A token cannot be replayed from another session.
Stacks and storage β
TIP
Asset tokens live in the shared fastify.cache Valkey instance (app and workbench share it). Session cookies are per-stack (sid_app vs sid_workbench). A session-bound link minted on the app backend is only usable while that app session is active.
On logout (or session expiry / cleanup), core revokes all session-bound asset tokens for that session.
Reference implementation β
Playground plugin pacifico__todo:
- Backend resolver:
plugins/pacifico__todo/backend/assetLink.resolver.ts - Widget:
plugins/pacifico__todo/frontend/widgets/assetLinkExample
Related β
- Authentication overview β session cookies and endpoints
- Plugin auth migration β JWT downloads β asset tokens
- Valkey instances β where asset tokens are stored