Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/lazy-server-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@transloadit/convex": patch
---

Resolve API credentials only when an operation needs them. Constructing a client and reading local
metadata no longer require API credentials; signing and refresh still require both the key and
secret, while webhook verification requires only the secret.

Recommend app-authorized functions and internal webhook ingestion in the quickstart. Deprecate
`makeTransloaditAPI()` and `client.api()` without changing their public exports; existing integrations
must still add their own authorization. Link the runnable wedding app instead of duplicating its
upload flow in a partial example.
124 changes: 64 additions & 60 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,44 +38,53 @@ app.use(transloadit);
export default app;
```

### 2) Set environment variables
### 2) Configure server-side credentials

```bash
npx convex env set TRANSLOADIT_KEY <your_auth_key>
npx convex env set TRANSLOADIT_SECRET <your_auth_secret>
```

Creating a client, registering functions and reading stored metadata do not require these
credentials. Assembly creation, upload signing and refresh require both when called; webhook
verification requires only the secret. Missing credentials fail the operation, not module loading
or codegen. Keep credentials on the Convex backend, never in browser code.

## Upload flow

1. **Server-only create**: a Convex action creates signed `assemblyOptions` (auth secret stays server-side).
1. **Authorize and sign**: your app action checks access, chooses the pipeline on the server, and creates signed `assemblyOptions`.
2. **Client upload**: use Uppy + `@uppy/transloadit` with `assemblyOptions()`.
3. **Webhook ingestion**: verify the signature and `queueWebhook` for durable processing.
4. **Realtime UI**: query status/results and render the gallery.

## Backend API
## Backend API: app-owned functions

```ts
// convex/transloadit.ts
import { makeTransloaditAPI } from "@transloadit/convex";
// convex/transloaditClient.ts
import { Transloadit } from "@transloadit/convex";
import { components } from "./_generated/api";

export const {
createAssembly,
createAssemblyOptions,
handleWebhook,
queueWebhook,
refreshAssembly,
getAssemblyStatus,
listAssemblies,
listResults,
storeAssemblyMetadata,
} = makeTransloaditAPI(components.transloadit);
export const transloadit = new Transloadit(components.transloadit);
```

These wrappers do not add application authorization. Expose only the operations your app needs,
and add membership/ownership checks for signing, queries, refresh, edits, and deletion. The example
limits guest signing to its wedding pipeline and keeps cleanup internal; album reads require an
admitted guest.
This creates a server-side helper, **not public Convex endpoints**. Call its methods from your own
`action`, `query` or `mutation` after checking the caller's access. An authenticated caller is not
automatically allowed to access every album or Assembly. Keep pipeline, Template, Workspace,
ownership fields and Storage paths server-chosen; do not forward arbitrary browser signing inputs.

The runnable [wedding app](example/) is the reference integration, rather than a second copy of its
access rules here:

| Boundary | Existing app implementation |
| --- | --- |
| Upload signing | [`wedding.ts`](example/convex/wedding.ts): admitted guest, bounded batch, server-chosen recipe and upload record/path |
| Gallery and exact media | [`media.ts`](example/convex/media.ts): live admission, album and upload ownership, server-chosen rendition map |
| Assembly activity and refresh | [`transloadit.ts`](example/convex/transloadit.ts): album-wide activity summaries, owner-only status/results and expected owner fields on refresh |
| Webhooks and deletion | [`http.ts`](example/convex/http.ts), [`transloadit.ts`](example/convex/transloadit.ts) and [`storageCleanup.ts`](example/convex/storageCleanup.ts): verified ingestion and internal-only mutations |

`makeTransloaditAPI()` and `client.api()` remain compatible for existing integrations, but are
deprecated: they register public functions without app authorization. Do not re-export them as a
new app's backend. See [migration guidance](docs/advanced.md#legacy-public-wrappers).

Note: pass `expires` in `createAssembly` when you need a custom expiry; otherwise the component defaults to 1 hour from now.

Expand Down Expand Up @@ -121,7 +130,7 @@ can keep each canonical receipt (`workspace`, `asset_id`, `version_id`, `path`,

```ts
const transloadit = new Transloadit(components.transloadit, { storageWorkspace: "my-workspace" });
// or set TRANSLOADIT_WORKSPACE for makeTransloaditAPI and the class defaults
// or set TRANSLOADIT_WORKSPACE for the default
```

- Only completed Assemblies register receipts, from verified webhooks or authoritative refreshes.
Expand Down Expand Up @@ -206,12 +215,34 @@ example strips it from versions its guests may only fetch as originals).
## Webhook route

Transloadit sends webhooks as `multipart/form-data` with `transloadit` (JSON) and `signature` fields.
Keep the Convex ingestion function internal; only the HTTP route should invoke it. With the client
above, define:

```ts
// convex/transloadit.ts
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { transloadit } from "./transloaditClient";

