2.0 beta — new releases can include breaking changes. Stable 1.x docs
dimah-s3v2.0.0-beta.0

Upload Tickets

The signed ticket that carries an upload from presign to confirm, its secret, lifetime, and subject binding.

Presign and multipart init return a ticket. It is a signed, stateless record of the upload: the route, the server-chosen key, the declared file, and for multipart the part layout. Every later call sends only the ticket:

CallBody
confirm{ ticket }
multipart.signPart{ ticket, partNumber, checksum? }
multipart.listParts{ ticket }
multipart.complete{ ticket }
multipart.abort{ ticket }

The server keeps no session state. Because the ticket is signed, a client cannot change the key, the route, or the declared size between presign and confirm. A ticket that fails verification returns 403 UPLOAD_TICKET_INVALID.

Treat a ticket as opaque. The React hooks and createS3Client pass it along for you.

Upload ticket
  1. PresignIssues the ticket
  2. Upload to S3S3
  3. ConfirmVerifies the ticket

Secret

Tickets are signed with HMAC-SHA256. The key is derived from a secret you provide:

.env
# openssl rand -base64 32
DIMAH_S3_SECRET="…"

Or pass it in code:

lib/s3.ts
export const s3 = dimahS3({
  secret: process.env.DIMAH_S3_SECRET || undefined, 
  // ...
});

An explicit secret is checked when dimahS3() runs, so an empty string throws. || undefined falls back to reading the environment on first use.

  • Every value must have at least 32 characters. A short secret option throws at dimahS3(). A short DIMAH_S3_SECRET returns 500 INTERNAL_ERROR on the first upload.
  • Use the same value on every instance. A ticket issued by one server must verify on another.
  • With no secret, uploads return 500 INTERNAL_ERROR and the cause is logged. The exception is NODE_ENV set to exactly development or test: a random secret is used and one warning is logged. It survives hot reloads but not a restart, so tickets issued before a restart stop verifying.

next build runs without your runtime environment, so the secret is read lazily, on the first upload, not when dimahS3() runs.

Rotating the secret

Pass an array. The first value signs new tickets; every value verifies:

export const s3 = dimahS3({
  secret: [process.env.DIMAH_S3_SECRET!, process.env.DIMAH_S3_SECRET_OLD!], 
  // ...
});

Keep the old value until every ticket it signed has expired: one day by default, plus the 7-day abort window if you want abandoned multipart uploads to stay abortable.


Lifetime

UploadTicket expiresDefault
Single-shotURL expiry + upload.confirmWindow600 s + 3600 s
MultipartInit + upload.multipart.ticketExpiresIn86 400 s (1 day)

After expiry, every call returns 410 UPLOAD_TICKET_EXPIRED, except multipart.abort, which accepts the ticket for 7 more days so an abandoned upload can still be cleaned up.

upload.onStart receives the real deadline as expiresAt, and the URL's own expiry as urlExpiresAt. Use expiresAt for pending records: once it passes, the upload can never be confirmed.

upload: {
  confirmWindow: 15 * 60, 
  multipart: {
    ticketExpiresIn: 6 * 60 * 60, 
  },
},

Binding a ticket to a user

A ticket on its own lets the holder confirm or continue one upload until it expires. Bind it to a subject, such as a user or tenant id, so it works only for that subject:

lib/s3.ts
declare module "@dimah-s3/server" {
  interface DimahS3Locals {
    userId?: string;
  }
}

export const s3 = dimahS3({
  guard: async ({ request }) => {
    const session = await getSession(request);
    if (!session) throw s3Error("UNAUTHORIZED");
    return { locals: { userId: session.userId } };
  },
  ticketSubject: ({ locals }) => locals.userId, 
  // ...
});
  • ticketSubject runs when the ticket is issued and again on every call that carries it, after the instance and route guards. locals holds what those guards returned. Feature guards such as upload.guard have not run yet.
  • A different subject, or no subject, on a bound ticket returns UPLOAD_TICKET_INVALID.
  • Return undefined to issue an unbound ticket.
  • When the config sets no ticketSubject, the first plugin that provides one is used. db() binds tickets to the resolved scope.
  • The ticket stores a keyed hash of the subject, not the subject itself. The binding survives a secret rotation.

s3.put has no ticket, so ticketSubject does not run for it.


Upload token and marker

Each upload has an uploadToken, a random id carried in the ticket. Hooks receive it as uploadToken, and the server writes it to the object as S3 metadata (x-amz-meta-dimah-upload), signed into the upload.

  • Confirm and multipart complete require the stored object to carry the same token. Otherwise the key holds another upload's object: the call returns OBJECT_NOT_FOUND and nothing is deleted.
  • The internal key is removed from responses and from hook metadata.

Confirm runs at least once

Confirm is stateless. A retried confirm with the same ticket runs every check and upload.onConfirmed again. Dedupe on uploadToken:

upload: {
  onStart: async ({ uploadToken, key, expiresAt, locals }) => {
    await db.upload.create({
      data: { token: uploadToken, key, userId: locals.userId, expiresAt },
    });
  },
  onConfirmed: async ({ uploadToken, object }) => {
    // A replay finds the row already active and changes nothing.
    await db.upload.updateMany({
      where: { token: uploadToken, status: "pending" },
      data: { status: "active", size: object.size },
    });
  },
},

Handling tickets

  • Tickets never go into URLs. They travel in JSON bodies only.
  • Keep them out of logs and analytics.
  • A leaked ticket allows writes to one key until it expires, and only for the bound subject when one is configured.
  • The React upload store keeps the ticket so an interrupted multipart upload can resume. When several users share a browser, bind tickets to a subject so a stored ticket is useless to the next user.

On this page