# Storage

Files live in buckets; each bucket belongs to a project and is its own bucket in object storage. A file's key is its path (`avatars/42.png`); folders are just the part of the key before a slash.

Files are private. To upload or download, your server asks Harakumo for a signed URL for one key, and the client sends or fetches the bytes directly. There are no public bucket URLs and no S3-style access keys yet.

From a phone app, the app's backend function signs the link and the app sends the bytes: see Use Harakumo from a phone app.

## In plain words

Private folders for your app's files, such as photos, documents and videos. People upload and download straight from their browser with links that stop working after a while, so nothing is public by accident.

For example: A customer attaches a photo of a damaged parcel to a support request.

## Upload from the browser

1. The browser tells your server the file's size (`file.size`). Your server signs a PUT for a key, naming the Content-Type and that `size`.
2. The browser PUTs the file to that URL within 15 minutes, with the same Content-Type header. Buckets accept browser uploads from any origin.

**Server**

```js
// size: the file's length in bytes, sent by the browser
const { url, expiresAt } = await hk.storage.presign(bucketId, {
  key: 'avatars/' + userId + '.png', method: 'put', contentType: 'image/png',
  size, maxBytes: 5 * 1024 * 1024,   // refuse anything over 5 MB before a link exists
});
// hand url to the browser
```

**Browser**

```js
await fetch(url, { method: 'PUT', headers: { 'content-type': 'image/png' }, body: file });
```

## Upload links

- An upload link lasts at most 15 minutes. A longer `expiresIn` is shortened to 900 rather than refused (any value up to 604800, the most any link may ask for): the answer's `expiresIn` is what the link got, `requestedExpiresIn` what you asked for, and `note` says so.
- With `size`, the file's exact length in bytes, the link accepts exactly that many bytes and no other length, so nobody who gets hold of it can upload more. `size` is optional on every plan, Hobby included, but a link without it is not locked to any length: whoever holds it can send one file of up to 5 GB. Send `size` before a link goes into an app. The dashboard's Upload button and the SDK's `hk.storage.upload` send it for you.
- A `size` over the plan's per-file cap (1 GB on Hobby, all the file storage it includes; 5 GB on the other plans) is refused with 413 `file_too_large` and no link. So is one over `maxBytes`, the most you will accept; `max_bytes` in the answer is the limit that refused it.
- `maxBytes` without `size` is refused with 400 `size_required`, since a link cannot be held to "at most": send both.
- `contentType` is signed too: the PUT must send exactly that Content-Type.

## Resumable uploads

For a big file, or a phone on a bad network: the file goes up in parts, each straight to storage on its own link, so a part that fails is sent again on its own and an upload left half done carries on later from the parts already there.

1. Start it: `POST /api/storage/:id/uploads { key, size, contentType?, partSize?, maxBytes? }` → `{ uploadId, partSize, partCount, expiresAt }`. `size`, the file's exact length, is required here. Parts are 8 MiB unless `partSize` (5 MiB up to 5 GiB) says otherwise; every part but the last is that long.
2. Get links: `POST /api/storage/:id/uploads/:uploadId { action: "sign", parts: [1, 2, …] }`, up to 100 parts a call. PUT exactly that part's bytes to each `url` within 15 minutes; a link takes no other length.
3. Carry on after a dropped connection: `GET /api/storage/:id/uploads/:uploadId` lists the parts storage holds (`uploaded`) and the ones still `missing`; `GET /api/storage/:id/uploads` finds an open upload again by its key.
4. Finish: `{ action: "complete" }` joins the parts into the file (409 `parts_missing`, with `missing`, until every part is there). `DELETE` on the upload stops it and drops its parts.

> One file is held to the same per-file cap as a single upload (413 `file_too_large`). An upload must be finished within six days of its start (410 `upload_expired` after). In Node, SDK 0.3.0 and later does all of it with `hk.storage.uploadResumable(bucketId, key, data)`, and `hk.storage.uploads` has each call for a server that hands a phone the part links. Starting, signing and finishing need the developer role (a full key); any member can read an upload.

