# 10 — Files API

File storage is opt-in per extension. Set `usesFileStorage: true` in
`Flowrunner.createExtension(...)` (see [01-service-structure.md](01-service-structure.md)) to provision
`ctx.files` in every handler's context bag. When `usesFileStorage` is false (the default), `ctx.files` is
a **proxy that throws** — so accidental use fails loudly instead of silently no-op'ing.

- `files` appears in the [handler context bag](07-actions.md) only when `usesFileStorage: true`.
- A param that references a stored file is declared `z.url()` (or `z.string()`) and carries the file's URL.
  There is no file-picker widget, so the flow author binds the URL from an earlier block or pastes it.

## Method surface

`ctx.files` is the platform file-service client, and it is typed as such — the methods, options and
return shapes below are what an editor completes and checks, whether or not `usesFileStorage` is on (when
it is off the same type is backed by the proxy that throws). All options objects are optional.

| Method | Options | Returns | Options as a param |
|---|---|---|---|
| `uploadFile(buffer, options)` | `scope`, `filename`, `ttl`, `generateUrl` (default `true`), `objectTtl`, `overwrite` | `{ key, filename, url, size, urlExpiresAt, objectExpiresAt, overwritten }` | `z.filesUploadOptions([…])` |
| `list(options)` | `scope`, `ttl`, `generateUrl`, `limit`, `cursor` | `{ files: FileInfo[], cursor, hasMore }` | `z.filesListOptions([…])` |
| `get(filename, options)` | `scope`, `ttl`, `generateUrl` | `FileInfo` — `{ key, filename, url, size, createdAt, urlExpiresAt, objectExpiresAt }` | `z.filesGetOptions([…])` |
| `delete(filename, options)` | `scope` | `{ key, freedBytes, deleted }` | `z.filesDeleteOptions([…])` |
| `usage()` | — | `{ workspaceId, usedBytes, quotaBytes, usedFormatted, quotaFormatted, utilizationPercent, fileCount }` | — |

The last column is the same option list **as a parameter the flow author fills in** — see below.

| Option | Meaning |
|---|---|
| `scope` | which storage layer — **defaults to `'FLOW'`**. Name it with `filesScope` rather than a string; `usage()` is always workspace-scoped |
| `ttl` | signed-URL lifetime, in seconds |
| `generateUrl` | whether to include a pre-signed URL in the result |
| `objectTtl` | how long the stored object lives; omit for permanent |
| `overwrite` | replace an existing file with the same name instead of failing |
| `limit` / `cursor` | pagination for `list` |

### Letting the flow author choose — the option groups

Every option in the tables above is also available as a **param group the platform ships**, one per call. So
a flow author can pick the storage layer — or a filename, a limit, a link lifetime — without you redeclaring
the dropdown, its values, its labels and its default:

```js
params: z.object({
  fileOptions: z.filesUploadOptions(['scope']).label('File Settings').optional(),
})
```

The argument is the whitelist of fields to expose, and the parsed object goes straight into the matching
call — `params.fileOptions` into `uploadFile`, `params.listOptions` into `list`, and so on:

```js
execute: async ({ params, files }) => {
  const bytes = await Flowrunner.Request.get(params.url).toBuffer()

  return files.uploadFile(bytes, { ...params.fileOptions, filename: 'agreement.pdf' })
}
```

**Do not hand-write these objects.** A copy in your service is a copy that stops tracking the platform: when
an option is added, a label reworded or a default changed, every service using the group picks it up and
yours does not. It is also how a service ends up publishing a setting the runtime does not have — a field a
flow author fills in that is silently dropped.

See *Platform param schemas* in [03-parameters.md](03-parameters.md) for the whitelist rules, the runtime
contract, and which options are risky to expose.

### Naming a layer — `filesScope`

The handler bag carries `filesScope`, the three layers as named constants, so a typo is a `TypeError` at the
call site instead of a scope that silently does not exist:

```js
execute: async ({ files, filesScope }) => {
  const scope = filesScope.WORKSPACE

  const saved = await files.uploadFile(buffer, { scope, filename: 'report.txt' })

  return files.list({ scope })
}
```

| | |
|---|---|
| `filesScope.WORKSPACE` | shared by the whole workspace, outlives every run |
| `filesScope.FLOW` | one flow, shared across its executions — the **default** |
| `filesScope.EXECUTION` | a single run; intermediate artifacts |

It is in the bag whether or not `usesFileStorage` is on — it is a set of constants, not a capability. Plain
strings still work, so nothing existing breaks.

`FLOW` and `EXECUTION` scopes require the flow / execution ids to be present in the execution context — the
runtime supplies them, but a call made outside a flow run (e.g. a design-time sample loader) can only use
`WORKSPACE`.

```js
const ext = Flowrunner.createExtension({ /* … */ usesFileStorage: true })

ext.addAction({
  category: 'Files', id: 'saveReport', label: 'Save Report', description: '…',
  params : z.object({ name: z.string().label('File name') }),
  result : { url: 'https://…', size: 1024 },
  execute: async ({ params, files, filesScope, apiRequest }) => {
    const report = await apiRequest({ url: '/reports/export' })
    const saved  = await files.uploadFile(Buffer.from(report), {
      scope: filesScope.FLOW, filename: params.name, generateUrl: true, ttl: 3600,
    })

    return { url: saved.url, size: saved.size }
  },
})
```

> **`uploadFile` takes bytes, so fetch bytes.** `Buffer.from(text)` is right only when the source really is
> text. Download a PDF, an image or a zip with **`.toBuffer()`** and hand it over as it comes; fetching one
> through `apiRequest` decodes and parses it, and the corruption only shows up when someone opens the stored
> file ([04-http-requests.md](04-http-requests.md)).
>
> ```js
> const bytes = await Flowrunner.Request.get(params.fileUrl).toBuffer()
> const saved = await files.uploadFile(bytes, { scope: filesScope.FLOW, filename: 'report.pdf' })
> ```

> **Substituting the store.** A host can replace this client for one invocation by passing
> `runServiceMethod({ createFiles })`. The factory receives the file-service context —
> `{ serverURL, appId, apiKey, runtimeKey, flowId, executionId, retrieveNewS3Token }` — and returns anything
> satisfying **`FilesApi`**: the same five methods tabled above. `FilesApi` and its value types are exported
> from the package root, so an implementation can be type-checked against the contract. It is consulted only
> when the extension declares `usesFileStorage: true`; otherwise the throwing proxy is used as before.
>
> **Service code is unaffected and unaware** — it still calls `files.uploadFile(...)` and reads `url` off the
> result, and whatever URL the implementation returns is the URL the service passes on. The case this exists
> for is end-to-end testing: a harness implements `FilesApi` over its own storage (a local MinIO, a scratch
> bucket) and returns presigned URLs, so a third party in the test can really fetch what the service
> uploaded — no HTTP to the platform, no patching of runtime internals.
>
> Writing one, two things matter. Lay keys out per storage layer and list **one level at a time**: the
> execution prefix nests inside the flow prefix, so a recursive listing leaks a run's files into its flow —
> list with a delimiter. And clean up between runs yourself; there is no teardown hook, so deleting the test
> prefix is the harness's job.

> **Scopes need a flow.** `FLOW` and `EXECUTION` scopes address a path built from the running flow and
> execution, so they only work when there *is* one. A method invoked outside a flow execution — an OAuth
> step, a dictionary or loader, a manual run from the console — has neither, and a `FLOW`-scoped call (the
> default for `uploadFile` / `list` / `get` / `delete`) fails there. Use `WORKSPACE` scope for anything such
> a method has to read or write, and keep `FLOW`/`EXECUTION` for work that happens inside a flow.
>
> The same applies to `usage()`, which is always workspace-scoped and therefore works everywhere.