export const queueWebhook = internalAction({
args: {
payload: v.any(),
rawBody: v.optional(v.string()),
signature: v.optional(v.string()),
},
handler: (ctx, args) => transloadit.queueWebhook(ctx, args),
});
```

`queueWebhook` verifies the raw body's signature before enqueueing anything. Do not expose a
`verifySignature` argument to clients or disable verification for HTTP requests. Configure
`TRANSLOADIT_WORKSPACE` too if these notifications should register Storage receipts.

```ts
// convex/http.ts
import { httpRouter } from "convex/server";
import { handleWebhookRequest } from "@transloadit/convex";
import { api } from "./_generated/api";
import { internal } from "./_generated/api";
import { httpAction } from "./_generated/server";

const http = httpRouter();
Expand All @@ -222,53 +253,26 @@ http.route({
handler: httpAction((ctx, request) =>
handleWebhookRequest(request, {
mode: "queue",
runAction: (args) => ctx.runAction(api.transloadit.queueWebhook, args),
runAction: (args) => ctx.runAction(internal.transloadit.queueWebhook, args),
}),
),
});

export default http;
```

## Client wrapper (optional)

Most integrations should use `makeTransloaditAPI` (above). If you prefer a class-based API
(similar to other Convex components), use `Transloadit`:

```ts
import { Transloadit } from "@transloadit/convex";
import { components } from "./_generated/api";

const transloadit = new Transloadit(components.transloadit, {
authKey: process.env.TRANSLOADIT_KEY!,
authSecret: process.env.TRANSLOADIT_SECRET!,
});
```
## Uppy client

## Uppy client (React example)

```tsx
import Uppy from "@uppy/core";
import Transloadit from "@uppy/transloadit";
import { api } from "../convex/_generated/api";

const uppy = new Uppy().use(Transloadit, {
waitForEncoding: true,
assemblyOptions: async () => {
const { assemblyOptions } = await runAction(
api.wedding.createWeddingAssemblyOptions,
{ fileCount, guestName },
);
return assemblyOptions;
},
});
Use Uppy + `@uppy/transloadit` directly. Start at
[`WeddingUploadsClient.tsx`](example/app/WeddingUploadsClient.tsx): it calls the authorized wedding
action for each batch, derives its file count from Uppy, and handles authentication and upload errors.
[`useWeddingUppy.ts`](example/app/useWeddingUppy.ts) and
[`createWeddingUppy.ts`](example/app/createWeddingUppy.ts) manage the Uppy instance and plugin.
Start from that flow rather than exposing a generic signing action.