## Download links

Sign a GET for a key and send the person (or an img tag) to the URL. Links last 15 minutes by default; pass `expiresIn` from 60 seconds to 7 days (604800). Viewers and read-only keys get download links of at most 15 minutes. For files shown to many people, sign a longer link once and reuse it instead of signing on every view.

**curl**

```bash
curl -X POST https://harakumo.com/api/storage/7 \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "key": "reports/q3.pdf", "method": "get", "expiresIn": 86400 }'
# → { "url": "…", "expiresIn": 86400, "expiresAt": "…" }
```

## Listing and deleting

- `GET /api/storage/:id` lists objects: `prefix` narrows to a path, `delimiter=/` groups keys into `folders`, and `limit` (up to 1,000, default 100) with `cursor` pages through; `nextCursor` is set while more remain.
- `POST { key, method: "delete" }` removes one object. `DELETE /api/storage/:id` empties and deletes the bucket.
- Sizes and file counts are measured daily (or on `?measure=1`) and shown as a dash until a full count exists.

## Who can do what

- Viewers and read-only keys can list and get download links.
- Uploading and deleting need the developer role (a full key).
- A key limited to the project works for all of it.

## Limits and scaling

- One file is at most 5 GB, in one signed PUT or as a resumable upload in parts. On Hobby a link with `size` is at most 1 GB, all the file storage the plan includes.
- Keys are up to 1,024 characters.
- Buckets per plan: 2 hobby, 20 pro, 100 team, 500 scale.
- File storage per plan, all buckets together: 1 GB hobby, 50 GB pro, 250 GB team, 1,000 GB scale, measured daily (on Hobby, across all your free workspaces; on a paid plan, across the workspaces it covers; a Pro subscription from before these plans keeps 100 GB). On Hobby, new uploads are refused once the plan's file storage allowance is used up (402 `usage_limit`). On Pro, Team and Scale, use past it is charged daily from the paying workspace's Balance at $0.05 per GB a month (Team's $20 monthly usage credit pays first), and new uploads pause (402 `usage_paused`) only while a charge cannot be paid. Deleting files frees room once the bucket is measured again: daily, or right away with `GET /api/storage/:id?measure=1` (for a bucket of up to 10,000 files).
- The bytes never pass through Harakumo; each signed URL is one API call.

## Reference

**SDK**

```js
const { bucket } = await hk.storage.create(project.id, { name: 'uploads' });
const { url } = await hk.storage.presign(bucket.id, { key: 'file.png', method: 'put', contentType: 'image/png', size: 48213 });
const { url: download } = await hk.storage.presign(bucket.id, { key: 'file.png', method: 'get', expiresIn: 3600 });
const { objects, folders, nextCursor } = await hk.storage.objects(bucket.id);   // first page
await hk.storage.deleteObject(bucket.id, 'file.png');
```

**CLI**

```bash
harakumo storage 12              # list buckets
harakumo storage create 12 uploads
```

**REST**

```http
POST   /api/projects/:id/storage     { "name": "uploads" }
POST   /api/storage/:id              { "key": "file.png", "method": "put"|"get"|"delete", "contentType"?, "expiresIn"?, "size"?, "maxBytes"? }
GET    /api/storage/:id?prefix=&delimiter=/&limit=&cursor=     → { objects, folders, nextCursor, truncated }
DELETE /api/storage/:id              # empties and deletes the bucket
POST   /api/storage/:id/uploads      { "key", "size", "contentType"?, "partSize"?, "maxBytes"? }   # a resumable upload
GET    /api/storage/:id/uploads      # open uploads, newest first
GET    /api/storage/:id/uploads/:uploadId                  → { uploaded, missing } while open
POST   /api/storage/:id/uploads/:uploadId  { "action": "sign", "parts": [1, 2] } | { "action": "complete" }
DELETE /api/storage/:id/uploads/:uploadId                  # stop it and drop its parts
```
