Files & Directories
AeorDB exposes a content-addressable filesystem through its file routes. Every path under /files/ represents either a file or a directory.
Endpoint Summary
| Method | Path | Description | Auth | Status Codes |
|---|---|---|---|---|
| PUT | /files/{path} | Store a file | Yes | 201, 400, 404, 409, 413, 500 |
| GET | /files/{path} | Read a file or list a directory | Yes | 200, 404, 500 |
| DELETE | /files/{path} | Delete a file | Yes | 200, 404, 500 |
| HEAD | /files/{path} | Check existence and get metadata | Yes | 200, 404, 500 |
| PATCH | /files/{path} | Rename or move a file or symlink | Yes | 200, 400, 404, 500 |
| POST | /files/share | Share paths with users/groups | Yes (root) | 200, 400, 404, 500 |
| GET | /files/shares?path= | List active shares for a path | Yes | 200, 500 |
| DELETE | /files/shares | Revoke a share | Yes (root) | 200, 404, 500 |
| POST | /files/share-link | Create a share link (scoped API key + JWT URL) | Yes (root) | 201, 400, 403, 500 |
| GET | /files/share-links?path= | List active share links for a path | Yes (root) | 200, 403, 500 |
| DELETE | /files/share-links/{key_id} | Revoke a share link | Yes (root) | 200, 403, 404, 500 |
| GET | /files/shared-with-me | List paths shared with the current user | Yes | 200, 403, 500 |
| PUT | /links/{path} | Create or update a symlink | Yes | 201, 400, 500 |
Searching by metadata: Files can also be searched by their metadata – filename, extension, size, content type, and timestamps – using virtual fields in the query API. Virtual field queries require no index configuration; just query with
@-prefixed field names like@filename,@extension, or@size.
PUT /files/
Store a file at the given path. Parent directories are created automatically. If a file already exists at the path, it is overwritten (creating a new version).
Streaming uploads: Files are streamed in 256 KB chunks – the server never buffers the full file in memory. Files up to the router-level limit (10 GB) are supported. For resumable uploads of very large files, see the chunked upload protocol.
Request
- Headers:
Authorization: Bearer <token>(required)Content-Type(optional) – auto-detected from magic bytes if omitted
- Body: raw file bytes
Response
Status: 201 Created
{
"path": "/data/report.pdf",
"content_type": "application/pdf",
"size": 245678,
"created_at": 1775968398000,
"updated_at": 1775968398000
}
Side Effects
- If the path matches
/.config/indexes.json(or a nested variant like/data/.config/indexes.json), a reindex task is automatically enqueued for the parent directory. Any existing pending or running reindex for that path is cancelled first. - Triggers
entries_createdevents on the event bus. - Runs any deployed store-phase plugins.
Example
curl -X PUT http://localhost:6830/files/data/report.pdf \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/pdf" \
--data-binary @report.pdf
Error Responses
| Status | Condition |
|---|---|
| 400 | Invalid input (e.g., empty path) |
| 404 | Parent path references a non-existent entity |
| 409 | Path conflict (e.g., file exists where directory expected) |
| 413 | Payload exceeds the router-level body limit (10 GB) |
| 500 | Internal storage failure |
GET /files/
Read a file or list a directory. The server determines the type automatically:
- If the path resolves to a file, the file content is streamed with appropriate headers.
- If the path resolves to a directory, a JSON object with an
itemsarray of children is returned.
Request
- Headers:
Authorization: Bearer <token>(required)
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
snapshot | string | — | Read the file as it was at this named snapshot |
version | string | — | Read the file at this version hash (hex) |
nofollow | boolean | false | If the path is a symlink, return metadata instead of following |
depth | integer | 0 | Directory listing depth: 0 = immediate children, -1 = unlimited recursion |
glob | string | — | Filter directory listing by file name glob pattern (*, ?, [abc]) |
limit | integer | — | Maximum number of entries to return in a directory listing |
offset | integer | — | Number of entries to skip before returning results |
File Response
Status: 200 OK
Headers:
| Header | Description |
|---|---|
X-AeorDB-Path | Canonical path of the file |
X-AeorDB-Size | File size in bytes |
X-AeorDB-Created-At | Unix timestamp (milliseconds) |
X-AeorDB-Updated-At | Unix timestamp (milliseconds) |
Content-Type | MIME type (if known) |
Body: raw file bytes (streamed)
Directory Response
Status: 200 OK
Each entry includes path, hash, and numeric entry_type fields. Symlink entries also include a target field.
Entry types: 2 = file, 3 = directory, 8 = symlink.
Effective permissions: When a directory listing is accessed with a scoped API key or as a non-root user, each item in the response includes an
effective_permissionsfield – an 8-character crudlify string indicating what operations the caller can perform on that item. Ancestor directory items (directories leading to the scoped path) receive read+list permissions only.
{
"items": [
{
"path": "/data/report.pdf",
"name": "report.pdf",
"entry_type": 2,
"hash": "a3f8c1...",
"size": 245678,
"created_at": 1775968398000,
"updated_at": 1775968398000,
"content_type": "application/pdf"
},
{
"path": "/data/images",
"name": "images",
"entry_type": 3,
"hash": "b2c4d5...",
"size": 0,
"created_at": 1775968000000,
"updated_at": 1775968000000,
"content_type": null
},
{
"path": "/data/latest",
"name": "latest",
"entry_type": 8,
"hash": "c3d5e6...",
"target": "/data/report.pdf",
"size": 0,
"created_at": 1775968500000,
"updated_at": 1775968500000,
"content_type": null
}
]
}
Paginated Directory Listing
Use limit and offset to paginate directory listings:
curl "http://localhost:6830/files/data/?limit=10&offset=20" \
-H "Authorization: Bearer $TOKEN"
When limit or offset is provided, the response includes pagination metadata:
{
"items": [...],
"total": 150,
"limit": 10,
"offset": 20
}
| Field | Type | Description |
|---|---|---|
total | integer | Total number of entries (before pagination) |
limit | integer | The limit that was applied |
offset | integer | The offset that was applied |
Examples
Read a file:
curl http://localhost:6830/files/data/report.pdf \
-H "Authorization: Bearer $TOKEN" \
-o report.pdf
List a directory:
curl http://localhost:6830/files/data/ \
-H "Authorization: Bearer $TOKEN"
Recursive Directory Listing
Use the depth and glob query parameters to list files recursively:
# List all files recursively
curl http://localhost:6830/files/data/?depth=-1 \
-H "Authorization: Bearer $TOKEN"
# List only .psd files anywhere under /assets/
curl "http://localhost:6830/files/assets/?depth=-1&glob=*.psd" \
-H "Authorization: Bearer $TOKEN"
# List one level deep
curl http://localhost:6830/files/data/?depth=1 \
-H "Authorization: Bearer $TOKEN"
When depth > 0 or depth = -1, the response contains files only in a flat list. Directory entries are traversed but not included in the output.
Versioned Reads
Read a file as it was at a specific snapshot or version:
# Read file at a named snapshot
curl "http://localhost:6830/files/data/report.pdf?snapshot=v1.0" \
-H "Authorization: Bearer $TOKEN"
# Read file at a specific version hash
curl "http://localhost:6830/files/data/report.pdf?version=a1b2c3..." \
-H "Authorization: Bearer $TOKEN"
If both snapshot and version are provided, snapshot takes precedence. Returns 404 if the file did not exist at that version.
Error Responses
| Status | Condition |
|---|---|
| 404 | Path does not exist as file or directory |
| 500 | Internal read failure |
DELETE /files/
Delete a file or empty directory at the given path. Creates a DeletionRecord and removes the entry from its parent directory listing. The handler tries file deletion first, then directory deletion, then returns 404. Non-empty directories return 400 with “Directory is not empty (N children)”.
Request
- Headers:
Authorization: Bearer <token>(required)
Response
Status: 200 OK
{
"deleted": true,
"path": "/data/report.pdf"
}
Side Effects
- Triggers
entries_deletedevents on the event bus. - Updates index entries for the deleted file.
Example
curl -X DELETE http://localhost:6830/files/data/report.pdf \
-H "Authorization: Bearer $TOKEN"
Error Responses
| Status | Condition |
|---|---|
| 400 | Directory is not empty |
| 404 | Path not found (neither file nor directory) |
| 500 | Internal deletion failure |
PATCH /files/
Rename or move a file or symlink to a new path. This is a metadata-only operation – no data is copied in the content-addressed store. The file’s content hash remains the same; only the path mapping changes.
Request
- Headers:
Authorization: Bearer <token>(required)Content-Type: application/json(required)
- Body:
{
"to": "/new/path/report.pdf"
}
| Field | Type | Required | Description |
|---|---|---|---|
to | string | Yes | Destination path for the file or symlink |
Response
Status: 200 OK
{
"from": "/data/report.pdf",
"to": "/archive/report.pdf"
}
Side Effects
- Triggers
entries_createdandentries_deletedevents on the event bus. - If the path is a symlink, the symlink itself is moved (not the target).
- If a file already exists at the destination path, the operation fails.
Example
# Move a file
curl -X PATCH http://localhost:6830/files/data/report.pdf \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to": "/archive/report.pdf"}'
# Rename a symlink
curl -X PATCH http://localhost:6830/files/latest-logo \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to": "/current-logo"}'
Error Responses
| Status | Condition |
|---|---|
| 400 | Missing to field or invalid destination path |
| 404 | Source file or symlink not found |
| 500 | Internal rename failure |
Symlinks
AeorDB supports soft symlinks — entries that point to another path. Symlinks are transparent by default: reading a symlink path returns the target’s content.
PUT /links/
Create or update a symlink.
Request Body:
{
"target": "/assets/logo.psd"
}
Response: 201 Created
{
"path": "/latest-logo",
"target": "/assets/logo.psd",
"entry_type": 8,
"created_at": 1775968398000,
"updated_at": 1775968398000
}
The target path does not need to exist at creation time (dangling symlinks are allowed).
Reading Symlinks
By default, GET /files/{path} follows symlinks transparently:
# Returns the content of /assets/logo.psd
curl http://localhost:6830/files/latest-logo \
-H "Authorization: Bearer $TOKEN"
To inspect the symlink itself without following it, use ?nofollow=true:
curl "http://localhost:6830/files/latest-logo?nofollow=true" \
-H "Authorization: Bearer $TOKEN"
Returns the symlink metadata as JSON instead of the target’s content.
Symlink Resolution
Symlinks can point to other symlinks — chains are followed recursively. AeorDB detects cycles and enforces a maximum resolution depth of 32 hops.
| Scenario | Result |
|---|---|
| Symlink -> file | Returns file content |
| Symlink -> directory | Returns directory listing |
| Symlink -> symlink -> file | Follows chain, returns file content |
| Symlink -> nonexistent | 404 (dangling symlink) |
| Symlink cycle (A -> B -> A) | 400 with cycle detection message |
| Chain exceeds 32 hops | 400 with depth exceeded message |
HEAD on Symlinks
HEAD /files/{path} returns symlink metadata as headers:
X-AeorDB-Entry-Type: symlink
X-AeorDB-Symlink-Target: /assets/logo.psd
X-AeorDB-Path: /latest-logo
X-AeorDB-Created-At: 1775968398000
X-AeorDB-Updated-At: 1775968398000
Deleting Symlinks
DELETE /files/{path} on a symlink deletes the symlink itself, not the target:
curl -X DELETE http://localhost:6830/files/latest-logo \
-H "Authorization: Bearer $TOKEN"
{
"deleted": true,
"path": "latest-logo",
"entry_type": "symlink"
}
Symlinks in Directory Listings
Symlinks appear in directory listings with entry_type: 8 and a target field:
{
"path": "/data/latest",
"name": "latest",
"entry_type": 8,
"hash": "c3d5e6...",
"target": "/data/report.pdf",
"size": 0,
"created_at": 1775968500000,
"updated_at": 1775968500000,
"content_type": null
}
Symlink Versioning
Symlinks are versioned like files. Snapshots capture the symlink’s target path at that point in time. Restoring a snapshot restores the link, not the resolved content.
Sharing
AeorDB supports sharing files and directories with specific users and groups. Shares are stored as .permissions files in the filesystem — the sharing API is a convenience layer on top of the existing permission system.
POST /files/share
Share one or more paths with users and/or groups. For files, the permission is scoped to just that file using a path_pattern. For directories, the permission applies to everything inside.
Auth: Root only.
Request Body:
{
"paths": ["/photos/sunset.jpg", "/photos/beach.jpg"],
"users": ["6f94eecf-b136-47b4-9b47-c20f781f1f5b"],
"groups": ["design-team"],
"permissions": "crudl..."
}
| Field | Type | Required | Description |
|---|---|---|---|
paths | array | Yes | Paths to share (files or directories) |
users | array | No | User UUIDs to share with |
groups | array | No | Group names to share with |
permissions | string | Yes | 8-character crudlify permission flags |
At least one of users or groups must be non-empty.
Response: 200 OK
{
"shared": 2,
"paths": ["/photos/sunset.jpg", "/photos/beach.jpg"]
}
How it works:
- For each file path, a
PermissionLinkis created on the parent directory’s.permissionsfile with apath_patternmatching the filename — so the permission only applies to that specific file. - For each directory path, the link is created with no
path_pattern— it applies to everything in the directory. - If a link for the same group and pattern already exists, it is updated (not duplicated).
Example:
curl -X POST http://localhost:6830/files/share \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"paths": ["/photos/sunset.jpg"],
"users": ["6f94eecf-b136-47b4-9b47-c20f781f1f5b"],
"permissions": "cr..l..."
}'
GET /files/shares
List active shares for a path.
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
path | string | Yes | Path to list shares for |
Response: 200 OK
{
"path": "/photos/sunset.jpg",
"shares": [
{
"group": "user:6f94eecf-...",
"username": "alice",
"allow": "cr..l...",
"deny": "........",
"path_pattern": "sunset.jpg"
},
{
"group": "design-team",
"allow": "crudl...",
"deny": "........",
"path_pattern": null
}
]
}
For user:{uuid} groups, the username field is resolved from the user store. For named groups, username is null.
When querying a specific file, only shares with a matching path_pattern (or directory-wide shares with no pattern) are returned.
Example:
curl "http://localhost:6830/files/shares?path=/photos/sunset.jpg" \
-H "Authorization: Bearer $TOKEN"
DELETE /files/shares
Revoke a specific share by removing the matching PermissionLink.
Auth: Root only.
Request Body:
{
"path": "/photos/sunset.jpg",
"group": "user:6f94eecf-...",
"path_pattern": "sunset.jpg"
}
| Field | Type | Required | Description |
|---|---|---|---|
path | string | Yes | The shared path |
group | string | Yes | The group to revoke (user:{uuid} or group name) |
path_pattern | string | No | The path pattern to match (null for directory-wide links) |
Response: 200 OK
{
"revoked": true,
"group": "user:6f94eecf-..."
}
Example:
curl -X DELETE http://localhost:6830/files/shares \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"path": "/photos/sunset.jpg",
"group": "user:6f94eecf-...",
"path_pattern": "sunset.jpg"
}'
Error Responses (Sharing)
| Status | Condition |
|---|---|
| 400 | Empty paths, no users/groups, or invalid permissions string |
| 403 | Non-root user attempted to share or unshare |
| 404 | Path not found, or no matching share to revoke |
| 500 | Internal failure writing permissions |
Link Sharing
Shareable URLs backed by scoped API keys. A share link bundles a scoped API key with a JWT token into a URL that opens the portal file browser, scoped to the shared path.
POST /files/share-link
Create a share link for one or more paths. This creates a scoped API key with user_id: null and generates a JWT token embedded in the URL.
Auth: Root only.
Request Body:
{
"paths": ["/photos/vacation"],
"permissions": "cr..l...",
"expires_in_days": 30,
"base_url": "https://files.example.com"
}
| Field | Type | Required | Description |
|---|---|---|---|
paths | array | Yes | Paths to share |
permissions | string | Yes | 8-character crudlify permission flags |
expires_in_days | integer | No | Days until expiry (null = never expires) |
base_url | string | Yes | Base URL for the generated share link |
Response: 201 Created
{
"url": "https://files.example.com/share?token=eyJ...",
"token": "eyJhbGciOiJIUzI1NiIs...",
"key_id": "a1b2c3d4-...",
"permissions": "cr..l...",
"expires_at": 1778560398000,
"paths": ["/photos/vacation"]
}
The URL opens the portal file browser scoped to the shared path. Ancestor directories are navigable (read+list only) – only path components leading to the target are visible.
Example:
curl -X POST http://localhost:6830/files/share-link \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"paths": ["/photos/vacation"],
"permissions": "-r--l---",
"expires_in_days": 7,
"base_url": "https://files.example.com"
}'
GET /files/share-links
List active share links for a path.
Auth: Root only.
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
path | string | Yes | Path to list share links for |
Response: 200 OK
{
"path": "/photos/vacation",
"links": [
{
"key_id": "a1b2c3d4-...",
"label": "share-link:/photos/vacation",
"permissions": "-r--l---",
"expires_at": 1778560398000,
"created_at": 1775968398000
}
]
}
Example:
curl "http://localhost:6830/files/share-links?path=/photos/vacation" \
-H "Authorization: Bearer $TOKEN"
DELETE /files/share-links/
Revoke a share link. This deletes the underlying scoped API key and invalidates the JWT.
Auth: Root only.
Response: 200 OK
{
"revoked": true,
"key_id": "a1b2c3d4-..."
}
Example:
curl -X DELETE http://localhost:6830/files/share-links/a1b2c3d4-... \
-H "Authorization: Bearer $TOKEN"
Error Responses (Link Sharing)
| Status | Condition |
|---|---|
| 400 | Missing or invalid fields (paths, permissions, base_url) |
| 403 | Non-root user attempted to create, list, or revoke share links |
| 404 | Share link not found (revoke) |
| 500 | Internal failure creating or revoking share link |
Shared With Me
GET /files/shared-with-me
List paths that have been shared with the current user. Scans all .permissions files to find paths where the calling user has group-level access. This is used by the portal to discover entry points when the user has no root-level permissions.
Auth: Requires a user-bound token. Share tokens receive 403. Root users receive an empty list (they already have access to everything).
Response: 200 OK
{
"paths": [
{
"path": "/photos/vacation/",
"permissions": ".r..l...",
"path_pattern": null
}
]
}
Each entry includes the shared path, the permissions string granted, and an optional path_pattern (non-null when the share targets a specific file within a directory).
Example:
curl http://localhost:6830/files/shared-with-me \
-H "Authorization: Bearer $TOKEN"
Error Responses:
| Status | Condition |
|---|---|
| 403 | Called with a share token instead of a user token |
| 500 | Internal failure scanning permissions |
HEAD /files/
Check whether a path exists and retrieve its metadata as response headers, without downloading the body. Works for both files and directories.
Request
- Headers:
Authorization: Bearer <token>(required)
Response
Status: 200 OK (empty body)
Headers:
| Header | Value |
|---|---|
X-AeorDB-Entry-Type | file, directory, or symlink |
X-AeorDB-Path | Canonical path |
X-AeorDB-Size | File size in bytes (files only) |
X-AeorDB-Created-At | Unix timestamp in milliseconds (files only) |
X-AeorDB-Updated-At | Unix timestamp in milliseconds (files only) |
Content-Type | MIME type (files only, if known) |
X-AeorDB-Symlink-Target | Target path (symlinks only) |
Example
curl -I http://localhost:6830/files/data/report.pdf \
-H "Authorization: Bearer $TOKEN"
HTTP/1.1 200 OK
X-AeorDB-Entry-Type: file
X-AeorDB-Path: /data/report.pdf
X-AeorDB-Size: 245678
X-AeorDB-Created-At: 1775968398000
X-AeorDB-Updated-At: 1775968398000
Content-Type: application/pdf
Error Responses
| Status | Condition |
|---|---|
| 404 | Path does not exist |
| 500 | Internal metadata lookup failure |