
# File Manager — reference

> Load this file when: evaluating, integrating with, installing or troubleshooting the **File Manager app** — what it does, the storage backends it spans, how a member's identity is bound to what they can see, its `p_filerecords` custom object, and the `api.worksby.design/apps/filemanager/` endpoints.

> 🔒 **Works by Design system — not a HubSpot platform feature.** File Manager is our app, and these endpoints work only for portals where it is installed. Portable regardless: routing files to different backends by rule rather than by hand, treating a signed capability as the whole credential for a browser navigation, and the reason a download must never be a redirect to a signed CDN URL. What we share vs. what we can only demo: [projects-catalog.md](projects-catalog.md)

## What File Manager is

A file library your customers, members or applicants use on your website — with the documents filed against the CRM records they belong to, rather than in a folder tree nobody maintains.

A member signs in to your site and sees the files that are theirs: attached to their company, their ticket, their project, or to a record type you chose. They upload, and the upload lands attached to the right records automatically. Nothing about it requires anyone to have a HubSpot seat.

| | |
|---|---|
| **Kind** | Public HubSpot app (OAuth), installed per portal |
| **Where it runs** | A module on your own website pages, behind whatever membership you already use |
| **Stores files in** | More than one place, by rule — see [Storage](#storage-that-spans-more-than-one-place) |
| **Stores metadata in** | A custom object on your own portal, `p_filerecords`, provisioned automatically at install |
| **Also available as** | A component another app can render inside itself — the Portal app shows its file UI without holding a copy of it |
| **How to get it** | Not self-service. Installs are allowlisted: [w@reus.ie](mailto:w@reus.ie) |

## What it does

**Files follow records, not folders.** A file is attached to CRM records — a company, a contact, a ticket, a project, or a custom object type you nominate — and who can see it follows from that. There is no permission tree to maintain in parallel with your CRM, because the CRM *is* the tree.

**One upload, several records.** A member attaches a document once and it lands against every record it belongs to — an invoice against the company and the ticket, a certificate against the contact and the project. This is one component, not a workflow.

**Uploads arrive where you decided.** Routing rules decide the backend, and mapping rules decide the records — both configured once by an administrator, not chosen by the member at upload time.

**Downloads stream.** A download is a real streamed response with a filename, not a base64 payload the browser has to reassemble, which is what lets it handle files far larger than a page can hold in memory.

## Storage that spans more than one place

The point of the multi-source design is that "where the bytes live" is a rule, not a decision anyone makes per file. A member sees one library.

| Backend | Status |
|---|---|
| **HubSpot Files** | Live. The default for ordinary documents |
| **Private file server** | Live. Takes what HubSpot should not or cannot — video by extension, and anything above the routing threshold |
| **Google Drive** | Live. A folder on the customer's own Drive, swept on a schedule as well as on demand |
| **SharePoint** | **Built, and switched off everywhere.** Written against a documented Microsoft Graph contract and a fixture — no real tenant has answered any of it yet. It is not a supported option until credentials arrive and the go-live measurements are taken. We would rather say that than ship it quietly enabled |

Adding another backend is a bounded piece of work rather than a rewrite: the registry, the sync engine and the download dispatcher are generic, and a new source supplies the provider-specific parts.

**Size:** the server refuses anything above **4 GB** outright. What a given portal actually accepts is usually lower — computed per portal from its own configuration — and what the upload area advertises to a member is lower still. Treat the advertised figure as a hint and the server's answer as the truth.

## What it does not do

- **It is not a document management system.** No versioning, no check-out, no approval workflow, no retention policy engine.
- **It is not a HubSpot seat replacement for staff.** It is the member-facing side; your team works in the CRM.
- **It does not sync a whole Drive.** A configured folder, not a whole account.
- **It does not do previews in-app.** A file is downloaded, not rendered inline — deliberately, because the shortcut that makes preview easy is the one that breaks downloads (see [Conventions](#conventions-and-gotchas)).

## Identity — how a member is bound to their files

Every request arrives on behalf of a **proven contact**, not a claimed one.

The page tells the app who the visitor is; the app verifies that claim against the CRM before minting a **signed session token**, and every contact-scoped route reads the contact from that token. An id in a request body is never taken as proof of identity — if one is supplied, the token's contact overrides it. That is what makes "show me the files for contact 123" safe to expose to a browser.

Two consequences worth knowing before you design against it:

- **Enforcement is per install.** A portal's posture depends on its own configuration and on having a session-aware client deployed. Send the token whenever you have one, and never treat its absence as optional.
- **Downloads carry their own credential.** A browser navigation cannot attach a header, so a download URL is minted on demand, short-lived, and bound to the specific file. The capability *is* the credential, and it is cryptographically distinct from a session token — neither verifies as the other, which is what makes its ten-minute life real rather than decorative.

The deeper trust boundary — what the session proves and what it does not — is a deployment-review conversation rather than a published one. Ask for it.

## Endpoint index

Generated from the same policy manifest the service's own route-coverage test asserts against, so it cannot drift from what is running.

**Member** is the bulk of it — a proven contact acting on their own files. **Scoped** routes are the ones a browser reaches without a session header: a download link, an upload already in progress, an OAuth callback. Each carries a signed capability bound to that one thing, which is what lets a plain navigation or a redirect work without ever being a way in to anything else.

<!-- routes:filemanager — GENERATED from routes/apps/filemanager/policy-manifest.js by sync-routes.mjs. Do not edit by hand. -->

15 endpoints — 2 public, 8 member, 5 scoped.

| Endpoint | Surface | Auth required |
|---|---|---|
| `GET /apps/filemanager/license-status` | Public | None |
| `POST /apps/filemanager/session` | Public | Portal + licence |
| `POST /apps/filemanager/associable-targets` | Member | Portal + contact session |
| `POST /apps/filemanager/delete-files` | Member | Portal + contact session |
| `POST /apps/filemanager/download-files` | Member | Portal + contact session |
| `POST /apps/filemanager/download-url` | Member | Portal + contact session |
| `POST /apps/filemanager/fetch-files` | Member | Portal + contact session |
| `POST /apps/filemanager/search-files` | Member | Portal + contact session |
| `POST /apps/filemanager/upload-file` | Member | Portal + contact session |
| `POST /apps/filemanager/upload-init` | Member | Portal + contact session |
| `GET /apps/filemanager/download/:capability` | Scoped | A signed capability in the URL, which is the whole credential |
| `GET /apps/filemanager/google/callback` | Scoped | A signed capability in the URL, which is the whole credential |
| `GET /apps/filemanager/upload-status/:uploadId` | Scoped | Bound to the upload in progress |
| `POST /apps/filemanager/upload-chunk/:uploadId` | Scoped | Bound to the upload in progress |
| `POST /apps/filemanager/upload-finalize/:uploadId` | Scoped | Bound to the upload in progress |

<!-- /routes:filemanager -->

**Base URL:** `https://api.worksby.design/apps/filemanager/`. Every request carries `portalId`; contact-scoped routes additionally carry the session token as a header.

## Data model

One custom object, `p_filerecords`, created on your portal automatically during install. It holds the metadata — name, size, type, when and by whom — and the associations to whatever the file belongs to. The bytes live in whichever backend the routing rules chose.

One field is worth understanding because it is load-bearing: **the stored path tells you which backend holds the file**, without a separate column to keep in step. A relative path means the private file server; a full URL means HubSpot. A schema field that could disagree with reality was deliberately not added.

Object type ids differ per portal — resolve them at runtime from the schema list, and use the `p_` prefix form of the name rather than a portal-stamped one.

## Surfaces

| Surface | Where | What it is |
|---|---|---|
| **File library** | A module on your website pages | The member-facing library — browse, search, upload, download |
| **Upload area** | Same module, or standalone | Drag and drop, with the record targets applied by rule |
| **Settings** | HubSpot, in the app's settings | Sources, routing, mapping targets, sync schedule, and a security and storage status panel |
| **Rendered inside another app** | Wherever that app runs | The library can render inside the Portal app at runtime — one implementation, two products, no fork |

## Integration seams

| With | What crosses |
|---|---|
| **Portal** | Portal renders File Manager's file UI **at runtime** rather than holding a copy of it. That is why a fix here reaches both products at once, and why Portal ships no file code of its own |
| **Your website's membership** | Whatever already authenticates your visitors. File Manager takes the identity your page asserts, verifies it against the CRM, and works from there — it does not want to own your login |

## Conventions and gotchas

- **Never turn a download into a redirect to a signed CDN URL.** HubSpot's signed URLs carry no `Content-Disposition`, so the browser renders PDFs and images inline instead of saving them, and the bug looks like a UI problem rather than a routing one. Stream it and set the header yourself. This one has cost time on more than one app.
- **The advertised upload limit is UI, the server's is truth.** Three numbers are in play — the hard ceiling, the portal's effective maximum, and what the dropzone displays. Only the first is guaranteed; validate against the server's answer, not the label.
- **A scope added to the app does not reach a portal until it reinstalls.** Until then the affected calls fail in ways that read like unrelated bugs. Check the install state before debugging the feature.
- **Storage routing is one shared module**, used by File Manager and Portal alike, so a routing change is a two-product change. That is deliberate — two copies of a routing policy is how files start landing in two different places.
- **Resolve association type ids by label at runtime.** They are per portal, and a hardcoded id fails silently rather than loudly.

## Verifying it works

Deploying is not verifying. On the portal you care about, in order:

1. **Check the app reports itself licensed and installed** with no outstanding scope changes.
2. **Load the module on a page as a signed-in member** and confirm the library lists that member's files — and only theirs.
3. **Sign in as a different member** and confirm the list changes. This is the check that actually tests the identity binding; step 2 alone passes just as happily when everyone sees everything.
4. **Upload a file** and confirm it appears against every record the mapping rules should have attached it to — not just the first.
5. **Download it** and confirm it saves with the right filename rather than opening in a tab.
6. **Upload something large enough to route to the other backend** and repeat step 5. Routing is the part that is easy to get right for the common case and wrong for the exception.
