Content-Disposition: attachment + octet-stream (never rendered in the console's origin).| Location | Name | Required | Meaning |
|---|---|---|---|
| path | id | Yes | Use the ID from an authorized staff response; source validators reject malformed IDs. |
| path | n | Yes | Zero-based attachment index. |
Content-Disposition: attachment + octet-stream (never rendered in the console's origin).{error:{code,message}} for CloudError failures, attaches Request-Id, and disables caching. Handle 401 by signing in; show role refusal for 403 forbidden, reauthenticate only for step_up_required, and collect a reason for 400 reason_required. A CSRF failure needs a current page/session token, not blind retries. Operation-specific 404/409/413/422/502/503 errors retain their status. Honor Retry-After on 429./**
* GET /api/admin/support/tickets/:id/attachments/:n (support.read): a 5-minute presigned download URL for the thread's
* nth attachment, served as `Content-Disposition: attachment` + octet-stream (never rendered in the console's origin).
* Every download is audited (support.attachment.download, with the message id and size; not the file name).
*/
export const GET = adminRoute("support.read", async (_request, ctx, { params }: Context) => {
const { id: raw, n: index } = await params;
const id = ticketIdParam(raw);
if (!/^\d{1,5}$/.test(index)) throw notFound("Attachment");
const { message, file } = await attachmentAt(id, Number(index));
const url = await audited(ctx, {
action: "support.attachment.download", targetType: "ticket", targetId: id,
after: () => ({ messageId: message.id, n: Number(index), size: file.size, type: file.type }),
}, () => attachmentDownloadUrl(file.key, file.name, EXPIRES));
return adminJson({ url, expiresIn: EXPIRES, name: file.name });
});