npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gilhrpenner/convex-files-control

v0.5.1

Published

A convex files control component for Convex.

Readme

Convex Files Control

A Convex component for secure file uploads, access control, download grants, and lifecycle cleanup. Works with Convex storage and Cloudflare R2, and ships with an optional HTTP upload/download router plus a React upload hook.

Live Demo →

Features

  • Two-step uploads (presigned URL) with access keys and optional expiration.
  • Optional HTTP upload/download routes with auth hooks.
  • Download grants with max uses, expiration, optional password, and shareable links.
  • Access-key based authorization (user IDs, tenant IDs, etc.).
  • Built-in cleanup for expired uploads, grants, and files.
  • Transfer files between Convex and R2.
  • React hook for presigned or HTTP uploads.

Install

npm install @gilhrpenner/convex-files-control

Quick start

1) Add the component

// convex.config.ts
import { defineApp } from "convex/server";
import convexFilesControl from "@gilhrpenner/convex-files-control/convex.config";

const app = defineApp();
app.use(convexFilesControl);

export default app;

2) Create wrapper functions in your app

The component stores access control and download grants. Your app should store its own file metadata (name, owner, etc.) and enforce auth. The wrappers below mirror the example app in example/convex/files.ts.

// convex/files.ts
import { ConvexError, v } from "convex/values";
import { mutation } from "./_generated/server";
import { components } from "./_generated/api";

export const generateUploadUrl = mutation({
  args: {
    provider: v.union(v.literal("convex"), v.literal("r2")),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new ConvexError("Unauthorized");

    return await ctx.runMutation(
      components.convexFilesControl.upload.generateUploadUrl,
      {
        provider: args.provider,
        // r2Config: { accountId, accessKeyId, secretAccessKey, bucketName },
      },
    );
  },
});

export const finalizeUpload = mutation({
  args: {
    uploadToken: v.string(),
    storageId: v.string(),
    fileName: v.string(),
    expiresAt: v.optional(v.union(v.null(), v.number())),
    metadata: v.optional(
      v.object({
        size: v.number(),
        sha256: v.string(),
        contentType: v.union(v.string(), v.null()),
      }),
    ),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new ConvexError("Unauthorized");

    const { fileName, ...componentArgs } = args;
    const result = await ctx.runMutation(
      components.convexFilesControl.upload.finalizeUpload,
      {
        ...componentArgs,
        accessKeys: [identity.subject],
      },
    );

    // Store your own file record (name, owner, etc.) here.
    // await ctx.db.insert("files", { ... });

    return result;
  },
});

3) Optional HTTP routes

If you want /files/upload and /files/download, register the router in convex/http.ts. Access keys are provided by your hook (not via the form).

// convex/http.ts
import { httpRouter } from "convex/server";
import { registerRoutes } from "@gilhrpenner/convex-files-control";
import { components } from "./_generated/api";

const http = httpRouter();

registerRoutes(http, components.convexFilesControl, {
  pathPrefix: "files",
  enableUploadRoute: true,

  // Required when enableUploadRoute is true
  checkUploadRequest: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      return new Response(JSON.stringify({ error: "Unauthorized" }), {
        status: 401,
        headers: { "Content-Type": "application/json" },
      });
    }

    return { accessKeys: [identity.subject] };
  },

  // Optional: persist file metadata after a successful HTTP upload
  onUploadComplete: async (ctx, { result, file, formData }) => {
    const fileNameFromForm = formData.get("fileName");
    const fileName =
      typeof fileNameFromForm === "string"
        ? fileNameFromForm
        : (file as File).name ?? "untitled";
    // await ctx.runMutation(api.files.recordUpload, { ...result, fileName });
  },

  // Optional: provide accessKey for downloads
  checkDownloadRequest: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (identity) return { accessKey: identity.subject };
  },
});

export default http;

HTTP upload requires multipart/form-data with fields:

  • file (required)
  • provider (optional, "convex" | "r2")
  • expiresAt (optional, timestamp or null)

Access keys are not accepted via the form; they must come from checkUploadRequest. Additional form fields are available on onUploadComplete via formData.

