Skip to content

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 ​

ConcernOwner
Resolve + authorize the fileYour plugin (registerAssetResolver)
Mint a download URLYour backend or raclette__core assetLink from any widget
Serve /auth/asset/:token and /auth/session-asset/:tokenCore

assetLink is a routes-only core datatype (no model / store entity). Call it with options: { useStore: false }.

Choose a token type ​

Single-useSession-bound
Mint (backend)mintAssetTokenmintSessionAssetToken
Mint (frontend)$data.assetLink.createSingle$data.assetLink.createSession
DownloadGET /auth/asset/:tokenGET /auth/session-asset/:token
ReuseOnce (atomic consume)Many times
Lifetime5 minutesUntil the bound session ends
BindingUser idUser 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.

typescript
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.

From the backend ​

Single-use:

typescript
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):

typescript
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:

typescript
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.url

Single-use (same pattern, different operation):

typescript
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.url

Mint 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:

typescript
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