---
title: Scratch storage & public links
order: '12'
summary: "Server-side S3-backed storage for big clipboard content, and WeTransfer-style\
  \ public share links \u2014 both end-to-end encrypted, both quota-metered."
path: concepts/scratch-and-public-sharing
status: published
---

Small text-shaped clips live inline in the envelope. That's cheap, fast, and requires nothing from the server beyond opaque bytes.

Big content is different. A 200 MB video, a PDF, a photo — inline in the envelope would bloat the log, slow fan-out, and fail entirely if the origin device logs off before every recipient catches up.

**Scratch storage** is where big content goes. **Public share links** are what you do with it when you want to hand something to someone who isn't on your ScaiGrid.

Both preserve end-to-end encryption. The server holds ciphertext and opaque metadata only.

## When to reach for scratch

- The content is over the inline threshold (default 256 KiB after encryption).
- The receiver's device might not be online at publish time; they'll pick it up later.
- The origin device might go offline before every receiver has downloaded.
- You want the clip to survive across a device swap.

For clips that don't need any of the above — a copy-paste of text between your own devices, right now — inline is still the right choice.

## How it works

Client encrypts locally (same primitives as inline clips — XChaCha20-Poly1305 for content, HPKE for wraps). Uploads ciphertext directly to Garage S3 via a signed URL. Publishes a small envelope carrying a `MediaRef` pointer to the scratch object. Recipients decrypt after fetching the ciphertext from Garage.

```
client → grid   scratch.reserve  (size, sha256, mime_hint)
grid → client   { object_id, upload_url }
client → S3     PUT ciphertext directly
client → grid   scratch.commit   (object_id, wraps for each recipient device)
grid            HEADs S3 to verify hash + size, installs wraps
client publish  envelope carrying MediaRef{ref_scratch, object_id, sha256, size}
```

Reader side:

```
receiver ← grid  clip.new  (envelope with MediaRef pointer)
receiver → grid  scratch.get { object_id }
grid → receiver  { download_url, my_wrap }
receiver → S3    GET ciphertext
receiver         unwrap key with own priv, decrypt, done
```

Grid never handles payload bytes. Latency is one round-trip to grid for the URL + a direct S3 fetch.

## Quotas

Scratch storage is metered. Three levels, **most-restrictive wins**:

| Level | Default limit |
|---|---|
| Per user | 5 GiB |
| Per tenant | 100 GiB |
| Per partner | 1 TiB |

Object count is also capped (per user: 10 000) so a single user can't fragment the store with millions of tiny objects.

Uploads over quota get rejected at `scratch.reserve` with `E_QUOTA_EXCEEDED { scope, used_bytes, limit_bytes, oldest_reclaimable }`. The `oldest_reclaimable` hint lets your UI offer *"You're out of scratch space. Delete this old file to upload?"* without a separate list call.

Soft warning at 80% capacity. The `scratch.reserve` response carries `quota_warning` for that case — your client can prompt before uploads land users in the reject zone.

Per-tenant quota overrides for enterprise deployments are wired via tenant policy in a later drop.

## Retention

Two paths reclaim scratch storage:

- **Idle** — objects untouched for 30 days (default) get reclaimed.
- **Absolute** — hard cap of 90 days regardless of touch, catches "I uploaded it once and never checked back."

Both are configurable per-deployment. Downloads refresh the idle timer.

Deleted-explicitly objects (via `scratch.delete`) go immediately.

## Public sharing

Any scratch object can be minted into a **public URL**. This is how you hand a big file to someone who isn't on your ScaiGrid — a customer, a contractor, a friend. WeTransfer without the trust ask.

### The URL shape

```
https://scaigrid.scailabs.ai/p/<token>#<b64url_key>
```

- `<token>` is a random 128-bit ID. The server sees this.
- `<b64url_key>` is the AEAD key that unlocks the ciphertext. Rides in the URL **fragment** — never sent to the server, never appears in logs. Only the browser sees it.

The server delivers ciphertext by token. The browser derives the key from the fragment and decrypts client-side. **The server never handles plaintext, ever.**

If you shared the URL over an untrusted channel — a customer's email, a Slack DM — an attacker who tapped the network sees `/p/<token>` in a proxy log, tries to fetch, and gets ciphertext they can't unlock without the fragment. That's the security posture Firefox Send was after, done right.

### Optional password

Pass `password` at publish time and the URL structure becomes:

```
https://scaigrid.scailabs.ai/p/<token>#<b64url_kdf_salt>
```

Client-side: user enters password, Argon2id(password, salt) derives the key, decrypts. Server sees ciphertext + salt but never the password or the key.

Rate-limited login flow (server-side): 5 attempts per minute per token before the whole token is temporarily locked.

### Download limits

Cap the number of successful downloads at publish time — the link auto-expires after that many. Right primitive for one-time shares.

### Revoke

`DELETE /v1/modules/scaiclip/scratch/{id}/public/{token}` at any time. Returns 410-Gone on subsequent fetches.

### Audit

Every public event lands in `audit_log`:

- `scaiclip.public_link.created` — with `service_id` if minted via SCSG
- `scaiclip.public_link.downloaded` — includes IP hash, user-agent class, bytes_out
- `scaiclip.public_link.revoked`
- `scaiclip.public_link.expired`
- `scaiclip.public_link.password_failed` — per-token attempt counter

Filter with the standard audit query API.

### Anti-abuse

Per-tenant rate limits on `scratch.publish`. Per-token rate limits on both fetch and password attempts. Enterprise deployments can plug in a scan-on-mint hook (call out to your own scanner) — it sees the client-supplied `mime_hint` and `sha256` only, not plaintext, so scanning is opt-in and hash-based.

## When to use `ref_origin` instead

The other sharing mode — `ref_origin` — streams from the source device on demand, cached briefly by the grid. Use it when:

- The content is ephemeral to that device (a screenshot you just took, a paste-buffer thumbnail).
- You don't want it hitting central storage at all.
- You don't need it to survive the origin going offline.

See [MediaRef sharing modes](./channels.md#mediaref) for the mode comparison.

## What's next

- [SCWP reference](../reference/scwp.md) — scratch verbs (`scratch.reserve` / `commit` / `get` / `list` / `delete`) and error codes.
- [Encryption & consent](./encryption.md) — the crypto that makes public sharing safe.
- [Server-side integrations](./service-consumers.md) — how SCSG-mediated services get `SCRATCH_READ` / `SCRATCH_WRITE` / `PUBLIC_SHARE` capabilities.