Useful route options:

  • pathPrefix (default: /files)
  • defaultUploadProvider (\"convex\" or \"r2\")
  • enableDownloadRoute (default: true)
  • requireAccessKey (force checkDownloadRequest to return an access key)
  • passwordHeader / passwordQueryParam (override or disable password inputs)

Uploading files

Presigned URL flow

// Client-side
const { uploadUrl, uploadToken } = await generateUploadUrl({
  provider: "convex",
});

const uploadResponse = await fetch(uploadUrl, {
  method: "POST",
  body: file,
  headers: { "Content-Type": file.type || "application/octet-stream" },
});

const { storageId } = await uploadResponse.json();

const result = await finalizeUpload({
  uploadToken,
  storageId,
  fileName: file.name,
  expiresAt: Date.now() + 60 * 60 * 1000,
});

React hook

import { useUploadFile } from "@gilhrpenner/convex-files-control/react";
import { api } from "../convex/_generated/api";

const convexSiteUrl = import.meta.env.VITE_CONVEX_URL.replace(
  ".cloud",
  ".site",
);

const { uploadFile } = useUploadFile(api.files, {
  method: "presigned",
  http: { baseUrl: convexSiteUrl },
});

// Presigned
await uploadFile({ file, provider: "convex" });

// HTTP route
await uploadFile({
  file,
  method: "http",
  provider: "convex",
  http: {
    baseUrl: convexSiteUrl,
    // authToken: useAuthToken() from @convex-dev/auth/react
  },
});

uploadFile accepts:

  • file (required)
  • provider ("convex" | "r2")
  • expiresAt (timestamp or null)
  • method ("presigned" | "http")

Downloading files

Create a grant + build a URL

import { buildDownloadUrl } from "@gilhrpenner/convex-files-control";

const grant = await ctx.runMutation(
  components.convexFilesControl.download.createDownloadGrant,
  {
    storageId,
    maxUses: 1,
    expiresAt: Date.now() + 10 * 60 * 1000,
    shareableLink: false,
  },
);

const url = buildDownloadUrl({
  baseUrl: "https://<your-convex-site>",
  downloadToken: grant.downloadToken,
  filename: "report.pdf",
  // pathPrefix: "/files", // Optional if you changed the HTTP route prefix
});

Access keys are not placed in the URL. For private grants, supply them via checkDownloadRequest (HTTP route) or pass accessKey when calling consumeDownloadGrantForUrl.

Shareable links

Set shareableLink: true to allow unauthenticated downloads (no access key required). This is how the example app generates public links. If you enable requireAccessKey on the HTTP route, shareable links will still require checkDownloadRequest to return an access key.

Password-protected grants

const grant = await ctx.runMutation(
  components.convexFilesControl.download.createDownloadGrant,
  { storageId, password: "secret-passphrase" },
);

To consume a password-protected grant, pass password to consumeDownloadGrantForUrl, or send it to the HTTP route via the x-download-password header (preferred) or the password query param. Query params can leak into logs, so headers or POST flows are safer.

Access control & queries

Access keys are normalized (trimmed) and must contain at least one non-empty value.

  • accessControl.addAccessKey(storageId, accessKey)
  • accessControl.removeAccessKey(storageId, accessKey)
  • accessControl.updateFileExpiration(storageId, expiresAt)
  • queries.hasAccessKey(storageId, accessKey)
  • queries.listAccessKeysPage(storageId, paginationOpts)
  • queries.listFilesPage(paginationOpts)
  • queries.listFilesByAccessKeyPage(accessKey, paginationOpts)
  • queries.listDownloadGrantsPage(paginationOpts)
  • queries.getFile({ storageId })

Pagination uses { numItems: number, cursor: string | null }.

Cleanup

Use cleanUp.cleanupExpired to delete expired uploads, grants, and files. The example app wraps this in a mutation and runs it in a cron job.

// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";

const crons = cronJobs();
crons.hourly(
  "cleanup-expired-files",
  { minuteUTC: 0 },
  internal.files.cleanupExpiredFiles,
  {},
);
export default crons;

Server-side helper (FilesControl)

If you prefer a class wrapper around component calls, use FilesControl:

import { FilesControl } from "@gilhrpenner/convex-files-control";
import { components } from "./_generated/api";

const files = new FilesControl(components.convexFilesControl, {
  // r2: { accountId, accessKeyId, secretAccessKey, bucketName },
});

await files.generateUploadUrl(ctx, { provider: "convex" });

FilesControl.clientApi() also returns a ready-to-export API surface with optional hooks if you want the component to generate your Convex mutations and queries for you.

R2 configuration

Provide R2 credentials when you use R2 for uploads, downloads, deletes, or transfers. You can pass r2Config to the component calls or supply env vars for the HTTP routes:

  • R2_ACCOUNT_ID
  • R2_ACCESS_KEY_ID
  • R2_SECRET_ACCESS_KEY
  • R2_BUCKET_NAME

Transfer between providers

const result = await ctx.runAction(
  components.convexFilesControl.transfer.transferFile,
  { storageId, targetProvider: "r2", r2Config },
);

The transfer preserves access keys and download grants, updates the file record, and deletes the original storage object.

Testing helper

import { convexTest } from "convex-test";
import { register } from "@gilhrpenner/convex-files-control/test";

const t = convexTest(schema, modules);
register(t, "convexFilesControl");

Example app

Live Demo →

A full Convex + React + Convex Auth implementation lives in example/. It demonstrates:

  • presigned and HTTP uploads
  • authenticated downloads and shareable links
  • access key management
  • transfer between Convex and R2
  • scheduled cleanup