HTTP Storage Protocol
HTTP Storage Protocol v1 defines requests and responses between the Office HTTP Storage adapter and a customer-operated HTTP Storage Provider. You implement the Provider endpoints below; Office sends requests to them. See HTTP Storage for connection instructions and examples.
This document defines the methods and paths, request headers and bodies, response fields, and status codes for all nine endpoints. Common authentication, path, and size rules apply to every endpoint. Additional conditions appear in each operation's section.
From document listing to closing
First, prepare code that builds a document-open URL in your application. This example assumes that the http-remote-directory adapter is connected to a Provider that implements list, put, lock, and unlock.
- Choose the Office address and registered adapter name. Send
/opento the Office address, not the Provider. - Put the selected document's relative path in the URL path and set
app,docId, anduser_id. - Add a
userSessionTokenissued by your service after checking the user and document access. Serialize the metadata as a JSON string and set__tfo_adapter_http_storage_client_metadata;URLSearchParamsapplies URL encoding once.
function createOpenUrl(documentPath, docId, userSessionToken) {
const encodedPath = documentPath.split("/").map(encodeURIComponent).join("/");
const url = new URL(
`/cloud-office/api/http-remote-directory/${encodedPath}/open`,
"https://office.example.com",
);
url.searchParams.set("app", "WORD_EDITOR");
url.searchParams.set("docId", docId);
url.searchParams.set("user_id", "example-user");
url.searchParams.set("__tfo_adapter_http_storage_client_metadata", JSON.stringify({
project: "example-project",
userSessionToken,
}));
return url.toString();
}
const openUrl = createOpenUrl(
"contracts/sample.docx",
"sample01",
"example-short-lived-document-session-token",
);
The token above is illustrative. Use the value issued by your service when the document is selected. Open the completed openUrl in the browser or set it as the Office iframe's src. The customer metadata example shows the JSON string, encoded parameter, and complete URL.
After preparing this URL builder, the sequence below follows listing → document selection and URL creation/opening → editing → saving → closing. list serves the document selection screen; info and later operations handle the selected document's /open and subsequent work. Listing also requires the customer session and listing permission expected by the Provider.
TFO and HTTP Storage Adapter are built into the Office framework; you do not need to implement them. For the storage integration, the component you must implement and operate is HTTP Storage Provider. Your application builds the document-open URL and supplies the customer session as shown above.
The Office Adapter signs a JWT containing the document path, request details, and request.client_metadata, then places it in the X-TFO-Storage-Request-JWT header of each HTTP request to the Provider. Document file bytes travel in the HTTP body rather than the JWT.
On every HTTP request, the Provider must verify the JWT signature and compare the signed request details with the actual request. You must also validate the customer session and the current permissions for the document and operation. Reject failed verification or authorization before accessing storage. Follow JWT signing and verification for the complete checks.
Implement each endpoint statelessly: complete the request without depending on intermediate processing state from an earlier request. For example, get must not rely on temporary state created by an earlier info call. It must be able to verify the incoming request and read the document independently.
The lock and unlock operations must share the document's lock state and owner. You must also retain JWT replay-protection records. These are separate from carrying intermediate request state from info to get.
Closing a document sends no separate close or stop request to the Provider. An automatic save during closing uses put. See Save types for the meaning of save_type: close.
Request order and frequency can vary with editor state and collaboration. A document may be saved several times while editing. The diagram shows the last participant finishing a save and closing the document. See Unsupported operations for Providers that do not implement lock and unlock.
URLs and paths
{PROVIDER_BASE_URL}/tfo-http-storage/v1/{ENCODED_DOCUMENT_PATH}/{OPERATION}
The Provider base URL is an HTTP or HTTPS address and may include a path prefix. User information, query strings, fragments, and . or .. path segments are not allowed. Remove any trailing / before appending the protocol path.
Document paths are relative to the Provider root. Percent-encode each UTF-8 path segment separately and join them with /. The root has an empty document path, as in /tfo-http-storage/v1/list. Do not insert empty intermediate segments or extra / characters.
| Item | Example |
|---|---|
| Provider base URL | https://storage.example.com/office |
| Document path | contracts/Proposal 2026.docx |
| Document information request | GET /office/tfo-http-storage/v1/contracts/Proposal%202026.docx/info |
| Root listing request | GET /office/tfo-http-storage/v1/list |
The Provider must resolve decoded paths only within its storage root. Reject . and .., / or \ inside path segments, control characters, invalid UTF-8, and symbolic links that escape the root. The protocol does not use query parameters, cookies, redirects, or forwarding of arbitrary authentication headers. Proxies must preserve the raw encoded path used for JWT verification.
Common request headers
| Header | Scope | Value |
|---|---|---|
X-TFO-Storage-Request-JWT | Required on every request | The raw JWT signed according to the rules below, without a Bearer prefix |
Content-Type | put, lock, unlock, mkdir, rename | application/octet-stream for put; application/json for JSON operations |
Content-Length | Required on requests with a body | A single decimal integer giving the exact body length in bytes |
Requests for info, list, get, and delete have no body or Content-Type. Sign them with body length 0 and the SHA-256 digest of an empty body. Do not wrap bodies in multipart or base64, or use chunked uploads or compression. Send JSON as UTF-8 bytes and include their length and SHA-256 digest in the signed JWT.
JWT signing and verification
Office signs a JWT containing request details with the shared secret. Before reading or modifying a document, the Provider must verify the signature with that secret and compare the signed method, path, and body details with the received request. This detects changes in transit. It must also check the JWT's validity period and unique request ID to reject expired or reused JWTs.
Signing does not encrypt document contents. Use HTTPS to protect data in transit, and separately check user authentication and document permissions through your service's session and permission records.
Office and the Provider share a random secret of at least 32 UTF-8 bytes per adapter. The HMAC key is the UTF-8 byte representation of this string. Never send the secret in request URLs or bodies.
The JWT uses compact JWS format, signed with HS256, and the following header.
{
"alg": "HS256",
"typ": "tfo-storage-request+jwt"
}
The following payload represents a request to save contracts/sample.docx. The file size and SHA-256 digest are illustrative; calculate them from the file you send. Generate fresh iat, exp, and jti values for every request.
{
"iss": "thinkfree-office",
"aud": "tfo-http-storage-provider",
"iat": 1789344000,
"exp": 1789344060,
"jti": "26bc9b5e-9dab-4a78-95d8-b1ae34a5d9eb",
"request": {
"adapter": "http-remote-directory",
"method": "PUT",
"path": "/tfo-http-storage/v1/contracts/sample.docx/put",
"content_type": "application/octet-stream",
"content_length": 231958,
"content_sha256": "754464a613ba4d87d1535f69a9b1e3a0a8f84b8277d2bf4d0c74aacceceb23d7",
"office_connection_id": "example-connection",
"arguments": {
"save_type": "save"
},
"client_metadata": {
"project": "example-project",
"userSessionToken": "example-short-lived-document-session-token"
}
}
}
| Field | Type | Required | Validation rule |
|---|---|---|---|
Header alg | string | Yes | Exactly HS256 |
Header typ | string | Yes | Exactly tfo-storage-request+jwt |
iss | string | Yes | Exactly thinkfree-office |
aud | string or single-element array | Yes | tfo-http-storage-provider, or an array containing only that value |
iat | integer | Yes | Issued-at Unix time in seconds, not later than the current time |
exp | integer | Yes | Expiration Unix time in seconds, later than the current time, with 0 < exp - iat <= 60 |
jti | string | Yes | A unique ID for each request |
request | object | Yes | Contains the signed request fields below |
request.adapter | string | Yes | Registered adapter name: 1–128 ASCII alphanumeric, ., _, or - characters, beginning with an alphanumeric character. Must match the adapter name associated with the secret used to verify the signature |
request.method | string | Yes | Matches the actual uppercase HTTP method |
request.path | string | Yes | Matches the actual raw encoded path, including the Provider base path, without scheme, host, or query |
request.content_type | string | When the header is present | Exactly matches the actual Content-Type |
request.content_length | integer | Yes | The actual number of body bytes received, or 0 when there is no body |
request.content_sha256 | string | Yes | SHA-256 of the actual body bytes, as 64 lowercase hexadecimal characters |
request.office_connection_id | string | No | Identifies an Office connection; do not interpret its internal structure or use it as proof of user authentication |
request.arguments | object | No | Operation context such as save_type; does not grant access |
request.client_metadata | object | No | Customer-defined JSON object that the Provider can use for user authentication and authorization after signature verification |
The complete JWT must not exceed 8,192 bytes. See Client metadata for the client_metadata input and limits.
Verification order
- Check that the request method, path, headers, and body size meet the requirements.
- Use
request.adapterto select a configured secret, then verify the original JWT's signature, issuer, audience, and validity period. Use the unverified adapter name only to select the secret. - Compare the signed request fields with the actual request. Calculate the body length and SHA-256 digest from the received bytes.
- Check and record
jtiin one atomic operation, and reject reuse of that JWT untilexp. Multiple Provider instances must share these records. - Check document access permissions, then perform the operation or return an unsupported operation response.
Reject requests that fail verification before accessing storage. Keep the Office and Provider server clocks synchronized. Office must generate fresh iat, exp, and jti values for retries. jti is not an idempotency key that prevents duplicate operations, so check storage state if a response is lost.
Client metadata
To pass customer-defined context when opening a document, supply __tfo_adapter_http_storage_client_metadata as a JSON string in the Office /open parameters. URL-encode it once when using a query string. For a signed document-open ticket, put the same JSON string in command.params under that name; unsigned query parameters cannot override the signed value. This is not a Provider endpoint query parameter.
The request JWT received by the Provider contains the parsed JSON object in request.client_metadata.
For example, build the following URL to open contracts/sample.docx through the http-remote-directory adapter. Replace the Office address and document path for your environment, and use a real token issued by your service. The token below is illustrative.
const metadata = {
project: "example-project",
userSessionToken: "example-short-lived-document-session-token",
};
const openUrl = new URL(
"/cloud-office/api/http-remote-directory/contracts/sample.docx/open",
"https://office.example.com",
);
openUrl.searchParams.set("app", "WORD_EDITOR");
openUrl.searchParams.set("docId", "sample01");
openUrl.searchParams.set("user_id", "example-user");
openUrl.searchParams.set(
"__tfo_adapter_http_storage_client_metadata",
JSON.stringify(metadata),
);
console.log(openUrl.toString());
The customer application produces this JSON string with JSON.stringify(metadata):
{"project":"example-project","userSessionToken":"example-short-lived-document-session-token"}
URLSearchParams encodes it once and adds the following parameter to /open. Do not encode this value again.
__tfo_adapter_http_storage_client_metadata=%7B%22project%22%3A%22example-project%22%2C%22userSessionToken%22%3A%22example-short-lived-document-session-token%22%7D
The complete document-open URL is shown below. The Provider receives the request.client_metadata object inside the JWT, rather than this URL's query string.
https://office.example.com/cloud-office/api/http-remote-directory/contracts/sample.docx/open?app=WORD_EDITOR&docId=sample01&user_id=example-user&__tfo_adapter_http_storage_client_metadata=%7B%22project%22%3A%22example-project%22%2C%22userSessionToken%22%3A%22example-short-lived-document-session-token%22%7D
| Item | Required | Limit |
|---|---|---|
| Metadata input | Optional; a Provider's access policy may require it | JSON object encoded as a string; the 4,096 UTF-8 byte limit applies only to the original input |
| Object key | For each field | 1–64 UTF-16 code units; not blank according to Java String.isBlank() |
| String value | When present | At most 512 UTF-16 code units |
| Nesting | Yes | Root object depth 0; each object field value or array element adds 1; maximum 8 |
The Provider must also check received metadata against the structural limits above.
You can pass a token issued by your service for document access, such as userSessionToken above, so the Provider can authenticate the user. After verifying the JWT, the Provider checks this token against your service's session records to verify the user, expiration, target document, and allowed operations. userSessionToken is an example field name; your service implements token issuance and verification.
Check permissions on every request: info and get require read permission, and put requires write permission. Reject requests that require this token if it is missing, expired, revoked, or lacks permission for the operation.
Metadata is not encrypted: it can appear in the open URL, connection state, and request JWT. Do not include login cookies, refresh tokens, shared signing secrets, long-lived credentials, or document contents. If metadata carries an access capability, issue a separate short-lived value scoped to the document and allowed operations, and redact it from logs.
Response framing and size limits
Successful info, list, get, and put responses must have exactly one decimal Content-Length header matching the number of bytes actually sent. Transfer-Encoding and Content-Encoding are not allowed. A charset parameter in the media type is allowed, but JSON bodies must be UTF-8.
| Target | Content-Type | Limit |
|---|---|---|
Successful info, list, and put responses | application/json | 5 MiB each (5,242,880 bytes) |
Successful get response | application/octet-stream | 300 MiB (314,572,800 bytes) |
put request | application/octet-stream | 300 MiB (314,572,800 bytes) |
File size in metadata | JSON integer | 0–314,572,800 |
entries in list | JSON array | At most 10,000 entries, with the complete JSON also limited to 5 MiB |
The limits in the table are inclusive. A Provider may impose a lower document size limit.
Office fails the operation if headers, size, or actual body length do not meet these requirements. Unsupported operation responses and ordinary errors follow their respective sections below.
Success statuses and bodies
| Operation | Normal response | Body format |
|---|---|---|
info, list | 200 OK | A JSON object is required; do not substitute an empty body or 204 |
get | 200 OK | The complete file bytes; return an empty file with Content-Length: 0 |
put | 200 OK | A JSON object with docId is required. See PUT response |
lock, unlock, mkdir, rename, delete | 204 No Content | No body |
Office also accepts other 2xx statuses, but info, list, get, and put must pass response body validation. The remaining operations require no result fields, so use 204 without a body. If result text is needed, return 200 with text/plain; charset=utf-8, up to 10 MiB.
Metadata fields
info returns one entry object; list returns one object containing an entries array. Do not add fields other than those below to entry objects.
| Field | Type | Required | Meaning and limits |
|---|---|---|---|
path | string | Yes | Decoded path relative to the root, at most 4,096 characters. The root uses an empty string |
name | string | Yes | A 1–255 character name matching the final path segment. The root uses a fixed display name |
type | string | Yes | file or directory |
size | integer | Yes | File length in bytes, 0–314,572,800. Must be 0 for directories |
readable | boolean | Yes | Whether the document can be read |
writable | boolean | Yes | Whether the document can be written |
locked | boolean | Yes | Current lock state |
locker | string or null | No | Lock owner, at most 255 characters |
createdAt | string or null | No | RFC 3339 creation time, at most 64 characters |
modifiedAt | string or null | No | RFC 3339 modification time, at most 64 characters |
revision | string or null | No | Storage revision or ETag-equivalent value, at most 1,024 characters |
info.path must match the requested document path. list contains only the requested directory's immediate children, with no duplicate paths. Pagination is not supported.
Required fields must not be null. Optional fields may be omitted or returned as null. path is decoded, not URL-encoded. createdAt and modifiedAt include a time zone, as in 2026-09-14T00:00:00Z. readable and writable describe an entry's accessibility; the Provider must check permissions again on each request. revision describes the storage version and is not automatically used as a conditional request header or lock token in this protocol.
To validate the JSON response format, use the entry schema, info response schema, and list response schema. Schema validation alone cannot verify every relationship between the request and response paths. Also check the path conditions and size limits above.
Requests and responses by operation
path identifies the target entry, and parent identifies the parent for a new directory. Both are relative paths under the Provider root. Encode each segment according to the URL rules, rather than encoding the entire path. Every request requires the X-TFO-Storage-Request-JWT header described in Common request headers. Query parameters are not supported.
The request bodies for lock, unlock, mkdir, and rename are JSON objects containing only the field specified for that operation. Required fields cannot be omitted, null, or another type, and extra fields are not allowed. The request Content-Type must be exactly application/json, and Content-Length is the length of the UTF-8 serialized bytes.
Each operation's error table describes its specific conditions. The common errors for paths, authentication, permissions, body formats, and storage failures also apply to every endpoint.
| Operation | Method | Path suffix | Implementation |
|---|---|---|---|
| info | GET | /{path}/info | Required |
| list | GET | /{path}/list | Optional |
| get | GET | /{path}/get | Required |
| put | PUT | /{path}/put | Optional |
| lock | POST | /{path}/lock | Implement with unlock, or declare both unsupported |
| unlock | POST | /{path}/unlock | Implement with lock, or declare both unsupported |
| mkdir | POST | /{parent}/mkdir | Optional |
| rename | POST | /{path}/rename | Optional |
| delete | DELETE | /{path}/delete | Optional |
The examples below use a Provider at https://storage.example.com to read and save contracts/sample.docx, rename it to reviewed.docx, and then delete it. Replace <SIGNED_REQUEST_JWT> in every request with a fresh JWT signing that request's method, path, and body. Example bodies exclude the final newline in the code block; the displayed Content-Length values reflect this. In the get response and put request, <DOCX binary> represents the omitted DOCX file bytes. Send the raw file bytes in place of this text and replace <DOCX_BYTE_LENGTH> with their actual byte count.
Reading the implementation snippets
The Node.js (Express), Java (Spring MVC), and Python (FastAPI) snippets show request parsing, JWT verification, storage calls, and responses for each operation. Selecting a language changes all snippet tabs together.
These snippets use the helpers and configuration from the public Provider examples; they are not standalone programs. Each language tab explains where to place the code and which imports and configuration it needs.
Implement storage access and customer-session and document-permission checks for your service. Use the original URL-encoded request path for JWT verification.
- Node.js
- Java
- Python
Shared helpers and storage service used by this snippet: Node.js Provider source
Start from the public example's storage-router.mjs. Keep its imports and shared helpers. Inside createStorageRouter, replace the protocol-handling router.use with the operation handlers below. Keep the existing error-handling middleware.
requestVerifier.verify(request, { rawPath }, body) verifies the JWT signature and validity period, then compares the signed request details with the actual request. It needs body to check the received body's length and SHA-256. Verification errors are handled by the middleware, which returns 401 Unauthorized.
Required imports in the existing example (keep them if already present)
import { rm } from "node:fs/promises";
import { requirePathSegment } from "../domain/storage-route.mjs";
Shared helpers and storage service used by this snippet: Java Provider source
Start from the public example's StorageController.java. Keep its imports, constructor, and shared methods. Replace handle with the operation methods below, and keep the existing exception handler.
Routes ending in an operation name, such as /**/info, require the Ant path-matching configuration below. Add the configuration class in a separate StorageWebConfig.java file.
Pass the JWT header, HTTP method, path, Content-Type, body length, and SHA-256 to requestVerifier.verify. Verification errors are handled by the exception handler, which returns 401 Unauthorized.
Required imports in the existing example (keep them if already present) (StorageController.java)
import com.thinkfree.storage.service.StoragePathPolicy;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
Path matching configuration (separate StorageWebConfig.java)
package com.thinkfree.storage.web;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.util.UrlPathHelper;
@Configuration
public class StorageWebConfig implements WebMvcConfigurer {
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
UrlPathHelper paths = new UrlPathHelper();
paths.setUrlDecode(false);
paths.setRemoveSemicolonContent(false);
configurer.setPatternParser(null).setUrlPathHelper(paths);
}
}
Shared helpers and storage service used by this snippet: Python Provider source
Start from the public example's app/api/routes.py. Keep its imports, shared helpers, and dependency setup. Replace storage_request and its route registration with the operation functions below. Keep the existing exception handler.
Pass the JWT header, HTTP method, path, Content-Type, body length, and SHA-256 to verifier.verify. Verification errors are handled by the exception handler, which returns 401 Unauthorized.
Required imports in the existing example (keep them if already present)
from urllib.parse import unquote_to_bytes
If JWT verification fails, return an error before accessing storage. If you use customer sessions, add user authentication and document/operation permission checks where indicated by the comments.
info - Retrieve document information
This required endpoint retrieves metadata for a document or directory. It does not return document contents.
Request
GET /tfo-http-storage/v1/{path}/info
GET /tfo-http-storage/v1/contracts/sample.docx/info HTTP/1.1
Host: storage.example.com
X-TFO-Storage-Request-JWT: <SIGNED_REQUEST_JWT>
| Item | Format | Description |
|---|---|---|
path | Path · string | Relative file or directory path. Use /tfo-http-storage/v1/info for the root |
| Authentication headers | Required · string | JWT for this GET request. See Common headers |
| Request body | None | Omit Content-Type. Follow the common request rules |
Implementation example
- Node.js
- Java
- Python
Shared helpers and storage service used by this snippet: Node.js Provider source
// GET /tfo-http-storage/v1/{path}/info
router.get(/^\/tfo-http-storage\/v1\/(?:.+\/)?info$/, async function infoDocument(request, response) {
const rawPath = request.originalUrl;
if (rawPath.includes("?")) throw new StorageError(400, "Query not allowed");
// Strip the prefix and operation from the raw path; decode each directory/file segment.
const encodedPath = rawPath.slice("/tfo-http-storage/v1".length, -"/info".length);
let segments;
try {
segments = encodedPath ? encodedPath.slice(1).split("/").map(
part => requirePathSegment(decodeURIComponent(part)),
) : [];
} catch { throw new StorageError(400, "Invalid document path"); }
// Require no body. Use length 0 and the SHA-256 of the empty body.
const body = await readRequestBody(request, "none", config, stateStore);
// Verify the JWT signature and validity period, match the request details, and check for replay.
// Read X-TFO-Storage-Request-JWT from request.headers.
// Even without a body, length 0 and the empty-body SHA-256 must match the JWT.
const signedRequest = await requestVerifier.verify(request, { rawPath }, body);
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
sendJson(response, await storageService.info(segments));
});
Shared helpers and storage service used by this snippet: Java Provider source
@RequestMapping(value = {"/tfo-http-storage/v1/info", "/tfo-http-storage/v1/**/info"},
method = RequestMethod.GET)
public ResponseEntity<?> info(HttpServletRequest request) throws Exception {
String rawPath = request.getRequestURI();
if (request.getQueryString() != null) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Query not allowed");
}
// Strip the prefix and operation from the raw path; decode each directory/file segment.
String encodedPath = rawPath.substring("/tfo-http-storage/v1".length(), rawPath.length() - "/info".length());
List<String> path = new ArrayList<>();
if (!encodedPath.isEmpty()) {
for (String part : encodedPath.substring(1).split("/", -1)) {
try {
String decoded = URLDecoder.decode(part.replace("+", "%2B"), StandardCharsets.UTF_8);
path.add(StoragePathPolicy.requireSegment(decoded));
} catch (IllegalArgumentException exception) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Invalid document path");
}
}
}
// Require no body. Use length 0 and the SHA-256 of the empty body.
RequestBody body = readEmptyBody(request);
// Verify the JWT signature and validity period, match the request details, and check for replay.
String jwt = request.getHeader("X-TFO-Storage-Request-JWT");
Map<String, Object> signedRequest = requestVerifier.verify(
jwt, request.getMethod(), rawPath,
request.getHeader(HttpHeaders.CONTENT_TYPE), body.length(), body.sha256());
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
return jsonResponse(HttpStatus.OK, storageService.info(path));
}
Shared helpers and storage service used by this snippet: Python Provider source
@router.get("/tfo-http-storage/v1/info")
@router.get("/tfo-http-storage/v1/{document_path:path}/info")
async def info_document(
request: Request, storage: StorageServiceDependency,
state_store: StateStoreDependency, verifier: RequestVerifierDependency,
) -> Response:
raw_path = request.scope["raw_path"].decode("ascii")
if request.url.query:
raise StorageError(400, "Query not allowed")
# Strip the prefix and operation from the raw path; decode each directory/file segment.
encoded_path = raw_path[len(PROTOCOL_PREFIX):-len("/info")]
if re.search(r"%(?![0-9A-Fa-f]{2})", encoded_path):
raise StorageError(400, "Invalid percent-encoding")
try:
segments = tuple(storage.require_segment(unquote_to_bytes(part).decode("utf-8"))
for part in encoded_path[1:].split("/")) if encoded_path else ()
except UnicodeDecodeError:
raise StorageError(400, "Invalid UTF-8 path") from None
# Require no body. Use length 0 and the SHA-256 of the empty body.
body = await read_request_body(request, Operation.INFO, storage.settings, state_store)
# Verify the JWT signature and validity period, match the request details, and check for replay.
jwt = request.headers.get("X-TFO-Storage-Request-JWT")
signed_request = verifier.verify(
jwt, request.method, raw_path,
request.headers.get("Content-Type"), body.length, body.sha256,
)
# For customer sessions, validate the token in client_metadata and check document/operation permissions here.
return model_response(storage.info(segments))
Response
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 283
{
"path": "contracts/sample.docx",
"name": "sample.docx",
"type": "file",
"size": 231958,
"readable": true,
"writable": true,
"locked": false,
"locker": null,
"createdAt": "2026-09-14T00:00:00Z",
"modifiedAt": "2026-09-14T00:10:00Z",
"revision": "revision-17"
}
| Item | Format | Description |
|---|---|---|
| Status | 200 OK | Target entry metadata retrieved successfully |
Content-Type | Required header | application/json |
Content-Length | Required header | Response JSON length in bytes, subject to the size limit |
| Body | object | One entry object containing the metadata fields directly, without an entry or data wrapper |
Follow the metadata field requirements. For the root, use path: "", type: "directory", and size: 0.
| Status | Conditions and handling |
|---|---|
404 Not Found | The target does not exist. Office treats the entry as absent and does not require fabricated metadata |
413 Payload Too Large | File size or response JSON exceeds its limit |
501 Not Implemented | Not accepted as an unsupported capability because this endpoint is required |
list - List child entries
This optional endpoint lists the immediate files and directories within a directory. It provides neither recursive listing nor pagination.
Request
GET /tfo-http-storage/v1/{path}/list
GET /tfo-http-storage/v1/contracts/list HTTP/1.1
Host: storage.example.com
X-TFO-Storage-Request-JWT: <SIGNED_REQUEST_JWT>
| Item | Format | Description |
|---|---|---|
path | Path · string | Directory to list. Use /tfo-http-storage/v1/list for the root |
| Authentication headers | Required · string | JWT for this GET request. See Common headers |
| Request body | None | Omit Content-Type. Follow the common request rules |
Implementation example
- Node.js
- Java
- Python
Shared helpers and storage service used by this snippet: Node.js Provider source
// GET /tfo-http-storage/v1/{path}/list
router.get(/^\/tfo-http-storage\/v1\/(?:.+\/)?list$/, async function listDocument(request, response) {
const rawPath = request.originalUrl;
if (rawPath.includes("?")) throw new StorageError(400, "Query not allowed");
// Strip the prefix and operation from the raw path; decode each directory/file segment.
const encodedPath = rawPath.slice("/tfo-http-storage/v1".length, -"/list".length);
let segments;
try {
segments = encodedPath ? encodedPath.slice(1).split("/").map(
part => requirePathSegment(decodeURIComponent(part)),
) : [];
} catch { throw new StorageError(400, "Invalid document path"); }
// Require no body. Use length 0 and the SHA-256 of the empty body.
const body = await readRequestBody(request, "none", config, stateStore);
// Verify the JWT signature and validity period, match the request details, and check for replay.
// Read X-TFO-Storage-Request-JWT from request.headers.
// Even without a body, length 0 and the empty-body SHA-256 must match the JWT.
const signedRequest = await requestVerifier.verify(request, { rawPath }, body);
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (config.unsupportedOperations?.has("list")) {
return sendOperationNotSupported(response, "list");
}
sendJson(response, await storageService.list(segments));
});
Shared helpers and storage service used by this snippet: Java Provider source
@RequestMapping(value = {"/tfo-http-storage/v1/list", "/tfo-http-storage/v1/**/list"},
method = RequestMethod.GET)
public ResponseEntity<?> list(HttpServletRequest request) throws Exception {
String rawPath = request.getRequestURI();
if (request.getQueryString() != null) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Query not allowed");
}
// Strip the prefix and operation from the raw path; decode each directory/file segment.
String encodedPath = rawPath.substring("/tfo-http-storage/v1".length(), rawPath.length() - "/list".length());
List<String> path = new ArrayList<>();
if (!encodedPath.isEmpty()) {
for (String part : encodedPath.substring(1).split("/", -1)) {
try {
String decoded = URLDecoder.decode(part.replace("+", "%2B"), StandardCharsets.UTF_8);
path.add(StoragePathPolicy.requireSegment(decoded));
} catch (IllegalArgumentException exception) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Invalid document path");
}
}
}
// Require no body. Use length 0 and the SHA-256 of the empty body.
RequestBody body = readEmptyBody(request);
// Verify the JWT signature and validity period, match the request details, and check for replay.
String jwt = request.getHeader("X-TFO-Storage-Request-JWT");
Map<String, Object> signedRequest = requestVerifier.verify(
jwt, request.getMethod(), rawPath,
request.getHeader(HttpHeaders.CONTENT_TYPE), body.length(), body.sha256());
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (properties.unsupportedOperations().contains("list")) {
return operationNotSupported(StorageOperation.LIST);
}
return jsonResponse(HttpStatus.OK, storageService.list(path));
}
Shared helpers and storage service used by this snippet: Python Provider source
@router.get("/tfo-http-storage/v1/list")
@router.get("/tfo-http-storage/v1/{document_path:path}/list")
async def list_document(
request: Request, storage: StorageServiceDependency,
state_store: StateStoreDependency, verifier: RequestVerifierDependency,
) -> Response:
raw_path = request.scope["raw_path"].decode("ascii")
if request.url.query:
raise StorageError(400, "Query not allowed")
# Strip the prefix and operation from the raw path; decode each directory/file segment.
encoded_path = raw_path[len(PROTOCOL_PREFIX):-len("/list")]
if re.search(r"%(?![0-9A-Fa-f]{2})", encoded_path):
raise StorageError(400, "Invalid percent-encoding")
try:
segments = tuple(storage.require_segment(unquote_to_bytes(part).decode("utf-8"))
for part in encoded_path[1:].split("/")) if encoded_path else ()
except UnicodeDecodeError:
raise StorageError(400, "Invalid UTF-8 path") from None
# Require no body. Use length 0 and the SHA-256 of the empty body.
body = await read_request_body(request, Operation.LIST, storage.settings, state_store)
# Verify the JWT signature and validity period, match the request details, and check for replay.
jwt = request.headers.get("X-TFO-Storage-Request-JWT")
signed_request = verifier.verify(
jwt, request.method, raw_path,
request.headers.get("Content-Type"), body.length, body.sha256,
)
# For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if storage.settings.is_operation_unsupported("list"):
return fixed_json_response({"code": "LIST_NOT_SUPPORTED"}, status_code=501)
return model_response(storage.list(segments))
Response
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 358
{
"entries": [
{
"path": "contracts/sample.docx",
"name": "sample.docx",
"type": "file",
"size": 231958,
"readable": true,
"writable": true,
"locked": false,
"locker": null,
"createdAt": "2026-09-14T00:00:00Z",
"modifiedAt": "2026-09-14T00:10:00Z",
"revision": "revision-17"
}
]
}
| Item | Format | Description |
|---|---|---|
| Status | 200 OK | Directory listed successfully, including an empty directory |
Content-Type | Required header | application/json |
Content-Length | Required header | Complete JSON length in bytes, subject to the size limit |
entries | Body · array · required | 0–10,000 entry objects; the only field in the top-level object |
entries[] | object | Same required and optional fields as info. Each decoded path must be an immediate child of the listed directory, without duplicates |
Entry paths are always relative to the root: return contracts in a root listing and contracts/sample.docx in a listing of contracts. Do not include the directory itself, its parent, or entries inside subdirectories. Ordering is unspecified. Do not send additional fields such as total, cursor, or hasMore.
For an empty directory, return 200 with an empty entries array. Return 404 if the directory does not exist, or 501 with LIST_NOT_SUPPORTED after authentication if listing is unsupported.
| Status | Conditions and handling |
|---|---|
404 Not Found | The directory to list does not exist |
413 Payload Too Large | Entry count, metadata size, or an included file size exceeds its limit. Do not return a partial listing as successful |
501 Not Implemented | After authentication, LIST_NOT_SUPPORTED. File listing is unavailable in Office |
get - Download a document
This required endpoint returns the complete original file bytes. Office reads this content to open the document.
Request
GET /tfo-http-storage/v1/{path}/get
GET /tfo-http-storage/v1/contracts/sample.docx/get HTTP/1.1
Host: storage.example.com
X-TFO-Storage-Request-JWT: <SIGNED_REQUEST_JWT>
| Item | Format | Description |
|---|---|---|
path | Path · string | File to download. Use list to list a directory |
| Authentication headers | Required · string | JWT for this GET request. See Common headers |
| Request body | None | Omit Content-Type. Follow the common request rules |
Range requests for partial downloads are not supported. Return the file itself, rather than a download URL, JSON, or Base64.
Implementation example
- Node.js
- Java
- Python
Shared helpers and storage service used by this snippet: Node.js Provider source
// GET /tfo-http-storage/v1/{path}/get
router.get(/^\/tfo-http-storage\/v1\/(?:.+\/)?get$/, async function getDocument(request, response) {
const rawPath = request.originalUrl;
if (rawPath.includes("?")) throw new StorageError(400, "Query not allowed");
// Strip the prefix and operation from the raw path; decode each directory/file segment.
const encodedPath = rawPath.slice("/tfo-http-storage/v1".length, -"/get".length);
let segments;
try {
segments = encodedPath ? encodedPath.slice(1).split("/").map(
part => requirePathSegment(decodeURIComponent(part)),
) : [];
} catch { throw new StorageError(400, "Invalid document path"); }
// Require no body. Use length 0 and the SHA-256 of the empty body.
const body = await readRequestBody(request, "none", config, stateStore);
// Verify the JWT signature and validity period, match the request details, and check for replay.
// Read X-TFO-Storage-Request-JWT from request.headers.
// Even without a body, length 0 and the empty-body SHA-256 must match the JWT.
const signedRequest = await requestVerifier.verify(request, { rawPath }, body);
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
const file = await storageService.download(segments);
response.status(200).set({
"Content-Type": "application/octet-stream",
"Content-Length": file.contentLength,
"Cache-Control": "no-store",
});
await pipeline(file.stream, response);
});
Shared helpers and storage service used by this snippet: Java Provider source
@RequestMapping(value = {"/tfo-http-storage/v1/get", "/tfo-http-storage/v1/**/get"},
method = RequestMethod.GET)
public ResponseEntity<?> get(HttpServletRequest request) throws Exception {
String rawPath = request.getRequestURI();
if (request.getQueryString() != null) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Query not allowed");
}
// Strip the prefix and operation from the raw path; decode each directory/file segment.
String encodedPath = rawPath.substring("/tfo-http-storage/v1".length(), rawPath.length() - "/get".length());
List<String> path = new ArrayList<>();
if (!encodedPath.isEmpty()) {
for (String part : encodedPath.substring(1).split("/", -1)) {
try {
String decoded = URLDecoder.decode(part.replace("+", "%2B"), StandardCharsets.UTF_8);
path.add(StoragePathPolicy.requireSegment(decoded));
} catch (IllegalArgumentException exception) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Invalid document path");
}
}
}
// Require no body. Use length 0 and the SHA-256 of the empty body.
RequestBody body = readEmptyBody(request);
// Verify the JWT signature and validity period, match the request details, and check for replay.
String jwt = request.getHeader("X-TFO-Storage-Request-JWT");
Map<String, Object> signedRequest = requestVerifier.verify(
jwt, request.getMethod(), rawPath,
request.getHeader(HttpHeaders.CONTENT_TYPE), body.length(), body.sha256());
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
LocalDirectoryStorageService.Download file = storageService.download(path);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(file.contentLength()).body(file.resource());
}
Shared helpers and storage service used by this snippet: Python Provider source
@router.get("/tfo-http-storage/v1/get")
@router.get("/tfo-http-storage/v1/{document_path:path}/get")
async def get_document(
request: Request, storage: StorageServiceDependency,
state_store: StateStoreDependency, verifier: RequestVerifierDependency,
) -> Response:
raw_path = request.scope["raw_path"].decode("ascii")
if request.url.query:
raise StorageError(400, "Query not allowed")
# Strip the prefix and operation from the raw path; decode each directory/file segment.
encoded_path = raw_path[len(PROTOCOL_PREFIX):-len("/get")]
if re.search(r"%(?![0-9A-Fa-f]{2})", encoded_path):
raise StorageError(400, "Invalid percent-encoding")
try:
segments = tuple(storage.require_segment(unquote_to_bytes(part).decode("utf-8"))
for part in encoded_path[1:].split("/")) if encoded_path else ()
except UnicodeDecodeError:
raise StorageError(400, "Invalid UTF-8 path") from None
# Require no body. Use length 0 and the SHA-256 of the empty body.
body = await read_request_body(request, Operation.GET, storage.settings, state_store)
# Verify the JWT signature and validity period, match the request details, and check for replay.
jwt = request.headers.get("X-TFO-Storage-Request-JWT")
signed_request = verifier.verify(
jwt, request.method, raw_path,
request.headers.get("Content-Type"), body.length, body.sha256,
)
# For customer sessions, validate the token in client_metadata and check document/operation permissions here.
return FileResponse(
storage.download(segments),
media_type="application/octet-stream", headers=NO_STORE,
)
Response
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: <DOCX_BYTE_LENGTH>
<DOCX binary>
| Item | Format | Description |
|---|---|---|
| Status | 200 OK | Complete file downloaded successfully |
Content-Type | Required header | application/octet-stream, regardless of file extension |
Content-Length | Required header | Actual file length in bytes, 0–314,572,800 |
| Body | binary | Original file bytes of exactly Content-Length; no JSON fields |
Return an empty file with 200 and Content-Length: 0. Return the same version of the file throughout the download.
| Status | Conditions and handling |
|---|---|
404 Not Found | The file does not exist |
413 Payload Too Large | The file exceeds the size limit. Reject it before sending success headers or file bytes |
501 Not Implemented | Not accepted as an unsupported capability because this endpoint is required |
put - Save a document
Receives the complete edited file in the HTTP body and saves it. Partial updates and multipart uploads are not supported. Verify the request before saving, and preserve the existing file if verification or saving fails.
Request
PUT /tfo-http-storage/v1/{path}/put
PUT /tfo-http-storage/v1/contracts/sample.docx/put HTTP/1.1
Host: storage.example.com
X-TFO-Storage-Request-JWT: <SIGNED_REQUEST_JWT>
Content-Type: application/octet-stream
Content-Length: <DOCX_BYTE_LENGTH>
<DOCX binary>
| Item | Format | Description |
|---|---|---|
path | Path · string | Relative path of the file to save. An empty path cannot overwrite the Provider root |
| Authentication headers | Required · string | JWT for this PUT request. See Common headers |
Content-Type | Required header | Exactly application/octet-stream |
Content-Length | Required header | Actual complete file length in bytes, 0–314,572,800 |
| Request body | binary | Complete file bytes, not a JSON object, Base64, or multipart data |
request.arguments.save_type | JWT · string · optional | Why Office is saving the file. See Save types. Included in the JWT, not in the body or query parameters |
Save types
request.arguments.save_type indicates what triggered the save. Office sends the following values; spelling and capitalization matter.
| Value | Meaning |
|---|---|
save | A regular save, such as when the user selects Save |
saveAs | Save As, writing the document to the destination in this request's path |
autoSave | Automatic saving during editing |
autoput | Server-triggered saving after an editing connection expires, for example after the browser disconnects |
close | An automatic save triggered while closing the document |
This field is optional. Process the save even when it is absent; absence does not imply a particular save type.
When closing a document triggers an automatic save, Office calls put with request.arguments.save_type set to close. This value describes why the save occurred; it is not a document-close event. The Provider must save the file. It must not call dispose, release connection or session resources, or assume that no further requests will arrive just because the save type is close.
Implementation example
- Node.js
- Java
- Python
Shared helpers and storage service used by this snippet: Node.js Provider source
// PUT /tfo-http-storage/v1/{path}/put
router.put(/^\/tfo-http-storage\/v1\/(?:.+\/)?put$/, async function putDocument(request, response) {
const rawPath = request.originalUrl;
if (rawPath.includes("?")) throw new StorageError(400, "Query not allowed");
// Strip the prefix and operation from the raw path; decode each directory/file segment.
const encodedPath = rawPath.slice("/tfo-http-storage/v1".length, -"/put".length);
let segments;
try {
segments = encodedPath ? encodedPath.slice(1).split("/").map(
part => requirePathSegment(decodeURIComponent(part)),
) : [];
} catch { throw new StorageError(400, "Invalid document path"); }
// Enforce the size limit while staging the upload and calculating length and SHA-256.
const body = await readRequestBody(request, "document", config, stateStore);
try {
// Verify the JWT signature and validity period, match the request details, and check for replay.
// Read X-TFO-Storage-Request-JWT from request.headers.
// Compare the received length/SHA-256 in body with the signed JWT values to detect body tampering.
const signedRequest = await requestVerifier.verify(request, { rawPath }, body);
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (config.unsupportedOperations?.has("put")) {
return sendOperationNotSupported(response, "put");
}
const docId = await storageService.save(segments, body.stagingFile);
sendJson(response, { docId });
} finally {
// Delete temporary files left after verification failure, an unsupported response, or a failed save; skip files already moved.
await rm(body.stagingFile, { force: true });
}
});
Shared helpers and storage service used by this snippet: Java Provider source
@RequestMapping(value = {"/tfo-http-storage/v1/put", "/tfo-http-storage/v1/**/put"},
method = RequestMethod.PUT)
public ResponseEntity<?> put(HttpServletRequest request) throws Exception {
String rawPath = request.getRequestURI();
if (request.getQueryString() != null) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Query not allowed");
}
// Strip the prefix and operation from the raw path; decode each directory/file segment.
String encodedPath = rawPath.substring("/tfo-http-storage/v1".length(), rawPath.length() - "/put".length());
List<String> path = new ArrayList<>();
if (!encodedPath.isEmpty()) {
for (String part : encodedPath.substring(1).split("/", -1)) {
try {
String decoded = URLDecoder.decode(part.replace("+", "%2B"), StandardCharsets.UTF_8);
path.add(StoragePathPolicy.requireSegment(decoded));
} catch (IllegalArgumentException exception) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Invalid document path");
}
}
}
// Enforce the size limit while staging the upload and calculating length and SHA-256.
RequestBody body = stageDocument(request);
try {
// Verify the JWT signature and validity period, match the request details, and check for replay.
String jwt = request.getHeader("X-TFO-Storage-Request-JWT");
Map<String, Object> signedRequest = requestVerifier.verify(
jwt, request.getMethod(), rawPath,
request.getHeader(HttpHeaders.CONTENT_TYPE), body.length(), body.sha256());
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (properties.unsupportedOperations().contains("put")) {
return operationNotSupported(StorageOperation.PUT);
}
String docId = storageService.save(path, body.stagedFile());
return jsonResponse(HttpStatus.OK, java.util.Map.of("docId", docId));
} finally {
// Delete temporary files left after verification failure, an unsupported response, or a failed save; skip files already moved.
Files.deleteIfExists(body.stagedFile());
}
}
Shared helpers and storage service used by this snippet: Python Provider source
@router.put("/tfo-http-storage/v1/put")
@router.put("/tfo-http-storage/v1/{document_path:path}/put")
async def put_document(
request: Request, storage: StorageServiceDependency,
state_store: StateStoreDependency, verifier: RequestVerifierDependency,
) -> Response:
raw_path = request.scope["raw_path"].decode("ascii")
if request.url.query:
raise StorageError(400, "Query not allowed")
# Strip the prefix and operation from the raw path; decode each directory/file segment.
encoded_path = raw_path[len(PROTOCOL_PREFIX):-len("/put")]
if re.search(r"%(?![0-9A-Fa-f]{2})", encoded_path):
raise StorageError(400, "Invalid percent-encoding")
try:
segments = tuple(storage.require_segment(unquote_to_bytes(part).decode("utf-8"))
for part in encoded_path[1:].split("/")) if encoded_path else ()
except UnicodeDecodeError:
raise StorageError(400, "Invalid UTF-8 path") from None
# Enforce the size limit while staging the upload and calculating length and SHA-256.
body = await read_request_body(request, Operation.PUT, storage.settings, state_store)
try:
# Verify the JWT signature and validity period, match the request details, and check for replay.
jwt = request.headers.get("X-TFO-Storage-Request-JWT")
signed_request = verifier.verify(
jwt, request.method, raw_path,
request.headers.get("Content-Type"), body.length, body.sha256,
)
# For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if storage.settings.is_operation_unsupported("put"):
return fixed_json_response({"code": "PUT_NOT_SUPPORTED"}, status_code=501)
doc_id = storage.save(segments, body.staging_file)
return fixed_json_response({"docId": doc_id})
finally:
# Delete temporary files left after verification failure, an unsupported response, or a failed save; skip files already moved.
body.staging_file.unlink(missing_ok=True)
Response
After committing the file to storage, return the saved document's identifier as a JSON object.
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 34
{
"docId": "saved-document-id"
}
| Item | Format | Description |
|---|---|---|
| Status | 200 OK | Other 2xx statuses must pass body validation. 204 No Content is not allowed |
Content-Type | Required header | UTF-8 application/json |
Content-Length | Required header | Exact JSON body byte count. The fixed-length rules apply |
| Body | JSON object · required | Contains only docId. Extra or duplicate fields, trailing JSON values, and trailing text are not allowed |
docId | string · required | 1–1,024 ASCII letters, digits, ., _, :, or -, starting with a letter or digit. true and false are reserved regardless of case |
docId identifies the saved destination. Within an adapter, the same destination keeps the same ID when its contents change, and different destinations have different IDs. This applies to both regular saves and Save As. Do not substitute a revision or the original session's document ID.
Providers can use their own document IDs that satisfy the format above. See the PUT response schema for response validation.
| Status | Conditions and handling |
|---|---|
400 Bad Request | Empty document path, declared length differing from the actual body, or similar invalid input |
409 Conflict | The save conflicts with the destination's current state |
413 Payload Too Large | The document exceeds the protocol limit or a lower Provider limit |
501 Not Implemented | After authentication, PUT_NOT_SUPPORTED. Nothing is written to storage, and Office reports a save failure |
lock - Lock a document
lock sets a document lock and records its owner. A repeated request with the same owner succeeds; a lock held by another owner returns 409 Conflict. Implement both lock and unlock, or declare both unsupported.
Request
POST /tfo-http-storage/v1/{path}/lock
POST /tfo-http-storage/v1/contracts/sample.docx/lock HTTP/1.1
Host: storage.example.com
X-TFO-Storage-Request-JWT: <SIGNED_REQUEST_JWT>
Content-Type: application/json
Content-Length: 25
{
"owner": "editor-1"
}
| Item | Format | Description |
|---|---|---|
path | Path · string | Relative path of the document to lock |
| Authentication headers | Required · string | JWT for this POST request. See Common headers |
Content-Type | Required header | Exactly application/json |
Content-Length | Required header | UTF-8 byte length of the JSON body |
owner | Body · string · required | Nonempty lock owner identifier; the only JSON field |
Store owner unchanged and compare it for equality when processing unlock requests. Do not use it as a user authentication token. A value returned as locker in info or list must fit that field's 255-character limit. This request defines no lock expiration, lease duration, or renewal interval fields.
Implementation example
- Node.js
- Java
- Python
Shared helpers and storage service used by this snippet: Node.js Provider source
// POST /tfo-http-storage/v1/{path}/lock
router.post(/^\/tfo-http-storage\/v1\/(?:.+\/)?lock$/, async function lockDocument(request, response) {
const rawPath = request.originalUrl;
if (rawPath.includes("?")) throw new StorageError(400, "Query not allowed");
// Strip the prefix and operation from the raw path; decode each directory/file segment.
const encodedPath = rawPath.slice("/tfo-http-storage/v1".length, -"/lock".length);
let segments;
try {
segments = encodedPath ? encodedPath.slice(1).split("/").map(
part => requirePathSegment(decodeURIComponent(part)),
) : [];
} catch { throw new StorageError(400, "Invalid document path"); }
// Read the original JSON bytes and calculate their length and SHA-256.
const body = await readRequestBody(request, "json", config, stateStore);
// Verify the JWT signature and validity period, match the request details, and check for replay.
// Read X-TFO-Storage-Request-JWT from request.headers.
// Compare the received length/SHA-256 in body with the signed JWT values to detect body tampering.
const signedRequest = await requestVerifier.verify(request, { rawPath }, body);
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (config.unsupportedOperations?.has("lock")) {
return sendOperationNotSupported(response, "lock");
}
const owner = parseSingleField(body.bytes, "owner");
await storageService.lock(segments, owner);
response.status(204).set("Cache-Control", "no-store").end();
});
Shared helpers and storage service used by this snippet: Java Provider source
@RequestMapping(value = {"/tfo-http-storage/v1/lock", "/tfo-http-storage/v1/**/lock"},
method = RequestMethod.POST)
public ResponseEntity<?> lock(HttpServletRequest request) throws Exception {
String rawPath = request.getRequestURI();
if (request.getQueryString() != null) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Query not allowed");
}
// Strip the prefix and operation from the raw path; decode each directory/file segment.
String encodedPath = rawPath.substring("/tfo-http-storage/v1".length(), rawPath.length() - "/lock".length());
List<String> path = new ArrayList<>();
if (!encodedPath.isEmpty()) {
for (String part : encodedPath.substring(1).split("/", -1)) {
try {
String decoded = URLDecoder.decode(part.replace("+", "%2B"), StandardCharsets.UTF_8);
path.add(StoragePathPolicy.requireSegment(decoded));
} catch (IllegalArgumentException exception) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Invalid document path");
}
}
}
// Read the original JSON bytes and calculate their length and SHA-256.
RequestBody body = readJsonBody(request);
// Verify the JWT signature and validity period, match the request details, and check for replay.
String jwt = request.getHeader("X-TFO-Storage-Request-JWT");
Map<String, Object> signedRequest = requestVerifier.verify(
jwt, request.getMethod(), rawPath,
request.getHeader(HttpHeaders.CONTENT_TYPE), body.length(), body.sha256());
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (properties.unsupportedOperations().contains("lock")) {
return operationNotSupported(StorageOperation.LOCK);
}
String owner = lockRequest(body.bytes()).owner();
storageService.lock(path, owner);
return ResponseEntity.noContent().cacheControl(CacheControl.noStore()).build();
}
Shared helpers and storage service used by this snippet: Python Provider source
@router.post("/tfo-http-storage/v1/lock")
@router.post("/tfo-http-storage/v1/{document_path:path}/lock")
async def lock_document(
request: Request, storage: StorageServiceDependency,
state_store: StateStoreDependency, verifier: RequestVerifierDependency,
) -> Response:
raw_path = request.scope["raw_path"].decode("ascii")
if request.url.query:
raise StorageError(400, "Query not allowed")
# Strip the prefix and operation from the raw path; decode each directory/file segment.
encoded_path = raw_path[len(PROTOCOL_PREFIX):-len("/lock")]
if re.search(r"%(?![0-9A-Fa-f]{2})", encoded_path):
raise StorageError(400, "Invalid percent-encoding")
try:
segments = tuple(storage.require_segment(unquote_to_bytes(part).decode("utf-8"))
for part in encoded_path[1:].split("/")) if encoded_path else ()
except UnicodeDecodeError:
raise StorageError(400, "Invalid UTF-8 path") from None
# Read the original JSON bytes and calculate their length and SHA-256.
body = await read_request_body(request, Operation.LOCK, storage.settings, state_store)
# Verify the JWT signature and validity period, match the request details, and check for replay.
jwt = request.headers.get("X-TFO-Storage-Request-JWT")
signed_request = verifier.verify(
jwt, request.method, raw_path,
request.headers.get("Content-Type"), body.length, body.sha256,
)
# For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if storage.settings.is_operation_unsupported("lock"):
return fixed_json_response({"code": "LOCK_NOT_SUPPORTED"}, status_code=501)
owner = parse_model(LockRequest, body.content, "owner").owner
storage.lock(segments, owner)
return Response(status_code=204, headers=NO_STORE)
Response
HTTP/1.1 204 No Content
| Item | Format | Description |
|---|---|---|
| Status | 204 No Content | A new lock was acquired or an existing lock for the same owner was confirmed. Other 2xx statuses also succeed |
| Body | None | No lock token or result JSON is required. Bodies of other 2xx responses contain no fields required to determine success |
| Status | Conditions and handling |
|---|---|
400 Bad Request | Missing owner, empty string, incorrect type, or extra JSON fields |
409 Conflict | Another owner holds the lock. Do not change the existing lock |
501 Not Implemented | After authentication, LOCK_NOT_SUPPORTED. Office treats this as successful without an actual lock |
If lock and unlock are unsupported, return the unsupported operation response.
unlock - Unlock a document
unlock releases the lock held by the requested owner. It succeeds if the lock is already absent. If a different owner holds the lock, return 409 Conflict. Implement it together with lock.
Request
POST /tfo-http-storage/v1/{path}/unlock
POST /tfo-http-storage/v1/contracts/sample.docx/unlock HTTP/1.1
Host: storage.example.com
X-TFO-Storage-Request-JWT: <SIGNED_REQUEST_JWT>
Content-Type: application/json
Content-Length: 25
{
"owner": "editor-1"
}
| Item | Format | Description |
|---|---|---|
path | Path · string | Relative path of the document to unlock |
| Authentication headers | Required · string | JWT for this POST request. See Common headers |
Content-Type | Required header | Exactly application/json |
Content-Length | Required header | UTF-8 byte length of the JSON body |
owner | Body · string · required | The same owner identifier used to acquire the lock; the only JSON field |
Implementation example
- Node.js
- Java
- Python
Shared helpers and storage service used by this snippet: Node.js Provider source
// POST /tfo-http-storage/v1/{path}/unlock
router.post(/^\/tfo-http-storage\/v1\/(?:.+\/)?unlock$/, async function unlockDocument(request, response) {
const rawPath = request.originalUrl;
if (rawPath.includes("?")) throw new StorageError(400, "Query not allowed");
// Strip the prefix and operation from the raw path; decode each directory/file segment.
const encodedPath = rawPath.slice("/tfo-http-storage/v1".length, -"/unlock".length);
let segments;
try {
segments = encodedPath ? encodedPath.slice(1).split("/").map(
part => requirePathSegment(decodeURIComponent(part)),
) : [];
} catch { throw new StorageError(400, "Invalid document path"); }
// Read the original JSON bytes and calculate their length and SHA-256.
const body = await readRequestBody(request, "json", config, stateStore);
// Verify the JWT signature and validity period, match the request details, and check for replay.
// Read X-TFO-Storage-Request-JWT from request.headers.
// Compare the received length/SHA-256 in body with the signed JWT values to detect body tampering.
const signedRequest = await requestVerifier.verify(request, { rawPath }, body);
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (config.unsupportedOperations?.has("unlock")) {
return sendOperationNotSupported(response, "unlock");
}
const owner = parseSingleField(body.bytes, "owner");
await storageService.unlock(segments, owner);
response.status(204).set("Cache-Control", "no-store").end();
});
Shared helpers and storage service used by this snippet: Java Provider source
@RequestMapping(value = {"/tfo-http-storage/v1/unlock", "/tfo-http-storage/v1/**/unlock"},
method = RequestMethod.POST)
public ResponseEntity<?> unlock(HttpServletRequest request) throws Exception {
String rawPath = request.getRequestURI();
if (request.getQueryString() != null) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Query not allowed");
}
// Strip the prefix and operation from the raw path; decode each directory/file segment.
String encodedPath = rawPath.substring("/tfo-http-storage/v1".length(), rawPath.length() - "/unlock".length());
List<String> path = new ArrayList<>();
if (!encodedPath.isEmpty()) {
for (String part : encodedPath.substring(1).split("/", -1)) {
try {
String decoded = URLDecoder.decode(part.replace("+", "%2B"), StandardCharsets.UTF_8);
path.add(StoragePathPolicy.requireSegment(decoded));
} catch (IllegalArgumentException exception) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Invalid document path");
}
}
}
// Read the original JSON bytes and calculate their length and SHA-256.
RequestBody body = readJsonBody(request);
// Verify the JWT signature and validity period, match the request details, and check for replay.
String jwt = request.getHeader("X-TFO-Storage-Request-JWT");
Map<String, Object> signedRequest = requestVerifier.verify(
jwt, request.getMethod(), rawPath,
request.getHeader(HttpHeaders.CONTENT_TYPE), body.length(), body.sha256());
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (properties.unsupportedOperations().contains("unlock")) {
return operationNotSupported(StorageOperation.UNLOCK);
}
String owner = lockRequest(body.bytes()).owner();
storageService.unlock(path, owner);
return ResponseEntity.noContent().cacheControl(CacheControl.noStore()).build();
}
Shared helpers and storage service used by this snippet: Python Provider source
@router.post("/tfo-http-storage/v1/unlock")
@router.post("/tfo-http-storage/v1/{document_path:path}/unlock")
async def unlock_document(
request: Request, storage: StorageServiceDependency,
state_store: StateStoreDependency, verifier: RequestVerifierDependency,
) -> Response:
raw_path = request.scope["raw_path"].decode("ascii")
if request.url.query:
raise StorageError(400, "Query not allowed")
# Strip the prefix and operation from the raw path; decode each directory/file segment.
encoded_path = raw_path[len(PROTOCOL_PREFIX):-len("/unlock")]
if re.search(r"%(?![0-9A-Fa-f]{2})", encoded_path):
raise StorageError(400, "Invalid percent-encoding")
try:
segments = tuple(storage.require_segment(unquote_to_bytes(part).decode("utf-8"))
for part in encoded_path[1:].split("/")) if encoded_path else ()
except UnicodeDecodeError:
raise StorageError(400, "Invalid UTF-8 path") from None
# Read the original JSON bytes and calculate their length and SHA-256.
body = await read_request_body(request, Operation.UNLOCK, storage.settings, state_store)
# Verify the JWT signature and validity period, match the request details, and check for replay.
jwt = request.headers.get("X-TFO-Storage-Request-JWT")
signed_request = verifier.verify(
jwt, request.method, raw_path,
request.headers.get("Content-Type"), body.length, body.sha256,
)
# For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if storage.settings.is_operation_unsupported("unlock"):
return fixed_json_response({"code": "UNLOCK_NOT_SUPPORTED"}, status_code=501)
owner = parse_model(LockRequest, body.content, "owner").owner
storage.unlock(segments, owner)
return Response(status_code=204, headers=NO_STORE)
Response
HTTP/1.1 204 No Content
| Item | Format | Description |
|---|---|---|
| Status | 204 No Content | The same owner's lock was released, or no lock remains. Other 2xx statuses also succeed |
| Body | None | No unlock result JSON is required |
| Status | Conditions and handling |
|---|---|
400 Bad Request | Missing owner, empty string, incorrect type, or extra JSON fields |
409 Conflict | The current lock owner differs. Preserve the existing lock |
501 Not Implemented | After authentication, UNLOCK_NOT_SUPPORTED. Office treats this as successful without an actual unlock operation |
mkdir - Create a directory
This optional endpoint creates one directory directly under the specified parent. It does not create a multilevel path with intermediate directories.
Request
POST /tfo-http-storage/v1/{parent}/mkdir
POST /tfo-http-storage/v1/contracts/mkdir HTTP/1.1
Host: storage.example.com
X-TFO-Storage-Request-JWT: <SIGNED_REQUEST_JWT>
Content-Type: application/json
Content-Length: 23
{
"name": "archive"
}
| Item | Format | Description |
|---|---|---|
parent | Path · string | Relative path of an existing parent directory. Use /tfo-http-storage/v1/mkdir to create a directory under the root |
| Authentication headers | Required · string | JWT for this POST request. See Common headers |
Content-Type | Required header | Exactly application/json |
Content-Length | Required header | UTF-8 byte length of the JSON body |
name | Body · string · required | One new directory name, 1–255 characters; the only JSON field |
name is the actual name, not a URL-encoded path. /, \, control characters, and names equal to . or .. are not allowed. For example, 2026/reports cannot create a nested path in one request.
Implementation example
- Node.js
- Java
- Python
Shared helpers and storage service used by this snippet: Node.js Provider source
// POST /tfo-http-storage/v1/{path}/mkdir
router.post(/^\/tfo-http-storage\/v1\/(?:.+\/)?mkdir$/, async function mkdirDocument(request, response) {
const rawPath = request.originalUrl;
if (rawPath.includes("?")) throw new StorageError(400, "Query not allowed");
// Strip the prefix and operation from the raw path; decode each directory/file segment.
const encodedPath = rawPath.slice("/tfo-http-storage/v1".length, -"/mkdir".length);
let segments;
try {
segments = encodedPath ? encodedPath.slice(1).split("/").map(
part => requirePathSegment(decodeURIComponent(part)),
) : [];
} catch { throw new StorageError(400, "Invalid document path"); }
// Read the original JSON bytes and calculate their length and SHA-256.
const body = await readRequestBody(request, "json", config, stateStore);
// Verify the JWT signature and validity period, match the request details, and check for replay.
// Read X-TFO-Storage-Request-JWT from request.headers.
// Compare the received length/SHA-256 in body with the signed JWT values to detect body tampering.
const signedRequest = await requestVerifier.verify(request, { rawPath }, body);
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (config.unsupportedOperations?.has("mkdir")) {
return sendOperationNotSupported(response, "mkdir");
}
const name = requireChildName(parseSingleField(body.bytes, "name"));
await storageService.createDirectory(segments, name);
response.status(204).set("Cache-Control", "no-store").end();
});
Shared helpers and storage service used by this snippet: Java Provider source
@RequestMapping(value = {"/tfo-http-storage/v1/mkdir", "/tfo-http-storage/v1/**/mkdir"},
method = RequestMethod.POST)
public ResponseEntity<?> mkdir(HttpServletRequest request) throws Exception {
String rawPath = request.getRequestURI();
if (request.getQueryString() != null) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Query not allowed");
}
// Strip the prefix and operation from the raw path; decode each directory/file segment.
String encodedPath = rawPath.substring("/tfo-http-storage/v1".length(), rawPath.length() - "/mkdir".length());
List<String> path = new ArrayList<>();
if (!encodedPath.isEmpty()) {
for (String part : encodedPath.substring(1).split("/", -1)) {
try {
String decoded = URLDecoder.decode(part.replace("+", "%2B"), StandardCharsets.UTF_8);
path.add(StoragePathPolicy.requireSegment(decoded));
} catch (IllegalArgumentException exception) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Invalid document path");
}
}
}
// Read the original JSON bytes and calculate their length and SHA-256.
RequestBody body = readJsonBody(request);
// Verify the JWT signature and validity period, match the request details, and check for replay.
String jwt = request.getHeader("X-TFO-Storage-Request-JWT");
Map<String, Object> signedRequest = requestVerifier.verify(
jwt, request.getMethod(), rawPath,
request.getHeader(HttpHeaders.CONTENT_TYPE), body.length(), body.sha256());
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (properties.unsupportedOperations().contains("mkdir")) {
return operationNotSupported(StorageOperation.MKDIR);
}
String name = nameRequest(body.bytes()).name();
storageService.createDirectory(path, name);
return ResponseEntity.noContent().cacheControl(CacheControl.noStore()).build();
}
Shared helpers and storage service used by this snippet: Python Provider source
@router.post("/tfo-http-storage/v1/mkdir")
@router.post("/tfo-http-storage/v1/{document_path:path}/mkdir")
async def mkdir_document(
request: Request, storage: StorageServiceDependency,
state_store: StateStoreDependency, verifier: RequestVerifierDependency,
) -> Response:
raw_path = request.scope["raw_path"].decode("ascii")
if request.url.query:
raise StorageError(400, "Query not allowed")
# Strip the prefix and operation from the raw path; decode each directory/file segment.
encoded_path = raw_path[len(PROTOCOL_PREFIX):-len("/mkdir")]
if re.search(r"%(?![0-9A-Fa-f]{2})", encoded_path):
raise StorageError(400, "Invalid percent-encoding")
try:
segments = tuple(storage.require_segment(unquote_to_bytes(part).decode("utf-8"))
for part in encoded_path[1:].split("/")) if encoded_path else ()
except UnicodeDecodeError:
raise StorageError(400, "Invalid UTF-8 path") from None
# Read the original JSON bytes and calculate their length and SHA-256.
body = await read_request_body(request, Operation.MKDIR, storage.settings, state_store)
# Verify the JWT signature and validity period, match the request details, and check for replay.
jwt = request.headers.get("X-TFO-Storage-Request-JWT")
signed_request = verifier.verify(
jwt, request.method, raw_path,
request.headers.get("Content-Type"), body.length, body.sha256,
)
# For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if storage.settings.is_operation_unsupported("mkdir"):
return fixed_json_response({"code": "MKDIR_NOT_SUPPORTED"}, status_code=501)
name = parse_model(NameRequest, body.content, "name").name
storage.create_directory(segments, name)
return Response(status_code=204, headers=NO_STORE)
Response
HTTP/1.1 204 No Content
| Item | Format | Description |
|---|---|---|
| Status | 204 No Content | The parent/name directory was created. Other 2xx statuses also succeed |
| Body | None | No created entry object is required. Retrieve it later with info or list |
| Status | Conditions and handling |
|---|---|
400 Bad Request | Missing name, incorrect type, extra fields, or an invalid name |
501 Not Implemented | After authentication, MKDIR_NOT_SUPPORTED. No directory is created |
rename - Rename an entry
This optional endpoint renames a file or directory within the same parent directory.
Request
POST /tfo-http-storage/v1/{path}/rename
POST /tfo-http-storage/v1/contracts/sample.docx/rename HTTP/1.1
Host: storage.example.com
X-TFO-Storage-Request-JWT: <SIGNED_REQUEST_JWT>
Content-Type: application/json
Content-Length: 29
{
"name": "reviewed.docx"
}
| Item | Format | Description |
|---|---|---|
path | Path · string | Relative path of the existing entry to rename. The Provider root cannot be renamed |
| Authentication headers | Required · string | JWT for this POST request. See Common headers |
Content-Type | Required header | Exactly application/json |
Content-Length | Required header | UTF-8 byte length of the JSON body |
name | Body · string · required | One new name, 1–255 characters; the only JSON field |
Use the actual decoded name for name, following the mkdir name rules. Sending reviewed.docx for contracts/sample.docx results in contracts/reviewed.docx.
Implementation example
- Node.js
- Java
- Python
Shared helpers and storage service used by this snippet: Node.js Provider source
// POST /tfo-http-storage/v1/{path}/rename
router.post(/^\/tfo-http-storage\/v1\/(?:.+\/)?rename$/, async function renameDocument(request, response) {
const rawPath = request.originalUrl;
if (rawPath.includes("?")) throw new StorageError(400, "Query not allowed");
// Strip the prefix and operation from the raw path; decode each directory/file segment.
const encodedPath = rawPath.slice("/tfo-http-storage/v1".length, -"/rename".length);
let segments;
try {
segments = encodedPath ? encodedPath.slice(1).split("/").map(
part => requirePathSegment(decodeURIComponent(part)),
) : [];
} catch { throw new StorageError(400, "Invalid document path"); }
// Read the original JSON bytes and calculate their length and SHA-256.
const body = await readRequestBody(request, "json", config, stateStore);
// Verify the JWT signature and validity period, match the request details, and check for replay.
// Read X-TFO-Storage-Request-JWT from request.headers.
// Compare the received length/SHA-256 in body with the signed JWT values to detect body tampering.
const signedRequest = await requestVerifier.verify(request, { rawPath }, body);
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (config.unsupportedOperations?.has("rename")) {
return sendOperationNotSupported(response, "rename");
}
const name = requireChildName(parseSingleField(body.bytes, "name"));
await storageService.rename(segments, name);
response.status(204).set("Cache-Control", "no-store").end();
});
Shared helpers and storage service used by this snippet: Java Provider source
@RequestMapping(value = {"/tfo-http-storage/v1/rename", "/tfo-http-storage/v1/**/rename"},
method = RequestMethod.POST)
public ResponseEntity<?> rename(HttpServletRequest request) throws Exception {
String rawPath = request.getRequestURI();
if (request.getQueryString() != null) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Query not allowed");
}
// Strip the prefix and operation from the raw path; decode each directory/file segment.
String encodedPath = rawPath.substring("/tfo-http-storage/v1".length(), rawPath.length() - "/rename".length());
List<String> path = new ArrayList<>();
if (!encodedPath.isEmpty()) {
for (String part : encodedPath.substring(1).split("/", -1)) {
try {
String decoded = URLDecoder.decode(part.replace("+", "%2B"), StandardCharsets.UTF_8);
path.add(StoragePathPolicy.requireSegment(decoded));
} catch (IllegalArgumentException exception) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Invalid document path");
}
}
}
// Read the original JSON bytes and calculate their length and SHA-256.
RequestBody body = readJsonBody(request);
// Verify the JWT signature and validity period, match the request details, and check for replay.
String jwt = request.getHeader("X-TFO-Storage-Request-JWT");
Map<String, Object> signedRequest = requestVerifier.verify(
jwt, request.getMethod(), rawPath,
request.getHeader(HttpHeaders.CONTENT_TYPE), body.length(), body.sha256());
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (properties.unsupportedOperations().contains("rename")) {
return operationNotSupported(StorageOperation.RENAME);
}
String name = nameRequest(body.bytes()).name();
storageService.rename(path, name);
return ResponseEntity.noContent().cacheControl(CacheControl.noStore()).build();
}
Shared helpers and storage service used by this snippet: Python Provider source
@router.post("/tfo-http-storage/v1/rename")
@router.post("/tfo-http-storage/v1/{document_path:path}/rename")
async def rename_document(
request: Request, storage: StorageServiceDependency,
state_store: StateStoreDependency, verifier: RequestVerifierDependency,
) -> Response:
raw_path = request.scope["raw_path"].decode("ascii")
if request.url.query:
raise StorageError(400, "Query not allowed")
# Strip the prefix and operation from the raw path; decode each directory/file segment.
encoded_path = raw_path[len(PROTOCOL_PREFIX):-len("/rename")]
if re.search(r"%(?![0-9A-Fa-f]{2})", encoded_path):
raise StorageError(400, "Invalid percent-encoding")
try:
segments = tuple(storage.require_segment(unquote_to_bytes(part).decode("utf-8"))
for part in encoded_path[1:].split("/")) if encoded_path else ()
except UnicodeDecodeError:
raise StorageError(400, "Invalid UTF-8 path") from None
# Read the original JSON bytes and calculate their length and SHA-256.
body = await read_request_body(request, Operation.RENAME, storage.settings, state_store)
# Verify the JWT signature and validity period, match the request details, and check for replay.
jwt = request.headers.get("X-TFO-Storage-Request-JWT")
signed_request = verifier.verify(
jwt, request.method, raw_path,
request.headers.get("Content-Type"), body.length, body.sha256,
)
# For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if storage.settings.is_operation_unsupported("rename"):
return fixed_json_response({"code": "RENAME_NOT_SUPPORTED"}, status_code=501)
name = parse_model(NameRequest, body.content, "name").name
storage.rename(segments, name)
return Response(status_code=204, headers=NO_STORE)
Response
HTTP/1.1 204 No Content
| Item | Format | Description |
|---|---|---|
| Status | 204 No Content | The entry was renamed within the same parent. Other 2xx statuses also succeed |
| Body | None | No new path or entry object is required. Use info on the new path or list on its parent to retrieve it |
| Status | Conditions and handling |
|---|---|
400 Bad Request | Invalid name, extra JSON fields, or an attempt to rename the Provider root |
404 Not Found | No entry exists at the original path |
409 Conflict | The new name is already in use |
501 Not Implemented | After authentication, RENAME_NOT_SUPPORTED. The original name is preserved |
delete - Delete an entry
This optional endpoint deletes a file or directory. Reject attempts to delete the Provider root.
Request
DELETE /tfo-http-storage/v1/{path}/delete
DELETE /tfo-http-storage/v1/contracts/reviewed.docx/delete HTTP/1.1
Host: storage.example.com
X-TFO-Storage-Request-JWT: <SIGNED_REQUEST_JWT>
| Item | Format | Description |
|---|---|---|
path | Path · string | Relative path of the entry to delete. The Provider root cannot be deleted |
| Authentication headers | Required · string | JWT for this DELETE request. See Common headers |
| Request body | None | Omit Content-Type. Follow the common request rules |
Implementation example
- Node.js
- Java
- Python
Shared helpers and storage service used by this snippet: Node.js Provider source
// DELETE /tfo-http-storage/v1/{path}/delete
router.delete(/^\/tfo-http-storage\/v1\/(?:.+\/)?delete$/, async function deleteDocument(request, response) {
const rawPath = request.originalUrl;
if (rawPath.includes("?")) throw new StorageError(400, "Query not allowed");
// Strip the prefix and operation from the raw path; decode each directory/file segment.
const encodedPath = rawPath.slice("/tfo-http-storage/v1".length, -"/delete".length);
let segments;
try {
segments = encodedPath ? encodedPath.slice(1).split("/").map(
part => requirePathSegment(decodeURIComponent(part)),
) : [];
} catch { throw new StorageError(400, "Invalid document path"); }
// Require no body. Use length 0 and the SHA-256 of the empty body.
const body = await readRequestBody(request, "none", config, stateStore);
// Verify the JWT signature and validity period, match the request details, and check for replay.
// Read X-TFO-Storage-Request-JWT from request.headers.
// Even without a body, length 0 and the empty-body SHA-256 must match the JWT.
const signedRequest = await requestVerifier.verify(request, { rawPath }, body);
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (config.unsupportedOperations?.has("delete")) {
return sendOperationNotSupported(response, "delete");
}
await storageService.delete(segments);
response.status(204).set("Cache-Control", "no-store").end();
});
Shared helpers and storage service used by this snippet: Java Provider source
@RequestMapping(value = {"/tfo-http-storage/v1/delete", "/tfo-http-storage/v1/**/delete"},
method = RequestMethod.DELETE)
public ResponseEntity<?> delete(HttpServletRequest request) throws Exception {
String rawPath = request.getRequestURI();
if (request.getQueryString() != null) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Query not allowed");
}
// Strip the prefix and operation from the raw path; decode each directory/file segment.
String encodedPath = rawPath.substring("/tfo-http-storage/v1".length(), rawPath.length() - "/delete".length());
List<String> path = new ArrayList<>();
if (!encodedPath.isEmpty()) {
for (String part : encodedPath.substring(1).split("/", -1)) {
try {
String decoded = URLDecoder.decode(part.replace("+", "%2B"), StandardCharsets.UTF_8);
path.add(StoragePathPolicy.requireSegment(decoded));
} catch (IllegalArgumentException exception) {
throw new StorageException(HttpStatus.BAD_REQUEST, "Invalid document path");
}
}
}
// Require no body. Use length 0 and the SHA-256 of the empty body.
RequestBody body = readEmptyBody(request);
// Verify the JWT signature and validity period, match the request details, and check for replay.
String jwt = request.getHeader("X-TFO-Storage-Request-JWT");
Map<String, Object> signedRequest = requestVerifier.verify(
jwt, request.getMethod(), rawPath,
request.getHeader(HttpHeaders.CONTENT_TYPE), body.length(), body.sha256());
// For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if (properties.unsupportedOperations().contains("delete")) {
return operationNotSupported(StorageOperation.DELETE);
}
storageService.delete(path);
return ResponseEntity.noContent().cacheControl(CacheControl.noStore()).build();
}
Shared helpers and storage service used by this snippet: Python Provider source
@router.delete("/tfo-http-storage/v1/delete")
@router.delete("/tfo-http-storage/v1/{document_path:path}/delete")
async def delete_document(
request: Request, storage: StorageServiceDependency,
state_store: StateStoreDependency, verifier: RequestVerifierDependency,
) -> Response:
raw_path = request.scope["raw_path"].decode("ascii")
if request.url.query:
raise StorageError(400, "Query not allowed")
# Strip the prefix and operation from the raw path; decode each directory/file segment.
encoded_path = raw_path[len(PROTOCOL_PREFIX):-len("/delete")]
if re.search(r"%(?![0-9A-Fa-f]{2})", encoded_path):
raise StorageError(400, "Invalid percent-encoding")
try:
segments = tuple(storage.require_segment(unquote_to_bytes(part).decode("utf-8"))
for part in encoded_path[1:].split("/")) if encoded_path else ()
except UnicodeDecodeError:
raise StorageError(400, "Invalid UTF-8 path") from None
# Require no body. Use length 0 and the SHA-256 of the empty body.
body = await read_request_body(request, Operation.DELETE, storage.settings, state_store)
# Verify the JWT signature and validity period, match the request details, and check for replay.
jwt = request.headers.get("X-TFO-Storage-Request-JWT")
signed_request = verifier.verify(
jwt, request.method, raw_path,
request.headers.get("Content-Type"), body.length, body.sha256,
)
# For customer sessions, validate the token in client_metadata and check document/operation permissions here.
if storage.settings.is_operation_unsupported("delete"):
return fixed_json_response({"code": "DELETE_NOT_SUPPORTED"}, status_code=501)
storage.delete(segments)
return Response(status_code=204, headers=NO_STORE)
Response
HTTP/1.1 204 No Content
| Item | Format | Description |
|---|---|---|
| Status | 204 No Content | The entry was deleted. Other 2xx statuses also succeed |
| Body | None | No deleted entry object or deletion count is required |
| Status | Conditions and handling |
|---|---|
400 Bad Request | Attempt to delete the Provider root |
501 Not Implemented | After authentication, DELETE_NOT_SUPPORTED. The entry is not deleted |
Unsupported operations
Use the following response only for an optional operation that is not implemented. Return it after authentication, body verification, and replay checks for that request. The status must be 501, the media type must be application/json, and the JSON body must contain only one code field matching the current operation. A charset parameter is allowed.
| Item | Format | Description |
|---|---|---|
| Status | Required | Exactly 501 Not Implemented |
Content-Type | Required header | application/json; a charset parameter is allowed |
code | Body · string · required | The exact value for the current operation from the table below. No additional fields |
| Body length | Brief UTF-8 JSON | The complete object must fit within Office's 8 KiB error-body read limit |
The fixed-length checks for successful read responses do not apply here, but you may include an exact Content-Length as in the example below.
HTTP/1.1 501 Not Implemented
Content-Type: application/json
Content-Length: 34
{
"code": "LIST_NOT_SUPPORTED"
}
| Operation | Exact code | Office behavior |
|---|---|---|
list | LIST_NOT_SUPPORTED | File listing is unavailable. The connection can be saved after acknowledging the notice during registration |
put | PUT_NOT_SUPPORTED | Saving fails |
lock | LOCK_NOT_SUPPORTED | Treated as successful without an actual lock |
unlock | UNLOCK_NOT_SUPPORTED | Treated as successful without an actual unlock |
mkdir | MKDIR_NOT_SUPPORTED | Directory creation is unavailable |
rename | RENAME_NOT_SUPPORTED | Renaming is unavailable |
delete | DELETE_NOT_SUPPORTED | Deletion is unavailable |
Because info and get are required, a 501 fails those operations. For optional operations, Office also reports failure if the status, media type, or body does not match the format above.
Among unsupported-operation responses, only those for lock and unlock are treated as successful by Office. Declare both operations unsupported and return LOCK_NOT_SUPPORTED or UNLOCK_NOT_SUPPORTED for the corresponding request. Do not implement only one of the pair. No document lock is established in this case, so the Provider must define how it handles overwrites or conflicts from concurrent saves.
For implemented lock and unlock operations, the normal response is 204. Office treats 409 Conflict, authentication failures, network errors, and storage failures as operation failures.
The admin connection test calls only list. Verify other capabilities through actual document operations; registration does not arbitrarily execute writes or deletes. Also see the unsupported operation JSON schema.
Error responses
Office treats 404 from info as an absent target. Except for that case and the exact unsupported responses above, non-2xx responses fail the Office operation. Ordinary errors have no fixed JSON body schema. Return a brief error description without secrets, JWTs, internal paths, document contents, or stack traces. Office may display up to 8 KiB of the error body.
| Item | Format | Description |
|---|---|---|
| Status | HTTP status code | Choose according to the error cause below. The status determines success or failure |
Content-Type | Match the body | No fixed media type for ordinary errors |
| Body | text or Provider-defined JSON | Optional description of the error |
The following is an authentication error response. Do not distinguish unknown adapters, signature mismatches, expiration, and replay through detailed response messages.
HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=utf-8
Content-Length: 12
Unauthorized
| Status | When to use |
|---|---|
400 Bad Request | Invalid path, JSON, body length, or a disallowed query |
401 Unauthorized | Unknown adapter, invalid signature or claims, or an expired or reused JWT |
403 Forbidden | No permission to access the storage or document |
404 Not Found | Target entry or request path does not exist |
405 Method Not Allowed | Incorrect method for a known operation path |
409 Conflict | Name, type, or lock-owner conflict |
411 Length Required | Required body Content-Length is missing |
413 Payload Too Large | Request body, document, or listing exceeds its limit, or a Provider-defined limit is exceeded |
415 Unsupported Media Type | Request Content-Type does not match the operation's required media type |
500 Internal Server Error | Internal Provider error. Do not expose exception details in the response |
501 Not Implemented | Exact unsupported declaration for an optional operation as specified above |
503 Service Unavailable | Temporary storage failure |
Do not turn authentication errors into unsupported responses or report storage failures as successful. See Run a Provider example for example-specific configuration.