await uppy.upload();
```
Note: `assemblyOptions()` is called once per batch, so pass per-file metadata via Uppy file meta
(e.g. `uppy.setFileMeta(fileId, {...})`) and use `fields` for shared values.
The wedding action requires an existing guest session; an optional invitation code belongs to
sign-in, not the upload action.
`assemblyOptions()` is called once per batch. Use Uppy file metadata for per-file values and
Assembly `fields` for shared display values, not as the sole ownership proof. The optional wedding
invitation code belongs to sign-in, not the upload action.

Migration note: the `@transloadit/convex/react` entrypoint has been removed; use Uppy +
`@uppy/transloadit` directly.
Expand Down
31 changes: 29 additions & 2 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

This page collects optional helpers that build on the Uppy-first integration path.

## Legacy public wrappers

`makeTransloaditAPI(component, config)` and `client.api()` still return the same public Convex
functions for compatibility, but are deprecated. They do not check authentication, album membership
or Assembly ownership. Merely keeping the API secret server-side does not make those endpoints
safe: callers can request signatures for their own Steps, read metadata or invoke `purgeAlbum`.

For an existing integration, replace the exported factory functions with app-owned wrappers around
`new Transloadit(components.transloadit)` (or direct component calls):

- Authorize public signing actions and fix the recipe, ownership fields and Storage paths on the
server. Do not accept arbitrary `CreateAssemblyArgs` from the browser.
- Authorize reads and refresh against the caller's album/Assembly, not just a logged-in session.
- Keep webhook ingestion, metadata writes and cleanup internal. Update HTTP routes and server
callers to use `internal.transloadit.*` references when moving a function from public to internal.
- Keep receipt reads app-owned; the factory does not expose them or the `assemblyFields` selector.

Use the [wedding app's functions](../example/convex/) as the concrete reference. Existing factory
exports are not changed automatically by upgrading this package; review them before deploying.
The class's writable `config` API and explicit credential overrides remain supported. Environment
credentials resolve when an operation needs them, so local receipt reads and codegen need neither
an Assembly key nor a secret. Webhook handlers need only the secret; signing and refresh need both.
Reading or copying `client.config` also evaluates its credential getters and can throw if credentials
are absent. Do not serialize that credential-bearing object into logs or browser data.

## Typed helpers (raw payload parsing)

When working with raw assembly payloads, use these helpers to avoid stringly-typed access.
Expand Down Expand Up @@ -71,11 +96,13 @@ This reads `TRANSLOADIT_KEY/TRANSLOADIT_SECRET` from `.env` and prints the templ

## Webhook handling (sync mode)

By default, the README shows the queued flow. If you need synchronous handling, you can use:
By default, the README shows the queued flow. For synchronous handling, define an `internalAction`
with the same webhook validators as that example, calling `transloadit.handleWebhook(ctx, args)`
instead. The HTTP route then uses its internal reference:

```ts
return handleWebhookRequest(request, {
mode: "sync",
runAction: (args) => ctx.runAction(api.transloadit.handleWebhook, args),
runAction: (args) => ctx.runAction(internal.transloadit.handleWebhook, args),
});
```
46 changes: 44 additions & 2 deletions example/convex/wedding.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="vite/client" />

import { generateKeyPairSync } from 'node:crypto'
import { createHmac, generateKeyPairSync } from 'node:crypto'
import { anyApi, componentsGeneric } from 'convex/server'
import { convexTest } from 'convex-test'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
Expand All @@ -16,13 +16,15 @@ vi.mock('./_generated/api', async () => {
vi.mock('./_generated/server', async () => {
const {
actionGeneric,
httpActionGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
queryGeneric,
} = await import('convex/server')
return {
action: actionGeneric,
httpAction: httpActionGeneric,
internalAction: internalActionGeneric,
internalMutation: internalMutationGeneric,
internalQuery: internalQueryGeneric,
Expand All @@ -41,6 +43,7 @@ const setup = (modules = componentModules, transactionLimits = false) => {
'./guests.ts': () => import('./guests'),
'./transloadit.ts': () => import('./transloadit'),
'./auth.ts': () => import('./auth'),
'./http.ts': () => import('./http'),
'./_generated/server.ts': () => import('convex/server'),
},
transactionLimits,
Expand Down Expand Up @@ -87,7 +90,46 @@ const admitted = async (t = setup()) => {

beforeEach(() => vi.stubEnv('WEDDING_UPLOAD_CODE', ''))

afterEach(() => vi.unstubAllEnvs())
afterEach(() => {
vi.unstubAllEnvs()
vi.useRealTimers()
})

test('the HTTP webhook verifies before queueing with only a secret, never an API key', async () => {
vi.stubEnv('TRANSLOADIT_KEY', undefined)
vi.stubEnv('TRANSLOADIT_SECRET', 'test-webhook-secret')
vi.stubEnv('TRANSLOADIT_WORKSPACE', undefined)
vi.useFakeTimers()
const t = setup()
const rawBody = JSON.stringify({ assembly_id: 'signed-http', ok: 'ASSEMBLY_COMPLETED' })
const body = new FormData()
body.set('transloadit', rawBody)
body.set(
'signature',
`sha384:${createHmac('sha384', 'test-webhook-secret').update(rawBody).digest('hex')}`,
)
const response = await t.fetch('/transloadit/webhook', { method: 'POST', body })
expect(response.status).toBe(202)
await t.finishAllScheduledFunctions(vi.runAllTimers)
expect(
await t.query(components.transloadit.lib.getAssemblyStatus, { assemblyId: 'signed-http' }),
).not.toBeNull()

body.set(
'transloadit',
JSON.stringify({ assembly_id: 'unsigned-http', ok: 'ASSEMBLY_COMPLETED' }),
)
await expect(t.fetch('/transloadit/webhook', { method: 'POST', body })).rejects.toThrow(
'Invalid Transloadit webhook signature',
)
body.delete('signature')
await expect(t.fetch('/transloadit/webhook', { method: 'POST', body })).rejects.toThrow(
'Invalid Transloadit webhook signature',
)
expect(
await t.query(components.transloadit.lib.getAssemblyStatus, { assemblyId: 'unsigned-http' }),
).toBeNull()
})

test.each(['', ' \n\t ', 'x'.repeat(101)])(
'rejects invalid guest names before signing or consuming the upload limit (%j)',
Expand Down
Loading
Loading