feat: Presigned object URLs (s3 driver)

POST /buckets/:id/object-presigns mints SigV4 query-signed GET/PUT
URLs so clients move bytes directly against S3 — large transfers skip
the backend entirely.

- storage.Presigner optional interface; S3Store implements, local
  driver returns a clear error (no external surface to sign).
- TTL 60s–1h (default 15m); download requires read role + existing
  object row (no key probing); upload requires write role.
- S3_PUBLIC_ENDPOINT rewrites the client-facing host for internal
  S3_ENDPOINTs — signatures bind the host, URLs are valid only where
  minted.
- CLI: primora objects:presign <bucket> <key> [--upload] [--ttl].

Verified live against cloudserver: GET fetched the object with no
auth headers; PUT pushed 1 MB direct to S3; unsigned list → 403;
untracked-key download presign → 404.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-24 20:35:21 +02:00
1 parent a84049b8fc
commit fd36389328
22 files changed
+446 -7

No files matched your search

+7
View File
@@ -12,6 +12,7 @@ import {
cmdObjectsDownload,
cmdObjectsList,
cmdObjectsRemove,
cmdObjectsPresign,
cmdObjectsUpload,
} from "./commands/objects.js";
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
@@ -114,6 +115,12 @@ cli
.command("objects:rm <bucket> <key>", "Delete an object")
.option("--project <id>", "Project override")
.action(cmdObjectsRemove);
cli
.command("objects:presign <bucket> <key>", "Mint a presigned URL (s3 driver)")
.option("--project <id>", "Project override")
.option("--upload", "PUT URL for direct upload instead of GET download")
.option("--ttl <seconds>", "Lifetime 60-3600 (default 900)")
.action(cmdObjectsPresign);
cli
.command("keys:list", "List API keys for the current project")
+29 -1
View File
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { basename } from "node:path";
import { StorageService } from "@primora/api-client";
import { PresignObjectRequest, StorageService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import {
@@ -133,3 +133,31 @@ export async function cmdObjectsRemove(
await StorageService.deleteBucketObject({ bucketId, objectKey });
success(`Deleted ${objectKey}`);
}
export async function cmdObjectsPresign(
bucketRef: string,
objectKey: string,
options: { project?: string; upload?: boolean; ttl?: string; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const bucketId = await bucketIdFor(cfg, bucketRef, options.project);
const presigned = await StorageService.presignBucketObject({
bucketId,
requestBody: {
key: objectKey,
op: options.upload
? PresignObjectRequest.op.UPLOAD
: PresignObjectRequest.op.DOWNLOAD,
ttl_seconds: options.ttl ? Number(options.ttl) : undefined,
},
});
if (isJson(options)) {
printJson(presigned);
return;
}
console.log(presigned.url);
console.error(`# ${presigned.method} — expires ${presigned.expires_at}`);
}